mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-11 00:09:28 +00:00
7b979a3f95
- Add server-side detection of SDK processor's internal system prompt
in handleSessionInit, since CLAUDE_MNEMONIC_INTERNAL env var is not
propagated by Claude Code to hook subprocesses
- Add cross-session duplicate detection (FindRecentPromptByTextGlobal)
to catch same prompt text arriving from different session IDs
- Add hooks, mcpServers, and commands references to plugin.json per
Claude Code plugin spec
- Remove MCP server injection from register-plugin.sh (now in plugin.json)
- Use ${CLAUDE_PLUGIN_ROOT} for statusline path instead of hardcoded path
- Add python3 fallback for plugin registration when jq is unavailable
- Replace hardcoded 1.0.0 version in findWorkerBinary with glob lookup
- Add cache copy verification in register-plugin.sh
- Add update-version Makefile target to keep metadata in sync
403 lines
13 KiB
Go
403 lines
13 KiB
Go
// Package worker provides session-related HTTP handlers.
|
|
package worker
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/lukaszraczylo/claude-mnemonic/internal/privacy"
|
|
"github.com/lukaszraczylo/claude-mnemonic/internal/worker/session"
|
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// SessionInitRequest is the request body for session initialization.
|
|
type SessionInitRequest struct {
|
|
ClaudeSessionID string `json:"claudeSessionId"`
|
|
Project string `json:"project"`
|
|
Prompt string `json:"prompt"`
|
|
MatchedObservations int `json:"matchedObservations"`
|
|
}
|
|
|
|
// SessionInitResponse is the response for session initialization.
|
|
type SessionInitResponse struct {
|
|
Reason string `json:"reason,omitempty"`
|
|
SessionDBID int64 `json:"sessionDbId"`
|
|
PromptNumber int `json:"promptNumber"`
|
|
Skipped bool `json:"skipped,omitempty"`
|
|
}
|
|
|
|
// DuplicatePromptWindowSeconds is the time window for detecting duplicate prompt submissions.
|
|
// If the same prompt text is seen within this window, it's considered a duplicate hook invocation.
|
|
const DuplicatePromptWindowSeconds = 10
|
|
|
|
// handleSessionInit handles session initialization from user-prompt hook.
|
|
// This handler is idempotent - duplicate requests within a short time window
|
|
// return the existing prompt data without creating duplicates.
|
|
func (s *Service) handleSessionInit(w http.ResponseWriter, r *http.Request) {
|
|
var req SessionInitRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Reject requests from internal CLI calls (callClaudeCLI runs from /tmp with no session ID).
|
|
// These are the memory extraction agent's own prompts, not real user prompts.
|
|
if req.ClaudeSessionID == "" {
|
|
log.Debug().Str("project", req.Project).Msg("Rejecting session init with empty claude session ID (internal call)")
|
|
writeJSON(w, SessionInitResponse{
|
|
Skipped: true,
|
|
Reason: "internal",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Reject prompts that contain the internal system prompt (from SDK processor).
|
|
// The SDK processor sets CLAUDE_MNEMONIC_INTERNAL=1 on the subprocess env,
|
|
// but Claude Code does NOT propagate custom env vars to hook subprocesses,
|
|
// so that guard fails and the system prompt leaks into the database as a
|
|
// regular user prompt. This server-side check catches those cases reliably.
|
|
if strings.Contains(req.Prompt, "You are a memory extraction agent for Claude Code sessions") {
|
|
log.Debug().Str("project", req.Project).Msg("Rejecting session init with internal system prompt")
|
|
writeJSON(w, SessionInitResponse{
|
|
Skipped: true,
|
|
Reason: "internal",
|
|
})
|
|
return
|
|
}
|
|
// Privacy check
|
|
if privacy.IsEntirelyPrivate(req.Prompt) {
|
|
// Create session but skip processing
|
|
sessionID, _ := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, "")
|
|
promptNum, _ := s.sessionStore.IncrementPromptCounter(r.Context(), sessionID)
|
|
|
|
writeJSON(w, SessionInitResponse{
|
|
SessionDBID: sessionID,
|
|
PromptNumber: promptNum,
|
|
Skipped: true,
|
|
Reason: "private",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Clean prompt
|
|
cleanedPrompt := privacy.Clean(req.Prompt)
|
|
|
|
// DUPLICATE DETECTION: Check if this exact prompt was already saved recently.
|
|
// This prevents the bug where the hook fires multiple times for the same user action,
|
|
// creating many duplicate prompts with incrementing numbers.
|
|
if existingID, existingNum, found := s.promptStore.FindRecentPromptByText(r.Context(), req.ClaudeSessionID, cleanedPrompt, DuplicatePromptWindowSeconds); found {
|
|
// Get or create session (idempotent)
|
|
sessionID, _ := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, cleanedPrompt)
|
|
|
|
log.Debug().
|
|
Int64("sessionId", sessionID).
|
|
Int("promptNumber", existingNum).
|
|
Int64("promptId", existingID).
|
|
Msg("Duplicate prompt detected - returning existing")
|
|
|
|
// Return existing prompt data without incrementing or saving again
|
|
writeJSON(w, SessionInitResponse{
|
|
SessionDBID: sessionID,
|
|
PromptNumber: existingNum,
|
|
})
|
|
return
|
|
}
|
|
|
|
// CROSS-SESSION DUPLICATE DETECTION: Same prompt text from ANY session within the window.
|
|
// This catches duplicates when the SDK processor's callClaudeCLI subprocess fires
|
|
// UserPromptSubmit with a different Claude session ID than the original hook.
|
|
if _, existingNum, found := s.promptStore.FindRecentPromptByTextGlobal(r.Context(), cleanedPrompt, DuplicatePromptWindowSeconds); found {
|
|
sessionID, _ := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, cleanedPrompt)
|
|
|
|
log.Debug().
|
|
Int64("sessionId", sessionID).
|
|
Int("promptNumber", existingNum).
|
|
Msg("Cross-session duplicate prompt detected")
|
|
|
|
writeJSON(w, SessionInitResponse{
|
|
SessionDBID: sessionID,
|
|
PromptNumber: existingNum,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Create session (idempotent)
|
|
sessionID, err := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, cleanedPrompt)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Increment prompt counter
|
|
promptNum, err := s.sessionStore.IncrementPromptCounter(r.Context(), sessionID)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Save user prompt with matched observation count
|
|
promptID, err := s.promptStore.SaveUserPromptWithMatches(r.Context(), req.ClaudeSessionID, promptNum, cleanedPrompt, req.MatchedObservations)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("Failed to save user prompt")
|
|
// Non-fatal: continue with session initialization
|
|
} else if s.vectorSync != nil {
|
|
// Sync to vector DB asynchronously (non-blocking)
|
|
now := time.Now()
|
|
promptWithSession := &models.UserPromptWithSession{
|
|
UserPrompt: models.UserPrompt{
|
|
ID: promptID,
|
|
ClaudeSessionID: req.ClaudeSessionID,
|
|
PromptNumber: promptNum,
|
|
PromptText: cleanedPrompt,
|
|
MatchedObservations: req.MatchedObservations,
|
|
CreatedAt: now.Format(time.RFC3339),
|
|
CreatedAtEpoch: now.UnixMilli(),
|
|
},
|
|
Project: req.Project,
|
|
SDKSessionID: req.ClaudeSessionID,
|
|
}
|
|
s.asyncVectorSync(func() {
|
|
// Use service context as parent to respect shutdown signals
|
|
ctx, cancel := context.WithTimeout(s.ctx, 10*time.Second)
|
|
defer cancel()
|
|
if err := s.vectorSync.SyncUserPrompt(ctx, promptWithSession); err != nil {
|
|
if s.ctx.Err() == nil { // Don't log during shutdown
|
|
log.Warn().Err(err).Int64("id", promptID).Msg("Failed to sync user prompt to sqlite-vec")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
log.Info().
|
|
Int64("sessionId", sessionID).
|
|
Int("promptNumber", promptNum).
|
|
Str("project", req.Project).
|
|
Msg("Session initialized")
|
|
|
|
// Broadcast prompt event for dashboard refresh
|
|
s.sseBroadcaster.Broadcast(map[string]any{
|
|
"type": "prompt",
|
|
"action": "created",
|
|
"project": req.Project,
|
|
})
|
|
|
|
writeJSON(w, SessionInitResponse{
|
|
SessionDBID: sessionID,
|
|
PromptNumber: promptNum,
|
|
})
|
|
}
|
|
|
|
// SessionStartRequest is the request body for starting SDK agent.
|
|
type SessionStartRequest struct {
|
|
UserPrompt string `json:"userPrompt"`
|
|
PromptNumber int `json:"promptNumber"`
|
|
}
|
|
|
|
// handleSessionStart handles SDK agent session start.
|
|
func (s *Service) handleSessionStart(w http.ResponseWriter, r *http.Request) {
|
|
idStr := chi.URLParam(r, "id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "invalid session id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req SessionStartRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Initialize session in manager
|
|
sess, err := s.sessionManager.InitializeSession(r.Context(), id, req.UserPrompt, req.PromptNumber)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if sess == nil {
|
|
http.Error(w, "session not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Session is now registered. Observations will be processed
|
|
// asynchronously by the background queue processor (processQueue in service.go).
|
|
log.Info().
|
|
Int64("sessionId", id).
|
|
Int("promptNumber", req.PromptNumber).
|
|
Msg("SDK agent session initialized")
|
|
|
|
s.broadcastProcessingStatus()
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// ObservationRequest is the request body for posting observations.
|
|
type ObservationRequest struct {
|
|
ClaudeSessionID string `json:"claudeSessionId"`
|
|
Project string `json:"project"`
|
|
ToolName string `json:"tool_name"`
|
|
ToolInput any `json:"tool_input"`
|
|
ToolResponse any `json:"tool_response"`
|
|
CWD string `json:"cwd"`
|
|
}
|
|
|
|
// handleObservation handles observation posting from post-tool-use hook.
|
|
func (s *Service) handleObservation(w http.ResponseWriter, r *http.Request) {
|
|
var req ObservationRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Reject requests from internal CLI calls (no session ID = internal callClaudeCLI)
|
|
if req.ClaudeSessionID == "" {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
|
|
// Find session
|
|
sess, err := s.sessionStore.FindAnySDKSession(r.Context(), req.ClaudeSessionID)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if sess == nil {
|
|
// Create session on-the-fly with project from request
|
|
id, err := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, "")
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
sess, _ = s.sessionStore.GetSessionByID(r.Context(), id)
|
|
}
|
|
|
|
// Queue observation
|
|
if err := s.sessionManager.QueueObservation(r.Context(), sess.ID, session.ObservationData{
|
|
ToolName: req.ToolName,
|
|
ToolInput: req.ToolInput,
|
|
ToolResponse: req.ToolResponse,
|
|
CWD: req.CWD,
|
|
}); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
s.broadcastProcessingStatus()
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// SubagentCompleteRequest is the request body for subagent completion.
|
|
type SubagentCompleteRequest struct {
|
|
ClaudeSessionID string `json:"claudeSessionId"`
|
|
Project string `json:"project"`
|
|
}
|
|
|
|
// handleSubagentComplete handles subagent/Task completion notifications.
|
|
// This triggers immediate processing of any queued observations from the subagent.
|
|
func (s *Service) handleSubagentComplete(w http.ResponseWriter, r *http.Request) {
|
|
var req SubagentCompleteRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Find session
|
|
sess, err := s.sessionStore.FindAnySDKSession(r.Context(), req.ClaudeSessionID)
|
|
if err != nil || sess == nil {
|
|
// Session not found - subagent may have been in a different context
|
|
log.Debug().
|
|
Str("claudeSessionId", req.ClaudeSessionID).
|
|
Msg("Subagent complete - no active session found")
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
// Trigger immediate processing of queued observations
|
|
messages := s.sessionManager.DrainMessages(sess.ID)
|
|
if len(messages) > 0 && s.processor != nil {
|
|
log.Info().
|
|
Int64("sessionId", sess.ID).
|
|
Int("messages", len(messages)).
|
|
Msg("Processing queued observations from subagent")
|
|
|
|
for _, msg := range messages {
|
|
if msg.Type == session.MessageTypeObservation && msg.Observation != nil {
|
|
err := s.processor.ProcessObservation(
|
|
r.Context(),
|
|
sess.SDKSessionID.String,
|
|
sess.Project,
|
|
msg.Observation.ToolName,
|
|
msg.Observation.ToolInput,
|
|
msg.Observation.ToolResponse,
|
|
msg.Observation.PromptNumber,
|
|
msg.Observation.CWD,
|
|
)
|
|
if err != nil {
|
|
log.Error().Err(err).
|
|
Str("tool", msg.Observation.ToolName).
|
|
Msg("Failed to process subagent observation")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
s.broadcastProcessingStatus()
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// handleGetSessionByClaudeID looks up a session by Claude session ID.
|
|
func (s *Service) handleGetSessionByClaudeID(w http.ResponseWriter, r *http.Request) {
|
|
claudeSessionID := r.URL.Query().Get("claudeSessionId")
|
|
if claudeSessionID == "" {
|
|
http.Error(w, "claudeSessionId required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
session, err := s.sessionStore.FindAnySDKSession(r.Context(), claudeSessionID)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if session == nil {
|
|
http.Error(w, "session not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, session)
|
|
}
|
|
|
|
// SummarizeRequest is the request body for summarize requests.
|
|
type SummarizeRequest struct {
|
|
LastUserMessage string `json:"lastUserMessage"`
|
|
LastAssistantMessage string `json:"lastAssistantMessage"`
|
|
}
|
|
|
|
// handleSummarize handles summarize requests from stop hook.
|
|
func (s *Service) handleSummarize(w http.ResponseWriter, r *http.Request) {
|
|
idStr := chi.URLParam(r, "id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "invalid session id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req SummarizeRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Queue summarize request
|
|
if err := s.sessionManager.QueueSummarize(r.Context(), id, req.LastUserMessage, req.LastAssistantMessage); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
s.broadcastProcessingStatus()
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|