Compare commits

..
6 Commits
10 changed files with 341 additions and 33 deletions
+39
View File
@@ -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
+11
View File
@@ -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.
+41 -1
View File
@@ -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 = `
+34 -16
View File
@@ -27,7 +27,7 @@ const (
GitHubRepo = "lukaszraczylo/claude-mnemonic"
ReleasesAPI = "https://api.github.com/repos/" + GitHubRepo + "/releases/latest"
CheckInterval = 24 * time.Hour
MaxExtractedSize = 100 * 1024 * 1024 // 100MB max per extracted file
MaxExtractedSize = 250 * 1024 * 1024 // 250MB max per extracted file
RestartDelay = 500 * time.Millisecond
)
@@ -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()
+30 -1
View File
@@ -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)
+47 -2
View File
@@ -642,25 +642,57 @@ Your response:`,
// rather than actual user work. These summaries should be filtered out.
func isSelfReferentialSummary(summary *models.ParsedSummary) bool {
// Combine all summary fields for checking
content := strings.ToLower(summary.Request + " " + summary.Completed + " " + summary.Learned + " " + summary.NextSteps)
content := strings.ToLower(summary.Request + " " + summary.Completed + " " + summary.Learned + " " + summary.NextSteps + " " + summary.Investigated + " " + summary.Notes)
// Indicators that the summary is about the memory agent, not user work
selfReferentialPhrases := []string{
// Agent references
"memory extraction",
"memory agent",
"extraction agent",
"hook execution",
"hook mechanism",
// Session meta-state
"session initialization",
"session setup",
"session has just started",
"session just started",
"agent initialization",
"no technical learnings",
"no code or project work",
// Waiting states
"waiting for the user",
"waiting for user",
"awaiting actual",
"awaiting claude code",
"awaiting tool",
"awaiting user",
// Meta checkpoint references
"progress checkpoint",
"checkpoint request",
// Common no-work phrases
"no work has been completed",
"no work completed",
"no work done",
"no actual work",
"nothing has been completed",
"nothing completed",
// Role/guideline parroting
"role definition",
"operational guidelines",
"providing role",
"providing guidelines",
// System prompt echoes
"extract meaningful observations",
"meaningful learnings",
"analyze tool executions",
"observations for future sessions",
// Empty session indicators
"empty session",
"no substantive work",
"no meaningful work",
"just beginning",
"just begun",
}
matchCount := 0
@@ -685,20 +717,33 @@ func hasMeaningfulContent(assistantMsg string) bool {
lowerMsg := strings.ToLower(assistantMsg)
// Skip messages that are primarily about system/hook status
// Skip messages that are primarily about system/hook status or meta-instructions
skipIndicators := []string{
// System/hook markers
"hook success",
"callback hook",
"session start",
"sessionstart",
"system-reminder",
// Agent self-references
"memory extraction agent",
"memory agent",
"extraction agent",
// No-work indicators
"no technical learnings",
"waiting for",
"waiting to",
"no code or project work",
"no substantive",
"no work has been completed",
"no work done",
"awaiting tool",
"awaiting user",
// Meta-instruction echoes
"role definition",
"operational guidelines",
"analyze tool executions",
"extract meaningful observations",
}
skipCount := 0
+126
View File
@@ -0,0 +1,126 @@
package sdk
import (
"testing"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
)
func TestIsSelfReferentialSummary(t *testing.T) {
tests := []struct {
name string
summary *models.ParsedSummary
expected bool
}{
{
name: "meta summary about memory agent role",
summary: &models.ParsedSummary{
Request: "Memory extraction agent role - analyze tool executions and extract meaningful observations for future sessions",
Completed: "No work has been completed yet. The session has just started with the user providing role definition and operational guidelines.",
Learned: "The system expects observations to be created from meaningful learnings during Claude Code sessions, with focus on decisions, bugs fixed, patterns discovered, project structure changes, and code modifications.",
NextSteps: "Awaiting tool executions or user requests that contain actual work performed in a Claude Code session.",
},
expected: true,
},
{
name: "legitimate summary about code changes",
summary: &models.ParsedSummary{
Request: "Fix authentication bug in login handler",
Completed: "Updated the auth middleware to properly validate JWT tokens and fixed the session expiry check.",
Learned: "The JWT library requires explicit algorithm validation to prevent token substitution attacks.",
NextSteps: "Add unit tests for the authentication flow.",
},
expected: false,
},
{
name: "awaiting user summary",
summary: &models.ParsedSummary{
Request: "Session initialization",
Completed: "No work completed yet.",
Learned: "Awaiting user input to begin work.",
NextSteps: "Waiting for the user to provide instructions.",
},
expected: true,
},
{
name: "summary about refactoring",
summary: &models.ParsedSummary{
Request: "Refactor database connection pooling",
Completed: "Implemented connection pooling using pgxpool with max 10 connections.",
Learned: "pgxpool automatically handles connection reuse and health checks.",
NextSteps: "Run benchmarks to verify performance improvement.",
},
expected: false,
},
{
name: "meta summary with extraction agent mention",
summary: &models.ParsedSummary{
Request: "Extraction agent initialization",
Completed: "No substantive work has been done.",
Learned: "The memory extraction agent analyzes tool executions.",
NextSteps: "Awaiting tool results to extract observations.",
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isSelfReferentialSummary(tt.summary)
if result != tt.expected {
t.Errorf("isSelfReferentialSummary() = %v, want %v", result, tt.expected)
}
})
}
}
func TestHasMeaningfulContent(t *testing.T) {
tests := []struct {
name string
content string
expected bool
}{
{
name: "empty content",
content: "",
expected: false,
},
{
name: "too short content",
content: "Hello world",
expected: false,
},
{
name: "meta content about memory agent",
content: `This is the memory extraction agent role definition.
The system expects you to analyze tool executions and extract meaningful observations.
No work has been completed yet. Awaiting tool results from the user's session.`,
expected: false,
},
{
name: "legitimate code discussion",
content: `I've updated the handler.go file to fix the authentication bug.
The function validateToken() was not checking token expiry correctly.
I've added a check for exp claim and implemented proper error handling.
The changes have been tested and the build passes.`,
expected: true,
},
{
name: "hook status messages",
content: `SessionStart:Callback hook success: Success
The memory agent is waiting for user input.
System-reminder about available tools.
No substantive work performed yet.`,
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := hasMeaningfulContent(tt.content)
if result != tt.expected {
t.Errorf("hasMeaningfulContent() = %v, want %v", result, tt.expected)
}
})
}
}
+10 -10
View File
@@ -13,7 +13,7 @@
{
"name": "claude-mnemonic",
"description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB vector search",
"version": "0.6.4",
"version": "0.6.15",
"author": {
"name": "lukaszraczylo",
"url": "https://github.com/lukaszraczylo"
@@ -23,25 +23,25 @@
"license": "MIT",
"homepage": "https://github.com/lukaszraczylo/claude-mnemonic",
"releases": {
"latest": "0.6.4",
"latest": "0.6.15",
"versions": {
"0.6.4": {
"releaseDate": "2025-12-16",
"0.6.15": {
"releaseDate": "2025-12-17",
"downloads": {
"darwin-arm64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.4/claude-mnemonic_0.6.4_darwin_arm64.tar.gz",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.15/claude-mnemonic_0.6.15_darwin_arm64.tar.gz",
"format": "tar.gz",
"sha256": "5bb082901e8b4f4751d011675a235a5ed25403204732e09842b5ae75136f65ae"
"sha256": "f26095d797a563803def30d18c9bfab0c2277e3e64d81c97ba8baf26e415a603"
},
"linux-amd64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.4/claude-mnemonic_0.6.4_linux_amd64.tar.gz",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.15/claude-mnemonic_0.6.15_linux_amd64.tar.gz",
"format": "tar.gz",
"sha256": "dee774fbc39e78ee6ff3836403c2901471dd0d417f62e214088f6b8285b3b23d"
"sha256": "d90097495a44bc4599458b61d4bf01d2337d19d0017418f278aec9b028350c66"
},
"windows-amd64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.4/claude-mnemonic_0.6.4_windows_amd64.zip",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.6.15/claude-mnemonic_0.6.15_windows_amd64.zip",
"format": "zip",
"sha256": "e3973bc1107f9cc92cf95697c62138b4cab4d0d4e00f836dfc4592ca84bbdab7"
"sha256": "fd1d82e207098caf100edcb94cc22f14ffb50cd89a3576522720861ba798955b"
}
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-mnemonic-dashboard",
"version": "v0.6.5-25-g372942e-dirty",
"version": "v0.6.11-2-g8ff0873-dirty",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-mnemonic-dashboard",
"version": "v0.6.5-25-g372942e-dirty",
"version": "v0.6.11-2-g8ff0873-dirty",
"dependencies": {
"vue": "^3.5.13"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mnemonic-dashboard",
"version": "v0.6.5-25-g372942e-dirty",
"version": "v0.6.11-2-g8ff0873-dirty",
"private": true,
"type": "module",
"scripts": {