diff --git a/cmd/hooks/post-tool-use/main.go b/cmd/hooks/post-tool-use/main.go index 01cc7e1..c5fe8a3 100644 --- a/cmd/hooks/post-tool-use/main.go +++ b/cmd/hooks/post-tool-use/main.go @@ -17,11 +17,50 @@ type Input struct { ToolUseID string `json:"tool_use_id"` } +// skipTools lists tools that never produce useful observations. +// Skip the HTTP call entirely for these to reduce overhead during heavy tool usage. +var skipTools = map[string]bool{ + // Internal tracking tools (but NOT TodoWrite - it captures planned work) + "Task": true, + "TaskOutput": true, + + // File discovery tools (just listings, no insights) + "Glob": true, + "ListDir": true, + "LS": true, + "KillShell": true, + + // Question/interaction tools (no code insights) + "AskUserQuestion": true, + + // Plan mode tools (planning, not execution) + "EnterPlanMode": true, + "ExitPlanMode": true, + + // Skill/command execution (meta-operations) + "Skill": true, + "SlashCommand": true, + + // Read is high-volume and rarely produces insights worth the overhead + // The processor would skip most reads anyway after filtering + "Read": true, + + // Search tools are for finding, not modifying + "Grep": true, + "WebSearch": true, +} + func main() { hooks.RunHook("PostToolUse", handlePostToolUse) } func handlePostToolUse(ctx *hooks.HookContext, input *Input) (string, error) { + // Skip HTTP call entirely for tools that never produce useful observations. + // This significantly reduces overhead during heavy tool usage. + if skipTools[input.ToolName] { + return "", nil + } + fmt.Fprintf(os.Stderr, "[post-tool-use] %s\n", input.ToolName) // Send observation to worker diff --git a/internal/db/sqlite/migrations.go b/internal/db/sqlite/migrations.go index 5cadfe7..1d5483f 100644 --- a/internal/db/sqlite/migrations.go +++ b/internal/db/sqlite/migrations.go @@ -272,6 +272,17 @@ var Migrations = []Migration{ ); `, }, + { + Version: 18, + Name: "user_prompts_unique_constraint", + SQL: ` + -- Add unique constraint to prevent duplicate prompts + -- This fixes a bug where the user-prompt hook could fire multiple times + -- creating duplicate prompt records with incrementing numbers + CREATE UNIQUE INDEX IF NOT EXISTS idx_user_prompts_session_number_unique + ON user_prompts(claude_session_id, prompt_number); + `, + }, } // MigrationManager handles database schema migrations. diff --git a/internal/db/sqlite/prompt.go b/internal/db/sqlite/prompt.go index 96e1350..978bb68 100644 --- a/internal/db/sqlite/prompt.go +++ b/internal/db/sqlite/prompt.go @@ -32,11 +32,15 @@ func (s *PromptStore) SetCleanupFunc(fn PromptCleanupFunc) { } // SaveUserPromptWithMatches saves a user prompt with matched observation count. +// Uses INSERT OR IGNORE to be idempotent - duplicate (session, prompt_number) pairs are silently ignored. +// This prevents duplicate prompts when the user-prompt hook fires multiple times. func (s *PromptStore) SaveUserPromptWithMatches(ctx context.Context, claudeSessionID string, promptNumber int, promptText string, matchedObservations int) (int64, error) { now := time.Now() + // Use INSERT OR IGNORE for idempotency - if (claude_session_id, prompt_number) already exists, + // the insert is silently ignored. This handles concurrent/duplicate hook invocations. const query = ` - INSERT INTO user_prompts + INSERT OR IGNORE INTO user_prompts (claude_session_id, prompt_number, prompt_text, matched_observations, created_at, created_at_epoch) VALUES (?, ?, ?, ?, ?, ?) ` @@ -51,6 +55,17 @@ func (s *PromptStore) SaveUserPromptWithMatches(ctx context.Context, claudeSessi id, _ := result.LastInsertId() + // If id is 0, the insert was ignored (duplicate) - fetch the existing ID + if id == 0 { + const selectQuery = `SELECT id FROM user_prompts WHERE claude_session_id = ? AND prompt_number = ?` + row := s.store.QueryRowContext(ctx, selectQuery, claudeSessionID, promptNumber) + if err := row.Scan(&id); err != nil { + return 0, err + } + // Return existing ID without triggering cleanup (already handled when first inserted) + return id, nil + } + // Cleanup old prompts beyond the global limit (async to not block handler) go func() { cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -184,6 +199,31 @@ func (s *PromptStore) GetAllRecentUserPrompts(ctx context.Context, limit int) ([ return scanPromptWithSessionRows(rows) } +// FindRecentPromptByText finds a prompt with the same text for a session within the last few seconds. +// This is used to detect duplicate hook invocations. +// Returns (promptID, promptNumber, found). +func (s *PromptStore) FindRecentPromptByText(ctx context.Context, claudeSessionID, promptText string, withinSeconds int) (int64, int, bool) { + // Look for an existing prompt with the same text within the time window + // This catches duplicate hook invocations that happen in quick succession + const query = ` + SELECT id, prompt_number FROM user_prompts + WHERE claude_session_id = ? AND prompt_text = ? + AND created_at_epoch > ? + ORDER BY created_at_epoch DESC + LIMIT 1 + ` + + cutoff := time.Now().Add(-time.Duration(withinSeconds) * time.Second).UnixMilli() + + var id int64 + var promptNumber int + err := s.store.QueryRowContext(ctx, query, claudeSessionID, promptText, cutoff).Scan(&id, &promptNumber) + if err != nil { + return 0, 0, false + } + return id, promptNumber, true +} + // GetRecentUserPromptsByProject retrieves recent user prompts for a specific project. func (s *PromptStore) GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error) { const query = ` diff --git a/internal/update/update.go b/internal/update/update.go index a74f9a4..90e3377 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -49,22 +49,36 @@ type Asset struct { // UpdateInfo contains information about an available update. type UpdateInfo struct { - Available bool `json:"available"` - CurrentVersion string `json:"current_version"` - LatestVersion string `json:"latest_version"` - ReleaseNotes string `json:"release_notes,omitempty"` - PublishedAt time.Time `json:"published_at,omitempty"` - DownloadURL string `json:"download_url,omitempty"` - ChecksumsURL string `json:"checksums_url,omitempty"` - BundleURL string `json:"bundle_url,omitempty"` // Sigstore bundle (.sigstore.json) + Available bool `json:"available"` + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version"` + ReleaseNotes string `json:"release_notes,omitempty"` + PublishedAt time.Time `json:"published_at,omitempty"` + DownloadURL string `json:"download_url,omitempty"` + ChecksumsURL string `json:"checksums_url,omitempty"` + BundleURL string `json:"bundle_url,omitempty"` // Sigstore bundle (.sigstore.json) + ManualUpdateCommand string `json:"manual_update_command,omitempty"` +} + +// InstallScriptURL is the URL to the remote installation script. +const InstallScriptURL = "https://raw.githubusercontent.com/" + GitHubRepo + "/main/scripts/install.sh" + +// GetManualUpdateCommand returns the curl command for manual update. +// If version is empty, it installs the latest version. +func GetManualUpdateCommand(version string) string { + if version == "" { + return fmt.Sprintf("curl -sSL %s | bash", InstallScriptURL) + } + return fmt.Sprintf("curl -sSL %s | bash -s -- %s", InstallScriptURL, version) } // UpdateStatus represents the current update status. type UpdateStatus struct { - State string `json:"state"` // "idle", "checking", "downloading", "verifying", "applying", "done", "error" - Progress float64 `json:"progress"` - Message string `json:"message"` - Error string `json:"error,omitempty"` + State string `json:"state"` // "idle", "checking", "downloading", "verifying", "applying", "done", "error" + Progress float64 `json:"progress"` + Message string `json:"message"` + Error string `json:"error,omitempty"` + ManualUpdateCommand string `json:"manual_update_command,omitempty"` // Shown when update fails } // Updater handles self-updates. @@ -112,9 +126,10 @@ func (u *Updater) setError(err error) { u.mu.Lock() defer u.mu.Unlock() u.status = UpdateStatus{ - State: "error", - Message: "Update failed", - Error: err.Error(), + State: "error", + Message: "Update failed", + Error: err.Error(), + ManualUpdateCommand: GetManualUpdateCommand(""), // Always provide fallback command } } @@ -169,6 +184,9 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (*UpdateInfo, error) { // Compare versions info.Available = isNewerVersion(info.LatestVersion, u.currentVersion) + // Always include manual update command as an alternative option + info.ManualUpdateCommand = GetManualUpdateCommand("v" + info.LatestVersion) + if info.Available { // Find download URLs for current platform platform := getPlatform() diff --git a/internal/worker/handlers.go b/internal/worker/handlers.go index 6d08df8..926027e 100644 --- a/internal/worker/handlers.go +++ b/internal/worker/handlers.go @@ -159,7 +159,13 @@ type SessionInitResponse struct { Reason string `json:"reason,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 { @@ -182,8 +188,31 @@ func (s *Service) handleSessionInit(w http.ResponseWriter, r *http.Request) { return } - // Clean prompt and create session + // 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 + } + + // 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) diff --git a/ui/package-lock.json b/ui/package-lock.json index 3cfd3f1..04b54ad 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-mnemonic-dashboard", - "version": "v0.6.8-2-g378e7dc-dirty", + "version": "v0.6.11-2-g8ff0873-dirty", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-mnemonic-dashboard", - "version": "v0.6.8-2-g378e7dc-dirty", + "version": "v0.6.11-2-g8ff0873-dirty", "dependencies": { "vue": "^3.5.13" }, diff --git a/ui/package.json b/ui/package.json index 20f8787..4911d6b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "claude-mnemonic-dashboard", - "version": "v0.6.8-2-g378e7dc-dirty", + "version": "v0.6.11-2-g8ff0873-dirty", "private": true, "type": "module", "scripts": {