Compare commits

...
10 Commits
20 changed files with 550 additions and 78 deletions
+3
View File
@@ -266,6 +266,9 @@ archives:
- src: plugin/hooks/hooks.json
dst: hooks
strip_parent: true
- src: plugin/commands/*
dst: commands
strip_parent: true
format_overrides:
- goos: windows
formats:
+3
View File
@@ -138,10 +138,13 @@ install: build stop-worker
@# Install to marketplaces directory (for direct installs)
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands
cp $(BUILD_DIR)/worker $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
cp $(BUILD_DIR)/mcp-server $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
cp $(BUILD_DIR)/hooks/* $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
cp $(PLUGIN_DIR)/hooks/hooks.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
@# Copy slash commands if they exist
@if [ -d "$(PLUGIN_DIR)/commands" ]; then cp -r $(PLUGIN_DIR)/commands/* $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands/ 2>/dev/null || true; fi
@# Update plugin.json and marketplace.json with current version to prevent stale version directories
@sed 's/"version": "[^"]*"/"version": "$(VERSION)"/g' $(PLUGIN_DIR)/.claude-plugin/plugin.json > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/plugin.json
@sed 's/"version": "[^"]*"/"version": "$(VERSION)"/g' $(PLUGIN_DIR)/.claude-plugin/marketplace.json > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/marketplace.json
+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
+2 -2
View File
@@ -13,7 +13,7 @@ require (
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.11.1
github.com/sugarme/tokenizer v0.3.0
github.com/yalue/onnxruntime_go v1.21.0
github.com/yalue/onnxruntime_go v1.25.0
)
require (
@@ -28,6 +28,6 @@ require (
github.com/schollz/progressbar/v2 v2.15.0 // indirect
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/text v0.32.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+4 -4
View File
@@ -44,15 +44,15 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c h1:pwb4kNSHb4K89ymCaN+5lPH/MwnfSVg4rzGDh4d+iy4=
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c/go.mod h1:2gwkXLWbDGUQWeL3RtpCmcY4mzCtU13kb9UsAg9xMaw=
github.com/yalue/onnxruntime_go v1.21.0 h1:DdtvfY7OP5gR8mwPDqAOAQckf+KcI30hPNJL8hQaYWI=
github.com/yalue/onnxruntime_go v1.21.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
github.com/yalue/onnxruntime_go v1.25.0 h1:nlhVau1BpLZ/BYr+WpPZCJRD/WES0qo6dK7aKyyAs3g=
github.com/yalue/onnxruntime_go v1.25.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+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 = `
+114 -53
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()
@@ -451,57 +469,100 @@ func (u *Updater) extractTarGz(archivePath, destDir string) error {
}
func (u *Updater) replaceBinaries(extractDir string) error {
// Files to replace
binaries := []struct {
src string
dest string
}{
{"worker", filepath.Join(u.installDir, "worker")},
{"mcp-server", filepath.Join(u.installDir, "mcp-server")},
{"hooks/session-start", filepath.Join(u.installDir, "hooks", "session-start")},
{"hooks/user-prompt", filepath.Join(u.installDir, "hooks", "user-prompt")},
{"hooks/post-tool-use", filepath.Join(u.installDir, "hooks", "post-tool-use")},
{"hooks/stop", filepath.Join(u.installDir, "hooks", "stop")},
{"hooks/subagent-stop", filepath.Join(u.installDir, "hooks", "subagent-stop")},
// Binary files to replace
binaryFiles := []string{
"worker",
"mcp-server",
"hooks/session-start",
"hooks/user-prompt",
"hooks/post-tool-use",
"hooks/stop",
"hooks/subagent-stop",
"hooks/statusline",
}
for _, b := range binaries {
src := filepath.Join(extractDir, b.src)
if _, err := os.Stat(src); os.IsNotExist(err) {
continue // Skip if not in archive
}
// Get all install directories (marketplaces and cache directories)
installDirs := u.getInstallDirectories()
// Backup existing binary
if _, err := os.Stat(b.dest); err == nil {
backup := b.dest + ".bak"
if err := os.Rename(b.dest, backup); err != nil {
return fmt.Errorf("failed to backup %s: %w", b.dest, err)
for _, installDir := range installDirs {
log.Debug().Str("dir", installDir).Msg("Installing binaries to directory")
for _, binaryFile := range binaryFiles {
src := filepath.Join(extractDir, binaryFile)
if _, err := os.Stat(src); os.IsNotExist(err) {
continue // Skip if not in archive
}
defer func(backup, dest string) {
// Clean up backup on success, restore on failure
if _, err := os.Stat(dest); err == nil {
_ = os.Remove(backup)
dest := filepath.Join(installDir, binaryFile)
// Backup existing binary
if _, err := os.Stat(dest); err == nil {
backup := dest + ".bak"
if err := os.Rename(dest, backup); err != nil {
log.Warn().Err(err).Str("file", dest).Msg("Failed to backup, continuing anyway")
} else {
_ = os.Rename(backup, dest)
defer func(backup, dest string) {
// Clean up backup on success, restore on failure
if _, err := os.Stat(dest); err == nil {
_ = os.Remove(backup)
} else {
_ = os.Rename(backup, dest)
}
}(backup, dest)
}
}(backup, b.dest)
}
}
// Copy new binary
if err := copyFile(src, b.dest); err != nil {
return fmt.Errorf("failed to install %s: %w", b.dest, err)
}
// Copy new binary
if err := copyFile(src, dest); err != nil {
return fmt.Errorf("failed to install %s: %w", dest, err)
}
// Make executable
// #nosec G302 -- executables require 0755 permissions
if err := os.Chmod(b.dest, 0755); err != nil {
return fmt.Errorf("failed to chmod %s: %w", b.dest, err)
// Make executable
// #nosec G302 -- executables require 0755 permissions
if err := os.Chmod(dest, 0755); err != nil {
return fmt.Errorf("failed to chmod %s: %w", dest, err)
}
}
}
return nil
}
// getInstallDirectories returns all directories where binaries should be installed.
// This includes the marketplaces directory and any cache directories.
func (u *Updater) getInstallDirectories() []string {
dirs := []string{u.installDir}
// Also check cache directories where Claude Code looks for plugins
home, err := os.UserHomeDir()
if err != nil {
return dirs
}
// Look for cache directories under ~/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/
cacheBase := filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic")
entries, err := os.ReadDir(cacheBase)
if err != nil {
return dirs
}
for _, entry := range entries {
if entry.IsDir() {
cacheDir := filepath.Join(cacheBase, entry.Name())
// Only add if it's different from installDir and contains a worker binary
if cacheDir != u.installDir {
workerPath := filepath.Join(cacheDir, "worker")
if _, err := os.Stat(workerPath); err == nil {
dirs = append(dirs, cacheDir)
log.Debug().Str("dir", cacheDir).Msg("Found cache directory to update")
}
}
}
}
return dirs
}
func copyFile(src, dst string) error {
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(dst), 0750); err != nil {
+57 -2
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)
@@ -995,7 +1024,7 @@ func (s *Service) handleSelfCheck(w http.ResponseWriter, r *http.Request) {
})
}
// handleUpdateRestart restarts the worker with the new binary.
// handleUpdateRestart restarts the worker with the new binary (after update).
func (s *Service) handleUpdateRestart(w http.ResponseWriter, r *http.Request) {
status := s.updater.GetStatus()
if status.State != "done" {
@@ -1021,3 +1050,29 @@ func (s *Service) handleUpdateRestart(w http.ResponseWriter, r *http.Request) {
}
}()
}
// handleRestart restarts the worker process (general restart, not tied to update).
func (s *Service) handleRestart(w http.ResponseWriter, r *http.Request) {
log.Info().Msg("Manual restart requested via API")
// Send response before restarting
writeJSON(w, map[string]interface{}{
"success": true,
"message": "Restarting worker...",
"version": s.version,
})
// Flush the response
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// Restart in background after response is sent
go func() {
// Small delay to ensure response is sent
time.Sleep(100 * time.Millisecond)
if err := s.updater.Restart(); err != nil {
log.Error().Err(err).Msg("Failed to restart worker")
}
}()
}
+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)
}
})
}
}
+3
View File
@@ -635,6 +635,9 @@ func (s *Service) setupRoutes() {
s.router.Get("/api/update/status", s.handleUpdateStatus)
s.router.Post("/api/update/restart", s.handleUpdateRestart)
// General restart endpoint (works before DB is ready)
s.router.Post("/api/restart", s.handleRestart)
// Selfcheck endpoint (works before DB is ready - checks all components)
s.router.Get("/api/selfcheck", s.handleSelfCheck)
+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.22",
"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.22",
"versions": {
"0.6.4": {
"releaseDate": "2025-12-16",
"0.6.22": {
"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.22/claude-mnemonic_0.6.22_darwin_arm64.tar.gz",
"format": "tar.gz",
"sha256": "5bb082901e8b4f4751d011675a235a5ed25403204732e09842b5ae75136f65ae"
"sha256": "19059371bcdc913c0fca1e42461f8f2477af0419046c5f9f1e521ea101def536"
},
"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.22/claude-mnemonic_0.6.22_linux_amd64.tar.gz",
"format": "tar.gz",
"sha256": "dee774fbc39e78ee6ff3836403c2901471dd0d417f62e214088f6b8285b3b23d"
"sha256": "dfeea1a63ebbb85a363ea59b85349d98dad34353b131aa33ccf3b599ed1a89c8"
},
"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.22/claude-mnemonic_0.6.22_windows_amd64.zip",
"format": "zip",
"sha256": "e3973bc1107f9cc92cf95697c62138b4cab4d0d4e00f836dfc4592ca84bbdab7"
"sha256": "1c9357d3655a00ec840783efc5f7f25bbe320ce75b07200e54806f44c8a5d641"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
# Restart Claude Mnemonic Worker
Restart the claude-mnemonic worker process. Use this command when experiencing issues with the memory system.
## Instructions
1. Call the restart API endpoint using curl:
```bash
curl -X POST http://127.0.0.1:37777/api/restart
```
2. Wait a moment for the worker to restart (typically 1-2 seconds)
3. Verify the worker is running by checking the version:
```bash
curl -s http://127.0.0.1:37777/api/version
```
4. Report the result to the user, including the version number from the response.
If the restart fails, suggest the user check `/tmp/claude-mnemonic-worker.log` for errors.
+7
View File
@@ -176,6 +176,7 @@ download_release() {
info "Installing to ${INSTALL_DIR}..."
mkdir -p "$INSTALL_DIR/hooks"
mkdir -p "$INSTALL_DIR/.claude-plugin"
mkdir -p "$INSTALL_DIR/commands"
# Copy binaries
cp "$tmp_dir/worker" "$INSTALL_DIR/"
@@ -185,6 +186,11 @@ download_release() {
# Copy plugin configuration
cp "$tmp_dir/.claude-plugin/"* "$INSTALL_DIR/.claude-plugin/"
# Copy slash commands if they exist in the release
if [[ -d "$tmp_dir/commands" ]]; then
cp -r "$tmp_dir/commands/"* "$INSTALL_DIR/commands/" 2>/dev/null || true
fi
# Make binaries executable
chmod +x "$INSTALL_DIR/worker"
chmod +x "$INSTALL_DIR/mcp-server"
@@ -230,6 +236,7 @@ register_plugin() {
# Copy files to cache directory
mkdir -p "$cache_path/.claude-plugin"
mkdir -p "$cache_path/hooks"
mkdir -p "$cache_path/commands"
cp -r "$INSTALL_DIR/"* "$cache_path/" 2>/dev/null || true
# Register in installed_plugins.json
+1
View File
@@ -45,6 +45,7 @@ if command -v jq &> /dev/null; then
# Ensure cache directory exists and copy plugin files
mkdir -p "$CACHE_PATH/.claude-plugin"
mkdir -p "$CACHE_PATH/hooks"
mkdir -p "$CACHE_PATH/commands"
# Copy files from marketplace to cache
cp -r "$MARKETPLACE_PATH/"* "$CACHE_PATH/" 2>/dev/null || true
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-mnemonic-dashboard",
"version": "v0.6.5-25-g372942e-dirty",
"version": "v0.6.15-2-gd8a4939-dirty",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-mnemonic-dashboard",
"version": "v0.6.5-25-g372942e-dirty",
"version": "v0.6.15-2-gd8a4939-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.15-2-gd8a4939-dirty",
"private": true,
"type": "module",
"scripts": {
+25 -1
View File
@@ -7,7 +7,7 @@ import Timeline from '@/components/Timeline.vue'
import Sidebar from '@/components/Sidebar.vue'
// Composables
const { isConnected, isProcessing, queueDepth } = useSSE()
const { isConnected, isReconnecting, reconnectCountdown, isProcessing, queueDepth } = useSSE()
const { stats } = useStats()
const { updateInfo, updateStatus, isUpdating, applyUpdate } = useUpdate()
const { health } = useHealth()
@@ -33,6 +33,17 @@ const {
<template>
<div class="min-h-screen">
<!-- Reconnection Banner -->
<Transition name="slide">
<div
v-if="isReconnecting"
class="fixed top-0 left-0 right-0 z-50 bg-amber-500/90 backdrop-blur-sm text-black px-4 py-2 text-center text-sm font-medium shadow-lg"
>
<i class="fas fa-sync-alt fa-spin mr-2" />
Connection lost. Reconnecting<span v-if="reconnectCountdown > 0"> in {{ reconnectCountdown }}s</span>...
</div>
</Transition>
<!-- Header -->
<Header
:is-connected="isConnected"
@@ -94,3 +105,16 @@ const {
</main>
</div>
</template>
<style scoped>
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateY(-100%);
opacity: 0;
}
</style>
+33
View File
@@ -3,12 +3,15 @@ import type { SSEEvent } from '@/types'
// Singleton state - shared across all useSSE() calls
const isConnected = ref(false)
const isReconnecting = ref(false)
const reconnectCountdown = ref(0)
const isProcessing = ref(false)
const queueDepth = ref(0)
const lastEvent = ref<SSEEvent | null>(null)
let eventSource: EventSource | null = null
let reconnectTimeout: number | null = null
let countdownInterval: number | null = null
let connectionCount = 0
let reconnectAttempt = 0
@@ -24,6 +27,28 @@ function getBackoffDelay(): number {
return Math.floor(baseDelay + jitter)
}
function startCountdown(delayMs: number) {
reconnectCountdown.value = Math.ceil(delayMs / 1000)
if (countdownInterval) {
clearInterval(countdownInterval)
}
countdownInterval = window.setInterval(() => {
reconnectCountdown.value = Math.max(0, reconnectCountdown.value - 1)
if (reconnectCountdown.value <= 0 && countdownInterval) {
clearInterval(countdownInterval)
countdownInterval = null
}
}, 1000)
}
function stopCountdown() {
if (countdownInterval) {
clearInterval(countdownInterval)
countdownInterval = null
}
reconnectCountdown.value = 0
}
export function useSSE() {
const connect = () => {
@@ -36,6 +61,8 @@ export function useSSE() {
eventSource.onopen = () => {
isConnected.value = true
isReconnecting.value = false
stopCountdown()
reconnectAttempt = 0 // Reset backoff on successful connection
console.log('[SSE] Connected')
}
@@ -62,6 +89,7 @@ export function useSSE() {
eventSource.onerror = () => {
isConnected.value = false
isReconnecting.value = true
eventSource?.close()
eventSource = null
@@ -70,6 +98,7 @@ export function useSSE() {
reconnectAttempt++
console.log(`[SSE] Reconnecting in ${Math.round(delay/1000)}s (attempt ${reconnectAttempt})`)
startCountdown(delay)
reconnectTimeout = window.setTimeout(() => {
connect()
}, delay)
@@ -81,11 +110,13 @@ export function useSSE() {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
stopCountdown()
if (eventSource) {
eventSource.close()
eventSource = null
}
isConnected.value = false
isReconnecting.value = false
}
// Handle page unload/refresh to ensure SSE connection is closed immediately
@@ -132,6 +163,8 @@ export function useSSE() {
return {
isConnected,
isReconnecting,
reconnectCountdown,
isProcessing,
queueDepth,
lastEvent,