mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-18 06:09:15 +00:00
Prevent endless loops during connectivity issues.
This commit is contained in:
@@ -17,11 +17,50 @@ type Input struct {
|
|||||||
ToolUseID string `json:"tool_use_id"`
|
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() {
|
func main() {
|
||||||
hooks.RunHook("PostToolUse", handlePostToolUse)
|
hooks.RunHook("PostToolUse", handlePostToolUse)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handlePostToolUse(ctx *hooks.HookContext, input *Input) (string, error) {
|
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)
|
fmt.Fprintf(os.Stderr, "[post-tool-use] %s\n", input.ToolName)
|
||||||
|
|
||||||
// Send observation to worker
|
// Send observation to worker
|
||||||
|
|||||||
@@ -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.
|
// MigrationManager handles database schema migrations.
|
||||||
|
|||||||
@@ -32,11 +32,15 @@ func (s *PromptStore) SetCleanupFunc(fn PromptCleanupFunc) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SaveUserPromptWithMatches saves a user prompt with matched observation count.
|
// 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) {
|
func (s *PromptStore) SaveUserPromptWithMatches(ctx context.Context, claudeSessionID string, promptNumber int, promptText string, matchedObservations int) (int64, error) {
|
||||||
now := time.Now()
|
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 = `
|
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)
|
(claude_session_id, prompt_number, prompt_text, matched_observations, created_at, created_at_epoch)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
@@ -51,6 +55,17 @@ func (s *PromptStore) SaveUserPromptWithMatches(ctx context.Context, claudeSessi
|
|||||||
|
|
||||||
id, _ := result.LastInsertId()
|
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)
|
// Cleanup old prompts beyond the global limit (async to not block handler)
|
||||||
go func() {
|
go func() {
|
||||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
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)
|
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.
|
// GetRecentUserPromptsByProject retrieves recent user prompts for a specific project.
|
||||||
func (s *PromptStore) GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error) {
|
func (s *PromptStore) GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error) {
|
||||||
const query = `
|
const query = `
|
||||||
|
|||||||
+33
-15
@@ -49,22 +49,36 @@ type Asset struct {
|
|||||||
|
|
||||||
// UpdateInfo contains information about an available update.
|
// UpdateInfo contains information about an available update.
|
||||||
type UpdateInfo struct {
|
type UpdateInfo struct {
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
CurrentVersion string `json:"current_version"`
|
CurrentVersion string `json:"current_version"`
|
||||||
LatestVersion string `json:"latest_version"`
|
LatestVersion string `json:"latest_version"`
|
||||||
ReleaseNotes string `json:"release_notes,omitempty"`
|
ReleaseNotes string `json:"release_notes,omitempty"`
|
||||||
PublishedAt time.Time `json:"published_at,omitempty"`
|
PublishedAt time.Time `json:"published_at,omitempty"`
|
||||||
DownloadURL string `json:"download_url,omitempty"`
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
ChecksumsURL string `json:"checksums_url,omitempty"`
|
ChecksumsURL string `json:"checksums_url,omitempty"`
|
||||||
BundleURL string `json:"bundle_url,omitempty"` // Sigstore bundle (.sigstore.json)
|
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.
|
// UpdateStatus represents the current update status.
|
||||||
type UpdateStatus struct {
|
type UpdateStatus struct {
|
||||||
State string `json:"state"` // "idle", "checking", "downloading", "verifying", "applying", "done", "error"
|
State string `json:"state"` // "idle", "checking", "downloading", "verifying", "applying", "done", "error"
|
||||||
Progress float64 `json:"progress"`
|
Progress float64 `json:"progress"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
ManualUpdateCommand string `json:"manual_update_command,omitempty"` // Shown when update fails
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updater handles self-updates.
|
// Updater handles self-updates.
|
||||||
@@ -112,9 +126,10 @@ func (u *Updater) setError(err error) {
|
|||||||
u.mu.Lock()
|
u.mu.Lock()
|
||||||
defer u.mu.Unlock()
|
defer u.mu.Unlock()
|
||||||
u.status = UpdateStatus{
|
u.status = UpdateStatus{
|
||||||
State: "error",
|
State: "error",
|
||||||
Message: "Update failed",
|
Message: "Update failed",
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
|
ManualUpdateCommand: GetManualUpdateCommand(""), // Always provide fallback command
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +184,9 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (*UpdateInfo, error) {
|
|||||||
// Compare versions
|
// Compare versions
|
||||||
info.Available = isNewerVersion(info.LatestVersion, u.currentVersion)
|
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 {
|
if info.Available {
|
||||||
// Find download URLs for current platform
|
// Find download URLs for current platform
|
||||||
platform := getPlatform()
|
platform := getPlatform()
|
||||||
|
|||||||
@@ -159,7 +159,13 @@ type SessionInitResponse struct {
|
|||||||
Reason string `json:"reason,omitempty"`
|
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.
|
// 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) {
|
func (s *Service) handleSessionInit(w http.ResponseWriter, r *http.Request) {
|
||||||
var req SessionInitRequest
|
var req SessionInitRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean prompt and create session
|
// Clean prompt
|
||||||
cleanedPrompt := privacy.Clean(req.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)
|
sessionID, err := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, cleanedPrompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mnemonic-dashboard",
|
"name": "claude-mnemonic-dashboard",
|
||||||
"version": "v0.6.8-2-g378e7dc-dirty",
|
"version": "v0.6.11-2-g8ff0873-dirty",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "claude-mnemonic-dashboard",
|
"name": "claude-mnemonic-dashboard",
|
||||||
"version": "v0.6.8-2-g378e7dc-dirty",
|
"version": "v0.6.11-2-g8ff0873-dirty",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mnemonic-dashboard",
|
"name": "claude-mnemonic-dashboard",
|
||||||
"version": "v0.6.8-2-g378e7dc-dirty",
|
"version": "v0.6.11-2-g8ff0873-dirty",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user