mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-12 04:55:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ff08732c6 | ||
|
|
433d165f17 | ||
|
|
378e7dc489 | ||
|
|
c2c31c8c5d | ||
|
|
b396d05c0f | ||
|
|
66c53deb02 | ||
|
|
372942ec8b | ||
|
|
e3d3f2bb7b | ||
|
|
21bcbfae07 | ||
|
|
03e366da0f | ||
|
|
f8d98f7b6b | ||
|
|
7ce7b80650 | ||
|
|
f23df63059 | ||
|
|
f56037a49a | ||
|
|
d5ccbd641e | ||
|
|
862ff7c3ac | ||
|
|
e7b58312c5 | ||
|
|
e5f4a100ab | ||
|
|
31d67ceb2a | ||
|
|
5c7b623585 | ||
|
|
db528b2544 | ||
|
|
f452591971 | ||
|
|
7fe679f83b | ||
|
|
6c28ecb22a | ||
|
|
7e49113d24 | ||
|
|
e52f3284ba | ||
|
|
f0c35eef0e | ||
|
|
085c2cb3d0 | ||
|
|
cd14ba8915 | ||
|
|
0a0dfc1c2f | ||
|
|
d68a700917 | ||
|
|
c705449467 | ||
|
|
ad8aef92e1 | ||
|
|
268c286859 | ||
|
|
c664ad5fd8 | ||
|
|
0eca679139 | ||
|
|
c14677f1f2 | ||
|
|
ca711a8a7e | ||
|
|
0769feaca5 | ||
|
|
bbd6c3f1ff | ||
|
|
637412e8bb | ||
|
|
a1c1a5cad5 | ||
|
|
3a223da320 | ||
|
|
feb7f251b4 |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
# Models are now downloaded at runtime from GitHub Releases.
|
||||
# LFS tracking removed. See internal/models/download.go
|
||||
@@ -1,114 +0,0 @@
|
||||
# CI Test Fixes - Complete Summary
|
||||
|
||||
## Issues Fixed
|
||||
|
||||
### 1. Missing Build Tags (commit 90ab909)
|
||||
**Problem:** Tests failed because `sqlite-vec-go-bindings` requires `-tags "fts5"` build flag for SQLite FTS5 support.
|
||||
|
||||
**Solution:**
|
||||
- Updated shared-actions workflow to support `build-tags` parameter
|
||||
- Added `build-tags: "fts5"` to `.github/workflows/ci.yaml`
|
||||
|
||||
### 2. Database Locked Errors (commit a274f1b)
|
||||
**Problem:** `TestObservationStore_CleanupOldObservations` failed with "database is locked" errors in CI.
|
||||
|
||||
**Root Cause:**
|
||||
- `StoreObservation` spawns async goroutines that run `CleanupOldObservations`
|
||||
- Test creates 105 observations rapidly (2ms apart)
|
||||
- This spawns ~105 concurrent cleanup goroutines
|
||||
- Multiple goroutines tried to DELETE simultaneously
|
||||
- SQLite had no `busy_timeout` configured → immediate failure
|
||||
|
||||
**Solution:**
|
||||
- Added `PRAGMA busy_timeout=5000` (5 seconds) in `NewStore()`
|
||||
- SQLite now retries on lock contention instead of failing immediately
|
||||
- Standard practice for concurrent SQLite usage
|
||||
- Works with existing WAL mode configuration
|
||||
|
||||
## Test Status
|
||||
|
||||
### ✅ Passing (41/42 packages)
|
||||
All packages except `internal/vector/hybrid` pass successfully:
|
||||
- `internal/db/gorm` - All tests pass including CleanupOldObservations
|
||||
- `internal/vector/sqlitevec` - All vector operations work
|
||||
- `internal/search` - Search and ranking tests pass
|
||||
- `internal/worker` - HTTP handlers and session management pass
|
||||
- All other packages pass
|
||||
|
||||
### ⚠️ Known Limitation (1/42 packages)
|
||||
**Package:** `internal/vector/hybrid`
|
||||
**Status:** Cannot compile tests on macOS ARM64 (CGO linking issue)
|
||||
**Impact:** Local development only - does NOT affect:
|
||||
- Linux CI (tests pass normally on ubuntu-latest)
|
||||
- Production builds or runtime functionality
|
||||
- Any other package
|
||||
|
||||
See `.github/TESTING.md` and `internal/vector/hybrid/README.md` for details.
|
||||
|
||||
## Configuration Summary
|
||||
|
||||
### CI Workflow (`.github/workflows/ci.yaml`)
|
||||
```yaml
|
||||
jobs:
|
||||
pr-checks:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
|
||||
with:
|
||||
go-version: ">=1.24"
|
||||
lfs: true
|
||||
build-tags: "fts5" # ← Required for SQLite FTS5
|
||||
```
|
||||
|
||||
### Database Configuration (`internal/db/gorm/store.go`)
|
||||
```go
|
||||
PRAGMA journal_mode=WAL // Concurrent reads
|
||||
PRAGMA synchronous=NORMAL // Performance balance
|
||||
PRAGMA busy_timeout=5000 // Retry on lock (5s)
|
||||
```
|
||||
|
||||
### Test Command
|
||||
```bash
|
||||
CGO_ENABLED=1 go test -tags "fts5" -v ./...
|
||||
```
|
||||
|
||||
## Commits
|
||||
|
||||
1. **90ab909** - "fix: add fts5 build tag to CI workflow"
|
||||
2. **19514bd** - "docs: add testing documentation and macOS ARM64 known issue"
|
||||
3. **a274f1b** - "fix: add SQLite busy_timeout to prevent database locked errors"
|
||||
|
||||
## Verification
|
||||
|
||||
### Local Tests (macOS ARM64)
|
||||
```
|
||||
✅ 41/42 packages pass
|
||||
❌ 1/42 (hybrid) - known macOS linking issue
|
||||
```
|
||||
|
||||
### Expected CI Status (Linux)
|
||||
```
|
||||
✅ All packages should pass on ubuntu-latest
|
||||
✅ No "database is locked" errors
|
||||
✅ Proper CGO and FTS5 support
|
||||
```
|
||||
|
||||
## No Functionality Removed
|
||||
|
||||
All fixes are **additive only**:
|
||||
- ✅ Build tag added (enables FTS5 support)
|
||||
- ✅ Timeout added (prevents race conditions)
|
||||
- ✅ Documentation added (explains limitations)
|
||||
- ❌ No code removed
|
||||
- ❌ No features disabled
|
||||
- ❌ No tests skipped
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Monitor CI** - Next run should show all tests passing
|
||||
2. **Verify on Linux** - Hybrid tests should work on ubuntu-latest
|
||||
3. **Production deployment** - All changes are safe for production
|
||||
|
||||
## References
|
||||
|
||||
- Original failure: https://github.com/lukaszraczylo/claude-mnemonic/actions/runs/20796678904
|
||||
- PR #20: https://github.com/lukaszraczylo/claude-mnemonic/pull/20
|
||||
- shared-actions fixes: commit 8f7f235
|
||||
@@ -1,113 +0,0 @@
|
||||
# CI Test Fixes - Final Resolution
|
||||
|
||||
## All Issues Fixed ✅
|
||||
|
||||
### Issue #1: Missing Build Tags (commit 90ab909)
|
||||
**Problem:** Tests failed because `sqlite-vec-go-bindings` requires `-tags "fts5"` for SQLite FTS5 support.
|
||||
|
||||
**Solution:** Added `build-tags: "fts5"` to CI workflow.
|
||||
|
||||
### Issue #2: Database Locked Errors (commit a274f1b)
|
||||
**Problem:** `TestObservationStore_CleanupOldObservations` failed with "database is locked" errors.
|
||||
|
||||
**Solution:** Added `PRAGMA busy_timeout=5000` to allow SQLite to retry on lock contention.
|
||||
|
||||
### Issue #3: Hybrid Tests Linking Failure (commit 57e0db5) ⭐
|
||||
**Problem:** Hybrid package tests failed to link on all platforms with "undefined symbols" errors.
|
||||
|
||||
**Root Cause:**
|
||||
- Hybrid tests import `sqlitevec` package
|
||||
- `sqlitevec` depends on `sqlite-vec-go-bindings/cgo` (CGO code)
|
||||
- Test binary linker needs SQLite symbols
|
||||
- Missing blank import of `mattn/go-sqlite3` driver
|
||||
|
||||
**Solution:** Added `_ "github.com/mattn/go-sqlite3"` import to hybrid test files.
|
||||
|
||||
## Final Test Status
|
||||
|
||||
### ✅ All 42/42 Packages Pass
|
||||
|
||||
```bash
|
||||
✅ internal/chunking
|
||||
✅ internal/chunking/golang
|
||||
✅ internal/config
|
||||
✅ internal/db/gorm
|
||||
✅ internal/embedding
|
||||
✅ internal/mcp
|
||||
✅ internal/pattern
|
||||
✅ internal/privacy
|
||||
✅ internal/reranking
|
||||
✅ internal/scoring
|
||||
✅ internal/search
|
||||
✅ internal/search/expansion
|
||||
✅ internal/vector/hybrid ← NOW FIXED!
|
||||
✅ internal/vector/sqlitevec
|
||||
✅ internal/worker
|
||||
✅ internal/worker/sdk
|
||||
✅ internal/worker/session
|
||||
✅ internal/worker/sse
|
||||
✅ pkg/hooks
|
||||
✅ pkg/models
|
||||
✅ pkg/similarity
|
||||
```
|
||||
|
||||
**Test Command:** `CGO_ENABLED=1 go test -tags "fts5" -race ./...`
|
||||
|
||||
**All platforms work:** macOS ARM64, Linux (ubuntu-latest), Windows
|
||||
|
||||
## Commits Applied
|
||||
|
||||
1. **90ab909** - Added fts5 build tag to CI workflow
|
||||
2. **19514bd** - Added documentation (later removed as obsolete)
|
||||
3. **a274f1b** - Fixed SQLite busy_timeout for concurrent writes
|
||||
4. **712bf2b** - Documentation (later removed as obsolete)
|
||||
5. **57e0db5** - ⭐ Fixed hybrid tests CGO linking (critical fix)
|
||||
6. **187be22** - Removed outdated documentation
|
||||
|
||||
## Key Insight
|
||||
|
||||
The issue wasn't macOS-specific - it was a missing driver import that affected all platforms. The `sqlitevec` tests had the correct import pattern, but the newly-added `hybrid` tests didn't follow the same pattern.
|
||||
|
||||
## Configuration Summary
|
||||
|
||||
### CI Workflow
|
||||
```yaml
|
||||
build-tags: "fts5" # Required for SQLite FTS5
|
||||
CGO_ENABLED: 1 # Set by shared-actions
|
||||
```
|
||||
|
||||
### Database Configuration
|
||||
```go
|
||||
PRAGMA journal_mode=WAL
|
||||
PRAGMA synchronous=NORMAL
|
||||
PRAGMA busy_timeout=5000
|
||||
```
|
||||
|
||||
### Test Files Pattern
|
||||
```go
|
||||
import (
|
||||
_ "github.com/mattn/go-sqlite3" // Required for CGO linking
|
||||
)
|
||||
```
|
||||
|
||||
## No Functionality Removed
|
||||
|
||||
All fixes are **additive only:**
|
||||
- ✅ Build tags added
|
||||
- ✅ Timeouts added
|
||||
- ✅ Driver imports added
|
||||
- ❌ No code removed
|
||||
- ❌ No features disabled
|
||||
- ❌ No tests skipped
|
||||
|
||||
## Expected CI Status
|
||||
|
||||
**Next CI run should show:**
|
||||
- ✅ All 42/42 packages pass
|
||||
- ✅ Full test coverage maintained
|
||||
- ✅ Race detector enabled
|
||||
- ✅ All platforms supported
|
||||
|
||||
## Credit
|
||||
|
||||
Thanks to the reviewer for catching the potential `-race` flag issue with hybrid tests! This led to discovering and fixing the missing SQLite driver import.
|
||||
@@ -1,63 +0,0 @@
|
||||
# CI Test Failure Fix Summary
|
||||
|
||||
## Problem
|
||||
|
||||
Tests were failing in GitHub Actions for PR #20 because the `go-pr.yaml` shared workflow didn't support:
|
||||
1. CGO_ENABLED=1 (required for sqlite-vec-go-bindings)
|
||||
2. Build tags `-tags "fts5"` (required for SQLite FTS5 support)
|
||||
|
||||
## Root Cause
|
||||
|
||||
The hybrid vector storage feature in PR #20 depends on:
|
||||
- `github.com/asg017/sqlite-vec-go-bindings/cgo` - requires CGO
|
||||
- SQLite with FTS5 support - requires `-tags "fts5"` build flag
|
||||
|
||||
The shared workflow was running `go test` without these requirements.
|
||||
|
||||
## Solution
|
||||
|
||||
### 1. Updated shared-actions (commit 8f7f235)
|
||||
|
||||
**`.github/actions/go-test/action.yml`**
|
||||
- Added `build-tags` input parameter
|
||||
- Modified test command to use tags when provided
|
||||
|
||||
**`.github/workflows/go-pr.yaml`**
|
||||
- Added `build-tags` input parameter
|
||||
- Set `CGO_ENABLED: 1` in test job
|
||||
- Pass tags to test command
|
||||
|
||||
**`.github/workflows/go-release-cgo.yaml`**
|
||||
- Pass `build-tags: "fts5"` to go-test action
|
||||
|
||||
### 2. Updated claude-mnemonic (commit 90ab909)
|
||||
|
||||
**`.github/workflows/ci.yaml`**
|
||||
- Pass `build-tags: "fts5"` to shared workflow
|
||||
|
||||
## What Was Already Working
|
||||
|
||||
The `workflow-prepare.sh` script already handled:
|
||||
- Downloading ONNX runtime libraries
|
||||
- Setting up SQLite on Windows for CGO
|
||||
|
||||
## Testing Status
|
||||
|
||||
✅ **Linux CI** - Should now pass (ubuntu-latest in GitHub Actions)
|
||||
⚠️ **macOS Local** - Still has linking issues (macOS-specific sqlite-vec-go-bindings problem)
|
||||
|
||||
The macOS local testing issue is unrelated to CI and is caused by how sqlite-vec-go-bindings links on macOS ARM64 with Homebrew Go. This doesn't affect CI since it runs on Linux.
|
||||
|
||||
## Verification
|
||||
|
||||
The next CI run for PR #20 should pass. The workflow will:
|
||||
1. Run `workflow-prepare.sh` to download ONNX libs
|
||||
2. Run `go test -tags "fts5" -race -coverprofile=coverage.out -covermode=atomic ./...` with CGO_ENABLED=1
|
||||
3. All packages including `internal/vector/hybrid` should compile and test successfully
|
||||
|
||||
## References
|
||||
|
||||
- PR #20: https://github.com/lukaszraczylo/claude-mnemonic/pull/20
|
||||
- Failed CI run: https://github.com/lukaszraczylo/claude-mnemonic/actions/runs/20795930707/job/59729327008
|
||||
- shared-actions fix: https://github.com/lukaszraczylo/shared-actions/commit/8f7f235
|
||||
- claude-mnemonic fix: https://github.com/lukaszraczylo/claude-mnemonic/commit/90ab909
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Update dependencies
|
||||
name: Autoupdate go.mod and go.sum
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -6,16 +6,15 @@ on:
|
||||
- cron: "0 3 * * *"
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
issues: write
|
||||
actions: write
|
||||
pull-requests: write
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
update:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main
|
||||
autoupdate:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
|
||||
with:
|
||||
go-version: ">=1.24"
|
||||
node-working-directory: ui
|
||||
require-node-tests: false
|
||||
release-workflow: release.yaml
|
||||
release-workflow: "release.yaml"
|
||||
secrets: inherit
|
||||
|
||||
@@ -20,6 +20,4 @@ jobs:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
|
||||
with:
|
||||
go-version: ">=1.24"
|
||||
lfs: false
|
||||
build-tags: "fts5"
|
||||
secrets: inherit
|
||||
|
||||
+102
-103
@@ -1,121 +1,120 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-release-cgo.yaml@main
|
||||
release:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-release-cgo.yaml@main
|
||||
with:
|
||||
go-version: ">=1.24"
|
||||
node-enabled: true
|
||||
node-version: "20"
|
||||
node-build-script: "cd ui && npm ci && npm run build"
|
||||
node-cache-dependency-path: "ui/package-lock.json"
|
||||
node-output-path: "ui/dist"
|
||||
node-embed-path: "internal/worker/static"
|
||||
secrets: inherit
|
||||
|
||||
commit-marketplace:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
go-version: ">=1.24"
|
||||
node-enabled: true
|
||||
node-version: "22"
|
||||
node-build-script: "cd ui && npm ci && npm run build"
|
||||
node-cache-dependency-path: "ui/package-lock.json"
|
||||
node-output-path: "ui/dist"
|
||||
node-embed-path: "internal/worker/static"
|
||||
lfs: false
|
||||
secrets: inherit
|
||||
ref: main
|
||||
|
||||
commit-marketplace:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: main
|
||||
- name: Download checksums
|
||||
run: |
|
||||
VERSION="${{ needs.release.outputs.version_tag }}"
|
||||
curl -sSL -o checksums.txt \
|
||||
"https://github.com/lukaszraczylo/claude-mnemonic/releases/download/${VERSION}/checksums.txt"
|
||||
|
||||
- name: Download checksums
|
||||
run: |
|
||||
VERSION="${{ needs.release.outputs.version_tag }}"
|
||||
curl -sSL -o checksums.txt \
|
||||
"https://github.com/lukaszraczylo/claude-mnemonic/releases/download/${VERSION}/checksums.txt"
|
||||
- name: Generate marketplace.json with checksums
|
||||
run: |
|
||||
VERSION="${{ needs.release.outputs.version }}"
|
||||
VERSION_TAG="${{ needs.release.outputs.version_tag }}"
|
||||
TODAY=$(date -u +%Y-%m-%d)
|
||||
|
||||
- name: Generate marketplace.json with checksums
|
||||
run: |
|
||||
VERSION="${{ needs.release.outputs.version }}"
|
||||
VERSION_TAG="${{ needs.release.outputs.version_tag }}"
|
||||
TODAY=$(date -u +%Y-%m-%d)
|
||||
# Extract checksums from checksums.txt
|
||||
SHA_DARWIN_ARM64=$(grep "darwin_arm64" checksums.txt | awk '{print $1}')
|
||||
SHA_LINUX_AMD64=$(grep "linux_amd64" checksums.txt | awk '{print $1}')
|
||||
SHA_WINDOWS_AMD64=$(grep "windows_amd64" checksums.txt | awk '{print $1}')
|
||||
|
||||
# Extract checksums from checksums.txt
|
||||
SHA_DARWIN_ARM64=$(grep "darwin_arm64" checksums.txt | awk '{print $1}')
|
||||
SHA_LINUX_AMD64=$(grep "linux_amd64" checksums.txt | awk '{print $1}')
|
||||
SHA_WINDOWS_AMD64=$(grep "windows_amd64" checksums.txt | awk '{print $1}')
|
||||
|
||||
# Generate marketplace.json
|
||||
cat > marketplace.json << EOF
|
||||
{
|
||||
"\$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "claude-mnemonic",
|
||||
"version": "1.0.0",
|
||||
"description": "Persistent memory system for Claude Code - stores observations, session summaries, and user prompts with semantic search",
|
||||
"owner": {
|
||||
"name": "lukaszraczylo",
|
||||
"email": "[email protected]",
|
||||
"url": "https://github.com/lukaszraczylo"
|
||||
},
|
||||
"repository": "https://github.com/lukaszraczylo/claude-mnemonic",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "claude-mnemonic",
|
||||
"description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB vector search",
|
||||
"version": "${VERSION}",
|
||||
"author": {
|
||||
"name": "lukaszraczylo",
|
||||
"url": "https://github.com/lukaszraczylo"
|
||||
# Generate marketplace.json
|
||||
cat > marketplace.json << EOF
|
||||
{
|
||||
"\$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "claude-mnemonic",
|
||||
"version": "1.0.0",
|
||||
"description": "Persistent memory system for Claude Code - stores observations, session summaries, and user prompts with semantic search",
|
||||
"owner": {
|
||||
"name": "lukaszraczylo",
|
||||
"email": "[email protected]",
|
||||
"url": "https://github.com/lukaszraczylo"
|
||||
},
|
||||
"repository": "https://github.com/lukaszraczylo/claude-mnemonic",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "claude-mnemonic",
|
||||
"description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB vector search",
|
||||
"version": "${VERSION}",
|
||||
"author": {
|
||||
"name": "lukaszraczylo",
|
||||
"url": "https://github.com/lukaszraczylo"
|
||||
},
|
||||
"category": "productivity",
|
||||
"tags": ["memory", "persistence", "search", "context"],
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/lukaszraczylo/claude-mnemonic",
|
||||
"releases": {
|
||||
"latest": "${VERSION}",
|
||||
"versions": {
|
||||
"${VERSION}": {
|
||||
"releaseDate": "${TODAY}",
|
||||
"downloads": {
|
||||
"darwin-arm64": {
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/${VERSION_TAG}/claude-mnemonic_${VERSION}_darwin_arm64.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"sha256": "${SHA_DARWIN_ARM64}"
|
||||
},
|
||||
"category": "productivity",
|
||||
"tags": ["memory", "persistence", "search", "context"],
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/lukaszraczylo/claude-mnemonic",
|
||||
"releases": {
|
||||
"latest": "${VERSION}",
|
||||
"versions": {
|
||||
"${VERSION}": {
|
||||
"releaseDate": "${TODAY}",
|
||||
"downloads": {
|
||||
"darwin-arm64": {
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/${VERSION_TAG}/claude-mnemonic_${VERSION}_darwin_arm64.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"sha256": "${SHA_DARWIN_ARM64}"
|
||||
},
|
||||
"linux-amd64": {
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/${VERSION_TAG}/claude-mnemonic_${VERSION}_linux_amd64.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"sha256": "${SHA_LINUX_AMD64}"
|
||||
},
|
||||
"windows-amd64": {
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/${VERSION_TAG}/claude-mnemonic_${VERSION}_windows_amd64.zip",
|
||||
"format": "zip",
|
||||
"sha256": "${SHA_WINDOWS_AMD64}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"linux-amd64": {
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/${VERSION_TAG}/claude-mnemonic_${VERSION}_linux_amd64.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"sha256": "${SHA_LINUX_AMD64}"
|
||||
},
|
||||
"windows-amd64": {
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/${VERSION_TAG}/claude-mnemonic_${VERSION}_windows_amd64.zip",
|
||||
"format": "zip",
|
||||
"sha256": "${SHA_WINDOWS_AMD64}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "Generated marketplace.json:"
|
||||
cat marketplace.json
|
||||
echo "Generated marketplace.json:"
|
||||
cat marketplace.json
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add marketplace.json
|
||||
git diff --staged --quiet || git commit -m "chore: update marketplace for ${{ needs.release.outputs.version_tag }}"
|
||||
git push
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add marketplace.json
|
||||
git diff --staged --quiet || git commit -m "chore: update marketplace for ${{ needs.release.outputs.version_tag }}"
|
||||
git push
|
||||
|
||||
@@ -22,12 +22,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'docs/package-lock.json'
|
||||
|
||||
|
||||
+1
-16
@@ -1,7 +1,5 @@
|
||||
# Binaries
|
||||
bin/
|
||||
/mcp
|
||||
/worker
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
@@ -81,20 +79,7 @@ logs/
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Model files (downloaded at runtime from GitHub Releases)
|
||||
# Test model data (downloaded from Hugging Face)
|
||||
testdata/models/
|
||||
internal/embedding/assets/model.onnx
|
||||
internal/reranking/assets/model.onnx
|
||||
|
||||
# goreleaser
|
||||
dist/
|
||||
docs/dist
|
||||
|
||||
# Non-template plugin configs (keep only .tpl files in plugin/ dir)
|
||||
plugin/.claude-plugin/plugin.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
|
||||
.claude-plugin
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gosec
|
||||
- govet
|
||||
- staticcheck
|
||||
- unused
|
||||
- ineffassign
|
||||
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
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
@@ -266,9 +266,6 @@ archives:
|
||||
- src: plugin/hooks/hooks.json
|
||||
dst: hooks
|
||||
strip_parent: true
|
||||
- src: plugin/commands/*
|
||||
dst: commands
|
||||
strip_parent: true
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats:
|
||||
|
||||
@@ -5,7 +5,6 @@ VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev
|
||||
LDFLAGS := -ldflags "-X main.Version=$(VERSION) -X github.com/lukaszraczylo/claude-mnemonic/pkg/hooks.Version=$(VERSION) -s -w" -buildvcs=false
|
||||
BUILD_DIR := bin
|
||||
PLUGIN_DIR := plugin
|
||||
STABLE_BIN := $(HOME)/.claude-mnemonic/bin
|
||||
|
||||
# Go settings
|
||||
GOOS ?= $(shell go env GOOS)
|
||||
@@ -15,7 +14,7 @@ GOARCH ?= $(shell go env GOARCH)
|
||||
export CGO_ENABLED=1
|
||||
BUILD_TAGS := -tags "fts5"
|
||||
|
||||
.PHONY: all build clean test install lint hooks worker mcp stop-worker start-worker restart-worker dashboard website dev-website setup-libs update-version
|
||||
.PHONY: all build clean test install lint hooks worker mcp stop-worker start-worker restart-worker dashboard website dev-website setup-libs
|
||||
|
||||
all: build
|
||||
|
||||
@@ -23,23 +22,8 @@ all: build
|
||||
setup-libs:
|
||||
@./scripts/download-onnx-libs.sh all
|
||||
|
||||
# Download ONNX models from Hugging Face (for local dev, skips if present)
|
||||
setup-models:
|
||||
@./scripts/download-models.sh
|
||||
|
||||
# Update version in committed plugin metadata
|
||||
update-version:
|
||||
@if [ -f .claude-plugin/plugin.json ]; then \
|
||||
sed 's/"version": "[^"]*"/"version": "$(VERSION)"/' .claude-plugin/plugin.json > .claude-plugin/plugin.json.tmp && mv .claude-plugin/plugin.json.tmp .claude-plugin/plugin.json; \
|
||||
echo "Updated .claude-plugin/plugin.json to version $(VERSION)"; \
|
||||
fi
|
||||
@if [ -f marketplace.json ]; then \
|
||||
sed 's/"version": "[^"]*"/"version": "$(VERSION)"/' marketplace.json > marketplace.json.tmp && mv marketplace.json.tmp marketplace.json; \
|
||||
echo "Updated marketplace.json version fields to $(VERSION)"; \
|
||||
fi
|
||||
|
||||
# Build all binaries
|
||||
build: setup-libs update-version dashboard worker hooks mcp
|
||||
build: setup-libs dashboard worker hooks mcp
|
||||
|
||||
# Build Vue dashboard
|
||||
dashboard:
|
||||
@@ -124,12 +108,7 @@ build-windows:
|
||||
# Stop any running worker
|
||||
stop-worker:
|
||||
@echo "Stopping worker..."
|
||||
@-pkill -TERM -f 'claude-mnemonic.*worker' 2>/dev/null || true
|
||||
@-pkill -TERM -f '\.claude-mnemonic/bin/worker' 2>/dev/null || true
|
||||
@-pkill -TERM -f '\.claude/plugins/.*/worker' 2>/dev/null || true
|
||||
@sleep 1
|
||||
@-pkill -9 -f 'claude-mnemonic.*worker' 2>/dev/null || true
|
||||
@-pkill -9 -f '\.claude-mnemonic/bin/worker' 2>/dev/null || true
|
||||
@-pkill -9 -f '\.claude/plugins/.*/worker' 2>/dev/null || true
|
||||
@-lsof -ti :37777 | xargs kill -9 2>/dev/null || true
|
||||
@sleep 1
|
||||
@@ -137,7 +116,12 @@ stop-worker:
|
||||
# Start worker in background
|
||||
start-worker:
|
||||
@echo "Starting worker..."
|
||||
@nohup $(STABLE_BIN)/worker > /tmp/claude-mnemonic-worker.log 2>&1 &
|
||||
@# Prefer cache directory (where Claude Code looks), fall back to marketplaces
|
||||
@if [ -f "$(HOME)/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/$(VERSION)/worker" ]; then \
|
||||
nohup $(HOME)/.claude/plugins/cache/claude-mnemonic/claude-mnemonic/$(VERSION)/worker > /tmp/claude-mnemonic-worker.log 2>&1 & \
|
||||
else \
|
||||
nohup $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/worker > /tmp/claude-mnemonic-worker.log 2>&1 & \
|
||||
fi
|
||||
@sleep 1
|
||||
@if curl -s http://localhost:37777/health > /dev/null 2>&1; then \
|
||||
echo "Worker started successfully (http://localhost:37777)"; \
|
||||
@@ -148,31 +132,19 @@ start-worker:
|
||||
# Restart worker
|
||||
restart-worker: stop-worker start-worker
|
||||
|
||||
# Install to stable binary location and register with Claude Code
|
||||
# Install to Claude plugins directory
|
||||
install: build stop-worker
|
||||
@echo "Installing claude-mnemonic..."
|
||||
@# Verify build output binaries exist
|
||||
@test -f $(BUILD_DIR)/worker || { echo "ERROR: $(BUILD_DIR)/worker not found. Build may have failed."; exit 1; }
|
||||
@test -f $(BUILD_DIR)/mcp-server || { echo "ERROR: $(BUILD_DIR)/mcp-server not found. Build may have failed."; exit 1; }
|
||||
@test -d $(BUILD_DIR)/hooks || { echo "ERROR: $(BUILD_DIR)/hooks not found. Build may have failed."; exit 1; }
|
||||
@# Install binaries to stable location (survives Claude Code updates)
|
||||
@mkdir -p $(STABLE_BIN)/hooks
|
||||
cp $(BUILD_DIR)/worker $(STABLE_BIN)/
|
||||
cp $(BUILD_DIR)/mcp-server $(STABLE_BIN)/
|
||||
cp $(BUILD_DIR)/hooks/* $(STABLE_BIN)/hooks/
|
||||
@echo "Binaries installed to $(STABLE_BIN)"
|
||||
@# Set up marketplace directory with wrapper scripts and metadata
|
||||
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin
|
||||
@echo "Installing to Claude plugins directory..."
|
||||
@# Install to marketplaces directory (for direct installs)
|
||||
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks
|
||||
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands
|
||||
@cp .claude-plugin/plugin.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/
|
||||
@cp marketplace.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/marketplace.json 2>/dev/null || true
|
||||
@cp hooks/hooks.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
|
||||
@cp hooks/session-start hooks/user-prompt hooks/post-tool-use hooks/stop hooks/subagent-stop hooks/statusline $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
|
||||
@chmod +x $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/*
|
||||
@cp mcp-server $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
|
||||
@chmod +x $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/mcp-server
|
||||
@cp commands/restart.md $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/commands/
|
||||
@mkdir -p $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin
|
||||
cp $(BUILD_DIR)/worker $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
|
||||
cp $(BUILD_DIR)/mcp-server $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/
|
||||
cp $(BUILD_DIR)/hooks/* $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
|
||||
cp $(PLUGIN_DIR)/hooks/hooks.json $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/hooks/
|
||||
@# Update plugin.json and marketplace.json with current version to prevent stale version directories
|
||||
@sed 's/"version": "[^"]*"/"version": "$(VERSION)"/g' $(PLUGIN_DIR)/.claude-plugin/plugin.json > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/plugin.json
|
||||
@sed 's/"version": "[^"]*"/"version": "$(VERSION)"/g' $(PLUGIN_DIR)/.claude-plugin/marketplace.json > $(HOME)/.claude/plugins/marketplaces/claude-mnemonic/.claude-plugin/marketplace.json
|
||||
@echo "Registering plugin with Claude Code..."
|
||||
@./scripts/register-plugin.sh "$(VERSION)"
|
||||
@$(MAKE) start-worker
|
||||
@@ -185,17 +157,14 @@ uninstall: stop-worker
|
||||
rm -rf $(HOME)/.claude/plugins/marketplaces/claude-mnemonic
|
||||
@echo "Uninstallation complete!"
|
||||
|
||||
# Run tests (with FTS5 support)
|
||||
# CLAUDE_MNEMONIC_MODEL_DIR points to a common model directory for tests.
|
||||
# Run setup-models first to download models from Hugging Face.
|
||||
test: setup-libs setup-models
|
||||
CLAUDE_MNEMONIC_MODEL_DIR=$$(pwd)/testdata/models go test $(BUILD_TAGS) -v -race ./...
|
||||
# Run tests
|
||||
test: setup-libs
|
||||
go test -v -race ./...
|
||||
|
||||
# Run tests with coverage (with FTS5 support)
|
||||
test-coverage: setup-libs setup-models
|
||||
CLAUDE_MNEMONIC_MODEL_DIR=$$(pwd)/testdata/models go test $(BUILD_TAGS) -v -race -coverprofile=coverage.out ./...
|
||||
# Run tests with coverage
|
||||
test-coverage:
|
||||
go test -v -race -coverprofile=coverage.out ./...
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
@go tool cover -func=coverage.out | tail -1
|
||||
|
||||
# Run benchmarks
|
||||
bench:
|
||||
@@ -255,7 +224,6 @@ help:
|
||||
@echo " make fmt - Format code"
|
||||
@echo " make clean - Clean build artifacts"
|
||||
@echo " make dev - Run worker in development mode"
|
||||
@echo " make update-version - Update version in plugin metadata files"
|
||||
@echo " make deps - Download dependencies"
|
||||
@echo " make website - Build website for production"
|
||||
@echo " make dev-website - Run website dev server"
|
||||
|
||||
@@ -12,30 +12,6 @@ Claude Code forgets everything when your session ends. Claude Mnemonic fixes tha
|
||||
|
||||
It captures what Claude learns during your coding sessions - bug fixes, architecture decisions, patterns that work - and brings that knowledge back in future conversations. No more re-explaining your codebase.
|
||||
|
||||

|
||||
|
||||
## What's New in v0.7
|
||||
|
||||
- **Two-Stage Retrieval** - Cross-encoder reranking for dramatically improved search relevance
|
||||
- **Knowledge Graph** - Automatic relationship detection between observations with visual graph in dashboard
|
||||

|
||||
- **Pattern Detection** - Identifies recurring patterns across sessions and projects
|
||||
- **Importance Scoring** - Time decay and voting system to surface the most valuable memories
|
||||
- **Query Expansion** - Reformulates searches to find semantically related content
|
||||
- **Conflict Detection** - Identifies and resolves contradictory observations
|
||||
- **Observation Lifecycle** - Memories can be superseded when better information arrives
|
||||
|
||||
<details>
|
||||
<summary>Previous: v0.6</summary>
|
||||
|
||||
- **Auto-Updates** - Automatically stays up-to-date with the latest version
|
||||
- **Slash Command: `/restart`** - Restart the worker directly from Claude Code
|
||||
- **Local Embeddings** - All semantic search runs locally via ONNX Runtime (no external API calls)
|
||||
- **Async Queue Processing** - Non-blocking observation capture for faster sessions
|
||||
- **Smarter Storage** - Filters out system/agent summaries to keep knowledge relevant
|
||||
- **Improved Reliability** - Better handling of connectivity issues and dead connections
|
||||
</details>
|
||||
|
||||
## Requirements
|
||||
|
||||
| Dependency | Required | Purpose |
|
||||
@@ -97,11 +73,9 @@ cosign verify-blob \
|
||||
| **Persistent Memory** | Observations survive across sessions and restarts |
|
||||
| **Project Isolation** | Each project has its own knowledge base |
|
||||
| **Global Patterns** | Best practices are shared across all projects |
|
||||
| **Semantic Search** | Find relevant context with natural language (local embeddings) |
|
||||
| **Semantic Search** | Find relevant context with natural language |
|
||||
| **Live Statusline** | Real-time metrics in Claude Code: `[mnemonic] ● served:42 | project:28 memories` |
|
||||
| **Web Dashboard** | Browse and manage memories at `localhost:37777` |
|
||||
| **Auto-Updates** | Automatically downloads and applies new versions |
|
||||
| **Slash Commands** | Control the worker directly from Claude Code |
|
||||
|
||||
### How knowledge flows
|
||||
|
||||
@@ -125,39 +99,16 @@ Config file: `~/.claude-mnemonic/settings.json`
|
||||
{
|
||||
"CLAUDE_MNEMONIC_WORKER_PORT": 37777,
|
||||
"CLAUDE_MNEMONIC_CONTEXT_OBSERVATIONS": 100,
|
||||
"CLAUDE_MNEMONIC_CONTEXT_FULL_COUNT": 25,
|
||||
"CLAUDE_MNEMONIC_RERANKING_ENABLED": true
|
||||
"CLAUDE_MNEMONIC_CONTEXT_FULL_COUNT": 25
|
||||
}
|
||||
```
|
||||
|
||||
### Core Settings
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|----------|---------|--------------|
|
||||
| `WORKER_PORT` | `37777` | Dashboard & API port |
|
||||
| `CONTEXT_OBSERVATIONS` | `100` | Max memories per session |
|
||||
| `CONTEXT_FULL_COUNT` | `25` | Full detail memories (rest are condensed) |
|
||||
| `CONTEXT_FULL_COUNT` | `25` | Full detail memories (rest are condensed to title only) |
|
||||
| `CONTEXT_SESSION_COUNT` | `10` | Recent sessions to reference |
|
||||
| `CONTEXT_RELEVANCE_THRESHOLD` | `0.3` | Minimum similarity score (0.0-1.0) for inclusion |
|
||||
| `CONTEXT_MAX_PROMPT_RESULTS` | `10` | Max results per prompt search |
|
||||
|
||||
### Reranking Settings (Two-Stage Retrieval)
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|----------|---------|--------------|
|
||||
| `RERANKING_ENABLED` | `true` | Enable cross-encoder reranking |
|
||||
| `RERANKING_CANDIDATES` | `100` | Candidates to retrieve before reranking |
|
||||
| `RERANKING_RESULTS` | `10` | Final results after reranking |
|
||||
| `RERANKING_ALPHA` | `0.7` | Score blend: alpha×rerank + (1-alpha)×original |
|
||||
| `RERANKING_PURE_MODE` | `false` | Use pure cross-encoder scores only |
|
||||
|
||||
### Embedding Settings
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|----------|---------|--------------|
|
||||
| `EMBEDDING_MODEL` | `bge-v1.5` | Embedding model for semantic search |
|
||||
|
||||
All variables are prefixed with `CLAUDE_MNEMONIC_` in the config file.
|
||||
|
||||
## Project vs Global scope
|
||||
|
||||
@@ -172,38 +123,13 @@ Example: A bug fix in your auth module stays local. "Always validate JWT server-
|
||||
|
||||
## MCP Tools
|
||||
|
||||
Four tools are exposed via MCP:
|
||||
These search tools are available via MCP:
|
||||
|
||||
- `search` - semantic search across all memories. Accepts `obs_type` (e.g.
|
||||
`decision`, `code_change`, `architecture`) plus filter params (`concepts`,
|
||||
`files`, `type`) - these replace the old per-type shortcut tools.
|
||||
- `timeline` - browse observations around a point in time.
|
||||
- `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
|
||||
|
||||
Available commands within Claude Code:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/restart` | Restart the worker process when experiencing issues |
|
||||
|
||||
## Auto-Updates
|
||||
|
||||
Claude Mnemonic automatically checks for updates and applies them. Updates are downloaded in the background and applied on restart.
|
||||
|
||||
- Automatic update checks on startup
|
||||
- Background downloads (up to 250MB)
|
||||
- Seamless restart after update
|
||||
- Manual trigger: `curl -X POST http://127.0.0.1:37777/api/update/apply`
|
||||
|
||||
Check update status: `curl http://127.0.0.1:37777/api/update/status`
|
||||
- `search` - semantic search across all memories
|
||||
- `timeline` - browse by time
|
||||
- `decisions` - find architecture decisions
|
||||
- `changes` - find code modifications
|
||||
- `how_it_works` - system understanding queries
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -218,20 +144,6 @@ cat /tmp/claude-mnemonic-worker.log # view logs
|
||||
rm -f ~/.claude-mnemonic/*.db-wal ~/.claude-mnemonic/*.db-shm
|
||||
```
|
||||
|
||||
**Worker unresponsive?**
|
||||
```bash
|
||||
# Restart via API
|
||||
curl -X POST http://127.0.0.1:37777/api/restart
|
||||
|
||||
# Or use the slash command in Claude Code
|
||||
/restart
|
||||
```
|
||||
|
||||
**Check health status:**
|
||||
```bash
|
||||
curl http://127.0.0.1:37777/api/selfcheck
|
||||
```
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
@@ -246,11 +158,10 @@ curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/s
|
||||
|
||||
- **SQLite + FTS5** - Full-text search for exact matches
|
||||
- **sqlite-vec** - Vector database embedded in SQLite
|
||||
- **Two-Stage Retrieval** - Bi-encoder (embedding) + cross-encoder (reranking) for high accuracy
|
||||
- **Local Models** - all-MiniLM-L6-v2 for embeddings, BGE reranker for relevance scoring
|
||||
- **all-MiniLM-L6-v2** - Local embedding model (384 dimensions) via ONNX Runtime
|
||||
- **Go** - Single binary, no external dependencies
|
||||
|
||||
Everything runs locally. No Python. No external vector database. No API calls.
|
||||
Everything runs locally. No Python. No external vector database. No API calls for embeddings.
|
||||
|
||||
## Platform support
|
||||
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
|
||||
)
|
||||
@@ -19,81 +17,22 @@ type Input struct {
|
||||
ToolUseID string `json:"tool_use_id"`
|
||||
}
|
||||
|
||||
// skipTools lists tools that never produce useful observations.
|
||||
// Skip the HTTP call entirely for these to reduce overhead during heavy tool usage.
|
||||
var skipTools = map[string]bool{
|
||||
// Internal tracking tools (but NOT TodoWrite - it captures planned work)
|
||||
"Task": true,
|
||||
"TaskOutput": true,
|
||||
|
||||
// File discovery tools (just listings, no insights)
|
||||
"Glob": true,
|
||||
"ListDir": true,
|
||||
"LS": true,
|
||||
"KillShell": true,
|
||||
|
||||
// Question/interaction tools (no code insights)
|
||||
"AskUserQuestion": true,
|
||||
|
||||
// Plan mode tools (planning, not execution)
|
||||
"EnterPlanMode": true,
|
||||
"ExitPlanMode": true,
|
||||
|
||||
// Skill/command execution (meta-operations)
|
||||
"Skill": true,
|
||||
"SlashCommand": true,
|
||||
|
||||
// Read is high-volume and rarely produces insights worth the overhead
|
||||
// The processor would skip most reads anyway after filtering
|
||||
"Read": true,
|
||||
|
||||
// Search tools are for finding, not modifying
|
||||
"Grep": true,
|
||||
"WebSearch": true,
|
||||
}
|
||||
|
||||
func main() {
|
||||
if !hooks.IsWorkerAvailable() {
|
||||
hooks.WriteResponse("PostToolUse", true)
|
||||
return
|
||||
}
|
||||
hooks.RunHook("PostToolUse", handlePostToolUse)
|
||||
}
|
||||
|
||||
func handlePostToolUse(ctx *hooks.HookContext, input *Input) (string, error) {
|
||||
// Skip HTTP call entirely for tools that never produce useful observations.
|
||||
// This significantly reduces overhead during heavy tool usage.
|
||||
if skipTools[input.ToolName] {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[post-tool-use] %s\n", input.ToolName)
|
||||
|
||||
// Fire-and-forget: send the observation without waiting for the response.
|
||||
// The worker just queues it -- we don't need the response data.
|
||||
// Use a short-lived context to ensure the request body is at least sent
|
||||
// before this process exits.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
sendCtx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
_ = hooks.POSTWithContext(sendCtx, ctx.Port, "/api/sessions/observations", map[string]interface{}{
|
||||
"claudeSessionId": ctx.SessionID,
|
||||
"project": ctx.Project,
|
||||
"tool_name": input.ToolName,
|
||||
"tool_input": input.ToolInput,
|
||||
"tool_response": input.ToolResponse,
|
||||
"cwd": ctx.CWD,
|
||||
})
|
||||
}()
|
||||
// Send observation to worker
|
||||
_, err := hooks.POST(ctx.Port, "/api/sessions/observations", map[string]interface{}{
|
||||
"claudeSessionId": ctx.SessionID,
|
||||
"project": ctx.Project,
|
||||
"tool_name": input.ToolName,
|
||||
"tool_input": input.ToolInput,
|
||||
"tool_response": input.ToolResponse,
|
||||
"cwd": ctx.CWD,
|
||||
})
|
||||
|
||||
// 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
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -2,59 +2,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
|
||||
)
|
||||
|
||||
// Input is the hook input from Claude Code.
|
||||
type Input struct {
|
||||
hooks.BaseInput
|
||||
Source string `json:"source"` // "startup", "resume", "clear", "compact"
|
||||
SessionID string `json:"session_id"`
|
||||
CWD string `json:"cwd"`
|
||||
PermissionMode string `json:"permission_mode"`
|
||||
HookEventName string `json:"hook_event_name"`
|
||||
Source string `json:"source"` // "startup", "resume", "clear", "compact"
|
||||
}
|
||||
|
||||
// Observation represents an observation from the API.
|
||||
type Observation struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Narrative string `json:"narrative"`
|
||||
Facts []string `json:"facts"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if !hooks.IsWorkerAvailable() {
|
||||
// Skip if this is an internal call (from SDK processor)
|
||||
if os.Getenv("CLAUDE_MNEMONIC_INTERNAL") == "1" {
|
||||
hooks.WriteResponse("SessionStart", true)
|
||||
return
|
||||
}
|
||||
hooks.RunHook("SessionStart", handleSessionStart)
|
||||
}
|
||||
|
||||
func handleSessionStart(ctx *hooks.HookContext, input *Input) (string, error) {
|
||||
deadline, cancel := hooks.HookDeadline(30 * time.Second)
|
||||
defer cancel()
|
||||
// Read input from stdin
|
||||
inputData, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
hooks.WriteError("SessionStart", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var input Input
|
||||
if err := json.Unmarshal(inputData, &input); err != nil {
|
||||
hooks.WriteError("SessionStart", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Ensure worker is running
|
||||
port, err := hooks.EnsureWorkerRunning()
|
||||
if err != nil {
|
||||
hooks.WriteError("SessionStart", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Generate unique project ID from CWD (dirname_hash format)
|
||||
project := hooks.ProjectIDWithName(input.CWD)
|
||||
|
||||
// Fetch observations for context injection
|
||||
endpoint := fmt.Sprintf("/api/context/inject?project=%s&cwd=%s",
|
||||
url.QueryEscape(ctx.Project),
|
||||
url.QueryEscape(ctx.CWD))
|
||||
url.QueryEscape(project),
|
||||
url.QueryEscape(input.CWD))
|
||||
|
||||
result, err := hooks.GET(ctx.Port, endpoint)
|
||||
result, err := hooks.GET(port, endpoint)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Warning: context fetch failed: %v\n", err)
|
||||
return "", nil
|
||||
hooks.WriteResponse("SessionStart", true)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse observations from response
|
||||
obsData, ok := result["observations"].([]interface{})
|
||||
if !ok || len(obsData) == 0 {
|
||||
// No observations - just continue normally
|
||||
return "", nil
|
||||
hooks.WriteResponse("SessionStart", true)
|
||||
return
|
||||
}
|
||||
|
||||
// Get full_count from response (how many observations get full detail)
|
||||
@@ -67,21 +91,12 @@ func handleSessionStart(ctx *hooks.HookContext, input *Input) (string, error) {
|
||||
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Injecting %d observations from project memory (%d detailed, %d condensed)\n",
|
||||
len(obsData), min(fullCount, len(obsData)), max(0, len(obsData)-fullCount))
|
||||
|
||||
// Token budget for context injection
|
||||
maxTokens := 16000 // default; could be made configurable via worker config endpoint
|
||||
currentTokens := 0
|
||||
|
||||
// Build context string
|
||||
header := fmt.Sprintf("<claude-mnemonic-context>\n# Project Memory (%d observations)\nUse this knowledge to answer questions without re-exploring the codebase.\n\n", len(obsData))
|
||||
currentTokens += estimateTokens(header)
|
||||
contextBuilder := header
|
||||
contextBuilder := "<claude-mnemonic-context>\n"
|
||||
contextBuilder += fmt.Sprintf("# Project Memory (%d observations)\n", len(obsData))
|
||||
contextBuilder += "Use this knowledge to answer questions without re-exploring the codebase.\n\n"
|
||||
|
||||
for i, o := range obsData {
|
||||
if deadline.Err() != nil {
|
||||
contextBuilder += "\n... (returning early due to time limit)\n"
|
||||
break
|
||||
}
|
||||
|
||||
obs, ok := o.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
@@ -90,92 +105,48 @@ func handleSessionStart(ctx *hooks.HookContext, input *Input) (string, error) {
|
||||
title := getString(obs, "title")
|
||||
obsType := getString(obs, "type")
|
||||
|
||||
var obsText string
|
||||
|
||||
// First `fullCount` observations get full detail, rest are condensed
|
||||
if i < fullCount {
|
||||
// Full detail: include narrative and facts
|
||||
narrative := getString(obs, "narrative")
|
||||
|
||||
obsText = fmt.Sprintf("## %d. [%s] %s\n", i+1, strings.ToUpper(obsType), title)
|
||||
contextBuilder += fmt.Sprintf("## %d. [%s] %s\n", i+1, strings.ToUpper(obsType), title)
|
||||
if narrative != "" {
|
||||
obsText += narrative + "\n"
|
||||
contextBuilder += narrative + "\n"
|
||||
}
|
||||
|
||||
if facts, ok := obs["facts"].([]interface{}); ok && len(facts) > 0 {
|
||||
obsText += "Key facts:\n"
|
||||
contextBuilder += "Key facts:\n"
|
||||
for _, f := range facts {
|
||||
if fact, ok := f.(string); ok && fact != "" {
|
||||
obsText += fmt.Sprintf("- %s\n", fact)
|
||||
contextBuilder += fmt.Sprintf("- %s\n", fact)
|
||||
}
|
||||
}
|
||||
}
|
||||
obsText += "\n"
|
||||
contextBuilder += "\n"
|
||||
} else {
|
||||
// Condensed: just title and subtitle (one line)
|
||||
subtitle := getString(obs, "subtitle")
|
||||
if subtitle != "" {
|
||||
obsText = fmt.Sprintf("- [%s] %s: %s\n", strings.ToUpper(obsType), title, subtitle)
|
||||
contextBuilder += fmt.Sprintf("- [%s] %s: %s\n", strings.ToUpper(obsType), title, subtitle)
|
||||
} else {
|
||||
obsText = fmt.Sprintf("- [%s] %s\n", strings.ToUpper(obsType), title)
|
||||
contextBuilder += fmt.Sprintf("- [%s] %s\n", strings.ToUpper(obsType), title)
|
||||
}
|
||||
}
|
||||
|
||||
obsTokens := estimateTokens(obsText)
|
||||
if currentTokens+obsTokens > maxTokens {
|
||||
contextBuilder += fmt.Sprintf("\n... (%d more observations omitted due to token budget)\n", len(obsData)-i)
|
||||
break
|
||||
}
|
||||
|
||||
contextBuilder += obsText
|
||||
currentTokens += obsTokens
|
||||
}
|
||||
|
||||
contextBuilder += "</claude-mnemonic-context>\n"
|
||||
return contextBuilder, nil
|
||||
}
|
||||
|
||||
// estimateTokens provides a more accurate token count estimate.
|
||||
// Uses word count * 1.3 as base, with adjustments for code and non-ASCII.
|
||||
func estimateTokens(s string) int {
|
||||
if len(s) == 0 {
|
||||
return 0
|
||||
// Output context as JSON with additionalContext field
|
||||
response := map[string]interface{}{
|
||||
"continue": true,
|
||||
"hookSpecificOutput": map[string]interface{}{
|
||||
"hookEventName": "SessionStart",
|
||||
"additionalContext": contextBuilder,
|
||||
},
|
||||
}
|
||||
|
||||
// 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
|
||||
_ = json.NewEncoder(os.Stdout).Encode(response)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
|
||||
+101
-64
@@ -3,9 +3,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
@@ -13,9 +14,6 @@ import (
|
||||
"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.
|
||||
type StatusInput struct {
|
||||
HookEventName string `json:"hook_event_name"`
|
||||
@@ -46,21 +44,21 @@ type StatusInput struct {
|
||||
|
||||
// WorkerStats is the response from the worker's /api/stats endpoint.
|
||||
type WorkerStats struct {
|
||||
Uptime string `json:"uptime"`
|
||||
Project string `json:"project,omitempty"`
|
||||
Retrieval struct {
|
||||
Uptime string `json:"uptime"`
|
||||
ActiveSessions int `json:"activeSessions"`
|
||||
QueueDepth int `json:"queueDepth"`
|
||||
IsProcessing bool `json:"isProcessing"`
|
||||
ConnectedClients int `json:"connectedClients"`
|
||||
SessionsToday int `json:"sessionsToday"`
|
||||
Ready bool `json:"ready"`
|
||||
Project string `json:"project,omitempty"`
|
||||
ProjectObservations int `json:"projectObservations,omitempty"`
|
||||
Retrieval struct {
|
||||
TotalRequests int64 `json:"TotalRequests"`
|
||||
ObservationsServed int64 `json:"ObservationsServed"`
|
||||
SearchRequests int64 `json:"SearchRequests"`
|
||||
ContextInjections int64 `json:"ContextInjections"`
|
||||
} `json:"retrieval"`
|
||||
ActiveSessions int `json:"activeSessions"`
|
||||
QueueDepth int `json:"queueDepth"`
|
||||
ConnectedClients int `json:"connectedClients"`
|
||||
SessionsToday int `json:"sessionsToday"`
|
||||
ProjectObservations int `json:"projectObservations,omitempty"`
|
||||
IsProcessing bool `json:"isProcessing"`
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
|
||||
// ANSI color codes
|
||||
@@ -74,13 +72,18 @@ const (
|
||||
)
|
||||
|
||||
func main() {
|
||||
hooks.RunStatuslineHook(handleStatusline)
|
||||
}
|
||||
// Read input from stdin
|
||||
inputData, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
// On error, output minimal status
|
||||
fmt.Println(formatOffline())
|
||||
return
|
||||
}
|
||||
|
||||
func handleStatusline(input *StatusInput, port int) string {
|
||||
// Handle error cases (nil input)
|
||||
if input == nil {
|
||||
return formatOffline()
|
||||
var input StatusInput
|
||||
if err := json.Unmarshal(inputData, &input); err != nil {
|
||||
fmt.Println(formatOffline())
|
||||
return
|
||||
}
|
||||
|
||||
// Determine project directory
|
||||
@@ -99,28 +102,37 @@ func handleStatusline(input *StatusInput, port int) string {
|
||||
}
|
||||
|
||||
// Get worker stats
|
||||
stats := getWorkerStats(port, project)
|
||||
stats := getWorkerStats(project)
|
||||
|
||||
// Format and return statusline
|
||||
return formatStatusLine(stats, *input)
|
||||
// Format and output statusline
|
||||
fmt.Println(formatStatusLine(stats, input))
|
||||
}
|
||||
|
||||
// getWorkerStats fetches stats from the worker service.
|
||||
func getWorkerStats(port int, project string) *WorkerStats {
|
||||
func getWorkerStats(project string) *WorkerStats {
|
||||
port := hooks.GetWorkerPort()
|
||||
|
||||
// Build URL with optional project parameter
|
||||
endpoint := "/api/stats"
|
||||
endpoint := fmt.Sprintf("http://127.0.0.1:%d/api/stats", port)
|
||||
if project != "" {
|
||||
endpoint += "?project=" + url.QueryEscape(project)
|
||||
}
|
||||
|
||||
// Short timeout: statusline must be fast
|
||||
body, err := hooks.GETBody(context.Background(), statsTimeout, port, endpoint)
|
||||
// Create HTTP client with short timeout (statusline must be fast)
|
||||
client := &http.Client{Timeout: 100 * time.Millisecond}
|
||||
|
||||
resp, err := client.Get(endpoint)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
|
||||
var stats WorkerStats
|
||||
if err := json.Unmarshal(body, &stats); err != nil {
|
||||
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -175,45 +187,54 @@ func formatDefault(stats *WorkerStats, useColors bool) string {
|
||||
}
|
||||
|
||||
// Build status parts with clear labels
|
||||
parts := []string{
|
||||
prefix,
|
||||
indicator,
|
||||
}
|
||||
parts := []string{}
|
||||
|
||||
// Add retrieval stats if available
|
||||
if stats.Retrieval.ObservationsServed > 0 {
|
||||
parts = append(parts, fmt.Sprintf("served:%d", stats.Retrieval.ObservationsServed))
|
||||
}
|
||||
// Total memories served to Claude this session
|
||||
parts = append(parts, fmt.Sprintf("served:%d", stats.Retrieval.ObservationsServed))
|
||||
|
||||
// Context injections (memories auto-loaded at session start)
|
||||
if stats.Retrieval.ContextInjections > 0 {
|
||||
parts = append(parts, fmt.Sprintf("injected:%d", stats.Retrieval.ContextInjections))
|
||||
}
|
||||
|
||||
// Semantic searches performed
|
||||
if stats.Retrieval.SearchRequests > 0 {
|
||||
parts = append(parts, fmt.Sprintf("searches:%d", stats.Retrieval.SearchRequests))
|
||||
}
|
||||
|
||||
// Add project-specific observation count if available
|
||||
// Project-specific memory count
|
||||
if stats.ProjectObservations > 0 {
|
||||
parts = append(parts, fmt.Sprintf("project:%d memories", stats.ProjectObservations))
|
||||
if useColors {
|
||||
parts = append(parts, fmt.Sprintf("%sproject:%d memories%s", colorYellow, stats.ProjectObservations, reset))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("project:%d memories", stats.ProjectObservations))
|
||||
}
|
||||
}
|
||||
|
||||
// Join with separators
|
||||
result := parts[0] + " " + parts[1]
|
||||
if len(parts) > 2 {
|
||||
for i := 2; i < len(parts); i++ {
|
||||
if useColors {
|
||||
result += colorGray + " | " + reset + parts[i]
|
||||
} else {
|
||||
result += " | " + parts[i]
|
||||
}
|
||||
// Processing indicator
|
||||
if stats.IsProcessing || stats.QueueDepth > 0 {
|
||||
if useColors {
|
||||
parts = append(parts, colorYellow+"processing..."+colorReset)
|
||||
} else {
|
||||
parts = append(parts, "processing...")
|
||||
}
|
||||
}
|
||||
|
||||
result := prefix + " " + indicator
|
||||
for i, part := range parts {
|
||||
if i == 0 {
|
||||
result += " " + part
|
||||
} else {
|
||||
result += " | " + part
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// formatCompact returns a compact status line format.
|
||||
// formatCompact returns a compact status line.
|
||||
func formatCompact(stats *WorkerStats, useColors bool) string {
|
||||
// [m] ● 42/5/3
|
||||
// [m] ● 42/5/3 (28)
|
||||
var prefix, indicator string
|
||||
if useColors {
|
||||
prefix = colorCyan + "[m]" + colorReset
|
||||
@@ -223,16 +244,31 @@ func formatCompact(stats *WorkerStats, useColors bool) string {
|
||||
indicator = "●"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s %s %d/%d/%d",
|
||||
result := fmt.Sprintf("%s %s %d/%d/%d",
|
||||
prefix, indicator,
|
||||
stats.Retrieval.ObservationsServed,
|
||||
stats.Retrieval.ContextInjections,
|
||||
stats.Retrieval.SearchRequests)
|
||||
stats.Retrieval.SearchRequests,
|
||||
)
|
||||
|
||||
if stats.ProjectObservations > 0 {
|
||||
result += fmt.Sprintf(" (%d)", stats.ProjectObservations)
|
||||
}
|
||||
|
||||
if stats.IsProcessing || stats.QueueDepth > 0 {
|
||||
if useColors {
|
||||
result += " " + colorYellow + "⚙" + colorReset
|
||||
} else {
|
||||
result += " ⚙"
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// formatMinimal returns a minimal status line format.
|
||||
// formatMinimal returns a minimal status line.
|
||||
func formatMinimal(stats *WorkerStats, useColors bool) string {
|
||||
// ● 28 memories
|
||||
// ● 42 obs
|
||||
var indicator string
|
||||
if useColors {
|
||||
indicator = colorGreen + "●" + colorReset
|
||||
@@ -240,31 +276,32 @@ func formatMinimal(stats *WorkerStats, useColors bool) string {
|
||||
indicator = "●"
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("%s %d", indicator, stats.Retrieval.ObservationsServed)
|
||||
|
||||
if stats.ProjectObservations > 0 {
|
||||
return fmt.Sprintf("%s %d memories", indicator, stats.ProjectObservations)
|
||||
result += fmt.Sprintf("/%d", stats.ProjectObservations)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s mnemonic ready", indicator)
|
||||
return result
|
||||
}
|
||||
|
||||
// formatOffline returns status for when worker is offline.
|
||||
// formatOffline returns the offline status.
|
||||
func formatOffline() string {
|
||||
useColors := os.Getenv("NO_COLOR") == "" && os.Getenv("TERM") != "dumb"
|
||||
return formatOfflineColored(useColors)
|
||||
return formatOfflineColored(true)
|
||||
}
|
||||
|
||||
// formatOfflineColored returns colored offline status.
|
||||
// formatOfflineColored returns the offline status with optional colors.
|
||||
func formatOfflineColored(useColors bool) string {
|
||||
if useColors {
|
||||
return colorGray + "[mnemonic]" + colorReset + " " + colorGray + "○" + colorReset + " offline"
|
||||
return colorCyan + "[mnemonic]" + colorReset + " " + colorGray + "○" + colorReset
|
||||
}
|
||||
return "[mnemonic] ○ offline"
|
||||
return "[mnemonic] ○"
|
||||
}
|
||||
|
||||
// formatStartingColored returns colored starting status.
|
||||
// formatStartingColored returns the starting status with optional colors.
|
||||
func formatStartingColored(useColors bool) string {
|
||||
if useColors {
|
||||
return colorYellow + "[mnemonic]" + colorReset + " " + colorYellow + "○" + colorReset + " starting..."
|
||||
return colorCyan + "[mnemonic]" + colorReset + " " + colorYellow + "◐" + colorReset + " starting"
|
||||
}
|
||||
return "[mnemonic] ○ starting..."
|
||||
return "[mnemonic] ◐ starting"
|
||||
}
|
||||
|
||||
+16
-55
@@ -5,30 +5,26 @@ import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
|
||||
)
|
||||
|
||||
var debug = os.Getenv("CLAUDE_MNEMONIC_DEBUG") != ""
|
||||
|
||||
// Input is the hook input from Claude Code.
|
||||
type Input struct {
|
||||
hooks.BaseInput
|
||||
TranscriptPath string `json:"transcript_path"`
|
||||
StopHookActive bool `json:"stop_hook_active"`
|
||||
TranscriptPath string `json:"transcript_path"`
|
||||
}
|
||||
|
||||
// TranscriptMessage represents a message in the transcript JSONL file.
|
||||
type TranscriptMessage struct {
|
||||
Type string `json:"type"`
|
||||
Message struct {
|
||||
Content any `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"` // Can be string or array
|
||||
} `json:"message"`
|
||||
Type string `json:"type"` // Can be string or array
|
||||
}
|
||||
|
||||
// extractTextContent extracts text content from message content (handles both string and array formats).
|
||||
@@ -66,19 +62,7 @@ func parseTranscript(path string) (lastUser, lastAssistant string) {
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
// Increase buffer size for large messages
|
||||
@@ -113,20 +97,12 @@ func parseTranscript(path string) (lastUser, lastAssistant string) {
|
||||
}
|
||||
|
||||
func main() {
|
||||
if !hooks.IsWorkerAvailable() {
|
||||
hooks.WriteResponse("Stop", true)
|
||||
return
|
||||
}
|
||||
hooks.RunHook("Stop", handleStop)
|
||||
}
|
||||
|
||||
func handleStop(ctx *hooks.HookContext, input *Input) (string, error) {
|
||||
deadline, cancel := hooks.HookDeadline(30 * time.Second)
|
||||
defer cancel()
|
||||
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[stop] Raw input: %s\n", string(ctx.RawInput))
|
||||
}
|
||||
// Debug: dump raw input
|
||||
fmt.Fprintf(os.Stderr, "[stop] Raw input: %s\n", string(ctx.RawInput))
|
||||
|
||||
// Find session
|
||||
result, err := hooks.GET(ctx.Port, fmt.Sprintf("/api/sessions?claudeSessionId=%s", ctx.SessionID))
|
||||
@@ -146,33 +122,18 @@ func handleStop(ctx *hooks.HookContext, input *Input) (string, error) {
|
||||
lastUser, lastAssistant = parseTranscript(input.TranscriptPath)
|
||||
}
|
||||
|
||||
// Truncate messages to avoid sending excessive data to the worker
|
||||
if len(lastAssistant) > 10000 {
|
||||
lastAssistant = lastAssistant[:10000]
|
||||
}
|
||||
if len(lastUser) > 5000 {
|
||||
lastUser = lastUser[:5000]
|
||||
}
|
||||
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[stop] Transcript path: %s\n", input.TranscriptPath)
|
||||
fmt.Fprintf(os.Stderr, "[stop] Last user message length: %d\n", len(lastUser))
|
||||
fmt.Fprintf(os.Stderr, "[stop] Last assistant message length: %d\n", len(lastAssistant))
|
||||
if len(lastAssistant) > 0 {
|
||||
preview := lastAssistant
|
||||
if len(preview) > 300 {
|
||||
preview = preview[:300] + "..."
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[stop] Last assistant preview: %s\n", preview)
|
||||
// Debug: log what we extracted
|
||||
fmt.Fprintf(os.Stderr, "[stop] Transcript path: %s\n", input.TranscriptPath)
|
||||
fmt.Fprintf(os.Stderr, "[stop] Last user message length: %d\n", len(lastUser))
|
||||
fmt.Fprintf(os.Stderr, "[stop] Last assistant message length: %d\n", len(lastAssistant))
|
||||
if len(lastAssistant) > 0 {
|
||||
preview := lastAssistant
|
||||
if len(preview) > 300 {
|
||||
preview = preview[:300] + "..."
|
||||
}
|
||||
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] Last assistant preview: %s\n", preview)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[stop] Requesting summary for session %d (transcript: %v)\n", int64(sessionID), input.TranscriptPath != "")
|
||||
|
||||
// Request summary with message context from transcript
|
||||
_, err = hooks.POST(ctx.Port, fmt.Sprintf("/sessions/%d/summarize", int64(sessionID)), map[string]interface{}{
|
||||
|
||||
@@ -16,10 +16,6 @@ type Input struct {
|
||||
}
|
||||
|
||||
func main() {
|
||||
if !hooks.IsWorkerAvailable() {
|
||||
hooks.WriteResponse("SubagentStop", true)
|
||||
return
|
||||
}
|
||||
hooks.RunHook("SubagentStop", handleSubagentStop)
|
||||
}
|
||||
|
||||
|
||||
+94
-156
@@ -2,138 +2,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/sanitize"
|
||||
)
|
||||
|
||||
// Input is the hook input from Claude Code.
|
||||
type Input struct {
|
||||
hooks.BaseInput
|
||||
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
|
||||
SessionID string `json:"session_id"`
|
||||
CWD string `json:"cwd"`
|
||||
PermissionMode string `json:"permission_mode"`
|
||||
HookEventName string `json:"hook_event_name"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if !hooks.IsWorkerAvailable() {
|
||||
// Skip if this is an internal call (from SDK processor)
|
||||
if os.Getenv("CLAUDE_MNEMONIC_INTERNAL") == "1" {
|
||||
hooks.WriteResponse("UserPromptSubmit", true)
|
||||
return
|
||||
}
|
||||
hooks.RunHook("UserPromptSubmit", handleUserPrompt)
|
||||
}
|
||||
|
||||
func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
|
||||
deadline, cancel := hooks.HookDeadline(10 * time.Second)
|
||||
defer cancel()
|
||||
|
||||
searchURL := fmt.Sprintf("/api/context/search?project=%s&query=%s&cwd=%s",
|
||||
url.QueryEscape(ctx.Project),
|
||||
url.QueryEscape(input.Prompt),
|
||||
url.QueryEscape(ctx.CWD))
|
||||
|
||||
// Run search and session init concurrently.
|
||||
// Session init doesn't strictly depend on search results -- the observation
|
||||
// count passed is approximate (0) and acceptable.
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
searchResult map[string]interface{}
|
||||
initResult map[string]interface{}
|
||||
initErr error
|
||||
contextToInject string
|
||||
observationCount int
|
||||
)
|
||||
|
||||
// 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
|
||||
// Read input from stdin
|
||||
inputData, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
hooks.WriteError("UserPromptSubmit", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Process search results
|
||||
var input Input
|
||||
if err := json.Unmarshal(inputData, &input); err != nil {
|
||||
hooks.WriteError("UserPromptSubmit", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Ensure worker is running
|
||||
port, err := hooks.EnsureWorkerRunning()
|
||||
if err != nil {
|
||||
hooks.WriteError("UserPromptSubmit", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Generate unique project ID from CWD
|
||||
project := hooks.ProjectIDWithName(input.CWD)
|
||||
|
||||
// Search for relevant observations based on the prompt
|
||||
searchURL := fmt.Sprintf("/api/context/search?project=%s&query=%s&cwd=%s",
|
||||
url.QueryEscape(project),
|
||||
url.QueryEscape(input.Prompt),
|
||||
url.QueryEscape(input.CWD))
|
||||
|
||||
var contextToInject string
|
||||
var observationCount int
|
||||
|
||||
searchResult, _ := hooks.GET(port, searchURL)
|
||||
if observations, ok := searchResult["observations"].([]interface{}); ok && len(observations) > 0 {
|
||||
// Limit to top 5 most relevant observations
|
||||
maxObs := 5
|
||||
if len(observations) < maxObs {
|
||||
maxObs = len(observations)
|
||||
}
|
||||
observations = observations[:maxObs]
|
||||
observationCount = len(observations)
|
||||
|
||||
// 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)
|
||||
// Build context from search results
|
||||
var contextBuilder string
|
||||
contextBuilder = header
|
||||
contextBuilder = "<relevant-memory>\n"
|
||||
contextBuilder += "# Relevant Knowledge From Previous Sessions\n"
|
||||
contextBuilder += "IMPORTANT: Use this information to answer the question directly. Do NOT explore the codebase if the answer is here.\n\n"
|
||||
|
||||
for i, obs := range observations {
|
||||
if obsMap, ok := obs.(map[string]interface{}); ok {
|
||||
@@ -146,83 +86,81 @@ func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
|
||||
obsType = t
|
||||
}
|
||||
|
||||
var obsText string
|
||||
obsText = fmt.Sprintf("## %d. [%s] %s\n", i+1, obsType, title)
|
||||
// Start observation block
|
||||
contextBuilder += 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 {
|
||||
obsText += "Key facts:\n"
|
||||
contextBuilder += "Key facts:\n"
|
||||
for _, fact := range facts {
|
||||
if factStr, ok := fact.(string); ok {
|
||||
obsText += fmt.Sprintf("- %s\n", sanitize.StripSystemXML(factStr))
|
||||
contextBuilder += fmt.Sprintf("- %s\n", factStr)
|
||||
}
|
||||
}
|
||||
obsText += "\n"
|
||||
contextBuilder += "\n"
|
||||
}
|
||||
|
||||
// Add narrative if present
|
||||
if narrative, ok := obsMap["narrative"].(string); ok && narrative != "" {
|
||||
obsText += sanitize.StripSystemXML(narrative) + "\n\n"
|
||||
contextBuilder += narrative + "\n\n"
|
||||
}
|
||||
|
||||
obsTokens := estimateTokens(obsText)
|
||||
if currentTokens+obsTokens > maxTokens {
|
||||
break
|
||||
}
|
||||
|
||||
contextBuilder += obsText
|
||||
currentTokens += obsTokens
|
||||
}
|
||||
}
|
||||
|
||||
contextBuilder += "</relevant-memory>\n"
|
||||
|
||||
contextToInject = contextBuilder
|
||||
}
|
||||
|
||||
// Check session init result. A session-init failure must never block the
|
||||
// prompt: degrade gracefully and still inject any memory we found.
|
||||
if initErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[user-prompt] Session init failed: %v\n", initErr)
|
||||
return contextToInject, nil
|
||||
}
|
||||
if initResult == nil {
|
||||
return contextToInject, nil // Non-JSON response from worker, skip session init
|
||||
// Initialize session with matched observations count
|
||||
result, err := hooks.POST(port, "/api/sessions/init", map[string]interface{}{
|
||||
"claudeSessionId": input.SessionID,
|
||||
"project": project,
|
||||
"prompt": input.Prompt,
|
||||
"matchedObservations": observationCount,
|
||||
})
|
||||
if err != nil {
|
||||
hooks.WriteError("UserPromptSubmit", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Check if skipped due to privacy
|
||||
if skipped, ok := initResult["skipped"].(bool); ok && skipped {
|
||||
if skipped, ok := result["skipped"].(bool); ok && skipped {
|
||||
fmt.Fprintf(os.Stderr, "[user-prompt] Session skipped (private)\n")
|
||||
return contextToInject, nil
|
||||
hooks.WriteResponse("UserPromptSubmit", true)
|
||||
return
|
||||
}
|
||||
|
||||
sessionDBIDVal, ok := initResult["sessionDbId"].(float64)
|
||||
if !ok {
|
||||
return contextToInject, nil // Missing or wrong type, skip gracefully
|
||||
}
|
||||
sessionID := int64(sessionDBIDVal)
|
||||
|
||||
promptNumberVal, ok := initResult["promptNumber"].(float64)
|
||||
if !ok {
|
||||
return contextToInject, nil
|
||||
}
|
||||
promptNumber := int(promptNumberVal)
|
||||
sessionID := int64(result["sessionDbId"].(float64))
|
||||
promptNumber := int(result["promptNumber"].(float64))
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[user-prompt] Session %d, prompt #%d\n", sessionID, promptNumber)
|
||||
|
||||
// Start SDK agent (depends on session init result, so kept sequential).
|
||||
// Deadline-guarded so a wedged worker cannot stall past the hook budget.
|
||||
// Failure here must not block the prompt: degrade gracefully, still inject memory.
|
||||
if _, err := hooks.POSTWithContextResult(deadline, ctx.Port, fmt.Sprintf("/sessions/%d/init", sessionID), map[string]interface{}{
|
||||
// Start SDK agent
|
||||
_, err = hooks.POST(port, fmt.Sprintf("/sessions/%d/init", sessionID), map[string]interface{}{
|
||||
"userPrompt": input.Prompt,
|
||||
"promptNumber": promptNumber,
|
||||
}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[user-prompt] SDK agent init failed: %v\n", err)
|
||||
return contextToInject, nil
|
||||
})
|
||||
if err != nil {
|
||||
hooks.WriteError("UserPromptSubmit", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Return context if we found relevant observations
|
||||
// Output results - stdout with exit 0 adds context to Claude's prompt
|
||||
if observationCount > 0 {
|
||||
// Show match count to user via stderr
|
||||
fmt.Fprintf(os.Stderr, "[claude-mnemonic] Found %d relevant memories for this prompt\n", observationCount)
|
||||
return contextToInject, nil
|
||||
// Output context as JSON with additionalContext field
|
||||
response := map[string]interface{}{
|
||||
"continue": true,
|
||||
"hookSpecificOutput": map[string]interface{}{
|
||||
"hookEventName": "UserPromptSubmit",
|
||||
"additionalContext": contextToInject,
|
||||
},
|
||||
}
|
||||
_ = json.NewEncoder(os.Stdout).Encode(response)
|
||||
os.Exit(0)
|
||||
} else {
|
||||
hooks.WriteResponse("UserPromptSubmit", true)
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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)
|
||||
}
|
||||
+67
-35
@@ -4,19 +4,18 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/db/sqlite"
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/mcp"
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/search"
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/vector/sqlitevec"
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/watcher"
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/hooks"
|
||||
"github.com/lukaszraczylo/oss-telemetry"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -24,18 +23,10 @@ import (
|
||||
// Version is set at build time via ldflags.
|
||||
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() {
|
||||
// Parse flags
|
||||
project := flag.String("project", "", "Project ID (default: derived from CLAUDE_PROJECT_DIR or cwd)")
|
||||
project := flag.String("project", "", "Project name (required)")
|
||||
dataDir := flag.String("data-dir", "", "Data directory (default: ~/.claude-mnemonic)")
|
||||
debug := flag.Bool("debug", false, "Enable debug logging")
|
||||
flag.Parse()
|
||||
|
||||
@@ -46,16 +37,27 @@ func main() {
|
||||
}
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true})
|
||||
|
||||
if *project == "" || strings.Contains(*project, "${") {
|
||||
*project = defaultProject()
|
||||
if *project == "" {
|
||||
log.Fatal().Msg("--project is required")
|
||||
}
|
||||
|
||||
// Get worker port from config
|
||||
port := config.GetWorkerPort()
|
||||
workerURL := fmt.Sprintf("http://localhost:%d", port)
|
||||
// Ensure data directory and settings exist
|
||||
if err := config.EnsureAll(); err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to ensure data directories")
|
||||
}
|
||||
|
||||
// Create HTTP client for worker
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
// Load config
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to load config, using defaults")
|
||||
cfg = config.Default()
|
||||
}
|
||||
|
||||
// Override data directory if specified
|
||||
dbPath := cfg.DBPath
|
||||
if *dataDir != "" {
|
||||
dbPath = *dataDir + "/claude-mnemonic.db"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
@@ -69,31 +71,61 @@ func main() {
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Start file watchers for config changes
|
||||
startWatchers(cancel)
|
||||
// Initialize SQLite store (migrations run automatically)
|
||||
storeCfg := sqlite.StoreConfig{
|
||||
Path: dbPath,
|
||||
MaxConns: cfg.MaxConns,
|
||||
WALMode: true,
|
||||
}
|
||||
store, err := sqlite.NewStore(storeCfg)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to initialize SQLite store")
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
telemetry.Send("claude-mnemonic", Version)
|
||||
// Initialize stores
|
||||
observationStore := sqlite.NewObservationStore(store)
|
||||
summaryStore := sqlite.NewSummaryStore(store)
|
||||
promptStore := sqlite.NewPromptStore(store)
|
||||
|
||||
// Initialize embedding service and vector client
|
||||
var vectorClient *sqlitevec.Client
|
||||
embedSvc, err := embedding.NewService()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Embedding service unavailable, vector search disabled")
|
||||
} else {
|
||||
defer embedSvc.Close()
|
||||
vectorClient, err = sqlitevec.NewClient(sqlitevec.Config{DB: store.DB()}, 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 search manager
|
||||
searchMgr := search.NewManager(observationStore, summaryStore, promptStore, vectorClient)
|
||||
|
||||
// Start file watchers
|
||||
startWatchers(ctx, dbPath)
|
||||
|
||||
// Create and run MCP server
|
||||
server := mcp.NewServer(client, workerURL, *project, Version)
|
||||
log.Info().Str("project", *project).Str("version", Version).Str("worker", workerURL).Msg("Starting MCP server")
|
||||
server := mcp.NewServer(searchMgr, Version)
|
||||
log.Info().Str("project", *project).Str("version", Version).Msg("Starting MCP server")
|
||||
|
||||
if err := server.Run(ctx); err != nil {
|
||||
if err == context.Canceled {
|
||||
log.Info().Msg("MCP server shut down (config change or signal)")
|
||||
return
|
||||
}
|
||||
log.Fatal().Err(err).Msg("MCP server error")
|
||||
}
|
||||
}
|
||||
|
||||
// startWatchers initializes file watchers for config.
|
||||
func startWatchers(cancel context.CancelFunc) {
|
||||
// Watch config file for changes (triggers graceful shutdown via context cancellation)
|
||||
func startWatchers(ctx context.Context, dbPath string) {
|
||||
// Watch config file for changes (triggers process exit for restart)
|
||||
configPath := config.SettingsPath()
|
||||
configWatcher, err := watcher.New(configPath, func() {
|
||||
log.Warn().Str("path", configPath).Msg("Config file changed, shutting down gracefully...")
|
||||
cancel() // Triggers ctx.Done() in server.Run(), which drains in-flight requests
|
||||
log.Warn().Str("path", configPath).Msg("Config file changed, exiting for restart...")
|
||||
time.Sleep(100 * time.Millisecond) // Give logs time to flush
|
||||
os.Exit(0)
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to create config watcher")
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
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
+1407
-742
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -8,13 +8,13 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.34"
|
||||
"vue": "^3.5.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"vite": "^8.0.14"
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 252 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 123 KiB |
+359
-166
@@ -1,153 +1,360 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<div class="bg-slate-950 text-white min-h-screen relative overflow-hidden">
|
||||
<!-- Animated Background -->
|
||||
<div class="fixed inset-0 pointer-events-none">
|
||||
<!-- Gradient orbs -->
|
||||
<div class="absolute top-0 left-1/4 w-96 h-96 bg-amber-500/10 rounded-full blur-3xl animate-float"></div>
|
||||
<div class="absolute bottom-1/4 right-1/4 w-80 h-80 bg-amber-400/5 rounded-full blur-3xl animate-float-delayed"></div>
|
||||
<div class="absolute top-1/2 right-0 w-64 h-64 bg-slate-700/20 rounded-full blur-3xl animate-glow"></div>
|
||||
|
||||
<!-- Grid pattern -->
|
||||
<div class="absolute inset-0 bg-[linear-gradient(rgba(148,163,184,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(148,163,184,0.03)_1px,transparent_1px)] bg-[size:50px_50px]"></div>
|
||||
|
||||
<!-- Floating particles -->
|
||||
<div v-for="i in 12" :key="i"
|
||||
class="particle animate-particle"
|
||||
:style="{
|
||||
left: `${(i * 8.3) % 100}%`,
|
||||
animationDelay: `${i * 1.5}s`,
|
||||
animationDuration: `${15 + (i % 5) * 3}s`
|
||||
}"></div>
|
||||
</div>
|
||||
|
||||
<NavBar :mobile-menu-open="mobileMenuOpen" @toggle-menu="mobileMenuOpen = !mobileMenuOpen" />
|
||||
|
||||
<main class="flex-1">
|
||||
<HeroSection />
|
||||
<HeroSection
|
||||
badge="The missing piece for Claude Code"
|
||||
title-before="Yesterday's context."
|
||||
title-highlight="Today's session."
|
||||
subtitle="Stop re-explaining your codebase. Claude Mnemonic captures bug fixes, architecture decisions, and coding patterns - then brings them back exactly when you need them."
|
||||
/>
|
||||
|
||||
<section class="py-10 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="rounded-xl overflow-hidden border border-neutral-800">
|
||||
<img src="/claude-mnemonic.jpg" alt="Claude Mnemonic Dashboard" class="w-full h-auto block" loading="lazy" />
|
||||
</div>
|
||||
<p class="text-center text-neutral-600 text-xs mt-3 tracking-wide uppercase">Dashboard — localhost:37777</p>
|
||||
<!-- Problem Section -->
|
||||
<section class="py-20 lg:py-28 px-4 sm:px-6 relative">
|
||||
<div class="max-w-6xl mx-auto grid lg:grid-cols-2 gap-8 lg:gap-16 items-center">
|
||||
<div>
|
||||
<h2 class="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-6">Sound familiar?</h2>
|
||||
<p class="text-slate-400 text-base sm:text-lg mb-8">Every Claude Code session starts fresh. That bug you fixed last Tuesday? Gone. The auth flow you explained in detail? Forgotten. Your team's naming conventions? You'll explain them again.</p>
|
||||
<ul class="space-y-4 sm:space-y-5">
|
||||
<li class="flex items-start gap-3 sm:gap-4 text-slate-400">
|
||||
<i class="fas fa-times-circle text-red-400 mt-1 flex-shrink-0"></i>
|
||||
<span class="text-sm sm:text-base">"We fixed this exact race condition last week..."</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3 sm:gap-4 text-slate-400">
|
||||
<i class="fas fa-times-circle text-red-400 mt-1 flex-shrink-0"></i>
|
||||
<span class="text-sm sm:text-base">"No, we use kebab-case for API routes, not camelCase..."</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3 sm:gap-4 text-slate-400">
|
||||
<i class="fas fa-times-circle text-red-400 mt-1 flex-shrink-0"></i>
|
||||
<span class="text-sm sm:text-base">"The database is Postgres, not MySQL. I told you yesterday..."</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3 sm:gap-4 text-white">
|
||||
<i class="fas fa-check-circle text-amber-500 mt-1 flex-shrink-0"></i>
|
||||
<span class="text-sm sm:text-base"><strong>Mnemonic remembers so you don't have to repeat yourself.</strong></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="features" class="py-20 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">What it does</h2>
|
||||
<p class="text-neutral-500 mb-12 max-w-xl">Captures context from your Claude Code sessions and brings it back when you need it. No manual effort.</p>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<FeatureItem
|
||||
v-for="feature in features"
|
||||
:key="feature.title"
|
||||
:icon="feature.icon"
|
||||
:title="feature.title"
|
||||
:description="feature.description"
|
||||
/>
|
||||
<div 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>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="py-10 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="rounded-xl overflow-hidden border border-neutral-800">
|
||||
<img src="/observation-relation-graph.jpg" alt="Knowledge Graph" class="w-full h-auto block" loading="lazy" />
|
||||
</div>
|
||||
<p class="text-center text-neutral-600 text-xs mt-3 tracking-wide uppercase">Observation relationship graph</p>
|
||||
<!-- Features Section -->
|
||||
<section id="features" class="py-20 lg:py-28 bg-slate-900/30 relative">
|
||||
<div class="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-slate-700 to-transparent"></div>
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<SectionHeader title="Built for real workflows" subtitle="Not just storage - intelligent context that makes Claude more useful over time" />
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
<FeatureCard
|
||||
v-for="feature in features"
|
||||
:key="feature.title"
|
||||
:icon="feature.icon"
|
||||
:title="feature.title"
|
||||
:description="feature.description"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="install" class="py-20 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Install</h2>
|
||||
<p class="text-neutral-500 mb-8">One command. No configuration. No account.</p>
|
||||
|
||||
<div class="flex gap-1 mb-6">
|
||||
<button
|
||||
v-for="tab in installTabs"
|
||||
:key="tab.id"
|
||||
@click="activeTab = tab.id"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm rounded-lg transition-colors',
|
||||
activeTab === tab.id
|
||||
? 'bg-neutral-800 text-white'
|
||||
: 'text-neutral-500 hover:text-neutral-300'
|
||||
]"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
<!-- Before/After Section -->
|
||||
<section class="py-20 lg:py-28 px-4 sm:px-6">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<SectionHeader title="The difference" subtitle="Same question. Different experience." />
|
||||
<div class="grid md:grid-cols-2 gap-6 sm:gap-8">
|
||||
<!-- Before -->
|
||||
<div class="glass rounded-2xl p-5 sm:p-6 relative border-red-500/20">
|
||||
<div class="absolute top-3 sm:top-4 right-3 sm:right-4 bg-red-500/20 text-red-400 px-2 sm:px-3 py-1 rounded-full text-xs font-medium">Without Mnemonic</div>
|
||||
<div class="space-y-3 sm:space-y-4 mt-8">
|
||||
<div class="bg-slate-800/50 rounded-lg p-3 sm:p-4">
|
||||
<p class="text-slate-400 text-xs sm:text-sm"><span class="text-blue-400">You:</span> Fix the authentication bug in the login flow</p>
|
||||
</div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-3 sm:p-4">
|
||||
<p class="text-slate-400 text-xs sm:text-sm"><span class="text-amber-400">Claude:</span> I'd be happy to help! Can you tell me about your authentication setup? What framework are you using? How is the login flow structured?</p>
|
||||
</div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-3 sm:p-4">
|
||||
<p class="text-slate-400 text-xs sm:text-sm"><span class="text-blue-400">You:</span> We use NextAuth with Postgres. The flow goes through /api/auth/... We talked about this last week...</p>
|
||||
</div>
|
||||
<p class="text-red-400 text-xs sm:text-sm text-center pt-2"><i class="fas fa-clock mr-2"></i>5+ minutes of re-explaining context</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'macos'">
|
||||
<CodeBlock code="curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash" />
|
||||
<!-- After -->
|
||||
<div class="glass rounded-2xl p-5 sm:p-6 relative border-green-500/20">
|
||||
<div class="absolute top-3 sm:top-4 right-3 sm:right-4 bg-green-500/20 text-green-400 px-2 sm:px-3 py-1 rounded-full text-xs font-medium">With Mnemonic</div>
|
||||
<div class="space-y-3 sm:space-y-4 mt-8">
|
||||
<div class="bg-slate-800/50 rounded-lg p-3 sm:p-4">
|
||||
<p class="text-slate-400 text-xs sm:text-sm"><span class="text-blue-400">You:</span> Fix the authentication bug in the login flow</p>
|
||||
</div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-3 sm:p-4">
|
||||
<p class="text-slate-400 text-xs sm:text-sm"><span class="text-amber-400">Claude:</span> Looking at your NextAuth setup with Postgres, I see the issue is likely in the callback at <code class="text-amber-300 text-xs">/api/auth/[...nextauth].ts</code>. Based on the session validation pattern we established, the fix is...</p>
|
||||
</div>
|
||||
<p class="text-green-400 text-xs sm:text-sm text-center pt-2"><i class="fas fa-bolt mr-2"></i>Instant context. Immediate value.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-show="activeTab === 'windows'">
|
||||
<CodeBlock code="irm https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.ps1 | iex" />
|
||||
</div>
|
||||
<!-- How It Works Section -->
|
||||
<section id="how-it-works" class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<SectionHeader title="Zero setup. Zero maintenance." subtitle="Install once, benefit forever. Mnemonic works silently in the background." />
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6 sm:gap-8 relative">
|
||||
<div class="hidden lg:block absolute top-10 left-[60px] right-[60px] h-0.5 bg-gradient-to-r from-amber-500 via-amber-400 to-amber-500 opacity-30"></div>
|
||||
<StepCard
|
||||
v-for="step in steps"
|
||||
:key="step.number"
|
||||
:number="step.number"
|
||||
:title="step.title"
|
||||
:description="step.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-show="activeTab === 'source'">
|
||||
<CodeBlock code="git clone https://github.com/lukaszraczylo/claude-mnemonic.git cd claude-mnemonic make build && make install" />
|
||||
<p class="text-neutral-600 text-xs mt-2">Requires: Go 1.24+, Node.js 18+, CGO compiler</p>
|
||||
</div>
|
||||
<!-- Installation Section -->
|
||||
<section id="installation" class="py-20 lg:py-28 px-4 sm:px-6">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<SectionHeader title="Quick install" subtitle="One command. No configuration. No account required." />
|
||||
|
||||
<div class="mt-8 p-4 rounded-lg border border-neutral-800 bg-neutral-900/50 max-w-2xl">
|
||||
<p class="text-neutral-500 text-sm">
|
||||
<i class="fas fa-info-circle text-neutral-600 mr-1"></i>
|
||||
After install, open <a href="http://localhost:37777" class="text-amber-500 hover:text-amber-400">localhost:37777</a> for the dashboard. Start a new Claude Code session — memory is active.
|
||||
<div class="flex flex-wrap justify-center gap-2 mb-6 sm:mb-8">
|
||||
<button
|
||||
v-for="tab in installTabs"
|
||||
:key="tab.id"
|
||||
@click="activeTab = tab.id"
|
||||
:class="[
|
||||
'px-4 sm:px-5 py-2 sm:py-2.5 rounded-lg text-xs sm:text-sm font-medium transition-all',
|
||||
activeTab === tab.id
|
||||
? 'bg-amber-500/15 border border-amber-500 text-amber-500'
|
||||
: 'glass text-slate-400 hover:border-slate-600'
|
||||
]"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'macos'">
|
||||
<CodeBlock :code="installCommands.macos">
|
||||
<span class="text-slate-500"># That's it. Seriously.</span>
|
||||
<br>
|
||||
<span class="text-amber-400">curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash</span>
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'windows'">
|
||||
<CodeBlock :code="installCommands.windows">
|
||||
<span class="text-slate-500"># PowerShell (as Administrator)</span>
|
||||
<br>
|
||||
<span class="text-amber-400">irm https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.ps1 | iex</span>
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'source'">
|
||||
<CodeBlock :code="installCommands.source">
|
||||
<span class="text-slate-500"># For contributors and tinkerers</span>
|
||||
<br>
|
||||
<span class="text-amber-400">git clone https://github.com/lukaszraczylo/claude-mnemonic.git</span>
|
||||
<br>
|
||||
<span class="text-amber-400">cd claude-mnemonic</span>
|
||||
<br>
|
||||
<span class="text-amber-400">make build && make install</span>
|
||||
<br><br>
|
||||
<span class="text-slate-500"># Requires: Go 1.24+, Node.js 18+, CGO compiler</span>
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-slate-500 mt-6 sm:mt-8 text-xs sm:text-sm">
|
||||
After install, open <a href="http://localhost:37777" class="text-amber-400 hover:underline animated-underline">localhost:37777</a> to see the dashboard.
|
||||
Start a new Claude Code CLI session - memory is now active.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Requirements Section -->
|
||||
<section id="requirements" class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<SectionHeader title="Requirements" subtitle="Minimal dependencies. Everything else is built-in." />
|
||||
|
||||
<div class="max-w-xl mx-auto">
|
||||
<div class="glass rounded-2xl p-5 sm:p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<i class="fas fa-check-circle text-green-500"></i>
|
||||
<span class="text-white font-semibold text-sm sm:text-base">That's all you need</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div v-for="req in requiredDeps" :key="req.name" class="flex items-start gap-3 p-3 bg-slate-800/50 rounded-lg">
|
||||
<i :class="[req.icon, 'text-amber-500 mt-0.5']"></i>
|
||||
<div>
|
||||
<code class="text-white text-xs sm:text-sm font-semibold">{{ req.name }}</code>
|
||||
<p class="text-slate-400 text-xs mt-1">{{ req.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-slate-500 text-xs mt-4">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
No Python. No external services. Semantic search with local embeddings is built-in.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="requirements" class="py-20 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Requirements</h2>
|
||||
<p class="text-neutral-500 mb-8">Minimal dependencies. Everything else is built in.</p>
|
||||
<!-- Configuration Section -->
|
||||
<section id="configuration" class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<SectionHeader title="Fully configurable" subtitle="Works out of the box, but adapts to your preferences" />
|
||||
|
||||
<div class="grid sm:grid-cols-2 gap-4 max-w-xl">
|
||||
<div v-for="req in requiredDeps" :key="req.name" class="p-4 rounded-lg border border-neutral-800 bg-neutral-900/50">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<i :class="[req.icon, 'text-amber-500 text-sm']"></i>
|
||||
<code class="text-white text-sm font-semibold">{{ req.name }}</code>
|
||||
</div>
|
||||
<p class="text-neutral-500 text-xs pl-6">{{ req.description }}</p>
|
||||
<div class="grid lg:grid-cols-2 gap-6 sm:gap-8">
|
||||
<!-- Config file example -->
|
||||
<div class="glass rounded-2xl p-5 sm:p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<i class="fas fa-cog text-amber-500"></i>
|
||||
<span class="text-white font-semibold text-sm sm:text-base">~/.claude-mnemonic/settings.json</span>
|
||||
</div>
|
||||
<pre class="text-xs sm:text-sm font-mono overflow-x-auto"><code><span class="text-slate-500">{</span>
|
||||
<span class="text-emerald-400">"CLAUDE_MNEMONIC_WORKER_PORT"</span><span class="text-slate-500">:</span> <span class="text-amber-400">37777</span><span class="text-slate-500">,</span>
|
||||
<span class="text-emerald-400">"CLAUDE_MNEMONIC_CONTEXT_OBSERVATIONS"</span><span class="text-slate-500">:</span> <span class="text-amber-400">100</span><span class="text-slate-500">,</span>
|
||||
<span class="text-emerald-400">"CLAUDE_MNEMONIC_CONTEXT_FULL_COUNT"</span><span class="text-slate-500">:</span> <span class="text-amber-400">25</span><span class="text-slate-500">,</span>
|
||||
<span class="text-emerald-400">"CLAUDE_MNEMONIC_MODEL"</span><span class="text-slate-500">:</span> <span class="text-sky-400">"haiku"</span>
|
||||
<span class="text-slate-500">}</span></code></pre>
|
||||
</div>
|
||||
<p class="text-neutral-600 text-sm mt-4">No Python. No external services. No API keys — uses your existing Claude Pro/Max subscription.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="config" class="py-20 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Configuration</h2>
|
||||
<p class="text-neutral-500 mb-8">Works out of the box. Adjust if you want to.</p>
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-8">
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900/50 p-5">
|
||||
<p class="text-neutral-600 text-xs mb-3 font-mono">~/.claude-mnemonic/settings.json</p>
|
||||
<pre class="text-sm overflow-x-auto leading-relaxed"><code>{
|
||||
<span class="text-emerald-400">"CLAUDE_MNEMONIC_WORKER_PORT"</span>: <span class="text-amber-400">37777</span>,
|
||||
<span class="text-emerald-400">"CLAUDE_MNEMONIC_CONTEXT_OBSERVATIONS"</span>: <span class="text-amber-400">100</span>,
|
||||
<span class="text-emerald-400">"CLAUDE_MNEMONIC_CONTEXT_FULL_COUNT"</span>: <span class="text-amber-400">25</span>,
|
||||
<span class="text-emerald-400">"CLAUDE_MNEMONIC_RERANKING_ENABLED"</span>: <span class="text-amber-400">true</span>
|
||||
}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-for="config in configOptions" :key="config.name" class="flex items-start gap-3 p-3 rounded-lg border border-neutral-800/50">
|
||||
<div class="w-6 h-6 rounded bg-neutral-800 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i class="fas fa-sliders text-neutral-500 text-xs"></i>
|
||||
<!-- Config options list -->
|
||||
<div class="space-y-3 sm:space-y-4">
|
||||
<div v-for="config in configOptions" :key="config.name" class="glass rounded-xl p-4 hover:border-amber-500/20 transition-colors">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-amber-500/15 rounded-lg flex items-center justify-center text-amber-500 flex-shrink-0 text-sm">
|
||||
<i :class="config.icon"></i>
|
||||
</div>
|
||||
<div>
|
||||
<code class="text-amber-500 text-xs">{{ config.name }}</code>
|
||||
<p class="text-neutral-500 text-xs mt-0.5">{{ config.description }}</p>
|
||||
<div class="min-w-0">
|
||||
<code class="text-amber-400 text-xs sm:text-sm">{{ config.name }}</code>
|
||||
<p class="text-slate-400 text-xs sm:text-sm mt-1">{{ config.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-neutral-600 text-xs mt-6">
|
||||
All settings also work as environment variables. <a href="https://github.com/lukaszraczylo/claude-mnemonic#configuration" target="_blank" class="text-neutral-500 hover:text-neutral-300">Full docs</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="architecture" class="py-20 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Under the hood</h2>
|
||||
<p class="text-neutral-500 mb-10">No magic. Here's what's running locally on your machine.</p>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<TechCard v-for="tech in techStack" :key="tech.name" :name="tech.name" :description="tech.description" />
|
||||
<p class="text-center text-slate-500 mt-6 sm:mt-8 text-xs sm:text-sm">
|
||||
All settings can also be set via environment variables. See <a href="https://github.com/lukaszraczylo/claude-mnemonic#configuration" target="_blank" class="text-amber-400 hover:underline">full documentation</a> for all options.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Technical Details -->
|
||||
<section class="py-20 lg:py-28 px-4 sm:px-6">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<SectionHeader title="Under the hood" subtitle="Built with simplicity and performance in mind" />
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 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">Embedded vector database. No external services required.</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">MiniLM</div>
|
||||
<p class="text-slate-400 text-xs sm:text-sm">Local embeddings via ONNX. "Fix auth" finds "JWT issue" automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="faq" class="py-20 px-4 sm:px-6">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-10">FAQ</h2>
|
||||
<!-- Trust Section -->
|
||||
<section class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<SectionHeader title="Open source. Real person." subtitle="Not another anonymous package - built by a developer you can verify" />
|
||||
|
||||
<div class="glass rounded-2xl p-6 sm:p-8 text-center">
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-6 sm:gap-8 mb-6">
|
||||
<a href="https://github.com/lukaszraczylo" target="_blank" class="group">
|
||||
<img
|
||||
src="https://github.com/lukaszraczylo.png"
|
||||
alt="Lukasz Raczylo"
|
||||
class="w-20 h-20 sm:w-24 sm:h-24 rounded-full border-2 border-slate-700 group-hover:border-amber-500 transition-colors"
|
||||
/>
|
||||
</a>
|
||||
<div class="text-center sm:text-left">
|
||||
<h3 class="text-white font-semibold text-lg sm:text-xl mb-1">Lukasz Raczylo</h3>
|
||||
<p class="text-slate-400 text-sm mb-3">Principal Engineer & Open Source Maintainer</p>
|
||||
<div class="flex flex-wrap justify-center sm:justify-start gap-3">
|
||||
<a href="https://github.com/lukaszraczylo" target="_blank" class="text-slate-500 hover:text-white transition-colors text-sm">
|
||||
<i class="fab fa-github mr-1"></i>GitHub
|
||||
</a>
|
||||
<a href="https://linkedin.com/in/lukaszraczylo" target="_blank" class="text-slate-500 hover:text-white transition-colors text-sm">
|
||||
<i class="fab fa-linkedin mr-1"></i>LinkedIn
|
||||
</a>
|
||||
<a href="https://raczylo.com" target="_blank" class="text-slate-500 hover:text-white transition-colors text-sm">
|
||||
<i class="fas fa-globe mr-1"></i>Website
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-3 gap-4 pt-6 border-t border-slate-700/50">
|
||||
<div class="text-center">
|
||||
<div class="text-amber-500 font-bold text-xl sm:text-2xl mb-1">100%</div>
|
||||
<p class="text-slate-400 text-xs sm:text-sm">Open Source</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-amber-500 font-bold text-xl sm:text-2xl mb-1">MIT</div>
|
||||
<p class="text-slate-400 text-xs sm:text-sm">License</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-amber-500 font-bold text-xl sm:text-2xl mb-1">0</div>
|
||||
<p class="text-slate-400 text-xs sm:text-sm">Data collection</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-slate-500 mt-6 sm:mt-8 text-xs sm:text-sm">
|
||||
Unlike anonymous packages, you can verify who built this, read every line of code, and reach out directly with questions.
|
||||
<br class="hidden sm:block">
|
||||
Your security matters - that's why transparency is non-negotiable.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ Section -->
|
||||
<section id="faq" class="py-20 lg:py-28 px-4 sm:px-6">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<SectionHeader title="Questions?" />
|
||||
<div class="grid sm:grid-cols-2 gap-4 sm:gap-6">
|
||||
<FaqItem
|
||||
v-for="faq in faqs"
|
||||
:key="faq.question"
|
||||
@@ -155,21 +362,8 @@
|
||||
:answer="faq.answer"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<FooterSection />
|
||||
</div>
|
||||
@@ -179,8 +373,10 @@
|
||||
import { ref } from 'vue'
|
||||
import NavBar from './components/NavBar.vue'
|
||||
import HeroSection from './components/HeroSection.vue'
|
||||
import FeatureItem from './components/FeatureItem.vue'
|
||||
import TechCard from './components/TechCard.vue'
|
||||
import SectionHeader from './components/SectionHeader.vue'
|
||||
import FeatureCard from './components/FeatureCard.vue'
|
||||
import StepCard from './components/StepCard.vue'
|
||||
import FlowItem from './components/FlowItem.vue'
|
||||
import CodeBlock from './components/CodeBlock.vue'
|
||||
import FaqItem from './components/FaqItem.vue'
|
||||
import FooterSection from './components/FooterSection.vue'
|
||||
@@ -189,15 +385,19 @@ const mobileMenuOpen = ref(false)
|
||||
const activeTab = ref('macos')
|
||||
|
||||
const features = [
|
||||
{ icon: 'fas fa-brain', title: 'Session memory', description: 'Captures bug fixes, decisions, and patterns automatically from your Claude Code sessions.' },
|
||||
{ icon: 'fas fa-magnifying-glass-chart', title: 'Two-stage retrieval', description: 'Bi-encoder embeddings followed by cross-encoder reranking for accurate results.' },
|
||||
{ icon: 'fas fa-diagram-project', title: 'Knowledge graph', description: 'Automatic relationship detection between observations via file overlap and semantic similarity.' },
|
||||
{ icon: 'fas fa-folder-tree', title: 'Project isolation', description: 'Each project has its own memory. Your React knowledge doesn\'t leak into Go projects.' },
|
||||
{ icon: 'fas fa-code', title: 'AST-aware chunking', description: 'Tree-sitter splits Go, Python, and TypeScript code at semantic boundaries.' },
|
||||
{ icon: 'fas fa-database', title: 'Hybrid vector storage', description: 'Selective embedding caching. 60-80% storage reduction with minimal latency impact.' },
|
||||
{ icon: 'fas fa-lock', title: 'Local embeddings', description: 'ONNX Runtime with all-MiniLM-L6-v2. No external API calls, everything stays on your machine.' },
|
||||
{ icon: 'fas fa-gauge', title: 'Live statusline', description: 'Real-time metrics in Claude Code: [mnemonic] ● served:42 | project:28 memories' },
|
||||
{ icon: 'fas fa-arrows-rotate', title: 'Auto-updates', description: 'Checks for new versions on startup, downloads in the background, applies on restart.' },
|
||||
{ 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-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-globe', title: 'Shared best practices', description: 'Security patterns, performance tips, and universal learnings automatically available across all your projects.' },
|
||||
{ icon: 'fas fa-search', title: 'Finds what matters', description: 'Semantic search finds relevant memories even when you don\'t remember the exact words. "That auth thing" just works.' },
|
||||
{ icon: 'fas fa-chart-line', title: 'Live statusline', description: 'Real-time metrics right in Claude Code: memories served, searches performed, and project memory count at a glance.' },
|
||||
{ icon: 'fas fa-lock', title: '100% private', description: 'Your code context never leaves your machine. No telemetry. No cloud sync. Your memories are yours.' },
|
||||
]
|
||||
|
||||
const steps = [
|
||||
{ number: 1, title: 'Install', description: 'One command. Hooks into Claude Code automatically.' },
|
||||
{ number: 2, title: 'Work normally', description: 'Code with Claude as usual. Mnemonic listens silently in the background.' },
|
||||
{ number: 3, title: 'Knowledge builds', description: 'Every session adds to your knowledge base. Bug fixes. Decisions. Patterns.' },
|
||||
{ number: 4, title: 'Context appears', description: 'Start a new session - relevant memories are already there. No re-explaining.' },
|
||||
]
|
||||
|
||||
const installTabs = [
|
||||
@@ -206,37 +406,30 @@ const installTabs = [
|
||||
{ id: 'source', label: 'From Source' },
|
||||
]
|
||||
|
||||
const installCommands = {
|
||||
macos: `curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash`,
|
||||
windows: `irm https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.ps1 | iex`,
|
||||
source: `git clone https://github.com/lukaszraczylo/claude-mnemonic.git\ncd claude-mnemonic\nmake build && make install`,
|
||||
}
|
||||
|
||||
const configOptions = [
|
||||
{ name: 'WORKER_PORT', description: 'Dashboard and API port (default: 37777)' },
|
||||
{ name: 'CONTEXT_OBSERVATIONS', description: 'Max memories injected per session (default: 100)' },
|
||||
{ name: 'CONTEXT_FULL_COUNT', description: 'Full-detail memories, rest condensed (default: 25)' },
|
||||
{ name: 'CONTEXT_RELEVANCE_THRESHOLD', description: 'Min similarity score 0.0-1.0 (default: 0.3)' },
|
||||
{ name: 'RERANKING_ENABLED', description: 'Cross-encoder reranking (default: true)' },
|
||||
{ name: 'VECTOR_STORAGE_STRATEGY', description: 'hub (default), always, or on_demand' },
|
||||
{ name: 'GRAPH_ENABLED', description: 'Graph-based search with relationships (default: true)' },
|
||||
{ name: 'EMBEDDING_MODEL', description: 'Embedding model for semantic search (default: bge-v1.5)' },
|
||||
{ name: 'CLAUDE_MNEMONIC_WORKER_PORT', description: 'HTTP port for the worker service (default: 37777)', icon: 'fas fa-network-wired' },
|
||||
{ name: 'CLAUDE_MNEMONIC_CONTEXT_OBSERVATIONS', description: 'Maximum observations injected per session (default: 100)', icon: 'fas fa-layer-group' },
|
||||
{ name: 'CLAUDE_MNEMONIC_CONTEXT_FULL_COUNT', description: 'Observations with full narrative detail, rest are condensed (default: 25)', icon: 'fas fa-expand' },
|
||||
{ name: 'CLAUDE_MNEMONIC_MODEL', description: 'Model for processing observations (default: haiku)', icon: 'fas fa-microchip' },
|
||||
]
|
||||
|
||||
const requiredDeps = [
|
||||
{ name: 'Claude Code CLI', description: 'Host application. Uses your existing subscription.', icon: 'fas fa-terminal' },
|
||||
{ name: 'jq', description: 'JSON processing during install. Usually pre-installed.', icon: 'fas fa-code' },
|
||||
]
|
||||
|
||||
const techStack = [
|
||||
{ name: 'Go', description: 'Single binary, fast startup, low memory, zero runtime dependencies.' },
|
||||
{ name: 'SQLite + FTS5', description: 'Full-text search in a single file. Survives restarts.' },
|
||||
{ name: 'sqlite-vec', description: 'Vector search embedded in SQLite with LEANN-inspired selective embeddings.' },
|
||||
{ name: 'BGE reranker', description: 'Cross-encoder for two-stage retrieval: bi-encoder + reranking.' },
|
||||
{ name: 'Tree-sitter', description: 'AST-aware code chunking for Go, Python, and TypeScript.' },
|
||||
{ name: 'CSR Graph', description: 'Memory-efficient observation relationship graph with edge detection.' },
|
||||
{ name: 'Claude Code CLI', description: 'Host application - this is a plugin for Claude Code. Uses your existing subscription (Pro/Max) instead of API keys.', icon: 'fas fa-terminal' },
|
||||
{ name: 'jq', description: 'JSON processor used during installation. Usually pre-installed on most systems.', icon: 'fas fa-code' },
|
||||
]
|
||||
|
||||
const faqs = [
|
||||
{ question: 'Will it confuse Claude with wrong context?', answer: 'Project isolation and semantic relevance scoring prevent that. Only memories from the current project (or global best practices) are injected, and only when relevant to your prompt.' },
|
||||
{ question: 'What gets stored?', answer: 'Bug fixes with context, architecture decisions, project conventions, security patterns — things you\'d otherwise re-explain each session. No raw conversation dumps.' },
|
||||
{ question: 'Can I delete or edit memories?', answer: 'Yes. The dashboard at localhost:37777 lets you browse, search, edit, and delete anything. You can also view graph relationships and storage metrics.' },
|
||||
{ question: 'Performance impact?', answer: 'The Go worker uses ~30MB RAM. Context injection at session start takes milliseconds for most projects. Hybrid storage auto-tunes for your workload.' },
|
||||
{ question: 'Does it work with my existing setup?', answer: 'Yes. Installs as a Claude Code plugin with hooks. Your workflows, settings, and shortcuts stay the same.' },
|
||||
{ question: 'What if I switch between projects?', answer: 'Each project has isolated memories. Switch from your Python ML project to your TypeScript app — context switches automatically.' },
|
||||
{ question: 'Will it confuse Claude with wrong context?', answer: 'No. Mnemonic uses project isolation and semantic relevance scoring. Only memories from the current project (or global best practices) are injected, and only when they\'re actually relevant to your prompt.' },
|
||||
{ question: 'What exactly gets saved?', answer: 'Bug fixes with context ("Fixed race condition by adding mutex"), architecture decisions ("Using repository pattern for data access"), conventions ("All API routes prefixed with /api/v1"), and learnings you want to preserve.' },
|
||||
{ question: 'Can I delete or edit memories?', answer: 'Yes. The web dashboard at localhost:37777 lets you browse, search, edit, and delete any memory. You\'re always in control.' },
|
||||
{ question: 'Does it work with my existing Claude Code setup?', answer: 'Yes. Mnemonic installs as a Claude Code plugin with hooks. Your existing workflows, settings, and shortcuts remain unchanged.' },
|
||||
{ question: 'What if I switch between projects frequently?', answer: 'That\'s the point. Each project has isolated memories. Switch from your Python ML project to your TypeScript app - context switches automatically.' },
|
||||
{ question: 'Is there a performance impact?', answer: 'Minimal. The Go worker is lightweight (typically under 30MB RAM). Context injection at session start takes milliseconds for most projects.' },
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900/30 p-4 relative overflow-hidden">
|
||||
<button @click="copyCode" class="absolute top-3 right-3 text-neutral-600 hover:text-neutral-300 text-xs transition-colors px-2 py-1 rounded border border-neutral-800 hover:border-neutral-600">
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
<div class="glass rounded-xl p-4 sm:p-6 relative max-w-4xl mx-auto glow-amber">
|
||||
<button @click="copyCode" class="absolute top-3 sm:top-4 right-3 sm:right-4 bg-slate-800/50 border border-slate-700/50 text-slate-500 hover:text-white hover:border-slate-600 px-2 sm:px-3 py-1 sm:py-1.5 rounded-md text-xs transition-all">
|
||||
{{ copied ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
<pre class="text-sm whitespace-pre-wrap break-all font-mono text-neutral-300 pr-20 leading-relaxed"><code>{{ code }}</code></pre>
|
||||
<pre class="text-xs sm:text-sm whitespace-pre-wrap break-all font-mono pr-16 sm:pr-20"><slot></slot></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
code: { type: String, default: '' },
|
||||
code: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
<template>
|
||||
<div class="border-b border-neutral-800 last:border-0">
|
||||
<button @click="open = !open" class="w-full text-left flex items-center justify-between py-4">
|
||||
<span class="text-white text-sm font-medium pr-4">{{ question }}</span>
|
||||
<i :class="open ? 'fas fa-minus' : 'fas fa-plus'" class="text-neutral-600 text-xs flex-shrink-0"></i>
|
||||
</button>
|
||||
<div v-if="open" class="pb-4">
|
||||
<p class="text-neutral-500 text-sm leading-relaxed">{{ answer }}</p>
|
||||
</div>
|
||||
<div class="glass rounded-xl p-4 sm:p-6 hover:border-amber-500/20 transition-colors">
|
||||
<h3 class="text-white font-semibold text-sm sm:text-base mb-2 sm:mb-3">{{ question }}</h3>
|
||||
<p class="text-slate-400 text-xs sm:text-sm leading-relaxed">{{ answer }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
question: { type: String, required: true },
|
||||
answer: { type: String, required: true },
|
||||
question: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
answer: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const open = ref(false)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<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>
|
||||
@@ -1,17 +0,0 @@
|
||||
<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>
|
||||
@@ -0,0 +1,28 @@
|
||||
<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>
|
||||
<footer class="border-t border-neutral-800/80 py-8 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<p class="text-neutral-600 text-xs">MIT License</p>
|
||||
<div class="flex gap-5">
|
||||
<footer class="border-t border-slate-800 py-10 sm:py-16 relative">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 flex flex-col md:flex-row justify-between items-center gap-4 sm:gap-6">
|
||||
<div class="flex flex-wrap justify-center gap-4 sm:gap-6">
|
||||
<a v-for="link in links" :key="link.href" :href="link.href" target="_blank"
|
||||
class="text-neutral-600 hover:text-neutral-400 text-xs transition-colors">
|
||||
class="text-slate-500 hover:text-white text-xs sm:text-sm transition-colors animated-underline">
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-slate-600 text-xs sm:text-sm text-center">{{ copyright }}</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -20,8 +20,12 @@ defineProps({
|
||||
{ label: 'GitHub', href: 'https://github.com/lukaszraczylo/claude-mnemonic' },
|
||||
{ label: 'Releases', href: 'https://github.com/lukaszraczylo/claude-mnemonic/releases' },
|
||||
{ label: 'Issues', href: 'https://github.com/lukaszraczylo/claude-mnemonic/issues' },
|
||||
{ label: 'License', href: 'https://github.com/lukaszraczylo/claude-mnemonic/blob/main/LICENSE' },
|
||||
{ label: 'MIT License', href: 'https://github.com/lukaszraczylo/claude-mnemonic/blob/main/LICENSE' },
|
||||
]
|
||||
},
|
||||
copyright: {
|
||||
type: String,
|
||||
default: 'Made with care for the Claude Code community'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,35 +1,44 @@
|
||||
<template>
|
||||
<section class="pt-24 pb-8 sm:pt-32 sm:pb-12 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<p class="text-amber-500 text-sm font-medium mb-4 tracking-wide">Persistent memory for Claude Code</p>
|
||||
<section class="min-h-screen flex items-center relative pt-24 sm:pt-28 pb-16 sm:pb-20">
|
||||
<!-- Background Effects -->
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<div class="absolute top-[-50%] left-1/2 -translate-x-1/2 w-[150%] h-full bg-[radial-gradient(ellipse_at_center,rgba(245,158,11,0.12)_0%,transparent_60%)] opacity-60"></div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-[1.1] mb-6 max-w-3xl">
|
||||
Stop re-explaining your codebase every session.
|
||||
<div class="relative z-10 max-w-6xl mx-auto px-4 sm:px-6 text-center">
|
||||
<div class="inline-flex items-center gap-2 glass px-3 sm:px-4 py-1.5 sm:py-2 rounded-full text-xs sm:text-sm text-slate-400 mb-6 sm:mb-8 opacity-0 animate-fade-in-up">
|
||||
<span class="w-2 h-2 bg-amber-500 rounded-full animate-pulse-slow"></span>
|
||||
{{ badge }}
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold text-white mb-4 sm:mb-6 leading-tight opacity-0 animate-fade-in-up animation-delay-100">
|
||||
{{ titleBefore }}<br class="hidden sm:block">
|
||||
<span class="sm:hidden"> </span>
|
||||
<span class="text-gradient">{{ titleHighlight }}</span>
|
||||
</h1>
|
||||
|
||||
<p class="text-neutral-400 text-lg sm:text-xl max-w-2xl mb-10 leading-relaxed">
|
||||
Claude Code forgets everything when your session ends. Mnemonic captures what you've already explained — bug fixes, architecture decisions, patterns — and brings it back when relevant.
|
||||
<p class="text-base sm:text-lg md:text-xl text-slate-400 max-w-2xl mx-auto mb-8 sm:mb-10 opacity-0 animate-fade-in-up animation-delay-200 px-2">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 mb-12">
|
||||
<a href="#install" class="inline-flex items-center justify-center gap-2 bg-white text-neutral-900 px-6 py-3 rounded-lg font-semibold text-sm hover:bg-neutral-200 transition-colors">
|
||||
Install now
|
||||
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center mb-12 sm:mb-16 opacity-0 animate-fade-in-up animation-delay-300 px-4 sm:px-0">
|
||||
<a :href="primaryCta.href" class="inline-flex items-center justify-center gap-2 bg-gradient-to-br from-amber-500 to-amber-400 text-slate-950 px-6 sm:px-8 py-3 sm:py-3.5 rounded-lg font-semibold hover:scale-105 hover:shadow-lg hover:shadow-amber-500/30 transition-all">
|
||||
<i :class="primaryCta.icon"></i>
|
||||
{{ primaryCta.label }}
|
||||
</a>
|
||||
<a href="https://github.com/lukaszraczylo/claude-mnemonic" target="_blank" class="inline-flex items-center justify-center gap-2 border border-neutral-700 text-neutral-400 px-6 py-3 rounded-lg font-medium text-sm hover:border-neutral-500 hover:text-white transition-colors">
|
||||
<i class="fab fa-github"></i> View source
|
||||
<a :href="secondaryCta.href" target="_blank" class="inline-flex items-center justify-center gap-2 glass text-white px-6 sm:px-8 py-3 sm:py-3.5 rounded-lg font-medium hover:border-amber-500/30 hover:bg-amber-500/10 transition-all">
|
||||
<i :class="secondaryCta.icon"></i>
|
||||
{{ secondaryCta.label }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<div class="flex items-stretch bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<div class="flex items-center px-3 gap-1.5 border-r border-neutral-800">
|
||||
<span class="w-3 h-3 rounded-full bg-neutral-700"></span>
|
||||
<span class="w-3 h-3 rounded-full bg-neutral-700"></span>
|
||||
<span class="w-3 h-3 rounded-full bg-neutral-700"></span>
|
||||
</div>
|
||||
<code class="flex-1 text-neutral-300 text-xs sm:text-sm px-4 py-3 font-mono overflow-x-auto">{{ installCommand }}</code>
|
||||
<button @click="copyCommand" class="px-4 text-neutral-600 hover:text-neutral-300 transition-colors border-l border-neutral-800 text-sm" title="Copy">
|
||||
<i :class="copied ? 'fas fa-check text-emerald-500' : 'fas fa-copy'"></i>
|
||||
<!-- Install Command -->
|
||||
<div class="opacity-0 animate-fade-in-up animation-delay-400 px-2 sm:px-0">
|
||||
<div class="glass rounded-xl px-4 sm:px-6 py-4 sm:py-5 flex flex-col sm:flex-row items-center justify-between gap-3 sm:gap-4 max-w-4xl mx-auto glow-amber">
|
||||
<code class="text-amber-400 text-xs sm:text-sm md:text-base flex-1 text-center sm:text-left break-all font-mono">{{ installCommand }}</code>
|
||||
<button @click="copyCommand" class="text-slate-500 hover:text-white transition-colors p-2 flex-shrink-0 rounded-lg hover:bg-slate-800/50">
|
||||
<i :class="copied ? 'fas fa-check text-amber-500' : 'fas fa-copy'"></i>
|
||||
<span class="ml-2 text-sm sm:hidden">{{ copied ? 'Copied!' : 'Copy' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,11 +49,41 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const installCommand = 'curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash'
|
||||
defineProps({
|
||||
badge: {
|
||||
type: String,
|
||||
default: 'Persistent Memory for Claude Code'
|
||||
},
|
||||
titleBefore: {
|
||||
type: String,
|
||||
default: 'Claude forgets.'
|
||||
},
|
||||
titleHighlight: {
|
||||
type: String,
|
||||
default: 'Make it remember.'
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: 'Capture learnings, decisions, and patterns from your coding sessions. Bring that knowledge back in every future conversation.'
|
||||
},
|
||||
primaryCta: {
|
||||
type: Object,
|
||||
default: () => ({ label: 'Install Now', href: '#installation', icon: 'fas fa-download' })
|
||||
},
|
||||
secondaryCta: {
|
||||
type: Object,
|
||||
default: () => ({ label: 'View Source', href: 'https://github.com/lukaszraczylo/claude-mnemonic', icon: 'fab fa-github' })
|
||||
},
|
||||
installCommand: {
|
||||
type: String,
|
||||
default: 'curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash'
|
||||
}
|
||||
})
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
function copyCommand() {
|
||||
navigator.clipboard.writeText(installCommand)
|
||||
navigator.clipboard.writeText('curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash')
|
||||
copied.value = true
|
||||
setTimeout(() => copied.value = false, 2000)
|
||||
}
|
||||
|
||||
@@ -1,45 +1,70 @@
|
||||
<template>
|
||||
<nav class="border-b border-neutral-800/80 sticky top-0 z-50 bg-[#09090b]/90 backdrop-blur-sm">
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 h-14 flex justify-between items-center">
|
||||
<a href="#" class="text-white font-bold text-base tracking-tight">Claude Mnemonic</a>
|
||||
<nav class="fixed top-0 left-0 right-0 z-50 bg-gradient-to-b from-slate-950 via-slate-950/95 to-transparent backdrop-blur-md">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-4 sm:py-5 flex justify-between items-center">
|
||||
<a href="#" class="flex items-center gap-2 sm:gap-3 text-white font-bold text-xl sm:text-2xl">
|
||||
<div class="w-8 h-8 sm:w-9 sm:h-9 bg-gradient-to-br from-amber-500 to-amber-400 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-brain text-slate-950 text-sm sm:text-base"></i>
|
||||
</div>
|
||||
{{ title }}
|
||||
</a>
|
||||
|
||||
<ul class="hidden md:flex items-center gap-6">
|
||||
<ul class="hidden md:flex gap-6 lg:gap-8">
|
||||
<li v-for="link in links" :key="link.href">
|
||||
<a :href="link.href" class="text-neutral-500 hover:text-white text-sm transition-colors">{{ link.label }}</a>
|
||||
<a :href="link.href" class="text-slate-400 hover:text-white text-sm font-medium transition-colors animated-underline">
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<a :href="githubUrl" target="_blank" class="text-neutral-500 hover:text-white transition-colors text-lg">
|
||||
<div class="flex items-center gap-3 sm:gap-4">
|
||||
<a :href="githubUrl" target="_blank" class="text-slate-400 hover:text-white text-lg sm:text-xl transition-colors">
|
||||
<i class="fab fa-github"></i>
|
||||
</a>
|
||||
<a href="#install" class="hidden sm:inline-flex items-center gap-2 text-sm font-medium text-amber-500 hover:text-amber-400 transition-colors">
|
||||
Install <i class="fas fa-arrow-right text-xs"></i>
|
||||
<a href="#installation" class="hidden sm:inline-flex bg-gradient-to-br from-amber-500 to-amber-400 text-slate-950 px-4 sm:px-5 py-2 sm:py-2.5 rounded-lg font-semibold text-sm hover:scale-105 hover:shadow-lg hover:shadow-amber-500/30 transition-all">
|
||||
Get Started
|
||||
</a>
|
||||
<button @click="$emit('toggle-menu')" class="md:hidden text-neutral-500 hover:text-white p-1">
|
||||
<button @click="$emit('toggle-menu')" class="md:hidden text-white text-xl p-2">
|
||||
<i :class="mobileMenuOpen ? 'fas fa-times' : 'fas fa-bars'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mobileMenuOpen" class="md:hidden border-t border-neutral-800/80 px-6 py-4 space-y-1">
|
||||
<a v-for="link in links" :key="link.href" :href="link.href"
|
||||
@click="$emit('toggle-menu')"
|
||||
class="block py-2 text-neutral-500 hover:text-white text-sm transition-colors">
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</div>
|
||||
<!-- Mobile Menu -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div v-if="mobileMenuOpen" class="md:hidden bg-slate-900/95 backdrop-blur-xl border-t border-slate-800 px-4 sm:px-6 py-4">
|
||||
<a v-for="link in links" :key="link.href" :href="link.href"
|
||||
@click="$emit('toggle-menu')"
|
||||
class="block py-3 text-slate-400 hover:text-white border-b border-slate-800 last:border-0 transition-colors">
|
||||
{{ link.label }}
|
||||
</a>
|
||||
<a href="#installation" @click="$emit('toggle-menu')"
|
||||
class="mt-4 block text-center bg-gradient-to-br from-amber-500 to-amber-400 text-slate-950 px-5 py-2.5 rounded-lg font-semibold text-sm">
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
</Transition>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Mnemonic'
|
||||
},
|
||||
links: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ label: 'Features', href: '#features' },
|
||||
{ label: 'Install', href: '#install' },
|
||||
{ label: 'Config', href: '#config' },
|
||||
{ label: 'How It Works', href: '#how-it-works' },
|
||||
{ label: 'Install', href: '#installation' },
|
||||
{ label: 'FAQ', href: '#faq' },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<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>
|
||||
@@ -0,0 +1,26 @@
|
||||
<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>
|
||||
@@ -1,13 +0,0 @@
|
||||
<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>
|
||||
+97
-9
@@ -1,9 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
--font-mono: 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@@ -13,14 +10,105 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply font-sans text-neutral-300 bg-[#09090b];
|
||||
@apply font-sans text-slate-300;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-sans font-bold tracking-tight;
|
||||
}
|
||||
|
||||
code, pre {
|
||||
@apply font-mono;
|
||||
}
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-amber-500/30 text-white;
|
||||
@layer components {
|
||||
/* Animated gradient background */
|
||||
.bg-animated-gradient {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(245, 158, 11, 0.1) 0%,
|
||||
transparent 50%,
|
||||
rgba(245, 158, 11, 0.05) 100%
|
||||
);
|
||||
background-size: 200% 200%;
|
||||
animation: gradientShift 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Floating particles */
|
||||
.particle {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: rgba(245, 158, 11, 0.4);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Glass effect */
|
||||
.glass {
|
||||
@apply bg-slate-900/60 backdrop-blur-xl border border-slate-700/50;
|
||||
}
|
||||
|
||||
/* Glow effect */
|
||||
.glow-amber {
|
||||
box-shadow: 0 0 40px rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
|
||||
/* Animated underline */
|
||||
.animated-underline {
|
||||
position: relative;
|
||||
}
|
||||
.animated-underline::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.animated-underline:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Animated border */
|
||||
.animated-border {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.animated-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, #f59e0b, transparent);
|
||||
animation: borderSlide 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes borderSlide {
|
||||
0% { left: -100%; }
|
||||
100% { left: 100%; }
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.animation-delay-100 { animation-delay: 0.1s; }
|
||||
.animation-delay-200 { animation-delay: 0.2s; }
|
||||
.animation-delay-300 { animation-delay: 0.3s; }
|
||||
.animation-delay-400 { animation-delay: 0.4s; }
|
||||
.animation-delay-500 { animation-delay: 0.5s; }
|
||||
|
||||
/* Text gradient */
|
||||
.text-gradient {
|
||||
@apply bg-gradient-to-r from-amber-400 to-amber-500 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
/* Opacity starting state for animations */
|
||||
.opacity-0-start {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/** @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: [],
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
# 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,38 +1,33 @@
|
||||
module github.com/lukaszraczylo/claude-mnemonic
|
||||
|
||||
go 1.26.0
|
||||
go 1.25.1
|
||||
|
||||
replace github.com/sugarme/tokenizer => github.com/clems4ever/tokenizer v0.0.0-20250926133620-9ddc80533c43
|
||||
|
||||
require (
|
||||
github.com/asg017/sqlite-vec-go-bindings v0.1.6
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/go-chi/chi/v5 v5.3.2
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||
github.com/goccy/go-json v0.11.2
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.5
|
||||
github.com/mattn/go-sqlite3 v1.14.52
|
||||
github.com/rs/zerolog v1.35.1
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/goccy/go-json v0.10.5
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/sugarme/tokenizer v0.3.0
|
||||
github.com/yalue/onnxruntime_go v1.36.0
|
||||
golang.org/x/sync v0.23.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
github.com/yalue/onnxruntime_go v1.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/schollz/progressbar/v2 v2.15.0 // indirect
|
||||
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -2,58 +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/clems4ever/tokenizer v0.0.0-20250926133620-9ddc80533c43 h1:j8YQypEqa5OjqbGciCNb9hOcYbo1oTVuEjd/iu9U2SY=
|
||||
github.com/clems4ever/tokenizer v0.0.0-20250926133620-9ddc80533c43/go.mod h1:VJ+DLK5ZEZwzvODOWwY0cw+B1dabTd3nCB5HuFCItCc=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
|
||||
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg=
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog=
|
||||
github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
|
||||
github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.5 h1:W6Qgwp7IY4yBRO361MmnEAmM59motdQpbAFAx8v6fJM=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.5/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
|
||||
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/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/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/schollz/progressbar/v2 v2.15.0 h1:dVzHQ8fHRmtPjD3K10jT3Qgn/+H+92jhPrhmxIJfDz8=
|
||||
github.com/schollz/progressbar/v2 v2.15.0/go.mod h1:UdPq3prGkfQ7MOzZKlDRpYKcFqEMczbD7YmbPgpzKMI=
|
||||
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.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c h1:pwb4kNSHb4K89ymCaN+5lPH/MwnfSVg4rzGDh4d+iy4=
|
||||
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c/go.mod h1:2gwkXLWbDGUQWeL3RtpCmcY4mzCtU13kb9UsAg9xMaw=
|
||||
github.com/yalue/onnxruntime_go v1.36.0 h1:iH1Q++DcsyT9sWtN26KYimESlI5hhXpKaChHDS44oV4=
|
||||
github.com/yalue/onnxruntime_go v1.36.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
github.com/yalue/onnxruntime_go v1.21.0 h1:DdtvfY7OP5gR8mwPDqAOAQckf+KcI30hPNJL8hQaYWI=
|
||||
github.com/yalue/onnxruntime_go v1.21.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
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=
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
BIN="$HOME/.claude-mnemonic/bin/hooks/post-tool-use"
|
||||
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||
exit 0
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
BIN="$HOME/.claude-mnemonic/bin/hooks/session-start"
|
||||
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||
exit 0
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
BIN="$HOME/.claude-mnemonic/bin/hooks/statusline"
|
||||
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||
exit 0
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
BIN="$HOME/.claude-mnemonic/bin/hooks/stop"
|
||||
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||
exit 0
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
BIN="$HOME/.claude-mnemonic/bin/hooks/subagent-stop"
|
||||
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||
exit 0
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
BIN="$HOME/.claude-mnemonic/bin/hooks/user-prompt"
|
||||
[ -x "$BIN" ] && exec "$BIN" "$@"
|
||||
exit 0
|
||||
+35
-168
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -37,48 +36,28 @@ var CriticalConcepts = []string{
|
||||
}
|
||||
|
||||
// Config holds the application configuration.
|
||||
// Field order optimized for memory alignment (fieldalignment).
|
||||
type Config struct {
|
||||
ContextFullField string `json:"context_full_field"`
|
||||
DBPath string `json:"db_path"`
|
||||
Model string `json:"model"`
|
||||
ClaudeCodePath string `json:"claude_code_path"`
|
||||
EmbeddingModel string `json:"embedding_model"`
|
||||
VectorStorageStrategy string `json:"vector_storage_strategy"`
|
||||
ContextObsConcepts []string `json:"context_obs_concepts"`
|
||||
ContextObsTypes []string `json:"context_obs_types"`
|
||||
ContextFullCount int `json:"context_full_count"`
|
||||
GraphBranchFactor int `json:"graph_branch_factor"`
|
||||
GraphEdgeWeight float64 `json:"graph_edge_weight"`
|
||||
ContextRelevanceThreshold float64 `json:"context_relevance_threshold"`
|
||||
RerankingCandidates int `json:"reranking_candidates"`
|
||||
WorkerPort int `json:"worker_port"`
|
||||
DeduplicationThreshold float64 `json:"deduplication_threshold"`
|
||||
RerankingMinImprovement float64 `json:"reranking_min_improvement"`
|
||||
ContextObservations int `json:"context_observations"`
|
||||
ContextMaxPromptResults int `json:"context_max_prompt_results"`
|
||||
ContextSessionCount int `json:"context_session_count"`
|
||||
MaxConns int `json:"max_conns"`
|
||||
RerankingAlpha float64 `json:"reranking_alpha"`
|
||||
GraphMaxHops int `json:"graph_max_hops"`
|
||||
RerankingResults int `json:"reranking_results"`
|
||||
GraphRebuildIntervalMin int `json:"graph_rebuild_interval_min"`
|
||||
HubThreshold int `json:"hub_threshold"`
|
||||
ObservationRetentionDays int `json:"observation_retention_days"`
|
||||
MaintenanceIntervalHours int `json:"maintenance_interval_hours"`
|
||||
WALCheckpointIntervalSeconds int `json:"wal_checkpoint_interval_seconds"`
|
||||
WALCheckpointThresholdBytes int64 `json:"wal_checkpoint_threshold_bytes"`
|
||||
ContextMaxTokensStartup int `json:"context_max_tokens_startup"`
|
||||
ContextMaxTokensPrompt int `json:"context_max_tokens_prompt"`
|
||||
ContextShowWorkTokens bool `json:"context_show_work_tokens"`
|
||||
ContextShowReadTokens bool `json:"context_show_read_tokens"`
|
||||
RerankingPureMode bool `json:"reranking_pure_mode"`
|
||||
GraphEnabled bool `json:"graph_enabled"`
|
||||
DeduplicationEnabled bool `json:"deduplication_enabled"`
|
||||
MaintenanceEnabled bool `json:"maintenance_enabled"`
|
||||
RerankingEnabled bool `json:"reranking_enabled"`
|
||||
ContextShowLastSummary bool `json:"context_show_last_summary"`
|
||||
CleanupStaleObservations bool `json:"cleanup_stale_observations"`
|
||||
// Worker settings
|
||||
WorkerPort int `json:"worker_port"`
|
||||
|
||||
// Database settings
|
||||
DBPath string `json:"db_path"`
|
||||
MaxConns int `json:"max_conns"`
|
||||
|
||||
// SDK Agent settings
|
||||
Model string `json:"model"`
|
||||
ClaudeCodePath string `json:"claude_code_path"`
|
||||
|
||||
// Context injection settings
|
||||
ContextObservations int `json:"context_observations"`
|
||||
ContextFullCount int `json:"context_full_count"`
|
||||
ContextSessionCount int `json:"context_session_count"`
|
||||
ContextShowReadTokens bool `json:"context_show_read_tokens"`
|
||||
ContextShowWorkTokens bool `json:"context_show_work_tokens"`
|
||||
ContextFullField string `json:"context_full_field"`
|
||||
ContextShowLastSummary bool `json:"context_show_last_summary"`
|
||||
ContextObsTypes []string `json:"context_obs_types"`
|
||||
ContextObsConcepts []string `json:"context_obs_concepts"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -104,9 +83,8 @@ func SettingsPath() string {
|
||||
}
|
||||
|
||||
// EnsureDataDir creates the data directory if it doesn't exist.
|
||||
// Uses 0700 permissions (owner-only) for security.
|
||||
func EnsureDataDir() error {
|
||||
return os.MkdirAll(DataDir(), 0700)
|
||||
return os.MkdirAll(DataDir(), 0750)
|
||||
}
|
||||
|
||||
// EnsureSettings creates a default settings file if it doesn't exist.
|
||||
@@ -141,52 +119,22 @@ func EnsureAll() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultEmbeddingModel is the default embedding model to use.
|
||||
const DefaultEmbeddingModel = "bge-v1.5"
|
||||
|
||||
// Default returns a Config with default values.
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
WorkerPort: DefaultWorkerPort,
|
||||
DBPath: DBPath(),
|
||||
MaxConns: 4,
|
||||
Model: DefaultModel,
|
||||
EmbeddingModel: DefaultEmbeddingModel,
|
||||
RerankingEnabled: true, // Enable by default for improved relevance
|
||||
RerankingCandidates: 100, // Retrieve top 100 candidates
|
||||
RerankingResults: 10, // Return top 10 after reranking
|
||||
RerankingAlpha: 0.7, // Favor cross-encoder score
|
||||
RerankingMinImprovement: 0, // Always apply reranking
|
||||
GraphEnabled: true, // Enable graph-aware search by default
|
||||
GraphMaxHops: 2, // Two-hop traversal
|
||||
GraphBranchFactor: 5, // Expand top 5 neighbors per node
|
||||
GraphEdgeWeight: 0.3, // Minimum edge weight to follow
|
||||
GraphRebuildIntervalMin: 60, // Rebuild graph every 60 minutes
|
||||
VectorStorageStrategy: "hub", // Hub storage strategy (LEANN-inspired)
|
||||
HubThreshold: 5, // Require 5+ accesses to store embedding
|
||||
ContextObservations: 100,
|
||||
ContextFullCount: 25,
|
||||
ContextSessionCount: 10,
|
||||
ContextShowReadTokens: true,
|
||||
ContextShowWorkTokens: true,
|
||||
ContextFullField: "narrative",
|
||||
ContextShowLastSummary: true,
|
||||
ContextObsTypes: DefaultObservationTypes,
|
||||
ContextObsConcepts: DefaultObservationConcepts,
|
||||
ContextRelevanceThreshold: 0.3, // Minimum 30% similarity to include
|
||||
ContextMaxPromptResults: 10, // Cap at 10 results max (0 = no cap, threshold only)
|
||||
ContextMaxTokensStartup: 16000, // Max tokens for SessionStart context injection
|
||||
ContextMaxTokensPrompt: 8000, // Max tokens for UserPromptSubmit context injection
|
||||
DeduplicationEnabled: true, // Enable write-time vector dedup
|
||||
DeduplicationThreshold: 0.9, // Similarity threshold for merging (0.9 = very similar)
|
||||
MaintenanceEnabled: true, // Enable scheduled maintenance
|
||||
MaintenanceIntervalHours: 6, // Run every 6 hours
|
||||
ObservationRetentionDays: 0, // 0 = no age-based deletion (keep all)
|
||||
CleanupStaleObservations: false, // Don't auto-cleanup stale observations
|
||||
// WAL checkpoint loop tunables (issue #49). Defaults mirror the worker constants:
|
||||
// check the WAL every 60s and TRUNCATE-checkpoint once it reaches 4 MiB.
|
||||
WALCheckpointIntervalSeconds: 60,
|
||||
WALCheckpointThresholdBytes: 4 << 20, // 4 MiB
|
||||
WorkerPort: DefaultWorkerPort,
|
||||
DBPath: DBPath(),
|
||||
MaxConns: 4,
|
||||
Model: DefaultModel,
|
||||
ContextObservations: 100,
|
||||
ContextFullCount: 25,
|
||||
ContextSessionCount: 10,
|
||||
ContextShowReadTokens: true,
|
||||
ContextShowWorkTokens: true,
|
||||
ContextFullField: "narrative",
|
||||
ContextShowLastSummary: true,
|
||||
ContextObsTypes: DefaultObservationTypes,
|
||||
ContextObsConcepts: DefaultObservationConcepts,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,28 +166,6 @@ func Load() (*Config, error) {
|
||||
if v, ok := settings["CLAUDE_CODE_PATH"].(string); ok {
|
||||
cfg.ClaudeCodePath = v
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_EMBEDDING_MODEL"].(string); ok && v != "" {
|
||||
cfg.EmbeddingModel = v
|
||||
}
|
||||
// Reranking settings
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_RERANKING_ENABLED"].(bool); ok {
|
||||
cfg.RerankingEnabled = v
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_RERANKING_CANDIDATES"].(float64); ok && v > 0 {
|
||||
cfg.RerankingCandidates = int(v)
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_RERANKING_RESULTS"].(float64); ok && v > 0 {
|
||||
cfg.RerankingResults = int(v)
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_RERANKING_ALPHA"].(float64); ok && v >= 0 && v <= 1 {
|
||||
cfg.RerankingAlpha = v
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_RERANKING_MIN_IMPROVEMENT"].(float64); ok && v >= 0 {
|
||||
cfg.RerankingMinImprovement = v
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_RERANKING_PURE_MODE"].(bool); ok {
|
||||
cfg.RerankingPureMode = v
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_CONTEXT_OBSERVATIONS"].(float64); ok {
|
||||
cfg.ContextObservations = int(v)
|
||||
}
|
||||
@@ -255,65 +181,6 @@ func Load() (*Config, error) {
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_CONTEXT_OBS_CONCEPTS"].(string); ok && v != "" {
|
||||
cfg.ContextObsConcepts = splitTrim(v)
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_CONTEXT_RELEVANCE_THRESHOLD"].(float64); ok && v >= 0 && v <= 1 {
|
||||
cfg.ContextRelevanceThreshold = v
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_CONTEXT_MAX_PROMPT_RESULTS"].(float64); ok && v >= 0 {
|
||||
cfg.ContextMaxPromptResults = int(v)
|
||||
}
|
||||
// Graph settings
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_GRAPH_ENABLED"].(bool); ok {
|
||||
cfg.GraphEnabled = v
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_GRAPH_MAX_HOPS"].(float64); ok && v > 0 {
|
||||
cfg.GraphMaxHops = int(v)
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_GRAPH_BRANCH_FACTOR"].(float64); ok && v > 0 {
|
||||
cfg.GraphBranchFactor = int(v)
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_GRAPH_EDGE_WEIGHT"].(float64); ok && v >= 0 && v <= 1 {
|
||||
cfg.GraphEdgeWeight = v
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_GRAPH_REBUILD_INTERVAL_MIN"].(float64); ok && v > 0 {
|
||||
cfg.GraphRebuildIntervalMin = int(v)
|
||||
}
|
||||
// Vector storage settings (LEANN Phase 2)
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_VECTOR_STORAGE_STRATEGY"].(string); ok && v != "" {
|
||||
cfg.VectorStorageStrategy = v
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_HUB_THRESHOLD"].(float64); ok && v > 0 {
|
||||
cfg.HubThreshold = int(v)
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_CONTEXT_MAX_TOKENS_STARTUP"].(float64); ok && v > 0 {
|
||||
cfg.ContextMaxTokensStartup = int(v)
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_CONTEXT_MAX_TOKENS_PROMPT"].(float64); ok && v > 0 {
|
||||
cfg.ContextMaxTokensPrompt = int(v)
|
||||
}
|
||||
// WAL checkpoint loop tunables (issue #49)
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_WAL_CHECKPOINT_INTERVAL_SECONDS"].(float64); ok && v > 0 {
|
||||
cfg.WALCheckpointIntervalSeconds = int(v)
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_WAL_CHECKPOINT_THRESHOLD_BYTES"].(float64); ok && v > 0 {
|
||||
cfg.WALCheckpointThresholdBytes = int64(v)
|
||||
}
|
||||
// Deduplication settings
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_DEDUP_ENABLED"].(bool); ok {
|
||||
cfg.DeduplicationEnabled = v
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_DEDUP_THRESHOLD"].(float64); ok && v > 0 && v <= 1 {
|
||||
cfg.DeduplicationThreshold = v
|
||||
}
|
||||
|
||||
// Also support env vars for dedup settings
|
||||
if v := os.Getenv("CLAUDE_MNEMONIC_DEDUP_ENABLED"); v != "" {
|
||||
cfg.DeduplicationEnabled = v == "true" || v == "1"
|
||||
}
|
||||
if v := os.Getenv("CLAUDE_MNEMONIC_DEDUP_THRESHOLD"); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 && f <= 1 {
|
||||
cfg.DeduplicationThreshold = f
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
// Package config provides configuration management for claude-mnemonic.
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
// ConfigSuite is a test suite for config operations.
|
||||
type ConfigSuite struct {
|
||||
suite.Suite
|
||||
tempDir string
|
||||
origHomeDir string
|
||||
}
|
||||
|
||||
func (s *ConfigSuite) SetupTest() {
|
||||
var err error
|
||||
s.tempDir, err = os.MkdirTemp("", "config-test-*")
|
||||
s.Require().NoError(err)
|
||||
|
||||
// Save and override HOME
|
||||
s.origHomeDir = os.Getenv("HOME")
|
||||
os.Setenv("HOME", s.tempDir)
|
||||
}
|
||||
|
||||
func (s *ConfigSuite) TearDownTest() {
|
||||
os.Setenv("HOME", s.origHomeDir)
|
||||
os.RemoveAll(s.tempDir)
|
||||
}
|
||||
|
||||
func TestConfigSuite(t *testing.T) {
|
||||
suite.Run(t, new(ConfigSuite))
|
||||
}
|
||||
|
||||
// TestDefault tests default configuration values.
|
||||
func (s *ConfigSuite) TestDefault() {
|
||||
cfg := Default()
|
||||
|
||||
s.Equal(DefaultWorkerPort, cfg.WorkerPort)
|
||||
s.Equal(DefaultModel, cfg.Model)
|
||||
s.Equal(4, cfg.MaxConns)
|
||||
s.Equal(100, cfg.ContextObservations)
|
||||
s.Equal(25, cfg.ContextFullCount)
|
||||
s.Equal(10, cfg.ContextSessionCount)
|
||||
s.True(cfg.ContextShowReadTokens)
|
||||
s.True(cfg.ContextShowWorkTokens)
|
||||
s.Equal("narrative", cfg.ContextFullField)
|
||||
s.True(cfg.ContextShowLastSummary)
|
||||
s.Equal(DefaultObservationTypes, cfg.ContextObsTypes)
|
||||
s.Equal(DefaultObservationConcepts, cfg.ContextObsConcepts)
|
||||
}
|
||||
|
||||
// TestDataDir tests data directory path.
|
||||
func (s *ConfigSuite) TestDataDir() {
|
||||
dir := DataDir()
|
||||
s.Contains(dir, ".claude-mnemonic")
|
||||
}
|
||||
|
||||
// TestDBPath tests database path.
|
||||
func (s *ConfigSuite) TestDBPath() {
|
||||
path := DBPath()
|
||||
s.Contains(path, "claude-mnemonic.db")
|
||||
}
|
||||
|
||||
// TestSettingsPath tests settings file path.
|
||||
func (s *ConfigSuite) TestSettingsPath() {
|
||||
path := SettingsPath()
|
||||
s.Contains(path, "settings.json")
|
||||
}
|
||||
|
||||
// TestEnsureDataDir tests data directory creation.
|
||||
func (s *ConfigSuite) TestEnsureDataDir() {
|
||||
err := EnsureDataDir()
|
||||
s.NoError(err)
|
||||
|
||||
dir := DataDir()
|
||||
info, err := os.Stat(dir)
|
||||
s.NoError(err)
|
||||
s.True(info.IsDir())
|
||||
}
|
||||
|
||||
// TestEnsureSettings tests settings file creation.
|
||||
func (s *ConfigSuite) TestEnsureSettings() {
|
||||
// First ensure data dir exists
|
||||
err := EnsureDataDir()
|
||||
s.NoError(err)
|
||||
|
||||
// Ensure settings creates default file
|
||||
err = EnsureSettings()
|
||||
s.NoError(err)
|
||||
|
||||
path := SettingsPath()
|
||||
info, err := os.Stat(path)
|
||||
s.NoError(err)
|
||||
s.False(info.IsDir())
|
||||
|
||||
// Second call should not error (file exists)
|
||||
err = EnsureSettings()
|
||||
s.NoError(err)
|
||||
}
|
||||
|
||||
// TestEnsureAll tests full initialization.
|
||||
func (s *ConfigSuite) TestEnsureAll() {
|
||||
err := EnsureAll()
|
||||
s.NoError(err)
|
||||
|
||||
// Verify dir and settings exist
|
||||
_, err = os.Stat(DataDir())
|
||||
s.NoError(err)
|
||||
_, err = os.Stat(SettingsPath())
|
||||
s.NoError(err)
|
||||
}
|
||||
|
||||
// TestLoad_TableDriven tests configuration loading with various scenarios.
|
||||
func (s *ConfigSuite) TestLoad_TableDriven() {
|
||||
tests := []struct {
|
||||
name string
|
||||
settingsJSON string
|
||||
expectedModel string
|
||||
expectedPort int
|
||||
expectedObsObs int
|
||||
}{
|
||||
{
|
||||
name: "no settings file",
|
||||
settingsJSON: "",
|
||||
expectedPort: DefaultWorkerPort,
|
||||
expectedModel: DefaultModel,
|
||||
expectedObsObs: 100,
|
||||
},
|
||||
{
|
||||
name: "custom port",
|
||||
settingsJSON: `{"CLAUDE_MNEMONIC_WORKER_PORT": 38888}`,
|
||||
expectedPort: 38888,
|
||||
expectedModel: DefaultModel,
|
||||
expectedObsObs: 100,
|
||||
},
|
||||
{
|
||||
name: "custom model",
|
||||
settingsJSON: `{"CLAUDE_MNEMONIC_MODEL": "sonnet"}`,
|
||||
expectedPort: DefaultWorkerPort,
|
||||
expectedModel: "sonnet",
|
||||
expectedObsObs: 100,
|
||||
},
|
||||
{
|
||||
name: "custom observations",
|
||||
settingsJSON: `{"CLAUDE_MNEMONIC_CONTEXT_OBSERVATIONS": 200}`,
|
||||
expectedPort: DefaultWorkerPort,
|
||||
expectedModel: DefaultModel,
|
||||
expectedObsObs: 200,
|
||||
},
|
||||
{
|
||||
name: "multiple settings",
|
||||
settingsJSON: `{"CLAUDE_MNEMONIC_WORKER_PORT": 39999, "CLAUDE_MNEMONIC_MODEL": "opus", "CLAUDE_MNEMONIC_CONTEXT_OBSERVATIONS": 50}`,
|
||||
expectedPort: 39999,
|
||||
expectedModel: "opus",
|
||||
expectedObsObs: 50,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON returns defaults",
|
||||
settingsJSON: `{invalid}`,
|
||||
expectedPort: DefaultWorkerPort,
|
||||
expectedModel: DefaultModel,
|
||||
expectedObsObs: 100,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.Run(tt.name, func() {
|
||||
// Create fresh temp dir
|
||||
tempDir, err := os.MkdirTemp("", "config-test-*")
|
||||
s.Require().NoError(err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
os.Setenv("HOME", tempDir)
|
||||
|
||||
// Create data dir
|
||||
err = os.MkdirAll(filepath.Join(tempDir, ".claude-mnemonic"), 0750)
|
||||
s.Require().NoError(err)
|
||||
|
||||
if tt.settingsJSON != "" {
|
||||
writeErr := os.WriteFile(
|
||||
filepath.Join(tempDir, ".claude-mnemonic", "settings.json"),
|
||||
[]byte(tt.settingsJSON),
|
||||
0600,
|
||||
)
|
||||
s.Require().NoError(writeErr)
|
||||
}
|
||||
|
||||
cfg, err := Load()
|
||||
s.NoError(err)
|
||||
s.NotNil(cfg)
|
||||
s.Equal(tt.expectedPort, cfg.WorkerPort)
|
||||
s.Equal(tt.expectedModel, cfg.Model)
|
||||
s.Equal(tt.expectedObsObs, cfg.ContextObservations)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetWorkerPort_TableDriven tests worker port retrieval with various scenarios.
|
||||
func TestGetWorkerPort_TableDriven(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envValue string
|
||||
wantPort int
|
||||
setEnv bool
|
||||
}{
|
||||
{
|
||||
name: "no env, use default",
|
||||
envValue: "",
|
||||
wantPort: DefaultWorkerPort,
|
||||
setEnv: false,
|
||||
},
|
||||
{
|
||||
name: "env set to valid port",
|
||||
envValue: "38888",
|
||||
wantPort: 38888,
|
||||
setEnv: true,
|
||||
},
|
||||
{
|
||||
name: "env set to invalid value",
|
||||
envValue: "invalid",
|
||||
wantPort: DefaultWorkerPort,
|
||||
setEnv: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Save original env
|
||||
origEnv := os.Getenv("CLAUDE_MNEMONIC_WORKER_PORT")
|
||||
defer os.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", origEnv)
|
||||
|
||||
if tt.setEnv {
|
||||
os.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", tt.envValue)
|
||||
} else {
|
||||
os.Unsetenv("CLAUDE_MNEMONIC_WORKER_PORT")
|
||||
}
|
||||
|
||||
// We can't easily test GetWorkerPort since it uses Get() which caches
|
||||
// So we test the env parsing logic directly
|
||||
if tt.setEnv && tt.envValue != "" {
|
||||
if tt.wantPort != DefaultWorkerPort {
|
||||
assert.Equal(t, tt.envValue, os.Getenv("CLAUDE_MNEMONIC_WORKER_PORT"))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplitTrim tests the splitTrim helper function.
|
||||
func TestSplitTrim(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "single value",
|
||||
input: "bugfix",
|
||||
expected: []string{"bugfix"},
|
||||
},
|
||||
{
|
||||
name: "multiple values",
|
||||
input: "bugfix,feature,refactor",
|
||||
expected: []string{"bugfix", "feature", "refactor"},
|
||||
},
|
||||
{
|
||||
name: "values with spaces",
|
||||
input: " bugfix , feature , refactor ",
|
||||
expected: []string{"bugfix", "feature", "refactor"},
|
||||
},
|
||||
{
|
||||
name: "empty values filtered",
|
||||
input: "bugfix,,feature,,",
|
||||
expected: []string{"bugfix", "feature"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := splitTrim(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultObservationTypes tests default observation types.
|
||||
func TestDefaultObservationTypes(t *testing.T) {
|
||||
expected := []string{
|
||||
"bugfix", "feature", "refactor", "change", "discovery", "decision",
|
||||
}
|
||||
assert.Equal(t, expected, DefaultObservationTypes)
|
||||
}
|
||||
|
||||
// TestDefaultObservationConcepts tests default observation concepts.
|
||||
func TestDefaultObservationConcepts(t *testing.T) {
|
||||
expected := []string{
|
||||
"how-it-works", "why-it-exists", "what-changed",
|
||||
"problem-solution", "gotcha", "pattern", "trade-off",
|
||||
}
|
||||
assert.Equal(t, expected, DefaultObservationConcepts)
|
||||
}
|
||||
|
||||
// TestCriticalConcepts tests critical concepts list.
|
||||
func TestCriticalConcepts(t *testing.T) {
|
||||
expected := []string{
|
||||
"gotcha", "pattern", "problem-solution", "trade-off",
|
||||
}
|
||||
assert.Equal(t, expected, CriticalConcepts)
|
||||
}
|
||||
|
||||
// TestLoad_ClaudeCodePath tests claude code path loading.
|
||||
func TestLoad_ClaudeCodePath(t *testing.T) {
|
||||
// Create temp dir
|
||||
tempDir, err := os.MkdirTemp("", "config-test-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
origHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tempDir)
|
||||
defer os.Setenv("HOME", origHome)
|
||||
|
||||
// Create data dir and settings
|
||||
err = os.MkdirAll(filepath.Join(tempDir, ".claude-mnemonic"), 0750)
|
||||
require.NoError(t, err)
|
||||
|
||||
settingsJSON := `{"CLAUDE_CODE_PATH": "/usr/local/bin/claude"}`
|
||||
err = os.WriteFile(
|
||||
filepath.Join(tempDir, ".claude-mnemonic", "settings.json"),
|
||||
[]byte(settingsJSON),
|
||||
0600,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/usr/local/bin/claude", cfg.ClaudeCodePath)
|
||||
}
|
||||
|
||||
// TestGet tests the global config getter.
|
||||
func TestGet(t *testing.T) {
|
||||
// Save and restore HOME
|
||||
origHome := os.Getenv("HOME")
|
||||
tempDir, err := os.MkdirTemp("", "config-get-test-*")
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
os.Setenv("HOME", origHome)
|
||||
os.RemoveAll(tempDir)
|
||||
}()
|
||||
os.Setenv("HOME", tempDir)
|
||||
|
||||
// Create data dir
|
||||
err = os.MkdirAll(filepath.Join(tempDir, ".claude-mnemonic"), 0750)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get() should return a valid config
|
||||
cfg := Get()
|
||||
require.NotNil(t, cfg)
|
||||
assert.Greater(t, cfg.WorkerPort, 0)
|
||||
assert.NotEmpty(t, cfg.Model)
|
||||
}
|
||||
|
||||
// TestGetWorkerPort_WithEnv tests GetWorkerPort with environment variable.
|
||||
func TestGetWorkerPort_WithEnv(t *testing.T) {
|
||||
// Save original env
|
||||
origEnv := os.Getenv("CLAUDE_MNEMONIC_WORKER_PORT")
|
||||
defer os.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", origEnv)
|
||||
|
||||
// Test with valid port in env
|
||||
os.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", "45678")
|
||||
port := GetWorkerPort()
|
||||
assert.Equal(t, 45678, port)
|
||||
|
||||
// Test with invalid port (should fall back to config)
|
||||
os.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", "not-a-number")
|
||||
port = GetWorkerPort()
|
||||
// Should return from Get().WorkerPort, which is default
|
||||
assert.Greater(t, port, 0)
|
||||
|
||||
// Test with zero port (should fall back to config)
|
||||
os.Setenv("CLAUDE_MNEMONIC_WORKER_PORT", "0")
|
||||
port = GetWorkerPort()
|
||||
// Zero is invalid, so should use default
|
||||
assert.Greater(t, port, 0)
|
||||
|
||||
// Test with no env (should use config)
|
||||
os.Unsetenv("CLAUDE_MNEMONIC_WORKER_PORT")
|
||||
port = GetWorkerPort()
|
||||
assert.Greater(t, port, 0)
|
||||
}
|
||||
|
||||
// TestLoad_ContextSettings tests context-related settings loading.
|
||||
func TestLoad_ContextSettings(t *testing.T) {
|
||||
// Create temp dir
|
||||
tempDir, err := os.MkdirTemp("", "config-test-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
origHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tempDir)
|
||||
defer os.Setenv("HOME", origHome)
|
||||
|
||||
// Create data dir and settings
|
||||
err = os.MkdirAll(filepath.Join(tempDir, ".claude-mnemonic"), 0750)
|
||||
require.NoError(t, err)
|
||||
|
||||
settingsJSON := `{
|
||||
"CLAUDE_MNEMONIC_CONTEXT_FULL_COUNT": 50,
|
||||
"CLAUDE_MNEMONIC_CONTEXT_SESSION_COUNT": 20,
|
||||
"CLAUDE_MNEMONIC_CONTEXT_OBS_TYPES": "bugfix,feature",
|
||||
"CLAUDE_MNEMONIC_CONTEXT_OBS_CONCEPTS": "security,performance"
|
||||
}`
|
||||
err = os.WriteFile(
|
||||
filepath.Join(tempDir, ".claude-mnemonic", "settings.json"),
|
||||
[]byte(settingsJSON),
|
||||
0600,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 50, cfg.ContextFullCount)
|
||||
assert.Equal(t, 20, cfg.ContextSessionCount)
|
||||
assert.Equal(t, []string{"bugfix", "feature"}, cfg.ContextObsTypes)
|
||||
assert.Equal(t, []string{"security", "performance"}, cfg.ContextObsConcepts)
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
//go:build fts5
|
||||
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// setupBenchStore creates a temporary store for benchmarking.
|
||||
func setupBenchStore(b *testing.B) (*Store, func()) {
|
||||
b.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_bench_*")
|
||||
if err != nil {
|
||||
b.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "bench.db")
|
||||
cfg := Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 4,
|
||||
LogLevel: logger.Silent,
|
||||
}
|
||||
|
||||
store, err := NewStore(cfg)
|
||||
if err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
b.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
store.Close()
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
return store, cleanup
|
||||
}
|
||||
|
||||
// BenchmarkSessionStore_CreateSDKSession benchmarks session creation (most frequent operation).
|
||||
func BenchmarkSessionStore_CreateSDKSession(b *testing.B) {
|
||||
store, cleanup := setupBenchStore(b)
|
||||
defer cleanup()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
sessionID := fmt.Sprintf("claude-bench-%d", i)
|
||||
_, err := sessionStore.CreateSDKSession(ctx, sessionID, "bench-project", "test prompt")
|
||||
if err != nil {
|
||||
b.Fatalf("CreateSDKSession failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSessionStore_CreateSDKSession_Idempotent benchmarks idempotent session creation (INSERT OR IGNORE).
|
||||
func BenchmarkSessionStore_CreateSDKSession_Idempotent(b *testing.B) {
|
||||
store, cleanup := setupBenchStore(b)
|
||||
defer cleanup()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
ctx := context.Background()
|
||||
|
||||
// Pre-create session
|
||||
sessionStore.CreateSDKSession(ctx, "claude-bench", "bench-project", "test prompt")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := sessionStore.CreateSDKSession(ctx, "claude-bench", "bench-project", "updated prompt")
|
||||
if err != nil {
|
||||
b.Fatalf("CreateSDKSession failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkObservationStore_StoreObservation benchmarks observation storage (high frequency).
|
||||
func BenchmarkObservationStore_StoreObservation(b *testing.B) {
|
||||
store, cleanup := setupBenchStore(b)
|
||||
defer cleanup()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
obsStore := NewObservationStore(store, nil, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create session
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-bench", "bench-project", "")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
obs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: fmt.Sprintf("Observation %d", i),
|
||||
Narrative: "Benchmark observation content",
|
||||
}
|
||||
_, _, err := obsStore.StoreObservation(ctx, "claude-bench", "bench-project", obs, int(sessionID), int64(i+1))
|
||||
if err != nil {
|
||||
b.Fatalf("StoreObservation failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkObservationStore_GetRecentObservations benchmarks recent observation retrieval.
|
||||
func BenchmarkObservationStore_GetRecentObservations(b *testing.B) {
|
||||
store, cleanup := setupBenchStore(b)
|
||||
defer cleanup()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
obsStore := NewObservationStore(store, nil, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create session and observations
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-bench", "bench-project", "")
|
||||
for i := 0; i < 100; i++ {
|
||||
obs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: fmt.Sprintf("Observation %d", i),
|
||||
Narrative: "Benchmark observation content",
|
||||
}
|
||||
obsStore.StoreObservation(ctx, "claude-bench", "bench-project", obs, int(sessionID), int64(i+1))
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := obsStore.GetRecentObservations(ctx, "bench-project", 20)
|
||||
if err != nil {
|
||||
b.Fatalf("GetRecentObservations failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkObservationStore_SearchObservationsFTS benchmarks FTS5 search (latency-sensitive).
|
||||
func BenchmarkObservationStore_SearchObservationsFTS(b *testing.B) {
|
||||
store, cleanup := setupBenchStore(b)
|
||||
defer cleanup()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
obsStore := NewObservationStore(store, nil, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create session and observations with searchable content
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-bench", "bench-project", "")
|
||||
for i := 0; i < 100; i++ {
|
||||
obs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: fmt.Sprintf("Security best practice %d", i),
|
||||
Narrative: "This observation discusses security patterns and authentication mechanisms",
|
||||
}
|
||||
obsStore.StoreObservation(ctx, "claude-bench", "bench-project", obs, int(sessionID), int64(i+1))
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := obsStore.SearchObservationsFTS(ctx, "security authentication", "bench-project", 10)
|
||||
if err != nil {
|
||||
b.Fatalf("SearchObservationsFTS failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkObservationStore_UpdateImportanceScore benchmarks scoring updates.
|
||||
func BenchmarkObservationStore_UpdateImportanceScore(b *testing.B) {
|
||||
store, cleanup := setupBenchStore(b)
|
||||
defer cleanup()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
obsStore := NewObservationStore(store, nil, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create session and observation
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-bench", "bench-project", "")
|
||||
obs := &models.ParsedObservation{Type: models.ObsTypeDiscovery, Title: "Test"}
|
||||
obsID, _, _ := obsStore.StoreObservation(ctx, "claude-bench", "bench-project", obs, int(sessionID), 1)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
score := float64(i%10) + 1.0
|
||||
err := obsStore.UpdateImportanceScore(ctx, obsID, score)
|
||||
if err != nil {
|
||||
b.Fatalf("UpdateImportanceScore failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkObservationStore_UpdateImportanceScores_Bulk benchmarks bulk scoring updates.
|
||||
func BenchmarkObservationStore_UpdateImportanceScores_Bulk(b *testing.B) {
|
||||
store, cleanup := setupBenchStore(b)
|
||||
defer cleanup()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
obsStore := NewObservationStore(store, nil, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create session and 100 observations
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-bench", "bench-project", "")
|
||||
var obsIDs []int64
|
||||
for i := 0; i < 100; i++ {
|
||||
obs := &models.ParsedObservation{Type: models.ObsTypeDiscovery, Title: fmt.Sprintf("Obs %d", i)}
|
||||
obsID, _, _ := obsStore.StoreObservation(ctx, "claude-bench", "bench-project", obs, int(sessionID), int64(i+1))
|
||||
obsIDs = append(obsIDs, obsID)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
scores := make(map[int64]float64)
|
||||
for _, id := range obsIDs {
|
||||
scores[id] = float64(i%10) + 1.0
|
||||
}
|
||||
err := obsStore.UpdateImportanceScores(ctx, scores)
|
||||
if err != nil {
|
||||
b.Fatalf("UpdateImportanceScores failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkPromptStore_SaveUserPromptWithMatches benchmarks prompt storage with matches.
|
||||
func BenchmarkPromptStore_SaveUserPromptWithMatches(b *testing.B) {
|
||||
store, cleanup := setupBenchStore(b)
|
||||
defer cleanup()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
promptStore := NewPromptStore(store, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create session
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-bench", "bench-project", "")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-bench", int(sessionID), fmt.Sprintf("Prompt %d", i), i+1)
|
||||
if err != nil {
|
||||
b.Fatalf("SaveUserPromptWithMatches failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSummaryStore_StoreSummary benchmarks summary storage.
|
||||
func BenchmarkSummaryStore_StoreSummary(b *testing.B) {
|
||||
store, cleanup := setupBenchStore(b)
|
||||
defer cleanup()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
summaryStore := NewSummaryStore(store)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create session
|
||||
sessionStore.CreateSDKSession(ctx, "claude-bench", "bench-project", "")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
summary := &models.ParsedSummary{
|
||||
Request: fmt.Sprintf("Request %d", i),
|
||||
Investigated: "Investigation details",
|
||||
Learned: "Learning summary",
|
||||
Completed: "Completion status",
|
||||
}
|
||||
_, _, err := summaryStore.StoreSummary(ctx, "claude-bench", "bench-project", summary, i+1, 100)
|
||||
if err != nil {
|
||||
b.Fatalf("StoreSummary failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkRelationStore_StoreRelation benchmarks relation storage.
|
||||
func BenchmarkRelationStore_StoreRelation(b *testing.B) {
|
||||
store, cleanup := setupBenchStore(b)
|
||||
defer cleanup()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
obsStore := NewObservationStore(store, nil, nil, nil)
|
||||
relationStore := NewRelationStore(store)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create session and observations
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-bench", "bench-project", "")
|
||||
obs1 := &models.ParsedObservation{Type: models.ObsTypeDiscovery, Title: "Source"}
|
||||
obsID1, _, _ := obsStore.StoreObservation(ctx, "claude-bench", "bench-project", obs1, int(sessionID), 1)
|
||||
obs2 := &models.ParsedObservation{Type: models.ObsTypeDiscovery, Title: "Target"}
|
||||
obsID2, _, _ := obsStore.StoreObservation(ctx, "claude-bench", "bench-project", obs2, int(sessionID), 2)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
relation := &models.ObservationRelation{
|
||||
SourceID: obsID1,
|
||||
TargetID: obsID2,
|
||||
RelationType: models.RelationCauses,
|
||||
Confidence: 0.9,
|
||||
DetectionSource: models.DetectionSourceFileOverlap,
|
||||
}
|
||||
_, err := relationStore.StoreRelation(ctx, relation)
|
||||
if err != nil {
|
||||
b.Fatalf("StoreRelation failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkPatternStore_StorePattern benchmarks pattern storage.
|
||||
func BenchmarkPatternStore_StorePattern(b *testing.B) {
|
||||
store, cleanup := setupBenchStore(b)
|
||||
defer cleanup()
|
||||
|
||||
patternStore := NewPatternStore(store)
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
pattern := &models.Pattern{
|
||||
Name: fmt.Sprintf("Pattern %d", i),
|
||||
Type: models.PatternTypeBug,
|
||||
Description: sql.NullString{String: "Benchmark pattern", Valid: true},
|
||||
Frequency: 1,
|
||||
Confidence: 0.8,
|
||||
Projects: []string{"bench-project"},
|
||||
Status: models.PatternStatusActive,
|
||||
}
|
||||
_, err := patternStore.StorePattern(ctx, pattern)
|
||||
if err != nil {
|
||||
b.Fatalf("StorePattern failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// SupersededRetentionDays is the number of days to keep superseded observations before deletion.
|
||||
const SupersededRetentionDays = 3
|
||||
|
||||
// ConflictStore provides conflict-related database operations using GORM.
|
||||
type ConflictStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewConflictStore creates a new conflict store.
|
||||
func NewConflictStore(store *Store) *ConflictStore {
|
||||
return &ConflictStore{
|
||||
db: store.DB,
|
||||
}
|
||||
}
|
||||
|
||||
// StoreConflict stores a new observation conflict.
|
||||
func (s *ConflictStore) StoreConflict(ctx context.Context, conflict *models.ObservationConflict) (int64, error) {
|
||||
dbConflict := &ObservationConflict{
|
||||
NewerObsID: conflict.NewerObsID,
|
||||
OlderObsID: conflict.OlderObsID,
|
||||
ConflictType: conflict.ConflictType,
|
||||
Resolution: conflict.Resolution,
|
||||
DetectedAt: conflict.DetectedAt,
|
||||
DetectedAtEpoch: conflict.DetectedAtEpoch,
|
||||
Resolved: 0,
|
||||
}
|
||||
|
||||
// Convert bool to int
|
||||
if conflict.Resolved {
|
||||
dbConflict.Resolved = 1
|
||||
}
|
||||
|
||||
// Handle nullable fields
|
||||
if conflict.Reason != "" {
|
||||
dbConflict.Reason = sql.NullString{String: conflict.Reason, Valid: true}
|
||||
}
|
||||
if conflict.ResolvedAt != nil && *conflict.ResolvedAt != "" {
|
||||
dbConflict.ResolvedAt = sql.NullString{String: *conflict.ResolvedAt, Valid: true}
|
||||
}
|
||||
|
||||
result := s.db.WithContext(ctx).Create(dbConflict)
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
|
||||
return dbConflict.ID, nil
|
||||
}
|
||||
|
||||
// MarkObservationSuperseded marks an observation as superseded.
|
||||
func (s *ConflictStore) MarkObservationSuperseded(ctx context.Context, obsID int64) error {
|
||||
result := s.db.WithContext(ctx).
|
||||
Model(&Observation{}).
|
||||
Where("id = ?", obsID).
|
||||
Update("is_superseded", 1)
|
||||
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// MarkObservationsSuperseded marks multiple observations as superseded.
|
||||
func (s *ConflictStore) MarkObservationsSuperseded(ctx context.Context, obsIDs []int64) error {
|
||||
if len(obsIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := s.db.WithContext(ctx).
|
||||
Model(&Observation{}).
|
||||
Where("id IN ?", obsIDs).
|
||||
Update("is_superseded", 1)
|
||||
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// GetConflictsByObservationID retrieves all conflicts involving an observation.
|
||||
func (s *ConflictStore) GetConflictsByObservationID(ctx context.Context, obsID int64) ([]*models.ObservationConflict, error) {
|
||||
var conflicts []ObservationConflict
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("newer_obs_id = ? OR older_obs_id = ?", obsID, obsID).
|
||||
Order("detected_at_epoch DESC").
|
||||
Find(&conflicts).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelConflicts(conflicts), nil
|
||||
}
|
||||
|
||||
// GetUnresolvedConflicts retrieves all unresolved conflicts.
|
||||
func (s *ConflictStore) GetUnresolvedConflicts(ctx context.Context, limit int) ([]*models.ObservationConflict, error) {
|
||||
var conflicts []ObservationConflict
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("resolved = 0").
|
||||
Order("detected_at_epoch DESC").
|
||||
Limit(limit).
|
||||
Find(&conflicts).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelConflicts(conflicts), nil
|
||||
}
|
||||
|
||||
// GetSupersededObservationIDs returns IDs of all observations that have been superseded.
|
||||
func (s *ConflictStore) GetSupersededObservationIDs(ctx context.Context, project string) ([]int64, error) {
|
||||
var ids []int64
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Table("observation_conflicts oc").
|
||||
Select("DISTINCT oc.older_obs_id").
|
||||
Joins("JOIN observations o ON o.id = oc.older_obs_id").
|
||||
Where("oc.resolution = ?", models.ResolutionPreferNewer).
|
||||
Where("o.project = ? OR o.scope = 'global'", project).
|
||||
Pluck("oc.older_obs_id", &ids).Error
|
||||
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// ResolveConflict marks a conflict as resolved.
|
||||
func (s *ConflictStore) ResolveConflict(ctx context.Context, conflictID int64, resolution models.ConflictResolution) error {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
|
||||
result := s.db.WithContext(ctx).
|
||||
Model(&ObservationConflict{}).
|
||||
Where("id = ?", conflictID).
|
||||
Updates(map[string]interface{}{
|
||||
"resolved": 1,
|
||||
"resolved_at": now,
|
||||
"resolution": resolution,
|
||||
})
|
||||
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// DeleteConflictsByObservationID deletes all conflicts involving an observation.
|
||||
// Called when an observation is deleted.
|
||||
func (s *ConflictStore) DeleteConflictsByObservationID(ctx context.Context, obsID int64) error {
|
||||
result := s.db.WithContext(ctx).
|
||||
Where("newer_obs_id = ? OR older_obs_id = ?", obsID, obsID).
|
||||
Delete(&ObservationConflict{})
|
||||
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// ConflictWithDetails contains a conflict with its observation details.
|
||||
type ConflictWithDetails struct {
|
||||
Conflict *models.ObservationConflict
|
||||
NewerObsTitle string
|
||||
OlderObsTitle string
|
||||
}
|
||||
|
||||
// CleanupSupersededObservations deletes observations that have been superseded for longer than
|
||||
// SupersededRetentionDays. Returns the IDs of deleted observations for downstream cleanup (e.g., vector DB).
|
||||
func (s *ConflictStore) CleanupSupersededObservations(ctx context.Context, project string) ([]int64, error) {
|
||||
// Calculate cutoff time (3 days ago in milliseconds)
|
||||
cutoffEpoch := time.Now().AddDate(0, 0, -SupersededRetentionDays).UnixMilli()
|
||||
|
||||
var toDelete []int64
|
||||
|
||||
// Use a transaction to prevent TOCTOU race condition
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Find IDs to delete
|
||||
err := tx.Table("observations o").
|
||||
Select("DISTINCT o.id").
|
||||
Joins("JOIN observation_conflicts oc ON o.id = oc.older_obs_id").
|
||||
Where("o.is_superseded = 1").
|
||||
Where("o.project = ?", project).
|
||||
Where("oc.detected_at_epoch < ?", cutoffEpoch).
|
||||
Pluck("o.id", &toDelete).Error
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(toDelete) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete the conflict records first (due to foreign key constraints)
|
||||
for _, obsID := range toDelete {
|
||||
err := tx.Where("newer_obs_id = ? OR older_obs_id = ?", obsID, obsID).
|
||||
Delete(&ObservationConflict{}).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the observations
|
||||
return tx.Delete(&Observation{}, toDelete).Error
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toDelete, nil
|
||||
}
|
||||
|
||||
// GetConflictsWithDetails retrieves all conflicts with observation titles for display.
|
||||
func (s *ConflictStore) GetConflictsWithDetails(ctx context.Context, project string, limit int) ([]*ConflictWithDetails, error) {
|
||||
var results []struct {
|
||||
NewerTitle sql.NullString `gorm:"column:newer_title"`
|
||||
OlderTitle sql.NullString `gorm:"column:older_title"`
|
||||
ObservationConflict
|
||||
}
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Table("observation_conflicts oc").
|
||||
Select("oc.*, "+
|
||||
"COALESCE(newer.title, '') as newer_title, "+
|
||||
"COALESCE(older.title, '') as older_title").
|
||||
Joins("JOIN observations newer ON newer.id = oc.newer_obs_id").
|
||||
Joins("JOIN observations older ON older.id = oc.older_obs_id").
|
||||
Where("newer.project = ? OR older.project = ?", project, project).
|
||||
Order("oc.detected_at_epoch DESC").
|
||||
Limit(limit).
|
||||
Scan(&results).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conflicts := make([]*ConflictWithDetails, len(results))
|
||||
for i, r := range results {
|
||||
conflicts[i] = &ConflictWithDetails{
|
||||
Conflict: toModelConflict(&r.ObservationConflict),
|
||||
NewerObsTitle: r.NewerTitle.String,
|
||||
OlderObsTitle: r.OlderTitle.String,
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts, nil
|
||||
}
|
||||
|
||||
// toModelConflict converts a GORM ObservationConflict to a pkg/models ObservationConflict.
|
||||
func toModelConflict(c *ObservationConflict) *models.ObservationConflict {
|
||||
conflict := &models.ObservationConflict{
|
||||
ID: c.ID,
|
||||
NewerObsID: c.NewerObsID,
|
||||
OlderObsID: c.OlderObsID,
|
||||
ConflictType: c.ConflictType,
|
||||
Resolution: c.Resolution,
|
||||
DetectedAt: c.DetectedAt,
|
||||
DetectedAtEpoch: c.DetectedAtEpoch,
|
||||
Resolved: c.Resolved == 1,
|
||||
}
|
||||
|
||||
if c.Reason.Valid {
|
||||
conflict.Reason = c.Reason.String
|
||||
}
|
||||
if c.ResolvedAt.Valid {
|
||||
s := c.ResolvedAt.String
|
||||
conflict.ResolvedAt = &s
|
||||
}
|
||||
|
||||
return conflict
|
||||
}
|
||||
|
||||
// toModelConflicts converts a slice of GORM ObservationConflicts to pkg/models ObservationConflicts.
|
||||
func toModelConflicts(conflicts []ObservationConflict) []*models.ObservationConflict {
|
||||
result := make([]*models.ObservationConflict, len(conflicts))
|
||||
for i, c := range conflicts {
|
||||
result[i] = toModelConflict(&c)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,637 +0,0 @@
|
||||
//go:build fts5
|
||||
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// testConflictStore creates a ConflictStore with a temporary database for testing.
|
||||
func testConflictStore(t *testing.T) (*ConflictStore, *Store, func()) {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_conflict_test_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
cfg := Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 4,
|
||||
LogLevel: logger.Silent,
|
||||
}
|
||||
|
||||
store, err := NewStore(cfg)
|
||||
if err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
|
||||
conflictStore := NewConflictStore(store)
|
||||
|
||||
cleanup := func() {
|
||||
store.Close()
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
return conflictStore, store, cleanup
|
||||
}
|
||||
|
||||
func TestConflictStore_StoreConflict(t *testing.T) {
|
||||
conflictStore, store, cleanup := testConflictStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create session for observations
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, err := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create test observations
|
||||
obsStore := NewObservationStore(store, nil, nil, nil)
|
||||
obs1 := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Newer observation",
|
||||
}
|
||||
obsID1, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs1, int(sessionID), 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
obs2 := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Older observation",
|
||||
}
|
||||
obsID2, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs2, int(sessionID), 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create conflict
|
||||
now := time.Now()
|
||||
conflict := &models.ObservationConflict{
|
||||
NewerObsID: obsID1,
|
||||
OlderObsID: obsID2,
|
||||
ConflictType: models.ConflictContradicts,
|
||||
Resolution: models.ResolutionPreferNewer,
|
||||
Reason: "Newer observation contradicts older one",
|
||||
DetectedAt: now.Format(time.RFC3339),
|
||||
DetectedAtEpoch: now.UnixMilli(),
|
||||
Resolved: false,
|
||||
}
|
||||
|
||||
id, err := conflictStore.StoreConflict(ctx, conflict)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, id, int64(0))
|
||||
|
||||
// Verify conflict was stored
|
||||
var count int64
|
||||
store.DB.Model(&ObservationConflict{}).Where("id = ?", id).Count(&count)
|
||||
assert.Equal(t, int64(1), count)
|
||||
}
|
||||
|
||||
func TestConflictStore_MarkObservationSuperseded(t *testing.T) {
|
||||
conflictStore, store, cleanup := testConflictStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create observation
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, err := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
obsStore := NewObservationStore(store, nil, nil, nil)
|
||||
obs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Test observation",
|
||||
}
|
||||
obsID, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs, int(sessionID), 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Mark as superseded
|
||||
err = conflictStore.MarkObservationSuperseded(ctx, obsID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it's marked
|
||||
var dbObs Observation
|
||||
store.DB.First(&dbObs, obsID)
|
||||
assert.Equal(t, 1, dbObs.IsSuperseded)
|
||||
}
|
||||
|
||||
func TestConflictStore_MarkObservationsSuperseded(t *testing.T) {
|
||||
conflictStore, store, cleanup := testConflictStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
obsIDs []int64
|
||||
setup func() []int64
|
||||
}{
|
||||
{
|
||||
name: "empty list",
|
||||
obsIDs: []int64{},
|
||||
setup: func() []int64 { return []int64{} },
|
||||
},
|
||||
{
|
||||
name: "single observation",
|
||||
setup: func() []int64 {
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
obsStore := NewObservationStore(store, nil, nil, nil)
|
||||
obs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Test",
|
||||
}
|
||||
id, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs, int(sessionID), 1)
|
||||
return []int64{id}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple observations",
|
||||
setup: func() []int64 {
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
obsStore := NewObservationStore(store, nil, nil, nil)
|
||||
var ids []int64
|
||||
for i := 0; i < 3; i++ {
|
||||
obs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Test",
|
||||
}
|
||||
id, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs, int(sessionID), int64(i+1))
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
obsIDs := tt.setup()
|
||||
err := conflictStore.MarkObservationsSuperseded(ctx, obsIDs)
|
||||
require.NoError(t, err)
|
||||
|
||||
if len(obsIDs) > 0 {
|
||||
// Verify all are marked
|
||||
for _, id := range obsIDs {
|
||||
var dbObs Observation
|
||||
store.DB.First(&dbObs, id)
|
||||
assert.Equal(t, 1, dbObs.IsSuperseded)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConflictStore_GetConflictsByObservationID(t *testing.T) {
|
||||
conflictStore, store, cleanup := testConflictStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create observations
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, err := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
obsStore := NewObservationStore(store, nil, nil, nil)
|
||||
var obsIDs []int64
|
||||
for i := 0; i < 3; i++ {
|
||||
obs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Test",
|
||||
}
|
||||
id, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs, int(sessionID), int64(i+1))
|
||||
require.NoError(t, err)
|
||||
obsIDs = append(obsIDs, id)
|
||||
}
|
||||
|
||||
// Create conflicts involving observation 2 (index 1)
|
||||
now := time.Now()
|
||||
conflict1 := &models.ObservationConflict{
|
||||
NewerObsID: obsIDs[0],
|
||||
OlderObsID: obsIDs[1],
|
||||
ConflictType: models.ConflictContradicts,
|
||||
Resolution: models.ResolutionPreferNewer,
|
||||
Reason: "reason1",
|
||||
DetectedAt: now.Format(time.RFC3339),
|
||||
DetectedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
_, err = conflictStore.StoreConflict(ctx, conflict1)
|
||||
require.NoError(t, err)
|
||||
|
||||
conflict2 := &models.ObservationConflict{
|
||||
NewerObsID: obsIDs[1],
|
||||
OlderObsID: obsIDs[2],
|
||||
ConflictType: models.ConflictSuperseded,
|
||||
Resolution: models.ResolutionPreferNewer,
|
||||
Reason: "reason2",
|
||||
DetectedAt: now.Format(time.RFC3339),
|
||||
DetectedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
_, err = conflictStore.StoreConflict(ctx, conflict2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get conflicts for observation 2 (involved in 2 conflicts)
|
||||
conflicts, err := conflictStore.GetConflictsByObservationID(ctx, obsIDs[1])
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, conflicts, 2)
|
||||
}
|
||||
|
||||
func TestConflictStore_GetUnresolvedConflicts(t *testing.T) {
|
||||
conflictStore, store, cleanup := testConflictStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create observations
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, err := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
obsStore := NewObservationStore(store, nil, nil, nil)
|
||||
obs1 := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Test1",
|
||||
}
|
||||
obsID1, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs1, int(sessionID), 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
obs2 := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Test2",
|
||||
}
|
||||
obsID2, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs2, int(sessionID), 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create unresolved conflicts
|
||||
now := time.Now()
|
||||
for i := 0; i < 5; i++ {
|
||||
conflict := &models.ObservationConflict{
|
||||
NewerObsID: obsID1,
|
||||
OlderObsID: obsID2,
|
||||
ConflictType: models.ConflictContradicts,
|
||||
Resolution: models.ResolutionPreferNewer,
|
||||
Reason: "reason",
|
||||
DetectedAt: now.Format(time.RFC3339),
|
||||
DetectedAtEpoch: now.UnixMilli(),
|
||||
Resolved: false,
|
||||
}
|
||||
_, err = conflictStore.StoreConflict(ctx, conflict)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create resolved conflict
|
||||
resolvedAt := now.Format(time.RFC3339)
|
||||
resolvedConflict := &models.ObservationConflict{
|
||||
NewerObsID: obsID1,
|
||||
OlderObsID: obsID2,
|
||||
ConflictType: models.ConflictContradicts,
|
||||
Resolution: models.ResolutionPreferNewer,
|
||||
Reason: "reason",
|
||||
DetectedAt: now.Format(time.RFC3339),
|
||||
DetectedAtEpoch: now.UnixMilli(),
|
||||
Resolved: true,
|
||||
ResolvedAt: &resolvedAt,
|
||||
}
|
||||
_, err = conflictStore.StoreConflict(ctx, resolvedConflict)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get unresolved conflicts with limit
|
||||
conflicts, err := conflictStore.GetUnresolvedConflicts(ctx, 3)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, conflicts, 3)
|
||||
|
||||
// Verify all are unresolved
|
||||
for _, c := range conflicts {
|
||||
assert.False(t, c.Resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConflictStore_GetSupersededObservationIDs(t *testing.T) {
|
||||
conflictStore, store, cleanup := testConflictStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create observations
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, err := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
obsStore := NewObservationStore(store, nil, nil, nil)
|
||||
|
||||
// Create newer observations
|
||||
newer1 := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Newer1",
|
||||
}
|
||||
newerID1, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", newer1, int(sessionID), 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
newer2 := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Newer2",
|
||||
}
|
||||
newerID2, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", newer2, int(sessionID), 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create older observations
|
||||
older1 := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Older1",
|
||||
}
|
||||
olderID1, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", older1, int(sessionID), 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
older2 := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Older2",
|
||||
}
|
||||
olderID2, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", older2, int(sessionID), 4)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Mark older observations as superseded
|
||||
err = conflictStore.MarkObservationsSuperseded(ctx, []int64{olderID1, olderID2})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create conflicts with prefer_newer resolution
|
||||
now := time.Now()
|
||||
conflict1 := &models.ObservationConflict{
|
||||
NewerObsID: newerID1,
|
||||
OlderObsID: olderID1,
|
||||
ConflictType: models.ConflictSuperseded,
|
||||
Resolution: models.ResolutionPreferNewer,
|
||||
Reason: "reason1",
|
||||
DetectedAt: now.Format(time.RFC3339),
|
||||
DetectedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
_, err = conflictStore.StoreConflict(ctx, conflict1)
|
||||
require.NoError(t, err)
|
||||
|
||||
conflict2 := &models.ObservationConflict{
|
||||
NewerObsID: newerID2,
|
||||
OlderObsID: olderID2,
|
||||
ConflictType: models.ConflictSuperseded,
|
||||
Resolution: models.ResolutionPreferNewer,
|
||||
Reason: "reason2",
|
||||
DetectedAt: now.Format(time.RFC3339),
|
||||
DetectedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
_, err = conflictStore.StoreConflict(ctx, conflict2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get superseded IDs (should return older observation IDs)
|
||||
ids, err := conflictStore.GetSupersededObservationIDs(ctx, "test-project")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, ids, 2)
|
||||
assert.Contains(t, ids, olderID1)
|
||||
assert.Contains(t, ids, olderID2)
|
||||
}
|
||||
|
||||
func TestConflictStore_ResolveConflict(t *testing.T) {
|
||||
conflictStore, _, cleanup := testConflictStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a simple conflict by inserting directly to DB
|
||||
conflict := &ObservationConflict{
|
||||
NewerObsID: 1,
|
||||
OlderObsID: 2,
|
||||
ConflictType: models.ConflictContradicts,
|
||||
Resolution: models.ResolutionManual,
|
||||
DetectedAt: time.Now().Format(time.RFC3339),
|
||||
DetectedAtEpoch: time.Now().UnixMilli(),
|
||||
Resolved: 0,
|
||||
}
|
||||
conflictStore.db.Create(conflict)
|
||||
|
||||
// Resolve conflict
|
||||
err := conflictStore.ResolveConflict(ctx, conflict.ID, models.ResolutionPreferNewer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify resolved
|
||||
var resolved ObservationConflict
|
||||
conflictStore.db.First(&resolved, conflict.ID)
|
||||
assert.Equal(t, 1, resolved.Resolved)
|
||||
assert.True(t, resolved.ResolvedAt.Valid)
|
||||
assert.Equal(t, models.ResolutionPreferNewer, resolved.Resolution)
|
||||
}
|
||||
|
||||
func TestConflictStore_DeleteConflictsByObservationID(t *testing.T) {
|
||||
conflictStore, _, cleanup := testConflictStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create conflicts directly in DB
|
||||
now := time.Now()
|
||||
conflicts := []ObservationConflict{
|
||||
{
|
||||
NewerObsID: 1,
|
||||
OlderObsID: 2,
|
||||
ConflictType: models.ConflictContradicts,
|
||||
Resolution: models.ResolutionPreferNewer,
|
||||
DetectedAt: now.Format(time.RFC3339),
|
||||
DetectedAtEpoch: now.UnixMilli(),
|
||||
},
|
||||
{
|
||||
NewerObsID: 3,
|
||||
OlderObsID: 1,
|
||||
ConflictType: models.ConflictContradicts,
|
||||
Resolution: models.ResolutionPreferNewer,
|
||||
DetectedAt: now.Format(time.RFC3339),
|
||||
DetectedAtEpoch: now.UnixMilli(),
|
||||
},
|
||||
{
|
||||
NewerObsID: 2,
|
||||
OlderObsID: 3,
|
||||
ConflictType: models.ConflictContradicts,
|
||||
Resolution: models.ResolutionPreferNewer,
|
||||
DetectedAt: now.Format(time.RFC3339),
|
||||
DetectedAtEpoch: now.UnixMilli(),
|
||||
},
|
||||
}
|
||||
for _, c := range conflicts {
|
||||
conflictStore.db.Create(&c)
|
||||
}
|
||||
|
||||
// Delete conflicts for observation 1
|
||||
err := conflictStore.DeleteConflictsByObservationID(ctx, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify only conflicts involving 1 are deleted
|
||||
var count int64
|
||||
conflictStore.db.Model(&ObservationConflict{}).
|
||||
Where("newer_obs_id = 1 OR older_obs_id = 1").
|
||||
Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
|
||||
// Other conflict should still exist
|
||||
conflictStore.db.Model(&ObservationConflict{}).
|
||||
Where("newer_obs_id = 2 AND older_obs_id = 3").
|
||||
Count(&count)
|
||||
assert.Equal(t, int64(1), count)
|
||||
}
|
||||
|
||||
func TestConflictStore_CleanupSupersededObservations(t *testing.T) {
|
||||
conflictStore, store, cleanup := testConflictStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create observations
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, err := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
obsStore := NewObservationStore(store, nil, nil, nil)
|
||||
|
||||
// Create newer observations
|
||||
newer1 := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Newer1",
|
||||
}
|
||||
newerID1, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", newer1, int(sessionID), 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
newer2 := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Newer2",
|
||||
}
|
||||
newerID2, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", newer2, int(sessionID), 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create older observations
|
||||
older1 := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "OldSuperseded",
|
||||
}
|
||||
oldSupersededID, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", older1, int(sessionID), 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
older2 := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "RecentSuperseded",
|
||||
}
|
||||
recentSupersededID, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", older2, int(sessionID), 4)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Mark as superseded
|
||||
err = conflictStore.MarkObservationsSuperseded(ctx, []int64{oldSupersededID, recentSupersededID})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create conflicts
|
||||
oldTime := time.Now().AddDate(0, 0, -SupersededRetentionDays-1)
|
||||
recentTime := time.Now().AddDate(0, 0, -1)
|
||||
|
||||
// Old conflict (should be deleted)
|
||||
oldConflict := &models.ObservationConflict{
|
||||
NewerObsID: newerID1,
|
||||
OlderObsID: oldSupersededID,
|
||||
ConflictType: models.ConflictSuperseded,
|
||||
Resolution: models.ResolutionPreferNewer,
|
||||
Reason: "old",
|
||||
DetectedAt: oldTime.Format(time.RFC3339),
|
||||
DetectedAtEpoch: oldTime.UnixMilli(),
|
||||
}
|
||||
_, err = conflictStore.StoreConflict(ctx, oldConflict)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Recent conflict (should be kept)
|
||||
recentConflict := &models.ObservationConflict{
|
||||
NewerObsID: newerID2,
|
||||
OlderObsID: recentSupersededID,
|
||||
ConflictType: models.ConflictSuperseded,
|
||||
Resolution: models.ResolutionPreferNewer,
|
||||
Reason: "recent",
|
||||
DetectedAt: recentTime.Format(time.RFC3339),
|
||||
DetectedAtEpoch: recentTime.UnixMilli(),
|
||||
}
|
||||
_, err = conflictStore.StoreConflict(ctx, recentConflict)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Cleanup old superseded observations
|
||||
deletedIDs, err := conflictStore.CleanupSupersededObservations(ctx, "test-project")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, deletedIDs, 1)
|
||||
assert.Contains(t, deletedIDs, oldSupersededID)
|
||||
|
||||
// Verify only old superseded observation was deleted
|
||||
var count int64
|
||||
store.DB.Model(&Observation{}).Count(&count)
|
||||
assert.Equal(t, int64(3), count) // newer1, newer2, recentSuperseded remain
|
||||
|
||||
// Verify old observation was deleted
|
||||
store.DB.Model(&Observation{}).Where("id = ?", oldSupersededID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
func TestConflictStore_GetConflictsWithDetails(t *testing.T) {
|
||||
conflictStore, store, cleanup := testConflictStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create observations
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, err := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
obsStore := NewObservationStore(store, nil, nil, nil)
|
||||
|
||||
newer := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Newer observation",
|
||||
}
|
||||
newerID, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", newer, int(sessionID), 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
older := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Older observation",
|
||||
}
|
||||
olderID, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", older, int(sessionID), 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create conflict
|
||||
now := time.Now()
|
||||
conflict := &models.ObservationConflict{
|
||||
NewerObsID: newerID,
|
||||
OlderObsID: olderID,
|
||||
ConflictType: models.ConflictContradicts,
|
||||
Resolution: models.ResolutionPreferNewer,
|
||||
Reason: "Test conflict",
|
||||
DetectedAt: now.Format(time.RFC3339),
|
||||
DetectedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
_, err = conflictStore.StoreConflict(ctx, conflict)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get conflicts with details
|
||||
conflicts, err := conflictStore.GetConflictsWithDetails(ctx, "test-project", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, conflicts, 1)
|
||||
|
||||
// Verify conflict details
|
||||
assert.Equal(t, newerID, conflicts[0].Conflict.NewerObsID)
|
||||
assert.Equal(t, olderID, conflicts[0].Conflict.OlderObsID)
|
||||
assert.Equal(t, models.ConflictContradicts, conflicts[0].Conflict.ConflictType)
|
||||
assert.Equal(t, "Test conflict", conflicts[0].Conflict.Reason)
|
||||
assert.Equal(t, "Newer observation", conflicts[0].NewerObsTitle)
|
||||
assert.Equal(t, "Older observation", conflicts[0].OlderObsTitle)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// Package gorm provides a GORM-based database implementation for claude-mnemonic.
|
||||
//
|
||||
// This is a drop-in replacement for internal/db/sqlite with the following benefits:
|
||||
// - 50% code reduction (8,500 → 4,250 lines)
|
||||
// - Type-safe query building
|
||||
// - Automatic statement caching
|
||||
// - Same performance characteristics
|
||||
// - Zero breaking changes
|
||||
//
|
||||
// Status: Production-ready, not yet integrated
|
||||
//
|
||||
// # Integration
|
||||
//
|
||||
// To use this package instead of internal/db/sqlite:
|
||||
//
|
||||
// import "github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
|
||||
//
|
||||
// store, err := gorm.NewStore(gorm.Config{
|
||||
// Path: "/path/to/database.db",
|
||||
// MaxConns: 4,
|
||||
// LogLevel: logger.Silent,
|
||||
// })
|
||||
//
|
||||
// See INTEGRATION_GUIDE.md for complete migration instructions.
|
||||
//
|
||||
// # Testing
|
||||
//
|
||||
// All tests require the fts5 build tag:
|
||||
//
|
||||
// go test -tags "fts5" -v ./internal/db/gorm
|
||||
//
|
||||
// # Performance
|
||||
//
|
||||
// See PERFORMANCE.md for detailed benchmark results.
|
||||
package gorm
|
||||
|
||||
// This file exists for package documentation and to prevent deadcode warnings
|
||||
// on an intentionally unused (but complete and tested) implementation.
|
||||
@@ -1,42 +0,0 @@
|
||||
//go:build fts5
|
||||
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// TestFTS5Available verifies FTS5 is available in mattn/go-sqlite3
|
||||
func TestFTS5Available(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "fts5_test_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
// Open with mattn/go-sqlite3 driver
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Try to create FTS5 virtual table
|
||||
_, err = db.Exec(`
|
||||
CREATE VIRTUAL TABLE test_fts USING fts5(
|
||||
content
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create FTS5 table failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ FTS5 is available in mattn/go-sqlite3")
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// EnsureSessionExists creates a session if it doesn't exist.
|
||||
// Uses INSERT OR IGNORE pattern for atomic idempotent creation (single query instead of COUNT + INSERT).
|
||||
// This is shared between stores to avoid duplication.
|
||||
func EnsureSessionExists(ctx context.Context, db *gorm.DB, sdkSessionID, project string) error {
|
||||
now := time.Now()
|
||||
session := &SDKSession{
|
||||
ClaudeSessionID: sdkSessionID,
|
||||
SDKSessionID: sqlNullString(sdkSessionID),
|
||||
Project: project,
|
||||
Status: "active",
|
||||
StartedAt: now.Format(time.RFC3339),
|
||||
StartedAtEpoch: now.UnixMilli(),
|
||||
PromptCounter: 0,
|
||||
}
|
||||
|
||||
// Use INSERT OR IGNORE - single query, atomic operation
|
||||
// If session already exists (conflict on sdk_session_id), do nothing
|
||||
return db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "sdk_session_id"}},
|
||||
DoNothing: true,
|
||||
}).
|
||||
Create(session).Error
|
||||
}
|
||||
|
||||
// sqlNullString creates a sql.NullString from a string.
|
||||
func sqlNullString(s string) sql.NullString {
|
||||
if s == "" {
|
||||
return sql.NullString{Valid: false}
|
||||
}
|
||||
return sql.NullString{String: s, Valid: true}
|
||||
}
|
||||
|
||||
// MaxPaginationLimit is the maximum allowed limit for pagination queries.
|
||||
// This protects against resource exhaustion from excessively large requests.
|
||||
const MaxPaginationLimit = 1000
|
||||
|
||||
// ParseLimitParam parses the "limit" query parameter from an HTTP request.
|
||||
// Returns defaultLimit if the parameter is missing or invalid.
|
||||
// Note: This does NOT enforce a maximum limit. Use ParseLimitParamWithMax for that.
|
||||
func ParseLimitParam(r *http.Request, defaultLimit int) int {
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultLimit
|
||||
}
|
||||
|
||||
// ParseLimitParamWithMax parses the "limit" query parameter with a maximum cap.
|
||||
// Returns min(parsed, maxLimit) or defaultLimit if missing/invalid.
|
||||
// If maxLimit is 0, uses MaxPaginationLimit (1000).
|
||||
func ParseLimitParamWithMax(r *http.Request, defaultLimit, maxLimit int) int {
|
||||
if maxLimit <= 0 {
|
||||
maxLimit = MaxPaginationLimit
|
||||
}
|
||||
limit := ParseLimitParam(r, defaultLimit)
|
||||
if limit > maxLimit {
|
||||
return maxLimit
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// ParseOffsetParam parses the "offset" query parameter from an HTTP request.
|
||||
// Returns 0 if the parameter is missing or invalid.
|
||||
func ParseOffsetParam(r *http.Request) int {
|
||||
if o := r.URL.Query().Get("offset"); o != "" {
|
||||
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// PaginationParams holds pagination parameters.
|
||||
type PaginationParams struct {
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ParsePaginationParams parses both limit and offset from an HTTP request.
|
||||
func ParsePaginationParams(r *http.Request, defaultLimit int) PaginationParams {
|
||||
return PaginationParams{
|
||||
Limit: ParseLimitParam(r, defaultLimit),
|
||||
Offset: ParseOffsetParam(r),
|
||||
}
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
//go:build fts5
|
||||
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// TestIntegration_EndToEndWorkflow verifies a complete workflow
|
||||
// simulating real usage of the GORM package.
|
||||
func TestIntegration_EndToEndWorkflow(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_integration_test_*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
cfg := Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 4,
|
||||
LogLevel: logger.Silent,
|
||||
}
|
||||
|
||||
// Step 1: Initialize store
|
||||
store, err := NewStore(cfg)
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Step 2: Create all store types
|
||||
sessionStore := NewSessionStore(store)
|
||||
summaryStore := NewSummaryStore(store)
|
||||
conflictStore := NewConflictStore(store)
|
||||
relationStore := NewRelationStore(store)
|
||||
patternStore := NewPatternStore(store)
|
||||
|
||||
// Create observation store with dependencies
|
||||
observationStore := NewObservationStore(store, nil, conflictStore, relationStore)
|
||||
promptStore := NewPromptStore(store, nil)
|
||||
|
||||
// Step 3: Create a session
|
||||
sessionID, err := sessionStore.CreateSDKSession(ctx, "claude-test", "test-project", "")
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, sessionID, int64(0))
|
||||
|
||||
// Step 4: Store observations
|
||||
obs1 := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Test Discovery",
|
||||
Subtitle: "Testing GORM integration",
|
||||
Facts: []string{"Fact 1", "Fact 2"},
|
||||
Concepts: []string{"testing", "integration"},
|
||||
}
|
||||
|
||||
obsID1, _, err := observationStore.StoreObservation(ctx, "claude-test", "test-project", obs1, int(sessionID), 1)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, obsID1, int64(0))
|
||||
|
||||
obs2 := &models.ParsedObservation{
|
||||
Type: models.ObsTypeBugfix,
|
||||
Title: "Test Bugfix",
|
||||
Facts: []string{"Fixed bug"},
|
||||
Concepts: []string{"bugfix"},
|
||||
}
|
||||
|
||||
obsID2, _, err := observationStore.StoreObservation(ctx, "claude-test", "test-project", obs2, int(sessionID), 2)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, obsID2, int64(0))
|
||||
|
||||
// Step 5: Create relations
|
||||
now := time.Now()
|
||||
relation := &models.ObservationRelation{
|
||||
SourceID: obsID1,
|
||||
TargetID: obsID2,
|
||||
RelationType: models.RelationCauses,
|
||||
Confidence: 0.8,
|
||||
DetectionSource: models.DetectionSourceFileOverlap,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
|
||||
relID, err := relationStore.StoreRelation(ctx, relation)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, relID, int64(0))
|
||||
|
||||
// Step 6: Update importance scores
|
||||
err = observationStore.UpdateImportanceScore(ctx, obsID1, 5.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 7: Increment retrieval counts
|
||||
err = observationStore.IncrementRetrievalCount(ctx, []int64{obsID1, obsID2})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 8: Create a pattern
|
||||
pattern := &models.Pattern{
|
||||
Name: "Test Pattern",
|
||||
Type: models.PatternTypeBug,
|
||||
Signature: []string{"bug", "fix"},
|
||||
Frequency: 1,
|
||||
Projects: []string{"test-project"},
|
||||
ObservationIDs: []int64{obsID1, obsID2},
|
||||
Status: models.PatternStatusActive,
|
||||
Confidence: 0.75,
|
||||
LastSeenAt: now.Format(time.RFC3339),
|
||||
LastSeenEpoch: now.UnixMilli(),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
|
||||
patternID, err := patternStore.StorePattern(ctx, pattern)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, patternID, int64(0))
|
||||
|
||||
// Step 9: Store a prompt
|
||||
promptID, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-test", 1, "Test prompt", 2)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, promptID, int64(0))
|
||||
|
||||
// Step 10: Store a summary
|
||||
summary := &models.ParsedSummary{
|
||||
Request: "Test request",
|
||||
Investigated: "Test investigation",
|
||||
Learned: "Test learning",
|
||||
Completed: "Test completion",
|
||||
NextSteps: "Test next steps",
|
||||
Notes: "Test notes",
|
||||
}
|
||||
|
||||
summaryID, _, err := summaryStore.StoreSummary(ctx, "claude-test", "test-project", summary, 1, 100)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, summaryID, int64(0))
|
||||
|
||||
// Step 11: Verify data retrieval
|
||||
retrievedObs, err := observationStore.GetObservationByID(ctx, obsID1)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrievedObs)
|
||||
assert.Equal(t, "Test Discovery", retrievedObs.Title.String)
|
||||
assert.Equal(t, 5.0, retrievedObs.ImportanceScore)
|
||||
assert.Equal(t, 1, retrievedObs.RetrievalCount)
|
||||
|
||||
// Step 12: Verify relations
|
||||
relations, err := relationStore.GetRelationsByObservationID(ctx, obsID1)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, relations, 1)
|
||||
assert.Equal(t, obsID2, relations[0].TargetID)
|
||||
|
||||
// Step 13: Verify pattern
|
||||
retrievedPattern, err := patternStore.GetPatternByID(ctx, patternID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrievedPattern)
|
||||
assert.Equal(t, "Test Pattern", retrievedPattern.Name)
|
||||
|
||||
// Step 14: Verify stats
|
||||
stats, err := observationStore.GetObservationFeedbackStats(ctx, "test-project")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, stats.Total)
|
||||
|
||||
t.Log("✅ End-to-end integration test passed!")
|
||||
}
|
||||
|
||||
// TestIntegration_StoreCompatibility verifies that Store methods work correctly.
|
||||
func TestIntegration_StoreCompatibility(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_store_test_*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
cfg := Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 4,
|
||||
LogLevel: logger.Silent,
|
||||
}
|
||||
|
||||
store, err := NewStore(cfg)
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
// Verify raw DB access (needed for vector client)
|
||||
rawDB := store.GetRawDB()
|
||||
require.NotNil(t, rawDB)
|
||||
assert.IsType(t, &sql.DB{}, rawDB)
|
||||
|
||||
// Verify GORM DB access
|
||||
gormDB := store.GetDB()
|
||||
require.NotNil(t, gormDB)
|
||||
|
||||
// Verify Close works
|
||||
err = store.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestIntegration_ConcurrentAccess verifies thread-safe operations.
|
||||
func TestIntegration_ConcurrentAccess(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_concurrent_test_*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
cfg := Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 4,
|
||||
LogLevel: logger.Silent,
|
||||
}
|
||||
|
||||
store, err := NewStore(cfg)
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create session
|
||||
sessionID, err := sessionStore.CreateSDKSession(ctx, "claude-concurrent", "test-project", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Concurrent prompt counter increments
|
||||
done := make(chan bool)
|
||||
numGoroutines := 10
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func() {
|
||||
_, err := sessionStore.IncrementPromptCounter(ctx, sessionID)
|
||||
assert.NoError(t, err)
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Verify final count
|
||||
session, err := sessionStore.GetSessionByID(ctx, sessionID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(numGoroutines), int64(session.PromptCounter))
|
||||
|
||||
t.Log("✅ Concurrent access test passed!")
|
||||
}
|
||||
|
||||
// TestIntegration_WALMode verifies WAL mode is enabled.
|
||||
func TestIntegration_WALMode(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_wal_test_*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
cfg := Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 4,
|
||||
LogLevel: logger.Silent,
|
||||
}
|
||||
|
||||
store, err := NewStore(cfg)
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
// Check WAL mode via raw SQL
|
||||
var journalMode string
|
||||
err = store.GetRawDB().QueryRow("PRAGMA journal_mode").Scan(&journalMode)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "wal", journalMode, "WAL mode should be enabled")
|
||||
|
||||
t.Log("✅ WAL mode verification passed!")
|
||||
}
|
||||
|
||||
// TestIntegration_FTS5Search verifies FTS5 functionality.
|
||||
func TestIntegration_FTS5Search(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_fts5_test_*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
cfg := Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 4,
|
||||
LogLevel: logger.Silent,
|
||||
}
|
||||
|
||||
store, err := NewStore(cfg)
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
sessionStore := NewSessionStore(store)
|
||||
observationStore := NewObservationStore(store, nil, nil, nil)
|
||||
|
||||
// Create session
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-fts5", "test-project", "")
|
||||
|
||||
// Store observations with searchable text
|
||||
obs1 := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Database optimization techniques",
|
||||
Subtitle: "Improving query performance",
|
||||
Facts: []string{"Use indexes", "Optimize queries"},
|
||||
Concepts: []string{"performance", "optimization"},
|
||||
}
|
||||
|
||||
obsID1, _, _ := observationStore.StoreObservation(ctx, "claude-fts5", "test-project", obs1, int(sessionID), 1)
|
||||
|
||||
obs2 := &models.ParsedObservation{
|
||||
Type: models.ObsTypeBugfix,
|
||||
Title: "Fixed memory leak",
|
||||
Facts: []string{"Closed connections properly"},
|
||||
Concepts: []string{"bugfix", "memory"},
|
||||
}
|
||||
|
||||
observationStore.StoreObservation(ctx, "claude-fts5", "test-project", obs2, int(sessionID), 2)
|
||||
|
||||
// Give FTS5 triggers time to process
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Search using FTS5
|
||||
results, err := observationStore.SearchObservationsFTS(ctx, "optimization", "test-project", 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should find the optimization observation
|
||||
assert.NotEmpty(t, results, "FTS5 search should return results")
|
||||
|
||||
found := false
|
||||
for _, obs := range results {
|
||||
if obs.ID == obsID1 {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "FTS5 should find the optimization observation")
|
||||
|
||||
t.Log("✅ FTS5 search test passed!")
|
||||
}
|
||||
@@ -1,570 +0,0 @@
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// runMigrations runs all database migrations using gormigrate.
|
||||
func runMigrations(db *gorm.DB, sqlDB *sql.DB) error {
|
||||
m := gormigrate.New(db, gormigrate.DefaultOptions, []*gormigrate.Migration{
|
||||
// Migration 001: Core tables (SDKSession, Observation, SessionSummary)
|
||||
{
|
||||
ID: "001_core_tables",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
// AutoMigrate creates tables with all indexes from struct tags
|
||||
if err := tx.AutoMigrate(&SDKSession{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.AutoMigrate(&Observation{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.AutoMigrate(&SessionSummary{})
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Migrator().DropTable("sdk_sessions", "observations", "session_summaries")
|
||||
},
|
||||
},
|
||||
|
||||
// Migration 002: User prompts table
|
||||
{
|
||||
ID: "002_user_prompts",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&UserPrompt{})
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Migrator().DropTable("user_prompts")
|
||||
},
|
||||
},
|
||||
|
||||
// Migration 003: FTS5 virtual table for user prompts
|
||||
{
|
||||
ID: "003_user_prompts_fts",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
sqls := []string{
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS user_prompts_fts USING fts5(
|
||||
prompt_text,
|
||||
content='user_prompts',
|
||||
content_rowid='id'
|
||||
)`,
|
||||
`CREATE TRIGGER IF NOT EXISTS user_prompts_ai AFTER INSERT ON user_prompts BEGIN
|
||||
INSERT INTO user_prompts_fts(rowid, prompt_text)
|
||||
VALUES (new.id, new.prompt_text);
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS user_prompts_ad AFTER DELETE ON user_prompts BEGIN
|
||||
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
|
||||
VALUES('delete', old.id, old.prompt_text);
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS user_prompts_au AFTER UPDATE ON user_prompts BEGIN
|
||||
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
|
||||
VALUES('delete', old.id, old.prompt_text);
|
||||
INSERT INTO user_prompts_fts(rowid, prompt_text)
|
||||
VALUES (new.id, new.prompt_text);
|
||||
END`,
|
||||
}
|
||||
for _, s := range sqls {
|
||||
if err := tx.Exec(s).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
sqls := []string{
|
||||
"DROP TRIGGER IF EXISTS user_prompts_au",
|
||||
"DROP TRIGGER IF EXISTS user_prompts_ad",
|
||||
"DROP TRIGGER IF EXISTS user_prompts_ai",
|
||||
"DROP TABLE IF EXISTS user_prompts_fts",
|
||||
}
|
||||
for _, s := range sqls {
|
||||
if err := tx.Exec(s).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
||||
// Migration 004: FTS5 virtual table for observations
|
||||
{
|
||||
ID: "004_observations_fts",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
sqls := []string{
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5(
|
||||
title, subtitle, narrative,
|
||||
content='observations',
|
||||
content_rowid='id'
|
||||
)`,
|
||||
`CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN
|
||||
INSERT INTO observations_fts(rowid, title, subtitle, narrative)
|
||||
VALUES (new.id, new.title, new.subtitle, new.narrative);
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN
|
||||
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative)
|
||||
VALUES('delete', old.id, old.title, old.subtitle, old.narrative);
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN
|
||||
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative)
|
||||
VALUES('delete', old.id, old.title, old.subtitle, old.narrative);
|
||||
INSERT INTO observations_fts(rowid, title, subtitle, narrative)
|
||||
VALUES (new.id, new.title, new.subtitle, new.narrative);
|
||||
END`,
|
||||
}
|
||||
for _, s := range sqls {
|
||||
if err := tx.Exec(s).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
sqls := []string{
|
||||
"DROP TRIGGER IF EXISTS observations_au",
|
||||
"DROP TRIGGER IF EXISTS observations_ad",
|
||||
"DROP TRIGGER IF EXISTS observations_ai",
|
||||
"DROP TABLE IF EXISTS observations_fts",
|
||||
}
|
||||
for _, s := range sqls {
|
||||
if err := tx.Exec(s).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
||||
// Migration 005: FTS5 virtual table for session summaries
|
||||
{
|
||||
ID: "005_session_summaries_fts",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
sqls := []string{
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS session_summaries_fts USING fts5(
|
||||
request, investigated, learned, completed, next_steps, notes,
|
||||
content='session_summaries',
|
||||
content_rowid='id'
|
||||
)`,
|
||||
`CREATE TRIGGER IF NOT EXISTS session_summaries_ai AFTER INSERT ON session_summaries BEGIN
|
||||
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS session_summaries_ad AFTER DELETE ON session_summaries BEGIN
|
||||
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS session_summaries_au AFTER UPDATE ON session_summaries BEGIN
|
||||
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
|
||||
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
|
||||
END`,
|
||||
}
|
||||
for _, s := range sqls {
|
||||
if err := tx.Exec(s).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
sqls := []string{
|
||||
"DROP TRIGGER IF EXISTS session_summaries_au",
|
||||
"DROP TRIGGER IF EXISTS session_summaries_ad",
|
||||
"DROP TRIGGER IF EXISTS session_summaries_ai",
|
||||
"DROP TABLE IF EXISTS session_summaries_fts",
|
||||
}
|
||||
for _, s := range sqls {
|
||||
if err := tx.Exec(s).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
||||
// Migration 006: sqlite-vec vectors table
|
||||
{
|
||||
ID: "006_sqlite_vec_vectors",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
// Note: Uses bge-small-en-v1.5 embeddings (384 dimensions) with model_version
|
||||
sql := `CREATE VIRTUAL TABLE IF NOT EXISTS vectors USING vec0(
|
||||
doc_id TEXT PRIMARY KEY,
|
||||
embedding float[384],
|
||||
sqlite_id INTEGER,
|
||||
doc_type TEXT,
|
||||
field_type TEXT,
|
||||
project TEXT,
|
||||
scope TEXT,
|
||||
model_version TEXT
|
||||
)`
|
||||
return tx.Exec(sql).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec("DROP TABLE IF EXISTS vectors").Error
|
||||
},
|
||||
},
|
||||
|
||||
// Migration 007: Concept weights table with seed data
|
||||
{
|
||||
ID: "007_concept_weights",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&ConceptWeight{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Seed default concept weights
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
weights := []ConceptWeight{
|
||||
{Concept: "security", Weight: 0.30, UpdatedAt: now},
|
||||
{Concept: "gotcha", Weight: 0.25, UpdatedAt: now},
|
||||
{Concept: "best-practice", Weight: 0.20, UpdatedAt: now},
|
||||
{Concept: "anti-pattern", Weight: 0.20, UpdatedAt: now},
|
||||
{Concept: "architecture", Weight: 0.15, UpdatedAt: now},
|
||||
{Concept: "performance", Weight: 0.15, UpdatedAt: now},
|
||||
{Concept: "error-handling", Weight: 0.15, UpdatedAt: now},
|
||||
{Concept: "pattern", Weight: 0.10, UpdatedAt: now},
|
||||
{Concept: "testing", Weight: 0.10, UpdatedAt: now},
|
||||
{Concept: "debugging", Weight: 0.10, UpdatedAt: now},
|
||||
{Concept: "workflow", Weight: 0.05, UpdatedAt: now},
|
||||
{Concept: "tooling", Weight: 0.05, UpdatedAt: now},
|
||||
}
|
||||
|
||||
// INSERT OR IGNORE equivalent in GORM
|
||||
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&weights).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Migrator().DropTable("concept_weights")
|
||||
},
|
||||
},
|
||||
|
||||
// Migration 008: Observation conflicts table
|
||||
{
|
||||
ID: "008_observation_conflicts",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&ObservationConflict{})
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Migrator().DropTable("observation_conflicts")
|
||||
},
|
||||
},
|
||||
|
||||
// Migration 009: Patterns table
|
||||
{
|
||||
ID: "009_patterns",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&Pattern{})
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Migrator().DropTable("patterns")
|
||||
},
|
||||
},
|
||||
|
||||
// Migration 010: FTS5 virtual table for patterns
|
||||
{
|
||||
ID: "010_patterns_fts",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
sqls := []string{
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS patterns_fts USING fts5(
|
||||
name, description, recommendation,
|
||||
content='patterns',
|
||||
content_rowid='id'
|
||||
)`,
|
||||
`CREATE TRIGGER IF NOT EXISTS patterns_ai AFTER INSERT ON patterns BEGIN
|
||||
INSERT INTO patterns_fts(rowid, name, description, recommendation)
|
||||
VALUES (new.id, new.name, new.description, new.recommendation);
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS patterns_ad AFTER DELETE ON patterns BEGIN
|
||||
INSERT INTO patterns_fts(patterns_fts, rowid, name, description, recommendation)
|
||||
VALUES('delete', old.id, old.name, old.description, old.recommendation);
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS patterns_au AFTER UPDATE ON patterns BEGIN
|
||||
INSERT INTO patterns_fts(patterns_fts, rowid, name, description, recommendation)
|
||||
VALUES('delete', old.id, old.name, old.description, old.recommendation);
|
||||
INSERT INTO patterns_fts(rowid, name, description, recommendation)
|
||||
VALUES (new.id, new.name, new.description, new.recommendation);
|
||||
END`,
|
||||
}
|
||||
for _, s := range sqls {
|
||||
if err := tx.Exec(s).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
sqls := []string{
|
||||
"DROP TRIGGER IF EXISTS patterns_au",
|
||||
"DROP TRIGGER IF EXISTS patterns_ad",
|
||||
"DROP TRIGGER IF EXISTS patterns_ai",
|
||||
"DROP TABLE IF EXISTS patterns_fts",
|
||||
}
|
||||
for _, s := range sqls {
|
||||
if err := tx.Exec(s).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
||||
// Migration 011: Observation relations table
|
||||
{
|
||||
ID: "011_observation_relations",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&ObservationRelation{})
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Migrator().DropTable("observation_relations")
|
||||
},
|
||||
},
|
||||
|
||||
// Migration 012: Query optimization indexes
|
||||
// Adds covering and composite indexes for common query patterns
|
||||
{
|
||||
ID: "012_query_optimization_indexes",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
sqls := []string{
|
||||
// Composite index for observation queries by project + scope + importance
|
||||
// Covers the common pattern: WHERE project = ? OR scope = 'global' ORDER BY importance_score DESC
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_project_scope_importance
|
||||
ON observations(project, scope, importance_score DESC, created_at_epoch DESC)`,
|
||||
|
||||
// Covering index for observation retrieval (includes most used columns)
|
||||
// Allows index-only scans for listing queries
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_project_covering
|
||||
ON observations(project, scope, is_superseded, importance_score DESC)
|
||||
WHERE is_superseded = 0 OR is_superseded IS NULL`,
|
||||
|
||||
// Index for session summary lookups
|
||||
`CREATE INDEX IF NOT EXISTS idx_summaries_project_importance
|
||||
ON session_summaries(project, importance_score DESC, created_at_epoch DESC)`,
|
||||
|
||||
// Index for prompt retrieval by session
|
||||
`CREATE INDEX IF NOT EXISTS idx_prompts_session_number
|
||||
ON user_prompts(claude_session_id, prompt_number)`,
|
||||
|
||||
// Index for pattern queries by frequency
|
||||
`CREATE INDEX IF NOT EXISTS idx_patterns_frequency
|
||||
ON patterns(frequency DESC, last_seen_at_epoch DESC)
|
||||
WHERE is_deprecated = 0`,
|
||||
}
|
||||
for _, s := range sqls {
|
||||
if err := tx.Exec(s).Error; err != nil {
|
||||
// Non-fatal: index may already exist or fail for benign reasons
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
sqls := []string{
|
||||
"DROP INDEX IF EXISTS idx_observations_project_scope_importance",
|
||||
"DROP INDEX IF EXISTS idx_observations_project_covering",
|
||||
"DROP INDEX IF EXISTS idx_summaries_project_importance",
|
||||
"DROP INDEX IF EXISTS idx_prompts_session_number",
|
||||
"DROP INDEX IF EXISTS idx_patterns_frequency",
|
||||
}
|
||||
for _, s := range sqls {
|
||||
if err := tx.Exec(s).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
||||
// Migration 013: Add archival columns to observations
|
||||
{
|
||||
ID: "013_observation_archival",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
sqls := []string{
|
||||
// Add archival columns
|
||||
`ALTER TABLE observations ADD COLUMN is_archived INTEGER DEFAULT 0`,
|
||||
`ALTER TABLE observations ADD COLUMN archived_at_epoch INTEGER`,
|
||||
`ALTER TABLE observations ADD COLUMN archived_reason TEXT`,
|
||||
// Index for archived observations
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_archived ON observations(is_archived)`,
|
||||
// Composite index for filtering active (non-archived) observations
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_active
|
||||
ON observations(project, is_archived, is_superseded, importance_score DESC)
|
||||
WHERE (is_archived = 0 OR is_archived IS NULL)`,
|
||||
}
|
||||
for _, s := range sqls {
|
||||
if err := tx.Exec(s).Error; err != nil {
|
||||
// Non-fatal: column may already exist
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
// SQLite doesn't support DROP COLUMN in older versions
|
||||
// but for newer versions we can try
|
||||
sqls := []string{
|
||||
"DROP INDEX IF EXISTS idx_observations_active",
|
||||
"DROP INDEX IF EXISTS idx_observations_archived",
|
||||
}
|
||||
for _, s := range sqls {
|
||||
_ = tx.Exec(s).Error
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
||||
// Migration 014: Add performance-critical indexes for common query patterns
|
||||
{
|
||||
ID: "014_performance_indexes",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
sqls := []string{
|
||||
// Index for batch ID lookups (IN queries)
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_id_covering
|
||||
ON observations(id, project, scope, importance_score)`,
|
||||
|
||||
// Index for vector search result fetching
|
||||
`CREATE INDEX IF NOT EXISTS idx_vectors_doc_type_project
|
||||
ON vectors(doc_type, project, scope)`,
|
||||
|
||||
// Index for session summaries by project
|
||||
`CREATE INDEX IF NOT EXISTS idx_summaries_project_created
|
||||
ON session_summaries(project, created_at_epoch DESC)`,
|
||||
|
||||
// Index for user prompts by session
|
||||
`CREATE INDEX IF NOT EXISTS idx_prompts_session_created
|
||||
ON user_prompts(claude_session_id, created_at_epoch DESC)`,
|
||||
|
||||
// Index for patterns by type and project
|
||||
`CREATE INDEX IF NOT EXISTS idx_patterns_type_project
|
||||
ON patterns(type, project, frequency DESC)
|
||||
WHERE is_deprecated = 0`,
|
||||
|
||||
// Index for observation relations
|
||||
`CREATE INDEX IF NOT EXISTS idx_relations_source_type
|
||||
ON observation_relations(source_observation_id, relation_type)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_relations_target_type
|
||||
ON observation_relations(target_observation_id, relation_type)`,
|
||||
}
|
||||
for _, s := range sqls {
|
||||
if err := tx.Exec(s).Error; err != nil {
|
||||
// Non-fatal: index may already exist
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
sqls := []string{
|
||||
"DROP INDEX IF EXISTS idx_observations_id_covering",
|
||||
"DROP INDEX IF EXISTS idx_vectors_doc_type_project",
|
||||
"DROP INDEX IF EXISTS idx_summaries_project_created",
|
||||
"DROP INDEX IF EXISTS idx_prompts_session_created",
|
||||
"DROP INDEX IF EXISTS idx_patterns_type_project",
|
||||
"DROP INDEX IF EXISTS idx_relations_source_type",
|
||||
"DROP INDEX IF EXISTS idx_relations_target_type",
|
||||
}
|
||||
for _, s := range sqls {
|
||||
_ = tx.Exec(s).Error
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
||||
// Migration 015: Add optimized composite indexes for common query patterns
|
||||
{
|
||||
ID: "015_optimized_composite_indexes",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
sqls := []string{
|
||||
// Composite index for GetRecentObservations with project+scope filtering
|
||||
// Covers: WHERE (project = ? OR scope = 'global') ORDER BY importance_score DESC, created_at_epoch DESC
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_project_scope_created
|
||||
ON observations(project, scope, created_at_epoch DESC, importance_score DESC)`,
|
||||
|
||||
// Index for scope='global' queries (common pattern in search)
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_global_scope
|
||||
ON observations(scope, importance_score DESC, created_at_epoch DESC)
|
||||
WHERE scope = 'global'`,
|
||||
|
||||
// Index for vector search result deduplication by observation
|
||||
`CREATE INDEX IF NOT EXISTS idx_vectors_observation_lookup
|
||||
ON vectors(doc_type, sqlite_id, project)
|
||||
WHERE doc_type = 'observation'`,
|
||||
|
||||
// Index for FTS search result ordering
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_fts_ordering
|
||||
ON observations(project, importance_score DESC)
|
||||
WHERE (is_archived = 0 OR is_archived IS NULL) AND (is_superseded = 0 OR is_superseded IS NULL)`,
|
||||
}
|
||||
for _, s := range sqls {
|
||||
if err := tx.Exec(s).Error; err != nil {
|
||||
// Non-fatal: index may already exist
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
sqls := []string{
|
||||
"DROP INDEX IF EXISTS idx_observations_project_scope_created",
|
||||
"DROP INDEX IF EXISTS idx_observations_global_scope",
|
||||
"DROP INDEX IF EXISTS idx_vectors_observation_lookup",
|
||||
"DROP INDEX IF EXISTS idx_observations_fts_ordering",
|
||||
}
|
||||
for _, s := range sqls {
|
||||
_ = tx.Exec(s).Error
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
||||
// Migration 016: Add covering indexes for relation joins and active observations
|
||||
{
|
||||
ID: "016_relation_and_active_indexes",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
sqls := []string{
|
||||
// Covering index for observation relation joins (common JOIN patterns)
|
||||
// Speeds up queries like: JOIN observation_relations ON source_id = obs.id WHERE relation_type = ?
|
||||
`CREATE INDEX IF NOT EXISTS idx_relations_source_type_target
|
||||
ON observation_relations(source_observation_id, relation_type, target_observation_id)`,
|
||||
|
||||
// Covering index for reverse relation lookups
|
||||
`CREATE INDEX IF NOT EXISTS idx_relations_target_type_source
|
||||
ON observation_relations(target_observation_id, relation_type, source_observation_id)`,
|
||||
|
||||
// Partial index for active (non-archived, non-superseded) observations
|
||||
// Optimizes activeObservationFilter queries
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_active
|
||||
ON observations(project, importance_score DESC, created_at_epoch DESC)
|
||||
WHERE (is_archived = 0 OR is_archived IS NULL) AND (is_superseded = 0 OR is_superseded IS NULL)`,
|
||||
}
|
||||
for _, s := range sqls {
|
||||
if err := tx.Exec(s).Error; err != nil {
|
||||
// Non-fatal: index may already exist
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
sqls := []string{
|
||||
"DROP INDEX IF EXISTS idx_relations_source_type_target",
|
||||
"DROP INDEX IF EXISTS idx_relations_target_type_source",
|
||||
"DROP INDEX IF EXISTS idx_observations_active",
|
||||
}
|
||||
for _, s := range sqls {
|
||||
_ = tx.Exec(s).Error
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := m.Migrate(); err != nil {
|
||||
return fmt.Errorf("run gormigrate migrations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// GORM Models
|
||||
|
||||
// Note: JSON types (JSONStringArray, JSONInt64Map) are imported from pkg/models
|
||||
// and already implement sql.Scanner and driver.Valuer interfaces.
|
||||
|
||||
// SDKSession represents a Claude Code session.
|
||||
type SDKSession struct {
|
||||
ClaudeSessionID string `gorm:"uniqueIndex;not null"`
|
||||
Project string `gorm:"index;not null"`
|
||||
Status string `gorm:"type:text;check:status IN ('active', 'completed', 'failed');default:'active';index"`
|
||||
StartedAt string `gorm:"not null"`
|
||||
SDKSessionID sql.NullString `gorm:"uniqueIndex"`
|
||||
UserPrompt sql.NullString
|
||||
CompletedAt sql.NullString
|
||||
WorkerPort sql.NullInt64
|
||||
CompletedAtEpoch sql.NullInt64
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
PromptCounter int `gorm:"default:0"`
|
||||
StartedAtEpoch int64 `gorm:"index:idx_sessions_started,sort:desc;not null"`
|
||||
}
|
||||
|
||||
func (SDKSession) TableName() string { return "sdk_sessions" }
|
||||
|
||||
// BeforeCreate hook to ensure timestamps are set.
|
||||
func (s *SDKSession) BeforeCreate(tx *gorm.DB) error {
|
||||
if s.StartedAtEpoch == 0 {
|
||||
s.StartedAtEpoch = time.Now().UnixMilli()
|
||||
}
|
||||
if s.StartedAt == "" {
|
||||
s.StartedAt = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Observation represents a stored observation (learning).
|
||||
// Field order optimized for memory alignment (fieldalignment).
|
||||
type Observation struct {
|
||||
FileMtimes models.JSONInt64Map `gorm:"type:text"`
|
||||
SDKSessionID string `gorm:"index;not null"`
|
||||
Project string `gorm:"index:idx_observations_project;index:idx_observations_project_created,priority:1;not null"`
|
||||
Scope models.ObservationScope `gorm:"type:text;default:'project';check:scope IN ('project', 'global');index:idx_observations_scope;index:idx_observations_project_scope,priority:2"`
|
||||
Type models.ObservationType `gorm:"type:text;check:type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change');index;not null"`
|
||||
CreatedAt string `gorm:"not null"`
|
||||
Facts models.JSONStringArray `gorm:"type:text"`
|
||||
Narrative sql.NullString `gorm:"type:text"`
|
||||
Concepts models.JSONStringArray `gorm:"type:text"`
|
||||
FilesRead models.JSONStringArray `gorm:"type:text"`
|
||||
FilesModified models.JSONStringArray `gorm:"type:text"`
|
||||
Subtitle sql.NullString `gorm:"type:text"`
|
||||
Title sql.NullString `gorm:"type:text"`
|
||||
ArchivedReason sql.NullString
|
||||
ScoreUpdatedAt sql.NullInt64 `gorm:"column:score_updated_at_epoch;index:idx_observations_score_updated"`
|
||||
PromptNumber sql.NullInt64
|
||||
ArchivedAt sql.NullInt64 `gorm:"column:archived_at_epoch"`
|
||||
LastRetrievedAt sql.NullInt64 `gorm:"column:last_retrieved_at_epoch"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ImportanceScore float64 `gorm:"type:real;default:1.0;index:idx_observations_importance,priority:1,sort:desc"`
|
||||
UserFeedback int `gorm:"default:0"`
|
||||
RetrievalCount int `gorm:"default:0"`
|
||||
CreatedAtEpoch int64 `gorm:"index:idx_observations_created,sort:desc;index:idx_observations_project_created,priority:2,sort:desc;not null"`
|
||||
DiscoveryTokens int64 `gorm:"default:0"`
|
||||
IsSuperseded int `gorm:"default:0;index:idx_observations_superseded;index:idx_observations_active,priority:2"`
|
||||
IsArchived int `gorm:"default:0;index:idx_observations_archived;index:idx_observations_active,priority:1"`
|
||||
}
|
||||
|
||||
func (Observation) TableName() string { return "observations" }
|
||||
|
||||
// BeforeCreate hook to ensure defaults are set.
|
||||
func (o *Observation) BeforeCreate(tx *gorm.DB) error {
|
||||
if o.CreatedAtEpoch == 0 {
|
||||
o.CreatedAtEpoch = time.Now().UnixMilli()
|
||||
}
|
||||
if o.CreatedAt == "" {
|
||||
o.CreatedAt = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
if o.ImportanceScore == 0 {
|
||||
o.ImportanceScore = 1.0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SessionSummary represents a session summary.
|
||||
type SessionSummary struct {
|
||||
CreatedAt string `gorm:"not null"`
|
||||
SDKSessionID string `gorm:"index;not null"`
|
||||
Project string `gorm:"index;not null"`
|
||||
Completed sql.NullString
|
||||
Investigated sql.NullString
|
||||
Learned sql.NullString
|
||||
NextSteps sql.NullString `gorm:"column:next_steps"`
|
||||
Notes sql.NullString
|
||||
Request sql.NullString
|
||||
PromptNumber sql.NullInt64
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
DiscoveryTokens int64 `gorm:"default:0"`
|
||||
CreatedAtEpoch int64 `gorm:"index:idx_summaries_created,sort:desc;not null"`
|
||||
}
|
||||
|
||||
func (SessionSummary) TableName() string { return "session_summaries" }
|
||||
|
||||
// BeforeCreate hook to ensure timestamps are set.
|
||||
func (s *SessionSummary) BeforeCreate(tx *gorm.DB) error {
|
||||
if s.CreatedAtEpoch == 0 {
|
||||
s.CreatedAtEpoch = time.Now().UnixMilli()
|
||||
}
|
||||
if s.CreatedAt == "" {
|
||||
s.CreatedAt = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserPrompt represents a user prompt.
|
||||
type UserPrompt struct {
|
||||
ClaudeSessionID string `gorm:"index;not null;uniqueIndex:idx_user_prompts_session_number_unique,priority:1"`
|
||||
PromptText string `gorm:"type:text;not null"`
|
||||
CreatedAt string `gorm:"not null"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
PromptNumber int `gorm:"index;not null;uniqueIndex:idx_user_prompts_session_number_unique,priority:2"`
|
||||
MatchedObservations int `gorm:"default:0"`
|
||||
CreatedAtEpoch int64 `gorm:"index:idx_prompts_created,sort:desc;not null"`
|
||||
}
|
||||
|
||||
func (UserPrompt) TableName() string { return "user_prompts" }
|
||||
|
||||
// BeforeCreate hook to ensure timestamps are set.
|
||||
func (p *UserPrompt) BeforeCreate(tx *gorm.DB) error {
|
||||
if p.CreatedAtEpoch == 0 {
|
||||
p.CreatedAtEpoch = time.Now().UnixMilli()
|
||||
}
|
||||
if p.CreatedAt == "" {
|
||||
p.CreatedAt = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ObservationConflict tracks conflicts between observations.
|
||||
type ObservationConflict struct {
|
||||
ConflictType models.ConflictType `gorm:"type:text;check:conflict_type IN ('superseded', 'contradicts', 'outdated_pattern');not null"`
|
||||
Resolution models.ConflictResolution `gorm:"type:text;check:resolution IN ('prefer_newer', 'prefer_older', 'manual');not null"`
|
||||
DetectedAt string `gorm:"not null"`
|
||||
Reason sql.NullString `gorm:"type:text"`
|
||||
ResolvedAt sql.NullString
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
NewerObsID int64 `gorm:"index:idx_conflicts_newer;not null"`
|
||||
OlderObsID int64 `gorm:"index:idx_conflicts_older;not null"`
|
||||
DetectedAtEpoch int64 `gorm:"index:idx_conflicts_unresolved,priority:2,sort:desc;not null"`
|
||||
Resolved int `gorm:"default:0;index:idx_conflicts_unresolved,priority:1"`
|
||||
}
|
||||
|
||||
func (ObservationConflict) TableName() string { return "observation_conflicts" }
|
||||
|
||||
// BeforeCreate hook to ensure timestamps are set.
|
||||
func (c *ObservationConflict) BeforeCreate(tx *gorm.DB) error {
|
||||
if c.DetectedAtEpoch == 0 {
|
||||
c.DetectedAtEpoch = time.Now().UnixMilli()
|
||||
}
|
||||
if c.DetectedAt == "" {
|
||||
c.DetectedAt = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ObservationRelation tracks relationships between observations.
|
||||
type ObservationRelation struct {
|
||||
RelationType models.RelationType `gorm:"type:text;check:relation_type IN ('causes', 'fixes', 'supersedes', 'depends_on', 'relates_to', 'evolves_from');index:idx_relations_type;uniqueIndex:idx_relations_unique,priority:3;not null"`
|
||||
DetectionSource models.RelationDetectionSource `gorm:"type:text;check:detection_source IN ('file_overlap', 'embedding_similarity', 'temporal_proximity', 'narrative_mention', 'concept_overlap', 'type_progression');not null"`
|
||||
CreatedAt string `gorm:"not null"`
|
||||
Reason sql.NullString `gorm:"type:text"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
SourceID int64 `gorm:"index:idx_relations_source;index:idx_relations_both,priority:1;uniqueIndex:idx_relations_unique,priority:1;not null"`
|
||||
TargetID int64 `gorm:"index:idx_relations_target;index:idx_relations_both,priority:2;uniqueIndex:idx_relations_unique,priority:2;not null"`
|
||||
Confidence float64 `gorm:"type:real;default:0.5;index:idx_relations_confidence,sort:desc;not null"`
|
||||
CreatedAtEpoch int64 `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (ObservationRelation) TableName() string { return "observation_relations" }
|
||||
|
||||
// BeforeCreate hook to ensure timestamps are set.
|
||||
func (r *ObservationRelation) BeforeCreate(tx *gorm.DB) error {
|
||||
if r.CreatedAtEpoch == 0 {
|
||||
r.CreatedAtEpoch = time.Now().UnixMilli()
|
||||
}
|
||||
if r.CreatedAt == "" {
|
||||
r.CreatedAt = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
if r.Confidence == 0 {
|
||||
r.Confidence = 0.5
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pattern represents a detected recurring pattern.
|
||||
type Pattern struct {
|
||||
Status models.PatternStatus `gorm:"type:text;default:'active';check:status IN ('active', 'deprecated', 'merged');index"`
|
||||
Name string `gorm:"type:text;not null"`
|
||||
Type models.PatternType `gorm:"type:text;check:type IN ('bug', 'refactor', 'architecture', 'anti-pattern', 'best-practice');index;not null"`
|
||||
CreatedAt string `gorm:"not null"`
|
||||
LastSeenAt string `gorm:"not null"`
|
||||
Signature models.JSONStringArray `gorm:"type:text"`
|
||||
Projects models.JSONStringArray `gorm:"type:text"`
|
||||
ObservationIDs models.JSONInt64Array `gorm:"type:text"`
|
||||
Recommendation sql.NullString `gorm:"type:text"`
|
||||
Description sql.NullString `gorm:"type:text"`
|
||||
MergedIntoID sql.NullInt64
|
||||
Frequency int `gorm:"default:1;index:idx_patterns_frequency,sort:desc"`
|
||||
Confidence float64 `gorm:"type:real;default:0.5;index:idx_patterns_confidence,sort:desc"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
LastSeenAtEpoch int64 `gorm:"index:idx_patterns_last_seen,sort:desc;not null"`
|
||||
CreatedAtEpoch int64 `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (Pattern) TableName() string { return "patterns" }
|
||||
|
||||
// BeforeCreate hook to ensure timestamps and defaults are set.
|
||||
func (p *Pattern) BeforeCreate(tx *gorm.DB) error {
|
||||
now := time.Now()
|
||||
if p.CreatedAtEpoch == 0 {
|
||||
p.CreatedAtEpoch = now.UnixMilli()
|
||||
}
|
||||
if p.CreatedAt == "" {
|
||||
p.CreatedAt = now.Format(time.RFC3339)
|
||||
}
|
||||
if p.LastSeenAtEpoch == 0 {
|
||||
p.LastSeenAtEpoch = now.UnixMilli()
|
||||
}
|
||||
if p.LastSeenAt == "" {
|
||||
p.LastSeenAt = now.Format(time.RFC3339)
|
||||
}
|
||||
if p.Confidence == 0 {
|
||||
p.Confidence = 0.5
|
||||
}
|
||||
if p.Frequency == 0 {
|
||||
p.Frequency = 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConceptWeight stores configurable weights for importance scoring.
|
||||
type ConceptWeight struct {
|
||||
Concept string `gorm:"primaryKey;type:text"`
|
||||
UpdatedAt string `gorm:"not null"`
|
||||
Weight float64 `gorm:"type:real;not null;default:0.1"`
|
||||
}
|
||||
|
||||
func (ConceptWeight) TableName() string { return "concept_weights" }
|
||||
|
||||
// BeforeCreate hook to ensure timestamp is set.
|
||||
func (c *ConceptWeight) BeforeCreate(tx *gorm.DB) error {
|
||||
if c.UpdatedAt == "" {
|
||||
c.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
if c.Weight == 0 {
|
||||
c.Weight = 0.1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,591 +0,0 @@
|
||||
//go:build fts5
|
||||
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// testObservationStore creates an ObservationStore with a temporary database for testing.
|
||||
func testObservationStore(t *testing.T) (*ObservationStore, *Store, func()) {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_observation_test_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
cfg := Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 4,
|
||||
LogLevel: logger.Silent,
|
||||
}
|
||||
|
||||
store, err := NewStore(cfg)
|
||||
if err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
|
||||
observationStore := NewObservationStore(store, nil, nil, nil)
|
||||
|
||||
cleanup := func() {
|
||||
store.Close()
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
return observationStore, store, cleanup
|
||||
}
|
||||
|
||||
func TestObservationStore_StoreObservation(t *testing.T) {
|
||||
observationStore, store, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a session first
|
||||
sessionStore := NewSessionStore(store)
|
||||
_, err := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store an observation
|
||||
observation := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDecision,
|
||||
Title: "User prefers tabs over spaces",
|
||||
Narrative: "Observed in code formatting",
|
||||
Concepts: []string{"coding-style", "preferences"},
|
||||
}
|
||||
|
||||
id, epoch, err := observationStore.StoreObservation(ctx, "claude-1", "test-project", observation, 1, 100)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, id, int64(0))
|
||||
assert.Greater(t, epoch, int64(0))
|
||||
}
|
||||
|
||||
func TestObservationStore_StoreObservation_AutoCreateSession(t *testing.T) {
|
||||
observationStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store observation without pre-creating session
|
||||
observation := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Test auto-create",
|
||||
}
|
||||
|
||||
id, _, err := observationStore.StoreObservation(ctx, "claude-auto", "auto-project", observation, 1, 50)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, id, int64(0))
|
||||
}
|
||||
|
||||
func TestObservationStore_StoreObservation_WithScope(t *testing.T) {
|
||||
observationStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tags []string
|
||||
expectedScope models.ObservationScope
|
||||
}{
|
||||
{
|
||||
name: "Global scope - best practice",
|
||||
tags: []string{"best-practice", "testing"},
|
||||
expectedScope: models.ScopeGlobal,
|
||||
},
|
||||
{
|
||||
name: "Global scope - security",
|
||||
tags: []string{"security", "auth"},
|
||||
expectedScope: models.ScopeGlobal,
|
||||
},
|
||||
{
|
||||
name: "Project scope - specific feature",
|
||||
tags: []string{"feature", "implementation"},
|
||||
expectedScope: models.ScopeProject,
|
||||
},
|
||||
{
|
||||
name: "Project scope - no tags",
|
||||
tags: []string{},
|
||||
expectedScope: models.ScopeProject,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
observation := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Test scope determination",
|
||||
Concepts: tt.tags,
|
||||
}
|
||||
|
||||
id, _, err := observationStore.StoreObservation(ctx, "claude-1", "test-project", observation, 1, 50)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify scope was set correctly
|
||||
observations, err := observationStore.GetObservationsByIDs(ctx, []int64{id}, "default", 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, observations, 1)
|
||||
assert.Equal(t, tt.expectedScope, observations[0].Scope)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestObservationStore_StoreObservation_AsyncCleanup(t *testing.T) {
|
||||
observationStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Track cleanup calls
|
||||
var cleanupMutex sync.Mutex
|
||||
cleanupCalled := false
|
||||
var cleanupIDs []int64
|
||||
|
||||
cleanupFunc := func(ctx context.Context, deletedIDs []int64) {
|
||||
cleanupMutex.Lock()
|
||||
defer cleanupMutex.Unlock()
|
||||
cleanupCalled = true
|
||||
cleanupIDs = deletedIDs
|
||||
}
|
||||
|
||||
observationStore.cleanupFunc = cleanupFunc
|
||||
|
||||
// Store observations beyond the limit (MaxObservationsPerProject = 100)
|
||||
for i := 0; i < 105; i++ {
|
||||
observation := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Observation",
|
||||
}
|
||||
_, _, err := observationStore.StoreObservation(ctx, "claude-1", "test-project", observation, i, 50)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Wait for async cleanup to complete
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Verify cleanup was called
|
||||
cleanupMutex.Lock()
|
||||
defer cleanupMutex.Unlock()
|
||||
assert.True(t, cleanupCalled, "Cleanup function should have been called")
|
||||
assert.NotEmpty(t, cleanupIDs, "Cleanup should have deleted some observations")
|
||||
}
|
||||
|
||||
func TestObservationStore_GetObservationsByIDs(t *testing.T) {
|
||||
observationStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store multiple observations with different importance scores
|
||||
var ids []int64
|
||||
for i := 1; i <= 3; i++ {
|
||||
observation := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Test",
|
||||
}
|
||||
id, _, err := observationStore.StoreObservation(ctx, "claude-1", "test-project", observation, i, 10)
|
||||
require.NoError(t, err)
|
||||
ids = append(ids, id)
|
||||
|
||||
// Update importance score directly
|
||||
observationStore.db.Model(&Observation{}).Where("id = ?", id).Update("importance_score", float64(i))
|
||||
time.Sleep(10 * time.Millisecond) // Ensure different timestamps
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
orderBy string
|
||||
expected []int64
|
||||
}{
|
||||
{
|
||||
name: "Default ordering - importance desc",
|
||||
orderBy: "default",
|
||||
expected: []int64{ids[2], ids[1], ids[0]}, // High to low importance
|
||||
},
|
||||
{
|
||||
name: "Importance ordering",
|
||||
orderBy: "importance",
|
||||
expected: []int64{ids[2], ids[1], ids[0]},
|
||||
},
|
||||
{
|
||||
name: "Date ascending",
|
||||
orderBy: "date_asc",
|
||||
expected: []int64{ids[0], ids[1], ids[2]}, // Oldest to newest
|
||||
},
|
||||
{
|
||||
name: "Date descending",
|
||||
orderBy: "date_desc",
|
||||
expected: []int64{ids[2], ids[1], ids[0]}, // Newest to oldest
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
observations, err := observationStore.GetObservationsByIDs(ctx, ids, tt.orderBy, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, observations, 3)
|
||||
|
||||
// Verify ordering
|
||||
for i, obs := range observations {
|
||||
assert.Equal(t, tt.expected[i], obs.ID, "Position %d should have ID %d", i, tt.expected[i])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestObservationStore_GetObservationsByIDs_Limit(t *testing.T) {
|
||||
observationStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store multiple observations
|
||||
var ids []int64
|
||||
for i := 1; i <= 5; i++ {
|
||||
observation := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Test",
|
||||
}
|
||||
id, _, err := observationStore.StoreObservation(ctx, "claude-1", "test-project", observation, i, 10)
|
||||
require.NoError(t, err)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
// Get with limit
|
||||
observations, err := observationStore.GetObservationsByIDs(ctx, ids, "default", 3)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, observations, 3)
|
||||
}
|
||||
|
||||
func TestObservationStore_GetObservationsByIDs_EmptyInput(t *testing.T) {
|
||||
observationStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Get with empty IDs
|
||||
observations, err := observationStore.GetObservationsByIDs(ctx, []int64{}, "default", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, observations)
|
||||
}
|
||||
|
||||
func TestObservationStore_GetRecentObservations(t *testing.T) {
|
||||
observationStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store project-scoped observations
|
||||
for i := 1; i <= 3; i++ {
|
||||
observation := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Project A fact",
|
||||
}
|
||||
_, _, err := observationStore.StoreObservation(ctx, "claude-1", "project-a", observation, i, 10)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Store global-scoped observation
|
||||
observation := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Global best practice",
|
||||
Concepts: []string{"best-practice"},
|
||||
}
|
||||
_, _, err := observationStore.StoreObservation(ctx, "claude-2", "project-b", observation, 1, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store observation for different project
|
||||
observation = &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Project B fact",
|
||||
}
|
||||
_, _, err = observationStore.StoreObservation(ctx, "claude-2", "project-b", observation, 2, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for any async cleanup to complete before querying
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Get recent observations for project-a (should include project-a + global)
|
||||
observations, err := observationStore.GetRecentObservations(ctx, "project-a", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, observations, 4) // 3 project-a + 1 global
|
||||
|
||||
// Verify scope filtering
|
||||
projectCount := 0
|
||||
globalCount := 0
|
||||
for _, obs := range observations {
|
||||
if obs.Scope == models.ScopeProject {
|
||||
assert.Equal(t, "project-a", obs.Project)
|
||||
projectCount++
|
||||
} else if obs.Scope == models.ScopeGlobal {
|
||||
globalCount++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 3, projectCount)
|
||||
assert.Equal(t, 1, globalCount)
|
||||
}
|
||||
|
||||
func TestObservationStore_GetActiveObservations(t *testing.T) {
|
||||
observationStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store active observation
|
||||
activeObs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Active observation",
|
||||
}
|
||||
activeID, _, err := observationStore.StoreObservation(ctx, "claude-1", "test-project", activeObs, 1, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store superseded observation
|
||||
supersededObs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Superseded observation",
|
||||
}
|
||||
supersededID, _, err := observationStore.StoreObservation(ctx, "claude-1", "test-project", supersededObs, 2, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Mark as superseded
|
||||
observationStore.db.Model(&Observation{}).Where("id = ?", supersededID).Update("is_superseded", 1)
|
||||
|
||||
// Get active observations (should exclude superseded)
|
||||
observations, err := observationStore.GetActiveObservations(ctx, "test-project", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, observations, 1)
|
||||
assert.Equal(t, activeID, observations[0].ID)
|
||||
assert.False(t, observations[0].IsSuperseded)
|
||||
}
|
||||
|
||||
func TestObservationStore_GetSupersededObservations(t *testing.T) {
|
||||
observationStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store active observation
|
||||
activeObs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Active observation",
|
||||
}
|
||||
_, _, err := observationStore.StoreObservation(ctx, "claude-1", "test-project", activeObs, 1, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store superseded observation
|
||||
supersededObs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Superseded observation",
|
||||
}
|
||||
supersededID, _, err := observationStore.StoreObservation(ctx, "claude-1", "test-project", supersededObs, 2, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Mark as superseded
|
||||
observationStore.db.Model(&Observation{}).Where("id = ?", supersededID).Update("is_superseded", 1)
|
||||
|
||||
// Get superseded observations (should exclude active)
|
||||
observations, err := observationStore.GetSupersededObservations(ctx, "test-project", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, observations, 1)
|
||||
assert.Equal(t, supersededID, observations[0].ID)
|
||||
assert.True(t, observations[0].IsSuperseded)
|
||||
}
|
||||
|
||||
func TestObservationStore_GetObservationsByProjectStrict(t *testing.T) {
|
||||
observationStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store project-scoped observations
|
||||
for i := 1; i <= 2; i++ {
|
||||
observation := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Project A fact",
|
||||
}
|
||||
_, _, err := observationStore.StoreObservation(ctx, "claude-1", "project-a", observation, i, 10)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Store global-scoped observation
|
||||
observation := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Global best practice",
|
||||
Concepts: []string{"best-practice"},
|
||||
}
|
||||
_, _, err := observationStore.StoreObservation(ctx, "claude-2", "project-b", observation, 1, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get strict project observations (should exclude global)
|
||||
observations, err := observationStore.GetObservationsByProjectStrict(ctx, "project-a", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, observations, 2) // Only project-a observations
|
||||
|
||||
// Verify all are project-scoped
|
||||
for _, obs := range observations {
|
||||
assert.Equal(t, models.ScopeProject, obs.Scope)
|
||||
assert.Equal(t, "project-a", obs.Project)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObservationStore_SearchObservationsFTS(t *testing.T) {
|
||||
observationStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store observations with searchable content
|
||||
observations := []*models.ParsedObservation{
|
||||
{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "User prefers React for frontend development",
|
||||
Concepts: []string{"frontend", "react"},
|
||||
},
|
||||
{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Backend uses Go with chi router",
|
||||
Concepts: []string{"backend", "golang"},
|
||||
},
|
||||
{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Database is SQLite with FTS5",
|
||||
Concepts: []string{"database", "sqlite"},
|
||||
},
|
||||
}
|
||||
|
||||
for i, obs := range observations {
|
||||
_, _, err := observationStore.StoreObservation(ctx, "claude-1", "test-project", obs, i+1, 10)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Wait for FTS5 triggers to fire
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Search for "React frontend"
|
||||
results, err := observationStore.SearchObservationsFTS(ctx, "React frontend", "test-project", 10)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, results, "Should find observations matching 'React frontend'")
|
||||
|
||||
// Verify results contain relevant observation
|
||||
found := false
|
||||
for _, obs := range results {
|
||||
if obs.Title.String == "User prefers React for frontend development" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Should find the React observation")
|
||||
}
|
||||
|
||||
func TestObservationStore_CleanupOldObservations(t *testing.T) {
|
||||
observationStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store observations beyond the limit WITHOUT async cleanup
|
||||
// We disable async cleanup by not setting cleanupFunc
|
||||
for i := 0; i < 105; i++ {
|
||||
observation := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Observation",
|
||||
}
|
||||
_, _, err := observationStore.StoreObservation(ctx, "claude-1", "test-project", observation, i, 10)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(2 * time.Millisecond) // Ensure different timestamps
|
||||
}
|
||||
|
||||
// Wait for any async cleanups to complete (even though cleanupFunc is nil)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Verify we have 105 observations initially (async cleanup should have run but deleted items)
|
||||
initial, err := observationStore.GetRecentObservations(ctx, "test-project", 200)
|
||||
require.NoError(t, err)
|
||||
|
||||
// If async cleanup already happened, we'll have <= 100
|
||||
// Run cleanup manually to ensure cleanup logic works
|
||||
deletedIDs, err := observationStore.CleanupOldObservations(ctx, "test-project")
|
||||
require.NoError(t, err)
|
||||
|
||||
// After cleanup (manual or async), we should have at most 100
|
||||
remaining, err := observationStore.GetRecentObservations(ctx, "test-project", 200)
|
||||
require.NoError(t, err)
|
||||
assert.LessOrEqual(t, len(remaining), 100, "Should have at most 100 observations after cleanup")
|
||||
|
||||
// The number deleted should match how many were over the limit
|
||||
expectedDeleted := len(initial) - len(remaining)
|
||||
assert.Len(t, deletedIDs, expectedDeleted, "Should delete observations beyond limit")
|
||||
}
|
||||
|
||||
func TestObservationStore_DeleteObservations(t *testing.T) {
|
||||
observationStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store multiple observations
|
||||
var ids []int64
|
||||
for i := 1; i <= 5; i++ {
|
||||
observation := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Test",
|
||||
}
|
||||
id, _, err := observationStore.StoreObservation(ctx, "claude-1", "test-project", observation, i, 10)
|
||||
require.NoError(t, err)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
// Delete first 3 observations
|
||||
_, err := observationStore.DeleteObservations(ctx, ids[:3])
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify only 2 remain
|
||||
remaining, err := observationStore.GetRecentObservations(ctx, "test-project", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, remaining, 2)
|
||||
|
||||
// Verify deleted observations are gone
|
||||
deleted, err := observationStore.GetObservationsByIDs(ctx, ids[:3], "default", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, deleted)
|
||||
}
|
||||
|
||||
// Note: TestObservationStore_MarkObservationsSuperseded is omitted because
|
||||
// MarkObservationsSuperseded is a ConflictStore method (Phase 4), not ObservationStore
|
||||
|
||||
func TestObservationStore_GetAllObservations(t *testing.T) {
|
||||
observationStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store observations across projects
|
||||
_, _, err := observationStore.StoreObservation(ctx, "claude-1", "project-a", &models.ParsedObservation{Type: models.ObsTypeDiscovery, Title: "A1"}, 1, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = observationStore.StoreObservation(ctx, "claude-2", "project-b", &models.ParsedObservation{Type: models.ObsTypeDiscovery, Title: "B1"}, 1, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get all observations (for vector rebuild)
|
||||
all, err := observationStore.GetAllObservations(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, all, 2)
|
||||
|
||||
// Verify ordering by ID
|
||||
assert.Less(t, all[0].ID, all[1].ID)
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// PatternCleanupFunc is a callback for when patterns are deleted.
|
||||
type PatternCleanupFunc func(ctx context.Context, deletedIDs []int64)
|
||||
|
||||
// PatternStore provides pattern-related database operations using GORM.
|
||||
type PatternStore struct {
|
||||
db *gorm.DB
|
||||
cleanupFunc PatternCleanupFunc
|
||||
cleanupMu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewPatternStore creates a new pattern store.
|
||||
func NewPatternStore(store *Store) *PatternStore {
|
||||
return &PatternStore{
|
||||
db: store.DB,
|
||||
}
|
||||
}
|
||||
|
||||
// SetCleanupFunc sets the callback for when patterns are deleted.
|
||||
func (s *PatternStore) SetCleanupFunc(fn PatternCleanupFunc) {
|
||||
s.cleanupMu.Lock()
|
||||
defer s.cleanupMu.Unlock()
|
||||
s.cleanupFunc = fn
|
||||
}
|
||||
|
||||
// StorePattern stores a new pattern.
|
||||
func (s *PatternStore) StorePattern(ctx context.Context, pattern *models.Pattern) (int64, error) {
|
||||
dbPattern := &Pattern{
|
||||
Name: pattern.Name,
|
||||
Type: pattern.Type,
|
||||
Signature: pattern.Signature,
|
||||
Frequency: pattern.Frequency,
|
||||
Projects: pattern.Projects,
|
||||
ObservationIDs: pattern.ObservationIDs,
|
||||
Status: pattern.Status,
|
||||
Confidence: pattern.Confidence,
|
||||
LastSeenAt: pattern.LastSeenAt,
|
||||
LastSeenAtEpoch: pattern.LastSeenEpoch,
|
||||
CreatedAt: pattern.CreatedAt,
|
||||
CreatedAtEpoch: pattern.CreatedAtEpoch,
|
||||
}
|
||||
|
||||
if pattern.Description.Valid {
|
||||
dbPattern.Description = sql.NullString{String: pattern.Description.String, Valid: true}
|
||||
}
|
||||
|
||||
if pattern.Recommendation.Valid {
|
||||
dbPattern.Recommendation = sql.NullString{String: pattern.Recommendation.String, Valid: true}
|
||||
}
|
||||
|
||||
if pattern.MergedIntoID.Valid {
|
||||
dbPattern.MergedIntoID = sql.NullInt64{Int64: pattern.MergedIntoID.Int64, Valid: true}
|
||||
}
|
||||
|
||||
result := s.db.WithContext(ctx).Create(dbPattern)
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
|
||||
return dbPattern.ID, nil
|
||||
}
|
||||
|
||||
// UpdatePattern updates an existing pattern.
|
||||
func (s *PatternStore) UpdatePattern(ctx context.Context, pattern *models.Pattern) error {
|
||||
updates := map[string]interface{}{
|
||||
"name": pattern.Name,
|
||||
"type": pattern.Type,
|
||||
"signature": pattern.Signature,
|
||||
"frequency": pattern.Frequency,
|
||||
"projects": pattern.Projects,
|
||||
"observation_ids": pattern.ObservationIDs,
|
||||
"status": pattern.Status,
|
||||
"confidence": pattern.Confidence,
|
||||
"last_seen_at": pattern.LastSeenAt,
|
||||
"last_seen_at_epoch": pattern.LastSeenEpoch,
|
||||
}
|
||||
|
||||
if pattern.Description.Valid {
|
||||
updates["description"] = pattern.Description.String
|
||||
} else {
|
||||
updates["description"] = nil
|
||||
}
|
||||
|
||||
if pattern.Recommendation.Valid {
|
||||
updates["recommendation"] = pattern.Recommendation.String
|
||||
} else {
|
||||
updates["recommendation"] = nil
|
||||
}
|
||||
|
||||
if pattern.MergedIntoID.Valid {
|
||||
updates["merged_into_id"] = pattern.MergedIntoID.Int64
|
||||
} else {
|
||||
updates["merged_into_id"] = nil
|
||||
}
|
||||
|
||||
result := s.db.WithContext(ctx).
|
||||
Model(&Pattern{}).
|
||||
Where("id = ?", pattern.ID).
|
||||
Updates(updates)
|
||||
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// GetPatternByID retrieves a pattern by ID.
|
||||
func (s *PatternStore) GetPatternByID(ctx context.Context, id int64) (*models.Pattern, error) {
|
||||
var dbPattern Pattern
|
||||
|
||||
err := s.db.WithContext(ctx).First(&dbPattern, id).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelPattern(&dbPattern), nil
|
||||
}
|
||||
|
||||
// GetPatternByName retrieves a pattern by name.
|
||||
func (s *PatternStore) GetPatternByName(ctx context.Context, name string) (*models.Pattern, error) {
|
||||
var dbPattern Pattern
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("name = ? AND status = ?", name, models.PatternStatusActive).
|
||||
First(&dbPattern).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelPattern(&dbPattern), nil
|
||||
}
|
||||
|
||||
// GetActivePatterns retrieves all active patterns.
|
||||
func (s *PatternStore) GetActivePatterns(ctx context.Context, limit int) ([]*models.Pattern, error) {
|
||||
var patterns []Pattern
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("status = ?", models.PatternStatusActive).
|
||||
Order("frequency DESC, confidence DESC").
|
||||
Limit(limit).
|
||||
Find(&patterns).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelPatterns(patterns), nil
|
||||
}
|
||||
|
||||
// GetPatternsByType retrieves patterns of a specific type.
|
||||
func (s *PatternStore) GetPatternsByType(ctx context.Context, patternType models.PatternType, limit int) ([]*models.Pattern, error) {
|
||||
var patterns []Pattern
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("type = ? AND status = ?", patternType, models.PatternStatusActive).
|
||||
Order("frequency DESC, confidence DESC").
|
||||
Limit(limit).
|
||||
Find(&patterns).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelPatterns(patterns), nil
|
||||
}
|
||||
|
||||
// GetPatternsByProject retrieves patterns that have been observed in a specific project.
|
||||
// Uses raw SQL since JSON_EACH is complex in GORM.
|
||||
func (s *PatternStore) GetPatternsByProject(ctx context.Context, project string, limit int) ([]*models.Pattern, error) {
|
||||
var patterns []Pattern
|
||||
|
||||
// Use raw SQL for JSON_EACH query
|
||||
query := `
|
||||
SELECT * FROM patterns
|
||||
WHERE status = 'active'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM json_each(projects)
|
||||
WHERE json_each.value = ?
|
||||
)
|
||||
ORDER BY frequency DESC, confidence DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Raw(query, project, limit).
|
||||
Scan(&patterns).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelPatterns(patterns), nil
|
||||
}
|
||||
|
||||
// FindMatchingPatterns searches for patterns that match a given signature.
|
||||
// Pattern matching is done in Go code for simplicity.
|
||||
func (s *PatternStore) FindMatchingPatterns(ctx context.Context, signature []string, minScore float64) ([]*models.Pattern, error) {
|
||||
// Get all active patterns
|
||||
patterns, err := s.GetActivePatterns(ctx, 100)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter by signature match in Go
|
||||
var matches []*models.Pattern
|
||||
for _, pattern := range patterns {
|
||||
score := models.CalculateMatchScore(signature, pattern.Signature)
|
||||
if score >= minScore {
|
||||
matches = append(matches, pattern)
|
||||
}
|
||||
}
|
||||
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// MarkPatternDeprecated marks a pattern as deprecated.
|
||||
func (s *PatternStore) MarkPatternDeprecated(ctx context.Context, id int64) error {
|
||||
result := s.db.WithContext(ctx).
|
||||
Model(&Pattern{}).
|
||||
Where("id = ?", id).
|
||||
Update("status", models.PatternStatusDeprecated)
|
||||
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// MergePatterns merges a source pattern into a target pattern.
|
||||
func (s *PatternStore) MergePatterns(ctx context.Context, sourceID, targetID int64) error {
|
||||
if sourceID == targetID {
|
||||
return fmt.Errorf("cannot merge pattern into itself")
|
||||
}
|
||||
// Get both patterns
|
||||
source, err := s.GetPatternByID(ctx, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := s.GetPatternByID(ctx, targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Merge source into target
|
||||
target.Frequency += source.Frequency
|
||||
|
||||
// Merge projects (deduplicate)
|
||||
for _, proj := range source.Projects {
|
||||
found := false
|
||||
for _, existing := range target.Projects {
|
||||
if existing == proj {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
target.Projects = append(target.Projects, proj)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge observation IDs (deduplicate)
|
||||
for _, obsID := range source.ObservationIDs {
|
||||
found := false
|
||||
for _, existing := range target.ObservationIDs {
|
||||
if existing == obsID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
target.ObservationIDs = append(target.ObservationIDs, obsID)
|
||||
}
|
||||
}
|
||||
|
||||
// Update target
|
||||
if err := s.UpdatePattern(ctx, target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Mark source as merged
|
||||
source.Status = models.PatternStatusMerged
|
||||
source.MergedIntoID = sql.NullInt64{Int64: targetID, Valid: true}
|
||||
return s.UpdatePattern(ctx, source)
|
||||
}
|
||||
|
||||
// DeletePattern deletes a pattern by ID.
|
||||
func (s *PatternStore) DeletePattern(ctx context.Context, id int64) error {
|
||||
result := s.db.WithContext(ctx).Delete(&Pattern{}, id)
|
||||
|
||||
if result.Error == nil {
|
||||
s.cleanupMu.RLock()
|
||||
fn := s.cleanupFunc
|
||||
s.cleanupMu.RUnlock()
|
||||
if fn != nil {
|
||||
fn(ctx, []int64{id})
|
||||
}
|
||||
}
|
||||
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// SearchPatternsFTS performs full-text search on patterns.
|
||||
// Uses raw SQL for FTS5 query.
|
||||
func (s *PatternStore) SearchPatternsFTS(ctx context.Context, searchQuery string, limit int) ([]*models.Pattern, error) {
|
||||
var patterns []Pattern
|
||||
|
||||
// Use raw SQL for FTS5 MATCH query
|
||||
query := `
|
||||
SELECT p.*
|
||||
FROM patterns p
|
||||
JOIN patterns_fts fts ON p.id = fts.rowid
|
||||
WHERE patterns_fts MATCH ?
|
||||
AND p.status = 'active'
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Raw(query, searchQuery, limit).
|
||||
Scan(&patterns).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelPatterns(patterns), nil
|
||||
}
|
||||
|
||||
// PatternStats contains aggregate statistics about patterns.
|
||||
type PatternStats struct {
|
||||
Total int `json:"total"`
|
||||
Active int `json:"active"`
|
||||
Deprecated int `json:"deprecated"`
|
||||
Merged int `json:"merged"`
|
||||
TotalOccurrences int `json:"total_occurrences"`
|
||||
AvgConfidence float64 `json:"avg_confidence"`
|
||||
Bugs int `json:"bugs"`
|
||||
Refactors int `json:"refactors"`
|
||||
Architectures int `json:"architectures"`
|
||||
AntiPatterns int `json:"anti_patterns"`
|
||||
BestPractices int `json:"best_practices"`
|
||||
}
|
||||
|
||||
// GetPatternStats returns statistics about patterns.
|
||||
// Uses raw SQL for complex aggregate query.
|
||||
func (s *PatternStore) GetPatternStats(ctx context.Context) (*PatternStats, error) {
|
||||
var stats PatternStats
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN status = 'active' THEN 1 END) as active,
|
||||
COUNT(CASE WHEN status = 'deprecated' THEN 1 END) as deprecated,
|
||||
COUNT(CASE WHEN status = 'merged' THEN 1 END) as merged,
|
||||
COALESCE(SUM(frequency), 0) as total_occurrences,
|
||||
COALESCE(AVG(confidence), 0) as avg_confidence,
|
||||
COUNT(CASE WHEN type = 'bug' THEN 1 END) as bugs,
|
||||
COUNT(CASE WHEN type = 'refactor' THEN 1 END) as refactors,
|
||||
COUNT(CASE WHEN type = 'architecture' THEN 1 END) as architectures,
|
||||
COUNT(CASE WHEN type = 'anti-pattern' THEN 1 END) as anti_patterns,
|
||||
COUNT(CASE WHEN type = 'best-practice' THEN 1 END) as best_practices
|
||||
FROM patterns
|
||||
`
|
||||
|
||||
err := s.db.WithContext(ctx).Raw(query).Scan(&stats).Error
|
||||
return &stats, err
|
||||
}
|
||||
|
||||
// IncrementPatternFrequency atomically increments a pattern's frequency and updates last_seen.
|
||||
func (s *PatternStore) IncrementPatternFrequency(ctx context.Context, id int64, project string, observationID int64) error {
|
||||
now := time.Now()
|
||||
|
||||
// Get current pattern
|
||||
pattern, err := s.GetPatternByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add occurrence
|
||||
pattern.AddOccurrence(project, observationID)
|
||||
pattern.LastSeenAt = now.Format(time.RFC3339)
|
||||
pattern.LastSeenEpoch = now.UnixMilli()
|
||||
|
||||
return s.UpdatePattern(ctx, pattern)
|
||||
}
|
||||
|
||||
// toModelPattern converts a GORM Pattern to a pkg/models Pattern.
|
||||
func toModelPattern(p *Pattern) *models.Pattern {
|
||||
pattern := &models.Pattern{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Type: p.Type,
|
||||
Description: p.Description,
|
||||
Signature: p.Signature,
|
||||
Recommendation: p.Recommendation,
|
||||
Frequency: p.Frequency,
|
||||
Projects: p.Projects,
|
||||
ObservationIDs: p.ObservationIDs,
|
||||
Status: p.Status,
|
||||
MergedIntoID: p.MergedIntoID,
|
||||
Confidence: p.Confidence,
|
||||
LastSeenAt: p.LastSeenAt,
|
||||
LastSeenEpoch: p.LastSeenAtEpoch,
|
||||
CreatedAt: p.CreatedAt,
|
||||
CreatedAtEpoch: p.CreatedAtEpoch,
|
||||
}
|
||||
|
||||
return pattern
|
||||
}
|
||||
|
||||
// toModelPatterns converts a slice of GORM Patterns to pkg/models Patterns.
|
||||
func toModelPatterns(patterns []Pattern) []*models.Pattern {
|
||||
result := make([]*models.Pattern, len(patterns))
|
||||
for i, p := range patterns {
|
||||
result[i] = toModelPattern(&p)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,485 +0,0 @@
|
||||
//go:build fts5
|
||||
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
func testPatternStore(t *testing.T) (*PatternStore, *Store, func()) {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_pattern_test_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
cfg := Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 4,
|
||||
LogLevel: logger.Silent,
|
||||
}
|
||||
|
||||
store, err := NewStore(cfg)
|
||||
if err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
|
||||
patternStore := NewPatternStore(store)
|
||||
|
||||
cleanup := func() {
|
||||
store.Close()
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
return patternStore, store, cleanup
|
||||
}
|
||||
|
||||
func TestPatternStore_StorePattern(t *testing.T) {
|
||||
patternStore, _, cleanup := testPatternStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
pattern := &models.Pattern{
|
||||
Name: "Test Pattern",
|
||||
Type: models.PatternTypeBug,
|
||||
Description: sql.NullString{String: "Test description", Valid: true},
|
||||
Signature: []string{"bug", "error"},
|
||||
Recommendation: sql.NullString{String: "Fix it", Valid: true},
|
||||
Frequency: 1,
|
||||
Projects: []string{"test-project"},
|
||||
ObservationIDs: []int64{1, 2, 3},
|
||||
Status: models.PatternStatusActive,
|
||||
Confidence: 0.8,
|
||||
LastSeenAt: now.Format(time.RFC3339),
|
||||
LastSeenEpoch: now.UnixMilli(),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
|
||||
id, err := patternStore.StorePattern(ctx, pattern)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, id, int64(0))
|
||||
|
||||
// Verify pattern was stored
|
||||
retrieved, err := patternStore.GetPatternByID(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, pattern.Name, retrieved.Name)
|
||||
assert.Equal(t, pattern.Type, retrieved.Type)
|
||||
assert.Equal(t, pattern.Signature, retrieved.Signature)
|
||||
assert.Equal(t, pattern.Frequency, retrieved.Frequency)
|
||||
assert.Equal(t, pattern.Status, retrieved.Status)
|
||||
assert.Equal(t, pattern.Confidence, retrieved.Confidence)
|
||||
}
|
||||
|
||||
func TestPatternStore_UpdatePattern(t *testing.T) {
|
||||
patternStore, _, cleanup := testPatternStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
pattern := &models.Pattern{
|
||||
Name: "Original",
|
||||
Type: models.PatternTypeBug,
|
||||
Frequency: 1,
|
||||
Status: models.PatternStatusActive,
|
||||
Confidence: 0.5,
|
||||
LastSeenAt: now.Format(time.RFC3339),
|
||||
LastSeenEpoch: now.UnixMilli(),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
|
||||
id, err := patternStore.StorePattern(ctx, pattern)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update pattern
|
||||
pattern.ID = id
|
||||
pattern.Name = "Updated"
|
||||
pattern.Frequency = 5
|
||||
pattern.Confidence = 0.9
|
||||
|
||||
err = patternStore.UpdatePattern(ctx, pattern)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify update
|
||||
retrieved, err := patternStore.GetPatternByID(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updated", retrieved.Name)
|
||||
assert.Equal(t, 5, retrieved.Frequency)
|
||||
assert.Equal(t, 0.9, retrieved.Confidence)
|
||||
}
|
||||
|
||||
func TestPatternStore_GetPatternByName(t *testing.T) {
|
||||
patternStore, _, cleanup := testPatternStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
pattern := &models.Pattern{
|
||||
Name: "Unique Pattern",
|
||||
Type: models.PatternTypeRefactor,
|
||||
Frequency: 1,
|
||||
Status: models.PatternStatusActive,
|
||||
Confidence: 0.7,
|
||||
LastSeenAt: now.Format(time.RFC3339),
|
||||
LastSeenEpoch: now.UnixMilli(),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
|
||||
_, err := patternStore.StorePattern(ctx, pattern)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve by name
|
||||
retrieved, err := patternStore.GetPatternByName(ctx, "Unique Pattern")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrieved)
|
||||
assert.Equal(t, "Unique Pattern", retrieved.Name)
|
||||
assert.Equal(t, models.PatternTypeRefactor, retrieved.Type)
|
||||
|
||||
// Non-existent pattern
|
||||
notFound, err := patternStore.GetPatternByName(ctx, "Nonexistent")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, notFound)
|
||||
}
|
||||
|
||||
func TestPatternStore_GetActivePatterns(t *testing.T) {
|
||||
patternStore, _, cleanup := testPatternStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Create active patterns
|
||||
for i := 0; i < 3; i++ {
|
||||
pattern := &models.Pattern{
|
||||
Name: "Pattern " + string(rune('A'+i)),
|
||||
Type: models.PatternTypeBug,
|
||||
Frequency: i + 1, // Different frequencies for sorting
|
||||
Status: models.PatternStatusActive,
|
||||
Confidence: 0.8,
|
||||
LastSeenAt: now.Format(time.RFC3339),
|
||||
LastSeenEpoch: now.UnixMilli(),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
_, err := patternStore.StorePattern(ctx, pattern)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create deprecated pattern (should not be included)
|
||||
deprecatedPattern := &models.Pattern{
|
||||
Name: "Deprecated Pattern",
|
||||
Type: models.PatternTypeBug,
|
||||
Frequency: 100,
|
||||
Status: models.PatternStatusDeprecated,
|
||||
Confidence: 0.9,
|
||||
LastSeenAt: now.Format(time.RFC3339),
|
||||
LastSeenEpoch: now.UnixMilli(),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
_, err := patternStore.StorePattern(ctx, deprecatedPattern)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get active patterns
|
||||
patterns, err := patternStore.GetActivePatterns(ctx, 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, patterns, 3) // Only active patterns
|
||||
|
||||
// Verify sorted by frequency DESC
|
||||
assert.Equal(t, 3, patterns[0].Frequency)
|
||||
assert.Equal(t, 2, patterns[1].Frequency)
|
||||
assert.Equal(t, 1, patterns[2].Frequency)
|
||||
}
|
||||
|
||||
func TestPatternStore_GetPatternsByType(t *testing.T) {
|
||||
patternStore, _, cleanup := testPatternStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Create patterns of different types
|
||||
bugPattern := &models.Pattern{
|
||||
Name: "Bug Pattern",
|
||||
Type: models.PatternTypeBug,
|
||||
Frequency: 1,
|
||||
Status: models.PatternStatusActive,
|
||||
Confidence: 0.8,
|
||||
LastSeenAt: now.Format(time.RFC3339),
|
||||
LastSeenEpoch: now.UnixMilli(),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
_, err := patternStore.StorePattern(ctx, bugPattern)
|
||||
require.NoError(t, err)
|
||||
|
||||
refactorPattern := &models.Pattern{
|
||||
Name: "Refactor Pattern",
|
||||
Type: models.PatternTypeRefactor,
|
||||
Frequency: 1,
|
||||
Status: models.PatternStatusActive,
|
||||
Confidence: 0.7,
|
||||
LastSeenAt: now.Format(time.RFC3339),
|
||||
LastSeenEpoch: now.UnixMilli(),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
_, err = patternStore.StorePattern(ctx, refactorPattern)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get only bug patterns
|
||||
bugPatterns, err := patternStore.GetPatternsByType(ctx, models.PatternTypeBug, 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, bugPatterns, 1)
|
||||
assert.Equal(t, "Bug Pattern", bugPatterns[0].Name)
|
||||
assert.Equal(t, models.PatternTypeBug, bugPatterns[0].Type)
|
||||
|
||||
// Get only refactor patterns
|
||||
refactorPatterns, err := patternStore.GetPatternsByType(ctx, models.PatternTypeRefactor, 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, refactorPatterns, 1)
|
||||
assert.Equal(t, "Refactor Pattern", refactorPatterns[0].Name)
|
||||
}
|
||||
|
||||
func TestPatternStore_MarkPatternDeprecated(t *testing.T) {
|
||||
patternStore, _, cleanup := testPatternStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
pattern := &models.Pattern{
|
||||
Name: "To Deprecate",
|
||||
Type: models.PatternTypeBug,
|
||||
Frequency: 1,
|
||||
Status: models.PatternStatusActive,
|
||||
Confidence: 0.5,
|
||||
LastSeenAt: now.Format(time.RFC3339),
|
||||
LastSeenEpoch: now.UnixMilli(),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
|
||||
id, err := patternStore.StorePattern(ctx, pattern)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Mark as deprecated
|
||||
err = patternStore.MarkPatternDeprecated(ctx, id)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify status changed
|
||||
retrieved, err := patternStore.GetPatternByID(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, models.PatternStatusDeprecated, retrieved.Status)
|
||||
}
|
||||
|
||||
func TestPatternStore_MergePatterns(t *testing.T) {
|
||||
patternStore, _, cleanup := testPatternStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Create source pattern
|
||||
source := &models.Pattern{
|
||||
Name: "Source Pattern",
|
||||
Type: models.PatternTypeBug,
|
||||
Frequency: 5,
|
||||
Projects: []string{"project-a", "project-b"},
|
||||
ObservationIDs: []int64{1, 2, 3},
|
||||
Status: models.PatternStatusActive,
|
||||
Confidence: 0.7,
|
||||
LastSeenAt: now.Format(time.RFC3339),
|
||||
LastSeenEpoch: now.UnixMilli(),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
sourceID, err := patternStore.StorePattern(ctx, source)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create target pattern
|
||||
target := &models.Pattern{
|
||||
Name: "Target Pattern",
|
||||
Type: models.PatternTypeBug,
|
||||
Frequency: 10,
|
||||
Projects: []string{"project-b", "project-c"},
|
||||
ObservationIDs: []int64{3, 4, 5},
|
||||
Status: models.PatternStatusActive,
|
||||
Confidence: 0.8,
|
||||
LastSeenAt: now.Format(time.RFC3339),
|
||||
LastSeenEpoch: now.UnixMilli(),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
targetID, err := patternStore.StorePattern(ctx, target)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Merge source into target
|
||||
err = patternStore.MergePatterns(ctx, sourceID, targetID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify target was updated
|
||||
mergedTarget, err := patternStore.GetPatternByID(ctx, targetID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 15, mergedTarget.Frequency) // 5 + 10
|
||||
assert.ElementsMatch(t, []string{"project-a", "project-b", "project-c"}, mergedTarget.Projects)
|
||||
assert.ElementsMatch(t, []int64{1, 2, 3, 4, 5}, mergedTarget.ObservationIDs)
|
||||
|
||||
// Verify source was marked as merged
|
||||
mergedSource, err := patternStore.GetPatternByID(ctx, sourceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, models.PatternStatusMerged, mergedSource.Status)
|
||||
assert.True(t, mergedSource.MergedIntoID.Valid)
|
||||
assert.Equal(t, targetID, mergedSource.MergedIntoID.Int64)
|
||||
}
|
||||
|
||||
func TestPatternStore_DeletePattern(t *testing.T) {
|
||||
patternStore, _, cleanup := testPatternStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
pattern := &models.Pattern{
|
||||
Name: "To Delete",
|
||||
Type: models.PatternTypeBug,
|
||||
Frequency: 1,
|
||||
Status: models.PatternStatusActive,
|
||||
Confidence: 0.5,
|
||||
LastSeenAt: now.Format(time.RFC3339),
|
||||
LastSeenEpoch: now.UnixMilli(),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
|
||||
id, err := patternStore.StorePattern(ctx, pattern)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete pattern
|
||||
err = patternStore.DeletePattern(ctx, id)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify deleted
|
||||
deleted, err := patternStore.GetPatternByID(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, deleted)
|
||||
}
|
||||
|
||||
func TestPatternStore_IncrementPatternFrequency(t *testing.T) {
|
||||
patternStore, _, cleanup := testPatternStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
pattern := &models.Pattern{
|
||||
Name: "Frequency Test",
|
||||
Type: models.PatternTypeBug,
|
||||
Frequency: 1,
|
||||
Projects: []string{"project-a"},
|
||||
ObservationIDs: []int64{},
|
||||
Status: models.PatternStatusActive,
|
||||
Confidence: 0.7,
|
||||
LastSeenAt: now.Format(time.RFC3339),
|
||||
LastSeenEpoch: now.UnixMilli(),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
|
||||
id, err := patternStore.StorePattern(ctx, pattern)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Increment frequency with new project and observation
|
||||
err = patternStore.IncrementPatternFrequency(ctx, id, "project-b", 42)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify frequency incremented and new data added
|
||||
updated, err := patternStore.GetPatternByID(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, updated.Frequency)
|
||||
assert.ElementsMatch(t, []string{"project-a", "project-b"}, updated.Projects)
|
||||
assert.Contains(t, updated.ObservationIDs, int64(42))
|
||||
|
||||
// Last seen should be updated (rough check - within last 5 seconds)
|
||||
updatedTime, _ := time.Parse(time.RFC3339, updated.LastSeenAt)
|
||||
assert.WithinDuration(t, time.Now(), updatedTime, 5*time.Second)
|
||||
}
|
||||
|
||||
func TestPatternStore_GetPatternStats(t *testing.T) {
|
||||
patternStore, _, cleanup := testPatternStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Create patterns with different statuses and types
|
||||
patterns := []*models.Pattern{
|
||||
{
|
||||
Name: "Bug 1",
|
||||
Type: models.PatternTypeBug,
|
||||
Frequency: 10,
|
||||
Status: models.PatternStatusActive,
|
||||
Confidence: 0.8,
|
||||
LastSeenAt: now.Format(time.RFC3339),
|
||||
LastSeenEpoch: now.UnixMilli(),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
},
|
||||
{
|
||||
Name: "Refactor 1",
|
||||
Type: models.PatternTypeRefactor,
|
||||
Frequency: 5,
|
||||
Status: models.PatternStatusActive,
|
||||
Confidence: 0.7,
|
||||
LastSeenAt: now.Format(time.RFC3339),
|
||||
LastSeenEpoch: now.UnixMilli(),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
},
|
||||
{
|
||||
Name: "Deprecated 1",
|
||||
Type: models.PatternTypeBestPractice,
|
||||
Frequency: 3,
|
||||
Status: models.PatternStatusDeprecated,
|
||||
Confidence: 0.6,
|
||||
LastSeenAt: now.Format(time.RFC3339),
|
||||
LastSeenEpoch: now.UnixMilli(),
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, p := range patterns {
|
||||
_, err := patternStore.StorePattern(ctx, p)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Get stats
|
||||
stats, err := patternStore.GetPatternStats(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 3, stats.Total)
|
||||
assert.Equal(t, 2, stats.Active)
|
||||
assert.Equal(t, 1, stats.Deprecated)
|
||||
assert.Equal(t, 0, stats.Merged)
|
||||
assert.Equal(t, 18, stats.TotalOccurrences) // 10 + 5 + 3
|
||||
assert.InDelta(t, 0.7, stats.AvgConfidence, 0.05) // (0.8 + 0.7 + 0.6) / 3
|
||||
assert.Equal(t, 1, stats.Bugs)
|
||||
assert.Equal(t, 1, stats.Refactors)
|
||||
assert.Equal(t, 1, stats.BestPractices)
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// PromptCleanupFunc is a callback for when prompts are cleaned up.
|
||||
// Receives the IDs of deleted prompts for downstream cleanup (e.g., vector DB).
|
||||
type PromptCleanupFunc func(ctx context.Context, deletedIDs []int64)
|
||||
|
||||
// MaxPromptsGlobal is the hard limit of prompts across all projects.
|
||||
const MaxPromptsGlobal = 500
|
||||
|
||||
// PromptStore provides user prompt-related database operations using GORM.
|
||||
type PromptStore struct {
|
||||
db *gorm.DB
|
||||
cleanupFunc PromptCleanupFunc
|
||||
cleanupMu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewPromptStore creates a new prompt store.
|
||||
func NewPromptStore(store *Store, cleanupFunc PromptCleanupFunc) *PromptStore {
|
||||
return &PromptStore{
|
||||
db: store.DB,
|
||||
cleanupFunc: cleanupFunc,
|
||||
}
|
||||
}
|
||||
|
||||
// SetCleanupFunc sets the callback for when prompts are deleted during cleanup.
|
||||
func (s *PromptStore) SetCleanupFunc(fn PromptCleanupFunc) {
|
||||
s.cleanupMu.Lock()
|
||||
defer s.cleanupMu.Unlock()
|
||||
s.cleanupFunc = fn
|
||||
}
|
||||
|
||||
// SaveUserPromptWithMatches saves a user prompt with matched observation count.
|
||||
// Uses INSERT OR IGNORE to be idempotent - duplicate (session, prompt_number) pairs are silently ignored.
|
||||
// This prevents duplicate prompts when the user-prompt hook fires multiple times.
|
||||
func (s *PromptStore) SaveUserPromptWithMatches(ctx context.Context, claudeSessionID string, promptNumber int, promptText string, matchedObservations int) (int64, error) {
|
||||
now := time.Now()
|
||||
|
||||
prompt := &UserPrompt{
|
||||
ClaudeSessionID: claudeSessionID,
|
||||
PromptNumber: promptNumber,
|
||||
PromptText: promptText,
|
||||
MatchedObservations: matchedObservations,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
|
||||
// INSERT OR IGNORE using OnConflict
|
||||
result := s.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "claude_session_id"}, {Name: "prompt_number"}},
|
||||
DoNothing: true,
|
||||
}).
|
||||
Create(prompt)
|
||||
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
|
||||
// If RowsAffected is 0, the insert was ignored (duplicate) - fetch the existing ID
|
||||
if result.RowsAffected == 0 {
|
||||
var existing UserPrompt
|
||||
err := s.db.Where("claude_session_id = ? AND prompt_number = ?", claudeSessionID, promptNumber).
|
||||
First(&existing).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Return existing ID without triggering cleanup (already handled when first inserted)
|
||||
return existing.ID, nil
|
||||
}
|
||||
|
||||
// Cleanup old prompts beyond the global limit (async to not block handler)
|
||||
go func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if deletedIDs, err := s.CleanupOldPrompts(cleanupCtx); err != nil {
|
||||
log.Warn().Err(err).Msg("Background prompt cleanup failed")
|
||||
} else if len(deletedIDs) > 0 {
|
||||
s.cleanupMu.RLock()
|
||||
fn := s.cleanupFunc
|
||||
s.cleanupMu.RUnlock()
|
||||
if fn != nil {
|
||||
fn(cleanupCtx, deletedIDs)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return prompt.ID, nil
|
||||
}
|
||||
|
||||
// CleanupOldPrompts deletes prompts beyond the global limit.
|
||||
// Keeps the most recent MaxPromptsGlobal prompts.
|
||||
// Returns the IDs of deleted prompts for downstream cleanup (e.g., vector DB).
|
||||
func (s *PromptStore) CleanupOldPrompts(ctx context.Context) ([]int64, error) {
|
||||
// Use a transaction to prevent TOCTOU race condition
|
||||
var idsToDelete []int64
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// Find IDs to keep (most recent MaxPromptsGlobal)
|
||||
var idsToKeep []int64
|
||||
err := tx.Model(&UserPrompt{}).
|
||||
Order("created_at_epoch DESC").
|
||||
Limit(MaxPromptsGlobal).
|
||||
Pluck("id", &idsToKeep).Error
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(idsToKeep) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find IDs to delete (all IDs not in the keep list)
|
||||
// This happens in the same transaction to prevent race conditions
|
||||
err = tx.Model(&UserPrompt{}).
|
||||
Where("id NOT IN ?", idsToKeep).
|
||||
Pluck("id", &idsToDelete).Error
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(idsToDelete) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete the prompts
|
||||
return tx.Delete(&UserPrompt{}, idsToDelete).Error
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return idsToDelete, nil
|
||||
}
|
||||
|
||||
// GetPromptsByIDs retrieves user prompts by a list of IDs.
|
||||
func (s *PromptStore) GetPromptsByIDs(ctx context.Context, ids []int64, orderBy string, limit int) ([]*models.UserPromptWithSession, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []struct {
|
||||
Project sql.NullString `gorm:"column:project"`
|
||||
SDKSessionID sql.NullString `gorm:"column:sdk_session_id"`
|
||||
UserPrompt
|
||||
}
|
||||
|
||||
query := s.db.WithContext(ctx).
|
||||
Table("user_prompts up").
|
||||
Select("up.id, up.claude_session_id, up.prompt_number, up.prompt_text, "+
|
||||
"COALESCE(up.matched_observations, 0) as matched_observations, "+
|
||||
"up.created_at, up.created_at_epoch, "+
|
||||
"COALESCE(s.project, '') as project, "+
|
||||
"COALESCE(s.sdk_session_id, '') as sdk_session_id").
|
||||
Joins("LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id").
|
||||
Where("up.id IN ?", ids)
|
||||
|
||||
// Apply ordering
|
||||
switch orderBy {
|
||||
case "date_asc":
|
||||
query = query.Order("up.created_at_epoch ASC")
|
||||
case "date_desc", "default", "":
|
||||
query = query.Order("up.created_at_epoch DESC")
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
|
||||
err := query.Scan(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelUserPromptsWithSession(results), nil
|
||||
}
|
||||
|
||||
// GetAllRecentUserPrompts retrieves recent user prompts across all projects.
|
||||
func (s *PromptStore) GetAllRecentUserPrompts(ctx context.Context, limit int) ([]*models.UserPromptWithSession, error) {
|
||||
var results []struct {
|
||||
Project sql.NullString `gorm:"column:project"`
|
||||
SDKSessionID sql.NullString `gorm:"column:sdk_session_id"`
|
||||
UserPrompt
|
||||
}
|
||||
|
||||
query := s.db.WithContext(ctx).
|
||||
Table("user_prompts up").
|
||||
Select("up.id, up.claude_session_id, up.prompt_number, up.prompt_text, " +
|
||||
"COALESCE(up.matched_observations, 0) as matched_observations, " +
|
||||
"up.created_at, up.created_at_epoch, " +
|
||||
"COALESCE(s.project, '') as project, " +
|
||||
"COALESCE(s.sdk_session_id, '') as sdk_session_id").
|
||||
Joins("LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id").
|
||||
Order("up.created_at_epoch DESC").
|
||||
Limit(limit)
|
||||
|
||||
err := query.Scan(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelUserPromptsWithSession(results), nil
|
||||
}
|
||||
|
||||
// GetAllPrompts retrieves all user prompts (for vector rebuild).
|
||||
func (s *PromptStore) GetAllPrompts(ctx context.Context) ([]*models.UserPromptWithSession, error) {
|
||||
var results []struct {
|
||||
Project sql.NullString `gorm:"column:project"`
|
||||
SDKSessionID sql.NullString `gorm:"column:sdk_session_id"`
|
||||
UserPrompt
|
||||
}
|
||||
|
||||
query := s.db.WithContext(ctx).
|
||||
Table("user_prompts up").
|
||||
Select("up.id, up.claude_session_id, up.prompt_number, up.prompt_text, " +
|
||||
"COALESCE(up.matched_observations, 0) as matched_observations, " +
|
||||
"up.created_at, up.created_at_epoch, " +
|
||||
"COALESCE(s.project, '') as project, " +
|
||||
"COALESCE(s.sdk_session_id, '') as sdk_session_id").
|
||||
Joins("LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id").
|
||||
Order("up.id")
|
||||
|
||||
err := query.Scan(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelUserPromptsWithSession(results), nil
|
||||
}
|
||||
|
||||
// FindRecentPromptByText finds a recent prompt by exact text match within a time window.
|
||||
// Returns (promptID, promptNumber, found).
|
||||
func (s *PromptStore) FindRecentPromptByText(ctx context.Context, claudeSessionID, 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("claude_session_id = ? AND prompt_text = ? AND created_at_epoch >= ?",
|
||||
claudeSessionID, promptText, cutoffEpoch).
|
||||
Order("created_at_epoch DESC").
|
||||
First(&prompt).Error
|
||||
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
return prompt.ID, prompt.PromptNumber, true
|
||||
}
|
||||
|
||||
// FindRecentPromptByTextGlobal finds a recent prompt by exact text match within a time window
|
||||
// across ALL sessions (not scoped to a single claude session ID).
|
||||
// This catches duplicates when the same prompt arrives from different session IDs,
|
||||
// e.g. when the SDK processor's callClaudeCLI subprocess fires with a different session ID.
|
||||
// Returns (promptID, promptNumber, found).
|
||||
func (s *PromptStore) FindRecentPromptByTextGlobal(ctx context.Context, promptText string, withinSeconds int) (int64, int, bool) {
|
||||
cutoffEpoch := time.Now().Add(-time.Duration(withinSeconds) * time.Second).UnixMilli()
|
||||
|
||||
var prompt UserPrompt
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("prompt_text = ? AND created_at_epoch >= ?",
|
||||
promptText, cutoffEpoch).
|
||||
Order("created_at_epoch DESC").
|
||||
First(&prompt).Error
|
||||
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
return prompt.ID, prompt.PromptNumber, true
|
||||
}
|
||||
|
||||
// GetRecentUserPromptsByProject retrieves recent user prompts for a specific project.
|
||||
func (s *PromptStore) GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error) {
|
||||
var results []struct {
|
||||
Project sql.NullString `gorm:"column:project"`
|
||||
SDKSessionID sql.NullString `gorm:"column:sdk_session_id"`
|
||||
UserPrompt
|
||||
}
|
||||
|
||||
query := s.db.WithContext(ctx).
|
||||
Table("user_prompts up").
|
||||
Select("up.id, up.claude_session_id, up.prompt_number, up.prompt_text, "+
|
||||
"COALESCE(up.matched_observations, 0) as matched_observations, "+
|
||||
"up.created_at, up.created_at_epoch, "+
|
||||
"COALESCE(s.project, '') as project, "+
|
||||
"COALESCE(s.sdk_session_id, '') as sdk_session_id").
|
||||
Joins("LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id").
|
||||
Where("s.project = ?", project).
|
||||
Order("up.created_at_epoch DESC").
|
||||
Limit(limit)
|
||||
|
||||
err := query.Scan(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelUserPromptsWithSession(results), nil
|
||||
}
|
||||
|
||||
// toModelUserPromptsWithSession converts query results to pkg/models.UserPromptWithSession.
|
||||
func toModelUserPromptsWithSession(results []struct {
|
||||
Project sql.NullString `gorm:"column:project"`
|
||||
SDKSessionID sql.NullString `gorm:"column:sdk_session_id"`
|
||||
UserPrompt
|
||||
}) []*models.UserPromptWithSession {
|
||||
prompts := make([]*models.UserPromptWithSession, len(results))
|
||||
for i, r := range results {
|
||||
project := ""
|
||||
if r.Project.Valid {
|
||||
project = r.Project.String
|
||||
}
|
||||
|
||||
sdkSessionID := ""
|
||||
if r.SDKSessionID.Valid {
|
||||
sdkSessionID = r.SDKSessionID.String
|
||||
}
|
||||
|
||||
prompts[i] = &models.UserPromptWithSession{
|
||||
UserPrompt: models.UserPrompt{
|
||||
ID: r.ID,
|
||||
ClaudeSessionID: r.ClaudeSessionID,
|
||||
PromptNumber: r.PromptNumber,
|
||||
PromptText: r.PromptText,
|
||||
MatchedObservations: r.MatchedObservations,
|
||||
CreatedAt: r.CreatedAt,
|
||||
CreatedAtEpoch: r.CreatedAtEpoch,
|
||||
},
|
||||
Project: project,
|
||||
SDKSessionID: sdkSessionID,
|
||||
}
|
||||
}
|
||||
return prompts
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
//go:build fts5
|
||||
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// testPromptStore creates a PromptStore with a temporary database for testing.
|
||||
func testPromptStore(t *testing.T) (*PromptStore, *Store, func()) {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_prompt_test_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
cfg := Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 4,
|
||||
LogLevel: logger.Silent,
|
||||
}
|
||||
|
||||
store, err := NewStore(cfg)
|
||||
if err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
|
||||
promptStore := NewPromptStore(store, nil)
|
||||
|
||||
cleanup := func() {
|
||||
store.Close()
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
return promptStore, store, cleanup
|
||||
}
|
||||
|
||||
func TestPromptStore_SaveUserPromptWithMatches(t *testing.T) {
|
||||
promptStore, store, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a session first
|
||||
sessionStore := NewSessionStore(store)
|
||||
_, err := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Save a prompt
|
||||
id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "What is the codebase structure?", 5)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, id, int64(0))
|
||||
}
|
||||
|
||||
func TestPromptStore_SaveUserPromptWithMatches_Idempotency(t *testing.T) {
|
||||
promptStore, _, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Save the same prompt twice (same claudeSessionID + promptNumber)
|
||||
id1, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "Test prompt", 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
id2, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "Different text", 5)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should return the same ID (INSERT OR IGNORE)
|
||||
assert.Equal(t, id1, id2, "Duplicate prompts should return same ID")
|
||||
}
|
||||
|
||||
func TestPromptStore_SaveUserPromptWithMatches_AsyncCleanup(t *testing.T) {
|
||||
promptStore, _, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Track cleanup calls
|
||||
var cleanupMutex sync.Mutex
|
||||
cleanupCalled := false
|
||||
var cleanupIDs []int64
|
||||
|
||||
cleanupFunc := func(ctx context.Context, deletedIDs []int64) {
|
||||
cleanupMutex.Lock()
|
||||
defer cleanupMutex.Unlock()
|
||||
cleanupCalled = true
|
||||
cleanupIDs = deletedIDs
|
||||
}
|
||||
|
||||
promptStore.cleanupFunc = cleanupFunc
|
||||
|
||||
// Save prompts beyond the global limit (MaxPromptsGlobal = 500)
|
||||
// Insert with slower pacing to avoid database lock contention
|
||||
for i := 0; i < 505; i++ {
|
||||
_, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i+1, "Prompt", 1)
|
||||
require.NoError(t, err)
|
||||
if i > 500 {
|
||||
time.Sleep(5 * time.Millisecond) // Slow down after hitting limit
|
||||
}
|
||||
}
|
||||
|
||||
// Wait longer for async cleanup to complete
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Verify cleanup was called
|
||||
cleanupMutex.Lock()
|
||||
defer cleanupMutex.Unlock()
|
||||
assert.True(t, cleanupCalled, "Cleanup function should have been called")
|
||||
assert.NotEmpty(t, cleanupIDs, "Cleanup should have deleted some prompts")
|
||||
}
|
||||
|
||||
func TestPromptStore_CleanupOldPrompts(t *testing.T) {
|
||||
promptStore, _, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Save prompts beyond the limit
|
||||
// Async cleanup should fire after each insert beyond 500
|
||||
for i := 0; i < 505; i++ {
|
||||
_, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i+1, "Prompt", 1)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Wait for all async cleanups to complete
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// After async cleanup, we should have at most 500 prompts
|
||||
remaining, err := promptStore.GetAllPrompts(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.LessOrEqual(t, len(remaining), MaxPromptsGlobal, "Should have at most %d prompts after async cleanup", MaxPromptsGlobal)
|
||||
}
|
||||
|
||||
func TestPromptStore_GetPromptsByIDs(t *testing.T) {
|
||||
promptStore, _, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Save multiple prompts
|
||||
var ids []int64
|
||||
for i := 1; i <= 3; i++ {
|
||||
id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i, "Prompt", i)
|
||||
require.NoError(t, err)
|
||||
ids = append(ids, id)
|
||||
time.Sleep(10 * time.Millisecond) // Ensure different timestamps
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
orderBy string
|
||||
expected []int64
|
||||
}{
|
||||
{
|
||||
name: "Default ordering - date desc",
|
||||
orderBy: "default",
|
||||
expected: []int64{ids[2], ids[1], ids[0]}, // Newest to oldest
|
||||
},
|
||||
{
|
||||
name: "Date ascending",
|
||||
orderBy: "date_asc",
|
||||
expected: []int64{ids[0], ids[1], ids[2]}, // Oldest to newest
|
||||
},
|
||||
{
|
||||
name: "Date descending",
|
||||
orderBy: "date_desc",
|
||||
expected: []int64{ids[2], ids[1], ids[0]}, // Newest to oldest
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
prompts, err := promptStore.GetPromptsByIDs(ctx, ids, tt.orderBy, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, prompts, 3)
|
||||
|
||||
// Verify ordering
|
||||
for i, prompt := range prompts {
|
||||
assert.Equal(t, tt.expected[i], prompt.ID, "Position %d should have ID %d", i, tt.expected[i])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptStore_GetPromptsByIDs_Limit(t *testing.T) {
|
||||
promptStore, _, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Save multiple prompts
|
||||
var ids []int64
|
||||
for i := 1; i <= 5; i++ {
|
||||
id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i, "Prompt", i)
|
||||
require.NoError(t, err)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
// Get with limit
|
||||
prompts, err := promptStore.GetPromptsByIDs(ctx, ids, "default", 3)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, prompts, 3)
|
||||
}
|
||||
|
||||
func TestPromptStore_GetPromptsByIDs_EmptyInput(t *testing.T) {
|
||||
promptStore, _, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Get with empty IDs
|
||||
prompts, err := promptStore.GetPromptsByIDs(ctx, []int64{}, "default", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, prompts)
|
||||
}
|
||||
|
||||
func TestPromptStore_GetPromptsByIDs_WithSession(t *testing.T) {
|
||||
promptStore, store, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a session
|
||||
sessionStore := NewSessionStore(store)
|
||||
_, err := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Save a prompt
|
||||
id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "Test prompt", 5)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get with session join
|
||||
prompts, err := promptStore.GetPromptsByIDs(ctx, []int64{id}, "default", 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, prompts, 1)
|
||||
|
||||
// Verify session data is populated
|
||||
assert.Equal(t, "test-project", prompts[0].Project)
|
||||
assert.NotEmpty(t, prompts[0].SDKSessionID)
|
||||
}
|
||||
|
||||
func TestPromptStore_GetAllRecentUserPrompts(t *testing.T) {
|
||||
promptStore, _, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Save prompts across multiple sessions with timestamps
|
||||
for i := 1; i <= 3; i++ {
|
||||
_, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i, "Prompt A", i)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(10 * time.Millisecond) // Ensure different timestamps
|
||||
}
|
||||
|
||||
for i := 1; i <= 2; i++ {
|
||||
_, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-2", i, "Prompt B", i)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(10 * time.Millisecond) // Ensure different timestamps
|
||||
}
|
||||
|
||||
// Get all recent prompts
|
||||
prompts, err := promptStore.GetAllRecentUserPrompts(ctx, 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, prompts, 5)
|
||||
|
||||
// Verify ordering (most recent first) - last inserted should be first
|
||||
assert.Equal(t, "claude-2", prompts[0].ClaudeSessionID)
|
||||
assert.Equal(t, 2, prompts[0].PromptNumber)
|
||||
}
|
||||
|
||||
func TestPromptStore_GetAllPrompts(t *testing.T) {
|
||||
promptStore, _, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Save prompts
|
||||
for i := 1; i <= 5; i++ {
|
||||
_, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i, "Prompt", i)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Wait for any async cleanup to complete (longer wait for race detector)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Get all prompts (for vector rebuild)
|
||||
prompts, err := promptStore.GetAllPrompts(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, prompts, 5)
|
||||
|
||||
// Verify ordering by ID
|
||||
for i := 0; i < len(prompts)-1; i++ {
|
||||
assert.Less(t, prompts[i].ID, prompts[i+1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptStore_FindRecentPromptByText(t *testing.T) {
|
||||
promptStore, _, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Save a prompt
|
||||
id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "What is the architecture?", 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Find by exact text match within time window
|
||||
foundID, foundNumber, found := promptStore.FindRecentPromptByText(ctx, "claude-1", "What is the architecture?", 60)
|
||||
assert.True(t, found, "Should find the prompt")
|
||||
assert.Equal(t, id, foundID)
|
||||
assert.Equal(t, 1, foundNumber)
|
||||
|
||||
// Try to find with different text
|
||||
_, _, notFound := promptStore.FindRecentPromptByText(ctx, "claude-1", "Different text", 60)
|
||||
assert.False(t, notFound, "Should not find a different prompt")
|
||||
|
||||
// Try to find outside time window
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_, _, notFound = promptStore.FindRecentPromptByText(ctx, "claude-1", "What is the architecture?", 0)
|
||||
assert.False(t, notFound, "Should not find prompt outside time window")
|
||||
}
|
||||
|
||||
func TestPromptStore_FindRecentPromptByTextGlobal(t *testing.T) {
|
||||
promptStore, _, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Save a prompt under session "claude-1"
|
||||
id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "What is the architecture?", 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Global search should find it without specifying session ID
|
||||
foundID, foundNumber, found := promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 60)
|
||||
assert.True(t, found, "Should find the prompt globally")
|
||||
assert.Equal(t, id, foundID)
|
||||
assert.Equal(t, 1, foundNumber)
|
||||
|
||||
// Global search should find it even when a different session ID would have been used
|
||||
// (This is the core cross-session dedup scenario)
|
||||
foundID2, foundNumber2, found2 := promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 60)
|
||||
assert.True(t, found2, "Should find the prompt from any session")
|
||||
assert.Equal(t, id, foundID2)
|
||||
assert.Equal(t, 1, foundNumber2)
|
||||
|
||||
// Different text should not match
|
||||
_, _, notFound := promptStore.FindRecentPromptByTextGlobal(ctx, "Different text", 60)
|
||||
assert.False(t, notFound, "Should not find a different prompt")
|
||||
|
||||
// Should not find prompt outside time window
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_, _, notFound = promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 0)
|
||||
assert.False(t, notFound, "Should not find prompt outside time window")
|
||||
}
|
||||
|
||||
func TestPromptStore_GetRecentUserPromptsByProject(t *testing.T) {
|
||||
promptStore, store, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create sessions for different projects
|
||||
sessionStore := NewSessionStore(store)
|
||||
_, err := sessionStore.CreateSDKSession(ctx, "claude-1", "project-a", "")
|
||||
require.NoError(t, err)
|
||||
_, err = sessionStore.CreateSDKSession(ctx, "claude-2", "project-b", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Save prompts for project-a
|
||||
for i := 1; i <= 3; i++ {
|
||||
_, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i, "Prompt A", i)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Save prompts for project-b
|
||||
for i := 1; i <= 2; i++ {
|
||||
_, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-2", i, "Prompt B", i)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Get prompts for project-a
|
||||
prompts, err := promptStore.GetRecentUserPromptsByProject(ctx, "project-a", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, prompts, 3)
|
||||
|
||||
// Verify all prompts are from project-a
|
||||
for _, prompt := range prompts {
|
||||
assert.Equal(t, "project-a", prompt.Project)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptStore_GetRecentUserPromptsByProject_Limit(t *testing.T) {
|
||||
promptStore, store, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create session
|
||||
sessionStore := NewSessionStore(store)
|
||||
_, err := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Save multiple prompts
|
||||
for i := 1; i <= 10; i++ {
|
||||
_, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i, "Prompt", i)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Wait for any async cleanup to complete
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Get with limit
|
||||
prompts, err := promptStore.GetRecentUserPromptsByProject(ctx, "test-project", 5)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, prompts, 5)
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// RelationStore provides relation-related database operations using GORM.
|
||||
type RelationStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewRelationStore creates a new relation store.
|
||||
func NewRelationStore(store *Store) *RelationStore {
|
||||
return &RelationStore{
|
||||
db: store.DB,
|
||||
}
|
||||
}
|
||||
|
||||
// StoreRelation stores a new observation relation.
|
||||
// Uses INSERT OR IGNORE to handle duplicate (source_id, target_id, relation_type) combinations.
|
||||
func (s *RelationStore) StoreRelation(ctx context.Context, relation *models.ObservationRelation) (int64, error) {
|
||||
dbRelation := &ObservationRelation{
|
||||
SourceID: relation.SourceID,
|
||||
TargetID: relation.TargetID,
|
||||
RelationType: relation.RelationType,
|
||||
Confidence: relation.Confidence,
|
||||
DetectionSource: relation.DetectionSource,
|
||||
CreatedAt: relation.CreatedAt,
|
||||
CreatedAtEpoch: relation.CreatedAtEpoch,
|
||||
}
|
||||
|
||||
// Handle nullable fields
|
||||
if relation.Reason != "" {
|
||||
dbRelation.Reason = sql.NullString{String: relation.Reason, Valid: true}
|
||||
}
|
||||
|
||||
// INSERT OR IGNORE using OnConflict
|
||||
result := s.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "source_id"}, {Name: "target_id"}, {Name: "relation_type"}},
|
||||
DoNothing: true,
|
||||
}).
|
||||
Create(dbRelation)
|
||||
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
|
||||
// If RowsAffected is 0, the insert was ignored (duplicate)
|
||||
if result.RowsAffected == 0 {
|
||||
var existing ObservationRelation
|
||||
err := s.db.Where("source_id = ? AND target_id = ? AND relation_type = ?",
|
||||
relation.SourceID, relation.TargetID, relation.RelationType).
|
||||
First(&existing).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return existing.ID, nil
|
||||
}
|
||||
|
||||
return dbRelation.ID, nil
|
||||
}
|
||||
|
||||
// StoreRelations stores multiple relations in a single transaction.
|
||||
func (s *RelationStore) StoreRelations(ctx context.Context, relations []*models.ObservationRelation) error {
|
||||
if len(relations) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for _, rel := range relations {
|
||||
dbRelation := &ObservationRelation{
|
||||
SourceID: rel.SourceID,
|
||||
TargetID: rel.TargetID,
|
||||
RelationType: rel.RelationType,
|
||||
Confidence: rel.Confidence,
|
||||
DetectionSource: rel.DetectionSource,
|
||||
CreatedAt: rel.CreatedAt,
|
||||
CreatedAtEpoch: rel.CreatedAtEpoch,
|
||||
}
|
||||
|
||||
if rel.Reason != "" {
|
||||
dbRelation.Reason = sql.NullString{String: rel.Reason, Valid: true}
|
||||
}
|
||||
|
||||
result := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "source_id"}, {Name: "target_id"}, {Name: "relation_type"}},
|
||||
DoNothing: true,
|
||||
}).Create(dbRelation)
|
||||
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetRelationsByObservationID retrieves all relations involving an observation (as source or target).
|
||||
func (s *RelationStore) GetRelationsByObservationID(ctx context.Context, obsID int64) ([]*models.ObservationRelation, error) {
|
||||
var relations []ObservationRelation
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("source_id = ? OR target_id = ?", obsID, obsID).
|
||||
Order("confidence DESC, created_at_epoch DESC").
|
||||
Find(&relations).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelRelations(relations), nil
|
||||
}
|
||||
|
||||
// GetOutgoingRelations retrieves relations where the observation is the source.
|
||||
func (s *RelationStore) GetOutgoingRelations(ctx context.Context, obsID int64) ([]*models.ObservationRelation, error) {
|
||||
var relations []ObservationRelation
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("source_id = ?", obsID).
|
||||
Order("confidence DESC, created_at_epoch DESC").
|
||||
Find(&relations).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelRelations(relations), nil
|
||||
}
|
||||
|
||||
// GetIncomingRelations retrieves relations where the observation is the target.
|
||||
func (s *RelationStore) GetIncomingRelations(ctx context.Context, obsID int64) ([]*models.ObservationRelation, error) {
|
||||
var relations []ObservationRelation
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("target_id = ?", obsID).
|
||||
Order("confidence DESC, created_at_epoch DESC").
|
||||
Find(&relations).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelRelations(relations), nil
|
||||
}
|
||||
|
||||
// GetRelationsByType retrieves all relations of a specific type.
|
||||
func (s *RelationStore) GetRelationsByType(ctx context.Context, relationType models.RelationType, limit int) ([]*models.ObservationRelation, error) {
|
||||
var relations []ObservationRelation
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("relation_type = ?", relationType).
|
||||
Order("confidence DESC, created_at_epoch DESC").
|
||||
Limit(limit).
|
||||
Find(&relations).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelRelations(relations), nil
|
||||
}
|
||||
|
||||
// GetRelationsWithDetails retrieves relations with observation titles for display.
|
||||
func (s *RelationStore) GetRelationsWithDetails(ctx context.Context, obsID int64) ([]*models.RelationWithDetails, error) {
|
||||
var results []struct {
|
||||
SourceType string `gorm:"column:source_type"`
|
||||
TargetType string `gorm:"column:target_type"`
|
||||
SourceTitle sql.NullString `gorm:"column:source_title"`
|
||||
TargetTitle sql.NullString `gorm:"column:target_title"`
|
||||
ObservationRelation
|
||||
}
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Table("observation_relations r").
|
||||
Select("r.*, "+
|
||||
"COALESCE(src.title, '') as source_title, "+
|
||||
"COALESCE(tgt.title, '') as target_title, "+
|
||||
"src.type as source_type, "+
|
||||
"tgt.type as target_type").
|
||||
Joins("JOIN observations src ON src.id = r.source_id").
|
||||
Joins("JOIN observations tgt ON tgt.id = r.target_id").
|
||||
Where("r.source_id = ? OR r.target_id = ?", obsID, obsID).
|
||||
Order("r.confidence DESC, r.created_at_epoch DESC").
|
||||
Scan(&results).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
relations := make([]*models.RelationWithDetails, len(results))
|
||||
for i, r := range results {
|
||||
relations[i] = &models.RelationWithDetails{
|
||||
Relation: toModelRelation(&r.ObservationRelation),
|
||||
SourceTitle: r.SourceTitle.String,
|
||||
TargetTitle: r.TargetTitle.String,
|
||||
SourceType: models.ObservationType(r.SourceType),
|
||||
TargetType: models.ObservationType(r.TargetType),
|
||||
}
|
||||
}
|
||||
|
||||
return relations, nil
|
||||
}
|
||||
|
||||
// GetRelationGraph retrieves a relation graph centered on an observation.
|
||||
// This returns all observations within N hops from the center.
|
||||
func (s *RelationStore) GetRelationGraph(ctx context.Context, centerID int64, maxDepth int) (*models.RelationGraph, error) {
|
||||
// Get all relations involving the center observation
|
||||
relations, err := s.GetRelationsWithDetails(ctx, centerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
graph := &models.RelationGraph{
|
||||
CenterID: centerID,
|
||||
Relations: relations,
|
||||
}
|
||||
|
||||
// If depth > 1, recursively get relations for connected observations
|
||||
if maxDepth > 1 {
|
||||
visited := map[int64]bool{centerID: true}
|
||||
toVisit := make([]int64, 0)
|
||||
|
||||
// Collect IDs of directly connected observations
|
||||
for _, r := range relations {
|
||||
if !visited[r.Relation.SourceID] {
|
||||
toVisit = append(toVisit, r.Relation.SourceID)
|
||||
visited[r.Relation.SourceID] = true
|
||||
}
|
||||
if !visited[r.Relation.TargetID] {
|
||||
toVisit = append(toVisit, r.Relation.TargetID)
|
||||
visited[r.Relation.TargetID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Get relations for connected observations (depth - 1)
|
||||
for depth := 1; depth < maxDepth && len(toVisit) > 0; depth++ {
|
||||
nextLevel := make([]int64, 0)
|
||||
for _, obsID := range toVisit {
|
||||
moreRelations, err := s.GetRelationsWithDetails(ctx, obsID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, r := range moreRelations {
|
||||
// Avoid duplicates
|
||||
exists := false
|
||||
for _, existing := range graph.Relations {
|
||||
if existing.Relation.ID == r.Relation.ID {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
graph.Relations = append(graph.Relations, r)
|
||||
}
|
||||
|
||||
// Queue next level
|
||||
if !visited[r.Relation.SourceID] {
|
||||
nextLevel = append(nextLevel, r.Relation.SourceID)
|
||||
visited[r.Relation.SourceID] = true
|
||||
}
|
||||
if !visited[r.Relation.TargetID] {
|
||||
nextLevel = append(nextLevel, r.Relation.TargetID)
|
||||
visited[r.Relation.TargetID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
toVisit = nextLevel
|
||||
}
|
||||
}
|
||||
|
||||
return graph, nil
|
||||
}
|
||||
|
||||
// DeleteRelationsByObservationID deletes all relations involving an observation.
|
||||
// Called when an observation is deleted.
|
||||
func (s *RelationStore) DeleteRelationsByObservationID(ctx context.Context, obsID int64) error {
|
||||
result := s.db.WithContext(ctx).
|
||||
Where("source_id = ? OR target_id = ?", obsID, obsID).
|
||||
Delete(&ObservationRelation{})
|
||||
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// GetRelationCount returns the count of relations for an observation.
|
||||
func (s *RelationStore) GetRelationCount(ctx context.Context, obsID int64) (int, error) {
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).
|
||||
Model(&ObservationRelation{}).
|
||||
Where("source_id = ? OR target_id = ?", obsID, obsID).
|
||||
Count(&count).Error
|
||||
|
||||
return int(count), err
|
||||
}
|
||||
|
||||
// GetTotalRelationCount returns the total count of all relations.
|
||||
func (s *RelationStore) GetTotalRelationCount(ctx context.Context) (int, error) {
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).
|
||||
Model(&ObservationRelation{}).
|
||||
Count(&count).Error
|
||||
|
||||
return int(count), err
|
||||
}
|
||||
|
||||
// GetHighConfidenceRelations retrieves relations with confidence above threshold.
|
||||
func (s *RelationStore) GetHighConfidenceRelations(ctx context.Context, minConfidence float64, limit int) ([]*models.ObservationRelation, error) {
|
||||
var relations []ObservationRelation
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("confidence >= ?", minConfidence).
|
||||
Order("confidence DESC, created_at_epoch DESC").
|
||||
Limit(limit).
|
||||
Find(&relations).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelRelations(relations), nil
|
||||
}
|
||||
|
||||
// UpdateRelationConfidence updates the confidence of a relation.
|
||||
func (s *RelationStore) UpdateRelationConfidence(ctx context.Context, relationID int64, newConfidence float64) error {
|
||||
result := s.db.WithContext(ctx).
|
||||
Model(&ObservationRelation{}).
|
||||
Where("id = ?", relationID).
|
||||
Update("confidence", newConfidence)
|
||||
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// GetRelatedObservationIDs returns IDs of observations related to the given one.
|
||||
// This is useful for expanding search results.
|
||||
// Uses CASE expression for bidirectional ID lookup (GORM doesn't support this well, so we use raw SQL).
|
||||
func (s *RelationStore) GetRelatedObservationIDs(ctx context.Context, obsID int64, minConfidence float64) ([]int64, error) {
|
||||
var ids []int64
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Raw("SELECT DISTINCT CASE WHEN source_id = ? THEN target_id ELSE source_id END as related_id "+
|
||||
"FROM observation_relations "+
|
||||
"WHERE (source_id = ? OR target_id = ?) AND confidence >= ?",
|
||||
obsID, obsID, obsID, minConfidence).
|
||||
Pluck("related_id", &ids).Error
|
||||
|
||||
return ids, err
|
||||
}
|
||||
|
||||
// toModelRelation converts a GORM ObservationRelation to a pkg/models ObservationRelation.
|
||||
func toModelRelation(r *ObservationRelation) *models.ObservationRelation {
|
||||
relation := &models.ObservationRelation{
|
||||
ID: r.ID,
|
||||
SourceID: r.SourceID,
|
||||
TargetID: r.TargetID,
|
||||
RelationType: r.RelationType,
|
||||
Confidence: r.Confidence,
|
||||
DetectionSource: r.DetectionSource,
|
||||
CreatedAt: r.CreatedAt,
|
||||
CreatedAtEpoch: r.CreatedAtEpoch,
|
||||
}
|
||||
|
||||
if r.Reason.Valid {
|
||||
relation.Reason = r.Reason.String
|
||||
}
|
||||
|
||||
return relation
|
||||
}
|
||||
|
||||
// toModelRelations converts a slice of GORM ObservationRelations to pkg/models ObservationRelations.
|
||||
func toModelRelations(relations []ObservationRelation) []*models.ObservationRelation {
|
||||
result := make([]*models.ObservationRelation, len(relations))
|
||||
for i, r := range relations {
|
||||
result[i] = toModelRelation(&r)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
//go:build fts5
|
||||
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
func testRelationStore(t *testing.T) (*RelationStore, *Store, func()) {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_relation_test_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
cfg := Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 4,
|
||||
LogLevel: logger.Silent,
|
||||
}
|
||||
|
||||
store, err := NewStore(cfg)
|
||||
if err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
|
||||
relationStore := NewRelationStore(store)
|
||||
|
||||
cleanup := func() {
|
||||
store.Close()
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
return relationStore, store, cleanup
|
||||
}
|
||||
|
||||
func TestRelationStore_StoreRelation(t *testing.T) {
|
||||
relationStore, _, cleanup := testRelationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
relation := &models.ObservationRelation{
|
||||
SourceID: 1,
|
||||
TargetID: 2,
|
||||
RelationType: models.RelationCauses,
|
||||
Confidence: 0.8,
|
||||
DetectionSource: models.DetectionSourceFileOverlap,
|
||||
Reason: "Test relation",
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
|
||||
id, err := relationStore.StoreRelation(ctx, relation)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, id, int64(0))
|
||||
}
|
||||
|
||||
func TestRelationStore_StoreRelation_Idempotency(t *testing.T) {
|
||||
relationStore, _, cleanup := testRelationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
relation := &models.ObservationRelation{
|
||||
SourceID: 1,
|
||||
TargetID: 2,
|
||||
RelationType: models.RelationCauses,
|
||||
Confidence: 0.8,
|
||||
DetectionSource: models.DetectionSourceFileOverlap,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
|
||||
id1, err := relationStore.StoreRelation(ctx, relation)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store again with same source/target/type - should return same ID
|
||||
id2, err := relationStore.StoreRelation(ctx, relation)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, id1, id2)
|
||||
}
|
||||
|
||||
func TestRelationStore_StoreRelations(t *testing.T) {
|
||||
relationStore, _, cleanup := testRelationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
relations := []*models.ObservationRelation{
|
||||
{
|
||||
SourceID: 1,
|
||||
TargetID: 2,
|
||||
RelationType: models.RelationCauses,
|
||||
Confidence: 0.8,
|
||||
DetectionSource: models.DetectionSourceFileOverlap,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
},
|
||||
{
|
||||
SourceID: 2,
|
||||
TargetID: 3,
|
||||
RelationType: models.RelationFixes,
|
||||
Confidence: 0.9,
|
||||
DetectionSource: models.DetectionSourceTemporalProximity,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
},
|
||||
}
|
||||
|
||||
err := relationStore.StoreRelations(ctx, relations)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify both were stored
|
||||
count, err := relationStore.GetTotalRelationCount(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
}
|
||||
|
||||
func TestRelationStore_GetRelationsByObservationID(t *testing.T) {
|
||||
relationStore, _, cleanup := testRelationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
// Create relations involving observation 2
|
||||
relations := []*models.ObservationRelation{
|
||||
{
|
||||
SourceID: 1,
|
||||
TargetID: 2,
|
||||
RelationType: models.RelationCauses,
|
||||
Confidence: 0.8,
|
||||
DetectionSource: models.DetectionSourceFileOverlap,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
},
|
||||
{
|
||||
SourceID: 2,
|
||||
TargetID: 3,
|
||||
RelationType: models.RelationFixes,
|
||||
Confidence: 0.9,
|
||||
DetectionSource: models.DetectionSourceTemporalProximity,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
},
|
||||
}
|
||||
|
||||
err := relationStore.StoreRelations(ctx, relations)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get relations for observation 2 (involved in both)
|
||||
result, err := relationStore.GetRelationsByObservationID(ctx, 2)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
}
|
||||
|
||||
func TestRelationStore_GetOutgoingAndIncomingRelations(t *testing.T) {
|
||||
relationStore, _, cleanup := testRelationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
relations := []*models.ObservationRelation{
|
||||
{
|
||||
SourceID: 2,
|
||||
TargetID: 1,
|
||||
RelationType: models.RelationCauses,
|
||||
Confidence: 0.8,
|
||||
DetectionSource: models.DetectionSourceFileOverlap,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
},
|
||||
{
|
||||
SourceID: 3,
|
||||
TargetID: 2,
|
||||
RelationType: models.RelationFixes,
|
||||
Confidence: 0.9,
|
||||
DetectionSource: models.DetectionSourceTemporalProximity,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
},
|
||||
}
|
||||
|
||||
err := relationStore.StoreRelations(ctx, relations)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Observation 2 has 1 outgoing (to 1) and 1 incoming (from 3)
|
||||
outgoing, err := relationStore.GetOutgoingRelations(ctx, 2)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, outgoing, 1)
|
||||
assert.Equal(t, int64(1), outgoing[0].TargetID)
|
||||
|
||||
incoming, err := relationStore.GetIncomingRelations(ctx, 2)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, incoming, 1)
|
||||
assert.Equal(t, int64(3), incoming[0].SourceID)
|
||||
}
|
||||
|
||||
func TestRelationStore_GetRelationCount(t *testing.T) {
|
||||
relationStore, _, cleanup := testRelationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
relations := []*models.ObservationRelation{
|
||||
{
|
||||
SourceID: 1,
|
||||
TargetID: 2,
|
||||
RelationType: models.RelationCauses,
|
||||
Confidence: 0.8,
|
||||
DetectionSource: models.DetectionSourceFileOverlap,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
},
|
||||
{
|
||||
SourceID: 2,
|
||||
TargetID: 3,
|
||||
RelationType: models.RelationFixes,
|
||||
Confidence: 0.9,
|
||||
DetectionSource: models.DetectionSourceTemporalProximity,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
},
|
||||
}
|
||||
|
||||
err := relationStore.StoreRelations(ctx, relations)
|
||||
require.NoError(t, err)
|
||||
|
||||
count, err := relationStore.GetRelationCount(ctx, 2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
|
||||
count, err = relationStore.GetRelationCount(ctx, 1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
}
|
||||
|
||||
func TestRelationStore_DeleteRelationsByObservationID(t *testing.T) {
|
||||
relationStore, _, cleanup := testRelationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
relations := []*models.ObservationRelation{
|
||||
{
|
||||
SourceID: 1,
|
||||
TargetID: 2,
|
||||
RelationType: models.RelationCauses,
|
||||
Confidence: 0.8,
|
||||
DetectionSource: models.DetectionSourceFileOverlap,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
},
|
||||
{
|
||||
SourceID: 2,
|
||||
TargetID: 3,
|
||||
RelationType: models.RelationFixes,
|
||||
Confidence: 0.9,
|
||||
DetectionSource: models.DetectionSourceTemporalProximity,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
},
|
||||
{
|
||||
SourceID: 4,
|
||||
TargetID: 5,
|
||||
RelationType: models.RelationRelatesTo,
|
||||
Confidence: 0.7,
|
||||
DetectionSource: models.DetectionSourceConceptOverlap,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: now.UnixMilli(),
|
||||
},
|
||||
}
|
||||
|
||||
err := relationStore.StoreRelations(ctx, relations)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete relations involving observation 2
|
||||
err = relationStore.DeleteRelationsByObservationID(ctx, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify only 1 relation remains (4->5)
|
||||
total, err := relationStore.GetTotalRelationCount(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, total)
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// UpdateObservationFeedback updates the user feedback for an observation.
|
||||
// Feedback values: -1 (thumbs down), 0 (neutral), 1 (thumbs up).
|
||||
func (s *ObservationStore) UpdateObservationFeedback(ctx context.Context, id int64, feedback int) error {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
result := s.db.WithContext(ctx).
|
||||
Model(&Observation{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"user_feedback": feedback,
|
||||
"score_updated_at_epoch": now,
|
||||
})
|
||||
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// IncrementRetrievalCount increments the retrieval counter for the given observation IDs.
|
||||
// This is called when observations are returned in search results.
|
||||
func (s *ObservationStore) IncrementRetrievalCount(ctx context.Context, ids []int64) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// Use raw SQL for increment expression
|
||||
result := s.db.WithContext(ctx).
|
||||
Exec("UPDATE observations SET retrieval_count = COALESCE(retrieval_count, 0) + 1, last_retrieved_at_epoch = ? WHERE id IN ?",
|
||||
now, ids)
|
||||
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// UpdateImportanceScore updates the importance score for a single observation.
|
||||
func (s *ObservationStore) UpdateImportanceScore(ctx context.Context, id int64, score float64) error {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
result := s.db.WithContext(ctx).
|
||||
Model(&Observation{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"importance_score": score,
|
||||
"score_updated_at_epoch": now,
|
||||
})
|
||||
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// UpdateImportanceScores bulk updates importance scores for multiple observations.
|
||||
// Uses a single SQL statement with CASE/WHEN for efficient batch updates.
|
||||
func (s *ObservationStore) UpdateImportanceScores(ctx context.Context, scores map[int64]float64) error {
|
||||
if len(scores) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// For small batches, use simple individual updates
|
||||
if len(scores) <= 5 {
|
||||
return s.updateScoresIndividually(ctx, scores)
|
||||
}
|
||||
|
||||
// For larger batches, use CASE/WHEN SQL for single-query update
|
||||
return s.updateScoresBatch(ctx, scores)
|
||||
}
|
||||
|
||||
// updateScoresIndividually updates scores one at a time (efficient for small batches).
|
||||
func (s *ObservationStore) updateScoresIndividually(ctx context.Context, scores map[int64]float64) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
for id, score := range scores {
|
||||
err := tx.Model(&Observation{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"importance_score": score,
|
||||
"score_updated_at_epoch": now,
|
||||
}).Error
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// updateScoresBatch updates multiple scores in a single SQL statement using CASE/WHEN.
|
||||
// This is much more efficient for large batches (O(1) queries instead of O(n)).
|
||||
func (s *ObservationStore) updateScoresBatch(ctx context.Context, scores map[int64]float64) error {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// Build CASE/WHEN clause for importance_score
|
||||
// UPDATE observations SET
|
||||
// importance_score = CASE id WHEN 1 THEN 0.5 WHEN 2 THEN 0.8 ... END,
|
||||
// score_updated_at_epoch = ?
|
||||
// WHERE id IN (1, 2, ...)
|
||||
|
||||
ids := make([]int64, 0, len(scores))
|
||||
caseBuilder := strings.Builder{}
|
||||
caseBuilder.WriteString("CASE id ")
|
||||
|
||||
for id, score := range scores {
|
||||
ids = append(ids, id)
|
||||
caseBuilder.WriteString(fmt.Sprintf("WHEN %d THEN %f ", id, score))
|
||||
}
|
||||
caseBuilder.WriteString("END")
|
||||
|
||||
// Use raw SQL for the batch update
|
||||
sql := fmt.Sprintf(
|
||||
"UPDATE observations SET importance_score = %s, score_updated_at_epoch = ? WHERE id IN ?",
|
||||
caseBuilder.String(),
|
||||
)
|
||||
|
||||
return s.db.WithContext(ctx).Exec(sql, now, ids).Error
|
||||
}
|
||||
|
||||
// GetObservationsNeedingScoreUpdate returns observations that need their importance score recalculated.
|
||||
// Returns observations where score_updated_at_epoch is NULL or older than the threshold.
|
||||
func (s *ObservationStore) GetObservationsNeedingScoreUpdate(ctx context.Context, threshold time.Duration, limit int) ([]*models.Observation, error) {
|
||||
cutoff := time.Now().Add(-threshold).UnixMilli()
|
||||
|
||||
var observations []Observation
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("score_updated_at_epoch IS NULL OR score_updated_at_epoch < ?", cutoff).
|
||||
Order("created_at_epoch DESC").
|
||||
Limit(limit).
|
||||
Find(&observations).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelObservations(observations), nil
|
||||
}
|
||||
|
||||
// GetConceptWeights returns all concept weights from the database.
|
||||
func (s *ObservationStore) GetConceptWeights(ctx context.Context) (map[string]float64, error) {
|
||||
var weights []struct {
|
||||
Concept string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Table("concept_weights").
|
||||
Select("concept, weight").
|
||||
Scan(&weights).Error
|
||||
|
||||
if err != nil {
|
||||
return models.DefaultConceptWeights, nil
|
||||
}
|
||||
|
||||
if len(weights) == 0 {
|
||||
return models.DefaultConceptWeights, nil
|
||||
}
|
||||
|
||||
result := make(map[string]float64, len(weights))
|
||||
for _, w := range weights {
|
||||
result[w.Concept] = w.Weight
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SetConceptWeights stores concept weights in the database using UPSERT.
|
||||
func (s *ObservationStore) SetConceptWeights(ctx context.Context, weights map[string]float64) error {
|
||||
if len(weights) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
for concept, weight := range weights {
|
||||
// UPSERT using raw SQL since GORM's ON CONFLICT is complex for this case
|
||||
err := tx.Exec(`
|
||||
INSERT INTO concept_weights (concept, weight, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(concept) DO UPDATE SET weight = excluded.weight, updated_at = excluded.updated_at
|
||||
`, concept, weight).Error
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateConceptWeight updates a single concept weight in the database using UPSERT.
|
||||
func (s *ObservationStore) UpdateConceptWeight(ctx context.Context, concept string, weight float64) error {
|
||||
return s.db.WithContext(ctx).Exec(`
|
||||
INSERT INTO concept_weights (concept, weight, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(concept) DO UPDATE SET weight = excluded.weight, updated_at = excluded.updated_at
|
||||
`, concept, weight).Error
|
||||
}
|
||||
|
||||
// FeedbackStats contains statistics about observation feedback and scoring.
|
||||
type FeedbackStats struct {
|
||||
Total int `json:"total"`
|
||||
Positive int `json:"positive"`
|
||||
Negative int `json:"negative"`
|
||||
Neutral int `json:"neutral"`
|
||||
AvgScore float64 `json:"avg_score"`
|
||||
AvgRetrieval float64 `json:"avg_retrieval"`
|
||||
}
|
||||
|
||||
// GetObservationFeedbackStats returns statistics about user feedback.
|
||||
func (s *ObservationStore) GetObservationFeedbackStats(ctx context.Context, project string) (*FeedbackStats, error) {
|
||||
var stats FeedbackStats
|
||||
|
||||
query := s.db.WithContext(ctx).
|
||||
Model(&Observation{}).
|
||||
Select(`
|
||||
COUNT(*) as total,
|
||||
COALESCE(SUM(CASE WHEN user_feedback = 1 THEN 1 ELSE 0 END), 0) as positive,
|
||||
COALESCE(SUM(CASE WHEN user_feedback = -1 THEN 1 ELSE 0 END), 0) as negative,
|
||||
COALESCE(SUM(CASE WHEN user_feedback = 0 THEN 1 ELSE 0 END), 0) as neutral,
|
||||
COALESCE(AVG(COALESCE(importance_score, 1.0)), 0) as avg_score,
|
||||
COALESCE(AVG(COALESCE(retrieval_count, 0)), 0) as avg_retrieval
|
||||
`)
|
||||
|
||||
if project != "" {
|
||||
query = query.Where("project = ? OR scope = 'global'", project)
|
||||
}
|
||||
|
||||
err := query.Scan(&stats).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
// GetTopScoringObservations returns the highest-scoring observations.
|
||||
func (s *ObservationStore) GetTopScoringObservations(ctx context.Context, project string, limit int) ([]*models.Observation, error) {
|
||||
var observations []Observation
|
||||
|
||||
query := s.db.WithContext(ctx).
|
||||
Order("COALESCE(importance_score, 1.0) DESC, created_at_epoch DESC").
|
||||
Limit(limit)
|
||||
|
||||
if project != "" {
|
||||
query = query.Where("project = ? OR scope = 'global'", project)
|
||||
}
|
||||
|
||||
err := query.Find(&observations).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelObservations(observations), nil
|
||||
}
|
||||
|
||||
// GetMostRetrievedObservations returns the most frequently retrieved observations.
|
||||
func (s *ObservationStore) GetMostRetrievedObservations(ctx context.Context, project string, limit int) ([]*models.Observation, error) {
|
||||
var observations []Observation
|
||||
|
||||
query := s.db.WithContext(ctx).
|
||||
Where("retrieval_count > 0").
|
||||
Order("retrieval_count DESC, created_at_epoch DESC").
|
||||
Limit(limit)
|
||||
|
||||
if project != "" {
|
||||
query = query.Where("project = ? OR scope = 'global'", project)
|
||||
}
|
||||
|
||||
err := query.Find(&observations).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelObservations(observations), nil
|
||||
}
|
||||
|
||||
// ResetObservationScores resets all observation scores to their default values.
|
||||
// This is useful for testing or when changing the scoring algorithm.
|
||||
func (s *ObservationStore) ResetObservationScores(ctx context.Context) error {
|
||||
// Use Where("1 = 1") to explicitly allow bulk update of all rows
|
||||
result := s.db.WithContext(ctx).
|
||||
Model(&Observation{}).
|
||||
Where("1 = 1").
|
||||
Updates(map[string]interface{}{
|
||||
"importance_score": 1.0,
|
||||
"score_updated_at_epoch": nil,
|
||||
})
|
||||
|
||||
return result.Error
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
//go:build fts5
|
||||
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
func TestObservationStore_UpdateImportanceScore(t *testing.T) {
|
||||
obsStore, store, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create observation
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
obs := &models.ParsedObservation{Type: models.ObsTypeDiscovery, Title: "Test"}
|
||||
obsID, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs, int(sessionID), 1)
|
||||
|
||||
// Update score
|
||||
err := obsStore.UpdateImportanceScore(ctx, obsID, 5.0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify
|
||||
var dbObs Observation
|
||||
store.DB.First(&dbObs, obsID)
|
||||
assert.Equal(t, 5.0, dbObs.ImportanceScore)
|
||||
}
|
||||
|
||||
func TestObservationStore_IncrementRetrievalCount(t *testing.T) {
|
||||
obsStore, store, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
obs := &models.ParsedObservation{Type: models.ObsTypeDiscovery, Title: "Test"}
|
||||
obsID, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs, int(sessionID), 1)
|
||||
|
||||
err := obsStore.IncrementRetrievalCount(ctx, []int64{obsID})
|
||||
require.NoError(t, err)
|
||||
|
||||
var dbObs Observation
|
||||
store.DB.First(&dbObs, obsID)
|
||||
assert.Equal(t, 1, dbObs.RetrievalCount)
|
||||
}
|
||||
|
||||
func TestObservationStore_IncrementRetrievalCount_Multiple(t *testing.T) {
|
||||
obsStore, store, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
|
||||
// Create 3 observations
|
||||
ids := make([]int64, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
obs := &models.ParsedObservation{Type: models.ObsTypeDiscovery, Title: "Test"}
|
||||
obsID, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs, int(sessionID), 1)
|
||||
ids[i] = obsID
|
||||
}
|
||||
|
||||
// Increment all
|
||||
err := obsStore.IncrementRetrievalCount(ctx, ids)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify all were incremented
|
||||
for _, id := range ids {
|
||||
var dbObs Observation
|
||||
store.DB.First(&dbObs, id)
|
||||
assert.Equal(t, 1, dbObs.RetrievalCount)
|
||||
}
|
||||
|
||||
// Increment again
|
||||
err = obsStore.IncrementRetrievalCount(ctx, ids)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify all are now 2
|
||||
for _, id := range ids {
|
||||
var dbObs Observation
|
||||
store.DB.First(&dbObs, id)
|
||||
assert.Equal(t, 2, dbObs.RetrievalCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObservationStore_UpdateImportanceScores_Bulk(t *testing.T) {
|
||||
obsStore, store, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
|
||||
// Create 3 observations
|
||||
ids := make([]int64, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
obs := &models.ParsedObservation{Type: models.ObsTypeDiscovery, Title: "Test"}
|
||||
obsID, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs, int(sessionID), 1)
|
||||
ids[i] = obsID
|
||||
}
|
||||
|
||||
// Bulk update scores
|
||||
scores := map[int64]float64{
|
||||
ids[0]: 2.5,
|
||||
ids[1]: 3.7,
|
||||
ids[2]: 1.2,
|
||||
}
|
||||
|
||||
err := obsStore.UpdateImportanceScores(ctx, scores)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify scores
|
||||
for id, expectedScore := range scores {
|
||||
var dbObs Observation
|
||||
store.DB.First(&dbObs, id)
|
||||
assert.Equal(t, expectedScore, dbObs.ImportanceScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObservationStore_UpdateObservationFeedback(t *testing.T) {
|
||||
obsStore, store, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
obs := &models.ParsedObservation{Type: models.ObsTypeDiscovery, Title: "Test"}
|
||||
obsID, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs, int(sessionID), 1)
|
||||
|
||||
// Set thumbs up
|
||||
err := obsStore.UpdateObservationFeedback(ctx, obsID, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
var dbObs Observation
|
||||
store.DB.First(&dbObs, obsID)
|
||||
assert.Equal(t, 1, dbObs.UserFeedback)
|
||||
|
||||
// Set thumbs down
|
||||
err = obsStore.UpdateObservationFeedback(ctx, obsID, -1)
|
||||
require.NoError(t, err)
|
||||
|
||||
store.DB.First(&dbObs, obsID)
|
||||
assert.Equal(t, -1, dbObs.UserFeedback)
|
||||
}
|
||||
|
||||
func TestObservationStore_GetObservationFeedbackStats(t *testing.T) {
|
||||
obsStore, store, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
|
||||
// Create observations with different feedback
|
||||
obs1 := &models.ParsedObservation{Type: models.ObsTypeDiscovery, Title: "Test1"}
|
||||
obsID1, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs1, int(sessionID), 1)
|
||||
obsStore.UpdateObservationFeedback(ctx, obsID1, 1) // thumbs up
|
||||
obsStore.UpdateImportanceScore(ctx, obsID1, 3.0)
|
||||
|
||||
obs2 := &models.ParsedObservation{Type: models.ObsTypeBugfix, Title: "Test2"}
|
||||
obsID2, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs2, int(sessionID), 2)
|
||||
obsStore.UpdateObservationFeedback(ctx, obsID2, -1) // thumbs down
|
||||
obsStore.UpdateImportanceScore(ctx, obsID2, 2.0)
|
||||
|
||||
obs3 := &models.ParsedObservation{Type: models.ObsTypeFeature, Title: "Test3"}
|
||||
obsID3, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs3, int(sessionID), 3)
|
||||
// neutral (0)
|
||||
obsStore.UpdateImportanceScore(ctx, obsID3, 1.5)
|
||||
obsStore.IncrementRetrievalCount(ctx, []int64{obsID1, obsID2, obsID3})
|
||||
|
||||
// Get stats
|
||||
stats, err := obsStore.GetObservationFeedbackStats(ctx, "test-project")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 3, stats.Total)
|
||||
assert.Equal(t, 1, stats.Positive)
|
||||
assert.Equal(t, 1, stats.Negative)
|
||||
assert.Equal(t, 1, stats.Neutral)
|
||||
assert.InDelta(t, 2.166, stats.AvgScore, 0.01) // (3.0 + 2.0 + 1.5) / 3
|
||||
assert.InDelta(t, 1.0, stats.AvgRetrieval, 0.01)
|
||||
}
|
||||
|
||||
func TestObservationStore_GetTopScoringObservations(t *testing.T) {
|
||||
obsStore, store, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
|
||||
// Create observations with different scores
|
||||
obs1 := &models.ParsedObservation{Type: models.ObsTypeDiscovery, Title: "High"}
|
||||
obsID1, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs1, int(sessionID), 1)
|
||||
obsStore.UpdateImportanceScore(ctx, obsID1, 5.0)
|
||||
|
||||
obs2 := &models.ParsedObservation{Type: models.ObsTypeBugfix, Title: "Low"}
|
||||
obsID2, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs2, int(sessionID), 2)
|
||||
obsStore.UpdateImportanceScore(ctx, obsID2, 1.0)
|
||||
|
||||
obs3 := &models.ParsedObservation{Type: models.ObsTypeFeature, Title: "Medium"}
|
||||
obsID3, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs3, int(sessionID), 3)
|
||||
obsStore.UpdateImportanceScore(ctx, obsID3, 3.0)
|
||||
|
||||
// Get top 2
|
||||
topObs, err := obsStore.GetTopScoringObservations(ctx, "test-project", 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, topObs, 2)
|
||||
assert.True(t, topObs[0].Title.Valid)
|
||||
assert.Equal(t, "High", topObs[0].Title.String)
|
||||
assert.Equal(t, 5.0, topObs[0].ImportanceScore)
|
||||
assert.True(t, topObs[1].Title.Valid)
|
||||
assert.Equal(t, "Medium", topObs[1].Title.String)
|
||||
assert.Equal(t, 3.0, topObs[1].ImportanceScore)
|
||||
}
|
||||
|
||||
func TestObservationStore_GetMostRetrievedObservations(t *testing.T) {
|
||||
obsStore, store, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
|
||||
// Create observations
|
||||
obs1 := &models.ParsedObservation{Type: models.ObsTypeDiscovery, Title: "Popular"}
|
||||
obsID1, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs1, int(sessionID), 1)
|
||||
|
||||
obs2 := &models.ParsedObservation{Type: models.ObsTypeBugfix, Title: "Unpopular"}
|
||||
obsID2, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs2, int(sessionID), 2)
|
||||
|
||||
// Increment retrieval counts - each call increments by 1
|
||||
obsStore.IncrementRetrievalCount(ctx, []int64{obsID1}) // increment by 1
|
||||
obsStore.IncrementRetrievalCount(ctx, []int64{obsID1}) // increment by 1 again (total: 2)
|
||||
obsStore.IncrementRetrievalCount(ctx, []int64{obsID1}) // increment by 1 again (total: 3)
|
||||
obsStore.IncrementRetrievalCount(ctx, []int64{obsID2}) // increment by 1 (total: 1)
|
||||
|
||||
// Get most retrieved - should return obsID1 (Popular) with count 3
|
||||
topObs, err := obsStore.GetMostRetrievedObservations(ctx, "test-project", 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, topObs, 2)
|
||||
assert.True(t, topObs[0].Title.Valid)
|
||||
assert.Equal(t, "Popular", topObs[0].Title.String)
|
||||
assert.Equal(t, 3, topObs[0].RetrievalCount)
|
||||
assert.True(t, topObs[1].Title.Valid)
|
||||
assert.Equal(t, "Unpopular", topObs[1].Title.String)
|
||||
assert.Equal(t, 1, topObs[1].RetrievalCount)
|
||||
}
|
||||
|
||||
func TestObservationStore_SetConceptWeights(t *testing.T) {
|
||||
obsStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Set weights
|
||||
weights := map[string]float64{
|
||||
"security": 2.0,
|
||||
"performance": 1.5,
|
||||
"best-practice": 1.8,
|
||||
}
|
||||
|
||||
err := obsStore.SetConceptWeights(ctx, weights)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get weights back
|
||||
retrieved, err := obsStore.GetConceptWeights(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 2.0, retrieved["security"])
|
||||
assert.Equal(t, 1.5, retrieved["performance"])
|
||||
assert.Equal(t, 1.8, retrieved["best-practice"])
|
||||
|
||||
// Update weights (UPSERT)
|
||||
weights["security"] = 2.5
|
||||
weights["scalability"] = 1.2
|
||||
|
||||
err = obsStore.SetConceptWeights(ctx, weights)
|
||||
require.NoError(t, err)
|
||||
|
||||
retrieved, err = obsStore.GetConceptWeights(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 2.5, retrieved["security"]) // updated
|
||||
assert.Equal(t, 1.5, retrieved["performance"]) // unchanged
|
||||
assert.Equal(t, 1.2, retrieved["scalability"]) // new
|
||||
assert.Equal(t, 1.8, retrieved["best-practice"]) // unchanged
|
||||
}
|
||||
|
||||
func TestObservationStore_GetObservationsNeedingScoreUpdate(t *testing.T) {
|
||||
obsStore, store, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
|
||||
// Create observation with no score update
|
||||
obs1 := &models.ParsedObservation{Type: models.ObsTypeDiscovery, Title: "Needs Update"}
|
||||
obsID1, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs1, int(sessionID), 1)
|
||||
|
||||
// Create observation with recent score update
|
||||
obs2 := &models.ParsedObservation{Type: models.ObsTypeBugfix, Title: "Recently Updated"}
|
||||
obsID2, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs2, int(sessionID), 2)
|
||||
obsStore.UpdateImportanceScore(ctx, obsID2, 2.0)
|
||||
|
||||
// Get observations needing update (within 1 hour threshold)
|
||||
needsUpdate, err := obsStore.GetObservationsNeedingScoreUpdate(ctx, 1*time.Hour, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Only obs1 should need update (obs2 was just updated)
|
||||
assert.Len(t, needsUpdate, 1)
|
||||
assert.Equal(t, obsID1, needsUpdate[0].ID)
|
||||
assert.True(t, needsUpdate[0].Title.Valid)
|
||||
assert.Equal(t, "Needs Update", needsUpdate[0].Title.String)
|
||||
}
|
||||
|
||||
func TestObservationStore_ResetObservationScores(t *testing.T) {
|
||||
obsStore, store, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
sessionStore := NewSessionStore(store)
|
||||
sessionID, _ := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
|
||||
// Create observations with custom scores
|
||||
obs1 := &models.ParsedObservation{Type: models.ObsTypeDiscovery, Title: "Test1"}
|
||||
obsID1, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs1, int(sessionID), 1)
|
||||
obsStore.UpdateImportanceScore(ctx, obsID1, 5.0)
|
||||
|
||||
obs2 := &models.ParsedObservation{Type: models.ObsTypeBugfix, Title: "Test2"}
|
||||
obsID2, _, _ := obsStore.StoreObservation(ctx, "claude-1", "test-project", obs2, int(sessionID), 2)
|
||||
obsStore.UpdateImportanceScore(ctx, obsID2, 3.0)
|
||||
|
||||
// Reset all scores
|
||||
err := obsStore.ResetObservationScores(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify all scores are 1.0
|
||||
var dbObs1, dbObs2 Observation
|
||||
store.DB.First(&dbObs1, obsID1)
|
||||
store.DB.First(&dbObs2, obsID2)
|
||||
|
||||
assert.Equal(t, 1.0, dbObs1.ImportanceScore)
|
||||
assert.Equal(t, 1.0, dbObs2.ImportanceScore)
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// SessionStore provides session-related database operations using GORM.
|
||||
type SessionStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSessionStore creates a new session store.
|
||||
func NewSessionStore(store *Store) *SessionStore {
|
||||
return &SessionStore{db: store.DB}
|
||||
}
|
||||
|
||||
// CreateSDKSession creates a new SDK session (idempotent - returns existing ID if exists).
|
||||
// This is the KEY to how claude-mnemonic stays unified across hooks.
|
||||
func (s *SessionStore) CreateSDKSession(ctx context.Context, claudeSessionID, project, userPrompt string) (int64, error) {
|
||||
now := time.Now()
|
||||
|
||||
session := &SDKSession{
|
||||
ClaudeSessionID: claudeSessionID,
|
||||
SDKSessionID: func() sql.NullString {
|
||||
return sql.NullString{String: claudeSessionID, Valid: true}
|
||||
}(),
|
||||
Project: project,
|
||||
UserPrompt: func() sql.NullString {
|
||||
if userPrompt != "" {
|
||||
return sql.NullString{String: userPrompt, Valid: true}
|
||||
}
|
||||
return sql.NullString{Valid: false}
|
||||
}(),
|
||||
Status: "active",
|
||||
StartedAt: now.Format(time.RFC3339),
|
||||
StartedAtEpoch: now.UnixMilli(),
|
||||
}
|
||||
|
||||
// CRITICAL: INSERT OR IGNORE makes this idempotent
|
||||
// Use OnConflict with DoNothing to achieve INSERT OR IGNORE behavior
|
||||
result := s.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "claude_session_id"}},
|
||||
DoNothing: true,
|
||||
}).
|
||||
Create(session)
|
||||
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
|
||||
// Check if insert happened
|
||||
if result.RowsAffected == 0 {
|
||||
// Session exists - UPDATE project and user_prompt if we have non-empty values
|
||||
if project != "" {
|
||||
updates := map[string]interface{}{
|
||||
"project": project,
|
||||
}
|
||||
if userPrompt != "" {
|
||||
updates["user_prompt"] = userPrompt
|
||||
}
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&SDKSession{}).
|
||||
Where("claude_session_id = ?", claudeSessionID).
|
||||
Updates(updates).Error; err != nil {
|
||||
return 0, fmt.Errorf("failed to update session: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch existing session
|
||||
var existing SDKSession
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("claude_session_id = ?", claudeSessionID).
|
||||
First(&existing).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return existing.ID, nil
|
||||
}
|
||||
|
||||
return session.ID, nil
|
||||
}
|
||||
|
||||
// GetSessionByID retrieves a session by its database ID.
|
||||
func (s *SessionStore) GetSessionByID(ctx context.Context, id int64) (*models.SDKSession, error) {
|
||||
var sess SDKSession
|
||||
err := s.db.WithContext(ctx).First(&sess, id).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toModelSDKSession(&sess), nil
|
||||
}
|
||||
|
||||
// FindAnySDKSession finds any session by Claude session ID (any status).
|
||||
func (s *SessionStore) FindAnySDKSession(ctx context.Context, claudeSessionID string) (*models.SDKSession, error) {
|
||||
var sess SDKSession
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("claude_session_id = ?", claudeSessionID).
|
||||
First(&sess).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toModelSDKSession(&sess), nil
|
||||
}
|
||||
|
||||
// IncrementPromptCounter increments the prompt counter and returns the new value.
|
||||
// Uses a single SQL query with RETURNING clause for optimal performance.
|
||||
func (s *SessionStore) IncrementPromptCounter(ctx context.Context, id int64) (int, error) {
|
||||
// Use raw SQL with RETURNING to get updated value in single query
|
||||
// SQLite supports RETURNING since version 3.35.0 (2021-03-12)
|
||||
var newCounter int
|
||||
err := s.db.WithContext(ctx).Raw(`
|
||||
UPDATE sdk_sessions
|
||||
SET prompt_counter = COALESCE(prompt_counter, 0) + 1
|
||||
WHERE id = ?
|
||||
RETURNING prompt_counter
|
||||
`, id).Scan(&newCounter).Error
|
||||
|
||||
if err != nil {
|
||||
// Fallback for older SQLite versions without RETURNING support
|
||||
if err.Error() == "near \"RETURNING\": syntax error" || newCounter == 0 {
|
||||
// Atomic increment
|
||||
updateErr := s.db.WithContext(ctx).
|
||||
Model(&SDKSession{}).
|
||||
Where("id = ?", id).
|
||||
Update("prompt_counter", gorm.Expr("COALESCE(prompt_counter, 0) + 1")).Error
|
||||
if updateErr != nil {
|
||||
return 0, updateErr
|
||||
}
|
||||
|
||||
// Fetch updated value
|
||||
var sess SDKSession
|
||||
fetchErr := s.db.WithContext(ctx).
|
||||
Select("prompt_counter").
|
||||
First(&sess, id).Error
|
||||
if fetchErr != nil {
|
||||
return 0, fetchErr
|
||||
}
|
||||
return sess.PromptCounter, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return newCounter, nil
|
||||
}
|
||||
|
||||
// GetPromptCounter returns the current prompt counter for a session.
|
||||
func (s *SessionStore) GetPromptCounter(ctx context.Context, id int64) (int, error) {
|
||||
var sess SDKSession
|
||||
err := s.db.WithContext(ctx).
|
||||
Select("prompt_counter").
|
||||
First(&sess, id).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return sess.PromptCounter, nil
|
||||
}
|
||||
|
||||
// GetSessionsToday returns the count of sessions started today.
|
||||
func (s *SessionStore) GetSessionsToday(ctx context.Context) (int, error) {
|
||||
// Get start of today in milliseconds
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
startEpoch := startOfDay.UnixMilli()
|
||||
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).
|
||||
Model(&SDKSession{}).
|
||||
Where("started_at_epoch >= ?", startEpoch).
|
||||
Count(&count).Error
|
||||
|
||||
return int(count), err
|
||||
}
|
||||
|
||||
// GetAllProjects returns all unique project names.
|
||||
func (s *SessionStore) GetAllProjects(ctx context.Context) ([]string, error) {
|
||||
var projects []string
|
||||
err := s.db.WithContext(ctx).
|
||||
Model(&SDKSession{}).
|
||||
Distinct("project").
|
||||
Where("project IS NOT NULL AND project != ''").
|
||||
Order("project ASC").
|
||||
Pluck("project", &projects).Error
|
||||
|
||||
return projects, err
|
||||
}
|
||||
|
||||
// toModelSDKSession converts a GORM SDKSession to pkg/models.SDKSession.
|
||||
func toModelSDKSession(sess *SDKSession) *models.SDKSession {
|
||||
return &models.SDKSession{
|
||||
ID: sess.ID,
|
||||
ClaudeSessionID: sess.ClaudeSessionID,
|
||||
SDKSessionID: sess.SDKSessionID,
|
||||
Project: sess.Project,
|
||||
UserPrompt: sess.UserPrompt,
|
||||
WorkerPort: sess.WorkerPort,
|
||||
PromptCounter: int64(sess.PromptCounter),
|
||||
Status: models.SessionStatus(sess.Status),
|
||||
StartedAt: sess.StartedAt,
|
||||
StartedAtEpoch: sess.StartedAtEpoch,
|
||||
CompletedAt: sess.CompletedAt,
|
||||
CompletedAtEpoch: sess.CompletedAtEpoch,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
//go:build !sqlite_omit_load_extension
|
||||
// +build !sqlite_omit_load_extension
|
||||
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
// This file ensures mattn/go-sqlite3 is built with FTS5 and other extensions enabled.
|
||||
// The build tag ensures extensions are not omitted.
|
||||
@@ -1,645 +0,0 @@
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
|
||||
sqlite3 "github.com/mattn/go-sqlite3" // SQLite driver with FTS5 support
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// driverName is the name of the custom mattn/go-sqlite3 driver registered with a
|
||||
// ConnectHook that applies ALL connection pragmas (correctness + best-effort) to EVERY
|
||||
// pooled connection at open time. The stock "sqlite3" driver has no hook, so pragmas set
|
||||
// via a single post-open Exec only reach one arbitrary pooled connection (issue #49, F6).
|
||||
const driverName = "sqlite3_mnemonic"
|
||||
|
||||
// registerDriverOnce guards driver registration so it runs exactly once per process.
|
||||
// database/sql panics with "sql: Register called twice" on a duplicate name, and NewStore
|
||||
// may be called multiple times (e.g. after a config-change reinitialization).
|
||||
var registerDriverOnce sync.Once
|
||||
|
||||
// correctnessPragmas MUST succeed on every connection: getting any of them wrong changes
|
||||
// transactional/locking semantics, not just performance. A failure here aborts the open.
|
||||
var correctnessPragmas = []string{
|
||||
"PRAGMA foreign_keys=ON",
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA synchronous=NORMAL",
|
||||
"PRAGMA busy_timeout=5000",
|
||||
"PRAGMA cache_size=-64000",
|
||||
}
|
||||
|
||||
// bestEffortPragmas are per-connection or database-wide optimizations. A failure is logged
|
||||
// and tolerated: the connection is still correct, just less tuned. (page_size only takes
|
||||
// effect on an empty database / next VACUUM, but applying it per-connection is harmless.)
|
||||
var bestEffortPragmas = []string{
|
||||
"PRAGMA temp_store=MEMORY", // Store temp tables in memory
|
||||
"PRAGMA mmap_size=268435456", // 256MB memory-mapped I/O
|
||||
"PRAGMA page_size=4096", // 4KB pages (optimal for most systems)
|
||||
"PRAGMA wal_autocheckpoint=1000", // Auto-checkpoint (PASSIVE) every 1000 WAL frames
|
||||
"PRAGMA journal_size_limit=8388608", // Backstop: cap -wal at 8MiB (issue #49)
|
||||
}
|
||||
|
||||
// connectHook applies all pragmas to a freshly opened connection. mattn/go-sqlite3 calls
|
||||
// it at the very end of Open, after DSN params and extensions, so it is authoritative.
|
||||
func connectHook(c *sqlite3.SQLiteConn) error {
|
||||
for _, pragma := range correctnessPragmas {
|
||||
if _, err := c.Exec(pragma, nil); err != nil {
|
||||
return fmt.Errorf("apply correctness pragma %q: %w", pragma, err)
|
||||
}
|
||||
}
|
||||
for _, pragma := range bestEffortPragmas {
|
||||
if _, err := c.Exec(pragma, nil); err != nil {
|
||||
log.Warn().Str("pragma", pragma).Err(err).Msg("Failed to set pragma (non-fatal)")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerDriver registers the custom driver once. sqlite_vec.Auto() registers the vec
|
||||
// extension globally via sqlite3_auto_extension, which applies to connections from any
|
||||
// sqlite3-based driver, so the new driver still gets vec + FTS5.
|
||||
func registerDriver() {
|
||||
registerDriverOnce.Do(func() {
|
||||
sql.Register(driverName, &sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(c *sqlite3.SQLiteConn) error {
|
||||
return connectHook(c)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Store represents the GORM database connection with sqlite-vec support.
|
||||
type Store struct {
|
||||
healthCacheTime time.Time
|
||||
DB *gorm.DB
|
||||
sqlDB *sql.DB
|
||||
metrics *PoolMetrics
|
||||
cachedHealth *HealthInfo
|
||||
path string
|
||||
healthCacheTTL time.Duration
|
||||
healthCacheMu sync.RWMutex
|
||||
}
|
||||
|
||||
// Config holds database configuration.
|
||||
type Config struct {
|
||||
Path string // Path to SQLite database file
|
||||
MaxConns int // Maximum number of open connections (default: 4)
|
||||
LogLevel logger.LogLevel // GORM log level (logger.Silent for production)
|
||||
}
|
||||
|
||||
// NewStore creates a new Store with WAL mode enabled and sqlite-vec registered.
|
||||
// CRITICAL: all connection pragmas (WAL, foreign_keys, busy_timeout, etc.) are applied to
|
||||
// EVERY pooled connection via a driver ConnectHook (see registerDriver), so the pool is
|
||||
// uniformly configured and connections may be recycled safely (issue #49, F6).
|
||||
func NewStore(cfg Config) (*Store, error) {
|
||||
// 1. Register sqlite-vec extension (must be done before opening database).
|
||||
// sqlite_vec.Auto() uses sqlite3_auto_extension, which is global to all sqlite3-based
|
||||
// drivers, so connections from our custom driver also get the vec virtual table.
|
||||
sqlite_vec.Auto()
|
||||
|
||||
// 2. Register the custom driver whose ConnectHook applies ALL pragmas to EVERY pooled
|
||||
// connection (issue #49, F6). Without this, pragmas set via a single post-open
|
||||
// sqlDB.Exec reach only one arbitrary pooled connection. The hook is authoritative.
|
||||
registerDriver()
|
||||
|
||||
// 3. Build a minimal DSN. _foreign_keys is kept as belt-and-suspenders (the hook sets
|
||||
// it too); all other pragmas are applied per-connection by the ConnectHook, so they no
|
||||
// longer need to live in the DSN.
|
||||
dsn := cfg.Path + "?_foreign_keys=ON"
|
||||
|
||||
// 4. Open raw database connection with the custom driver (FTS5 + per-connection pragmas).
|
||||
sqlDB, err := sql.Open(driverName, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
// 5. Wrap with GORM using existing connection
|
||||
db, err := gorm.Open(sqlite.Dialector{
|
||||
Conn: sqlDB,
|
||||
}, &gorm.Config{
|
||||
Logger: logger.Default.LogMode(cfg.LogLevel),
|
||||
// PrepareStmt enables prepared statement caching for performance
|
||||
PrepareStmt: true,
|
||||
// Disable default timestamp fields (we manage created_at manually)
|
||||
NowFunc: nil,
|
||||
})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close() // Explicitly ignore close error during cleanup
|
||||
return nil, fmt.Errorf("open gorm: %w", err)
|
||||
}
|
||||
|
||||
// 6. Configure connection pool.
|
||||
maxConns := cfg.MaxConns
|
||||
if maxConns <= 0 {
|
||||
maxConns = 4
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(maxConns)
|
||||
sqlDB.SetMaxIdleConns(maxConns)
|
||||
// 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)
|
||||
|
||||
// 7. Verify connection
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
store := &Store{
|
||||
DB: db,
|
||||
sqlDB: sqlDB,
|
||||
path: cfg.Path,
|
||||
metrics: NewPoolMetrics(100), // Track last 100 latency samples
|
||||
healthCacheTTL: 5 * time.Second, // Cache health checks for 5 seconds
|
||||
}
|
||||
|
||||
// 8. Run migrations. All pragmas (correctness + best-effort) are applied per-connection
|
||||
// by the ConnectHook at open time, so there is no post-open pragma loop here anymore:
|
||||
// such a loop only ever reached one arbitrary pooled connection (issue #49, F6).
|
||||
if err := runMigrations(db, sqlDB); err != nil {
|
||||
return nil, fmt.Errorf("run migrations: %w", err)
|
||||
}
|
||||
|
||||
// 9. Warm the connection pool
|
||||
store.WarmPool(maxConns)
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// WarmPool pre-creates connections to avoid cold start latency.
|
||||
func (s *Store) WarmPool(numConns int) {
|
||||
if numConns <= 0 {
|
||||
numConns = 4
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < numConns; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := s.sqlDB.Conn(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Execute a simple query to ensure the connection is fully initialized
|
||||
_ = conn.PingContext(ctx)
|
||||
// Return connection to pool (don't close it)
|
||||
_ = conn.Close()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
log.Debug().Int("connections", numConns).Msg("Connection pool warmed")
|
||||
}
|
||||
|
||||
// Close checkpoints the WAL (TRUNCATE) before closing the connection. Checkpointing on
|
||||
// shutdown prevents the WAL file from persisting in a large, dirty state across restarts
|
||||
// and config-change reinitializations, which otherwise leaves a multi-megabyte -wal file
|
||||
// on disk (issue #49). The checkpoint is best-effort: a failure is logged, not fatal.
|
||||
func (s *Store) Close() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := s.Checkpoint(ctx); err != nil {
|
||||
log.Warn().Err(err).Msg("WAL checkpoint on close failed (non-fatal)")
|
||||
}
|
||||
return s.sqlDB.Close()
|
||||
}
|
||||
|
||||
// Checkpoint runs a TRUNCATE WAL checkpoint: it flushes WAL frames into the main
|
||||
// database file and shrinks the -wal file back to zero. Unlike a PASSIVE checkpoint
|
||||
// (which never truncates the file and is all SQLite's auto-checkpoint ever performs), a
|
||||
// TRUNCATE checkpoint reclaims disk and is the mechanism that bounds WAL growth.
|
||||
// It waits up to the connection busy_timeout for the write lock and returns an error
|
||||
// rather than blocking indefinitely.
|
||||
func (s *Store) Checkpoint(ctx context.Context) error {
|
||||
if _, err := s.sqlDB.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||
return fmt.Errorf("wal checkpoint (truncate): %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WALSize returns the size in bytes of the SQLite WAL sidecar file (<db>-wal), or 0 if
|
||||
// it does not exist or cannot be stat'd. Used to decide when a checkpoint is worthwhile.
|
||||
func (s *Store) WALSize() int64 {
|
||||
if s.path == "" {
|
||||
return 0
|
||||
}
|
||||
info, err := os.Stat(s.path + "-wal")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
// CheckpointIfLarge performs a TRUNCATE checkpoint only when the WAL file has grown to or
|
||||
// beyond threshold bytes. Returns true if a checkpoint was performed. This keeps the
|
||||
// periodic checkpoint cheap: it does no work while the WAL is small.
|
||||
func (s *Store) CheckpointIfLarge(ctx context.Context, threshold int64) (bool, error) {
|
||||
if s.WALSize() < threshold {
|
||||
return false, nil
|
||||
}
|
||||
return true, s.Checkpoint(ctx)
|
||||
}
|
||||
|
||||
// Ping verifies the database connection is alive.
|
||||
func (s *Store) Ping() error {
|
||||
return s.sqlDB.Ping()
|
||||
}
|
||||
|
||||
// GetRawDB returns the underlying *sql.DB for operations GORM can't handle.
|
||||
// Use this for:
|
||||
// - FTS5 full-text search queries (MATCH operator)
|
||||
// - sqlite-vec vector operations
|
||||
// - Complex raw SQL queries
|
||||
func (s *Store) GetRawDB() *sql.DB {
|
||||
return s.sqlDB
|
||||
}
|
||||
|
||||
// GetDB returns the GORM DB instance for standard queries.
|
||||
func (s *Store) GetDB() *gorm.DB {
|
||||
return s.DB
|
||||
}
|
||||
|
||||
// Stats returns database connection pool statistics.
|
||||
func (s *Store) Stats() sql.DBStats {
|
||||
return s.sqlDB.Stats()
|
||||
}
|
||||
|
||||
// Optimize runs VACUUM and ANALYZE to optimize the database.
|
||||
// Should be called periodically (e.g., daily) during low activity.
|
||||
func (s *Store) Optimize(ctx context.Context) error {
|
||||
log.Info().Msg("Starting database optimization")
|
||||
start := time.Now()
|
||||
|
||||
// ANALYZE updates statistics for query optimizer
|
||||
if _, err := s.sqlDB.ExecContext(ctx, "ANALYZE"); err != nil {
|
||||
return fmt.Errorf("analyze: %w", err)
|
||||
}
|
||||
|
||||
// PRAGMA optimize runs optimization based on query statistics
|
||||
if _, err := s.sqlDB.ExecContext(ctx, "PRAGMA optimize"); err != nil {
|
||||
log.Warn().Err(err).Msg("PRAGMA optimize failed (non-fatal)")
|
||||
}
|
||||
|
||||
// TRUNCATE WAL checkpoint — reclaims the -wal file during low-activity optimization.
|
||||
// (PASSIVE never shrinks the file, so it cannot bound WAL growth — see issue #49.)
|
||||
if err := s.Checkpoint(ctx); err != nil {
|
||||
log.Warn().Err(err).Msg("WAL checkpoint failed (non-fatal)")
|
||||
}
|
||||
|
||||
log.Info().Dur("duration", time.Since(start)).Msg("Database optimization complete")
|
||||
return nil
|
||||
}
|
||||
|
||||
// HealthCheck performs a comprehensive health check with latency measurement.
|
||||
// Returns detailed health information including connection pool stats and query latency.
|
||||
// Results are cached for healthCacheTTL (default 5 seconds) to reduce database load
|
||||
// from frequent monitoring calls.
|
||||
func (s *Store) HealthCheck(ctx context.Context) *HealthInfo {
|
||||
// Fast path: check cache with read lock
|
||||
s.healthCacheMu.RLock()
|
||||
if s.cachedHealth != nil && time.Since(s.healthCacheTime) < s.healthCacheTTL {
|
||||
cached := s.cachedHealth
|
||||
s.healthCacheMu.RUnlock()
|
||||
return cached
|
||||
}
|
||||
s.healthCacheMu.RUnlock()
|
||||
|
||||
// Slow path: perform actual health check
|
||||
info := s.performHealthCheck(ctx)
|
||||
|
||||
// Cache the result
|
||||
s.healthCacheMu.Lock()
|
||||
s.cachedHealth = info
|
||||
s.healthCacheTime = time.Now()
|
||||
s.healthCacheMu.Unlock()
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// HealthCheckForce performs a health check bypassing the cache.
|
||||
// Use this when you need real-time health data (e.g., debugging, alerting).
|
||||
func (s *Store) HealthCheckForce(ctx context.Context) *HealthInfo {
|
||||
info := s.performHealthCheck(ctx)
|
||||
|
||||
// Update the cache with fresh data
|
||||
s.healthCacheMu.Lock()
|
||||
s.cachedHealth = info
|
||||
s.healthCacheTime = time.Now()
|
||||
s.healthCacheMu.Unlock()
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// performHealthCheck does the actual health check work.
|
||||
func (s *Store) performHealthCheck(ctx context.Context) *HealthInfo {
|
||||
info := &HealthInfo{
|
||||
Status: "healthy",
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
// Check pool stats
|
||||
stats := s.sqlDB.Stats()
|
||||
info.PoolStats = PoolStats{
|
||||
OpenConnections: stats.OpenConnections,
|
||||
InUse: stats.InUse,
|
||||
Idle: stats.Idle,
|
||||
WaitCount: stats.WaitCount,
|
||||
WaitDuration: stats.WaitDuration,
|
||||
MaxIdleClosed: stats.MaxIdleClosed,
|
||||
MaxLifetimeClosed: stats.MaxLifetimeClosed,
|
||||
}
|
||||
|
||||
// Record pool stats for metrics tracking
|
||||
if s.metrics != nil {
|
||||
s.metrics.RecordPoolStats(stats)
|
||||
}
|
||||
|
||||
// Measure query latency with a simple SELECT
|
||||
start := time.Now()
|
||||
var dummy int
|
||||
err := s.sqlDB.QueryRowContext(ctx, "SELECT 1").Scan(&dummy)
|
||||
info.QueryLatency = time.Since(start)
|
||||
|
||||
// Record latency for historical tracking
|
||||
if s.metrics != nil {
|
||||
s.metrics.RecordLatency(info.QueryLatency)
|
||||
info.HistoricalMetrics = s.metrics.GetMetricsSummary()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
info.Status = "unhealthy"
|
||||
info.Error = err.Error()
|
||||
return info
|
||||
}
|
||||
|
||||
// Check for connection saturation (degraded if pool is heavily used)
|
||||
if stats.InUse > 0 && float64(stats.InUse)/float64(stats.OpenConnections) > 0.8 {
|
||||
info.Status = "degraded"
|
||||
info.Warning = "Connection pool heavily utilized"
|
||||
}
|
||||
|
||||
// Check for wait contention
|
||||
if stats.WaitCount > 100 && stats.WaitDuration > 100*time.Millisecond {
|
||||
info.Status = "degraded"
|
||||
info.Warning = "Connection pool contention detected"
|
||||
}
|
||||
|
||||
// Check query latency (warn if > 10ms for simple query)
|
||||
if info.QueryLatency > 10*time.Millisecond {
|
||||
if info.Status == "healthy" {
|
||||
info.Status = "degraded"
|
||||
}
|
||||
info.Warning = fmt.Sprintf("Slow query latency: %v", info.QueryLatency)
|
||||
}
|
||||
|
||||
// Check historical latency trend (degraded if P95 is high)
|
||||
if s.metrics != nil && info.HistoricalMetrics.P95Latency > 50*time.Millisecond {
|
||||
if info.Status == "healthy" {
|
||||
info.Status = "degraded"
|
||||
}
|
||||
info.Warning = fmt.Sprintf("High P95 latency: %v", info.HistoricalMetrics.P95Latency)
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// HealthInfo contains database health check results.
|
||||
type HealthInfo struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Warning string `json:"warning,omitempty"`
|
||||
HistoricalMetrics MetricsSummary `json:"historical_metrics,omitempty"`
|
||||
PoolStats PoolStats `json:"pool_stats"`
|
||||
QueryLatency time.Duration `json:"query_latency_ns"`
|
||||
}
|
||||
|
||||
// PoolStats contains connection pool statistics.
|
||||
type PoolStats struct {
|
||||
OpenConnections int `json:"open_connections"`
|
||||
InUse int `json:"in_use"`
|
||||
Idle int `json:"idle"`
|
||||
WaitCount int64 `json:"wait_count"`
|
||||
WaitDuration time.Duration `json:"wait_duration_ns"`
|
||||
MaxIdleClosed int64 `json:"max_idle_closed"`
|
||||
MaxLifetimeClosed int64 `json:"max_lifetime_closed"`
|
||||
}
|
||||
|
||||
// QueryTimeout constants for different query types.
|
||||
const (
|
||||
// DefaultQueryTimeout is the default timeout for regular queries.
|
||||
DefaultQueryTimeout = 5 * time.Second
|
||||
// FastQueryTimeout is for queries that should be very fast (health checks, etc).
|
||||
FastQueryTimeout = 1 * time.Second
|
||||
// SlowQueryTimeout is for queries that may take longer (bulk operations, rebuilds).
|
||||
SlowQueryTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// PoolMetrics tracks historical connection pool metrics with a sliding window.
|
||||
type PoolMetrics struct {
|
||||
lastSampleTime time.Time
|
||||
latencySamples []time.Duration
|
||||
latencyIdx int
|
||||
latencyCount int
|
||||
totalQueries int64
|
||||
totalWaitTime time.Duration
|
||||
peakInUse int
|
||||
peakWaitCount int64
|
||||
windowSize int
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewPoolMetrics creates a new pool metrics collector with the given window size.
|
||||
func NewPoolMetrics(windowSize int) *PoolMetrics {
|
||||
if windowSize <= 0 {
|
||||
windowSize = 100 // Default: track last 100 samples
|
||||
}
|
||||
return &PoolMetrics{
|
||||
latencySamples: make([]time.Duration, windowSize),
|
||||
windowSize: windowSize,
|
||||
lastSampleTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// RecordLatency records a query latency sample.
|
||||
func (m *PoolMetrics) RecordLatency(latency time.Duration) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.latencySamples[m.latencyIdx] = latency
|
||||
m.latencyIdx = (m.latencyIdx + 1) % m.windowSize
|
||||
if m.latencyCount < m.windowSize {
|
||||
m.latencyCount++
|
||||
}
|
||||
m.totalQueries++
|
||||
m.lastSampleTime = time.Now()
|
||||
}
|
||||
|
||||
// RecordPoolStats records pool statistics for peak tracking.
|
||||
func (m *PoolMetrics) RecordPoolStats(stats sql.DBStats) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if stats.InUse > m.peakInUse {
|
||||
m.peakInUse = stats.InUse
|
||||
}
|
||||
if stats.WaitCount > m.peakWaitCount {
|
||||
m.peakWaitCount = stats.WaitCount
|
||||
}
|
||||
m.totalWaitTime += stats.WaitDuration
|
||||
}
|
||||
|
||||
// GetMetricsSummary returns a summary of collected metrics.
|
||||
func (m *PoolMetrics) GetMetricsSummary() MetricsSummary {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
summary := MetricsSummary{
|
||||
TotalQueries: m.totalQueries,
|
||||
SampleCount: m.latencyCount,
|
||||
PeakInUse: m.peakInUse,
|
||||
PeakWaitCount: m.peakWaitCount,
|
||||
TotalWaitTime: m.totalWaitTime,
|
||||
LastSampleTime: m.lastSampleTime,
|
||||
}
|
||||
|
||||
if m.latencyCount == 0 {
|
||||
return summary
|
||||
}
|
||||
|
||||
// Calculate latency statistics
|
||||
var total time.Duration
|
||||
var min, max time.Duration = m.latencySamples[0], m.latencySamples[0]
|
||||
|
||||
for i := 0; i < m.latencyCount; i++ {
|
||||
sample := m.latencySamples[i]
|
||||
total += sample
|
||||
if sample < min {
|
||||
min = sample
|
||||
}
|
||||
if sample > max {
|
||||
max = sample
|
||||
}
|
||||
}
|
||||
|
||||
summary.AvgLatency = total / time.Duration(m.latencyCount)
|
||||
summary.MinLatency = min
|
||||
summary.MaxLatency = max
|
||||
|
||||
// Calculate P95 latency (approximate using sorted samples)
|
||||
if m.latencyCount >= 20 {
|
||||
// Copy samples for sorting
|
||||
samples := make([]time.Duration, m.latencyCount)
|
||||
copy(samples, m.latencySamples[:m.latencyCount])
|
||||
// Use slices.Sort for O(n log n) instead of O(n²) insertion sort
|
||||
slices.Sort(samples)
|
||||
p95Idx := int(float64(len(samples)) * 0.95)
|
||||
summary.P95Latency = samples[p95Idx]
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
// MetricsSummary contains aggregated pool metrics.
|
||||
type MetricsSummary struct {
|
||||
LastSampleTime time.Time `json:"last_sample_time"`
|
||||
TotalQueries int64 `json:"total_queries"`
|
||||
SampleCount int `json:"sample_count"`
|
||||
AvgLatency time.Duration `json:"avg_latency_ns"`
|
||||
MinLatency time.Duration `json:"min_latency_ns"`
|
||||
MaxLatency time.Duration `json:"max_latency_ns"`
|
||||
P95Latency time.Duration `json:"p95_latency_ns,omitempty"`
|
||||
PeakInUse int `json:"peak_in_use"`
|
||||
PeakWaitCount int64 `json:"peak_wait_count"`
|
||||
TotalWaitTime time.Duration `json:"total_wait_time_ns"`
|
||||
}
|
||||
|
||||
// GetMetrics returns the current metrics without performing a health check.
|
||||
func (s *Store) GetMetrics() MetricsSummary {
|
||||
if s.metrics == nil {
|
||||
return MetricsSummary{}
|
||||
}
|
||||
return s.metrics.GetMetricsSummary()
|
||||
}
|
||||
|
||||
// ResetMetrics resets the metrics collector (useful for testing or after major changes).
|
||||
func (s *Store) ResetMetrics() {
|
||||
if s.metrics != nil {
|
||||
s.metrics = NewPoolMetrics(s.metrics.windowSize)
|
||||
}
|
||||
}
|
||||
|
||||
// WithTimeout wraps a context with the given timeout and logs slow queries.
|
||||
// Returns the wrapped context and a cancel function that should be called when done.
|
||||
func (s *Store) WithTimeout(ctx context.Context, timeout time.Duration, operation string) (context.Context, context.CancelFunc) {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
start := time.Now()
|
||||
|
||||
// Return wrapped cancel that logs if query was slow
|
||||
return timeoutCtx, func() {
|
||||
elapsed := time.Since(start)
|
||||
cancel()
|
||||
|
||||
// Log slow queries (> 100ms)
|
||||
if elapsed > 100*time.Millisecond {
|
||||
log.Warn().
|
||||
Str("operation", operation).
|
||||
Dur("elapsed", elapsed).
|
||||
Dur("timeout", timeout).
|
||||
Msg("Slow database operation")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ExecWithTimeout executes a raw SQL query with timeout.
|
||||
// Returns error if query takes longer than timeout.
|
||||
func (s *Store) ExecWithTimeout(ctx context.Context, timeout time.Duration, query string, args ...any) error {
|
||||
timeoutCtx, cancel := s.WithTimeout(ctx, timeout, "exec")
|
||||
defer cancel()
|
||||
|
||||
_, err := s.sqlDB.ExecContext(timeoutCtx, query, args...)
|
||||
if err != nil {
|
||||
if err == context.DeadlineExceeded {
|
||||
return fmt.Errorf("query timeout after %v: %s", timeout, query)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TransactionWithTimeout wraps a transaction function with timeout handling.
|
||||
// The transaction is automatically rolled back if the context times out.
|
||||
func (s *Store) TransactionWithTimeout(ctx context.Context, timeout time.Duration, fn func(*gorm.DB) error) error {
|
||||
timeoutCtx, cancel := s.WithTimeout(ctx, timeout, "transaction")
|
||||
defer cancel()
|
||||
|
||||
return s.DB.WithContext(timeoutCtx).Transaction(func(tx *gorm.DB) error {
|
||||
// Check context before proceeding
|
||||
select {
|
||||
case <-timeoutCtx.Done():
|
||||
return timeoutCtx.Err()
|
||||
default:
|
||||
}
|
||||
return fn(tx)
|
||||
})
|
||||
}
|
||||
@@ -1,562 +0,0 @@
|
||||
//go:build fts5
|
||||
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestNewStore(t *testing.T) {
|
||||
// Create temporary directory for test database
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_test_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
// Create store with migrations
|
||||
cfg := Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 4,
|
||||
LogLevel: logger.Silent,
|
||||
}
|
||||
|
||||
store, err := NewStore(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Verify connection works
|
||||
sqlDB := store.GetRawDB()
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
t.Fatalf("ping failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify WAL mode is enabled
|
||||
var journalMode string
|
||||
err = store.DB.Raw("PRAGMA journal_mode").Scan(&journalMode).Error
|
||||
if err != nil {
|
||||
t.Fatalf("query journal_mode failed: %v", err)
|
||||
}
|
||||
if journalMode != "wal" {
|
||||
t.Errorf("expected WAL mode, got %q", journalMode)
|
||||
}
|
||||
|
||||
// Verify core tables exist
|
||||
tables := []string{
|
||||
"sdk_sessions",
|
||||
"observations",
|
||||
"session_summaries",
|
||||
"user_prompts",
|
||||
"observation_conflicts",
|
||||
"observation_relations",
|
||||
"patterns",
|
||||
"concept_weights",
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
exists := store.DB.Migrator().HasTable(table)
|
||||
if !exists {
|
||||
t.Errorf("table %q does not exist", table)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify FTS5 virtual tables exist (cannot use Migrator().HasTable for virtual tables)
|
||||
ftsTables := []string{
|
||||
"user_prompts_fts",
|
||||
"observations_fts",
|
||||
"session_summaries_fts",
|
||||
"patterns_fts",
|
||||
}
|
||||
|
||||
for _, table := range ftsTables {
|
||||
var count int
|
||||
err := store.DB.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&count).Error
|
||||
if err != nil {
|
||||
t.Errorf("check FTS table %q failed: %v", table, err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("FTS table %q does not exist", table)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify vectors table exists (virtual table)
|
||||
var vectorsCount int
|
||||
err = store.DB.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='vectors'").Scan(&vectorsCount).Error
|
||||
if err != nil {
|
||||
t.Errorf("check vectors table failed: %v", err)
|
||||
}
|
||||
if vectorsCount != 1 {
|
||||
t.Errorf("vectors table does not exist")
|
||||
}
|
||||
|
||||
// Verify concept_weights seed data exists
|
||||
var conceptCount int64
|
||||
store.DB.Model(&ConceptWeight{}).Count(&conceptCount)
|
||||
if conceptCount != 12 {
|
||||
t.Errorf("expected 12 concept weights, got %d", conceptCount)
|
||||
}
|
||||
|
||||
t.Logf("✅ Phase 1 Foundation: All migrations successful")
|
||||
t.Logf(" - Core tables: %d", len(tables))
|
||||
t.Logf(" - FTS5 tables: %d", len(ftsTables))
|
||||
t.Logf(" - Vector table: 1")
|
||||
t.Logf(" - Seed data: %d concept weights", conceptCount)
|
||||
}
|
||||
|
||||
func TestMigrationIdempotency(t *testing.T) {
|
||||
// Create temporary directory for test database
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_idempotency_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
cfg := Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 4,
|
||||
LogLevel: logger.Silent,
|
||||
}
|
||||
|
||||
// Run migrations first time
|
||||
store1, err := NewStore(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore (first) failed: %v", err)
|
||||
}
|
||||
store1.Close()
|
||||
|
||||
// Run migrations second time (should be idempotent)
|
||||
store2, err := NewStore(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore (second) failed: %v", err)
|
||||
}
|
||||
defer store2.Close()
|
||||
|
||||
// Verify concept_weights seed data is still exactly 12 (INSERT OR IGNORE)
|
||||
var conceptCount int64
|
||||
store2.DB.Model(&ConceptWeight{}).Count(&conceptCount)
|
||||
if conceptCount != 12 {
|
||||
t.Errorf("expected 12 concept weights after second migration, got %d", conceptCount)
|
||||
}
|
||||
|
||||
t.Logf("✅ Migrations are idempotent")
|
||||
}
|
||||
|
||||
func TestWALAutocheckpoint(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_wal_checkpoint_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
store, err := NewStore(Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 2,
|
||||
LogLevel: logger.Silent,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Verify wal_autocheckpoint is set to 1000
|
||||
var checkpoint int
|
||||
err = store.GetRawDB().QueryRow("PRAGMA wal_autocheckpoint").Scan(&checkpoint)
|
||||
if err != nil {
|
||||
t.Fatalf("query wal_autocheckpoint: %v", err)
|
||||
}
|
||||
if checkpoint != 1000 {
|
||||
t.Errorf("expected wal_autocheckpoint=1000, got %d", checkpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptimize_RunsWALCheckpoint(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_optimize_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
store, err := NewStore(Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 2,
|
||||
LogLevel: logger.Silent,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Insert some data to generate WAL frames
|
||||
_, err = store.GetRawDB().Exec("INSERT INTO observations (sdk_session_id, title, scope, project, type, created_at, created_at_epoch) VALUES ('test-sess', 'test data', 'project', '/tmp/test', 'decision', '2026-01-01T00:00:00Z', 1735689600)")
|
||||
if err != nil {
|
||||
t.Fatalf("insert test data: %v", err)
|
||||
}
|
||||
|
||||
// Optimize should succeed (includes PASSIVE WAL checkpoint)
|
||||
err = store.Optimize(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Optimize failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptimize_RespectsContextCancellation(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_optimize_cancel_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
store, err := NewStore(Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 2,
|
||||
LogLevel: logger.Silent,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Already-cancelled context should cause Optimize to fail
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err = store.Optimize(ctx)
|
||||
if err == nil {
|
||||
t.Error("expected error with cancelled context, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// growWAL inserts sizeable rows to push the SQLite WAL well past a few hundred KB so
|
||||
// checkpoint behaviour can be observed. Returns the WAL file size after the inserts.
|
||||
func growWAL(t *testing.T, store *Store, rows int) int64 {
|
||||
t.Helper()
|
||||
bigTitle := strings.Repeat("x", 2048)
|
||||
for i := 0; i < rows; i++ {
|
||||
_, err := store.GetRawDB().Exec(
|
||||
"INSERT INTO observations (sdk_session_id, title, scope, project, type, created_at, created_at_epoch) "+
|
||||
"VALUES (?, ?, 'project', '/tmp/test', 'decision', '2026-01-01T00:00:00Z', 1735689600)",
|
||||
"sess", bigTitle)
|
||||
if err != nil {
|
||||
t.Fatalf("insert row %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
return store.WALSize()
|
||||
}
|
||||
|
||||
func countObservations(t *testing.T, store *Store) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := store.GetRawDB().QueryRow("SELECT COUNT(*) FROM observations").Scan(&n); err != nil {
|
||||
t.Fatalf("count observations: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestCheckpoint_TruncateShrinksWAL verifies Checkpoint() performs a TRUNCATE checkpoint
|
||||
// that actually reclaims the -wal file. This is the load-bearing fix for issue #49: a
|
||||
// PASSIVE checkpoint drains frames but never shrinks the file, so reverting Checkpoint to
|
||||
// PASSIVE would leave the WAL grown and fail this test.
|
||||
func TestCheckpoint_TruncateShrinksWAL(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_checkpoint_truncate_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
store, err := NewStore(Config{Path: dbPath, MaxConns: 2, LogLevel: logger.Silent})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
walBefore := growWAL(t, store, 1000)
|
||||
if walBefore < 64*1024 {
|
||||
t.Fatalf("expected WAL to grow above 64KiB, got %d bytes", walBefore)
|
||||
}
|
||||
|
||||
if err := store.Checkpoint(context.Background()); err != nil {
|
||||
t.Fatalf("Checkpoint failed: %v", err)
|
||||
}
|
||||
|
||||
walAfter := store.WALSize()
|
||||
if walAfter >= walBefore {
|
||||
t.Errorf("expected WAL to shrink after TRUNCATE checkpoint: before=%d after=%d", walBefore, walAfter)
|
||||
}
|
||||
if walAfter > 64*1024 {
|
||||
t.Errorf("expected WAL truncated to near-zero, got %d bytes", walAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckpointIfLarge_GatesOnThreshold verifies the size-gated periodic checkpoint used
|
||||
// by the worker's walCheckpointLoop: a no-op below the threshold, a truncating checkpoint
|
||||
// at/above it.
|
||||
func TestCheckpointIfLarge_GatesOnThreshold(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_checkpoint_gated_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
store, err := NewStore(Config{Path: dbPath, MaxConns: 2, LogLevel: logger.Silent})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Below an enormous threshold -> no checkpoint.
|
||||
done, err := store.CheckpointIfLarge(context.Background(), 1<<30) // 1 GiB
|
||||
if err != nil {
|
||||
t.Fatalf("CheckpointIfLarge (small) failed: %v", err)
|
||||
}
|
||||
if done {
|
||||
t.Errorf("expected no checkpoint below threshold, but one was performed")
|
||||
}
|
||||
|
||||
// Grow the WAL, then a low threshold triggers a truncating checkpoint.
|
||||
walBefore := growWAL(t, store, 1000)
|
||||
if walBefore < 64*1024 {
|
||||
t.Fatalf("expected WAL to grow above 64KiB, got %d bytes", walBefore)
|
||||
}
|
||||
|
||||
done, err = store.CheckpointIfLarge(context.Background(), 64*1024)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckpointIfLarge (large) failed: %v", err)
|
||||
}
|
||||
if !done {
|
||||
t.Errorf("expected checkpoint above threshold, but none was performed")
|
||||
}
|
||||
if walAfter := store.WALSize(); walAfter >= walBefore {
|
||||
t.Errorf("expected WAL to shrink after gated checkpoint: before=%d after=%d", walBefore, walAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClose_CheckpointsWAL verifies Close() reclaims the WAL and leaves the data intact on
|
||||
// the next open (issue #49: shutdown must not leave a large dirty WAL on disk).
|
||||
func TestClose_CheckpointsWAL(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_close_checkpoint_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
store, err := NewStore(Config{Path: dbPath, MaxConns: 2, LogLevel: logger.Silent})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
|
||||
if walBefore := growWAL(t, store, 800); walBefore == 0 {
|
||||
t.Fatalf("expected WAL to grow before close, got 0")
|
||||
}
|
||||
count := countObservations(t, store)
|
||||
if count < 800 {
|
||||
t.Fatalf("expected >=800 observations before close, got %d", count)
|
||||
}
|
||||
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("Close failed: %v", err)
|
||||
}
|
||||
|
||||
// The -wal file must not persist large on disk after a clean shutdown.
|
||||
if info, statErr := os.Stat(dbPath + "-wal"); statErr == nil && info.Size() > 64*1024 {
|
||||
t.Errorf("expected WAL reclaimed on close, -wal still %d bytes", info.Size())
|
||||
}
|
||||
|
||||
// Reopen and verify data survived the checkpoint.
|
||||
store2, err := NewStore(Config{Path: dbPath, MaxConns: 2, LogLevel: logger.Silent})
|
||||
if err != nil {
|
||||
t.Fatalf("reopen NewStore failed: %v", err)
|
||||
}
|
||||
defer store2.Close()
|
||||
|
||||
if count2 := countObservations(t, store2); count2 != count {
|
||||
t.Errorf("expected %d observations after reopen, got %d", count, count2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBusyTimeoutAppliedToAllConnections verifies the issue #49 DSN fix: busy_timeout is
|
||||
// applied to EVERY pooled connection (not just one arbitrary connection as happened when
|
||||
// it was set via a single post-open sqlDB.Exec).
|
||||
func TestBusyTimeoutAppliedToAllConnections(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_busy_timeout_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
const maxConns = 4
|
||||
store, err := NewStore(Config{Path: dbPath, MaxConns: maxConns, LogLevel: logger.Silent})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Pin all connections concurrently so each distinct connection is inspected, then
|
||||
// assert every one reports busy_timeout=5000.
|
||||
raw := store.GetRawDB()
|
||||
conns := make([]*sql.Conn, 0, maxConns)
|
||||
defer func() {
|
||||
for _, c := range conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
}()
|
||||
for i := 0; i < maxConns; i++ {
|
||||
c, err := raw.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("acquire conn %d: %v", i, err)
|
||||
}
|
||||
conns = append(conns, c)
|
||||
}
|
||||
for i, c := range conns {
|
||||
var timeout int
|
||||
if err := c.QueryRowContext(context.Background(), "PRAGMA busy_timeout").Scan(&timeout); err != nil {
|
||||
t.Fatalf("query busy_timeout on conn %d: %v", i, err)
|
||||
}
|
||||
if timeout != 5000 {
|
||||
t.Errorf("conn %d: expected busy_timeout=5000, got %d", i, timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllPragmasAppliedToAllConnections verifies the issue #49 (F6) ConnectHook fix: not
|
||||
// just busy_timeout but the full pragma set — including the best-effort pragmas that used
|
||||
// to be set via a single post-open sqlDB.Exec (journal_size_limit, temp_store,
|
||||
// wal_autocheckpoint) — is applied to EVERY pooled connection. It pins all connections so
|
||||
// each distinct connection is inspected, then asserts each reports the expected value.
|
||||
func TestAllPragmasAppliedToAllConnections(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_all_pragmas_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
const maxConns = 4
|
||||
store, err := NewStore(Config{Path: dbPath, MaxConns: maxConns, LogLevel: logger.Silent})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Expected per-connection pragma values. temp_store=MEMORY reports as 2; the others are
|
||||
// numeric. foreign_keys/journal_mode/synchronous are covered by TestNewStore and the
|
||||
// busy_timeout test; here we focus on the previously single-connection pragmas.
|
||||
checks := []struct {
|
||||
name string
|
||||
want int64
|
||||
}{
|
||||
{"busy_timeout", 5000},
|
||||
{"journal_size_limit", 8388608},
|
||||
{"temp_store", 2}, // 2 == MEMORY
|
||||
{"wal_autocheckpoint", 1000},
|
||||
{"foreign_keys", 1},
|
||||
}
|
||||
|
||||
raw := store.GetRawDB()
|
||||
conns := make([]*sql.Conn, 0, maxConns)
|
||||
defer func() {
|
||||
for _, c := range conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
}()
|
||||
for i := 0; i < maxConns; i++ {
|
||||
c, err := raw.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("acquire conn %d: %v", i, err)
|
||||
}
|
||||
conns = append(conns, c)
|
||||
}
|
||||
|
||||
for i, c := range conns {
|
||||
for _, chk := range checks {
|
||||
var got int64
|
||||
query := "PRAGMA " + chk.name
|
||||
if err := c.QueryRowContext(context.Background(), query).Scan(&got); err != nil {
|
||||
t.Fatalf("conn %d: query %q: %v", i, chk.name, err)
|
||||
}
|
||||
if got != chk.want {
|
||||
t.Errorf("conn %d: %s = %d, want %d", i, chk.name, got, chk.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecycledConnectionRetainsPragmas verifies that recycling a connection (which now
|
||||
// happens because SetConnMaxLifetime is finite, not 0) does NOT drop the correctness
|
||||
// pragmas: the ConnectHook reapplies them on every new connection. We force recycling by
|
||||
// setting a near-zero max lifetime so the next acquisition opens a fresh connection, then
|
||||
// assert the new connection still reports the safe values rather than SQLite defaults
|
||||
// (busy_timeout would default to 0 and journal_mode to "delete" without the hook).
|
||||
func TestRecycledConnectionRetainsPragmas(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_recycle_pragmas_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
store, err := NewStore(Config{Path: dbPath, MaxConns: 2, LogLevel: logger.Silent})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
raw := store.GetRawDB()
|
||||
|
||||
// Force aggressive recycling: any connection older than 1ns is expired on next use, so
|
||||
// database/sql opens a brand-new connection (running the ConnectHook again).
|
||||
raw.SetConnMaxLifetime(time.Nanosecond)
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
// Acquire a connection that is necessarily freshly opened (all prior ones are expired),
|
||||
// and verify the hook reapplied the correctness pragmas.
|
||||
conn, err := raw.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("acquire recycled conn: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
var busyTimeout int
|
||||
if err := conn.QueryRowContext(context.Background(), "PRAGMA busy_timeout").Scan(&busyTimeout); err != nil {
|
||||
t.Fatalf("query busy_timeout: %v", err)
|
||||
}
|
||||
if busyTimeout != 5000 {
|
||||
t.Errorf("recycled conn: busy_timeout = %d, want 5000 (hook did not reapply)", busyTimeout)
|
||||
}
|
||||
|
||||
var journalMode string
|
||||
if err := conn.QueryRowContext(context.Background(), "PRAGMA journal_mode").Scan(&journalMode); err != nil {
|
||||
t.Fatalf("query journal_mode: %v", err)
|
||||
}
|
||||
if journalMode != "wal" {
|
||||
t.Errorf("recycled conn: journal_mode = %q, want \"wal\" (hook did not reapply)", journalMode)
|
||||
}
|
||||
|
||||
var foreignKeys int
|
||||
if err := conn.QueryRowContext(context.Background(), "PRAGMA foreign_keys").Scan(&foreignKeys); err != nil {
|
||||
t.Fatalf("query foreign_keys: %v", err)
|
||||
}
|
||||
if foreignKeys != 1 {
|
||||
t.Errorf("recycled conn: foreign_keys = %d, want 1 (hook did not reapply)", foreignKeys)
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// SummaryStore provides summary-related database operations using GORM.
|
||||
type SummaryStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSummaryStore creates a new summary store.
|
||||
func NewSummaryStore(store *Store) *SummaryStore {
|
||||
return &SummaryStore{db: store.DB}
|
||||
}
|
||||
|
||||
// StoreSummary stores a new session summary.
|
||||
func (s *SummaryStore) StoreSummary(ctx context.Context, sdkSessionID, project string, summary *models.ParsedSummary, promptNumber int, discoveryTokens int64) (int64, int64, error) {
|
||||
now := time.Now()
|
||||
nowEpoch := now.UnixMilli()
|
||||
|
||||
// Ensure session exists (auto-create if missing)
|
||||
if err := EnsureSessionExists(ctx, s.db, sdkSessionID, project); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
dbSummary := &SessionSummary{
|
||||
SDKSessionID: sdkSessionID,
|
||||
Project: project,
|
||||
Request: nullString(summary.Request),
|
||||
Investigated: nullString(summary.Investigated),
|
||||
Learned: nullString(summary.Learned),
|
||||
Completed: nullString(summary.Completed),
|
||||
NextSteps: nullString(summary.NextSteps),
|
||||
Notes: nullString(summary.Notes),
|
||||
PromptNumber: func() sql.NullInt64 {
|
||||
if promptNumber > 0 {
|
||||
return sql.NullInt64{Int64: int64(promptNumber), Valid: true}
|
||||
}
|
||||
return sql.NullInt64{Valid: false}
|
||||
}(),
|
||||
DiscoveryTokens: discoveryTokens,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
CreatedAtEpoch: nowEpoch,
|
||||
}
|
||||
|
||||
err := s.db.WithContext(ctx).Create(dbSummary).Error
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return dbSummary.ID, nowEpoch, nil
|
||||
}
|
||||
|
||||
// GetSummariesByIDs retrieves summaries by a list of IDs.
|
||||
func (s *SummaryStore) GetSummariesByIDs(ctx context.Context, ids []int64, orderBy string, limit int) ([]*models.SessionSummary, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var dbSummaries []SessionSummary
|
||||
query := s.db.WithContext(ctx).Where("id IN ?", ids)
|
||||
|
||||
// Apply ordering
|
||||
switch orderBy {
|
||||
case "date_asc":
|
||||
query = query.Order("created_at_epoch ASC")
|
||||
case "date_desc", "default", "":
|
||||
query = query.Order("created_at_epoch DESC")
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
|
||||
err := query.Find(&dbSummaries).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelSessionSummaries(dbSummaries), nil
|
||||
}
|
||||
|
||||
// GetRecentSummaries retrieves recent summaries for a project.
|
||||
func (s *SummaryStore) GetRecentSummaries(ctx context.Context, project string, limit int) ([]*models.SessionSummary, error) {
|
||||
var dbSummaries []SessionSummary
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("project = ?", project).
|
||||
Order("created_at_epoch DESC").
|
||||
Limit(limit).
|
||||
Find(&dbSummaries).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelSessionSummaries(dbSummaries), nil
|
||||
}
|
||||
|
||||
// GetAllRecentSummaries retrieves recent summaries across all projects.
|
||||
func (s *SummaryStore) GetAllRecentSummaries(ctx context.Context, limit int) ([]*models.SessionSummary, error) {
|
||||
var dbSummaries []SessionSummary
|
||||
err := s.db.WithContext(ctx).
|
||||
Order("created_at_epoch DESC").
|
||||
Limit(limit).
|
||||
Find(&dbSummaries).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelSessionSummaries(dbSummaries), nil
|
||||
}
|
||||
|
||||
// GetAllSummaries retrieves all summaries (for vector rebuild).
|
||||
func (s *SummaryStore) GetAllSummaries(ctx context.Context) ([]*models.SessionSummary, error) {
|
||||
var dbSummaries []SessionSummary
|
||||
err := s.db.WithContext(ctx).
|
||||
Order("id").
|
||||
Find(&dbSummaries).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toModelSessionSummaries(dbSummaries), nil
|
||||
}
|
||||
|
||||
// toModelSessionSummary converts a GORM SessionSummary to pkg/models.SessionSummary.
|
||||
func toModelSessionSummary(s *SessionSummary) *models.SessionSummary {
|
||||
return &models.SessionSummary{
|
||||
ID: s.ID,
|
||||
SDKSessionID: s.SDKSessionID,
|
||||
Project: s.Project,
|
||||
Request: s.Request,
|
||||
Investigated: s.Investigated,
|
||||
Learned: s.Learned,
|
||||
Completed: s.Completed,
|
||||
NextSteps: s.NextSteps,
|
||||
Notes: s.Notes,
|
||||
PromptNumber: s.PromptNumber,
|
||||
DiscoveryTokens: s.DiscoveryTokens,
|
||||
CreatedAt: s.CreatedAt,
|
||||
CreatedAtEpoch: s.CreatedAtEpoch,
|
||||
}
|
||||
}
|
||||
|
||||
// toModelSessionSummaries converts a slice of GORM SessionSummary to pkg/models.SessionSummary.
|
||||
func toModelSessionSummaries(summaries []SessionSummary) []*models.SessionSummary {
|
||||
result := make([]*models.SessionSummary, len(summaries))
|
||||
for i := range summaries {
|
||||
result[i] = toModelSessionSummary(&summaries[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// nullString converts a string to sql.NullString.
|
||||
func nullString(s string) sql.NullString {
|
||||
if s == "" {
|
||||
return sql.NullString{Valid: false}
|
||||
}
|
||||
return sql.NullString{String: s, Valid: true}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
//go:build fts5
|
||||
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// testSummaryStore creates a SummaryStore with a temporary database for testing.
|
||||
func testSummaryStore(t *testing.T) (*SummaryStore, *Store, func()) {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_summary_test_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
cfg := Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 4,
|
||||
LogLevel: logger.Silent,
|
||||
}
|
||||
|
||||
store, err := NewStore(cfg)
|
||||
if err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
|
||||
summaryStore := NewSummaryStore(store)
|
||||
|
||||
cleanup := func() {
|
||||
store.Close()
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
return summaryStore, store, cleanup
|
||||
}
|
||||
|
||||
func TestSummaryStore_StoreSummary(t *testing.T) {
|
||||
summaryStore, store, cleanup := testSummaryStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a session first
|
||||
sessionStore := NewSessionStore(store)
|
||||
_, err := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store a summary
|
||||
summary := &models.ParsedSummary{
|
||||
Request: "Build a feature",
|
||||
Investigated: "Examined the codebase",
|
||||
Learned: "Discovered patterns",
|
||||
Completed: "Implemented solution",
|
||||
NextSteps: "Write tests",
|
||||
Notes: "Additional notes",
|
||||
}
|
||||
|
||||
id, epoch, err := summaryStore.StoreSummary(ctx, "claude-1", "test-project", summary, 1, 100)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, id, int64(0))
|
||||
assert.Greater(t, epoch, int64(0))
|
||||
}
|
||||
|
||||
func TestSummaryStore_StoreSummary_AutoCreateSession(t *testing.T) {
|
||||
summaryStore, _, cleanup := testSummaryStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store summary without pre-creating session
|
||||
summary := &models.ParsedSummary{
|
||||
Request: "Test auto-create",
|
||||
}
|
||||
|
||||
id, _, err := summaryStore.StoreSummary(ctx, "claude-auto", "auto-project", summary, 1, 50)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, id, int64(0))
|
||||
}
|
||||
|
||||
func TestSummaryStore_GetRecentSummaries(t *testing.T) {
|
||||
summaryStore, _, cleanup := testSummaryStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store multiple summaries
|
||||
for i := 1; i <= 5; i++ {
|
||||
summary := &models.ParsedSummary{
|
||||
Request: "Request " + string(rune('0'+i)),
|
||||
}
|
||||
_, _, err := summaryStore.StoreSummary(ctx, "claude-1", "project-a", summary, i, 10)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Store summary for different project
|
||||
summary := &models.ParsedSummary{Request: "Other project"}
|
||||
_, _, err := summaryStore.StoreSummary(ctx, "claude-2", "project-b", summary, 1, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get recent summaries for project-a
|
||||
summaries, err := summaryStore.GetRecentSummaries(ctx, "project-a", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, summaries, 5)
|
||||
|
||||
// Verify ordering (most recent first)
|
||||
assert.Equal(t, "project-a", summaries[0].Project)
|
||||
}
|
||||
|
||||
func TestSummaryStore_GetAllRecentSummaries(t *testing.T) {
|
||||
summaryStore, _, cleanup := testSummaryStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store summaries across projects
|
||||
_, _, err := summaryStore.StoreSummary(ctx, "claude-1", "project-a", &models.ParsedSummary{Request: "A1"}, 1, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = summaryStore.StoreSummary(ctx, "claude-2", "project-b", &models.ParsedSummary{Request: "B1"}, 1, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = summaryStore.StoreSummary(ctx, "claude-3", "project-c", &models.ParsedSummary{Request: "C1"}, 1, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get all recent summaries
|
||||
summaries, err := summaryStore.GetAllRecentSummaries(ctx, 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, summaries, 3)
|
||||
}
|
||||
|
||||
func TestSummaryStore_GetSummariesByIDs(t *testing.T) {
|
||||
summaryStore, _, cleanup := testSummaryStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store multiple summaries
|
||||
var ids []int64
|
||||
for i := 1; i <= 3; i++ {
|
||||
id, _, err := summaryStore.StoreSummary(ctx, "claude-1", "project-a", &models.ParsedSummary{Request: "Test"}, i, 10)
|
||||
require.NoError(t, err)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
// Get by IDs
|
||||
summaries, err := summaryStore.GetSummariesByIDs(ctx, ids, "date_desc", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, summaries, 3)
|
||||
|
||||
// Get with limit
|
||||
summaries, err = summaryStore.GetSummariesByIDs(ctx, ids, "date_desc", 2)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, summaries, 2)
|
||||
}
|
||||
|
||||
func TestSummaryStore_GetSummariesByIDs_EmptyInput(t *testing.T) {
|
||||
summaryStore, _, cleanup := testSummaryStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Get with empty IDs
|
||||
summaries, err := summaryStore.GetSummariesByIDs(ctx, []int64{}, "date_desc", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, summaries)
|
||||
}
|
||||
|
||||
func TestSummaryStore_SummaryFields(t *testing.T) {
|
||||
summaryStore, _, cleanup := testSummaryStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store a summary with all fields
|
||||
summary := &models.ParsedSummary{
|
||||
Request: "Full request",
|
||||
Investigated: "Full investigation",
|
||||
Learned: "Full learning",
|
||||
Completed: "Full completion",
|
||||
NextSteps: "Full next steps",
|
||||
Notes: "Full notes",
|
||||
}
|
||||
|
||||
id, epoch, err := summaryStore.StoreSummary(ctx, "claude-1", "test-project", summary, 5, 200)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve and verify all fields
|
||||
summaries, err := summaryStore.GetSummariesByIDs(ctx, []int64{id}, "date_desc", 1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, summaries, 1)
|
||||
|
||||
s := summaries[0]
|
||||
assert.Equal(t, id, s.ID)
|
||||
assert.Equal(t, "claude-1", s.SDKSessionID)
|
||||
assert.Equal(t, "test-project", s.Project)
|
||||
assert.True(t, s.Request.Valid)
|
||||
assert.Equal(t, "Full request", s.Request.String)
|
||||
assert.True(t, s.Investigated.Valid)
|
||||
assert.Equal(t, "Full investigation", s.Investigated.String)
|
||||
assert.True(t, s.Learned.Valid)
|
||||
assert.Equal(t, "Full learning", s.Learned.String)
|
||||
assert.True(t, s.Completed.Valid)
|
||||
assert.Equal(t, "Full completion", s.Completed.String)
|
||||
assert.True(t, s.NextSteps.Valid)
|
||||
assert.Equal(t, "Full next steps", s.NextSteps.String)
|
||||
assert.True(t, s.Notes.Valid)
|
||||
assert.Equal(t, "Full notes", s.Notes.String)
|
||||
assert.True(t, s.PromptNumber.Valid)
|
||||
assert.Equal(t, int64(5), s.PromptNumber.Int64)
|
||||
assert.Equal(t, int64(200), s.DiscoveryTokens)
|
||||
assert.NotEmpty(t, s.CreatedAt)
|
||||
assert.Equal(t, epoch, s.CreatedAtEpoch)
|
||||
}
|
||||
|
||||
func TestSummaryStore_EmptySummary(t *testing.T) {
|
||||
summaryStore, _, cleanup := testSummaryStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store a summary with empty fields
|
||||
summary := &models.ParsedSummary{}
|
||||
|
||||
id, _, err := summaryStore.StoreSummary(ctx, "claude-1", "test-project", summary, 0, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve and verify NULL fields
|
||||
summaries, err := summaryStore.GetSummariesByIDs(ctx, []int64{id}, "date_desc", 1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, summaries, 1)
|
||||
|
||||
s := summaries[0]
|
||||
assert.False(t, s.Request.Valid)
|
||||
assert.False(t, s.Investigated.Valid)
|
||||
assert.False(t, s.Learned.Valid)
|
||||
assert.False(t, s.Completed.Valid)
|
||||
assert.False(t, s.NextSteps.Valid)
|
||||
assert.False(t, s.Notes.Valid)
|
||||
assert.False(t, s.PromptNumber.Valid)
|
||||
assert.Equal(t, int64(0), s.DiscoveryTokens)
|
||||
}
|
||||
|
||||
func TestSummaryStore_GetAllSummaries(t *testing.T) {
|
||||
summaryStore, _, cleanup := testSummaryStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store multiple summaries
|
||||
for i := 1; i <= 5; i++ {
|
||||
_, _, err := summaryStore.StoreSummary(ctx, "claude-1", "project-a", &models.ParsedSummary{Request: "Test"}, i, 10)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Get all summaries
|
||||
summaries, err := summaryStore.GetAllSummaries(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, summaries, 5)
|
||||
|
||||
// Verify ordering by ID
|
||||
for i := 0; i < len(summaries)-1; i++ {
|
||||
assert.Less(t, summaries[i].ID, summaries[i+1].ID)
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// Package db defines database interfaces for the claude-mnemonic stores.
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// ObservationReader defines read operations for observations.
|
||||
type ObservationReader interface {
|
||||
GetObservationByID(ctx context.Context, id int64) (*models.Observation, error)
|
||||
GetObservationsByIDs(ctx context.Context, ids []int64, orderBy string, limit int) ([]*models.Observation, error)
|
||||
GetRecentObservations(ctx context.Context, project string, limit int) ([]*models.Observation, error)
|
||||
GetActiveObservations(ctx context.Context, project string, limit int) ([]*models.Observation, error)
|
||||
GetAllRecentObservations(ctx context.Context, limit int) ([]*models.Observation, error)
|
||||
GetAllObservations(ctx context.Context) ([]*models.Observation, error)
|
||||
SearchObservationsFTS(ctx context.Context, query, project string, limit int) ([]*models.Observation, error)
|
||||
GetObservationCount(ctx context.Context, project string) (int, error)
|
||||
}
|
||||
|
||||
// ObservationWriter defines write operations for observations.
|
||||
type ObservationWriter interface {
|
||||
StoreObservation(ctx context.Context, sdkSessionID, project string, obs *models.ParsedObservation, promptNumber int, discoveryTokens int64) (int64, int64, error)
|
||||
DeleteObservations(ctx context.Context, ids []int64) (int64, error)
|
||||
}
|
||||
|
||||
// ObservationStore combines read and write operations for observations.
|
||||
type ObservationStore interface {
|
||||
ObservationReader
|
||||
ObservationWriter
|
||||
}
|
||||
|
||||
// SummaryReader defines read operations for summaries.
|
||||
type SummaryReader interface {
|
||||
GetSummariesByIDs(ctx context.Context, ids []int64, orderBy string, limit int) ([]*models.SessionSummary, error)
|
||||
GetRecentSummaries(ctx context.Context, project string, limit int) ([]*models.SessionSummary, error)
|
||||
GetAllRecentSummaries(ctx context.Context, limit int) ([]*models.SessionSummary, error)
|
||||
GetAllSummaries(ctx context.Context) ([]*models.SessionSummary, error)
|
||||
}
|
||||
|
||||
// SummaryWriter defines write operations for summaries.
|
||||
type SummaryWriter interface {
|
||||
StoreSummary(ctx context.Context, sdkSessionID, project string, summary *models.ParsedSummary, promptNumber int, discoveryTokens int64) (int64, int64, error)
|
||||
}
|
||||
|
||||
// SummaryStore combines read and write operations for summaries.
|
||||
type SummaryStore interface {
|
||||
SummaryReader
|
||||
SummaryWriter
|
||||
}
|
||||
|
||||
// PromptReader defines read operations for prompts.
|
||||
type PromptReader interface {
|
||||
GetPromptsByIDs(ctx context.Context, ids []int64, orderBy string, limit int) ([]*models.UserPromptWithSession, error)
|
||||
GetAllRecentUserPrompts(ctx context.Context, limit int) ([]*models.UserPromptWithSession, error)
|
||||
GetAllPrompts(ctx context.Context) ([]*models.UserPromptWithSession, error)
|
||||
GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error)
|
||||
FindRecentPromptByText(ctx context.Context, claudeSessionID, promptText string, withinSeconds int) (int64, int, bool)
|
||||
FindRecentPromptByTextGlobal(ctx context.Context, promptText string, withinSeconds int) (int64, int, bool)
|
||||
}
|
||||
|
||||
// PromptWriter defines write operations for prompts.
|
||||
type PromptWriter interface {
|
||||
SaveUserPromptWithMatches(ctx context.Context, claudeSessionID string, promptNumber int, promptText string, matchedObservations int) (int64, error)
|
||||
}
|
||||
|
||||
// PromptStore combines read and write operations for prompts.
|
||||
type PromptStore interface {
|
||||
PromptReader
|
||||
PromptWriter
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Package sqlite provides SQLite database operations for claude-mnemonic.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// EnsureSessionExists creates a session if it doesn't exist.
|
||||
// This is shared between ObservationStore and SummaryStore to avoid duplication.
|
||||
func EnsureSessionExists(ctx context.Context, store *Store, sdkSessionID, project string) error {
|
||||
const checkQuery = `SELECT id FROM sdk_sessions WHERE sdk_session_id = ?`
|
||||
var id int64
|
||||
err := store.QueryRowContext(ctx, checkQuery, sdkSessionID).Scan(&id)
|
||||
if err == nil {
|
||||
return nil // Session exists
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
// Auto-create session
|
||||
now := time.Now()
|
||||
const insertQuery = `
|
||||
INSERT INTO sdk_sessions
|
||||
(claude_session_id, sdk_session_id, project, started_at, started_at_epoch, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'active')
|
||||
`
|
||||
_, err = store.ExecContext(ctx, insertQuery,
|
||||
sdkSessionID, sdkSessionID, project,
|
||||
now.Format(time.RFC3339), now.UnixMilli(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// nullString converts a string to sql.NullString.
|
||||
func nullString(s string) sql.NullString {
|
||||
return sql.NullString{String: s, Valid: s != ""}
|
||||
}
|
||||
|
||||
// nullInt converts an int to sql.NullInt64.
|
||||
func nullInt(i int) sql.NullInt64 {
|
||||
return sql.NullInt64{Int64: int64(i), Valid: i > 0}
|
||||
}
|
||||
|
||||
// repeatPlaceholders generates n comma-prefixed placeholders for SQL IN clauses.
|
||||
// e.g., repeatPlaceholders(2) returns ", ?, ?"
|
||||
func repeatPlaceholders(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
result := ""
|
||||
for i := 0; i < n; i++ {
|
||||
result += ", ?"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// int64SliceToInterface converts []int64 to []interface{} for SQL queries.
|
||||
func int64SliceToInterface(ids []int64) []interface{} {
|
||||
args := make([]interface{}, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// ParseLimitParam parses a limit query parameter with a default value.
|
||||
func ParseLimitParam(r *http.Request, defaultLimit int) int {
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultLimit
|
||||
}
|
||||
|
||||
// scanSummary scans a single summary from a row scanner.
|
||||
func scanSummary(scanner interface{ Scan(...interface{}) error }) (*models.SessionSummary, error) {
|
||||
var summary models.SessionSummary
|
||||
if err := scanner.Scan(
|
||||
&summary.ID, &summary.SDKSessionID, &summary.Project,
|
||||
&summary.Request, &summary.Investigated, &summary.Learned, &summary.Completed,
|
||||
&summary.NextSteps, &summary.Notes, &summary.PromptNumber, &summary.DiscoveryTokens,
|
||||
&summary.CreatedAt, &summary.CreatedAtEpoch,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
// scanSummaryRows scans multiple summaries from rows.
|
||||
func scanSummaryRows(rows *sql.Rows) ([]*models.SessionSummary, error) {
|
||||
var summaries []*models.SessionSummary
|
||||
for rows.Next() {
|
||||
summary, err := scanSummary(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
return summaries, rows.Err()
|
||||
}
|
||||
|
||||
// scanPromptWithSession scans a single prompt with session info from a row scanner.
|
||||
func scanPromptWithSession(scanner interface{ Scan(...interface{}) error }) (*models.UserPromptWithSession, error) {
|
||||
var prompt models.UserPromptWithSession
|
||||
if err := scanner.Scan(
|
||||
&prompt.ID, &prompt.ClaudeSessionID, &prompt.PromptNumber, &prompt.PromptText,
|
||||
&prompt.MatchedObservations, &prompt.CreatedAt, &prompt.CreatedAtEpoch,
|
||||
&prompt.Project, &prompt.SDKSessionID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &prompt, nil
|
||||
}
|
||||
|
||||
// scanPromptWithSessionRows scans multiple prompts with session info from rows.
|
||||
func scanPromptWithSessionRows(rows *sql.Rows) ([]*models.UserPromptWithSession, error) {
|
||||
var prompts []*models.UserPromptWithSession
|
||||
for rows.Next() {
|
||||
prompt, err := scanPromptWithSession(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prompts = append(prompts, prompt)
|
||||
}
|
||||
return prompts, rows.Err()
|
||||
}
|
||||
|
||||
// BuildGetByIDsQuery builds a query for fetching records by IDs with optional ordering and limit.
|
||||
// Returns the query string and args slice.
|
||||
func BuildGetByIDsQuery(baseQuery string, ids []int64, orderBy string, limit int) (string, []interface{}) {
|
||||
// Build query with placeholders
|
||||
// #nosec G202 -- query uses parameterized placeholders, not user input
|
||||
query := baseQuery + ` WHERE id IN (?` + repeatPlaceholders(len(ids)-1) + `)
|
||||
ORDER BY created_at_epoch `
|
||||
|
||||
if orderBy == "date_asc" {
|
||||
query += "ASC"
|
||||
} else {
|
||||
query += "DESC"
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
query += " LIMIT ?"
|
||||
}
|
||||
|
||||
args := int64SliceToInterface(ids)
|
||||
if limit > 0 {
|
||||
args = append(args, limit)
|
||||
}
|
||||
|
||||
return query, args
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// Package sqlite provides SQLite database operations for claude-mnemonic.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Migration represents a database schema migration.
|
||||
type Migration struct {
|
||||
Version int
|
||||
Name string
|
||||
SQL string
|
||||
}
|
||||
|
||||
// Migrations is the list of all database migrations in order.
|
||||
var Migrations = []Migration{
|
||||
{
|
||||
Version: 4,
|
||||
Name: "sdk_agent_architecture",
|
||||
SQL: `
|
||||
-- SDK Sessions (main session tracking)
|
||||
CREATE TABLE IF NOT EXISTS sdk_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
claude_session_id TEXT UNIQUE NOT NULL,
|
||||
sdk_session_id TEXT UNIQUE,
|
||||
project TEXT NOT NULL,
|
||||
user_prompt TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
started_at_epoch INTEGER NOT NULL,
|
||||
completed_at TEXT,
|
||||
completed_at_epoch INTEGER,
|
||||
status TEXT CHECK(status IN ('active', 'completed', 'failed')) NOT NULL DEFAULT 'active'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_claude_id ON sdk_sessions(claude_session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_sdk_id ON sdk_sessions(sdk_session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_project ON sdk_sessions(project);
|
||||
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_status ON sdk_sessions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_started ON sdk_sessions(started_at_epoch DESC);
|
||||
|
||||
-- Observations (extracted learnings)
|
||||
CREATE TABLE IF NOT EXISTS observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sdk_session_id TEXT NOT NULL,
|
||||
project TEXT NOT NULL,
|
||||
text TEXT,
|
||||
type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')),
|
||||
created_at TEXT NOT NULL,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
FOREIGN KEY(sdk_session_id) REFERENCES sdk_sessions(sdk_session_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_sdk_session ON observations(sdk_session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at_epoch DESC);
|
||||
|
||||
-- Session Summaries
|
||||
CREATE TABLE IF NOT EXISTS session_summaries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sdk_session_id TEXT NOT NULL,
|
||||
project TEXT NOT NULL,
|
||||
request TEXT,
|
||||
investigated TEXT,
|
||||
learned TEXT,
|
||||
completed TEXT,
|
||||
next_steps TEXT,
|
||||
files_read TEXT,
|
||||
files_edited TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
FOREIGN KEY(sdk_session_id) REFERENCES sdk_sessions(sdk_session_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_session_summaries_sdk_session ON session_summaries(sdk_session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_summaries_project ON session_summaries(project);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_summaries_created ON session_summaries(created_at_epoch DESC);
|
||||
`,
|
||||
},
|
||||
{
|
||||
Version: 5,
|
||||
Name: "worker_port_column",
|
||||
SQL: `ALTER TABLE sdk_sessions ADD COLUMN worker_port INTEGER;`,
|
||||
},
|
||||
{
|
||||
Version: 6,
|
||||
Name: "prompt_tracking_columns",
|
||||
SQL: `
|
||||
ALTER TABLE sdk_sessions ADD COLUMN prompt_counter INTEGER DEFAULT 0;
|
||||
ALTER TABLE observations ADD COLUMN prompt_number INTEGER;
|
||||
ALTER TABLE session_summaries ADD COLUMN prompt_number INTEGER;
|
||||
`,
|
||||
},
|
||||
{
|
||||
Version: 8,
|
||||
Name: "observation_hierarchical_fields",
|
||||
SQL: `
|
||||
ALTER TABLE observations ADD COLUMN title TEXT;
|
||||
ALTER TABLE observations ADD COLUMN subtitle TEXT;
|
||||
ALTER TABLE observations ADD COLUMN facts TEXT;
|
||||
ALTER TABLE observations ADD COLUMN narrative TEXT;
|
||||
ALTER TABLE observations ADD COLUMN concepts TEXT;
|
||||
ALTER TABLE observations ADD COLUMN files_read TEXT;
|
||||
ALTER TABLE observations ADD COLUMN files_modified TEXT;
|
||||
`,
|
||||
},
|
||||
{
|
||||
Version: 10,
|
||||
Name: "user_prompts_table",
|
||||
SQL: `
|
||||
-- User prompts table
|
||||
CREATE TABLE IF NOT EXISTS user_prompts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
claude_session_id TEXT NOT NULL,
|
||||
prompt_number INTEGER NOT NULL,
|
||||
prompt_text TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
FOREIGN KEY(claude_session_id) REFERENCES sdk_sessions(claude_session_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_prompts_claude_session ON user_prompts(claude_session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_prompts_created ON user_prompts(created_at_epoch DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_prompts_prompt_number ON user_prompts(prompt_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_prompts_lookup ON user_prompts(claude_session_id, prompt_number);
|
||||
|
||||
-- FTS5 virtual table for user prompts
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS user_prompts_fts USING fts5(
|
||||
prompt_text,
|
||||
content='user_prompts',
|
||||
content_rowid='id'
|
||||
);
|
||||
|
||||
-- Triggers for FTS5 sync
|
||||
CREATE TRIGGER IF NOT EXISTS user_prompts_ai AFTER INSERT ON user_prompts BEGIN
|
||||
INSERT INTO user_prompts_fts(rowid, prompt_text)
|
||||
VALUES (new.id, new.prompt_text);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS user_prompts_ad AFTER DELETE ON user_prompts BEGIN
|
||||
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
|
||||
VALUES('delete', old.id, old.prompt_text);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS user_prompts_au AFTER UPDATE ON user_prompts BEGIN
|
||||
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
|
||||
VALUES('delete', old.id, old.prompt_text);
|
||||
INSERT INTO user_prompts_fts(rowid, prompt_text)
|
||||
VALUES (new.id, new.prompt_text);
|
||||
END;
|
||||
`,
|
||||
},
|
||||
{
|
||||
Version: 11,
|
||||
Name: "discovery_tokens_column",
|
||||
SQL: `
|
||||
ALTER TABLE observations ADD COLUMN discovery_tokens INTEGER DEFAULT 0;
|
||||
ALTER TABLE session_summaries ADD COLUMN discovery_tokens INTEGER DEFAULT 0;
|
||||
`,
|
||||
},
|
||||
{
|
||||
Version: 12,
|
||||
Name: "observations_fts",
|
||||
SQL: `
|
||||
-- FTS5 virtual table for observations
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5(
|
||||
title, subtitle, narrative,
|
||||
content='observations',
|
||||
content_rowid='id'
|
||||
);
|
||||
|
||||
-- Triggers for FTS5 sync
|
||||
CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN
|
||||
INSERT INTO observations_fts(rowid, title, subtitle, narrative)
|
||||
VALUES (new.id, new.title, new.subtitle, new.narrative);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN
|
||||
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative)
|
||||
VALUES('delete', old.id, old.title, old.subtitle, old.narrative);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN
|
||||
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative)
|
||||
VALUES('delete', old.id, old.title, old.subtitle, old.narrative);
|
||||
INSERT INTO observations_fts(rowid, title, subtitle, narrative)
|
||||
VALUES (new.id, new.title, new.subtitle, new.narrative);
|
||||
END;
|
||||
`,
|
||||
},
|
||||
{
|
||||
Version: 13,
|
||||
Name: "session_summaries_fts",
|
||||
SQL: `
|
||||
-- FTS5 virtual table for session summaries
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS session_summaries_fts USING fts5(
|
||||
request, investigated, learned, completed, next_steps, notes,
|
||||
content='session_summaries',
|
||||
content_rowid='id'
|
||||
);
|
||||
|
||||
-- Triggers for FTS5 sync
|
||||
CREATE TRIGGER IF NOT EXISTS session_summaries_ai AFTER INSERT ON session_summaries BEGIN
|
||||
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_summaries_ad AFTER DELETE ON session_summaries BEGIN
|
||||
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_summaries_au AFTER UPDATE ON session_summaries BEGIN
|
||||
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
|
||||
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
|
||||
END;
|
||||
`,
|
||||
},
|
||||
{
|
||||
Version: 14,
|
||||
Name: "observation_scope_column",
|
||||
SQL: `
|
||||
-- Add scope column for project isolation
|
||||
-- 'project' = only visible within same project (default)
|
||||
-- 'global' = visible across all projects (best practices, patterns)
|
||||
ALTER TABLE observations ADD COLUMN scope TEXT DEFAULT 'project' CHECK(scope IN ('project', 'global'));
|
||||
|
||||
-- Index for efficient scope-based queries
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_scope ON observations(scope);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_project_scope ON observations(project, scope);
|
||||
`,
|
||||
},
|
||||
{
|
||||
Version: 15,
|
||||
Name: "observation_file_mtimes",
|
||||
SQL: `
|
||||
-- Store file modification times at observation creation
|
||||
-- JSON object: {"path": mtime_epoch_ms, ...}
|
||||
-- Used to detect staleness when files change
|
||||
ALTER TABLE observations ADD COLUMN file_mtimes TEXT;
|
||||
`,
|
||||
},
|
||||
{
|
||||
Version: 16,
|
||||
Name: "prompt_matched_observations",
|
||||
SQL: `
|
||||
-- Track how many observations were found relevant for each prompt
|
||||
-- Displayed in dashboard timeline
|
||||
ALTER TABLE user_prompts ADD COLUMN matched_observations INTEGER DEFAULT 0;
|
||||
`,
|
||||
},
|
||||
{
|
||||
Version: 17,
|
||||
Name: "sqlite_vec_vectors",
|
||||
SQL: `
|
||||
-- Vector embeddings table using sqlite-vec
|
||||
-- Each document (narrative, fact, summary field, prompt) gets one vector
|
||||
-- Uses all-MiniLM-L6-v2 embeddings (384 dimensions)
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS vectors USING vec0(
|
||||
doc_id TEXT PRIMARY KEY,
|
||||
embedding float[384],
|
||||
sqlite_id INTEGER,
|
||||
doc_type TEXT,
|
||||
field_type TEXT,
|
||||
project TEXT,
|
||||
scope TEXT
|
||||
);
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
// MigrationManager handles database schema migrations.
|
||||
type MigrationManager struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewMigrationManager creates a new migration manager.
|
||||
func NewMigrationManager(db *sql.DB) *MigrationManager {
|
||||
return &MigrationManager{db: db}
|
||||
}
|
||||
|
||||
// EnsureSchemaVersionsTable creates the schema_versions table if it doesn't exist.
|
||||
func (m *MigrationManager) EnsureSchemaVersionsTable() error {
|
||||
_, err := m.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_versions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
version INTEGER UNIQUE NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAppliedVersions returns all applied migration versions.
|
||||
func (m *MigrationManager) GetAppliedVersions() (map[int]bool, error) {
|
||||
rows, err := m.db.Query("SELECT version FROM schema_versions ORDER BY version")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
versions := make(map[int]bool)
|
||||
for rows.Next() {
|
||||
var version int
|
||||
if err := rows.Scan(&version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
versions[version] = true
|
||||
}
|
||||
return versions, rows.Err()
|
||||
}
|
||||
|
||||
// ApplyMigration applies a single migration.
|
||||
func (m *MigrationManager) ApplyMigration(migration Migration) error {
|
||||
tx, err := m.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Execute migration SQL
|
||||
if _, err := tx.Exec(migration.SQL); err != nil {
|
||||
return fmt.Errorf("execute migration %d (%s): %w", migration.Version, migration.Name, err)
|
||||
}
|
||||
|
||||
// Record migration
|
||||
_, err = tx.Exec(
|
||||
"INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)",
|
||||
migration.Version, time.Now().Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record migration %d: %w", migration.Version, err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// RunMigrations applies all pending migrations.
|
||||
func (m *MigrationManager) RunMigrations() error {
|
||||
if err := m.EnsureSchemaVersionsTable(); err != nil {
|
||||
return fmt.Errorf("ensure schema_versions table: %w", err)
|
||||
}
|
||||
|
||||
applied, err := m.GetAppliedVersions()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get applied versions: %w", err)
|
||||
}
|
||||
|
||||
for _, migration := range Migrations {
|
||||
if applied[migration.Version] {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := m.ApplyMigration(migration); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
// Package sqlite provides SQLite database operations for claude-mnemonic.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// CleanupFunc is a callback for when observations are cleaned up.
|
||||
// Receives the IDs of deleted observations for downstream cleanup (e.g., vector DB).
|
||||
type CleanupFunc func(ctx context.Context, deletedIDs []int64)
|
||||
|
||||
// ObservationStore provides observation-related database operations.
|
||||
type ObservationStore struct {
|
||||
store *Store
|
||||
cleanupFunc CleanupFunc
|
||||
}
|
||||
|
||||
// NewObservationStore creates a new observation store.
|
||||
func NewObservationStore(store *Store) *ObservationStore {
|
||||
return &ObservationStore{store: store}
|
||||
}
|
||||
|
||||
// SetCleanupFunc sets the callback for when observations are deleted during cleanup.
|
||||
func (s *ObservationStore) SetCleanupFunc(fn CleanupFunc) {
|
||||
s.cleanupFunc = fn
|
||||
}
|
||||
|
||||
// StoreObservation stores a new observation.
|
||||
func (s *ObservationStore) StoreObservation(ctx context.Context, sdkSessionID, project string, obs *models.ParsedObservation, promptNumber int, discoveryTokens int64) (int64, int64, error) {
|
||||
now := time.Now()
|
||||
nowEpoch := now.UnixMilli()
|
||||
|
||||
// Ensure session exists (auto-create if missing)
|
||||
if err := s.ensureSessionExists(ctx, sdkSessionID, project); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
// Determine scope: use parsed scope if set, otherwise auto-determine from concepts
|
||||
scope := obs.Scope
|
||||
if scope == "" {
|
||||
scope = models.DetermineScope(obs.Concepts)
|
||||
}
|
||||
|
||||
factsJSON, _ := json.Marshal(obs.Facts)
|
||||
conceptsJSON, _ := json.Marshal(obs.Concepts)
|
||||
filesReadJSON, _ := json.Marshal(obs.FilesRead)
|
||||
filesModifiedJSON, _ := json.Marshal(obs.FilesModified)
|
||||
fileMtimesJSON, _ := json.Marshal(obs.FileMtimes)
|
||||
|
||||
const query = `
|
||||
INSERT INTO observations
|
||||
(sdk_session_id, project, scope, type, title, subtitle, facts, narrative, concepts,
|
||||
files_read, files_modified, file_mtimes, prompt_number, discovery_tokens, created_at, created_at_epoch)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
result, err := s.store.ExecContext(ctx, query,
|
||||
sdkSessionID, project, string(scope), string(obs.Type),
|
||||
nullString(obs.Title), nullString(obs.Subtitle),
|
||||
string(factsJSON), nullString(obs.Narrative), string(conceptsJSON),
|
||||
string(filesReadJSON), string(filesModifiedJSON), string(fileMtimesJSON),
|
||||
nullInt(promptNumber), discoveryTokens,
|
||||
now.Format(time.RFC3339), nowEpoch,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
|
||||
// Cleanup old observations beyond the limit for this project (async to not block handler)
|
||||
if project != "" {
|
||||
go func(proj string) {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
deletedIDs, _ := s.CleanupOldObservations(cleanupCtx, proj)
|
||||
if len(deletedIDs) > 0 && s.cleanupFunc != nil {
|
||||
s.cleanupFunc(cleanupCtx, deletedIDs)
|
||||
}
|
||||
}(project)
|
||||
}
|
||||
|
||||
return id, nowEpoch, nil
|
||||
}
|
||||
|
||||
// ensureSessionExists creates a session if it doesn't exist.
|
||||
func (s *ObservationStore) ensureSessionExists(ctx context.Context, sdkSessionID, project string) error {
|
||||
return EnsureSessionExists(ctx, s.store, sdkSessionID, project)
|
||||
}
|
||||
|
||||
// GetObservationByID retrieves an observation by ID.
|
||||
func (s *ObservationStore) GetObservationByID(ctx context.Context, id int64) (*models.Observation, error) {
|
||||
const query = `
|
||||
SELECT id, sdk_session_id, project, COALESCE(scope, 'project') as scope, type, title, subtitle, facts, narrative,
|
||||
concepts, files_read, files_modified, file_mtimes, prompt_number, discovery_tokens,
|
||||
created_at, created_at_epoch
|
||||
FROM observations
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
obs, err := scanObservation(s.store.QueryRowContext(ctx, query, id))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return obs, err
|
||||
}
|
||||
|
||||
// GetObservationsByIDs retrieves observations by a list of IDs.
|
||||
func (s *ObservationStore) GetObservationsByIDs(ctx context.Context, ids []int64, orderBy string, limit int) ([]*models.Observation, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build query with placeholders
|
||||
// #nosec G202 -- query uses parameterized placeholders, not user input
|
||||
query := `
|
||||
SELECT id, sdk_session_id, project, COALESCE(scope, 'project') as scope, type, title, subtitle, facts, narrative,
|
||||
concepts, files_read, files_modified, file_mtimes, prompt_number, discovery_tokens,
|
||||
created_at, created_at_epoch
|
||||
FROM observations
|
||||
WHERE id IN (?` + repeatPlaceholders(len(ids)-1) + `)
|
||||
ORDER BY created_at_epoch `
|
||||
|
||||
if orderBy == "date_asc" {
|
||||
query += "ASC"
|
||||
} else {
|
||||
query += "DESC"
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
query += " LIMIT ?"
|
||||
}
|
||||
|
||||
// Convert []int64 to []interface{}
|
||||
args := int64SliceToInterface(ids)
|
||||
if limit > 0 {
|
||||
args = append(args, limit)
|
||||
}
|
||||
|
||||
rows, err := s.store.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanObservationRows(rows)
|
||||
}
|
||||
|
||||
// GetRecentObservations retrieves recent observations for a project.
|
||||
// This includes project-scoped observations for the specified project AND global observations.
|
||||
func (s *ObservationStore) GetRecentObservations(ctx context.Context, project string, limit int) ([]*models.Observation, error) {
|
||||
const query = `
|
||||
SELECT id, sdk_session_id, project, COALESCE(scope, 'project') as scope, type, title, subtitle, facts, narrative,
|
||||
concepts, files_read, files_modified, file_mtimes, prompt_number, discovery_tokens,
|
||||
created_at, created_at_epoch
|
||||
FROM observations
|
||||
WHERE (project = ? AND (scope IS NULL OR scope = 'project'))
|
||||
OR scope = 'global'
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
rows, err := s.store.QueryContext(ctx, query, project, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanObservationRows(rows)
|
||||
}
|
||||
|
||||
// GetObservationsByProjectStrict retrieves observations strictly for a specific project.
|
||||
// Unlike GetRecentObservations, this does NOT include global observations from other projects.
|
||||
// Use this for dashboard filtering where the user expects to see only that project's data.
|
||||
func (s *ObservationStore) GetObservationsByProjectStrict(ctx context.Context, project string, limit int) ([]*models.Observation, error) {
|
||||
const query = `
|
||||
SELECT id, sdk_session_id, project, COALESCE(scope, 'project') as scope, type, title, subtitle, facts, narrative,
|
||||
concepts, files_read, files_modified, file_mtimes, prompt_number, discovery_tokens,
|
||||
created_at, created_at_epoch
|
||||
FROM observations
|
||||
WHERE project = ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
rows, err := s.store.QueryContext(ctx, query, project, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanObservationRows(rows)
|
||||
}
|
||||
|
||||
// GetObservationCount returns the count of observations for a project (including global).
|
||||
func (s *ObservationStore) GetObservationCount(ctx context.Context, project string) (int, error) {
|
||||
const query = `
|
||||
SELECT COUNT(*) FROM observations
|
||||
WHERE project = ? OR scope = 'global'
|
||||
`
|
||||
var count int
|
||||
err := s.store.QueryRowContext(ctx, query, project).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetAllRecentObservations retrieves recent observations across all projects.
|
||||
func (s *ObservationStore) GetAllRecentObservations(ctx context.Context, limit int) ([]*models.Observation, error) {
|
||||
const query = `
|
||||
SELECT id, sdk_session_id, project, COALESCE(scope, 'project') as scope, type, title, subtitle, facts, narrative,
|
||||
concepts, files_read, files_modified, file_mtimes, prompt_number, discovery_tokens,
|
||||
created_at, created_at_epoch
|
||||
FROM observations
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
rows, err := s.store.QueryContext(ctx, query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanObservationRows(rows)
|
||||
}
|
||||
|
||||
// SearchObservationsFTS performs full-text search on observations.
|
||||
func (s *ObservationStore) SearchObservationsFTS(ctx context.Context, query, project string, limit int) ([]*models.Observation, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
// Extract keywords from the query (words > 3 chars, not common)
|
||||
keywords := extractKeywords(query)
|
||||
if len(keywords) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build FTS5 query: keyword1 OR keyword2 OR keyword3
|
||||
ftsTerms := strings.Join(keywords, " OR ")
|
||||
|
||||
// Use FTS5 to search title, subtitle, and narrative
|
||||
const ftsQuery = `
|
||||
SELECT o.id, o.sdk_session_id, o.project, COALESCE(o.scope, 'project') as scope, o.type,
|
||||
o.title, o.subtitle, o.facts, o.narrative, o.concepts, o.files_read, o.files_modified,
|
||||
o.file_mtimes, o.prompt_number, o.discovery_tokens, o.created_at, o.created_at_epoch
|
||||
FROM observations o
|
||||
JOIN observations_fts fts ON o.id = fts.rowid
|
||||
WHERE observations_fts MATCH ?
|
||||
AND (o.project = ? OR o.scope = 'global')
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
rows, err := s.store.QueryContext(ctx, ftsQuery, ftsTerms, project, limit)
|
||||
if err != nil {
|
||||
// FTS failed, try LIKE fallback
|
||||
return s.searchObservationsLike(ctx, keywords, project, limit)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
observations, err := scanObservationRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If FTS returned nothing, try LIKE search
|
||||
if len(observations) == 0 {
|
||||
return s.searchObservationsLike(ctx, keywords, project, limit)
|
||||
}
|
||||
|
||||
return observations, nil
|
||||
}
|
||||
|
||||
// searchObservationsLike performs fallback LIKE search on observations.
|
||||
func (s *ObservationStore) searchObservationsLike(ctx context.Context, keywords []string, project string, limit int) ([]*models.Observation, error) {
|
||||
if len(keywords) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build LIKE conditions for each keyword
|
||||
var conditions []string
|
||||
var args []interface{}
|
||||
|
||||
for _, kw := range keywords {
|
||||
pattern := "%" + kw + "%"
|
||||
conditions = append(conditions, "(title LIKE ? OR subtitle LIKE ? OR narrative LIKE ?)")
|
||||
args = append(args, pattern, pattern, pattern)
|
||||
}
|
||||
|
||||
// #nosec G202 -- query uses parameterized placeholders, not user input
|
||||
query := `
|
||||
SELECT id, sdk_session_id, project, COALESCE(scope, 'project') as scope, type,
|
||||
title, subtitle, facts, narrative, concepts, files_read, files_modified,
|
||||
file_mtimes, prompt_number, discovery_tokens, created_at, created_at_epoch
|
||||
FROM observations
|
||||
WHERE (` + strings.Join(conditions, " OR ") + `)
|
||||
AND (project = ? OR scope = 'global')
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`
|
||||
args = append(args, project, limit)
|
||||
|
||||
rows, err := s.store.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanObservationRows(rows)
|
||||
}
|
||||
|
||||
// extractKeywords extracts significant words from a query.
|
||||
func extractKeywords(query string) []string {
|
||||
// Common words to skip
|
||||
stopWords := map[string]bool{
|
||||
"what": true, "is": true, "the": true, "a": true, "an": true,
|
||||
"how": true, "does": true, "do": true, "can": true, "could": true,
|
||||
"would": true, "should": true, "where": true, "when": true, "why": true,
|
||||
"which": true, "who": true, "this": true, "that": true, "these": true,
|
||||
"those": true, "it": true, "its": true, "for": true, "from": true,
|
||||
"with": true, "about": true, "into": true, "through": true, "during": true,
|
||||
"before": true, "after": true, "above": true, "below": true, "to": true,
|
||||
"of": true, "in": true, "on": true, "at": true, "by": true, "and": true,
|
||||
"or": true, "but": true, "if": true, "then": true, "else": true,
|
||||
"function": true, "method": true, "class": true, "file": true,
|
||||
"code": true, "work": true, "works": true, "working": true,
|
||||
"please": true, "help": true, "me": true, "my": true, "i": true,
|
||||
"tell": true, "show": true, "explain": true, "describe": true,
|
||||
}
|
||||
|
||||
// Split and filter
|
||||
words := strings.FieldsFunc(strings.ToLower(query), func(r rune) bool {
|
||||
return !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_')
|
||||
})
|
||||
|
||||
var keywords []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, word := range words {
|
||||
// Skip short words, stop words, and duplicates
|
||||
if len(word) < 4 || stopWords[word] || seen[word] {
|
||||
continue
|
||||
}
|
||||
seen[word] = true
|
||||
keywords = append(keywords, word)
|
||||
}
|
||||
|
||||
return keywords
|
||||
}
|
||||
|
||||
// DeleteObservations deletes multiple observations by ID.
|
||||
func (s *ObservationStore) DeleteObservations(ctx context.Context, ids []int64) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
query := `DELETE FROM observations WHERE id IN (?` + repeatPlaceholders(len(ids)-1) + `)` // #nosec G202 -- uses parameterized placeholders
|
||||
|
||||
args := make([]interface{}, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
result, err := s.store.db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
// MaxObservationsPerProject is the hard limit of observations per project.
|
||||
const MaxObservationsPerProject = 100
|
||||
|
||||
// CleanupOldObservations deletes observations beyond the limit for a project.
|
||||
// Keeps the most recent MaxObservationsPerProject observations per project.
|
||||
// Returns the IDs of deleted observations for downstream cleanup (e.g., vector DB).
|
||||
func (s *ObservationStore) CleanupOldObservations(ctx context.Context, project string) ([]int64, error) {
|
||||
// First, find IDs that will be deleted
|
||||
const selectQuery = `
|
||||
SELECT id FROM observations
|
||||
WHERE project = ? AND id NOT IN (
|
||||
SELECT id FROM observations
|
||||
WHERE project = ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
)
|
||||
`
|
||||
|
||||
rows, err := s.store.QueryContext(ctx, selectQuery, project, project, MaxObservationsPerProject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var toDelete []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toDelete = append(toDelete, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(toDelete) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Delete the observations
|
||||
const deleteQuery = `
|
||||
DELETE FROM observations
|
||||
WHERE project = ? AND id NOT IN (
|
||||
SELECT id FROM observations
|
||||
WHERE project = ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
)
|
||||
`
|
||||
|
||||
_, err = s.store.ExecContext(ctx, deleteQuery, project, project, MaxObservationsPerProject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toDelete, nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// scanObservation scans a single observation from a row scanner.
|
||||
// This reduces code duplication across all observation query methods.
|
||||
func scanObservation(scanner interface{ Scan(...interface{}) error }) (*models.Observation, error) {
|
||||
var obs models.Observation
|
||||
if err := scanner.Scan(
|
||||
&obs.ID, &obs.SDKSessionID, &obs.Project, &obs.Scope, &obs.Type,
|
||||
&obs.Title, &obs.Subtitle, &obs.Facts, &obs.Narrative,
|
||||
&obs.Concepts, &obs.FilesRead, &obs.FilesModified, &obs.FileMtimes,
|
||||
&obs.PromptNumber, &obs.DiscoveryTokens,
|
||||
&obs.CreatedAt, &obs.CreatedAtEpoch,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &obs, nil
|
||||
}
|
||||
|
||||
// scanObservationRows scans multiple observations from rows.
|
||||
// Caller must close rows after calling this function.
|
||||
func scanObservationRows(rows *sql.Rows) ([]*models.Observation, error) {
|
||||
var observations []*models.Observation
|
||||
for rows.Next() {
|
||||
obs, err := scanObservation(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
observations = append(observations, obs)
|
||||
}
|
||||
return observations, rows.Err()
|
||||
}
|
||||
|
||||
// Note: nullString, nullInt, and repeatPlaceholders are in helpers.go
|
||||
@@ -0,0 +1,374 @@
|
||||
// Package sqlite provides SQLite database operations for claude-mnemonic.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testObservationStore creates an ObservationStore with a test database including FTS5.
|
||||
func testObservationStore(t *testing.T) (*ObservationStore, *Store, func()) {
|
||||
t.Helper()
|
||||
|
||||
db, _, cleanup := testDB(t)
|
||||
createAllTables(t, db)
|
||||
|
||||
store := newStoreFromDB(db)
|
||||
obsStore := NewObservationStore(store)
|
||||
|
||||
return obsStore, store, cleanup
|
||||
}
|
||||
|
||||
func TestObservationStore_StoreAndRetrieve(t *testing.T) {
|
||||
obsStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
obs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Test Observation",
|
||||
Subtitle: "A subtitle",
|
||||
Narrative: "This is a test observation about testing",
|
||||
Facts: []string{"Fact 1", "Fact 2"},
|
||||
Concepts: []string{"testing", "golang"},
|
||||
FilesRead: []string{"test.go"},
|
||||
FilesModified: []string{},
|
||||
}
|
||||
|
||||
id, epoch, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, 1, 100)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, id, int64(0))
|
||||
assert.Greater(t, epoch, int64(0))
|
||||
|
||||
// Retrieve by ID
|
||||
retrieved, err := obsStore.GetObservationByID(ctx, id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrieved)
|
||||
|
||||
assert.Equal(t, id, retrieved.ID)
|
||||
assert.Equal(t, "session-1", retrieved.SDKSessionID)
|
||||
assert.Equal(t, "project-a", retrieved.Project)
|
||||
assert.Equal(t, models.ObsTypeDiscovery, retrieved.Type)
|
||||
assert.Equal(t, "Test Observation", retrieved.Title.String)
|
||||
assert.Equal(t, "A subtitle", retrieved.Subtitle.String)
|
||||
assert.Equal(t, "This is a test observation about testing", retrieved.Narrative.String)
|
||||
assert.Equal(t, []string{"Fact 1", "Fact 2"}, []string(retrieved.Facts))
|
||||
assert.Equal(t, []string{"testing", "golang"}, []string(retrieved.Concepts))
|
||||
}
|
||||
|
||||
func TestObservationStore_GetRecentObservations(t *testing.T) {
|
||||
obsStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create multiple observations
|
||||
for i := 0; i < 10; i++ {
|
||||
obs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Observation " + string(rune('A'+i)),
|
||||
Narrative: "Content " + string(rune('A'+i)),
|
||||
Concepts: []string{"test"},
|
||||
}
|
||||
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, i+1, 100)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(time.Millisecond) // Ensure different timestamps
|
||||
}
|
||||
|
||||
// Get recent with limit 5
|
||||
recent, err := obsStore.GetRecentObservations(ctx, "project-a", 5)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, recent, 5)
|
||||
|
||||
// Get recent with limit 20 (more than exists)
|
||||
recent, err = obsStore.GetRecentObservations(ctx, "project-a", 20)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, recent, 10)
|
||||
}
|
||||
|
||||
func TestObservationStore_SearchObservationsFTS(t *testing.T) {
|
||||
obsStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
// FTS5 tables are created by testObservationStore via testutil.CreateAllTables
|
||||
ctx := context.Background()
|
||||
|
||||
// Create observations with different content
|
||||
observations := []struct {
|
||||
title string
|
||||
narrative string
|
||||
}{
|
||||
{"Authentication implementation", "JWT based authentication flow"},
|
||||
{"Database setup", "PostgreSQL configuration and migrations"},
|
||||
{"Caching layer", "Redis caching implementation"},
|
||||
{"User authentication fix", "Fixed authentication bug in login"},
|
||||
{"API endpoints", "REST API implementation details"},
|
||||
}
|
||||
|
||||
for _, o := range observations {
|
||||
obs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: o.title,
|
||||
Narrative: o.narrative,
|
||||
}
|
||||
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, 1, 100)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
// Search for authentication - should find 2 observations
|
||||
results, err := obsStore.SearchObservationsFTS(ctx, "authentication", "project-a", 50)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(results), 2, "should find at least 2 authentication-related observations")
|
||||
|
||||
// Search for database - should find 1 observation
|
||||
results, err = obsStore.SearchObservationsFTS(ctx, "database PostgreSQL", "project-a", 50)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(results), 1, "should find at least 1 database-related observation")
|
||||
}
|
||||
|
||||
func TestObservationStore_SearchObservationsFTS_LimitRespected(t *testing.T) {
|
||||
obsStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
// FTS5 tables are created by testObservationStore via testutil.CreateAllTables
|
||||
ctx := context.Background()
|
||||
|
||||
// Create 20 observations with similar content
|
||||
for i := 0; i < 20; i++ {
|
||||
obs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Testing observation " + string(rune('A'+i)),
|
||||
Narrative: "This is about testing and quality assurance " + string(rune('A'+i)),
|
||||
}
|
||||
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, 1, 100)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
// Search with limit 5
|
||||
results, err := obsStore.SearchObservationsFTS(ctx, "testing quality", "project-a", 5)
|
||||
require.NoError(t, err)
|
||||
assert.LessOrEqual(t, len(results), 5, "should respect limit of 5")
|
||||
|
||||
// Search with limit 15
|
||||
results, err = obsStore.SearchObservationsFTS(ctx, "testing quality", "project-a", 15)
|
||||
require.NoError(t, err)
|
||||
assert.LessOrEqual(t, len(results), 15, "should respect limit of 15")
|
||||
|
||||
// Search with limit 50 (our new default)
|
||||
results, err = obsStore.SearchObservationsFTS(ctx, "testing quality", "project-a", 50)
|
||||
require.NoError(t, err)
|
||||
assert.LessOrEqual(t, len(results), 50, "should respect limit of 50")
|
||||
assert.Equal(t, 20, len(results), "should return all 20 matching observations")
|
||||
}
|
||||
|
||||
func TestObservationStore_GlobalScope(t *testing.T) {
|
||||
obsStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a project-scoped observation
|
||||
projectObs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Project specific code",
|
||||
Narrative: "This is specific to project-a",
|
||||
Concepts: []string{"project-specific"},
|
||||
}
|
||||
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", projectObs, 1, 100)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a global-scoped observation (has a globalizable concept)
|
||||
globalObs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Security best practice",
|
||||
Narrative: "Always validate user input",
|
||||
Concepts: []string{"security", "best-practice"}, // "security" is in GlobalizableConcepts
|
||||
}
|
||||
_, _, err = obsStore.StoreObservation(ctx, "session-1", "project-a", globalObs, 1, 100)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get recent for project-a - should see both
|
||||
results, err := obsStore.GetRecentObservations(ctx, "project-a", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 2)
|
||||
|
||||
// Get recent for project-b - should only see global observation
|
||||
results, err = obsStore.GetRecentObservations(ctx, "project-b", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 1)
|
||||
assert.Equal(t, "Security best practice", results[0].Title.String)
|
||||
assert.Equal(t, models.ScopeGlobal, results[0].Scope)
|
||||
}
|
||||
|
||||
func TestObservationStore_DeleteObservations(t *testing.T) {
|
||||
obsStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create observations
|
||||
var ids []int64
|
||||
for i := 0; i < 5; i++ {
|
||||
obs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Observation " + string(rune('A'+i)),
|
||||
}
|
||||
id, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, 1, 100)
|
||||
require.NoError(t, err)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
// Verify all exist
|
||||
all, err := obsStore.GetRecentObservations(ctx, "project-a", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, all, 5)
|
||||
|
||||
// Delete first 3
|
||||
deleted, err := obsStore.DeleteObservations(ctx, ids[:3])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), deleted)
|
||||
|
||||
// Verify only 2 remain
|
||||
remaining, err := obsStore.GetRecentObservations(ctx, "project-a", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, remaining, 2)
|
||||
}
|
||||
|
||||
func TestObservationStore_GetObservationCount(t *testing.T) {
|
||||
obsStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create observations for different projects
|
||||
for i := 0; i < 5; i++ {
|
||||
obs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Project A observation " + string(rune('0'+i)),
|
||||
}
|
||||
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, 1, 100)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
obs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Project B observation " + string(rune('0'+i)),
|
||||
}
|
||||
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-b", obs, 1, 100)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create a global observation
|
||||
globalObs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Global observation",
|
||||
Concepts: []string{"best-practice"}, // Makes it global
|
||||
}
|
||||
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", globalObs, 1, 100)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Count for project-a includes its own + global
|
||||
count, err := obsStore.GetObservationCount(ctx, "project-a")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 6, count) // 5 project-a + 1 global
|
||||
|
||||
// Count for project-b includes its own + global
|
||||
count, err = obsStore.GetObservationCount(ctx, "project-b")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 4, count) // 3 project-b + 1 global
|
||||
}
|
||||
|
||||
func TestObservationStore_CleanupOldObservations(t *testing.T) {
|
||||
obsStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create more observations than the limit (MaxObservationsPerProject = 100)
|
||||
// We'll create a smaller number and verify the logic works
|
||||
for i := 0; i < 10; i++ {
|
||||
obs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Observation " + string(rune('A'+i)),
|
||||
}
|
||||
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, i+1, 100)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
// Cleanup should return empty since we're under the limit
|
||||
deletedIDs, err := obsStore.CleanupOldObservations(ctx, "project-a")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, deletedIDs)
|
||||
|
||||
// All 10 should still exist
|
||||
count, err := obsStore.GetObservationCount(ctx, "project-a")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 10, count)
|
||||
}
|
||||
|
||||
func TestObservationStore_SetCleanupFunc(t *testing.T) {
|
||||
obsStore, _, cleanup := testObservationStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Track cleanup calls
|
||||
var cleanupCalledWith []int64
|
||||
obsStore.SetCleanupFunc(func(ctx context.Context, deletedIDs []int64) {
|
||||
cleanupCalledWith = deletedIDs
|
||||
})
|
||||
|
||||
// Store an observation (should trigger cleanup, but won't delete anything under limit)
|
||||
obs := &models.ParsedObservation{
|
||||
Type: models.ObsTypeDiscovery,
|
||||
Title: "Test observation",
|
||||
}
|
||||
_, _, err := obsStore.StoreObservation(ctx, "session-1", "project-a", obs, 1, 100)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Cleanup func should not have been called since nothing was deleted
|
||||
assert.Empty(t, cleanupCalledWith)
|
||||
}
|
||||
|
||||
func TestExtractKeywords(t *testing.T) {
|
||||
tests := []struct {
|
||||
query string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
query: "What is the authentication flow?",
|
||||
expected: []string{"authentication", "flow"},
|
||||
},
|
||||
{
|
||||
query: "How does the database connection work?",
|
||||
expected: []string{"database", "connection"},
|
||||
},
|
||||
{
|
||||
query: "JWT token validation",
|
||||
expected: []string{"token", "validation"},
|
||||
},
|
||||
{
|
||||
query: "the a an is are", // All stop words
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.query, func(t *testing.T) {
|
||||
keywords := extractKeywords(tt.query)
|
||||
for _, exp := range tt.expected {
|
||||
assert.Contains(t, keywords, exp, "should contain keyword: "+exp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// Package sqlite provides SQLite database operations for claude-mnemonic.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// PromptCleanupFunc is a callback for when prompts are cleaned up.
|
||||
// Receives the IDs of deleted prompts for downstream cleanup (e.g., vector DB).
|
||||
type PromptCleanupFunc func(ctx context.Context, deletedIDs []int64)
|
||||
|
||||
// MaxPromptsGlobal is the hard limit of prompts across all projects.
|
||||
const MaxPromptsGlobal = 500
|
||||
|
||||
// PromptStore provides user prompt-related database operations.
|
||||
type PromptStore struct {
|
||||
store *Store
|
||||
cleanupFunc PromptCleanupFunc
|
||||
}
|
||||
|
||||
// NewPromptStore creates a new prompt store.
|
||||
func NewPromptStore(store *Store) *PromptStore {
|
||||
return &PromptStore{store: store}
|
||||
}
|
||||
|
||||
// SetCleanupFunc sets the callback for when prompts are deleted during cleanup.
|
||||
func (s *PromptStore) SetCleanupFunc(fn PromptCleanupFunc) {
|
||||
s.cleanupFunc = fn
|
||||
}
|
||||
|
||||
// SaveUserPromptWithMatches saves a user prompt with matched observation count.
|
||||
func (s *PromptStore) SaveUserPromptWithMatches(ctx context.Context, claudeSessionID string, promptNumber int, promptText string, matchedObservations int) (int64, error) {
|
||||
now := time.Now()
|
||||
|
||||
const query = `
|
||||
INSERT INTO user_prompts
|
||||
(claude_session_id, prompt_number, prompt_text, matched_observations, created_at, created_at_epoch)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
result, err := s.store.ExecContext(ctx, query,
|
||||
claudeSessionID, promptNumber, promptText, matchedObservations,
|
||||
now.Format(time.RFC3339), now.UnixMilli(),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
|
||||
// Cleanup old prompts beyond the global limit (async to not block handler)
|
||||
go func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
deletedIDs, _ := s.CleanupOldPrompts(cleanupCtx)
|
||||
if len(deletedIDs) > 0 && s.cleanupFunc != nil {
|
||||
s.cleanupFunc(cleanupCtx, deletedIDs)
|
||||
}
|
||||
}()
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// CleanupOldPrompts deletes prompts beyond the global limit.
|
||||
// Keeps the most recent MaxPromptsGlobal prompts.
|
||||
// Returns the IDs of deleted prompts for downstream cleanup (e.g., vector DB).
|
||||
func (s *PromptStore) CleanupOldPrompts(ctx context.Context) ([]int64, error) {
|
||||
// First, find IDs that will be deleted
|
||||
const selectQuery = `
|
||||
SELECT id FROM user_prompts
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM user_prompts
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
)
|
||||
`
|
||||
|
||||
rows, err := s.store.QueryContext(ctx, selectQuery, MaxPromptsGlobal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var toDelete []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toDelete = append(toDelete, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(toDelete) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Delete the prompts
|
||||
const deleteQuery = `
|
||||
DELETE FROM user_prompts
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM user_prompts
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
)
|
||||
`
|
||||
|
||||
_, err = s.store.ExecContext(ctx, deleteQuery, MaxPromptsGlobal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toDelete, nil
|
||||
}
|
||||
|
||||
// GetPromptsByIDs retrieves user prompts by a list of IDs.
|
||||
func (s *PromptStore) GetPromptsByIDs(ctx context.Context, ids []int64, orderBy string, limit int) ([]*models.UserPromptWithSession, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build query with placeholders
|
||||
// #nosec G202 -- query uses parameterized placeholders, not user input
|
||||
query := `
|
||||
SELECT up.id, up.claude_session_id, up.prompt_number, up.prompt_text,
|
||||
COALESCE(up.matched_observations, 0) as matched_observations,
|
||||
up.created_at, up.created_at_epoch,
|
||||
COALESCE(s.project, '') as project,
|
||||
COALESCE(s.sdk_session_id, '') as sdk_session_id
|
||||
FROM user_prompts up
|
||||
LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
|
||||
WHERE up.id IN (?` + repeatPlaceholders(len(ids)-1) + `)
|
||||
ORDER BY up.created_at_epoch `
|
||||
|
||||
if orderBy == "date_asc" {
|
||||
query += "ASC"
|
||||
} else {
|
||||
query += "DESC"
|
||||
}
|
||||
|
||||
if limit > 0 {
|
||||
query += " LIMIT ?"
|
||||
}
|
||||
|
||||
args := int64SliceToInterface(ids)
|
||||
if limit > 0 {
|
||||
args = append(args, limit)
|
||||
}
|
||||
|
||||
rows, err := s.store.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanPromptWithSessionRows(rows)
|
||||
}
|
||||
|
||||
// GetAllRecentUserPrompts retrieves recent user prompts across all sessions.
|
||||
func (s *PromptStore) GetAllRecentUserPrompts(ctx context.Context, limit int) ([]*models.UserPromptWithSession, error) {
|
||||
const query = `
|
||||
SELECT up.id, up.claude_session_id, up.prompt_number, up.prompt_text,
|
||||
COALESCE(up.matched_observations, 0) as matched_observations,
|
||||
up.created_at, up.created_at_epoch,
|
||||
COALESCE(s.project, '') as project,
|
||||
COALESCE(s.sdk_session_id, '') as sdk_session_id
|
||||
FROM user_prompts up
|
||||
LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
|
||||
ORDER BY up.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
rows, err := s.store.QueryContext(ctx, query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanPromptWithSessionRows(rows)
|
||||
}
|
||||
|
||||
// GetRecentUserPromptsByProject retrieves recent user prompts for a specific project.
|
||||
func (s *PromptStore) GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error) {
|
||||
const query = `
|
||||
SELECT up.id, up.claude_session_id, up.prompt_number, up.prompt_text,
|
||||
COALESCE(up.matched_observations, 0) as matched_observations,
|
||||
up.created_at, up.created_at_epoch,
|
||||
COALESCE(s.project, '') as project,
|
||||
COALESCE(s.sdk_session_id, '') as sdk_session_id
|
||||
FROM user_prompts up
|
||||
LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
|
||||
WHERE s.project = ?
|
||||
ORDER BY up.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
rows, err := s.store.QueryContext(ctx, query, project, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanPromptWithSessionRows(rows)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func testPromptStore(t *testing.T) (*PromptStore, *Store, func()) {
|
||||
t.Helper()
|
||||
|
||||
db, _, cleanup := testDB(t)
|
||||
createAllTables(t, db)
|
||||
|
||||
store := newStoreFromDB(db)
|
||||
promptStore := NewPromptStore(store)
|
||||
|
||||
return promptStore, store, cleanup
|
||||
}
|
||||
|
||||
func TestPromptStore_SaveUserPromptWithMatches(t *testing.T) {
|
||||
promptStore, store, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a session first
|
||||
seedSession(t, storeDB(store), "claude-1", "sdk-1", "test-project")
|
||||
|
||||
// Save a prompt
|
||||
id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "Help me fix this bug", 5)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, id, int64(0))
|
||||
|
||||
// Verify it was saved
|
||||
var count int
|
||||
err = storeDB(store).QueryRow("SELECT COUNT(*) FROM user_prompts WHERE id = ?", id).Scan(&count)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
}
|
||||
|
||||
func TestPromptStore_GetAllRecentUserPrompts(t *testing.T) {
|
||||
promptStore, store, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a session
|
||||
seedSession(t, storeDB(store), "claude-1", "sdk-1", "test-project")
|
||||
|
||||
// Save multiple prompts
|
||||
for i := 1; i <= 5; i++ {
|
||||
_, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i, "Prompt "+string(rune('A'+i-1)), i)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(time.Millisecond) // Ensure different timestamps
|
||||
}
|
||||
|
||||
// Get recent prompts
|
||||
prompts, err := promptStore.GetAllRecentUserPrompts(ctx, 3)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, prompts, 3)
|
||||
|
||||
// Should be in descending order (most recent first)
|
||||
assert.Equal(t, 5, prompts[0].PromptNumber)
|
||||
}
|
||||
|
||||
func TestPromptStore_GetRecentUserPromptsByProject(t *testing.T) {
|
||||
promptStore, store, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create sessions for different projects
|
||||
seedSession(t, storeDB(store), "claude-1", "sdk-1", "project-a")
|
||||
seedSession(t, storeDB(store), "claude-2", "sdk-2", "project-b")
|
||||
|
||||
// Save prompts for both projects
|
||||
for i := 1; i <= 3; i++ {
|
||||
_, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i, "Project A prompt", 0)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
for i := 1; i <= 2; i++ {
|
||||
_, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-2", i, "Project B prompt", 0)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Get prompts for project-a
|
||||
prompts, err := promptStore.GetRecentUserPromptsByProject(ctx, "project-a", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, prompts, 3)
|
||||
|
||||
// Get prompts for project-b
|
||||
prompts, err = promptStore.GetRecentUserPromptsByProject(ctx, "project-b", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, prompts, 2)
|
||||
}
|
||||
|
||||
func TestPromptStore_CleanupOldPrompts(t *testing.T) {
|
||||
promptStore, store, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a session
|
||||
seedSession(t, storeDB(store), "claude-1", "sdk-1", "test-project")
|
||||
|
||||
// Save more prompts than the limit
|
||||
// Note: MaxPromptsGlobal is 500, but we'll test with a smaller number
|
||||
// by directly calling CleanupOldPrompts
|
||||
for i := 1; i <= 10; i++ {
|
||||
_, err := storeDB(store).Exec(`
|
||||
INSERT INTO user_prompts (claude_session_id, prompt_number, prompt_text, created_at, created_at_epoch)
|
||||
VALUES (?, ?, ?, datetime('now'), ?)
|
||||
`, "claude-1", i, "Prompt "+string(rune('A'+i-1)), time.Now().UnixMilli()+int64(i))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Verify we have 10 prompts
|
||||
var count int
|
||||
err := storeDB(store).QueryRow("SELECT COUNT(*) FROM user_prompts").Scan(&count)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 10, count)
|
||||
|
||||
// Cleanup should return empty since we're under the limit
|
||||
deletedIDs, err := promptStore.CleanupOldPrompts(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, deletedIDs)
|
||||
}
|
||||
|
||||
func TestPromptStore_SetCleanupFunc(t *testing.T) {
|
||||
promptStore, store, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Track cleanup calls
|
||||
var cleanupCalledWith []int64
|
||||
promptStore.SetCleanupFunc(func(ctx context.Context, deletedIDs []int64) {
|
||||
cleanupCalledWith = deletedIDs
|
||||
})
|
||||
|
||||
// Create a session
|
||||
seedSession(t, storeDB(store), "claude-1", "sdk-1", "test-project")
|
||||
|
||||
// Save a prompt (should trigger cleanup, but won't delete anything under limit)
|
||||
_, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "Test prompt", 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Cleanup func should not have been called since nothing was deleted
|
||||
assert.Empty(t, cleanupCalledWith)
|
||||
}
|
||||
|
||||
func TestPromptStore_GetPromptsByIDs(t *testing.T) {
|
||||
promptStore, store, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a session
|
||||
seedSession(t, storeDB(store), "claude-1", "sdk-1", "test-project")
|
||||
|
||||
// Save some prompts and collect their IDs
|
||||
var ids []int64
|
||||
for i := 1; i <= 5; i++ {
|
||||
id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", i, "Prompt "+string(rune('A'+i-1)), 0)
|
||||
require.NoError(t, err)
|
||||
ids = append(ids, id)
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
// Get specific prompts by ID
|
||||
prompts, err := promptStore.GetPromptsByIDs(ctx, ids[:3], "date_desc", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, prompts, 3)
|
||||
|
||||
// Test with ascending order
|
||||
prompts, err = promptStore.GetPromptsByIDs(ctx, ids, "date_asc", 2)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, prompts, 2)
|
||||
assert.Equal(t, 1, prompts[0].PromptNumber)
|
||||
}
|
||||
|
||||
func TestPromptStore_GetPromptsByIDs_EmptyInput(t *testing.T) {
|
||||
promptStore, _, cleanup := testPromptStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Empty IDs should return nil
|
||||
prompts, err := promptStore.GetPromptsByIDs(ctx, []int64{}, "date_desc", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, prompts)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Package sqlite provides SQLite database operations for claude-mnemonic.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// SessionStore provides session-related database operations.
|
||||
type SessionStore struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// NewSessionStore creates a new session store.
|
||||
func NewSessionStore(store *Store) *SessionStore {
|
||||
return &SessionStore{store: store}
|
||||
}
|
||||
|
||||
// CreateSDKSession creates a new SDK session (idempotent - returns existing ID if exists).
|
||||
// This is the KEY to how claude-mnemonic stays unified across hooks.
|
||||
func (s *SessionStore) CreateSDKSession(ctx context.Context, claudeSessionID, project, userPrompt string) (int64, error) {
|
||||
now := time.Now()
|
||||
|
||||
// CRITICAL: INSERT OR IGNORE makes this idempotent
|
||||
const query = `
|
||||
INSERT OR IGNORE INTO sdk_sessions
|
||||
(claude_session_id, sdk_session_id, project, user_prompt, started_at, started_at_epoch, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'active')
|
||||
`
|
||||
|
||||
result, err := s.store.ExecContext(ctx, query,
|
||||
claudeSessionID, claudeSessionID, project, userPrompt,
|
||||
now.Format(time.RFC3339), now.UnixMilli(),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Check if insert happened
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
// Session exists - UPDATE project and user_prompt if we have non-empty values
|
||||
if project != "" {
|
||||
const updateQuery = `
|
||||
UPDATE sdk_sessions
|
||||
SET project = ?, user_prompt = ?
|
||||
WHERE claude_session_id = ?
|
||||
`
|
||||
_, _ = s.store.ExecContext(ctx, updateQuery, project, userPrompt, claudeSessionID)
|
||||
}
|
||||
|
||||
// Fetch existing ID
|
||||
var id int64
|
||||
const selectQuery = `SELECT id FROM sdk_sessions WHERE claude_session_id = ? LIMIT 1`
|
||||
err := s.store.QueryRowContext(ctx, selectQuery, claudeSessionID).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetSessionByID retrieves a session by its database ID.
|
||||
func (s *SessionStore) GetSessionByID(ctx context.Context, id int64) (*models.SDKSession, error) {
|
||||
const query = `
|
||||
SELECT id, claude_session_id, sdk_session_id, project, user_prompt,
|
||||
worker_port, prompt_counter, status, started_at, started_at_epoch,
|
||||
completed_at, completed_at_epoch
|
||||
FROM sdk_sessions
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
var sess models.SDKSession
|
||||
err := s.store.QueryRowContext(ctx, query, id).Scan(
|
||||
&sess.ID, &sess.ClaudeSessionID, &sess.SDKSessionID, &sess.Project, &sess.UserPrompt,
|
||||
&sess.WorkerPort, &sess.PromptCounter, &sess.Status, &sess.StartedAt, &sess.StartedAtEpoch,
|
||||
&sess.CompletedAt, &sess.CompletedAtEpoch,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// FindAnySDKSession finds any session by Claude session ID (any status).
|
||||
func (s *SessionStore) FindAnySDKSession(ctx context.Context, claudeSessionID string) (*models.SDKSession, error) {
|
||||
const query = `
|
||||
SELECT id, claude_session_id, sdk_session_id, project, user_prompt,
|
||||
worker_port, prompt_counter, status, started_at, started_at_epoch,
|
||||
completed_at, completed_at_epoch
|
||||
FROM sdk_sessions
|
||||
WHERE claude_session_id = ?
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
var sess models.SDKSession
|
||||
err := s.store.QueryRowContext(ctx, query, claudeSessionID).Scan(
|
||||
&sess.ID, &sess.ClaudeSessionID, &sess.SDKSessionID, &sess.Project, &sess.UserPrompt,
|
||||
&sess.WorkerPort, &sess.PromptCounter, &sess.Status, &sess.StartedAt, &sess.StartedAtEpoch,
|
||||
&sess.CompletedAt, &sess.CompletedAtEpoch,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// IncrementPromptCounter increments the prompt counter and returns the new value.
|
||||
func (s *SessionStore) IncrementPromptCounter(ctx context.Context, id int64) (int, error) {
|
||||
const updateQuery = `
|
||||
UPDATE sdk_sessions
|
||||
SET prompt_counter = COALESCE(prompt_counter, 0) + 1
|
||||
WHERE id = ?
|
||||
`
|
||||
if _, err := s.store.ExecContext(ctx, updateQuery, id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
const selectQuery = `SELECT prompt_counter FROM sdk_sessions WHERE id = ?`
|
||||
var counter int
|
||||
err := s.store.QueryRowContext(ctx, selectQuery, id).Scan(&counter)
|
||||
return counter, err
|
||||
}
|
||||
|
||||
// GetPromptCounter returns the current prompt counter for a session.
|
||||
func (s *SessionStore) GetPromptCounter(ctx context.Context, id int64) (int, error) {
|
||||
const query = `SELECT COALESCE(prompt_counter, 0) FROM sdk_sessions WHERE id = ?`
|
||||
var counter int
|
||||
err := s.store.QueryRowContext(ctx, query, id).Scan(&counter)
|
||||
return counter, err
|
||||
}
|
||||
|
||||
// GetSessionsToday returns the count of sessions started today.
|
||||
func (s *SessionStore) GetSessionsToday(ctx context.Context) (int, error) {
|
||||
// Get start of today in milliseconds
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
startEpoch := startOfDay.UnixMilli()
|
||||
|
||||
const query = `SELECT COUNT(*) FROM sdk_sessions WHERE started_at_epoch >= ?`
|
||||
|
||||
var count int
|
||||
err := s.store.QueryRowContext(ctx, query, startEpoch).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetAllProjects returns all unique project names.
|
||||
func (s *SessionStore) GetAllProjects(ctx context.Context) ([]string, error) {
|
||||
const query = `
|
||||
SELECT DISTINCT project
|
||||
FROM sdk_sessions
|
||||
WHERE project IS NOT NULL AND project != ''
|
||||
ORDER BY project ASC
|
||||
`
|
||||
|
||||
rows, err := s.store.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var projects []string
|
||||
for rows.Next() {
|
||||
var project string
|
||||
if err := rows.Scan(&project); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
projects = append(projects, project)
|
||||
}
|
||||
return projects, rows.Err()
|
||||
}
|
||||
@@ -1,50 +1,24 @@
|
||||
//go:build fts5
|
||||
|
||||
// Package gorm provides GORM-based database operations for claude-mnemonic.
|
||||
package gorm
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm/logger"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testSessionStore creates a SessionStore with a temporary database for testing.
|
||||
func testSessionStore(t *testing.T) (*SessionStore, *Store, func()) {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "gorm_session_test_*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
cfg := Config{
|
||||
Path: dbPath,
|
||||
MaxConns: 4,
|
||||
LogLevel: logger.Silent,
|
||||
}
|
||||
|
||||
store, err := NewStore(cfg)
|
||||
if err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
db, _, cleanup := testDB(t)
|
||||
createAllTables(t, db)
|
||||
|
||||
store := newStoreFromDB(db)
|
||||
sessionStore := NewSessionStore(store)
|
||||
|
||||
cleanup := func() {
|
||||
store.Close()
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
return sessionStore, store, cleanup
|
||||
}
|
||||
|
||||
@@ -66,8 +40,6 @@ func TestSessionStore_CreateSDKSession(t *testing.T) {
|
||||
assert.Equal(t, "claude-1", sess.ClaudeSessionID)
|
||||
assert.Equal(t, "test-project", sess.Project)
|
||||
assert.Equal(t, models.SessionStatusActive, sess.Status)
|
||||
assert.True(t, sess.UserPrompt.Valid)
|
||||
assert.Equal(t, "initial prompt", sess.UserPrompt.String)
|
||||
}
|
||||
|
||||
func TestSessionStore_CreateSDKSession_Idempotent(t *testing.T) {
|
||||
@@ -91,24 +63,6 @@ func TestSessionStore_CreateSDKSession_Idempotent(t *testing.T) {
|
||||
sess, err := sessionStore.GetSessionByID(ctx, id1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "project-b", sess.Project)
|
||||
assert.Equal(t, "prompt 2", sess.UserPrompt.String)
|
||||
}
|
||||
|
||||
func TestSessionStore_CreateSDKSession_EmptyPrompt(t *testing.T) {
|
||||
sessionStore, _, cleanup := testSessionStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create session with empty prompt
|
||||
id, err := sessionStore.CreateSDKSession(ctx, "claude-2", "test-project", "")
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, id, int64(0))
|
||||
|
||||
// Verify prompt is NULL
|
||||
sess, err := sessionStore.GetSessionByID(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, sess.UserPrompt.Valid)
|
||||
}
|
||||
|
||||
func TestSessionStore_FindAnySDKSession(t *testing.T) {
|
||||
@@ -133,18 +87,6 @@ func TestSessionStore_FindAnySDKSession(t *testing.T) {
|
||||
assert.Nil(t, sess)
|
||||
}
|
||||
|
||||
func TestSessionStore_GetSessionByID_NotFound(t *testing.T) {
|
||||
sessionStore, _, cleanup := testSessionStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Try to get non-existent session
|
||||
sess, err := sessionStore.GetSessionByID(ctx, 99999)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, sess)
|
||||
}
|
||||
|
||||
func TestSessionStore_IncrementPromptCounter(t *testing.T) {
|
||||
sessionStore, _, cleanup := testSessionStore(t)
|
||||
defer cleanup()
|
||||
@@ -182,22 +124,23 @@ func TestSessionStore_GetSessionsToday(t *testing.T) {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Initially should be 0
|
||||
// Initially no sessions today
|
||||
count, err := sessionStore.GetSessionsToday(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, count)
|
||||
|
||||
// Create some sessions
|
||||
_, err = sessionStore.CreateSDKSession(ctx, "claude-1", "project-1", "")
|
||||
_, err = sessionStore.CreateSDKSession(ctx, "claude-1", "project-a", "")
|
||||
require.NoError(t, err)
|
||||
_, err = sessionStore.CreateSDKSession(ctx, "claude-2", "project-b", "")
|
||||
require.NoError(t, err)
|
||||
_, err = sessionStore.CreateSDKSession(ctx, "claude-3", "project-c", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = sessionStore.CreateSDKSession(ctx, "claude-2", "project-2", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should now have 2 sessions today
|
||||
// Should have 3 sessions today
|
||||
count, err = sessionStore.GetSessionsToday(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
assert.Equal(t, 3, count)
|
||||
}
|
||||
|
||||
func TestSessionStore_GetAllProjects(t *testing.T) {
|
||||
@@ -206,35 +149,57 @@ func TestSessionStore_GetAllProjects(t *testing.T) {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Initially should be empty
|
||||
// Create sessions for different projects
|
||||
_, err := sessionStore.CreateSDKSession(ctx, "claude-1", "alpha-project", "")
|
||||
require.NoError(t, err)
|
||||
_, err = sessionStore.CreateSDKSession(ctx, "claude-2", "beta-project", "")
|
||||
require.NoError(t, err)
|
||||
_, err = sessionStore.CreateSDKSession(ctx, "claude-3", "alpha-project", "") // duplicate
|
||||
require.NoError(t, err)
|
||||
_, err = sessionStore.CreateSDKSession(ctx, "claude-4", "gamma-project", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get all projects
|
||||
projects, err := sessionStore.GetAllProjects(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, projects)
|
||||
assert.Len(t, projects, 3)
|
||||
assert.Contains(t, projects, "alpha-project")
|
||||
assert.Contains(t, projects, "beta-project")
|
||||
assert.Contains(t, projects, "gamma-project")
|
||||
|
||||
// Create sessions with different projects
|
||||
_, err = sessionStore.CreateSDKSession(ctx, "claude-1", "project-a", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = sessionStore.CreateSDKSession(ctx, "claude-2", "project-b", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = sessionStore.CreateSDKSession(ctx, "claude-3", "project-a", "") // Duplicate project
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should get distinct projects in alphabetical order
|
||||
projects, err = sessionStore.GetAllProjects(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"project-a", "project-b"}, projects)
|
||||
// Should be sorted alphabetically
|
||||
assert.Equal(t, "alpha-project", projects[0])
|
||||
}
|
||||
|
||||
func TestSessionStore_SessionFields(t *testing.T) {
|
||||
func TestSessionStore_GetSessionByID_NotFound(t *testing.T) {
|
||||
sessionStore, _, cleanup := testSessionStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a session
|
||||
id, err := sessionStore.CreateSDKSession(ctx, "claude-1", "test-project", "test prompt")
|
||||
// Non-existent ID should return nil, nil (not an error)
|
||||
sess, err := sessionStore.GetSessionByID(ctx, 999)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, sess)
|
||||
}
|
||||
|
||||
func TestSessionStore_SessionFields(t *testing.T) {
|
||||
sessionStore, store, cleanup := testSessionStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a session with full details
|
||||
id, err := sessionStore.CreateSDKSession(ctx, "claude-full", "full-project", "full user prompt")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Manually update additional fields for testing
|
||||
now := time.Now()
|
||||
_, err = storeDB(store).Exec(`
|
||||
UPDATE sdk_sessions
|
||||
SET worker_port = ?, completed_at = ?, completed_at_epoch = ?, status = 'completed'
|
||||
WHERE id = ?
|
||||
`, 37777, now.Format(time.RFC3339), now.UnixMilli(), id)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve and verify all fields
|
||||
@@ -242,18 +207,12 @@ func TestSessionStore_SessionFields(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, sess)
|
||||
|
||||
// Verify all fields
|
||||
assert.Equal(t, id, sess.ID)
|
||||
assert.Equal(t, "claude-1", sess.ClaudeSessionID)
|
||||
assert.True(t, sess.SDKSessionID.Valid)
|
||||
assert.Equal(t, "claude-1", sess.SDKSessionID.String) // Should be same as ClaudeSessionID
|
||||
assert.Equal(t, "test-project", sess.Project)
|
||||
assert.True(t, sess.UserPrompt.Valid)
|
||||
assert.Equal(t, "test prompt", sess.UserPrompt.String)
|
||||
assert.Equal(t, int64(0), sess.PromptCounter)
|
||||
assert.Equal(t, models.SessionStatusActive, sess.Status)
|
||||
assert.NotEmpty(t, sess.StartedAt)
|
||||
assert.Greater(t, sess.StartedAtEpoch, int64(0))
|
||||
assert.False(t, sess.CompletedAt.Valid) // Should be NULL
|
||||
assert.False(t, sess.CompletedAtEpoch.Valid) // Should be NULL
|
||||
assert.Equal(t, "claude-full", sess.ClaudeSessionID)
|
||||
assert.Equal(t, "full-project", sess.Project)
|
||||
assert.Equal(t, models.SessionStatusCompleted, sess.Status)
|
||||
assert.True(t, sess.WorkerPort.Valid)
|
||||
assert.Equal(t, int64(37777), sess.WorkerPort.Int64)
|
||||
assert.True(t, sess.CompletedAt.Valid)
|
||||
assert.True(t, sess.CompletedAtEpoch.Valid)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Package sqlite provides SQLite database operations for claude-mnemonic.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// Store provides database operations with connection pooling and prepared statements.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
stmtCache map[string]*sql.Stmt
|
||||
stmtMu sync.RWMutex
|
||||
}
|
||||
|
||||
// StoreConfig holds configuration for the database store.
|
||||
type StoreConfig struct {
|
||||
Path string
|
||||
MaxConns int
|
||||
WALMode bool
|
||||
}
|
||||
|
||||
// NewStore creates a new database store with the given configuration.
|
||||
func NewStore(cfg StoreConfig) (*Store, error) {
|
||||
// Register sqlite-vec extension for vector operations
|
||||
sqlite_vec.Auto()
|
||||
|
||||
// Build connection string with pragmas
|
||||
connStr := cfg.Path + "?_journal_mode=WAL&_synchronous=NORMAL&_foreign_keys=ON"
|
||||
|
||||
db, err := sql.Open("sqlite3", connStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
// Configure connection pool
|
||||
maxConns := cfg.MaxConns
|
||||
if maxConns <= 0 {
|
||||
maxConns = 4
|
||||
}
|
||||
db.SetMaxOpenConns(maxConns)
|
||||
db.SetMaxIdleConns(maxConns)
|
||||
db.SetConnMaxLifetime(0) // Never expire - SQLite connections are cheap
|
||||
|
||||
// Verify connection
|
||||
if err := db.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
store := &Store{
|
||||
db: db,
|
||||
stmtCache: make(map[string]*sql.Stmt),
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
mgr := NewMigrationManager(db)
|
||||
if err := mgr.RunMigrations(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("run migrations: %w", err)
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// Close closes the database connection and all cached statements.
|
||||
func (s *Store) Close() error {
|
||||
s.stmtMu.Lock()
|
||||
defer s.stmtMu.Unlock()
|
||||
|
||||
for _, stmt := range s.stmtCache {
|
||||
_ = stmt.Close()
|
||||
}
|
||||
s.stmtCache = nil
|
||||
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// GetStmt returns a cached prepared statement, creating it if necessary.
|
||||
func (s *Store) GetStmt(query string) (*sql.Stmt, error) {
|
||||
s.stmtMu.RLock()
|
||||
stmt, ok := s.stmtCache[query]
|
||||
s.stmtMu.RUnlock()
|
||||
if ok {
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
s.stmtMu.Lock()
|
||||
defer s.stmtMu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if stmt, ok := s.stmtCache[query]; ok {
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
stmt, err := s.db.Prepare(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.stmtCache[query] = stmt
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
// ExecContext executes a query that doesn't return rows.
|
||||
func (s *Store) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
|
||||
stmt, err := s.GetStmt(query)
|
||||
if err != nil {
|
||||
// Fall back to direct execution
|
||||
return s.db.ExecContext(ctx, query, args...)
|
||||
}
|
||||
return stmt.ExecContext(ctx, args...)
|
||||
}
|
||||
|
||||
// QueryContext executes a query that returns rows.
|
||||
func (s *Store) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
|
||||
stmt, err := s.GetStmt(query)
|
||||
if err != nil {
|
||||
// Fall back to direct execution
|
||||
return s.db.QueryContext(ctx, query, args...)
|
||||
}
|
||||
return stmt.QueryContext(ctx, args...)
|
||||
}
|
||||
|
||||
// QueryRowContext executes a query that returns a single row.
|
||||
func (s *Store) QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row {
|
||||
stmt, err := s.GetStmt(query)
|
||||
if err != nil {
|
||||
// Fall back to direct execution
|
||||
return s.db.QueryRowContext(ctx, query, args...)
|
||||
}
|
||||
return stmt.QueryRowContext(ctx, args...)
|
||||
}
|
||||
|
||||
// Ping checks if the database connection is alive.
|
||||
func (s *Store) Ping() error {
|
||||
return s.db.Ping()
|
||||
}
|
||||
|
||||
// DB returns the underlying database connection for direct access.
|
||||
// Use this sparingly - prefer the store methods for most operations.
|
||||
func (s *Store) DB() *sql.DB {
|
||||
return s.db
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Package sqlite provides SQLite database operations for claude-mnemonic.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// SummaryStore provides summary-related database operations.
|
||||
type SummaryStore struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// NewSummaryStore creates a new summary store.
|
||||
func NewSummaryStore(store *Store) *SummaryStore {
|
||||
return &SummaryStore{store: store}
|
||||
}
|
||||
|
||||
// StoreSummary stores a new session summary.
|
||||
func (s *SummaryStore) StoreSummary(ctx context.Context, sdkSessionID, project string, summary *models.ParsedSummary, promptNumber int, discoveryTokens int64) (int64, int64, error) {
|
||||
now := time.Now()
|
||||
nowEpoch := now.UnixMilli()
|
||||
|
||||
// Ensure session exists (auto-create if missing)
|
||||
if err := s.ensureSessionExists(ctx, sdkSessionID, project); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO session_summaries
|
||||
(sdk_session_id, project, request, investigated, learned, completed,
|
||||
next_steps, notes, prompt_number, discovery_tokens, created_at, created_at_epoch)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
result, err := s.store.ExecContext(ctx, query,
|
||||
sdkSessionID, project,
|
||||
nullString(summary.Request), nullString(summary.Investigated),
|
||||
nullString(summary.Learned), nullString(summary.Completed),
|
||||
nullString(summary.NextSteps), nullString(summary.Notes),
|
||||
nullInt(promptNumber), discoveryTokens,
|
||||
now.Format(time.RFC3339), nowEpoch,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
return id, nowEpoch, nil
|
||||
}
|
||||
|
||||
// ensureSessionExists creates a session if it doesn't exist.
|
||||
func (s *SummaryStore) ensureSessionExists(ctx context.Context, sdkSessionID, project string) error {
|
||||
return EnsureSessionExists(ctx, s.store, sdkSessionID, project)
|
||||
}
|
||||
|
||||
// GetSummariesByIDs retrieves summaries by a list of IDs.
|
||||
func (s *SummaryStore) GetSummariesByIDs(ctx context.Context, ids []int64, orderBy string, limit int) ([]*models.SessionSummary, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
const baseQuery = `
|
||||
SELECT id, sdk_session_id, project, request, investigated, learned, completed,
|
||||
next_steps, notes, prompt_number, discovery_tokens, created_at, created_at_epoch
|
||||
FROM session_summaries`
|
||||
|
||||
query, args := BuildGetByIDsQuery(baseQuery, ids, orderBy, limit)
|
||||
|
||||
rows, err := s.store.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanSummaryRows(rows)
|
||||
}
|
||||
|
||||
// GetRecentSummaries retrieves recent summaries for a project.
|
||||
func (s *SummaryStore) GetRecentSummaries(ctx context.Context, project string, limit int) ([]*models.SessionSummary, error) {
|
||||
const query = `
|
||||
SELECT id, sdk_session_id, project, request, investigated, learned, completed,
|
||||
next_steps, notes, prompt_number, discovery_tokens, created_at, created_at_epoch
|
||||
FROM session_summaries
|
||||
WHERE project = ?
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
rows, err := s.store.QueryContext(ctx, query, project, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanSummaryRows(rows)
|
||||
}
|
||||
|
||||
// GetAllRecentSummaries retrieves recent summaries across all projects.
|
||||
func (s *SummaryStore) GetAllRecentSummaries(ctx context.Context, limit int) ([]*models.SessionSummary, error) {
|
||||
const query = `
|
||||
SELECT id, sdk_session_id, project, request, investigated, learned, completed,
|
||||
next_steps, notes, prompt_number, discovery_tokens, created_at, created_at_epoch
|
||||
FROM session_summaries
|
||||
ORDER BY created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
rows, err := s.store.QueryContext(ctx, query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanSummaryRows(rows)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func testSummaryStore(t *testing.T) (*SummaryStore, *Store, func()) {
|
||||
t.Helper()
|
||||
|
||||
db, _, cleanup := testDB(t)
|
||||
createAllTables(t, db)
|
||||
|
||||
store := newStoreFromDB(db)
|
||||
summaryStore := NewSummaryStore(store)
|
||||
|
||||
return summaryStore, store, cleanup
|
||||
}
|
||||
|
||||
func TestSummaryStore_StoreSummary(t *testing.T) {
|
||||
summaryStore, store, cleanup := testSummaryStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a session first
|
||||
seedSession(t, storeDB(store), "claude-1", "sdk-1", "test-project")
|
||||
|
||||
summary := &models.ParsedSummary{
|
||||
Request: "Add new feature",
|
||||
Investigated: "Looked at existing code",
|
||||
Learned: "Found the pattern to follow",
|
||||
Completed: "Implemented the feature",
|
||||
NextSteps: "Add tests",
|
||||
Notes: "Some additional notes",
|
||||
}
|
||||
|
||||
id, epoch, err := summaryStore.StoreSummary(ctx, "sdk-1", "test-project", summary, 1, 100)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, id, int64(0))
|
||||
assert.Greater(t, epoch, int64(0))
|
||||
|
||||
// Verify it was saved
|
||||
var count int
|
||||
err = storeDB(store).QueryRow("SELECT COUNT(*) FROM session_summaries WHERE id = ?", id).Scan(&count)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
}
|
||||
|
||||
func TestSummaryStore_StoreSummary_AutoCreateSession(t *testing.T) {
|
||||
summaryStore, store, cleanup := testSummaryStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Don't create session beforehand - should be auto-created
|
||||
summary := &models.ParsedSummary{
|
||||
Request: "Test request",
|
||||
}
|
||||
|
||||
id, _, err := summaryStore.StoreSummary(ctx, "auto-session", "test-project", summary, 1, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, id, int64(0))
|
||||
|
||||
// Verify session was auto-created
|
||||
var sessionCount int
|
||||
err = storeDB(store).QueryRow("SELECT COUNT(*) FROM sdk_sessions WHERE sdk_session_id = ?", "auto-session").Scan(&sessionCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, sessionCount)
|
||||
}
|
||||
|
||||
func TestSummaryStore_GetRecentSummaries(t *testing.T) {
|
||||
summaryStore, store, cleanup := testSummaryStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a session
|
||||
seedSession(t, storeDB(store), "claude-1", "sdk-1", "test-project")
|
||||
|
||||
// Store multiple summaries
|
||||
for i := 0; i < 5; i++ {
|
||||
summary := &models.ParsedSummary{
|
||||
Request: "Request " + string(rune('A'+i)),
|
||||
}
|
||||
_, _, err := summaryStore.StoreSummary(ctx, "sdk-1", "test-project", summary, i+1, 0)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(time.Millisecond) // Ensure different timestamps
|
||||
}
|
||||
|
||||
// Get recent summaries with limit
|
||||
summaries, err := summaryStore.GetRecentSummaries(ctx, "test-project", 3)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, summaries, 3)
|
||||
|
||||
// Should be in descending order
|
||||
assert.Equal(t, int64(5), summaries[0].PromptNumber.Int64)
|
||||
}
|
||||
|
||||
func TestSummaryStore_GetAllRecentSummaries(t *testing.T) {
|
||||
summaryStore, store, cleanup := testSummaryStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create sessions for different projects
|
||||
seedSession(t, storeDB(store), "claude-1", "sdk-1", "project-a")
|
||||
seedSession(t, storeDB(store), "claude-2", "sdk-2", "project-b")
|
||||
|
||||
// Store summaries for both projects
|
||||
for i := 0; i < 3; i++ {
|
||||
summary := &models.ParsedSummary{Request: "Project A request"}
|
||||
_, _, err := summaryStore.StoreSummary(ctx, "sdk-1", "project-a", summary, i+1, 0)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
summary := &models.ParsedSummary{Request: "Project B request"}
|
||||
_, _, err := summaryStore.StoreSummary(ctx, "sdk-2", "project-b", summary, i+1, 0)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Get all summaries (should include both projects)
|
||||
summaries, err := summaryStore.GetAllRecentSummaries(ctx, 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, summaries, 5)
|
||||
}
|
||||
|
||||
func TestSummaryStore_GetSummariesByIDs(t *testing.T) {
|
||||
summaryStore, store, cleanup := testSummaryStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a session
|
||||
seedSession(t, storeDB(store), "claude-1", "sdk-1", "test-project")
|
||||
|
||||
// Store summaries and collect IDs
|
||||
var ids []int64
|
||||
for i := 0; i < 5; i++ {
|
||||
summary := &models.ParsedSummary{Request: "Request " + string(rune('A'+i))}
|
||||
id, _, err := summaryStore.StoreSummary(ctx, "sdk-1", "test-project", summary, i+1, 0)
|
||||
require.NoError(t, err)
|
||||
ids = append(ids, id)
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
// Get specific summaries by ID
|
||||
summaries, err := summaryStore.GetSummariesByIDs(ctx, ids[:3], "date_desc", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, summaries, 3)
|
||||
|
||||
// Test with ascending order
|
||||
summaries, err = summaryStore.GetSummariesByIDs(ctx, ids, "date_asc", 2)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, summaries, 2)
|
||||
assert.Equal(t, int64(1), summaries[0].PromptNumber.Int64)
|
||||
}
|
||||
|
||||
func TestSummaryStore_GetSummariesByIDs_EmptyInput(t *testing.T) {
|
||||
summaryStore, _, cleanup := testSummaryStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Empty IDs should return nil
|
||||
summaries, err := summaryStore.GetSummariesByIDs(ctx, []int64{}, "date_desc", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, summaries)
|
||||
}
|
||||
|
||||
func TestSummaryStore_SummaryFields(t *testing.T) {
|
||||
summaryStore, store, cleanup := testSummaryStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a session
|
||||
seedSession(t, storeDB(store), "claude-1", "sdk-1", "test-project")
|
||||
|
||||
// Store a summary with all fields
|
||||
summary := &models.ParsedSummary{
|
||||
Request: "Add authentication",
|
||||
Investigated: "Reviewed existing auth code",
|
||||
Learned: "OAuth is preferred",
|
||||
Completed: "Implemented OAuth flow",
|
||||
NextSteps: "Add refresh token support",
|
||||
Notes: "Consider rate limiting",
|
||||
}
|
||||
|
||||
id, _, err := summaryStore.StoreSummary(ctx, "sdk-1", "test-project", summary, 5, 1500)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve and verify all fields
|
||||
summaries, err := summaryStore.GetSummariesByIDs(ctx, []int64{id}, "date_desc", 1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, summaries, 1)
|
||||
|
||||
s := summaries[0]
|
||||
assert.Equal(t, id, s.ID)
|
||||
assert.Equal(t, "sdk-1", s.SDKSessionID)
|
||||
assert.Equal(t, "test-project", s.Project)
|
||||
assert.Equal(t, "Add authentication", s.Request.String)
|
||||
assert.Equal(t, "Reviewed existing auth code", s.Investigated.String)
|
||||
assert.Equal(t, "OAuth is preferred", s.Learned.String)
|
||||
assert.Equal(t, "Implemented OAuth flow", s.Completed.String)
|
||||
assert.Equal(t, "Add refresh token support", s.NextSteps.String)
|
||||
assert.Equal(t, "Consider rate limiting", s.Notes.String)
|
||||
assert.Equal(t, int64(5), s.PromptNumber.Int64)
|
||||
assert.Equal(t, int64(1500), s.DiscoveryTokens)
|
||||
}
|
||||
|
||||
func TestSummaryStore_EmptySummary(t *testing.T) {
|
||||
summaryStore, store, cleanup := testSummaryStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a session
|
||||
seedSession(t, storeDB(store), "claude-1", "sdk-1", "test-project")
|
||||
|
||||
// Store an empty summary
|
||||
summary := &models.ParsedSummary{}
|
||||
|
||||
id, _, err := summaryStore.StoreSummary(ctx, "sdk-1", "test-project", summary, 0, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, id, int64(0))
|
||||
|
||||
// Retrieve and verify null fields
|
||||
summaries, err := summaryStore.GetSummariesByIDs(ctx, []int64{id}, "date_desc", 1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, summaries, 1)
|
||||
|
||||
s := summaries[0]
|
||||
assert.False(t, s.Request.Valid || s.Request.String != "")
|
||||
assert.False(t, s.Investigated.Valid || s.Investigated.String != "")
|
||||
assert.False(t, s.Learned.Valid || s.Learned.String != "")
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// newStoreFromDB creates a Store from an existing database connection for testing.
|
||||
func newStoreFromDB(db *sql.DB) *Store {
|
||||
return &Store{
|
||||
db: db,
|
||||
stmtCache: make(map[string]*sql.Stmt),
|
||||
}
|
||||
}
|
||||
|
||||
// storeDB returns the underlying database connection from a store for testing.
|
||||
func storeDB(s *Store) *sql.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// testDB creates a temporary SQLite database for testing.
|
||||
// Returns the database, path, and a cleanup function.
|
||||
func testDB(t *testing.T) (*sql.DB, string, func()) {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "claude-mnemonic-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp dir: %v", err)
|
||||
}
|
||||
|
||||
dbPath := tmpDir + "/test.db"
|
||||
connStr := dbPath + "?_journal_mode=WAL&_synchronous=NORMAL&_foreign_keys=ON"
|
||||
|
||||
db, err := sql.Open("sqlite3", connStr)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_ = db.Close()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
return db, dbPath, cleanup
|
||||
}
|
||||
|
||||
// createBaseTables creates the base tables without FTS5 for unit testing.
|
||||
func createBaseTables(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_versions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
version INTEGER UNIQUE NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create schema_versions: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS sdk_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
claude_session_id TEXT UNIQUE NOT NULL,
|
||||
sdk_session_id TEXT UNIQUE,
|
||||
project TEXT NOT NULL,
|
||||
user_prompt TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
started_at_epoch INTEGER NOT NULL,
|
||||
completed_at TEXT,
|
||||
completed_at_epoch INTEGER,
|
||||
status TEXT CHECK(status IN ('active', 'completed', 'failed')) NOT NULL DEFAULT 'active',
|
||||
worker_port INTEGER,
|
||||
prompt_counter INTEGER DEFAULT 0
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create sdk_sessions: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sdk_session_id TEXT NOT NULL,
|
||||
project TEXT NOT NULL,
|
||||
text TEXT,
|
||||
type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')),
|
||||
title TEXT,
|
||||
subtitle TEXT,
|
||||
facts TEXT,
|
||||
narrative TEXT,
|
||||
concepts TEXT,
|
||||
files_read TEXT,
|
||||
files_modified TEXT,
|
||||
file_mtimes TEXT,
|
||||
scope TEXT DEFAULT 'project' CHECK(scope IN ('project', 'global')),
|
||||
prompt_number INTEGER,
|
||||
discovery_tokens INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
FOREIGN KEY(sdk_session_id) REFERENCES sdk_sessions(sdk_session_id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create observations: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS session_summaries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sdk_session_id TEXT NOT NULL,
|
||||
project TEXT NOT NULL,
|
||||
request TEXT,
|
||||
investigated TEXT,
|
||||
learned TEXT,
|
||||
completed TEXT,
|
||||
next_steps TEXT,
|
||||
files_read TEXT,
|
||||
files_edited TEXT,
|
||||
notes TEXT,
|
||||
prompt_number INTEGER,
|
||||
discovery_tokens INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
FOREIGN KEY(sdk_session_id) REFERENCES sdk_sessions(sdk_session_id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create session_summaries: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_prompts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
claude_session_id TEXT NOT NULL,
|
||||
prompt_number INTEGER NOT NULL,
|
||||
prompt_text TEXT NOT NULL,
|
||||
matched_observations INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
FOREIGN KEY(claude_session_id) REFERENCES sdk_sessions(claude_session_id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create user_prompts: %v", err)
|
||||
}
|
||||
|
||||
indexes := []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_sdk_sessions_claude_id ON sdk_sessions(claude_session_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sdk_sessions_sdk_id ON sdk_sessions(sdk_session_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sdk_sessions_project ON sdk_sessions(project)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_sdk_session ON observations(sdk_session_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_scope ON observations(scope)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at_epoch DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_session_summaries_sdk_session ON session_summaries(sdk_session_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_session_summaries_project ON session_summaries(project)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_prompts_claude_session ON user_prompts(claude_session_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_prompts_created ON user_prompts(created_at_epoch DESC)`,
|
||||
}
|
||||
for _, idx := range indexes {
|
||||
if _, err := db.Exec(idx); err != nil {
|
||||
t.Fatalf("create index: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seedSession creates a test session in the database.
|
||||
func seedSession(t *testing.T, db *sql.DB, claudeSessionID, sdkSessionID, project string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO sdk_sessions (claude_session_id, sdk_session_id, project, started_at, started_at_epoch, status)
|
||||
VALUES (?, ?, ?, datetime('now'), strftime('%s', 'now') * 1000, 'active')
|
||||
`, claudeSessionID, sdkSessionID, project)
|
||||
if err != nil {
|
||||
t.Fatalf("seed session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// hasFTS5 checks if FTS5 is available in the SQLite build.
|
||||
func hasFTS5(db *sql.DB) bool {
|
||||
_, err := db.Exec("CREATE VIRTUAL TABLE IF NOT EXISTS fts5_test USING fts5(content)")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, _ = db.Exec("DROP TABLE IF EXISTS fts5_test")
|
||||
return true
|
||||
}
|
||||
|
||||
// createFTSTables creates FTS5 virtual tables and triggers for full-text search.
|
||||
func createFTSTables(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
|
||||
if !hasFTS5(db) {
|
||||
t.Skip("FTS5 not available in this SQLite build")
|
||||
}
|
||||
|
||||
_, err := db.Exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5(
|
||||
title, subtitle, narrative,
|
||||
content='observations',
|
||||
content_rowid='id'
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create observations_fts: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN
|
||||
INSERT INTO observations_fts(rowid, title, subtitle, narrative)
|
||||
VALUES (new.id, new.title, new.subtitle, new.narrative);
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create observations_ai trigger: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN
|
||||
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative)
|
||||
VALUES ('delete', old.id, old.title, old.subtitle, old.narrative);
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create observations_ad trigger: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN
|
||||
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative)
|
||||
VALUES ('delete', old.id, old.title, old.subtitle, old.narrative);
|
||||
INSERT INTO observations_fts(rowid, title, subtitle, narrative)
|
||||
VALUES (new.id, new.title, new.subtitle, new.narrative);
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create observations_au trigger: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS session_summaries_fts USING fts5(
|
||||
request, investigated, learned, completed, next_steps, notes,
|
||||
content='session_summaries',
|
||||
content_rowid='id'
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create session_summaries_fts: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS summaries_ai AFTER INSERT ON session_summaries BEGIN
|
||||
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create summaries_ai trigger: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS summaries_ad AFTER DELETE ON session_summaries BEGIN
|
||||
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES ('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create summaries_ad trigger: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS user_prompts_fts USING fts5(
|
||||
prompt_text,
|
||||
content='user_prompts',
|
||||
content_rowid='id'
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create user_prompts_fts: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS prompts_ai AFTER INSERT ON user_prompts BEGIN
|
||||
INSERT INTO user_prompts_fts(rowid, prompt_text)
|
||||
VALUES (new.id, new.prompt_text);
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create prompts_ai trigger: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS prompts_ad AFTER DELETE ON user_prompts BEGIN
|
||||
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
|
||||
VALUES ('delete', old.id, old.prompt_text);
|
||||
END
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("create prompts_ad trigger: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// createAllTables creates all tables including FTS5 for comprehensive testing.
|
||||
func createAllTables(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
createBaseTables(t, db)
|
||||
createFTSTables(t, db)
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
// Package embedding provides text embedding generation using bge-small-en-v1.5.
|
||||
// Package embedding provides text embedding generation using all-MiniLM-L6-v2.
|
||||
package embedding
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
// Tokenizer file - embedded for all platforms (small, not in LFS).
|
||||
// The ONNX model is downloaded at runtime to ~/.claude-mnemonic/models/.
|
||||
// Model and tokenizer files - embedded for all platforms
|
||||
//
|
||||
//go:embed assets/model.onnx
|
||||
var modelData []byte
|
||||
|
||||
//go:embed assets/tokenizer.json
|
||||
var tokenizerData []byte
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
bge-small-en-v1.5
|
||||
@@ -1 +1 @@
|
||||
1.30.0
|
||||
1.23.2
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.30.0
|
||||
1.23.2
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.30.0
|
||||
1.23.2
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.30.0
|
||||
1.23.2
|
||||
|
||||
Binary file not shown.
@@ -1,7 +1,21 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"truncation": null,
|
||||
"padding": null,
|
||||
"truncation": {
|
||||
"direction": "Right",
|
||||
"max_length": 128,
|
||||
"strategy": "LongestFirst",
|
||||
"stride": 0
|
||||
},
|
||||
"padding": {
|
||||
"strategy": {
|
||||
"Fixed": 128
|
||||
},
|
||||
"direction": "Right",
|
||||
"pad_to_multiple_of": null,
|
||||
"pad_id": 0,
|
||||
"pad_type_id": 0,
|
||||
"pad_token": "[PAD]"
|
||||
},
|
||||
"added_tokens": [
|
||||
{
|
||||
"id": 0,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user