mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-12 04:55:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f19c247278 | ||
|
|
8cddc88ab6 | ||
|
|
c9c0d988bc | ||
|
|
3945c187be | ||
|
|
06b8b47584 | ||
|
|
8dc524ea59 | ||
|
|
9dc59d13fe | ||
|
|
d8c49d8d89 | ||
|
|
6e26c4e85d | ||
|
|
8c2c69582a | ||
|
|
fbe938fef2 | ||
|
|
64c2ace22e | ||
|
|
f2886bbedb | ||
|
|
9b6e5f7648 | ||
|
|
055103cb3b | ||
|
|
67de0f0c84 | ||
|
|
eba3136fff | ||
|
|
17f7bffef5 | ||
|
|
0d75b867e6 | ||
|
|
a8e9d0f256 | ||
|
|
8857cc9c72 | ||
|
|
096490a2d3 | ||
|
|
eaf7c4ddca | ||
|
|
2d1f52cdf4 | ||
|
|
9d7959f98f | ||
|
|
daa9959961 | ||
|
|
f8e5b08de7 | ||
|
|
ad718f9292 | ||
|
|
1cfe6936de | ||
|
|
2b2bc47fe5 | ||
|
|
88d316b3ec | ||
|
|
4cef2eb792 | ||
|
|
c3cad756f9 | ||
|
|
c3fdfed512 | ||
|
|
7cb0dfbc13 | ||
|
|
9e5bcc64a8 | ||
|
|
0bbba6c078 | ||
|
|
da1d96dab4 | ||
|
|
443f557000 | ||
|
|
affdd4fab0 | ||
|
|
41b774f973 | ||
|
|
ee285441e4 | ||
|
|
2658694839 | ||
|
|
e71d34912e | ||
|
|
9c832f9ac0 | ||
|
|
7d55a6a493 | ||
|
|
8995781628 | ||
|
|
10ab2a6d41 | ||
|
|
052fef0af8 | ||
|
|
3384bc1d5f | ||
|
|
3caf83b7ea | ||
|
|
93b19d5402 | ||
|
|
6a819918f6 | ||
|
|
98ab1cd3e9 | ||
|
|
55ba09e0c6 | ||
|
|
a369a421aa | ||
|
|
76f31e97ae | ||
|
|
132666a190 | ||
|
|
3a8e3667b3 | ||
|
|
3ea3904875 | ||
|
|
8a8814968e | ||
|
|
6b8ecdd2ec | ||
|
|
aae542565e | ||
|
|
1fcb8722c4 | ||
|
|
d64a384e92 | ||
|
|
bd04da2b69 | ||
|
|
5191bc474a | ||
|
|
1b5c974473 | ||
|
|
b38ecee4da | ||
|
|
406b539c84 | ||
|
|
a23b8c4936 | ||
|
|
98e9a2d59c | ||
|
|
af19b6a798 | ||
|
|
11ee9a4f1f | ||
|
|
70a6eb2e52 | ||
|
|
86ee0e28ed | ||
|
|
1b5697b316 | ||
|
|
a8a8c532c4 | ||
|
|
e057f121eb | ||
|
|
7d1998e975 | ||
|
|
af8e6f44cd | ||
|
|
cf4161d459 | ||
|
|
910aabda22 | ||
|
|
6115ea8a44 | ||
|
|
93446d1437 | ||
|
|
1d1807da9e | ||
|
|
7bdb523106 | ||
|
|
abd34c9629 | ||
|
|
5fe25e9328 | ||
|
|
b7b82ce22f | ||
|
|
f78370a531 | ||
|
|
5f89559842 | ||
|
|
794eacf02a | ||
|
|
6fc752abb1 | ||
|
|
9ce29ac7b1 | ||
|
|
a0d0e856f5 | ||
|
|
549fefc675 | ||
|
|
c5967c3eaa | ||
|
|
dbe89f37c8 | ||
|
|
1a4fea5c17 | ||
|
|
c8b462aaec | ||
|
|
a5b18140d3 | ||
|
|
a81482d06a | ||
|
|
de5796bbe6 | ||
|
|
29d57857ff | ||
|
|
56616d0616 | ||
|
|
90803a4885 | ||
|
|
e2dd736e38 | ||
|
|
a9677881df | ||
|
|
620d538f23 | ||
|
|
a59b9b1622 | ||
|
|
00d8871f5e | ||
|
|
28b6582dac | ||
|
|
dc010fe57d | ||
|
|
7e1f6efe3c | ||
|
|
eab3b58e0f | ||
|
|
720a14cd65 | ||
|
|
f07875ee82 | ||
|
|
cfc95c9ce4 | ||
|
|
62da8e5315 | ||
|
|
d0200ff9ee | ||
|
|
c14dbc6877 | ||
|
|
9316c7ac8d | ||
|
|
73c42c9f55 | ||
|
|
c258c0c845 | ||
|
|
be536e56d5 | ||
|
|
6b575b92d6 | ||
|
|
c572408ab4 | ||
|
|
fae538c169 | ||
|
|
9e333a7de1 | ||
|
|
458f607b80 | ||
|
|
9c0083c24c | ||
|
|
a0fecab186 | ||
|
|
96e64a5796 | ||
|
|
ef92d710b8 | ||
|
|
f8702cb0c2 | ||
|
|
c52bab1e95 | ||
|
|
1eec436746 | ||
|
|
3ccb20df63 | ||
|
|
5fbba6bace | ||
|
|
de422c0de0 | ||
|
|
db2a552eb8 | ||
|
|
195caffc59 | ||
|
|
7b979a3f95 | ||
|
|
49e7efd27d | ||
|
|
fcab3ebef4 | ||
|
|
a1d4459329 | ||
|
|
5a81f37779 | ||
|
|
11fd1969de | ||
|
|
cd00f74e2c | ||
|
|
319f2ca28b | ||
|
|
77f5f02510 | ||
|
|
1a6f6b6e5e | ||
|
|
b0c0b4208e | ||
|
|
84aff03d7c | ||
|
|
9432958b03 | ||
|
|
1c63843b6f |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"commands": ["./commands/restart.md"]
|
||||||
|
}
|
||||||
+2
-1
@@ -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,4 +1,4 @@
|
|||||||
name: Autoupdate go.mod and go.sum
|
name: Update dependencies
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -6,16 +6,17 @@ on:
|
|||||||
- cron: "0 3 * * *"
|
- cron: "0 3 * * *"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
|
||||||
actions: write
|
actions: write
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
security-events: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
autoupdate:
|
update:
|
||||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
|
uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main
|
||||||
with:
|
with:
|
||||||
go-version: ">=1.24"
|
go-version: ">=1.24"
|
||||||
release-workflow: "release.yaml"
|
build-tags: "fts5"
|
||||||
lfs: true
|
node-working-directory: ui
|
||||||
secrets: inherit
|
require-node-tests: false
|
||||||
|
release-workflow: release.yaml
|
||||||
|
|||||||
@@ -20,6 +20,6 @@ jobs:
|
|||||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
|
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
|
||||||
with:
|
with:
|
||||||
go-version: ">=1.24"
|
go-version: ">=1.24"
|
||||||
lfs: true
|
lfs: false
|
||||||
build-tags: "fts5"
|
build-tags: "fts5"
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
@@ -20,19 +20,19 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: ">=1.24"
|
go-version: ">=1.24"
|
||||||
node-enabled: true
|
node-enabled: true
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
node-build-script: "cd ui && npm ci && npm run build"
|
node-build-script: "cd ui && npm ci && npm run build"
|
||||||
node-cache-dependency-path: "ui/package-lock.json"
|
node-cache-dependency-path: "ui/package-lock.json"
|
||||||
node-output-path: "ui/dist"
|
node-output-path: "ui/dist"
|
||||||
node-embed-path: "internal/worker/static"
|
node-embed-path: "internal/worker/static"
|
||||||
lfs: true
|
lfs: false
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
commit-marketplace:
|
commit-marketplace:
|
||||||
needs: release
|
needs: release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '22'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: 'docs/package-lock.json'
|
cache-dependency-path: 'docs/package-lock.json'
|
||||||
|
|
||||||
|
|||||||
+13
-5
@@ -1,5 +1,7 @@
|
|||||||
# Binaries
|
# Binaries
|
||||||
bin/
|
bin/
|
||||||
|
/mcp
|
||||||
|
/worker
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
@@ -79,14 +81,20 @@ logs/
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.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
|
# goreleaser
|
||||||
dist/
|
dist/
|
||||||
docs/dist
|
docs/dist
|
||||||
.claude-plugin
|
|
||||||
|
|
||||||
# Auto-generated plugin configs (generated by scripts/generate-plugin-config.sh)
|
# Non-template plugin configs (keep only .tpl files in plugin/ dir)
|
||||||
.claude-plugin/
|
|
||||||
|
|
||||||
# Non-template plugin configs (keep only .tpl files)
|
|
||||||
plugin/.claude-plugin/plugin.json
|
plugin/.claude-plugin/plugin.json
|
||||||
plugin/.claude-plugin/marketplace.json
|
plugin/.claude-plugin/marketplace.json
|
||||||
|
user-prompt
|
||||||
|
|
||||||
|
# Compressed ONNX runtime libs are produced by scripts/download-onnx-libs.sh
|
||||||
|
internal/embedding/assets/lib/**/*.gz
|
||||||
|
|||||||
+23
-21
@@ -1,34 +1,36 @@
|
|||||||
linters-settings:
|
version: "2"
|
||||||
govet:
|
|
||||||
enable:
|
|
||||||
- fieldalignment
|
|
||||||
errcheck:
|
|
||||||
# Ignore error checks in test files for common test helpers
|
|
||||||
exclude-functions:
|
|
||||||
- (io.Closer).Close
|
|
||||||
- (*encoding/json.Encoder).Encode
|
|
||||||
- (io.Writer).Write
|
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
- errcheck
|
- errcheck
|
||||||
- gosec
|
- gosec
|
||||||
- govet
|
- govet
|
||||||
- gofmt
|
|
||||||
- staticcheck
|
- staticcheck
|
||||||
- unused
|
- unused
|
||||||
- ineffassign
|
- ineffassign
|
||||||
- typecheck
|
settings:
|
||||||
|
govet:
|
||||||
|
enable:
|
||||||
|
- fieldalignment
|
||||||
|
errcheck:
|
||||||
|
# Ignore error checks in test files for common test helpers
|
||||||
|
exclude-functions:
|
||||||
|
- (io.Closer).Close
|
||||||
|
- (*encoding/json.Encoder).Encode
|
||||||
|
- (io.Writer).Write
|
||||||
|
exclusions:
|
||||||
|
paths:
|
||||||
|
- vendor
|
||||||
|
# Exclude some linters from running on test files
|
||||||
|
rules:
|
||||||
|
- path: _test\.go
|
||||||
|
linters:
|
||||||
|
- errcheck
|
||||||
|
- gosec
|
||||||
|
|
||||||
issues:
|
formatters:
|
||||||
exclude-dirs:
|
enable:
|
||||||
- vendor
|
- gofmt
|
||||||
# Exclude some linters from running on test files
|
|
||||||
exclude-rules:
|
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- errcheck
|
|
||||||
- gosec
|
|
||||||
|
|
||||||
run:
|
run:
|
||||||
timeout: 5m
|
timeout: 5m
|
||||||
|
|||||||
@@ -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
|
LDFLAGS := -ldflags "-X main.Version=$(VERSION) -X github.com/lukaszraczylo/claude-mnemonic/pkg/hooks.Version=$(VERSION) -s -w" -buildvcs=false
|
||||||
BUILD_DIR := bin
|
BUILD_DIR := bin
|
||||||
PLUGIN_DIR := plugin
|
PLUGIN_DIR := plugin
|
||||||
|
STABLE_BIN := $(HOME)/.claude-mnemonic/bin
|
||||||
|
|
||||||
# Go settings
|
# Go settings
|
||||||
GOOS ?= $(shell go env GOOS)
|
GOOS ?= $(shell go env GOOS)
|
||||||
@@ -14,7 +15,7 @@ GOARCH ?= $(shell go env GOARCH)
|
|||||||
export CGO_ENABLED=1
|
export CGO_ENABLED=1
|
||||||
BUILD_TAGS := -tags "fts5"
|
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
|
all: build
|
||||||
|
|
||||||
@@ -22,8 +23,23 @@ all: build
|
|||||||
setup-libs:
|
setup-libs:
|
||||||
@./scripts/download-onnx-libs.sh all
|
@./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 all binaries
|
||||||
build: setup-libs dashboard worker hooks mcp
|
build: setup-libs update-version dashboard worker hooks mcp
|
||||||
|
|
||||||
# Build Vue dashboard
|
# Build Vue dashboard
|
||||||
dashboard:
|
dashboard:
|
||||||
@@ -108,7 +124,12 @@ build-windows:
|
|||||||
# Stop any running worker
|
# Stop any running worker
|
||||||
stop-worker:
|
stop-worker:
|
||||||
@echo "Stopping 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.*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
|
@-pkill -9 -f '\.claude/plugins/.*/worker' 2>/dev/null || true
|
||||||
@-lsof -ti :37777 | xargs kill -9 2>/dev/null || true
|
@-lsof -ti :37777 | xargs kill -9 2>/dev/null || true
|
||||||
@sleep 1
|
@sleep 1
|
||||||
@@ -116,12 +137,7 @@ stop-worker:
|
|||||||
# Start worker in background
|
# Start worker in background
|
||||||
start-worker:
|
start-worker:
|
||||||
@echo "Starting worker..."
|
@echo "Starting worker..."
|
||||||
@# Prefer cache directory (where Claude Code looks), fall back to marketplaces
|
@nohup $(STABLE_BIN)/worker > /tmp/claude-mnemonic-worker.log 2>&1 &
|
||||||
@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
|
|
||||||
@sleep 1
|
@sleep 1
|
||||||
@if curl -s http://localhost:37777/health > /dev/null 2>&1; then \
|
@if curl -s http://localhost:37777/health > /dev/null 2>&1; then \
|
||||||
echo "Worker started successfully (http://localhost:37777)"; \
|
echo "Worker started successfully (http://localhost:37777)"; \
|
||||||
@@ -132,22 +148,31 @@ start-worker:
|
|||||||
# Restart worker
|
# Restart worker
|
||||||
restart-worker: stop-worker start-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
|
install: build stop-worker
|
||||||
@echo "Installing to Claude plugins directory..."
|
@echo "Installing claude-mnemonic..."
|
||||||
@# Install to marketplaces directory (for direct installs)
|
@# Verify build output binaries exist
|
||||||
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks
|
@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/.claude-plugin
|
||||||
|
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks
|
||||||
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands
|
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands
|
||||||
cp $(BUILD_DIR)/worker $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
|
@cp .claude-plugin/plugin.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/
|
||||||
cp $(BUILD_DIR)/mcp-server $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
|
@cp marketplace.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/marketplace.json 2>/dev/null || true
|
||||||
cp $(BUILD_DIR)/hooks/* $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
|
@cp hooks/hooks.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
|
||||||
cp $(PLUGIN_DIR)/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/
|
||||||
@# Copy slash commands if they exist
|
@chmod +x $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/*
|
||||||
@if [ -d "$(PLUGIN_DIR)/commands" ]; then cp -r $(PLUGIN_DIR)/commands/* $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands/ 2>/dev/null || true; fi
|
@cp mcp-server $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
|
||||||
@# Update plugin.json and marketplace.json with current version to prevent stale version directories
|
@chmod +x $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/mcp-server
|
||||||
@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
|
@cp commands/restart.md $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands/
|
||||||
@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
|
|
||||||
@echo "Registering plugin with Claude Code..."
|
@echo "Registering plugin with Claude Code..."
|
||||||
@./scripts/register-plugin.sh "$(VERSION)"
|
@./scripts/register-plugin.sh "$(VERSION)"
|
||||||
@$(MAKE) start-worker
|
@$(MAKE) start-worker
|
||||||
@@ -161,12 +186,14 @@ uninstall: stop-worker
|
|||||||
@echo "Uninstallation complete!"
|
@echo "Uninstallation complete!"
|
||||||
|
|
||||||
# Run tests (with FTS5 support)
|
# Run tests (with FTS5 support)
|
||||||
test: setup-libs
|
# CLAUDE_MNEMONIC_MODEL_DIR points to a common model directory for tests.
|
||||||
go test $(BUILD_TAGS) -v -race ./...
|
# 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)
|
# Run tests with coverage (with FTS5 support)
|
||||||
test-coverage: setup-libs
|
test-coverage: setup-libs setup-models
|
||||||
go test $(BUILD_TAGS) -v -race -coverprofile=coverage.out ./...
|
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 -html=coverage.out -o coverage.html
|
||||||
@go tool cover -func=coverage.out | tail -1
|
@go tool cover -func=coverage.out | tail -1
|
||||||
|
|
||||||
@@ -228,6 +255,7 @@ help:
|
|||||||
@echo " make fmt - Format code"
|
@echo " make fmt - Format code"
|
||||||
@echo " make clean - Clean build artifacts"
|
@echo " make clean - Clean build artifacts"
|
||||||
@echo " make dev - Run worker in development mode"
|
@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 deps - Download dependencies"
|
||||||
@echo " make website - Build website for production"
|
@echo " make website - Build website for production"
|
||||||
@echo " make dev-website - Run website dev server"
|
@echo " make dev-website - Run website dev server"
|
||||||
|
|||||||
@@ -172,13 +172,19 @@ Example: A bug fix in your auth module stays local. "Always validate JWT server-
|
|||||||
|
|
||||||
## MCP Tools
|
## MCP Tools
|
||||||
|
|
||||||
These search tools are available via MCP:
|
Four tools are exposed via MCP:
|
||||||
|
|
||||||
- `search` - semantic search across all memories
|
- `search` - semantic search across all memories. Accepts `obs_type` (e.g.
|
||||||
- `timeline` - browse by time
|
`decision`, `code_change`, `architecture`) plus filter params (`concepts`,
|
||||||
- `decisions` - find architecture decisions
|
`files`, `type`) - these replace the old per-type shortcut tools.
|
||||||
- `changes` - find code modifications
|
- `timeline` - browse observations around a point in time.
|
||||||
- `how_it_works` - system understanding queries
|
- `observation` - manage individual observations. Set `action` to one of:
|
||||||
|
`get`, `edit`, `delete`, `supersede`, `boost`, `merge`, `related`, `similar`,
|
||||||
|
`quality`, `relationships`, `scoring`, `tag`, `by_tag`, `batch_tag`.
|
||||||
|
- `memory_admin` - administration and analytics. Set `action` to one of:
|
||||||
|
`stats`, `health`, `maintenance_stats`, `run_maintenance`, `importance`,
|
||||||
|
`search_patterns`, `explain_ranking`, `temporal_trends`, `data_quality`,
|
||||||
|
`export`, `suggest_consolidations`, `patterns`.
|
||||||
|
|
||||||
## Slash Commands
|
## Slash Commands
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
|
||||||
)
|
)
|
||||||
@@ -51,6 +53,10 @@ var skipTools = map[string]bool{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if !hooks.IsWorkerAvailable() {
|
||||||
|
hooks.WriteResponse("PostToolUse", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
hooks.RunHook("PostToolUse", handlePostToolUse)
|
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)
|
fmt.Fprintf(os.Stderr, "[post-tool-use] %s\n", input.ToolName)
|
||||||
|
|
||||||
// Send observation to worker
|
// Fire-and-forget: send the observation without waiting for the response.
|
||||||
_, err := hooks.POST(ctx.Port, "/api/sessions/observations", map[string]interface{}{
|
// The worker just queues it -- we don't need the response data.
|
||||||
"claudeSessionId": ctx.SessionID,
|
// Use a short-lived context to ensure the request body is at least sent
|
||||||
"project": ctx.Project,
|
// before this process exits.
|
||||||
"tool_name": input.ToolName,
|
done := make(chan struct{})
|
||||||
"tool_input": input.ToolInput,
|
go func() {
|
||||||
"tool_response": input.ToolResponse,
|
defer close(done)
|
||||||
"cwd": ctx.CWD,
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
|
||||||
)
|
)
|
||||||
@@ -27,10 +28,17 @@ type Observation struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if !hooks.IsWorkerAvailable() {
|
||||||
|
hooks.WriteResponse("SessionStart", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
hooks.RunHook("SessionStart", handleSessionStart)
|
hooks.RunHook("SessionStart", handleSessionStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSessionStart(ctx *hooks.HookContext, input *Input) (string, error) {
|
func handleSessionStart(ctx *hooks.HookContext, input *Input) (string, error) {
|
||||||
|
deadline, cancel := hooks.HookDeadline(30 * time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
// Fetch observations for context injection
|
// Fetch observations for context injection
|
||||||
endpoint := fmt.Sprintf("/api/context/inject?project=%s&cwd=%s",
|
endpoint := fmt.Sprintf("/api/context/inject?project=%s&cwd=%s",
|
||||||
url.QueryEscape(ctx.Project),
|
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",
|
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))
|
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
|
// Build context string
|
||||||
contextBuilder := "<claude-mnemonic-context>\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))
|
||||||
contextBuilder += fmt.Sprintf("# Project Memory (%d observations)\n", len(obsData))
|
currentTokens += estimateTokens(header)
|
||||||
contextBuilder += "Use this knowledge to answer questions without re-exploring the codebase.\n\n"
|
contextBuilder := header
|
||||||
|
|
||||||
for i, o := range obsData {
|
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{})
|
obs, ok := o.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
@@ -73,40 +90,94 @@ func handleSessionStart(ctx *hooks.HookContext, input *Input) (string, error) {
|
|||||||
title := getString(obs, "title")
|
title := getString(obs, "title")
|
||||||
obsType := getString(obs, "type")
|
obsType := getString(obs, "type")
|
||||||
|
|
||||||
|
var obsText string
|
||||||
|
|
||||||
// First `fullCount` observations get full detail, rest are condensed
|
// First `fullCount` observations get full detail, rest are condensed
|
||||||
if i < fullCount {
|
if i < fullCount {
|
||||||
// Full detail: include narrative and facts
|
// Full detail: include narrative and facts
|
||||||
narrative := getString(obs, "narrative")
|
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 != "" {
|
if narrative != "" {
|
||||||
contextBuilder += narrative + "\n"
|
obsText += narrative + "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
if facts, ok := obs["facts"].([]interface{}); ok && len(facts) > 0 {
|
if facts, ok := obs["facts"].([]interface{}); ok && len(facts) > 0 {
|
||||||
contextBuilder += "Key facts:\n"
|
obsText += "Key facts:\n"
|
||||||
for _, f := range facts {
|
for _, f := range facts {
|
||||||
if fact, ok := f.(string); ok && fact != "" {
|
if fact, ok := f.(string); ok && fact != "" {
|
||||||
contextBuilder += fmt.Sprintf("- %s\n", fact)
|
obsText += fmt.Sprintf("- %s\n", fact)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
contextBuilder += "\n"
|
obsText += "\n"
|
||||||
} else {
|
} else {
|
||||||
// Condensed: just title and subtitle (one line)
|
// Condensed: just title and subtitle (one line)
|
||||||
subtitle := getString(obs, "subtitle")
|
subtitle := getString(obs, "subtitle")
|
||||||
if 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 {
|
} 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"
|
contextBuilder += "</claude-mnemonic-context>\n"
|
||||||
return contextBuilder, nil
|
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 {
|
func getString(m map[string]interface{}, key string) string {
|
||||||
if v, ok := m[key].(string); ok {
|
if v, ok := m[key].(string); ok {
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,6 +13,9 @@ import (
|
|||||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// statsTimeout bounds the worker stats request.
|
||||||
|
const statsTimeout = 100 * time.Millisecond
|
||||||
|
|
||||||
// StatusInput is the JSON input from Claude Code's statusline feature.
|
// StatusInput is the JSON input from Claude Code's statusline feature.
|
||||||
type StatusInput struct {
|
type StatusInput struct {
|
||||||
HookEventName string `json:"hook_event_name"`
|
HookEventName string `json:"hook_event_name"`
|
||||||
@@ -105,26 +108,19 @@ func handleStatusline(input *StatusInput, port int) string {
|
|||||||
// getWorkerStats fetches stats from the worker service.
|
// getWorkerStats fetches stats from the worker service.
|
||||||
func getWorkerStats(port int, project string) *WorkerStats {
|
func getWorkerStats(port int, project string) *WorkerStats {
|
||||||
// Build URL with optional project parameter
|
// Build URL with optional project parameter
|
||||||
endpoint := fmt.Sprintf("http://127.0.0.1:%d/api/stats", port)
|
endpoint := "/api/stats"
|
||||||
if project != "" {
|
if project != "" {
|
||||||
endpoint += "?project=" + url.QueryEscape(project)
|
endpoint += "?project=" + url.QueryEscape(project)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create HTTP client with short timeout (statusline must be fast)
|
// Short timeout: statusline must be fast
|
||||||
client := &http.Client{Timeout: 100 * time.Millisecond}
|
body, err := hooks.GETBody(context.Background(), statsTimeout, port, endpoint)
|
||||||
|
|
||||||
resp, err := client.Get(endpoint)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats WorkerStats
|
var stats WorkerStats
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
|
if err := json.Unmarshal(body, &stats); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+52
-13
@@ -5,12 +5,16 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var debug = os.Getenv("CLAUDE_MNEMONIC_DEBUG") != ""
|
||||||
|
|
||||||
// Input is the hook input from Claude Code.
|
// Input is the hook input from Claude Code.
|
||||||
type Input struct {
|
type Input struct {
|
||||||
hooks.BaseInput
|
hooks.BaseInput
|
||||||
@@ -62,7 +66,19 @@ func parseTranscript(path string) (lastUser, lastAssistant string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", ""
|
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)
|
scanner := bufio.NewScanner(file)
|
||||||
// Increase buffer size for large messages
|
// Increase buffer size for large messages
|
||||||
@@ -97,12 +113,20 @@ func parseTranscript(path string) (lastUser, lastAssistant string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if !hooks.IsWorkerAvailable() {
|
||||||
|
hooks.WriteResponse("Stop", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
hooks.RunHook("Stop", handleStop)
|
hooks.RunHook("Stop", handleStop)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleStop(ctx *hooks.HookContext, input *Input) (string, error) {
|
func handleStop(ctx *hooks.HookContext, input *Input) (string, error) {
|
||||||
// Debug: dump raw input
|
deadline, cancel := hooks.HookDeadline(30 * time.Second)
|
||||||
fmt.Fprintf(os.Stderr, "[stop] Raw input: %s\n", string(ctx.RawInput))
|
defer cancel()
|
||||||
|
|
||||||
|
if debug {
|
||||||
|
fmt.Fprintf(os.Stderr, "[stop] Raw input: %s\n", string(ctx.RawInput))
|
||||||
|
}
|
||||||
|
|
||||||
// Find session
|
// Find session
|
||||||
result, err := hooks.GET(ctx.Port, fmt.Sprintf("/api/sessions?claudeSessionId=%s", ctx.SessionID))
|
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)
|
lastUser, lastAssistant = parseTranscript(input.TranscriptPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug: log what we extracted
|
// Truncate messages to avoid sending excessive data to the worker
|
||||||
fmt.Fprintf(os.Stderr, "[stop] Transcript path: %s\n", input.TranscriptPath)
|
if len(lastAssistant) > 10000 {
|
||||||
fmt.Fprintf(os.Stderr, "[stop] Last user message length: %d\n", len(lastUser))
|
lastAssistant = lastAssistant[:10000]
|
||||||
fmt.Fprintf(os.Stderr, "[stop] Last assistant message length: %d\n", len(lastAssistant))
|
}
|
||||||
if len(lastAssistant) > 0 {
|
if len(lastUser) > 5000 {
|
||||||
preview := lastAssistant
|
lastUser = lastUser[:5000]
|
||||||
if len(preview) > 300 {
|
}
|
||||||
preview = preview[:300] + "..."
|
|
||||||
|
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
|
// Request summary with message context from transcript
|
||||||
_, err = hooks.POST(ctx.Port, fmt.Sprintf("/sessions/%d/summarize", int64(sessionID)), map[string]interface{}{
|
_, err = hooks.POST(ctx.Port, fmt.Sprintf("/sessions/%d/summarize", int64(sessionID)), map[string]interface{}{
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ type Input struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if !hooks.IsWorkerAvailable() {
|
||||||
|
hooks.WriteResponse("SubagentStop", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
hooks.RunHook("SubagentStop", handleSubagentStop)
|
hooks.RunHook("SubagentStop", handleSubagentStop)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+141
-42
@@ -5,8 +5,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/sanitize"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Input is the hook input from Claude Code.
|
// Input is the hook input from Claude Code.
|
||||||
@@ -15,31 +19,121 @@ type Input struct {
|
|||||||
Prompt string `json:"prompt"`
|
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() {
|
func main() {
|
||||||
|
if !hooks.IsWorkerAvailable() {
|
||||||
|
hooks.WriteResponse("UserPromptSubmit", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
hooks.RunHook("UserPromptSubmit", handleUserPrompt)
|
hooks.RunHook("UserPromptSubmit", handleUserPrompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
|
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",
|
searchURL := fmt.Sprintf("/api/context/search?project=%s&query=%s&cwd=%s",
|
||||||
url.QueryEscape(ctx.Project),
|
url.QueryEscape(ctx.Project),
|
||||||
url.QueryEscape(input.Prompt),
|
url.QueryEscape(input.Prompt),
|
||||||
url.QueryEscape(ctx.CWD))
|
url.QueryEscape(ctx.CWD))
|
||||||
|
|
||||||
var contextToInject string
|
// Run search and session init concurrently.
|
||||||
var observationCount int
|
// 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 {
|
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)
|
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
|
var contextBuilder string
|
||||||
contextBuilder = "<relevant-memory>\n"
|
contextBuilder = header
|
||||||
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"
|
|
||||||
|
|
||||||
for i, obs := range observations {
|
for i, obs := range observations {
|
||||||
if obsMap, ok := obs.(map[string]interface{}); ok {
|
if obsMap, ok := obs.(map[string]interface{}); ok {
|
||||||
@@ -52,24 +146,30 @@ func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
|
|||||||
obsType = t
|
obsType = t
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start observation block
|
var obsText string
|
||||||
contextBuilder += fmt.Sprintf("## %d. [%s] %s\n", i+1, obsType, title)
|
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 {
|
if facts, ok := obsMap["facts"].([]interface{}); ok && len(facts) > 0 {
|
||||||
contextBuilder += "Key facts:\n"
|
obsText += "Key facts:\n"
|
||||||
for _, fact := range facts {
|
for _, fact := range facts {
|
||||||
if factStr, ok := fact.(string); ok {
|
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 != "" {
|
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
|
contextToInject = contextBuilder
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize session with matched observations count
|
// Check session init result. A session-init failure must never block the
|
||||||
result, err := hooks.POST(ctx.Port, "/api/sessions/init", map[string]interface{}{
|
// prompt: degrade gracefully and still inject any memory we found.
|
||||||
"claudeSessionId": ctx.SessionID,
|
if initErr != nil {
|
||||||
"project": ctx.Project,
|
fmt.Fprintf(os.Stderr, "[user-prompt] Session init failed: %v\n", initErr)
|
||||||
"prompt": input.Prompt,
|
return contextToInject, nil
|
||||||
"matchedObservations": observationCount,
|
}
|
||||||
})
|
if initResult == nil {
|
||||||
if err != nil {
|
return contextToInject, nil // Non-JSON response from worker, skip session init
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if skipped due to privacy
|
// 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")
|
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
|
sessionDBIDVal, ok := initResult["sessionDbId"].(float64)
|
||||||
sessionDbIdRaw, ok := result["sessionDbId"].(float64)
|
|
||||||
if !ok {
|
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 {
|
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)
|
fmt.Fprintf(os.Stderr, "[user-prompt] Session %d, prompt #%d\n", sessionID, promptNumber)
|
||||||
|
|
||||||
// Start SDK agent
|
// Start SDK agent (depends on session init result, so kept sequential).
|
||||||
_, err = hooks.POST(ctx.Port, fmt.Sprintf("/sessions/%d/init", sessionID), map[string]interface{}{
|
// 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,
|
"userPrompt": input.Prompt,
|
||||||
"promptNumber": promptNumber,
|
"promptNumber": promptNumber,
|
||||||
})
|
}); err != nil {
|
||||||
if err != nil {
|
fmt.Fprintf(os.Stderr, "[user-prompt] SDK agent init failed: %v\n", err)
|
||||||
return "", err
|
return contextToInject, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return context if we found relevant observations
|
// Return context if we found relevant observations
|
||||||
if observationCount > 0 {
|
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)
|
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Found %d relevant memories for this prompt\n", observationCount)
|
||||||
return contextToInject, nil
|
return contextToInject, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+36
-92
@@ -4,20 +4,19 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
|
"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/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/internal/watcher"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
|
||||||
|
"github.com/lukaszraczylo/oss-telemetry"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
@@ -25,10 +24,18 @@ import (
|
|||||||
// Version is set at build time via ldflags.
|
// Version is set at build time via ldflags.
|
||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
|
|
||||||
|
// defaultProject derives the project ID the same way the hooks do.
|
||||||
|
func defaultProject() string {
|
||||||
|
dir := os.Getenv("CLAUDE_PROJECT_DIR")
|
||||||
|
if dir == "" {
|
||||||
|
dir, _ = os.Getwd()
|
||||||
|
}
|
||||||
|
return hooks.ProjectIDWithName(dir)
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Parse flags
|
// Parse flags
|
||||||
project := flag.String("project", "", "Project name (required)")
|
project := flag.String("project", "", "Project ID (default: derived from CLAUDE_PROJECT_DIR or cwd)")
|
||||||
dataDir := flag.String("data-dir", "", "Data directory (default: ~/.claude-mnemonic)")
|
|
||||||
debug := flag.Bool("debug", false, "Enable debug logging")
|
debug := flag.Bool("debug", false, "Enable debug logging")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -39,27 +46,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true})
|
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true})
|
||||||
|
|
||||||
if *project == "" {
|
if *project == "" || strings.Contains(*project, "${") {
|
||||||
log.Fatal().Msg("--project is required")
|
*project = defaultProject()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure data directory and settings exist
|
// Get worker port from config
|
||||||
if err := config.EnsureAll(); err != nil {
|
port := config.GetWorkerPort()
|
||||||
log.Fatal().Err(err).Msg("Failed to ensure data directories")
|
workerURL := fmt.Sprintf("http://localhost:%d", port)
|
||||||
}
|
|
||||||
|
|
||||||
// Load config
|
// Create HTTP client for worker
|
||||||
cfg, err := config.Load()
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -73,83 +69,31 @@ func main() {
|
|||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Initialize database store (migrations run automatically)
|
// Start file watchers for config changes
|
||||||
storeCfg := gorm.Config{
|
startWatchers(cancel)
|
||||||
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()
|
|
||||||
|
|
||||||
// Initialize stores
|
telemetry.Send("claude-mnemonic", Version)
|
||||||
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)
|
|
||||||
|
|
||||||
// Initialize embedding service and vector client
|
// Create and run MCP server
|
||||||
var vectorClient *sqlitevec.Client
|
server := mcp.NewServer(client, workerURL, *project, Version)
|
||||||
embedSvc, err := embedding.NewService()
|
log.Info().Str("project", *project).Str("version", Version).Str("worker", workerURL).Msg("Starting MCP server")
|
||||||
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")
|
|
||||||
|
|
||||||
if err := server.Run(ctx); err != nil {
|
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")
|
log.Fatal().Err(err).Msg("MCP server error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startWatchers initializes file watchers for config.
|
// startWatchers initializes file watchers for config.
|
||||||
func startWatchers(ctx context.Context, dbPath string) {
|
func startWatchers(cancel context.CancelFunc) {
|
||||||
// Watch config file for changes (triggers process exit for restart)
|
// Watch config file for changes (triggers graceful shutdown via context cancellation)
|
||||||
configPath := config.SettingsPath()
|
configPath := config.SettingsPath()
|
||||||
configWatcher, err := watcher.New(configPath, func() {
|
configWatcher, err := watcher.New(configPath, func() {
|
||||||
log.Warn().Str("path", configPath).Msg("Config file changed, exiting for restart...")
|
log.Warn().Str("path", configPath).Msg("Config file changed, shutting down gracefully...")
|
||||||
time.Sleep(100 * time.Millisecond) // Give logs time to flush
|
cancel() // Triggers ctx.Done() in server.Run(), which drains in-flight requests
|
||||||
os.Exit(0)
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().Err(err).Msg("Failed to create config watcher")
|
log.Warn().Err(err).Msg("Failed to create config watcher")
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
description: Restart the Claude Mnemonic worker process
|
||||||
|
allowed-tools: Bash(curl:*)
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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.
|
||||||
Generated
+732
-1397
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -8,13 +8,13 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.25"
|
"vue": "^3.5.34"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"autoprefixer": "^10.4.23",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.15",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^4.3.0",
|
||||||
"vite": "^7.3.0"
|
"vite": "^8.0.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'@tailwindcss/postcss': {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+165
-405
@@ -1,398 +1,153 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-slate-950 text-white min-h-screen relative overflow-hidden">
|
<div class="min-h-screen flex flex-col">
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<NavBar :mobile-menu-open="mobileMenuOpen" @toggle-menu="mobileMenuOpen = !mobileMenuOpen" />
|
<NavBar :mobile-menu-open="mobileMenuOpen" @toggle-menu="mobileMenuOpen = !mobileMenuOpen" />
|
||||||
|
|
||||||
<HeroSection
|
<main class="flex-1">
|
||||||
badge="The missing piece for Claude Code"
|
<HeroSection />
|
||||||
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."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Dashboard Preview -->
|
<section class="py-10 px-4 sm:px-6">
|
||||||
<section class="py-12 lg:py-16 px-4 sm:px-6 relative">
|
<div class="max-w-5xl mx-auto">
|
||||||
<div class="max-w-6xl mx-auto">
|
<div class="rounded-xl overflow-hidden border border-neutral-800">
|
||||||
<div class="relative rounded-xl overflow-hidden border border-slate-700/50 shadow-2xl shadow-amber-500/5">
|
<img src="/claude-mnemonic.jpg" alt="Claude Mnemonic Dashboard" class="w-full h-auto block" loading="lazy" />
|
||||||
<div class="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent pointer-events-none z-10"></div>
|
</div>
|
||||||
<img
|
<p class="text-center text-neutral-600 text-xs mt-3 tracking-wide uppercase">Dashboard — localhost:37777</p>
|
||||||
src="/claude-mnemonic.jpg"
|
|
||||||
alt="Claude Mnemonic Dashboard"
|
|
||||||
class="w-full h-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Problem Section -->
|
<section id="features" class="py-20 px-4 sm:px-6">
|
||||||
<section class="py-20 lg:py-28 px-4 sm:px-6 relative">
|
<div class="max-w-5xl mx-auto">
|
||||||
<div class="max-w-6xl mx-auto grid lg:grid-cols-2 gap-8 lg:gap-16 items-center">
|
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">What it does</h2>
|
||||||
<div>
|
<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>
|
||||||
<h2 class="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-6">Sound familiar?</h2>
|
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<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>
|
<FeatureItem
|
||||||
<ul class="space-y-4 sm:space-y-5">
|
v-for="feature in features"
|
||||||
<li class="flex items-start gap-3 sm:gap-4 text-slate-400">
|
:key="feature.title"
|
||||||
<i class="fas fa-times-circle text-red-400 mt-1 flex-shrink-0"></i>
|
:icon="feature.icon"
|
||||||
<span class="text-sm sm:text-base">"We fixed this exact race condition last week..."</span>
|
:title="feature.title"
|
||||||
</li>
|
:description="feature.description"
|
||||||
<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"' />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Features Section -->
|
<section class="py-10 px-4 sm:px-6">
|
||||||
<section id="features" class="py-20 lg:py-28 bg-slate-900/30 relative">
|
<div class="max-w-5xl mx-auto">
|
||||||
<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="rounded-xl overflow-hidden border border-neutral-800">
|
||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
<img src="/observation-relation-graph.jpg" alt="Knowledge Graph" class="w-full h-auto block" loading="lazy" />
|
||||||
<SectionHeader title="Built for real workflows" subtitle="Not just storage - intelligent context that makes Claude more useful over time" />
|
</div>
|
||||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
<p class="text-center text-neutral-600 text-xs mt-3 tracking-wide uppercase">Observation relationship graph</p>
|
||||||
<FeatureCard
|
|
||||||
v-for="feature in features"
|
|
||||||
:key="feature.title"
|
|
||||||
:icon="feature.icon"
|
|
||||||
:title="feature.title"
|
|
||||||
:description="feature.description"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Knowledge Graph Preview -->
|
<section id="install" class="py-20 px-4 sm:px-6">
|
||||||
<section class="py-16 lg:py-20 px-4 sm:px-6 relative">
|
<div class="max-w-5xl mx-auto">
|
||||||
<div class="max-w-5xl mx-auto">
|
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Install</h2>
|
||||||
<SectionHeader title="See how knowledge connects" subtitle="The knowledge graph reveals relationships between your memories automatically" />
|
<p class="text-neutral-500 mb-8">One command. No configuration. No account.</p>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Before/After Section -->
|
<div class="flex gap-1 mb-6">
|
||||||
<section class="py-20 lg:py-28 px-4 sm:px-6">
|
<button
|
||||||
<div class="max-w-6xl mx-auto">
|
v-for="tab in installTabs"
|
||||||
<SectionHeader title="The difference" subtitle="Same question. Different experience." />
|
:key="tab.id"
|
||||||
<div class="grid md:grid-cols-2 gap-6 sm:gap-8">
|
@click="activeTab = tab.id"
|
||||||
<!-- Before -->
|
:class="[
|
||||||
<div class="glass rounded-2xl p-5 sm:p-6 relative border-red-500/20">
|
'px-4 py-2 text-sm rounded-lg transition-colors',
|
||||||
<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>
|
activeTab === tab.id
|
||||||
<div class="space-y-3 sm:space-y-4 mt-8">
|
? 'bg-neutral-800 text-white'
|
||||||
<div class="bg-slate-800/50 rounded-lg p-3 sm:p-4">
|
: 'text-neutral-500 hover:text-neutral-300'
|
||||||
<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">
|
{{ tab.label }}
|
||||||
<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>
|
</button>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- After -->
|
<div v-show="activeTab === 'macos'">
|
||||||
<div class="glass rounded-2xl p-5 sm:p-6 relative border-green-500/20">
|
<CodeBlock code="curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash" />
|
||||||
<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>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- How It Works Section -->
|
<div v-show="activeTab === 'windows'">
|
||||||
<section id="how-it-works" class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
|
<CodeBlock code="irm https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.ps1 | iex" />
|
||||||
<div class="max-w-6xl mx-auto">
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Installation Section -->
|
<div v-show="activeTab === 'source'">
|
||||||
<section id="installation" class="py-20 lg:py-28 px-4 sm:px-6">
|
<CodeBlock code="git clone https://github.com/lukaszraczylo/claude-mnemonic.git cd claude-mnemonic make build && make install" />
|
||||||
<div class="max-w-6xl mx-auto">
|
<p class="text-neutral-600 text-xs mt-2">Requires: Go 1.24+, Node.js 18+, CGO compiler</p>
|
||||||
<SectionHeader title="Quick install" subtitle="One command. No configuration. No account required." />
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap justify-center gap-2 mb-6 sm:mb-8">
|
<div class="mt-8 p-4 rounded-lg border border-neutral-800 bg-neutral-900/50 max-w-2xl">
|
||||||
<button
|
<p class="text-neutral-500 text-sm">
|
||||||
v-for="tab in installTabs"
|
<i class="fas fa-info-circle text-neutral-600 mr-1"></i>
|
||||||
:key="tab.id"
|
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.
|
||||||
@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.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Configuration Section -->
|
<section id="requirements" class="py-20 px-4 sm:px-6">
|
||||||
<section id="configuration" class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
|
<div class="max-w-5xl mx-auto">
|
||||||
<div class="max-w-6xl mx-auto">
|
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Requirements</h2>
|
||||||
<SectionHeader title="Fully configurable" subtitle="Works out of the box, but adapts to your preferences" />
|
<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">
|
<div class="grid sm:grid-cols-2 gap-4 max-w-xl">
|
||||||
<!-- Config file example -->
|
<div v-for="req in requiredDeps" :key="req.name" class="p-4 rounded-lg border border-neutral-800 bg-neutral-900/50">
|
||||||
<div class="glass rounded-2xl p-5 sm:p-6">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<i :class="[req.icon, 'text-amber-500 text-sm']"></i>
|
||||||
<i class="fas fa-cog text-amber-500"></i>
|
<code class="text-white text-sm font-semibold">{{ req.name }}</code>
|
||||||
<span class="text-white font-semibold text-sm sm:text-base">~/.claude-mnemonic/settings.json</span>
|
</div>
|
||||||
|
<p class="text-neutral-500 text-xs pl-6">{{ req.description }}</p>
|
||||||
</div>
|
</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>
|
</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 -->
|
<section id="config" class="py-20 px-4 sm:px-6">
|
||||||
<div class="space-y-3 sm:space-y-4">
|
<div class="max-w-5xl mx-auto">
|
||||||
<div v-for="config in configOptions" :key="config.name" class="glass rounded-xl p-4 hover:border-amber-500/20 transition-colors">
|
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Configuration</h2>
|
||||||
<div class="flex items-start gap-3">
|
<p class="text-neutral-500 mb-8">Works out of the box. Adjust if you want to.</p>
|
||||||
<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>
|
<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>
|
||||||
<div class="min-w-0">
|
<div>
|
||||||
<code class="text-amber-400 text-xs sm:text-sm">{{ config.name }}</code>
|
<code class="text-amber-500 text-xs">{{ config.name }}</code>
|
||||||
<p class="text-slate-400 text-xs sm:text-sm mt-1">{{ config.description }}</p>
|
<p class="text-neutral-500 text-xs mt-0.5">{{ config.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p class="text-center text-slate-500 mt-6 sm:mt-8 text-xs sm:text-sm">
|
<section id="architecture" class="py-20 px-4 sm:px-6">
|
||||||
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.
|
<div class="max-w-5xl mx-auto">
|
||||||
</p>
|
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Under the hood</h2>
|
||||||
</div>
|
<p class="text-neutral-500 mb-10">No magic. Here's what's running locally on your machine.</p>
|
||||||
</section>
|
<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" />
|
||||||
<!-- 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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Trust Section -->
|
<section id="faq" class="py-20 px-4 sm:px-6">
|
||||||
<section class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
|
<div class="max-w-3xl mx-auto">
|
||||||
<div class="max-w-4xl mx-auto">
|
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-10">FAQ</h2>
|
||||||
<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">
|
|
||||||
<FaqItem
|
<FaqItem
|
||||||
v-for="faq in faqs"
|
v-for="faq in faqs"
|
||||||
:key="faq.question"
|
:key="faq.question"
|
||||||
@@ -400,8 +155,21 @@
|
|||||||
:answer="faq.answer"
|
:answer="faq.answer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 />
|
<FooterSection />
|
||||||
</div>
|
</div>
|
||||||
@@ -411,10 +179,8 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import NavBar from './components/NavBar.vue'
|
import NavBar from './components/NavBar.vue'
|
||||||
import HeroSection from './components/HeroSection.vue'
|
import HeroSection from './components/HeroSection.vue'
|
||||||
import SectionHeader from './components/SectionHeader.vue'
|
import FeatureItem from './components/FeatureItem.vue'
|
||||||
import FeatureCard from './components/FeatureCard.vue'
|
import TechCard from './components/TechCard.vue'
|
||||||
import StepCard from './components/StepCard.vue'
|
|
||||||
import FlowItem from './components/FlowItem.vue'
|
|
||||||
import CodeBlock from './components/CodeBlock.vue'
|
import CodeBlock from './components/CodeBlock.vue'
|
||||||
import FaqItem from './components/FaqItem.vue'
|
import FaqItem from './components/FaqItem.vue'
|
||||||
import FooterSection from './components/FooterSection.vue'
|
import FooterSection from './components/FooterSection.vue'
|
||||||
@@ -423,22 +189,15 @@ const mobileMenuOpen = ref(false)
|
|||||||
const activeTab = ref('macos')
|
const activeTab = ref('macos')
|
||||||
|
|
||||||
const features = [
|
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-brain', title: 'Session memory', description: 'Captures bug fixes, decisions, and patterns automatically from your Claude Code sessions.' },
|
||||||
{ 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-magnifying-glass-chart', title: 'Two-stage retrieval', description: 'Bi-encoder embeddings followed by cross-encoder reranking for accurate results.' },
|
||||||
{ 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-diagram-project', title: 'Knowledge graph', description: 'Automatic relationship detection between observations via file overlap and semantic similarity.' },
|
||||||
{ 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-folder-tree', title: 'Project isolation', description: 'Each project has its own memory. Your React knowledge doesn\'t leak into Go projects.' },
|
||||||
{ 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-code', title: 'AST-aware chunking', description: 'Tree-sitter splits Go, Python, and TypeScript code at semantic boundaries.' },
|
||||||
{ 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-database', title: 'Hybrid vector storage', description: 'Selective embedding caching. 60-80% storage reduction with minimal latency impact.' },
|
||||||
{ 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-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-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-gauge', title: 'Live statusline', description: 'Real-time metrics in Claude Code: [mnemonic] ● served:42 | project:28 memories' },
|
||||||
{ icon: 'fas fa-lock', title: '100% private', description: 'Your code context never leaves your machine. No telemetry. No cloud sync. Your memories are yours.' },
|
{ icon: 'fas fa-arrows-rotate', title: 'Auto-updates', description: 'Checks for new versions on startup, downloads in the background, applies on restart.' },
|
||||||
]
|
|
||||||
|
|
||||||
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.' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const installTabs = [
|
const installTabs = [
|
||||||
@@ -447,36 +206,37 @@ const installTabs = [
|
|||||||
{ id: 'source', label: 'From Source' },
|
{ 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 = [
|
const configOptions = [
|
||||||
{ name: 'CLAUDE_MNEMONIC_WORKER_PORT', description: 'HTTP port for the worker service (default: 37777)', icon: 'fas fa-network-wired' },
|
{ name: 'WORKER_PORT', description: 'Dashboard and API port (default: 37777)' },
|
||||||
{ name: 'CLAUDE_MNEMONIC_CONTEXT_OBSERVATIONS', description: 'Maximum observations injected per session (default: 100)', icon: 'fas fa-layer-group' },
|
{ name: 'CONTEXT_OBSERVATIONS', description: 'Max memories injected per session (default: 100)' },
|
||||||
{ name: 'CLAUDE_MNEMONIC_RERANKING_ENABLED', description: 'Enable cross-encoder reranking for improved search relevance (default: true)', icon: 'fas fa-sort-amount-down' },
|
{ name: 'CONTEXT_FULL_COUNT', description: 'Full-detail memories, rest condensed (default: 25)' },
|
||||||
{ name: 'CLAUDE_MNEMONIC_CONTEXT_RELEVANCE_THRESHOLD', description: 'Minimum similarity score for inclusion, 0.0-1.0 (default: 0.3)', icon: 'fas fa-filter' },
|
{ name: 'CONTEXT_RELEVANCE_THRESHOLD', description: 'Min similarity score 0.0-1.0 (default: 0.3)' },
|
||||||
{ name: 'CLAUDE_MNEMONIC_VECTOR_STORAGE_STRATEGY', description: 'Storage strategy: "hub" (default), "always", or "on_demand"', icon: 'fas fa-database' },
|
{ name: 'RERANKING_ENABLED', description: 'Cross-encoder reranking (default: true)' },
|
||||||
{ name: 'CLAUDE_MNEMONIC_GRAPH_ENABLED', description: 'Enable graph-based search with observation relationships (default: true)', icon: 'fas fa-project-diagram' },
|
{ name: 'VECTOR_STORAGE_STRATEGY', description: 'hub (default), always, or on_demand' },
|
||||||
{ name: 'CLAUDE_MNEMONIC_GRAPH_MAX_HOPS', description: 'Maximum graph traversal depth for search expansion (default: 2)', icon: 'fas fa-route' },
|
{ name: 'GRAPH_ENABLED', description: 'Graph-based search with relationships (default: true)' },
|
||||||
{ name: 'CLAUDE_MNEMONIC_GRAPH_REBUILD_INTERVAL_MIN', description: 'How often to rebuild the observation graph in minutes (default: 60)', icon: 'fas fa-clock' },
|
{ name: 'EMBEDDING_MODEL', description: 'Embedding model for semantic search (default: bge-v1.5)' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const requiredDeps = [
|
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: 'Claude Code CLI', description: 'Host application. Uses your existing subscription.', icon: 'fas fa-terminal' },
|
||||||
{ name: 'jq', description: 'JSON processor used during installation. Usually pre-installed on most systems.', icon: 'fas fa-code' },
|
{ 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 = [
|
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: '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 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: '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: '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 dashboard at localhost:37777 lets you browse, search, edit, and delete anything. You can also view graph relationships and storage metrics.' },
|
||||||
{ 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: '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 Claude Code setup?', answer: 'Yes. Mnemonic installs as a Claude Code plugin with hooks. Your existing workflows, settings, and shortcuts remain unchanged.' },
|
{ 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 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: '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.' },
|
||||||
{ 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.' },
|
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="glass rounded-xl p-4 sm:p-6 relative max-w-4xl mx-auto glow-amber">
|
<div class="rounded-lg border border-neutral-800 bg-neutral-900/30 p-4 relative overflow-hidden">
|
||||||
<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">
|
<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' }}
|
{{ copied ? 'Copied' : 'Copy' }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -11,10 +11,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
code: {
|
code: { type: String, default: '' },
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const copied = ref(false)
|
const copied = ref(false)
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="glass rounded-xl p-4 sm:p-6 hover:border-amber-500/20 transition-colors">
|
<div class="border-b border-neutral-800 last:border-0">
|
||||||
<h3 class="text-white font-semibold text-sm sm:text-base mb-2 sm:mb-3">{{ question }}</h3>
|
<button @click="open = !open" class="w-full text-left flex items-center justify-between py-4">
|
||||||
<p class="text-slate-400 text-xs sm:text-sm leading-relaxed">{{ answer }}</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
question: {
|
question: { type: String, required: true },
|
||||||
type: String,
|
answer: { type: String, required: true },
|
||||||
required: true
|
|
||||||
},
|
|
||||||
answer: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<footer class="border-t border-slate-800 py-10 sm:py-16 relative">
|
<footer class="border-t border-neutral-800/80 py-8 px-4 sm:px-6">
|
||||||
<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="max-w-5xl mx-auto flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||||
<div class="flex flex-wrap justify-center gap-4 sm:gap-6">
|
<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"
|
<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 }}
|
{{ link.label }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-slate-600 text-xs sm:text-sm text-center">{{ copyright }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
@@ -20,12 +20,8 @@ defineProps({
|
|||||||
{ label: 'GitHub', href: 'https://github.com/lukaszraczylo/claude-mnemonic' },
|
{ label: 'GitHub', href: 'https://github.com/lukaszraczylo/claude-mnemonic' },
|
||||||
{ label: 'Releases', href: 'https://github.com/lukaszraczylo/claude-mnemonic/releases' },
|
{ label: 'Releases', href: 'https://github.com/lukaszraczylo/claude-mnemonic/releases' },
|
||||||
{ label: 'Issues', href: 'https://github.com/lukaszraczylo/claude-mnemonic/issues' },
|
{ 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>
|
</script>
|
||||||
|
|||||||
@@ -1,44 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="min-h-screen flex items-center relative pt-24 sm:pt-28 pb-16 sm:pb-20">
|
<section class="pt-24 pb-8 sm:pt-32 sm:pb-12 px-4 sm:px-6">
|
||||||
<!-- Background Effects -->
|
<div class="max-w-5xl mx-auto">
|
||||||
<div class="absolute inset-0 overflow-hidden">
|
<p class="text-amber-500 text-sm font-medium mb-4 tracking-wide">Persistent memory for Claude Code</p>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="relative z-10 max-w-6xl mx-auto px-4 sm:px-6 text-center">
|
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-[1.1] mb-6 max-w-3xl">
|
||||||
<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">
|
Stop re-explaining your codebase every session.
|
||||||
<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>
|
</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">
|
<p class="text-neutral-400 text-lg sm:text-xl max-w-2xl mb-10 leading-relaxed">
|
||||||
{{ subtitle }}
|
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>
|
</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">
|
<div class="flex flex-col sm:flex-row gap-3 mb-12">
|
||||||
<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">
|
<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">
|
||||||
<i :class="primaryCta.icon"></i>
|
Install now
|
||||||
{{ primaryCta.label }}
|
|
||||||
</a>
|
</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">
|
<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="secondaryCta.icon"></i>
|
<i class="fab fa-github"></i> View source
|
||||||
{{ secondaryCta.label }}
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Install Command -->
|
<div class="max-w-2xl">
|
||||||
<div class="opacity-0 animate-fade-in-up animation-delay-400 px-2 sm:px-0">
|
<div class="flex items-stretch bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||||
<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">
|
<div class="flex items-center px-3 gap-1.5 border-r border-neutral-800">
|
||||||
<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>
|
<span class="w-3 h-3 rounded-full bg-neutral-700"></span>
|
||||||
<button @click="copyCommand" class="text-slate-500 hover:text-white transition-colors p-2 flex-shrink-0 rounded-lg hover:bg-slate-800/50">
|
<span class="w-3 h-3 rounded-full bg-neutral-700"></span>
|
||||||
<i :class="copied ? 'fas fa-check text-amber-500' : 'fas fa-copy'"></i>
|
<span class="w-3 h-3 rounded-full bg-neutral-700"></span>
|
||||||
<span class="ml-2 text-sm sm:hidden">{{ copied ? 'Copied!' : 'Copy' }}</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,41 +40,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
defineProps({
|
const installCommand = 'curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash'
|
||||||
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 copied = ref(false)
|
const copied = ref(false)
|
||||||
|
|
||||||
function copyCommand() {
|
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
|
copied.value = true
|
||||||
setTimeout(() => copied.value = false, 2000)
|
setTimeout(() => copied.value = false, 2000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,45 @@
|
|||||||
<template>
|
<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">
|
<nav class="border-b border-neutral-800/80 sticky top-0 z-50 bg-[#09090b]/90 backdrop-blur-sm">
|
||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-4 sm:py-5 flex justify-between items-center">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 h-14 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">
|
<a href="#" class="text-white font-bold text-base tracking-tight">Claude Mnemonic</a>
|
||||||
<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>
|
|
||||||
|
|
||||||
<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">
|
<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">
|
<a :href="link.href" class="text-neutral-500 hover:text-white text-sm transition-colors">{{ link.label }}</a>
|
||||||
{{ link.label }}
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 sm:gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a :href="githubUrl" target="_blank" class="text-slate-400 hover:text-white text-lg sm:text-xl transition-colors">
|
<a :href="githubUrl" target="_blank" class="text-neutral-500 hover:text-white transition-colors text-lg">
|
||||||
<i class="fab fa-github"></i>
|
<i class="fab fa-github"></i>
|
||||||
</a>
|
</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">
|
<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">
|
||||||
Get Started
|
Install <i class="fas fa-arrow-right text-xs"></i>
|
||||||
</a>
|
</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>
|
<i :class="mobileMenuOpen ? 'fas fa-times' : 'fas fa-bars'"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Menu -->
|
<div v-if="mobileMenuOpen" class="md:hidden border-t border-neutral-800/80 px-6 py-4 space-y-1">
|
||||||
<Transition
|
<a v-for="link in links" :key="link.href" :href="link.href"
|
||||||
enter-active-class="transition duration-200 ease-out"
|
@click="$emit('toggle-menu')"
|
||||||
enter-from-class="opacity-0 -translate-y-2"
|
class="block py-2 text-neutral-500 hover:text-white text-sm transition-colors">
|
||||||
enter-to-class="opacity-100 translate-y-0"
|
{{ link.label }}
|
||||||
leave-active-class="transition duration-150 ease-in"
|
</a>
|
||||||
leave-from-class="opacity-100 translate-y-0"
|
</div>
|
||||||
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>
|
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
defineProps({
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: 'Mnemonic'
|
|
||||||
},
|
|
||||||
links: {
|
links: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [
|
default: () => [
|
||||||
{ label: 'Features', href: '#features' },
|
{ label: 'Features', href: '#features' },
|
||||||
{ label: 'How It Works', href: '#how-it-works' },
|
{ label: 'Install', href: '#install' },
|
||||||
{ label: 'Install', href: '#installation' },
|
{ label: 'Config', href: '#config' },
|
||||||
{ label: 'FAQ', href: '#faq' },
|
{ label: 'FAQ', href: '#faq' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
@@ -1,6 +1,9 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
@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 {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
@@ -10,105 +13,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply font-sans text-slate-300;
|
@apply font-sans text-neutral-300 bg-[#09090b];
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
@apply font-sans font-bold tracking-tight;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code, pre {
|
code, pre {
|
||||||
@apply font-mono;
|
@apply font-mono;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
::selection {
|
||||||
/* Animated gradient background */
|
@apply bg-amber-500/30 text-white;
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [],
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Experiment journal
|
||||||
|
|
||||||
|
## Campaign B: binary size
|
||||||
|
|
||||||
|
Metric: sum of bytes of worker, mcp and 6 hook binaries (release flags, `-s -w -trimpath`, `-tags fts5`). Lower is better.
|
||||||
|
Threshold: at least 3% drop, tests green. Stop: 5 consecutive no-improve trials or 15 trials.
|
||||||
|
Mutate: Go code and build flags. Frozen: public API, DB schema, plugin manifest.
|
||||||
|
|
||||||
|
| id | mutation | total bytes | delta vs base | kept? |
|
||||||
|
|----|----------|-------------|---------------|-------|
|
||||||
|
| 01 | baseline | 102698416 | - | base |
|
||||||
|
| 02 | pkg/hooks: raw HTTP/1.0 client replaces net/http | 87603952 | -14.7% | KEEP |
|
||||||
|
| 03 | statusline: shared client, drop net/http | 84531056 | -3.5% vs 02 | KEEP |
|
||||||
|
| 04 | embed gzipped ONNX runtime libs, gunzip at first use | 57216880 | -32.3% vs 03 | KEEP |
|
||||||
|
| 05 | hooks built with CGO_ENABLED=0 | 57216880 | 0% | revert (not applied) |
|
||||||
|
| - | statically dedupe or compress tokenizer.json | - | projected ~1.2-1.6% | not run, under 3% threshold |
|
||||||
|
| - | drop net/http from mcp | - | none possible | not run, oss-telemetry links net/http |
|
||||||
|
|
||||||
|
Campaign B stopped on diminishing returns: 102698416 -> 57216880 bytes (-44.3%).
|
||||||
|
|
||||||
|
## Campaign A: hook latency (stub worker, 200 runs, p50)
|
||||||
|
|
||||||
|
Harness: hook binary against a local stub worker (100-observation context), same machine, alternating runs. Lower is better.
|
||||||
|
|
||||||
|
| hook | before (ee28544) | after trial 02 | delta |
|
||||||
|
|------|------------------|----------------|-------|
|
||||||
|
| session-start | 4.6-5.6 ms | 3.3 ms | -30% or better |
|
||||||
|
| user-prompt | 4.3-4.7 ms | 3.5-3.7 ms | -15% to -22% |
|
||||||
|
|
||||||
|
Hooks now sit near the process-start floor. No further trials run.
|
||||||
|
|
||||||
|
## Campaign D: reliability
|
||||||
|
|
||||||
|
Metric: failing cases in the extraction fault test (`TestWriteGunzipped`). Lower is better.
|
||||||
|
|
||||||
|
| id | mutation | failing cases | kept? |
|
||||||
|
|----|----------|---------------|-------|
|
||||||
|
| 01 | baseline (truncated archive leaves partial lib.so) | 1 | base |
|
||||||
|
| 02 | extract via temp file and rename | 0 | KEEP |
|
||||||
|
|
||||||
|
## Campaign C: search accuracy
|
||||||
|
|
||||||
|
Harness: `scripts/eval-search` seeds 30 labelled observations, starts a real worker (fresh HOME, model cache copied), waits for the vector rebuild, then runs 30 paraphrased queries against `/api/context/search`. Metric: MRR@10 (higher is better). Threshold: at least +0.02. Runs are deterministic (repeat runs identical).
|
||||||
|
|
||||||
|
| id | mutation | MRR@10 | recall@5 | kept? |
|
||||||
|
|----|----------|--------|----------|-------|
|
||||||
|
| 01 | baseline | 0.7944 | 0.8667 | base |
|
||||||
|
| 02 | L2-normalise embeddings (similarity was negative for every vector hit, so search fell back to FTS) | 0.8944 | 0.9667 | KEEP |
|
||||||
|
| 03 | reranking alpha 0.7 -> 0.5 | 0.9111 | 0.9667 | revert (+0.017 < 0.02) |
|
||||||
|
| 04 | reranking alpha 0.7 -> 0.9 | 0.9111 | 0.9667 | revert (+0.017 < 0.02) |
|
||||||
|
| 05 | reranker off | 0.8889 | 0.9667 | revert |
|
||||||
|
| 06 | reranker pure mode | 0.8714 | 0.9667 | revert |
|
||||||
|
| 07 | CLS pooling instead of mean | 0.8944 | 0.9667 | revert |
|
||||||
|
|
||||||
|
Trial 02 changes stored vectors. `BGEStorageVersion` is bumped to `bge-v1.5-l2`, so existing installs rebuild vectors on next start.
|
||||||
|
Caveat: 30 queries is a small set, so borderline gains such as alpha 0.5 are not distinguishable from noise.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Trial 04 needs `scripts/download-onnx-libs.sh` to produce `.gz` files; existing raw libs trigger a re-download.
|
||||||
|
- Extracted library verified byte-identical to the raw dylib (`cmp`).
|
||||||
@@ -1,40 +1,38 @@
|
|||||||
module github.com/lukaszraczylo/claude-mnemonic
|
module github.com/lukaszraczylo/claude-mnemonic
|
||||||
|
|
||||||
go 1.25.1
|
go 1.26.0
|
||||||
|
|
||||||
replace github.com/sugarme/tokenizer => github.com/clems4ever/tokenizer v0.0.0-20250926133620-9ddc80533c43
|
replace github.com/sugarme/tokenizer => github.com/clems4ever/tokenizer v0.0.0-20250926133620-9ddc80533c43
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/asg017/sqlite-vec-go-bindings v0.1.6
|
github.com/asg017/sqlite-vec-go-bindings v0.1.6
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.10.1
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.3.2
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5
|
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||||
github.com/goccy/go-json v0.10.5
|
github.com/goccy/go-json v0.11.2
|
||||||
github.com/mattn/go-sqlite3 v1.14.33
|
github.com/lukaszraczylo/oss-telemetry v0.2.5
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/mattn/go-sqlite3 v1.14.52
|
||||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
|
github.com/rs/zerolog v1.35.1
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.12.1
|
||||||
github.com/sugarme/tokenizer v0.3.0
|
github.com/sugarme/tokenizer v0.3.0
|
||||||
github.com/yalue/onnxruntime_go v1.25.0
|
github.com/yalue/onnxruntime_go v1.36.0
|
||||||
golang.org/x/sync v0.19.0
|
golang.org/x/sync v0.24.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.16 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible // 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/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/schollz/progressbar/v2 v2.15.0 // indirect
|
github.com/schollz/progressbar/v2 v2.15.0 // indirect
|
||||||
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c // indirect
|
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/sys v0.49.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
golang.org/x/text v0.43.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,72 +2,58 @@ 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/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 h1:j8YQypEqa5OjqbGciCNb9hOcYbo1oTVuEjd/iu9U2SY=
|
||||||
github.com/clems4ever/tokenizer v0.0.0-20250926133620-9ddc80533c43/go.mod h1:VJ+DLK5ZEZwzvODOWwY0cw+B1dabTd3nCB5HuFCItCc=
|
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.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/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 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
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.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiIhS4/QTTa/SM8=
|
github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg=
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M=
|
github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
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/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 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/lukaszraczylo/oss-telemetry v0.2.5 h1:W6Qgwp7IY4yBRO361MmnEAmM59motdQpbAFAx8v6fJM=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/lukaszraczylo/oss-telemetry v0.2.5/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
|
||||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
|
||||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
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/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 h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
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/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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
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.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
|
||||||
github.com/schollz/progressbar/v2 v2.15.0 h1:dVzHQ8fHRmtPjD3K10jT3Qgn/+H+92jhPrhmxIJfDz8=
|
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/schollz/progressbar/v2 v2.15.0/go.mod h1:UdPq3prGkfQ7MOzZKlDRpYKcFqEMczbD7YmbPgpzKMI=
|
||||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4=
|
|
||||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82/go.mod h1:xe4pgH49k4SsmkQq5OT8abwhWmnzkhpgnXeekbx2efw=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c h1:pwb4kNSHb4K89ymCaN+5lPH/MwnfSVg4rzGDh4d+iy4=
|
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/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.36.0 h1:iH1Q++DcsyT9sWtN26KYimESlI5hhXpKaChHDS44oV4=
|
||||||
github.com/yalue/onnxruntime_go v1.25.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
github.com/yalue/onnxruntime_go v1.36.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
|
||||||
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=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
BIN="$HOME/.claude-mnemonic/bin/hooks/post-tool-use"
|
||||||
|
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||||
|
exit 0
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
BIN="$HOME/.claude-mnemonic/bin/hooks/session-start"
|
||||||
|
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||||
|
exit 0
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
BIN="$HOME/.claude-mnemonic/bin/hooks/statusline"
|
||||||
|
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||||
|
exit 0
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
BIN="$HOME/.claude-mnemonic/bin/hooks/stop"
|
||||||
|
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||||
|
exit 0
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
BIN="$HOME/.claude-mnemonic/bin/hooks/subagent-stop"
|
||||||
|
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||||
|
exit 0
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
BIN="$HOME/.claude-mnemonic/bin/hooks/user-prompt"
|
||||||
|
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||||
|
exit 0
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
// Package golang provides AST-aware chunking for Go source files.
|
|
||||||
package golang
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"go/ast"
|
|
||||||
"go/parser"
|
|
||||||
"go/token"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Chunker implements AST-aware chunking for Go files.
|
|
||||||
type Chunker struct {
|
|
||||||
options chunking.ChunkOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewChunker creates a new Go chunker.
|
|
||||||
func NewChunker(options chunking.ChunkOptions) *Chunker {
|
|
||||||
return &Chunker{options: options}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Language returns the language this chunker supports.
|
|
||||||
func (c *Chunker) Language() chunking.Language {
|
|
||||||
return chunking.LanguageGo
|
|
||||||
}
|
|
||||||
|
|
||||||
// SupportedExtensions returns the file extensions this chunker handles.
|
|
||||||
func (c *Chunker) SupportedExtensions() []string {
|
|
||||||
return []string{".go"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chunk parses a Go source file and returns semantic code chunks.
|
|
||||||
func (c *Chunker) Chunk(ctx context.Context, filePath string) ([]chunking.Chunk, error) {
|
|
||||||
// Read file content
|
|
||||||
content, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the Go file
|
|
||||||
fset := token.NewFileSet()
|
|
||||||
file, err := parser.ParseFile(fset, filePath, content, parser.ParseComments)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse Go file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
chunks := make([]chunking.Chunk, 0)
|
|
||||||
sourceLines := strings.Split(string(content), "\n")
|
|
||||||
|
|
||||||
// Extract chunks from declarations
|
|
||||||
for _, decl := range file.Decls {
|
|
||||||
switch d := decl.(type) {
|
|
||||||
case *ast.FuncDecl:
|
|
||||||
chunk := c.extractFunction(fset, d, sourceLines, filePath)
|
|
||||||
if chunk != nil {
|
|
||||||
chunks = append(chunks, *chunk)
|
|
||||||
}
|
|
||||||
case *ast.GenDecl:
|
|
||||||
extracted := c.extractGenDecl(fset, d, sourceLines, filePath)
|
|
||||||
chunks = append(chunks, extracted...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractFunction extracts a function or method declaration as a chunk.
|
|
||||||
func (c *Chunker) extractFunction(fset *token.FileSet, fn *ast.FuncDecl, sourceLines []string, filePath string) *chunking.Chunk {
|
|
||||||
// Skip unexported if configured
|
|
||||||
if !c.options.IncludePrivate && !fn.Name.IsExported() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
startPos := fset.Position(fn.Pos())
|
|
||||||
endPos := fset.Position(fn.End())
|
|
||||||
|
|
||||||
chunk := &chunking.Chunk{
|
|
||||||
FilePath: filePath,
|
|
||||||
Language: chunking.LanguageGo,
|
|
||||||
Name: fn.Name.Name,
|
|
||||||
StartLine: startPos.Line,
|
|
||||||
EndLine: endPos.Line,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if this is a method or a function
|
|
||||||
if fn.Recv != nil && len(fn.Recv.List) > 0 {
|
|
||||||
chunk.Type = chunking.ChunkTypeMethod
|
|
||||||
chunk.ParentName = c.extractReceiverType(fn.Recv)
|
|
||||||
} else {
|
|
||||||
chunk.Type = chunking.ChunkTypeFunction
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract content
|
|
||||||
chunk.Content = c.extractLines(sourceLines, startPos.Line, endPos.Line)
|
|
||||||
|
|
||||||
// Extract signature (function declaration without body)
|
|
||||||
chunk.Signature = c.extractFunctionSignature(fn, fset, sourceLines)
|
|
||||||
|
|
||||||
// Extract doc comment
|
|
||||||
if c.options.IncludeDocComments && fn.Doc != nil {
|
|
||||||
chunk.DocComment = strings.TrimSpace(fn.Doc.Text())
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunk
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractGenDecl extracts general declarations (type, const, var).
|
|
||||||
func (c *Chunker) extractGenDecl(fset *token.FileSet, gd *ast.GenDecl, sourceLines []string, filePath string) []chunking.Chunk {
|
|
||||||
var chunks []chunking.Chunk
|
|
||||||
|
|
||||||
for _, spec := range gd.Specs {
|
|
||||||
switch s := spec.(type) {
|
|
||||||
case *ast.TypeSpec:
|
|
||||||
chunk := c.extractTypeSpec(fset, gd, s, sourceLines, filePath)
|
|
||||||
if chunk != nil {
|
|
||||||
chunks = append(chunks, *chunk)
|
|
||||||
}
|
|
||||||
case *ast.ValueSpec:
|
|
||||||
// Handle const and var declarations
|
|
||||||
chunk := c.extractValueSpec(fset, gd, s, sourceLines, filePath)
|
|
||||||
if chunk != nil {
|
|
||||||
chunks = append(chunks, *chunk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractTypeSpec extracts a type declaration (struct, interface, type alias).
|
|
||||||
func (c *Chunker) extractTypeSpec(fset *token.FileSet, gd *ast.GenDecl, ts *ast.TypeSpec, sourceLines []string, filePath string) *chunking.Chunk {
|
|
||||||
// Skip unexported if configured
|
|
||||||
if !c.options.IncludePrivate && !ts.Name.IsExported() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
startPos := fset.Position(gd.Pos())
|
|
||||||
endPos := fset.Position(gd.End())
|
|
||||||
|
|
||||||
chunk := &chunking.Chunk{
|
|
||||||
FilePath: filePath,
|
|
||||||
Language: chunking.LanguageGo,
|
|
||||||
Name: ts.Name.Name,
|
|
||||||
StartLine: startPos.Line,
|
|
||||||
EndLine: endPos.Line,
|
|
||||||
Content: c.extractLines(sourceLines, startPos.Line, endPos.Line),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine chunk type based on type expression
|
|
||||||
switch ts.Type.(type) {
|
|
||||||
case *ast.StructType:
|
|
||||||
chunk.Type = chunking.ChunkTypeClass // Treat struct as class
|
|
||||||
case *ast.InterfaceType:
|
|
||||||
chunk.Type = chunking.ChunkTypeInterface
|
|
||||||
default:
|
|
||||||
chunk.Type = chunking.ChunkTypeType
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract doc comment
|
|
||||||
if c.options.IncludeDocComments && gd.Doc != nil {
|
|
||||||
chunk.DocComment = strings.TrimSpace(gd.Doc.Text())
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunk
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractValueSpec extracts const or var declarations.
|
|
||||||
func (c *Chunker) extractValueSpec(fset *token.FileSet, gd *ast.GenDecl, vs *ast.ValueSpec, sourceLines []string, filePath string) *chunking.Chunk {
|
|
||||||
// Skip if all names are unexported and we're excluding private
|
|
||||||
if !c.options.IncludePrivate {
|
|
||||||
allUnexported := true
|
|
||||||
for _, name := range vs.Names {
|
|
||||||
if name.IsExported() {
|
|
||||||
allUnexported = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if allUnexported {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startPos := fset.Position(gd.Pos())
|
|
||||||
endPos := fset.Position(gd.End())
|
|
||||||
|
|
||||||
// Use first name as the chunk name, join multiple if present
|
|
||||||
names := make([]string, len(vs.Names))
|
|
||||||
for i, name := range vs.Names {
|
|
||||||
names[i] = name.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
chunk := &chunking.Chunk{
|
|
||||||
FilePath: filePath,
|
|
||||||
Language: chunking.LanguageGo,
|
|
||||||
Name: strings.Join(names, ", "),
|
|
||||||
StartLine: startPos.Line,
|
|
||||||
EndLine: endPos.Line,
|
|
||||||
Content: c.extractLines(sourceLines, startPos.Line, endPos.Line),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set type based on token
|
|
||||||
if gd.Tok == token.CONST {
|
|
||||||
chunk.Type = chunking.ChunkTypeConst
|
|
||||||
} else {
|
|
||||||
chunk.Type = chunking.ChunkTypeVar
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract doc comment
|
|
||||||
if c.options.IncludeDocComments && gd.Doc != nil {
|
|
||||||
chunk.DocComment = strings.TrimSpace(gd.Doc.Text())
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunk
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractReceiverType extracts the receiver type name from a method.
|
|
||||||
func (c *Chunker) extractReceiverType(recv *ast.FieldList) string {
|
|
||||||
if len(recv.List) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
field := recv.List[0]
|
|
||||||
switch t := field.Type.(type) {
|
|
||||||
case *ast.Ident:
|
|
||||||
return t.Name
|
|
||||||
case *ast.StarExpr:
|
|
||||||
if ident, ok := t.X.(*ast.Ident); ok {
|
|
||||||
return ident.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractFunctionSignature extracts the function signature without the body.
|
|
||||||
func (c *Chunker) extractFunctionSignature(fn *ast.FuncDecl, fset *token.FileSet, sourceLines []string) string {
|
|
||||||
if fn.Body == nil {
|
|
||||||
// No body, return entire declaration
|
|
||||||
startPos := fset.Position(fn.Pos())
|
|
||||||
endPos := fset.Position(fn.End())
|
|
||||||
return c.extractLines(sourceLines, startPos.Line, endPos.Line)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract from start of function to just before body
|
|
||||||
startPos := fset.Position(fn.Pos())
|
|
||||||
bodyPos := fset.Position(fn.Body.Pos())
|
|
||||||
|
|
||||||
// If body is on the same line, extract just that line up to the opening brace
|
|
||||||
if startPos.Line == bodyPos.Line {
|
|
||||||
line := sourceLines[startPos.Line-1]
|
|
||||||
// Find the opening brace position
|
|
||||||
if idx := strings.Index(line[startPos.Column-1:], "{"); idx >= 0 {
|
|
||||||
return strings.TrimSpace(line[startPos.Column-1 : startPos.Column-1+idx])
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(line[startPos.Column-1:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get lines from start to the line containing the opening brace
|
|
||||||
sig := c.extractLines(sourceLines, startPos.Line, bodyPos.Line)
|
|
||||||
// Remove the opening brace and anything after it
|
|
||||||
if idx := strings.Index(sig, "{"); idx >= 0 {
|
|
||||||
sig = sig[:idx]
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(sig)
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractLines extracts a range of lines from source (1-indexed, inclusive).
|
|
||||||
func (c *Chunker) extractLines(lines []string, start, end int) string {
|
|
||||||
if start < 1 || end < start || start > len(lines) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust for 0-indexed array (start and end are 1-indexed)
|
|
||||||
startIdx := start - 1
|
|
||||||
endIdx := end
|
|
||||||
if endIdx > len(lines) {
|
|
||||||
endIdx = len(lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(lines[startIdx:endIdx], "\n")
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
package golang
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGoChunker_BasicFunctions(t *testing.T) {
|
|
||||||
// Create temp test file
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
testFile := filepath.Join(tmpDir, "test.go")
|
|
||||||
|
|
||||||
testCode := `package main
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// Greet prints a greeting message
|
|
||||||
func Greet(name string) {
|
|
||||||
fmt.Printf("Hello, %s!\n", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add adds two numbers
|
|
||||||
func Add(a, b int) int {
|
|
||||||
return a + b
|
|
||||||
}
|
|
||||||
|
|
||||||
// unexported function should be included by default
|
|
||||||
func helper() string {
|
|
||||||
return "helper"
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
if err := os.WriteFile(testFile, []byte(testCode), 0600); err != nil {
|
|
||||||
t.Fatalf("Failed to create test file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create chunker with default options
|
|
||||||
chunker := NewChunker(chunking.DefaultChunkOptions())
|
|
||||||
|
|
||||||
// Chunk the file
|
|
||||||
chunks, err := chunker.Chunk(context.Background(), testFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chunk() failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify we got all functions
|
|
||||||
if len(chunks) != 3 {
|
|
||||||
t.Errorf("Expected 3 chunks (Greet, Add, helper), got %d", len(chunks))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify chunk details
|
|
||||||
expectedNames := map[string]bool{
|
|
||||||
"Greet": false,
|
|
||||||
"Add": false,
|
|
||||||
"helper": false,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, chunk := range chunks {
|
|
||||||
if chunk.Type != chunking.ChunkTypeFunction {
|
|
||||||
t.Errorf("Expected chunk type 'function', got '%s'", chunk.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
if chunk.Language != chunking.LanguageGo {
|
|
||||||
t.Errorf("Expected language 'go', got '%s'", chunk.Language)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := expectedNames[chunk.Name]; !ok {
|
|
||||||
t.Errorf("Unexpected function name: %s", chunk.Name)
|
|
||||||
} else {
|
|
||||||
expectedNames[chunk.Name] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify content is non-empty
|
|
||||||
if chunk.Content == "" {
|
|
||||||
t.Errorf("Chunk %s has empty content", chunk.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify signature is present for functions
|
|
||||||
if chunk.Signature == "" {
|
|
||||||
t.Errorf("Chunk %s has empty signature", chunk.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify all expected functions were found
|
|
||||||
for name, found := range expectedNames {
|
|
||||||
if !found {
|
|
||||||
t.Errorf("Expected function %s not found", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGoChunker_StructsAndMethods(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
testFile := filepath.Join(tmpDir, "test.go")
|
|
||||||
|
|
||||||
testCode := `package main
|
|
||||||
|
|
||||||
// User represents a user
|
|
||||||
type User struct {
|
|
||||||
ID int
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetName returns the user's name
|
|
||||||
func (u *User) GetName() string {
|
|
||||||
return u.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetName sets the user's name
|
|
||||||
func (u *User) SetName(name string) {
|
|
||||||
u.Name = name
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
if err := os.WriteFile(testFile, []byte(testCode), 0600); err != nil {
|
|
||||||
t.Fatalf("Failed to create test file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
chunker := NewChunker(chunking.DefaultChunkOptions())
|
|
||||||
chunks, err := chunker.Chunk(context.Background(), testFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chunk() failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have 1 struct + 2 methods = 3 chunks
|
|
||||||
if len(chunks) != 3 {
|
|
||||||
t.Errorf("Expected 3 chunks (User struct, GetName, SetName), got %d", len(chunks))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the struct and methods
|
|
||||||
var structChunk, getNameChunk, setNameChunk *chunking.Chunk
|
|
||||||
for i := range chunks {
|
|
||||||
switch chunks[i].Name {
|
|
||||||
case "User":
|
|
||||||
structChunk = &chunks[i]
|
|
||||||
case "GetName":
|
|
||||||
getNameChunk = &chunks[i]
|
|
||||||
case "SetName":
|
|
||||||
setNameChunk = &chunks[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify struct
|
|
||||||
if structChunk == nil {
|
|
||||||
t.Fatal("User struct not found")
|
|
||||||
}
|
|
||||||
if structChunk.Type != chunking.ChunkTypeClass {
|
|
||||||
t.Errorf("Expected User to be ChunkTypeClass, got %s", structChunk.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify methods
|
|
||||||
if getNameChunk == nil {
|
|
||||||
t.Fatal("GetName method not found")
|
|
||||||
}
|
|
||||||
if getNameChunk.Type != chunking.ChunkTypeMethod {
|
|
||||||
t.Errorf("Expected GetName to be ChunkTypeMethod, got %s", getNameChunk.Type)
|
|
||||||
}
|
|
||||||
if getNameChunk.ParentName != "User" {
|
|
||||||
t.Errorf("Expected GetName parent to be 'User', got '%s'", getNameChunk.ParentName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if setNameChunk == nil {
|
|
||||||
t.Fatal("SetName method not found")
|
|
||||||
}
|
|
||||||
if setNameChunk.Type != chunking.ChunkTypeMethod {
|
|
||||||
t.Errorf("Expected SetName to be ChunkTypeMethod, got %s", setNameChunk.Type)
|
|
||||||
}
|
|
||||||
if setNameChunk.ParentName != "User" {
|
|
||||||
t.Errorf("Expected SetName parent to be 'User', got '%s'", setNameChunk.ParentName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGoChunker_DocComments(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
testFile := filepath.Join(tmpDir, "test.go")
|
|
||||||
|
|
||||||
testCode := `package main
|
|
||||||
|
|
||||||
// Calculate performs a calculation.
|
|
||||||
// It takes two integers and returns their sum.
|
|
||||||
func Calculate(a, b int) int {
|
|
||||||
return a + b
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
if err := os.WriteFile(testFile, []byte(testCode), 0600); err != nil {
|
|
||||||
t.Fatalf("Failed to create test file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
chunker := NewChunker(chunking.DefaultChunkOptions())
|
|
||||||
chunks, err := chunker.Chunk(context.Background(), testFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Chunk() failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(chunks) != 1 {
|
|
||||||
t.Fatalf("Expected 1 chunk, got %d", len(chunks))
|
|
||||||
}
|
|
||||||
|
|
||||||
chunk := chunks[0]
|
|
||||||
if chunk.DocComment == "" {
|
|
||||||
t.Error("Expected doc comment to be present")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Doc comment should contain the comment text
|
|
||||||
expectedComment := "Calculate performs a calculation.\nIt takes two integers and returns their sum."
|
|
||||||
if chunk.DocComment != expectedComment {
|
|
||||||
t.Errorf("Expected doc comment '%s', got '%s'", expectedComment, chunk.DocComment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package chunking
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Manager dispatches files to appropriate language-specific chunkers.
|
|
||||||
type Manager struct {
|
|
||||||
chunkers map[string]Chunker // extension -> chunker
|
|
||||||
options ChunkOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewManager creates a new chunking manager with the given chunkers.
|
|
||||||
func NewManager(chunkers []Chunker, options ChunkOptions) *Manager {
|
|
||||||
m := &Manager{
|
|
||||||
chunkers: make(map[string]Chunker),
|
|
||||||
options: options,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register chunkers by their supported extensions
|
|
||||||
for _, chunker := range chunkers {
|
|
||||||
for _, ext := range chunker.SupportedExtensions() {
|
|
||||||
m.chunkers[ext] = chunker
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChunkFile chunks a single file using the appropriate language chunker.
|
|
||||||
// Returns an error if no chunker is found for the file extension.
|
|
||||||
func (m *Manager) ChunkFile(ctx context.Context, filePath string) ([]Chunk, error) {
|
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
|
||||||
chunker, ok := m.chunkers[ext]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("no chunker for extension %s", ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
chunks, err := chunker.Chunk(ctx, filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("chunk %s: %w", filePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply options-based filtering
|
|
||||||
filtered := make([]Chunk, 0, len(chunks))
|
|
||||||
for _, chunk := range chunks {
|
|
||||||
// Filter by minimum lines
|
|
||||||
if m.options.MinLines > 0 {
|
|
||||||
lineCount := chunk.EndLine - chunk.StartLine + 1
|
|
||||||
if lineCount < m.options.MinLines {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by maximum chunk size
|
|
||||||
if m.options.MaxChunkSize > 0 && len(chunk.Content) > m.options.MaxChunkSize {
|
|
||||||
// TODO: Consider splitting large chunks intelligently
|
|
||||||
// For now, skip chunks that are too large
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered = append(filtered, chunk)
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChunkFiles chunks multiple files in parallel.
|
|
||||||
// Returns a map of file path to chunks, and any errors encountered.
|
|
||||||
// Errors for individual files do not stop processing of other files.
|
|
||||||
func (m *Manager) ChunkFiles(ctx context.Context, filePaths []string) (map[string][]Chunk, []error) {
|
|
||||||
results := make(map[string][]Chunk)
|
|
||||||
var errors []error
|
|
||||||
|
|
||||||
for _, filePath := range filePaths {
|
|
||||||
chunks, err := m.ChunkFile(ctx, filePath)
|
|
||||||
if err != nil {
|
|
||||||
errors = append(errors, fmt.Errorf("%s: %w", filePath, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(chunks) > 0 {
|
|
||||||
results[filePath] = chunks
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results, errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// SupportsFile checks if the manager can chunk the given file based on extension.
|
|
||||||
func (m *Manager) SupportsFile(filePath string) bool {
|
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
|
||||||
_, ok := m.chunkers[ext]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// SupportedExtensions returns all file extensions supported by registered chunkers.
|
|
||||||
func (m *Manager) SupportedExtensions() []string {
|
|
||||||
exts := make([]string, 0, len(m.chunkers))
|
|
||||||
for ext := range m.chunkers {
|
|
||||||
exts = append(exts, ext)
|
|
||||||
}
|
|
||||||
return exts
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package chunking
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mockChunker is a test chunker that returns dummy chunks
|
|
||||||
type mockChunker struct{}
|
|
||||||
|
|
||||||
func (m *mockChunker) Chunk(ctx context.Context, filePath string) ([]Chunk, error) {
|
|
||||||
// Just return an empty chunk for testing
|
|
||||||
return []Chunk{
|
|
||||||
{
|
|
||||||
FilePath: filePath,
|
|
||||||
Language: LanguageGo,
|
|
||||||
Type: ChunkTypeFunction,
|
|
||||||
Name: "TestFunc",
|
|
||||||
StartLine: 1,
|
|
||||||
EndLine: 1,
|
|
||||||
Content: "test",
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockChunker) Language() Language {
|
|
||||||
return LanguageGo
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockChunker) SupportedExtensions() []string {
|
|
||||||
return []string{".go", ".py", ".ts"}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_ChunkMultipleFiles(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
|
|
||||||
// Create a Go file
|
|
||||||
goFile := filepath.Join(tmpDir, "test.go")
|
|
||||||
goCode := `package main
|
|
||||||
|
|
||||||
func Hello() string {
|
|
||||||
return "hello"
|
|
||||||
}
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(goFile, []byte(goCode), 0600); err != nil {
|
|
||||||
t.Fatalf("Failed to create Go file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a Python file
|
|
||||||
pyFile := filepath.Join(tmpDir, "test.py")
|
|
||||||
pyCode := `def greet(name):
|
|
||||||
return f"Hello, {name}!"
|
|
||||||
|
|
||||||
class User:
|
|
||||||
def __init__(self, name):
|
|
||||||
self.name = name
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(pyFile, []byte(pyCode), 0600); err != nil {
|
|
||||||
t.Fatalf("Failed to create Python file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a TypeScript file
|
|
||||||
tsFile := filepath.Join(tmpDir, "test.ts")
|
|
||||||
tsCode := `function add(a: number, b: number): number {
|
|
||||||
return a + b;
|
|
||||||
}
|
|
||||||
|
|
||||||
class Calculator {
|
|
||||||
multiply(a: number, b: number): number {
|
|
||||||
return a * b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(tsFile, []byte(tsCode), 0600); err != nil {
|
|
||||||
t.Fatalf("Failed to create TypeScript file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create manager
|
|
||||||
manager := NewManager([]Chunker{&mockChunker{}}, DefaultChunkOptions())
|
|
||||||
|
|
||||||
// Test SupportsFile
|
|
||||||
if !manager.SupportsFile(goFile) {
|
|
||||||
t.Error("Manager should support .go files")
|
|
||||||
}
|
|
||||||
if !manager.SupportsFile(pyFile) {
|
|
||||||
t.Error("Manager should support .py files")
|
|
||||||
}
|
|
||||||
if !manager.SupportsFile(tsFile) {
|
|
||||||
t.Error("Manager should support .ts files")
|
|
||||||
}
|
|
||||||
|
|
||||||
unsupportedFile := filepath.Join(tmpDir, "test.txt")
|
|
||||||
if manager.SupportsFile(unsupportedFile) {
|
|
||||||
t.Error("Manager should not support .txt files")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test ChunkFiles
|
|
||||||
results, errs := manager.ChunkFiles(context.Background(), []string{goFile, pyFile, tsFile})
|
|
||||||
if len(errs) > 0 {
|
|
||||||
t.Errorf("ChunkFiles returned errors: %v", errs)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(results) != 3 {
|
|
||||||
t.Errorf("Expected results for 3 files, got %d", len(results))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify each file has chunks
|
|
||||||
for _, file := range []string{goFile, pyFile, tsFile} {
|
|
||||||
if chunks, ok := results[file]; !ok || len(chunks) == 0 {
|
|
||||||
t.Errorf("No chunks found for file %s", file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mockChunkerWithExts is a test chunker with configurable extensions
|
|
||||||
type mockChunkerWithExts struct {
|
|
||||||
exts []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockChunkerWithExts) Chunk(ctx context.Context, filePath string) ([]Chunk, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockChunkerWithExts) Language() Language {
|
|
||||||
return LanguageGo
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockChunkerWithExts) SupportedExtensions() []string {
|
|
||||||
return m.exts
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestManager_SupportedExtensions(t *testing.T) {
|
|
||||||
|
|
||||||
// Create manager with mock chunkers
|
|
||||||
manager := NewManager([]Chunker{
|
|
||||||
&mockChunkerWithExts{exts: []string{".go"}},
|
|
||||||
&mockChunkerWithExts{exts: []string{".py", ".pyw"}},
|
|
||||||
}, DefaultChunkOptions())
|
|
||||||
|
|
||||||
exts := manager.SupportedExtensions()
|
|
||||||
expectedExts := map[string]bool{
|
|
||||||
".go": false,
|
|
||||||
".py": false,
|
|
||||||
".pyw": false,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ext := range exts {
|
|
||||||
if _, ok := expectedExts[ext]; ok {
|
|
||||||
expectedExts[ext] = true
|
|
||||||
} else {
|
|
||||||
t.Errorf("Unexpected extension: %s", ext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for ext, found := range expectedExts {
|
|
||||||
if !found {
|
|
||||||
t.Errorf("Expected extension %s not found", ext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
// Package python provides AST-aware chunking for Python source files using tree-sitter.
|
|
||||||
package python
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
sitter "github.com/smacker/go-tree-sitter"
|
|
||||||
"github.com/smacker/go-tree-sitter/python"
|
|
||||||
|
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Chunker implements AST-aware chunking for Python files.
|
|
||||||
type Chunker struct {
|
|
||||||
parser *sitter.Parser
|
|
||||||
options chunking.ChunkOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewChunker creates a new Python chunker.
|
|
||||||
func NewChunker(options chunking.ChunkOptions) *Chunker {
|
|
||||||
parser := sitter.NewParser()
|
|
||||||
parser.SetLanguage(python.GetLanguage())
|
|
||||||
|
|
||||||
return &Chunker{
|
|
||||||
options: options,
|
|
||||||
parser: parser,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Language returns the language this chunker supports.
|
|
||||||
func (c *Chunker) Language() chunking.Language {
|
|
||||||
return chunking.LanguagePython
|
|
||||||
}
|
|
||||||
|
|
||||||
// SupportedExtensions returns the file extensions this chunker handles.
|
|
||||||
func (c *Chunker) SupportedExtensions() []string {
|
|
||||||
return []string{".py"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chunk parses a Python source file and returns semantic code chunks.
|
|
||||||
func (c *Chunker) Chunk(ctx context.Context, filePath string) ([]chunking.Chunk, error) {
|
|
||||||
// Read file content
|
|
||||||
content, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the Python file
|
|
||||||
tree, err := c.parser.ParseCtx(ctx, nil, content)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse Python file: %w", err)
|
|
||||||
}
|
|
||||||
defer tree.Close()
|
|
||||||
|
|
||||||
sourceLines := strings.Split(string(content), "\n")
|
|
||||||
chunks := make([]chunking.Chunk, 0)
|
|
||||||
|
|
||||||
// Walk the AST and extract chunks
|
|
||||||
c.walkNode(tree.RootNode(), content, sourceLines, filePath, "", &chunks)
|
|
||||||
|
|
||||||
return chunks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// walkNode recursively walks the tree-sitter AST and extracts chunks.
|
|
||||||
func (c *Chunker) walkNode(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string, chunks *[]chunking.Chunk) {
|
|
||||||
nodeType := node.Type()
|
|
||||||
|
|
||||||
switch nodeType {
|
|
||||||
case "function_definition":
|
|
||||||
chunk := c.extractFunction(node, source, sourceLines, filePath, parentName)
|
|
||||||
if chunk != nil {
|
|
||||||
*chunks = append(*chunks, *chunk)
|
|
||||||
}
|
|
||||||
|
|
||||||
case "class_definition":
|
|
||||||
chunk := c.extractClass(node, source, sourceLines, filePath)
|
|
||||||
if chunk != nil {
|
|
||||||
*chunks = append(*chunks, *chunk)
|
|
||||||
|
|
||||||
// Walk class body to find methods
|
|
||||||
for i := 0; i < int(node.ChildCount()); i++ {
|
|
||||||
child := node.Child(i)
|
|
||||||
if child.Type() == "block" {
|
|
||||||
c.walkNode(child, source, sourceLines, filePath, chunk.Name, chunks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return // Don't walk children again
|
|
||||||
|
|
||||||
case "block":
|
|
||||||
// Walk statements in block
|
|
||||||
for i := 0; i < int(node.ChildCount()); i++ {
|
|
||||||
c.walkNode(node.Child(i), source, sourceLines, filePath, parentName, chunks)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk all children
|
|
||||||
for i := 0; i < int(node.ChildCount()); i++ {
|
|
||||||
c.walkNode(node.Child(i), source, sourceLines, filePath, parentName, chunks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractFunction extracts a function definition chunk.
|
|
||||||
func (c *Chunker) extractFunction(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string) *chunking.Chunk {
|
|
||||||
// Find function name
|
|
||||||
var nameNode *sitter.Node
|
|
||||||
for i := 0; i < int(node.ChildCount()); i++ {
|
|
||||||
child := node.Child(i)
|
|
||||||
if child.Type() == "identifier" {
|
|
||||||
nameNode = child
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if nameNode == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
name := nameNode.Content(source)
|
|
||||||
|
|
||||||
// Skip private functions if configured
|
|
||||||
if !c.options.IncludePrivate && strings.HasPrefix(name, "_") && !strings.HasPrefix(name, "__") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
startLine := int(node.StartPoint().Row) + 1
|
|
||||||
endLine := int(node.EndPoint().Row) + 1
|
|
||||||
|
|
||||||
chunk := &chunking.Chunk{
|
|
||||||
FilePath: filePath,
|
|
||||||
Language: chunking.LanguagePython,
|
|
||||||
Name: name,
|
|
||||||
ParentName: parentName,
|
|
||||||
StartLine: startLine,
|
|
||||||
EndLine: endLine,
|
|
||||||
Content: c.extractLines(sourceLines, startLine, endLine),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if this is a method or function
|
|
||||||
if parentName != "" {
|
|
||||||
chunk.Type = chunking.ChunkTypeMethod
|
|
||||||
} else {
|
|
||||||
chunk.Type = chunking.ChunkTypeFunction
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract signature (def line)
|
|
||||||
chunk.Signature = c.extractFunctionSignature(node, source, sourceLines)
|
|
||||||
|
|
||||||
// Extract docstring as doc comment
|
|
||||||
if c.options.IncludeDocComments {
|
|
||||||
chunk.DocComment = c.extractDocstring(node, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunk
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractClass extracts a class definition chunk.
|
|
||||||
func (c *Chunker) extractClass(node *sitter.Node, source []byte, sourceLines []string, filePath string) *chunking.Chunk {
|
|
||||||
// Find class name
|
|
||||||
var nameNode *sitter.Node
|
|
||||||
for i := 0; i < int(node.ChildCount()); i++ {
|
|
||||||
child := node.Child(i)
|
|
||||||
if child.Type() == "identifier" {
|
|
||||||
nameNode = child
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if nameNode == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
name := nameNode.Content(source)
|
|
||||||
|
|
||||||
// Skip private classes if configured
|
|
||||||
if !c.options.IncludePrivate && strings.HasPrefix(name, "_") && !strings.HasPrefix(name, "__") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
startLine := int(node.StartPoint().Row) + 1
|
|
||||||
endLine := int(node.EndPoint().Row) + 1
|
|
||||||
|
|
||||||
chunk := &chunking.Chunk{
|
|
||||||
FilePath: filePath,
|
|
||||||
Language: chunking.LanguagePython,
|
|
||||||
Type: chunking.ChunkTypeClass,
|
|
||||||
Name: name,
|
|
||||||
StartLine: startLine,
|
|
||||||
EndLine: endLine,
|
|
||||||
Content: c.extractLines(sourceLines, startLine, endLine),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract class signature (class line)
|
|
||||||
chunk.Signature = c.extractClassSignature(node, source, sourceLines)
|
|
||||||
|
|
||||||
// Extract docstring as doc comment
|
|
||||||
if c.options.IncludeDocComments {
|
|
||||||
chunk.DocComment = c.extractDocstring(node, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunk
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractFunctionSignature extracts the function definition line.
|
|
||||||
func (c *Chunker) extractFunctionSignature(node *sitter.Node, source []byte, sourceLines []string) string {
|
|
||||||
startLine := int(node.StartPoint().Row) + 1
|
|
||||||
|
|
||||||
// Find the colon that ends the signature
|
|
||||||
for i := 0; i < int(node.ChildCount()); i++ {
|
|
||||||
child := node.Child(i)
|
|
||||||
if child.Type() == ":" {
|
|
||||||
endLine := int(child.EndPoint().Row) + 1
|
|
||||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, endLine))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: just return first line
|
|
||||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, startLine))
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractClassSignature extracts the class definition line.
|
|
||||||
func (c *Chunker) extractClassSignature(node *sitter.Node, source []byte, sourceLines []string) string {
|
|
||||||
startLine := int(node.StartPoint().Row) + 1
|
|
||||||
|
|
||||||
// Find the colon that ends the signature
|
|
||||||
for i := 0; i < int(node.ChildCount()); i++ {
|
|
||||||
child := node.Child(i)
|
|
||||||
if child.Type() == ":" {
|
|
||||||
endLine := int(child.EndPoint().Row) + 1
|
|
||||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, endLine))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: just return first line
|
|
||||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, startLine))
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractDocstring extracts the docstring from a function or class.
|
|
||||||
func (c *Chunker) extractDocstring(node *sitter.Node, source []byte) string {
|
|
||||||
// Find the block
|
|
||||||
var blockNode *sitter.Node
|
|
||||||
for i := 0; i < int(node.ChildCount()); i++ {
|
|
||||||
child := node.Child(i)
|
|
||||||
if child.Type() == "block" {
|
|
||||||
blockNode = child
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if blockNode == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if first statement in block is a string (docstring)
|
|
||||||
for i := 0; i < int(blockNode.ChildCount()); i++ {
|
|
||||||
child := blockNode.Child(i)
|
|
||||||
if child.Type() == "expression_statement" {
|
|
||||||
// Check if it contains a string
|
|
||||||
for j := 0; j < int(child.ChildCount()); j++ {
|
|
||||||
grandchild := child.Child(j)
|
|
||||||
if grandchild.Type() == "string" {
|
|
||||||
docstring := grandchild.Content(source)
|
|
||||||
// Remove quotes
|
|
||||||
docstring = strings.Trim(docstring, `"'`)
|
|
||||||
return strings.TrimSpace(docstring)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractLines extracts a range of lines from source (1-indexed, inclusive).
|
|
||||||
func (c *Chunker) extractLines(lines []string, start, end int) string {
|
|
||||||
if start < 1 || end < start || start > len(lines) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
startIdx := start - 1
|
|
||||||
endIdx := end
|
|
||||||
if endIdx > len(lines) {
|
|
||||||
endIdx = len(lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(lines[startIdx:endIdx], "\n")
|
|
||||||
}
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
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"])
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
// Package chunking provides AST-aware code chunking for semantic code search.
|
|
||||||
// Chunks code files into logical units (functions, classes, methods) that preserve
|
|
||||||
// semantic boundaries for better vector embedding and retrieval.
|
|
||||||
package chunking
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ChunkType represents the type of code chunk.
|
|
||||||
type ChunkType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// ChunkTypeFunction represents a standalone function.
|
|
||||||
ChunkTypeFunction ChunkType = "function"
|
|
||||||
// ChunkTypeMethod represents a method on a class/struct/type.
|
|
||||||
ChunkTypeMethod ChunkType = "method"
|
|
||||||
// ChunkTypeClass represents a class or struct definition.
|
|
||||||
ChunkTypeClass ChunkType = "class"
|
|
||||||
// ChunkTypeInterface represents an interface definition.
|
|
||||||
ChunkTypeInterface ChunkType = "interface"
|
|
||||||
// ChunkTypeType represents a type alias or type definition.
|
|
||||||
ChunkTypeType ChunkType = "type"
|
|
||||||
// ChunkTypeConst represents constant declarations.
|
|
||||||
ChunkTypeConst ChunkType = "const"
|
|
||||||
// ChunkTypeVar represents variable declarations.
|
|
||||||
ChunkTypeVar ChunkType = "var"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Language represents a programming language.
|
|
||||||
type Language string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// LanguageGo represents the Go programming language.
|
|
||||||
LanguageGo Language = "go"
|
|
||||||
// LanguagePython represents the Python programming language.
|
|
||||||
LanguagePython Language = "python"
|
|
||||||
// LanguageTypeScript represents the TypeScript programming language.
|
|
||||||
LanguageTypeScript Language = "typescript"
|
|
||||||
// LanguageJavaScript represents the JavaScript programming language.
|
|
||||||
LanguageJavaScript Language = "javascript"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Chunk represents a semantic code chunk with AST-derived boundaries.
|
|
||||||
type Chunk struct {
|
|
||||||
Metadata map[string]interface{}
|
|
||||||
FilePath string
|
|
||||||
Language Language
|
|
||||||
Type ChunkType
|
|
||||||
Name string
|
|
||||||
ParentName string
|
|
||||||
Content string
|
|
||||||
Signature string
|
|
||||||
DocComment string
|
|
||||||
StartLine int
|
|
||||||
EndLine int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Identifier returns a human-readable identifier for this chunk.
|
|
||||||
// Format: "ParentName.Name" for methods, "Name" for top-level.
|
|
||||||
func (c *Chunk) Identifier() string {
|
|
||||||
if c.ParentName != "" {
|
|
||||||
return fmt.Sprintf("%s.%s", c.ParentName, c.Name)
|
|
||||||
}
|
|
||||||
return c.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// LineRange returns a human-readable line range.
|
|
||||||
// Format: "L123-L456"
|
|
||||||
func (c *Chunk) LineRange() string {
|
|
||||||
return fmt.Sprintf("L%d-L%d", c.StartLine, c.EndLine)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchableContent returns content optimized for semantic search.
|
|
||||||
// Combines signature, doc comment, and content in a structured format.
|
|
||||||
func (c *Chunk) SearchableContent() string {
|
|
||||||
var parts []string
|
|
||||||
|
|
||||||
// Include signature for functions/methods
|
|
||||||
if c.Signature != "" {
|
|
||||||
parts = append(parts, c.Signature)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include doc comment
|
|
||||||
if c.DocComment != "" {
|
|
||||||
parts = append(parts, c.DocComment)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include actual content
|
|
||||||
if c.Content != "" {
|
|
||||||
parts = append(parts, c.Content)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(parts, "\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chunker is the interface for language-specific code chunkers.
|
|
||||||
type Chunker interface {
|
|
||||||
// Chunk parses a source file and returns semantic code chunks.
|
|
||||||
// Returns an error if the file cannot be parsed or read.
|
|
||||||
Chunk(ctx context.Context, filePath string) ([]Chunk, error)
|
|
||||||
|
|
||||||
// Language returns the language this chunker supports.
|
|
||||||
Language() Language
|
|
||||||
|
|
||||||
// SupportedExtensions returns file extensions this chunker handles.
|
|
||||||
// Example: []string{".go"} for Go chunker
|
|
||||||
SupportedExtensions() []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChunkOptions provides options for chunking behavior.
|
|
||||||
type ChunkOptions struct {
|
|
||||||
// MaxChunkSize is the maximum size of a chunk in bytes.
|
|
||||||
// Chunks larger than this will be split (respecting boundaries where possible).
|
|
||||||
// 0 means no limit.
|
|
||||||
MaxChunkSize int
|
|
||||||
|
|
||||||
// IncludeDocComments controls whether to include documentation comments.
|
|
||||||
IncludeDocComments bool
|
|
||||||
|
|
||||||
// IncludePrivate controls whether to include private/unexported symbols.
|
|
||||||
IncludePrivate bool
|
|
||||||
|
|
||||||
// MinLines is the minimum number of lines for a chunk to be included.
|
|
||||||
// Chunks smaller than this will be skipped.
|
|
||||||
// 0 means no minimum.
|
|
||||||
MinLines int
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultChunkOptions returns sensible default options.
|
|
||||||
func DefaultChunkOptions() ChunkOptions {
|
|
||||||
return ChunkOptions{
|
|
||||||
MaxChunkSize: 8192, // ~8KB per chunk (well under token limit)
|
|
||||||
IncludeDocComments: true,
|
|
||||||
IncludePrivate: true, // Include all symbols for comprehensive search
|
|
||||||
MinLines: 0, // No minimum - include even single-line functions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
// Package typescript provides AST-aware chunking for TypeScript and JavaScript source files using tree-sitter.
|
|
||||||
package typescript
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
sitter "github.com/smacker/go-tree-sitter"
|
|
||||||
"github.com/smacker/go-tree-sitter/typescript/typescript"
|
|
||||||
|
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Chunker implements AST-aware chunking for TypeScript/JavaScript files.
|
|
||||||
type Chunker struct {
|
|
||||||
parser *sitter.Parser
|
|
||||||
options chunking.ChunkOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewChunker creates a new TypeScript chunker.
|
|
||||||
func NewChunker(options chunking.ChunkOptions) *Chunker {
|
|
||||||
parser := sitter.NewParser()
|
|
||||||
parser.SetLanguage(typescript.GetLanguage())
|
|
||||||
|
|
||||||
return &Chunker{
|
|
||||||
options: options,
|
|
||||||
parser: parser,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Language returns the language this chunker supports.
|
|
||||||
func (c *Chunker) Language() chunking.Language {
|
|
||||||
return chunking.LanguageTypeScript
|
|
||||||
}
|
|
||||||
|
|
||||||
// SupportedExtensions returns the file extensions this chunker handles.
|
|
||||||
func (c *Chunker) SupportedExtensions() []string {
|
|
||||||
return []string{".ts", ".tsx", ".js", ".jsx"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chunk parses a TypeScript/JavaScript source file and returns semantic code chunks.
|
|
||||||
func (c *Chunker) Chunk(ctx context.Context, filePath string) ([]chunking.Chunk, error) {
|
|
||||||
// Read file content
|
|
||||||
content, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the file
|
|
||||||
tree, err := c.parser.ParseCtx(ctx, nil, content)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse TypeScript file: %w", err)
|
|
||||||
}
|
|
||||||
defer tree.Close()
|
|
||||||
|
|
||||||
sourceLines := strings.Split(string(content), "\n")
|
|
||||||
chunks := make([]chunking.Chunk, 0)
|
|
||||||
|
|
||||||
// Walk the AST and extract chunks
|
|
||||||
c.walkNode(tree.RootNode(), content, sourceLines, filePath, "", &chunks)
|
|
||||||
|
|
||||||
return chunks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// walkNode recursively walks the tree-sitter AST and extracts chunks.
|
|
||||||
func (c *Chunker) walkNode(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string, chunks *[]chunking.Chunk) {
|
|
||||||
nodeType := node.Type()
|
|
||||||
|
|
||||||
switch nodeType {
|
|
||||||
case "function_declaration":
|
|
||||||
chunk := c.extractFunction(node, source, sourceLines, filePath, parentName)
|
|
||||||
if chunk != nil {
|
|
||||||
*chunks = append(*chunks, *chunk)
|
|
||||||
}
|
|
||||||
|
|
||||||
case "method_definition":
|
|
||||||
chunk := c.extractMethod(node, source, sourceLines, filePath, parentName)
|
|
||||||
if chunk != nil {
|
|
||||||
*chunks = append(*chunks, *chunk)
|
|
||||||
}
|
|
||||||
|
|
||||||
case "arrow_function", "function_expression":
|
|
||||||
// Handle arrow functions and function expressions assigned to variables
|
|
||||||
chunk := c.extractFunctionExpression(node, source, sourceLines, filePath, parentName)
|
|
||||||
if chunk != nil {
|
|
||||||
*chunks = append(*chunks, *chunk)
|
|
||||||
}
|
|
||||||
|
|
||||||
case "class_declaration":
|
|
||||||
chunk := c.extractClass(node, source, sourceLines, filePath)
|
|
||||||
if chunk != nil {
|
|
||||||
*chunks = append(*chunks, *chunk)
|
|
||||||
|
|
||||||
// Walk class body to find methods
|
|
||||||
for i := 0; i < int(node.ChildCount()); i++ {
|
|
||||||
child := node.Child(i)
|
|
||||||
if child.Type() == "class_body" {
|
|
||||||
c.walkNode(child, source, sourceLines, filePath, chunk.Name, chunks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return // Don't walk children again
|
|
||||||
|
|
||||||
case "interface_declaration":
|
|
||||||
chunk := c.extractInterface(node, source, sourceLines, filePath)
|
|
||||||
if chunk != nil {
|
|
||||||
*chunks = append(*chunks, *chunk)
|
|
||||||
}
|
|
||||||
|
|
||||||
case "type_alias_declaration":
|
|
||||||
chunk := c.extractTypeAlias(node, source, sourceLines, filePath)
|
|
||||||
if chunk != nil {
|
|
||||||
*chunks = append(*chunks, *chunk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk all children
|
|
||||||
for i := 0; i < int(node.ChildCount()); i++ {
|
|
||||||
c.walkNode(node.Child(i), source, sourceLines, filePath, parentName, chunks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractFunction extracts a function declaration.
|
|
||||||
func (c *Chunker) extractFunction(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string) *chunking.Chunk {
|
|
||||||
name := c.findChildContent(node, "identifier", source)
|
|
||||||
if name == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
startLine := int(node.StartPoint().Row) + 1
|
|
||||||
endLine := int(node.EndPoint().Row) + 1
|
|
||||||
|
|
||||||
chunk := &chunking.Chunk{
|
|
||||||
FilePath: filePath,
|
|
||||||
Language: chunking.LanguageTypeScript,
|
|
||||||
Type: chunking.ChunkTypeFunction,
|
|
||||||
Name: name,
|
|
||||||
ParentName: parentName,
|
|
||||||
StartLine: startLine,
|
|
||||||
EndLine: endLine,
|
|
||||||
Content: c.extractLines(sourceLines, startLine, endLine),
|
|
||||||
Signature: c.extractFunctionSignature(node, source, sourceLines),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract JSDoc comment
|
|
||||||
if c.options.IncludeDocComments {
|
|
||||||
chunk.DocComment = c.extractComment(node, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunk
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractMethod extracts a method definition from a class.
|
|
||||||
func (c *Chunker) extractMethod(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string) *chunking.Chunk {
|
|
||||||
name := c.findChildContent(node, "property_identifier", source)
|
|
||||||
if name == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip private methods if configured
|
|
||||||
if !c.options.IncludePrivate && strings.HasPrefix(name, "_") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
startLine := int(node.StartPoint().Row) + 1
|
|
||||||
endLine := int(node.EndPoint().Row) + 1
|
|
||||||
|
|
||||||
chunk := &chunking.Chunk{
|
|
||||||
FilePath: filePath,
|
|
||||||
Language: chunking.LanguageTypeScript,
|
|
||||||
Type: chunking.ChunkTypeMethod,
|
|
||||||
Name: name,
|
|
||||||
ParentName: parentName,
|
|
||||||
StartLine: startLine,
|
|
||||||
EndLine: endLine,
|
|
||||||
Content: c.extractLines(sourceLines, startLine, endLine),
|
|
||||||
Signature: c.extractMethodSignature(node, source, sourceLines),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract JSDoc comment
|
|
||||||
if c.options.IncludeDocComments {
|
|
||||||
chunk.DocComment = c.extractComment(node, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunk
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractFunctionExpression extracts arrow functions and function expressions.
|
|
||||||
func (c *Chunker) extractFunctionExpression(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string) *chunking.Chunk {
|
|
||||||
// Try to find the variable name from parent
|
|
||||||
parent := node.Parent()
|
|
||||||
if parent == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var name string
|
|
||||||
if parent.Type() == "variable_declarator" {
|
|
||||||
name = c.findChildContent(parent, "identifier", source)
|
|
||||||
} else if parent.Type() == "assignment_expression" {
|
|
||||||
// Handle const foo = () => {}
|
|
||||||
for i := 0; i < int(parent.ChildCount()); i++ {
|
|
||||||
child := parent.Child(i)
|
|
||||||
if child.Type() == "identifier" || child.Type() == "member_expression" {
|
|
||||||
name = child.Content(source)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if name == "" {
|
|
||||||
return nil // Anonymous function, skip
|
|
||||||
}
|
|
||||||
|
|
||||||
startLine := int(node.StartPoint().Row) + 1
|
|
||||||
endLine := int(node.EndPoint().Row) + 1
|
|
||||||
|
|
||||||
chunk := &chunking.Chunk{
|
|
||||||
FilePath: filePath,
|
|
||||||
Language: chunking.LanguageTypeScript,
|
|
||||||
Type: chunking.ChunkTypeFunction,
|
|
||||||
Name: name,
|
|
||||||
ParentName: parentName,
|
|
||||||
StartLine: startLine,
|
|
||||||
EndLine: endLine,
|
|
||||||
Content: c.extractLines(sourceLines, startLine, endLine),
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunk
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractClass extracts a class declaration.
|
|
||||||
func (c *Chunker) extractClass(node *sitter.Node, source []byte, sourceLines []string, filePath string) *chunking.Chunk {
|
|
||||||
name := c.findChildContent(node, "type_identifier", source)
|
|
||||||
if name == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
startLine := int(node.StartPoint().Row) + 1
|
|
||||||
endLine := int(node.EndPoint().Row) + 1
|
|
||||||
|
|
||||||
chunk := &chunking.Chunk{
|
|
||||||
FilePath: filePath,
|
|
||||||
Language: chunking.LanguageTypeScript,
|
|
||||||
Type: chunking.ChunkTypeClass,
|
|
||||||
Name: name,
|
|
||||||
StartLine: startLine,
|
|
||||||
EndLine: endLine,
|
|
||||||
Content: c.extractLines(sourceLines, startLine, endLine),
|
|
||||||
Signature: c.extractClassSignature(node, source, sourceLines),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract JSDoc comment
|
|
||||||
if c.options.IncludeDocComments {
|
|
||||||
chunk.DocComment = c.extractComment(node, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunk
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractInterface extracts an interface declaration.
|
|
||||||
func (c *Chunker) extractInterface(node *sitter.Node, source []byte, sourceLines []string, filePath string) *chunking.Chunk {
|
|
||||||
name := c.findChildContent(node, "type_identifier", source)
|
|
||||||
if name == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
startLine := int(node.StartPoint().Row) + 1
|
|
||||||
endLine := int(node.EndPoint().Row) + 1
|
|
||||||
|
|
||||||
chunk := &chunking.Chunk{
|
|
||||||
FilePath: filePath,
|
|
||||||
Language: chunking.LanguageTypeScript,
|
|
||||||
Type: chunking.ChunkTypeInterface,
|
|
||||||
Name: name,
|
|
||||||
StartLine: startLine,
|
|
||||||
EndLine: endLine,
|
|
||||||
Content: c.extractLines(sourceLines, startLine, endLine),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract JSDoc comment
|
|
||||||
if c.options.IncludeDocComments {
|
|
||||||
chunk.DocComment = c.extractComment(node, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunk
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractTypeAlias extracts a type alias declaration.
|
|
||||||
func (c *Chunker) extractTypeAlias(node *sitter.Node, source []byte, sourceLines []string, filePath string) *chunking.Chunk {
|
|
||||||
name := c.findChildContent(node, "type_identifier", source)
|
|
||||||
if name == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
startLine := int(node.StartPoint().Row) + 1
|
|
||||||
endLine := int(node.EndPoint().Row) + 1
|
|
||||||
|
|
||||||
chunk := &chunking.Chunk{
|
|
||||||
FilePath: filePath,
|
|
||||||
Language: chunking.LanguageTypeScript,
|
|
||||||
Type: chunking.ChunkTypeType,
|
|
||||||
Name: name,
|
|
||||||
StartLine: startLine,
|
|
||||||
EndLine: endLine,
|
|
||||||
Content: c.extractLines(sourceLines, startLine, endLine),
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunk
|
|
||||||
}
|
|
||||||
|
|
||||||
// findChildContent finds the first child of the given type and returns its content.
|
|
||||||
func (c *Chunker) findChildContent(node *sitter.Node, childType string, source []byte) string {
|
|
||||||
for i := 0; i < int(node.ChildCount()); i++ {
|
|
||||||
child := node.Child(i)
|
|
||||||
if child.Type() == childType {
|
|
||||||
return child.Content(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractFunctionSignature extracts the function signature.
|
|
||||||
func (c *Chunker) extractFunctionSignature(node *sitter.Node, source []byte, sourceLines []string) string {
|
|
||||||
startLine := int(node.StartPoint().Row) + 1
|
|
||||||
|
|
||||||
// Find the opening brace of the body
|
|
||||||
for i := 0; i < int(node.ChildCount()); i++ {
|
|
||||||
child := node.Child(i)
|
|
||||||
if child.Type() == "statement_block" {
|
|
||||||
endLine := int(child.StartPoint().Row) + 1
|
|
||||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, endLine-1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: just return first line
|
|
||||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, startLine))
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractMethodSignature extracts the method signature.
|
|
||||||
func (c *Chunker) extractMethodSignature(node *sitter.Node, source []byte, sourceLines []string) string {
|
|
||||||
startLine := int(node.StartPoint().Row) + 1
|
|
||||||
|
|
||||||
// Find the opening brace of the body
|
|
||||||
for i := 0; i < int(node.ChildCount()); i++ {
|
|
||||||
child := node.Child(i)
|
|
||||||
if child.Type() == "statement_block" {
|
|
||||||
endLine := int(child.StartPoint().Row) + 1
|
|
||||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, endLine-1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, startLine))
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractClassSignature extracts the class declaration line.
|
|
||||||
func (c *Chunker) extractClassSignature(node *sitter.Node, source []byte, sourceLines []string) string {
|
|
||||||
startLine := int(node.StartPoint().Row) + 1
|
|
||||||
|
|
||||||
// Find the opening brace of the class body
|
|
||||||
for i := 0; i < int(node.ChildCount()); i++ {
|
|
||||||
child := node.Child(i)
|
|
||||||
if child.Type() == "class_body" {
|
|
||||||
endLine := int(child.StartPoint().Row) + 1
|
|
||||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, endLine-1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, startLine))
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractComment extracts JSDoc or other comments from a node.
|
|
||||||
func (c *Chunker) extractComment(node *sitter.Node, source []byte) string {
|
|
||||||
// Check previous sibling for comment
|
|
||||||
prevSibling := node.PrevSibling()
|
|
||||||
if prevSibling != nil && prevSibling.Type() == "comment" {
|
|
||||||
comment := prevSibling.Content(source)
|
|
||||||
// Remove comment markers
|
|
||||||
comment = strings.TrimPrefix(comment, "/**")
|
|
||||||
comment = strings.TrimPrefix(comment, "/*")
|
|
||||||
comment = strings.TrimSuffix(comment, "*/")
|
|
||||||
comment = strings.TrimPrefix(comment, "//")
|
|
||||||
return strings.TrimSpace(comment)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractLines extracts a range of lines from source (1-indexed, inclusive).
|
|
||||||
func (c *Chunker) extractLines(lines []string, start, end int) string {
|
|
||||||
if start < 1 || end < start || start > len(lines) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
startIdx := start - 1
|
|
||||||
endIdx := end
|
|
||||||
if endIdx > len(lines) {
|
|
||||||
endIdx = len(lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(lines[startIdx:endIdx], "\n")
|
|
||||||
}
|
|
||||||
@@ -1,398 +0,0 @@
|
|||||||
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
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -38,40 +39,46 @@ var CriticalConcepts = []string{
|
|||||||
// Config holds the application configuration.
|
// Config holds the application configuration.
|
||||||
// Field order optimized for memory alignment (fieldalignment).
|
// Field order optimized for memory alignment (fieldalignment).
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ContextFullField string `json:"context_full_field"`
|
ContextFullField string `json:"context_full_field"`
|
||||||
DBPath string `json:"db_path"`
|
DBPath string `json:"db_path"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
ClaudeCodePath string `json:"claude_code_path"`
|
ClaudeCodePath string `json:"claude_code_path"`
|
||||||
EmbeddingModel string `json:"embedding_model"`
|
EmbeddingModel string `json:"embedding_model"`
|
||||||
VectorStorageStrategy string `json:"vector_storage_strategy"`
|
VectorStorageStrategy string `json:"vector_storage_strategy"`
|
||||||
ContextObsConcepts []string `json:"context_obs_concepts"`
|
ContextObsConcepts []string `json:"context_obs_concepts"`
|
||||||
ContextObsTypes []string `json:"context_obs_types"`
|
ContextObsTypes []string `json:"context_obs_types"`
|
||||||
ContextFullCount int `json:"context_full_count"`
|
ContextFullCount int `json:"context_full_count"`
|
||||||
GraphBranchFactor int `json:"graph_branch_factor"`
|
GraphBranchFactor int `json:"graph_branch_factor"`
|
||||||
GraphEdgeWeight float64 `json:"graph_edge_weight"`
|
GraphEdgeWeight float64 `json:"graph_edge_weight"`
|
||||||
ContextRelevanceThreshold float64 `json:"context_relevance_threshold"`
|
ContextRelevanceThreshold float64 `json:"context_relevance_threshold"`
|
||||||
RerankingCandidates int `json:"reranking_candidates"`
|
RerankingCandidates int `json:"reranking_candidates"`
|
||||||
WorkerPort int `json:"worker_port"`
|
WorkerPort int `json:"worker_port"`
|
||||||
RerankingMinImprovement float64 `json:"reranking_min_improvement"`
|
DeduplicationThreshold float64 `json:"deduplication_threshold"`
|
||||||
ContextObservations int `json:"context_observations"`
|
RerankingMinImprovement float64 `json:"reranking_min_improvement"`
|
||||||
ContextMaxPromptResults int `json:"context_max_prompt_results"`
|
ContextObservations int `json:"context_observations"`
|
||||||
ContextSessionCount int `json:"context_session_count"`
|
ContextMaxPromptResults int `json:"context_max_prompt_results"`
|
||||||
MaxConns int `json:"max_conns"`
|
ContextSessionCount int `json:"context_session_count"`
|
||||||
RerankingAlpha float64 `json:"reranking_alpha"`
|
MaxConns int `json:"max_conns"`
|
||||||
GraphMaxHops int `json:"graph_max_hops"`
|
RerankingAlpha float64 `json:"reranking_alpha"`
|
||||||
RerankingResults int `json:"reranking_results"`
|
GraphMaxHops int `json:"graph_max_hops"`
|
||||||
GraphRebuildIntervalMin int `json:"graph_rebuild_interval_min"`
|
RerankingResults int `json:"reranking_results"`
|
||||||
HubThreshold int `json:"hub_threshold"`
|
GraphRebuildIntervalMin int `json:"graph_rebuild_interval_min"`
|
||||||
ObservationRetentionDays int `json:"observation_retention_days"`
|
HubThreshold int `json:"hub_threshold"`
|
||||||
MaintenanceIntervalHours int `json:"maintenance_interval_hours"`
|
ObservationRetentionDays int `json:"observation_retention_days"`
|
||||||
ContextShowWorkTokens bool `json:"context_show_work_tokens"`
|
MaintenanceIntervalHours int `json:"maintenance_interval_hours"`
|
||||||
ContextShowReadTokens bool `json:"context_show_read_tokens"`
|
WALCheckpointIntervalSeconds int `json:"wal_checkpoint_interval_seconds"`
|
||||||
RerankingPureMode bool `json:"reranking_pure_mode"`
|
WALCheckpointThresholdBytes int64 `json:"wal_checkpoint_threshold_bytes"`
|
||||||
GraphEnabled bool `json:"graph_enabled"`
|
ContextMaxTokensStartup int `json:"context_max_tokens_startup"`
|
||||||
MaintenanceEnabled bool `json:"maintenance_enabled"`
|
ContextMaxTokensPrompt int `json:"context_max_tokens_prompt"`
|
||||||
RerankingEnabled bool `json:"reranking_enabled"`
|
ContextShowWorkTokens bool `json:"context_show_work_tokens"`
|
||||||
ContextShowLastSummary bool `json:"context_show_last_summary"`
|
ContextShowReadTokens bool `json:"context_show_read_tokens"`
|
||||||
CleanupStaleObservations bool `json:"cleanup_stale_observations"`
|
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 (
|
var (
|
||||||
@@ -168,10 +175,18 @@ func Default() *Config {
|
|||||||
ContextObsConcepts: DefaultObservationConcepts,
|
ContextObsConcepts: DefaultObservationConcepts,
|
||||||
ContextRelevanceThreshold: 0.3, // Minimum 30% similarity to include
|
ContextRelevanceThreshold: 0.3, // Minimum 30% similarity to include
|
||||||
ContextMaxPromptResults: 10, // Cap at 10 results max (0 = no cap, threshold only)
|
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
|
MaintenanceEnabled: true, // Enable scheduled maintenance
|
||||||
MaintenanceIntervalHours: 6, // Run every 6 hours
|
MaintenanceIntervalHours: 6, // Run every 6 hours
|
||||||
ObservationRetentionDays: 0, // 0 = no age-based deletion (keep all)
|
ObservationRetentionDays: 0, // 0 = no age-based deletion (keep all)
|
||||||
CleanupStaleObservations: false, // Don't auto-cleanup stale observations
|
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 {
|
if v, ok := settings["CLAUDE_MNEMONIC_HUB_THRESHOLD"].(float64); ok && v > 0 {
|
||||||
cfg.HubThreshold = int(v)
|
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
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,9 +252,6 @@ func (s *ObservationStore) UpdateObservation(ctx context.Context, id int64, upda
|
|||||||
updates["scope"] = sql.NullString{String: *update.Scope, Valid: true}
|
updates["scope"] = sql.NullString{String: *update.Scope, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add updated_at timestamp
|
|
||||||
updates["updated_at_epoch"] = sql.NullInt64{Int64: time.Now().Unix(), Valid: true}
|
|
||||||
|
|
||||||
if len(updates) == 0 {
|
if len(updates) == 0 {
|
||||||
// Nothing to update, just return existing observation
|
// Nothing to update, just return existing observation
|
||||||
return toModelObservation(&dbObs), nil
|
return toModelObservation(&dbObs), nil
|
||||||
@@ -618,7 +615,7 @@ func (s *ObservationStore) SearchObservationsFTS(ctx context.Context, query, pro
|
|||||||
// FTS failed, try LIKE fallback
|
// FTS failed, try LIKE fallback
|
||||||
return s.searchObservationsLike(ctx, keywords, project, limit)
|
return s.searchObservationsLike(ctx, keywords, project, limit)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
observations, err := scanObservationRows(rows)
|
observations, err := scanObservationRows(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -500,15 +500,13 @@ func TestObservationStore_CleanupOldObservations(t *testing.T) {
|
|||||||
|
|
||||||
// Store observations beyond the limit WITHOUT async cleanup
|
// Store observations beyond the limit WITHOUT async cleanup
|
||||||
// We disable async cleanup by not setting cleanupFunc
|
// We disable async cleanup by not setting cleanupFunc
|
||||||
var allIDs []int64
|
|
||||||
for i := 0; i < 105; i++ {
|
for i := 0; i < 105; i++ {
|
||||||
observation := &models.ParsedObservation{
|
observation := &models.ParsedObservation{
|
||||||
Type: models.ObsTypeDiscovery,
|
Type: models.ObsTypeDiscovery,
|
||||||
Title: "Observation",
|
Title: "Observation",
|
||||||
}
|
}
|
||||||
id, _, err := observationStore.StoreObservation(ctx, "claude-1", "test-project", observation, i, 10)
|
_, _, err := observationStore.StoreObservation(ctx, "claude-1", "test-project", observation, i, 10)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
allIDs = append(allIDs, id)
|
|
||||||
time.Sleep(2 * time.Millisecond) // Ensure different timestamps
|
time.Sleep(2 * time.Millisecond) // Ensure different timestamps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
//go:build fts5
|
||||||
|
|
||||||
|
package gorm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func storeForUpdate(t *testing.T) (*ObservationStore, int64, func()) {
|
||||||
|
t.Helper()
|
||||||
|
obsStore, store, cleanup := testObservationStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
sessionID, err := NewSessionStore(store).CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
id, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", &models.ParsedObservation{
|
||||||
|
Type: models.ObsTypeDiscovery, Title: "Original title", Subtitle: "Original subtitle", Narrative: "Original narrative",
|
||||||
|
Facts: []string{"fact one"}, Concepts: []string{"cache"}, FilesRead: []string{"a.go"},
|
||||||
|
}, int(sessionID), 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return obsStore, id, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|
||||||
|
func TestUpdateObservation_ChangesOnlyTheGivenFields(t *testing.T) {
|
||||||
|
obsStore, id, cleanup := storeForUpdate(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
got, err := obsStore.UpdateObservation(ctx, id, &ObservationUpdate{Title: ptr("New title"), Narrative: ptr("New narrative")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "New title", got.Title.String)
|
||||||
|
assert.Equal(t, "New narrative", got.Narrative.String)
|
||||||
|
assert.Equal(t, "Original subtitle", got.Subtitle.String, "a field that was not given stays")
|
||||||
|
assert.Equal(t, []string{"fact one"}, []string(got.Facts))
|
||||||
|
|
||||||
|
stored, err := obsStore.GetObservationByID(ctx, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "New title", stored.Title.String, "and it is stored, not only returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateObservation_ReplacesListsAndScope(t *testing.T) {
|
||||||
|
obsStore, id, cleanup := storeForUpdate(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
got, err := obsStore.UpdateObservation(ctx, id, &ObservationUpdate{
|
||||||
|
Facts: &[]string{"a", "b"}, Concepts: &[]string{}, FilesRead: &[]string{"x.go"}, FilesModified: &[]string{"y.go"}, Scope: ptr("global"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"a", "b"}, []string(got.Facts))
|
||||||
|
assert.Empty(t, got.Concepts, "an empty list clears the field")
|
||||||
|
assert.Equal(t, []string{"x.go"}, []string(got.FilesRead))
|
||||||
|
assert.Equal(t, []string{"y.go"}, []string(got.FilesModified))
|
||||||
|
assert.Equal(t, models.ScopeGlobal, got.Scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateObservation_NothingToChangeReturnsTheObservation(t *testing.T) {
|
||||||
|
obsStore, id, cleanup := storeForUpdate(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
got, err := obsStore.UpdateObservation(context.Background(), id, &ObservationUpdate{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "Original title", got.Title.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateObservation_Refusals(t *testing.T) {
|
||||||
|
obsStore, id, cleanup := storeForUpdate(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := obsStore.UpdateObservation(ctx, id, nil)
|
||||||
|
assert.Error(t, err, "a nil update")
|
||||||
|
_, err = obsStore.UpdateObservation(ctx, 999999, &ObservationUpdate{Title: ptr("x")})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateObservation_KeepsTheSearchIndexInStep(t *testing.T) {
|
||||||
|
obsStore, id, cleanup := storeForUpdate(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := obsStore.UpdateObservation(ctx, id, &ObservationUpdate{Title: ptr("Zebrafish enclosure")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
var n int64
|
||||||
|
require.NoError(t, obsStore.db.WithContext(ctx).Raw(
|
||||||
|
`SELECT COUNT(*) FROM observations_fts WHERE observations_fts MATCH 'zebrafish'`).Scan(&n).Error)
|
||||||
|
assert.EqualValues(t, 1, n, "the full-text index sees the new title")
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ package gorm
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -18,6 +20,7 @@ type PatternCleanupFunc func(ctx context.Context, deletedIDs []int64)
|
|||||||
type PatternStore struct {
|
type PatternStore struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
cleanupFunc PatternCleanupFunc
|
cleanupFunc PatternCleanupFunc
|
||||||
|
cleanupMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPatternStore creates a new pattern store.
|
// NewPatternStore creates a new pattern store.
|
||||||
@@ -29,6 +32,8 @@ func NewPatternStore(store *Store) *PatternStore {
|
|||||||
|
|
||||||
// SetCleanupFunc sets the callback for when patterns are deleted.
|
// SetCleanupFunc sets the callback for when patterns are deleted.
|
||||||
func (s *PatternStore) SetCleanupFunc(fn PatternCleanupFunc) {
|
func (s *PatternStore) SetCleanupFunc(fn PatternCleanupFunc) {
|
||||||
|
s.cleanupMu.Lock()
|
||||||
|
defer s.cleanupMu.Unlock()
|
||||||
s.cleanupFunc = fn
|
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.
|
// MergePatterns merges a source pattern into a target pattern.
|
||||||
func (s *PatternStore) MergePatterns(ctx context.Context, sourceID, targetID int64) error {
|
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
|
// Get both patterns
|
||||||
source, err := s.GetPatternByID(ctx, sourceID)
|
source, err := s.GetPatternByID(ctx, sourceID)
|
||||||
if err != nil {
|
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 {
|
func (s *PatternStore) DeletePattern(ctx context.Context, id int64) error {
|
||||||
result := s.db.WithContext(ctx).Delete(&Pattern{}, id)
|
result := s.db.WithContext(ctx).Delete(&Pattern{}, id)
|
||||||
|
|
||||||
if result.Error == nil && s.cleanupFunc != nil {
|
if result.Error == nil {
|
||||||
s.cleanupFunc(ctx, []int64{id})
|
s.cleanupMu.RLock()
|
||||||
|
fn := s.cleanupFunc
|
||||||
|
s.cleanupMu.RUnlock()
|
||||||
|
if fn != nil {
|
||||||
|
fn(ctx, []int64{id})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.Error
|
return result.Error
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ package gorm
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ const MaxPromptsGlobal = 500
|
|||||||
type PromptStore struct {
|
type PromptStore struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
cleanupFunc PromptCleanupFunc
|
cleanupFunc PromptCleanupFunc
|
||||||
|
cleanupMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPromptStore creates a new prompt store.
|
// 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.
|
// SetCleanupFunc sets the callback for when prompts are deleted during cleanup.
|
||||||
func (s *PromptStore) SetCleanupFunc(fn PromptCleanupFunc) {
|
func (s *PromptStore) SetCleanupFunc(fn PromptCleanupFunc) {
|
||||||
|
s.cleanupMu.Lock()
|
||||||
|
defer s.cleanupMu.Unlock()
|
||||||
s.cleanupFunc = fn
|
s.cleanupFunc = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,9 +86,15 @@ func (s *PromptStore) SaveUserPromptWithMatches(ctx context.Context, claudeSessi
|
|||||||
go func() {
|
go func() {
|
||||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
deletedIDs, _ := s.CleanupOldPrompts(cleanupCtx)
|
if deletedIDs, err := s.CleanupOldPrompts(cleanupCtx); err != nil {
|
||||||
if len(deletedIDs) > 0 && s.cleanupFunc != nil {
|
log.Warn().Err(err).Msg("Background prompt cleanup failed")
|
||||||
s.cleanupFunc(cleanupCtx, deletedIDs)
|
} 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
|
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.
|
// GetRecentUserPromptsByProject retrieves recent user prompts for a specific project.
|
||||||
func (s *PromptStore) GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error) {
|
func (s *PromptStore) GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error) {
|
||||||
var results []struct {
|
var results []struct {
|
||||||
|
|||||||
@@ -333,6 +333,39 @@ func TestPromptStore_FindRecentPromptByText(t *testing.T) {
|
|||||||
assert.False(t, notFound, "Should not find prompt outside time window")
|
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) {
|
func TestPromptStore_GetRecentUserPromptsByProject(t *testing.T) {
|
||||||
promptStore, store, cleanup := testPromptStore(t)
|
promptStore, store, cleanup := testPromptStore(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|||||||
+148
-42
@@ -5,18 +5,80 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
|
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"
|
"github.com/rs/zerolog/log"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"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.
|
// Store represents the GORM database connection with sqlite-vec support.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
healthCacheTime time.Time
|
healthCacheTime time.Time
|
||||||
@@ -24,6 +86,7 @@ type Store struct {
|
|||||||
sqlDB *sql.DB
|
sqlDB *sql.DB
|
||||||
metrics *PoolMetrics
|
metrics *PoolMetrics
|
||||||
cachedHealth *HealthInfo
|
cachedHealth *HealthInfo
|
||||||
|
path string
|
||||||
healthCacheTTL time.Duration
|
healthCacheTTL time.Duration
|
||||||
healthCacheMu sync.RWMutex
|
healthCacheMu sync.RWMutex
|
||||||
}
|
}
|
||||||
@@ -36,22 +99,32 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewStore creates a new Store with WAL mode enabled and sqlite-vec registered.
|
// 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) {
|
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()
|
sqlite_vec.Auto()
|
||||||
|
|
||||||
// 2. Build connection string (foreign keys enabled in DSN)
|
// 2. Register the custom driver whose ConnectHook applies ALL pragmas to EVERY pooled
|
||||||
// Use sqlite3 driver (mattn/go-sqlite3) which has FTS5 support
|
// 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"
|
dsn := cfg.Path + "?_foreign_keys=ON"
|
||||||
|
|
||||||
// 3. Open raw database connection with mattn/go-sqlite3 (has FTS5 support)
|
// 4. Open raw database connection with the custom driver (FTS5 + per-connection pragmas).
|
||||||
sqlDB, err := sql.Open("sqlite3", dsn)
|
sqlDB, err := sql.Open(driverName, dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open database: %w", err)
|
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{
|
db, err := gorm.Open(sqlite.Dialector{
|
||||||
Conn: sqlDB,
|
Conn: sqlDB,
|
||||||
}, &gorm.Config{
|
}, &gorm.Config{
|
||||||
@@ -66,16 +139,25 @@ func NewStore(cfg Config) (*Store, error) {
|
|||||||
return nil, fmt.Errorf("open gorm: %w", err)
|
return nil, fmt.Errorf("open gorm: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Configure connection pool (same settings as current implementation)
|
// 6. Configure connection pool.
|
||||||
maxConns := cfg.MaxConns
|
maxConns := cfg.MaxConns
|
||||||
if maxConns <= 0 {
|
if maxConns <= 0 {
|
||||||
maxConns = 4
|
maxConns = 4
|
||||||
}
|
}
|
||||||
sqlDB.SetMaxOpenConns(maxConns)
|
sqlDB.SetMaxOpenConns(maxConns)
|
||||||
sqlDB.SetMaxIdleConns(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 {
|
if err := sqlDB.Ping(); err != nil {
|
||||||
return nil, fmt.Errorf("ping database: %w", err)
|
return nil, fmt.Errorf("ping database: %w", err)
|
||||||
}
|
}
|
||||||
@@ -83,36 +165,18 @@ func NewStore(cfg Config) (*Store, error) {
|
|||||||
store := &Store{
|
store := &Store{
|
||||||
DB: db,
|
DB: db,
|
||||||
sqlDB: sqlDB,
|
sqlDB: sqlDB,
|
||||||
|
path: cfg.Path,
|
||||||
metrics: NewPoolMetrics(100), // Track last 100 latency samples
|
metrics: NewPoolMetrics(100), // Track last 100 latency samples
|
||||||
healthCacheTTL: 5 * time.Second, // Cache health checks for 5 seconds
|
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 {
|
if err := runMigrations(db, sqlDB); err != nil {
|
||||||
return nil, fmt.Errorf("run migrations: %w", err)
|
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
|
// 9. Warm the connection pool
|
||||||
store.WarmPool(maxConns)
|
store.WarmPool(maxConns)
|
||||||
|
|
||||||
@@ -147,11 +211,55 @@ func (s *Store) WarmPool(numConns int) {
|
|||||||
log.Debug().Int("connections", numConns).Msg("Connection pool warmed")
|
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 {
|
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()
|
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.
|
// Ping verifies the database connection is alive.
|
||||||
func (s *Store) Ping() error {
|
func (s *Store) Ping() error {
|
||||||
return s.sqlDB.Ping()
|
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)")
|
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")
|
log.Info().Dur("duration", time.Since(start)).Msg("Database optimization complete")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -513,14 +627,6 @@ func (s *Store) ExecWithTimeout(ctx context.Context, timeout time.Duration, quer
|
|||||||
return nil
|
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.
|
// TransactionWithTimeout wraps a transaction function with timeout handling.
|
||||||
// The transaction is automatically rolled back if the context times out.
|
// 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 {
|
func (s *Store) TransactionWithTimeout(ctx context.Context, timeout time.Duration, fn func(*gorm.DB) error) error {
|
||||||
|
|||||||
@@ -4,9 +4,13 @@
|
|||||||
package gorm
|
package gorm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
@@ -150,3 +154,409 @@ func TestMigrationIdempotency(t *testing.T) {
|
|||||||
|
|
||||||
t.Logf("✅ Migrations are idempotent")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type PromptReader interface {
|
|||||||
GetAllPrompts(ctx context.Context) ([]*models.UserPromptWithSession, error)
|
GetAllPrompts(ctx context.Context) ([]*models.UserPromptWithSession, error)
|
||||||
GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*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)
|
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.
|
// PromptWriter defines write operations for prompts.
|
||||||
|
|||||||
@@ -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
|
package embedding
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "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
|
//go:embed assets/tokenizer.json
|
||||||
var tokenizerData []byte
|
var tokenizerData []byte
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.23.2
|
1.30.0
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.23.2
|
1.30.0
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.23.2
|
1.30.0
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.23.2
|
1.30.0
|
||||||
|
|||||||
Binary file not shown.
@@ -6,7 +6,7 @@ import (
|
|||||||
_ "embed"
|
_ "embed"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed assets/lib/darwin-arm64/libonnxruntime.dylib
|
//go:embed assets/lib/darwin-arm64/libonnxruntime.dylib.gz
|
||||||
var onnxRuntimeLib []byte
|
var onnxRuntimeLib []byte
|
||||||
|
|
||||||
const onnxRuntimeLibName = "libonnxruntime.dylib"
|
const onnxRuntimeLibName = "libonnxruntime.dylib"
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import (
|
|||||||
_ "embed"
|
_ "embed"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed assets/lib/linux-amd64/libonnxruntime.so
|
//go:embed assets/lib/linux-amd64/libonnxruntime.so.gz
|
||||||
var onnxRuntimeLib []byte
|
var onnxRuntimeLib []byte
|
||||||
|
|
||||||
//go:embed assets/lib/linux-amd64/libonnxruntime_providers_shared.so
|
//go:embed assets/lib/linux-amd64/libonnxruntime_providers_shared.so.gz
|
||||||
var onnxRuntimeProvidersLib []byte
|
var onnxRuntimeProvidersLib []byte
|
||||||
|
|
||||||
const onnxRuntimeLibName = "libonnxruntime.so"
|
const onnxRuntimeLibName = "libonnxruntime.so"
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import (
|
|||||||
_ "embed"
|
_ "embed"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed assets/lib/linux-arm64/libonnxruntime.so
|
//go:embed assets/lib/linux-arm64/libonnxruntime.so.gz
|
||||||
var onnxRuntimeLib []byte
|
var onnxRuntimeLib []byte
|
||||||
|
|
||||||
//go:embed assets/lib/linux-arm64/libonnxruntime_providers_shared.so
|
//go:embed assets/lib/linux-arm64/libonnxruntime_providers_shared.so.gz
|
||||||
var onnxRuntimeProvidersLib []byte
|
var onnxRuntimeProvidersLib []byte
|
||||||
|
|
||||||
const onnxRuntimeLibName = "libonnxruntime.so"
|
const onnxRuntimeLibName = "libonnxruntime.so"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
_ "embed"
|
_ "embed"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed assets/lib/windows-amd64/onnxruntime.dll
|
//go:embed assets/lib/windows-amd64/onnxruntime.dll.gz
|
||||||
var onnxRuntimeLib []byte
|
var onnxRuntimeLib []byte
|
||||||
|
|
||||||
const onnxRuntimeLibName = "onnxruntime.dll"
|
const onnxRuntimeLibName = "onnxruntime.dll"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package embedding
|
package embedding
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -44,6 +45,14 @@ type EmbeddingModel interface {
|
|||||||
// EmbedBatch generates embeddings for multiple texts.
|
// EmbedBatch generates embeddings for multiple texts.
|
||||||
EmbedBatch(texts []string) ([][]float32, error)
|
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 releases model resources.
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
@@ -129,7 +138,25 @@ func (r *ModelRegistry) List() []ModelMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DefaultRegistry is the global model registry with all available models.
|
// DefaultRegistry is the global model registry with all available models.
|
||||||
var DefaultRegistry = NewModelRegistry()
|
// It is constructed eagerly at package load via newDefaultRegistry, which
|
||||||
|
// registers the built-in BGE model — replacing the previous init()-based
|
||||||
|
// mutate-after-init pattern while preserving identical behaviour.
|
||||||
|
var DefaultRegistry = newDefaultRegistry()
|
||||||
|
|
||||||
|
// newDefaultRegistry builds a model registry pre-populated with the built-in
|
||||||
|
// models (currently BGE). Registering at construction time keeps DefaultRegistry
|
||||||
|
// ready-to-use without a separate init() mutating it after package load.
|
||||||
|
func newDefaultRegistry() *ModelRegistry {
|
||||||
|
r := NewModelRegistry()
|
||||||
|
r.Register(ModelMetadata{
|
||||||
|
Name: BGEModelName,
|
||||||
|
Version: BGEModelVersion,
|
||||||
|
Dimensions: EmbeddingDim,
|
||||||
|
Description: "High-quality semantic search model",
|
||||||
|
Default: true,
|
||||||
|
}, newBGEModel)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterModel adds a model to the default registry.
|
// RegisterModel adds a model to the default registry.
|
||||||
func RegisterModel(meta ModelMetadata, factory ModelFactory) {
|
func RegisterModel(meta ModelMetadata, factory ModelFactory) {
|
||||||
|
|||||||
+194
-23
@@ -3,13 +3,19 @@ package embedding
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/internal/models"
|
||||||
"github.com/sugarme/tokenizer"
|
"github.com/sugarme/tokenizer"
|
||||||
"github.com/sugarme/tokenizer/pretrained"
|
"github.com/sugarme/tokenizer/pretrained"
|
||||||
ort "github.com/yalue/onnxruntime_go"
|
ort "github.com/yalue/onnxruntime_go"
|
||||||
@@ -21,12 +27,22 @@ const EmbeddingDim = 384
|
|||||||
|
|
||||||
// Model version constants
|
// Model version constants
|
||||||
const (
|
const (
|
||||||
|
// BGEStorageVersion tags stored vectors; bump it to force a rebuild when vectors change.
|
||||||
|
BGEStorageVersion = "bge-v1.5-l2"
|
||||||
|
|
||||||
// BGEModelVersion is the version string for bge-small-en-v1.5
|
// BGEModelVersion is the version string for bge-small-en-v1.5
|
||||||
BGEModelVersion = "bge-v1.5"
|
BGEModelVersion = "bge-v1.5"
|
||||||
// BGEModelName is the human-readable name for bge-small-en-v1.5
|
// BGEModelName is the human-readable name for bge-small-en-v1.5
|
||||||
BGEModelName = "bge-small-en-v1.5"
|
BGEModelName = "bge-small-en-v1.5"
|
||||||
// DefaultModelVersion is the default model to use
|
// DefaultModelVersion is the default model to use
|
||||||
DefaultModelVersion = BGEModelVersion
|
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.
|
// MaxSequenceLength is the maximum token sequence length for the model.
|
||||||
@@ -58,6 +74,7 @@ var _ EmbeddingModel = (*bgeModel)(nil)
|
|||||||
var _ ONNXConfigurer = (*bgeModel)(nil)
|
var _ ONNXConfigurer = (*bgeModel)(nil)
|
||||||
|
|
||||||
// newBGEModel creates a new BGE embedding model using bundled ONNX runtime and model.
|
// 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) {
|
func newBGEModel() (EmbeddingModel, error) {
|
||||||
// Extract ONNX runtime library to temp directory
|
// Extract ONNX runtime library to temp directory
|
||||||
libDir, err := extractONNXLibrary()
|
libDir, err := extractONNXLibrary()
|
||||||
@@ -69,8 +86,10 @@ func newBGEModel() (EmbeddingModel, error) {
|
|||||||
libPath := filepath.Join(libDir, onnxRuntimeLibName)
|
libPath := filepath.Join(libDir, onnxRuntimeLibName)
|
||||||
ort.SetSharedLibraryPath(libPath)
|
ort.SetSharedLibraryPath(libPath)
|
||||||
|
|
||||||
// Initialize ONNX runtime
|
// Initialize ONNX runtime (idempotent - ignore "already initialized" since
|
||||||
if err := ort.InitializeEnvironment(); err != nil {
|
// 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)
|
return nil, fmt.Errorf("initialize ONNX runtime: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,9 +99,15 @@ func newBGEModel() (EmbeddingModel, error) {
|
|||||||
return nil, fmt.Errorf("load tokenizer: %w", err)
|
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
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create ONNX session: %w", err)
|
return nil, fmt.Errorf("create ONNX session: %w", err)
|
||||||
}
|
}
|
||||||
@@ -107,7 +132,7 @@ func (m *bgeModel) Name() string {
|
|||||||
|
|
||||||
// Version returns the short version string for storage.
|
// Version returns the short version string for storage.
|
||||||
func (m *bgeModel) Version() string {
|
func (m *bgeModel) Version() string {
|
||||||
return BGEModelVersion
|
return BGEStorageVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dimensions returns the embedding vector size.
|
// Dimensions returns the embedding vector size.
|
||||||
@@ -115,7 +140,7 @@ func (m *bgeModel) Dimensions() int {
|
|||||||
return EmbeddingDim
|
return EmbeddingDim
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractONNXLibrary extracts the embedded ONNX runtime library to a temp directory.
|
// extractONNXLibrary gunzips the embedded ONNX runtime library to a temp directory.
|
||||||
// Uses content hash to avoid re-extracting if already present.
|
// Uses content hash to avoid re-extracting if already present.
|
||||||
func extractONNXLibrary() (string, error) {
|
func extractONNXLibrary() (string, error) {
|
||||||
// Create a hash of the library content for cache key
|
// Create a hash of the library content for cache key
|
||||||
@@ -138,16 +163,14 @@ func extractONNXLibrary() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write main library
|
// Write main library
|
||||||
// #nosec G306 -- Shared library needs executable permission (0755) for dynamic linker
|
if err := writeGunzipped(libPath, onnxRuntimeLib); err != nil {
|
||||||
if err := os.WriteFile(libPath, onnxRuntimeLib, 0755); err != nil {
|
|
||||||
return "", fmt.Errorf("write library: %w", err)
|
return "", fmt.Errorf("write library: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write providers library if present (Linux only)
|
// Write providers library if present (Linux only)
|
||||||
if len(onnxRuntimeProvidersLib) > 0 && onnxRuntimeProvidersLibName != "" {
|
if len(onnxRuntimeProvidersLib) > 0 && onnxRuntimeProvidersLibName != "" {
|
||||||
providersPath := filepath.Join(cacheDir, onnxRuntimeProvidersLibName)
|
providersPath := filepath.Join(cacheDir, onnxRuntimeProvidersLibName)
|
||||||
// #nosec G306 -- Shared library needs executable permission (0755) for dynamic linker
|
if err := writeGunzipped(providersPath, onnxRuntimeProvidersLib); err != nil {
|
||||||
if err := os.WriteFile(providersPath, onnxRuntimeProvidersLib, 0755); err != nil {
|
|
||||||
return "", fmt.Errorf("write providers library: %w", err)
|
return "", fmt.Errorf("write providers library: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,6 +245,103 @@ func (m *bgeModel) EmbedBatch(texts []string) ([][]float32, error) {
|
|||||||
return results, nil
|
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.
|
// computeBatch runs inference on a batch of texts. Must be called with lock held.
|
||||||
func (m *bgeModel) computeBatch(sentences []string) ([][]float32, error) {
|
func (m *bgeModel) computeBatch(sentences []string) ([][]float32, error) {
|
||||||
if len(sentences) == 0 {
|
if len(sentences) == 0 {
|
||||||
@@ -396,12 +516,28 @@ func meanPooling(embeddings []float32, attentionMask []int64, batchSize, seqLen,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
results[b] = result
|
results[b] = l2Normalize(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// l2Normalize scales v to unit length in place; sqlite-vec distances assume unit vectors.
|
||||||
|
func l2Normalize(v []float32) []float32 {
|
||||||
|
var sum float64
|
||||||
|
for _, x := range v {
|
||||||
|
sum += float64(x) * float64(x)
|
||||||
|
}
|
||||||
|
if sum == 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
inv := float32(1 / math.Sqrt(sum))
|
||||||
|
for i := range v {
|
||||||
|
v[i] *= inv
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
// clsPooling extracts the [CLS] token embedding (first token).
|
// clsPooling extracts the [CLS] token embedding (first token).
|
||||||
// Input shape: [batch, seq_len, hidden]
|
// Input shape: [batch, seq_len, hidden]
|
||||||
// Output shape: [batch, hidden]
|
// Output shape: [batch, hidden]
|
||||||
@@ -413,7 +549,7 @@ func clsPooling(embeddings []float32, batchSize, seqLen, hiddenSize int) [][]flo
|
|||||||
// CLS token is at position 0
|
// CLS token is at position 0
|
||||||
embOffset := b * seqLen * hiddenSize
|
embOffset := b * seqLen * hiddenSize
|
||||||
copy(result, embeddings[embOffset:embOffset+hiddenSize])
|
copy(result, embeddings[embOffset:embOffset+hiddenSize])
|
||||||
results[b] = result
|
results[b] = l2Normalize(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
@@ -446,17 +582,6 @@ func (m *bgeModel) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the BGE model with the default registry at init time
|
|
||||||
func init() {
|
|
||||||
RegisterModel(ModelMetadata{
|
|
||||||
Name: BGEModelName,
|
|
||||||
Version: BGEModelVersion,
|
|
||||||
Dimensions: EmbeddingDim,
|
|
||||||
Description: "High-quality semantic search model",
|
|
||||||
Default: true,
|
|
||||||
}, newBGEModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service provides thread-safe text embedding generation with model abstraction.
|
// Service provides thread-safe text embedding generation with model abstraction.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
model EmbeddingModel
|
model EmbeddingModel
|
||||||
@@ -506,7 +631,53 @@ func (s *Service) EmbedBatch(texts []string) ([][]float32, error) {
|
|||||||
return s.model.EmbedBatch(texts)
|
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.
|
// Close releases model resources.
|
||||||
func (s *Service) Close() error {
|
func (s *Service) Close() error {
|
||||||
return s.model.Close()
|
return s.model.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeGunzipped decompresses gz into path with the executable bit the dynamic linker needs.
|
||||||
|
// It writes a temp file and renames it, so a crash never leaves a truncated library at path.
|
||||||
|
func writeGunzipped(path string, gz []byte) (err error) {
|
||||||
|
zr, err := gzip.NewReader(bytes.NewReader(gz))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = zr.Close() }()
|
||||||
|
|
||||||
|
f, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := f.Name()
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// #nosec G110 -- archive is embedded at build time, not external input
|
||||||
|
if _, err = io.Copy(f, zr); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = f.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// #nosec G302 -- shared library needs the executable bit for the dynamic linker
|
||||||
|
if err = os.Chmod(tmp, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
package embedding
|
package embedding
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"math"
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -310,6 +317,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
|
// Helper function to calculate cosine similarity
|
||||||
func cosineSimilarity(a, b []float32) float64 {
|
func cosineSimilarity(a, b []float32) float64 {
|
||||||
if len(a) != len(b) {
|
if len(a) != len(b) {
|
||||||
@@ -332,3 +479,57 @@ func cosineSimilarity(a, b []float32) float64 {
|
|||||||
|
|
||||||
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
|
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func gzipBytes(t *testing.T, data []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := gzip.NewWriter(&buf)
|
||||||
|
_, err := zw.Write(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, zw.Close())
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteGunzipped(t *testing.T) {
|
||||||
|
payload := bytes.Repeat([]byte("onnx"), 50_000)
|
||||||
|
good := gzipBytes(t, payload)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
gz []byte
|
||||||
|
wantErr bool
|
||||||
|
wantFile bool
|
||||||
|
}{
|
||||||
|
{"valid archive", good, false, true},
|
||||||
|
{"truncated archive leaves no file", good[:len(good)/2], true, false},
|
||||||
|
{"not gzip leaves no file", []byte("plain text"), true, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "lib.so")
|
||||||
|
|
||||||
|
err := writeGunzipped(path, tt.gz)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
_, statErr := os.Stat(path)
|
||||||
|
assert.Equal(t, tt.wantFile, statErr == nil)
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
if !tt.wantFile {
|
||||||
|
assert.Empty(t, entries, "no partial or temp files may remain")
|
||||||
|
}
|
||||||
|
if tt.wantFile {
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, payload, got)
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, os.FileMode(0o755), info.Mode().Perm())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -83,8 +83,20 @@ func (s *Service) Start(ctx context.Context) {
|
|||||||
Bool("cleanup_stale", s.config.CleanupStaleObservations).
|
Bool("cleanup_stale", s.config.CleanupStaleObservations).
|
||||||
Msg("Starting maintenance scheduler")
|
Msg("Starting maintenance scheduler")
|
||||||
|
|
||||||
// Initial run after 5 minutes (allow system to stabilize)
|
// Initial run after 5 minutes (allow system to stabilize).
|
||||||
time.Sleep(5 * time.Minute)
|
// 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)
|
s.runMaintenance(ctx)
|
||||||
|
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
@@ -151,10 +163,11 @@ func (s *Service) runMaintenance(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Task 3: Optimize database
|
// Task 3: Optimize database
|
||||||
|
var optimized bool
|
||||||
if err := s.store.Optimize(ctx); err != nil {
|
if err := s.store.Optimize(ctx); err != nil {
|
||||||
s.log.Error().Err(err).Msg("Failed to optimize database")
|
s.log.Error().Err(err).Msg("Failed to optimize database")
|
||||||
} else {
|
} else {
|
||||||
s.totalOptimizeRun++
|
optimized = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task 4: Clean up old prompts (keep last 1000 per session)
|
// 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.lastRunTime = time.Now()
|
||||||
s.lastRunDuration = time.Since(start)
|
s.lastRunDuration = time.Since(start)
|
||||||
s.totalCleanedObs += totalCleaned
|
s.totalCleanedObs += totalCleaned
|
||||||
|
if optimized {
|
||||||
|
s.totalOptimizeRun++
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
s.log.Info().
|
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) {
|
func (s *Service) RunNow(ctx context.Context) {
|
||||||
go s.runMaintenance(ctx)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIdleTimeoutFromEnv(t *testing.T) {
|
||||||
|
// The idle shutdown is off unless asked for; anything that is not a positive duration leaves it off.
|
||||||
|
for value, want := range map[string]time.Duration{
|
||||||
|
"": 0,
|
||||||
|
"0": 0,
|
||||||
|
"abc": 0,
|
||||||
|
"-5m": 0,
|
||||||
|
"45": 0, // a bare number is not a duration
|
||||||
|
"90m": 90 * time.Minute,
|
||||||
|
"2h": 2 * time.Hour,
|
||||||
|
"30s": 30 * time.Second,
|
||||||
|
" 2h ": 0, // not trimmed: the operator wrote it, so it is wrong rather than guessed
|
||||||
|
} {
|
||||||
|
t.Run(value, func(t *testing.T) {
|
||||||
|
t.Setenv(IdleTimeoutEnv, value)
|
||||||
|
assert.Equal(t, want, idleTimeoutFromEnv())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewServer_HasNoIdleTimeoutByDefault(t *testing.T) {
|
||||||
|
t.Setenv(IdleTimeoutEnv, "")
|
||||||
|
assert.Zero(t, NewServer(nil, "http://localhost:1", "p", "v").idleTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewServer_TakesTheIdleTimeoutFromTheEnvironment(t *testing.T) {
|
||||||
|
t.Setenv(IdleTimeoutEnv, "3h")
|
||||||
|
assert.Equal(t, 3*time.Hour, NewServer(nil, "http://localhost:1", "p", "v").idleTimeout)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// blockingPipe returns the two ends of a pipe in blocking mode, the way an inherited stdin is: a Read on it cannot be
|
||||||
|
// woken by closing the file (the standard pipe from os.Pipe is pollable and can, which hid this).
|
||||||
|
func blockingPipe(t *testing.T) (r, w *os.File) {
|
||||||
|
t.Helper()
|
||||||
|
var fds [2]int
|
||||||
|
require.NoError(t, syscall.Pipe(fds[:]))
|
||||||
|
r = os.NewFile(uintptr(fds[0]), "stdin-like")
|
||||||
|
w = os.NewFile(uintptr(fds[1]), "stdin-writer")
|
||||||
|
t.Cleanup(func() { _ = w.Close(); _ = r.Close() })
|
||||||
|
return r, w
|
||||||
|
}
|
||||||
|
|
||||||
|
type lockedBuffer struct {
|
||||||
|
buf bytes.Buffer
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *lockedBuffer) Write(p []byte) (int, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.buf.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *lockedBuffer) String() string {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The idle timeout used to close stdin and hope the read loop ended. On a real (blocking) stdin the read stays blocked,
|
||||||
|
// so the server logged "shutting down" and then lived on with a closed stdin; the next message from the client woke the
|
||||||
|
// read, which failed with "file already closed" and killed the connection mid-call (seen in Claude Desktop after the
|
||||||
|
// connector sat unused for more than 30 minutes). An idle timeout that is on must end Run for real.
|
||||||
|
func TestRun_IdleTimeoutEndsTheServerCleanly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stdin, _ := blockingPipe(t)
|
||||||
|
server := &Server{stdin: stdin, stdout: &lockedBuffer{}, idleTimeout: 50 * time.Millisecond, monitorInterval: 10 * time.Millisecond}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- server.Run(context.Background()) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
require.NoError(t, err, "an idle shutdown is a clean exit, not an error")
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("Run did not return after the idle timeout: stdin was closed but the blocked read never ended")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// By default there is no idle timeout: a stdio server lives as long as its client, and a client that talks to it after a
|
||||||
|
// long pause must get an answer.
|
||||||
|
func TestRun_WithoutAnIdleTimeoutTheServerKeepsAnsweringAfterALongPause(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stdin, stdinWriter := blockingPipe(t)
|
||||||
|
stdout := &lockedBuffer{}
|
||||||
|
server := &Server{stdin: stdin, stdout: stdout, monitorInterval: 5 * time.Millisecond}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- server.Run(context.Background()) }()
|
||||||
|
|
||||||
|
time.Sleep(300 * time.Millisecond) // many monitor ticks with no traffic
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatalf("Run ended during a pause with no traffic: %v", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := stdinWriter.WriteString(`{"jsonrpc":"2.0","id":7,"method":"tools/list"}` + "\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Eventually(t, func() bool { return bytes.Contains([]byte(stdout.String()), []byte(`"id":7`)) }, 3*time.Second, 10*time.Millisecond,
|
||||||
|
"the request after the pause is answered")
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatalf("Run ended after answering: %v", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
+1059
-2690
File diff suppressed because it is too large
Load Diff
+903
-256
File diff suppressed because it is too large
Load Diff
@@ -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) // best-effort cleanup
|
||||||
|
}
|
||||||
|
} else if len(currentVersion) > 0 {
|
||||||
|
log.Info().Str("old", string(currentVersion)).Str("new", ModelVersion).Msg("Model version updated, clearing cache")
|
||||||
|
_ = os.RemoveAll(dir) // best-effort cleanup
|
||||||
|
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) // best-effort cleanup
|
||||||
|
return "", fmt.Errorf("download %s: %w", assetName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if valid, err := verifyChecksum(modelPath, expectedSHA256); err != nil || !valid {
|
||||||
|
_ = os.Remove(modelPath) // best-effort cleanup
|
||||||
|
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 func() { _ = resp.Body.Close() }() // best-effort cleanup
|
||||||
|
|
||||||
|
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) } // best-effort cleanup
|
||||||
|
|
||||||
|
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() // best-effort cleanup; data already flushed via Sync above
|
||||||
|
|
||||||
|
if err := os.Rename(tmp, dest); err != nil {
|
||||||
|
_ = os.Remove(tmp) // best-effort cleanup
|
||||||
|
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 func() { _ = f.Close() }() // best-effort cleanup
|
||||||
|
|
||||||
|
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) // best-effort cleanup
|
||||||
|
log.Debug().Str("file", tmpPath).Msg("Cleaned stale temp file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// Package models provides runtime model download and caching.
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sha256Hex returns the hex-encoded SHA-256 of b.
|
||||||
|
func sha256Hex(t *testing.T, b []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
h := sha256.Sum256(b)
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// isolateCacheDir points ModelsDir() at a fresh temp directory by overriding
|
||||||
|
// the OS cache-dir resolution env vars (HOME and XDG_CACHE_HOME, which
|
||||||
|
// os.UserCacheDir honors on Linux and macOS). It also clears
|
||||||
|
// CLAUDE_MNEMONIC_MODEL_DIR so EnsureModel takes the download/cache path
|
||||||
|
// rather than the local-dir shortcut. It returns the resulting models dir.
|
||||||
|
func isolateCacheDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
|
||||||
|
t.Setenv("CLAUDE_MNEMONIC_MODEL_DIR", "")
|
||||||
|
|
||||||
|
dir := ModelsDir()
|
||||||
|
require.True(t, strings.HasPrefix(dir, home),
|
||||||
|
"ModelsDir() %q must live under temp HOME %q for hermetic test", dir, home)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileServer returns an httptest server that serves body for any request and
|
||||||
|
// records how many requests it received.
|
||||||
|
func fileServer(t *testing.T, body []byte) (*httptest.Server, *int32) {
|
||||||
|
t.Helper()
|
||||||
|
var hits int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
atomic.AddInt32(&hits, 1)
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv, &hits
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureModel_Download_CachesValidModel verifies the happy path: a model is
|
||||||
|
// downloaded, passes checksum verification, and the version file is written.
|
||||||
|
func TestEnsureModel_Download_CachesValidModel(t *testing.T) {
|
||||||
|
dir := isolateCacheDir(t)
|
||||||
|
body := []byte("model-bytes-happy-path")
|
||||||
|
srv, hits := fileServer(t, body)
|
||||||
|
|
||||||
|
const asset = "test-model.onnx"
|
||||||
|
path, err := EnsureModel(asset, srv.URL, sha256Hex(t, body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, filepath.Join(dir, asset), path)
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, body, got, "cached file content must match downloaded body")
|
||||||
|
|
||||||
|
ver, err := os.ReadFile(filepath.Join(dir, ".model_version"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ModelVersion, string(ver), "version file must record current ModelVersion")
|
||||||
|
|
||||||
|
assert.Equal(t, int32(1), atomic.LoadInt32(hits), "exactly one download on first fetch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureModel_ChecksumMismatch_FailsAndRemoves verifies that a downloaded
|
||||||
|
// file whose content does not match the expected SHA-256 is rejected with a
|
||||||
|
// verification error and is not left behind in the cache.
|
||||||
|
func TestEnsureModel_ChecksumMismatch_FailsAndRemoves(t *testing.T) {
|
||||||
|
dir := isolateCacheDir(t)
|
||||||
|
body := []byte("the-actual-downloaded-bytes")
|
||||||
|
srv, _ := fileServer(t, body)
|
||||||
|
|
||||||
|
const asset = "mismatch-model.onnx"
|
||||||
|
wrongSum := sha256Hex(t, []byte("a-different-expected-payload"))
|
||||||
|
|
||||||
|
path, err := EnsureModel(asset, srv.URL, wrongSum)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Empty(t, path)
|
||||||
|
assert.Contains(t, err.Error(), "checksum verification")
|
||||||
|
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, asset))
|
||||||
|
assert.True(t, os.IsNotExist(statErr),
|
||||||
|
"corrupt download must be removed from cache, got statErr=%v", statErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureModel_VersionBump_InvalidatesCache verifies that when the on-disk
|
||||||
|
// .model_version differs from ModelVersion, the cache directory is cleared and
|
||||||
|
// the model is re-downloaded fresh.
|
||||||
|
func TestEnsureModel_VersionBump_InvalidatesCache(t *testing.T) {
|
||||||
|
dir := isolateCacheDir(t)
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0700))
|
||||||
|
|
||||||
|
// Seed a stale cache: an old version file, a leftover model file, and an
|
||||||
|
// unrelated sidecar file. Only the version-bump branch wipes the whole dir
|
||||||
|
// (os.RemoveAll), so the sidecar's removal uniquely proves invalidation ran
|
||||||
|
// — a plain checksum-driven re-download would leave the sidecar in place.
|
||||||
|
const asset = "versioned-model.onnx"
|
||||||
|
staleModel := filepath.Join(dir, asset)
|
||||||
|
staleSidecar := filepath.Join(dir, "leftover-sidecar.bin")
|
||||||
|
require.NoError(t, os.WriteFile(staleModel, []byte("stale-garbage"), 0600))
|
||||||
|
require.NoError(t, os.WriteFile(staleSidecar, []byte("unrelated-leftover"), 0600))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, ".model_version"), []byte("OLD-VERSION"), 0600))
|
||||||
|
|
||||||
|
body := []byte("freshly-downloaded-model-bytes")
|
||||||
|
srv, hits := fileServer(t, body)
|
||||||
|
|
||||||
|
path, err := EnsureModel(asset, srv.URL, sha256Hex(t, body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, body, got, "stale cached file must be replaced by fresh download")
|
||||||
|
|
||||||
|
ver, err := os.ReadFile(filepath.Join(dir, ".model_version"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ModelVersion, string(ver), "version file must be rewritten to current ModelVersion")
|
||||||
|
|
||||||
|
assert.Equal(t, int32(1), atomic.LoadInt32(hits),
|
||||||
|
"version bump must force exactly one re-download, not serve the stale file")
|
||||||
|
|
||||||
|
_, sidecarErr := os.Stat(staleSidecar)
|
||||||
|
assert.True(t, os.IsNotExist(sidecarErr),
|
||||||
|
"version bump must wipe the whole cache dir, removing unrelated sidecar files")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureModel_SameVersionValidCache_NoDownload verifies that a matching
|
||||||
|
// version file plus a checksum-valid cached file short-circuits the download.
|
||||||
|
func TestEnsureModel_SameVersionValidCache_NoDownload(t *testing.T) {
|
||||||
|
dir := isolateCacheDir(t)
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0700))
|
||||||
|
|
||||||
|
const asset = "cached-model.onnx"
|
||||||
|
body := []byte("already-cached-valid-bytes")
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, asset), body, 0600))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, ".model_version"), []byte(ModelVersion), 0600))
|
||||||
|
|
||||||
|
// Server would record a hit if EnsureModel wrongly re-downloaded.
|
||||||
|
srv, hits := fileServer(t, []byte("should-not-be-fetched"))
|
||||||
|
|
||||||
|
path, err := EnsureModel(asset, srv.URL, sha256Hex(t, body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, filepath.Join(dir, asset), path)
|
||||||
|
assert.Equal(t, int32(0), atomic.LoadInt32(hits), "valid same-version cache must not trigger a download")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureModel_RetryExhaustion_ReturnsError verifies that when every download
|
||||||
|
// attempt fails (server always returns HTTP 500), EnsureModel exhausts its
|
||||||
|
// retries and returns an error reporting the attempt count, leaving no model
|
||||||
|
// file behind.
|
||||||
|
//
|
||||||
|
// Note: downloadWithRetries sleeps with exponential backoff between attempts,
|
||||||
|
// so this test takes ~6s by design; the backoff is production behavior and is
|
||||||
|
// intentionally not stubbed.
|
||||||
|
func TestEnsureModel_RetryExhaustion_ReturnsError(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping retry-exhaustion test (real backoff sleeps) in -short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := isolateCacheDir(t)
|
||||||
|
|
||||||
|
var hits int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
atomic.AddInt32(&hits, 1)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
const asset = "unreachable-model.onnx"
|
||||||
|
path, err := EnsureModel(asset, srv.URL, sha256Hex(t, []byte("never-served")))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Empty(t, path)
|
||||||
|
assert.Contains(t, err.Error(), "attempts", "error must report retry exhaustion")
|
||||||
|
|
||||||
|
assert.Equal(t, int32(maxRetries), atomic.LoadInt32(&hits),
|
||||||
|
"server must be hit exactly maxRetries times")
|
||||||
|
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, asset))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "failed download must not leave a model file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureModel_LocalDir verifies the CLAUDE_MNEMONIC_MODEL_DIR shortcut:
|
||||||
|
// a present, checksum-valid file is returned directly, and a checksum mismatch
|
||||||
|
// or missing file is reported without any network access.
|
||||||
|
func TestEnsureModel_LocalDir(t *testing.T) {
|
||||||
|
body := []byte("local-dir-model-bytes")
|
||||||
|
validSum := sha256Hex(t, body)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
writeFile bool
|
||||||
|
expectSum string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "valid_local_model", writeFile: true, expectSum: validSum, wantErr: ""},
|
||||||
|
{name: "checksum_mismatch", writeFile: true, expectSum: sha256Hex(t, []byte("other")), wantErr: "checksum mismatch"},
|
||||||
|
{name: "missing_file", writeFile: false, expectSum: validSum, wantErr: "not found"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
localDir := t.TempDir()
|
||||||
|
t.Setenv("CLAUDE_MNEMONIC_MODEL_DIR", localDir)
|
||||||
|
|
||||||
|
const asset = "local-model.onnx"
|
||||||
|
if tt.writeFile {
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(localDir, asset), body, 0600))
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := EnsureModel(asset, "http://127.0.0.1:0/should-not-be-used", tt.expectSum)
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
assert.Empty(t, path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, filepath.Join(localDir, asset), path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,8 @@ import (
|
|||||||
_ "embed"
|
_ "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
|
//go:embed assets/tokenizer.json
|
||||||
var crossEncoderTokenizerData []byte
|
var crossEncoderTokenizerData []byte
|
||||||
|
|||||||
Binary file not shown.
@@ -9,6 +9,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/internal/models"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/sugarme/tokenizer"
|
"github.com/sugarme/tokenizer"
|
||||||
"github.com/sugarme/tokenizer/pretrained"
|
"github.com/sugarme/tokenizer/pretrained"
|
||||||
@@ -26,6 +27,17 @@ const (
|
|||||||
DefaultCandidateLimit = 100
|
DefaultCandidateLimit = 100
|
||||||
// DefaultResultLimit is the default number of results to return after reranking
|
// DefaultResultLimit is the default number of results to return after reranking
|
||||||
DefaultResultLimit = 10
|
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"
|
||||||
|
|
||||||
|
// DefaultAlpha is the default score-combining weight, favoring the
|
||||||
|
// cross-encoder: combined = alpha*rerank + (1-alpha)*original.
|
||||||
|
DefaultAlpha = 0.7
|
||||||
)
|
)
|
||||||
|
|
||||||
// Candidate represents a search result candidate for reranking.
|
// Candidate represents a search result candidate for reranking.
|
||||||
@@ -71,7 +83,7 @@ type Config struct {
|
|||||||
// DefaultConfig returns sensible defaults for reranking.
|
// DefaultConfig returns sensible defaults for reranking.
|
||||||
func DefaultConfig() Config {
|
func DefaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Alpha: 0.7, // Favor cross-encoder by default
|
Alpha: DefaultAlpha, // Favor cross-encoder by default
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,12 +103,18 @@ func NewService(cfg Config) (*Service, error) {
|
|||||||
Stride: 0,
|
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
|
// Cross-encoder outputs a single logit for relevance scoring
|
||||||
inputNames := []string{"input_ids", "attention_mask", "token_type_ids"}
|
inputNames := []string{"input_ids", "attention_mask", "token_type_ids"}
|
||||||
outputNames := []string{"logits"}
|
outputNames := []string{"logits"}
|
||||||
|
|
||||||
session, err := ort.NewDynamicAdvancedSessionWithONNXData(
|
session, err := ort.NewDynamicAdvancedSession(
|
||||||
crossEncoderModelData,
|
modelPath,
|
||||||
inputNames,
|
inputNames,
|
||||||
outputNames,
|
outputNames,
|
||||||
nil,
|
nil,
|
||||||
@@ -107,7 +125,7 @@ func NewService(cfg Config) (*Service, error) {
|
|||||||
|
|
||||||
alpha := cfg.Alpha
|
alpha := cfg.Alpha
|
||||||
if alpha <= 0 || alpha > 1 {
|
if alpha <= 0 || alpha > 1 {
|
||||||
alpha = 0.7
|
alpha = DefaultAlpha
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Service{
|
return &Service{
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import (
|
|||||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// retrievalLogFactor scales the diminishing-returns retrieval boost:
|
||||||
|
// log2(retrieval_count + 1) is multiplied by this factor before applying
|
||||||
|
// the configured retrieval weight.
|
||||||
|
const retrievalLogFactor = 0.1
|
||||||
|
|
||||||
// Calculator computes importance scores for observations.
|
// Calculator computes importance scores for observations.
|
||||||
type Calculator struct {
|
type Calculator struct {
|
||||||
config *models.ScoringConfig
|
config *models.ScoringConfig
|
||||||
@@ -72,7 +77,7 @@ func (c *Calculator) CalculateComponents(obs *models.Observation, now time.Time)
|
|||||||
retrievalContrib := 0.0
|
retrievalContrib := 0.0
|
||||||
if obs.RetrievalCount > 0 {
|
if obs.RetrievalCount > 0 {
|
||||||
// log2(count + 1) gives diminishing returns: 1→1, 3→2, 7→3, 15→4, etc.
|
// log2(count + 1) gives diminishing returns: 1→1, 3→2, 7→3, 15→4, etc.
|
||||||
retrievalBoost := math.Log2(float64(obs.RetrievalCount)+1) * 0.1
|
retrievalBoost := math.Log2(float64(obs.RetrievalCount)+1) * retrievalLogFactor
|
||||||
retrievalContrib = retrievalBoost * c.config.RetrievalWeight
|
retrievalContrib = retrievalBoost * c.config.RetrievalWeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-15
@@ -46,7 +46,7 @@ const (
|
|||||||
cacheWarmingInterval = 20 * time.Second // Run warming cycle every 20 seconds
|
cacheWarmingInterval = 20 * time.Second // Run warming cycle every 20 seconds
|
||||||
frequencyCleanupInterval = 5 * time.Minute // Cleanup stale entries every 5 minutes
|
frequencyCleanupInterval = 5 * time.Minute // Cleanup stale entries every 5 minutes
|
||||||
cacheCleanupInterval = time.Minute // Cleanup expired cache every minute
|
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
|
warmingBatchSize = 5 // Warm top 5 queries per cycle
|
||||||
minRecencyFactor = 0.1 // Minimum recency factor for scoring
|
minRecencyFactor = 0.1 // Minimum recency factor for scoring
|
||||||
|
|
||||||
@@ -127,6 +127,7 @@ type Manager struct {
|
|||||||
queryFrequency map[string]*queryFrequencyInfo
|
queryFrequency map[string]*queryFrequencyInfo
|
||||||
cacheTTL time.Duration
|
cacheTTL time.Duration
|
||||||
cacheMaxSize int
|
cacheMaxSize int
|
||||||
|
activeQueries atomic.Int32 // tracks in-flight user queries to yield embedding mutex
|
||||||
resultCacheMu sync.RWMutex
|
resultCacheMu sync.RWMutex
|
||||||
queryFrequencyMu sync.RWMutex
|
queryFrequencyMu sync.RWMutex
|
||||||
}
|
}
|
||||||
@@ -256,7 +257,14 @@ func (m *Manager) cleanupStaleFrequencyEntries() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// warmFrequentQueries pre-executes frequently used queries to warm the cache.
|
// 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() {
|
func (m *Manager) warmFrequentQueries() {
|
||||||
|
if m.activeQueries.Load() > 0 {
|
||||||
|
log.Debug().Msg("Skipping cache warming: user queries in flight")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
m.queryFrequencyMu.RLock()
|
m.queryFrequencyMu.RLock()
|
||||||
// Find top N most frequent queries that aren't recently cached
|
// Find top N most frequent queries that aren't recently cached
|
||||||
type queryScore struct {
|
type queryScore struct {
|
||||||
@@ -687,30 +695,40 @@ func (m *Manager) UnifiedSearch(ctx context.Context, params SearchParams) (*Unif
|
|||||||
params.OrderBy = defaultOrderBy
|
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
|
// Check cache first
|
||||||
cacheKey := m.getCacheKey(params)
|
cacheKey := m.getCacheKey(params)
|
||||||
if cached, ok := m.getFromCache(cacheKey); ok {
|
if cached, ok := m.getFromCache(cacheKey); ok {
|
||||||
return cached, nil
|
return cached, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use singleflight to coalesce concurrent identical requests
|
// Use singleflight DoChan to coalesce concurrent identical requests.
|
||||||
result, err, _ := m.searchGroup.Do(cacheKey, func() (any, error) {
|
// 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)
|
return m.executeSearch(ctx, params)
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
select {
|
||||||
return nil, err
|
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.
|
// executeSearch performs the actual search without caching/coalescing.
|
||||||
|
|||||||
@@ -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.
|
// TestUnifiedSearchResult_MultipleResults tests result with multiple items.
|
||||||
func TestUnifiedSearchResult_MultipleResults(t *testing.T) {
|
func TestUnifiedSearchResult_MultipleResults(t *testing.T) {
|
||||||
results := []SearchResult{
|
results := []SearchResult{
|
||||||
|
|||||||
@@ -254,12 +254,16 @@ func (u *Updater) ApplyUpdate(ctx context.Context, info *UpdateInfo) error {
|
|||||||
u.setStatus("verifying", 0.2, "Verifying signature...")
|
u.setStatus("verifying", 0.2, "Verifying signature...")
|
||||||
|
|
||||||
if info.ChecksumsURL != "" && info.BundleURL != "" {
|
if info.ChecksumsURL != "" && info.BundleURL != "" {
|
||||||
|
// Fail-closed: a signature bundle is published for this release, so a
|
||||||
|
// verification failure (including cosign being unavailable) MUST abort
|
||||||
|
// the update before any binary is replaced. Silently proceeding would
|
||||||
|
// allow an unverified binary to overwrite the running one.
|
||||||
if err := u.verifySigstoreBundle(ctx, checksumsPath, bundlePath); err != nil {
|
if err := u.verifySigstoreBundle(ctx, checksumsPath, bundlePath); err != nil {
|
||||||
// Log warning but continue - signature verification is optional if cosign isn't installed
|
u.setError(err)
|
||||||
log.Warn().Err(err).Msg("Signature verification failed or skipped")
|
return fmt.Errorf("release signature verification failed; aborting update for safety, please update manually with: %s: %w",
|
||||||
} else {
|
GetManualUpdateCommand("v"+info.LatestVersion), err)
|
||||||
log.Info().Msg("Sigstore signature verification passed")
|
|
||||||
}
|
}
|
||||||
|
log.Info().Msg("Sigstore signature verification passed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Download the archive
|
// Step 3: Download the archive
|
||||||
@@ -532,13 +536,20 @@ func (u *Updater) replaceBinaries(extractDir string) error {
|
|||||||
func (u *Updater) getInstallDirectories() []string {
|
func (u *Updater) getInstallDirectories() []string {
|
||||||
dirs := []string{u.installDir}
|
dirs := []string{u.installDir}
|
||||||
|
|
||||||
// Also check cache directories where Claude Code looks for plugins
|
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return dirs
|
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")
|
cacheBase := filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic")
|
||||||
entries, err := os.ReadDir(cacheBase)
|
entries, err := os.ReadDir(cacheBase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -548,7 +559,6 @@ func (u *Updater) getInstallDirectories() []string {
|
|||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() {
|
if entry.IsDir() {
|
||||||
cacheDir := filepath.Join(cacheBase, entry.Name())
|
cacheDir := filepath.Join(cacheBase, entry.Name())
|
||||||
// Only add if it's different from installDir and contains a worker binary
|
|
||||||
if cacheDir != u.installDir {
|
if cacheDir != u.installDir {
|
||||||
workerPath := filepath.Join(cacheDir, "worker")
|
workerPath := filepath.Join(cacheDir, "worker")
|
||||||
if _, err := os.Stat(workerPath); err == nil {
|
if _, err := os.Stat(workerPath); err == nil {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+194
-127
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -139,29 +140,48 @@ func NewClient(cfg Config, embedSvc *embedding.Service) (*Client, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddDocuments adds documents with their embeddings to the vector store.
|
// AddDocuments embeds the documents and inserts them. A document whose ID already exists is an error (the vec0
|
||||||
|
// table does not replace on insert); use ReplaceDocuments to write the documents of a note that may have some.
|
||||||
func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
|
func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
|
||||||
if len(docs) == 0 {
|
return c.writeDocuments(ctx, docs, "", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceDocuments writes the documents of one note (observation, summary, ...) in place of the ones it had: the old
|
||||||
|
// documents are deleted and the new ones inserted in one transaction, so an edit that removes a fact leaves no stale
|
||||||
|
// document behind and a failure leaves the old ones untouched. docs may be empty, which only deletes.
|
||||||
|
func (c *Client) ReplaceDocuments(ctx context.Context, docType string, sqliteID int64, docs []Document) error {
|
||||||
|
return c.writeDocuments(ctx, docs, docType, sqliteID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeDocuments inserts docs; when docType is not empty it first deletes every document of that note.
|
||||||
|
func (c *Client) writeDocuments(ctx context.Context, docs []Document, docType string, sqliteID int64) (err error) {
|
||||||
|
if len(docs) == 0 && docType == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
c.writeMu.Lock()
|
// Prepare texts for embedding
|
||||||
defer c.writeMu.Unlock()
|
|
||||||
|
|
||||||
// Generate embeddings for all documents
|
|
||||||
texts := make([]string, len(docs))
|
texts := make([]string, len(docs))
|
||||||
for i, doc := range docs {
|
for i, doc := range docs {
|
||||||
texts[i] = doc.Content
|
texts[i] = doc.Content
|
||||||
}
|
}
|
||||||
|
|
||||||
embeddings, err := c.embedSvc.EmbedBatch(texts)
|
// Compute embeddings OUTSIDE the write lock for better concurrency.
|
||||||
if err != nil {
|
// Embedding is ONNX inference (slow, mutex-protected internally) — holding
|
||||||
return fmt.Errorf("generate embeddings: %w", err)
|
// writeMu during inference blocks all concurrent writes and reads.
|
||||||
|
var embeddings [][]float32
|
||||||
|
if len(texts) > 0 {
|
||||||
|
if embeddings, err = c.embedSvc.EmbedBatchWithContext(ctx, texts); 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
|
// Insert into vectors table with model version tracking
|
||||||
const insertQuery = `
|
const insertQuery = `
|
||||||
INSERT OR REPLACE INTO vectors (doc_id, embedding, sqlite_id, doc_type, field_type, project, scope, model_version)
|
INSERT INTO vectors (doc_id, embedding, sqlite_id, doc_type, field_type, project, scope, model_version)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -172,6 +192,8 @@ func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin transaction: %w", err)
|
return fmt.Errorf("begin transaction: %w", err)
|
||||||
}
|
}
|
||||||
|
// err is the named result, so every failed return below reaches this and the write lock is released. (A shadowed
|
||||||
|
// err in the loop once left the transaction open, and the database locked, after a failed insert.)
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if rbErr := tx.Rollback(); rbErr != nil {
|
if rbErr := tx.Rollback(); rbErr != nil {
|
||||||
@@ -181,6 +203,12 @@ func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
if docType != "" {
|
||||||
|
if _, err = tx.ExecContext(ctx, `DELETE FROM vectors WHERE doc_type = ? AND sqlite_id = ?`, docType, sqliteID); err != nil {
|
||||||
|
return fmt.Errorf("delete old documents: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stmt, err := tx.PrepareContext(ctx, insertQuery)
|
stmt, err := tx.PrepareContext(ctx, insertQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("prepare statement: %w", err)
|
return fmt.Errorf("prepare statement: %w", err)
|
||||||
@@ -189,29 +217,28 @@ func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
|
|||||||
|
|
||||||
for i, doc := range docs {
|
for i, doc := range docs {
|
||||||
// Serialize embedding to blob format
|
// Serialize embedding to blob format
|
||||||
embBlob, err := sqlite_vec.SerializeFloat32(embeddings[i])
|
var embBlob []byte
|
||||||
if err != nil {
|
if embBlob, err = sqlite_vec.SerializeFloat32(embeddings[i]); err != nil {
|
||||||
return fmt.Errorf("serialize embedding for %s: %w", doc.ID, err)
|
return fmt.Errorf("serialize embedding for %s: %w", doc.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract metadata
|
// Extract metadata
|
||||||
sqliteID, _ := doc.Metadata["sqlite_id"].(int64)
|
docSQLiteID, _ := doc.Metadata["sqlite_id"].(int64)
|
||||||
docType, _ := doc.Metadata["doc_type"].(string)
|
docDocType, _ := doc.Metadata["doc_type"].(string)
|
||||||
fieldType, _ := doc.Metadata["field_type"].(string)
|
fieldType, _ := doc.Metadata["field_type"].(string)
|
||||||
project, _ := doc.Metadata["project"].(string)
|
project, _ := doc.Metadata["project"].(string)
|
||||||
scope, _ := doc.Metadata["scope"].(string)
|
scope, _ := doc.Metadata["scope"].(string)
|
||||||
|
|
||||||
_, err = stmt.ExecContext(ctx,
|
if _, err = stmt.ExecContext(ctx,
|
||||||
doc.ID,
|
doc.ID,
|
||||||
embBlob,
|
embBlob,
|
||||||
sqliteID,
|
docSQLiteID,
|
||||||
docType,
|
docDocType,
|
||||||
fieldType,
|
fieldType,
|
||||||
project,
|
project,
|
||||||
scope,
|
scope,
|
||||||
modelVersion,
|
modelVersion,
|
||||||
)
|
); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("insert document %s: %w", doc.ID, err)
|
return fmt.Errorf("insert document %s: %w", doc.ID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,7 +298,7 @@ func (c *Client) Query(ctx context.Context, query string, limit int, where map[s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate query embedding OUTSIDE the lock for better concurrency
|
// Generate query embedding OUTSIDE the lock for better concurrency
|
||||||
queryEmb, err := c.getOrComputeEmbedding(query)
|
queryEmb, err := c.getOrComputeEmbedding(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("embed query: %w", err)
|
return nil, fmt.Errorf("embed query: %w", err)
|
||||||
}
|
}
|
||||||
@@ -282,71 +309,19 @@ func (c *Client) Query(ctx context.Context, query string, limit int, where map[s
|
|||||||
return nil, fmt.Errorf("serialize query embedding: %w", err)
|
return nil, fmt.Errorf("serialize query embedding: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now acquire read lock for the actual DB query
|
// Acquire read lock with context awareness to prevent indefinite blocking
|
||||||
c.readMu.RLock()
|
if err := acquireRLockWithContext(ctx, &c.readMu); err != nil {
|
||||||
|
return nil, fmt.Errorf("acquire read lock: %w", err)
|
||||||
|
}
|
||||||
defer c.readMu.RUnlock()
|
defer c.readMu.RUnlock()
|
||||||
|
|
||||||
// Build query with filters
|
// vec0 answers equality filters on metadata columns, but not an OR between them, so a project filter
|
||||||
// vec0 supports WHERE clauses on metadata columns
|
// (the project's own documents, plus global ones) is asked as two searches and merged.
|
||||||
args := []any{queryBlob}
|
docType, _ := where["doc_type"].(string)
|
||||||
|
project, _ := where["project"].(string)
|
||||||
sqlQuery := `
|
results, err := c.nearest(ctx, queryBlob, docType, project, limit)
|
||||||
SELECT
|
|
||||||
doc_id,
|
|
||||||
distance,
|
|
||||||
sqlite_id,
|
|
||||||
doc_type,
|
|
||||||
field_type,
|
|
||||||
project,
|
|
||||||
scope
|
|
||||||
FROM vectors
|
|
||||||
WHERE embedding MATCH ?
|
|
||||||
`
|
|
||||||
|
|
||||||
// Add filters - these work with vec0 metadata columns
|
|
||||||
if docType, ok := where["doc_type"].(string); ok && docType != "" {
|
|
||||||
sqlQuery += " AND doc_type = ?"
|
|
||||||
args = append(args, docType)
|
|
||||||
}
|
|
||||||
if project, ok := where["project"].(string); ok && project != "" {
|
|
||||||
// Include project-specific OR global scope
|
|
||||||
sqlQuery += " AND (project = ? OR scope = 'global')"
|
|
||||||
args = append(args, project)
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlQuery += " ORDER BY distance LIMIT ?"
|
|
||||||
args = append(args, limit)
|
|
||||||
|
|
||||||
rows, err := c.db.QueryContext(ctx, sqlQuery, args...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query vectors: %w", err)
|
return nil, err
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var results []QueryResult
|
|
||||||
for rows.Next() {
|
|
||||||
var r QueryResult
|
|
||||||
var sqliteID int64
|
|
||||||
var docType, fieldType, project, scope sql.NullString
|
|
||||||
|
|
||||||
if err := rows.Scan(&r.ID, &r.Distance, &sqliteID, &docType, &fieldType, &project, &scope); err != nil {
|
|
||||||
return nil, fmt.Errorf("scan row: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r.Similarity = DistanceToSimilarity(r.Distance)
|
|
||||||
r.Metadata = map[string]any{
|
|
||||||
"sqlite_id": float64(sqliteID), // Keep as float64 for compatibility
|
|
||||||
"doc_type": docType.String,
|
|
||||||
"field_type": fieldType.String,
|
|
||||||
"project": project.String,
|
|
||||||
"scope": scope.String,
|
|
||||||
}
|
|
||||||
|
|
||||||
results = append(results, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("iterate rows: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the results
|
// Cache the results
|
||||||
@@ -485,7 +460,7 @@ func (c *Client) QueryBatch(ctx context.Context, queries []string, limit int, wh
|
|||||||
// Combines results from different field types and deduplicates by document ID.
|
// 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) {
|
func (c *Client) QueryMultiField(ctx context.Context, query string, limit int, docType string, project string) ([]QueryResult, error) {
|
||||||
// Generate embedding once
|
// Generate embedding once
|
||||||
queryEmb, err := c.getOrComputeEmbedding(query)
|
queryEmb, err := c.getOrComputeEmbedding(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("embed query: %w", err)
|
return nil, fmt.Errorf("embed query: %w", err)
|
||||||
}
|
}
|
||||||
@@ -496,63 +471,136 @@ func (c *Client) QueryMultiField(ctx context.Context, query string, limit int, d
|
|||||||
return nil, fmt.Errorf("serialize query embedding: %w", err)
|
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()
|
defer c.readMu.RUnlock()
|
||||||
|
|
||||||
// Query with field type aggregation - get best match per document
|
// Each observation has several vector documents (one per field). Ask for enough of them that `limit`
|
||||||
sqlQuery := `
|
// distinct observations are likely to be among them, then keep the best match per observation.
|
||||||
WITH ranked_results AS (
|
found, err := c.nearest(ctx, queryBlob, docType, project, limit*multiFieldFetchFactor)
|
||||||
SELECT
|
if err != nil {
|
||||||
doc_id,
|
return nil, err
|
||||||
distance,
|
}
|
||||||
sqlite_id,
|
results := make([]QueryResult, 0, limit)
|
||||||
doc_type,
|
seen := make(map[float64]bool, limit)
|
||||||
field_type,
|
for _, r := range found { // best first
|
||||||
project,
|
id, _ := r.Metadata["sqlite_id"].(float64)
|
||||||
scope,
|
if seen[id] {
|
||||||
ROW_NUMBER() OVER (PARTITION BY sqlite_id ORDER BY distance ASC) as rn
|
continue
|
||||||
FROM vectors
|
}
|
||||||
WHERE embedding MATCH ?
|
seen[id] = true
|
||||||
AND doc_type = ?
|
results = append(results, r)
|
||||||
AND (project = ? OR scope = 'global')
|
if len(results) == limit {
|
||||||
)
|
break
|
||||||
SELECT doc_id, distance, sqlite_id, doc_type, field_type, project, scope
|
}
|
||||||
FROM ranked_results
|
}
|
||||||
WHERE rn = 1
|
return results, nil
|
||||||
ORDER BY distance
|
}
|
||||||
LIMIT ?
|
|
||||||
`
|
|
||||||
|
|
||||||
rows, err := c.db.QueryContext(ctx, sqlQuery, queryBlob, docType, project, limit)
|
// multiFieldFetchFactor is how many vector documents QueryMultiField asks for per wanted observation.
|
||||||
|
const multiFieldFetchFactor = 6
|
||||||
|
|
||||||
|
// nearest runs the nearest-neighbour search for a document type and, when project is not empty, for that
|
||||||
|
// project's documents and the global ones. The caller holds the read lock. Results are best first, at most limit.
|
||||||
|
func (c *Client) nearest(ctx context.Context, queryBlob []byte, docType, project string, limit int) ([]QueryResult, error) {
|
||||||
|
base := " WHERE embedding MATCH ?"
|
||||||
|
baseArgs := []any{queryBlob}
|
||||||
|
if docType != "" {
|
||||||
|
base += " AND doc_type = ?"
|
||||||
|
baseArgs = append(baseArgs, docType)
|
||||||
|
}
|
||||||
|
if project == "" {
|
||||||
|
return c.knn(ctx, base, baseArgs, limit)
|
||||||
|
}
|
||||||
|
own, err := c.knn(ctx, base+" AND project = ?", append(append([]any{}, baseArgs...), project), limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
global, err := c.knn(ctx, base+" AND scope = 'global'", baseArgs, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mergeNearest(limit, own, global), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// knn runs one vec0 nearest-neighbour query. where holds the MATCH and filters vec0 can answer on their own.
|
||||||
|
func (c *Client) knn(ctx context.Context, where string, args []any, limit int) ([]QueryResult, error) {
|
||||||
|
// #nosec G202 -- where is built from constant clauses with "?" placeholders only (see nearest); every value goes via args
|
||||||
|
rows, err := c.db.QueryContext(ctx,
|
||||||
|
"SELECT doc_id, distance, sqlite_id, doc_type, field_type, project, scope FROM vectors"+where+" ORDER BY distance LIMIT ?",
|
||||||
|
append(append([]any{}, args...), limit)...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query vectors: %w", err)
|
return nil, fmt.Errorf("query vectors: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
// Pre-allocate with limit to avoid repeated slice growth
|
var results []QueryResult
|
||||||
results := make([]QueryResult, 0, limit)
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r QueryResult
|
var r QueryResult
|
||||||
var sqliteID int64
|
var sqliteID int64
|
||||||
var docTypeVal, fieldType, projectVal, scope sql.NullString
|
var docType, fieldType, project, scope sql.NullString
|
||||||
|
|
||||||
if err := rows.Scan(&r.ID, &r.Distance, &sqliteID, &docTypeVal, &fieldType, &projectVal, &scope); err != nil {
|
if err := rows.Scan(&r.ID, &r.Distance, &sqliteID, &docType, &fieldType, &project, &scope); err != nil {
|
||||||
return nil, fmt.Errorf("scan row: %w", err)
|
return nil, fmt.Errorf("scan row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Similarity = DistanceToSimilarity(r.Distance)
|
r.Similarity = DistanceToSimilarity(r.Distance)
|
||||||
r.Metadata = map[string]any{
|
r.Metadata = map[string]any{
|
||||||
"sqlite_id": float64(sqliteID),
|
"sqlite_id": float64(sqliteID), // Keep as float64 for compatibility
|
||||||
"doc_type": docTypeVal.String,
|
"doc_type": docType.String,
|
||||||
"field_type": fieldType.String,
|
"field_type": fieldType.String,
|
||||||
"project": projectVal.String,
|
"project": project.String,
|
||||||
"scope": scope.String,
|
"scope": scope.String,
|
||||||
}
|
}
|
||||||
|
|
||||||
results = append(results, r)
|
results = append(results, r)
|
||||||
}
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate rows: %w", err)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
return results, rows.Err()
|
// mergeNearest combines result lists into one, best first, without repeating a document, at most limit long.
|
||||||
|
func mergeNearest(limit int, lists ...[]QueryResult) []QueryResult {
|
||||||
|
var all []QueryResult
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, list := range lists {
|
||||||
|
for _, r := range list {
|
||||||
|
if !seen[r.ID] {
|
||||||
|
seen[r.ID] = true
|
||||||
|
all = append(all, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(all, func(i, j int) bool { return all[i].Distance < all[j].Distance })
|
||||||
|
if len(all) > limit {
|
||||||
|
all = all[:limit]
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
// truncateString truncates a string to maxLen characters.
|
||||||
@@ -565,7 +613,9 @@ func truncateString(s string, maxLen int) string {
|
|||||||
|
|
||||||
// Count returns the total number of vectors in the store.
|
// Count returns the total number of vectors in the store.
|
||||||
func (c *Client) Count(ctx context.Context) (int64, error) {
|
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()
|
defer c.readMu.RUnlock()
|
||||||
|
|
||||||
var count int64
|
var count int64
|
||||||
@@ -586,7 +636,9 @@ func (c *Client) ModelVersion() string {
|
|||||||
// - The vectors table is empty
|
// - The vectors table is empty
|
||||||
// - Any vectors have a different model_version than the current model
|
// - Any vectors have a different model_version than the current model
|
||||||
func (c *Client) NeedsRebuild(ctx context.Context) (bool, string) {
|
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()
|
defer c.readMu.RUnlock()
|
||||||
|
|
||||||
currentModel := c.embedSvc.Version()
|
currentModel := c.embedSvc.Version()
|
||||||
@@ -634,7 +686,9 @@ type StaleVectorInfo struct {
|
|||||||
// GetStaleVectors returns doc_ids of vectors with mismatched or null model versions.
|
// GetStaleVectors returns doc_ids of vectors with mismatched or null model versions.
|
||||||
// This enables granular rebuild - only re-embedding documents that need updating.
|
// This enables granular rebuild - only re-embedding documents that need updating.
|
||||||
func (c *Client) GetStaleVectors(ctx context.Context) ([]StaleVectorInfo, error) {
|
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()
|
defer c.readMu.RUnlock()
|
||||||
|
|
||||||
currentModel := c.embedSvc.Version()
|
currentModel := c.embedSvc.Version()
|
||||||
@@ -692,7 +746,9 @@ type VectorHealthStats struct {
|
|||||||
|
|
||||||
// GetHealthStats returns comprehensive health statistics about the vector store.
|
// GetHealthStats returns comprehensive health statistics about the vector store.
|
||||||
func (c *Client) GetHealthStats(ctx context.Context) (*VectorHealthStats, error) {
|
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()
|
defer c.readMu.RUnlock()
|
||||||
|
|
||||||
stats := &VectorHealthStats{
|
stats := &VectorHealthStats{
|
||||||
@@ -856,7 +912,9 @@ func (c *Client) DeleteByObservationID(ctx context.Context, obsID int64) error {
|
|||||||
|
|
||||||
// getOrComputeEmbedding returns a cached embedding or computes a new one.
|
// getOrComputeEmbedding returns a cached embedding or computes a new one.
|
||||||
// Uses singleflight to prevent duplicate concurrent computations for the same query.
|
// 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()
|
now := time.Now().UnixNano()
|
||||||
|
|
||||||
// Check cache first (read lock)
|
// Check cache first (read lock)
|
||||||
@@ -870,8 +928,10 @@ func (c *Client) getOrComputeEmbedding(query string) ([]float32, error) {
|
|||||||
}
|
}
|
||||||
c.queryCacheMu.RUnlock()
|
c.queryCacheMu.RUnlock()
|
||||||
|
|
||||||
// Cache miss - use singleflight to deduplicate concurrent embedding requests
|
// Cache miss - use singleflight DoChan to deduplicate concurrent embedding requests.
|
||||||
result, err, _ := c.embeddingGroup.Do(query, func() (any, error) {
|
// 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)
|
// Double-check cache inside singleflight (another goroutine may have just cached it)
|
||||||
c.queryCacheMu.RLock()
|
c.queryCacheMu.RLock()
|
||||||
if entry, ok := c.queryCache[query]; ok {
|
if entry, ok := c.queryCache[query]; ok {
|
||||||
@@ -885,7 +945,9 @@ func (c *Client) getOrComputeEmbedding(query string) ([]float32, error) {
|
|||||||
// Record cache miss
|
// Record cache miss
|
||||||
c.stats.embeddingMisses.Add(1)
|
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)
|
emb, err := c.embedSvc.Embed(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -933,10 +995,15 @@ func (c *Client) getOrComputeEmbedding(query string) ([]float32, error) {
|
|||||||
return emb, nil
|
return emb, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
select {
|
||||||
return nil, err
|
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.
|
// ClearCache clears the embedding cache.
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ package sqlitevec
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
|
sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
|
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
|
||||||
@@ -402,6 +405,50 @@ func TestClient_Query_WithProjectFilter(t *testing.T) {
|
|||||||
results, err := client.Query(context.Background(), "authentication security", 10, nil)
|
results, err := client.Query(context.Background(), "authentication security", 10, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotEmpty(t, results, "Should find some results")
|
assert.NotEmpty(t, results, "Should find some results")
|
||||||
|
assert.Len(t, results, 3)
|
||||||
|
|
||||||
|
ids := func(rs []QueryResult) []string {
|
||||||
|
var out []string
|
||||||
|
for _, r := range rs {
|
||||||
|
out = append(out, r.ID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a project filter: the project's own documents and the global ones, not another project's.
|
||||||
|
results, err = client.Query(context.Background(), "authentication security", 10, BuildWhereFilter(DocTypeObservation, "project-a"))
|
||||||
|
require.NoError(t, err, "a project filter must be answerable (vec0 rejects an OR between metadata filters)")
|
||||||
|
assert.ElementsMatch(t, []string{"obs-1", "obs-3"}, ids(results), "project-a's note and the global one, not project-b's own")
|
||||||
|
|
||||||
|
results, err = client.Query(context.Background(), "authentication security", 10, BuildWhereFilter(DocTypeObservation, "project-b"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.ElementsMatch(t, []string{"obs-2", "obs-3"}, ids(results))
|
||||||
|
|
||||||
|
// Best first, and the limit applies to the merged list.
|
||||||
|
results, err = client.Query(context.Background(), "authentication security", 1, BuildWhereFilter(DocTypeObservation, "project-a"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, results, 1)
|
||||||
|
all, err := client.Query(context.Background(), "authentication security", 10, BuildWhereFilter(DocTypeObservation, "project-a"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, all[0].ID, results[0].ID, "the one result is the best of the merged list")
|
||||||
|
assert.LessOrEqual(t, all[0].Distance, all[1].Distance)
|
||||||
|
|
||||||
|
// A project nobody wrote to still sees the global documents.
|
||||||
|
results, err = client.Query(context.Background(), "authentication security", 10, BuildWhereFilter(DocTypeObservation, "project-z"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"obs-3"}, ids(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeNearest(t *testing.T) {
|
||||||
|
r := func(id string, d float64) QueryResult { return QueryResult{ID: id, Distance: d} }
|
||||||
|
got := mergeNearest(3, []QueryResult{r("a", 0.5), r("b", 0.9)}, []QueryResult{r("b", 0.9), r("c", 0.1), r("d", 0.7)})
|
||||||
|
var ids []string
|
||||||
|
for _, x := range got {
|
||||||
|
ids = append(ids, x.ID)
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{"c", "a", "d"}, ids, "best first, a document found twice counts once, cut to the limit")
|
||||||
|
assert.Empty(t, mergeNearest(5))
|
||||||
|
assert.Len(t, mergeNearest(5, []QueryResult{r("a", 1)}, nil), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClient_IsConnected(t *testing.T) {
|
func TestClient_IsConnected(t *testing.T) {
|
||||||
@@ -1102,8 +1149,6 @@ func TestClient_QueryBatch_WithContextCancellation(t *testing.T) {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
func TestClient_QueryMultiField_Basic(t *testing.T) {
|
func TestClient_QueryMultiField_Basic(t *testing.T) {
|
||||||
t.Skip("QueryMultiField SQL query needs 'k' parameter fix for sqlite-vec")
|
|
||||||
|
|
||||||
db, dbCleanup := testDB(t)
|
db, dbCleanup := testDB(t)
|
||||||
defer dbCleanup()
|
defer dbCleanup()
|
||||||
|
|
||||||
@@ -1170,8 +1215,6 @@ func TestClient_QueryMultiField_Basic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestClient_QueryMultiField_WithGlobalScope(t *testing.T) {
|
func TestClient_QueryMultiField_WithGlobalScope(t *testing.T) {
|
||||||
t.Skip("QueryMultiField SQL query needs 'k' parameter fix for sqlite-vec")
|
|
||||||
|
|
||||||
db, dbCleanup := testDB(t)
|
db, dbCleanup := testDB(t)
|
||||||
defer dbCleanup()
|
defer dbCleanup()
|
||||||
|
|
||||||
@@ -1214,7 +1257,37 @@ func TestClient_QueryMultiField_WithGlobalScope(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should include both project-scoped (matching project) and global
|
// Should include both project-scoped (matching project) and global
|
||||||
assert.NotEmpty(t, results)
|
require.Len(t, results, 2)
|
||||||
|
assert.ElementsMatch(t, []float64{1, 2}, []float64{
|
||||||
|
results[0].Metadata["sqlite_id"].(float64), results[1].Metadata["sqlite_id"].(float64),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The query the de-duplication of new observations makes: the same project, a few results, and a note that
|
||||||
|
// says nearly the same thing must come back with a high similarity.
|
||||||
|
func TestClient_Query_ProjectFilterFindsANearDuplicate(t *testing.T) {
|
||||||
|
db, dbCleanup := testDB(t)
|
||||||
|
defer dbCleanup()
|
||||||
|
embedSvc, embedCleanup := testEmbeddingService(t)
|
||||||
|
defer embedCleanup()
|
||||||
|
client, err := NewClient(Config{DB: db}, embedSvc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
text := "The shipping rate cache lives for sixty minutes and is stored in Redis."
|
||||||
|
require.NoError(t, client.AddDocuments(context.Background(), []Document{
|
||||||
|
{ID: "obs-1", Content: text, Metadata: map[string]any{"sqlite_id": int64(1), "doc_type": "observation", "project": "shop", "scope": "project"}},
|
||||||
|
{ID: "obs-2", Content: text, Metadata: map[string]any{"sqlite_id": int64(2), "doc_type": "observation", "project": "elsewhere", "scope": "project"}},
|
||||||
|
{ID: "obs-3", Content: "Parcel labels are printed as PDF.", Metadata: map[string]any{"sqlite_id": int64(3), "doc_type": "observation", "project": "shop", "scope": "project"}},
|
||||||
|
}))
|
||||||
|
|
||||||
|
results, err := client.Query(context.Background(), text, 3, BuildWhereFilter(DocTypeObservation, "shop"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, results)
|
||||||
|
assert.Equal(t, "obs-1", results[0].ID, "the same text in the same project is the best match")
|
||||||
|
assert.GreaterOrEqual(t, results[0].Similarity, 0.99)
|
||||||
|
for _, r := range results {
|
||||||
|
assert.NotEqual(t, "obs-2", r.ID, "another project's note is not a duplicate")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -1901,3 +1974,315 @@ func TestExtractObservationIDs_GlobalScope(t *testing.T) {
|
|||||||
assert.Len(t, ids, 1)
|
assert.Len(t, ids, 1)
|
||||||
assert.Equal(t, int64(123), ids[0])
|
assert.Equal(t, int64(123), ids[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REGRESSION TESTS FOR acquireRLockWithContext (Fix #45)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestAcquireRLockWithContext_Cancel verifies that when a write lock is held
|
||||||
|
// and the context times out, acquireRLockWithContext returns context.DeadlineExceeded
|
||||||
|
// promptly and the cleanup goroutine eventually releases the lock.
|
||||||
|
func TestAcquireRLockWithContext_Cancel(t *testing.T) {
|
||||||
|
var mu sync.RWMutex
|
||||||
|
|
||||||
|
// Hold write lock so any RLock() call blocks.
|
||||||
|
locked := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
mu.Lock()
|
||||||
|
close(locked)
|
||||||
|
<-release
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
<-locked // write lock is held
|
||||||
|
|
||||||
|
// Context with a tight deadline — must expire before we release the write lock.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := acquireRLockWithContext(ctx, &mu)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
assert.ErrorIs(t, err, context.DeadlineExceeded, "should return DeadlineExceeded")
|
||||||
|
assert.Less(t, elapsed, 200*time.Millisecond, "should return within ~100ms of deadline")
|
||||||
|
|
||||||
|
// Release the write lock so the cleanup goroutine can finish.
|
||||||
|
close(release)
|
||||||
|
|
||||||
|
// After the write lock is released the cleanup goroutine acquires+releases
|
||||||
|
// the RLock. Wait long enough for it to drain.
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Now an uncontended RLock should succeed immediately.
|
||||||
|
ctx2 := context.Background()
|
||||||
|
err2 := acquireRLockWithContext(ctx2, &mu)
|
||||||
|
assert.NoError(t, err2, "should succeed when uncontended after cleanup")
|
||||||
|
if err2 == nil {
|
||||||
|
mu.RUnlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAcquireRLockWithContext_Success verifies that an uncontended mutex is
|
||||||
|
// acquired without error and can be properly unlocked.
|
||||||
|
func TestAcquireRLockWithContext_Success(t *testing.T) {
|
||||||
|
var mu sync.RWMutex
|
||||||
|
|
||||||
|
err := acquireRLockWithContext(context.Background(), &mu)
|
||||||
|
assert.NoError(t, err, "should succeed on uncontended mutex")
|
||||||
|
if err == nil {
|
||||||
|
// Panics if not held — validates that the lock was actually taken.
|
||||||
|
mu.RUnlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAcquireRLockWithContext_CleanupOnCancel verifies that when
|
||||||
|
// acquireRLockWithContext returns an error due to context cancellation, the
|
||||||
|
// cleanup goroutine eventually releases the RLock so the mutex can be write-
|
||||||
|
// locked again without deadlock.
|
||||||
|
func TestAcquireRLockWithContext_CleanupOnCancel(t *testing.T) {
|
||||||
|
var mu sync.RWMutex
|
||||||
|
|
||||||
|
// Hold write lock to force RLock to block.
|
||||||
|
release := make(chan struct{})
|
||||||
|
locked := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
mu.Lock()
|
||||||
|
close(locked)
|
||||||
|
<-release
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
<-locked
|
||||||
|
|
||||||
|
// Context cancels after 10ms — way before we release the write lock.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := acquireRLockWithContext(ctx, &mu)
|
||||||
|
assert.Error(t, err, "should fail due to cancelled context")
|
||||||
|
|
||||||
|
// Release the write lock; the cleanup goroutine inside acquireRLockWithContext
|
||||||
|
// will now acquire the RLock and immediately release it.
|
||||||
|
close(release)
|
||||||
|
|
||||||
|
// Give the cleanup goroutine time to run.
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Acquire a write lock — would deadlock if cleanup goroutine left an RLock
|
||||||
|
// dangling. Use a done channel and select to avoid hanging the test.
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
close(done) //nolint:SA2001 // intentional: proves no deadlock from leaked RLock
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Success — write lock acquired without deadlock.
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("write lock acquisition timed out: cleanup goroutine may have leaked an RLock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REGRESSION TESTS FOR Fix #1: Embedding outside writeMu in AddDocuments
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestAddDocuments_EmbeddingOutsideWriteLock verifies that AddDocuments does NOT
|
||||||
|
// hold the write lock during embedding computation. A concurrent Query call
|
||||||
|
// should complete quickly while AddDocuments is computing embeddings.
|
||||||
|
func TestAddDocuments_EmbeddingOutsideWriteLock(t *testing.T) {
|
||||||
|
db, dbCleanup := testDB(t)
|
||||||
|
defer dbCleanup()
|
||||||
|
|
||||||
|
embedSvc, embedCleanup := testEmbeddingService(t)
|
||||||
|
defer embedCleanup()
|
||||||
|
|
||||||
|
client, err := NewClient(Config{DB: db}, embedSvc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Seed the DB with one document so Query has something to search
|
||||||
|
seedDocs := []Document{
|
||||||
|
{ID: "seed-1", Content: "Seed document for concurrency test"},
|
||||||
|
}
|
||||||
|
err = client.AddDocuments(context.Background(), seedDocs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Pre-warm the embedding cache for the query text so the Query call
|
||||||
|
// itself doesn't need the embedding mutex — it only needs the DB read lock.
|
||||||
|
_, err = client.Query(context.Background(), "concurrency test", 5, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Prepare a batch of documents to trigger a slow AddDocuments call
|
||||||
|
batchDocs := make([]Document, 10)
|
||||||
|
for i := range batchDocs {
|
||||||
|
batchDocs[i] = Document{
|
||||||
|
ID: fmt.Sprintf("batch-%d", i),
|
||||||
|
Content: fmt.Sprintf("Batch document number %d for write lock test with unique content", i),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch AddDocuments in background — embedding will take time
|
||||||
|
addDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
addDone <- client.AddDocuments(context.Background(), batchDocs)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Give AddDocuments a moment to start embedding computation
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
// Invalidate result cache so Query must go through to DB (tests read lock)
|
||||||
|
client.InvalidateResultCache()
|
||||||
|
|
||||||
|
// A concurrent Query should NOT be blocked by AddDocuments.
|
||||||
|
// If the old code held writeMu during embedding, this would block until
|
||||||
|
// embedding finishes. With the fix, it should complete quickly.
|
||||||
|
queryCtx, queryCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer queryCancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err = client.Query(queryCtx, "concurrency test", 5, nil)
|
||||||
|
queryDuration := time.Since(start)
|
||||||
|
|
||||||
|
require.NoError(t, err, "Query should succeed while AddDocuments is embedding")
|
||||||
|
// The query should complete well within the timeout if writeMu is not held
|
||||||
|
assert.Less(t, queryDuration, 1*time.Second,
|
||||||
|
"Query should complete quickly when writeMu is not held during embedding")
|
||||||
|
|
||||||
|
// Wait for AddDocuments to finish
|
||||||
|
err = <-addDone
|
||||||
|
require.NoError(t, err, "AddDocuments should succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REGRESSION TESTS FOR Fix #2a: DoChan + context select in getOrComputeEmbedding
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TestGetOrComputeEmbedding_ContextCancelDuringSingleflight verifies that when
|
||||||
|
// a singleflight embedding computation is in progress, a second caller with a
|
||||||
|
// short-lived context returns context.DeadlineExceeded promptly rather than
|
||||||
|
// waiting for the slow first call to finish.
|
||||||
|
func TestGetOrComputeEmbedding_ContextCancelDuringSingleflight(t *testing.T) {
|
||||||
|
db, dbCleanup := testDB(t)
|
||||||
|
defer dbCleanup()
|
||||||
|
|
||||||
|
embedSvc, embedCleanup := testEmbeddingService(t)
|
||||||
|
defer embedCleanup()
|
||||||
|
|
||||||
|
client, err := NewClient(Config{DB: db}, embedSvc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Add a document so we can query
|
||||||
|
docs := []Document{
|
||||||
|
{ID: "sf-test-1", Content: "Singleflight context cancellation test document"},
|
||||||
|
}
|
||||||
|
err = client.AddDocuments(context.Background(), docs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Clear cache to force embedding computation
|
||||||
|
client.ClearCache()
|
||||||
|
client.InvalidateResultCache()
|
||||||
|
|
||||||
|
queryText := "unique singleflight context test query"
|
||||||
|
|
||||||
|
// First call: start a normal query in background (will trigger singleflight)
|
||||||
|
firstDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(firstDone)
|
||||||
|
_, _ = client.Query(context.Background(), queryText, 5, nil)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Give the first call a moment to start the singleflight computation
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
|
||||||
|
// Second call: use a very short context that should expire quickly
|
||||||
|
shortCtx, shortCancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer shortCancel()
|
||||||
|
|
||||||
|
// Clear result cache again so the second call hits getOrComputeEmbedding
|
||||||
|
client.InvalidateResultCache()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err = client.Query(shortCtx, queryText, 5, nil)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
// With DoChan + select, the second caller should return quickly with context error.
|
||||||
|
// Note: If the embedding completes fast enough, the second call may succeed
|
||||||
|
// via singleflight sharing. That's also valid — the test primarily checks
|
||||||
|
// that it doesn't BLOCK for the full embedding duration on context cancel.
|
||||||
|
if err != nil {
|
||||||
|
assert.ErrorIs(t, err, context.DeadlineExceeded,
|
||||||
|
"Should return DeadlineExceeded when context expires during singleflight")
|
||||||
|
assert.Less(t, elapsed, 500*time.Millisecond,
|
||||||
|
"Should return promptly on context cancellation, not wait for slow computation")
|
||||||
|
}
|
||||||
|
// If err == nil, the singleflight completed before the context expired — also valid.
|
||||||
|
|
||||||
|
// Wait for first call to finish
|
||||||
|
<-firstDone
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_AddDocuments_FailedBatchIsRolledBackAndDoesNotLockTheDatabase(t *testing.T) {
|
||||||
|
db, dbCleanup := testDB(t)
|
||||||
|
defer dbCleanup()
|
||||||
|
embedSvc, embedCleanup := testEmbeddingService(t)
|
||||||
|
defer embedCleanup()
|
||||||
|
client, err := NewClient(Config{DB: db}, embedSvc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
doc := func(id string, sqliteID int64) Document {
|
||||||
|
return Document{ID: id, Content: "text of " + id, Metadata: map[string]any{
|
||||||
|
"sqlite_id": sqliteID, "doc_type": "observation", "field_type": "narrative", "project": "alpha", "scope": "project",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
require.NoError(t, client.AddDocuments(context.Background(), []Document{doc("obs_1_narrative", 1)}))
|
||||||
|
|
||||||
|
// The second document of the batch exists already, so the batch fails after the first was inserted.
|
||||||
|
err = client.AddDocuments(context.Background(), []Document{doc("obs_2_narrative", 2), doc("obs_1_narrative", 1)})
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
var n int
|
||||||
|
require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM vectors WHERE doc_id = 'obs_2_narrative'").Scan(&n))
|
||||||
|
assert.Zero(t, n, "nothing of the failed batch is kept")
|
||||||
|
|
||||||
|
// With the transaction left open, this would wait for the lock until the context ends.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
require.NoError(t, client.AddDocuments(ctx, []Document{doc("obs_3_narrative", 3)}), "a write after a failed one goes through")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_ReplaceDocuments_ReplacesOnlyTheDocumentsOfThatNote(t *testing.T) {
|
||||||
|
db, dbCleanup := testDB(t)
|
||||||
|
defer dbCleanup()
|
||||||
|
embedSvc, embedCleanup := testEmbeddingService(t)
|
||||||
|
defer embedCleanup()
|
||||||
|
client, err := NewClient(Config{DB: db}, embedSvc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
doc := func(id string, sqliteID int64, docType, scope string) Document {
|
||||||
|
return Document{ID: id, Content: "text of " + id, Metadata: map[string]any{
|
||||||
|
"sqlite_id": sqliteID, "doc_type": docType, "field_type": "narrative", "project": "alpha", "scope": scope,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
require.NoError(t, client.AddDocuments(ctx, []Document{
|
||||||
|
doc("obs_1_narrative", 1, "observation", "global"), doc("obs_1_fact_0", 1, "observation", "global"),
|
||||||
|
doc("obs_2_narrative", 2, "observation", "global"),
|
||||||
|
doc("summary_1_request", 1, "session_summary", ""), // the same sqlite id, another kind of document
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.NoError(t, client.ReplaceDocuments(ctx, "observation", 1, []Document{doc("obs_1_narrative", 1, "observation", "project")}))
|
||||||
|
|
||||||
|
count := func(where string, args ...any) (n int) {
|
||||||
|
require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM vectors WHERE "+where, args...).Scan(&n))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, count("doc_type = 'observation' AND sqlite_id = 1"), "the fact document is gone")
|
||||||
|
assert.Equal(t, 1, count("doc_id = 'obs_1_narrative' AND scope = 'project'"), "and the narrative carries the new scope")
|
||||||
|
assert.Equal(t, 1, count("doc_id = 'obs_2_narrative' AND scope = 'global'"), "another note is untouched")
|
||||||
|
assert.Equal(t, 1, count("doc_id = 'summary_1_request'"), "so is a summary with the same id")
|
||||||
|
|
||||||
|
require.NoError(t, client.ReplaceDocuments(ctx, "observation", 1, nil), "no documents left: only the old ones are deleted")
|
||||||
|
assert.Zero(t, count("doc_type = 'observation' AND sqlite_id = 1"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,11 +22,8 @@ func NewSync(client *Client) *Sync {
|
|||||||
// SyncObservation syncs a single observation to the vector store.
|
// SyncObservation syncs a single observation to the vector store.
|
||||||
func (s *Sync) SyncObservation(ctx context.Context, obs *models.Observation) error {
|
func (s *Sync) SyncObservation(ctx context.Context, obs *models.Observation) error {
|
||||||
docs := s.formatObservationDocs(obs)
|
docs := s.formatObservationDocs(obs)
|
||||||
if len(docs) == 0 {
|
// Replace, not add: a note that is synced again (after an edit, a re-scope or a re-import) already has documents.
|
||||||
return nil
|
if err := s.client.ReplaceDocuments(ctx, "observation", obs.ID, docs); err != nil {
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.client.AddDocuments(ctx, docs); err != nil {
|
|
||||||
return fmt.Errorf("add observation docs: %w", err)
|
return fmt.Errorf("add observation docs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,11 +99,7 @@ func (s *Sync) formatObservationDocs(obs *models.Observation) []Document {
|
|||||||
// SyncSummary syncs a single session summary to the vector store.
|
// SyncSummary syncs a single session summary to the vector store.
|
||||||
func (s *Sync) SyncSummary(ctx context.Context, summary *models.SessionSummary) error {
|
func (s *Sync) SyncSummary(ctx context.Context, summary *models.SessionSummary) error {
|
||||||
docs := s.formatSummaryDocs(summary)
|
docs := s.formatSummaryDocs(summary)
|
||||||
if len(docs) == 0 {
|
if err := s.client.ReplaceDocuments(ctx, "session_summary", summary.ID, docs); err != nil {
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.client.AddDocuments(ctx, docs); err != nil {
|
|
||||||
return fmt.Errorf("add summary docs: %w", err)
|
return fmt.Errorf("add summary docs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user