mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-11-01 07:34:29 +00:00
Compare commits
18
Commits
v0.12.21
...
6115ea8a44
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6115ea8a44 | ||
|
|
93446d1437 | ||
|
|
1d1807da9e | ||
|
|
7bdb523106 | ||
|
|
abd34c9629 | ||
|
|
5fe25e9328 | ||
|
|
b7b82ce22f | ||
|
|
f78370a531 | ||
|
|
5f89559842 | ||
|
|
794eacf02a | ||
|
|
6fc752abb1 | ||
|
|
9ce29ac7b1 | ||
|
|
a0d0e856f5 | ||
|
|
549fefc675 | ||
|
|
c5967c3eaa | ||
|
|
dbe89f37c8 | ||
|
|
1a4fea5c17 | ||
|
|
c8b462aaec |
+2
-1
@@ -1 +1,2 @@
|
|||||||
internal/embedding/assets/*.onnx filter=lfs diff=lfs merge=lfs -text
|
# Models are now downloaded at runtime from GitHub Releases.
|
||||||
|
# LFS tracking removed. See internal/models/download.go
|
||||||
|
|||||||
@@ -17,5 +17,5 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: ">=1.24"
|
go-version: ">=1.24"
|
||||||
release-workflow: "release.yaml"
|
release-workflow: "release.yaml"
|
||||||
lfs: true
|
lfs: false
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
@@ -20,6 +20,6 @@ jobs:
|
|||||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
|
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
|
||||||
with:
|
with:
|
||||||
go-version: ">=1.24"
|
go-version: ">=1.24"
|
||||||
lfs: true
|
lfs: false
|
||||||
build-tags: "fts5"
|
build-tags: "fts5"
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: ">=1.24"
|
go-version: ">=1.24"
|
||||||
node-enabled: true
|
node-enabled: true
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
node-build-script: "cd ui && npm ci && npm run build"
|
node-build-script: "cd ui && npm ci && npm run build"
|
||||||
node-cache-dependency-path: "ui/package-lock.json"
|
node-cache-dependency-path: "ui/package-lock.json"
|
||||||
node-output-path: "ui/dist"
|
node-output-path: "ui/dist"
|
||||||
node-embed-path: "internal/worker/static"
|
node-embed-path: "internal/worker/static"
|
||||||
lfs: true
|
lfs: false
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
commit-marketplace:
|
commit-marketplace:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '22'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: 'docs/package-lock.json'
|
cache-dependency-path: 'docs/package-lock.json'
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ logs/
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
|
||||||
|
# Model files (downloaded at runtime from GitHub Releases)
|
||||||
|
# Test model data (downloaded from Hugging Face)
|
||||||
|
testdata/models/
|
||||||
|
internal/embedding/assets/model.onnx
|
||||||
|
internal/reranking/assets/model.onnx
|
||||||
|
|
||||||
# goreleaser
|
# goreleaser
|
||||||
dist/
|
dist/
|
||||||
docs/dist
|
docs/dist
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ all: build
|
|||||||
setup-libs:
|
setup-libs:
|
||||||
@./scripts/download-onnx-libs.sh all
|
@./scripts/download-onnx-libs.sh all
|
||||||
|
|
||||||
|
# Download ONNX models from Hugging Face (for local dev, skips if present)
|
||||||
|
setup-models:
|
||||||
|
@./scripts/download-models.sh
|
||||||
|
|
||||||
# Update version in committed plugin metadata
|
# Update version in committed plugin metadata
|
||||||
update-version:
|
update-version:
|
||||||
@if [ -f .claude-plugin/plugin.json ]; then \
|
@if [ -f .claude-plugin/plugin.json ]; then \
|
||||||
@@ -182,12 +186,14 @@ uninstall: stop-worker
|
|||||||
@echo "Uninstallation complete!"
|
@echo "Uninstallation complete!"
|
||||||
|
|
||||||
# Run tests (with FTS5 support)
|
# Run tests (with FTS5 support)
|
||||||
test: setup-libs
|
# CLAUDE_MNEMONIC_MODEL_DIR points to a common model directory for tests.
|
||||||
go test $(BUILD_TAGS) -v -race ./...
|
# Run setup-models first to download models from Hugging Face.
|
||||||
|
test: setup-libs setup-models
|
||||||
|
CLAUDE_MNEMONIC_MODEL_DIR=$$(pwd)/testdata/models go test $(BUILD_TAGS) -v -race ./...
|
||||||
|
|
||||||
# Run tests with coverage (with FTS5 support)
|
# Run tests with coverage (with FTS5 support)
|
||||||
test-coverage: setup-libs
|
test-coverage: setup-libs setup-models
|
||||||
go test $(BUILD_TAGS) -v -race -coverprofile=coverage.out ./...
|
CLAUDE_MNEMONIC_MODEL_DIR=$$(pwd)/testdata/models go test $(BUILD_TAGS) -v -race -coverprofile=coverage.out ./...
|
||||||
go tool cover -html=coverage.out -o coverage.html
|
go tool cover -html=coverage.out -o coverage.html
|
||||||
@go tool cover -func=coverage.out | tail -1
|
@go tool cover -func=coverage.out | tail -1
|
||||||
|
|
||||||
|
|||||||
@@ -91,18 +91,21 @@ func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
|
|||||||
observationCount int
|
observationCount int
|
||||||
)
|
)
|
||||||
|
|
||||||
// Start search in background
|
// 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)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
searchResult, _ = hooks.GET(ctx.Port, searchURL)
|
searchResult, _ = hooks.GETWithContext(deadline, ctx.Port, searchURL)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Start session init in parallel (with observationCount=0; approximate is fine)
|
// Start session init in parallel (with observationCount=0; approximate is fine).
|
||||||
|
// Deadline context guards this call too.
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
initResult, initErr = hooks.POST(ctx.Port, "/api/sessions/init", map[string]interface{}{
|
initResult, initErr = hooks.POSTWithContextResult(deadline, ctx.Port, "/api/sessions/init", map[string]interface{}{
|
||||||
"claudeSessionId": ctx.SessionID,
|
"claudeSessionId": ctx.SessionID,
|
||||||
"project": ctx.Project,
|
"project": ctx.Project,
|
||||||
"prompt": input.Prompt,
|
"prompt": input.Prompt,
|
||||||
@@ -113,7 +116,8 @@ func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
|
|||||||
// Wait for both to complete
|
// Wait for both to complete
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
// Check deadline after network calls
|
// 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 {
|
if deadline.Err() != nil {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -173,9 +177,11 @@ func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
|
|||||||
contextToInject = contextBuilder
|
contextToInject = contextBuilder
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check session init result
|
// 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 {
|
if initErr != nil {
|
||||||
return "", initErr
|
fmt.Fprintf(os.Stderr, "[user-prompt] Session init failed: %v\n", initErr)
|
||||||
|
return contextToInject, nil
|
||||||
}
|
}
|
||||||
if initResult == nil {
|
if initResult == nil {
|
||||||
return contextToInject, nil // Non-JSON response from worker, skip session init
|
return contextToInject, nil // Non-JSON response from worker, skip session init
|
||||||
@@ -201,13 +207,15 @@ func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
|
|||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "[user-prompt] Session %d, prompt #%d\n", sessionID, promptNumber)
|
fmt.Fprintf(os.Stderr, "[user-prompt] Session %d, prompt #%d\n", sessionID, promptNumber)
|
||||||
|
|
||||||
// Start SDK agent (depends on session init result, so kept sequential)
|
// Start SDK agent (depends on session init result, so kept sequential).
|
||||||
_, err := hooks.POST(ctx.Port, fmt.Sprintf("/sessions/%d/init", sessionID), map[string]interface{}{
|
// Deadline-guarded so a wedged worker cannot stall past the hook budget.
|
||||||
|
// Failure here must not block the prompt: degrade gracefully, still inject memory.
|
||||||
|
if _, err := hooks.POSTWithContextResult(deadline, ctx.Port, fmt.Sprintf("/sessions/%d/init", sessionID), map[string]interface{}{
|
||||||
"userPrompt": input.Prompt,
|
"userPrompt": input.Prompt,
|
||||||
"promptNumber": promptNumber,
|
"promptNumber": promptNumber,
|
||||||
})
|
}); err != nil {
|
||||||
if err != nil {
|
fmt.Fprintf(os.Stderr, "[user-prompt] SDK agent init failed: %v\n", err)
|
||||||
return "", err
|
return contextToInject, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return context if we found relevant observations
|
// Return context if we found relevant observations
|
||||||
|
|||||||
Generated
+731
-1396
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -8,13 +8,13 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.25"
|
"vue": "^3.5.34"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"autoprefixer": "^10.4.23",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.15",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^4.3.0",
|
||||||
"vite": "^7.3.0"
|
"vite": "^8.0.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'@tailwindcss/postcss': {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+134
-374
@@ -1,95 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-slate-950 text-white min-h-screen relative overflow-hidden">
|
<div class="min-h-screen flex flex-col">
|
||||||
<!-- Animated Background -->
|
|
||||||
<div class="fixed inset-0 pointer-events-none">
|
|
||||||
<!-- Gradient orbs -->
|
|
||||||
<div class="absolute top-0 left-1/4 w-96 h-96 bg-amber-500/10 rounded-full blur-3xl animate-float"></div>
|
|
||||||
<div class="absolute bottom-1/4 right-1/4 w-80 h-80 bg-amber-400/5 rounded-full blur-3xl animate-float-delayed"></div>
|
|
||||||
<div class="absolute top-1/2 right-0 w-64 h-64 bg-slate-700/20 rounded-full blur-3xl animate-glow"></div>
|
|
||||||
|
|
||||||
<!-- Grid pattern -->
|
|
||||||
<div class="absolute inset-0 bg-[linear-gradient(rgba(148,163,184,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(148,163,184,0.03)_1px,transparent_1px)] bg-[size:50px_50px]"></div>
|
|
||||||
|
|
||||||
<!-- Floating particles -->
|
|
||||||
<div v-for="i in 12" :key="i"
|
|
||||||
class="particle animate-particle"
|
|
||||||
:style="{
|
|
||||||
left: `${(i * 8.3) % 100}%`,
|
|
||||||
animationDelay: `${i * 1.5}s`,
|
|
||||||
animationDuration: `${15 + (i % 5) * 3}s`
|
|
||||||
}"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NavBar :mobile-menu-open="mobileMenuOpen" @toggle-menu="mobileMenuOpen = !mobileMenuOpen" />
|
<NavBar :mobile-menu-open="mobileMenuOpen" @toggle-menu="mobileMenuOpen = !mobileMenuOpen" />
|
||||||
|
|
||||||
<HeroSection
|
<main class="flex-1">
|
||||||
badge="The missing piece for Claude Code"
|
<HeroSection />
|
||||||
title-before="Yesterday's context."
|
|
||||||
title-highlight="Today's session."
|
|
||||||
subtitle="Stop re-explaining your codebase. Claude Mnemonic captures bug fixes, architecture decisions, and coding patterns - then brings them back exactly when you need them."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Dashboard Preview -->
|
<section class="py-10 px-4 sm:px-6">
|
||||||
<section class="py-12 lg:py-16 px-4 sm:px-6 relative">
|
<div class="max-w-5xl mx-auto">
|
||||||
<div class="max-w-6xl mx-auto">
|
<div class="rounded-xl overflow-hidden border border-neutral-800">
|
||||||
<div class="relative rounded-xl overflow-hidden border border-slate-700/50 shadow-2xl shadow-amber-500/5">
|
<img src="/claude-mnemonic.jpg" alt="Claude Mnemonic Dashboard" class="w-full h-auto block" loading="lazy" />
|
||||||
<div class="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent pointer-events-none z-10"></div>
|
|
||||||
<img
|
|
||||||
src="/claude-mnemonic.jpg"
|
|
||||||
alt="Claude Mnemonic Dashboard"
|
|
||||||
class="w-full h-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-center text-slate-500 text-sm mt-4">The dashboard at localhost:37777 - browse, search, and manage your memories. View graph stats, vector metrics, storage savings, and performance analytics.</p>
|
<p class="text-center text-neutral-600 text-xs mt-3 tracking-wide uppercase">Dashboard — localhost:37777</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Problem Section -->
|
<section id="features" class="py-20 px-4 sm:px-6">
|
||||||
<section class="py-20 lg:py-28 px-4 sm:px-6 relative">
|
<div class="max-w-5xl mx-auto">
|
||||||
<div class="max-w-6xl mx-auto grid lg:grid-cols-2 gap-8 lg:gap-16 items-center">
|
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">What it does</h2>
|
||||||
<div>
|
<p class="text-neutral-500 mb-12 max-w-xl">Captures context from your Claude Code sessions and brings it back when you need it. No manual effort.</p>
|
||||||
<h2 class="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-6">Sound familiar?</h2>
|
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<p class="text-slate-400 text-base sm:text-lg mb-8">Every Claude Code session starts fresh. That bug you fixed last Tuesday? Gone. The auth flow you explained in detail? Forgotten. Your team's naming conventions? You'll explain them again.</p>
|
<FeatureItem
|
||||||
<ul class="space-y-4 sm:space-y-5">
|
|
||||||
<li class="flex items-start gap-3 sm:gap-4 text-slate-400">
|
|
||||||
<i class="fas fa-times-circle text-red-400 mt-1 flex-shrink-0"></i>
|
|
||||||
<span class="text-sm sm:text-base">"We fixed this exact race condition last week..."</span>
|
|
||||||
</li>
|
|
||||||
<li class="flex items-start gap-3 sm:gap-4 text-slate-400">
|
|
||||||
<i class="fas fa-times-circle text-red-400 mt-1 flex-shrink-0"></i>
|
|
||||||
<span class="text-sm sm:text-base">"No, we use kebab-case for API routes, not camelCase..."</span>
|
|
||||||
</li>
|
|
||||||
<li class="flex items-start gap-3 sm:gap-4 text-slate-400">
|
|
||||||
<i class="fas fa-times-circle text-red-400 mt-1 flex-shrink-0"></i>
|
|
||||||
<span class="text-sm sm:text-base">"The database is Postgres, not MySQL. I told you yesterday..."</span>
|
|
||||||
</li>
|
|
||||||
<li class="flex items-start gap-3 sm:gap-4 text-white">
|
|
||||||
<i class="fas fa-check-circle text-amber-500 mt-1 flex-shrink-0"></i>
|
|
||||||
<span class="text-sm sm:text-base"><strong>Mnemonic remembers so you don't have to repeat yourself.</strong></span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="glass rounded-2xl p-6 sm:p-8 relative overflow-hidden glow-amber">
|
|
||||||
<div class="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-amber-500 to-transparent"></div>
|
|
||||||
<p class="text-slate-500 text-xs sm:text-sm mb-4 font-mono">// What Mnemonic captures automatically:</p>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<FlowItem icon="fas fa-bug" title="Bug fixes & solutions" description='"Fixed N+1 query in user loader by adding .includes(:posts)"' />
|
|
||||||
<FlowItem icon="fas fa-sitemap" title="Architecture decisions" description='"Using event sourcing for audit trail - all mutations go through EventStore"' />
|
|
||||||
<FlowItem icon="fas fa-code-branch" title="Project conventions" description='"API routes use /api/v1/ prefix, kebab-case for endpoints"' />
|
|
||||||
<FlowItem icon="fas fa-shield-alt" title="Security patterns" description='"Always validate JWT on server side, never trust client claims"' />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Features Section -->
|
|
||||||
<section id="features" class="py-20 lg:py-28 bg-slate-900/30 relative">
|
|
||||||
<div class="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-slate-700 to-transparent"></div>
|
|
||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
|
||||||
<SectionHeader title="Built for real workflows" subtitle="Not just storage - intelligent context that makes Claude more useful over time" />
|
|
||||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
|
||||||
<FeatureCard
|
|
||||||
v-for="feature in features"
|
v-for="feature in features"
|
||||||
:key="feature.title"
|
:key="feature.title"
|
||||||
:icon="feature.icon"
|
:icon="feature.icon"
|
||||||
@@ -100,92 +30,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Knowledge Graph Preview -->
|
<section class="py-10 px-4 sm:px-6">
|
||||||
<section class="py-16 lg:py-20 px-4 sm:px-6 relative">
|
|
||||||
<div class="max-w-5xl mx-auto">
|
<div class="max-w-5xl mx-auto">
|
||||||
<SectionHeader title="See how knowledge connects" subtitle="The knowledge graph reveals relationships between your memories automatically" />
|
<div class="rounded-xl overflow-hidden border border-neutral-800">
|
||||||
<div class="relative rounded-xl overflow-hidden border border-slate-700/50 shadow-2xl shadow-purple-500/5">
|
<img src="/observation-relation-graph.jpg" alt="Knowledge Graph" class="w-full h-auto block" loading="lazy" />
|
||||||
<img
|
|
||||||
src="/observation-relation-graph.jpg"
|
|
||||||
alt="Knowledge Graph Visualization"
|
|
||||||
class="w-full h-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-center text-slate-500 text-sm mt-4">Click any observation to explore its relationships - see what causes what, what fixes what, and how concepts evolve</p>
|
<p class="text-center text-neutral-600 text-xs mt-3 tracking-wide uppercase">Observation relationship graph</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Before/After Section -->
|
<section id="install" class="py-20 px-4 sm:px-6">
|
||||||
<section class="py-20 lg:py-28 px-4 sm:px-6">
|
<div class="max-w-5xl mx-auto">
|
||||||
<div class="max-w-6xl mx-auto">
|
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Install</h2>
|
||||||
<SectionHeader title="The difference" subtitle="Same question. Different experience." />
|
<p class="text-neutral-500 mb-8">One command. No configuration. No account.</p>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- After -->
|
<div class="flex gap-1 mb-6">
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<!-- 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="flex flex-wrap justify-center gap-2 mb-6 sm:mb-8">
|
|
||||||
<button
|
<button
|
||||||
v-for="tab in installTabs"
|
v-for="tab in installTabs"
|
||||||
:key="tab.id"
|
:key="tab.id"
|
||||||
@click="activeTab = tab.id"
|
@click="activeTab = tab.id"
|
||||||
:class="[
|
:class="[
|
||||||
'px-4 sm:px-5 py-2 sm:py-2.5 rounded-lg text-xs sm:text-sm font-medium transition-all',
|
'px-4 py-2 text-sm rounded-lg transition-colors',
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'bg-amber-500/15 border border-amber-500 text-amber-500'
|
? 'bg-neutral-800 text-white'
|
||||||
: 'glass text-slate-400 hover:border-slate-600'
|
: 'text-neutral-500 hover:text-neutral-300'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
@@ -193,206 +61,93 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="activeTab === 'macos'">
|
<div v-show="activeTab === 'macos'">
|
||||||
<CodeBlock :code="installCommands.macos">
|
<CodeBlock code="curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash" />
|
||||||
<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>
|
||||||
|
|
||||||
<div v-show="activeTab === 'windows'">
|
<div v-show="activeTab === 'windows'">
|
||||||
<CodeBlock :code="installCommands.windows">
|
<CodeBlock code="irm https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.ps1 | iex" />
|
||||||
<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>
|
||||||
|
|
||||||
<div v-show="activeTab === 'source'">
|
<div v-show="activeTab === 'source'">
|
||||||
<CodeBlock :code="installCommands.source">
|
<CodeBlock code="git clone https://github.com/lukaszraczylo/claude-mnemonic.git cd claude-mnemonic make build && make install" />
|
||||||
<span class="text-slate-500"># For contributors and tinkerers</span>
|
<p class="text-neutral-600 text-xs mt-2">Requires: Go 1.24+, Node.js 18+, CGO compiler</p>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<p class="text-center text-slate-500 mt-6 sm:mt-8 text-xs sm:text-sm">
|
<div class="mt-8 p-4 rounded-lg border border-neutral-800 bg-neutral-900/50 max-w-2xl">
|
||||||
After install, open <a href="http://localhost:37777" class="text-amber-400 hover:underline animated-underline">localhost:37777</a> to see the dashboard.
|
<p class="text-neutral-500 text-sm">
|
||||||
Start a new Claude Code CLI session - memory is now active.
|
<i class="fas fa-info-circle text-neutral-600 mr-1"></i>
|
||||||
|
After install, open <a href="http://localhost:37777" class="text-amber-500 hover:text-amber-400">localhost:37777</a> for the dashboard. Start a new Claude Code session — memory is active.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Requirements Section -->
|
<section id="requirements" class="py-20 px-4 sm:px-6">
|
||||||
<section id="requirements" class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
|
<div class="max-w-5xl mx-auto">
|
||||||
<div class="max-w-6xl mx-auto">
|
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Requirements</h2>
|
||||||
<SectionHeader title="Requirements" subtitle="Minimal dependencies. Everything else is built-in." />
|
<p class="text-neutral-500 mb-8">Minimal dependencies. Everything else is built in.</p>
|
||||||
|
|
||||||
<div class="max-w-xl mx-auto">
|
<div class="grid sm:grid-cols-2 gap-4 max-w-xl">
|
||||||
<div class="glass rounded-2xl p-5 sm:p-6">
|
<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-4">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<i class="fas fa-check-circle text-green-500"></i>
|
<i :class="[req.icon, 'text-amber-500 text-sm']"></i>
|
||||||
<span class="text-white font-semibold text-sm sm:text-base">That's all you need</span>
|
<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>
|
||||||
|
</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>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<code class="text-white text-xs sm:text-sm font-semibold">{{ req.name }}</code>
|
<code class="text-amber-500 text-xs">{{ config.name }}</code>
|
||||||
<p class="text-slate-400 text-xs mt-1">{{ req.description }}</p>
|
<p class="text-neutral-500 text-xs mt-0.5">{{ config.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>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Configuration Section -->
|
|
||||||
<section id="configuration" class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
|
|
||||||
<div class="max-w-6xl mx-auto">
|
|
||||||
<SectionHeader title="Fully configurable" subtitle="Works out of the box, but adapts to your preferences" />
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 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 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-center text-slate-500 mt-6 sm:mt-8 text-xs sm:text-sm">
|
<p class="text-neutral-600 text-xs mt-6">
|
||||||
All settings can also be set via environment variables. See <a href="https://github.com/lukaszraczylo/claude-mnemonic#configuration" target="_blank" class="text-amber-400 hover:underline">full documentation</a> for all options.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Technical Details -->
|
<section id="architecture" class="py-20 px-4 sm:px-6">
|
||||||
<section class="py-20 lg:py-28 px-4 sm:px-6">
|
<div class="max-w-5xl mx-auto">
|
||||||
<div class="max-w-6xl mx-auto">
|
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Under the hood</h2>
|
||||||
<SectionHeader title="Under the hood" subtitle="Built with simplicity and performance in mind" />
|
<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 sm:gap-6 text-center">
|
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
|
<TechCard v-for="tech in techStack" :key="tech.name" :name="tech.name" :description="tech.description" />
|
||||||
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">Go</div>
|
|
||||||
<p class="text-slate-400 text-xs sm:text-sm">Single binary. Fast startup, low memory. Zero runtime dependencies.</p>
|
|
||||||
</div>
|
|
||||||
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
|
|
||||||
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">SQLite</div>
|
|
||||||
<p class="text-slate-400 text-xs sm:text-sm">FTS5 full-text search. Single file database. Survives restarts.</p>
|
|
||||||
</div>
|
|
||||||
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
|
|
||||||
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">sqlite-vec</div>
|
|
||||||
<p class="text-slate-400 text-xs sm:text-sm">Hybrid vector storage with LEANN-inspired selective embeddings. 60-80% storage reduction.</p>
|
|
||||||
</div>
|
|
||||||
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
|
|
||||||
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">BGE</div>
|
|
||||||
<p class="text-slate-400 text-xs sm:text-sm">Two-stage retrieval: bi-encoder embeddings + cross-encoder reranking for high accuracy.</p>
|
|
||||||
</div>
|
|
||||||
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
|
|
||||||
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">Tree-sitter</div>
|
|
||||||
<p class="text-slate-400 text-xs sm:text-sm">AST-aware code chunking respects function boundaries for Go, Python, and TypeScript.</p>
|
|
||||||
</div>
|
|
||||||
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
|
|
||||||
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">CSR Graph</div>
|
|
||||||
<p class="text-slate-400 text-xs sm:text-sm">Memory-efficient observation relationship graph with edge detection and hub identification.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Trust Section -->
|
<section id="faq" class="py-20 px-4 sm:px-6">
|
||||||
<section class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
|
<div class="max-w-3xl mx-auto">
|
||||||
<div class="max-w-4xl mx-auto">
|
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-10">FAQ</h2>
|
||||||
<SectionHeader title="Open source. Real person." subtitle="Not another anonymous package - built by a developer you can verify" />
|
|
||||||
|
|
||||||
<div class="glass rounded-2xl p-6 sm:p-8 text-center">
|
|
||||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-6 sm:gap-8 mb-6">
|
|
||||||
<a href="https://github.com/lukaszraczylo" target="_blank" class="group">
|
|
||||||
<img
|
|
||||||
src="https://github.com/lukaszraczylo.png"
|
|
||||||
alt="Lukasz Raczylo"
|
|
||||||
class="w-20 h-20 sm:w-24 sm:h-24 rounded-full border-2 border-slate-700 group-hover:border-amber-500 transition-colors"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
<div class="text-center sm:text-left">
|
|
||||||
<h3 class="text-white font-semibold text-lg sm:text-xl mb-1">Lukasz Raczylo</h3>
|
|
||||||
<p class="text-slate-400 text-sm mb-3">Principal Engineer & Open Source Maintainer</p>
|
|
||||||
<div class="flex flex-wrap justify-center sm:justify-start gap-3">
|
|
||||||
<a href="https://github.com/lukaszraczylo" target="_blank" class="text-slate-500 hover:text-white transition-colors text-sm">
|
|
||||||
<i class="fab fa-github mr-1"></i>GitHub
|
|
||||||
</a>
|
|
||||||
<a href="https://linkedin.com/in/lukaszraczylo" target="_blank" class="text-slate-500 hover:text-white transition-colors text-sm">
|
|
||||||
<i class="fab fa-linkedin mr-1"></i>LinkedIn
|
|
||||||
</a>
|
|
||||||
<a href="https://raczylo.com" target="_blank" class="text-slate-500 hover:text-white transition-colors text-sm">
|
|
||||||
<i class="fas fa-globe mr-1"></i>Website
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid sm:grid-cols-3 gap-4 pt-6 border-t border-slate-700/50">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-amber-500 font-bold text-xl sm:text-2xl mb-1">100%</div>
|
|
||||||
<p class="text-slate-400 text-xs sm:text-sm">Open Source</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-amber-500 font-bold text-xl sm:text-2xl mb-1">MIT</div>
|
|
||||||
<p class="text-slate-400 text-xs sm:text-sm">License</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-amber-500 font-bold text-xl sm:text-2xl mb-1">0</div>
|
|
||||||
<p class="text-slate-400 text-xs sm:text-sm">Data collection</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-center text-slate-500 mt-6 sm:mt-8 text-xs sm:text-sm">
|
|
||||||
Unlike anonymous packages, you can verify who built this, read every line of code, and reach out directly with questions.
|
|
||||||
<br class="hidden sm:block">
|
|
||||||
Your security matters - that's why transparency is non-negotiable.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- FAQ Section -->
|
|
||||||
<section id="faq" class="py-20 lg:py-28 px-4 sm:px-6">
|
|
||||||
<div class="max-w-4xl mx-auto">
|
|
||||||
<SectionHeader title="Questions?" />
|
|
||||||
<div class="grid sm:grid-cols-2 gap-4 sm:gap-6">
|
|
||||||
<FaqItem
|
<FaqItem
|
||||||
v-for="faq in faqs"
|
v-for="faq in faqs"
|
||||||
:key="faq.question"
|
:key="faq.question"
|
||||||
@@ -400,8 +155,21 @@
|
|||||||
:answer="faq.answer"
|
:answer="faq.answer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
<FooterSection />
|
<FooterSection />
|
||||||
</div>
|
</div>
|
||||||
@@ -411,10 +179,8 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import NavBar from './components/NavBar.vue'
|
import NavBar from './components/NavBar.vue'
|
||||||
import HeroSection from './components/HeroSection.vue'
|
import HeroSection from './components/HeroSection.vue'
|
||||||
import SectionHeader from './components/SectionHeader.vue'
|
import FeatureItem from './components/FeatureItem.vue'
|
||||||
import FeatureCard from './components/FeatureCard.vue'
|
import TechCard from './components/TechCard.vue'
|
||||||
import StepCard from './components/StepCard.vue'
|
|
||||||
import FlowItem from './components/FlowItem.vue'
|
|
||||||
import CodeBlock from './components/CodeBlock.vue'
|
import CodeBlock from './components/CodeBlock.vue'
|
||||||
import FaqItem from './components/FaqItem.vue'
|
import FaqItem from './components/FaqItem.vue'
|
||||||
import FooterSection from './components/FooterSection.vue'
|
import FooterSection from './components/FooterSection.vue'
|
||||||
@@ -423,22 +189,15 @@ const mobileMenuOpen = ref(false)
|
|||||||
const activeTab = ref('macos')
|
const activeTab = ref('macos')
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{ icon: 'fas fa-brain', title: 'Learns as you work', description: 'Every bug fix, every architecture decision, every "aha moment" - captured automatically without breaking your flow.' },
|
{ icon: 'fas fa-brain', title: 'Session memory', description: 'Captures bug fixes, decisions, and patterns automatically from your Claude Code sessions.' },
|
||||||
{ icon: 'fas fa-search', title: 'Two-stage retrieval', description: 'Cross-encoder reranking delivers highly relevant results. Finds what you need even with vague queries like "that auth thing".' },
|
{ icon: 'fas fa-magnifying-glass-chart', title: 'Two-stage retrieval', description: 'Bi-encoder embeddings followed by cross-encoder reranking for accurate results.' },
|
||||||
{ icon: 'fas fa-project-diagram', title: 'Graph-based search', description: 'LEANN Phase 2: Graph relationships between observations (file overlap, semantic similarity, temporal proximity) for smarter context retrieval.' },
|
{ icon: 'fas fa-diagram-project', title: 'Knowledge graph', description: 'Automatic relationship detection between observations via file overlap and semantic similarity.' },
|
||||||
{ icon: 'fas fa-microchip', title: 'AST-aware chunking', description: 'Intelligent code splitting respects function boundaries. Go, Python, and TypeScript code is chunked at semantic boundaries, not arbitrary line counts.' },
|
{ icon: 'fas fa-folder-tree', title: 'Project isolation', description: 'Each project has its own memory. Your React knowledge doesn\'t leak into Go projects.' },
|
||||||
{ icon: 'fas fa-database', title: 'Hybrid vector storage', description: 'LEANN-inspired selective storage: frequently-accessed "hub" observations store embeddings, others recompute on-demand. 60-80% storage savings with <50ms latency.' },
|
{ icon: 'fas fa-code', title: 'AST-aware chunking', description: 'Tree-sitter splits Go, Python, and TypeScript code at semantic boundaries.' },
|
||||||
{ icon: 'fas fa-folder-tree', title: 'Project-aware context', description: 'Your React knowledge stays in React projects. Your Go patterns stay in Go projects. No context pollution.' },
|
{ icon: 'fas fa-database', title: 'Hybrid vector storage', description: 'Selective embedding caching. 60-80% storage reduction with minimal latency impact.' },
|
||||||
{ icon: 'fas fa-chart-line', title: 'Smart scoring', description: 'Importance decay, pattern detection, and conflict resolution ensure the most valuable memories surface first.' },
|
{ icon: 'fas fa-lock', title: 'Local embeddings', description: 'ONNX Runtime with all-MiniLM-L6-v2. No external API calls, everything stays on your machine.' },
|
||||||
{ icon: 'fas fa-gauge-high', title: 'Auto-tuning', description: 'Dynamic hub threshold adjustment based on query performance. Automatically balances storage efficiency with search latency for your workload.' },
|
{ icon: 'fas fa-gauge', title: 'Live statusline', description: 'Real-time metrics in Claude Code: [mnemonic] ● served:42 | project:28 memories' },
|
||||||
{ icon: 'fas fa-lock', title: '100% private', description: 'Your code context never leaves your machine. No telemetry. No cloud sync. Your memories are yours.' },
|
{ icon: 'fas fa-arrows-rotate', title: 'Auto-updates', description: 'Checks for new versions on startup, downloads in the background, applies on restart.' },
|
||||||
]
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{ number: 1, title: 'Install', description: 'One command. Hooks into Claude Code automatically.' },
|
|
||||||
{ number: 2, title: 'Work normally', description: 'Code with Claude as usual. Mnemonic listens silently in the background.' },
|
|
||||||
{ number: 3, title: 'Knowledge builds', description: 'Every session adds to your knowledge base. Bug fixes. Decisions. Patterns.' },
|
|
||||||
{ number: 4, title: 'Context appears', description: 'Start a new session - relevant memories are already there. No re-explaining.' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const installTabs = [
|
const installTabs = [
|
||||||
@@ -447,36 +206,37 @@ const installTabs = [
|
|||||||
{ id: 'source', label: 'From Source' },
|
{ id: 'source', label: 'From Source' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const installCommands = {
|
|
||||||
macos: `curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash`,
|
|
||||||
windows: `irm https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.ps1 | iex`,
|
|
||||||
source: `git clone https://github.com/lukaszraczylo/claude-mnemonic.git\ncd claude-mnemonic\nmake build && make install`,
|
|
||||||
}
|
|
||||||
|
|
||||||
const configOptions = [
|
const configOptions = [
|
||||||
{ name: 'CLAUDE_MNEMONIC_WORKER_PORT', description: 'HTTP port for the worker service (default: 37777)', icon: 'fas fa-network-wired' },
|
{ name: 'WORKER_PORT', description: 'Dashboard and API port (default: 37777)' },
|
||||||
{ name: 'CLAUDE_MNEMONIC_CONTEXT_OBSERVATIONS', description: 'Maximum observations injected per session (default: 100)', icon: 'fas fa-layer-group' },
|
{ name: 'CONTEXT_OBSERVATIONS', description: 'Max memories injected per session (default: 100)' },
|
||||||
{ name: 'CLAUDE_MNEMONIC_RERANKING_ENABLED', description: 'Enable cross-encoder reranking for improved search relevance (default: true)', icon: 'fas fa-sort-amount-down' },
|
{ name: 'CONTEXT_FULL_COUNT', description: 'Full-detail memories, rest condensed (default: 25)' },
|
||||||
{ name: 'CLAUDE_MNEMONIC_CONTEXT_RELEVANCE_THRESHOLD', description: 'Minimum similarity score for inclusion, 0.0-1.0 (default: 0.3)', icon: 'fas fa-filter' },
|
{ name: 'CONTEXT_RELEVANCE_THRESHOLD', description: 'Min similarity score 0.0-1.0 (default: 0.3)' },
|
||||||
{ name: 'CLAUDE_MNEMONIC_VECTOR_STORAGE_STRATEGY', description: 'Storage strategy: "hub" (default), "always", or "on_demand"', icon: 'fas fa-database' },
|
{ name: 'RERANKING_ENABLED', description: 'Cross-encoder reranking (default: true)' },
|
||||||
{ name: 'CLAUDE_MNEMONIC_GRAPH_ENABLED', description: 'Enable graph-based search with observation relationships (default: true)', icon: 'fas fa-project-diagram' },
|
{ name: 'VECTOR_STORAGE_STRATEGY', description: 'hub (default), always, or on_demand' },
|
||||||
{ name: 'CLAUDE_MNEMONIC_GRAPH_MAX_HOPS', description: 'Maximum graph traversal depth for search expansion (default: 2)', icon: 'fas fa-route' },
|
{ name: 'GRAPH_ENABLED', description: 'Graph-based search with relationships (default: true)' },
|
||||||
{ name: 'CLAUDE_MNEMONIC_GRAPH_REBUILD_INTERVAL_MIN', description: 'How often to rebuild the observation graph in minutes (default: 60)', icon: 'fas fa-clock' },
|
{ name: 'EMBEDDING_MODEL', description: 'Embedding model for semantic search (default: bge-v1.5)' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const requiredDeps = [
|
const requiredDeps = [
|
||||||
{ name: 'Claude Code CLI', description: 'Host application - this is a plugin for Claude Code. Uses your existing subscription (Pro/Max) instead of API keys.', icon: 'fas fa-terminal' },
|
{ name: 'Claude Code CLI', description: 'Host application. Uses your existing subscription.', icon: 'fas fa-terminal' },
|
||||||
{ name: 'jq', description: 'JSON processor used during installation. Usually pre-installed on most systems.', icon: 'fas fa-code' },
|
{ name: 'jq', description: 'JSON processing during install. Usually pre-installed.', icon: 'fas fa-code' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const techStack = [
|
||||||
|
{ name: 'Go', description: 'Single binary, fast startup, low memory, zero runtime dependencies.' },
|
||||||
|
{ name: 'SQLite + FTS5', description: 'Full-text search in a single file. Survives restarts.' },
|
||||||
|
{ name: 'sqlite-vec', description: 'Vector search embedded in SQLite with LEANN-inspired selective embeddings.' },
|
||||||
|
{ name: 'BGE reranker', description: 'Cross-encoder for two-stage retrieval: bi-encoder + reranking.' },
|
||||||
|
{ name: 'Tree-sitter', description: 'AST-aware code chunking for Go, Python, and TypeScript.' },
|
||||||
|
{ name: 'CSR Graph', description: 'Memory-efficient observation relationship graph with edge detection.' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const faqs = [
|
const faqs = [
|
||||||
{ question: 'Will it confuse Claude with wrong context?', answer: 'No. Mnemonic uses project isolation and semantic relevance scoring. Only memories from the current project (or global best practices) are injected, and only when they\'re actually relevant to your prompt.' },
|
{ question: 'Will it confuse Claude with wrong context?', answer: 'Project isolation and semantic relevance scoring prevent that. Only memories from the current project (or global best practices) are injected, and only when relevant to your prompt.' },
|
||||||
{ question: 'What exactly gets saved?', answer: 'Bug fixes with context ("Fixed race condition by adding mutex"), architecture decisions ("Using repository pattern for data access"), conventions ("All API routes prefixed with /api/v1"), and learnings you want to preserve.' },
|
{ question: 'What gets stored?', answer: 'Bug fixes with context, architecture decisions, project conventions, security patterns — things you\'d otherwise re-explain each session. No raw conversation dumps.' },
|
||||||
{ question: 'How does hybrid vector storage work?', answer: 'LEANN-inspired selective storage: frequently-accessed "hub" observations (identified by access patterns and graph centrality) store embeddings. Infrequently-accessed observations recompute embeddings on-demand during search. This reduces storage by 60-80% with minimal latency impact (<50ms).' },
|
{ question: 'Can I delete or edit memories?', answer: 'Yes. The dashboard at localhost:37777 lets you browse, search, edit, and delete anything. You can also view graph relationships and storage metrics.' },
|
||||||
{ question: 'Can I delete or edit memories?', answer: 'Yes. The web dashboard at localhost:37777 lets you browse, search, edit, and delete any memory. You can also view graph relationships, storage metrics, and performance analytics. You\'re always in control.' },
|
{ question: 'Performance impact?', answer: 'The Go worker uses ~30MB RAM. Context injection at session start takes milliseconds for most projects. Hybrid storage auto-tunes for your workload.' },
|
||||||
{ question: 'Does it work with my existing Claude Code setup?', answer: 'Yes. Mnemonic installs as a Claude Code plugin with hooks. Your existing workflows, settings, and shortcuts remain unchanged.' },
|
{ question: 'Does it work with my existing setup?', answer: 'Yes. Installs as a Claude Code plugin with hooks. Your workflows, settings, and shortcuts stay the same.' },
|
||||||
{ question: 'What if I switch between projects frequently?', answer: 'That\'s the point. Each project has isolated memories. Switch from your Python ML project to your TypeScript app - context switches automatically.' },
|
{ question: 'What if I switch between projects?', answer: 'Each project has isolated memories. Switch from your Python ML project to your TypeScript app — context switches automatically.' },
|
||||||
{ question: 'Is there a performance impact?', answer: 'Minimal. The Go worker is lightweight (typically under 30MB RAM). Hybrid storage and auto-tuning optimize for your workload. Context injection at session start takes milliseconds for most projects.' },
|
|
||||||
{ question: 'What is AST-aware chunking?', answer: 'When processing code observations, Mnemonic uses Tree-sitter parsers to respect function and class boundaries instead of arbitrary line limits. Go, Python, and TypeScript code is chunked at semantic boundaries for better search accuracy.' },
|
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="glass rounded-xl p-4 sm:p-6 relative max-w-4xl mx-auto glow-amber">
|
<div class="rounded-lg border border-neutral-800 bg-neutral-900/30 p-4 relative overflow-hidden">
|
||||||
<button @click="copyCode" class="absolute top-3 sm:top-4 right-3 sm:right-4 bg-slate-800/50 border border-slate-700/50 text-slate-500 hover:text-white hover:border-slate-600 px-2 sm:px-3 py-1 sm:py-1.5 rounded-md text-xs transition-all">
|
<button @click="copyCode" class="absolute top-3 right-3 text-neutral-600 hover:text-neutral-300 text-xs transition-colors px-2 py-1 rounded border border-neutral-800 hover:border-neutral-600">
|
||||||
{{ copied ? 'Copied!' : 'Copy' }}
|
{{ copied ? 'Copied' : 'Copy' }}
|
||||||
</button>
|
</button>
|
||||||
<pre class="text-xs sm:text-sm whitespace-pre-wrap break-all font-mono pr-16 sm:pr-20"><slot></slot></pre>
|
<pre class="text-sm whitespace-pre-wrap break-all font-mono text-neutral-300 pr-20 leading-relaxed"><code>{{ code }}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -11,10 +11,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
code: {
|
code: { type: String, default: '' },
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const copied = ref(false)
|
const copied = ref(false)
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="glass rounded-xl p-4 sm:p-6 hover:border-amber-500/20 transition-colors">
|
<div class="border-b border-neutral-800 last:border-0">
|
||||||
<h3 class="text-white font-semibold text-sm sm:text-base mb-2 sm:mb-3">{{ question }}</h3>
|
<button @click="open = !open" class="w-full text-left flex items-center justify-between py-4">
|
||||||
<p class="text-slate-400 text-xs sm:text-sm leading-relaxed">{{ answer }}</p>
|
<span class="text-white text-sm font-medium pr-4">{{ question }}</span>
|
||||||
|
<i :class="open ? 'fas fa-minus' : 'fas fa-plus'" class="text-neutral-600 text-xs flex-shrink-0"></i>
|
||||||
|
</button>
|
||||||
|
<div v-if="open" class="pb-4">
|
||||||
|
<p class="text-neutral-500 text-sm leading-relaxed">{{ answer }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
question: {
|
question: { type: String, required: true },
|
||||||
type: String,
|
answer: { type: String, required: true },
|
||||||
required: true
|
|
||||||
},
|
|
||||||
answer: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="glass rounded-2xl p-5 sm:p-8 relative overflow-hidden group hover:border-amber-500/30 hover:-translate-y-1 transition-all duration-300">
|
|
||||||
<div class="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-amber-500 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
|
||||||
<div class="w-10 h-10 sm:w-12 sm:h-12 bg-amber-500/15 rounded-xl flex items-center justify-center mb-4 sm:mb-5 text-amber-500 text-lg sm:text-xl">
|
|
||||||
<i :class="icon"></i>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-white font-semibold text-base sm:text-lg mb-2 sm:mb-3">{{ title }}</h3>
|
|
||||||
<p class="text-slate-400 text-xs sm:text-sm leading-relaxed">{{ description }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
icon: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-5 rounded-lg border border-neutral-800 bg-neutral-900/30">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<i :class="[icon, 'text-amber-500 text-sm']"></i>
|
||||||
|
<h3 class="text-white font-semibold text-sm">{{ title }}</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-neutral-500 text-xs leading-relaxed pl-7">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
icon: { type: String, required: true },
|
||||||
|
title: { type: String, required: true },
|
||||||
|
description: { type: String, required: true },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex items-start sm:items-center gap-3 sm:gap-4 p-3 sm:p-4 bg-slate-800/50 rounded-lg border-l-4 border-amber-500">
|
|
||||||
<div class="w-8 h-8 sm:w-10 sm:h-10 bg-amber-500/15 rounded-lg flex items-center justify-center text-amber-500 flex-shrink-0 text-sm sm:text-base">
|
|
||||||
<i :class="icon"></i>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<strong class="block text-white text-sm sm:text-base mb-0.5 sm:mb-1">{{ title }}</strong>
|
|
||||||
<span class="text-slate-500 text-xs sm:text-sm block break-words">{{ description }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
icon: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<footer class="border-t border-slate-800 py-10 sm:py-16 relative">
|
<footer class="border-t border-neutral-800/80 py-8 px-4 sm:px-6">
|
||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 flex flex-col md:flex-row justify-between items-center gap-4 sm:gap-6">
|
<div class="max-w-5xl mx-auto flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||||
<div class="flex flex-wrap justify-center gap-4 sm:gap-6">
|
<p class="text-neutral-600 text-xs">MIT License</p>
|
||||||
|
<div class="flex gap-5">
|
||||||
<a v-for="link in links" :key="link.href" :href="link.href" target="_blank"
|
<a v-for="link in links" :key="link.href" :href="link.href" target="_blank"
|
||||||
class="text-slate-500 hover:text-white text-xs sm:text-sm transition-colors animated-underline">
|
class="text-neutral-600 hover:text-neutral-400 text-xs transition-colors">
|
||||||
{{ link.label }}
|
{{ link.label }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-slate-600 text-xs sm:text-sm text-center">{{ copyright }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
@@ -20,12 +20,8 @@ defineProps({
|
|||||||
{ label: 'GitHub', href: 'https://github.com/lukaszraczylo/claude-mnemonic' },
|
{ label: 'GitHub', href: 'https://github.com/lukaszraczylo/claude-mnemonic' },
|
||||||
{ label: 'Releases', href: 'https://github.com/lukaszraczylo/claude-mnemonic/releases' },
|
{ label: 'Releases', href: 'https://github.com/lukaszraczylo/claude-mnemonic/releases' },
|
||||||
{ label: 'Issues', href: 'https://github.com/lukaszraczylo/claude-mnemonic/issues' },
|
{ label: 'Issues', href: 'https://github.com/lukaszraczylo/claude-mnemonic/issues' },
|
||||||
{ label: 'MIT License', href: 'https://github.com/lukaszraczylo/claude-mnemonic/blob/main/LICENSE' },
|
{ label: 'License', href: 'https://github.com/lukaszraczylo/claude-mnemonic/blob/main/LICENSE' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
copyright: {
|
|
||||||
type: String,
|
|
||||||
default: 'Made with care for the Claude Code community'
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,44 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="min-h-screen flex items-center relative pt-24 sm:pt-28 pb-16 sm:pb-20">
|
<section class="pt-24 pb-8 sm:pt-32 sm:pb-12 px-4 sm:px-6">
|
||||||
<!-- Background Effects -->
|
<div class="max-w-5xl mx-auto">
|
||||||
<div class="absolute inset-0 overflow-hidden">
|
<p class="text-amber-500 text-sm font-medium mb-4 tracking-wide">Persistent memory for Claude Code</p>
|
||||||
<div class="absolute top-[-50%] left-1/2 -translate-x-1/2 w-[150%] h-full bg-[radial-gradient(ellipse_at_center,rgba(245,158,11,0.12)_0%,transparent_60%)] opacity-60"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative z-10 max-w-6xl mx-auto px-4 sm:px-6 text-center">
|
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-[1.1] mb-6 max-w-3xl">
|
||||||
<div class="inline-flex items-center gap-2 glass px-3 sm:px-4 py-1.5 sm:py-2 rounded-full text-xs sm:text-sm text-slate-400 mb-6 sm:mb-8 opacity-0 animate-fade-in-up">
|
Stop re-explaining your codebase every session.
|
||||||
<span class="w-2 h-2 bg-amber-500 rounded-full animate-pulse-slow"></span>
|
|
||||||
{{ badge }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold text-white mb-4 sm:mb-6 leading-tight opacity-0 animate-fade-in-up animation-delay-100">
|
|
||||||
{{ titleBefore }}<br class="hidden sm:block">
|
|
||||||
<span class="sm:hidden"> </span>
|
|
||||||
<span class="text-gradient">{{ titleHighlight }}</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p class="text-base sm:text-lg md:text-xl text-slate-400 max-w-2xl mx-auto mb-8 sm:mb-10 opacity-0 animate-fade-in-up animation-delay-200 px-2">
|
<p class="text-neutral-400 text-lg sm:text-xl max-w-2xl mb-10 leading-relaxed">
|
||||||
{{ subtitle }}
|
Claude Code forgets everything when your session ends. Mnemonic captures what you've already explained — bug fixes, architecture decisions, patterns — and brings it back when relevant.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center mb-12 sm:mb-16 opacity-0 animate-fade-in-up animation-delay-300 px-4 sm:px-0">
|
<div class="flex flex-col sm:flex-row gap-3 mb-12">
|
||||||
<a :href="primaryCta.href" class="inline-flex items-center justify-center gap-2 bg-gradient-to-br from-amber-500 to-amber-400 text-slate-950 px-6 sm:px-8 py-3 sm:py-3.5 rounded-lg font-semibold hover:scale-105 hover:shadow-lg hover:shadow-amber-500/30 transition-all">
|
<a href="#install" class="inline-flex items-center justify-center gap-2 bg-white text-neutral-900 px-6 py-3 rounded-lg font-semibold text-sm hover:bg-neutral-200 transition-colors">
|
||||||
<i :class="primaryCta.icon"></i>
|
Install now
|
||||||
{{ primaryCta.label }}
|
|
||||||
</a>
|
</a>
|
||||||
<a :href="secondaryCta.href" target="_blank" class="inline-flex items-center justify-center gap-2 glass text-white px-6 sm:px-8 py-3 sm:py-3.5 rounded-lg font-medium hover:border-amber-500/30 hover:bg-amber-500/10 transition-all">
|
<a href="https://github.com/lukaszraczylo/claude-mnemonic" target="_blank" class="inline-flex items-center justify-center gap-2 border border-neutral-700 text-neutral-400 px-6 py-3 rounded-lg font-medium text-sm hover:border-neutral-500 hover:text-white transition-colors">
|
||||||
<i :class="secondaryCta.icon"></i>
|
<i class="fab fa-github"></i> View source
|
||||||
{{ secondaryCta.label }}
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Install Command -->
|
<div class="max-w-2xl">
|
||||||
<div class="opacity-0 animate-fade-in-up animation-delay-400 px-2 sm:px-0">
|
<div class="flex items-stretch bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||||
<div class="glass rounded-xl px-4 sm:px-6 py-4 sm:py-5 flex flex-col sm:flex-row items-center justify-between gap-3 sm:gap-4 max-w-4xl mx-auto glow-amber">
|
<div class="flex items-center px-3 gap-1.5 border-r border-neutral-800">
|
||||||
<code class="text-amber-400 text-xs sm:text-sm md:text-base flex-1 text-center sm:text-left break-all font-mono">{{ installCommand }}</code>
|
<span class="w-3 h-3 rounded-full bg-neutral-700"></span>
|
||||||
<button @click="copyCommand" class="text-slate-500 hover:text-white transition-colors p-2 flex-shrink-0 rounded-lg hover:bg-slate-800/50">
|
<span class="w-3 h-3 rounded-full bg-neutral-700"></span>
|
||||||
<i :class="copied ? 'fas fa-check text-amber-500' : 'fas fa-copy'"></i>
|
<span class="w-3 h-3 rounded-full bg-neutral-700"></span>
|
||||||
<span class="ml-2 text-sm sm:hidden">{{ copied ? 'Copied!' : 'Copy' }}</span>
|
</div>
|
||||||
|
<code class="flex-1 text-neutral-300 text-xs sm:text-sm px-4 py-3 font-mono overflow-x-auto">{{ installCommand }}</code>
|
||||||
|
<button @click="copyCommand" class="px-4 text-neutral-600 hover:text-neutral-300 transition-colors border-l border-neutral-800 text-sm" title="Copy">
|
||||||
|
<i :class="copied ? 'fas fa-check text-emerald-500' : 'fas fa-copy'"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,41 +40,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
defineProps({
|
const installCommand = 'curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash'
|
||||||
badge: {
|
|
||||||
type: String,
|
|
||||||
default: 'Persistent Memory for Claude Code'
|
|
||||||
},
|
|
||||||
titleBefore: {
|
|
||||||
type: String,
|
|
||||||
default: 'Claude forgets.'
|
|
||||||
},
|
|
||||||
titleHighlight: {
|
|
||||||
type: String,
|
|
||||||
default: 'Make it remember.'
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
type: String,
|
|
||||||
default: 'Capture learnings, decisions, and patterns from your coding sessions. Bring that knowledge back in every future conversation.'
|
|
||||||
},
|
|
||||||
primaryCta: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({ label: 'Install Now', href: '#installation', icon: 'fas fa-download' })
|
|
||||||
},
|
|
||||||
secondaryCta: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({ label: 'View Source', href: 'https://github.com/lukaszraczylo/claude-mnemonic', icon: 'fab fa-github' })
|
|
||||||
},
|
|
||||||
installCommand: {
|
|
||||||
type: String,
|
|
||||||
default: 'curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const copied = ref(false)
|
const copied = ref(false)
|
||||||
|
|
||||||
function copyCommand() {
|
function copyCommand() {
|
||||||
navigator.clipboard.writeText('curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash')
|
navigator.clipboard.writeText(installCommand)
|
||||||
copied.value = true
|
copied.value = true
|
||||||
setTimeout(() => copied.value = false, 2000)
|
setTimeout(() => copied.value = false, 2000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,45 @@
|
|||||||
<template>
|
<template>
|
||||||
<nav class="fixed top-0 left-0 right-0 z-50 bg-gradient-to-b from-slate-950 via-slate-950/95 to-transparent backdrop-blur-md">
|
<nav class="border-b border-neutral-800/80 sticky top-0 z-50 bg-[#09090b]/90 backdrop-blur-sm">
|
||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-4 sm:py-5 flex justify-between items-center">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 h-14 flex justify-between items-center">
|
||||||
<a href="#" class="flex items-center gap-2 sm:gap-3 text-white font-bold text-xl sm:text-2xl">
|
<a href="#" class="text-white font-bold text-base tracking-tight">Claude Mnemonic</a>
|
||||||
<div class="w-8 h-8 sm:w-9 sm:h-9 bg-gradient-to-br from-amber-500 to-amber-400 rounded-lg flex items-center justify-center">
|
|
||||||
<i class="fas fa-brain text-slate-950 text-sm sm:text-base"></i>
|
|
||||||
</div>
|
|
||||||
{{ title }}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<ul class="hidden md:flex gap-6 lg:gap-8">
|
<ul class="hidden md:flex items-center gap-6">
|
||||||
<li v-for="link in links" :key="link.href">
|
<li v-for="link in links" :key="link.href">
|
||||||
<a :href="link.href" class="text-slate-400 hover:text-white text-sm font-medium transition-colors animated-underline">
|
<a :href="link.href" class="text-neutral-500 hover:text-white text-sm transition-colors">{{ link.label }}</a>
|
||||||
{{ link.label }}
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 sm:gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a :href="githubUrl" target="_blank" class="text-slate-400 hover:text-white text-lg sm:text-xl transition-colors">
|
<a :href="githubUrl" target="_blank" class="text-neutral-500 hover:text-white transition-colors text-lg">
|
||||||
<i class="fab fa-github"></i>
|
<i class="fab fa-github"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="#installation" class="hidden sm:inline-flex bg-gradient-to-br from-amber-500 to-amber-400 text-slate-950 px-4 sm:px-5 py-2 sm:py-2.5 rounded-lg font-semibold text-sm hover:scale-105 hover:shadow-lg hover:shadow-amber-500/30 transition-all">
|
<a href="#install" class="hidden sm:inline-flex items-center gap-2 text-sm font-medium text-amber-500 hover:text-amber-400 transition-colors">
|
||||||
Get Started
|
Install <i class="fas fa-arrow-right text-xs"></i>
|
||||||
</a>
|
</a>
|
||||||
<button @click="$emit('toggle-menu')" class="md:hidden text-white text-xl p-2">
|
<button @click="$emit('toggle-menu')" class="md:hidden text-neutral-500 hover:text-white p-1">
|
||||||
<i :class="mobileMenuOpen ? 'fas fa-times' : 'fas fa-bars'"></i>
|
<i :class="mobileMenuOpen ? 'fas fa-times' : 'fas fa-bars'"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Menu -->
|
<div v-if="mobileMenuOpen" class="md:hidden border-t border-neutral-800/80 px-6 py-4 space-y-1">
|
||||||
<Transition
|
|
||||||
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"
|
<a v-for="link in links" :key="link.href" :href="link.href"
|
||||||
@click="$emit('toggle-menu')"
|
@click="$emit('toggle-menu')"
|
||||||
class="block py-3 text-slate-400 hover:text-white border-b border-slate-800 last:border-0 transition-colors">
|
class="block py-2 text-neutral-500 hover:text-white text-sm transition-colors">
|
||||||
{{ link.label }}
|
{{ link.label }}
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</Transition>
|
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
defineProps({
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: 'Mnemonic'
|
|
||||||
},
|
|
||||||
links: {
|
links: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [
|
default: () => [
|
||||||
{ label: 'Features', href: '#features' },
|
{ label: 'Features', href: '#features' },
|
||||||
{ label: 'How It Works', href: '#how-it-works' },
|
{ label: 'Install', href: '#install' },
|
||||||
{ label: 'Install', href: '#installation' },
|
{ label: 'Config', href: '#config' },
|
||||||
{ label: 'FAQ', href: '#faq' },
|
{ label: 'FAQ', href: '#faq' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="text-center mb-10 sm:mb-16">
|
|
||||||
<h2 class="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3 sm:mb-4">
|
|
||||||
{{ title }}
|
|
||||||
</h2>
|
|
||||||
<p v-if="subtitle" class="text-sm sm:text-base md:text-lg text-slate-400 max-w-xl mx-auto px-2">
|
|
||||||
{{ subtitle }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
subtitle: String
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="text-center relative">
|
|
||||||
<div class="w-14 h-14 sm:w-20 sm:h-20 bg-slate-900 border-2 border-amber-500 rounded-full flex items-center justify-center mx-auto mb-4 sm:mb-6 text-amber-500 font-bold text-xl sm:text-3xl relative z-10">
|
|
||||||
{{ number }}
|
|
||||||
</div>
|
|
||||||
<h3 class="text-white font-semibold text-base sm:text-lg mb-2 sm:mb-3">{{ title }}</h3>
|
|
||||||
<p class="text-slate-400 text-xs sm:text-sm">{{ description }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
number: {
|
|
||||||
type: [String, Number],
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-4 rounded-lg border border-neutral-800">
|
||||||
|
<div class="text-white font-semibold text-sm mb-1">{{ name }}</div>
|
||||||
|
<p class="text-neutral-500 text-xs leading-relaxed">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
name: { type: String, required: true },
|
||||||
|
description: { type: String, required: true },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
+9
-97
@@ -1,6 +1,9 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
@theme {
|
||||||
|
--font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||||
|
--font-mono: 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
@@ -10,105 +13,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply font-sans text-slate-300;
|
@apply font-sans text-neutral-300 bg-[#09090b];
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
@apply font-sans font-bold tracking-tight;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code, pre {
|
code, pre {
|
||||||
@apply font-mono;
|
@apply font-mono;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@layer components {
|
::selection {
|
||||||
/* Animated gradient background */
|
@apply bg-amber-500/30 text-white;
|
||||||
.bg-animated-gradient {
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
rgba(245, 158, 11, 0.1) 0%,
|
|
||||||
transparent 50%,
|
|
||||||
rgba(245, 158, 11, 0.05) 100%
|
|
||||||
);
|
|
||||||
background-size: 200% 200%;
|
|
||||||
animation: gradientShift 8s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Floating particles */
|
|
||||||
.particle {
|
|
||||||
position: absolute;
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
background: rgba(245, 158, 11, 0.4);
|
|
||||||
border-radius: 50%;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glass effect */
|
|
||||||
.glass {
|
|
||||||
@apply bg-slate-900/60 backdrop-blur-xl border border-slate-700/50;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glow effect */
|
|
||||||
.glow-amber {
|
|
||||||
box-shadow: 0 0 40px rgba(245, 158, 11, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animated underline */
|
|
||||||
.animated-underline {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.animated-underline::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: -2px;
|
|
||||||
left: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 2px;
|
|
||||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
.animated-underline:hover::after {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animated border */
|
|
||||||
.animated-border {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.animated-border::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: -100%;
|
|
||||||
width: 100%;
|
|
||||||
height: 1px;
|
|
||||||
background: linear-gradient(90deg, transparent, #f59e0b, transparent);
|
|
||||||
animation: borderSlide 3s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes borderSlide {
|
|
||||||
0% { left: -100%; }
|
|
||||||
100% { left: 100%; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
.animation-delay-100 { animation-delay: 0.1s; }
|
|
||||||
.animation-delay-200 { animation-delay: 0.2s; }
|
|
||||||
.animation-delay-300 { animation-delay: 0.3s; }
|
|
||||||
.animation-delay-400 { animation-delay: 0.4s; }
|
|
||||||
.animation-delay-500 { animation-delay: 0.5s; }
|
|
||||||
|
|
||||||
/* Text gradient */
|
|
||||||
.text-gradient {
|
|
||||||
@apply bg-gradient-to-r from-amber-400 to-amber-500 bg-clip-text text-transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Opacity starting state for animations */
|
|
||||||
.opacity-0-start {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
amber: {
|
|
||||||
DEFAULT: '#f59e0b',
|
|
||||||
light: '#fbbf24',
|
|
||||||
glow: 'rgba(245, 158, 11, 0.15)',
|
|
||||||
},
|
|
||||||
slate: {
|
|
||||||
950: '#0a0f1a',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
|
|
||||||
mono: ['SF Mono', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', 'monospace'],
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
'fade-in-up': 'fadeInUp 0.8s ease-out forwards',
|
|
||||||
'pulse-slow': 'pulse 3s ease-in-out infinite',
|
|
||||||
'float': 'float 6s ease-in-out infinite',
|
|
||||||
'float-delayed': 'float 6s ease-in-out 3s infinite',
|
|
||||||
'glow': 'glow 4s ease-in-out infinite',
|
|
||||||
'gradient-shift': 'gradientShift 8s ease-in-out infinite',
|
|
||||||
'particle': 'particle 20s linear infinite',
|
|
||||||
},
|
|
||||||
keyframes: {
|
|
||||||
fadeInUp: {
|
|
||||||
'0%': { opacity: '0', transform: 'translateY(30px)' },
|
|
||||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
|
||||||
},
|
|
||||||
float: {
|
|
||||||
'0%, 100%': { transform: 'translateY(0px)' },
|
|
||||||
'50%': { transform: 'translateY(-20px)' },
|
|
||||||
},
|
|
||||||
glow: {
|
|
||||||
'0%, 100%': { opacity: '0.5' },
|
|
||||||
'50%': { opacity: '0.8' },
|
|
||||||
},
|
|
||||||
gradientShift: {
|
|
||||||
'0%, 100%': { backgroundPosition: '0% 50%' },
|
|
||||||
'50%': { backgroundPosition: '100% 50%' },
|
|
||||||
},
|
|
||||||
particle: {
|
|
||||||
'0%': { transform: 'translateY(100vh) rotate(0deg)', opacity: '0' },
|
|
||||||
'10%': { opacity: '1' },
|
|
||||||
'90%': { opacity: '1' },
|
|
||||||
'100%': { transform: 'translateY(-100vh) rotate(720deg)', opacity: '0' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
backgroundSize: {
|
|
||||||
'200%': '200% 200%',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
@@ -8,15 +8,15 @@ require (
|
|||||||
github.com/asg017/sqlite-vec-go-bindings v0.1.6
|
github.com/asg017/sqlite-vec-go-bindings v0.1.6
|
||||||
github.com/fsnotify/fsnotify v1.10.1
|
github.com/fsnotify/fsnotify v1.10.1
|
||||||
github.com/go-chi/chi/v5 v5.3.0
|
github.com/go-chi/chi/v5 v5.3.0
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5
|
github.com/go-gormigrate/gormigrate/v2 v2.1.6
|
||||||
github.com/goccy/go-json v0.10.6
|
github.com/goccy/go-json v0.10.6
|
||||||
github.com/lukaszraczylo/oss-telemetry v0.2.1
|
github.com/lukaszraczylo/oss-telemetry v0.2.3
|
||||||
github.com/mattn/go-sqlite3 v1.14.44
|
github.com/mattn/go-sqlite3 v1.14.44
|
||||||
github.com/rs/zerolog v1.35.1
|
github.com/rs/zerolog v1.35.1
|
||||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
|
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/sugarme/tokenizer v0.3.0
|
github.com/sugarme/tokenizer v0.3.0
|
||||||
github.com/yalue/onnxruntime_go v1.30.1
|
github.com/yalue/onnxruntime_go v1.31.0
|
||||||
golang.org/x/sync v0.20.0
|
golang.org/x/sync v0.20.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
@@ -27,7 +27,7 @@ require (
|
|||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||||
|
|||||||
@@ -11,18 +11,18 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5
|
|||||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiIhS4/QTTa/SM8=
|
github.com/go-gormigrate/gormigrate/v2 v2.1.6 h1:VtX+l1Stj2v5RGubVQk0LS/8EPGXR+ldcOyCmlmKoyg=
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M=
|
github.com/go-gormigrate/gormigrate/v2 v2.1.6/go.mod h1:PZpedQc4tWaxn6kvXicwhinh3L0seLpMc5ReKRX5id4=
|
||||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/lukaszraczylo/oss-telemetry v0.2.1 h1:6ULyfzXplpdmIY/i01OPM1jeod9+L1RAhI0jtbVnJI0=
|
github.com/lukaszraczylo/oss-telemetry v0.2.3 h1:xoDtBqeZGmXj7IteiE1M5WMuzeoqag58qEleI0Cf2Ms=
|
||||||
github.com/lukaszraczylo/oss-telemetry v0.2.1/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
|
github.com/lukaszraczylo/oss-telemetry v0.2.3/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||||
@@ -47,8 +47,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/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 h1:pwb4kNSHb4K89ymCaN+5lPH/MwnfSVg4rzGDh4d+iy4=
|
||||||
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c/go.mod h1:2gwkXLWbDGUQWeL3RtpCmcY4mzCtU13kb9UsAg9xMaw=
|
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c/go.mod h1:2gwkXLWbDGUQWeL3RtpCmcY4mzCtU13kb9UsAg9xMaw=
|
||||||
github.com/yalue/onnxruntime_go v1.30.1 h1:NaEng5lWbsHZ/8X1dtaw1mIj7eV1ozyjbFo//g0ktl4=
|
github.com/yalue/onnxruntime_go v1.31.0 h1:1ln4YW1SFOFfGJZXe3jNOb2JUSt+l2pEneZfV8HdtFA=
|
||||||
github.com/yalue/onnxruntime_go v1.30.1/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
github.com/yalue/onnxruntime_go v1.31.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ type Config struct {
|
|||||||
HubThreshold int `json:"hub_threshold"`
|
HubThreshold int `json:"hub_threshold"`
|
||||||
ObservationRetentionDays int `json:"observation_retention_days"`
|
ObservationRetentionDays int `json:"observation_retention_days"`
|
||||||
MaintenanceIntervalHours int `json:"maintenance_interval_hours"`
|
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"`
|
ContextMaxTokensStartup int `json:"context_max_tokens_startup"`
|
||||||
ContextMaxTokensPrompt int `json:"context_max_tokens_prompt"`
|
ContextMaxTokensPrompt int `json:"context_max_tokens_prompt"`
|
||||||
ContextShowWorkTokens bool `json:"context_show_work_tokens"`
|
ContextShowWorkTokens bool `json:"context_show_work_tokens"`
|
||||||
@@ -181,6 +183,10 @@ func Default() *Config {
|
|||||||
MaintenanceIntervalHours: 6, // Run every 6 hours
|
MaintenanceIntervalHours: 6, // Run every 6 hours
|
||||||
ObservationRetentionDays: 0, // 0 = no age-based deletion (keep all)
|
ObservationRetentionDays: 0, // 0 = no age-based deletion (keep all)
|
||||||
CleanupStaleObservations: false, // Don't auto-cleanup stale observations
|
CleanupStaleObservations: false, // Don't auto-cleanup stale observations
|
||||||
|
// WAL checkpoint loop tunables (issue #49). Defaults mirror the worker constants:
|
||||||
|
// check the WAL every 60s and TRUNCATE-checkpoint once it reaches 4 MiB.
|
||||||
|
WALCheckpointIntervalSeconds: 60,
|
||||||
|
WALCheckpointThresholdBytes: 4 << 20, // 4 MiB
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +290,13 @@ func Load() (*Config, error) {
|
|||||||
if v, ok := settings["CLAUDE_MNEMONIC_CONTEXT_MAX_TOKENS_PROMPT"].(float64); ok && v > 0 {
|
if v, ok := settings["CLAUDE_MNEMONIC_CONTEXT_MAX_TOKENS_PROMPT"].(float64); ok && v > 0 {
|
||||||
cfg.ContextMaxTokensPrompt = int(v)
|
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
|
// Deduplication settings
|
||||||
if v, ok := settings["CLAUDE_MNEMONIC_DEDUP_ENABLED"].(bool); ok {
|
if v, ok := settings["CLAUDE_MNEMONIC_DEDUP_ENABLED"].(bool); ok {
|
||||||
cfg.DeduplicationEnabled = v
|
cfg.DeduplicationEnabled = v
|
||||||
|
|||||||
+145
-38
@@ -5,18 +5,80 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
|
sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
|
||||||
_ "github.com/mattn/go-sqlite3" // Import SQLite driver with FTS5 support
|
sqlite3 "github.com/mattn/go-sqlite3" // SQLite driver with FTS5 support
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// driverName is the name of the custom mattn/go-sqlite3 driver registered with a
|
||||||
|
// ConnectHook that applies ALL connection pragmas (correctness + best-effort) to EVERY
|
||||||
|
// pooled connection at open time. The stock "sqlite3" driver has no hook, so pragmas set
|
||||||
|
// via a single post-open Exec only reach one arbitrary pooled connection (issue #49, F6).
|
||||||
|
const driverName = "sqlite3_mnemonic"
|
||||||
|
|
||||||
|
// registerDriverOnce guards driver registration so it runs exactly once per process.
|
||||||
|
// database/sql panics with "sql: Register called twice" on a duplicate name, and NewStore
|
||||||
|
// may be called multiple times (e.g. after a config-change reinitialization).
|
||||||
|
var registerDriverOnce sync.Once
|
||||||
|
|
||||||
|
// correctnessPragmas MUST succeed on every connection: getting any of them wrong changes
|
||||||
|
// transactional/locking semantics, not just performance. A failure here aborts the open.
|
||||||
|
var correctnessPragmas = []string{
|
||||||
|
"PRAGMA foreign_keys=ON",
|
||||||
|
"PRAGMA journal_mode=WAL",
|
||||||
|
"PRAGMA synchronous=NORMAL",
|
||||||
|
"PRAGMA busy_timeout=5000",
|
||||||
|
"PRAGMA cache_size=-64000",
|
||||||
|
}
|
||||||
|
|
||||||
|
// bestEffortPragmas are per-connection or database-wide optimizations. A failure is logged
|
||||||
|
// and tolerated: the connection is still correct, just less tuned. (page_size only takes
|
||||||
|
// effect on an empty database / next VACUUM, but applying it per-connection is harmless.)
|
||||||
|
var bestEffortPragmas = []string{
|
||||||
|
"PRAGMA temp_store=MEMORY", // Store temp tables in memory
|
||||||
|
"PRAGMA mmap_size=268435456", // 256MB memory-mapped I/O
|
||||||
|
"PRAGMA page_size=4096", // 4KB pages (optimal for most systems)
|
||||||
|
"PRAGMA wal_autocheckpoint=1000", // Auto-checkpoint (PASSIVE) every 1000 WAL frames
|
||||||
|
"PRAGMA journal_size_limit=8388608", // Backstop: cap -wal at 8MiB (issue #49)
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectHook applies all pragmas to a freshly opened connection. mattn/go-sqlite3 calls
|
||||||
|
// it at the very end of Open, after DSN params and extensions, so it is authoritative.
|
||||||
|
func connectHook(c *sqlite3.SQLiteConn) error {
|
||||||
|
for _, pragma := range correctnessPragmas {
|
||||||
|
if _, err := c.Exec(pragma, nil); err != nil {
|
||||||
|
return fmt.Errorf("apply correctness pragma %q: %w", pragma, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, pragma := range bestEffortPragmas {
|
||||||
|
if _, err := c.Exec(pragma, nil); err != nil {
|
||||||
|
log.Warn().Str("pragma", pragma).Err(err).Msg("Failed to set pragma (non-fatal)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerDriver registers the custom driver once. sqlite_vec.Auto() registers the vec
|
||||||
|
// extension globally via sqlite3_auto_extension, which applies to connections from any
|
||||||
|
// sqlite3-based driver, so the new driver still gets vec + FTS5.
|
||||||
|
func registerDriver() {
|
||||||
|
registerDriverOnce.Do(func() {
|
||||||
|
sql.Register(driverName, &sqlite3.SQLiteDriver{
|
||||||
|
ConnectHook: func(c *sqlite3.SQLiteConn) error {
|
||||||
|
return connectHook(c)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Store represents the GORM database connection with sqlite-vec support.
|
// Store represents the GORM database connection with sqlite-vec support.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
healthCacheTime time.Time
|
healthCacheTime time.Time
|
||||||
@@ -24,6 +86,7 @@ type Store struct {
|
|||||||
sqlDB *sql.DB
|
sqlDB *sql.DB
|
||||||
metrics *PoolMetrics
|
metrics *PoolMetrics
|
||||||
cachedHealth *HealthInfo
|
cachedHealth *HealthInfo
|
||||||
|
path string
|
||||||
healthCacheTTL time.Duration
|
healthCacheTTL time.Duration
|
||||||
healthCacheMu sync.RWMutex
|
healthCacheMu sync.RWMutex
|
||||||
}
|
}
|
||||||
@@ -36,22 +99,32 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewStore creates a new Store with WAL mode enabled and sqlite-vec registered.
|
// NewStore creates a new Store with WAL mode enabled and sqlite-vec registered.
|
||||||
// CRITICAL: WAL mode and foreign keys are enabled via pragmas for concurrent reads.
|
// CRITICAL: all connection pragmas (WAL, foreign_keys, busy_timeout, etc.) are applied to
|
||||||
|
// EVERY pooled connection via a driver ConnectHook (see registerDriver), so the pool is
|
||||||
|
// uniformly configured and connections may be recycled safely (issue #49, F6).
|
||||||
func NewStore(cfg Config) (*Store, error) {
|
func NewStore(cfg Config) (*Store, error) {
|
||||||
// 1. Register sqlite-vec extension (must be done before opening database)
|
// 1. Register sqlite-vec extension (must be done before opening database).
|
||||||
|
// sqlite_vec.Auto() uses sqlite3_auto_extension, which is global to all sqlite3-based
|
||||||
|
// drivers, so connections from our custom driver also get the vec virtual table.
|
||||||
sqlite_vec.Auto()
|
sqlite_vec.Auto()
|
||||||
|
|
||||||
// 2. Build connection string (foreign keys enabled in DSN)
|
// 2. Register the custom driver whose ConnectHook applies ALL pragmas to EVERY pooled
|
||||||
// Use sqlite3 driver (mattn/go-sqlite3) which has FTS5 support
|
// connection (issue #49, F6). Without this, pragmas set via a single post-open
|
||||||
|
// sqlDB.Exec reach only one arbitrary pooled connection. The hook is authoritative.
|
||||||
|
registerDriver()
|
||||||
|
|
||||||
|
// 3. Build a minimal DSN. _foreign_keys is kept as belt-and-suspenders (the hook sets
|
||||||
|
// it too); all other pragmas are applied per-connection by the ConnectHook, so they no
|
||||||
|
// longer need to live in the DSN.
|
||||||
dsn := cfg.Path + "?_foreign_keys=ON"
|
dsn := cfg.Path + "?_foreign_keys=ON"
|
||||||
|
|
||||||
// 3. Open raw database connection with mattn/go-sqlite3 (has FTS5 support)
|
// 4. Open raw database connection with the custom driver (FTS5 + per-connection pragmas).
|
||||||
sqlDB, err := sql.Open("sqlite3", dsn)
|
sqlDB, err := sql.Open(driverName, dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open database: %w", err)
|
return nil, fmt.Errorf("open database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Wrap with GORM using existing connection
|
// 5. Wrap with GORM using existing connection
|
||||||
db, err := gorm.Open(sqlite.Dialector{
|
db, err := gorm.Open(sqlite.Dialector{
|
||||||
Conn: sqlDB,
|
Conn: sqlDB,
|
||||||
}, &gorm.Config{
|
}, &gorm.Config{
|
||||||
@@ -66,16 +139,25 @@ func NewStore(cfg Config) (*Store, error) {
|
|||||||
return nil, fmt.Errorf("open gorm: %w", err)
|
return nil, fmt.Errorf("open gorm: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Configure connection pool (same settings as current implementation)
|
// 6. Configure connection pool.
|
||||||
maxConns := cfg.MaxConns
|
maxConns := cfg.MaxConns
|
||||||
if maxConns <= 0 {
|
if maxConns <= 0 {
|
||||||
maxConns = 4
|
maxConns = 4
|
||||||
}
|
}
|
||||||
sqlDB.SetMaxOpenConns(maxConns)
|
sqlDB.SetMaxOpenConns(maxConns)
|
||||||
sqlDB.SetMaxIdleConns(maxConns)
|
sqlDB.SetMaxIdleConns(maxConns)
|
||||||
sqlDB.SetConnMaxLifetime(0) // Never expire (SQLite connections are cheap)
|
// Finite recycling (issue #49): previously SetConnMaxLifetime(0) meant connections
|
||||||
|
// NEVER recycled, so a long-lived read connection could pin an old WAL read-mark for the
|
||||||
|
// whole process lifetime and block TRUNCATE checkpoints from reclaiming the -wal file.
|
||||||
|
// Recycling is safe now because the ConnectHook reapplies every correctness pragma on
|
||||||
|
// each new connection — a recycled connection comes back fully configured, not with
|
||||||
|
// defaults. 1h lifetime bounds read-mark staleness without churning the pool; 30m idle
|
||||||
|
// time reclaims connections that sit unused (e.g. between sessions) so the pool shrinks
|
||||||
|
// back to one warm connection during quiet periods, dropping their WAL read-marks.
|
||||||
|
sqlDB.SetConnMaxLifetime(1 * time.Hour)
|
||||||
|
sqlDB.SetConnMaxIdleTime(30 * time.Minute)
|
||||||
|
|
||||||
// 6. Verify connection
|
// 7. Verify connection
|
||||||
if err := sqlDB.Ping(); err != nil {
|
if err := sqlDB.Ping(); err != nil {
|
||||||
return nil, fmt.Errorf("ping database: %w", err)
|
return nil, fmt.Errorf("ping database: %w", err)
|
||||||
}
|
}
|
||||||
@@ -83,37 +165,18 @@ func NewStore(cfg Config) (*Store, error) {
|
|||||||
store := &Store{
|
store := &Store{
|
||||||
DB: db,
|
DB: db,
|
||||||
sqlDB: sqlDB,
|
sqlDB: sqlDB,
|
||||||
|
path: cfg.Path,
|
||||||
metrics: NewPoolMetrics(100), // Track last 100 latency samples
|
metrics: NewPoolMetrics(100), // Track last 100 latency samples
|
||||||
healthCacheTTL: 5 * time.Second, // Cache health checks for 5 seconds
|
healthCacheTTL: 5 * time.Second, // Cache health checks for 5 seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Run migrations FIRST (before PRAGMA commands)
|
// 8. Run migrations. All pragmas (correctness + best-effort) are applied per-connection
|
||||||
|
// by the ConnectHook at open time, so there is no post-open pragma loop here anymore:
|
||||||
|
// such a loop only ever reached one arbitrary pooled connection (issue #49, F6).
|
||||||
if err := runMigrations(db, sqlDB); err != nil {
|
if err := runMigrations(db, sqlDB); err != nil {
|
||||||
return nil, fmt.Errorf("run migrations: %w", err)
|
return nil, fmt.Errorf("run migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. CRITICAL: Set WAL mode and other performance pragmas
|
|
||||||
// Use raw sqlDB to avoid GORM transaction issues
|
|
||||||
pragmas := []string{
|
|
||||||
"PRAGMA journal_mode=WAL",
|
|
||||||
"PRAGMA synchronous=NORMAL",
|
|
||||||
"PRAGMA cache_size=-64000", // 64MB cache (negative = KB)
|
|
||||||
"PRAGMA temp_store=MEMORY", // Store temp tables in memory
|
|
||||||
"PRAGMA mmap_size=268435456", // 256MB memory-mapped I/O
|
|
||||||
"PRAGMA page_size=4096", // 4KB pages (optimal for most systems)
|
|
||||||
"PRAGMA wal_autocheckpoint=1000", // Explicit default; checkpoint every 1000 WAL frames
|
|
||||||
}
|
|
||||||
for _, pragma := range pragmas {
|
|
||||||
if _, err := sqlDB.Exec(pragma); err != nil {
|
|
||||||
log.Warn().Str("pragma", pragma).Err(err).Msg("Failed to set pragma (non-fatal)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Set busy timeout to 5 seconds to handle concurrent writes
|
|
||||||
// This allows SQLite to retry when database is locked instead of failing immediately
|
|
||||||
if _, err := sqlDB.Exec("PRAGMA busy_timeout=5000"); err != nil {
|
|
||||||
return nil, fmt.Errorf("set busy timeout: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. Warm the connection pool
|
// 9. Warm the connection pool
|
||||||
store.WarmPool(maxConns)
|
store.WarmPool(maxConns)
|
||||||
|
|
||||||
@@ -148,11 +211,55 @@ func (s *Store) WarmPool(numConns int) {
|
|||||||
log.Debug().Int("connections", numConns).Msg("Connection pool warmed")
|
log.Debug().Int("connections", numConns).Msg("Connection pool warmed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the database connection.
|
// Close checkpoints the WAL (TRUNCATE) before closing the connection. Checkpointing on
|
||||||
|
// shutdown prevents the WAL file from persisting in a large, dirty state across restarts
|
||||||
|
// and config-change reinitializations, which otherwise leaves a multi-megabyte -wal file
|
||||||
|
// on disk (issue #49). The checkpoint is best-effort: a failure is logged, not fatal.
|
||||||
func (s *Store) Close() error {
|
func (s *Store) Close() error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.Checkpoint(ctx); err != nil {
|
||||||
|
log.Warn().Err(err).Msg("WAL checkpoint on close failed (non-fatal)")
|
||||||
|
}
|
||||||
return s.sqlDB.Close()
|
return s.sqlDB.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checkpoint runs a TRUNCATE WAL checkpoint: it flushes WAL frames into the main
|
||||||
|
// database file and shrinks the -wal file back to zero. Unlike a PASSIVE checkpoint
|
||||||
|
// (which never truncates the file and is all SQLite's auto-checkpoint ever performs), a
|
||||||
|
// TRUNCATE checkpoint reclaims disk and is the mechanism that bounds WAL growth.
|
||||||
|
// It waits up to the connection busy_timeout for the write lock and returns an error
|
||||||
|
// rather than blocking indefinitely.
|
||||||
|
func (s *Store) Checkpoint(ctx context.Context) error {
|
||||||
|
if _, err := s.sqlDB.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||||
|
return fmt.Errorf("wal checkpoint (truncate): %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WALSize returns the size in bytes of the SQLite WAL sidecar file (<db>-wal), or 0 if
|
||||||
|
// it does not exist or cannot be stat'd. Used to decide when a checkpoint is worthwhile.
|
||||||
|
func (s *Store) WALSize() int64 {
|
||||||
|
if s.path == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
info, err := os.Stat(s.path + "-wal")
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckpointIfLarge performs a TRUNCATE checkpoint only when the WAL file has grown to or
|
||||||
|
// beyond threshold bytes. Returns true if a checkpoint was performed. This keeps the
|
||||||
|
// periodic checkpoint cheap: it does no work while the WAL is small.
|
||||||
|
func (s *Store) CheckpointIfLarge(ctx context.Context, threshold int64) (bool, error) {
|
||||||
|
if s.WALSize() < threshold {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, s.Checkpoint(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// Ping verifies the database connection is alive.
|
// Ping verifies the database connection is alive.
|
||||||
func (s *Store) Ping() error {
|
func (s *Store) Ping() error {
|
||||||
return s.sqlDB.Ping()
|
return s.sqlDB.Ping()
|
||||||
@@ -193,8 +300,9 @@ func (s *Store) Optimize(ctx context.Context) error {
|
|||||||
log.Warn().Err(err).Msg("PRAGMA optimize failed (non-fatal)")
|
log.Warn().Err(err).Msg("PRAGMA optimize failed (non-fatal)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passive WAL checkpoint — doesn't block readers/writers
|
// TRUNCATE WAL checkpoint — reclaims the -wal file during low-activity optimization.
|
||||||
if _, err := s.sqlDB.ExecContext(ctx, "PRAGMA wal_checkpoint(PASSIVE)"); err != nil {
|
// (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.Warn().Err(err).Msg("WAL checkpoint failed (non-fatal)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,7 +627,6 @@ func (s *Store) ExecWithTimeout(ctx context.Context, timeout time.Duration, quer
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// TransactionWithTimeout wraps a transaction function with timeout handling.
|
// TransactionWithTimeout wraps a transaction function with timeout handling.
|
||||||
// The transaction is automatically rolled back if the context times out.
|
// The transaction is automatically rolled back if the context times out.
|
||||||
func (s *Store) TransactionWithTimeout(ctx context.Context, timeout time.Duration, fn func(*gorm.DB) error) error {
|
func (s *Store) TransactionWithTimeout(ctx context.Context, timeout time.Duration, fn func(*gorm.DB) error) error {
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ package gorm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
@@ -239,3 +242,321 @@ func TestOptimize_RespectsContextCancellation(t *testing.T) {
|
|||||||
t.Error("expected error with cancelled context, got 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,14 +1,12 @@
|
|||||||
// Package embedding provides text embedding generation using all-MiniLM-L6-v2.
|
// Package embedding provides text embedding generation using bge-small-en-v1.5.
|
||||||
package embedding
|
package embedding
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Model and tokenizer files - embedded for all platforms
|
// Tokenizer file - embedded for all platforms (small, not in LFS).
|
||||||
|
// The ONNX model is downloaded at runtime to ~/.claude-mnemonic/models/.
|
||||||
//
|
//
|
||||||
//go:embed assets/model.onnx
|
|
||||||
var modelData []byte
|
|
||||||
|
|
||||||
//go:embed assets/tokenizer.json
|
//go:embed assets/tokenizer.json
|
||||||
var tokenizerData []byte
|
var tokenizerData []byte
|
||||||
|
|||||||
Binary file not shown.
@@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/internal/models"
|
||||||
"github.com/sugarme/tokenizer"
|
"github.com/sugarme/tokenizer"
|
||||||
"github.com/sugarme/tokenizer/pretrained"
|
"github.com/sugarme/tokenizer/pretrained"
|
||||||
ort "github.com/yalue/onnxruntime_go"
|
ort "github.com/yalue/onnxruntime_go"
|
||||||
@@ -29,6 +30,13 @@ const (
|
|||||||
BGEModelName = "bge-small-en-v1.5"
|
BGEModelName = "bge-small-en-v1.5"
|
||||||
// DefaultModelVersion is the default model to use
|
// DefaultModelVersion is the default model to use
|
||||||
DefaultModelVersion = BGEModelVersion
|
DefaultModelVersion = BGEModelVersion
|
||||||
|
|
||||||
|
// BGEAssetName is the local filename for the BGE model.
|
||||||
|
BGEAssetName = "bge-small-en-v1.5-model.onnx"
|
||||||
|
// BGEAssetURL is the Hugging Face URL for the BGE ONNX model.
|
||||||
|
BGEAssetURL = "https://huggingface.co/BAAI/bge-small-en-v1.5/resolve/main/onnx/model.onnx"
|
||||||
|
// BGEModelSHA256 is the expected SHA-256 of the BGE model file.
|
||||||
|
BGEModelSHA256 = "828e1496d7fabb79cfa4dcd84fa38625c0d3d21da474a00f08db0f559940cf35"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MaxSequenceLength is the maximum token sequence length for the model.
|
// MaxSequenceLength is the maximum token sequence length for the model.
|
||||||
@@ -60,6 +68,7 @@ var _ EmbeddingModel = (*bgeModel)(nil)
|
|||||||
var _ ONNXConfigurer = (*bgeModel)(nil)
|
var _ ONNXConfigurer = (*bgeModel)(nil)
|
||||||
|
|
||||||
// newBGEModel creates a new BGE embedding model using bundled ONNX runtime and model.
|
// newBGEModel creates a new BGE embedding model using bundled ONNX runtime and model.
|
||||||
|
// The ONNX model is downloaded at runtime from GitHub Releases and cached to disk.
|
||||||
func newBGEModel() (EmbeddingModel, error) {
|
func newBGEModel() (EmbeddingModel, error) {
|
||||||
// Extract ONNX runtime library to temp directory
|
// Extract ONNX runtime library to temp directory
|
||||||
libDir, err := extractONNXLibrary()
|
libDir, err := extractONNXLibrary()
|
||||||
@@ -84,9 +93,15 @@ func newBGEModel() (EmbeddingModel, error) {
|
|||||||
return nil, fmt.Errorf("load tokenizer: %w", err)
|
return nil, fmt.Errorf("load tokenizer: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create ONNX session using model-specific configuration
|
// Download model on first use (cached to ~/.claude-mnemonic/models/)
|
||||||
|
modelPath, err := models.EnsureModel(BGEAssetName, BGEAssetURL, BGEModelSHA256)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure embedding model: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ONNX session from file path using model-specific configuration
|
||||||
config := bgeONNXConfig
|
config := bgeONNXConfig
|
||||||
session, err := ort.NewDynamicAdvancedSessionWithONNXData(modelData, config.InputNames, config.OutputNames, nil)
|
session, err := ort.NewDynamicAdvancedSession(modelPath, config.InputNames, config.OutputNames, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create ONNX session: %w", err)
|
return nil, fmt.Errorf("create ONNX session: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,8 +83,20 @@ func (s *Service) Start(ctx context.Context) {
|
|||||||
Bool("cleanup_stale", s.config.CleanupStaleObservations).
|
Bool("cleanup_stale", s.config.CleanupStaleObservations).
|
||||||
Msg("Starting maintenance scheduler")
|
Msg("Starting maintenance scheduler")
|
||||||
|
|
||||||
// Initial run after 5 minutes (allow system to stabilize)
|
// Initial run after 5 minutes (allow system to stabilize).
|
||||||
time.Sleep(5 * time.Minute)
|
// Use a cancellable timer so shutdown (ctx cancel / Stop) is not blocked for up to 5m.
|
||||||
|
initialDelay := time.NewTimer(5 * time.Minute)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
initialDelay.Stop()
|
||||||
|
s.log.Info().Msg("Maintenance shutting down before initial run (context cancellation)")
|
||||||
|
return
|
||||||
|
case <-s.stopCh:
|
||||||
|
initialDelay.Stop()
|
||||||
|
s.log.Info().Msg("Maintenance shutting down before initial run (stop signal)")
|
||||||
|
return
|
||||||
|
case <-initialDelay.C:
|
||||||
|
}
|
||||||
s.runMaintenance(ctx)
|
s.runMaintenance(ctx)
|
||||||
|
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
@@ -288,7 +300,13 @@ func (s *Service) Stats() map[string]any {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunNow triggers an immediate maintenance run.
|
// RunNow triggers an immediate maintenance run in the background.
|
||||||
func (s *Service) RunNow(ctx context.Context) {
|
func (s *Service) RunNow(ctx context.Context) {
|
||||||
go s.runMaintenance(ctx)
|
go s.runMaintenance(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunNowSync triggers an immediate maintenance run and blocks until it completes.
|
||||||
|
// Use this when the caller needs to report a synchronous result (e.g. an HTTP handler).
|
||||||
|
func (s *Service) RunNowSync(ctx context.Context) {
|
||||||
|
s.runMaintenance(ctx)
|
||||||
|
}
|
||||||
|
|||||||
@@ -534,7 +534,7 @@ func (s *Server) handleToolsList(req *Request) *Response {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "trigger_maintenance",
|
Name: "trigger_maintenance",
|
||||||
Description: "Trigger an immediate maintenance run (cleanup old observations, optimize database).",
|
Description: "Trigger an immediate database maintenance run: optimize/checkpoint the database, clean up old prompts, apply any configured observation retention/stale cleanup, and recalculate importance scores.",
|
||||||
InputSchema: map[string]any{
|
InputSchema: map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{},
|
"properties": map[string]any{},
|
||||||
@@ -917,7 +917,7 @@ func (s *Server) callTool(ctx context.Context, name string, args json.RawMessage
|
|||||||
"project": s.project,
|
"project": s.project,
|
||||||
})
|
})
|
||||||
case "trigger_maintenance":
|
case "trigger_maintenance":
|
||||||
return s.proxyPostRaw(ctx, "/api/scoring/recalculate", nil)
|
return s.proxyPostRaw(ctx, "/api/maintenance/run", nil)
|
||||||
case "analyze_observation_importance":
|
case "analyze_observation_importance":
|
||||||
return s.handleAnalyzeImportanceProxy(ctx, args)
|
return s.handleAnalyzeImportanceProxy(ctx, args)
|
||||||
case "analyze_search_patterns":
|
case "analyze_search_patterns":
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
// Package models provides runtime model download and caching.
|
||||||
|
// Models are downloaded from Hugging Face on first use and cached
|
||||||
|
// to the OS cache directory (~/Library/Caches/claude-mnemonic/models/ on macOS,
|
||||||
|
// ~/.cache/claude-mnemonic/models/ on Linux, %LocalAppData%/claude-mnemonic/models/ on Windows).
|
||||||
|
// This replaces the previous approach of embedding models in the binary
|
||||||
|
// via go:embed and tracking them with Git LFS.
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ModelVersion is incremented to force re-download of all cached models.
|
||||||
|
ModelVersion = "1"
|
||||||
|
|
||||||
|
maxRetries = 3
|
||||||
|
retryBaseDelay = 2 * time.Second
|
||||||
|
progressEvery = 3 * time.Second
|
||||||
|
downloadTimeout = 15 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// ModelsDir returns the path to the model cache directory.
|
||||||
|
// Uses the OS cache dir for cross-platform compatibility:
|
||||||
|
//
|
||||||
|
// macOS: ~/Library/Caches/claude-mnemonic/models
|
||||||
|
// Linux: ~/.cache/claude-mnemonic/models
|
||||||
|
// Windows: %LocalAppData%/claude-mnemonic/models
|
||||||
|
func ModelsDir() string {
|
||||||
|
dir, err := os.UserCacheDir()
|
||||||
|
if err != nil {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
dir = filepath.Join(home, ".cache")
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "claude-mnemonic", "models")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureModel ensures the model file exists and returns its path.
|
||||||
|
//
|
||||||
|
// Resolution order:
|
||||||
|
// 1. CLAUDE_MNEMONIC_MODEL_DIR env var — if set, load models from this directory directly.
|
||||||
|
// This is intended for development and CI where models are already on disk.
|
||||||
|
// 2. User cache directory — if the model is already cached and its checksum matches,
|
||||||
|
// return it immediately.
|
||||||
|
// 3. url — download the model from the given URL with retries and cache it.
|
||||||
|
//
|
||||||
|
// assetName is the local filename (e.g. "bge-small-en-v1.5-model.onnx").
|
||||||
|
// expectedSHA256 is the hex-encoded SHA-256 of the file.
|
||||||
|
func EnsureModel(assetName, url, expectedSHA256 string) (string, error) {
|
||||||
|
if localDir := os.Getenv("CLAUDE_MNEMONIC_MODEL_DIR"); localDir != "" {
|
||||||
|
localPath := filepath.Join(localDir, assetName)
|
||||||
|
if _, err := os.Stat(localPath); err == nil {
|
||||||
|
if valid, err := verifyChecksum(localPath, expectedSHA256); err != nil || !valid {
|
||||||
|
return "", fmt.Errorf("local model %s checksum mismatch", assetName)
|
||||||
|
}
|
||||||
|
return localPath, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("local model %s not found in %s", assetName, localDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := ModelsDir()
|
||||||
|
versionFile := filepath.Join(dir, ".model_version")
|
||||||
|
modelPath := filepath.Join(dir, assetName)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return "", fmt.Errorf("create model cache dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentVersion, _ := os.ReadFile(versionFile)
|
||||||
|
|
||||||
|
if string(currentVersion) == ModelVersion {
|
||||||
|
if fi, err := os.Stat(modelPath); err == nil && fi.Size() > 0 {
|
||||||
|
if valid, _ := verifyChecksum(modelPath, expectedSHA256); valid {
|
||||||
|
return modelPath, nil
|
||||||
|
}
|
||||||
|
log.Warn().Str("model", assetName).Msg("Cached model corrupted, re-downloading")
|
||||||
|
os.Remove(modelPath)
|
||||||
|
}
|
||||||
|
} else if len(currentVersion) > 0 {
|
||||||
|
log.Info().Str("old", string(currentVersion)).Str("new", ModelVersion).Msg("Model version updated, clearing cache")
|
||||||
|
os.RemoveAll(dir)
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return "", fmt.Errorf("create model cache dir: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := downloadWithRetries(url, modelPath); err != nil {
|
||||||
|
os.Remove(modelPath)
|
||||||
|
return "", fmt.Errorf("download %s: %w", assetName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if valid, err := verifyChecksum(modelPath, expectedSHA256); err != nil || !valid {
|
||||||
|
os.Remove(modelPath)
|
||||||
|
return "", fmt.Errorf("downloaded model %s failed checksum verification — the file at %s may have changed", assetName, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(versionFile, []byte(ModelVersion), 0600); err != nil {
|
||||||
|
log.Warn().Err(err).Msg("Failed to write model version file")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Str("model", assetName).Str("cache", dir).Msg("Model ready")
|
||||||
|
return modelPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadWithRetries(url, dest string) error {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
delay := retryBaseDelay * time.Duration(1<<(attempt-1))
|
||||||
|
log.Warn().Int("attempt", attempt+1).Int("max", maxRetries).Dur("delay", delay).Msg("Retrying download")
|
||||||
|
time.Sleep(delay)
|
||||||
|
}
|
||||||
|
if err := downloadFile(url, dest); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed after %d attempts: %w", maxRetries, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFile(url, dest string) error {
|
||||||
|
client := &http.Client{Timeout: downloadTimeout}
|
||||||
|
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("HTTP GET: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp := dest + ".tmp"
|
||||||
|
f, err := os.Create(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temp file: %w", err)
|
||||||
|
}
|
||||||
|
cleanup := func() { f.Close(); os.Remove(tmp) }
|
||||||
|
|
||||||
|
name := filepath.Base(dest)
|
||||||
|
reader := &progressReader{
|
||||||
|
reader: resp.Body,
|
||||||
|
total: resp.ContentLength,
|
||||||
|
name: name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(f, reader); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return fmt.Errorf("write file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return fmt.Errorf("sync file: %w", err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
if err := os.Rename(tmp, dest); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return fmt.Errorf("commit file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// progressReader wraps an io.Reader and logs progress for large downloads.
|
||||||
|
type progressReader struct {
|
||||||
|
reader io.Reader
|
||||||
|
total int64
|
||||||
|
current int64
|
||||||
|
name string
|
||||||
|
lastLog time.Time
|
||||||
|
loggedPct int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pr *progressReader) Read(p []byte) (n int, err error) {
|
||||||
|
n, err = pr.reader.Read(p)
|
||||||
|
pr.current += int64(n)
|
||||||
|
|
||||||
|
if time.Since(pr.lastLog) >= progressEvery || err == io.EOF {
|
||||||
|
if pr.total > 0 {
|
||||||
|
pct := int(math.Round(float64(pr.current) / float64(pr.total) * 100))
|
||||||
|
if pct != pr.loggedPct {
|
||||||
|
pr.loggedPct = pct
|
||||||
|
mibCurrent := float64(pr.current) / (1024 * 1024)
|
||||||
|
mibTotal := float64(pr.total) / (1024 * 1024)
|
||||||
|
log.Info().
|
||||||
|
Str("model", pr.name).
|
||||||
|
Int("pct", pct).
|
||||||
|
Str("progress", fmt.Sprintf("%.0f/%.0f MiB", mibCurrent, mibTotal)).
|
||||||
|
Msg("Downloading")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mibCurrent := float64(pr.current) / (1024 * 1024)
|
||||||
|
log.Info().
|
||||||
|
Str("model", pr.name).
|
||||||
|
Str("progress", fmt.Sprintf("%.0f MiB", mibCurrent)).
|
||||||
|
Msg("Downloading (size unknown)")
|
||||||
|
}
|
||||||
|
pr.lastLog = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyChecksum(path, expectedHex string) (bool, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
actual := hex.EncodeToString(h.Sum(nil))
|
||||||
|
return actual == expectedHex, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanStale removes any temporary download files that may have been left behind
|
||||||
|
// by a previous interrupted download.
|
||||||
|
func CleanStale() error {
|
||||||
|
dir := ModelsDir()
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
if filepath.Ext(e.Name()) == ".tmp" {
|
||||||
|
tmpPath := filepath.Join(dir, e.Name())
|
||||||
|
if info, err := e.Info(); err == nil {
|
||||||
|
if time.Since(info.ModTime()) > time.Hour {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
log.Debug().Str("file", tmpPath).Msg("Cleaned stale temp file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -5,10 +5,8 @@ import (
|
|||||||
_ "embed"
|
_ "embed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cross-encoder model and tokenizer files - embedded for all platforms
|
// Tokenizer file - embedded for all platforms (small, not in LFS).
|
||||||
|
// The ONNX model is downloaded at runtime to ~/.claude-mnemonic/models/.
|
||||||
//
|
//
|
||||||
//go:embed assets/model.onnx
|
|
||||||
var crossEncoderModelData []byte
|
|
||||||
|
|
||||||
//go:embed assets/tokenizer.json
|
//go:embed assets/tokenizer.json
|
||||||
var crossEncoderTokenizerData []byte
|
var crossEncoderTokenizerData []byte
|
||||||
|
|||||||
Binary file not shown.
@@ -9,6 +9,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/internal/models"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/sugarme/tokenizer"
|
"github.com/sugarme/tokenizer"
|
||||||
"github.com/sugarme/tokenizer/pretrained"
|
"github.com/sugarme/tokenizer/pretrained"
|
||||||
@@ -26,6 +27,13 @@ const (
|
|||||||
DefaultCandidateLimit = 100
|
DefaultCandidateLimit = 100
|
||||||
// DefaultResultLimit is the default number of results to return after reranking
|
// DefaultResultLimit is the default number of results to return after reranking
|
||||||
DefaultResultLimit = 10
|
DefaultResultLimit = 10
|
||||||
|
|
||||||
|
// CrossEncoderAssetName is the local filename for the cross-encoder model.
|
||||||
|
CrossEncoderAssetName = "ms-marco-MiniLM-L6-v2-model.onnx"
|
||||||
|
// CrossEncoderAssetURL is the Hugging Face URL for the cross-encoder ONNX model.
|
||||||
|
CrossEncoderAssetURL = "https://huggingface.co/cross-encoder/ms-marco-MiniLM-L6-v2/resolve/main/onnx/model.onnx"
|
||||||
|
// CrossEncoderModelSHA256 is the expected SHA-256 of the cross-encoder model file.
|
||||||
|
CrossEncoderModelSHA256 = "5d3e70fd0c9ff14b9b5169a51e957b7a9c74897afd0a35ce4bd318150c1d4d4a"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Candidate represents a search result candidate for reranking.
|
// Candidate represents a search result candidate for reranking.
|
||||||
@@ -91,12 +99,18 @@ func NewService(cfg Config) (*Service, error) {
|
|||||||
Stride: 0,
|
Stride: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Download model on first use (cached to ~/.claude-mnemonic/models/)
|
||||||
|
modelPath, err := models.EnsureModel(CrossEncoderAssetName, CrossEncoderAssetURL, CrossEncoderModelSHA256)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure cross-encoder model: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Cross-encoder outputs a single logit for relevance scoring
|
// Cross-encoder outputs a single logit for relevance scoring
|
||||||
inputNames := []string{"input_ids", "attention_mask", "token_type_ids"}
|
inputNames := []string{"input_ids", "attention_mask", "token_type_ids"}
|
||||||
outputNames := []string{"logits"}
|
outputNames := []string{"logits"}
|
||||||
|
|
||||||
session, err := ort.NewDynamicAdvancedSessionWithONNXData(
|
session, err := ort.NewDynamicAdvancedSession(
|
||||||
crossEncoderModelData,
|
modelPath,
|
||||||
inputNames,
|
inputNames,
|
||||||
outputNames,
|
outputNames,
|
||||||
nil,
|
nil,
|
||||||
|
|||||||
@@ -314,6 +314,49 @@ func (s *Service) handleTriggerRecalculation(w http.ResponseWriter, r *http.Requ
|
|||||||
writeJSON(w, map[string]string{"status": "recalculation triggered"})
|
writeJSON(w, map[string]string{"status": "recalculation triggered"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRunMaintenance triggers an immediate, synchronous database maintenance run
|
||||||
|
// (Optimize/TRUNCATE checkpoint + prompt cleanup + any enabled retention/stale cleanup)
|
||||||
|
// and also kicks off an importance-score recalculation in the background so the behavior
|
||||||
|
// of the previous trigger_maintenance tool is preserved (issue #49).
|
||||||
|
func (s *Service) handleRunMaintenance(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// initMu.RLock held by requireReady middleware
|
||||||
|
maintSvc := s.maintenanceSvc
|
||||||
|
recalculator := s.recalculator
|
||||||
|
|
||||||
|
if maintSvc == nil {
|
||||||
|
http.Error(w, "maintenance service not available", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run maintenance synchronously with an independent, bounded context so the caller
|
||||||
|
// receives a real completion status. Use context.Background so an HTTP client timeout
|
||||||
|
// does not abort an in-progress DB maintenance pass.
|
||||||
|
mctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
maintSvc.RunNowSync(mctx)
|
||||||
|
|
||||||
|
// Preserve prior trigger_maintenance behavior: also recalculate importance scores.
|
||||||
|
recalcTriggered := false
|
||||||
|
if recalculator != nil {
|
||||||
|
recalcTriggered = true
|
||||||
|
s.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := recalculator.RecalculateNow(ctx); err != nil {
|
||||||
|
log.Error().Err(err).Msg("Background recalculation during maintenance failed")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"status": "maintenance completed",
|
||||||
|
"recalc_triggered": recalcTriggered,
|
||||||
|
"maintenance_stats": maintSvc.Stats(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// parseIntParam parses an integer query parameter with a default value.
|
// parseIntParam parses an integer query parameter with a default value.
|
||||||
func parseIntParam(r *http.Request, name string, defaultVal int) int {
|
func parseIntParam(r *http.Request, name string, defaultVal int) int {
|
||||||
if val := r.URL.Query().Get(name); val != "" {
|
if val := r.URL.Query().Get(name); val != "" {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
|
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
|
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
|
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/internal/maintenance"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/pattern"
|
"github.com/lukaszraczylo/claude-mnemonic/internal/pattern"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/reranking"
|
"github.com/lukaszraczylo/claude-mnemonic/internal/reranking"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/scoring"
|
"github.com/lukaszraczylo/claude-mnemonic/internal/scoring"
|
||||||
@@ -44,6 +45,14 @@ const (
|
|||||||
// QueueProcessInterval is how often the background queue processor runs.
|
// QueueProcessInterval is how often the background queue processor runs.
|
||||||
QueueProcessInterval = 2 * time.Second
|
QueueProcessInterval = 2 * time.Second
|
||||||
|
|
||||||
|
// WALCheckpointInterval is how often the worker checks whether the SQLite WAL needs a
|
||||||
|
// TRUNCATE checkpoint to reclaim disk and prevent unbounded growth (issue #49).
|
||||||
|
WALCheckpointInterval = 60 * time.Second
|
||||||
|
|
||||||
|
// WALCheckpointThreshold is the WAL file size at or above which the periodic check
|
||||||
|
// performs a TRUNCATE checkpoint. Keeps the steady-state WAL bounded to a few MB.
|
||||||
|
WALCheckpointThreshold = 4 << 20 // 4 MiB
|
||||||
|
|
||||||
// reinitializationDrainDelay is the delay after marking the service as not ready
|
// reinitializationDrainDelay is the delay after marking the service as not ready
|
||||||
// to allow in-flight requests to complete before reinitializing.
|
// to allow in-flight requests to complete before reinitializing.
|
||||||
reinitializationDrainDelay = 200 * time.Millisecond
|
reinitializationDrainDelay = 200 * time.Millisecond
|
||||||
@@ -121,6 +130,7 @@ type Service struct {
|
|||||||
patternStore *gorm.PatternStore
|
patternStore *gorm.PatternStore
|
||||||
relationStore *gorm.RelationStore
|
relationStore *gorm.RelationStore
|
||||||
patternDetector *pattern.Detector
|
patternDetector *pattern.Detector
|
||||||
|
maintenanceSvc *maintenance.Service
|
||||||
sessionManager *session.Manager
|
sessionManager *session.Manager
|
||||||
sseBroadcaster *sse.Broadcaster
|
sseBroadcaster *sse.Broadcaster
|
||||||
processor *sdk.Processor
|
processor *sdk.Processor
|
||||||
@@ -570,6 +580,34 @@ func (s *Service) initializeAsync() {
|
|||||||
go s.processQueue()
|
go s.processQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start periodic WAL checkpoint loop to bound SQLite WAL file growth (issue #49).
|
||||||
|
s.wg.Add(1)
|
||||||
|
go s.walCheckpointLoop()
|
||||||
|
|
||||||
|
// Start the scheduled maintenance service (issue #49: was dead code, never instantiated).
|
||||||
|
// vectorCleanupFn mirrors the observation store's cleanup hook so age/stale deletions done
|
||||||
|
// directly via GORM still remove their vectors from sqlite-vec.
|
||||||
|
var vectorCleanupFn func(ctx context.Context, deletedIDs []int64)
|
||||||
|
if vectorSync != nil {
|
||||||
|
vectorCleanupFn = func(ctx context.Context, deletedIDs []int64) {
|
||||||
|
if err := retryWithBackoff(ctx, VectorSyncMaxRetries, VectorSyncInitialBackoff, func() error {
|
||||||
|
return vectorSync.DeleteObservations(ctx, deletedIDs)
|
||||||
|
}); err != nil {
|
||||||
|
log.Warn().Err(err).Ints64("ids", deletedIDs).Msg("Failed to delete observations from sqlite-vec during maintenance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maintSvc := maintenance.NewService(store, observationStore, summaryStore, promptStore, vectorCleanupFn, s.config, log.Logger)
|
||||||
|
s.initMu.Lock()
|
||||||
|
s.maintenanceSvc = maintSvc
|
||||||
|
s.initMu.Unlock()
|
||||||
|
s.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
maintSvc.Start(s.ctx)
|
||||||
|
}()
|
||||||
|
log.Info().Msg("Maintenance scheduler started")
|
||||||
|
|
||||||
// Start file watchers for auto-recreation on deletion
|
// Start file watchers for auto-recreation on deletion
|
||||||
s.startWatchers()
|
s.startWatchers()
|
||||||
|
|
||||||
@@ -1290,6 +1328,9 @@ func (s *Service) setupRoutes() {
|
|||||||
r.Put("/api/scoring/concepts/{concept}", s.handleUpdateConceptWeight)
|
r.Put("/api/scoring/concepts/{concept}", s.handleUpdateConceptWeight)
|
||||||
r.Post("/api/scoring/recalculate", s.handleTriggerRecalculation)
|
r.Post("/api/scoring/recalculate", s.handleTriggerRecalculation)
|
||||||
|
|
||||||
|
// Maintenance: run an immediate synchronous DB maintenance pass (issue #49)
|
||||||
|
r.Post("/api/maintenance/run", s.handleRunMaintenance)
|
||||||
|
|
||||||
// Context injection
|
// Context injection
|
||||||
r.Get("/api/context/count", s.handleContextCount)
|
r.Get("/api/context/count", s.handleContextCount)
|
||||||
r.Get("/api/context/inject", s.handleContextInject)
|
r.Get("/api/context/inject", s.handleContextInject)
|
||||||
@@ -1621,6 +1662,52 @@ func (s *Service) processQueue() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// walCheckpointLoop periodically checkpoints the SQLite WAL so it cannot grow unbounded
|
||||||
|
// during long-lived sessions. SQLite's internal auto-checkpoint is PASSIVE and never
|
||||||
|
// shrinks the -wal file; under sustained writes with overlapping readers it can leave the
|
||||||
|
// WAL growing without limit (issue #49). This loop performs a TRUNCATE checkpoint whenever
|
||||||
|
// the WAL has grown to WALCheckpointThreshold, and does nothing while it is small.
|
||||||
|
func (s *Service) walCheckpointLoop() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
|
||||||
|
// Tunable via config; fall back to the package constants when unset/<=0 (issue #49).
|
||||||
|
interval := WALCheckpointInterval
|
||||||
|
if s.config != nil && s.config.WALCheckpointIntervalSeconds > 0 {
|
||||||
|
interval = time.Duration(s.config.WALCheckpointIntervalSeconds) * time.Second
|
||||||
|
}
|
||||||
|
threshold := int64(WALCheckpointThreshold)
|
||||||
|
if s.config != nil && s.config.WALCheckpointThresholdBytes > 0 {
|
||||||
|
threshold = s.config.WALCheckpointThresholdBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.initMu.RLock()
|
||||||
|
store := s.store
|
||||||
|
s.initMu.RUnlock()
|
||||||
|
if store == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(s.ctx, 15*time.Second)
|
||||||
|
done, err := store.CheckpointIfLarge(ctx, threshold)
|
||||||
|
cancel()
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
log.Warn().Err(err).Msg("Periodic WAL checkpoint failed (non-fatal)")
|
||||||
|
case done:
|
||||||
|
log.Debug().Msg("Periodic WAL checkpoint (TRUNCATE) completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// processAllSessions processes pending messages for all active sessions.
|
// processAllSessions processes pending messages for all active sessions.
|
||||||
// Messages are processed in parallel using goroutines, with concurrency
|
// Messages are processed in parallel using goroutines, with concurrency
|
||||||
// limited by a channel-based semaphore.
|
// limited by a channel-based semaphore.
|
||||||
@@ -1748,6 +1835,9 @@ func (s *Service) Shutdown(ctx context.Context) error {
|
|||||||
if s.patternDetector != nil {
|
if s.patternDetector != nil {
|
||||||
s.patternDetector.Stop()
|
s.patternDetector.Stop()
|
||||||
}
|
}
|
||||||
|
if s.maintenanceSvc != nil {
|
||||||
|
s.maintenanceSvc.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 4: Shutdown sessions (flush pending work)
|
// Phase 4: Shutdown sessions (flush pending work)
|
||||||
log.Debug().Msg("Phase 4: Shutting down sessions...")
|
log.Debug().Msg("Phase 4: Shutting down sessions...")
|
||||||
|
|||||||
+10
-10
@@ -13,7 +13,7 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mnemonic",
|
"name": "claude-mnemonic",
|
||||||
"description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB vector search",
|
"description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB vector search",
|
||||||
"version": "0.12.11",
|
"version": "0.14.5",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "lukaszraczylo",
|
"name": "lukaszraczylo",
|
||||||
"url": "https://github.com/lukaszraczylo"
|
"url": "https://github.com/lukaszraczylo"
|
||||||
@@ -23,25 +23,25 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://github.com/lukaszraczylo/claude-mnemonic",
|
"homepage": "https://github.com/lukaszraczylo/claude-mnemonic",
|
||||||
"releases": {
|
"releases": {
|
||||||
"latest": "0.12.11",
|
"latest": "0.14.5",
|
||||||
"versions": {
|
"versions": {
|
||||||
"0.12.11": {
|
"0.14.5": {
|
||||||
"releaseDate": "2026-05-24",
|
"releaseDate": "2026-06-05",
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.12.11/claude-mnemonic_0.12.11_darwin_arm64.tar.gz",
|
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.14.5/claude-mnemonic_0.14.5_darwin_arm64.tar.gz",
|
||||||
"format": "tar.gz",
|
"format": "tar.gz",
|
||||||
"sha256": "bd97ce0f434d857cf6f5619af6227b318043f1f0b4171def72d978a53e97795e"
|
"sha256": "9bab710ba803db123848e45ab15714bd3595d934e4e9ba8f3dc546c2abbacacb"
|
||||||
},
|
},
|
||||||
"linux-amd64": {
|
"linux-amd64": {
|
||||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.12.11/claude-mnemonic_0.12.11_linux_amd64.tar.gz",
|
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.14.5/claude-mnemonic_0.14.5_linux_amd64.tar.gz",
|
||||||
"format": "tar.gz",
|
"format": "tar.gz",
|
||||||
"sha256": "576aad5143041fef35e0dcfa9a633e4c78b92231fb17007e1e2b084de3c00a15"
|
"sha256": "132e82a45cdd0310e7109a38b68495f536fcf725c4926191307f087e25c8935b"
|
||||||
},
|
},
|
||||||
"windows-amd64": {
|
"windows-amd64": {
|
||||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.12.11/claude-mnemonic_0.12.11_windows_amd64.zip",
|
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.14.5/claude-mnemonic_0.14.5_windows_amd64.zip",
|
||||||
"format": "zip",
|
"format": "zip",
|
||||||
"sha256": "f6b1b8e3d6a8d29e853bcc3b63f3f844f477cb2a3e0049ff498208ac21aeb6ba"
|
"sha256": "ce7d93e658e35cb838ebe76dcd28f94a8f9584b0d4e76b5696511a0ebad0710a"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -564,6 +564,44 @@ func POSTWithContext(ctx context.Context, port int, path string, body interface{
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POSTWithContextResult sends a POST request using the provided context and
|
||||||
|
// decodes the JSON response body, mirroring POST but honoring ctx for
|
||||||
|
// cancellation/deadline. Used on the prompt critical path so a wedged worker
|
||||||
|
// aborts at the hook deadline instead of blocking for the full client timeout.
|
||||||
|
// A non-JSON body is returned as (nil, nil), matching POST's behavior.
|
||||||
|
func POSTWithContextResult(ctx context.Context, port int, path string, body interface{}) (map[string]interface{}, error) {
|
||||||
|
jsonBody, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
fmt.Sprintf("http://127.0.0.1:%d%s", port, path),
|
||||||
|
bytes.NewReader(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := hookClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("request failed: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
// Not all endpoints return JSON
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GET sends a GET request to the worker.
|
// GET sends a GET request to the worker.
|
||||||
func GET(port int, path string) (map[string]interface{}, error) {
|
func GET(port int, path string) (map[string]interface{}, error) {
|
||||||
resp, err := hookClient.Get(fmt.Sprintf("http://127.0.0.1:%d%s", port, path))
|
resp, err := hookClient.Get(fmt.Sprintf("http://127.0.0.1:%d%s", port, path))
|
||||||
@@ -584,6 +622,35 @@ func GET(port int, path string) (map[string]interface{}, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GETWithContext sends a GET request using the provided context and decodes the
|
||||||
|
// JSON response body, mirroring GET but honoring ctx for cancellation/deadline.
|
||||||
|
// Used on the prompt critical path so a wedged worker aborts at the hook
|
||||||
|
// deadline instead of blocking for the full client timeout.
|
||||||
|
func GETWithContext(ctx context.Context, port int, path string) (map[string]interface{}, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||||
|
fmt.Sprintf("http://127.0.0.1:%d%s", port, path), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := hookClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("request failed: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// versionsCompatible checks if two versions are compatible for dev builds.
|
// versionsCompatible checks if two versions are compatible for dev builds.
|
||||||
// Returns true if both versions share the same base version (ignoring -dirty, -dev, commit suffixes).
|
// Returns true if both versions share the same base version (ignoring -dirty, -dev, commit suffixes).
|
||||||
// This prevents unnecessary restarts during development.
|
// This prevents unnecessary restarts during development.
|
||||||
|
|||||||
@@ -952,6 +952,203 @@ func TestGET_Timeout(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGETWithContext tests GETWithContext with a mock server.
|
||||||
|
func TestGETWithContext(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
serverHandler func(w http.ResponseWriter, r *http.Request)
|
||||||
|
expectedResult map[string]interface{}
|
||||||
|
name string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful GET with JSON response",
|
||||||
|
serverHandler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"data": "test"})
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
expectedResult: map[string]interface{}{"data": "test"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GET with 404 error",
|
||||||
|
serverHandler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GET with invalid JSON",
|
||||||
|
serverHandler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("not valid json"))
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(tt.serverHandler))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
var port int
|
||||||
|
_, err := fmt.Sscanf(server.URL, "http://127.0.0.1:%d", &port)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
result, err := GETWithContext(context.Background(), port, "/test")
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if tt.expectedResult != nil {
|
||||||
|
assert.Equal(t, tt.expectedResult["data"], result["data"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGETWithContext_Timeout verifies the context deadline aborts a slow server
|
||||||
|
// well before the hookClient timeout, so a wedged worker cannot stall the prompt.
|
||||||
|
func TestGETWithContext_Timeout(t *testing.T) {
|
||||||
|
// Server that blocks longer than the context deadline.
|
||||||
|
blockUntil := make(chan struct{})
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
<-blockUntil // never closed during the test -> server hangs
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
defer close(blockUntil)
|
||||||
|
|
||||||
|
var port int
|
||||||
|
_, err := fmt.Sscanf(server.URL, "http://127.0.0.1:%d", &port)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err = GETWithContext(ctx, port, "/test")
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
// Should abort near the 100ms deadline, far below hookClient's 10s timeout.
|
||||||
|
assert.Less(t, elapsed, 2*time.Second, "context deadline must abort the request quickly")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGETWithContext_CancelledContext verifies an already-cancelled context
|
||||||
|
// returns immediately without making a real request.
|
||||||
|
func TestGETWithContext_CancelledContext(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // cancel immediately
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err := GETWithContext(ctx, 99994, "/test")
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Less(t, elapsed, 1*time.Second, "cancelled context should return immediately")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPOSTWithContextResult tests POSTWithContextResult with a mock server.
|
||||||
|
func TestPOSTWithContextResult(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
body interface{}
|
||||||
|
serverHandler func(w http.ResponseWriter, r *http.Request)
|
||||||
|
expectedResult map[string]interface{}
|
||||||
|
name string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful POST with JSON response",
|
||||||
|
serverHandler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
|
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok"})
|
||||||
|
},
|
||||||
|
body: map[string]string{"key": "value"},
|
||||||
|
expectError: false,
|
||||||
|
expectedResult: map[string]interface{}{"status": "ok"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "POST with 400 error",
|
||||||
|
serverHandler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
},
|
||||||
|
body: map[string]string{"key": "value"},
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "POST with non-JSON response returns nil",
|
||||||
|
serverHandler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("not json"))
|
||||||
|
},
|
||||||
|
body: map[string]string{"key": "value"},
|
||||||
|
expectError: false,
|
||||||
|
expectedResult: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(tt.serverHandler))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
var port int
|
||||||
|
_, err := fmt.Sscanf(server.URL, "http://127.0.0.1:%d", &port)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
result, err := POSTWithContextResult(context.Background(), port, "/test", tt.body)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if tt.expectedResult != nil {
|
||||||
|
assert.Equal(t, tt.expectedResult["status"], result["status"])
|
||||||
|
} else {
|
||||||
|
assert.Nil(t, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPOSTWithContextResult_MarshalError tests POSTWithContextResult with an unmarshalable body.
|
||||||
|
func TestPOSTWithContextResult_MarshalError(t *testing.T) {
|
||||||
|
badValue := make(chan int)
|
||||||
|
_, err := POSTWithContextResult(context.Background(), 99999, "/test", badValue)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPOSTWithContextResult_Timeout verifies the context deadline aborts a slow
|
||||||
|
// server before the hookClient timeout.
|
||||||
|
func TestPOSTWithContextResult_Timeout(t *testing.T) {
|
||||||
|
blockUntil := make(chan struct{})
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
<-blockUntil
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
defer close(blockUntil)
|
||||||
|
|
||||||
|
var port int
|
||||||
|
_, err := fmt.Sscanf(server.URL, "http://127.0.0.1:%d", &port)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err = POSTWithContextResult(ctx, port, "/test", map[string]string{"k": "v"})
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Less(t, elapsed, 2*time.Second, "context deadline must abort the request quickly")
|
||||||
|
}
|
||||||
|
|
||||||
// TestIsWorkerRunning_Timeout tests IsWorkerRunning with timeout.
|
// TestIsWorkerRunning_Timeout tests IsWorkerRunning with timeout.
|
||||||
func TestIsWorkerRunning_Timeout(t *testing.T) {
|
func TestIsWorkerRunning_Timeout(t *testing.T) {
|
||||||
// Non-existent port should quickly return false
|
// Non-existent port should quickly return false
|
||||||
|
|||||||
Executable
+65
@@ -0,0 +1,65 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Download ONNX models from Hugging Face for local development and CI.
|
||||||
|
# Usage: ./scripts/download-models.sh [--force]
|
||||||
|
#
|
||||||
|
# Downloads models to internal/*/assets/ for go:embed and to testdata/models/
|
||||||
|
# for Go tests (CLAUDE_MNEMONIC_MODEL_DIR points there).
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
ASSETS_EMB="internal/embedding/assets"
|
||||||
|
ASSETS_RERANK="internal/reranking/assets"
|
||||||
|
TESTDATA="testdata/models"
|
||||||
|
FORCE_DOWNLOAD=false
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ "$arg" = "--force" ]; then
|
||||||
|
FORCE_DOWNLOAD=true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
download_if_needed() {
|
||||||
|
local url="$1"
|
||||||
|
local dest="$2"
|
||||||
|
local name="$3"
|
||||||
|
local expected_sha="$4"
|
||||||
|
|
||||||
|
if [ "$FORCE_DOWNLOAD" = false ] && [ -f "$dest" ]; then
|
||||||
|
local actual_sha
|
||||||
|
actual_sha=$(shasum -a 256 "$dest" | awk '{print $1}')
|
||||||
|
if [ "$actual_sha" = "$expected_sha" ]; then
|
||||||
|
echo "[skip] $name"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo "[mismatch] $name checksum mismatch, re-downloading"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[download] $name ($(basename "$url"))"
|
||||||
|
curl -fsSL --retry 3 --retry-delay 2 "$url" -o "$dest"
|
||||||
|
echo "[ok] $name"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== Downloading models from Hugging Face ==="
|
||||||
|
|
||||||
|
# BGE-small-en-v1.5 embedding model (~127 MB)
|
||||||
|
download_if_needed \
|
||||||
|
"https://huggingface.co/BAAI/bge-small-en-v1.5/resolve/main/onnx/model.onnx" \
|
||||||
|
"${ASSETS_EMB}/model.onnx" \
|
||||||
|
"embedding (bge-small-en-v1.5)" \
|
||||||
|
"828e1496d7fabb79cfa4dcd84fa38625c0d3d21da474a00f08db0f559940cf35"
|
||||||
|
|
||||||
|
# MS-MARCO MiniLM-L6-v2 cross-encoder model (~91 MB)
|
||||||
|
download_if_needed \
|
||||||
|
"https://huggingface.co/cross-encoder/ms-marco-MiniLM-L6-v2/resolve/main/onnx/model.onnx" \
|
||||||
|
"${ASSETS_RERANK}/model.onnx" \
|
||||||
|
"cross-encoder (ms-marco-MiniLM-L6-v2)" \
|
||||||
|
"5d3e70fd0c9ff14b9b5169a51e957b7a9c74897afd0a35ce4bd318150c1d4d4a"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Staging models for tests ==="
|
||||||
|
mkdir -p "${TESTDATA}"
|
||||||
|
cp "${ASSETS_EMB}/model.onnx" "${TESTDATA}/bge-small-en-v1.5-model.onnx"
|
||||||
|
cp "${ASSETS_RERANK}/model.onnx" "${TESTDATA}/ms-marco-MiniLM-L6-v2-model.onnx"
|
||||||
|
echo "[ok] Test models staged to ${TESTDATA}/"
|
||||||
|
|
||||||
|
echo "Done!"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Resolve the FontAwesome *free* package from the public npm registry rather than
|
||||||
|
# the authenticated npm.fontawesome.com. This lets `npm ci` work in CI without a
|
||||||
|
# FontAwesome token (the free package is published to registry.npmjs.org too).
|
||||||
|
@fortawesome:registry=https://registry.npmjs.org/
|
||||||
Generated
+978
-1978
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -10,19 +10,19 @@
|
|||||||
"type-check": "vue-tsc --noEmit"
|
"type-check": "vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vis-data": "^7.1.9",
|
"vis-data": "^8.0.4",
|
||||||
"vis-network": "^9.1.9",
|
"vis-network": "^10.1.0",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.34"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@types/node": "^22.10.2",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@types/node": "^25.9.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.5.15",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~5.7.2",
|
"typescript": "~6.0.3",
|
||||||
"vite": "^6.0.5",
|
"vite": "^8.0.14",
|
||||||
"vue-tsc": "^2.2.0"
|
"vue-tsc": "^3.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-12
@@ -10,19 +10,19 @@
|
|||||||
"type-check": "vue-tsc --noEmit"
|
"type-check": "vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vis-data": "^7.1.9",
|
"vis-data": "^8.0.4",
|
||||||
"vis-network": "^9.1.9",
|
"vis-network": "^10.1.0",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.34"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@types/node": "^22.10.2",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@types/node": "^25.9.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.5.15",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~5.7.2",
|
"typescript": "~6.0.3",
|
||||||
"vite": "^6.0.5",
|
"vite": "^8.0.14",
|
||||||
"vue-tsc": "^2.2.0"
|
"vue-tsc": "^3.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'@tailwindcss/postcss': {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
@config "../../tailwind.config.js";
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
/* Glass morphism effect */
|
/* Glass morphism effect */
|
||||||
|
|||||||
+1
-2
@@ -16,9 +16,8 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true,
|
"noUncheckedSideEffectImports": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/components/index.ts","./src/composables/index.ts","./src/composables/usegraphmetrics.ts","./src/composables/usehealth.ts","./src/composables/usesse.ts","./src/composables/usestats.ts","./src/composables/usetimeline.ts","./src/composables/usetypes.ts","./src/composables/useupdate.ts","./src/types/api.ts","./src/types/index.ts","./src/types/observation.ts","./src/types/prompt.ts","./src/types/relation.ts","./src/types/summary.ts","./src/utils/api.ts","./src/utils/formatters.ts","./src/app.vue","./src/components/badge.vue","./src/components/card.vue","./src/components/filtertabs.vue","./src/components/header.vue","./src/components/iconbox.vue","./src/components/observationcard.vue","./src/components/projectfilter.vue","./src/components/promptcard.vue","./src/components/relationgraph.vue","./src/components/scorebreakdown.vue","./src/components/searchanalytics.vue","./src/components/sidebar.vue","./src/components/statscards.vue","./src/components/summarycard.vue","./src/components/systemhealthdetails.vue","./src/components/timeline.vue","./src/components/topobservations.vue"],"version":"5.7.3"}
|
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/components/index.ts","./src/composables/index.ts","./src/composables/usegraphmetrics.ts","./src/composables/usehealth.ts","./src/composables/usesse.ts","./src/composables/usestats.ts","./src/composables/usetimeline.ts","./src/composables/usetypes.ts","./src/composables/useupdate.ts","./src/types/api.ts","./src/types/index.ts","./src/types/observation.ts","./src/types/prompt.ts","./src/types/relation.ts","./src/types/summary.ts","./src/utils/api.ts","./src/utils/formatters.ts","./src/app.vue","./src/components/badge.vue","./src/components/card.vue","./src/components/filtertabs.vue","./src/components/header.vue","./src/components/iconbox.vue","./src/components/observationcard.vue","./src/components/projectfilter.vue","./src/components/promptcard.vue","./src/components/relationgraph.vue","./src/components/scorebreakdown.vue","./src/components/searchanalytics.vue","./src/components/sidebar.vue","./src/components/statscards.vue","./src/components/summarycard.vue","./src/components/systemhealthdetails.vue","./src/components/timeline.vue","./src/components/topobservations.vue"],"version":"6.0.3"}
|
||||||
@@ -53,3 +53,19 @@ fi
|
|||||||
|
|
||||||
# Download ONNX runtime libraries for target platform
|
# Download ONNX runtime libraries for target platform
|
||||||
./scripts/download-onnx-libs.sh "$ONNX_PLATFORM"
|
./scripts/download-onnx-libs.sh "$ONNX_PLATFORM"
|
||||||
|
|
||||||
|
# Download ONNX models from Hugging Face and stage for tests
|
||||||
|
# Non-fatal: if download fails, tests will attempt runtime download themselves
|
||||||
|
set +e
|
||||||
|
./scripts/download-models.sh
|
||||||
|
DOWNLOAD_EXIT=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ $DOWNLOAD_EXIT -eq 0 ]; then
|
||||||
|
# Export model directory so tests can find models without network access
|
||||||
|
if [ -n "$GITHUB_ENV" ]; then
|
||||||
|
echo "CLAUDE_MNEMONIC_MODEL_DIR=$GITHUB_WORKSPACE/testdata/models" >>"$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Warning: Model download failed — tests will try to download models at runtime"
|
||||||
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user