Compare commits

...
85 Commits
Author SHA1 Message Date
lukaszraczyloandGitHub af8e6f44cd Update go.mod and go.sum (#52) 2026-06-09 05:27:34 +01:00
github-actions[bot] cf4161d459 chore: update marketplace for v0.14.9 2026-06-06 04:33:01 +00:00
lukaszraczyloandGitHub 910aabda22 Update go.mod and go.sum (#51) 2026-06-06 05:25:16 +01:00
github-actions[bot] 6115ea8a44 chore: update marketplace for v0.14.5 2026-06-05 04:39:49 +00:00
lukaszraczyloandGitHub 93446d1437 Update go.mod and go.sum (#50) 2026-06-05 05:32:20 +01:00
github-actions[bot] 1d1807da9e chore: update marketplace for v0.14.1 2026-06-01 22:48:13 +00:00
lukaszraczylo 7bdb523106 fix(ci): sync ui/package.json.tpl with upgraded frontend deps
GoReleaser (.goreleaser.yaml before-hook) and the Makefile regenerate
ui/package.json from ui/package.json.tpl at build time, then run npm ci.
The deps upgrade updated ui/package.json and the lockfile but not the
template, so the release hook regenerated package.json with the old deps
and npm ci failed against the new lockfile. Sync the template to the
upgraded versions (Vite 8, Tailwind 4, vis-network 10, FontAwesome 7,
TypeScript 6, ...). Verified by replaying the hook locally: regenerate
from the template then npm ci + build both pass.
2026-06-01 23:41:32 +01:00
lukaszraczylo abd34c9629 fix(ci): resolve @fortawesome/fontawesome-free from public npm registry
The frontend deps bump regenerated ui/package-lock.json with the
@fortawesome/fontawesome-free tarball resolved from npm.fontawesome.com
(per the developer's scoped ~/.npmrc), which requires auth. CI has no
FontAwesome token, so `npm ci` failed with E401.

The free package is also published to the public registry with an
identical tarball/integrity, so pin @fortawesome to registry.npmjs.org
via ui/.npmrc and repoint the lockfile. Verified `npm ci` + build now
succeed with no token.
2026-06-01 16:48:19 +01:00
lukaszraczylo 5fe25e9328 chore: upgrade ui and docs frontend dependencies to latest
ui: Vite 8, vue-tsc 3, TypeScript 6, @vitejs/plugin-vue 6, vis-network 10,
vis-data 8, FontAwesome 7, @types/node 25. docs: Vite 8, plugin-vue 6.
Both migrated to Tailwind CSS v4.

- Tailwind v3 -> v4: @import "tailwindcss" with @theme/@config, switch to
  @tailwindcss/postcss, drop autoprefixer (bundled via Lightning CSS).
- docs: move theme into @theme, delete tailwind.config.js, remove stale
  pnpm-lock.yaml (npm/package-lock.json is authoritative).
- ui: TypeScript 6 removes deprecated tsconfig baseUrl; no code changes
  needed for vis-network 10 / FontAwesome 7.
- Bump CI Node 20 -> 22 (Vite 8 requires Node ^20.19 || >=22.12).

0 npm audit vulnerabilities; ui type-check and both production builds pass.
2026-06-01 16:40:27 +01:00
lukaszraczylo b7b82ce22f fix: bound SQLite WAL growth and prevent worker hangs (#49)
The worker's SQLite WAL could grow unbounded (observed 19MB) and wedge the
DB, hanging Claude Code on every prompt. No checkpoint ever truncated the
WAL (only PASSIVE auto-checkpoint, which cannot reclaim the file), the
connection-scoped pragmas were set via a single Exec so only one pooled
connection received them (e.g. busy_timeout=0 on the rest), and the
maintenance service that would optimize/checkpoint was never wired up.

- Register a sqlite3 ConnectHook driver so all pragmas (busy_timeout,
  journal_mode, synchronous, cache_size, foreign_keys, journal_size_limit)
  apply to every pooled connection; enable safe connection recycling.
- Add Store.Checkpoint (TRUNCATE), checkpoint-on-Close, and a periodic
  size-gated checkpoint loop with configurable interval/threshold.
- Wire up the previously-dead maintenance service; make trigger_maintenance
  actually run DB maintenance instead of only recalculating scores.
- Harden the user-prompt hook to honor its deadline and fail open so a
  slow worker can never stall a prompt.
- Add regression tests for WAL truncation, checkpoint-on-close, and
  per-connection pragmas.
2026-06-01 16:40:27 +01:00
github-actions[bot] f78370a531 chore: update marketplace for v0.13.17 2026-05-31 04:41:38 +00:00
lukaszraczyloandGitHub 5f89559842 Update go.mod and go.sum (#48) 2026-05-31 05:33:00 +01:00
github-actions[bot] 794eacf02a chore: update marketplace for v0.13.13 2026-05-30 04:32:51 +00:00
lukaszraczyloandGitHub 6fc752abb1 Update go.mod and go.sum (#47) 2026-05-30 05:24:21 +01:00
github-actions[bot] 9ce29ac7b1 chore: update marketplace for v0.13.9 2026-05-27 04:40:51 +00:00
lukaszraczyloandGitHub a0d0e856f5 Update go.mod and go.sum (#46) 2026-05-27 05:32:37 +01:00
lukaszraczylo 549fefc675 deslopify docs website 2026-05-26 19:22:47 +01:00
github-actions[bot] c5967c3eaa chore: update marketplace for v0.13.3 2026-05-26 17:56:36 +00:00
lukaszraczylo dbe89f37c8 fix: remove duplicate else block in workflow-prepare.sh 2026-05-26 18:51:02 +01:00
lukaszraczylo 1a4fea5c17 refactor: replace Git LFS with runtime model download from Hugging Face
Remove ~170MB of model files from the repository (LFS + committed).
Models are now downloaded at runtime from Hugging Face on first use
and cached to the OS cache directory with progress reporting and retries.

- Add internal/models/download.go: runtime downloader with retry, progress bar, checksums
- Remove go:embed for ONNX models (keep tokenizers embedded)
- Use file-based ONNX session loading instead of byte-slice
- Add scripts/download-models.sh for dev/CI model setup
- Update Makefile with setup-models target
- Update workflow-prepare.sh to download models in CI
- Set lfs: false in all CI workflows
- SHA256: bge=828e14..., cross-encoder=5d3e70...
2026-05-26 17:53:30 +01:00
github-actions[bot] c8b462aaec chore: update marketplace for v0.12.21 2026-05-26 13:53:19 +00:00
lukaszraczylo a5b18140d3 fix: use build tags for Setpgid (Unix-only) to fix Windows build 2026-05-26 14:46:36 +01:00
lukaszraczylo a81482d06a fix: address 15 additional hang vectors found during deep audit (#45)
MCP server (5 fixes):
- Move semaphore acquisition inside goroutine so main loop stays
  responsive when all slots are taken
- Add 10s write timeout to sendResponse to prevent pipe deadlock
  when Claude Code pauses reading stdout
- Send fallback JSON-RPC error when json.Marshal fails instead of
  silently swallowing the error and leaving caller waiting forever
- Silence unknown notification methods (req.ID == nil) instead of
  sending unsolicited error responses that may desync the host
- Return MCP isError content for tool failures instead of top-level
  JSON-RPC error, matching the MCP specification

Vector/embedding (3 fixes):
- Move EmbedBatchWithContext call before writeMu.Lock in AddDocuments
  so ONNX inference runs outside the write lock
- Replace singleflight.Do with DoChan + ctx select in both
  getOrComputeEmbedding and UnifiedSearch so callers can bail out
  independently when their context expires
- Add activeQueries atomic counter; skip cache warming when user
  queries are in-flight; reduce warming timeout from 5s to 2s

Hooks (4 fixes):
- Cap EnsureWorkerRunning to 15s hard deadline with context; reduce
  StartupTimeout from 30s to 10s; reduce port-in-use retries
- Fix nil dereference panic in user-prompt hook when initResult is
  nil (non-JSON worker response); use comma-ok assertions
- Use package-level hookClient/healthClient with DisableKeepAlives
  to prevent FD leaks in short-lived hook processes
- Set SysProcAttr{Setpgid: true} to detach worker from hook process
  group, preventing kill-cascade from Claude Code

Worker/DB (3 fixes):
- Replace os.Exit(0) in MCP config watcher with context cancellation
  for clean protocol shutdown
- Add 60s context.WithTimeout around ProcessObservation calls in
  processAllSessions to prevent hung CLI subprocesses from blocking
  the queue processor forever
- Set explicit PRAGMA wal_autocheckpoint=1000 and add PASSIVE WAL
  checkpoint to Optimize() to prevent checkpoint stalls

Adds 20+ regression tests across all fix areas.
2026-05-26 14:29:34 +01:00
lukaszraczylo de5796bbe6 test: add regression tests for #45 hang fixes
- MCP server: 4 tests verifying concurrent dispatch, slow-request
  isolation, semaphore limiting, and graceful drain on cancel
- Embedding: 4 tests verifying context-aware mutex cancellation,
  uncontended success, batch cancellation, and cleanup after cancel
- Vector client: 3 tests for acquireRLockWithContext cancel, success,
  and cleanup goroutine correctness
- Worker handlers: 1 test verifying handleSearchByPrompt inherits
  request context cancellation (skips without FTS5)

12 regression tests total covering the four fix areas.
2026-05-26 14:29:34 +01:00
lukaszraczylo 29d57857ff fix: prevent MCP server hanging by adding concurrency, timeouts, and context propagation (#45)
Root cause: synchronous MCP request processing combined with missing
context propagation to the embedding layer caused indefinite hangs when
ONNX inference was slow or the database was contended.

Changes:
- MCP server: dispatch each request in its own goroutine with semaphore
  (cap 10) and WaitGroup for clean shutdown drain
- Embedding: add context-aware mutex acquisition (acquireMutex) so
  callers can bail out instead of blocking forever on a stuck ONNX model
- Vector client: propagate context through getOrComputeEmbedding and
  replace bare RLock() calls with context-aware acquireRLockWithContext
- Worker handlers: add 15s request-scoped timeouts to all search/context
  handlers (handleSearchByPrompt, handleContextInject, handleFileContext,
  handleContextCount, handleGetObservations/Summaries/Prompts)
- Worker HTTP server: set WriteTimeout=60s (was 0); SSE endpoint extends
  deadline per-request via http.ResponseController

Fixes #45
2026-05-26 14:29:34 +01:00
github-actions[bot] 56616d0616 chore: update marketplace for v0.12.11 2026-05-24 04:39:28 +00:00
lukaszraczyloandGitHub 90803a4885 Update go.mod and go.sum (#44) 2026-05-24 05:30:05 +01:00
github-actions[bot] e2dd736e38 chore: update marketplace for v0.12.3 2026-05-24 02:13:16 +00:00
github-actions[bot] a9677881df chore: update marketplace for v0.12.1 2026-05-24 02:11:59 +00:00
github-actions[bot] 620d538f23 chore: update marketplace for v0.11.118 2026-05-24 02:07:29 +00:00
lukaszraczylo a59b9b1622 chore: remove hand-rolled telemetry pkg; ignore build binaries
- delete internal/telemetry (replaced by oss-telemetry library in prev commit)
- gitignore root /mcp and /worker build artifacts
2026-05-24 03:03:57 +01:00
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
lukaszraczyloandGitHub 76b1ec3c9f Update go.mod and go.sum (#27) 2026-02-09 03:55:54 +00:00
github-actions[bot] 968fd2293e chore: update marketplace for v0.11.25 2026-02-06 03:59:35 +00:00
lukaszraczyloandGitHub c123568dfd Update go.mod and go.sum (#26) 2026-02-06 03:49:52 +00:00
github-actions[bot] d69511dd9b chore: update marketplace for v0.11.21 2026-01-15 03:45:28 +00:00
lukaszraczyloandGitHub 617df091aa Update go.mod and go.sum (#25) 2026-01-15 03:34:58 +00:00
github-actions[bot] 9e4a001e7e chore: update marketplace for v0.11.17 2026-01-11 12:47:20 +00:00
lukaszraczyloandGitHub 5335a8a7a6 mnemonic ralphised (#24)
* Make things 'betterer' across the board

* fix: reorganize struct fields and config parameters for consistency

- [x] Reorder Config struct fields alphabetically and by related functionality
- [x] Reorganize Observation model fields with archival fields grouped together
- [x] Reorder ObservationStore fields to group related members
- [x] Reorder Store struct fields with health check caching grouped
- [x] Reorganize HealthInfo and PoolMetrics struct field order
- [x] Reorder maintenance Service struct fields logically
- [x] Reorganize MCP server handler parameter structs alphabetically
- [x] Reorder pattern detector candidate tracking fields
- [x] Reorganize search Manager struct fields by functionality
- [x] Reorder vector Client struct fields with mutex protections grouped
- [x] Reorganize handler request/response struct fields
- [x] Update handlers_test.go to expect wrapped response format
- [x] Reorder middleware TokenAuth and rate limiter fields
- [x] Reorganize Service struct fields with grouped functionality
- [x] Fix RateLimiter field ordering for clarity
- [x] Reorder CircuitBreaker metrics fields

* fix(security): improve JSON output safety and path traversal protection

- [x] Replace unsafe JSON string formatting with proper json.Marshal in export handler
- [x] Remove escapeJSONString helper function in favor of standard JSON marshaling
- [x] Add safeResolvePath function to validate paths and prevent directory traversal
- [x] Apply path traversal validation in captureFileMtimes operations
- [x] Cap result slice capacity in getRecentSearchQueries to prevent DoS via excessive allocation

* fix(sdk): improve path traversal protection and allocation safety

- [x] Enhance safeResolvePath with stricter validation using filepath.Rel
- [x] Reject paths containing ".." after cleaning to prevent traversal
- [x] Validate absolute paths are within cwd when cwd is specified
- [x] Apply safeResolvePath validation to GetFileContent for consistency
- [x] Add comprehensive test coverage for path traversal protection
- [x] Fix allocation safety in getRecentSearchQueries by using constant capacity

* feat(dashboard): add graph stats and vector metrics endpoints

- [x] Add handleGraphStats endpoint for knowledge graph visualization
- [x] Add handleVectorMetrics endpoint for vector database dashboard
- [x] Improve update check error handling with JSON response
- [x] Register new API routes for graph and vector metrics
- [x] Migrate Font Awesome to npm package from CDN
- [x] Fix observations API response type handling
- [x] Update package version to v0.10.5-15-g385d05a

* fixup! feat(dashboard): add graph stats and vector metrics endpoints

* test: add comprehensive test coverage across multiple packages

- [x] Add 298 tests for Python chunker functionality
- [x] Add 213 tests for chunking types and constants
- [x] Add 398 tests for TypeScript/JavaScript chunker
- [x] Add 954 tests for MCP server handlers and validation
- [x] Add 563 tests for pattern detector and analysis
- [x] Add 1149 tests for vector client cache and operations
- [x] Add 663 tests for SDK processor, circuit breaker, and deduplication
- [x] Add 731 tests for session manager lifecycle and concurrency
- [x] Add 331 tests for similarity clustering and term extraction

* fix(pattern): add nil check and fmt import for GetPatternInsight

- [x] Add `fmt` import for error formatting
- [x] Add nil check for pattern before using it
- [x] Remove duplicate comment line
2026-01-11 12:41:28 +00:00
github-actions[bot] f94e07ff6f chore: update marketplace for v0.11.13 2026-01-11 02:01:40 +00:00
127 changed files with 17637 additions and 7492 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"]
}
+2 -1
View File
@@ -1 +1,2 @@
internal/embedding/assets/*.onnx filter=lfs diff=lfs merge=lfs -text
# Models are now downloaded at runtime from GitHub Releases.
# LFS tracking removed. See internal/models/download.go
+1 -1
View File
@@ -17,5 +17,5 @@ jobs:
with:
go-version: ">=1.24"
release-workflow: "release.yaml"
lfs: true
lfs: false
secrets: inherit
+1 -1
View File
@@ -20,6 +20,6 @@ jobs:
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
with:
go-version: ">=1.24"
lfs: true
lfs: false
build-tags: "fts5"
secrets: inherit
+2 -2
View File
@@ -20,12 +20,12 @@ jobs:
with:
go-version: ">=1.24"
node-enabled: true
node-version: "20"
node-version: "22"
node-build-script: "cd ui && npm ci && npm run build"
node-cache-dependency-path: "ui/package-lock.json"
node-output-path: "ui/dist"
node-embed-path: "internal/worker/static"
lfs: true
lfs: false
secrets: inherit
commit-marketplace:
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'
cache-dependency-path: 'docs/package-lock.json'
+10 -5
View File
@@ -1,5 +1,7 @@
# Binaries
bin/
/mcp
/worker
*.exe
*.exe~
*.dll
@@ -79,14 +81,17 @@ logs/
*.db-wal
*.db-shm
# Model files (downloaded at runtime from GitHub Releases)
# Test model data (downloaded from Hugging Face)
testdata/models/
internal/embedding/assets/model.onnx
internal/reranking/assets/model.onnx
# 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
user-prompt
+53 -25
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,23 @@ all: build
setup-libs:
@./scripts/download-onnx-libs.sh all
# Download ONNX models from Hugging Face (for local dev, skips if present)
setup-models:
@./scripts/download-models.sh
# 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 +124,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 +137,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 +148,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
@@ -161,12 +186,14 @@ uninstall: stop-worker
@echo "Uninstallation complete!"
# Run tests (with FTS5 support)
test: setup-libs
go test $(BUILD_TAGS) -v -race ./...
# CLAUDE_MNEMONIC_MODEL_DIR points to a common model directory for tests.
# Run setup-models first to download models from Hugging Face.
test: setup-libs setup-models
CLAUDE_MNEMONIC_MODEL_DIR=$$(pwd)/testdata/models go test $(BUILD_TAGS) -v -race ./...
# Run tests with coverage (with FTS5 support)
test-coverage: setup-libs
go test $(BUILD_TAGS) -v -race -coverprofile=coverage.out ./...
test-coverage: setup-libs setup-models
CLAUDE_MNEMONIC_MODEL_DIR=$$(pwd)/testdata/models go test $(BUILD_TAGS) -v -race -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
@go tool cover -func=coverage.out | tail -1
@@ -228,6 +255,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)
}
+141 -42
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,121 @@ 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. Pass the deadline context so a wedged worker
// aborts the request at the deadline instead of blocking for the full
// hookClient timeout (10s). Errors are ignored -- fail open with no memory.
wg.Add(1)
go func() {
defer wg.Done()
searchResult, _ = hooks.GETWithContext(deadline, ctx.Port, searchURL)
}()
// Start session init in parallel (with observationCount=0; approximate is fine).
// Deadline context guards this call too.
wg.Add(1)
go func() {
defer wg.Done()
initResult, initErr = hooks.POSTWithContextResult(deadline, 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 exceeded, fail open (proceed with
// no injected memory) rather than blocking or erroring the user's prompt.
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 +146,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,50 +177,49 @@ 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. A session-init failure must never block the
// prompt: degrade gracefully and still inject any memory we found.
if initErr != nil {
fmt.Fprintf(os.Stderr, "[user-prompt] Session init failed: %v\n", initErr)
return contextToInject, nil
}
if initResult == nil {
return contextToInject, nil // Non-JSON response from worker, skip session init
}
// 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
return contextToInject, nil
}
// Safely extract session ID and prompt number with type checking
sessionDbIdRaw, ok := result["sessionDbId"].(float64)
sessionDBIDVal, ok := initResult["sessionDbId"].(float64)
if !ok {
return "", fmt.Errorf("invalid or missing sessionDbId in response")
return contextToInject, nil // Missing or wrong type, skip gracefully
}
sessionID := int64(sessionDbIdRaw)
sessionID := int64(sessionDBIDVal)
promptNumberRaw, ok := result["promptNumber"].(float64)
promptNumberVal, ok := initResult["promptNumber"].(float64)
if !ok {
return "", fmt.Errorf("invalid or missing promptNumber in response")
return contextToInject, nil
}
promptNumber := int(promptNumberRaw)
promptNumber := int(promptNumberVal)
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).
// Deadline-guarded so a wedged worker cannot stall past the hook budget.
// Failure here must not block the prompt: degrade gracefully, still inject memory.
if _, err := hooks.POSTWithContextResult(deadline, ctx.Port, fmt.Sprintf("/sessions/%d/init", sessionID), map[string]interface{}{
"userPrompt": input.Prompt,
"promptNumber": promptNumber,
})
if err != nil {
return "", err
}); err != nil {
fmt.Fprintf(os.Stderr, "[user-prompt] SDK agent init failed: %v\n", err)
return contextToInject, nil
}
// 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
}
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"testing"
"github.com/stretchr/testify/assert"
)
// TestEstimateTokens tests the token estimator.
func TestEstimateTokens(t *testing.T) {
tests := []struct {
name string
input string
minToken int
maxToken int
}{
{"empty string", "", 0, 0},
{"single word", "hello", 1, 3},
{"simple sentence", "Hello world this is a test", 5, 15},
{"code-heavy", "func() { return x.y.z(); }", 5, 30},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := estimateTokens(tt.input)
assert.GreaterOrEqual(t, result, tt.minToken)
assert.LessOrEqual(t, result, tt.maxToken)
})
}
}
// TestHandleUserPrompt_NilInitResult_Compile verifies that the nil-safety
// fix in handleUserPrompt compiles correctly. The actual nil dereference
// was at initResult["sessionDbId"].(float64) when initResult was nil.
// This test ensures the defensive type assertions are present by exercising
// the token estimator (the handler requires a live HookContext+worker).
func TestHandleUserPrompt_NilInitResult_Compile(t *testing.T) {
// The real regression test is that `go build ./cmd/hooks/user-prompt/`
// succeeds with the nil-safe assertions. We can't easily spin up
// a full HookContext here, but we verify the package compiles and
// the helper functions are sane.
assert.Equal(t, 0, estimateTokens(""))
assert.Greater(t, estimateTokens("test input"), 0)
}
+22 -89
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,83 +58,31 @@ 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(cancel)
// 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 {
if err == context.Canceled {
log.Info().Msg("MCP server shut down (config change or signal)")
return
}
log.Fatal().Err(err).Msg("MCP server error")
}
}
// startWatchers initializes file watchers for config.
func startWatchers(ctx context.Context, dbPath string) {
// Watch config file for changes (triggers process exit for restart)
func startWatchers(cancel context.CancelFunc) {
// Watch config file for changes (triggers graceful shutdown via context cancellation)
configPath := config.SettingsPath()
configWatcher, err := watcher.New(configPath, func() {
log.Warn().Str("path", configPath).Msg("Config file changed, exiting for restart...")
time.Sleep(100 * time.Millisecond) // Give logs time to flush
os.Exit(0)
log.Warn().Str("path", configPath).Msg("Config file changed, shutting down gracefully...")
cancel() // Triggers ctx.Done() in server.Run(), which drains in-flight requests
})
if err != nil {
log.Warn().Err(err).Msg("Failed to create config watcher")
+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.
+732 -1397
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -8,13 +8,13 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.25"
"vue": "^3.5.34"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.3",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"vite": "^7.3.0"
"@tailwindcss/postcss": "^4.3.0",
"@vitejs/plugin-vue": "^6.0.7",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.0",
"vite": "^8.0.14"
}
}
+1 -2
View File
@@ -1,6 +1,5 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
'@tailwindcss/postcss': {},
},
}
+165 -405
View File
@@ -1,398 +1,153 @@
<template>
<div class="bg-slate-950 text-white min-h-screen relative overflow-hidden">
<!-- Animated Background -->
<div class="fixed inset-0 pointer-events-none">
<!-- Gradient orbs -->
<div class="absolute top-0 left-1/4 w-96 h-96 bg-amber-500/10 rounded-full blur-3xl animate-float"></div>
<div class="absolute bottom-1/4 right-1/4 w-80 h-80 bg-amber-400/5 rounded-full blur-3xl animate-float-delayed"></div>
<div class="absolute top-1/2 right-0 w-64 h-64 bg-slate-700/20 rounded-full blur-3xl animate-glow"></div>
<!-- Grid pattern -->
<div class="absolute inset-0 bg-[linear-gradient(rgba(148,163,184,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(148,163,184,0.03)_1px,transparent_1px)] bg-[size:50px_50px]"></div>
<!-- Floating particles -->
<div v-for="i in 12" :key="i"
class="particle animate-particle"
:style="{
left: `${(i * 8.3) % 100}%`,
animationDelay: `${i * 1.5}s`,
animationDuration: `${15 + (i % 5) * 3}s`
}"></div>
</div>
<div class="min-h-screen flex flex-col">
<NavBar :mobile-menu-open="mobileMenuOpen" @toggle-menu="mobileMenuOpen = !mobileMenuOpen" />
<HeroSection
badge="The missing piece for Claude Code"
title-before="Yesterday's context."
title-highlight="Today's session."
subtitle="Stop re-explaining your codebase. Claude Mnemonic captures bug fixes, architecture decisions, and coding patterns - then brings them back exactly when you need them."
/>
<main class="flex-1">
<HeroSection />
<!-- Dashboard Preview -->
<section class="py-12 lg:py-16 px-4 sm:px-6 relative">
<div class="max-w-6xl mx-auto">
<div class="relative rounded-xl overflow-hidden border border-slate-700/50 shadow-2xl shadow-amber-500/5">
<div class="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent pointer-events-none z-10"></div>
<img
src="/claude-mnemonic.jpg"
alt="Claude Mnemonic Dashboard"
class="w-full h-auto"
/>
<section class="py-10 px-4 sm:px-6">
<div class="max-w-5xl mx-auto">
<div class="rounded-xl overflow-hidden border border-neutral-800">
<img src="/claude-mnemonic.jpg" alt="Claude Mnemonic Dashboard" class="w-full h-auto block" loading="lazy" />
</div>
<p class="text-center text-neutral-600 text-xs mt-3 tracking-wide uppercase">Dashboard — localhost:37777</p>
</div>
<p class="text-center text-slate-500 text-sm mt-4">The dashboard at localhost:37777 - browse, search, and manage your memories. View graph stats, vector metrics, storage savings, and performance analytics.</p>
</div>
</section>
</section>
<!-- Problem Section -->
<section class="py-20 lg:py-28 px-4 sm:px-6 relative">
<div class="max-w-6xl mx-auto grid lg:grid-cols-2 gap-8 lg:gap-16 items-center">
<div>
<h2 class="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-6">Sound familiar?</h2>
<p class="text-slate-400 text-base sm:text-lg mb-8">Every Claude Code session starts fresh. That bug you fixed last Tuesday? Gone. The auth flow you explained in detail? Forgotten. Your team's naming conventions? You'll explain them again.</p>
<ul class="space-y-4 sm:space-y-5">
<li class="flex items-start gap-3 sm:gap-4 text-slate-400">
<i class="fas fa-times-circle text-red-400 mt-1 flex-shrink-0"></i>
<span class="text-sm sm:text-base">"We fixed this exact race condition last week..."</span>
</li>
<li class="flex items-start gap-3 sm:gap-4 text-slate-400">
<i class="fas fa-times-circle text-red-400 mt-1 flex-shrink-0"></i>
<span class="text-sm sm:text-base">"No, we use kebab-case for API routes, not camelCase..."</span>
</li>
<li class="flex items-start gap-3 sm:gap-4 text-slate-400">
<i class="fas fa-times-circle text-red-400 mt-1 flex-shrink-0"></i>
<span class="text-sm sm:text-base">"The database is Postgres, not MySQL. I told you yesterday..."</span>
</li>
<li class="flex items-start gap-3 sm:gap-4 text-white">
<i class="fas fa-check-circle text-amber-500 mt-1 flex-shrink-0"></i>
<span class="text-sm sm:text-base"><strong>Mnemonic remembers so you don't have to repeat yourself.</strong></span>
</li>
</ul>
</div>
<div class="glass rounded-2xl p-6 sm:p-8 relative overflow-hidden glow-amber">
<div class="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-amber-500 to-transparent"></div>
<p class="text-slate-500 text-xs sm:text-sm mb-4 font-mono">// What Mnemonic captures automatically:</p>
<div class="space-y-3">
<FlowItem icon="fas fa-bug" title="Bug fixes & solutions" description='"Fixed N+1 query in user loader by adding .includes(:posts)"' />
<FlowItem icon="fas fa-sitemap" title="Architecture decisions" description='"Using event sourcing for audit trail - all mutations go through EventStore"' />
<FlowItem icon="fas fa-code-branch" title="Project conventions" description='"API routes use /api/v1/ prefix, kebab-case for endpoints"' />
<FlowItem icon="fas fa-shield-alt" title="Security patterns" description='"Always validate JWT on server side, never trust client claims"' />
<section id="features" class="py-20 px-4 sm:px-6">
<div class="max-w-5xl mx-auto">
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">What it does</h2>
<p class="text-neutral-500 mb-12 max-w-xl">Captures context from your Claude Code sessions and brings it back when you need it. No manual effort.</p>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
<FeatureItem
v-for="feature in features"
:key="feature.title"
:icon="feature.icon"
:title="feature.title"
:description="feature.description"
/>
</div>
</div>
</div>
</section>
</section>
<!-- Features Section -->
<section id="features" class="py-20 lg:py-28 bg-slate-900/30 relative">
<div class="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-slate-700 to-transparent"></div>
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<SectionHeader title="Built for real workflows" subtitle="Not just storage - intelligent context that makes Claude more useful over time" />
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
<FeatureCard
v-for="feature in features"
:key="feature.title"
:icon="feature.icon"
:title="feature.title"
:description="feature.description"
/>
<section class="py-10 px-4 sm:px-6">
<div class="max-w-5xl mx-auto">
<div class="rounded-xl overflow-hidden border border-neutral-800">
<img src="/observation-relation-graph.jpg" alt="Knowledge Graph" class="w-full h-auto block" loading="lazy" />
</div>
<p class="text-center text-neutral-600 text-xs mt-3 tracking-wide uppercase">Observation relationship graph</p>
</div>
</div>
</section>
</section>
<!-- Knowledge Graph Preview -->
<section class="py-16 lg:py-20 px-4 sm:px-6 relative">
<div class="max-w-5xl mx-auto">
<SectionHeader title="See how knowledge connects" subtitle="The knowledge graph reveals relationships between your memories automatically" />
<div class="relative rounded-xl overflow-hidden border border-slate-700/50 shadow-2xl shadow-purple-500/5">
<img
src="/observation-relation-graph.jpg"
alt="Knowledge Graph Visualization"
class="w-full h-auto"
/>
</div>
<p class="text-center text-slate-500 text-sm mt-4">Click any observation to explore its relationships - see what causes what, what fixes what, and how concepts evolve</p>
</div>
</section>
<section id="install" class="py-20 px-4 sm:px-6">
<div class="max-w-5xl mx-auto">
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Install</h2>
<p class="text-neutral-500 mb-8">One command. No configuration. No account.</p>
<!-- Before/After Section -->
<section class="py-20 lg:py-28 px-4 sm:px-6">
<div class="max-w-6xl mx-auto">
<SectionHeader title="The difference" subtitle="Same question. Different experience." />
<div class="grid md:grid-cols-2 gap-6 sm:gap-8">
<!-- Before -->
<div class="glass rounded-2xl p-5 sm:p-6 relative border-red-500/20">
<div class="absolute top-3 sm:top-4 right-3 sm:right-4 bg-red-500/20 text-red-400 px-2 sm:px-3 py-1 rounded-full text-xs font-medium">Without Mnemonic</div>
<div class="space-y-3 sm:space-y-4 mt-8">
<div class="bg-slate-800/50 rounded-lg p-3 sm:p-4">
<p class="text-slate-400 text-xs sm:text-sm"><span class="text-blue-400">You:</span> Fix the authentication bug in the login flow</p>
</div>
<div class="bg-slate-800/50 rounded-lg p-3 sm:p-4">
<p class="text-slate-400 text-xs sm:text-sm"><span class="text-amber-400">Claude:</span> I'd be happy to help! Can you tell me about your authentication setup? What framework are you using? How is the login flow structured?</p>
</div>
<div class="bg-slate-800/50 rounded-lg p-3 sm:p-4">
<p class="text-slate-400 text-xs sm:text-sm"><span class="text-blue-400">You:</span> We use NextAuth with Postgres. The flow goes through /api/auth/... We talked about this last week...</p>
</div>
<p class="text-red-400 text-xs sm:text-sm text-center pt-2"><i class="fas fa-clock mr-2"></i>5+ minutes of re-explaining context</p>
</div>
<div class="flex gap-1 mb-6">
<button
v-for="tab in installTabs"
:key="tab.id"
@click="activeTab = tab.id"
:class="[
'px-4 py-2 text-sm rounded-lg transition-colors',
activeTab === tab.id
? 'bg-neutral-800 text-white'
: 'text-neutral-500 hover:text-neutral-300'
]"
>
{{ tab.label }}
</button>
</div>
<!-- After -->
<div class="glass rounded-2xl p-5 sm:p-6 relative border-green-500/20">
<div class="absolute top-3 sm:top-4 right-3 sm:right-4 bg-green-500/20 text-green-400 px-2 sm:px-3 py-1 rounded-full text-xs font-medium">With Mnemonic</div>
<div class="space-y-3 sm:space-y-4 mt-8">
<div class="bg-slate-800/50 rounded-lg p-3 sm:p-4">
<p class="text-slate-400 text-xs sm:text-sm"><span class="text-blue-400">You:</span> Fix the authentication bug in the login flow</p>
</div>
<div class="bg-slate-800/50 rounded-lg p-3 sm:p-4">
<p class="text-slate-400 text-xs sm:text-sm"><span class="text-amber-400">Claude:</span> Looking at your NextAuth setup with Postgres, I see the issue is likely in the callback at <code class="text-amber-300 text-xs">/api/auth/[...nextauth].ts</code>. Based on the session validation pattern we established, the fix is...</p>
</div>
<p class="text-green-400 text-xs sm:text-sm text-center pt-2"><i class="fas fa-bolt mr-2"></i>Instant context. Immediate value.</p>
</div>
<div v-show="activeTab === 'macos'">
<CodeBlock code="curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash" />
</div>
</div>
</div>
</section>
<!-- How It Works Section -->
<section id="how-it-works" class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
<div class="max-w-6xl mx-auto">
<SectionHeader title="Zero setup. Zero maintenance." subtitle="Install once, benefit forever. Mnemonic works silently in the background." />
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6 sm:gap-8 relative">
<div class="hidden lg:block absolute top-10 left-[60px] right-[60px] h-0.5 bg-gradient-to-r from-amber-500 via-amber-400 to-amber-500 opacity-30"></div>
<StepCard
v-for="step in steps"
:key="step.number"
:number="step.number"
:title="step.title"
:description="step.description"
/>
</div>
</div>
</section>
<div v-show="activeTab === 'windows'">
<CodeBlock code="irm https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.ps1 | iex" />
</div>
<!-- Installation Section -->
<section id="installation" class="py-20 lg:py-28 px-4 sm:px-6">
<div class="max-w-6xl mx-auto">
<SectionHeader title="Quick install" subtitle="One command. No configuration. No account required." />
<div v-show="activeTab === 'source'">
<CodeBlock code="git clone https://github.com/lukaszraczylo/claude-mnemonic.git&#10;cd claude-mnemonic&#10;make build && make install" />
<p class="text-neutral-600 text-xs mt-2">Requires: Go 1.24+, Node.js 18+, CGO compiler</p>
</div>
<div class="flex flex-wrap justify-center gap-2 mb-6 sm:mb-8">
<button
v-for="tab in installTabs"
:key="tab.id"
@click="activeTab = tab.id"
:class="[
'px-4 sm:px-5 py-2 sm:py-2.5 rounded-lg text-xs sm:text-sm font-medium transition-all',
activeTab === tab.id
? 'bg-amber-500/15 border border-amber-500 text-amber-500'
: 'glass text-slate-400 hover:border-slate-600'
]"
>
{{ tab.label }}
</button>
</div>
<div v-show="activeTab === 'macos'">
<CodeBlock :code="installCommands.macos">
<span class="text-slate-500"># That's it. Seriously.</span>
<br>
<span class="text-amber-400">curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash</span>
</CodeBlock>
</div>
<div v-show="activeTab === 'windows'">
<CodeBlock :code="installCommands.windows">
<span class="text-slate-500"># PowerShell (as Administrator)</span>
<br>
<span class="text-amber-400">irm https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.ps1 | iex</span>
</CodeBlock>
</div>
<div v-show="activeTab === 'source'">
<CodeBlock :code="installCommands.source">
<span class="text-slate-500"># For contributors and tinkerers</span>
<br>
<span class="text-amber-400">git clone https://github.com/lukaszraczylo/claude-mnemonic.git</span>
<br>
<span class="text-amber-400">cd claude-mnemonic</span>
<br>
<span class="text-amber-400">make build && make install</span>
<br><br>
<span class="text-slate-500"># Requires: Go 1.24+, Node.js 18+, CGO compiler</span>
</CodeBlock>
</div>
<p class="text-center text-slate-500 mt-6 sm:mt-8 text-xs sm:text-sm">
After install, open <a href="http://localhost:37777" class="text-amber-400 hover:underline animated-underline">localhost:37777</a> to see the dashboard.
Start a new Claude Code CLI session - memory is now active.
</p>
</div>
</section>
<!-- Requirements Section -->
<section id="requirements" class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
<div class="max-w-6xl mx-auto">
<SectionHeader title="Requirements" subtitle="Minimal dependencies. Everything else is built-in." />
<div class="max-w-xl mx-auto">
<div class="glass rounded-2xl p-5 sm:p-6">
<div class="flex items-center gap-2 mb-4">
<i class="fas fa-check-circle text-green-500"></i>
<span class="text-white font-semibold text-sm sm:text-base">That's all you need</span>
</div>
<div class="space-y-3">
<div v-for="req in requiredDeps" :key="req.name" class="flex items-start gap-3 p-3 bg-slate-800/50 rounded-lg">
<i :class="[req.icon, 'text-amber-500 mt-0.5']"></i>
<div>
<code class="text-white text-xs sm:text-sm font-semibold">{{ req.name }}</code>
<p class="text-slate-400 text-xs mt-1">{{ req.description }}</p>
</div>
</div>
</div>
<p class="text-slate-500 text-xs mt-4">
<i class="fas fa-info-circle mr-1"></i>
No Python. No external services. Semantic search with local embeddings is built-in.
<div class="mt-8 p-4 rounded-lg border border-neutral-800 bg-neutral-900/50 max-w-2xl">
<p class="text-neutral-500 text-sm">
<i class="fas fa-info-circle text-neutral-600 mr-1"></i>
After install, open <a href="http://localhost:37777" class="text-amber-500 hover:text-amber-400">localhost:37777</a> for the dashboard. Start a new Claude Code session — memory is active.
</p>
</div>
</div>
</div>
</section>
</section>
<!-- Configuration Section -->
<section id="configuration" class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
<div class="max-w-6xl mx-auto">
<SectionHeader title="Fully configurable" subtitle="Works out of the box, but adapts to your preferences" />
<section id="requirements" class="py-20 px-4 sm:px-6">
<div class="max-w-5xl mx-auto">
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Requirements</h2>
<p class="text-neutral-500 mb-8">Minimal dependencies. Everything else is built in.</p>
<div class="grid lg:grid-cols-2 gap-6 sm:gap-8">
<!-- Config file example -->
<div class="glass rounded-2xl p-5 sm:p-6">
<div class="flex items-center gap-2 mb-4">
<i class="fas fa-cog text-amber-500"></i>
<span class="text-white font-semibold text-sm sm:text-base">~/.claude-mnemonic/settings.json</span>
<div class="grid sm:grid-cols-2 gap-4 max-w-xl">
<div v-for="req in requiredDeps" :key="req.name" class="p-4 rounded-lg border border-neutral-800 bg-neutral-900/50">
<div class="flex items-center gap-2 mb-1">
<i :class="[req.icon, 'text-amber-500 text-sm']"></i>
<code class="text-white text-sm font-semibold">{{ req.name }}</code>
</div>
<p class="text-neutral-500 text-xs pl-6">{{ req.description }}</p>
</div>
<pre class="text-xs sm:text-sm font-mono overflow-x-auto"><code><span class="text-slate-500">{</span>
<span class="text-emerald-400">"CLAUDE_MNEMONIC_WORKER_PORT"</span><span class="text-slate-500">:</span> <span class="text-amber-400">37777</span><span class="text-slate-500">,</span>
<span class="text-emerald-400">"CLAUDE_MNEMONIC_CONTEXT_OBSERVATIONS"</span><span class="text-slate-500">:</span> <span class="text-amber-400">100</span><span class="text-slate-500">,</span>
<span class="text-emerald-400">"CLAUDE_MNEMONIC_CONTEXT_FULL_COUNT"</span><span class="text-slate-500">:</span> <span class="text-amber-400">25</span><span class="text-slate-500">,</span>
<span class="text-emerald-400">"CLAUDE_MNEMONIC_MODEL"</span><span class="text-slate-500">:</span> <span class="text-sky-400">"haiku"</span>
<span class="text-slate-500">}</span></code></pre>
</div>
<p class="text-neutral-600 text-sm mt-4">No Python. No external services. No API keys — uses your existing Claude Pro/Max subscription.</p>
</div>
</section>
<!-- Config options list -->
<div class="space-y-3 sm:space-y-4">
<div v-for="config in configOptions" :key="config.name" class="glass rounded-xl p-4 hover:border-amber-500/20 transition-colors">
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-amber-500/15 rounded-lg flex items-center justify-center text-amber-500 flex-shrink-0 text-sm">
<i :class="config.icon"></i>
<section id="config" class="py-20 px-4 sm:px-6">
<div class="max-w-5xl mx-auto">
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Configuration</h2>
<p class="text-neutral-500 mb-8">Works out of the box. Adjust if you want to.</p>
<div class="grid lg:grid-cols-2 gap-8">
<div class="rounded-lg border border-neutral-800 bg-neutral-900/50 p-5">
<p class="text-neutral-600 text-xs mb-3 font-mono">~/.claude-mnemonic/settings.json</p>
<pre class="text-sm overflow-x-auto leading-relaxed"><code>{
<span class="text-emerald-400">"CLAUDE_MNEMONIC_WORKER_PORT"</span>: <span class="text-amber-400">37777</span>,
<span class="text-emerald-400">"CLAUDE_MNEMONIC_CONTEXT_OBSERVATIONS"</span>: <span class="text-amber-400">100</span>,
<span class="text-emerald-400">"CLAUDE_MNEMONIC_CONTEXT_FULL_COUNT"</span>: <span class="text-amber-400">25</span>,
<span class="text-emerald-400">"CLAUDE_MNEMONIC_RERANKING_ENABLED"</span>: <span class="text-amber-400">true</span>
}</code></pre>
</div>
<div class="space-y-2">
<div v-for="config in configOptions" :key="config.name" class="flex items-start gap-3 p-3 rounded-lg border border-neutral-800/50">
<div class="w-6 h-6 rounded bg-neutral-800 flex items-center justify-center flex-shrink-0 mt-0.5">
<i class="fas fa-sliders text-neutral-500 text-xs"></i>
</div>
<div class="min-w-0">
<code class="text-amber-400 text-xs sm:text-sm">{{ config.name }}</code>
<p class="text-slate-400 text-xs sm:text-sm mt-1">{{ config.description }}</p>
<div>
<code class="text-amber-500 text-xs">{{ config.name }}</code>
<p class="text-neutral-500 text-xs mt-0.5">{{ config.description }}</p>
</div>
</div>
</div>
</div>
<p class="text-neutral-600 text-xs mt-6">
All settings also work as environment variables. <a href="https://github.com/lukaszraczylo/claude-mnemonic#configuration" target="_blank" class="text-neutral-500 hover:text-neutral-300">Full docs</a>.
</p>
</div>
</section>
<p class="text-center text-slate-500 mt-6 sm:mt-8 text-xs sm:text-sm">
All settings can also be set via environment variables. See <a href="https://github.com/lukaszraczylo/claude-mnemonic#configuration" target="_blank" class="text-amber-400 hover:underline">full documentation</a> for all options.
</p>
</div>
</section>
<!-- Technical Details -->
<section class="py-20 lg:py-28 px-4 sm:px-6">
<div class="max-w-6xl mx-auto">
<SectionHeader title="Under the hood" subtitle="Built with simplicity and performance in mind" />
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 text-center">
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">Go</div>
<p class="text-slate-400 text-xs sm:text-sm">Single binary. Fast startup, low memory. Zero runtime dependencies.</p>
</div>
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">SQLite</div>
<p class="text-slate-400 text-xs sm:text-sm">FTS5 full-text search. Single file database. Survives restarts.</p>
</div>
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">sqlite-vec</div>
<p class="text-slate-400 text-xs sm:text-sm">Hybrid vector storage with LEANN-inspired selective embeddings. 60-80% storage reduction.</p>
</div>
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">BGE</div>
<p class="text-slate-400 text-xs sm:text-sm">Two-stage retrieval: bi-encoder embeddings + cross-encoder reranking for high accuracy.</p>
</div>
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">Tree-sitter</div>
<p class="text-slate-400 text-xs sm:text-sm">AST-aware code chunking respects function boundaries for Go, Python, and TypeScript.</p>
</div>
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">CSR Graph</div>
<p class="text-slate-400 text-xs sm:text-sm">Memory-efficient observation relationship graph with edge detection and hub identification.</p>
<section id="architecture" class="py-20 px-4 sm:px-6">
<div class="max-w-5xl mx-auto">
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Under the hood</h2>
<p class="text-neutral-500 mb-10">No magic. Here's what's running locally on your machine.</p>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<TechCard v-for="tech in techStack" :key="tech.name" :name="tech.name" :description="tech.description" />
</div>
</div>
</div>
</section>
</section>
<!-- Trust Section -->
<section class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
<div class="max-w-4xl mx-auto">
<SectionHeader title="Open source. Real person." subtitle="Not another anonymous package - built by a developer you can verify" />
<div class="glass rounded-2xl p-6 sm:p-8 text-center">
<div class="flex flex-col sm:flex-row items-center justify-center gap-6 sm:gap-8 mb-6">
<a href="https://github.com/lukaszraczylo" target="_blank" class="group">
<img
src="https://github.com/lukaszraczylo.png"
alt="Lukasz Raczylo"
class="w-20 h-20 sm:w-24 sm:h-24 rounded-full border-2 border-slate-700 group-hover:border-amber-500 transition-colors"
/>
</a>
<div class="text-center sm:text-left">
<h3 class="text-white font-semibold text-lg sm:text-xl mb-1">Lukasz Raczylo</h3>
<p class="text-slate-400 text-sm mb-3">Principal Engineer & Open Source Maintainer</p>
<div class="flex flex-wrap justify-center sm:justify-start gap-3">
<a href="https://github.com/lukaszraczylo" target="_blank" class="text-slate-500 hover:text-white transition-colors text-sm">
<i class="fab fa-github mr-1"></i>GitHub
</a>
<a href="https://linkedin.com/in/lukaszraczylo" target="_blank" class="text-slate-500 hover:text-white transition-colors text-sm">
<i class="fab fa-linkedin mr-1"></i>LinkedIn
</a>
<a href="https://raczylo.com" target="_blank" class="text-slate-500 hover:text-white transition-colors text-sm">
<i class="fas fa-globe mr-1"></i>Website
</a>
</div>
</div>
</div>
<div class="grid sm:grid-cols-3 gap-4 pt-6 border-t border-slate-700/50">
<div class="text-center">
<div class="text-amber-500 font-bold text-xl sm:text-2xl mb-1">100%</div>
<p class="text-slate-400 text-xs sm:text-sm">Open Source</p>
</div>
<div class="text-center">
<div class="text-amber-500 font-bold text-xl sm:text-2xl mb-1">MIT</div>
<p class="text-slate-400 text-xs sm:text-sm">License</p>
</div>
<div class="text-center">
<div class="text-amber-500 font-bold text-xl sm:text-2xl mb-1">0</div>
<p class="text-slate-400 text-xs sm:text-sm">Data collection</p>
</div>
</div>
</div>
<p class="text-center text-slate-500 mt-6 sm:mt-8 text-xs sm:text-sm">
Unlike anonymous packages, you can verify who built this, read every line of code, and reach out directly with questions.
<br class="hidden sm:block">
Your security matters - that's why transparency is non-negotiable.
</p>
</div>
</section>
<!-- FAQ Section -->
<section id="faq" class="py-20 lg:py-28 px-4 sm:px-6">
<div class="max-w-4xl mx-auto">
<SectionHeader title="Questions?" />
<div class="grid sm:grid-cols-2 gap-4 sm:gap-6">
<section id="faq" class="py-20 px-4 sm:px-6">
<div class="max-w-3xl mx-auto">
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-10">FAQ</h2>
<FaqItem
v-for="faq in faqs"
:key="faq.question"
@@ -400,8 +155,21 @@
:answer="faq.answer"
/>
</div>
</div>
</section>
</section>
<section class="py-20 px-4 sm:px-6">
<div class="max-w-3xl mx-auto text-center">
<p class="text-neutral-600 text-sm mb-2">Open source, built by</p>
<a href="https://github.com/lukaszraczylo" target="_blank" class="inline-flex items-center gap-3 text-neutral-400 hover:text-white transition-colors group">
<img src="https://github.com/lukaszraczylo.png" alt="Lukasz Raczylo" class="w-10 h-10 rounded-full border border-neutral-700 group-hover:border-neutral-500 transition-colors" />
<div class="text-left">
<div class="text-sm font-medium">Lukasz Raczylo</div>
<div class="text-xs text-neutral-600">MIT License</div>
</div>
</a>
</div>
</section>
</main>
<FooterSection />
</div>
@@ -411,10 +179,8 @@
import { ref } from 'vue'
import NavBar from './components/NavBar.vue'
import HeroSection from './components/HeroSection.vue'
import SectionHeader from './components/SectionHeader.vue'
import FeatureCard from './components/FeatureCard.vue'
import StepCard from './components/StepCard.vue'
import FlowItem from './components/FlowItem.vue'
import FeatureItem from './components/FeatureItem.vue'
import TechCard from './components/TechCard.vue'
import CodeBlock from './components/CodeBlock.vue'
import FaqItem from './components/FaqItem.vue'
import FooterSection from './components/FooterSection.vue'
@@ -423,22 +189,15 @@ const mobileMenuOpen = ref(false)
const activeTab = ref('macos')
const features = [
{ icon: 'fas fa-brain', title: 'Learns as you work', description: 'Every bug fix, every architecture decision, every "aha moment" - captured automatically without breaking your flow.' },
{ icon: 'fas fa-search', title: 'Two-stage retrieval', description: 'Cross-encoder reranking delivers highly relevant results. Finds what you need even with vague queries like "that auth thing".' },
{ icon: 'fas fa-project-diagram', title: 'Graph-based search', description: 'LEANN Phase 2: Graph relationships between observations (file overlap, semantic similarity, temporal proximity) for smarter context retrieval.' },
{ icon: 'fas fa-microchip', title: 'AST-aware chunking', description: 'Intelligent code splitting respects function boundaries. Go, Python, and TypeScript code is chunked at semantic boundaries, not arbitrary line counts.' },
{ icon: 'fas fa-database', title: 'Hybrid vector storage', description: 'LEANN-inspired selective storage: frequently-accessed "hub" observations store embeddings, others recompute on-demand. 60-80% storage savings with <50ms latency.' },
{ icon: 'fas fa-folder-tree', title: 'Project-aware context', description: 'Your React knowledge stays in React projects. Your Go patterns stay in Go projects. No context pollution.' },
{ icon: 'fas fa-chart-line', title: 'Smart scoring', description: 'Importance decay, pattern detection, and conflict resolution ensure the most valuable memories surface first.' },
{ icon: 'fas fa-gauge-high', title: 'Auto-tuning', description: 'Dynamic hub threshold adjustment based on query performance. Automatically balances storage efficiency with search latency for your workload.' },
{ icon: 'fas fa-lock', title: '100% private', description: 'Your code context never leaves your machine. No telemetry. No cloud sync. Your memories are yours.' },
]
const steps = [
{ number: 1, title: 'Install', description: 'One command. Hooks into Claude Code automatically.' },
{ number: 2, title: 'Work normally', description: 'Code with Claude as usual. Mnemonic listens silently in the background.' },
{ number: 3, title: 'Knowledge builds', description: 'Every session adds to your knowledge base. Bug fixes. Decisions. Patterns.' },
{ number: 4, title: 'Context appears', description: 'Start a new session - relevant memories are already there. No re-explaining.' },
{ icon: 'fas fa-brain', title: 'Session memory', description: 'Captures bug fixes, decisions, and patterns automatically from your Claude Code sessions.' },
{ icon: 'fas fa-magnifying-glass-chart', title: 'Two-stage retrieval', description: 'Bi-encoder embeddings followed by cross-encoder reranking for accurate results.' },
{ icon: 'fas fa-diagram-project', title: 'Knowledge graph', description: 'Automatic relationship detection between observations via file overlap and semantic similarity.' },
{ icon: 'fas fa-folder-tree', title: 'Project isolation', description: 'Each project has its own memory. Your React knowledge doesn\'t leak into Go projects.' },
{ icon: 'fas fa-code', title: 'AST-aware chunking', description: 'Tree-sitter splits Go, Python, and TypeScript code at semantic boundaries.' },
{ icon: 'fas fa-database', title: 'Hybrid vector storage', description: 'Selective embedding caching. 60-80% storage reduction with minimal latency impact.' },
{ icon: 'fas fa-lock', title: 'Local embeddings', description: 'ONNX Runtime with all-MiniLM-L6-v2. No external API calls, everything stays on your machine.' },
{ icon: 'fas fa-gauge', title: 'Live statusline', description: 'Real-time metrics in Claude Code: [mnemonic] ● served:42 | project:28 memories' },
{ icon: 'fas fa-arrows-rotate', title: 'Auto-updates', description: 'Checks for new versions on startup, downloads in the background, applies on restart.' },
]
const installTabs = [
@@ -447,36 +206,37 @@ const installTabs = [
{ id: 'source', label: 'From Source' },
]
const installCommands = {
macos: `curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash`,
windows: `irm https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.ps1 | iex`,
source: `git clone https://github.com/lukaszraczylo/claude-mnemonic.git\ncd claude-mnemonic\nmake build && make install`,
}
const configOptions = [
{ name: 'CLAUDE_MNEMONIC_WORKER_PORT', description: 'HTTP port for the worker service (default: 37777)', icon: 'fas fa-network-wired' },
{ name: 'CLAUDE_MNEMONIC_CONTEXT_OBSERVATIONS', description: 'Maximum observations injected per session (default: 100)', icon: 'fas fa-layer-group' },
{ name: 'CLAUDE_MNEMONIC_RERANKING_ENABLED', description: 'Enable cross-encoder reranking for improved search relevance (default: true)', icon: 'fas fa-sort-amount-down' },
{ name: 'CLAUDE_MNEMONIC_CONTEXT_RELEVANCE_THRESHOLD', description: 'Minimum similarity score for inclusion, 0.0-1.0 (default: 0.3)', icon: 'fas fa-filter' },
{ name: 'CLAUDE_MNEMONIC_VECTOR_STORAGE_STRATEGY', description: 'Storage strategy: "hub" (default), "always", or "on_demand"', icon: 'fas fa-database' },
{ name: 'CLAUDE_MNEMONIC_GRAPH_ENABLED', description: 'Enable graph-based search with observation relationships (default: true)', icon: 'fas fa-project-diagram' },
{ name: 'CLAUDE_MNEMONIC_GRAPH_MAX_HOPS', description: 'Maximum graph traversal depth for search expansion (default: 2)', icon: 'fas fa-route' },
{ name: 'CLAUDE_MNEMONIC_GRAPH_REBUILD_INTERVAL_MIN', description: 'How often to rebuild the observation graph in minutes (default: 60)', icon: 'fas fa-clock' },
{ name: 'WORKER_PORT', description: 'Dashboard and API port (default: 37777)' },
{ name: 'CONTEXT_OBSERVATIONS', description: 'Max memories injected per session (default: 100)' },
{ name: 'CONTEXT_FULL_COUNT', description: 'Full-detail memories, rest condensed (default: 25)' },
{ name: 'CONTEXT_RELEVANCE_THRESHOLD', description: 'Min similarity score 0.0-1.0 (default: 0.3)' },
{ name: 'RERANKING_ENABLED', description: 'Cross-encoder reranking (default: true)' },
{ name: 'VECTOR_STORAGE_STRATEGY', description: 'hub (default), always, or on_demand' },
{ name: 'GRAPH_ENABLED', description: 'Graph-based search with relationships (default: true)' },
{ name: 'EMBEDDING_MODEL', description: 'Embedding model for semantic search (default: bge-v1.5)' },
]
const requiredDeps = [
{ name: 'Claude Code CLI', description: 'Host application - this is a plugin for Claude Code. Uses your existing subscription (Pro/Max) instead of API keys.', icon: 'fas fa-terminal' },
{ name: 'jq', description: 'JSON processor used during installation. Usually pre-installed on most systems.', icon: 'fas fa-code' },
{ name: 'Claude Code CLI', description: 'Host application. Uses your existing subscription.', icon: 'fas fa-terminal' },
{ name: 'jq', description: 'JSON processing during install. Usually pre-installed.', icon: 'fas fa-code' },
]
const techStack = [
{ name: 'Go', description: 'Single binary, fast startup, low memory, zero runtime dependencies.' },
{ name: 'SQLite + FTS5', description: 'Full-text search in a single file. Survives restarts.' },
{ name: 'sqlite-vec', description: 'Vector search embedded in SQLite with LEANN-inspired selective embeddings.' },
{ name: 'BGE reranker', description: 'Cross-encoder for two-stage retrieval: bi-encoder + reranking.' },
{ name: 'Tree-sitter', description: 'AST-aware code chunking for Go, Python, and TypeScript.' },
{ name: 'CSR Graph', description: 'Memory-efficient observation relationship graph with edge detection.' },
]
const faqs = [
{ question: 'Will it confuse Claude with wrong context?', answer: 'No. Mnemonic uses project isolation and semantic relevance scoring. Only memories from the current project (or global best practices) are injected, and only when they\'re actually relevant to your prompt.' },
{ question: 'What exactly gets saved?', answer: 'Bug fixes with context ("Fixed race condition by adding mutex"), architecture decisions ("Using repository pattern for data access"), conventions ("All API routes prefixed with /api/v1"), and learnings you want to preserve.' },
{ question: 'How does hybrid vector storage work?', answer: 'LEANN-inspired selective storage: frequently-accessed "hub" observations (identified by access patterns and graph centrality) store embeddings. Infrequently-accessed observations recompute embeddings on-demand during search. This reduces storage by 60-80% with minimal latency impact (<50ms).' },
{ question: 'Can I delete or edit memories?', answer: 'Yes. The web dashboard at localhost:37777 lets you browse, search, edit, and delete any memory. You can also view graph relationships, storage metrics, and performance analytics. You\'re always in control.' },
{ question: 'Does it work with my existing Claude Code setup?', answer: 'Yes. Mnemonic installs as a Claude Code plugin with hooks. Your existing workflows, settings, and shortcuts remain unchanged.' },
{ question: 'What if I switch between projects frequently?', answer: 'That\'s the point. Each project has isolated memories. Switch from your Python ML project to your TypeScript app - context switches automatically.' },
{ question: 'Is there a performance impact?', answer: 'Minimal. The Go worker is lightweight (typically under 30MB RAM). Hybrid storage and auto-tuning optimize for your workload. Context injection at session start takes milliseconds for most projects.' },
{ question: 'What is AST-aware chunking?', answer: 'When processing code observations, Mnemonic uses Tree-sitter parsers to respect function and class boundaries instead of arbitrary line limits. Go, Python, and TypeScript code is chunked at semantic boundaries for better search accuracy.' },
{ question: 'Will it confuse Claude with wrong context?', answer: 'Project isolation and semantic relevance scoring prevent that. Only memories from the current project (or global best practices) are injected, and only when relevant to your prompt.' },
{ question: 'What gets stored?', answer: 'Bug fixes with context, architecture decisions, project conventions, security patterns — things you\'d otherwise re-explain each session. No raw conversation dumps.' },
{ question: 'Can I delete or edit memories?', answer: 'Yes. The dashboard at localhost:37777 lets you browse, search, edit, and delete anything. You can also view graph relationships and storage metrics.' },
{ question: 'Performance impact?', answer: 'The Go worker uses ~30MB RAM. Context injection at session start takes milliseconds for most projects. Hybrid storage auto-tunes for your workload.' },
{ question: 'Does it work with my existing setup?', answer: 'Yes. Installs as a Claude Code plugin with hooks. Your workflows, settings, and shortcuts stay the same.' },
{ question: 'What if I switch between projects?', answer: 'Each project has isolated memories. Switch from your Python ML project to your TypeScript app — context switches automatically.' },
]
</script>
+5 -8
View File
@@ -1,9 +1,9 @@
<template>
<div class="glass rounded-xl p-4 sm:p-6 relative max-w-4xl mx-auto glow-amber">
<button @click="copyCode" class="absolute top-3 sm:top-4 right-3 sm:right-4 bg-slate-800/50 border border-slate-700/50 text-slate-500 hover:text-white hover:border-slate-600 px-2 sm:px-3 py-1 sm:py-1.5 rounded-md text-xs transition-all">
{{ copied ? 'Copied!' : 'Copy' }}
<div class="rounded-lg border border-neutral-800 bg-neutral-900/30 p-4 relative overflow-hidden">
<button @click="copyCode" class="absolute top-3 right-3 text-neutral-600 hover:text-neutral-300 text-xs transition-colors px-2 py-1 rounded border border-neutral-800 hover:border-neutral-600">
{{ copied ? 'Copied' : 'Copy' }}
</button>
<pre class="text-xs sm:text-sm whitespace-pre-wrap break-all font-mono pr-16 sm:pr-20"><slot></slot></pre>
<pre class="text-sm whitespace-pre-wrap break-all font-mono text-neutral-300 pr-20 leading-relaxed"><code>{{ code }}</code></pre>
</div>
</template>
@@ -11,10 +11,7 @@
import { ref } from 'vue'
const props = defineProps({
code: {
type: String,
default: ''
}
code: { type: String, default: '' },
})
const copied = ref(false)
+14 -11
View File
@@ -1,19 +1,22 @@
<template>
<div class="glass rounded-xl p-4 sm:p-6 hover:border-amber-500/20 transition-colors">
<h3 class="text-white font-semibold text-sm sm:text-base mb-2 sm:mb-3">{{ question }}</h3>
<p class="text-slate-400 text-xs sm:text-sm leading-relaxed">{{ answer }}</p>
<div class="border-b border-neutral-800 last:border-0">
<button @click="open = !open" class="w-full text-left flex items-center justify-between py-4">
<span class="text-white text-sm font-medium pr-4">{{ question }}</span>
<i :class="open ? 'fas fa-minus' : 'fas fa-plus'" class="text-neutral-600 text-xs flex-shrink-0"></i>
</button>
<div v-if="open" class="pb-4">
<p class="text-neutral-500 text-sm leading-relaxed">{{ answer }}</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
question: {
type: String,
required: true
},
answer: {
type: String,
required: true
}
question: { type: String, required: true },
answer: { type: String, required: true },
})
const open = ref(false)
</script>
-27
View File
@@ -1,27 +0,0 @@
<template>
<div class="glass rounded-2xl p-5 sm:p-8 relative overflow-hidden group hover:border-amber-500/30 hover:-translate-y-1 transition-all duration-300">
<div class="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-amber-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div class="w-10 h-10 sm:w-12 sm:h-12 bg-amber-500/15 rounded-xl flex items-center justify-center mb-4 sm:mb-5 text-amber-500 text-lg sm:text-xl">
<i :class="icon"></i>
</div>
<h3 class="text-white font-semibold text-base sm:text-lg mb-2 sm:mb-3">{{ title }}</h3>
<p class="text-slate-400 text-xs sm:text-sm leading-relaxed">{{ description }}</p>
</div>
</template>
<script setup>
defineProps({
icon: {
type: String,
required: true
},
title: {
type: String,
required: true
},
description: {
type: String,
required: true
}
})
</script>
+17
View File
@@ -0,0 +1,17 @@
<template>
<div class="p-5 rounded-lg border border-neutral-800 bg-neutral-900/30">
<div class="flex items-center gap-3 mb-2">
<i :class="[icon, 'text-amber-500 text-sm']"></i>
<h3 class="text-white font-semibold text-sm">{{ title }}</h3>
</div>
<p class="text-neutral-500 text-xs leading-relaxed pl-7">{{ description }}</p>
</div>
</template>
<script setup>
defineProps({
icon: { type: String, required: true },
title: { type: String, required: true },
description: { type: String, required: true },
})
</script>
-28
View File
@@ -1,28 +0,0 @@
<template>
<div class="flex items-start sm:items-center gap-3 sm:gap-4 p-3 sm:p-4 bg-slate-800/50 rounded-lg border-l-4 border-amber-500">
<div class="w-8 h-8 sm:w-10 sm:h-10 bg-amber-500/15 rounded-lg flex items-center justify-center text-amber-500 flex-shrink-0 text-sm sm:text-base">
<i :class="icon"></i>
</div>
<div class="min-w-0">
<strong class="block text-white text-sm sm:text-base mb-0.5 sm:mb-1">{{ title }}</strong>
<span class="text-slate-500 text-xs sm:text-sm block break-words">{{ description }}</span>
</div>
</div>
</template>
<script setup>
defineProps({
icon: {
type: String,
required: true
},
title: {
type: String,
required: true
},
description: {
type: String,
required: true
}
})
</script>
+6 -10
View File
@@ -1,13 +1,13 @@
<template>
<footer class="border-t border-slate-800 py-10 sm:py-16 relative">
<div class="max-w-6xl mx-auto px-4 sm:px-6 flex flex-col md:flex-row justify-between items-center gap-4 sm:gap-6">
<div class="flex flex-wrap justify-center gap-4 sm:gap-6">
<footer class="border-t border-neutral-800/80 py-8 px-4 sm:px-6">
<div class="max-w-5xl mx-auto flex flex-col sm:flex-row justify-between items-center gap-4">
<p class="text-neutral-600 text-xs">MIT License</p>
<div class="flex gap-5">
<a v-for="link in links" :key="link.href" :href="link.href" target="_blank"
class="text-slate-500 hover:text-white text-xs sm:text-sm transition-colors animated-underline">
class="text-neutral-600 hover:text-neutral-400 text-xs transition-colors">
{{ link.label }}
</a>
</div>
<p class="text-slate-600 text-xs sm:text-sm text-center">{{ copyright }}</p>
</div>
</footer>
</template>
@@ -20,12 +20,8 @@ defineProps({
{ label: 'GitHub', href: 'https://github.com/lukaszraczylo/claude-mnemonic' },
{ label: 'Releases', href: 'https://github.com/lukaszraczylo/claude-mnemonic/releases' },
{ label: 'Issues', href: 'https://github.com/lukaszraczylo/claude-mnemonic/issues' },
{ label: 'MIT License', href: 'https://github.com/lukaszraczylo/claude-mnemonic/blob/main/LICENSE' },
{ label: 'License', href: 'https://github.com/lukaszraczylo/claude-mnemonic/blob/main/LICENSE' },
]
},
copyright: {
type: String,
default: 'Made with care for the Claude Code community'
}
})
</script>
+24 -63
View File
@@ -1,44 +1,35 @@
<template>
<section class="min-h-screen flex items-center relative pt-24 sm:pt-28 pb-16 sm:pb-20">
<!-- Background Effects -->
<div class="absolute inset-0 overflow-hidden">
<div class="absolute top-[-50%] left-1/2 -translate-x-1/2 w-[150%] h-full bg-[radial-gradient(ellipse_at_center,rgba(245,158,11,0.12)_0%,transparent_60%)] opacity-60"></div>
</div>
<section class="pt-24 pb-8 sm:pt-32 sm:pb-12 px-4 sm:px-6">
<div class="max-w-5xl mx-auto">
<p class="text-amber-500 text-sm font-medium mb-4 tracking-wide">Persistent memory for Claude Code</p>
<div class="relative z-10 max-w-6xl mx-auto px-4 sm:px-6 text-center">
<div class="inline-flex items-center gap-2 glass px-3 sm:px-4 py-1.5 sm:py-2 rounded-full text-xs sm:text-sm text-slate-400 mb-6 sm:mb-8 opacity-0 animate-fade-in-up">
<span class="w-2 h-2 bg-amber-500 rounded-full animate-pulse-slow"></span>
{{ badge }}
</div>
<h1 class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold text-white mb-4 sm:mb-6 leading-tight opacity-0 animate-fade-in-up animation-delay-100">
{{ titleBefore }}<br class="hidden sm:block">
<span class="sm:hidden"> </span>
<span class="text-gradient">{{ titleHighlight }}</span>
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-[1.1] mb-6 max-w-3xl">
Stop re-explaining your codebase every session.
</h1>
<p class="text-base sm:text-lg md:text-xl text-slate-400 max-w-2xl mx-auto mb-8 sm:mb-10 opacity-0 animate-fade-in-up animation-delay-200 px-2">
{{ subtitle }}
<p class="text-neutral-400 text-lg sm:text-xl max-w-2xl mb-10 leading-relaxed">
Claude Code forgets everything when your session ends. Mnemonic captures what you've already explained — bug fixes, architecture decisions, patterns — and brings it back when relevant.
</p>
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center mb-12 sm:mb-16 opacity-0 animate-fade-in-up animation-delay-300 px-4 sm:px-0">
<a :href="primaryCta.href" class="inline-flex items-center justify-center gap-2 bg-gradient-to-br from-amber-500 to-amber-400 text-slate-950 px-6 sm:px-8 py-3 sm:py-3.5 rounded-lg font-semibold hover:scale-105 hover:shadow-lg hover:shadow-amber-500/30 transition-all">
<i :class="primaryCta.icon"></i>
{{ primaryCta.label }}
<div class="flex flex-col sm:flex-row gap-3 mb-12">
<a href="#install" class="inline-flex items-center justify-center gap-2 bg-white text-neutral-900 px-6 py-3 rounded-lg font-semibold text-sm hover:bg-neutral-200 transition-colors">
Install now
</a>
<a :href="secondaryCta.href" target="_blank" class="inline-flex items-center justify-center gap-2 glass text-white px-6 sm:px-8 py-3 sm:py-3.5 rounded-lg font-medium hover:border-amber-500/30 hover:bg-amber-500/10 transition-all">
<i :class="secondaryCta.icon"></i>
{{ secondaryCta.label }}
<a href="https://github.com/lukaszraczylo/claude-mnemonic" target="_blank" class="inline-flex items-center justify-center gap-2 border border-neutral-700 text-neutral-400 px-6 py-3 rounded-lg font-medium text-sm hover:border-neutral-500 hover:text-white transition-colors">
<i class="fab fa-github"></i> View source
</a>
</div>
<!-- Install Command -->
<div class="opacity-0 animate-fade-in-up animation-delay-400 px-2 sm:px-0">
<div class="glass rounded-xl px-4 sm:px-6 py-4 sm:py-5 flex flex-col sm:flex-row items-center justify-between gap-3 sm:gap-4 max-w-4xl mx-auto glow-amber">
<code class="text-amber-400 text-xs sm:text-sm md:text-base flex-1 text-center sm:text-left break-all font-mono">{{ installCommand }}</code>
<button @click="copyCommand" class="text-slate-500 hover:text-white transition-colors p-2 flex-shrink-0 rounded-lg hover:bg-slate-800/50">
<i :class="copied ? 'fas fa-check text-amber-500' : 'fas fa-copy'"></i>
<span class="ml-2 text-sm sm:hidden">{{ copied ? 'Copied!' : 'Copy' }}</span>
<div class="max-w-2xl">
<div class="flex items-stretch bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
<div class="flex items-center px-3 gap-1.5 border-r border-neutral-800">
<span class="w-3 h-3 rounded-full bg-neutral-700"></span>
<span class="w-3 h-3 rounded-full bg-neutral-700"></span>
<span class="w-3 h-3 rounded-full bg-neutral-700"></span>
</div>
<code class="flex-1 text-neutral-300 text-xs sm:text-sm px-4 py-3 font-mono overflow-x-auto">{{ installCommand }}</code>
<button @click="copyCommand" class="px-4 text-neutral-600 hover:text-neutral-300 transition-colors border-l border-neutral-800 text-sm" title="Copy">
<i :class="copied ? 'fas fa-check text-emerald-500' : 'fas fa-copy'"></i>
</button>
</div>
</div>
@@ -49,41 +40,11 @@
<script setup>
import { ref } from 'vue'
defineProps({
badge: {
type: String,
default: 'Persistent Memory for Claude Code'
},
titleBefore: {
type: String,
default: 'Claude forgets.'
},
titleHighlight: {
type: String,
default: 'Make it remember.'
},
subtitle: {
type: String,
default: 'Capture learnings, decisions, and patterns from your coding sessions. Bring that knowledge back in every future conversation.'
},
primaryCta: {
type: Object,
default: () => ({ label: 'Install Now', href: '#installation', icon: 'fas fa-download' })
},
secondaryCta: {
type: Object,
default: () => ({ label: 'View Source', href: 'https://github.com/lukaszraczylo/claude-mnemonic', icon: 'fab fa-github' })
},
installCommand: {
type: String,
default: 'curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash'
}
})
const installCommand = 'curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash'
const copied = ref(false)
function copyCommand() {
navigator.clipboard.writeText('curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash')
navigator.clipboard.writeText(installCommand)
copied.value = true
setTimeout(() => copied.value = false, 2000)
}
+19 -44
View File
@@ -1,70 +1,45 @@
<template>
<nav class="fixed top-0 left-0 right-0 z-50 bg-gradient-to-b from-slate-950 via-slate-950/95 to-transparent backdrop-blur-md">
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-4 sm:py-5 flex justify-between items-center">
<a href="#" class="flex items-center gap-2 sm:gap-3 text-white font-bold text-xl sm:text-2xl">
<div class="w-8 h-8 sm:w-9 sm:h-9 bg-gradient-to-br from-amber-500 to-amber-400 rounded-lg flex items-center justify-center">
<i class="fas fa-brain text-slate-950 text-sm sm:text-base"></i>
</div>
{{ title }}
</a>
<nav class="border-b border-neutral-800/80 sticky top-0 z-50 bg-[#09090b]/90 backdrop-blur-sm">
<div class="max-w-5xl mx-auto px-4 sm:px-6 h-14 flex justify-between items-center">
<a href="#" class="text-white font-bold text-base tracking-tight">Claude Mnemonic</a>
<ul class="hidden md:flex gap-6 lg:gap-8">
<ul class="hidden md:flex items-center gap-6">
<li v-for="link in links" :key="link.href">
<a :href="link.href" class="text-slate-400 hover:text-white text-sm font-medium transition-colors animated-underline">
{{ link.label }}
</a>
<a :href="link.href" class="text-neutral-500 hover:text-white text-sm transition-colors">{{ link.label }}</a>
</li>
</ul>
<div class="flex items-center gap-3 sm:gap-4">
<a :href="githubUrl" target="_blank" class="text-slate-400 hover:text-white text-lg sm:text-xl transition-colors">
<div class="flex items-center gap-4">
<a :href="githubUrl" target="_blank" class="text-neutral-500 hover:text-white transition-colors text-lg">
<i class="fab fa-github"></i>
</a>
<a href="#installation" class="hidden sm:inline-flex bg-gradient-to-br from-amber-500 to-amber-400 text-slate-950 px-4 sm:px-5 py-2 sm:py-2.5 rounded-lg font-semibold text-sm hover:scale-105 hover:shadow-lg hover:shadow-amber-500/30 transition-all">
Get Started
<a href="#install" class="hidden sm:inline-flex items-center gap-2 text-sm font-medium text-amber-500 hover:text-amber-400 transition-colors">
Install <i class="fas fa-arrow-right text-xs"></i>
</a>
<button @click="$emit('toggle-menu')" class="md:hidden text-white text-xl p-2">
<button @click="$emit('toggle-menu')" class="md:hidden text-neutral-500 hover:text-white p-1">
<i :class="mobileMenuOpen ? 'fas fa-times' : 'fas fa-bars'"></i>
</button>
</div>
</div>
<!-- Mobile Menu -->
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="mobileMenuOpen" class="md:hidden bg-slate-900/95 backdrop-blur-xl border-t border-slate-800 px-4 sm:px-6 py-4">
<a v-for="link in links" :key="link.href" :href="link.href"
@click="$emit('toggle-menu')"
class="block py-3 text-slate-400 hover:text-white border-b border-slate-800 last:border-0 transition-colors">
{{ link.label }}
</a>
<a href="#installation" @click="$emit('toggle-menu')"
class="mt-4 block text-center bg-gradient-to-br from-amber-500 to-amber-400 text-slate-950 px-5 py-2.5 rounded-lg font-semibold text-sm">
Get Started
</a>
</div>
</Transition>
<div v-if="mobileMenuOpen" class="md:hidden border-t border-neutral-800/80 px-6 py-4 space-y-1">
<a v-for="link in links" :key="link.href" :href="link.href"
@click="$emit('toggle-menu')"
class="block py-2 text-neutral-500 hover:text-white text-sm transition-colors">
{{ link.label }}
</a>
</div>
</nav>
</template>
<script setup>
defineProps({
title: {
type: String,
default: 'Mnemonic'
},
links: {
type: Array,
default: () => [
{ label: 'Features', href: '#features' },
{ label: 'How It Works', href: '#how-it-works' },
{ label: 'Install', href: '#installation' },
{ label: 'Install', href: '#install' },
{ label: 'Config', href: '#config' },
{ label: 'FAQ', href: '#faq' },
]
},
-20
View File
@@ -1,20 +0,0 @@
<template>
<div class="text-center mb-10 sm:mb-16">
<h2 class="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3 sm:mb-4">
{{ title }}
</h2>
<p v-if="subtitle" class="text-sm sm:text-base md:text-lg text-slate-400 max-w-xl mx-auto px-2">
{{ subtitle }}
</p>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true
},
subtitle: String
})
</script>
-26
View File
@@ -1,26 +0,0 @@
<template>
<div class="text-center relative">
<div class="w-14 h-14 sm:w-20 sm:h-20 bg-slate-900 border-2 border-amber-500 rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6 text-amber-500 font-bold text-xl sm:text-3xl relative z-10">
{{ number }}
</div>
<h3 class="text-white font-semibold text-base sm:text-lg mb-2 sm:mb-3">{{ title }}</h3>
<p class="text-slate-400 text-xs sm:text-sm">{{ description }}</p>
</div>
</template>
<script setup>
defineProps({
number: {
type: [String, Number],
required: true
},
title: {
type: String,
required: true
},
description: {
type: String,
required: true
}
})
</script>
+13
View File
@@ -0,0 +1,13 @@
<template>
<div class="p-4 rounded-lg border border-neutral-800">
<div class="text-white font-semibold text-sm mb-1">{{ name }}</div>
<p class="text-neutral-500 text-xs leading-relaxed">{{ description }}</p>
</div>
</template>
<script setup>
defineProps({
name: { type: String, required: true },
description: { type: String, required: true },
})
</script>
+9 -97
View File
@@ -1,6 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@theme {
--font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
--font-mono: 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
@layer base {
html {
@@ -10,105 +13,14 @@
}
body {
@apply font-sans text-slate-300;
}
h1, h2, h3, h4, h5, h6 {
@apply font-sans font-bold tracking-tight;
@apply font-sans text-neutral-300 bg-[#09090b];
}
code, pre {
@apply font-mono;
}
}
@layer components {
/* Animated gradient background */
.bg-animated-gradient {
background: linear-gradient(
135deg,
rgba(245, 158, 11, 0.1) 0%,
transparent 50%,
rgba(245, 158, 11, 0.05) 100%
);
background-size: 200% 200%;
animation: gradientShift 8s ease-in-out infinite;
}
/* Floating particles */
.particle {
position: absolute;
width: 4px;
height: 4px;
background: rgba(245, 158, 11, 0.4);
border-radius: 50%;
pointer-events: none;
}
/* Glass effect */
.glass {
@apply bg-slate-900/60 backdrop-blur-xl border border-slate-700/50;
}
/* Glow effect */
.glow-amber {
box-shadow: 0 0 40px rgba(245, 158, 11, 0.15);
}
/* Animated underline */
.animated-underline {
position: relative;
}
.animated-underline::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background: linear-gradient(90deg, #f59e0b, #fbbf24);
transition: width 0.3s ease;
}
.animated-underline:hover::after {
width: 100%;
}
/* Animated border */
.animated-border {
position: relative;
overflow: hidden;
}
.animated-border::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 1px;
background: linear-gradient(90deg, transparent, #f59e0b, transparent);
animation: borderSlide 3s linear infinite;
}
@keyframes borderSlide {
0% { left: -100%; }
100% { left: 100%; }
}
}
@layer utilities {
.animation-delay-100 { animation-delay: 0.1s; }
.animation-delay-200 { animation-delay: 0.2s; }
.animation-delay-300 { animation-delay: 0.3s; }
.animation-delay-400 { animation-delay: 0.4s; }
.animation-delay-500 { animation-delay: 0.5s; }
/* Text gradient */
.text-gradient {
@apply bg-gradient-to-r from-amber-400 to-amber-500 bg-clip-text text-transparent;
}
/* Opacity starting state for animations */
.opacity-0-start {
opacity: 0;
::selection {
@apply bg-amber-500/30 text-white;
}
}
-63
View File
@@ -1,63 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
amber: {
DEFAULT: '#f59e0b',
light: '#fbbf24',
glow: 'rgba(245, 158, 11, 0.15)',
},
slate: {
950: '#0a0f1a',
}
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
mono: ['SF Mono', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', 'monospace'],
},
animation: {
'fade-in-up': 'fadeInUp 0.8s ease-out forwards',
'pulse-slow': 'pulse 3s ease-in-out infinite',
'float': 'float 6s ease-in-out infinite',
'float-delayed': 'float 6s ease-in-out 3s infinite',
'glow': 'glow 4s ease-in-out infinite',
'gradient-shift': 'gradientShift 8s ease-in-out infinite',
'particle': 'particle 20s linear infinite',
},
keyframes: {
fadeInUp: {
'0%': { opacity: '0', transform: 'translateY(30px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
float: {
'0%, 100%': { transform: 'translateY(0px)' },
'50%': { transform: 'translateY(-20px)' },
},
glow: {
'0%, 100%': { opacity: '0.5' },
'50%': { opacity: '0.8' },
},
gradientShift: {
'0%, 100%': { backgroundPosition: '0% 50%' },
'50%': { backgroundPosition: '100% 50%' },
},
particle: {
'0%': { transform: 'translateY(100vh) rotate(0deg)', opacity: '0' },
'10%': { opacity: '1' },
'90%': { opacity: '1' },
'100%': { transform: 'translateY(-100vh) rotate(720deg)', opacity: '0' },
},
},
backgroundSize: {
'200%': '200% 200%',
},
},
},
plugins: [],
}
+13 -12
View File
@@ -6,17 +6,18 @@ 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.3
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/fsnotify/fsnotify v1.10.1
github.com/go-chi/chi/v5 v5.3.0
github.com/go-gormigrate/gormigrate/v2 v2.1.6
github.com/goccy/go-json v0.10.6
github.com/lukaszraczylo/oss-telemetry v0.2.3
github.com/mattn/go-sqlite3 v1.14.45
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.31.0
golang.org/x/sync v0.21.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
@@ -26,15 +27,15 @@ 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/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-colorable v0.1.15 // 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.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+26 -34
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.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
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/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.6 h1:VtX+l1Stj2v5RGubVQk0LS/8EPGXR+ldcOyCmlmKoyg=
github.com/go-gormigrate/gormigrate/v2 v2.1.6/go.mod h1:PZpedQc4tWaxn6kvXicwhinh3L0seLpMc5ReKRX5id4=
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/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/lukaszraczylo/oss-telemetry v0.2.3 h1:xoDtBqeZGmXj7IteiE1M5WMuzeoqag58qEleI0Cf2Ms=
github.com/lukaszraczylo/oss-telemetry v0.2.3/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
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.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
github.com/mattn/go-sqlite3 v1.14.45/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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.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.31.0 h1:1ln4YW1SFOFfGJZXe3jNOb2JUSt+l2pEneZfV8HdtFA=
github.com/yalue/onnxruntime_go v1.31.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
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
+298
View File
@@ -0,0 +1,298 @@
package python
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
)
// =============================================================================
// TEST HELPERS
// =============================================================================
func createTempPythonFile(t *testing.T, content string) string {
t.Helper()
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "test.py")
err := os.WriteFile(filePath, []byte(content), 0600)
require.NoError(t, err)
return filePath
}
// =============================================================================
// TESTS FOR Chunker
// =============================================================================
func TestNewChunker(t *testing.T) {
t.Parallel()
opts := chunking.DefaultChunkOptions()
c := NewChunker(opts)
assert.NotNil(t, c)
assert.NotNil(t, c.parser)
}
func TestChunker_Language(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
assert.Equal(t, chunking.LanguagePython, c.Language())
}
func TestChunker_SupportedExtensions(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
exts := c.SupportedExtensions()
assert.Contains(t, exts, ".py")
}
func TestChunker_Chunk_SimpleFunction(t *testing.T) {
t.Parallel()
code := `def greet(name):
"""Greets a person by name."""
return f"Hello, {name}!"
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find the greet function
var foundGreet bool
for _, chunk := range chunks {
if chunk.Name == "greet" {
foundGreet = true
assert.Equal(t, chunking.ChunkTypeFunction, chunk.Type)
assert.Equal(t, chunking.LanguagePython, chunk.Language)
assert.Contains(t, chunk.Content, "def greet")
}
}
assert.True(t, foundGreet, "Should find 'greet' function")
}
func TestChunker_Chunk_ClassWithMethods(t *testing.T) {
t.Parallel()
code := `class Calculator:
"""A simple calculator class."""
def add(self, a, b):
"""Adds two numbers."""
return a + b
def multiply(self, a, b):
"""Multiplies two numbers."""
return a * b
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find the Calculator class and its methods
var foundClass, foundAdd, foundMultiply bool
for _, chunk := range chunks {
switch chunk.Name {
case "Calculator":
foundClass = true
assert.Equal(t, chunking.ChunkTypeClass, chunk.Type)
case "add":
foundAdd = true
assert.Equal(t, chunking.ChunkTypeMethod, chunk.Type)
assert.Equal(t, "Calculator", chunk.ParentName)
case "multiply":
foundMultiply = true
assert.Equal(t, chunking.ChunkTypeMethod, chunk.Type)
assert.Equal(t, "Calculator", chunk.ParentName)
}
}
assert.True(t, foundClass, "Should find 'Calculator' class")
assert.True(t, foundAdd, "Should find 'add' method")
assert.True(t, foundMultiply, "Should find 'multiply' method")
}
func TestChunker_Chunk_MultipleFunctions(t *testing.T) {
t.Parallel()
code := `def first_function():
pass
def second_function(x, y):
return x + y
def third_function():
"""Has a docstring."""
return 42
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
// Should find all three functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeFunction {
functionNames[chunk.Name] = true
}
}
assert.True(t, functionNames["first_function"])
assert.True(t, functionNames["second_function"])
assert.True(t, functionNames["third_function"])
}
func TestChunker_Chunk_FileNotFound(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
_, err := c.Chunk(context.Background(), "/nonexistent/path/file.py")
require.Error(t, err)
assert.Contains(t, err.Error(), "read file")
}
func TestChunker_Chunk_EmptyFile(t *testing.T) {
t.Parallel()
filePath := createTempPythonFile(t, "")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
assert.Empty(t, chunks)
}
func TestChunker_Chunk_OnlyComments(t *testing.T) {
t.Parallel()
code := `# This is a comment
# Another comment
"""
This is a module docstring
"""
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
// Comments and docstrings without code should not produce chunks
assert.Empty(t, chunks)
}
func TestChunker_Chunk_NestedClass(t *testing.T) {
t.Parallel()
code := `class Outer:
class Inner:
def inner_method(self):
pass
def outer_method(self):
pass
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find the Outer class at minimum
var foundOuter bool
for _, chunk := range chunks {
if chunk.Name == "Outer" {
foundOuter = true
}
}
assert.True(t, foundOuter, "Should find 'Outer' class")
}
func TestChunker_Chunk_Decorators(t *testing.T) {
t.Parallel()
code := `@staticmethod
def static_func():
pass
@classmethod
def class_func(cls):
pass
@property
def my_property(self):
return self._value
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find decorated functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
functionNames[chunk.Name] = true
}
assert.True(t, functionNames["static_func"])
assert.True(t, functionNames["class_func"])
assert.True(t, functionNames["my_property"])
}
func TestChunker_Chunk_AsyncFunction(t *testing.T) {
t.Parallel()
code := `async def fetch_data(url):
"""Fetches data from URL asynchronously."""
pass
async def process_items(items):
for item in items:
await process(item)
`
filePath := createTempPythonFile(t, code)
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find async functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
functionNames[chunk.Name] = true
}
assert.True(t, functionNames["fetch_data"])
assert.True(t, functionNames["process_items"])
}
+213
View File
@@ -0,0 +1,213 @@
package chunking
import (
"testing"
"github.com/stretchr/testify/assert"
)
// =============================================================================
// TESTS FOR Chunk METHODS
// =============================================================================
func TestChunk_Identifier(t *testing.T) {
tests := []struct {
name string
expected string
chunk Chunk
}{
// ===== GOOD CASES =====
{
name: "top-level function",
chunk: Chunk{
Name: "MyFunction",
ParentName: "",
},
expected: "MyFunction",
},
{
name: "method with parent",
chunk: Chunk{
Name: "Process",
ParentName: "Handler",
},
expected: "Handler.Process",
},
{
name: "nested method",
chunk: Chunk{
Name: "Validate",
ParentName: "UserService",
},
expected: "UserService.Validate",
},
// ===== EDGE CASES =====
{
name: "empty name",
chunk: Chunk{
Name: "",
ParentName: "",
},
expected: "",
},
{
name: "parent but no name",
chunk: Chunk{
Name: "",
ParentName: "Parent",
},
expected: "Parent.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.chunk.Identifier()
assert.Equal(t, tt.expected, result)
})
}
}
func TestChunk_LineRange(t *testing.T) {
tests := []struct {
name string
expected string
chunk Chunk
}{
// ===== GOOD CASES =====
{
name: "single line",
chunk: Chunk{
StartLine: 10,
EndLine: 10,
},
expected: "L10-L10",
},
{
name: "multi-line",
chunk: Chunk{
StartLine: 25,
EndLine: 50,
},
expected: "L25-L50",
},
// ===== EDGE CASES =====
{
name: "line 1",
chunk: Chunk{
StartLine: 1,
EndLine: 5,
},
expected: "L1-L5",
},
{
name: "large line numbers",
chunk: Chunk{
StartLine: 1000,
EndLine: 2500,
},
expected: "L1000-L2500",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.chunk.LineRange()
assert.Equal(t, tt.expected, result)
})
}
}
func TestChunk_SearchableContent(t *testing.T) {
tests := []struct {
name string
contains []string
chunk Chunk
}{
// ===== GOOD CASES =====
{
name: "full chunk with all fields",
chunk: Chunk{
Signature: "func ProcessData(input []byte) error",
DocComment: "// ProcessData handles incoming data",
Content: "func ProcessData(input []byte) error {\n\treturn nil\n}",
},
contains: []string{
"func ProcessData(input []byte) error",
"ProcessData handles incoming data",
"return nil",
},
},
{
name: "only signature",
chunk: Chunk{
Signature: "func Hello()",
},
contains: []string{"func Hello()"},
},
{
name: "only content",
chunk: Chunk{
Content: "some code here",
},
contains: []string{"some code here"},
},
// ===== EDGE CASES =====
{
name: "empty chunk",
chunk: Chunk{},
contains: []string{},
},
{
name: "only doc comment",
chunk: Chunk{
DocComment: "// Important documentation",
},
contains: []string{"Important documentation"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.chunk.SearchableContent()
for _, expected := range tt.contains {
assert.Contains(t, result, expected)
}
})
}
}
func TestDefaultChunkOptions(t *testing.T) {
opts := DefaultChunkOptions()
assert.Greater(t, opts.MaxChunkSize, 0, "MaxChunkSize should be positive")
assert.True(t, opts.IncludeDocComments, "IncludeDocComments should be true by default")
assert.True(t, opts.IncludePrivate, "IncludePrivate should be true by default")
assert.Equal(t, 0, opts.MinLines, "MinLines should be 0 by default")
}
// =============================================================================
// TESTS FOR ChunkType AND Language CONSTANTS
// =============================================================================
func TestChunkType_Values(t *testing.T) {
// Ensure all chunk types have expected values
assert.Equal(t, ChunkType("function"), ChunkTypeFunction)
assert.Equal(t, ChunkType("method"), ChunkTypeMethod)
assert.Equal(t, ChunkType("class"), ChunkTypeClass)
assert.Equal(t, ChunkType("interface"), ChunkTypeInterface)
assert.Equal(t, ChunkType("type"), ChunkTypeType)
assert.Equal(t, ChunkType("const"), ChunkTypeConst)
assert.Equal(t, ChunkType("var"), ChunkTypeVar)
}
func TestLanguage_Values(t *testing.T) {
// Ensure all language types have expected values
assert.Equal(t, Language("go"), LanguageGo)
assert.Equal(t, Language("python"), LanguagePython)
assert.Equal(t, Language("typescript"), LanguageTypeScript)
assert.Equal(t, Language("javascript"), LanguageJavaScript)
}
@@ -0,0 +1,398 @@
package typescript
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
)
// =============================================================================
// TEST HELPERS
// =============================================================================
func createTempTSFile(t *testing.T, content string, ext string) string {
t.Helper()
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "test"+ext)
err := os.WriteFile(filePath, []byte(content), 0600)
require.NoError(t, err)
return filePath
}
// =============================================================================
// TESTS FOR Chunker
// =============================================================================
func TestNewChunker(t *testing.T) {
t.Parallel()
opts := chunking.DefaultChunkOptions()
c := NewChunker(opts)
assert.NotNil(t, c)
assert.NotNil(t, c.parser)
}
func TestChunker_Language(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
assert.Equal(t, chunking.LanguageTypeScript, c.Language())
}
func TestChunker_SupportedExtensions(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
exts := c.SupportedExtensions()
assert.Contains(t, exts, ".ts")
assert.Contains(t, exts, ".tsx")
assert.Contains(t, exts, ".js")
assert.Contains(t, exts, ".jsx")
}
func TestChunker_Chunk_SimpleFunction(t *testing.T) {
t.Parallel()
code := `function greet(name: string): string {
return "Hello, " + name + "!";
}
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find the greet function
var foundGreet bool
for _, chunk := range chunks {
if chunk.Name == "greet" {
foundGreet = true
assert.Equal(t, chunking.ChunkTypeFunction, chunk.Type)
assert.Equal(t, chunking.LanguageTypeScript, chunk.Language)
assert.Contains(t, chunk.Content, "function greet")
}
}
assert.True(t, foundGreet, "Should find 'greet' function")
}
func TestChunker_Chunk_ClassWithMethods(t *testing.T) {
t.Parallel()
code := `class Calculator {
add(a: number, b: number): number {
return a + b;
}
multiply(a: number, b: number): number {
return a * b;
}
}
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find the Calculator class and its methods
var foundClass, foundAdd, foundMultiply bool
for _, chunk := range chunks {
switch chunk.Name {
case "Calculator":
foundClass = true
assert.Equal(t, chunking.ChunkTypeClass, chunk.Type)
case "add":
foundAdd = true
assert.Equal(t, chunking.ChunkTypeMethod, chunk.Type)
assert.Equal(t, "Calculator", chunk.ParentName)
case "multiply":
foundMultiply = true
assert.Equal(t, chunking.ChunkTypeMethod, chunk.Type)
assert.Equal(t, "Calculator", chunk.ParentName)
}
}
assert.True(t, foundClass, "Should find 'Calculator' class")
assert.True(t, foundAdd, "Should find 'add' method")
assert.True(t, foundMultiply, "Should find 'multiply' method")
}
func TestChunker_Chunk_Interface(t *testing.T) {
t.Parallel()
code := `interface User {
id: number;
name: string;
email: string;
}
interface Authenticator {
login(username: string, password: string): boolean;
logout(): void;
}
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find interfaces
interfaceNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeInterface {
interfaceNames[chunk.Name] = true
}
}
assert.True(t, interfaceNames["User"])
assert.True(t, interfaceNames["Authenticator"])
}
func TestChunker_Chunk_TypeAlias(t *testing.T) {
t.Parallel()
code := `type UserID = string;
type Handler = (event: Event) => void;
type Result<T> = { success: true; data: T } | { success: false; error: Error };
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find type aliases
typeNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeType {
typeNames[chunk.Name] = true
}
}
assert.True(t, typeNames["UserID"])
assert.True(t, typeNames["Handler"])
assert.True(t, typeNames["Result"])
}
func TestChunker_Chunk_ArrowFunction(t *testing.T) {
t.Parallel()
code := `const add = (a: number, b: number): number => a + b;
const greet = (name: string): string => {
return "Hello, " + name;
};
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
_, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
// Arrow functions may or may not be captured depending on AST structure
// At minimum, no error should occur
}
func TestChunker_Chunk_FileNotFound(t *testing.T) {
t.Parallel()
c := NewChunker(chunking.DefaultChunkOptions())
_, err := c.Chunk(context.Background(), "/nonexistent/path/file.ts")
require.Error(t, err)
assert.Contains(t, err.Error(), "read file")
}
func TestChunker_Chunk_EmptyFile(t *testing.T) {
t.Parallel()
filePath := createTempTSFile(t, "", ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
assert.Empty(t, chunks)
}
func TestChunker_Chunk_OnlyComments(t *testing.T) {
t.Parallel()
code := `// This is a comment
/* Another comment */
/**
* JSDoc comment
*/
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
// Comments without code should not produce chunks
assert.Empty(t, chunks)
}
func TestChunker_Chunk_AsyncFunction(t *testing.T) {
t.Parallel()
code := `async function fetchData(url: string): Promise<any> {
const response = await fetch(url);
return response.json();
}
async function processItems(items: string[]): Promise<void> {
for (const item of items) {
await process(item);
}
}
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find async functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeFunction {
functionNames[chunk.Name] = true
}
}
assert.True(t, functionNames["fetchData"])
assert.True(t, functionNames["processItems"])
}
func TestChunker_Chunk_ExportedFunction(t *testing.T) {
t.Parallel()
code := `export function publicFunction(): void {
console.log("public");
}
export default function defaultExport(): void {
console.log("default");
}
`
filePath := createTempTSFile(t, code, ".ts")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find exported functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeFunction {
functionNames[chunk.Name] = true
}
}
assert.True(t, functionNames["publicFunction"])
assert.True(t, functionNames["defaultExport"])
}
func TestChunker_Chunk_JSXFile(t *testing.T) {
t.Parallel()
code := `function Button({ label }: { label: string }) {
return <button>{label}</button>;
}
function App() {
return (
<div>
<Button label="Click me" />
</div>
);
}
`
filePath := createTempTSFile(t, code, ".tsx")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find JSX components as functions
functionNames := make(map[string]bool)
for _, chunk := range chunks {
if chunk.Type == chunking.ChunkTypeFunction {
functionNames[chunk.Name] = true
}
}
assert.True(t, functionNames["Button"])
assert.True(t, functionNames["App"])
}
func TestChunker_Chunk_JavaScript(t *testing.T) {
t.Parallel()
code := `function simpleFunc() {
return 42;
}
class MyClass {
constructor() {
this.value = 0;
}
getValue() {
return this.value;
}
}
`
filePath := createTempTSFile(t, code, ".js")
c := NewChunker(chunking.DefaultChunkOptions())
chunks, err := c.Chunk(context.Background(), filePath)
require.NoError(t, err)
require.NotEmpty(t, chunks)
// Should find JavaScript functions and classes
var foundFunc, foundClass bool
for _, chunk := range chunks {
if chunk.Name == "simpleFunc" {
foundFunc = true
}
if chunk.Name == "MyClass" {
foundClass = true
}
}
assert.True(t, foundFunc, "Should find 'simpleFunc' function")
assert.True(t, foundClass, "Should find 'MyClass' class")
}
+79 -34
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
)
@@ -38,40 +39,46 @@ var CriticalConcepts = []string{
// Config holds the application configuration.
// Field order optimized for memory alignment (fieldalignment).
type Config struct {
ContextFullField string `json:"context_full_field"`
DBPath string `json:"db_path"`
Model string `json:"model"`
ClaudeCodePath string `json:"claude_code_path"`
EmbeddingModel string `json:"embedding_model"`
VectorStorageStrategy string `json:"vector_storage_strategy"`
ContextObsConcepts []string `json:"context_obs_concepts"`
ContextObsTypes []string `json:"context_obs_types"`
ContextFullCount int `json:"context_full_count"`
GraphBranchFactor int `json:"graph_branch_factor"`
GraphEdgeWeight float64 `json:"graph_edge_weight"`
ContextRelevanceThreshold float64 `json:"context_relevance_threshold"`
RerankingCandidates int `json:"reranking_candidates"`
WorkerPort int `json:"worker_port"`
RerankingMinImprovement float64 `json:"reranking_min_improvement"`
ContextObservations int `json:"context_observations"`
ContextMaxPromptResults int `json:"context_max_prompt_results"`
ContextSessionCount int `json:"context_session_count"`
MaxConns int `json:"max_conns"`
RerankingAlpha float64 `json:"reranking_alpha"`
GraphMaxHops int `json:"graph_max_hops"`
RerankingResults int `json:"reranking_results"`
GraphRebuildIntervalMin int `json:"graph_rebuild_interval_min"`
HubThreshold int `json:"hub_threshold"`
ObservationRetentionDays int `json:"observation_retention_days"`
MaintenanceIntervalHours int `json:"maintenance_interval_hours"`
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"`
MaintenanceEnabled bool `json:"maintenance_enabled"`
RerankingEnabled bool `json:"reranking_enabled"`
ContextShowLastSummary bool `json:"context_show_last_summary"`
CleanupStaleObservations bool `json:"cleanup_stale_observations"`
ContextFullField string `json:"context_full_field"`
DBPath string `json:"db_path"`
Model string `json:"model"`
ClaudeCodePath string `json:"claude_code_path"`
EmbeddingModel string `json:"embedding_model"`
VectorStorageStrategy string `json:"vector_storage_strategy"`
ContextObsConcepts []string `json:"context_obs_concepts"`
ContextObsTypes []string `json:"context_obs_types"`
ContextFullCount int `json:"context_full_count"`
GraphBranchFactor int `json:"graph_branch_factor"`
GraphEdgeWeight float64 `json:"graph_edge_weight"`
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"`
ContextSessionCount int `json:"context_session_count"`
MaxConns int `json:"max_conns"`
RerankingAlpha float64 `json:"reranking_alpha"`
GraphMaxHops int `json:"graph_max_hops"`
RerankingResults int `json:"reranking_results"`
GraphRebuildIntervalMin int `json:"graph_rebuild_interval_min"`
HubThreshold int `json:"hub_threshold"`
ObservationRetentionDays int `json:"observation_retention_days"`
MaintenanceIntervalHours int `json:"maintenance_interval_hours"`
WALCheckpointIntervalSeconds int `json:"wal_checkpoint_interval_seconds"`
WALCheckpointThresholdBytes int64 `json:"wal_checkpoint_threshold_bytes"`
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"`
CleanupStaleObservations bool `json:"cleanup_stale_observations"`
}
var (
@@ -168,10 +175,18 @@ 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)
CleanupStaleObservations: false, // Don't auto-cleanup stale observations
// WAL checkpoint loop tunables (issue #49). Defaults mirror the worker constants:
// check the WAL every 60s and TRUNCATE-checkpoint once it reaches 4 MiB.
WALCheckpointIntervalSeconds: 60,
WALCheckpointThresholdBytes: 4 << 20, // 4 MiB
}
}
@@ -269,6 +284,36 @@ 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)
}
// WAL checkpoint loop tunables (issue #49)
if v, ok := settings["CLAUDE_MNEMONIC_WAL_CHECKPOINT_INTERVAL_SECONDS"].(float64); ok && v > 0 {
cfg.WALCheckpointIntervalSeconds = int(v)
}
if v, ok := settings["CLAUDE_MNEMONIC_WAL_CHECKPOINT_THRESHOLD_BYTES"].(float64); ok && v > 0 {
cfg.WALCheckpointThresholdBytes = int64(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()
+148 -42
View File
@@ -5,18 +5,80 @@ import (
"context"
"database/sql"
"fmt"
"os"
"slices"
"sync"
"time"
sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
_ "github.com/mattn/go-sqlite3" // Import SQLite driver with FTS5 support
sqlite3 "github.com/mattn/go-sqlite3" // SQLite driver with FTS5 support
"github.com/rs/zerolog/log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// driverName is the name of the custom mattn/go-sqlite3 driver registered with a
// ConnectHook that applies ALL connection pragmas (correctness + best-effort) to EVERY
// pooled connection at open time. The stock "sqlite3" driver has no hook, so pragmas set
// via a single post-open Exec only reach one arbitrary pooled connection (issue #49, F6).
const driverName = "sqlite3_mnemonic"
// registerDriverOnce guards driver registration so it runs exactly once per process.
// database/sql panics with "sql: Register called twice" on a duplicate name, and NewStore
// may be called multiple times (e.g. after a config-change reinitialization).
var registerDriverOnce sync.Once
// correctnessPragmas MUST succeed on every connection: getting any of them wrong changes
// transactional/locking semantics, not just performance. A failure here aborts the open.
var correctnessPragmas = []string{
"PRAGMA foreign_keys=ON",
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA busy_timeout=5000",
"PRAGMA cache_size=-64000",
}
// bestEffortPragmas are per-connection or database-wide optimizations. A failure is logged
// and tolerated: the connection is still correct, just less tuned. (page_size only takes
// effect on an empty database / next VACUUM, but applying it per-connection is harmless.)
var bestEffortPragmas = []string{
"PRAGMA temp_store=MEMORY", // Store temp tables in memory
"PRAGMA mmap_size=268435456", // 256MB memory-mapped I/O
"PRAGMA page_size=4096", // 4KB pages (optimal for most systems)
"PRAGMA wal_autocheckpoint=1000", // Auto-checkpoint (PASSIVE) every 1000 WAL frames
"PRAGMA journal_size_limit=8388608", // Backstop: cap -wal at 8MiB (issue #49)
}
// connectHook applies all pragmas to a freshly opened connection. mattn/go-sqlite3 calls
// it at the very end of Open, after DSN params and extensions, so it is authoritative.
func connectHook(c *sqlite3.SQLiteConn) error {
for _, pragma := range correctnessPragmas {
if _, err := c.Exec(pragma, nil); err != nil {
return fmt.Errorf("apply correctness pragma %q: %w", pragma, err)
}
}
for _, pragma := range bestEffortPragmas {
if _, err := c.Exec(pragma, nil); err != nil {
log.Warn().Str("pragma", pragma).Err(err).Msg("Failed to set pragma (non-fatal)")
}
}
return nil
}
// registerDriver registers the custom driver once. sqlite_vec.Auto() registers the vec
// extension globally via sqlite3_auto_extension, which applies to connections from any
// sqlite3-based driver, so the new driver still gets vec + FTS5.
func registerDriver() {
registerDriverOnce.Do(func() {
sql.Register(driverName, &sqlite3.SQLiteDriver{
ConnectHook: func(c *sqlite3.SQLiteConn) error {
return connectHook(c)
},
})
})
}
// Store represents the GORM database connection with sqlite-vec support.
type Store struct {
healthCacheTime time.Time
@@ -24,6 +86,7 @@ type Store struct {
sqlDB *sql.DB
metrics *PoolMetrics
cachedHealth *HealthInfo
path string
healthCacheTTL time.Duration
healthCacheMu sync.RWMutex
}
@@ -36,22 +99,32 @@ type Config struct {
}
// NewStore creates a new Store with WAL mode enabled and sqlite-vec registered.
// CRITICAL: WAL mode and foreign keys are enabled via pragmas for concurrent reads.
// CRITICAL: all connection pragmas (WAL, foreign_keys, busy_timeout, etc.) are applied to
// EVERY pooled connection via a driver ConnectHook (see registerDriver), so the pool is
// uniformly configured and connections may be recycled safely (issue #49, F6).
func NewStore(cfg Config) (*Store, error) {
// 1. Register sqlite-vec extension (must be done before opening database)
// 1. Register sqlite-vec extension (must be done before opening database).
// sqlite_vec.Auto() uses sqlite3_auto_extension, which is global to all sqlite3-based
// drivers, so connections from our custom driver also get the vec virtual table.
sqlite_vec.Auto()
// 2. Build connection string (foreign keys enabled in DSN)
// Use sqlite3 driver (mattn/go-sqlite3) which has FTS5 support
// 2. Register the custom driver whose ConnectHook applies ALL pragmas to EVERY pooled
// connection (issue #49, F6). Without this, pragmas set via a single post-open
// sqlDB.Exec reach only one arbitrary pooled connection. The hook is authoritative.
registerDriver()
// 3. Build a minimal DSN. _foreign_keys is kept as belt-and-suspenders (the hook sets
// it too); all other pragmas are applied per-connection by the ConnectHook, so they no
// longer need to live in the DSN.
dsn := cfg.Path + "?_foreign_keys=ON"
// 3. Open raw database connection with mattn/go-sqlite3 (has FTS5 support)
sqlDB, err := sql.Open("sqlite3", dsn)
// 4. Open raw database connection with the custom driver (FTS5 + per-connection pragmas).
sqlDB, err := sql.Open(driverName, dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
// 4. Wrap with GORM using existing connection
// 5. Wrap with GORM using existing connection
db, err := gorm.Open(sqlite.Dialector{
Conn: sqlDB,
}, &gorm.Config{
@@ -66,16 +139,25 @@ func NewStore(cfg Config) (*Store, error) {
return nil, fmt.Errorf("open gorm: %w", err)
}
// 5. Configure connection pool (same settings as current implementation)
// 6. Configure connection pool.
maxConns := cfg.MaxConns
if maxConns <= 0 {
maxConns = 4
}
sqlDB.SetMaxOpenConns(maxConns)
sqlDB.SetMaxIdleConns(maxConns)
sqlDB.SetConnMaxLifetime(0) // Never expire (SQLite connections are cheap)
// Finite recycling (issue #49): previously SetConnMaxLifetime(0) meant connections
// NEVER recycled, so a long-lived read connection could pin an old WAL read-mark for the
// whole process lifetime and block TRUNCATE checkpoints from reclaiming the -wal file.
// Recycling is safe now because the ConnectHook reapplies every correctness pragma on
// each new connection — a recycled connection comes back fully configured, not with
// defaults. 1h lifetime bounds read-mark staleness without churning the pool; 30m idle
// time reclaims connections that sit unused (e.g. between sessions) so the pool shrinks
// back to one warm connection during quiet periods, dropping their WAL read-marks.
sqlDB.SetConnMaxLifetime(1 * time.Hour)
sqlDB.SetConnMaxIdleTime(30 * time.Minute)
// 6. Verify connection
// 7. Verify connection
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
@@ -83,36 +165,18 @@ func NewStore(cfg Config) (*Store, error) {
store := &Store{
DB: db,
sqlDB: sqlDB,
path: cfg.Path,
metrics: NewPoolMetrics(100), // Track last 100 latency samples
healthCacheTTL: 5 * time.Second, // Cache health checks for 5 seconds
}
// 7. Run migrations FIRST (before PRAGMA commands)
// 8. Run migrations. All pragmas (correctness + best-effort) are applied per-connection
// by the ConnectHook at open time, so there is no post-open pragma loop here anymore:
// such a loop only ever reached one arbitrary pooled connection (issue #49, F6).
if err := runMigrations(db, sqlDB); err != nil {
return nil, fmt.Errorf("run migrations: %w", err)
}
// 8. CRITICAL: Set WAL mode and other performance pragmas
// Use raw sqlDB to avoid GORM transaction issues
pragmas := []string{
"PRAGMA journal_mode=WAL",
"PRAGMA synchronous=NORMAL",
"PRAGMA cache_size=-64000", // 64MB cache (negative = KB)
"PRAGMA temp_store=MEMORY", // Store temp tables in memory
"PRAGMA mmap_size=268435456", // 256MB memory-mapped I/O
"PRAGMA page_size=4096", // 4KB pages (optimal for most systems)
}
for _, pragma := range pragmas {
if _, err := sqlDB.Exec(pragma); err != nil {
log.Warn().Str("pragma", pragma).Err(err).Msg("Failed to set pragma (non-fatal)")
}
}
// Set busy timeout to 5 seconds to handle concurrent writes
// This allows SQLite to retry when database is locked instead of failing immediately
if _, err := sqlDB.Exec("PRAGMA busy_timeout=5000"); err != nil {
return nil, fmt.Errorf("set busy timeout: %w", err)
}
// 9. Warm the connection pool
store.WarmPool(maxConns)
@@ -147,11 +211,55 @@ func (s *Store) WarmPool(numConns int) {
log.Debug().Int("connections", numConns).Msg("Connection pool warmed")
}
// Close closes the database connection.
// Close checkpoints the WAL (TRUNCATE) before closing the connection. Checkpointing on
// shutdown prevents the WAL file from persisting in a large, dirty state across restarts
// and config-change reinitializations, which otherwise leaves a multi-megabyte -wal file
// on disk (issue #49). The checkpoint is best-effort: a failure is logged, not fatal.
func (s *Store) Close() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.Checkpoint(ctx); err != nil {
log.Warn().Err(err).Msg("WAL checkpoint on close failed (non-fatal)")
}
return s.sqlDB.Close()
}
// Checkpoint runs a TRUNCATE WAL checkpoint: it flushes WAL frames into the main
// database file and shrinks the -wal file back to zero. Unlike a PASSIVE checkpoint
// (which never truncates the file and is all SQLite's auto-checkpoint ever performs), a
// TRUNCATE checkpoint reclaims disk and is the mechanism that bounds WAL growth.
// It waits up to the connection busy_timeout for the write lock and returns an error
// rather than blocking indefinitely.
func (s *Store) Checkpoint(ctx context.Context) error {
if _, err := s.sqlDB.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
return fmt.Errorf("wal checkpoint (truncate): %w", err)
}
return nil
}
// WALSize returns the size in bytes of the SQLite WAL sidecar file (<db>-wal), or 0 if
// it does not exist or cannot be stat'd. Used to decide when a checkpoint is worthwhile.
func (s *Store) WALSize() int64 {
if s.path == "" {
return 0
}
info, err := os.Stat(s.path + "-wal")
if err != nil {
return 0
}
return info.Size()
}
// CheckpointIfLarge performs a TRUNCATE checkpoint only when the WAL file has grown to or
// beyond threshold bytes. Returns true if a checkpoint was performed. This keeps the
// periodic checkpoint cheap: it does no work while the WAL is small.
func (s *Store) CheckpointIfLarge(ctx context.Context, threshold int64) (bool, error) {
if s.WALSize() < threshold {
return false, nil
}
return true, s.Checkpoint(ctx)
}
// Ping verifies the database connection is alive.
func (s *Store) Ping() error {
return s.sqlDB.Ping()
@@ -192,6 +300,12 @@ func (s *Store) Optimize(ctx context.Context) error {
log.Warn().Err(err).Msg("PRAGMA optimize failed (non-fatal)")
}
// TRUNCATE WAL checkpoint — reclaims the -wal file during low-activity optimization.
// (PASSIVE never shrinks the file, so it cannot bound WAL growth — see issue #49.)
if err := s.Checkpoint(ctx); err != nil {
log.Warn().Err(err).Msg("WAL checkpoint failed (non-fatal)")
}
log.Info().Dur("duration", time.Since(start)).Msg("Database optimization complete")
return nil
}
@@ -513,14 +627,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.
func (s *Store) TransactionWithTimeout(ctx context.Context, timeout time.Duration, fn func(*gorm.DB) error) error {
+410
View File
@@ -4,9 +4,13 @@
package gorm
import (
"context"
"database/sql"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gorm.io/gorm/logger"
)
@@ -150,3 +154,409 @@ func TestMigrationIdempotency(t *testing.T) {
t.Logf("✅ Migrations are idempotent")
}
func TestWALAutocheckpoint(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "gorm_wal_checkpoint_*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
store, err := NewStore(Config{
Path: dbPath,
MaxConns: 2,
LogLevel: logger.Silent,
})
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
// Verify wal_autocheckpoint is set to 1000
var checkpoint int
err = store.GetRawDB().QueryRow("PRAGMA wal_autocheckpoint").Scan(&checkpoint)
if err != nil {
t.Fatalf("query wal_autocheckpoint: %v", err)
}
if checkpoint != 1000 {
t.Errorf("expected wal_autocheckpoint=1000, got %d", checkpoint)
}
}
func TestOptimize_RunsWALCheckpoint(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "gorm_optimize_*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
store, err := NewStore(Config{
Path: dbPath,
MaxConns: 2,
LogLevel: logger.Silent,
})
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
// Insert some data to generate WAL frames
_, err = store.GetRawDB().Exec("INSERT INTO observations (sdk_session_id, title, scope, project, type, created_at, created_at_epoch) VALUES ('test-sess', 'test data', 'project', '/tmp/test', 'decision', '2026-01-01T00:00:00Z', 1735689600)")
if err != nil {
t.Fatalf("insert test data: %v", err)
}
// Optimize should succeed (includes PASSIVE WAL checkpoint)
err = store.Optimize(context.Background())
if err != nil {
t.Fatalf("Optimize failed: %v", err)
}
}
func TestOptimize_RespectsContextCancellation(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "gorm_optimize_cancel_*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
store, err := NewStore(Config{
Path: dbPath,
MaxConns: 2,
LogLevel: logger.Silent,
})
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
// Already-cancelled context should cause Optimize to fail
ctx, cancel := context.WithCancel(context.Background())
cancel()
err = store.Optimize(ctx)
if err == nil {
t.Error("expected error with cancelled context, got nil")
}
}
// growWAL inserts sizeable rows to push the SQLite WAL well past a few hundred KB so
// checkpoint behaviour can be observed. Returns the WAL file size after the inserts.
func growWAL(t *testing.T, store *Store, rows int) int64 {
t.Helper()
bigTitle := strings.Repeat("x", 2048)
for i := 0; i < rows; i++ {
_, err := store.GetRawDB().Exec(
"INSERT INTO observations (sdk_session_id, title, scope, project, type, created_at, created_at_epoch) "+
"VALUES (?, ?, 'project', '/tmp/test', 'decision', '2026-01-01T00:00:00Z', 1735689600)",
"sess", bigTitle)
if err != nil {
t.Fatalf("insert row %d: %v", i, err)
}
}
return store.WALSize()
}
func countObservations(t *testing.T, store *Store) int64 {
t.Helper()
var n int64
if err := store.GetRawDB().QueryRow("SELECT COUNT(*) FROM observations").Scan(&n); err != nil {
t.Fatalf("count observations: %v", err)
}
return n
}
// TestCheckpoint_TruncateShrinksWAL verifies Checkpoint() performs a TRUNCATE checkpoint
// that actually reclaims the -wal file. This is the load-bearing fix for issue #49: a
// PASSIVE checkpoint drains frames but never shrinks the file, so reverting Checkpoint to
// PASSIVE would leave the WAL grown and fail this test.
func TestCheckpoint_TruncateShrinksWAL(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "gorm_checkpoint_truncate_*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
store, err := NewStore(Config{Path: dbPath, MaxConns: 2, LogLevel: logger.Silent})
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
walBefore := growWAL(t, store, 1000)
if walBefore < 64*1024 {
t.Fatalf("expected WAL to grow above 64KiB, got %d bytes", walBefore)
}
if err := store.Checkpoint(context.Background()); err != nil {
t.Fatalf("Checkpoint failed: %v", err)
}
walAfter := store.WALSize()
if walAfter >= walBefore {
t.Errorf("expected WAL to shrink after TRUNCATE checkpoint: before=%d after=%d", walBefore, walAfter)
}
if walAfter > 64*1024 {
t.Errorf("expected WAL truncated to near-zero, got %d bytes", walAfter)
}
}
// TestCheckpointIfLarge_GatesOnThreshold verifies the size-gated periodic checkpoint used
// by the worker's walCheckpointLoop: a no-op below the threshold, a truncating checkpoint
// at/above it.
func TestCheckpointIfLarge_GatesOnThreshold(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "gorm_checkpoint_gated_*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
store, err := NewStore(Config{Path: dbPath, MaxConns: 2, LogLevel: logger.Silent})
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
// Below an enormous threshold -> no checkpoint.
done, err := store.CheckpointIfLarge(context.Background(), 1<<30) // 1 GiB
if err != nil {
t.Fatalf("CheckpointIfLarge (small) failed: %v", err)
}
if done {
t.Errorf("expected no checkpoint below threshold, but one was performed")
}
// Grow the WAL, then a low threshold triggers a truncating checkpoint.
walBefore := growWAL(t, store, 1000)
if walBefore < 64*1024 {
t.Fatalf("expected WAL to grow above 64KiB, got %d bytes", walBefore)
}
done, err = store.CheckpointIfLarge(context.Background(), 64*1024)
if err != nil {
t.Fatalf("CheckpointIfLarge (large) failed: %v", err)
}
if !done {
t.Errorf("expected checkpoint above threshold, but none was performed")
}
if walAfter := store.WALSize(); walAfter >= walBefore {
t.Errorf("expected WAL to shrink after gated checkpoint: before=%d after=%d", walBefore, walAfter)
}
}
// TestClose_CheckpointsWAL verifies Close() reclaims the WAL and leaves the data intact on
// the next open (issue #49: shutdown must not leave a large dirty WAL on disk).
func TestClose_CheckpointsWAL(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "gorm_close_checkpoint_*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
store, err := NewStore(Config{Path: dbPath, MaxConns: 2, LogLevel: logger.Silent})
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
if walBefore := growWAL(t, store, 800); walBefore == 0 {
t.Fatalf("expected WAL to grow before close, got 0")
}
count := countObservations(t, store)
if count < 800 {
t.Fatalf("expected >=800 observations before close, got %d", count)
}
if err := store.Close(); err != nil {
t.Fatalf("Close failed: %v", err)
}
// The -wal file must not persist large on disk after a clean shutdown.
if info, statErr := os.Stat(dbPath + "-wal"); statErr == nil && info.Size() > 64*1024 {
t.Errorf("expected WAL reclaimed on close, -wal still %d bytes", info.Size())
}
// Reopen and verify data survived the checkpoint.
store2, err := NewStore(Config{Path: dbPath, MaxConns: 2, LogLevel: logger.Silent})
if err != nil {
t.Fatalf("reopen NewStore failed: %v", err)
}
defer store2.Close()
if count2 := countObservations(t, store2); count2 != count {
t.Errorf("expected %d observations after reopen, got %d", count, count2)
}
}
// TestBusyTimeoutAppliedToAllConnections verifies the issue #49 DSN fix: busy_timeout is
// applied to EVERY pooled connection (not just one arbitrary connection as happened when
// it was set via a single post-open sqlDB.Exec).
func TestBusyTimeoutAppliedToAllConnections(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "gorm_busy_timeout_*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
const maxConns = 4
store, err := NewStore(Config{Path: dbPath, MaxConns: maxConns, LogLevel: logger.Silent})
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
// Pin all connections concurrently so each distinct connection is inspected, then
// assert every one reports busy_timeout=5000.
raw := store.GetRawDB()
conns := make([]*sql.Conn, 0, maxConns)
defer func() {
for _, c := range conns {
_ = c.Close()
}
}()
for i := 0; i < maxConns; i++ {
c, err := raw.Conn(context.Background())
if err != nil {
t.Fatalf("acquire conn %d: %v", i, err)
}
conns = append(conns, c)
}
for i, c := range conns {
var timeout int
if err := c.QueryRowContext(context.Background(), "PRAGMA busy_timeout").Scan(&timeout); err != nil {
t.Fatalf("query busy_timeout on conn %d: %v", i, err)
}
if timeout != 5000 {
t.Errorf("conn %d: expected busy_timeout=5000, got %d", i, timeout)
}
}
}
// TestAllPragmasAppliedToAllConnections verifies the issue #49 (F6) ConnectHook fix: not
// just busy_timeout but the full pragma set — including the best-effort pragmas that used
// to be set via a single post-open sqlDB.Exec (journal_size_limit, temp_store,
// wal_autocheckpoint) — is applied to EVERY pooled connection. It pins all connections so
// each distinct connection is inspected, then asserts each reports the expected value.
func TestAllPragmasAppliedToAllConnections(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "gorm_all_pragmas_*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
const maxConns = 4
store, err := NewStore(Config{Path: dbPath, MaxConns: maxConns, LogLevel: logger.Silent})
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
// Expected per-connection pragma values. temp_store=MEMORY reports as 2; the others are
// numeric. foreign_keys/journal_mode/synchronous are covered by TestNewStore and the
// busy_timeout test; here we focus on the previously single-connection pragmas.
checks := []struct {
name string
want int64
}{
{"busy_timeout", 5000},
{"journal_size_limit", 8388608},
{"temp_store", 2}, // 2 == MEMORY
{"wal_autocheckpoint", 1000},
{"foreign_keys", 1},
}
raw := store.GetRawDB()
conns := make([]*sql.Conn, 0, maxConns)
defer func() {
for _, c := range conns {
_ = c.Close()
}
}()
for i := 0; i < maxConns; i++ {
c, err := raw.Conn(context.Background())
if err != nil {
t.Fatalf("acquire conn %d: %v", i, err)
}
conns = append(conns, c)
}
for i, c := range conns {
for _, chk := range checks {
var got int64
query := "PRAGMA " + chk.name
if err := c.QueryRowContext(context.Background(), query).Scan(&got); err != nil {
t.Fatalf("conn %d: query %q: %v", i, chk.name, err)
}
if got != chk.want {
t.Errorf("conn %d: %s = %d, want %d", i, chk.name, got, chk.want)
}
}
}
}
// TestRecycledConnectionRetainsPragmas verifies that recycling a connection (which now
// happens because SetConnMaxLifetime is finite, not 0) does NOT drop the correctness
// pragmas: the ConnectHook reapplies them on every new connection. We force recycling by
// setting a near-zero max lifetime so the next acquisition opens a fresh connection, then
// assert the new connection still reports the safe values rather than SQLite defaults
// (busy_timeout would default to 0 and journal_mode to "delete" without the hook).
func TestRecycledConnectionRetainsPragmas(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "gorm_recycle_pragmas_*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
store, err := NewStore(Config{Path: dbPath, MaxConns: 2, LogLevel: logger.Silent})
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
raw := store.GetRawDB()
// Force aggressive recycling: any connection older than 1ns is expired on next use, so
// database/sql opens a brand-new connection (running the ConnectHook again).
raw.SetConnMaxLifetime(time.Nanosecond)
time.Sleep(5 * time.Millisecond)
// Acquire a connection that is necessarily freshly opened (all prior ones are expired),
// and verify the hook reapplied the correctness pragmas.
conn, err := raw.Conn(context.Background())
if err != nil {
t.Fatalf("acquire recycled conn: %v", err)
}
defer conn.Close()
var busyTimeout int
if err := conn.QueryRowContext(context.Background(), "PRAGMA busy_timeout").Scan(&busyTimeout); err != nil {
t.Fatalf("query busy_timeout: %v", err)
}
if busyTimeout != 5000 {
t.Errorf("recycled conn: busy_timeout = %d, want 5000 (hook did not reapply)", busyTimeout)
}
var journalMode string
if err := conn.QueryRowContext(context.Background(), "PRAGMA journal_mode").Scan(&journalMode); err != nil {
t.Fatalf("query journal_mode: %v", err)
}
if journalMode != "wal" {
t.Errorf("recycled conn: journal_mode = %q, want \"wal\" (hook did not reapply)", journalMode)
}
var foreignKeys int
if err := conn.QueryRowContext(context.Background(), "PRAGMA foreign_keys").Scan(&foreignKeys); err != nil {
t.Fatalf("query foreign_keys: %v", err)
}
if foreignKeys != 1 {
t.Errorf("recycled conn: foreign_keys = %d, want 1 (hook did not reapply)", foreignKeys)
}
}
+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.
+3 -5
View File
@@ -1,14 +1,12 @@
// Package embedding provides text embedding generation using all-MiniLM-L6-v2.
// Package embedding provides text embedding generation using bge-small-en-v1.5.
package embedding
import (
_ "embed"
)
// Model and tokenizer files - embedded for all platforms
// Tokenizer file - embedded for all platforms (small, not in LFS).
// The ONNX model is downloaded at runtime to ~/.claude-mnemonic/models/.
//
//go:embed assets/model.onnx
var modelData []byte
//go:embed assets/tokenizer.json
var tokenizerData []byte
@@ -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
Binary file not shown.
+9
View File
@@ -2,6 +2,7 @@
package embedding
import (
"context"
"fmt"
"sync"
)
@@ -44,6 +45,14 @@ type EmbeddingModel interface {
// EmbedBatch generates embeddings for multiple texts.
EmbedBatch(texts []string) ([][]float32, error)
// EmbedWithContext generates an embedding for a single text with context-aware cancellation.
// The context controls mutex acquisition timeout — if ctx is cancelled while waiting
// for the model lock, the call returns immediately with ctx.Err().
EmbedWithContext(ctx context.Context, text string) ([]float32, error)
// EmbedBatchWithContext generates embeddings for multiple texts with context-aware cancellation.
EmbedBatchWithContext(ctx context.Context, texts []string) ([][]float32, error)
// Close releases model resources.
Close() error
}
+131 -4
View File
@@ -3,13 +3,16 @@ package embedding
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/lukaszraczylo/claude-mnemonic/internal/models"
"github.com/sugarme/tokenizer"
"github.com/sugarme/tokenizer/pretrained"
ort "github.com/yalue/onnxruntime_go"
@@ -27,6 +30,13 @@ const (
BGEModelName = "bge-small-en-v1.5"
// DefaultModelVersion is the default model to use
DefaultModelVersion = BGEModelVersion
// BGEAssetName is the local filename for the BGE model.
BGEAssetName = "bge-small-en-v1.5-model.onnx"
// BGEAssetURL is the Hugging Face URL for the BGE ONNX model.
BGEAssetURL = "https://huggingface.co/BAAI/bge-small-en-v1.5/resolve/main/onnx/model.onnx"
// BGEModelSHA256 is the expected SHA-256 of the BGE model file.
BGEModelSHA256 = "828e1496d7fabb79cfa4dcd84fa38625c0d3d21da474a00f08db0f559940cf35"
)
// MaxSequenceLength is the maximum token sequence length for the model.
@@ -58,6 +68,7 @@ var _ EmbeddingModel = (*bgeModel)(nil)
var _ ONNXConfigurer = (*bgeModel)(nil)
// newBGEModel creates a new BGE embedding model using bundled ONNX runtime and model.
// The ONNX model is downloaded at runtime from GitHub Releases and cached to disk.
func newBGEModel() (EmbeddingModel, error) {
// Extract ONNX runtime library to temp directory
libDir, err := extractONNXLibrary()
@@ -69,8 +80,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)
}
@@ -80,9 +93,15 @@ func newBGEModel() (EmbeddingModel, error) {
return nil, fmt.Errorf("load tokenizer: %w", err)
}
// Create ONNX session using model-specific configuration
// Download model on first use (cached to ~/.claude-mnemonic/models/)
modelPath, err := models.EnsureModel(BGEAssetName, BGEAssetURL, BGEModelSHA256)
if err != nil {
return nil, fmt.Errorf("ensure embedding model: %w", err)
}
// Create ONNX session from file path using model-specific configuration
config := bgeONNXConfig
session, err := ort.NewDynamicAdvancedSessionWithONNXData(modelData, config.InputNames, config.OutputNames, nil)
session, err := ort.NewDynamicAdvancedSession(modelPath, config.InputNames, config.OutputNames, nil)
if err != nil {
return nil, fmt.Errorf("create ONNX session: %w", err)
}
@@ -222,6 +241,103 @@ func (m *bgeModel) EmbedBatch(texts []string) ([][]float32, error) {
return results, nil
}
// acquireMutex attempts to acquire the model mutex, respecting context cancellation.
// On success the caller MUST call the returned unlock function.
// If ctx is cancelled while waiting, returns ctx.Err() and no unlock is needed.
func (m *bgeModel) acquireMutex(ctx context.Context) (unlock func(), err error) {
acquired := make(chan struct{})
go func() {
m.mu.Lock()
close(acquired)
}()
select {
case <-acquired:
// Got the lock normally.
return m.mu.Unlock, nil
case <-ctx.Done():
// Context cancelled while waiting. The goroutine above will eventually
// acquire the mutex — we must ensure it gets unlocked.
go func() {
<-acquired
m.mu.Unlock()
}()
return nil, ctx.Err()
}
}
// EmbedWithContext generates an embedding for a single text with context-aware mutex acquisition.
// If ctx is cancelled while waiting for the model lock, returns immediately with ctx.Err().
func (m *bgeModel) EmbedWithContext(ctx context.Context, text string) ([]float32, error) {
if text == "" {
return make([]float32, EmbeddingDim), nil
}
unlock, err := m.acquireMutex(ctx)
if err != nil {
return nil, fmt.Errorf("acquire embedding lock: %w", err)
}
defer unlock()
results, err := m.computeBatch([]string{text})
if err != nil {
return nil, err
}
if len(results) == 0 {
return make([]float32, EmbeddingDim), nil
}
return results[0], nil
}
// EmbedBatchWithContext generates embeddings for multiple texts with context-aware mutex acquisition.
func (m *bgeModel) EmbedBatchWithContext(ctx context.Context, texts []string) ([][]float32, error) {
if len(texts) == 0 {
return nil, nil
}
// Filter out empty texts and track indices
nonEmpty := make([]string, 0, len(texts))
indices := make([]int, 0, len(texts))
for i, t := range texts {
if t != "" {
nonEmpty = append(nonEmpty, t)
indices = append(indices, i)
}
}
// If all texts are empty, return zero vectors
if len(nonEmpty) == 0 {
results := make([][]float32, len(texts))
for i := range results {
results[i] = make([]float32, EmbeddingDim)
}
return results, nil
}
unlock, err := m.acquireMutex(ctx)
if err != nil {
return nil, fmt.Errorf("acquire embedding lock: %w", err)
}
defer unlock()
// Compute embeddings for non-empty texts
embeddings, err := m.computeBatch(nonEmpty)
if err != nil {
return nil, fmt.Errorf("compute batch embeddings: %w", err)
}
// Build result with zero vectors for empty texts
results := make([][]float32, len(texts))
for i := range results {
results[i] = make([]float32, EmbeddingDim)
}
for i, idx := range indices {
results[idx] = embeddings[i]
}
return results, nil
}
// computeBatch runs inference on a batch of texts. Must be called with lock held.
func (m *bgeModel) computeBatch(sentences []string) ([][]float32, error) {
if len(sentences) == 0 {
@@ -506,6 +622,17 @@ func (s *Service) EmbedBatch(texts []string) ([][]float32, error) {
return s.model.EmbedBatch(texts)
}
// EmbedWithContext generates an embedding with context-aware cancellation.
// If ctx is cancelled while waiting for the model lock, returns immediately.
func (s *Service) EmbedWithContext(ctx context.Context, text string) ([]float32, error) {
return s.model.EmbedWithContext(ctx, text)
}
// EmbedBatchWithContext generates embeddings with context-aware cancellation.
func (s *Service) EmbedBatchWithContext(ctx context.Context, texts []string) ([][]float32, error) {
return s.model.EmbedBatchWithContext(ctx, texts)
}
// Close releases model resources.
func (s *Service) Close() error {
return s.model.Close()
+143
View File
@@ -1,9 +1,12 @@
package embedding
import (
"context"
"errors"
"math"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -310,6 +313,146 @@ func TestEmbed_Deterministic(t *testing.T) {
}
}
// --- Regression tests: context-aware mutex (Fix #45) ---
// asBGE casts the service model to *bgeModel for direct mutex access.
// Tests are in the same package so this is safe.
func asBGE(t *testing.T, svc *Service) *bgeModel {
t.Helper()
m, ok := svc.model.(*bgeModel)
require.True(t, ok, "model is not *bgeModel — test invariant broken")
return m
}
// holdMutex locks m.mu in a background goroutine and returns a release func.
// The returned ready channel is closed once the lock is held.
func holdMutex(m *bgeModel) (ready <-chan struct{}, release func()) {
ch := make(chan struct{})
done := make(chan struct{})
go func() {
m.mu.Lock()
close(ch) // signal: lock acquired
<-done // wait for release signal
m.mu.Unlock()
}()
return ch, func() { close(done) }
}
// TestEmbedWithContext_CancelWhileWaitingForMutex is the core regression test.
// If the mutex is held and the context times out, EmbedWithContext must return
// immediately with a context error — not block until the mutex is released.
func TestEmbedWithContext_CancelWhileWaitingForMutex(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
defer svc.Close()
m := asBGE(t, svc)
// Hold the mutex to simulate a stuck ONNX call.
ready, release := holdMutex(m)
<-ready // ensure lock is held before proceeding
defer release()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
start := time.Now()
_, err = svc.EmbedWithContext(ctx, "test text")
elapsed := time.Since(start)
// Must return a context error.
require.Error(t, err)
assert.True(t,
errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled),
"expected context error, got: %v", err)
// Must return quickly (well under the 30 s default; allow 2× the timeout for CI slack).
assert.Less(t, elapsed, 200*time.Millisecond,
"EmbedWithContext blocked too long: %v", elapsed)
}
// TestEmbedWithContext_SuccessWhenUncontended verifies normal operation still works.
func TestEmbedWithContext_SuccessWhenUncontended(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
defer svc.Close()
emb, err := svc.EmbedWithContext(context.Background(), "hello world")
require.NoError(t, err)
assert.Len(t, emb, EmbeddingDim)
var sum float32
for _, v := range emb {
sum += v * v
}
assert.Greater(t, sum, float32(0), "embedding should not be all zeros")
}
// TestEmbedBatchWithContext_CancelDuringBatch verifies batch embedding respects
// context cancellation while blocked on mutex acquisition.
func TestEmbedBatchWithContext_CancelDuringBatch(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
defer svc.Close()
m := asBGE(t, svc)
ready, release := holdMutex(m)
<-ready
defer release()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
start := time.Now()
_, err = svc.EmbedBatchWithContext(ctx, []string{"a", "b", "c"})
elapsed := time.Since(start)
require.Error(t, err)
assert.True(t,
errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled),
"expected context error, got: %v", err)
assert.Less(t, elapsed, 200*time.Millisecond,
"EmbedBatchWithContext blocked too long: %v", elapsed)
}
// TestEmbedWithContext_CleanupAfterCancel verifies the cleanup goroutine in
// acquireMutex properly unlocks the mutex after context cancellation,
// so subsequent calls do not deadlock.
func TestEmbedWithContext_CleanupAfterCancel(t *testing.T) {
svc, err := NewService()
require.NoError(t, err)
defer svc.Close()
m := asBGE(t, svc)
// --- first call: context expires while mutex is held ---
ready, release := holdMutex(m)
<-ready
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
_, firstErr := svc.EmbedWithContext(ctx, "should fail")
require.Error(t, firstErr)
assert.True(t,
errors.Is(firstErr, context.DeadlineExceeded) || errors.Is(firstErr, context.Canceled),
"expected context error on first call, got: %v", firstErr)
// Release the held mutex so the cleanup goroutine inside acquireMutex can finish.
release()
// Give the cleanup goroutine a moment to acquire-and-release the mutex.
// 50 ms is generous; the goroutine only has to lock+unlock with no contention.
time.Sleep(50 * time.Millisecond)
// --- second call: mutex should be free, no deadlock ---
emb, secondErr := svc.EmbedWithContext(context.Background(), "should work")
require.NoError(t, secondErr, "second call should succeed after cleanup goroutine released mutex")
assert.Len(t, emb, EmbeddingDim)
}
// Helper function to calculate cosine similarity
func cosineSimilarity(a, b []float32) float64 {
if len(a) != len(b) {
+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
}
+26 -4
View File
@@ -83,8 +83,20 @@ func (s *Service) Start(ctx context.Context) {
Bool("cleanup_stale", s.config.CleanupStaleObservations).
Msg("Starting maintenance scheduler")
// Initial run after 5 minutes (allow system to stabilize)
time.Sleep(5 * time.Minute)
// Initial run after 5 minutes (allow system to stabilize).
// Use a cancellable timer so shutdown (ctx cancel / Stop) is not blocked for up to 5m.
initialDelay := time.NewTimer(5 * time.Minute)
select {
case <-ctx.Done():
initialDelay.Stop()
s.log.Info().Msg("Maintenance shutting down before initial run (context cancellation)")
return
case <-s.stopCh:
initialDelay.Stop()
s.log.Info().Msg("Maintenance shutting down before initial run (stop signal)")
return
case <-initialDelay.C:
}
s.runMaintenance(ctx)
ticker := time.NewTicker(interval)
@@ -151,10 +163,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 +183,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().
@@ -284,7 +300,13 @@ func (s *Service) Stats() map[string]any {
}
}
// RunNow triggers an immediate maintenance run.
// RunNow triggers an immediate maintenance run in the background.
func (s *Service) RunNow(ctx context.Context) {
go s.runMaintenance(ctx)
}
// RunNowSync triggers an immediate maintenance run and blocks until it completes.
// Use this when the caller needs to report a synchronous result (e.g. an HTTP handler).
func (s *Service) RunNowSync(ctx context.Context) {
s.runMaintenance(ctx)
}
+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")
}
+924 -2231
View File
File diff suppressed because it is too large Load Diff
+2551 -214
View File
File diff suppressed because it is too large Load Diff
+259
View File
@@ -0,0 +1,259 @@
// Package models provides runtime model download and caching.
// Models are downloaded from Hugging Face on first use and cached
// to the OS cache directory (~/Library/Caches/claude-mnemonic/models/ on macOS,
// ~/.cache/claude-mnemonic/models/ on Linux, %LocalAppData%/claude-mnemonic/models/ on Windows).
// This replaces the previous approach of embedding models in the binary
// via go:embed and tracking them with Git LFS.
package models
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
"time"
"github.com/rs/zerolog/log"
)
const (
// ModelVersion is incremented to force re-download of all cached models.
ModelVersion = "1"
maxRetries = 3
retryBaseDelay = 2 * time.Second
progressEvery = 3 * time.Second
downloadTimeout = 15 * time.Minute
)
// ModelsDir returns the path to the model cache directory.
// Uses the OS cache dir for cross-platform compatibility:
//
// macOS: ~/Library/Caches/claude-mnemonic/models
// Linux: ~/.cache/claude-mnemonic/models
// Windows: %LocalAppData%/claude-mnemonic/models
func ModelsDir() string {
dir, err := os.UserCacheDir()
if err != nil {
home, _ := os.UserHomeDir()
dir = filepath.Join(home, ".cache")
}
return filepath.Join(dir, "claude-mnemonic", "models")
}
// EnsureModel ensures the model file exists and returns its path.
//
// Resolution order:
// 1. CLAUDE_MNEMONIC_MODEL_DIR env var — if set, load models from this directory directly.
// This is intended for development and CI where models are already on disk.
// 2. User cache directory — if the model is already cached and its checksum matches,
// return it immediately.
// 3. url — download the model from the given URL with retries and cache it.
//
// assetName is the local filename (e.g. "bge-small-en-v1.5-model.onnx").
// expectedSHA256 is the hex-encoded SHA-256 of the file.
func EnsureModel(assetName, url, expectedSHA256 string) (string, error) {
if localDir := os.Getenv("CLAUDE_MNEMONIC_MODEL_DIR"); localDir != "" {
localPath := filepath.Join(localDir, assetName)
if _, err := os.Stat(localPath); err == nil {
if valid, err := verifyChecksum(localPath, expectedSHA256); err != nil || !valid {
return "", fmt.Errorf("local model %s checksum mismatch", assetName)
}
return localPath, nil
}
return "", fmt.Errorf("local model %s not found in %s", assetName, localDir)
}
dir := ModelsDir()
versionFile := filepath.Join(dir, ".model_version")
modelPath := filepath.Join(dir, assetName)
if err := os.MkdirAll(dir, 0700); err != nil {
return "", fmt.Errorf("create model cache dir: %w", err)
}
currentVersion, _ := os.ReadFile(versionFile)
if string(currentVersion) == ModelVersion {
if fi, err := os.Stat(modelPath); err == nil && fi.Size() > 0 {
if valid, _ := verifyChecksum(modelPath, expectedSHA256); valid {
return modelPath, nil
}
log.Warn().Str("model", assetName).Msg("Cached model corrupted, re-downloading")
os.Remove(modelPath)
}
} else if len(currentVersion) > 0 {
log.Info().Str("old", string(currentVersion)).Str("new", ModelVersion).Msg("Model version updated, clearing cache")
os.RemoveAll(dir)
if err := os.MkdirAll(dir, 0700); err != nil {
return "", fmt.Errorf("create model cache dir: %w", err)
}
}
if err := downloadWithRetries(url, modelPath); err != nil {
os.Remove(modelPath)
return "", fmt.Errorf("download %s: %w", assetName, err)
}
if valid, err := verifyChecksum(modelPath, expectedSHA256); err != nil || !valid {
os.Remove(modelPath)
return "", fmt.Errorf("downloaded model %s failed checksum verification — the file at %s may have changed", assetName, url)
}
if err := os.WriteFile(versionFile, []byte(ModelVersion), 0600); err != nil {
log.Warn().Err(err).Msg("Failed to write model version file")
}
log.Info().Str("model", assetName).Str("cache", dir).Msg("Model ready")
return modelPath, nil
}
func downloadWithRetries(url, dest string) error {
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
delay := retryBaseDelay * time.Duration(1<<(attempt-1))
log.Warn().Int("attempt", attempt+1).Int("max", maxRetries).Dur("delay", delay).Msg("Retrying download")
time.Sleep(delay)
}
if err := downloadFile(url, dest); err != nil {
lastErr = err
continue
}
return nil
}
return fmt.Errorf("failed after %d attempts: %w", maxRetries, lastErr)
}
func downloadFile(url, dest string) error {
client := &http.Client{Timeout: downloadTimeout}
resp, err := client.Get(url)
if err != nil {
return fmt.Errorf("HTTP GET: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
tmp := dest + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
cleanup := func() { f.Close(); os.Remove(tmp) }
name := filepath.Base(dest)
reader := &progressReader{
reader: resp.Body,
total: resp.ContentLength,
name: name,
}
if _, err := io.Copy(f, reader); err != nil {
cleanup()
return fmt.Errorf("write file: %w", err)
}
if err := f.Sync(); err != nil {
cleanup()
return fmt.Errorf("sync file: %w", err)
}
f.Close()
if err := os.Rename(tmp, dest); err != nil {
os.Remove(tmp)
return fmt.Errorf("commit file: %w", err)
}
return nil
}
// progressReader wraps an io.Reader and logs progress for large downloads.
type progressReader struct {
reader io.Reader
total int64
current int64
name string
lastLog time.Time
loggedPct int
}
func (pr *progressReader) Read(p []byte) (n int, err error) {
n, err = pr.reader.Read(p)
pr.current += int64(n)
if time.Since(pr.lastLog) >= progressEvery || err == io.EOF {
if pr.total > 0 {
pct := int(math.Round(float64(pr.current) / float64(pr.total) * 100))
if pct != pr.loggedPct {
pr.loggedPct = pct
mibCurrent := float64(pr.current) / (1024 * 1024)
mibTotal := float64(pr.total) / (1024 * 1024)
log.Info().
Str("model", pr.name).
Int("pct", pct).
Str("progress", fmt.Sprintf("%.0f/%.0f MiB", mibCurrent, mibTotal)).
Msg("Downloading")
}
} else {
mibCurrent := float64(pr.current) / (1024 * 1024)
log.Info().
Str("model", pr.name).
Str("progress", fmt.Sprintf("%.0f MiB", mibCurrent)).
Msg("Downloading (size unknown)")
}
pr.lastLog = time.Now()
}
return n, err
}
func verifyChecksum(path, expectedHex string) (bool, error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return false, err
}
actual := hex.EncodeToString(h.Sum(nil))
return actual == expectedHex, nil
}
// CleanStale removes any temporary download files that may have been left behind
// by a previous interrupted download.
func CleanStale() error {
dir := ModelsDir()
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
for _, e := range entries {
if filepath.Ext(e.Name()) == ".tmp" {
tmpPath := filepath.Join(dir, e.Name())
if info, err := e.Info(); err == nil {
if time.Since(info.ModTime()) > time.Hour {
os.Remove(tmpPath)
log.Debug().Str("file", tmpPath).Msg("Cleaned stale temp file")
}
}
}
}
return nil
}
+5
View File
@@ -3,6 +3,7 @@ package pattern
import (
"context"
"fmt"
"sort"
"strings"
"sync"
@@ -370,12 +371,16 @@ func (d *Detector) CandidateCount() int {
return len(d.candidates)
}
// GetPatternInsight returns a formatted insight string for a pattern.
// GetPatternInsight returns a formatted insight string for a pattern.
func (d *Detector) GetPatternInsight(ctx context.Context, patternID int64) (string, error) {
pattern, err := d.patternStore.GetPatternByID(ctx, patternID)
if err != nil {
return "", err
}
if pattern == nil {
return "", fmt.Errorf("pattern not found: %d", patternID)
}
return formatPatternInsight(pattern), nil
}
+563
View File
@@ -328,6 +328,26 @@ func TestDefaultConfig(t *testing.T) {
}
}
func TestDefaultConfig_AllFieldsValid(t *testing.T) {
config := DefaultConfig()
if config.MinMatchScore != 0.3 {
t.Errorf("MinMatchScore = %f, want 0.3", config.MinMatchScore)
}
if config.MinFrequencyForPattern != 2 {
t.Errorf("MinFrequencyForPattern = %d, want 2", config.MinFrequencyForPattern)
}
if config.AnalysisInterval != 5*time.Minute {
t.Errorf("AnalysisInterval = %v, want 5m", config.AnalysisInterval)
}
if config.MaxPatternsToTrack != 1000 {
t.Errorf("MaxPatternsToTrack = %d, want 1000", config.MaxPatternsToTrack)
}
if config.MaxCandidates != 500 {
t.Errorf("MaxCandidates = %d, want 500", config.MaxCandidates)
}
}
func TestGeneratePatternName(t *testing.T) {
tests := []struct {
patternType models.PatternType
@@ -352,6 +372,85 @@ func TestGeneratePatternName(t *testing.T) {
}
}
func TestGeneratePatternName_EdgeCases(t *testing.T) {
tests := []struct {
name string
ptype models.PatternType
title string
want string
signature []string
}{
{
name: "with title uses title directly",
ptype: models.PatternTypeBug,
signature: []string{"ignored"},
title: "Custom Title",
want: "Custom Title",
},
{
name: "long title generates from signature",
ptype: models.PatternTypeBug,
signature: []string{"sig1", "sig2"},
title: "This is a very long title that exceeds sixty characters and should be ignored",
want: "Bug Pattern: sig1, sig2",
},
{
name: "empty signature returns Unnamed",
ptype: models.PatternTypeBug,
signature: []string{},
title: "",
want: "Bug Pattern: Unnamed",
},
{
name: "single signature element",
ptype: models.PatternTypeRefactor,
signature: []string{"single"},
title: "",
want: "Refactor Pattern: single",
},
{
name: "more than 3 signature elements truncates",
ptype: models.PatternTypeBestPractice,
signature: []string{"a", "b", "c", "d", "e"},
title: "",
want: "Best Practice: a, b, c",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := generatePatternName(tt.ptype, tt.signature, tt.title)
if got != tt.want {
t.Errorf("generatePatternName() = %q, want %q", got, tt.want)
}
})
}
}
func TestGeneratePatternName_AllTypes(t *testing.T) {
tests := []struct {
ptype models.PatternType
wantPrefix string
}{
{models.PatternTypeBug, "Bug Pattern:"},
{models.PatternTypeRefactor, "Refactor Pattern:"},
{models.PatternTypeArchitecture, "Architecture Pattern:"},
{models.PatternTypeAntiPattern, "Anti-Pattern:"},
{models.PatternTypeBestPractice, "Best Practice:"},
{models.PatternType("unknown"), "test"}, // Unknown type has empty prefix, starts with first signature element
}
for _, tt := range tests {
t.Run(string(tt.ptype), func(t *testing.T) {
name := generatePatternName(tt.ptype, []string{"test", "sig"}, "")
if !hasPrefix(name, tt.wantPrefix) {
t.Errorf("Expected prefix %q for type %s, got: %s",
tt.wantPrefix, tt.ptype, name)
}
})
}
}
func TestFormatPatternInsight(t *testing.T) {
// Pattern without recommendation
pattern1 := &models.Pattern{
@@ -386,6 +485,470 @@ func TestFormatPatternInsight(t *testing.T) {
}
}
func TestFormatPatternInsight_AllTypes(t *testing.T) {
types := []struct {
ptype models.PatternType
contains string
}{
{models.PatternTypeBug, "bug pattern"},
{models.PatternTypeRefactor, "recognized pattern"}, // Falls to default case
{models.PatternTypeArchitecture, "recognized pattern"}, // Falls to default case
{models.PatternTypeAntiPattern, "anti-pattern"},
{models.PatternTypeBestPractice, "best practice"},
{models.PatternType("unknown"), "recognized pattern"}, // Falls to default case
}
for _, tt := range types {
t.Run(string(tt.ptype), func(t *testing.T) {
pattern := &models.Pattern{
Type: tt.ptype,
Frequency: 3,
Projects: []string{"proj1"},
}
insight := formatPatternInsight(pattern)
if !containsString(insight, tt.contains) {
t.Errorf("Expected insight to contain %q for type %s, got: %s",
tt.contains, tt.ptype, insight)
}
})
}
}
func TestFormatPatternInsight_MultiProject(t *testing.T) {
pattern := &models.Pattern{
Type: models.PatternTypeBug,
Frequency: 10,
Projects: []string{"proj1", "proj2", "proj3"},
}
insight := formatPatternInsight(pattern)
if !containsString(insight, "10 times") {
t.Error("Expected frequency in insight")
}
if !containsString(insight, "3 projects") {
t.Error("Expected project count in insight")
}
}
func TestFormatPatternInsight_SingleProject(t *testing.T) {
pattern := &models.Pattern{
Type: models.PatternTypeBestPractice,
Frequency: 5,
Projects: []string{"only-one"},
}
insight := formatPatternInsight(pattern)
if !containsString(insight, "5 times") {
t.Error("Expected frequency in insight")
}
// Single project should NOT mention "projects"
if containsString(insight, "projects") {
t.Error("Single project should not mention 'projects'")
}
}
func TestDetector_SetSyncFunc(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
detector := NewDetector(patternStore, observationStore, config)
// Initially nil
if detector.syncFunc != nil {
t.Error("Expected syncFunc to be nil initially")
}
// Set sync func
var syncCalled bool
detector.SetSyncFunc(func(p *models.Pattern) {
syncCalled = true
})
if detector.syncFunc == nil {
t.Error("Expected syncFunc to be set")
}
// Verify it can be called
detector.syncFunc(&models.Pattern{})
if !syncCalled {
t.Error("Expected sync function to be called")
}
}
func TestDetector_CandidateCount(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
detector := NewDetector(patternStore, observationStore, config)
// Initially zero
if count := detector.CandidateCount(); count != 0 {
t.Errorf("Expected 0 candidates, got %d", count)
}
// Add some candidates
detector.candidates["key1"] = &candidatePattern{}
detector.candidates["key2"] = &candidatePattern{}
if count := detector.CandidateCount(); count != 2 {
t.Errorf("Expected 2 candidates, got %d", count)
}
}
func TestDetector_AnalyzeRecentObservations(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Should not error even with no observations
err := detector.AnalyzeRecentObservations(ctx)
if err != nil {
t.Fatalf("AnalyzeRecentObservations() error = %v", err)
}
}
func TestGenerateCandidateKey(t *testing.T) {
tests := []struct {
name string
want string
signature []string
}{
{
name: "single element",
signature: []string{"error"},
want: "error|",
},
{
name: "multiple elements",
signature: []string{"error", "handling", "nil"},
want: "error|handling|nil|",
},
{
name: "empty signature",
signature: []string{},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := generateCandidateKey(tt.signature)
if got != tt.want {
t.Errorf("generateCandidateKey() = %q, want %q", got, tt.want)
}
})
}
}
func TestGenerateCandidateKey_EdgeCases(t *testing.T) {
tests := []struct {
name string
want string
signature []string
}{
{
name: "nil signature",
signature: nil,
want: "",
},
{
name: "empty strings in signature",
signature: []string{"", ""},
want: "||",
},
{
name: "special characters",
signature: []string{"error|handling", "nil"},
want: "error|handling|nil|",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := generateCandidateKey(tt.signature)
if got != tt.want {
t.Errorf("generateCandidateKey() = %q, want %q", got, tt.want)
}
})
}
}
func TestItoa(t *testing.T) {
tests := []struct {
want string
input int
}{
{"0", 0},
{"1", 1},
{"10", 10},
{"123", 123},
{"-1", -1},
{"-123", -123},
{"1000000", 1000000},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := itoa(tt.input)
if got != tt.want {
t.Errorf("itoa(%d) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestItoa_EdgeCases(t *testing.T) {
tests := []struct {
want string
input int
}{
{"0", 0},
{"0", -0},
{"1", 1},
{"-1", -1},
{"9", 9},
{"10", 10},
{"99", 99},
{"100", 100},
{"999", 999},
{"1000", 1000},
{"-999", -999},
{"-1000", -1000},
{"2147483647", 2147483647}, // Max int32
{"-2147483647", -2147483647}, // Min int32 + 1
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := itoa(tt.input)
if got != tt.want {
t.Errorf("itoa(%d) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestDetectionResult_ZeroValue(t *testing.T) {
result := &DetectionResult{}
if result.MatchedPattern != nil {
t.Error("Zero value should have nil MatchedPattern")
}
if result.MatchScore != 0 {
t.Error("Zero value should have 0 MatchScore")
}
if result.IsNewPattern {
t.Error("Zero value should have false IsNewPattern")
}
}
func TestCandidatePattern_Fields(t *testing.T) {
candidate := &candidatePattern{
patternType: models.PatternTypeBug,
title: "Test Title",
signature: []string{"sig1", "sig2"},
observationIDs: []int64{1, 2, 3},
projects: []string{"proj1", "proj2"},
lastSeenEpoch: time.Now().UnixMilli(),
}
if candidate.patternType != models.PatternTypeBug {
t.Error("Wrong pattern type")
}
if candidate.title != "Test Title" {
t.Error("Wrong title")
}
if len(candidate.signature) != 2 {
t.Error("Wrong signature length")
}
if len(candidate.observationIDs) != 3 {
t.Error("Wrong observationIDs length")
}
if len(candidate.projects) != 2 {
t.Error("Wrong projects length")
}
}
func TestDetector_AnalyzeObservation_EmptySignature(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Create observation with empty concepts/title/narrative
obs := &models.Observation{
ID: 1,
SDKSessionID: "test-session",
Project: "test-project",
Scope: models.ScopeProject,
Type: models.ObsTypeBugfix,
// All fields that would create signature are empty
}
result, err := detector.AnalyzeObservation(ctx, obs)
if err != nil {
t.Fatalf("AnalyzeObservation() error = %v", err)
}
// Should return empty result for empty signature
if result.MatchedPattern != nil {
t.Error("Expected nil pattern for empty signature")
}
}
func TestDetector_AnalyzeObservation_CandidateEviction(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
config.MaxCandidates = 2 // Very small for testing
config.MinFrequencyForPattern = 10 // High so nothing gets promoted
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Add observations with different signatures until we exceed MaxCandidates
obs1 := createTestObservation(1, "First", []string{"first", "unique"})
obs2 := createTestObservation(2, "Second", []string{"second", "unique"})
obs3 := createTestObservation(3, "Third", []string{"third", "unique"})
// Analyze all observations
_, _ = detector.AnalyzeObservation(ctx, obs1)
time.Sleep(10 * time.Millisecond) // Small delay so timestamps differ
_, _ = detector.AnalyzeObservation(ctx, obs2)
time.Sleep(10 * time.Millisecond)
_, _ = detector.AnalyzeObservation(ctx, obs3)
// Should have at most MaxCandidates
if count := detector.CandidateCount(); count > config.MaxCandidates {
t.Errorf("Expected at most %d candidates, got %d", config.MaxCandidates, count)
}
}
func TestDetector_PromoteCandidateWithSyncFunc(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
config.MinFrequencyForPattern = 2
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Set up sync function to track calls
var syncedPattern *models.Pattern
detector.SetSyncFunc(func(p *models.Pattern) {
syncedPattern = p
})
// Create two similar observations to trigger pattern promotion
obs1 := createTestObservation(1, "Sync Test", []string{"sync", "test"})
obs2 := createTestObservation(2, "Sync Test", []string{"sync", "test"})
_, _ = detector.AnalyzeObservation(ctx, obs1)
result, _ := detector.AnalyzeObservation(ctx, obs2)
if result.MatchedPattern == nil {
t.Fatal("Expected pattern to be created")
}
if syncedPattern == nil {
t.Error("Expected sync function to be called")
}
if syncedPattern != nil && syncedPattern.Name != result.MatchedPattern.Name {
t.Errorf("Synced pattern name mismatch: got %s, want %s",
syncedPattern.Name, result.MatchedPattern.Name)
}
}
func TestDetector_AnalyzeObservation_UpdateExistingCandidate(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
config.MinFrequencyForPattern = 5 // High enough that we don't promote
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Create observations with same signature
obs1 := createTestObservation(1, "Update Test", []string{"update", "test"})
obs2 := createTestObservation(2, "Update Test", []string{"update", "test"})
obs2.Project = "different-project"
// Analyze first observation
_, _ = detector.AnalyzeObservation(ctx, obs1)
// Check candidate count
if count := detector.CandidateCount(); count != 1 {
t.Errorf("Expected 1 candidate after first obs, got %d", count)
}
// Analyze second observation
_, _ = detector.AnalyzeObservation(ctx, obs2)
// Still 1 candidate (same signature)
if count := detector.CandidateCount(); count != 1 {
t.Errorf("Expected 1 candidate after second obs, got %d", count)
}
// Check that candidate has both projects
key := generateCandidateKey([]string{"update", "test"})
candidate := detector.candidates[key]
if candidate == nil {
t.Fatal("Expected candidate to exist")
}
if len(candidate.projects) != 2 {
t.Errorf("Expected 2 projects, got %d", len(candidate.projects))
}
}
func TestDetector_GetPatternInsight_NotFound(t *testing.T) {
store := setupTestStore(t)
defer store.Close()
patternStore := gorm.NewPatternStore(store)
observationStore := gorm.NewObservationStore(store, nil, nil, nil)
config := DefaultConfig()
detector := NewDetector(patternStore, observationStore, config)
ctx := context.Background()
// Try to get insight for non-existent pattern
_, err := detector.GetPatternInsight(ctx, 99999)
if err == nil {
t.Error("Expected error for non-existent pattern")
}
}
// Helper functions
func setupTestStore(t *testing.T) *gorm.Store {
+2 -4
View File
@@ -5,10 +5,8 @@ import (
_ "embed"
)
// Cross-encoder model and tokenizer files - embedded for all platforms
// Tokenizer file - embedded for all platforms (small, not in LFS).
// The ONNX model is downloaded at runtime to ~/.claude-mnemonic/models/.
//
//go:embed assets/model.onnx
var crossEncoderModelData []byte
//go:embed assets/tokenizer.json
var crossEncoderTokenizerData []byte
Binary file not shown.
+16 -2
View File
@@ -9,6 +9,7 @@ import (
"sort"
"sync"
"github.com/lukaszraczylo/claude-mnemonic/internal/models"
"github.com/rs/zerolog/log"
"github.com/sugarme/tokenizer"
"github.com/sugarme/tokenizer/pretrained"
@@ -26,6 +27,13 @@ const (
DefaultCandidateLimit = 100
// DefaultResultLimit is the default number of results to return after reranking
DefaultResultLimit = 10
// CrossEncoderAssetName is the local filename for the cross-encoder model.
CrossEncoderAssetName = "ms-marco-MiniLM-L6-v2-model.onnx"
// CrossEncoderAssetURL is the Hugging Face URL for the cross-encoder ONNX model.
CrossEncoderAssetURL = "https://huggingface.co/cross-encoder/ms-marco-MiniLM-L6-v2/resolve/main/onnx/model.onnx"
// CrossEncoderModelSHA256 is the expected SHA-256 of the cross-encoder model file.
CrossEncoderModelSHA256 = "5d3e70fd0c9ff14b9b5169a51e957b7a9c74897afd0a35ce4bd318150c1d4d4a"
)
// Candidate represents a search result candidate for reranking.
@@ -91,12 +99,18 @@ func NewService(cfg Config) (*Service, error) {
Stride: 0,
})
// Download model on first use (cached to ~/.claude-mnemonic/models/)
modelPath, err := models.EnsureModel(CrossEncoderAssetName, CrossEncoderAssetURL, CrossEncoderModelSHA256)
if err != nil {
return nil, fmt.Errorf("ensure cross-encoder model: %w", err)
}
// Cross-encoder outputs a single logit for relevance scoring
inputNames := []string{"input_ids", "attention_mask", "token_type_ids"}
outputNames := []string{"logits"}
session, err := ort.NewDynamicAdvancedSessionWithONNXData(
crossEncoderModelData,
session, err := ort.NewDynamicAdvancedSession(
modelPath,
inputNames,
outputNames,
nil,
+33 -15
View File
@@ -46,7 +46,7 @@ const (
cacheWarmingInterval = 20 * time.Second // Run warming cycle every 20 seconds
frequencyCleanupInterval = 5 * time.Minute // Cleanup stale entries every 5 minutes
cacheCleanupInterval = time.Minute // Cleanup expired cache every minute
warmingQueryTimeout = 5 * time.Second // Timeout for warming queries
warmingQueryTimeout = 2 * time.Second // Timeout for warming queries (kept short to minimize mutex hold)
warmingBatchSize = 5 // Warm top 5 queries per cycle
minRecencyFactor = 0.1 // Minimum recency factor for scoring
@@ -127,6 +127,7 @@ type Manager struct {
queryFrequency map[string]*queryFrequencyInfo
cacheTTL time.Duration
cacheMaxSize int
activeQueries atomic.Int32 // tracks in-flight user queries to yield embedding mutex
resultCacheMu sync.RWMutex
queryFrequencyMu sync.RWMutex
}
@@ -256,7 +257,14 @@ func (m *Manager) cleanupStaleFrequencyEntries() {
}
// warmFrequentQueries pre-executes frequently used queries to warm the cache.
// Skips warming entirely when user queries are in-flight to avoid competing
// for the embedding model mutex on throttled hardware.
func (m *Manager) warmFrequentQueries() {
if m.activeQueries.Load() > 0 {
log.Debug().Msg("Skipping cache warming: user queries in flight")
return
}
m.queryFrequencyMu.RLock()
// Find top N most frequent queries that aren't recently cached
type queryScore struct {
@@ -687,30 +695,40 @@ func (m *Manager) UnifiedSearch(ctx context.Context, params SearchParams) (*Unif
params.OrderBy = defaultOrderBy
}
// Track active user queries so cache warming can yield the embedding mutex
m.activeQueries.Add(1)
defer m.activeQueries.Add(-1)
// Check cache first
cacheKey := m.getCacheKey(params)
if cached, ok := m.getFromCache(cacheKey); ok {
return cached, nil
}
// Use singleflight to coalesce concurrent identical requests
result, err, _ := m.searchGroup.Do(cacheKey, func() (any, error) {
// Use singleflight DoChan to coalesce concurrent identical requests.
// DoChan + select allows per-caller context cancellation: waiting callers
// can bail out when their context expires without blocking on a slow first call.
ch := m.searchGroup.DoChan(cacheKey, func() (any, error) {
return m.executeSearch(ctx, params)
})
if err != nil {
return nil, err
select {
case res := <-ch:
if res.Err != nil {
return nil, res.Err
}
searchResult := res.Val.(*UnifiedSearchResult)
// Cache the result
m.putInCache(cacheKey, searchResult)
// Track query frequency for cache warming
m.trackQueryFrequency(params)
return searchResult, nil
case <-ctx.Done():
return nil, ctx.Err()
}
searchResult := result.(*UnifiedSearchResult)
// Cache the result
m.putInCache(cacheKey, searchResult)
// Track query frequency for cache warming
m.trackQueryFrequency(params)
return searchResult, nil
}
// executeSearch performs the actual search without caching/coalescing.
+85
View File
@@ -1009,6 +1009,91 @@ func TestSearchParams_FormatValues(t *testing.T) {
}
}
// =============================================================================
// REGRESSION TESTS FOR Fix #3: Cache warming skipped during active queries
// =============================================================================
// TestCacheWarming_SkippedDuringActiveQueries verifies that warmFrequentQueries
// returns immediately without doing work when activeQueries > 0, and actually
// warms when activeQueries == 0.
func TestCacheWarming_SkippedDuringActiveQueries(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
defer m.Close()
// Seed a frequent query so warming has something to consider
params := SearchParams{
Query: "test warming query",
Project: "test-project",
Limit: 10,
}
m.trackQueryFrequency(params)
// Track it multiple times to boost its score
for range 5 {
m.trackQueryFrequency(params)
}
// Case 1: activeQueries > 0 — warming should be skipped (fast return)
m.activeQueries.Store(1)
start := time.Now()
m.warmFrequentQueries()
elapsed := time.Since(start)
// Warming should return almost immediately when skipped
assert.Less(t, elapsed, 50*time.Millisecond,
"warmFrequentQueries should return quickly when user queries are in flight")
// Case 2: activeQueries == 0 — warming should proceed past the guard.
// With nil stores, executeSearch would panic, so we verify the guard was
// bypassed by checking that the frequency map's lastCached is NOT updated
// (warming fails silently on nil stores). The key assertion is that Case 1
// returns immediately (the guard works) while Case 2 enters the body.
m.activeQueries.Store(0)
// Clear frequency data to prevent executeSearch from being called
m.queryFrequencyMu.Lock()
m.queryFrequency = make(map[string]*queryFrequencyInfo)
m.queryFrequencyMu.Unlock()
start2 := time.Now()
m.warmFrequentQueries()
elapsed2 := time.Since(start2)
// With empty frequency map, warming enters the function body (past guard)
// but finds no candidates — fast return without calling executeSearch.
assert.Less(t, elapsed2, 200*time.Millisecond,
"warmFrequentQueries should complete quickly with no candidates")
}
// TestActiveQueries_IncrementDecrement verifies that the activeQueries counter
// is correctly incremented and decremented around search operations.
func TestActiveQueries_IncrementDecrement(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
defer m.Close()
// Before any search, counter should be 0
assert.Equal(t, int32(0), m.activeQueries.Load())
// Directly test the atomic increment/decrement pattern used in UnifiedSearch
// (can't call UnifiedSearch with nil stores without panicking on filterSearch)
m.activeQueries.Add(1)
assert.Equal(t, int32(1), m.activeQueries.Load(), "should be 1 after increment")
m.activeQueries.Add(1)
assert.Equal(t, int32(2), m.activeQueries.Load(), "should be 2 after second increment")
m.activeQueries.Add(-1)
assert.Equal(t, int32(1), m.activeQueries.Load(), "should be 1 after decrement")
m.activeQueries.Add(-1)
assert.Equal(t, int32(0), m.activeQueries.Load(), "should be 0 after final decrement")
// Verify the field is accessible from warmFrequentQueries guard
m.activeQueries.Store(3)
assert.Equal(t, int32(3), m.activeQueries.Load())
m.activeQueries.Store(0)
}
// TestUnifiedSearchResult_MultipleResults tests result with multiple items.
func TestUnifiedSearchResult_MultipleResults(t *testing.T) {
results := []SearchResult{
+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
+70 -21
View File
@@ -145,20 +145,24 @@ func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
return nil
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
// Generate embeddings for all documents
// Prepare texts for embedding
texts := make([]string, len(docs))
for i, doc := range docs {
texts[i] = doc.Content
}
embeddings, err := c.embedSvc.EmbedBatch(texts)
// Compute embeddings OUTSIDE the write lock for better concurrency.
// Embedding is ONNX inference (slow, mutex-protected internally) — holding
// writeMu during inference blocks all concurrent writes and reads.
embeddings, err := c.embedSvc.EmbedBatchWithContext(ctx, texts)
if err != nil {
return fmt.Errorf("generate embeddings: %w", err)
}
// Acquire write lock for DB operations only
c.writeMu.Lock()
defer c.writeMu.Unlock()
// Insert into vectors table with model version tracking
const insertQuery = `
INSERT OR REPLACE INTO vectors (doc_id, embedding, sqlite_id, doc_type, field_type, project, scope, model_version)
@@ -271,7 +275,7 @@ func (c *Client) Query(ctx context.Context, query string, limit int, where map[s
}
// Generate query embedding OUTSIDE the lock for better concurrency
queryEmb, err := c.getOrComputeEmbedding(query)
queryEmb, err := c.getOrComputeEmbedding(ctx, query)
if err != nil {
return nil, fmt.Errorf("embed query: %w", err)
}
@@ -282,8 +286,10 @@ func (c *Client) Query(ctx context.Context, query string, limit int, where map[s
return nil, fmt.Errorf("serialize query embedding: %w", err)
}
// Now acquire read lock for the actual DB query
c.readMu.RLock()
// Acquire read lock with context awareness to prevent indefinite blocking
if err := acquireRLockWithContext(ctx, &c.readMu); err != nil {
return nil, fmt.Errorf("acquire read lock: %w", err)
}
defer c.readMu.RUnlock()
// Build query with filters
@@ -485,7 +491,7 @@ func (c *Client) QueryBatch(ctx context.Context, queries []string, limit int, wh
// Combines results from different field types and deduplicates by document ID.
func (c *Client) QueryMultiField(ctx context.Context, query string, limit int, docType string, project string) ([]QueryResult, error) {
// Generate embedding once
queryEmb, err := c.getOrComputeEmbedding(query)
queryEmb, err := c.getOrComputeEmbedding(ctx, query)
if err != nil {
return nil, fmt.Errorf("embed query: %w", err)
}
@@ -496,7 +502,9 @@ func (c *Client) QueryMultiField(ctx context.Context, query string, limit int, d
return nil, fmt.Errorf("serialize query embedding: %w", err)
}
c.readMu.RLock()
if err := acquireRLockWithContext(ctx, &c.readMu); err != nil {
return nil, fmt.Errorf("acquire read lock: %w", err)
}
defer c.readMu.RUnlock()
// Query with field type aggregation - get best match per document
@@ -555,6 +563,28 @@ func (c *Client) QueryMultiField(ctx context.Context, query string, limit int, d
return results, rows.Err()
}
// acquireRLockWithContext acquires a read lock on mu, respecting ctx cancellation.
// If ctx is cancelled while waiting for the lock, the goroutine that eventually
// acquires it will release it immediately to prevent leaks.
func acquireRLockWithContext(ctx context.Context, mu *sync.RWMutex) error {
acquired := make(chan struct{})
go func() {
mu.RLock()
close(acquired)
}()
select {
case <-acquired:
return nil
case <-ctx.Done():
// Goroutine may still acquire lock after ctx cancelled — must unlock
go func() {
<-acquired
mu.RUnlock()
}()
return ctx.Err()
}
}
// truncateString truncates a string to maxLen characters.
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
@@ -565,7 +595,9 @@ func truncateString(s string, maxLen int) string {
// Count returns the total number of vectors in the store.
func (c *Client) Count(ctx context.Context) (int64, error) {
c.readMu.RLock()
if err := acquireRLockWithContext(ctx, &c.readMu); err != nil {
return 0, fmt.Errorf("acquire read lock: %w", err)
}
defer c.readMu.RUnlock()
var count int64
@@ -586,7 +618,9 @@ func (c *Client) ModelVersion() string {
// - The vectors table is empty
// - Any vectors have a different model_version than the current model
func (c *Client) NeedsRebuild(ctx context.Context) (bool, string) {
c.readMu.RLock()
if err := acquireRLockWithContext(ctx, &c.readMu); err != nil {
return false, ""
}
defer c.readMu.RUnlock()
currentModel := c.embedSvc.Version()
@@ -634,7 +668,9 @@ type StaleVectorInfo struct {
// GetStaleVectors returns doc_ids of vectors with mismatched or null model versions.
// This enables granular rebuild - only re-embedding documents that need updating.
func (c *Client) GetStaleVectors(ctx context.Context) ([]StaleVectorInfo, error) {
c.readMu.RLock()
if err := acquireRLockWithContext(ctx, &c.readMu); err != nil {
return nil, fmt.Errorf("acquire read lock: %w", err)
}
defer c.readMu.RUnlock()
currentModel := c.embedSvc.Version()
@@ -692,7 +728,9 @@ type VectorHealthStats struct {
// GetHealthStats returns comprehensive health statistics about the vector store.
func (c *Client) GetHealthStats(ctx context.Context) (*VectorHealthStats, error) {
c.readMu.RLock()
if err := acquireRLockWithContext(ctx, &c.readMu); err != nil {
return nil, fmt.Errorf("acquire read lock: %w", err)
}
defer c.readMu.RUnlock()
stats := &VectorHealthStats{
@@ -856,7 +894,9 @@ func (c *Client) DeleteByObservationID(ctx context.Context, obsID int64) error {
// getOrComputeEmbedding returns a cached embedding or computes a new one.
// Uses singleflight to prevent duplicate concurrent computations for the same query.
func (c *Client) getOrComputeEmbedding(query string) ([]float32, error) {
// The context controls timeout on the embedding mutex acquisition -- if the ONNX model
// hangs under CGO, callers can bail out instead of blocking forever.
func (c *Client) getOrComputeEmbedding(ctx context.Context, query string) ([]float32, error) {
now := time.Now().UnixNano()
// Check cache first (read lock)
@@ -870,8 +910,10 @@ func (c *Client) getOrComputeEmbedding(query string) ([]float32, error) {
}
c.queryCacheMu.RUnlock()
// Cache miss - use singleflight to deduplicate concurrent embedding requests
result, err, _ := c.embeddingGroup.Do(query, func() (any, error) {
// Cache miss - use singleflight DoChan to deduplicate concurrent embedding requests.
// DoChan + select allows per-caller context cancellation: if a caller's context
// expires it can bail out without waiting for the shared computation to finish.
ch := c.embeddingGroup.DoChan(query, func() (any, error) {
// Double-check cache inside singleflight (another goroutine may have just cached it)
c.queryCacheMu.RLock()
if entry, ok := c.queryCache[query]; ok {
@@ -885,7 +927,9 @@ func (c *Client) getOrComputeEmbedding(query string) ([]float32, error) {
// Record cache miss
c.stats.embeddingMisses.Add(1)
// Compute embedding
// Compute embedding — use non-context Embed here because the singleflight
// result is shared across callers with different contexts. Per-caller
// cancellation is handled by the select below.
emb, err := c.embedSvc.Embed(query)
if err != nil {
return nil, err
@@ -933,10 +977,15 @@ func (c *Client) getOrComputeEmbedding(query string) ([]float32, error) {
return emb, nil
})
if err != nil {
return nil, err
select {
case res := <-ch:
if res.Err != nil {
return nil, res.Err
}
return res.Val.([]float32), nil
case <-ctx.Done():
return nil, ctx.Err()
}
return result.([]float32), nil
}
// ClearCache clears the embedding cache.
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.
+22 -8
View File
@@ -23,6 +23,10 @@ import (
// IMPORTANT: This is on the critical startup path - must be fast!
// No synchronous verification - just filter by staleness and return.
func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) {
// Add request-scoped timeout to prevent indefinite blocking on DB operations
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
cwd := r.URL.Query().Get("cwd")
@@ -54,7 +58,7 @@ func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) {
var expandedQueries []expansion.ExpandedQuery
var detectedIntent string
if s.queryExpander != nil {
expandCtx, expandCancel := context.WithTimeout(r.Context(), 5*time.Second)
expandCtx, expandCancel := context.WithTimeout(ctx, 5*time.Second)
cfg := expansion.DefaultConfig()
cfg.EnableVocabularyExpansion = false // Vocabulary expansion is optional
expandedQueries = s.queryExpander.Expand(expandCtx, query, cfg)
@@ -83,7 +87,7 @@ func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) {
var vectorErrors int
for _, eq := range expandedQueries {
vectorResults, vecErr := s.vectorClient.Query(r.Context(), eq.Query, limit*2, where)
vectorResults, vecErr := s.vectorClient.Query(ctx, eq.Query, limit*2, where)
if vecErr != nil {
vectorErrors++
log.Debug().Err(vecErr).Str("query", eq.Query).Msg("Vector query failed")
@@ -125,7 +129,7 @@ func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) {
if len(obsIDs) > 0 {
// Fetch full observations from SQLite
observations, err = s.observationStore.GetObservationsByIDs(r.Context(), obsIDs, "date_desc", limit)
observations, err = s.observationStore.GetObservationsByIDs(ctx, obsIDs, "date_desc", limit)
if err == nil {
usedVector = true
}
@@ -138,11 +142,11 @@ func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) {
if vectorSearchFailed {
log.Info().Str("project", project).Msg("Using FTS fallback due to vector search failure")
}
observations, err = s.observationStore.SearchObservationsFTS(r.Context(), query, project, limit)
observations, err = s.observationStore.SearchObservationsFTS(ctx, query, project, limit)
if err != nil {
// FTS might fail if query has special chars, try without
log.Warn().Err(err).Str("query", query).Msg("FTS search failed, falling back to recent")
observations, err = s.observationStore.GetRecentObservations(r.Context(), project, limit)
observations, err = s.observationStore.GetRecentObservations(ctx, project, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -354,7 +358,9 @@ func (s *Service) handleFileContext(w http.ResponseWriter, r *http.Request) {
}
// Search for observations related to each file in parallel
ctx := r.Context()
// Add request-scoped timeout to prevent indefinite blocking on DB operations
ctx, ctxCancel := context.WithTimeout(r.Context(), 15*time.Second)
defer ctxCancel()
// Check if vector search is available
if s.vectorClient == nil || !s.vectorClient.IsConnected() {
@@ -562,6 +568,10 @@ func splitCamelCase(s string) string {
// IMPORTANT: This is on the critical startup path - must be fast!
// No synchronous verification - just filter by staleness and return.
func (s *Service) handleContextInject(w http.ResponseWriter, r *http.Request) {
// Add request-scoped timeout to prevent indefinite blocking on DB operations
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
project := r.URL.Query().Get("project")
if project == "" {
http.Error(w, "project required", http.StatusBadRequest)
@@ -592,7 +602,7 @@ func (s *Service) handleContextInject(w http.ResponseWriter, r *http.Request) {
}
// Get recent observations
observations, err := s.observationStore.GetRecentObservations(r.Context(), project, limit)
observations, err := s.observationStore.GetRecentObservations(ctx, project, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -658,13 +668,17 @@ func (s *Service) handleContextInject(w http.ResponseWriter, r *http.Request) {
// handleContextCount returns the count of observations for a project.
func (s *Service) handleContextCount(w http.ResponseWriter, r *http.Request) {
// Add request-scoped timeout to prevent indefinite blocking on DB operations
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
project := r.URL.Query().Get("project")
if project == "" {
http.Error(w, "project required", http.StatusBadRequest)
return
}
count, err := s.getCachedObservationCount(r.Context(), project)
count, err := s.getCachedObservationCount(ctx, project)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
+140 -12
View File
@@ -2,6 +2,7 @@
package worker
import (
"context"
"encoding/json"
"net/http"
"runtime"
@@ -20,6 +21,10 @@ import (
// Supports optional query parameter for semantic search via sqlite-vec.
// Supports pagination via limit and offset query parameters.
func (s *Service) handleGetObservations(w http.ResponseWriter, r *http.Request) {
// Add request-scoped timeout to prevent indefinite blocking on DB operations
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
pagination := gorm.ParsePaginationParams(r, DefaultObservationsLimit)
project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
@@ -38,11 +43,11 @@ func (s *Service) handleGetObservations(w http.ResponseWriter, r *http.Request)
// Use vector search if query is provided and vector client is available
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeObservation, "")
vectorResults, vecErr := s.vectorClient.Query(r.Context(), query, pagination.Limit*2, where)
vectorResults, vecErr := s.vectorClient.Query(ctx, query, pagination.Limit*2, where)
if vecErr == nil && len(vectorResults) > 0 {
obsIDs := sqlitevec.ExtractObservationIDs(vectorResults, project)
if len(obsIDs) > 0 {
observations, err = s.observationStore.GetObservationsByIDs(r.Context(), obsIDs, "date_desc", pagination.Limit)
observations, err = s.observationStore.GetObservationsByIDs(ctx, obsIDs, "date_desc", pagination.Limit)
if err == nil {
usedVector = true
total = int64(len(observations)) // Vector search doesn't have total, use returned count
@@ -55,10 +60,10 @@ func (s *Service) handleGetObservations(w http.ResponseWriter, r *http.Request)
if !usedVector {
if project != "" {
// Strict project filtering for dashboard - only observations from this project
observations, total, err = s.observationStore.GetObservationsByProjectStrictPaginated(r.Context(), project, pagination.Limit, pagination.Offset)
observations, total, err = s.observationStore.GetObservationsByProjectStrictPaginated(ctx, project, pagination.Limit, pagination.Offset)
} else {
// All projects
observations, total, err = s.observationStore.GetAllRecentObservationsPaginated(r.Context(), pagination.Limit, pagination.Offset)
observations, total, err = s.observationStore.GetAllRecentObservationsPaginated(ctx, pagination.Limit, pagination.Offset)
}
}
@@ -90,6 +95,10 @@ func (s *Service) handleGetObservations(w http.ResponseWriter, r *http.Request)
// handleGetSummaries returns recent summaries.
// Supports optional query parameter for semantic search via sqlite-vec.
func (s *Service) handleGetSummaries(w http.ResponseWriter, r *http.Request) {
// Add request-scoped timeout to prevent indefinite blocking on DB operations
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
limit := gorm.ParseLimitParam(r, DefaultSummariesLimit)
project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
@@ -107,11 +116,11 @@ func (s *Service) handleGetSummaries(w http.ResponseWriter, r *http.Request) {
// Use vector search if query is provided and vector client is available
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeSessionSummary, "")
vectorResults, vecErr := s.vectorClient.Query(r.Context(), query, limit*2, where)
vectorResults, vecErr := s.vectorClient.Query(ctx, query, limit*2, where)
if vecErr == nil && len(vectorResults) > 0 {
summaryIDs := sqlitevec.ExtractSummaryIDs(vectorResults, project)
if len(summaryIDs) > 0 {
summaries, err = s.summaryStore.GetSummariesByIDs(r.Context(), summaryIDs, "date_desc", limit)
summaries, err = s.summaryStore.GetSummariesByIDs(ctx, summaryIDs, "date_desc", limit)
if err == nil {
usedVector = true
}
@@ -122,9 +131,9 @@ func (s *Service) handleGetSummaries(w http.ResponseWriter, r *http.Request) {
// Fall back to SQLite if vector search not used
if !usedVector {
if project != "" {
summaries, err = s.summaryStore.GetRecentSummaries(r.Context(), project, limit)
summaries, err = s.summaryStore.GetRecentSummaries(ctx, project, limit)
} else {
summaries, err = s.summaryStore.GetAllRecentSummaries(r.Context(), limit)
summaries, err = s.summaryStore.GetAllRecentSummaries(ctx, limit)
}
}
@@ -143,6 +152,10 @@ func (s *Service) handleGetSummaries(w http.ResponseWriter, r *http.Request) {
// handleGetPrompts returns recent user prompts.
// Supports optional query parameter for semantic search via sqlite-vec.
func (s *Service) handleGetPrompts(w http.ResponseWriter, r *http.Request) {
// Add request-scoped timeout to prevent indefinite blocking on DB operations
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
limit := gorm.ParseLimitParam(r, DefaultPromptsLimit)
project := r.URL.Query().Get("project")
query := r.URL.Query().Get("query")
@@ -160,11 +173,11 @@ func (s *Service) handleGetPrompts(w http.ResponseWriter, r *http.Request) {
// Use vector search if query is provided and vector client is available
if query != "" && s.vectorClient != nil && s.vectorClient.IsConnected() {
where := sqlitevec.BuildWhereFilter(sqlitevec.DocTypeUserPrompt, "")
vectorResults, vecErr := s.vectorClient.Query(r.Context(), query, limit*2, where)
vectorResults, vecErr := s.vectorClient.Query(ctx, query, limit*2, where)
if vecErr == nil && len(vectorResults) > 0 {
promptIDs := sqlitevec.ExtractPromptIDs(vectorResults, project)
if len(promptIDs) > 0 {
prompts, err = s.promptStore.GetPromptsByIDs(r.Context(), promptIDs, "date_desc", limit)
prompts, err = s.promptStore.GetPromptsByIDs(ctx, promptIDs, "date_desc", limit)
if err == nil {
usedVector = true
}
@@ -175,9 +188,9 @@ func (s *Service) handleGetPrompts(w http.ResponseWriter, r *http.Request) {
// Fall back to SQLite if vector search not used
if !usedVector {
if project != "" {
prompts, err = s.promptStore.GetRecentUserPromptsByProject(r.Context(), project, limit)
prompts, err = s.promptStore.GetRecentUserPromptsByProject(ctx, project, limit)
} else {
prompts, err = s.promptStore.GetAllRecentUserPrompts(r.Context(), limit)
prompts, err = s.promptStore.GetAllRecentUserPrompts(ctx, limit)
}
}
@@ -593,3 +606,118 @@ func (s *Service) handleGetObservationByID(w http.ResponseWriter, r *http.Reques
writeJSON(w, obs)
}
// handleGraphStats returns graph statistics for the dashboard.
// Uses relation data to compute knowledge graph metrics.
func (s *Service) handleGraphStats(w http.ResponseWriter, r *http.Request) {
// Get relation count (edges) - this represents the knowledge graph
edgeCount, err := s.relationStore.GetTotalRelationCount(r.Context())
if err != nil {
edgeCount = 0
}
// Count by relation type
edgeTypes := make(map[string]int)
for _, t := range models.AllRelationTypes {
relations, err := s.relationStore.GetRelationsByType(r.Context(), t, 10000)
if err == nil {
edgeTypes[string(t)] = len(relations)
}
}
// Get unique observation IDs involved in relations (approximate node count)
// For now, use edge count as a proxy - each edge has 2 nodes
nodeCount := 0
if edgeCount > 0 {
// Rough estimate: unique nodes ≈ edges * 1.5 (since nodes can have multiple edges)
nodeCount = int(float64(edgeCount) * 1.5)
}
// Calculate average degree
var avgDegree float64
if nodeCount > 0 {
avgDegree = float64(edgeCount*2) / float64(nodeCount)
}
// Graph is enabled if we have any edges (relations)
enabled := edgeCount > 0
writeJSON(w, map[string]any{
"enabled": enabled,
"nodeCount": nodeCount,
"edgeCount": edgeCount,
"avgDegree": avgDegree,
"maxDegree": 0,
"minDegree": 0,
"medianDegree": 0.0,
"edgeTypes": edgeTypes,
"config": map[string]any{
"maxHops": 2,
"branchFactor": 10,
"edgeWeight": 0.3,
"rebuildIntervalMin": 30,
},
})
}
// handleVectorMetrics returns vector database metrics for the dashboard.
// Returns enabled: false if vector features are not available.
func (s *Service) handleVectorMetrics(w http.ResponseWriter, r *http.Request) {
if s.vectorClient == nil {
writeJSON(w, map[string]any{
"enabled": false,
"message": "Vector database not initialized",
})
return
}
// Get cache stats from vector client
cacheSize, cacheMax := s.vectorClient.CacheStats()
cacheStats := s.vectorClient.GetCacheStats()
count, _ := s.vectorClient.Count(r.Context())
uptime := time.Since(s.startTime).Round(time.Second).String()
// Calculate total queries from cache hits/misses
totalQueries := cacheStats.EmbeddingHits + cacheStats.EmbeddingMisses + cacheStats.ResultHits + cacheStats.ResultMisses
totalHits := cacheStats.EmbeddingHits + cacheStats.ResultHits
totalMisses := cacheStats.EmbeddingMisses + cacheStats.ResultMisses
writeJSON(w, map[string]any{
"enabled": true,
"queries": map[string]any{
"total": totalQueries,
"hubOnly": 0,
"hybrid": 0,
"onDemand": 0,
"graph": 0,
},
"latency": map[string]any{
"avg": "0ms",
"p50": "0ms",
"p95": "0ms",
"p99": "0ms",
"avgHub": "0ms",
"avgRecompute": "0ms",
},
"storage": map[string]any{
"totalDocuments": count,
"hubDocuments": 0,
"storedEmbeddings": count,
"savingsPercent": 0.0,
"recomputedTotal": 0,
},
"cache": map[string]any{
"hits": totalHits,
"misses": totalMisses,
"hitRate": cacheStats.HitRate(),
"size": cacheSize,
"maxSize": cacheMax,
},
"graph": map[string]any{
"traversals": 0,
"avgDepth": 0.0,
},
"uptime": uptime,
})
}
+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
}
}
+62 -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,25 +292,71 @@ 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")
}
}()
writeJSON(w, map[string]string{"status": "recalculation triggered"})
}
// handleRunMaintenance triggers an immediate, synchronous database maintenance run
// (Optimize/TRUNCATE checkpoint + prompt cleanup + any enabled retention/stale cleanup)
// and also kicks off an importance-score recalculation in the background so the behavior
// of the previous trigger_maintenance tool is preserved (issue #49).
func (s *Service) handleRunMaintenance(w http.ResponseWriter, r *http.Request) {
// initMu.RLock held by requireReady middleware
maintSvc := s.maintenanceSvc
recalculator := s.recalculator
if maintSvc == nil {
http.Error(w, "maintenance service not available", http.StatusServiceUnavailable)
return
}
// Run maintenance synchronously with an independent, bounded context so the caller
// receives a real completion status. Use context.Background so an HTTP client timeout
// does not abort an in-progress DB maintenance pass.
mctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
maintSvc.RunNowSync(mctx)
// Preserve prior trigger_maintenance behavior: also recalculate importance scores.
recalcTriggered := false
if recalculator != nil {
recalcTriggered = true
s.wg.Add(1)
go func() {
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 during maintenance failed")
}
}()
}
writeJSON(w, map[string]any{
"status": "maintenance completed",
"recalc_triggered": recalcTriggered,
"maintenance_stats": maintSvc.Stats(),
})
}
// parseIntParam parses an integer query parameter with a default value.
func parseIntParam(r *http.Request, name string, defaultVal int) int {
if val := r.URL.Query().Get(name); val != "" {
@@ -336,27 +374,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)
+59 -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.
@@ -3013,3 +3024,42 @@ func TestHandleSessionStart(t *testing.T) {
// May return various status codes depending on session state and endpoint
assert.Contains(t, []int{http.StatusOK, http.StatusBadRequest, http.StatusNotFound, http.StatusInternalServerError}, rec.Code)
}
// =============================================================================
// REGRESSION TESTS FOR handler request-scoped timeouts (Fix #45)
// =============================================================================
// TestHandleSearchByPrompt_RespectsTimeout verifies that handleSearchByPrompt
// uses r.Context() as the parent for its internal 15s timeout: if the request
// context is already cancelled the handler must return quickly rather than
// hanging for 15 seconds, and must not return StatusOK.
func TestHandleSearchByPrompt_RespectsTimeout(t *testing.T) {
svc, cleanup := testService(t)
defer cleanup()
// Pre-populate a project with an observation so there is DB work to do.
createTestObservation(t, svc.observationStore, "timeout-test",
"Authentication flow", "JWT token validation", []string{"security"})
// Build a request with a pre-cancelled context.
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancelled before the request is even sent
req := httptest.NewRequest(http.MethodGet,
"/api/context/search?project=timeout-test&query=authentication",
nil).WithContext(ctx)
rec := httptest.NewRecorder()
start := time.Now()
svc.router.ServeHTTP(rec, req)
elapsed := time.Since(start)
// The handler must not hang for 15 seconds — it inherits the cancelled ctx.
assert.Less(t, elapsed, 5*time.Second,
"handler should return quickly when request context is already cancelled")
// A cancelled-context request must not yield a successful 200 response.
// Acceptable: any error status, or an empty/error body on 200 (DB returned nothing).
// The key regression: it must NOT block for the full 15s timeout.
t.Logf("handler returned status=%d in %v", rec.Code, elapsed)
}
+9 -1
View File
@@ -4,6 +4,7 @@ package worker
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/rs/zerolog/log"
@@ -13,7 +14,14 @@ import (
func (s *Service) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
info, err := s.updater.CheckForUpdate(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
// Return a proper JSON response for errors instead of 500
// This allows the frontend to handle it gracefully
writeJSON(w, map[string]any{
"available": false,
"current_version": s.version,
"error": err.Error(),
"rate_limited": strings.Contains(err.Error(), "403"),
})
return
}
writeJSON(w, info)
+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()
+663
View File
@@ -1,9 +1,12 @@
package sdk
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/stretchr/testify/assert"
@@ -1178,3 +1181,663 @@ func TestSafeResolvePath(t *testing.T) {
})
}
}
// =============================================================================
// TESTS FOR CircuitBreaker
// =============================================================================
func TestNewCircuitBreaker(t *testing.T) {
cb := NewCircuitBreaker(5, 60)
assert.NotNil(t, cb)
assert.Equal(t, int64(5), cb.threshold)
assert.Equal(t, int64(60), cb.resetTimeout)
assert.Equal(t, "closed", cb.State())
}
func TestCircuitBreaker_Allow_Closed(t *testing.T) {
cb := NewCircuitBreaker(5, 60)
// Closed state should allow requests
assert.True(t, cb.Allow())
assert.True(t, cb.Allow())
}
func TestCircuitBreaker_Allow_Open(t *testing.T) {
cb := NewCircuitBreaker(2, 60) // Low threshold for testing
// Record enough failures to open the circuit
cb.RecordFailure()
cb.RecordFailure()
// Open state should block requests
assert.False(t, cb.Allow())
assert.Equal(t, "open", cb.State())
}
func TestCircuitBreaker_RecordSuccess(t *testing.T) {
cb := NewCircuitBreaker(2, 60)
// Record a failure
cb.RecordFailure()
assert.Equal(t, int64(1), cb.Metrics().Failures)
// Record success resets failures
cb.RecordSuccess()
assert.Equal(t, int64(0), cb.Metrics().Failures)
assert.Equal(t, "closed", cb.State())
}
func TestCircuitBreaker_RecordFailure_OpensCircuit(t *testing.T) {
cb := NewCircuitBreaker(3, 60)
// Record failures below threshold
cb.RecordFailure()
assert.Equal(t, "closed", cb.State())
cb.RecordFailure()
assert.Equal(t, "closed", cb.State())
// Third failure should open circuit
cb.RecordFailure()
assert.Equal(t, "open", cb.State())
}
func TestCircuitBreaker_State(t *testing.T) {
cb := NewCircuitBreaker(1, 60)
// Initially closed
assert.Equal(t, "closed", cb.State())
// After failure, open
cb.RecordFailure()
assert.Equal(t, "open", cb.State())
// After success, closed
cb.RecordSuccess()
assert.Equal(t, "closed", cb.State())
}
func TestCircuitBreaker_Metrics(t *testing.T) {
cb := NewCircuitBreaker(5, 120)
metrics := cb.Metrics()
assert.Equal(t, "closed", metrics.State)
assert.Equal(t, int64(0), metrics.Failures)
assert.Equal(t, int64(5), metrics.Threshold)
assert.Equal(t, int64(120), metrics.ResetTimeoutSecs)
assert.Equal(t, int64(0), metrics.LastFailureUnix)
// After failure
cb.RecordFailure()
metrics = cb.Metrics()
assert.Equal(t, int64(1), metrics.Failures)
assert.Greater(t, metrics.LastFailureUnix, int64(0))
}
func TestCircuitBreaker_Metrics_OpenWithReset(t *testing.T) {
cb := NewCircuitBreaker(1, 60)
cb.RecordFailure()
assert.Equal(t, "open", cb.State())
metrics := cb.Metrics()
assert.Equal(t, "open", metrics.State)
assert.Greater(t, metrics.SecondsUntilReset, int64(0))
assert.LessOrEqual(t, metrics.SecondsUntilReset, int64(60))
}
// =============================================================================
// TESTS FOR RequestDeduplicator
// =============================================================================
func TestNewRequestDeduplicator(t *testing.T) {
d := NewRequestDeduplicator(300, 1000)
assert.NotNil(t, d)
assert.NotNil(t, d.seen)
assert.Equal(t, int64(300), d.ttlSecs)
assert.Equal(t, 1000, d.maxSize)
}
func TestRequestDeduplicator_IsDuplicate_NotSeen(t *testing.T) {
d := NewRequestDeduplicator(300, 1000)
// New hash is not a duplicate
assert.False(t, d.IsDuplicate("newhash"))
}
func TestRequestDeduplicator_IsDuplicate_AfterRecord(t *testing.T) {
d := NewRequestDeduplicator(300, 1000)
hash := "testhash"
// Record the hash
d.Record(hash)
// Now it should be a duplicate
assert.True(t, d.IsDuplicate(hash))
}
func TestRequestDeduplicator_Record(t *testing.T) {
d := NewRequestDeduplicator(300, 1000)
hash := "recordtest"
d.Record(hash)
// Check it was recorded
d.mu.RLock()
_, exists := d.seen[hash]
d.mu.RUnlock()
assert.True(t, exists)
}
func TestRequestDeduplicator_Record_Eviction(t *testing.T) {
// Small maxSize for testing eviction
d := NewRequestDeduplicator(0, 2) // TTL of 0 means everything is "old"
// Record until capacity
d.Record("hash1")
d.Record("hash2")
// Recording a third should trigger eviction (since TTL is 0)
d.Record("hash3")
// Should have cleaned up old entries
d.mu.RLock()
size := len(d.seen)
d.mu.RUnlock()
// Size should be limited (eviction occurred)
assert.LessOrEqual(t, size, 3)
}
func TestHashRequest(t *testing.T) {
tests := []struct {
name string
toolName string
input string
output string
compareWith []string
wantLen int
wantSame bool
}{
{
name: "basic hash",
toolName: "Read",
input: "file.txt",
output: "content",
wantLen: 16,
},
{
name: "consistent hashing",
toolName: "Edit",
input: "same input",
output: "same output",
wantLen: 16,
},
{
name: "long output truncation",
toolName: "Bash",
input: "command",
output: string(make([]byte, 5000)), // Very long output
wantLen: 16,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hash := hashRequest(tt.toolName, tt.input, tt.output)
assert.Len(t, hash, tt.wantLen)
// Same inputs should produce same hash
hash2 := hashRequest(tt.toolName, tt.input, tt.output)
assert.Equal(t, hash, hash2)
})
}
}
func TestHashRequest_DifferentInputs(t *testing.T) {
// Different inputs should produce different hashes
hash1 := hashRequest("Read", "file1.txt", "content1")
hash2 := hashRequest("Read", "file2.txt", "content2")
assert.NotEqual(t, hash1, hash2)
}
func TestHashRequest_OutputTruncation(t *testing.T) {
// Hash should be the same for outputs that differ only after 1000 chars
longOutput1 := string(make([]byte, 1500))
longOutput2 := longOutput1[:1000] + "different suffix here"
hash1 := hashRequest("Read", "input", longOutput1)
hash2 := hashRequest("Read", "input", longOutput2)
// Since we only hash first 1000 chars, these should be the same
assert.Equal(t, hash1, hash2)
}
// =============================================================================
// TESTS FOR Processor methods
// =============================================================================
func TestProcessor_CircuitBreakerState(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(2, 60),
}
// Initially closed
assert.Equal(t, "closed", p.CircuitBreakerState())
// After enough failures, open
p.circuitBreaker.RecordFailure()
p.circuitBreaker.RecordFailure()
assert.Equal(t, "open", p.CircuitBreakerState())
// After success, closed
p.circuitBreaker.RecordSuccess()
assert.Equal(t, "closed", p.CircuitBreakerState())
}
func TestProcessor_CircuitBreakerMetrics(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 120),
}
metrics := p.CircuitBreakerMetrics()
assert.Equal(t, "closed", metrics.State)
assert.Equal(t, int64(0), metrics.Failures)
assert.Equal(t, int64(5), metrics.Threshold)
assert.Equal(t, int64(120), metrics.ResetTimeoutSecs)
// Record a failure and check metrics update
p.circuitBreaker.RecordFailure()
metrics = p.CircuitBreakerMetrics()
assert.Equal(t, int64(1), metrics.Failures)
assert.Greater(t, metrics.LastFailureUnix, int64(0))
}
// =============================================================================
// TESTS FOR Vector Sync Workers
// =============================================================================
func TestProcessor_StartAndStopVectorSyncWorkers(t *testing.T) {
var syncedObservations []*models.Observation
var mu sync.Mutex
p := &Processor{
vectorSyncChan: make(chan *models.Observation, MaxVectorSyncWorkers*2),
vectorSyncDone: make(chan struct{}),
syncObservationFunc: func(obs *models.Observation) {
mu.Lock()
syncedObservations = append(syncedObservations, obs)
mu.Unlock()
},
}
// Start workers
p.StartVectorSyncWorkers()
// Send some observations
obs1 := &models.Observation{SDKSessionID: "test1"}
obs2 := &models.Observation{SDKSessionID: "test2"}
p.vectorSyncChan <- obs1
p.vectorSyncChan <- obs2
// Give workers time to process
time.Sleep(50 * time.Millisecond)
// Stop workers
p.StopVectorSyncWorkers()
// Verify observations were synced
mu.Lock()
assert.Len(t, syncedObservations, 2)
mu.Unlock()
}
func TestProcessor_VectorSyncWorker_DrainOnShutdown(t *testing.T) {
var syncedCount int
var mu sync.Mutex
p := &Processor{
vectorSyncChan: make(chan *models.Observation, 10),
vectorSyncDone: make(chan struct{}),
syncObservationFunc: func(obs *models.Observation) {
mu.Lock()
syncedCount++
mu.Unlock()
},
}
// Queue observations before starting workers
for i := 0; i < 5; i++ {
p.vectorSyncChan <- &models.Observation{SDKSessionID: "pre-queued"}
}
// Start workers
p.StartVectorSyncWorkers()
// Stop immediately - workers should drain the queue
p.StopVectorSyncWorkers()
// All pre-queued items should have been processed
mu.Lock()
assert.Equal(t, 5, syncedCount)
mu.Unlock()
}
func TestProcessor_VectorSyncWorker_NilSyncFunc(t *testing.T) {
p := &Processor{
vectorSyncChan: make(chan *models.Observation, 10),
vectorSyncDone: make(chan struct{}),
syncObservationFunc: nil, // No sync function set
}
// Start workers
p.StartVectorSyncWorkers()
// Send observation - should not panic even with nil sync func
p.vectorSyncChan <- &models.Observation{SDKSessionID: "test"}
// Give it time to process
time.Sleep(50 * time.Millisecond)
// Stop workers - should not panic
p.StopVectorSyncWorkers()
}
// =============================================================================
// TESTS FOR CircuitBreaker Additional Behaviors
// =============================================================================
func TestCircuitBreaker_Allow_OpenBlocksRequests(t *testing.T) {
cb := NewCircuitBreaker(1, 60)
// Open the circuit
cb.RecordFailure()
assert.Equal(t, "open", cb.State())
// All requests should be blocked
assert.False(t, cb.Allow())
assert.False(t, cb.Allow())
assert.False(t, cb.Allow())
}
func TestCircuitBreaker_MultipleFailures(t *testing.T) {
cb := NewCircuitBreaker(3, 60) // Higher threshold
// Record failures below threshold
cb.RecordFailure()
assert.Equal(t, "closed", cb.State())
assert.Equal(t, int64(1), cb.Metrics().Failures)
cb.RecordFailure()
assert.Equal(t, "closed", cb.State())
assert.Equal(t, int64(2), cb.Metrics().Failures)
// Third failure opens circuit
cb.RecordFailure()
assert.Equal(t, "open", cb.State())
assert.Equal(t, int64(3), cb.Metrics().Failures)
}
func TestCircuitBreaker_SuccessResetsFailures(t *testing.T) {
cb := NewCircuitBreaker(5, 60)
// Record some failures
cb.RecordFailure()
cb.RecordFailure()
assert.Equal(t, int64(2), cb.Metrics().Failures)
// Success resets failures
cb.RecordSuccess()
assert.Equal(t, int64(0), cb.Metrics().Failures)
assert.Equal(t, "closed", cb.State())
}
func TestCircuitBreaker_Metrics_Comprehensive(t *testing.T) {
cb := NewCircuitBreaker(5, 120)
// Initial state
metrics := cb.Metrics()
assert.Equal(t, "closed", metrics.State)
assert.Equal(t, int64(0), metrics.Failures)
assert.Equal(t, int64(5), metrics.Threshold)
assert.Equal(t, int64(120), metrics.ResetTimeoutSecs)
assert.Equal(t, int64(0), metrics.LastFailureUnix)
assert.Equal(t, int64(0), metrics.SecondsUntilReset)
// After failures that open circuit
for i := 0; i < 5; i++ {
cb.RecordFailure()
}
metrics = cb.Metrics()
assert.Equal(t, "open", metrics.State)
assert.Equal(t, int64(5), metrics.Failures)
assert.Greater(t, metrics.LastFailureUnix, int64(0))
assert.Greater(t, metrics.SecondsUntilReset, int64(0))
}
// =============================================================================
// TESTS FOR MaxVectorSyncWorkers constant
// =============================================================================
func TestMaxVectorSyncWorkers(t *testing.T) {
assert.Equal(t, 8, MaxVectorSyncWorkers)
}
// =============================================================================
// ADDITIONAL EDGE CASE TESTS
// =============================================================================
func TestRequestDeduplicator_IsDuplicate_ExpiredEntry(t *testing.T) {
if testing.Short() {
t.Skip("Skipping time-dependent test in short mode")
}
// Use a 1-second TTL with enough margin
d := NewRequestDeduplicator(1, 100)
hash := "expiretest"
d.Record(hash)
// Initially duplicate
assert.True(t, d.IsDuplicate(hash))
// Wait for TTL to expire (2.5 seconds to ensure crossing second boundaries)
time.Sleep(2500 * time.Millisecond)
// Should no longer be considered duplicate
assert.False(t, d.IsDuplicate(hash))
}
// =============================================================================
// TESTS FOR ProcessObservation Early Returns
// =============================================================================
func TestProcessObservation_SkipTool(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 60),
deduplicator: NewRequestDeduplicator(300, 1000),
}
ctx := context.Background()
// TodoWrite should be skipped
err := p.ProcessObservation(ctx, "session-1", "project-1", "TodoWrite",
map[string]string{"content": "test"}, "success", 1, "/test/cwd")
assert.NoError(t, err)
// Glob should be skipped
err = p.ProcessObservation(ctx, "session-1", "project-1", "Glob",
map[string]string{"pattern": "*.go"}, []string{"main.go", "test.go"}, 1, "/test/cwd")
assert.NoError(t, err)
// AskUserQuestion should be skipped
err = p.ProcessObservation(ctx, "session-1", "project-1", "AskUserQuestion",
"question", "answer", 1, "/test/cwd")
assert.NoError(t, err)
}
func TestProcessObservation_SkipTrivial(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 60),
deduplicator: NewRequestDeduplicator(300, 1000),
}
ctx := context.Background()
// Short output should be skipped
err := p.ProcessObservation(ctx, "session-1", "project-1", "Read",
map[string]string{"file_path": "/test.go"}, "short", 1, "/test/cwd")
assert.NoError(t, err)
// "No matches found" should be skipped
err = p.ProcessObservation(ctx, "session-1", "project-1", "Grep",
map[string]string{"pattern": "test"}, "No matches found in the repository", 1, "/test/cwd")
assert.NoError(t, err)
}
func TestProcessObservation_SkipDuplicate(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 60),
deduplicator: NewRequestDeduplicator(300, 1000),
sem: make(chan struct{}, 4),
claudePath: "/nonexistent/path", // Will fail at CLI call stage
}
ctx := context.Background()
// Valid input that would be processed
input := map[string]string{"file_path": "/project/main.go"}
output := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello World\")\n}"
// First call should try to process (will fail because claudePath doesn't exist)
err := p.ProcessObservation(ctx, "session-1", "project-1", "Read", input, output, 1, "/test/cwd")
// Expect error because claudePath doesn't exist
assert.Error(t, err)
// Second call with same input should be skipped as duplicate
err = p.ProcessObservation(ctx, "session-1", "project-1", "Read", input, output, 1, "/test/cwd")
assert.NoError(t, err) // No error because it was skipped as duplicate
}
func TestProcessObservation_CircuitBreakerOpen(t *testing.T) {
cb := NewCircuitBreaker(1, 60) // Threshold of 1
cb.RecordFailure() // Open the circuit breaker
p := &Processor{
circuitBreaker: cb,
deduplicator: NewRequestDeduplicator(300, 1000),
}
ctx := context.Background()
// Valid input that would be processed
input := map[string]string{"file_path": "/project/main.go"}
output := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello World\")\n}"
err := p.ProcessObservation(ctx, "session-1", "project-1", "Read", input, output, 1, "/test/cwd")
assert.Error(t, err)
assert.Contains(t, err.Error(), "circuit breaker open")
}
func TestProcessObservation_ContextCancel(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 60),
deduplicator: NewRequestDeduplicator(300, 1000),
sem: make(chan struct{}, 1), // Small semaphore
claudePath: "/fake/claude",
}
// Fill the semaphore
p.sem <- struct{}{}
// Create a cancelled context
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
// Valid input that would be processed
input := map[string]string{"file_path": "/project/main.go"}
output := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello World\")\n}"
err := p.ProcessObservation(ctx, "session-1", "project-1", "Read", input, output, 1, "/test/cwd")
assert.Error(t, err)
assert.ErrorIs(t, err, context.Canceled)
}
// =============================================================================
// TESTS FOR ProcessSummary Early Returns
// =============================================================================
func TestProcessSummary_SkipEmptyRequest(t *testing.T) {
p := &Processor{
circuitBreaker: NewCircuitBreaker(5, 60),
deduplicator: NewRequestDeduplicator(300, 1000),
}
ctx := context.Background()
// Empty request should be skipped (sessionDBID, sdkSessionID, project, userPrompt, lastUserMsg, lastAssistantMsg)
err := p.ProcessSummary(ctx, 1, "session-1", "project-1", "", "", "")
assert.NoError(t, err)
}
func TestProcessSummary_CircuitBreakerOpen(t *testing.T) {
cb := NewCircuitBreaker(1, 60)
cb.RecordFailure() // Open the circuit breaker
p := &Processor{
circuitBreaker: cb,
deduplicator: NewRequestDeduplicator(300, 1000),
sem: make(chan struct{}, 4),
claudePath: "/nonexistent/path",
}
ctx := context.Background()
// Meaningful assistant message (> 200 chars, contains code discussion)
assistantMsg := `I've updated the handler.go file to fix the authentication bug.
The function validateToken() was not checking token expiry correctly.
I've added a check for the exp claim and implemented proper error handling.
The changes have been tested and the build passes successfully.
Here's the implementation details and code review.`
// Valid request but circuit breaker is open
err := p.ProcessSummary(ctx, 1, "session-1", "project-1",
"Implement authentication", "User message", assistantMsg)
assert.Error(t, err)
assert.Contains(t, err.Error(), "claude CLI failed")
}
// =============================================================================
// TESTS FOR callClaudeCLI Error Paths
// =============================================================================
func TestCallClaudeCLI_PromptTooLarge(t *testing.T) {
p := &Processor{
claudePath: "/fake/claude",
}
ctx := context.Background()
// Create a prompt that exceeds MaxPromptSize
largePrompt := string(make([]byte, MaxPromptSize+1))
_, err := p.callClaudeCLI(ctx, largePrompt)
assert.Error(t, err)
assert.Contains(t, err.Error(), "prompt exceeds maximum size")
}
func TestCallClaudeCLI_BinaryNotFound(t *testing.T) {
p := &Processor{
claudePath: "/nonexistent/path/to/claude",
}
ctx := context.Background()
_, err := p.callClaudeCLI(ctx, "test prompt")
assert.Error(t, err)
assert.Contains(t, err.Error(), "claude CLI failed")
}
+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
+170 -26
View File
@@ -8,6 +8,7 @@ import (
"os"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/go-chi/chi/v5"
@@ -15,6 +16,7 @@ import (
"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/maintenance"
"github.com/lukaszraczylo/claude-mnemonic/internal/pattern"
"github.com/lukaszraczylo/claude-mnemonic/internal/reranking"
"github.com/lukaszraczylo/claude-mnemonic/internal/scoring"
@@ -43,6 +45,21 @@ const (
// QueueProcessInterval is how often the background queue processor runs.
QueueProcessInterval = 2 * time.Second
// WALCheckpointInterval is how often the worker checks whether the SQLite WAL needs a
// TRUNCATE checkpoint to reclaim disk and prevent unbounded growth (issue #49).
WALCheckpointInterval = 60 * time.Second
// WALCheckpointThreshold is the WAL file size at or above which the periodic check
// performs a TRUNCATE checkpoint. Keeps the steady-state WAL bounded to a few MB.
WALCheckpointThreshold = 4 << 20 // 4 MiB
// 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
@@ -113,6 +130,7 @@ type Service struct {
patternStore *gorm.PatternStore
relationStore *gorm.RelationStore
patternDetector *pattern.Detector
maintenanceSvc *maintenance.Service
sessionManager *session.Manager
sseBroadcaster *sse.Broadcaster
processor *sdk.Processor
@@ -138,6 +156,7 @@ type Service struct {
updater *update.Updater
rateLimiter *PerClientRateLimiter
expensiveOpLimiter *ExpensiveOperationLimiter
contextCache sync.Map
version string
recentQueriesBuf [maxRecentQueries]RecentSearchQuery
wg sync.WaitGroup
@@ -178,6 +197,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 +314,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 +377,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
@@ -549,6 +580,34 @@ func (s *Service) initializeAsync() {
go s.processQueue()
}
// Start periodic WAL checkpoint loop to bound SQLite WAL file growth (issue #49).
s.wg.Add(1)
go s.walCheckpointLoop()
// Start the scheduled maintenance service (issue #49: was dead code, never instantiated).
// vectorCleanupFn mirrors the observation store's cleanup hook so age/stale deletions done
// directly via GORM still remove their vectors from sqlite-vec.
var vectorCleanupFn func(ctx context.Context, deletedIDs []int64)
if vectorSync != nil {
vectorCleanupFn = func(ctx context.Context, deletedIDs []int64) {
if err := retryWithBackoff(ctx, VectorSyncMaxRetries, VectorSyncInitialBackoff, func() error {
return vectorSync.DeleteObservations(ctx, deletedIDs)
}); err != nil {
log.Warn().Err(err).Ints64("ids", deletedIDs).Msg("Failed to delete observations from sqlite-vec during maintenance")
}
}
}
maintSvc := maintenance.NewService(store, observationStore, summaryStore, promptStore, vectorCleanupFn, s.config, log.Logger)
s.initMu.Lock()
s.maintenanceSvc = maintSvc
s.initMu.Unlock()
s.wg.Add(1)
go func() {
defer s.wg.Done()
maintSvc.Start(s.ctx)
}()
log.Info().Msg("Maintenance scheduler started")
// Start file watchers for auto-recreation on deletion
s.startWatchers()
@@ -614,6 +673,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 +775,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 +858,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.
@@ -1198,6 +1265,8 @@ func (s *Service) setupRoutes() {
// Vector management endpoints
s.router.Post("/api/vectors/rebuild", s.handleTriggerVectorRebuild)
s.router.Get("/api/vectors/health", s.handleVectorHealth)
s.router.Get("/api/vector/metrics", s.handleVectorMetrics)
s.router.Get("/api/graph/stats", s.handleGraphStats)
// Readiness check - returns 200 only when fully initialized
s.router.Get("/api/ready", s.handleReady)
@@ -1215,7 +1284,12 @@ func (s *Service) setupRoutes() {
s.router.Get("/api/selfcheck", s.handleSelfCheck)
// SSE endpoint (works before DB is ready)
s.router.Get("/api/events", s.sseBroadcaster.HandleSSE)
// Wrap with middleware that disables write deadline since SSE connections are long-lived
s.router.Get("/api/events", func(w http.ResponseWriter, r *http.Request) {
rc := http.NewResponseController(w)
_ = rc.SetWriteDeadline(time.Time{}) // no deadline for SSE
s.sseBroadcaster.HandleSSE(w, r)
})
// Routes that require DB to be ready
s.router.Group(func(r chi.Router) {
@@ -1254,6 +1328,9 @@ func (s *Service) setupRoutes() {
r.Put("/api/scoring/concepts/{concept}", s.handleUpdateConceptWeight)
r.Post("/api/scoring/recalculate", s.handleTriggerRecalculation)
// Maintenance: run an immediate synchronous DB maintenance pass (issue #49)
r.Post("/api/maintenance/run", s.handleRunMaintenance)
// Context injection
r.Get("/api/context/count", s.handleContextCount)
r.Get("/api/context/inject", s.handleContextInject)
@@ -1515,7 +1592,7 @@ func (s *Service) Start() error {
Handler: s.router,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 0, // Disabled for SSE (long-lived connections)
WriteTimeout: 60 * time.Second, // Default for API routes; SSE handler extends per-request
IdleTimeout: 120 * time.Second,
}
@@ -1569,47 +1646,107 @@ 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()
}
}
}
// walCheckpointLoop periodically checkpoints the SQLite WAL so it cannot grow unbounded
// during long-lived sessions. SQLite's internal auto-checkpoint is PASSIVE and never
// shrinks the -wal file; under sustained writes with overlapping readers it can leave the
// WAL growing without limit (issue #49). This loop performs a TRUNCATE checkpoint whenever
// the WAL has grown to WALCheckpointThreshold, and does nothing while it is small.
func (s *Service) walCheckpointLoop() {
defer s.wg.Done()
// Tunable via config; fall back to the package constants when unset/<=0 (issue #49).
interval := WALCheckpointInterval
if s.config != nil && s.config.WALCheckpointIntervalSeconds > 0 {
interval = time.Duration(s.config.WALCheckpointIntervalSeconds) * time.Second
}
threshold := int64(WALCheckpointThreshold)
if s.config != nil && s.config.WALCheckpointThresholdBytes > 0 {
threshold = s.config.WALCheckpointThresholdBytes
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-s.ctx.Done():
return
case <-ticker.C:
s.initMu.RLock()
store := s.store
s.initMu.RUnlock()
if store == nil {
continue
}
ctx, cancel := context.WithTimeout(s.ctx, 15*time.Second)
done, err := store.CheckpointIfLarge(ctx, threshold)
cancel()
switch {
case err != nil:
log.Warn().Err(err).Msg("Periodic WAL checkpoint failed (non-fatal)")
case done:
log.Debug().Msg("Periodic WAL checkpoint (TRUNCATE) completed")
}
}
}
}
// 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 }()
// Timeout prevents a hung subprocess from blocking the queue processor forever
procCtx, procCancel := context.WithTimeout(s.ctx, 60*time.Second)
defer procCancel()
switch msg.Type {
case session.MessageTypeObservation:
if msg.Observation != nil {
err := s.processor.ProcessObservation(
s.ctx,
err := proc.ProcessObservation(
procCtx,
sess.SDKSessionID,
sess.Project,
msg.Observation.ToolName,
@@ -1627,8 +1764,8 @@ func (s *Service) processAllSessions() {
case session.MessageTypeSummarize:
if msg.Summarize != nil {
err := s.processor.ProcessSummary(
s.ctx,
err := proc.ProcessSummary(
procCtx,
sess.SessionDBID,
sess.SDKSessionID,
sess.Project,
@@ -1641,18 +1778,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()
}
@@ -1701,6 +1835,9 @@ func (s *Service) Shutdown(ctx context.Context) error {
if s.patternDetector != nil {
s.patternDetector.Stop()
}
if s.maintenanceSvc != nil {
s.maintenanceSvc.Stop()
}
// Phase 4: Shutdown sessions (flush pending work)
log.Debug().Msg("Phase 4: Shutting down sessions...")
@@ -1761,8 +1898,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))
+732
View File
@@ -693,3 +693,735 @@ func TestToolInputResponse(t *testing.T) {
})
}
}
// =============================================================================
// TESTS FOR NewManager AND CLEANUP
// =============================================================================
// TestNewManager tests the NewManager function.
func TestNewManager(t *testing.T) {
t.Parallel()
// Test with nil session store (valid for testing)
manager := NewManager(nil)
assert.NotNil(t, manager)
assert.NotNil(t, manager.sessions)
assert.NotNil(t, manager.ProcessNotify)
assert.NotNil(t, manager.ctx)
assert.NotNil(t, manager.cancel)
assert.Equal(t, 0, manager.GetActiveSessionCount())
// Clean up - cancel context to stop cleanup goroutine
manager.cancel()
}
// TestNewManager_CleanupGoroutineStops tests that cleanup goroutine stops on cancel.
func TestNewManager_CleanupGoroutineStops(t *testing.T) {
t.Parallel()
manager := NewManager(nil)
// Give goroutine time to start
time.Sleep(10 * time.Millisecond)
// Cancel should stop the cleanup goroutine
manager.cancel()
// Context should be done
select {
case <-manager.ctx.Done():
// Expected
case <-time.After(100 * time.Millisecond):
t.Error("Context should be done after cancel")
}
}
// TestCleanupStaleSessions_NoSessions tests cleanup with no sessions.
func TestCleanupStaleSessions_NoSessions(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Should not panic with empty sessions
manager.cleanupStaleSessions()
assert.Equal(t, 0, manager.GetActiveSessionCount())
}
// TestCleanupStaleSessions_FreshSession tests that fresh sessions are not cleaned.
func TestCleanupStaleSessions_FreshSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Add a fresh session
sessionCtx, sessionCancel := context.WithCancel(context.Background())
manager.sessions[1] = &ActiveSession{
SessionDBID: 1,
StartTime: time.Now(), // Fresh
pendingMessages: []PendingMessage{},
ctx: sessionCtx,
cancel: sessionCancel,
}
manager.cleanupStaleSessions()
// Session should still exist (not stale)
assert.Equal(t, 1, manager.GetActiveSessionCount())
sessionCancel()
}
// TestCleanupStaleSessions_StaleSession tests that stale sessions are cleaned.
func TestCleanupStaleSessions_StaleSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Add a stale session
sessionCtx, sessionCancel := context.WithCancel(context.Background())
manager.sessions[1] = &ActiveSession{
SessionDBID: 1,
StartTime: time.Now().Add(-SessionTimeout - time.Minute), // Stale
pendingMessages: []PendingMessage{},
ctx: sessionCtx,
cancel: sessionCancel,
}
manager.cleanupStaleSessions()
// Session should be deleted
assert.Equal(t, 0, manager.GetActiveSessionCount())
}
// TestCleanupStaleSessions_StaleWithPending tests stale sessions with pending messages are not cleaned.
func TestCleanupStaleSessions_StaleWithPending(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Add a stale session with pending messages
sessionCtx, sessionCancel := context.WithCancel(context.Background())
defer sessionCancel()
manager.sessions[1] = &ActiveSession{
SessionDBID: 1,
StartTime: time.Now().Add(-SessionTimeout - time.Minute), // Stale
pendingMessages: []PendingMessage{{Type: MessageTypeObservation}},
ctx: sessionCtx,
cancel: sessionCancel,
}
manager.cleanupStaleSessions()
// Session should NOT be deleted (has pending messages)
assert.Equal(t, 1, manager.GetActiveSessionCount())
}
// TestCleanupStaleSessions_StaleWithActiveGenerator tests stale sessions with active generator are not cleaned.
func TestCleanupStaleSessions_StaleWithActiveGenerator(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Add a stale session with active generator
sessionCtx, sessionCancel := context.WithCancel(context.Background())
defer sessionCancel()
session := &ActiveSession{
SessionDBID: 1,
StartTime: time.Now().Add(-SessionTimeout - time.Minute), // Stale
pendingMessages: []PendingMessage{},
ctx: sessionCtx,
cancel: sessionCancel,
}
session.generatorActive.Store(true)
manager.sessions[1] = session
manager.cleanupStaleSessions()
// Session should NOT be deleted (generator is active)
assert.Equal(t, 1, manager.GetActiveSessionCount())
}
// TestCleanupStaleSessions_MixedSessions tests cleanup with mixed fresh and stale sessions.
func TestCleanupStaleSessions_MixedSessions(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Fresh session
ctx1, cancel1 := context.WithCancel(context.Background())
defer cancel1()
manager.sessions[1] = &ActiveSession{
SessionDBID: 1,
StartTime: time.Now(),
pendingMessages: []PendingMessage{},
ctx: ctx1,
cancel: cancel1,
}
// Stale session (should be deleted)
ctx2, cancel2 := context.WithCancel(context.Background())
manager.sessions[2] = &ActiveSession{
SessionDBID: 2,
StartTime: time.Now().Add(-SessionTimeout - time.Minute),
pendingMessages: []PendingMessage{},
ctx: ctx2,
cancel: cancel2,
}
// Stale session with pending (should NOT be deleted)
ctx3, cancel3 := context.WithCancel(context.Background())
defer cancel3()
manager.sessions[3] = &ActiveSession{
SessionDBID: 3,
StartTime: time.Now().Add(-SessionTimeout - time.Minute),
pendingMessages: []PendingMessage{{Type: MessageTypeObservation}},
ctx: ctx3,
cancel: cancel3,
}
manager.cleanupStaleSessions()
// Should have 2 sessions left (1 fresh, 1 stale with pending)
assert.Equal(t, 2, manager.GetActiveSessionCount())
// Verify which sessions remain
manager.mu.RLock()
_, has1 := manager.sessions[1]
_, has2 := manager.sessions[2]
_, has3 := manager.sessions[3]
manager.mu.RUnlock()
assert.True(t, has1, "Fresh session should remain")
assert.False(t, has2, "Stale session should be deleted")
assert.True(t, has3, "Stale session with pending should remain")
}
// TestCleanupLoop_ExitsOnCancel tests that cleanup loop exits when context is cancelled.
func TestCleanupLoop_ExitsOnCancel(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
manager.ctx = ctx
manager.cancel = cancel
// Start cleanup loop in goroutine
done := make(chan struct{})
manager.wg.Add(1)
go func() {
manager.cleanupLoop()
close(done)
}()
// Cancel immediately
cancel()
// Should exit quickly
select {
case <-done:
// Success - loop exited
case <-time.After(100 * time.Millisecond):
t.Error("Cleanup loop should exit when context is cancelled")
}
}
// =============================================================================
// TESTS FOR InitializeSession (without DB)
// =============================================================================
// TestInitializeSession_AlreadyActive tests reusing an already active session.
func TestInitializeSession_AlreadyActive(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add an active session
existingSession := &ActiveSession{
SessionDBID: 42,
ClaudeSessionID: "claude-existing",
Project: "test-project",
UserPrompt: "original prompt",
LastPromptNumber: 1,
StartTime: time.Now(),
pendingMessages: make([]PendingMessage, 0),
}
manager.sessions[42] = existingSession
// Initialize same session - should reuse
session, err := manager.InitializeSession(context.Background(), 42, "new prompt", 5)
assert.NoError(t, err)
assert.NotNil(t, session)
assert.Same(t, existingSession, session)
assert.Equal(t, "new prompt", session.UserPrompt)
assert.Equal(t, 5, session.LastPromptNumber)
}
// TestInitializeSession_AlreadyActive_EmptyPrompt tests reusing session with empty prompt.
func TestInitializeSession_AlreadyActive_EmptyPrompt(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add an active session
existingSession := &ActiveSession{
SessionDBID: 42,
UserPrompt: "original prompt",
LastPromptNumber: 1,
}
manager.sessions[42] = existingSession
// Initialize with empty prompt - should NOT update
session, err := manager.InitializeSession(context.Background(), 42, "", 0)
assert.NoError(t, err)
assert.NotNil(t, session)
assert.Equal(t, "original prompt", session.UserPrompt) // Unchanged
assert.Equal(t, 1, session.LastPromptNumber) // Unchanged
}
// TestInitializeSession_NoStore tests initialization without session store.
func TestInitializeSession_NoStore(t *testing.T) {
t.Parallel()
manager := &Manager{
sessionStore: nil, // No store
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Should fail gracefully with nil store (panic recovery not expected)
// This tests the guard against nil sessionStore
defer func() {
if r := recover(); r != nil {
_ = r // Expected panic when calling nil store - intentionally ignored
}
}()
_, _ = manager.InitializeSession(context.Background(), 999, "prompt", 1)
}
// TestInitializeSession_CallbackTriggered tests that created callback is triggered.
func TestInitializeSession_CallbackTriggered(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
var calledWithID int64
manager.SetOnSessionCreated(func(id int64) {
calledWithID = id
})
// Add session directly (simulating what would happen after DB fetch)
sessionCtx, sessionCancel := context.WithCancel(context.Background())
defer sessionCancel()
session := &ActiveSession{
SessionDBID: 100,
ClaudeSessionID: "test",
Project: "project",
StartTime: time.Now(),
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
ctx: sessionCtx,
cancel: sessionCancel,
}
manager.mu.Lock()
manager.sessions[100] = session
onCreated := manager.onCreated
manager.mu.Unlock()
// Trigger callback
if onCreated != nil {
onCreated(100)
}
assert.Equal(t, int64(100), calledWithID)
}
// =============================================================================
// TESTS FOR QueueObservation AND QueueSummarize (without DB)
// =============================================================================
// TestQueueObservation_ToExistingSession tests queuing to an existing session.
func TestQueueObservation_ToExistingSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
manager.sessions[1] = session
// Queue observation
err := manager.QueueObservation(context.Background(), 1, ObservationData{
ToolName: "Read",
ToolInput: map[string]string{"path": "/test"},
ToolResponse: "content",
PromptNumber: 1,
CWD: "/project",
})
assert.NoError(t, err)
assert.Equal(t, 1, manager.GetTotalQueueDepth())
// Verify message
messages := manager.DrainMessages(1)
assert.Len(t, messages, 1)
assert.Equal(t, MessageTypeObservation, messages[0].Type)
assert.Equal(t, "Read", messages[0].Observation.ToolName)
assert.Equal(t, "/project", messages[0].Observation.CWD)
}
// TestQueueObservation_NotifiesSession tests that notification is sent to session.
func TestQueueObservation_NotifiesSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session with notify channel
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
manager.sessions[1] = session
// Queue observation
err := manager.QueueObservation(context.Background(), 1, ObservationData{ToolName: "Test"})
assert.NoError(t, err)
// Should receive notification on session channel
select {
case <-session.notify:
// Success
default:
t.Error("Session should receive notification")
}
// Should receive notification on process channel
select {
case <-manager.ProcessNotify:
// Success
default:
t.Error("Manager ProcessNotify should receive notification")
}
}
// TestQueueSummarize_ToExistingSession tests queuing summarize to an existing session.
func TestQueueSummarize_ToExistingSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
manager.sessions[1] = session
// Queue summarize
err := manager.QueueSummarize(context.Background(), 1, "User asked question", "Assistant answered")
assert.NoError(t, err)
assert.Equal(t, 1, manager.GetTotalQueueDepth())
// Verify message
messages := manager.DrainMessages(1)
assert.Len(t, messages, 1)
assert.Equal(t, MessageTypeSummarize, messages[0].Type)
assert.Equal(t, "User asked question", messages[0].Summarize.LastUserMessage)
assert.Equal(t, "Assistant answered", messages[0].Summarize.LastAssistantMessage)
}
// TestQueueSummarize_NotifiesSession tests that notification is sent to session.
func TestQueueSummarize_NotifiesSession(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session with notify channel
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
manager.sessions[1] = session
// Queue summarize
err := manager.QueueSummarize(context.Background(), 1, "user", "assistant")
assert.NoError(t, err)
// Should receive notification on session channel
select {
case <-session.notify:
// Success
default:
t.Error("Session should receive notification")
}
// Should receive notification on process channel
select {
case <-manager.ProcessNotify:
// Success
default:
t.Error("Manager ProcessNotify should receive notification")
}
}
// TestQueueOperations_MultipleMessages tests queuing multiple messages.
func TestQueueOperations_MultipleMessages(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
manager.sessions[1] = session
// Queue multiple messages
for i := 0; i < 10; i++ {
if i%2 == 0 {
err := manager.QueueObservation(context.Background(), 1, ObservationData{
ToolName: "Tool" + string(rune('A'+i)),
})
assert.NoError(t, err)
} else {
err := manager.QueueSummarize(context.Background(), 1, "user", "assistant")
assert.NoError(t, err)
}
}
assert.Equal(t, 10, manager.GetTotalQueueDepth())
// Drain and verify
messages := manager.DrainMessages(1)
assert.Len(t, messages, 10)
}
// TestQueueOperations_NonBlockingNotification tests non-blocking notification behavior.
func TestQueueOperations_NonBlockingNotification(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add session with full notify channel
session := &ActiveSession{
SessionDBID: 1,
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
}
// Fill the notify channel
session.notify <- struct{}{}
manager.sessions[1] = session
// Fill ProcessNotify channel
manager.ProcessNotify <- struct{}{}
// Queue should NOT block even with full channels
done := make(chan bool)
go func() {
err := manager.QueueObservation(context.Background(), 1, ObservationData{ToolName: "Test"})
assert.NoError(t, err)
done <- true
}()
select {
case <-done:
// Success - didn't block
case <-time.After(100 * time.Millisecond):
t.Error("Queue operation should not block even with full notification channels")
}
}
// TestConcurrentQueueAndCleanup tests concurrent queue operations and cleanup.
func TestConcurrentQueueAndCleanup(t *testing.T) {
t.Parallel()
manager := &Manager{
sessions: make(map[int64]*ActiveSession),
ProcessNotify: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
manager.ctx = ctx
manager.cancel = cancel
// Pre-add multiple sessions
for i := int64(1); i <= 5; i++ {
sessionCtx, sessionCancel := context.WithCancel(context.Background())
manager.sessions[i] = &ActiveSession{
SessionDBID: i,
StartTime: time.Now(),
pendingMessages: make([]PendingMessage, 0),
notify: make(chan struct{}, 1),
ctx: sessionCtx,
cancel: sessionCancel,
}
}
var wg sync.WaitGroup
// Concurrent queue operations
for i := 0; i < 50; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
sessionID := int64((idx % 5) + 1)
if idx%2 == 0 {
_ = manager.QueueObservation(context.Background(), sessionID, ObservationData{ToolName: "Test"})
} else {
_ = manager.QueueSummarize(context.Background(), sessionID, "user", "assistant")
}
}(i)
}
// Concurrent cleanup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
manager.cleanupStaleSessions()
}()
}
// Concurrent reads
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = manager.GetActiveSessionCount()
_ = manager.GetTotalQueueDepth()
_ = manager.IsAnySessionProcessing()
_ = manager.GetAllSessions()
}()
}
wg.Wait()
// Should have all sessions (none are stale)
assert.Equal(t, 5, manager.GetActiveSessionCount())
// Should have 50 messages total
assert.Equal(t, 50, manager.GetTotalQueueDepth())
}
+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.9",
"version": "0.14.9",
"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.9",
"latest": "0.14.9",
"versions": {
"0.11.9": {
"releaseDate": "2026-01-10",
"0.14.9": {
"releaseDate": "2026-06-06",
"downloads": {
"darwin-arm64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.9/claude-mnemonic_0.11.9_darwin_arm64.tar.gz",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.14.9/claude-mnemonic_0.14.9_darwin_arm64.tar.gz",
"format": "tar.gz",
"sha256": "f93ace966b70fccbe6dd7ed8c9e4babbb968ad92e51e71a19c7dd4d15b765237"
"sha256": "65d8bad5083f1dcbc86e67a6b6a3996492774829348857ebc6fc65bc5e3aef2a"
},
"linux-amd64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.9/claude-mnemonic_0.11.9_linux_amd64.tar.gz",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.14.9/claude-mnemonic_0.14.9_linux_amd64.tar.gz",
"format": "tar.gz",
"sha256": "be2e952fb58c305c0a9258e755898988fb6a7c47445bb77374812999154c0209"
"sha256": "d857ed30e120d1c093e32c37431d5f4cc9c5d65a87fb75ddbecd5795bac7efb9"
},
"windows-amd64": {
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.11.9/claude-mnemonic_0.11.9_windows_amd64.zip",
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.14.9/claude-mnemonic_0.14.9_windows_amd64.zip",
"format": "zip",
"sha256": "89cc7eca2c1a5fd354ec0b2de18bfecf1f5d3e52e6062070a20394c83cc675a5"
"sha256": "3ac3ddba48768da6c7ff437f42e9dd4081514ab0633a647ae7391c8296ffef93"
}
}
}

Some files were not shown because too many files have changed in this diff Show More