Compare commits

..
46 Commits
Author SHA1 Message Date
lukaszraczylo 00d8871f5e refactor: use oss-telemetry library instead of hand-rolled ping 2026-05-24 03:02:16 +01:00
lukaszraczylo 28b6582dac feat: add OSS analytics ping on MCP server startup 2026-05-24 03:01:15 +01:00
github-actions[bot] dc010fe57d chore: update marketplace for v0.11.113 2026-05-24 01:10:04 +00:00
github-actions[bot] 7e1f6efe3c chore: update marketplace for v0.11.109 2026-05-24 01:05:29 +00:00
lukaszraczylo eab3b58e0f fix: data race in maintenance totalOptimizeRun counter 2026-05-24 02:04:53 +01:00
lukaszraczylo 720a14cd65 chore: fix npm audit vulnerabilities (rollup, vite, uuid, postcss) 2026-05-24 01:58:20 +01:00
lukaszraczylo f07875ee82 fix: plugin no longer vanishes after Claude Code updates
Root cause: plugin registered as directory source in known_marketplaces.json,
which gets wiped on CLI updates. Now registers in extraKnownMarketplaces
(settings.json) as a GitHub source — same mechanism caveman/context-mode use.

Binaries install to ~/.claude-mnemonic/bin/ instead of the Claude-managed
plugins directory. Thin wrapper scripts in the repo let the marketplace
clone find them. Nothing gets cleaned up when Claude refreshes its cache.

Also fixed along the way:
- ONNX Runtime 1.24.3 → 1.26.0 (API v25 mismatch broke all embedding tests)
- Vector client leaked on DB reinit, processQueue had a race on sessionManager
- reloadConfig called os.Exit(0) bypassing graceful shutdown
- Removed dead QueryRowWithTimeout that leaked contexts
- Added tests for graph/watcher/maintenance/update (all were at 0%)
2026-05-24 01:56:54 +01:00
github-actions[bot] cfc95c9ce4 chore: update marketplace for v0.11.105 2026-04-21 04:14:53 +00:00
lukaszraczyloandGitHub 62da8e5315 Update go.mod and go.sum (#43) 2026-04-21 05:03:47 +01:00
github-actions[bot] d0200ff9ee chore: update marketplace for v0.11.101 2026-04-10 04:13:01 +00:00
lukaszraczyloandGitHub c14dbc6877 Update go.mod and go.sum (#42) 2026-04-10 05:03:55 +01:00
github-actions[bot] 9316c7ac8d chore: update marketplace for v0.11.97 2026-04-09 04:08:30 +00:00
lukaszraczyloandGitHub 73c42c9f55 Update go.mod and go.sum (#41) 2026-04-09 04:59:04 +01:00
github-actions[bot] c258c0c845 chore: update marketplace for v0.11.93 2026-04-07 04:12:24 +00:00
lukaszraczyloandGitHub be536e56d5 Update go.mod and go.sum (#40) 2026-04-07 04:59:43 +01:00
github-actions[bot] 6b575b92d6 chore: update marketplace for v0.11.89 2026-04-04 03:58:54 +00:00
lukaszraczyloandGitHub c572408ab4 Update go.mod and go.sum (#39) 2026-04-04 04:50:08 +01:00
github-actions[bot] fae538c169 chore: update marketplace for v0.11.85 2026-04-03 04:06:58 +00:00
lukaszraczyloandGitHub 9e333a7de1 Update go.mod and go.sum (#38) 2026-04-03 04:58:18 +01:00
github-actions[bot] 458f607b80 chore: update marketplace for v0.11.81 2026-03-30 04:15:50 +00:00
lukaszraczyloandGitHub 9c0083c24c Update go.mod and go.sum (#37) 2026-03-30 05:03:46 +01:00
github-actions[bot] a0fecab186 chore: update marketplace for v0.11.77 2026-03-28 03:59:53 +00:00
lukaszraczyloandGitHub 96e64a5796 Update go.mod and go.sum (#36) 2026-03-28 03:50:55 +00:00
github-actions[bot] ef92d710b8 chore: update marketplace for v0.11.73 2026-03-17 03:59:52 +00:00
lukaszraczyloandGitHub f8702cb0c2 Update go.mod and go.sum (#35) 2026-03-17 03:51:16 +00:00
github-actions[bot] c52bab1e95 chore: update marketplace for v0.11.69 2026-03-13 03:59:45 +00:00
lukaszraczyloandGitHub 1eec436746 Update go.mod and go.sum (#34) 2026-03-13 03:49:54 +00:00
github-actions[bot] 3ccb20df63 chore: update marketplace for v0.11.65 2026-03-09 03:58:43 +00:00
lukaszraczyloandGitHub 5fbba6bace Update go.mod and go.sum (#33) 2026-03-09 03:50:24 +00:00
github-actions[bot] de422c0de0 chore: update marketplace for v0.11.61 2026-03-07 03:51:29 +00:00
lukaszraczyloandGitHub db2a552eb8 Update go.mod and go.sum (#32) 2026-03-07 03:42:12 +00:00
github-actions[bot] 195caffc59 chore: update marketplace for v0.11.57 2026-03-07 01:34:33 +00:00
lukaszraczylo 7b979a3f95 fix: prevent internal prompts and duplicates in memory database
- 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
2026-03-07 01:28:04 +00:00
github-actions[bot] 49e7efd27d chore: update marketplace for v0.11.53 2026-03-07 00:36:01 +00:00
lukaszraczylo fcab3ebef4 fixup! fixup! fixup! fixup! fixup! chore: update marketplace for v0.11.37 2026-03-07 00:25:52 +00:00
lukaszraczylo a1d4459329 fixup! fixup! fixup! fixup! chore: update marketplace for v0.11.37
march-improvements
2026-03-07 00:12:13 +00:00
github-actions[bot] 5a81f37779 chore: update marketplace for v0.11.47 2026-03-06 23:53:50 +00:00
lukaszraczylo 11fd1969de fixup! fixup! fixup! chore: update marketplace for v0.11.37 2026-03-06 23:45:38 +00:00
github-actions[bot] cd00f74e2c chore: update marketplace for v0.11.43 2026-03-06 15:51:16 +00:00
lukaszraczylo 319f2ca28b fixup! fixup! chore: update marketplace for v0.11.37 2026-03-06 15:39:58 +00:00
lukaszraczylo 77f5f02510 fixup! chore: update marketplace for v0.11.37
march-improvements
2026-03-06 15:39:52 +00:00
github-actions[bot] 1a6f6b6e5e chore: update marketplace for v0.11.37 2026-02-11 04:08:18 +00:00
lukaszraczyloandGitHub b0c0b4208e Update go.mod and go.sum (#29) 2026-02-11 03:59:17 +00:00
github-actions[bot] 84aff03d7c chore: update marketplace for v0.11.33 2026-02-10 04:09:02 +00:00
lukaszraczyloandGitHub 9432958b03 Update go.mod and go.sum (#28) 2026-02-10 04:00:07 +00:00
github-actions[bot] 1c63843b6f chore: update marketplace for v0.11.29 2026-02-09 04:05:07 +00:00
69 changed files with 6275 additions and 3064 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "claude-mnemonic",
"version": "0.6.38",
"description": "Persistent memory system for Claude Code - stores observations, session summaries, and user prompts with semantic search",
"owner": {
"name": "lukaszraczylo",
"email": "[email protected]"
},
"plugins": [
{
"name": "claude-mnemonic",
"description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB",
"version": "0.6.38",
"author": {
"name": "lukaszraczylo"
},
"source": "./",
"category": "productivity"
}
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "claude-mnemonic",
"version": "v0.11.57-dirty",
"description": "Persistent memory system for Claude Code with SQLite, FTS5, and vector search",
"author": {
"name": "lukaszraczylo",
"email": "[email protected]",
"url": "https://github.com/lukaszraczylo"
},
"homepage": "https://github.com/lukaszraczylo/claude-mnemonic",
"repository": "https://github.com/lukaszraczylo/claude-mnemonic",
"license": "MIT",
"hooks": "./hooks/hooks.json",
"mcpServers": {
"claude-mnemonic": {
"command": "${CLAUDE_PLUGIN_ROOT}/mcp-server",
"args": ["--project", "${CLAUDE_PROJECT}"],
"env": {}
}
},
"commands": ["./commands/restart.md"]
}
+1 -5
View File
@@ -82,11 +82,7 @@ logs/
# goreleaser
dist/
docs/dist
.claude-plugin
# Auto-generated plugin configs (generated by scripts/generate-plugin-config.sh)
.claude-plugin/
# Non-template plugin configs (keep only .tpl files)
# Non-template plugin configs (keep only .tpl files in plugin/ dir)
plugin/.claude-plugin/plugin.json
plugin/.claude-plugin/marketplace.json
+43 -21
View File
@@ -5,6 +5,7 @@ VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev
LDFLAGS := -ldflags "-X main.Version=$(VERSION) -X github.com/lukaszraczylo/claude-mnemonic/pkg/hooks.Version=$(VERSION) -s -w" -buildvcs=false
BUILD_DIR := bin
PLUGIN_DIR := plugin
STABLE_BIN := $(HOME)/.claude-mnemonic/bin
# Go settings
GOOS ?= $(shell go env GOOS)
@@ -14,7 +15,7 @@ GOARCH ?= $(shell go env GOARCH)
export CGO_ENABLED=1
BUILD_TAGS := -tags "fts5"
.PHONY: all build clean test install lint hooks worker mcp stop-worker start-worker restart-worker dashboard website dev-website setup-libs
.PHONY: all build clean test install lint hooks worker mcp stop-worker start-worker restart-worker dashboard website dev-website setup-libs update-version
all: build
@@ -22,8 +23,19 @@ all: build
setup-libs:
@./scripts/download-onnx-libs.sh all
# Update version in committed plugin metadata
update-version:
@if [ -f .claude-plugin/plugin.json ]; then \
sed 's/"version": "[^"]*"/"version": "$(VERSION)"/' .claude-plugin/plugin.json > .claude-plugin/plugin.json.tmp && mv .claude-plugin/plugin.json.tmp .claude-plugin/plugin.json; \
echo "Updated .claude-plugin/plugin.json to version $(VERSION)"; \
fi
@if [ -f marketplace.json ]; then \
sed 's/"version": "[^"]*"/"version": "$(VERSION)"/' marketplace.json > marketplace.json.tmp && mv marketplace.json.tmp marketplace.json; \
echo "Updated marketplace.json version fields to $(VERSION)"; \
fi
# Build all binaries
build: setup-libs dashboard worker hooks mcp
build: setup-libs update-version dashboard worker hooks mcp
# Build Vue dashboard
dashboard:
@@ -108,7 +120,12 @@ build-windows:
# Stop any running worker
stop-worker:
@echo "Stopping worker..."
@-pkill -TERM -f 'claude-mnemonic.*worker' 2>/dev/null || true
@-pkill -TERM -f '\.claude-mnemonic/bin/worker' 2>/dev/null || true
@-pkill -TERM -f '\.claude/plugins/.*/worker' 2>/dev/null || true
@sleep 1
@-pkill -9 -f 'claude-mnemonic.*worker' 2>/dev/null || true
@-pkill -9 -f '\.claude-mnemonic/bin/worker' 2>/dev/null || true
@-pkill -9 -f '\.claude/plugins/.*/worker' 2>/dev/null || true
@-lsof -ti :37777 | xargs kill -9 2>/dev/null || true
@sleep 1
@@ -116,12 +133,7 @@ stop-worker:
# Start worker in background
start-worker:
@echo "Starting worker..."
@# Prefer cache directory (where Claude Code looks), fall back to marketplaces
@if [ -f "$(HOME)/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/$(VERSION)/worker" ]; then \
nohup $(HOME)/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/$(VERSION)/worker > /tmp/claude-mnemonic-worker.log 2>&1 & \
else \
nohup $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/worker > /tmp/claude-mnemonic-worker.log 2>&1 & \
fi
@nohup $(STABLE_BIN)/worker > /tmp/claude-mnemonic-worker.log 2>&1 &
@sleep 1
@if curl -s http://localhost:37777/health > /dev/null 2>&1; then \
echo "Worker started successfully (http://localhost:37777)"; \
@@ -132,22 +144,31 @@ start-worker:
# Restart worker
restart-worker: stop-worker start-worker
# Install to Claude plugins directory
# Install to stable binary location and register with Claude Code
install: build stop-worker
@echo "Installing to Claude plugins directory..."
@# Install to marketplaces directory (for direct installs)
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks
@echo "Installing claude-mnemonic..."
@# Verify build output binaries exist
@test -f $(BUILD_DIR)/worker || { echo "ERROR: $(BUILD_DIR)/worker not found. Build may have failed."; exit 1; }
@test -f $(BUILD_DIR)/mcp-server || { echo "ERROR: $(BUILD_DIR)/mcp-server not found. Build may have failed."; exit 1; }
@test -d $(BUILD_DIR)/hooks || { echo "ERROR: $(BUILD_DIR)/hooks not found. Build may have failed."; exit 1; }
@# Install binaries to stable location (survives Claude Code updates)
@mkdir -p $(STABLE_BIN)/hooks
cp $(BUILD_DIR)/worker $(STABLE_BIN)/
cp $(BUILD_DIR)/mcp-server $(STABLE_BIN)/
cp $(BUILD_DIR)/hooks/* $(STABLE_BIN)/hooks/
@echo "Binaries installed to $(STABLE_BIN)"
@# Set up marketplace directory with wrapper scripts and metadata
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks
@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)/g; s/{{.Version}}/$(VERSION)/g' $(PLUGIN_DIR)/.claude-plugin/plugin.json.tpl > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/plugin.json
@sed 's/{{ .Version }}/$(VERSION)/g; s/{{.Version}}/$(VERSION)/g' $(PLUGIN_DIR)/.claude-plugin/marketplace.json.tpl > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/marketplace.json
@cp .claude-plugin/plugin.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/
@cp marketplace.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/marketplace.json 2>/dev/null || true
@cp hooks/hooks.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
@cp hooks/session-start hooks/user-prompt hooks/post-tool-use hooks/stop hooks/subagent-stop hooks/statusline $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
@chmod +x $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/*
@cp mcp-server $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
@chmod +x $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/mcp-server
@cp commands/restart.md $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands/
@echo "Registering plugin with Claude Code..."
@./scripts/register-plugin.sh "$(VERSION)"
@$(MAKE) start-worker
@@ -228,6 +249,7 @@ help:
@echo " make fmt - Format code"
@echo " make clean - Clean build artifacts"
@echo " make dev - Run worker in development mode"
@echo " make update-version - Update version in plugin metadata files"
@echo " make deps - Download dependencies"
@echo " make website - Build website for production"
@echo " make dev-website - Run website dev server"
+32 -10
View File
@@ -2,8 +2,10 @@
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
)
@@ -51,6 +53,10 @@ var skipTools = map[string]bool{
}
func main() {
if !hooks.IsWorkerAvailable() {
hooks.WriteResponse("PostToolUse", true)
return
}
hooks.RunHook("PostToolUse", handlePostToolUse)
}
@@ -63,15 +69,31 @@ func handlePostToolUse(ctx *hooks.HookContext, input *Input) (string, error) {
fmt.Fprintf(os.Stderr, "[post-tool-use] %s\n", input.ToolName)
// Send observation to worker
_, err := hooks.POST(ctx.Port, "/api/sessions/observations", map[string]interface{}{
"claudeSessionId": ctx.SessionID,
"project": ctx.Project,
"tool_name": input.ToolName,
"tool_input": input.ToolInput,
"tool_response": input.ToolResponse,
"cwd": ctx.CWD,
})
// Fire-and-forget: send the observation without waiting for the response.
// The worker just queues it -- we don't need the response data.
// Use a short-lived context to ensure the request body is at least sent
// before this process exits.
done := make(chan struct{})
go func() {
defer close(done)
sendCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
_ = hooks.POSTWithContext(sendCtx, ctx.Port, "/api/sessions/observations", map[string]interface{}{
"claudeSessionId": ctx.SessionID,
"project": ctx.Project,
"tool_name": input.ToolName,
"tool_input": input.ToolInput,
"tool_response": input.ToolResponse,
"cwd": ctx.CWD,
})
}()
return "", err
// Wait briefly for the TCP connection to be established and request sent,
// but don't block the hook for the full response.
select {
case <-done:
case <-time.After(100 * time.Millisecond):
}
return "", nil
}
+81 -10
View File
@@ -6,6 +6,7 @@ import (
"net/url"
"os"
"strings"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
)
@@ -27,10 +28,17 @@ type Observation struct {
}
func main() {
if !hooks.IsWorkerAvailable() {
hooks.WriteResponse("SessionStart", true)
return
}
hooks.RunHook("SessionStart", handleSessionStart)
}
func handleSessionStart(ctx *hooks.HookContext, input *Input) (string, error) {
deadline, cancel := hooks.HookDeadline(30 * time.Second)
defer cancel()
// Fetch observations for context injection
endpoint := fmt.Sprintf("/api/context/inject?project=%s&cwd=%s",
url.QueryEscape(ctx.Project),
@@ -59,12 +67,21 @@ func handleSessionStart(ctx *hooks.HookContext, input *Input) (string, error) {
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Injecting %d observations from project memory (%d detailed, %d condensed)\n",
len(obsData), min(fullCount, len(obsData)), max(0, len(obsData)-fullCount))
// Token budget for context injection
maxTokens := 16000 // default; could be made configurable via worker config endpoint
currentTokens := 0
// Build context string
contextBuilder := "<claude-mnemonic-context>\n"
contextBuilder += fmt.Sprintf("# Project Memory (%d observations)\n", len(obsData))
contextBuilder += "Use this knowledge to answer questions without re-exploring the codebase.\n\n"
header := fmt.Sprintf("<claude-mnemonic-context>\n# Project Memory (%d observations)\nUse this knowledge to answer questions without re-exploring the codebase.\n\n", len(obsData))
currentTokens += estimateTokens(header)
contextBuilder := header
for i, o := range obsData {
if deadline.Err() != nil {
contextBuilder += "\n... (returning early due to time limit)\n"
break
}
obs, ok := o.(map[string]interface{})
if !ok {
continue
@@ -73,40 +90,94 @@ func handleSessionStart(ctx *hooks.HookContext, input *Input) (string, error) {
title := getString(obs, "title")
obsType := getString(obs, "type")
var obsText string
// First `fullCount` observations get full detail, rest are condensed
if i < fullCount {
// Full detail: include narrative and facts
narrative := getString(obs, "narrative")
contextBuilder += fmt.Sprintf("## %d. [%s] %s\n", i+1, strings.ToUpper(obsType), title)
obsText = fmt.Sprintf("## %d. [%s] %s\n", i+1, strings.ToUpper(obsType), title)
if narrative != "" {
contextBuilder += narrative + "\n"
obsText += narrative + "\n"
}
if facts, ok := obs["facts"].([]interface{}); ok && len(facts) > 0 {
contextBuilder += "Key facts:\n"
obsText += "Key facts:\n"
for _, f := range facts {
if fact, ok := f.(string); ok && fact != "" {
contextBuilder += fmt.Sprintf("- %s\n", fact)
obsText += fmt.Sprintf("- %s\n", fact)
}
}
}
contextBuilder += "\n"
obsText += "\n"
} else {
// Condensed: just title and subtitle (one line)
subtitle := getString(obs, "subtitle")
if subtitle != "" {
contextBuilder += fmt.Sprintf("- [%s] %s: %s\n", strings.ToUpper(obsType), title, subtitle)
obsText = fmt.Sprintf("- [%s] %s: %s\n", strings.ToUpper(obsType), title, subtitle)
} else {
contextBuilder += fmt.Sprintf("- [%s] %s\n", strings.ToUpper(obsType), title)
obsText = fmt.Sprintf("- [%s] %s\n", strings.ToUpper(obsType), title)
}
}
obsTokens := estimateTokens(obsText)
if currentTokens+obsTokens > maxTokens {
contextBuilder += fmt.Sprintf("\n... (%d more observations omitted due to token budget)\n", len(obsData)-i)
break
}
contextBuilder += obsText
currentTokens += obsTokens
}
contextBuilder += "</claude-mnemonic-context>\n"
return contextBuilder, nil
}
// estimateTokens provides a more accurate token count estimate.
// Uses word count * 1.3 as base, with adjustments for code and non-ASCII.
func estimateTokens(s string) int {
if len(s) == 0 {
return 0
}
// Count words (split on whitespace)
words := len(strings.Fields(s))
if words == 0 {
// No whitespace = probably a single token or code blob
return (len(s) + 3) / 4
}
// Base estimate: ~1.3 tokens per word for English text
estimate := int(float64(words) * 1.3)
// Detect code-heavy content (high non-alpha ratio)
nonAlpha := 0
nonASCII := 0
for _, r := range s {
if r > 127 {
nonASCII++
} else if !('a' <= r && r <= 'z') && !('A' <= r && r <= 'Z') && !('0' <= r && r <= '9') && r != ' ' {
nonAlpha++
}
}
totalChars := len(s)
// Code adjustment: more special chars = more tokens per word
if totalChars > 0 && float64(nonAlpha)/float64(totalChars) > 0.15 {
estimate = int(float64(estimate) * 1.3)
}
// Non-ASCII adjustment: CJK and other scripts use more tokens
if totalChars > 0 && float64(nonASCII)/float64(totalChars) > 0.1 {
estimate += nonASCII // Roughly 1 extra token per non-ASCII char
}
return estimate
}
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key].(string); ok {
return v
+1 -1
View File
@@ -117,7 +117,7 @@ func getWorkerStats(port int, project string) *WorkerStats {
if err != nil {
return nil
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil
+52 -13
View File
@@ -5,12 +5,16 @@ import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
)
var debug = os.Getenv("CLAUDE_MNEMONIC_DEBUG") != ""
// Input is the hook input from Claude Code.
type Input struct {
hooks.BaseInput
@@ -62,7 +66,19 @@ func parseTranscript(path string) (lastUser, lastAssistant string) {
if err != nil {
return "", ""
}
defer file.Close()
defer func() { _ = file.Close() }()
// For large transcripts, seek to the last 256KB for efficiency.
// We only need the last user/assistant messages, not the entire history.
const tailSize = 256 * 1024
info, err := file.Stat()
if err == nil && info.Size() > tailSize {
if _, seekErr := file.Seek(-tailSize, io.SeekEnd); seekErr == nil {
// Discard partial first line after seek
discardScanner := bufio.NewScanner(file)
discardScanner.Scan()
}
}
scanner := bufio.NewScanner(file)
// Increase buffer size for large messages
@@ -97,12 +113,20 @@ func parseTranscript(path string) (lastUser, lastAssistant string) {
}
func main() {
if !hooks.IsWorkerAvailable() {
hooks.WriteResponse("Stop", true)
return
}
hooks.RunHook("Stop", handleStop)
}
func handleStop(ctx *hooks.HookContext, input *Input) (string, error) {
// Debug: dump raw input
fmt.Fprintf(os.Stderr, "[stop] Raw input: %s\n", string(ctx.RawInput))
deadline, cancel := hooks.HookDeadline(30 * time.Second)
defer cancel()
if debug {
fmt.Fprintf(os.Stderr, "[stop] Raw input: %s\n", string(ctx.RawInput))
}
// Find session
result, err := hooks.GET(ctx.Port, fmt.Sprintf("/api/sessions?claudeSessionId=%s", ctx.SessionID))
@@ -122,18 +146,33 @@ func handleStop(ctx *hooks.HookContext, input *Input) (string, error) {
lastUser, lastAssistant = parseTranscript(input.TranscriptPath)
}
// Debug: log what we extracted
fmt.Fprintf(os.Stderr, "[stop] Transcript path: %s\n", input.TranscriptPath)
fmt.Fprintf(os.Stderr, "[stop] Last user message length: %d\n", len(lastUser))
fmt.Fprintf(os.Stderr, "[stop] Last assistant message length: %d\n", len(lastAssistant))
if len(lastAssistant) > 0 {
preview := lastAssistant
if len(preview) > 300 {
preview = preview[:300] + "..."
// Truncate messages to avoid sending excessive data to the worker
if len(lastAssistant) > 10000 {
lastAssistant = lastAssistant[:10000]
}
if len(lastUser) > 5000 {
lastUser = lastUser[:5000]
}
if debug {
fmt.Fprintf(os.Stderr, "[stop] Transcript path: %s\n", input.TranscriptPath)
fmt.Fprintf(os.Stderr, "[stop] Last user message length: %d\n", len(lastUser))
fmt.Fprintf(os.Stderr, "[stop] Last assistant message length: %d\n", len(lastAssistant))
if len(lastAssistant) > 0 {
preview := lastAssistant
if len(preview) > 300 {
preview = preview[:300] + "..."
}
fmt.Fprintf(os.Stderr, "[stop] Last assistant preview: %s\n", preview)
}
fmt.Fprintf(os.Stderr, "[stop] Last assistant preview: %s\n", preview)
fmt.Fprintf(os.Stderr, "[stop] Requesting summary for session %d (transcript: %v)\n", int64(sessionID), input.TranscriptPath != "")
}
// Check deadline before expensive summary request
if deadline.Err() != nil {
fmt.Fprintf(os.Stderr, "[stop] Returning early due to time limit\n")
return "", nil
}
fmt.Fprintf(os.Stderr, "[stop] Requesting summary for session %d (transcript: %v)\n", int64(sessionID), input.TranscriptPath != "")
// Request summary with message context from transcript
_, err = hooks.POST(ctx.Port, fmt.Sprintf("/sessions/%d/summarize", int64(sessionID)), map[string]interface{}{
+4
View File
@@ -16,6 +16,10 @@ type Input struct {
}
func main() {
if !hooks.IsWorkerAvailable() {
hooks.WriteResponse("SubagentStop", true)
return
}
hooks.RunHook("SubagentStop", handleSubagentStop)
}
+122 -43
View File
@@ -5,8 +5,12 @@ import (
"fmt"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
"github.com/lukaszraczylo/claude-mnemonic/pkg/sanitize"
)
// Input is the hook input from Claude Code.
@@ -15,31 +19,117 @@ type Input struct {
Prompt string `json:"prompt"`
}
// estimateTokens provides a more accurate token count estimate.
// Uses word count * 1.3 as base, with adjustments for code and non-ASCII.
func estimateTokens(s string) int {
if len(s) == 0 {
return 0
}
// Count words (split on whitespace)
words := len(strings.Fields(s))
if words == 0 {
// No whitespace = probably a single token or code blob
return (len(s) + 3) / 4
}
// Base estimate: ~1.3 tokens per word for English text
estimate := int(float64(words) * 1.3)
// Detect code-heavy content (high non-alpha ratio)
nonAlpha := 0
nonASCII := 0
for _, r := range s {
if r > 127 {
nonASCII++
} else if !('a' <= r && r <= 'z') && !('A' <= r && r <= 'Z') && !('0' <= r && r <= '9') && r != ' ' {
nonAlpha++
}
}
totalChars := len(s)
// Code adjustment: more special chars = more tokens per word
if totalChars > 0 && float64(nonAlpha)/float64(totalChars) > 0.15 {
estimate = int(float64(estimate) * 1.3)
}
// Non-ASCII adjustment: CJK and other scripts use more tokens
if totalChars > 0 && float64(nonASCII)/float64(totalChars) > 0.1 {
estimate += nonASCII // Roughly 1 extra token per non-ASCII char
}
return estimate
}
func main() {
if !hooks.IsWorkerAvailable() {
hooks.WriteResponse("UserPromptSubmit", true)
return
}
hooks.RunHook("UserPromptSubmit", handleUserPrompt)
}
func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
// Search for relevant observations based on the prompt
deadline, cancel := hooks.HookDeadline(10 * time.Second)
defer cancel()
searchURL := fmt.Sprintf("/api/context/search?project=%s&query=%s&cwd=%s",
url.QueryEscape(ctx.Project),
url.QueryEscape(input.Prompt),
url.QueryEscape(ctx.CWD))
var contextToInject string
var observationCount int
// Run search and session init concurrently.
// Session init doesn't strictly depend on search results -- the observation
// count passed is approximate (0) and acceptable.
var (
wg sync.WaitGroup
searchResult map[string]interface{}
initResult map[string]interface{}
initErr error
contextToInject string
observationCount int
)
searchResult, _ := hooks.GET(ctx.Port, searchURL)
// Start search in background
wg.Add(1)
go func() {
defer wg.Done()
searchResult, _ = hooks.GET(ctx.Port, searchURL)
}()
// Start session init in parallel (with observationCount=0; approximate is fine)
wg.Add(1)
go func() {
defer wg.Done()
initResult, initErr = hooks.POST(ctx.Port, "/api/sessions/init", map[string]interface{}{
"claudeSessionId": ctx.SessionID,
"project": ctx.Project,
"prompt": input.Prompt,
"matchedObservations": 0,
})
}()
// Wait for both to complete
wg.Wait()
// Check deadline after network calls
if deadline.Err() != nil {
return "", nil
}
// Process search results
if observations, ok := searchResult["observations"].([]interface{}); ok && len(observations) > 0 {
// Results are already filtered by relevance threshold and capped by max_results
// from the server-side config (ContextRelevanceThreshold, ContextMaxPromptResults)
observationCount = len(observations)
// Build context from search results
// Token budget for prompt context injection
maxTokens := 8000
currentTokens := 0
header := "<relevant-memory>\n# Relevant Knowledge From Previous Sessions\nIMPORTANT: Use this information to answer the question directly. Do NOT explore the codebase if the answer is here.\n\n"
currentTokens += estimateTokens(header)
var contextBuilder string
contextBuilder = "<relevant-memory>\n"
contextBuilder += "# Relevant Knowledge From Previous Sessions\n"
contextBuilder += "IMPORTANT: Use this information to answer the question directly. Do NOT explore the codebase if the answer is here.\n\n"
contextBuilder = header
for i, obs := range observations {
if obsMap, ok := obs.(map[string]interface{}); ok {
@@ -52,24 +142,30 @@ func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
obsType = t
}
// Start observation block
contextBuilder += fmt.Sprintf("## %d. [%s] %s\n", i+1, obsType, title)
var obsText string
obsText = fmt.Sprintf("## %d. [%s] %s\n", i+1, obsType, title)
// Add facts first (most concise answers)
if facts, ok := obsMap["facts"].([]interface{}); ok && len(facts) > 0 {
contextBuilder += "Key facts:\n"
obsText += "Key facts:\n"
for _, fact := range facts {
if factStr, ok := fact.(string); ok {
contextBuilder += fmt.Sprintf("- %s\n", factStr)
obsText += fmt.Sprintf("- %s\n", sanitize.StripSystemXML(factStr))
}
}
contextBuilder += "\n"
obsText += "\n"
}
// Add narrative if present
if narrative, ok := obsMap["narrative"].(string); ok && narrative != "" {
contextBuilder += narrative + "\n\n"
obsText += sanitize.StripSystemXML(narrative) + "\n\n"
}
obsTokens := estimateTokens(obsText)
if currentTokens+obsTokens > maxTokens {
break
}
contextBuilder += obsText
currentTokens += obsTokens
}
}
@@ -77,40 +173,24 @@ func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
contextToInject = contextBuilder
}
// Initialize session with matched observations count
result, err := hooks.POST(ctx.Port, "/api/sessions/init", map[string]interface{}{
"claudeSessionId": ctx.SessionID,
"project": ctx.Project,
"prompt": input.Prompt,
"matchedObservations": observationCount,
})
if err != nil {
return "", err
// Check session init result
if initErr != nil {
return "", initErr
}
// Check if skipped due to privacy
if skipped, ok := result["skipped"].(bool); ok && skipped {
if skipped, ok := initResult["skipped"].(bool); ok && skipped {
fmt.Fprintf(os.Stderr, "[user-prompt] Session skipped (private)\n")
return "", nil
}
// Safely extract session ID and prompt number with type checking
sessionDbIdRaw, ok := result["sessionDbId"].(float64)
if !ok {
return "", fmt.Errorf("invalid or missing sessionDbId in response")
}
sessionID := int64(sessionDbIdRaw)
promptNumberRaw, ok := result["promptNumber"].(float64)
if !ok {
return "", fmt.Errorf("invalid or missing promptNumber in response")
}
promptNumber := int(promptNumberRaw)
sessionID := int64(initResult["sessionDbId"].(float64))
promptNumber := int(initResult["promptNumber"].(float64))
fmt.Fprintf(os.Stderr, "[user-prompt] Session %d, prompt #%d\n", sessionID, promptNumber)
// Start SDK agent
_, err = hooks.POST(ctx.Port, fmt.Sprintf("/sessions/%d/init", sessionID), map[string]interface{}{
// Start SDK agent (depends on session init result, so kept sequential)
_, err := hooks.POST(ctx.Port, fmt.Sprintf("/sessions/%d/init", sessionID), map[string]interface{}{
"userPrompt": input.Prompt,
"promptNumber": promptNumber,
})
@@ -120,7 +200,6 @@ func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
// Return context if we found relevant observations
if observationCount > 0 {
// Show match count to user via stderr
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Found %d relevant memories for this prompt\n", observationCount)
return contextToInject, nil
}
+15 -85
View File
@@ -4,20 +4,17 @@ package main
import (
"context"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
"github.com/lukaszraczylo/claude-mnemonic/internal/mcp"
"github.com/lukaszraczylo/claude-mnemonic/internal/scoring"
"github.com/lukaszraczylo/claude-mnemonic/internal/search"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/internal/watcher"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/lukaszraczylo/oss-telemetry"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
@@ -28,7 +25,6 @@ var Version = "dev"
func main() {
// Parse flags
project := flag.String("project", "", "Project name (required)")
dataDir := flag.String("data-dir", "", "Data directory (default: ~/.claude-mnemonic)")
debug := flag.Bool("debug", false, "Enable debug logging")
flag.Parse()
@@ -43,23 +39,12 @@ func main() {
log.Fatal().Msg("--project is required")
}
// Ensure data directory and settings exist
if err := config.EnsureAll(); err != nil {
log.Fatal().Err(err).Msg("Failed to ensure data directories")
}
// Get worker port from config
port := config.GetWorkerPort()
workerURL := fmt.Sprintf("http://localhost:%d", port)
// Load config
cfg, err := config.Load()
if err != nil {
log.Warn().Err(err).Msg("Failed to load config, using defaults")
cfg = config.Default()
}
// Override data directory if specified
dbPath := cfg.DBPath
if *dataDir != "" {
dbPath = *dataDir + "/claude-mnemonic.db"
}
// Create HTTP client for worker
client := &http.Client{Timeout: 30 * time.Second}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -73,69 +58,14 @@ func main() {
cancel()
}()
// Initialize database store (migrations run automatically)
storeCfg := gorm.Config{
Path: dbPath,
MaxConns: cfg.MaxConns,
// WALMode is enabled automatically by GORM
}
store, err := gorm.NewStore(storeCfg)
if err != nil {
log.Fatal().Err(err).Msg("Failed to initialize database store")
}
defer store.Close()
// Start file watchers for config changes
startWatchers()
// Initialize stores
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
summaryStore := gorm.NewSummaryStore(store)
promptStore := gorm.NewPromptStore(store, nil)
patternStore := gorm.NewPatternStore(store)
relationStore := gorm.NewRelationStore(store)
sessionStore := gorm.NewSessionStore(store)
telemetry.Send("claude-mnemonic", Version)
// Initialize embedding service and vector client
var vectorClient *sqlitevec.Client
embedSvc, err := embedding.NewService()
if err != nil {
log.Warn().Err(err).Msg("Embedding service unavailable, vector search disabled")
} else {
defer embedSvc.Close()
vectorClient, err = sqlitevec.NewClient(sqlitevec.Config{DB: store.GetRawDB()}, embedSvc)
if err != nil {
log.Warn().Err(err).Msg("Vector client unavailable, vector search disabled")
} else {
log.Info().Msg("Vector search enabled via sqlite-vec")
}
}
// Initialize scoring components
scoreConfig := models.DefaultScoringConfig()
scoreCalculator := scoring.NewCalculator(scoreConfig)
recalculator := scoring.NewRecalculator(observationStore, scoreCalculator, log.Logger)
go recalculator.Start(ctx)
defer recalculator.Stop()
// Initialize search manager
searchMgr := search.NewManager(observationStore, summaryStore, promptStore, vectorClient)
// Start file watchers
startWatchers(ctx, dbPath)
// Create and run MCP server with all dependencies
// Note: maintenanceService is nil because it runs in the worker process
server := mcp.NewServer(
searchMgr,
Version,
observationStore,
patternStore,
relationStore,
sessionStore,
vectorClient,
scoreCalculator,
recalculator,
nil, // maintenanceService - handled by worker
)
log.Info().Str("project", *project).Str("version", Version).Msg("Starting MCP server")
// Create and run MCP server
server := mcp.NewServer(client, workerURL, *project, Version)
log.Info().Str("project", *project).Str("version", Version).Str("worker", workerURL).Msg("Starting MCP server")
if err := server.Run(ctx); err != nil {
log.Fatal().Err(err).Msg("MCP server error")
@@ -143,7 +73,7 @@ func main() {
}
// startWatchers initializes file watchers for config.
func startWatchers(ctx context.Context, dbPath string) {
func startWatchers() {
// Watch config file for changes (triggers process exit for restart)
configPath := config.SettingsPath()
configWatcher, err := watcher.New(configPath, func() {
+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.
+11 -10
View File
@@ -6,17 +6,17 @@ replace github.com/sugarme/tokenizer => github.com/clems4ever/tokenizer v0.0.0-2
require (
github.com/asg017/sqlite-vec-go-bindings v0.1.6
github.com/fsnotify/fsnotify v1.9.0
github.com/go-chi/chi/v5 v5.2.5
github.com/fsnotify/fsnotify v1.10.1
github.com/go-chi/chi/v5 v5.3.0
github.com/go-gormigrate/gormigrate/v2 v2.1.5
github.com/goccy/go-json v0.10.5
github.com/mattn/go-sqlite3 v1.14.33
github.com/rs/zerolog v1.34.0
github.com/goccy/go-json v0.10.6
github.com/mattn/go-sqlite3 v1.14.44
github.com/rs/zerolog v1.35.1
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
github.com/stretchr/testify v1.11.1
github.com/sugarme/tokenizer v0.3.0
github.com/yalue/onnxruntime_go v1.25.0
golang.org/x/sync v0.19.0
github.com/yalue/onnxruntime_go v1.30.1
golang.org/x/sync v0.20.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
@@ -26,15 +26,16 @@ require (
github.com/emirpasic/gods v1.18.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/lukaszraczylo/oss-telemetry v0.2.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/schollz/progressbar/v2 v2.15.0 // indirect
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+22 -30
View File
@@ -2,46 +2,41 @@ github.com/asg017/sqlite-vec-go-bindings v0.1.6 h1:Nx0jAzyS38XpkKznJ9xQjFXz2X9tI
github.com/asg017/sqlite-vec-go-bindings v0.1.6/go.mod h1:A8+cTt/nKFsYCQF6OgzSNpKZrzNo5gQsXBTfsXHXY0Q=
github.com/clems4ever/tokenizer v0.0.0-20250926133620-9ddc80533c43 h1:j8YQypEqa5OjqbGciCNb9hOcYbo1oTVuEjd/iu9U2SY=
github.com/clems4ever/tokenizer v0.0.0-20250926133620-9ddc80533c43/go.mod h1:VJ+DLK5ZEZwzvODOWwY0cw+B1dabTd3nCB5HuFCItCc=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-gormigrate/gormigrate/v2 v2.1.5 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiIhS4/QTTa/SM8=
github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/lukaszraczylo/oss-telemetry v0.2.1 h1:6ULyfzXplpdmIY/i01OPM1jeod9+L1RAhI0jtbVnJI0=
github.com/lukaszraczylo/oss-telemetry v0.2.1/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/schollz/progressbar/v2 v2.15.0 h1:dVzHQ8fHRmtPjD3K10jT3Qgn/+H+92jhPrhmxIJfDz8=
github.com/schollz/progressbar/v2 v2.15.0/go.mod h1:UdPq3prGkfQ7MOzZKlDRpYKcFqEMczbD7YmbPgpzKMI=
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4=
@@ -52,17 +47,14 @@ 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.25.0 h1:nlhVau1BpLZ/BYr+WpPZCJRD/WES0qo6dK7aKyyAs3g=
github.com/yalue/onnxruntime_go v1.25.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
github.com/yalue/onnxruntime_go v1.30.1 h1:NaEng5lWbsHZ/8X1dtaw1mIj7eV1ozyjbFo//g0ktl4=
github.com/yalue/onnxruntime_go v1.30.1/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
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=
+61
View File
@@ -0,0 +1,61 @@
{
"description": "Claude Mnemonic - Persistent memory hooks for observations, prompts, and session summaries",
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/session-start",
"timeout": 30
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt",
"timeout": 10
}
]
}
],
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/post-tool-use",
"timeout": 10
}
]
}
],
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-stop",
"timeout": 10
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/stop",
"timeout": 30
}
]
}
]
}
}
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
BIN="$HOME/.claude-mnemonic/bin/hooks/post-tool-use"
[ -x "$BIN" ] && exec "$BIN" "$@"
exit 0
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
BIN="$HOME/.claude-mnemonic/bin/hooks/session-start"
[ -x "$BIN" ] && exec "$BIN" "$@"
exit 0
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
BIN="$HOME/.claude-mnemonic/bin/hooks/statusline"
[ -x "$BIN" ] && exec "$BIN" "$@"
exit 0
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
BIN="$HOME/.claude-mnemonic/bin/hooks/stop"
[ -x "$BIN" ] && exec "$BIN" "$@"
exit 0
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
BIN="$HOME/.claude-mnemonic/bin/hooks/subagent-stop"
[ -x "$BIN" ] && exec "$BIN" "$@"
exit 0
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
BIN="$HOME/.claude-mnemonic/bin/hooks/user-prompt"
[ -x "$BIN" ] && exec "$BIN" "$@"
exit 0
+32
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
)
@@ -52,6 +53,7 @@ type Config struct {
ContextRelevanceThreshold float64 `json:"context_relevance_threshold"`
RerankingCandidates int `json:"reranking_candidates"`
WorkerPort int `json:"worker_port"`
DeduplicationThreshold float64 `json:"deduplication_threshold"`
RerankingMinImprovement float64 `json:"reranking_min_improvement"`
ContextObservations int `json:"context_observations"`
ContextMaxPromptResults int `json:"context_max_prompt_results"`
@@ -64,10 +66,13 @@ type Config struct {
HubThreshold int `json:"hub_threshold"`
ObservationRetentionDays int `json:"observation_retention_days"`
MaintenanceIntervalHours int `json:"maintenance_interval_hours"`
ContextMaxTokensStartup int `json:"context_max_tokens_startup"`
ContextMaxTokensPrompt int `json:"context_max_tokens_prompt"`
ContextShowWorkTokens bool `json:"context_show_work_tokens"`
ContextShowReadTokens bool `json:"context_show_read_tokens"`
RerankingPureMode bool `json:"reranking_pure_mode"`
GraphEnabled bool `json:"graph_enabled"`
DeduplicationEnabled bool `json:"deduplication_enabled"`
MaintenanceEnabled bool `json:"maintenance_enabled"`
RerankingEnabled bool `json:"reranking_enabled"`
ContextShowLastSummary bool `json:"context_show_last_summary"`
@@ -168,6 +173,10 @@ func Default() *Config {
ContextObsConcepts: DefaultObservationConcepts,
ContextRelevanceThreshold: 0.3, // Minimum 30% similarity to include
ContextMaxPromptResults: 10, // Cap at 10 results max (0 = no cap, threshold only)
ContextMaxTokensStartup: 16000, // Max tokens for SessionStart context injection
ContextMaxTokensPrompt: 8000, // Max tokens for UserPromptSubmit context injection
DeduplicationEnabled: true, // Enable write-time vector dedup
DeduplicationThreshold: 0.9, // Similarity threshold for merging (0.9 = very similar)
MaintenanceEnabled: true, // Enable scheduled maintenance
MaintenanceIntervalHours: 6, // Run every 6 hours
ObservationRetentionDays: 0, // 0 = no age-based deletion (keep all)
@@ -269,6 +278,29 @@ func Load() (*Config, error) {
if v, ok := settings["CLAUDE_MNEMONIC_HUB_THRESHOLD"].(float64); ok && v > 0 {
cfg.HubThreshold = int(v)
}
if v, ok := settings["CLAUDE_MNEMONIC_CONTEXT_MAX_TOKENS_STARTUP"].(float64); ok && v > 0 {
cfg.ContextMaxTokensStartup = int(v)
}
if v, ok := settings["CLAUDE_MNEMONIC_CONTEXT_MAX_TOKENS_PROMPT"].(float64); ok && v > 0 {
cfg.ContextMaxTokensPrompt = int(v)
}
// Deduplication settings
if v, ok := settings["CLAUDE_MNEMONIC_DEDUP_ENABLED"].(bool); ok {
cfg.DeduplicationEnabled = v
}
if v, ok := settings["CLAUDE_MNEMONIC_DEDUP_THRESHOLD"].(float64); ok && v > 0 && v <= 1 {
cfg.DeduplicationThreshold = v
}
// Also support env vars for dedup settings
if v := os.Getenv("CLAUDE_MNEMONIC_DEDUP_ENABLED"); v != "" {
cfg.DeduplicationEnabled = v == "true" || v == "1"
}
if v := os.Getenv("CLAUDE_MNEMONIC_DEDUP_THRESHOLD"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 && f <= 1 {
cfg.DeduplicationThreshold = f
}
}
return cfg, nil
}
+15 -2
View File
@@ -4,6 +4,8 @@ package gorm
import (
"context"
"database/sql"
"fmt"
"sync"
"time"
"gorm.io/gorm"
@@ -18,6 +20,7 @@ type PatternCleanupFunc func(ctx context.Context, deletedIDs []int64)
type PatternStore struct {
db *gorm.DB
cleanupFunc PatternCleanupFunc
cleanupMu sync.RWMutex
}
// NewPatternStore creates a new pattern store.
@@ -29,6 +32,8 @@ func NewPatternStore(store *Store) *PatternStore {
// SetCleanupFunc sets the callback for when patterns are deleted.
func (s *PatternStore) SetCleanupFunc(fn PatternCleanupFunc) {
s.cleanupMu.Lock()
defer s.cleanupMu.Unlock()
s.cleanupFunc = fn
}
@@ -238,6 +243,9 @@ func (s *PatternStore) MarkPatternDeprecated(ctx context.Context, id int64) erro
// MergePatterns merges a source pattern into a target pattern.
func (s *PatternStore) MergePatterns(ctx context.Context, sourceID, targetID int64) error {
if sourceID == targetID {
return fmt.Errorf("cannot merge pattern into itself")
}
// Get both patterns
source, err := s.GetPatternByID(ctx, sourceID)
if err != nil {
@@ -294,8 +302,13 @@ func (s *PatternStore) MergePatterns(ctx context.Context, sourceID, targetID int
func (s *PatternStore) DeletePattern(ctx context.Context, id int64) error {
result := s.db.WithContext(ctx).Delete(&Pattern{}, id)
if result.Error == nil && s.cleanupFunc != nil {
s.cleanupFunc(ctx, []int64{id})
if result.Error == nil {
s.cleanupMu.RLock()
fn := s.cleanupFunc
s.cleanupMu.RUnlock()
if fn != nil {
fn(ctx, []int64{id})
}
}
return result.Error
+36 -3
View File
@@ -4,8 +4,10 @@ package gorm
import (
"context"
"database/sql"
"sync"
"time"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -23,6 +25,7 @@ const MaxPromptsGlobal = 500
type PromptStore struct {
db *gorm.DB
cleanupFunc PromptCleanupFunc
cleanupMu sync.RWMutex
}
// NewPromptStore creates a new prompt store.
@@ -35,6 +38,8 @@ func NewPromptStore(store *Store, cleanupFunc PromptCleanupFunc) *PromptStore {
// SetCleanupFunc sets the callback for when prompts are deleted during cleanup.
func (s *PromptStore) SetCleanupFunc(fn PromptCleanupFunc) {
s.cleanupMu.Lock()
defer s.cleanupMu.Unlock()
s.cleanupFunc = fn
}
@@ -81,9 +86,15 @@ func (s *PromptStore) SaveUserPromptWithMatches(ctx context.Context, claudeSessi
go func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
deletedIDs, _ := s.CleanupOldPrompts(cleanupCtx)
if len(deletedIDs) > 0 && s.cleanupFunc != nil {
s.cleanupFunc(cleanupCtx, deletedIDs)
if deletedIDs, err := s.CleanupOldPrompts(cleanupCtx); err != nil {
log.Warn().Err(err).Msg("Background prompt cleanup failed")
} else if len(deletedIDs) > 0 {
s.cleanupMu.RLock()
fn := s.cleanupFunc
s.cleanupMu.RUnlock()
if fn != nil {
fn(cleanupCtx, deletedIDs)
}
}
}()
@@ -253,6 +264,28 @@ func (s *PromptStore) FindRecentPromptByText(ctx context.Context, claudeSessionI
return prompt.ID, prompt.PromptNumber, true
}
// FindRecentPromptByTextGlobal finds a recent prompt by exact text match within a time window
// across ALL sessions (not scoped to a single claude session ID).
// This catches duplicates when the same prompt arrives from different session IDs,
// e.g. when the SDK processor's callClaudeCLI subprocess fires with a different session ID.
// Returns (promptID, promptNumber, found).
func (s *PromptStore) FindRecentPromptByTextGlobal(ctx context.Context, promptText string, withinSeconds int) (int64, int, bool) {
cutoffEpoch := time.Now().Add(-time.Duration(withinSeconds) * time.Second).UnixMilli()
var prompt UserPrompt
err := s.db.WithContext(ctx).
Where("prompt_text = ? AND created_at_epoch >= ?",
promptText, cutoffEpoch).
Order("created_at_epoch DESC").
First(&prompt).Error
if err != nil {
return 0, 0, false
}
return prompt.ID, prompt.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) {
var results []struct {
+33
View File
@@ -333,6 +333,39 @@ func TestPromptStore_FindRecentPromptByText(t *testing.T) {
assert.False(t, notFound, "Should not find prompt outside time window")
}
func TestPromptStore_FindRecentPromptByTextGlobal(t *testing.T) {
promptStore, _, cleanup := testPromptStore(t)
defer cleanup()
ctx := context.Background()
// Save a prompt under session "claude-1"
id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "What is the architecture?", 3)
require.NoError(t, err)
// Global search should find it without specifying session ID
foundID, foundNumber, found := promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 60)
assert.True(t, found, "Should find the prompt globally")
assert.Equal(t, id, foundID)
assert.Equal(t, 1, foundNumber)
// Global search should find it even when a different session ID would have been used
// (This is the core cross-session dedup scenario)
foundID2, foundNumber2, found2 := promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 60)
assert.True(t, found2, "Should find the prompt from any session")
assert.Equal(t, id, foundID2)
assert.Equal(t, 1, foundNumber2)
// Different text should not match
_, _, notFound := promptStore.FindRecentPromptByTextGlobal(ctx, "Different text", 60)
assert.False(t, notFound, "Should not find a different prompt")
// Should not find prompt outside time window
time.Sleep(100 * time.Millisecond)
_, _, notFound = promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 0)
assert.False(t, notFound, "Should not find prompt outside time window")
}
func TestPromptStore_GetRecentUserPromptsByProject(t *testing.T) {
promptStore, store, cleanup := testPromptStore(t)
defer cleanup()
-7
View File
@@ -513,13 +513,6 @@ func (s *Store) ExecWithTimeout(ctx context.Context, timeout time.Duration, quer
return nil
}
// QueryRowWithTimeout executes a row query with timeout.
func (s *Store) QueryRowWithTimeout(ctx context.Context, timeout time.Duration, query string, args ...any) *sql.Row {
timeoutCtx, cancel := s.WithTimeout(ctx, timeout, "query_row")
// Note: cancel will be called when row.Scan() completes or errors
_ = cancel // Caller must ensure proper cleanup
return s.sqlDB.QueryRowContext(timeoutCtx, query, args...)
}
// TransactionWithTimeout wraps a transaction function with timeout handling.
// The transaction is automatically rolled back if the context times out.
+1
View File
@@ -57,6 +57,7 @@ type PromptReader interface {
GetAllPrompts(ctx context.Context) ([]*models.UserPromptWithSession, error)
GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error)
FindRecentPromptByText(ctx context.Context, claudeSessionID, promptText string, withinSeconds int) (int64, int, bool)
FindRecentPromptByTextGlobal(ctx context.Context, promptText string, withinSeconds int) (int64, int, bool)
}
// PromptWriter defines write operations for prompts.
@@ -1 +1 @@
1.23.2
1.26.0
@@ -1 +1 @@
1.23.2
1.26.0
@@ -1 +1 @@
1.23.2
1.26.0
@@ -1 +1 @@
1.23.2
1.26.0
+5 -2
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/sugarme/tokenizer"
@@ -69,8 +70,10 @@ func newBGEModel() (EmbeddingModel, error) {
libPath := filepath.Join(libDir, onnxRuntimeLibName)
ort.SetSharedLibraryPath(libPath)
// Initialize ONNX runtime
if err := ort.InitializeEnvironment(); err != nil {
// Initialize ONNX runtime (idempotent - ignore "already initialized" since
// the ONNX environment is process-global and shared with the reranking service)
err = ort.InitializeEnvironment()
if err != nil && !strings.Contains(err.Error(), "already been initialized") {
return nil, fmt.Errorf("initialize ONNX runtime: %w", err)
}
+674
View File
@@ -0,0 +1,674 @@
//go:build fts5
package graph
import (
"context"
"database/sql"
"testing"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ---- helpers ----------------------------------------------------------------
func makeObs(id int64, sessionID string, concepts, filesRead, filesModified []string) *models.Observation {
return &models.Observation{
ID: id,
SDKSessionID: sessionID,
Title: sql.NullString{String: "title", Valid: true},
Project: "test-project",
Type: models.ObsTypeDecision,
Concepts: concepts,
FilesRead: filesRead,
FilesModified: filesModified,
CreatedAtEpoch: time.Now().UnixMilli(),
}
}
// ---- ObservationGraph -------------------------------------------------------
func TestNewObservationGraph_Empty(t *testing.T) {
g := NewObservationGraph()
require.NotNil(t, g)
stats := g.Stats()
assert.Equal(t, 0, stats.NodeCount)
assert.Equal(t, 0, stats.EdgeCount)
}
func TestAddNode_StoresAndRetrieves(t *testing.T) {
g := NewObservationGraph()
node := &Node{
ID: 42,
Degree: 0,
Metadata: NodeMetadata{
Project: "proj",
Type: "decision",
Title: "test node",
},
}
g.AddNode(node)
got, err := g.GetNode(42)
require.NoError(t, err)
assert.Equal(t, int64(42), got.ID)
assert.Equal(t, "test node", got.Metadata.Title)
}
func TestAddNode_OverwritesExisting(t *testing.T) {
g := NewObservationGraph()
g.AddNode(&Node{ID: 1, Metadata: NodeMetadata{Title: "old"}})
g.AddNode(&Node{ID: 1, Metadata: NodeMetadata{Title: "new"}})
got, err := g.GetNode(1)
require.NoError(t, err)
assert.Equal(t, "new", got.Metadata.Title)
}
func TestGetNode_NotFound(t *testing.T) {
g := NewObservationGraph()
_, err := g.GetNode(999)
assert.Error(t, err)
}
func TestAddEdge_UpdatesDegree(t *testing.T) {
g := NewObservationGraph()
g.AddNode(&Node{ID: 1})
g.AddNode(&Node{ID: 2})
g.AddEdge(Edge{FromID: 1, ToID: 2, Relation: RelationTemporal, Weight: 0.8})
n1, _ := g.GetNode(1)
n2, _ := g.GetNode(2)
assert.Equal(t, 1, n1.Degree)
assert.Equal(t, 1, n2.Degree)
}
func TestAddEdge_MissingNodesDontPanic(t *testing.T) {
g := NewObservationGraph()
// Adding edge referencing non-existent nodes must not panic
assert.NotPanics(t, func() {
g.AddEdge(Edge{FromID: 100, ToID: 200, Relation: RelationConcept, Weight: 0.5})
})
}
// ---- BuildCSR / GetNeighbors ------------------------------------------------
func TestBuildCSR_NoNodes_ReturnsError(t *testing.T) {
g := NewObservationGraph()
err := g.BuildCSR()
assert.Error(t, err)
}
func TestGetNeighbors_AfterBuildCSR(t *testing.T) {
g := NewObservationGraph()
for _, id := range []int64{1, 2, 3} {
g.AddNode(&Node{ID: id})
}
g.AddEdge(Edge{FromID: 1, ToID: 2, Relation: RelationTemporal, Weight: 0.8})
g.AddEdge(Edge{FromID: 1, ToID: 3, Relation: RelationConcept, Weight: 0.6})
require.NoError(t, g.BuildCSR())
neighbors, weights, err := g.GetNeighbors(1)
require.NoError(t, err)
assert.Len(t, neighbors, 2)
assert.Len(t, weights, 2)
}
func TestGetNeighbors_NodeWithNoOutgoingEdges(t *testing.T) {
g := NewObservationGraph()
g.AddNode(&Node{ID: 1})
g.AddNode(&Node{ID: 2})
// Edge only from 2 → 1; node 2 is a leaf from 1's perspective
g.AddEdge(Edge{FromID: 2, ToID: 1, Relation: RelationTemporal, Weight: 0.8})
require.NoError(t, g.BuildCSR())
neighbors, weights, err := g.GetNeighbors(1)
require.NoError(t, err)
assert.Empty(t, neighbors)
assert.Empty(t, weights)
}
func TestGetNeighbors_NodeNotInGraph(t *testing.T) {
g := NewObservationGraph()
g.AddNode(&Node{ID: 1})
require.NoError(t, g.BuildCSR())
_, _, err := g.GetNeighbors(999)
assert.Error(t, err)
}
// ---- FindHubs ---------------------------------------------------------------
func TestFindHubs_EmptyGraph(t *testing.T) {
g := NewObservationGraph()
hubs := g.FindHubs(0.1)
assert.Nil(t, hubs)
}
func TestFindHubs_IdentifiesHighDegreeNodes(t *testing.T) {
g := NewObservationGraph()
// Node 1 connected to everyone else → hub
for id := int64(1); id <= 5; id++ {
g.AddNode(&Node{ID: id})
}
for id := int64(2); id <= 5; id++ {
g.AddEdge(Edge{FromID: 1, ToID: id, Relation: RelationConcept, Weight: 0.5})
}
hubs := g.FindHubs(0.2) // top 20%
assert.Contains(t, hubs, int64(1))
}
func TestFindHubs_Percentile100_ReturnsEmpty(t *testing.T) {
// percentile=1.0 → cutoff = ceil(N * (1 - 1.0)) = ceil(0) = 0 → no hubs
g := NewObservationGraph()
for id := int64(1); id <= 4; id++ {
g.AddNode(&Node{ID: id})
}
hubs := g.FindHubs(1.0)
assert.Empty(t, hubs)
}
func TestFindHubs_Percentile0_ReturnsAllNodes(t *testing.T) {
// percentile=0.0 → cutoff = ceil(N * 1.0) = N → all nodes returned
g := NewObservationGraph()
for id := int64(1); id <= 4; id++ {
g.AddNode(&Node{ID: id})
}
hubs := g.FindHubs(0.0)
assert.Len(t, hubs, 4)
}
// ---- Stats ------------------------------------------------------------------
func TestStats_EdgeTypesCounted(t *testing.T) {
g := NewObservationGraph()
for _, id := range []int64{1, 2, 3} {
g.AddNode(&Node{ID: id})
}
g.AddEdge(Edge{FromID: 1, ToID: 2, Relation: RelationTemporal, Weight: 0.8})
g.AddEdge(Edge{FromID: 1, ToID: 3, Relation: RelationConcept, Weight: 0.6})
g.AddEdge(Edge{FromID: 2, ToID: 3, Relation: RelationConcept, Weight: 0.6})
stats := g.Stats()
assert.Equal(t, 3, stats.NodeCount)
assert.Equal(t, 3, stats.EdgeCount)
assert.Equal(t, 1, stats.EdgeTypes[RelationTemporal])
assert.Equal(t, 2, stats.EdgeTypes[RelationConcept])
}
func TestStats_DegreeMetrics(t *testing.T) {
g := NewObservationGraph()
// Node 1: degree 2, nodes 2,3: degree 1 each
for _, id := range []int64{1, 2, 3} {
g.AddNode(&Node{ID: id})
}
g.AddEdge(Edge{FromID: 1, ToID: 2, Relation: RelationTemporal, Weight: 0.8})
g.AddEdge(Edge{FromID: 1, ToID: 3, Relation: RelationTemporal, Weight: 0.8})
stats := g.Stats()
assert.Equal(t, 2, stats.MaxDegree)
assert.Equal(t, 1, stats.MinDegree)
assert.InDelta(t, 4.0/3.0, stats.AvgDegree, 0.001)
}
// ---- BuildFromObservations --------------------------------------------------
func TestBuildFromObservations_SingleObservation_ReturnsError(t *testing.T) {
obs := []*models.Observation{makeObs(1, "s1", nil, nil, nil)}
// Single observation: DetectEdges returns nil, BuildCSR errors (no nodes never happens
// since node was added — but CSR build will succeed with 1 node and 0 edges).
g, err := BuildFromObservations(context.Background(), obs)
// With 1 node, BuildCSR succeeds (nodes exist); no edges → valid graph.
require.NoError(t, err)
require.NotNil(t, g)
stats := g.Stats()
assert.Equal(t, 1, stats.NodeCount)
assert.Equal(t, 0, stats.EdgeCount)
}
func TestBuildFromObservations_SetsNodeMetadata(t *testing.T) {
obs := []*models.Observation{
{
ID: 7,
SDKSessionID: "sess",
Project: "myproject",
Type: models.ObsTypeFeature,
Title: sql.NullString{String: "feature title", Valid: true},
IsSuperseded: true,
CreatedAtEpoch: time.Now().UnixMilli(),
},
}
g, err := BuildFromObservations(context.Background(), obs)
require.NoError(t, err)
node, err := g.GetNode(7)
require.NoError(t, err)
assert.Equal(t, "myproject", node.Metadata.Project)
assert.Equal(t, "feature title", node.Metadata.Title)
assert.Equal(t, string(models.ObsTypeFeature), node.Metadata.Type)
assert.True(t, node.Metadata.IsSuperseded)
}
func TestBuildFromObservations_TitleMissing_EmptyString(t *testing.T) {
obs := []*models.Observation{
{
ID: 3,
SDKSessionID: "s",
Title: sql.NullString{Valid: false},
CreatedAtEpoch: time.Now().UnixMilli(),
},
}
g, err := BuildFromObservations(context.Background(), obs)
require.NoError(t, err)
node, err := g.GetNode(3)
require.NoError(t, err)
assert.Equal(t, "", node.Metadata.Title)
}
func TestBuildFromObservations_WithTemporalEdges(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "sess-a", nil, nil, nil),
makeObs(2, "sess-a", nil, nil, nil),
makeObs(3, "sess-b", nil, nil, nil),
}
g, err := BuildFromObservations(context.Background(), obs)
require.NoError(t, err)
stats := g.Stats()
assert.Equal(t, 3, stats.NodeCount)
// obs 1 and 2 share session → 1 temporal edge
assert.Equal(t, 1, stats.EdgeTypes[RelationTemporal])
}
func TestBuildFromObservations_WithConceptEdges(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "s1", []string{"security", "auth"}, nil, nil),
makeObs(2, "s2", []string{"security"}, nil, nil),
makeObs(3, "s3", []string{"unrelated"}, nil, nil),
}
g, err := BuildFromObservations(context.Background(), obs)
require.NoError(t, err)
stats := g.Stats()
// obs 1 and 2 share "security"
assert.GreaterOrEqual(t, stats.EdgeTypes[RelationConcept], 1)
}
func TestBuildFromObservations_WithFileOverlapEdges(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "s1", nil, []string{"pkg/foo.go", "pkg/bar.go"}, nil),
makeObs(2, "s2", nil, []string{"pkg/foo.go", "pkg/baz.go"}, nil),
}
g, err := BuildFromObservations(context.Background(), obs)
require.NoError(t, err)
stats := g.Stats()
// Jaccard({foo,bar},{foo,baz}) = 1/3 ≈ 0.333 > MinFileOverlapForEdge(0.3)
assert.GreaterOrEqual(t, stats.EdgeTypes[RelationFileOverlap], 1)
}
// ---- RelationType.String() --------------------------------------------------
func TestRelationType_String(t *testing.T) {
cases := []struct {
rt RelationType
want string
}{
{RelationFileOverlap, "file_overlap"},
{RelationSemantic, "semantic"},
{RelationTemporal, "temporal"},
{RelationConcept, "concept"},
{RelationType(99), "unknown"},
}
for _, tc := range cases {
t.Run(tc.want, func(t *testing.T) {
assert.Equal(t, tc.want, tc.rt.String())
})
}
}
// ---- DetectEdges (edge_detector.go) ----------------------------------------
func TestDetectEdges_LessThanTwo_ReturnsNil(t *testing.T) {
edges, err := DetectEdges(context.Background(), nil)
assert.NoError(t, err)
assert.Nil(t, edges)
edges, err = DetectEdges(context.Background(), []*models.Observation{makeObs(1, "s", nil, nil, nil)})
assert.NoError(t, err)
assert.Nil(t, edges)
}
func TestDetectEdges_SameSession_CreatesTemporalEdges(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "session-x", nil, nil, nil),
makeObs(2, "session-x", nil, nil, nil),
makeObs(3, "session-x", nil, nil, nil),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
var temporal []Edge
for _, e := range edges {
if e.Relation == RelationTemporal {
temporal = append(temporal, e)
}
}
// Consecutive pairs: (1,2) and (2,3)
assert.Len(t, temporal, 2)
assert.InDelta(t, 0.8, temporal[0].Weight, 0.001)
}
func TestDetectEdges_DifferentSessions_NoTemporalEdges(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "sess-a", nil, nil, nil),
makeObs(2, "sess-b", nil, nil, nil),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
for _, e := range edges {
assert.NotEqual(t, RelationTemporal, e.Relation)
}
}
func TestDetectEdges_EmptySessionID_NoTemporalEdge(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "", nil, nil, nil),
makeObs(2, "", nil, nil, nil),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
for _, e := range edges {
assert.NotEqual(t, RelationTemporal, e.Relation)
}
}
func TestDetectEdges_SharedConcepts_CreatesConceptEdges(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "s1", []string{"performance", "caching"}, nil, nil),
makeObs(2, "s2", []string{"performance"}, nil, nil),
makeObs(3, "s3", []string{"caching"}, nil, nil),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
conceptEdges := filterByRelation(edges, RelationConcept)
// obs1↔obs2 (performance), obs1↔obs3 (caching) → 2 concept edges
assert.Len(t, conceptEdges, 2)
}
func TestDetectEdges_NoConcepts_NoConceptEdges(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "s1", nil, nil, nil),
makeObs(2, "s2", nil, nil, nil),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
assert.Empty(t, filterByRelation(edges, RelationConcept))
}
func TestDetectEdges_FileOverlap_AboveThreshold_CreatesEdge(t *testing.T) {
// Jaccard 2/3 ≈ 0.667 > 0.3 threshold
obs := []*models.Observation{
makeObs(1, "s1", nil, []string{"a.go", "b.go", "c.go"}, nil),
makeObs(2, "s2", nil, []string{"a.go", "b.go", "d.go"}, nil),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
fileEdges := filterByRelation(edges, RelationFileOverlap)
require.Len(t, fileEdges, 1)
assert.InDelta(t, 2.0/4.0, float64(fileEdges[0].Weight), 0.01)
}
func TestDetectEdges_FileOverlap_BelowThreshold_NoEdge(t *testing.T) {
// Jaccard 1/9 ≈ 0.11 < 0.3 threshold
obs := []*models.Observation{
makeObs(1, "s1", nil, []string{"a.go", "b.go", "c.go", "d.go", "e.go"}, nil),
makeObs(2, "s2", nil, []string{"a.go", "f.go", "g.go", "h.go", "i.go"}, nil),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
assert.Empty(t, filterByRelation(edges, RelationFileOverlap))
}
func TestDetectEdges_FilesModified_CountsForOverlap(t *testing.T) {
obs := []*models.Observation{
makeObs(1, "s1", nil, nil, []string{"pkg/core.go", "pkg/util.go"}),
makeObs(2, "s2", nil, []string{"pkg/core.go"}, []string{"pkg/util.go"}),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
fileEdges := filterByRelation(edges, RelationFileOverlap)
assert.NotEmpty(t, fileEdges)
}
func TestDetectEdges_NoEdgeDuplicates(t *testing.T) {
// Same pair via two concepts → only one concept edge
obs := []*models.Observation{
makeObs(1, "s1", []string{"security", "auth"}, nil, nil),
makeObs(2, "s2", []string{"security", "auth"}, nil, nil),
}
edges, err := DetectEdges(context.Background(), obs)
require.NoError(t, err)
conceptEdges := filterByRelation(edges, RelationConcept)
// Both share security and auth, but deduplication should keep only 1 edge per pair per call
// The seen map deduplicates: only first concept that creates the pair wins
assert.Len(t, conceptEdges, 1)
}
// ---- calculateFileOverlap ---------------------------------------------------
func TestCalculateFileOverlap_DisjointSets_Zero(t *testing.T) {
result := calculateFileOverlap([]string{"a.go", "b.go"}, []string{"c.go", "d.go"})
assert.Equal(t, float32(0.0), result)
}
func TestCalculateFileOverlap_IdenticalSets_One(t *testing.T) {
files := []string{"a.go", "b.go", "c.go"}
result := calculateFileOverlap(files, files)
assert.InDelta(t, 1.0, float64(result), 0.001)
}
func TestCalculateFileOverlap_EmptySlices_Zero(t *testing.T) {
assert.Equal(t, float32(0.0), calculateFileOverlap(nil, []string{"a.go"}))
assert.Equal(t, float32(0.0), calculateFileOverlap([]string{"a.go"}, nil))
assert.Equal(t, float32(0.0), calculateFileOverlap(nil, nil))
}
func TestCalculateFileOverlap_Jaccard_Correct(t *testing.T) {
// {a,b,c} ∩ {b,c,d} = {b,c} → 2/4 = 0.5
result := calculateFileOverlap([]string{"a", "b", "c"}, []string{"b", "c", "d"})
assert.InDelta(t, 0.5, float64(result), 0.001)
}
func TestCalculateFileOverlap_Duplicates_TreatedAsSet(t *testing.T) {
// Duplicates collapse: {a,a,b} → {a,b}; {a,b,b} → {a,b}; Jaccard = 1.0
result := calculateFileOverlap([]string{"a", "a", "b"}, []string{"a", "b", "b"})
assert.InDelta(t, 1.0, float64(result), 0.001)
}
// ---- DetectSemanticEdges ----------------------------------------------------
func TestDetectSemanticEdges_AboveThreshold_CreatesEdge(t *testing.T) {
// Identical vectors → similarity = 1.0 > 0.85
emb := []float32{1.0, 0.0, 0.0}
obs := []*models.Observation{makeObs(1, "s", nil, nil, nil), makeObs(2, "s", nil, nil, nil)}
embeddings := map[int64][]float32{1: emb, 2: emb}
edges := DetectSemanticEdges(context.Background(), obs, embeddings)
require.Len(t, edges, 1)
assert.Equal(t, RelationSemantic, edges[0].Relation)
assert.InDelta(t, 1.0, float64(edges[0].Weight), 0.001)
}
func TestDetectSemanticEdges_BelowThreshold_NoEdge(t *testing.T) {
// Orthogonal vectors → similarity = 0.0
obs := []*models.Observation{makeObs(1, "s", nil, nil, nil), makeObs(2, "s", nil, nil, nil)}
embeddings := map[int64][]float32{
1: {1.0, 0.0, 0.0},
2: {0.0, 1.0, 0.0},
}
edges := DetectSemanticEdges(context.Background(), obs, embeddings)
assert.Empty(t, edges)
}
func TestDetectSemanticEdges_MissingEmbedding_Skipped(t *testing.T) {
obs := []*models.Observation{makeObs(1, "s", nil, nil, nil), makeObs(2, "s", nil, nil, nil)}
// Only obs 1 has embedding
embeddings := map[int64][]float32{1: {1.0, 0.0, 0.0}}
edges := DetectSemanticEdges(context.Background(), obs, embeddings)
assert.Empty(t, edges)
}
func TestDetectSemanticEdges_NoDuplicates(t *testing.T) {
emb := []float32{0.9, 0.1, 0.0}
obs := []*models.Observation{
makeObs(1, "s", nil, nil, nil),
makeObs(2, "s", nil, nil, nil),
makeObs(3, "s", nil, nil, nil),
}
embeddings := map[int64][]float32{1: emb, 2: emb, 3: emb}
edges := DetectSemanticEdges(context.Background(), obs, embeddings)
// 3 pairs: (1,2),(1,3),(2,3)
assert.Len(t, edges, 3)
}
// ---- cosineSimilarity -------------------------------------------------------
func TestCosineSimilarity_IdenticalVectors(t *testing.T) {
v := []float32{1.0, 2.0, 3.0}
result := cosineSimilarity(v, v)
assert.InDelta(t, 1.0, float64(result), 0.0001)
}
func TestCosineSimilarity_OppositeVectors(t *testing.T) {
a := []float32{1.0, 0.0}
b := []float32{-1.0, 0.0}
result := cosineSimilarity(a, b)
assert.InDelta(t, -1.0, float64(result), 0.0001)
}
func TestCosineSimilarity_OrthogonalVectors(t *testing.T) {
a := []float32{1.0, 0.0}
b := []float32{0.0, 1.0}
result := cosineSimilarity(a, b)
assert.InDelta(t, 0.0, float64(result), 0.0001)
}
func TestCosineSimilarity_ZeroVector_ReturnsZero(t *testing.T) {
a := []float32{0.0, 0.0}
b := []float32{1.0, 0.0}
assert.Equal(t, float32(0.0), cosineSimilarity(a, b))
assert.Equal(t, float32(0.0), cosineSimilarity(b, a))
}
func TestCosineSimilarity_MismatchedLength_ReturnsZero(t *testing.T) {
a := []float32{1.0, 2.0}
b := []float32{1.0, 2.0, 3.0}
assert.Equal(t, float32(0.0), cosineSimilarity(a, b))
}
// ---- edgeKey ----------------------------------------------------------------
func TestEdgeKey_Symmetric(t *testing.T) {
// Must produce the same key regardless of order
assert.Equal(t, edgeKey(1, 2), edgeKey(2, 1))
assert.Equal(t, edgeKey(100, 5), edgeKey(5, 100))
}
func TestEdgeKey_DifferentPairs_DifferentKeys(t *testing.T) {
assert.NotEqual(t, edgeKey(1, 2), edgeKey(1, 3))
assert.NotEqual(t, edgeKey(1, 2), edgeKey(2, 3))
}
// ---- pruneEdges -------------------------------------------------------------
func TestPruneEdges_BelowLimit_NoChange(t *testing.T) {
edges := []Edge{
{FromID: 1, ToID: 2, Weight: 0.9},
{FromID: 1, ToID: 3, Weight: 0.7},
}
pruned := pruneEdges(edges, 5)
assert.Len(t, pruned, 2)
}
func TestPruneEdges_ZeroLimit_ReturnsAll(t *testing.T) {
edges := []Edge{{FromID: 1, ToID: 2, Weight: 0.5}}
pruned := pruneEdges(edges, 0)
assert.Len(t, pruned, 1)
}
func TestPruneEdges_KeepsHighWeightEdges(t *testing.T) {
// Node 1 gets 4 edges, limit is 2 → only the 2 heaviest should survive
edges := []Edge{
{FromID: 1, ToID: 2, Weight: 0.9},
{FromID: 1, ToID: 3, Weight: 0.8},
{FromID: 1, ToID: 4, Weight: 0.3},
{FromID: 1, ToID: 5, Weight: 0.1},
}
pruned := pruneEdges(edges, 2)
weights := make([]float32, len(pruned))
for i, e := range pruned {
weights[i] = e.Weight
}
assert.Contains(t, weights, float32(0.9))
assert.Contains(t, weights, float32(0.8))
}
// ---- sortEdgesByWeight ------------------------------------------------------
func TestSortEdgesByWeight_DescendingOrder(t *testing.T) {
edges := []Edge{
{Weight: 0.3},
{Weight: 0.9},
{Weight: 0.1},
{Weight: 0.7},
}
sortEdgesByWeight(edges)
for i := 1; i < len(edges); i++ {
assert.GreaterOrEqual(t, edges[i-1].Weight, edges[i].Weight)
}
}
func TestSortEdgesByWeight_EmptySlice_NoPanic(t *testing.T) {
assert.NotPanics(t, func() {
sortEdgesByWeight([]Edge{})
})
}
func TestSortEdgesByWeight_SingleElement_Unchanged(t *testing.T) {
edges := []Edge{{Weight: 0.5}}
sortEdgesByWeight(edges)
assert.Equal(t, float32(0.5), edges[0].Weight)
}
// ---- helpers ----------------------------------------------------------------
func filterByRelation(edges []Edge, rel RelationType) []Edge {
var out []Edge
for _, e := range edges {
if e.Relation == rel {
out = append(out, e)
}
}
return out
}
+5 -1
View File
@@ -151,10 +151,11 @@ func (s *Service) runMaintenance(ctx context.Context) {
}
// Task 3: Optimize database
var optimized bool
if err := s.store.Optimize(ctx); err != nil {
s.log.Error().Err(err).Msg("Failed to optimize database")
} else {
s.totalOptimizeRun++
optimized = true
}
// Task 4: Clean up old prompts (keep last 1000 per session)
@@ -170,6 +171,9 @@ func (s *Service) runMaintenance(ctx context.Context) {
s.lastRunTime = time.Now()
s.lastRunDuration = time.Since(start)
s.totalCleanedObs += totalCleaned
if optimized {
s.totalOptimizeRun++
}
s.mu.Unlock()
s.log.Info().
+727
View File
@@ -0,0 +1,727 @@
//go:build fts5
// Package maintenance provides scheduled maintenance tasks for claude-mnemonic.
package maintenance
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/logger"
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
gormdb "github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
)
// testSetup creates a full maintenance service with a real temporary database.
func testSetup(t *testing.T, cfg *config.Config) (*Service, *gormdb.Store, *gormdb.ObservationStore, *gormdb.PromptStore, func()) {
t.Helper()
tmpDir, err := os.MkdirTemp("", "maintenance_test_*")
require.NoError(t, err, "create temp dir")
dbPath := filepath.Join(tmpDir, "test.db")
storeCfg := gormdb.Config{
Path: dbPath,
MaxConns: 4,
LogLevel: logger.Silent,
}
store, err := gormdb.NewStore(storeCfg)
if err != nil {
os.RemoveAll(tmpDir)
t.Fatalf("NewStore failed: %v", err)
}
observationStore := gormdb.NewObservationStore(store, nil, nil, nil)
summaryStore := gormdb.NewSummaryStore(store)
promptStore := gormdb.NewPromptStore(store, nil)
svc := NewService(store, observationStore, summaryStore, promptStore, nil, cfg, zerolog.Nop())
cleanup := func() {
store.Close()
os.RemoveAll(tmpDir)
}
return svc, store, observationStore, promptStore, cleanup
}
// defaultCfg returns a maintenance-enabled config for tests.
func defaultCfg() *config.Config {
cfg := config.Default()
cfg.MaintenanceEnabled = true
cfg.MaintenanceIntervalHours = 1
cfg.ObservationRetentionDays = 0
cfg.CleanupStaleObservations = false
return cfg
}
// insertObservation is a helper that inserts an observation and returns its ID.
func insertObservation(t *testing.T, obsStore *gormdb.ObservationStore, session, project string, seq int) int64 {
t.Helper()
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Title: "test observation",
}
id, _, err := obsStore.StoreObservation(context.Background(), session, project, obs, seq, 10)
require.NoError(t, err)
return id
}
// ---- NewService ----
func TestNewService_ReturnsNonNilService(t *testing.T) {
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
defer cleanup()
assert.NotNil(t, svc)
}
func TestNewService_InitializesChannels(t *testing.T) {
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
defer cleanup()
// stopCh and doneCh must be non-nil so Stop/Wait don't panic.
assert.NotNil(t, svc.stopCh)
assert.NotNil(t, svc.doneCh)
}
// ---- Stats ----
func TestStats_DefaultValues(t *testing.T) {
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
defer cleanup()
stats := svc.Stats()
assert.Equal(t, true, stats["enabled"])
assert.Equal(t, 1, stats["interval_hours"])
assert.Equal(t, 0, stats["retention_days"])
assert.Equal(t, false, stats["cleanup_stale"])
assert.Equal(t, int64(0), stats["total_cleaned_obs"])
assert.Equal(t, int64(0), stats["total_optimizes"])
assert.Equal(t, false, stats["running"])
}
func TestStats_ReflectsConfigFields(t *testing.T) {
tests := []struct {
name string
cfg *config.Config
wantEnabled bool
wantHours int
wantDays int
wantStale bool
}{
{
name: "maintenance disabled",
cfg: func() *config.Config {
c := defaultCfg()
c.MaintenanceEnabled = false
return c
}(),
wantEnabled: false,
wantHours: 1,
wantDays: 0,
wantStale: false,
},
{
name: "retention and stale cleanup enabled",
cfg: func() *config.Config {
c := defaultCfg()
c.ObservationRetentionDays = 30
c.CleanupStaleObservations = true
c.MaintenanceIntervalHours = 12
return c
}(),
wantEnabled: true,
wantHours: 12,
wantDays: 30,
wantStale: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc, _, _, _, cleanup := testSetup(t, tt.cfg)
defer cleanup()
stats := svc.Stats()
assert.Equal(t, tt.wantEnabled, stats["enabled"])
assert.Equal(t, tt.wantHours, stats["interval_hours"])
assert.Equal(t, tt.wantDays, stats["retention_days"])
assert.Equal(t, tt.wantStale, stats["cleanup_stale"])
})
}
}
// ---- Stop (idempotency) ----
func TestStop_WhenNotRunning_DoesNotPanic(t *testing.T) {
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
defer cleanup()
// Service was never started — Stop must be a no-op.
assert.NotPanics(t, func() { svc.Stop() })
}
func TestStop_CalledTwice_DoesNotPanic(t *testing.T) {
// Start with maintenance disabled so Start() returns immediately.
cfg := defaultCfg()
cfg.MaintenanceEnabled = false
svc, _, _, _, cleanup := testSetup(t, cfg)
defer cleanup()
ctx := context.Background()
go svc.Start(ctx)
svc.Wait() // drains doneCh after early return
// Stop after Wait — must not panic or double-close.
assert.NotPanics(t, func() { svc.Stop() })
}
// ---- Start / running flag ----
func TestStart_MaintenanceDisabled_ExitsImmediately(t *testing.T) {
cfg := defaultCfg()
cfg.MaintenanceEnabled = false
svc, _, _, _, cleanup := testSetup(t, cfg)
defer cleanup()
ctx := context.Background()
go svc.Start(ctx)
done := make(chan struct{})
go func() {
svc.Wait()
close(done)
}()
select {
case <-done:
// Good — returned without blocking.
case <-time.After(2 * time.Second):
t.Fatal("Start() did not return promptly when maintenance is disabled")
}
stats := svc.Stats()
assert.Equal(t, false, stats["running"])
}
func TestStart_StopSignal_ExitsCleanly(t *testing.T) {
// Start() with maintenance disabled exits immediately — verified in
// TestStart_MaintenanceDisabled_ExitsImmediately.
//
// The ticker/stop path is hard to test because Start() always sleeps
// 5 minutes before entering the loop. We verify instead that Stop()
// on an already-stopped service is safe and that the doneCh is closed
// after exit (i.e., Wait() returns).
cfg := defaultCfg()
cfg.MaintenanceEnabled = false
svc, _, _, _, cleanup := testSetup(t, cfg)
defer cleanup()
go svc.Start(context.Background())
done := make(chan struct{})
go func() {
svc.Wait()
close(done)
}()
select {
case <-done:
// doneCh was closed — Start exited and Wait returned.
case <-time.After(2 * time.Second):
t.Fatal("Wait() did not return after Start exited")
}
// Stop after Wait must be a no-op and must not panic.
assert.NotPanics(t, func() { svc.Stop() })
}
func TestStart_DoubleStart_SecondCallIsNoOp(t *testing.T) {
cfg := defaultCfg()
cfg.MaintenanceEnabled = false // exits immediately
svc, _, _, _, cleanup := testSetup(t, cfg)
defer cleanup()
ctx := context.Background()
// First call.
go svc.Start(ctx)
svc.Wait()
// Second call on the same (exhausted) svc should be a no-op and not panic.
assert.NotPanics(t, func() {
// svc.running is now false again — but doneCh is already closed.
// A second Start would attempt to close doneCh again which would panic
// if the running guard is missing. Verify the guard works.
svc.mu.Lock()
running := svc.running
svc.mu.Unlock()
assert.False(t, running)
})
}
// ---- RunNow ----
func TestRunNow_UpdatesLastRunTime(t *testing.T) {
cfg := defaultCfg()
cfg.ObservationRetentionDays = 0
cfg.CleanupStaleObservations = false
svc, _, _, _, cleanup := testSetup(t, cfg)
defer cleanup()
before := time.Now()
svc.RunNow(context.Background())
// Allow async goroutine to finish.
time.Sleep(200 * time.Millisecond)
svc.mu.Lock()
lastRun := svc.lastRunTime
svc.mu.Unlock()
assert.True(t, lastRun.After(before) || lastRun.Equal(before),
"lastRunTime should be updated after RunNow")
}
func TestRunNow_IncrementsOptimizeCounter(t *testing.T) {
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
defer cleanup()
svc.RunNow(context.Background())
time.Sleep(300 * time.Millisecond)
svc.mu.Lock()
optimizes := svc.totalOptimizeRun
svc.mu.Unlock()
assert.Equal(t, int64(1), optimizes)
}
func TestRunNow_StatsTotalOptimizesReflected(t *testing.T) {
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
defer cleanup()
svc.RunNow(context.Background())
time.Sleep(300 * time.Millisecond)
stats := svc.Stats()
assert.Equal(t, int64(1), stats["total_optimizes"])
}
// ---- cleanupOldObservations (via RunNow) ----
func TestRunNow_RetentionDaysZero_NothingDeleted(t *testing.T) {
cfg := defaultCfg()
cfg.ObservationRetentionDays = 0
svc, _, obsStore, _, cleanup := testSetup(t, cfg)
defer cleanup()
// Insert observations.
for i := 0; i < 5; i++ {
insertObservation(t, obsStore, "session-1", "proj", i)
}
svc.RunNow(context.Background())
time.Sleep(300 * time.Millisecond)
remaining, err := obsStore.GetRecentObservations(context.Background(), "proj", 20)
require.NoError(t, err)
assert.Equal(t, 5, len(remaining), "nothing should be deleted when retention_days = 0")
svc.mu.Lock()
cleaned := svc.totalCleanedObs
svc.mu.Unlock()
assert.Equal(t, int64(0), cleaned)
}
func TestRunNow_RetentionDays_DeletesExpiredObservations(t *testing.T) {
cfg := defaultCfg()
cfg.ObservationRetentionDays = 1 // keep only last 1 day
svc, store, _, _, cleanup := testSetup(t, cfg)
defer cleanup()
ctx := context.Background()
// Insert an observation and back-date it to 2 days ago.
obs := &gormdb.Observation{
SDKSessionID: "old-session",
Project: "proj",
Type: models.ObsTypeDiscovery,
CreatedAt: "2000-01-01T00:00:00Z",
CreatedAtEpoch: time.Now().AddDate(0, 0, -2).Unix(),
Scope: models.ScopeProject,
ImportanceScore: 1.0,
}
require.NoError(t, store.GetDB().WithContext(ctx).Create(obs).Error)
// Insert a recent observation (should survive).
recentObs := &gormdb.Observation{
SDKSessionID: "new-session",
Project: "proj",
Type: models.ObsTypeDiscovery,
CreatedAt: time.Now().Format(time.RFC3339),
CreatedAtEpoch: time.Now().Unix(),
Scope: models.ScopeProject,
ImportanceScore: 1.0,
}
require.NoError(t, store.GetDB().WithContext(ctx).Create(recentObs).Error)
svc.RunNow(ctx)
time.Sleep(300 * time.Millisecond)
// Only the recent observation should remain.
var count int64
store.GetDB().WithContext(ctx).Model(&gormdb.Observation{}).Count(&count)
assert.Equal(t, int64(1), count, "expired observation should have been deleted")
svc.mu.Lock()
cleaned := svc.totalCleanedObs
svc.mu.Unlock()
assert.Equal(t, int64(1), cleaned)
}
func TestRunNow_RetentionDays_VectorCleanupCalled(t *testing.T) {
cfg := defaultCfg()
cfg.ObservationRetentionDays = 1
tmpDir, err := os.MkdirTemp("", "maintenance_vec_test_*")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)
store, err := gormdb.NewStore(gormdb.Config{
Path: filepath.Join(tmpDir, "test.db"),
MaxConns: 4,
LogLevel: logger.Silent,
})
require.NoError(t, err)
defer store.Close()
observationStore := gormdb.NewObservationStore(store, nil, nil, nil)
summaryStore := gormdb.NewSummaryStore(store)
promptStore := gormdb.NewPromptStore(store, nil)
var mu sync.Mutex
var capturedIDs []int64
vectorCleanupFn := func(_ context.Context, ids []int64) {
mu.Lock()
defer mu.Unlock()
capturedIDs = append(capturedIDs, ids...)
}
svc := NewService(store, observationStore, summaryStore, promptStore, vectorCleanupFn, cfg, zerolog.Nop())
ctx := context.Background()
// Insert an expired observation directly.
obs := &gormdb.Observation{
SDKSessionID: "session-x",
Project: "proj",
Type: models.ObsTypeDiscovery,
CreatedAt: "2000-01-01T00:00:00Z",
CreatedAtEpoch: time.Now().AddDate(0, 0, -2).Unix(),
Scope: models.ScopeProject,
ImportanceScore: 1.0,
}
require.NoError(t, store.GetDB().WithContext(ctx).Create(obs).Error)
svc.RunNow(ctx)
time.Sleep(300 * time.Millisecond)
mu.Lock()
ids := capturedIDs
mu.Unlock()
assert.NotEmpty(t, ids, "vector cleanup callback must be called with deleted IDs")
assert.Contains(t, ids, obs.ID)
}
// ---- cleanupStaleObservations (via RunNow) ----
func TestRunNow_CleanupStale_DeletesSupersededObservations(t *testing.T) {
cfg := defaultCfg()
cfg.CleanupStaleObservations = true
cfg.ObservationRetentionDays = 0
svc, store, obsStore, _, cleanup := testSetup(t, cfg)
defer cleanup()
ctx := context.Background()
// Insert an active observation.
activeID := insertObservation(t, obsStore, "session-1", "proj", 1)
// Insert and mark a stale observation.
staleID := insertObservation(t, obsStore, "session-1", "proj", 2)
require.NoError(t, store.GetDB().WithContext(ctx).
Model(&gormdb.Observation{}).
Where("id = ?", staleID).
Update("is_superseded", 1).Error)
svc.RunNow(ctx)
time.Sleep(300 * time.Millisecond)
// Active observation must survive.
var activeCount int64
store.GetDB().WithContext(ctx).Model(&gormdb.Observation{}).Where("id = ?", activeID).Count(&activeCount)
assert.Equal(t, int64(1), activeCount, "active observation must not be deleted")
// Stale observation must be gone.
var staleCount int64
store.GetDB().WithContext(ctx).Model(&gormdb.Observation{}).Where("id = ?", staleID).Count(&staleCount)
assert.Equal(t, int64(0), staleCount, "stale observation must be deleted")
}
func TestRunNow_CleanupStale_DisabledLeavesStaleObservations(t *testing.T) {
cfg := defaultCfg()
cfg.CleanupStaleObservations = false
svc, store, obsStore, _, cleanup := testSetup(t, cfg)
defer cleanup()
ctx := context.Background()
staleID := insertObservation(t, obsStore, "session-1", "proj", 1)
require.NoError(t, store.GetDB().WithContext(ctx).
Model(&gormdb.Observation{}).
Where("id = ?", staleID).
Update("is_superseded", 1).Error)
svc.RunNow(ctx)
time.Sleep(300 * time.Millisecond)
var count int64
store.GetDB().WithContext(ctx).Model(&gormdb.Observation{}).Where("id = ?", staleID).Count(&count)
assert.Equal(t, int64(1), count, "stale observation must survive when cleanup_stale is false")
}
func TestRunNow_CleanupStale_NoStaleRows_NothingChanged(t *testing.T) {
cfg := defaultCfg()
cfg.CleanupStaleObservations = true
svc, _, obsStore, _, cleanup := testSetup(t, cfg)
defer cleanup()
ctx := context.Background()
// Only active observations.
for i := 0; i < 3; i++ {
insertObservation(t, obsStore, "session-1", "proj", i)
}
svc.RunNow(ctx)
time.Sleep(300 * time.Millisecond)
remaining, err := obsStore.GetRecentObservations(ctx, "proj", 20)
require.NoError(t, err)
assert.Equal(t, 3, len(remaining))
}
// ---- cleanupOldPrompts (via RunNow) ----
func TestRunNow_CleanupOldPrompts_DeletesExpiredPrompts(t *testing.T) {
svc, store, _, _, cleanup := testSetup(t, defaultCfg())
defer cleanup()
ctx := context.Background()
// Insert a prompt with an old epoch (31 days ago).
oldPrompt := &gormdb.UserPrompt{
ClaudeSessionID: "session-old",
PromptText: "old prompt",
PromptNumber: 1,
CreatedAt: "2000-01-01T00:00:00Z",
CreatedAtEpoch: time.Now().AddDate(0, 0, -31).Unix(),
}
require.NoError(t, store.GetDB().WithContext(ctx).Create(oldPrompt).Error)
// Insert a recent prompt (should survive).
recentPrompt := &gormdb.UserPrompt{
ClaudeSessionID: "session-new",
PromptText: "recent prompt",
PromptNumber: 1,
CreatedAt: time.Now().Format(time.RFC3339),
CreatedAtEpoch: time.Now().Unix(),
}
require.NoError(t, store.GetDB().WithContext(ctx).Create(recentPrompt).Error)
svc.RunNow(ctx)
time.Sleep(300 * time.Millisecond)
var count int64
store.GetDB().WithContext(ctx).Model(&gormdb.UserPrompt{}).Count(&count)
assert.Equal(t, int64(1), count, "only the recent prompt should survive")
}
func TestRunNow_CleanupOldPrompts_NothingExpired_AllSurvive(t *testing.T) {
svc, store, _, promptStore, cleanup := testSetup(t, defaultCfg())
defer cleanup()
ctx := context.Background()
for i := 1; i <= 5; i++ {
_, err := promptStore.SaveUserPromptWithMatches(ctx, "session-1", i, "prompt", 1)
require.NoError(t, err)
}
svc.RunNow(ctx)
time.Sleep(300 * time.Millisecond)
var count int64
store.GetDB().WithContext(ctx).Model(&gormdb.UserPrompt{}).Count(&count)
assert.Equal(t, int64(5), count, "no prompts should be deleted when none are expired")
}
// ---- Stats race safety ----
func TestStats_ConcurrentAccess_NoRace(t *testing.T) {
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
defer cleanup()
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = svc.Stats()
}()
}
wg.Wait()
}
// ---- RunNow concurrent safety ----
func TestRunNow_ConcurrentCalls_NoRace(t *testing.T) {
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
defer cleanup()
ctx := context.Background()
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
svc.RunNow(ctx)
}()
}
wg.Wait()
time.Sleep(500 * time.Millisecond)
}
// ---- lastRunDuration is populated ----
func TestRunNow_LastRunDuration_IsPopulated(t *testing.T) {
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
defer cleanup()
svc.RunNow(context.Background())
time.Sleep(300 * time.Millisecond)
svc.mu.Lock()
dur := svc.lastRunDuration
svc.mu.Unlock()
assert.Greater(t, int64(dur), int64(0), "lastRunDuration should be set after a maintenance run")
}
func TestStats_LastDurationMs_IsPopulated(t *testing.T) {
svc, _, _, _, cleanup := testSetup(t, defaultCfg())
defer cleanup()
svc.RunNow(context.Background())
time.Sleep(300 * time.Millisecond)
stats := svc.Stats()
// The value is int64 milliseconds; it might be 0 for very fast runs — just verify the key exists.
_, ok := stats["last_duration_ms"]
assert.True(t, ok, "stats must contain last_duration_ms key")
}
// ---- Batch deletion boundary ----
func TestRunNow_RetentionDays_BatchDeletion_MoreThan100Rows(t *testing.T) {
cfg := defaultCfg()
cfg.ObservationRetentionDays = 1
svc, store, _, _, cleanup := testSetup(t, cfg)
defer cleanup()
ctx := context.Background()
// Insert 150 expired observations (forces 2 batches of 100).
for i := 0; i < 150; i++ {
obs := &gormdb.Observation{
SDKSessionID: "session-old",
Project: "proj",
Type: models.ObsTypeDiscovery,
CreatedAt: "2000-01-01T00:00:00Z",
CreatedAtEpoch: time.Now().AddDate(0, 0, -2).Unix(),
Scope: models.ScopeProject,
ImportanceScore: 1.0,
}
require.NoError(t, store.GetDB().WithContext(ctx).Create(obs).Error)
}
svc.RunNow(ctx)
time.Sleep(500 * time.Millisecond)
var remaining int64
store.GetDB().WithContext(ctx).Model(&gormdb.Observation{}).Count(&remaining)
assert.Equal(t, int64(0), remaining, "all 150 expired observations should be deleted in batches")
svc.mu.Lock()
cleaned := svc.totalCleanedObs
svc.mu.Unlock()
assert.Equal(t, int64(150), cleaned)
}
func TestRunNow_CleanupStale_BatchDeletion_MoreThan100Rows(t *testing.T) {
cfg := defaultCfg()
cfg.CleanupStaleObservations = true
svc, store, _, _, cleanup := testSetup(t, cfg)
defer cleanup()
ctx := context.Background()
// Insert 120 superseded observations.
for i := 0; i < 120; i++ {
obs := &gormdb.Observation{
SDKSessionID: "session-stale",
Project: "proj",
Type: models.ObsTypeDiscovery,
CreatedAt: time.Now().Format(time.RFC3339),
CreatedAtEpoch: time.Now().Unix(),
Scope: models.ScopeProject,
ImportanceScore: 1.0,
IsSuperseded: 1,
}
require.NoError(t, store.GetDB().WithContext(ctx).Create(obs).Error)
}
svc.RunNow(ctx)
time.Sleep(500 * time.Millisecond)
var remaining int64
store.GetDB().WithContext(ctx).Model(&gormdb.Observation{}).Where("is_superseded = ?", 1).Count(&remaining)
assert.Equal(t, int64(0), remaining, "all 120 stale observations should be deleted in batches")
}
+866 -2230
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
// Package telemetry sends anonymous OSS usage pings.
package telemetry
import (
"bytes"
"context"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/rs/zerolog/log"
)
const endpoint = "https://oss.raczylo.com/v1/ping"
type pingPayload struct {
Project string `json:"project"`
Version string `json:"version"`
Ts int64 `json:"ts"`
}
// Ping fires an anonymous analytics ping asynchronously. Never blocks the caller.
// Silently skips dev/dirty builds; all errors are logged at debug level only.
func Ping(project, version string) {
if version == "dev" || strings.Contains(version, "dirty") {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
body, err := json.Marshal(pingPayload{
Project: project,
Version: strings.TrimPrefix(version, "v"),
Ts: time.Now().Unix(),
})
if err != nil {
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Debug().Err(err).Msg("telemetry ping failed")
return
}
resp.Body.Close()
}()
}
+9 -3
View File
@@ -532,13 +532,20 @@ func (u *Updater) replaceBinaries(extractDir string) error {
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/
// Primary stable binary location (survives Claude Code updates)
stableBin := filepath.Join(home, ".claude-mnemonic", "bin")
if stableBin != u.installDir {
if _, err := os.Stat(stableBin); err == nil {
dirs = append(dirs, stableBin)
}
}
// Also check cache directories where Claude Code looks for plugins
cacheBase := filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic")
entries, err := os.ReadDir(cacheBase)
if err != nil {
@@ -548,7 +555,6 @@ func (u *Updater) getInstallDirectories() []string {
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 {
File diff suppressed because it is too large Load Diff
+416
View File
@@ -0,0 +1,416 @@
//go:build fts5
package watcher
import (
"context"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// waitForCondition polls fn every 10ms until it returns true or timeout expires.
func waitForCondition(t *testing.T, timeout time.Duration, fn func() bool) bool {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if fn() {
return true
}
time.Sleep(10 * time.Millisecond)
}
return false
}
// TestNew_CreatesWatcherWithCorrectFields verifies New initialises all fields correctly.
func TestNew_CreatesWatcherWithCorrectFields(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
called := false
cb := func() { called = true }
w, err := New(target, cb)
require.NoError(t, err)
require.NotNil(t, w)
defer w.Stop() //nolint:errcheck
assert.Equal(t, target, w.targetPath)
assert.Equal(t, dir, w.parentPath)
assert.Equal(t, 100*time.Millisecond, w.debounce)
assert.NotNil(t, w.watcher)
assert.NotNil(t, w.ctx)
assert.NotNil(t, w.cancel)
assert.False(t, w.running)
assert.False(t, called, "callback must not be invoked on creation")
}
// TestNew_NilCallback is valid — handleDeletion guards for nil onDelete.
func TestNew_NilCallback(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
w, err := New(target, nil)
require.NoError(t, err)
require.NotNil(t, w)
defer w.Stop() //nolint:errcheck
assert.Nil(t, w.onDelete)
}
// TestStart_SetsRunningTrue verifies Start transitions running to true.
func TestStart_SetsRunningTrue(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
w, err := New(target, func() {})
require.NoError(t, err)
defer w.Stop() //nolint:errcheck
err = w.Start()
require.NoError(t, err)
w.mu.Lock()
running := w.running
w.mu.Unlock()
assert.True(t, running)
}
// TestStart_Idempotent verifies calling Start twice does not panic or return error.
func TestStart_Idempotent(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
w, err := New(target, func() {})
require.NoError(t, err)
defer w.Stop() //nolint:errcheck
require.NoError(t, w.Start())
require.NoError(t, w.Start(), "second Start must be a no-op without error")
// Still only one goroutine running — running flag is still true.
w.mu.Lock()
running := w.running
w.mu.Unlock()
assert.True(t, running)
}
// TestStop_SetsRunningFalse verifies Stop transitions running to false.
func TestStop_SetsRunningFalse(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
w, err := New(target, func() {})
require.NoError(t, err)
require.NoError(t, w.Start())
require.NoError(t, w.Stop())
w.mu.Lock()
running := w.running
w.mu.Unlock()
assert.False(t, running)
}
// TestStop_Idempotent verifies calling Stop when not running returns nil.
func TestStop_Idempotent(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
w, err := New(target, func() {})
require.NoError(t, err)
// Never started — Stop must be a no-op.
assert.NoError(t, w.Stop())
// Second stop after the first no-op must also succeed.
assert.NoError(t, w.Stop())
}
// TestStop_WithoutStart verifies Stop on an unstarted watcher is safe.
func TestStop_WithoutStart(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
w, err := New(target, func() {})
require.NoError(t, err)
err = w.Stop()
assert.NoError(t, err)
}
// TestTargetDeletion_CallbackFired verifies that deleting the target file triggers onDelete.
func TestTargetDeletion_CallbackFired(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
// Create the target file so the parent watch is real.
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
var callCount int32
w, err := New(target, func() {
atomic.AddInt32(&callCount, 1)
})
require.NoError(t, err)
defer w.Stop() //nolint:errcheck
require.NoError(t, w.Start())
// Delete the target file.
require.NoError(t, os.Remove(target))
// Wait up to 1 second for the debounced callback (debounce=100ms).
fired := waitForCondition(t, 1*time.Second, func() bool {
return atomic.LoadInt32(&callCount) > 0
})
assert.True(t, fired, "onDelete callback not called after target deletion")
}
// TestTargetDeletion_CallbackCalledOnce verifies debounce suppresses duplicate events.
func TestTargetDeletion_CallbackCalledOnce(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
var callCount int32
w, err := New(target, func() {
atomic.AddInt32(&callCount, 1)
})
require.NoError(t, err)
defer w.Stop() //nolint:errcheck
require.NoError(t, w.Start())
require.NoError(t, os.Remove(target))
// Wait for callback to fire.
waitForCondition(t, 1*time.Second, func() bool {
return atomic.LoadInt32(&callCount) > 0
})
// Wait an extra debounce window to confirm no second call arrives.
time.Sleep(300 * time.Millisecond)
assert.Equal(t, int32(1), atomic.LoadInt32(&callCount), "callback fired more than once for a single deletion")
}
// TestTargetRecreation_CancelsCallback verifies that recreating the target before the
// debounce fires suppresses the onDelete callback.
func TestTargetRecreation_CancelsCallback(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
var callCount int32
// Use a longer debounce so we can recreate before it fires.
w, err := New(target, func() {
atomic.AddInt32(&callCount, 1)
})
require.NoError(t, err)
// Override debounce to give us a larger window.
w.debounce = 300 * time.Millisecond
defer w.Stop() //nolint:errcheck
require.NoError(t, w.Start())
// Delete then immediately recreate within the debounce window.
require.NoError(t, os.Remove(target))
time.Sleep(20 * time.Millisecond) // ensure delete event is processed
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
// Wait past full debounce period to confirm callback was cancelled.
time.Sleep(500 * time.Millisecond)
assert.Equal(t, int32(0), atomic.LoadInt32(&callCount), "callback should have been cancelled by recreation")
}
// TestParentDirectoryDeletion_CallbackFired verifies that deleting the parent directory
// triggers the onDelete callback.
func TestParentDirectoryDeletion_CallbackFired(t *testing.T) {
// Create a nested structure: base/sub/db.sqlite so we can remove sub
// without losing t.TempDir (which is base).
base := t.TempDir()
sub := filepath.Join(base, "sub")
require.NoError(t, os.Mkdir(sub, 0o755))
target := filepath.Join(sub, "db.sqlite")
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
var callCount int32
w, err := New(target, func() {
atomic.AddInt32(&callCount, 1)
})
require.NoError(t, err)
defer w.Stop() //nolint:errcheck
require.NoError(t, w.Start())
// Remove parent directory entirely.
require.NoError(t, os.RemoveAll(sub))
fired := waitForCondition(t, 1500*time.Millisecond, func() bool {
return atomic.LoadInt32(&callCount) > 0
})
assert.True(t, fired, "onDelete callback not called after parent directory deletion")
}
// TestAddWatch_NonExistentParent verifies addWatch returns an error when parent is absent.
func TestAddWatch_NonExistentParent(t *testing.T) {
// Point watcher at a path whose parent definitely does not exist.
nonExistent := filepath.Join(t.TempDir(), "missing", "db.sqlite")
w, err := New(nonExistent, func() {})
require.NoError(t, err)
defer w.Stop() //nolint:errcheck
err = w.addWatch()
assert.Error(t, err, "addWatch must fail when parent directory does not exist")
}
// TestContextCancellation_StopsWatchLoop verifies the watchLoop exits when Stop is called.
func TestContextCancellation_StopsWatchLoop(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
w, err := New(target, func() {})
require.NoError(t, err)
require.NoError(t, w.Start())
// Stop cancels the context; the goroutine should exit cleanly.
require.NoError(t, w.Stop())
// Give the goroutine a moment to exit — then verify running is false.
time.Sleep(50 * time.Millisecond)
w.mu.Lock()
running := w.running
w.mu.Unlock()
assert.False(t, running)
}
// TestParentDirRecreation_ReEstablishesWatch verifies that recreating the parent after
// deletion allows subsequent target-deletion events to fire the callback.
func TestParentDirRecreation_ReEstablishesWatch(t *testing.T) {
base := t.TempDir()
sub := filepath.Join(base, "sub")
require.NoError(t, os.Mkdir(sub, 0o755))
target := filepath.Join(sub, "db.sqlite")
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
var callCount int32
w, err := New(target, func() {
atomic.AddInt32(&callCount, 1)
})
require.NoError(t, err)
defer w.Stop() //nolint:errcheck
require.NoError(t, w.Start())
// Remove the parent.
require.NoError(t, os.RemoveAll(sub))
// Wait for first callback.
fired := waitForCondition(t, 1500*time.Millisecond, func() bool {
return atomic.LoadInt32(&callCount) > 0
})
require.True(t, fired, "first deletion callback must fire")
firstCount := atomic.LoadInt32(&callCount)
// Recreate parent and target — re-established watch should allow a second callback.
require.NoError(t, os.Mkdir(sub, 0o755))
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
// Wait for handleDeletion's goroutine to attempt re-adding the watch (500ms sleep inside).
time.Sleep(700 * time.Millisecond)
// Now delete the target again.
require.NoError(t, os.Remove(target))
// We only assert the first callback fired; the re-watch is best-effort and
// OS-timing-dependent, so we don't hard-assert a second callback.
assert.GreaterOrEqual(t, atomic.LoadInt32(&callCount), firstCount, "call count must not decrease")
}
// TestConcurrentStartStop verifies that concurrent Start/Stop calls do not race or panic.
func TestConcurrentStartStop(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
w, err := New(target, func() {})
require.NoError(t, err)
var wg sync.WaitGroup
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
// Launch goroutines that repeatedly start/stop the watcher.
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
default:
_ = w.Start()
time.Sleep(5 * time.Millisecond)
_ = w.Stop()
time.Sleep(5 * time.Millisecond)
}
}
}()
}
wg.Wait()
// No panic = pass. Final state: running should be consistent (we don't assert
// a specific value since Stop may have won last).
}
// TestDebounceField_DefaultValue asserts the default debounce is 100ms.
func TestDebounceField_DefaultValue(t *testing.T) {
dir := t.TempDir()
w, err := New(filepath.Join(dir, "x"), func() {})
require.NoError(t, err)
defer w.Stop() //nolint:errcheck
assert.Equal(t, 100*time.Millisecond, w.debounce)
}
// TestCallbackNotCalledWhenStopped verifies that if we Stop before the debounce fires,
// the callback is not invoked after Stop (context cancel exits the watchLoop).
func TestCallbackNotCalledWhenStopped(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "db.sqlite")
require.NoError(t, os.WriteFile(target, []byte("data"), 0o644))
var callCount int32
w, err := New(target, func() {
atomic.AddInt32(&callCount, 1)
})
require.NoError(t, err)
w.debounce = 500 * time.Millisecond // wide window
require.NoError(t, w.Start())
// Delete file — debounce timer is now running (500ms).
require.NoError(t, os.Remove(target))
time.Sleep(20 * time.Millisecond) // let event propagate
// Stop before timer fires — context is cancelled, watchLoop exits.
require.NoError(t, w.Stop())
// Wait past the debounce window; the AfterFunc may still fire (it's not
// tied to the context), but the watcher is stopped. We assert the loop
// itself exited cleanly.
time.Sleep(700 * time.Millisecond)
// The AfterFunc timer fires outside the watchLoop — callback may or may not
// have fired depending on OS scheduling. We assert no panic occurred.
// The important invariant: running is false.
w.mu.Lock()
running := w.running
w.mu.Unlock()
assert.False(t, running)
}
+48 -11
View File
@@ -13,6 +13,7 @@ import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/rs/zerolog/log"
)
@@ -142,19 +143,55 @@ func formatWarning(format string, args ...any) string {
}
// handleHealth handles health check requests.
// Returns 200 OK immediately (even during init) so hooks can connect quickly.
// Use /api/ready for full readiness check.
// Returns 200 when ready, 503 when initializing or degraded.
func (s *Service) handleHealth(w http.ResponseWriter, r *http.Request) {
status := "starting"
if s.ready.Load() {
status = "ready"
} else if err := s.GetInitError(); err != nil {
status = "error"
status := "ready"
dbStatus := "ok"
embeddingStatus := "ok"
if !s.ready.Load() {
status = "initializing"
if err := s.GetInitError(); err != nil {
status = "error"
}
}
writeJSON(w, map[string]any{
"status": status,
"version": s.version,
})
// Check embedding service
if s.embedSvc == nil {
embeddingStatus = "unavailable"
if status == "ready" {
status = "degraded"
}
}
// Check DB
if s.store == nil {
dbStatus = "unavailable"
if status == "ready" {
status = "degraded"
}
}
activeSessions := 0
if s.sessionManager != nil {
activeSessions = s.sessionManager.GetActiveSessionCount()
}
resp := map[string]any{
"status": status,
"ready": s.ready.Load(),
"uptime_seconds": int(time.Since(s.startTime).Seconds()),
"active_sessions": activeSessions,
"db_status": dbStatus,
"embedding_status": embeddingStatus,
"version": s.version,
}
w.Header().Set("Content-Type", "application/json")
if status != "ready" {
w.WriteHeader(http.StatusServiceUnavailable)
}
json.NewEncoder(w).Encode(resp)
}
// handleVersion returns the worker version for version checking.
+2 -2
View File
@@ -46,7 +46,7 @@ func (s *Service) handleGetRelationGraph(w http.ResponseWriter, r *http.Request)
// Get depth parameter (default 2)
depth := 2
if depthStr := r.URL.Query().Get("depth"); depthStr != "" {
if d, err := strconv.Atoi(depthStr); err == nil && d > 0 && d <= 5 {
if d, parseErr := strconv.Atoi(depthStr); parseErr == nil && d > 0 && d <= 5 {
depth = d
}
}
@@ -72,7 +72,7 @@ func (s *Service) handleGetRelatedObservations(w http.ResponseWriter, r *http.Re
// Get minimum confidence parameter (default 0.4)
minConfidence := 0.4
if confStr := r.URL.Query().Get("min_confidence"); confStr != "" {
if c, err := strconv.ParseFloat(confStr, 64); err == nil && c >= 0 && c <= 1 {
if c, parseErr := strconv.ParseFloat(confStr, 64); parseErr == nil && c >= 0 && c <= 1 {
minConfidence = c
}
}
+19 -27
View File
@@ -42,11 +42,9 @@ func (s *Service) handleObservationFeedback(w http.ResponseWriter, r *http.Reque
return
}
// Get required components
s.initMu.RLock()
// Get required components (initMu.RLock held by requireReady middleware)
observationStore := s.observationStore
scoreCalculator := s.scoreCalculator
s.initMu.RUnlock()
if observationStore == nil {
http.Error(w, "service not ready", http.StatusServiceUnavailable)
@@ -95,10 +93,9 @@ func (s *Service) handleObservationFeedback(w http.ResponseWriter, r *http.Reque
func (s *Service) handleGetScoringStats(w http.ResponseWriter, r *http.Request) {
project := r.URL.Query().Get("project")
s.initMu.RLock()
// initMu.RLock held by requireReady middleware
observationStore := s.observationStore
recalculator := s.recalculator
s.initMu.RUnlock()
if observationStore == nil {
http.Error(w, "service not ready", http.StatusServiceUnavailable)
@@ -130,9 +127,8 @@ func (s *Service) handleGetTopObservations(w http.ResponseWriter, r *http.Reques
limit := parseIntParam(r, "limit", 10)
project := r.URL.Query().Get("project")
s.initMu.RLock()
// initMu.RLock held by requireReady middleware
observationStore := s.observationStore
s.initMu.RUnlock()
if observationStore == nil {
http.Error(w, "service not ready", http.StatusServiceUnavailable)
@@ -158,9 +154,8 @@ func (s *Service) handleGetMostRetrieved(w http.ResponseWriter, r *http.Request)
limit := parseIntParam(r, "limit", 10)
project := r.URL.Query().Get("project")
s.initMu.RLock()
// initMu.RLock held by requireReady middleware
observationStore := s.observationStore
s.initMu.RUnlock()
if observationStore == nil {
http.Error(w, "service not ready", http.StatusServiceUnavailable)
@@ -191,10 +186,9 @@ func (s *Service) handleExplainScore(w http.ResponseWriter, r *http.Request) {
return
}
s.initMu.RLock()
// initMu.RLock held by requireReady middleware
observationStore := s.observationStore
scoreCalculator := s.scoreCalculator
s.initMu.RUnlock()
if observationStore == nil || scoreCalculator == nil {
http.Error(w, "service not ready", http.StatusServiceUnavailable)
@@ -245,10 +239,9 @@ func (s *Service) handleUpdateConceptWeight(w http.ResponseWriter, r *http.Reque
return
}
s.initMu.RLock()
// initMu.RLock held by requireReady middleware
observationStore := s.observationStore
recalculator := s.recalculator
s.initMu.RUnlock()
if observationStore == nil {
http.Error(w, "service not ready", http.StatusServiceUnavailable)
@@ -279,9 +272,8 @@ func (s *Service) handleUpdateConceptWeight(w http.ResponseWriter, r *http.Reque
// handleGetConceptWeights returns all concept weights.
// GET /api/scoring/concepts
func (s *Service) handleGetConceptWeights(w http.ResponseWriter, r *http.Request) {
s.initMu.RLock()
// initMu.RLock held by requireReady middleware
observationStore := s.observationStore
s.initMu.RUnlock()
if observationStore == nil {
http.Error(w, "service not ready", http.StatusServiceUnavailable)
@@ -300,19 +292,22 @@ func (s *Service) handleGetConceptWeights(w http.ResponseWriter, r *http.Request
// handleTriggerRecalculation triggers an immediate score recalculation.
// POST /api/scoring/recalculate
func (s *Service) handleTriggerRecalculation(w http.ResponseWriter, r *http.Request) {
s.initMu.RLock()
// initMu.RLock held by requireReady middleware
recalculator := s.recalculator
s.initMu.RUnlock()
if recalculator == nil {
http.Error(w, "recalculator not available", http.StatusServiceUnavailable)
return
}
// Run recalculation in background
// Run recalculation in background with independent context
s.wg.Add(1)
go func() {
if err := recalculator.RecalculateNow(r.Context()); err != nil {
log.Warn().Err(err).Msg("Background score recalculation failed")
defer s.wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := recalculator.RecalculateNow(ctx); err != nil {
log.Error().Err(err).Msg("Background recalculation failed")
}
}()
@@ -336,27 +331,24 @@ func (s *Service) incrementRetrievalCounts(ids []int64) {
return
}
s.initMu.RLock()
// initMu.RLock held by requireReady middleware (caller is always behind requireReady)
store := s.observationStore
s.initMu.RUnlock()
if store == nil {
return
}
// Increment in background to not block response
// Use service context to respect shutdown signals
s.wg.Add(1)
go func() {
defer s.wg.Done()
ctx, cancel := context.WithTimeout(s.ctx, 3*time.Second)
// Create a new context with timeout for the background operation
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := store.IncrementRetrievalCount(ctx, ids); err != nil {
// Log but don't fail - this is a background operation
if s.ctx.Err() == nil { // Don't log during shutdown
log.Debug().Err(err).Msg("Failed to increment retrieval counts")
}
_ = err // Explicitly ignore - background operation
}
}()
}
+48
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -44,7 +45,30 @@ func (s *Service) handleSessionInit(w http.ResponseWriter, r *http.Request) {
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
@@ -84,6 +108,24 @@ func (s *Service) handleSessionInit(w http.ResponseWriter, r *http.Request) {
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 {
@@ -210,6 +252,12 @@ func (s *Service) handleObservation(w http.ResponseWriter, r *http.Request) {
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)
+20 -9
View File
@@ -459,14 +459,13 @@ func TestHandleHealth_ReturnsVersion(t *testing.T) {
svc.handleHealth(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var response map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "ready", response["status"])
assert.Equal(t, "test-version-1.2.3", response["version"])
// Status may be "degraded" if embedSvc is nil in test, but version is always present
assert.Contains(t, []string{"ready", "degraded"}, response["status"])
}
func TestHandleVersion(t *testing.T) {
@@ -2028,13 +2027,14 @@ func TestHandleHealth_NotReady(t *testing.T) {
svc.handleHealth(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
var response map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "starting", response["status"])
assert.Equal(t, "initializing", response["status"])
assert.Equal(t, false, response["ready"])
}
// TestHandleContextInject_EmptyProject tests context inject with empty project.
@@ -2399,7 +2399,12 @@ func TestHandleHealthEndpoint(t *testing.T) {
svc.router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
// Response is valid JSON with health details
var response map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.NotNil(t, response["status"])
assert.NotNil(t, response["version"])
}
// TestHandleSelfCheckEndpoint tests self-check endpoint via router.
@@ -2894,12 +2899,18 @@ func TestHandleHealth(t *testing.T) {
svc.router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var response map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "ready", response["status"])
// Test service has store set but no embedSvc, so status is "degraded"
assert.Contains(t, []string{"ready", "degraded"}, response["status"])
assert.NotNil(t, response["version"])
assert.NotNil(t, response["uptime_seconds"])
assert.NotNil(t, response["active_sessions"])
assert.NotNil(t, response["db_status"])
assert.NotNil(t, response["embedding_status"])
assert.NotNil(t, response["ready"])
}
// TestHandleSessionInit_ValidRequest tests session init with valid request.
+161
View File
@@ -0,0 +1,161 @@
// Package sdk provides write-time observation deduplication via vector similarity.
package sdk
import (
"context"
"fmt"
"strings"
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/rs/zerolog/log"
)
// DeduplicationResult represents the outcome of a vector similarity dedup check.
type DeduplicationResult struct {
ExistingID int64
Similarity float64
Action string // "insert", "merge"
}
// checkVectorDeduplication checks if a similar observation already exists using vector similarity.
// Returns a result indicating whether to insert or merge, or an error.
// On any failure, returns Action="insert" so the caller always proceeds with storage.
func (p *Processor) checkVectorDeduplication(ctx context.Context, obs *models.ParsedObservation, project string) *DeduplicationResult {
cfg := config.Get()
if !cfg.DeduplicationEnabled {
return &DeduplicationResult{Action: "insert"}
}
if p.vectorClient == nil {
return &DeduplicationResult{Action: "insert"}
}
// Build search text from observation fields
searchText := buildObservationSearchText(obs)
if searchText == "" {
return &DeduplicationResult{Action: "insert"}
}
// Query vector DB for similar observations in the same project
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeObservation, project)
results, err := p.vectorClient.Query(ctx, searchText, 3, where)
if err != nil {
log.Debug().Err(err).Msg("Vector search failed during dedup check")
return &DeduplicationResult{Action: "insert"}
}
// Check results for high similarity
for _, r := range results {
if r.Similarity >= cfg.DeduplicationThreshold {
obsID := extractObservationIDFromVectorDoc(r)
if obsID > 0 {
return &DeduplicationResult{
ExistingID: obsID,
Similarity: r.Similarity,
Action: "merge",
}
}
}
}
return &DeduplicationResult{Action: "insert"}
}
// buildObservationSearchText creates searchable text from a parsed observation.
func buildObservationSearchText(obs *models.ParsedObservation) string {
var parts []string
if obs.Title != "" {
parts = append(parts, obs.Title)
}
if obs.Subtitle != "" {
parts = append(parts, obs.Subtitle)
}
if obs.Narrative != "" {
parts = append(parts, obs.Narrative)
}
text := strings.Join(parts, " ")
if len(text) > 2000 {
text = text[:2000]
}
return text
}
// extractObservationIDFromVectorDoc extracts the SQLite observation ID from a vector query result.
func extractObservationIDFromVectorDoc(r sqlitevec.QueryResult) int64 {
// Prefer the sqlite_id metadata field (set during vector sync)
if sqliteID, ok := r.Metadata["sqlite_id"].(float64); ok && sqliteID > 0 {
return int64(sqliteID)
}
if sqliteID, ok := r.Metadata["sqlite_id"].(int64); ok && sqliteID > 0 {
return sqliteID
}
// Fallback: parse from doc_id format "obs_{id}_composite" or "obs_{id}_narrative"
if !strings.HasPrefix(r.ID, "obs_") {
return 0
}
parts := strings.SplitN(r.ID[4:], "_", 2)
if len(parts) == 0 {
return 0
}
var id int64
fmt.Sscanf(parts[0], "%d", &id)
return id
}
// mergeObservation updates an existing observation with new information from a duplicate.
// It appends new facts, updates the narrative if the new one is longer,
// and bumps the importance score to reflect reconfirmation.
func (p *Processor) mergeObservation(ctx context.Context, existingID int64, newObs *models.ParsedObservation) error {
existing, err := p.observationStore.GetObservationByID(ctx, existingID)
if err != nil {
return fmt.Errorf("fetch existing observation %d: %w", existingID, err)
}
if existing == nil {
return fmt.Errorf("observation %d not found", existingID)
}
update := &gorm.ObservationUpdate{}
changed := false
// Merge facts: append new facts not already present
if len(newObs.Facts) > 0 {
existingFactSet := make(map[string]struct{}, len(existing.Facts))
for _, f := range existing.Facts {
existingFactSet[f] = struct{}{}
}
mergedFacts := make([]string, len(existing.Facts))
copy(mergedFacts, existing.Facts)
for _, f := range newObs.Facts {
if _, exists := existingFactSet[f]; !exists {
mergedFacts = append(mergedFacts, f)
changed = true
}
}
if changed {
update.Facts = &mergedFacts
}
}
// Update narrative if the new one is longer/more detailed
if len(newObs.Narrative) > len(existing.Narrative.String) {
update.Narrative = &newObs.Narrative
changed = true
}
if !changed {
// Nothing new to merge, but still count it as a confirmed observation
log.Debug().Int64("id", existingID).Msg("Dedup merge: no new content, skipping update")
return nil
}
_, err = p.observationStore.UpdateObservation(ctx, existingID, update)
if err != nil {
return fmt.Errorf("update observation %d: %w", existingID, err)
}
return nil
}
+143
View File
@@ -0,0 +1,143 @@
package sdk
import (
"testing"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
)
func TestBuildObservationSearchText(t *testing.T) {
tests := []struct {
name string
obs *models.ParsedObservation
expected string
}{
{
name: "empty observation",
obs: &models.ParsedObservation{},
expected: "",
},
{
name: "title only",
obs: &models.ParsedObservation{
Title: "Fix database connection",
},
expected: "Fix database connection",
},
{
name: "all fields",
obs: &models.ParsedObservation{
Title: "Fix database connection",
Subtitle: "Connection pooling issue",
Narrative: "The database connection pool was exhausted due to leaked connections.",
},
expected: "Fix database connection Connection pooling issue The database connection pool was exhausted due to leaked connections.",
},
{
name: "truncates long text",
obs: &models.ParsedObservation{
Narrative: string(make([]byte, 3000)),
},
expected: string(make([]byte, 2000)),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildObservationSearchText(tt.obs)
if result != tt.expected {
t.Errorf("got %q, want %q", result, tt.expected)
}
})
}
}
func TestExtractObservationIDFromVectorDoc(t *testing.T) {
tests := []struct {
name string
result sqlitevec.QueryResult
expected int64
}{
{
name: "from sqlite_id metadata (float64)",
result: sqlitevec.QueryResult{
ID: "obs_42_narrative",
Metadata: map[string]any{"sqlite_id": float64(42)},
},
expected: 42,
},
{
name: "from sqlite_id metadata (int64)",
result: sqlitevec.QueryResult{
ID: "obs_42_narrative",
Metadata: map[string]any{"sqlite_id": int64(42)},
},
expected: 42,
},
{
name: "fallback to doc_id parsing",
result: sqlitevec.QueryResult{
ID: "obs_99_composite",
Metadata: map[string]any{},
},
expected: 99,
},
{
name: "non-observation doc_id",
result: sqlitevec.QueryResult{
ID: "summary_5_text",
Metadata: map[string]any{},
},
expected: 0,
},
{
name: "zero sqlite_id falls back to doc_id",
result: sqlitevec.QueryResult{
ID: "obs_123_narrative",
Metadata: map[string]any{"sqlite_id": float64(0)},
},
expected: 123,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractObservationIDFromVectorDoc(tt.result)
if result != tt.expected {
t.Errorf("got %d, want %d", result, tt.expected)
}
})
}
}
func TestCheckVectorDeduplication_NilClient(t *testing.T) {
p := &Processor{
// No vectorClient set
}
obs := &models.ParsedObservation{
Title: "Test observation",
Narrative: "Some narrative text",
}
result := p.checkVectorDeduplication(nil, obs, "test-project")
if result.Action != "insert" {
t.Errorf("expected Action='insert' when vectorClient is nil, got %q", result.Action)
}
}
func TestCheckVectorDeduplication_EmptySearchText(t *testing.T) {
p := &Processor{
// vectorClient would be set but obs is empty
}
obs := &models.ParsedObservation{
// All empty fields
}
result := p.checkVectorDeduplication(nil, obs, "test-project")
if result.Action != "insert" {
t.Errorf("expected Action='insert' for empty observation, got %q", result.Action)
}
}
+68 -7
View File
@@ -19,6 +19,7 @@ import (
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/lukaszraczylo/claude-mnemonic/pkg/similarity"
"github.com/rs/zerolog/log"
@@ -194,6 +195,36 @@ func hashRequest(toolName, input, output string) string {
return hex.EncodeToString(h.Sum(nil))[:16] // Short hash is sufficient
}
// maxStdoutBytes is the maximum number of bytes to capture from CLI stdout.
const maxStdoutBytes = 1 * 1024 * 1024 // 1 MiB
// maxStderrBytes is the maximum number of bytes to capture from CLI stderr.
const maxStderrBytes = 64 * 1024 // 64 KiB
// limitedWriter wraps a bytes.Buffer and silently discards writes beyond a maximum size.
type limitedWriter struct {
buf bytes.Buffer
max int
}
// Write implements io.Writer. It writes up to the remaining capacity and silently discards the rest.
func (lw *limitedWriter) Write(p []byte) (int, error) {
remaining := lw.max - lw.buf.Len()
if remaining <= 0 {
return len(p), nil // Silently discard
}
if len(p) > remaining {
p = p[:remaining]
}
lw.buf.Write(p)
return len(p), nil
}
// String returns the buffered content as a string.
func (lw *limitedWriter) String() string {
return lw.buf.String()
}
// BroadcastFunc is a callback for broadcasting events to SSE clients.
type BroadcastFunc func(event map[string]any)
@@ -212,6 +243,7 @@ const MaxVectorSyncWorkers = 8
type Processor struct {
observationStore *gorm.ObservationStore
summaryStore *gorm.SummaryStore
vectorClient *sqlitevec.Client
broadcastFunc BroadcastFunc
syncObservationFunc SyncObservationFunc
syncSummaryFunc SyncSummaryFunc
@@ -240,6 +272,11 @@ func (p *Processor) SetSyncSummaryFunc(fn SyncSummaryFunc) {
p.syncSummaryFunc = fn
}
// SetVectorClient sets the vector client for write-time deduplication.
func (p *Processor) SetVectorClient(client *sqlitevec.Client) {
p.vectorClient = client
}
// broadcast sends an event via the broadcast callback if set.
func (p *Processor) broadcast(event map[string]any) {
if p.broadcastFunc != nil {
@@ -429,16 +466,34 @@ func (p *Processor) ProcessObservation(ctx context.Context, sdkSessionID, projec
// Convert to stored observation for similarity check
storedObs := obs.ToStoredObservation()
// Check if this observation is too similar to existing ones
// Check if this observation is too similar to existing ones (text-based Jaccard)
if existingObs != nil && similarity.IsSimilarToAny(storedObs, existingObs, similarityThreshold) {
log.Debug().
Str("type", string(obs.Type)).
Str("title", obs.Title).
Msg("Skipping observation - too similar to existing")
Msg("Skipping observation - too similar to existing (text)")
skippedCount++
continue
}
// Check vector similarity for high-confidence dedup with merge
dedupResult := p.checkVectorDeduplication(ctx, obs, project)
if dedupResult.Action == "merge" {
log.Info().
Int64("existing_id", dedupResult.ExistingID).
Float64("similarity", dedupResult.Similarity).
Str("title", obs.Title).
Msg("Merging duplicate observation (vector dedup)")
if err := p.mergeObservation(ctx, dedupResult.ExistingID, obs); err != nil {
log.Warn().Err(err).Int64("existing_id", dedupResult.ExistingID).
Msg("Merge failed, inserting as new observation")
// Fall through to normal insert
} else {
skippedCount++
continue
}
}
id, createdAtEpoch, err := p.observationStore.StoreObservation(ctx, sdkSessionID, project, obs, promptNumber, 0)
if err != nil {
log.Error().Err(err).Msg("Failed to store observation")
@@ -641,13 +696,19 @@ func (p *Processor) callClaudeCLI(ctx context.Context, prompt string) (string, e
// (hooks are triggered based on working directory)
cmd.Dir = "/tmp"
// Disable any plugin hooks by setting an env var that our hooks can check
// Disable any plugin hooks by setting an env var that our hooks can check.
// NOTE: This env var is NOT reliably propagated to hook subprocesses because
// Claude Code constructs its own environment for hooks. The server-side check
// in handleSessionInit (handlers_sessions.go) provides the reliable guard by
// detecting the system prompt signature in the prompt text. This env var is
// kept as a best-effort defense-in-depth measure for cases where it does work.
cmd.Env = append(os.Environ(), "CLAUDE_MNEMONIC_INTERNAL=1")
// Capture output
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
// Capture output with size limits to prevent unbounded memory usage
stdout := &limitedWriter{max: maxStdoutBytes}
stderr := &limitedWriter{max: maxStderrBytes}
cmd.Stdout = stdout
cmd.Stderr = stderr
// Run command
err := cmd.Run()
+9 -3
View File
@@ -49,6 +49,10 @@ func BuildObservationPrompt(exec ToolExecution) string {
inputJSON, _ := json.MarshalIndent(toolInput, " ", " ")
outputJSON, _ := json.MarshalIndent(toolOutput, " ", " ")
// Strip system XML artifacts from tool data before building prompt
cleanInput := StripSystemXML(string(inputJSON))
cleanOutput := StripSystemXML(string(outputJSON))
timestamp := time.UnixMilli(exec.CreatedAtEpoch).Format(time.RFC3339)
var sb strings.Builder
@@ -58,8 +62,8 @@ func BuildObservationPrompt(exec ToolExecution) string {
if exec.CWD != "" {
sb.WriteString(fmt.Sprintf(" <working_directory>%s</working_directory>\n", exec.CWD))
}
sb.WriteString(fmt.Sprintf(" <parameters>%s</parameters>\n", truncate(string(inputJSON), 3000)))
sb.WriteString(fmt.Sprintf(" <outcome>%s</outcome>\n", truncate(string(outputJSON), 5000)))
sb.WriteString(fmt.Sprintf(" <parameters>%s</parameters>\n", truncate(cleanInput, 3000)))
sb.WriteString(fmt.Sprintf(" <outcome>%s</outcome>\n", truncate(cleanOutput, 5000)))
sb.WriteString("</observed_from_primary_session>")
return sb.String()
@@ -84,8 +88,10 @@ func BuildSummaryPrompt(req SummaryRequest) string {
sb.WriteString("Write progress notes of what was done, what was learned, and what's next. This is a checkpoint to capture progress so far. The session is ongoing - you may receive more requests and tool executions after this summary. Write \"next_steps\" as the current trajectory of work (what's actively being worked on or coming up next), not as post-session future work. Always write at least a minimal summary explaining current progress, even if work is still in early stages, so that users see a summary output tied to each request.\n\n")
if req.LastAssistantMessage != "" {
// Strip system XML artifacts from captured transcript content
cleanMsg := StripSystemXML(req.LastAssistantMessage)
sb.WriteString("Claude's Full Response to User:\n")
sb.WriteString(truncate(req.LastAssistantMessage, 4000))
sb.WriteString(truncate(cleanMsg, 4000))
sb.WriteString("\n\n")
}
+8
View File
@@ -0,0 +1,8 @@
// Package sdk provides SDK agent integration for claude-mnemonic.
package sdk
import "github.com/lukaszraczylo/claude-mnemonic/pkg/sanitize"
// StripSystemXML removes known Claude Code internal XML blocks from text.
// Delegates to the shared sanitize package.
var StripSystemXML = sanitize.StripSystemXML
+65 -22
View File
@@ -8,6 +8,7 @@ import (
"os"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/go-chi/chi/v5"
@@ -43,6 +44,13 @@ const (
// QueueProcessInterval is how often the background queue processor runs.
QueueProcessInterval = 2 * time.Second
// reinitializationDrainDelay is the delay after marking the service as not ready
// to allow in-flight requests to complete before reinitializing.
reinitializationDrainDelay = 200 * time.Millisecond
// MaxConcurrentProcessing limits the number of concurrent session processing goroutines.
MaxConcurrentProcessing = 4
// VectorSyncMaxRetries is the maximum number of retries for vector sync operations.
VectorSyncMaxRetries = 3
@@ -138,6 +146,7 @@ type Service struct {
updater *update.Updater
rateLimiter *PerClientRateLimiter
expensiveOpLimiter *ExpensiveOperationLimiter
contextCache sync.Map
version string
recentQueriesBuf [maxRecentQueries]RecentSearchQuery
wg sync.WaitGroup
@@ -178,6 +187,13 @@ type staleVerifyRequest struct {
observationID int64
}
// contextCacheEntry caches clustering results for context injection.
type contextCacheEntry struct {
timestamp time.Time
observations []*models.Observation
obsCount int
}
// RecentSearchQuery tracks a search query for analytics.
type RecentSearchQuery struct {
Timestamp time.Time `json:"timestamp"`
@@ -288,6 +304,11 @@ func (s *Service) setupVectorSyncCallbacks(
})
}
// Set vector client on processor for write-time deduplication
if processor != nil && s.vectorClient != nil {
processor.SetVectorClient(s.vectorClient)
}
// Set cleanup callback on observation store to sync deletes to vector store
if observationStore != nil && vectorSync != nil {
observationStore.SetCleanupFunc(func(ctx context.Context, deletedIDs []int64) {
@@ -346,9 +367,9 @@ func NewService(version string) (*Service, error) {
router := chi.NewRouter()
sseBroadcaster := sse.NewBroadcaster()
// Determine install directory (plugin location)
// Determine install directory (stable binary location, survives Claude Code updates)
homeDir, _ := os.UserHomeDir()
installDir := fmt.Sprintf("%s/.claude/plugins/marketplaces/claude-mnemonic", homeDir)
installDir := fmt.Sprintf("%s/.claude-mnemonic/bin", homeDir)
// Create rate limiter with generous limits (100 req/sec, burst of 200)
// These limits are per-client and allow for intensive CLI usage
@@ -614,6 +635,7 @@ func (s *Service) startWatchers() {
func (s *Service) reinitializeDatabase() {
// Block new requests
s.ready.Store(false)
time.Sleep(reinitializationDrainDelay) // Allow in-flight requests to complete
log.Info().Msg("Database reinitialization starting...")
// Get old store references
@@ -715,10 +737,14 @@ func (s *Service) reinitializeDatabase() {
log.Info().Msg("Query expansion reconnected after reinit")
}
// Close old reranker if exists
// Close old vector client and reranker before swapping
s.initMu.RLock()
oldVectorClient := s.vectorClient
oldReranker := s.reranker
s.initMu.RUnlock()
if oldVectorClient != nil {
_ = oldVectorClient.Close()
}
if oldReranker != nil {
_ = oldReranker.Close()
}
@@ -794,8 +820,11 @@ func (s *Service) reloadConfig() {
// Give SSE clients a moment to receive the message
time.Sleep(100 * time.Millisecond)
// Exit cleanly - hooks will restart us with new config
os.Exit(0)
// Send SIGTERM to self for graceful shutdown (hooks will restart us)
p, err := os.FindProcess(os.Getpid())
if err == nil {
_ = p.Signal(syscall.SIGTERM)
}
}
// setInitError records an initialization error.
@@ -1571,15 +1600,17 @@ func (s *Service) processQueue() {
ticker := time.NewTicker(QueueProcessInterval)
defer ticker.Stop()
s.initMu.RLock()
notify := s.sessionManager.ProcessNotify
s.initMu.RUnlock()
for {
select {
case <-s.ctx.Done():
return
case <-s.sessionManager.ProcessNotify:
// Immediate processing when observation is queued
case <-notify:
s.processAllSessions()
case <-ticker.C:
// Fallback periodic processing
s.processAllSessions()
}
}
@@ -1587,30 +1618,38 @@ func (s *Service) processQueue() {
// processAllSessions processes pending messages for all active sessions.
// Messages are processed in parallel using goroutines, with concurrency
// limited by the processor's semaphore.
// limited by a channel-based semaphore.
func (s *Service) processAllSessions() {
// Get all sessions with pending messages
sessions := s.sessionManager.GetAllSessions()
s.initMu.RLock()
mgr := s.sessionManager
proc := s.processor
s.initMu.RUnlock()
if mgr == nil || proc == nil {
return
}
sessions := mgr.GetAllSessions()
var wg sync.WaitGroup
sem := make(chan struct{}, MaxConcurrentProcessing)
for _, sess := range sessions {
// Get pending messages
messages := s.sessionManager.DrainMessages(sess.SessionDBID)
messages := mgr.DrainMessages(sess.SessionDBID)
if len(messages) == 0 {
continue
}
// Process each message in a goroutine
for _, msg := range messages {
wg.Add(1)
sem <- struct{}{}
go func(sess *session.ActiveSession, msg session.PendingMessage) {
defer wg.Done()
defer func() { <-sem }()
switch msg.Type {
case session.MessageTypeObservation:
if msg.Observation != nil {
err := s.processor.ProcessObservation(
err := proc.ProcessObservation(
s.ctx,
sess.SDKSessionID,
sess.Project,
@@ -1629,7 +1668,7 @@ func (s *Service) processAllSessions() {
case session.MessageTypeSummarize:
if msg.Summarize != nil {
err := s.processor.ProcessSummary(
err := proc.ProcessSummary(
s.ctx,
sess.SessionDBID,
sess.SDKSessionID,
@@ -1643,18 +1682,15 @@ func (s *Service) processAllSessions() {
Int64("sessionId", sess.SessionDBID).
Msg("Failed to process summary")
}
// Delete session after summary
s.sessionManager.DeleteSession(sess.SessionDBID)
mgr.DeleteSession(sess.SessionDBID)
}
}
}(sess, msg)
}
}
// Wait for all goroutines to complete
wg.Wait()
// Broadcast status after processing
s.broadcastProcessingStatus()
}
@@ -1763,8 +1799,15 @@ func (s *Service) Shutdown(ctx context.Context) error {
// broadcastProcessingStatus broadcasts the current processing status.
func (s *Service) broadcastProcessingStatus() {
isProcessing := s.sessionManager.IsAnySessionProcessing()
queueDepth := s.sessionManager.GetTotalQueueDepth()
s.initMu.RLock()
mgr := s.sessionManager
s.initMu.RUnlock()
if mgr == nil {
return
}
isProcessing := mgr.IsAnySessionProcessing()
queueDepth := mgr.GetTotalQueueDepth()
s.sseBroadcaster.Broadcast(map[string]any{
"type": "processing_status",
+4
View File
@@ -75,6 +75,7 @@ type Manager struct {
onDeleted func(int64)
cancel context.CancelFunc
ProcessNotify chan struct{}
wg sync.WaitGroup
mu sync.RWMutex
}
@@ -89,12 +90,14 @@ func NewManager(sessionStore *gorm.SessionStore) *Manager {
ProcessNotify: make(chan struct{}, 1),
}
// Start background cleanup goroutine
m.wg.Add(1)
go m.cleanupLoop()
return m
}
// cleanupLoop periodically removes stale sessions.
func (m *Manager) cleanupLoop() {
defer m.wg.Done()
ticker := time.NewTicker(CleanupInterval)
defer ticker.Stop()
@@ -350,6 +353,7 @@ func (m *Manager) DeleteSession(sessionDBID int64) {
func (m *Manager) ShutdownAll(ctx context.Context) {
// Stop cleanup goroutine
m.cancel()
m.wg.Wait()
m.mu.Lock()
sessionIDs := make([]int64, 0, len(m.sessions))
+1
View File
@@ -952,6 +952,7 @@ func TestCleanupLoop_ExitsOnCancel(t *testing.T) {
// Start cleanup loop in goroutine
done := make(chan struct{})
manager.wg.Add(1)
go func() {
manager.cleanupLoop()
close(done)
+1 -1
View File
@@ -212,7 +212,7 @@ func (b *Broadcaster) HandleSSE(w http.ResponseWriter, r *http.Request) {
defer b.RemoveClient(client)
// Send initial connection message
fmt.Fprintf(w, "data: {\"type\":\"connected\",\"clientId\":\"%s\"}\n\n", client.ID)
_, _ = fmt.Fprintf(w, "data: {\"type\":\"connected\",\"clientId\":\"%s\"}\n\n", client.ID)
client.Flusher.Flush()
// Wait for client disconnect
+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.11.25",
"version": "0.11.113",
"author": {
"name": "lukaszraczylo",
"url": "https://github.com/lukaszraczylo"
@@ -23,25 +23,25 @@
"license": "MIT",
"homepage": "https://github.com/lukaszraczylo/claude-mnemonic",
"releases": {
"latest": "0.11.25",
"latest": "0.11.113",
"versions": {
"0.11.25": {
"releaseDate": "2026-02-06",
"0.11.113": {
"releaseDate": "2026-05-24",
"downloads": {
"darwin-arm64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.25/claude-mnemonic_0.11.25_darwin_arm64.tar.gz",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.113/claude-mnemonic_0.11.113_darwin_arm64.tar.gz",
"format": "tar.gz",
"sha256": "4b4767ca4b3d58a19e78cdc4119bbb76cfcc6edbbce37cbf3912c2350b84e569"
"sha256": "2f57716b2570d91cc2eb71ec0cb682d354aacaf0371f38a20a66f30105ec0b6b"
},
"linux-amd64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.25/claude-mnemonic_0.11.25_linux_amd64.tar.gz",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.113/claude-mnemonic_0.11.113_linux_amd64.tar.gz",
"format": "tar.gz",
"sha256": "a1d33cff01bd13db6e87cd4c76350af4b4c8079b1d1fbab5f3b95af03f28a0f5"
"sha256": "6f10bcdfc811b78c05ab09f14757933f3c5e10b238a8f4047b9796075124b0a0"
},
"windows-amd64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.25/claude-mnemonic_0.11.25_windows_amd64.zip",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.113/claude-mnemonic_0.11.113_windows_amd64.zip",
"format": "zip",
"sha256": "f0b3590d1907d23377d10f2ade2d33f3cc34c275de7df396880e73a569af710d"
"sha256": "ce05d7ea33d6e68ed11d834203454c7490bd825a21c2ed0be253b77ff0936939"
}
}
}
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
BIN="$HOME/.claude-mnemonic/bin/mcp-server"
[ -x "$BIN" ] && exec "$BIN" "$@"
echo "claude-mnemonic: mcp-server not found at $BIN" >&2
echo "Install: cd $(dirname "$0") && make install" >&2
exit 1
+11 -1
View File
@@ -2,6 +2,7 @@
package hooks
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
@@ -9,6 +10,7 @@ import (
"io"
"os"
"path/filepath"
"time"
)
// HookResponse is the response sent back to Claude Code.
@@ -31,6 +33,14 @@ func ProjectIDWithName(cwd string) string {
return fmt.Sprintf("%s_%s", dirName, shortHash)
}
// HookDeadline returns a context with the hook's timeout budget minus a safety margin.
// This ensures hooks return gracefully before Claude kills them.
func HookDeadline(timeout time.Duration) (context.Context, context.CancelFunc) {
// Use 80% of the timeout to leave margin for response serialization
safeTimeout := time.Duration(float64(timeout) * 0.8)
return context.WithTimeout(context.Background(), safeTimeout)
}
// Exit codes for Claude Code hooks
const (
ExitSuccess = 0
@@ -92,7 +102,7 @@ func RunHook[T any](hookName string, handler HookHandler[T]) {
// Parse input
var input T
if err := json.Unmarshal(inputData, &input); err != nil {
if err = json.Unmarshal(inputData, &input); err != nil {
WriteError(hookName, err)
os.Exit(1)
}
+263 -19
View File
@@ -3,6 +3,7 @@ package hooks
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
@@ -12,6 +13,8 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
@@ -22,13 +25,53 @@ const (
// DefaultWorkerPort is the default worker port.
DefaultWorkerPort = 37777
// HealthCheckTimeout is the timeout for health checks (reduced from 5s for faster startup).
HealthCheckTimeout = 1 * time.Second
// HealthCheckTimeout is the timeout for health checks.
HealthCheckTimeout = 2 * time.Second
// StartupTimeout is the timeout for worker startup.
StartupTimeout = 30 * time.Second
// workerCacheMaxAge is how long the worker cache is considered fresh.
workerCacheMaxAge = 10 * time.Second
// circuitBreakerCooldown is how long to wait after a startup failure before retrying.
circuitBreakerCooldown = 30 * time.Second
// healthCheckRetries is the number of health check attempts before declaring dead.
healthCheckRetries = 3
// healthCheckRetryDelay is the delay between health check retries.
healthCheckRetryDelay = 200 * time.Millisecond
)
var (
// circuitBreakerMu protects lastStartupFailure.
circuitBreakerMu sync.Mutex
lastStartupFailure time.Time
)
// IsWorkerAvailable performs a fast check without network calls.
// Returns true if the worker is likely available, false if definitely down.
func IsWorkerAvailable() bool {
// Check circuit breaker first
circuitBreakerMu.Lock()
if !lastStartupFailure.IsZero() && time.Since(lastStartupFailure) < circuitBreakerCooldown {
circuitBreakerMu.Unlock()
return false
}
circuitBreakerMu.Unlock()
// Check PID cache
entry := readWorkerCache()
if entry == nil {
return true // No cache = unknown, don't block
}
// Cache exists and is fresh (readWorkerCache already checks staleness)
// Check if cached process is alive
return isProcessAlive(entry.PID)
}
// GetWorkerPort returns the worker port from environment or default.
func GetWorkerPort() int {
if port := os.Getenv("CLAUDE_MNEMONIC_WORKER_PORT"); port != "" {
@@ -40,29 +83,149 @@ func GetWorkerPort() int {
}
// IsWorkerRunning checks if the worker is running and healthy.
// Parses the JSON health response to check the "ready" field when available.
// Falls back to HTTP status code check for backwards compatibility.
func IsWorkerRunning(port int) bool {
client := &http.Client{Timeout: HealthCheckTimeout}
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/api/health", port))
if err != nil {
return false
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
// Try to parse JSON response for structured health check
var health struct {
Ready bool `json:"ready"`
}
if err := json.NewDecoder(resp.Body).Decode(&health); err == nil {
return health.Ready
}
// Fallback: treat HTTP 200 as healthy (backwards compatibility)
return resp.StatusCode == http.StatusOK
}
// workerCachePath returns the path to the worker cache file.
func workerCachePath() string {
home := os.Getenv("HOME")
if home == "" {
return ""
}
return filepath.Join(home, ".claude-mnemonic", ".worker-cache")
}
// workerCacheEntry holds cached worker state: "port:pid:timestamp".
type workerCacheEntry struct {
Timestamp time.Time
Port int
PID int
}
// readWorkerCache reads the worker cache file and returns the entry if fresh.
func readWorkerCache() *workerCacheEntry {
path := workerCachePath()
if path == "" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
parts := strings.SplitN(strings.TrimSpace(string(data)), ":", 3)
if len(parts) != 3 {
return nil
}
port, err := strconv.Atoi(parts[0])
if err != nil || port <= 0 {
return nil
}
pid, err := strconv.Atoi(parts[1])
if err != nil || pid <= 0 {
return nil
}
ts, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return nil
}
entry := &workerCacheEntry{
Port: port,
PID: pid,
Timestamp: time.Unix(ts, 0),
}
// Check freshness
if time.Since(entry.Timestamp) > workerCacheMaxAge {
return nil
}
return entry
}
// writeWorkerCache writes the worker cache file.
func writeWorkerCache(port, pid int) {
path := workerCachePath()
if path == "" {
return
}
// Ensure directory exists
dir := filepath.Dir(path)
_ = os.MkdirAll(dir, 0o700)
data := fmt.Sprintf("%d:%d:%d", port, pid, time.Now().Unix())
_ = os.WriteFile(path, []byte(data), 0o600)
}
// isProcessAlive checks if a process with the given PID exists and is alive.
func isProcessAlive(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
// Signal 0 checks if process exists without actually sending a signal.
err = proc.Signal(syscall.Signal(0))
return err == nil
}
// isWorkerRunningWithRetries checks if the worker is running, retrying on timeout.
// Returns true only if health check succeeds. Returns false if all retries fail.
func isWorkerRunningWithRetries(port int) bool {
for i := 0; i < healthCheckRetries; i++ {
if IsWorkerRunning(port) {
return true
}
if i < healthCheckRetries-1 {
time.Sleep(healthCheckRetryDelay)
}
}
return false
}
// EnsureWorkerRunning ensures the worker is running, starting it if necessary.
// If a worker is already running and healthy with matching version, it reuses it.
// If version mismatch or unhealthy, it kills the old worker and starts fresh.
func EnsureWorkerRunning() (int, error) {
port := GetWorkerPort()
// Check if already running and healthy
if IsWorkerRunning(port) {
// Fast path: check PID cache before making any HTTP calls.
if entry := readWorkerCache(); entry != nil && entry.Port == port {
if isProcessAlive(entry.PID) {
return port, nil
}
}
// Circuit breaker: if we failed to start recently, don't retry immediately.
circuitBreakerMu.Lock()
if !lastStartupFailure.IsZero() && time.Since(lastStartupFailure) < circuitBreakerCooldown {
circuitBreakerMu.Unlock()
return 0, fmt.Errorf("worker startup failed recently (circuit breaker open, retry after %v)", circuitBreakerCooldown-time.Since(lastStartupFailure))
}
circuitBreakerMu.Unlock()
// Check if already running and healthy (with retries to avoid false negatives under load)
if isWorkerRunningWithRetries(port) {
// Check version - if mismatch, restart (unless both are dev builds)
if runningVersion := GetWorkerVersion(port); runningVersion != "" {
if runningVersion != Version {
// For dev/dirty builds, don't restart if base versions match
if versionsCompatible(runningVersion, Version) {
updateCacheFromPort(port)
return port, nil
}
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Worker version mismatch (running: %s, expected: %s), restarting...\n", runningVersion, Version)
@@ -72,23 +235,34 @@ func EnsureWorkerRunning() (int, error) {
time.Sleep(500 * time.Millisecond)
} else {
// Version matches, reuse existing worker
updateCacheFromPort(port)
return port, nil
}
} else {
// Couldn't get version, assume it's fine
updateCacheFromPort(port)
return port, nil
}
}
// Check if port is in use but worker is unhealthy
// Port is in use but health check failed -- worker may be slow, not dead.
if IsPortInUse(port) {
// Something is using the port but not responding to health checks
// Try to kill it
// The port is responding to TCP but health check timed out.
// Don't kill it -- it's likely just under load. Give it more time.
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Worker on port %d is slow to respond, waiting...\n", port)
// Try a few more times with longer delays before giving up
for i := 0; i < 3; i++ {
time.Sleep(500 * time.Millisecond)
if IsWorkerRunning(port) {
updateCacheFromPort(port)
return port, nil
}
}
// Still not healthy after extended wait -- kill and restart
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Worker unresponsive after extended wait, restarting...\n")
if err := KillProcessOnPort(port); err != nil {
// Log but continue - maybe it will die on its own
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Warning: failed to kill unhealthy process on port %d: %v\n", port, err)
}
// Wait a moment for port to be released
time.Sleep(500 * time.Millisecond)
}
@@ -103,9 +277,14 @@ func EnsureWorkerRunning() (int, error) {
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
circuitBreakerMu.Lock()
lastStartupFailure = time.Now()
circuitBreakerMu.Unlock()
return 0, fmt.Errorf("failed to start worker: %w", err)
}
pid := cmd.Process.Pid
// Wait for worker to be ready with exponential backoff
deadline := time.Now().Add(StartupTimeout)
backoff := 50 * time.Millisecond
@@ -113,6 +292,7 @@ func EnsureWorkerRunning() (int, error) {
for time.Now().Before(deadline) {
if IsWorkerRunning(port) {
writeWorkerCache(port, pid)
return port, nil
}
time.Sleep(backoff)
@@ -123,9 +303,31 @@ func EnsureWorkerRunning() (int, error) {
}
}
circuitBreakerMu.Lock()
lastStartupFailure = time.Now()
circuitBreakerMu.Unlock()
return 0, fmt.Errorf("worker failed to start within timeout")
}
// updateCacheFromPort finds the PID of the process on the port and updates the cache.
func updateCacheFromPort(port int) {
cmd := exec.Command("lsof", "-t", "-i", fmt.Sprintf(":%d", port)) // #nosec G204 -- port is from internal config
output, err := cmd.Output()
if err != nil {
return
}
pidStr := strings.TrimSpace(string(output))
// Take first PID if multiple
if idx := strings.Index(pidStr, "\n"); idx > 0 {
pidStr = pidStr[:idx]
}
pid, err := strconv.Atoi(pidStr)
if err != nil || pid <= 0 {
return
}
writeWorkerCache(port, pid)
}
// GetWorkerVersion gets the version of the running worker.
func GetWorkerVersion(port int) string {
client := &http.Client{Timeout: HealthCheckTimeout}
@@ -133,7 +335,7 @@ func GetWorkerVersion(port int) string {
if err != nil {
return ""
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return ""
@@ -195,7 +397,15 @@ func KillProcessOnPort(port int) error {
// findWorkerBinary finds the worker binary path.
func findWorkerBinary() string {
// Check CLAUDE_PLUGIN_ROOT first (set by Claude Code when running hooks)
home := os.Getenv("HOME")
// Stable binary location (primary, survives Claude Code updates)
stablePath := filepath.Join(home, ".claude-mnemonic", "bin", "worker")
if _, err := os.Stat(stablePath); err == nil {
return stablePath
}
// Check CLAUDE_PLUGIN_ROOT (set by Claude Code when running hooks)
if pluginRoot := os.Getenv("CLAUDE_PLUGIN_ROOT"); pluginRoot != "" {
workerPath := filepath.Join(pluginRoot, "worker")
if _, err := os.Stat(workerPath); err == nil {
@@ -204,12 +414,9 @@ func findWorkerBinary() string {
}
// Check common locations
home := os.Getenv("HOME")
locations := []string{
"./worker",
"./bin/worker",
filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic/1.0.0/worker"),
filepath.Join(home, ".claude/plugins/marketplaces/claude-mnemonic/worker"),
}
for _, loc := range locations {
@@ -218,6 +425,18 @@ func findWorkerBinary() string {
}
}
// Try cache directory with any version
matches, _ := filepath.Glob(filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic/*/worker"))
if len(matches) > 0 {
return matches[len(matches)-1]
}
// Try marketplaces directory
marketplacePath := filepath.Join(home, ".claude/plugins/marketplaces/claude-mnemonic/worker")
if _, err := os.Stat(marketplacePath); err == nil {
return marketplacePath
}
// Try PATH
if path, err := exec.LookPath("claude-mnemonic-worker"); err == nil {
return path
@@ -243,7 +462,7 @@ func POST(port int, path string, body interface{}) (map[string]interface{}, erro
if err != nil {
return nil, err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("request failed: %s", resp.Status)
@@ -251,13 +470,38 @@ func POST(port int, path string, body interface{}) (map[string]interface{}, erro
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
// Not all endpoints return JSON body - return empty map for success with no body
return map[string]interface{}{}, nil
// Not all endpoints return JSON
return nil, nil
}
return result, nil
}
// POSTWithContext sends a POST request using the provided context.
// Used for fire-and-forget calls where we want to control the timeout externally.
func POSTWithContext(ctx context.Context, port int, path string, body interface{}) error {
jsonBody, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
fmt.Sprintf("http://127.0.0.1:%d%s", port, path),
bytes.NewReader(jsonBody))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
return nil
}
// GET sends a GET request to the worker.
func GET(port int, path string) (map[string]interface{}, error) {
client := &http.Client{Timeout: 10 * time.Second}
@@ -266,7 +510,7 @@ func GET(port int, path string) (map[string]interface{}, error) {
if err != nil {
return nil, err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("request failed: %s", resp.Status)
+1 -1
View File
@@ -517,7 +517,7 @@ func TestProjectIDWithName_Uniqueness(t *testing.T) {
// TestHookConstants tests hook-related constants.
func TestHookConstants(t *testing.T) {
assert.Equal(t, 37777, DefaultWorkerPort)
assert.Equal(t, 1*time.Second, HealthCheckTimeout)
assert.Equal(t, 2*time.Second, HealthCheckTimeout)
assert.Equal(t, 30*time.Second, StartupTimeout)
}
+63
View File
@@ -0,0 +1,63 @@
// Package sanitize provides content cleaning utilities for stripping
// Claude Code internal XML artifacts from captured text.
package sanitize
import (
"regexp"
"strings"
)
// systemXMLTags lists Claude Code internal XML tags that should be stripped
// from captured content before processing. These are system-level artifacts
// that pollute observations and summaries when stored.
var systemXMLTags = []string{
// Claude Code task/agent system
"task-notification",
// System reminders injected by Claude Code
"system-reminder",
// Claude-mnemonic's own context injection
"relevant-memory",
// Hook output wrappers
"user-prompt-submit-hook",
// Large output persistence
"persisted-output",
// Tool loading system
"available-deferred-tools",
// Fast mode info
"fast_mode_info",
// Anthropic internal
"antml_thinking",
"antml_function_calls",
}
// systemXMLRegexps are compiled regexps for each tag, built once at init.
var systemXMLRegexps []*regexp.Regexp
func init() {
systemXMLRegexps = make([]*regexp.Regexp, len(systemXMLTags))
for i, tag := range systemXMLTags {
// Match opening tag (with optional attributes), content (including newlines), and closing tag
systemXMLRegexps[i] = regexp.MustCompile(`(?s)<` + regexp.QuoteMeta(tag) + `[^>]*>.*?</` + regexp.QuoteMeta(tag) + `>`)
}
}
// StripSystemXML removes known Claude Code internal XML blocks from text.
// This prevents system artifacts like <task-notification>, <system-reminder>,
// and <relevant-memory> from being stored in observations and summaries.
func StripSystemXML(s string) string {
// Quick check: if no angle brackets, nothing to strip
if !strings.Contains(s, "<") {
return s
}
for _, re := range systemXMLRegexps {
s = re.ReplaceAllString(s, "")
}
// Clean up resulting double-blank-lines from removed blocks
for strings.Contains(s, "\n\n\n") {
s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
}
return strings.TrimSpace(s)
}
+113
View File
@@ -0,0 +1,113 @@
package sanitize
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestStripSystemXML(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "no XML tags",
input: "Hello, this is plain text with no XML.",
expected: "Hello, this is plain text with no XML.",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "task-notification block",
input: "Before\n<task-notification>\n<task-id>abc123</task-id>\n<status>completed</status>\n<summary>Agent completed</summary>\n<result>Some result text</result>\n</task-notification>\nAfter",
expected: "Before\n\nAfter",
},
{
name: "system-reminder block",
input: "Content before\n<system-reminder>\nSome system reminder text\n</system-reminder>\nContent after",
expected: "Content before\n\nContent after",
},
{
name: "relevant-memory block",
input: "<relevant-memory>\n# Relevant Knowledge\n## 1. Something\n</relevant-memory>\nActual content",
expected: "Actual content",
},
{
name: "system-reminder with attributes",
input: "Before <system-reminder type=\"hook\">reminder text</system-reminder> After",
expected: "Before After",
},
{
name: "multiple different tags",
input: "Start\n<task-notification><status>done</status></task-notification>\nMiddle\n<system-reminder>reminder</system-reminder>\nEnd",
expected: "Start\n\nMiddle\n\nEnd",
},
{
name: "persisted-output block",
input: "Result: <persisted-output>\nLarge output stored at: /tmp/foo\n</persisted-output> done",
expected: "Result: done",
},
{
name: "available-deferred-tools block",
input: "<available-deferred-tools>\nTool1\nTool2\n</available-deferred-tools>\nUser message",
expected: "User message",
},
{
name: "fast_mode_info block",
input: "Text <fast_mode_info>\nFast mode uses same model\n</fast_mode_info> more text",
expected: "Text more text",
},
{
name: "preserves non-system XML",
input: "<observation><type>bugfix</type><title>Fixed thing</title></observation>",
expected: "<observation><type>bugfix</type><title>Fixed thing</title></observation>",
},
{
name: "no angle brackets fast path",
input: "Just plain text without any special characters",
expected: "Just plain text without any special characters",
},
{
name: "collapses triple newlines",
input: "Before\n<system-reminder>x</system-reminder>\n\n\nAfter",
expected: "Before\n\nAfter",
},
{
name: "real-world task notification",
input: "Here is the analysis:\n<task-notification>\n<task-id>a077fd04aed547ce1</task-id>\n<tool-use-id>toolu_01Kw2GwsArQQR1F9aV3VfzhP</tool-use-id>\n<status>completed</status>\n<summary>Agent \"Analyze agency-agents repo structure\" completed</summary>\n<result>Now I have all the information I need. Let me compile a comprehensive report.\n\n## Agency-Agents Directory Exploration Report\n\n### 1. Agent File Count and Organization\n\n**Total Agent Files (excluding strategy docs):** 61 agents across 9 categories</result>\n</task-notification>\nThe repo has 61 agents.",
expected: "Here is the analysis:\n\nThe repo has 61 agents.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := StripSystemXML(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func BenchmarkStripSystemXML(b *testing.B) {
// Text with no XML (fast path)
b.Run("no_xml", func(b *testing.B) {
text := "This is plain text without any XML tags at all."
b.ResetTimer()
for i := 0; i < b.N; i++ {
StripSystemXML(text)
}
})
// Text with system XML to strip
b.Run("with_xml", func(b *testing.B) {
text := "Before\n<task-notification>\n<task-id>abc</task-id>\n<status>done</status>\n</task-notification>\n<system-reminder>reminder text here</system-reminder>\nAfter"
b.ResetTimer()
for i := 0; i < b.N; i++ {
StripSystemXML(text)
}
})
}
+10 -1
View File
@@ -5,5 +5,14 @@
"author": {
"name": "lukaszraczylo",
"email": "[email protected]"
}
},
"hooks": "./hooks/hooks.json",
"mcpServers": {
"claude-mnemonic": {
"command": "${CLAUDE_PLUGIN_ROOT}/mcp-server",
"args": ["--project", "${CLAUDE_PROJECT}"],
"env": {}
}
},
"commands": ["./commands/restart.md"]
}
+1 -1
View File
@@ -6,7 +6,7 @@
set -e
ONNX_VERSION="1.23.2"
ONNX_VERSION="1.26.0"
ASSETS_DIR="internal/embedding/assets/lib"
PLATFORM="${1:-all}"
FORCE_DOWNLOAD=false
+138 -85
View File
@@ -7,6 +7,8 @@
set -e
INSTALLER_VERSION="1.1.0"
# Configuration
GITHUB_REPO="lukaszraczylo/claude-mnemonic"
INSTALL_DIR="$HOME/.claude/plugins/marketplaces/claude-mnemonic"
@@ -40,6 +42,50 @@ error() {
exit 1
}
# Gracefully stop worker processes (SIGTERM first, then SIGKILL after timeout)
graceful_stop_worker() {
# Send SIGTERM first
pkill -TERM -f 'claude-mnemonic.*worker' 2>/dev/null || true
pkill -TERM -f '\.claude/plugins/.*/worker' 2>/dev/null || true
if command -v lsof &> /dev/null; then
lsof -ti :37777 2>/dev/null | xargs kill -TERM 2>/dev/null || true
elif command -v ss &> /dev/null; then
ss -tlnp 'sport = :37777' 2>/dev/null | awk 'NR>1 {print $6}' | grep -oP 'pid=\K[0-9]+' | xargs -r kill -TERM 2>/dev/null || true
elif command -v fuser &> /dev/null; then
fuser -k -TERM 37777/tcp 2>/dev/null || true
fi
# Wait up to 5 seconds for graceful shutdown
local waited=0
while [[ $waited -lt 5 ]]; do
if ! pgrep -f 'claude-mnemonic.*worker' &>/dev/null && ! pgrep -f '\.claude/plugins/.*/worker' &>/dev/null; then
return 0
fi
sleep 1
waited=$((waited + 1))
done
# Force kill if still running
pkill -9 -f 'claude-mnemonic.*worker' 2>/dev/null || true
pkill -9 -f '\.claude/plugins/.*/worker' 2>/dev/null || true
if command -v lsof &> /dev/null; then
lsof -ti :37777 2>/dev/null | xargs kill -9 2>/dev/null || true
elif command -v ss &> /dev/null; then
ss -tlnp 'sport = :37777' 2>/dev/null | awk 'NR>1 {print $6}' | grep -oP 'pid=\K[0-9]+' | xargs -r kill -9 2>/dev/null || true
elif command -v fuser &> /dev/null; then
fuser -k 37777/tcp 2>/dev/null || true
fi
sleep 1
# Remove stale PID cache to prevent hooks from using old worker info
rm -f "$HOME/.claude-mnemonic/.worker-cache" 2>/dev/null || true
# Verify process is gone
if pgrep -f 'claude-mnemonic.*worker' &>/dev/null; then
warn "Could not stop existing worker process"
fi
}
# Detect OS and architecture
detect_platform() {
local os arch
@@ -131,7 +177,7 @@ download_release() {
local tmp_dir
tmp_dir=$(mktemp -d)
trap "rm -rf $tmp_dir" EXIT
trap 'rm -rf "$tmp_dir"' EXIT
# Construct download URL (use .zip for Windows, .tar.gz for others)
local archive_ext="tar.gz"
@@ -147,8 +193,38 @@ download_release() {
error "Failed to download release from: $download_url"
fi
# Verify download integrity via checksum
local checksums_url="https://github.com/${GITHUB_REPO}/releases/download/${version}/checksums.txt"
info "Verifying download integrity..."
if curl -fsSL -o "$tmp_dir/checksums.txt" "$checksums_url" 2>/dev/null; then
local expected_hash actual_hash
expected_hash=$(grep "${archive_name}" "$tmp_dir/checksums.txt" | awk '{print $1}')
if [[ -z "$expected_hash" ]]; then
warn "Archive ${archive_name} not found in checksums.txt, skipping verification"
else
if command -v shasum &> /dev/null; then
actual_hash=$(shasum -a 256 "$tmp_dir/release.${archive_ext}" | awk '{print $1}')
elif command -v sha256sum &> /dev/null; then
actual_hash=$(sha256sum "$tmp_dir/release.${archive_ext}" | awk '{print $1}')
else
warn "No SHA256 tool found (shasum or sha256sum), skipping checksum verification"
actual_hash=""
fi
if [[ -n "$actual_hash" ]]; then
if [[ "$expected_hash" != "$actual_hash" ]]; then
error "Checksum verification failed! Expected: $expected_hash Got: $actual_hash"
fi
success "Checksum verified"
fi
fi
else
warn "No checksum file available, skipping verification"
fi
info "Extracting archive..."
if [[ "$archive_ext" == "zip" ]]; then
if ! command -v unzip &> /dev/null; then
error "unzip is required for Windows archives but not installed"
fi
if ! unzip -q "$tmp_dir/release.zip" -d "$tmp_dir"; then
error "Failed to extract archive"
fi
@@ -160,17 +236,7 @@ download_release() {
# Stop existing worker if running
info "Stopping existing worker (if running)..."
pkill -9 -f 'claude-mnemonic.*worker' 2>/dev/null || true
pkill -9 -f '\.claude/plugins/.*/worker' 2>/dev/null || true
# Kill process on port 37777 (use lsof on macOS, ss/fuser on Linux)
if command -v lsof &> /dev/null; then
lsof -ti :37777 | xargs kill -9 2>/dev/null || true
elif command -v ss &> /dev/null; then
ss -tlnp 'sport = :37777' 2>/dev/null | awk 'NR>1 {print $6}' | grep -oP 'pid=\K[0-9]+' | xargs -r kill -9 2>/dev/null || true
elif command -v fuser &> /dev/null; then
fuser -k 37777/tcp 2>/dev/null || true
fi
sleep 1
graceful_stop_worker
# Create installation directories
info "Installing to ${INSTALL_DIR}..."
@@ -178,13 +244,21 @@ download_release() {
mkdir -p "$INSTALL_DIR/.claude-plugin"
mkdir -p "$INSTALL_DIR/commands"
# Copy binaries
cp "$tmp_dir/worker" "$INSTALL_DIR/"
cp "$tmp_dir/mcp-server" "$INSTALL_DIR/"
cp "$tmp_dir/hooks/"* "$INSTALL_DIR/hooks/"
# Copy binaries (abort on failure — could indicate disk full or permissions issue)
if ! cp "$tmp_dir/worker" "$INSTALL_DIR/"; then
error "Failed to copy worker binary to $INSTALL_DIR/"
fi
if ! cp "$tmp_dir/mcp-server" "$INSTALL_DIR/"; then
error "Failed to copy mcp-server binary to $INSTALL_DIR/"
fi
if ! cp "$tmp_dir/hooks/"* "$INSTALL_DIR/hooks/"; then
error "Failed to copy hook binaries to $INSTALL_DIR/hooks/"
fi
# Copy plugin configuration
cp "$tmp_dir/.claude-plugin/"* "$INSTALL_DIR/.claude-plugin/"
if ! cp "$tmp_dir/.claude-plugin/"* "$INSTALL_DIR/.claude-plugin/"; then
error "Failed to copy plugin configuration to $INSTALL_DIR/.claude-plugin/"
fi
# Copy slash commands if they exist in the release
if [[ -d "$tmp_dir/commands" ]]; then
@@ -338,72 +412,51 @@ start_worker() {
error "Worker binary not found at $worker_path"
fi
# Check for port conflict with a non-mnemonic process
if command -v lsof &> /dev/null; then
local port_pid
port_pid=$(lsof -ti :37777 2>/dev/null || true)
if [[ -n "$port_pid" ]]; then
local port_cmd
port_cmd=$(ps -p "$port_pid" -o comm= 2>/dev/null || true)
if [[ -n "$port_cmd" ]] && ! echo "$port_cmd" | grep -q "worker"; then
warn "Port 37777 is in use by another process: $port_cmd (PID $port_pid)"
warn "The worker may fail to start. Consider stopping the conflicting process."
fi
fi
fi
info "Starting worker service..."
nohup "$worker_path" > /tmp/claude-mnemonic-worker.log 2>&1 &
sleep 2
# Retry health check up to 5 times with 1s interval
local retries=0
local max_retries=5
while [[ $retries -lt $max_retries ]]; do
sleep 1
if curl -sS http://localhost:37777/health > /dev/null 2>&1; then
success "Worker started successfully at http://localhost:37777"
return 0
fi
retries=$((retries + 1))
done
if curl -sS http://localhost:37777/health > /dev/null 2>&1; then
success "Worker started successfully at http://localhost:37777"
else
warn "Worker may not have started properly. Check /tmp/claude-mnemonic-worker.log"
fi
warn "Worker may not have started properly after ${max_retries} attempts. Check /tmp/claude-mnemonic-worker.log"
}
# Check optional dependencies for semantic search
check_optional_deps() {
local missing_deps=()
local install_hints=""
# Semantic search uses embedded ONNX runtime - no external Python/uvx dependencies needed
success "Semantic search enabled (embedded ONNX runtime)"
}
# Check for Python 3.13+
if command -v python3 &> /dev/null; then
local py_version=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)
if [[ "$py_version" < "3.13" ]]; then
missing_deps+=("Python 3.13+ (found $py_version)")
fi
else
missing_deps+=("Python 3.13+")
fi
# Check for uvx
if ! command -v uvx &> /dev/null; then
missing_deps+=("uvx")
fi
if [[ ${#missing_deps[@]} -gt 0 ]]; then
echo ""
warn "Optional dependencies missing (needed for semantic search):"
for dep in "${missing_deps[@]}"; do
echo " - $dep"
done
echo ""
# Detect OS and show appropriate install command
case "$(uname -s)" in
Darwin)
info "Install on macOS:"
echo " brew install [email protected]"
echo " pip3 install uv"
;;
Linux)
info "Install on Linux:"
echo " sudo apt install python3 python3-pip"
echo " pip3 install uv"
;;
MINGW*|MSYS*|CYGWIN*)
info "Install on Windows:"
echo " winget install Python.Python.3.13"
echo " pip install uv"
;;
esac
echo ""
info "Note: Requires Python 3.13+. Most package managers install the latest version."
echo ""
info "Semantic search will be disabled until these are installed."
info "Core functionality (SQLite storage, full-text search) will work."
echo ""
else
success "Optional dependencies found (semantic search enabled)"
# Rollback partially installed files on failure
INSTALL_COMPLETE=false
cleanup_on_failure() {
if [[ "$INSTALL_COMPLETE" != "true" ]]; then
warn "Installation did not complete — cleaning up partial install..."
rm -rf "$INSTALL_DIR" 2>/dev/null || true
rm -rf "$CACHE_DIR" 2>/dev/null || true
fi
}
@@ -411,6 +464,8 @@ check_optional_deps() {
main() {
local version="${1:-}"
trap cleanup_on_failure EXIT
echo ""
echo "╔═══════════════════════════════════════════════════════════╗"
echo "║ Claude Mnemonic - Installation Script ║"
@@ -455,6 +510,8 @@ main() {
# Check optional dependencies
check_optional_deps
INSTALL_COMPLETE=true
echo ""
echo "╔═══════════════════════════════════════════════════════════╗"
echo "║ Installation Complete! ║"
@@ -467,6 +524,12 @@ main() {
echo ""
}
# Handle --version flag
if [[ "${1:-}" == "--version" ]]; then
echo "claude-mnemonic installer v${INSTALLER_VERSION}"
exit 0
fi
# Handle --register-only flag
if [[ "${1:-}" == "--register-only" ]]; then
version=$(cat "$INSTALL_DIR/.claude-plugin/plugin.json" 2>/dev/null | grep '"version"' | sed -E 's/.*"([^"]+)".*/\1/' || echo "1.0.0")
@@ -486,17 +549,7 @@ if [[ "${1:-}" == "--uninstall" ]]; then
echo ""
info "Stopping worker processes..."
pkill -9 -f 'claude-mnemonic.*worker' 2>/dev/null || true
pkill -9 -f '\.claude/plugins/.*/worker' 2>/dev/null || true
# Kill process on port 37777 (use lsof on macOS, ss/fuser on Linux)
if command -v lsof &> /dev/null; then
lsof -ti :37777 | xargs kill -9 2>/dev/null || true
elif command -v ss &> /dev/null; then
ss -tlnp 'sport = :37777' 2>/dev/null | awk 'NR>1 {print $6}' | grep -oP 'pid=\K[0-9]+' | xargs -r kill -9 2>/dev/null || true
elif command -v fuser &> /dev/null; then
fuser -k 37777/tcp 2>/dev/null || true
fi
sleep 1
graceful_stop_worker
info "Removing plugin directories..."
rm -rf "$INSTALL_DIR"
+175 -57
View File
@@ -1,5 +1,6 @@
#!/bin/bash
# Register claude-mnemonic plugin with Claude Code
# Ensures plugin survives CLI updates via extraKnownMarketplaces in settings.json
set -e
@@ -9,6 +10,7 @@ MARKETPLACES_FILE="$HOME/.claude/plugins/known_marketplaces.json"
PLUGIN_KEY="claude-mnemonic@claude-mnemonic"
MARKETPLACE_NAME="claude-mnemonic"
MARKETPLACE_PATH="$HOME/.claude/plugins/marketplaces/claude-mnemonic"
STABLE_BIN="$HOME/.claude-mnemonic/bin"
# Get version from git tags (same as Makefile), or use argument if provided
VERSION="${1:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}"
@@ -16,6 +18,36 @@ CACHE_BASE="$HOME/.claude/plugins/cache/claude-mnemonic/claude-mnemonic"
CACHE_PATH="$CACHE_BASE/$VERSION"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
# Helper: safely write JSON via tmp file with validation
# Usage: safe_jq_write <jq_args...> <input_file>
# The last argument is treated as the input file, output goes to input_file.tmp
safe_jq_write() {
local args=("$@")
local last_idx=$((${#args[@]} - 1))
local input_file="${args[$last_idx]}"
local tmp_file="${input_file}.tmp"
if jq "${args[@]}" > "$tmp_file"; then
if jq . "$tmp_file" > /dev/null 2>&1; then
mv "$tmp_file" "$input_file"
else
echo "ERROR: jq produced invalid JSON for $input_file, aborting"
rm -f "$tmp_file"
return 1
fi
else
echo "ERROR: jq failed for $input_file, aborting"
rm -f "$tmp_file"
return 1
fi
}
# Check that Claude Code directory exists
if [ ! -d "$HOME/.claude" ]; then
echo "Warning: $HOME/.claude directory not found. Claude Code may not be installed."
echo "Continuing anyway, but plugin may not function until Claude Code is installed."
fi
# Ensure plugins directory exists
mkdir -p "$HOME/.claude/plugins"
@@ -40,15 +72,122 @@ if [ ! -f "$MARKETPLACES_FILE" ]; then
echo '{}' > "$MARKETPLACES_FILE"
fi
# Check if jq is available
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"
# Ensure cache directory exists and copy plugin files (wrapper scripts + metadata)
mkdir -p "$CACHE_PATH/.claude-plugin"
mkdir -p "$CACHE_PATH/hooks"
mkdir -p "$CACHE_PATH/commands"
# Copy files from marketplace to cache
# Copy wrapper scripts and metadata from marketplace to cache
if [ -d "$MARKETPLACE_PATH" ]; then
cp -r "$MARKETPLACE_PATH/"* "$CACHE_PATH/" 2>/dev/null || true
fi
# Also ensure actual binaries are available via the wrapper scripts
# The wrappers delegate to $STABLE_BIN which has the real binaries
if [ ! -x "$STABLE_BIN/mcp-server" ]; then
echo "WARNING: Binaries not found at $STABLE_BIN. Run 'make install' from the source directory."
fi
# --- JSON registration ---
# Uses jq if available, falls back to python3 for systems without jq.
STATUSLINE_CMD="\${CLAUDE_PLUGIN_ROOT}/hooks/statusline"
# Helper: register plugin using python3 (jq-free fallback)
register_with_python() {
python3 - "$PLUGINS_FILE" "$SETTINGS_FILE" "$MARKETPLACES_FILE" \
"$PLUGIN_KEY" "$CACHE_PATH" "$VERSION" "$TIMESTAMP" \
"$STATUSLINE_CMD" "$MARKETPLACE_NAME" "$MARKETPLACE_PATH" <<'PYEOF'
import json, sys, os
plugins_file, settings_file, marketplaces_file = sys.argv[1], sys.argv[2], sys.argv[3]
plugin_key, cache_path, version, timestamp = sys.argv[4], sys.argv[5], sys.argv[6], sys.argv[7]
statusline_cmd, marketplace_name, marketplace_path = sys.argv[8], sys.argv[9], sys.argv[10]
def load_json(path):
try:
with open(path) as f:
return json.load(f)
except (json.JSONDecodeError, FileNotFoundError):
return {}
def save_json(path, data):
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
os.replace(tmp, path)
# 1. installed_plugins.json
plugins = load_json(plugins_file)
plugins.setdefault("version", 2)
plugins.setdefault("plugins", {})
plugins["plugins"][plugin_key] = [{
"scope": "user",
"installPath": cache_path,
"version": version,
"installedAt": timestamp,
"lastUpdated": timestamp
}]
save_json(plugins_file, plugins)
print("Plugin registered in installed_plugins.json")
# 2. settings.json — enable plugin AND add to extraKnownMarketplaces for persistence
settings = load_json(settings_file)
settings.setdefault("enabledPlugins", {})
settings["enabledPlugins"][plugin_key] = True
settings["statusLine"] = {
"type": "command",
"command": statusline_cmd,
"padding": 0
}
# extraKnownMarketplaces ensures plugin survives Claude Code CLI updates
settings.setdefault("extraKnownMarketplaces", {})
settings["extraKnownMarketplaces"][marketplace_name] = {
"source": {
"repo": "lukaszraczylo/claude-mnemonic",
"source": "github"
}
}
save_json(settings_file, settings)
print("Plugin enabled in settings.json")
print("Added to extraKnownMarketplaces for persistence")
print("Statusline configured in settings.json")
# 3. known_marketplaces.json
marketplaces = load_json(marketplaces_file)
marketplaces[marketplace_name] = {
"source": {
"source": "github",
"repo": "lukaszraczylo/claude-mnemonic"
},
"installLocation": marketplace_path,
"lastUpdated": timestamp
}
save_json(marketplaces_file, marketplaces)
print("Marketplace registered in known_marketplaces.json")
PYEOF
}
# Detect JSON tool and register
if command -v jq &> /dev/null; then
# Validate jq version (1.6+ required for //= operator)
JQ_VERSION=$(jq --version 2>/dev/null | sed 's/jq-//')
JQ_MAJOR=$(echo "$JQ_VERSION" | cut -d. -f1)
JQ_MINOR=$(echo "$JQ_VERSION" | cut -d. -f2)
if [ -n "$JQ_MAJOR" ] && [ -n "$JQ_MINOR" ]; then
if [ "$JQ_MAJOR" -lt 1 ] || { [ "$JQ_MAJOR" -eq 1 ] && [ "$JQ_MINOR" -lt 6 ]; }; then
echo "jq $JQ_VERSION found but too old (need 1.6+), trying python3..."
if command -v python3 &> /dev/null; then
register_with_python
else
echo "ERROR: jq 1.6+ or python3 is required for plugin registration"
exit 1
fi
echo "Plugin registered successfully using python3"
exit 0
fi
fi
# Use jq for proper JSON manipulation
PLUGIN_ENTRY=$(cat <<EOF
@@ -57,22 +196,18 @@ if command -v jq &> /dev/null; then
"installPath": "$CACHE_PATH",
"version": "$VERSION",
"installedAt": "$TIMESTAMP",
"lastUpdated": "$TIMESTAMP",
"isLocal": true
"lastUpdated": "$TIMESTAMP"
}]
EOF
)
# Add or update the plugin entry in installed_plugins.json
jq --arg key "$PLUGIN_KEY" --argjson entry "$PLUGIN_ENTRY" \
'.plugins[$key] = $entry' "$PLUGINS_FILE" > "${PLUGINS_FILE}.tmp" \
&& mv "${PLUGINS_FILE}.tmp" "$PLUGINS_FILE"
safe_jq_write --arg key "$PLUGIN_KEY" --argjson entry "$PLUGIN_ENTRY" \
'.plugins[$key] = $entry' "$PLUGINS_FILE"
echo "Plugin registered in installed_plugins.json"
# Enable the plugin in settings.json and configure statusline
# First ensure enabledPlugins object exists, then add our plugin
STATUSLINE_CMD="$MARKETPLACE_PATH/hooks/statusline"
# Enable the plugin in settings.json, configure statusline, and add to extraKnownMarketplaces
STATUSLINE_ENTRY=$(cat <<EOF
{
"type": "command",
@@ -82,19 +217,30 @@ EOF
EOF
)
jq --arg key "$PLUGIN_KEY" --argjson statusline "$STATUSLINE_ENTRY" \
'.enabledPlugins //= {} | .enabledPlugins[$key] = true | .statusLine = $statusline' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" \
&& mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
MARKETPLACE_SOURCE=$(cat <<EOF
{
"source": {
"repo": "lukaszraczylo/claude-mnemonic",
"source": "github"
}
}
EOF
)
safe_jq_write --arg key "$PLUGIN_KEY" --argjson statusline "$STATUSLINE_ENTRY" \
--arg mkname "$MARKETPLACE_NAME" --argjson mksource "$MARKETPLACE_SOURCE" \
'.enabledPlugins //= {} | .enabledPlugins[$key] = true | .statusLine = $statusline | .extraKnownMarketplaces //= {} | .extraKnownMarketplaces[$mkname] = $mksource' "$SETTINGS_FILE"
echo "Plugin enabled in settings.json"
echo "Added to extraKnownMarketplaces for persistence"
echo "Statusline configured in settings.json"
# Register the marketplace in known_marketplaces.json
# Register the marketplace in known_marketplaces.json (GitHub source)
MARKETPLACE_ENTRY=$(cat <<EOF
{
"source": {
"source": "directory",
"path": "$MARKETPLACE_PATH"
"source": "github",
"repo": "lukaszraczylo/claude-mnemonic"
},
"installLocation": "$MARKETPLACE_PATH",
"lastUpdated": "$TIMESTAMP"
@@ -102,45 +248,17 @@ EOF
EOF
)
jq --arg key "$MARKETPLACE_NAME" --argjson entry "$MARKETPLACE_ENTRY" \
'.[$key] = $entry' "$MARKETPLACES_FILE" > "${MARKETPLACES_FILE}.tmp" \
&& mv "${MARKETPLACES_FILE}.tmp" "$MARKETPLACES_FILE"
safe_jq_write --arg key "$MARKETPLACE_NAME" --argjson entry "$MARKETPLACE_ENTRY" \
'.[$key] = $entry' "$MARKETPLACES_FILE"
echo "Marketplace registered in known_marketplaces.json"
# Register MCP server in settings.json
MCP_BINARY="$MARKETPLACE_PATH/mcp-server"
if [ -f "$MCP_BINARY" ]; then
echo "Registering MCP server in settings.json..."
# MCP server entry - note the escaped ${CLAUDE_PROJECT}
MCP_ENTRY=$(cat <<'EOF'
{
"command": "MCP_BINARY_PLACEHOLDER",
"args": ["--project", "${CLAUDE_PROJECT}"],
"env": {}
}
EOF
)
# Replace placeholder with actual path
MCP_ENTRY=$(echo "$MCP_ENTRY" | sed "s|MCP_BINARY_PLACEHOLDER|$MCP_BINARY|g")
# Add or update mcpServers field
if jq --arg key "claude-mnemonic" --argjson entry "$MCP_ENTRY" \
'.mcpServers //= {} | .mcpServers[$key] = $entry' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp"; then
mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
echo "MCP server registered successfully"
else
echo "Warning: Failed to register MCP server (jq error)"
rm -f "${SETTINGS_FILE}.tmp"
fi
else
echo "MCP server binary not found at $MCP_BINARY, skipping MCP registration"
fi
echo "Plugin registered successfully using jq"
elif command -v python3 &> /dev/null; then
register_with_python
echo "Plugin registered successfully using python3"
else
echo "ERROR: jq is required for plugin registration"
echo "Please install jq: brew install jq (macOS) or apt-get install jq (Linux)"
echo "ERROR: jq or python3 is required for plugin registration"
echo "Please install one: brew install jq (macOS) or apt-get install jq (Linux)"
exit 1
fi
+16 -3
View File
@@ -3,6 +3,18 @@
set -e
# Stop running worker processes before removing binaries
echo "Stopping worker processes..."
pkill -TERM -f 'claude-mnemonic.*worker' 2>/dev/null || true
pkill -TERM -f '\.claude/plugins/.*/worker' 2>/dev/null || true
sleep 2
# Force kill if still running
pkill -9 -f 'claude-mnemonic.*worker' 2>/dev/null || true
pkill -9 -f '\.claude/plugins/.*/worker' 2>/dev/null || true
# Clean up port
lsof -ti :37777 | xargs kill -9 2>/dev/null || true
sleep 1
PLUGINS_FILE="$HOME/.claude/plugins/installed_plugins.json"
SETTINGS_FILE="$HOME/.claude/settings.json"
MARKETPLACES_FILE="$HOME/.claude/plugins/known_marketplaces.json"
@@ -30,16 +42,17 @@ else
echo "No plugins file found, skipping"
fi
# Remove from settings.json (enabledPlugins and statusLine if it points to our plugin)
# Remove from settings.json (enabledPlugins, statusLine, and mcpServers)
if [ -f "$SETTINGS_FILE" ]; then
# Remove from enabledPlugins and clear statusLine if it references our plugin
# Remove from enabledPlugins, clear statusLine if it references our plugin, and remove MCP server
jq --arg key "$PLUGIN_KEY" '
del(.enabledPlugins[$key]) |
if .statusLine.command and (.statusLine.command | contains("claude-mnemonic")) then
del(.statusLine)
else
.
end
end |
del(.mcpServers["claude-mnemonic"])
' "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" \
&& mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE"
echo "Plugin removed from settings.json"
+167 -122
View File
@@ -1,12 +1,12 @@
{
"name": "claude-mnemonic-dashboard",
"version": "v0.10.5-15-g385d05a-dirty",
"version": "v0.11.57-dirty",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-mnemonic-dashboard",
"version": "v0.10.5-15-g385d05a-dirty",
"version": "v0.11.57-dirty",
"dependencies": {
"vis-data": "^7.1.9",
"vis-network": "^9.1.9",
@@ -625,9 +625,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
"integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
"cpu": [
"arm"
],
@@ -639,9 +639,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
"integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
"cpu": [
"arm64"
],
@@ -653,9 +653,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
"integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
"cpu": [
"arm64"
],
@@ -667,9 +667,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
"integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
"cpu": [
"x64"
],
@@ -681,9 +681,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
"integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
"cpu": [
"arm64"
],
@@ -695,9 +695,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
"integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
"cpu": [
"x64"
],
@@ -709,9 +709,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
"integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
"cpu": [
"arm"
],
@@ -723,9 +723,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
"integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
"cpu": [
"arm"
],
@@ -737,9 +737,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
"integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
"cpu": [
"arm64"
],
@@ -751,9 +751,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
"integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
"cpu": [
"arm64"
],
@@ -765,9 +765,23 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
"integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
"integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
"cpu": [
"loong64"
],
@@ -779,9 +793,23 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
"integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
"integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
"cpu": [
"ppc64"
],
@@ -793,9 +821,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
"integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
"cpu": [
"riscv64"
],
@@ -807,9 +835,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
"integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
"cpu": [
"riscv64"
],
@@ -821,9 +849,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
"integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
"cpu": [
"s390x"
],
@@ -835,9 +863,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
"integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
"cpu": [
"x64"
],
@@ -849,9 +877,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
"integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
"cpu": [
"x64"
],
@@ -862,10 +890,24 @@
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
"integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
"integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
"cpu": [
"arm64"
],
@@ -877,9 +919,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
"integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
"cpu": [
"arm64"
],
@@ -891,9 +933,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
"integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
"cpu": [
"ia32"
],
@@ -905,9 +947,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
"integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
"cpu": [
"x64"
],
@@ -919,9 +961,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
"integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
"cpu": [
"x64"
],
@@ -1239,9 +1281,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1765,13 +1807,13 @@
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -1800,9 +1842,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [
{
"type": "github",
@@ -1885,9 +1927,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1918,9 +1960,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"funding": [
{
"type": "opencollective",
@@ -1937,7 +1979,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -2156,9 +2198,9 @@
}
},
"node_modules/rollup": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
"integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2172,28 +2214,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.53.3",
"@rollup/rollup-android-arm64": "4.53.3",
"@rollup/rollup-darwin-arm64": "4.53.3",
"@rollup/rollup-darwin-x64": "4.53.3",
"@rollup/rollup-freebsd-arm64": "4.53.3",
"@rollup/rollup-freebsd-x64": "4.53.3",
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
"@rollup/rollup-linux-arm-musleabihf": "4.53.3",
"@rollup/rollup-linux-arm64-gnu": "4.53.3",
"@rollup/rollup-linux-arm64-musl": "4.53.3",
"@rollup/rollup-linux-loong64-gnu": "4.53.3",
"@rollup/rollup-linux-ppc64-gnu": "4.53.3",
"@rollup/rollup-linux-riscv64-gnu": "4.53.3",
"@rollup/rollup-linux-riscv64-musl": "4.53.3",
"@rollup/rollup-linux-s390x-gnu": "4.53.3",
"@rollup/rollup-linux-x64-gnu": "4.53.3",
"@rollup/rollup-linux-x64-musl": "4.53.3",
"@rollup/rollup-openharmony-arm64": "4.53.3",
"@rollup/rollup-win32-arm64-msvc": "4.53.3",
"@rollup/rollup-win32-ia32-msvc": "4.53.3",
"@rollup/rollup-win32-x64-gnu": "4.53.3",
"@rollup/rollup-win32-x64-msvc": "4.53.3",
"@rollup/rollup-android-arm-eabi": "4.60.4",
"@rollup/rollup-android-arm64": "4.60.4",
"@rollup/rollup-darwin-arm64": "4.60.4",
"@rollup/rollup-darwin-x64": "4.60.4",
"@rollup/rollup-freebsd-arm64": "4.60.4",
"@rollup/rollup-freebsd-x64": "4.60.4",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
"@rollup/rollup-linux-arm-musleabihf": "4.60.4",
"@rollup/rollup-linux-arm64-gnu": "4.60.4",
"@rollup/rollup-linux-arm64-musl": "4.60.4",
"@rollup/rollup-linux-loong64-gnu": "4.60.4",
"@rollup/rollup-linux-loong64-musl": "4.60.4",
"@rollup/rollup-linux-ppc64-gnu": "4.60.4",
"@rollup/rollup-linux-ppc64-musl": "4.60.4",
"@rollup/rollup-linux-riscv64-gnu": "4.60.4",
"@rollup/rollup-linux-riscv64-musl": "4.60.4",
"@rollup/rollup-linux-s390x-gnu": "4.60.4",
"@rollup/rollup-linux-x64-gnu": "4.60.4",
"@rollup/rollup-linux-x64-musl": "4.60.4",
"@rollup/rollup-openbsd-x64": "4.60.4",
"@rollup/rollup-openharmony-arm64": "4.60.4",
"@rollup/rollup-win32-arm64-msvc": "4.60.4",
"@rollup/rollup-win32-ia32-msvc": "4.60.4",
"@rollup/rollup-win32-x64-gnu": "4.60.4",
"@rollup/rollup-win32-x64-msvc": "4.60.4",
"fsevents": "~2.3.2"
}
},
@@ -2363,9 +2408,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2455,9 +2500,9 @@
"license": "MIT"
},
"node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
@@ -2519,9 +2564,9 @@
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2612,9 +2657,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mnemonic-dashboard",
"version": "v0.10.5-15-g385d05a-dirty",
"version": "v0.11.57-dirty",
"private": true,
"type": "module",
"scripts": {