mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-12 04:55:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98ab1cd3e9 | ||
|
|
55ba09e0c6 | ||
|
|
a369a421aa | ||
|
|
76f31e97ae | ||
|
|
132666a190 | ||
|
|
3a8e3667b3 | ||
|
|
3ea3904875 | ||
|
|
8a8814968e | ||
|
|
6b8ecdd2ec | ||
|
|
aae542565e | ||
|
|
1fcb8722c4 | ||
|
|
d64a384e92 | ||
|
|
bd04da2b69 | ||
|
|
5191bc474a | ||
|
|
1b5c974473 | ||
|
|
b38ecee4da | ||
|
|
406b539c84 | ||
|
|
a23b8c4936 | ||
|
|
98e9a2d59c | ||
|
|
af19b6a798 | ||
|
|
11ee9a4f1f | ||
|
|
70a6eb2e52 | ||
|
|
86ee0e28ed | ||
|
|
1b5697b316 | ||
|
|
a8a8c532c4 | ||
|
|
e057f121eb | ||
|
|
7d1998e975 | ||
|
|
af8e6f44cd | ||
|
|
cf4161d459 | ||
|
|
910aabda22 | ||
|
|
6115ea8a44 | ||
|
|
93446d1437 | ||
|
|
1d1807da9e | ||
|
|
7bdb523106 | ||
|
|
abd34c9629 | ||
|
|
5fe25e9328 | ||
|
|
b7b82ce22f | ||
|
|
f78370a531 | ||
|
|
5f89559842 | ||
|
|
794eacf02a | ||
|
|
6fc752abb1 | ||
|
|
9ce29ac7b1 | ||
|
|
a0d0e856f5 | ||
|
|
549fefc675 | ||
|
|
c5967c3eaa |
@@ -20,7 +20,7 @@ jobs:
|
||||
with:
|
||||
go-version: ">=1.24"
|
||||
node-enabled: true
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
node-build-script: "cd ui && npm ci && npm run build"
|
||||
node-cache-dependency-path: "ui/package-lock.json"
|
||||
node-output-path: "ui/dist"
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: main
|
||||
|
||||
|
||||
@@ -22,12 +22,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'docs/package-lock.json'
|
||||
|
||||
|
||||
+23
-21
@@ -1,34 +1,36 @@
|
||||
linters-settings:
|
||||
govet:
|
||||
enable:
|
||||
- fieldalignment
|
||||
errcheck:
|
||||
# Ignore error checks in test files for common test helpers
|
||||
exclude-functions:
|
||||
- (io.Closer).Close
|
||||
- (*encoding/json.Encoder).Encode
|
||||
- (io.Writer).Write
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gosec
|
||||
- govet
|
||||
- gofmt
|
||||
- staticcheck
|
||||
- unused
|
||||
- ineffassign
|
||||
- typecheck
|
||||
settings:
|
||||
govet:
|
||||
enable:
|
||||
- fieldalignment
|
||||
errcheck:
|
||||
# Ignore error checks in test files for common test helpers
|
||||
exclude-functions:
|
||||
- (io.Closer).Close
|
||||
- (*encoding/json.Encoder).Encode
|
||||
- (io.Writer).Write
|
||||
exclusions:
|
||||
paths:
|
||||
- vendor
|
||||
# Exclude some linters from running on test files
|
||||
rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- errcheck
|
||||
- gosec
|
||||
|
||||
issues:
|
||||
exclude-dirs:
|
||||
- vendor
|
||||
# Exclude some linters from running on test files
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- errcheck
|
||||
- gosec
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
|
||||
@@ -172,13 +172,19 @@ Example: A bug fix in your auth module stays local. "Always validate JWT server-
|
||||
|
||||
## MCP Tools
|
||||
|
||||
These search tools are available via MCP:
|
||||
Four tools are exposed via MCP:
|
||||
|
||||
- `search` - semantic search across all memories
|
||||
- `timeline` - browse by time
|
||||
- `decisions` - find architecture decisions
|
||||
- `changes` - find code modifications
|
||||
- `how_it_works` - system understanding queries
|
||||
- `search` - semantic search across all memories. Accepts `obs_type` (e.g.
|
||||
`decision`, `code_change`, `architecture`) plus filter params (`concepts`,
|
||||
`files`, `type`) - these replace the old per-type shortcut tools.
|
||||
- `timeline` - browse observations around a point in time.
|
||||
- `observation` - manage individual observations. Set `action` to one of:
|
||||
`get`, `edit`, `delete`, `supersede`, `boost`, `merge`, `related`, `similar`,
|
||||
`quality`, `relationships`, `scoring`, `tag`, `by_tag`, `batch_tag`.
|
||||
- `memory_admin` - administration and analytics. Set `action` to one of:
|
||||
`stats`, `health`, `maintenance_stats`, `run_maintenance`, `importance`,
|
||||
`search_patterns`, `explain_ranking`, `temporal_trends`, `data_quality`,
|
||||
`export`, `suggest_consolidations`, `patterns`.
|
||||
|
||||
## Slash Commands
|
||||
|
||||
|
||||
@@ -91,18 +91,21 @@ func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
|
||||
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)
|
||||
go func() {
|
||||
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)
|
||||
go func() {
|
||||
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,
|
||||
"project": ctx.Project,
|
||||
"prompt": input.Prompt,
|
||||
@@ -113,7 +116,8 @@ func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
|
||||
// Wait for both to complete
|
||||
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 {
|
||||
return "", nil
|
||||
}
|
||||
@@ -173,9 +177,11 @@ func handleUserPrompt(ctx *hooks.HookContext, input *Input) (string, error) {
|
||||
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 {
|
||||
return "", initErr
|
||||
fmt.Fprintf(os.Stderr, "[user-prompt] Session init failed: %v\n", initErr)
|
||||
return contextToInject, nil
|
||||
}
|
||||
if initResult == nil {
|
||||
return contextToInject, nil // Non-JSON response from worker, skip session init
|
||||
@@ -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)
|
||||
|
||||
// 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{}{
|
||||
// Start SDK agent (depends on session init result, so kept sequential).
|
||||
// Deadline-guarded so a wedged worker cannot stall past the hook budget.
|
||||
// Failure here must not block the prompt: degrade gracefully, still inject memory.
|
||||
if _, err := hooks.POSTWithContextResult(deadline, ctx.Port, fmt.Sprintf("/sessions/%d/init", sessionID), map[string]interface{}{
|
||||
"userPrompt": input.Prompt,
|
||||
"promptNumber": promptNumber,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[user-prompt] SDK agent init failed: %v\n", err)
|
||||
return contextToInject, nil
|
||||
}
|
||||
|
||||
// Return context if we found relevant observations
|
||||
|
||||
Generated
+732
-1397
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -8,13 +8,13 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.25"
|
||||
"vue": "^3.5.34"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^7.3.0"
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"vite": "^8.0.14"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
|
||||
+165
-405
@@ -1,398 +1,153 @@
|
||||
<template>
|
||||
<div class="bg-slate-950 text-white min-h-screen relative overflow-hidden">
|
||||
<!-- Animated Background -->
|
||||
<div class="fixed inset-0 pointer-events-none">
|
||||
<!-- Gradient orbs -->
|
||||
<div class="absolute top-0 left-1/4 w-96 h-96 bg-amber-500/10 rounded-full blur-3xl animate-float"></div>
|
||||
<div class="absolute bottom-1/4 right-1/4 w-80 h-80 bg-amber-400/5 rounded-full blur-3xl animate-float-delayed"></div>
|
||||
<div class="absolute top-1/2 right-0 w-64 h-64 bg-slate-700/20 rounded-full blur-3xl animate-glow"></div>
|
||||
|
||||
<!-- Grid pattern -->
|
||||
<div class="absolute inset-0 bg-[linear-gradient(rgba(148,163,184,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(148,163,184,0.03)_1px,transparent_1px)] bg-[size:50px_50px]"></div>
|
||||
|
||||
<!-- Floating particles -->
|
||||
<div v-for="i in 12" :key="i"
|
||||
class="particle animate-particle"
|
||||
:style="{
|
||||
left: `${(i * 8.3) % 100}%`,
|
||||
animationDelay: `${i * 1.5}s`,
|
||||
animationDuration: `${15 + (i % 5) * 3}s`
|
||||
}"></div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<NavBar :mobile-menu-open="mobileMenuOpen" @toggle-menu="mobileMenuOpen = !mobileMenuOpen" />
|
||||
|
||||
<HeroSection
|
||||
badge="The missing piece for Claude Code"
|
||||
title-before="Yesterday's context."
|
||||
title-highlight="Today's session."
|
||||
subtitle="Stop re-explaining your codebase. Claude Mnemonic captures bug fixes, architecture decisions, and coding patterns - then brings them back exactly when you need them."
|
||||
/>
|
||||
<main class="flex-1">
|
||||
<HeroSection />
|
||||
|
||||
<!-- Dashboard Preview -->
|
||||
<section class="py-12 lg:py-16 px-4 sm:px-6 relative">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="relative rounded-xl overflow-hidden border border-slate-700/50 shadow-2xl shadow-amber-500/5">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent pointer-events-none z-10"></div>
|
||||
<img
|
||||
src="/claude-mnemonic.jpg"
|
||||
alt="Claude Mnemonic Dashboard"
|
||||
class="w-full h-auto"
|
||||
/>
|
||||
<section class="py-10 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="rounded-xl overflow-hidden border border-neutral-800">
|
||||
<img src="/claude-mnemonic.jpg" alt="Claude Mnemonic Dashboard" class="w-full h-auto block" loading="lazy" />
|
||||
</div>
|
||||
<p class="text-center text-neutral-600 text-xs mt-3 tracking-wide uppercase">Dashboard — localhost:37777</p>
|
||||
</div>
|
||||
<p class="text-center text-slate-500 text-sm mt-4">The dashboard at localhost:37777 - browse, search, and manage your memories. View graph stats, vector metrics, storage savings, and performance analytics.</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- Problem Section -->
|
||||
<section class="py-20 lg:py-28 px-4 sm:px-6 relative">
|
||||
<div class="max-w-6xl mx-auto grid lg:grid-cols-2 gap-8 lg:gap-16 items-center">
|
||||
<div>
|
||||
<h2 class="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-6">Sound familiar?</h2>
|
||||
<p class="text-slate-400 text-base sm:text-lg mb-8">Every Claude Code session starts fresh. That bug you fixed last Tuesday? Gone. The auth flow you explained in detail? Forgotten. Your team's naming conventions? You'll explain them again.</p>
|
||||
<ul class="space-y-4 sm:space-y-5">
|
||||
<li class="flex items-start gap-3 sm:gap-4 text-slate-400">
|
||||
<i class="fas fa-times-circle text-red-400 mt-1 flex-shrink-0"></i>
|
||||
<span class="text-sm sm:text-base">"We fixed this exact race condition last week..."</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3 sm:gap-4 text-slate-400">
|
||||
<i class="fas fa-times-circle text-red-400 mt-1 flex-shrink-0"></i>
|
||||
<span class="text-sm sm:text-base">"No, we use kebab-case for API routes, not camelCase..."</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3 sm:gap-4 text-slate-400">
|
||||
<i class="fas fa-times-circle text-red-400 mt-1 flex-shrink-0"></i>
|
||||
<span class="text-sm sm:text-base">"The database is Postgres, not MySQL. I told you yesterday..."</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-3 sm:gap-4 text-white">
|
||||
<i class="fas fa-check-circle text-amber-500 mt-1 flex-shrink-0"></i>
|
||||
<span class="text-sm sm:text-base"><strong>Mnemonic remembers so you don't have to repeat yourself.</strong></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="glass rounded-2xl p-6 sm:p-8 relative overflow-hidden glow-amber">
|
||||
<div class="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-amber-500 to-transparent"></div>
|
||||
<p class="text-slate-500 text-xs sm:text-sm mb-4 font-mono">// What Mnemonic captures automatically:</p>
|
||||
<div class="space-y-3">
|
||||
<FlowItem icon="fas fa-bug" title="Bug fixes & solutions" description='"Fixed N+1 query in user loader by adding .includes(:posts)"' />
|
||||
<FlowItem icon="fas fa-sitemap" title="Architecture decisions" description='"Using event sourcing for audit trail - all mutations go through EventStore"' />
|
||||
<FlowItem icon="fas fa-code-branch" title="Project conventions" description='"API routes use /api/v1/ prefix, kebab-case for endpoints"' />
|
||||
<FlowItem icon="fas fa-shield-alt" title="Security patterns" description='"Always validate JWT on server side, never trust client claims"' />
|
||||
<section id="features" class="py-20 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">What it does</h2>
|
||||
<p class="text-neutral-500 mb-12 max-w-xl">Captures context from your Claude Code sessions and brings it back when you need it. No manual effort.</p>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<FeatureItem
|
||||
v-for="feature in features"
|
||||
:key="feature.title"
|
||||
:icon="feature.icon"
|
||||
:title="feature.title"
|
||||
:description="feature.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section id="features" class="py-20 lg:py-28 bg-slate-900/30 relative">
|
||||
<div class="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-slate-700 to-transparent"></div>
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<SectionHeader title="Built for real workflows" subtitle="Not just storage - intelligent context that makes Claude more useful over time" />
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
<FeatureCard
|
||||
v-for="feature in features"
|
||||
:key="feature.title"
|
||||
:icon="feature.icon"
|
||||
:title="feature.title"
|
||||
:description="feature.description"
|
||||
/>
|
||||
<section class="py-10 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="rounded-xl overflow-hidden border border-neutral-800">
|
||||
<img src="/observation-relation-graph.jpg" alt="Knowledge Graph" class="w-full h-auto block" loading="lazy" />
|
||||
</div>
|
||||
<p class="text-center text-neutral-600 text-xs mt-3 tracking-wide uppercase">Observation relationship graph</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- Knowledge Graph Preview -->
|
||||
<section class="py-16 lg:py-20 px-4 sm:px-6 relative">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<SectionHeader title="See how knowledge connects" subtitle="The knowledge graph reveals relationships between your memories automatically" />
|
||||
<div class="relative rounded-xl overflow-hidden border border-slate-700/50 shadow-2xl shadow-purple-500/5">
|
||||
<img
|
||||
src="/observation-relation-graph.jpg"
|
||||
alt="Knowledge Graph Visualization"
|
||||
class="w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-center text-slate-500 text-sm mt-4">Click any observation to explore its relationships - see what causes what, what fixes what, and how concepts evolve</p>
|
||||
</div>
|
||||
</section>
|
||||
<section id="install" class="py-20 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Install</h2>
|
||||
<p class="text-neutral-500 mb-8">One command. No configuration. No account.</p>
|
||||
|
||||
<!-- Before/After Section -->
|
||||
<section class="py-20 lg:py-28 px-4 sm:px-6">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<SectionHeader title="The difference" subtitle="Same question. Different experience." />
|
||||
<div class="grid md:grid-cols-2 gap-6 sm:gap-8">
|
||||
<!-- Before -->
|
||||
<div class="glass rounded-2xl p-5 sm:p-6 relative border-red-500/20">
|
||||
<div class="absolute top-3 sm:top-4 right-3 sm:right-4 bg-red-500/20 text-red-400 px-2 sm:px-3 py-1 rounded-full text-xs font-medium">Without Mnemonic</div>
|
||||
<div class="space-y-3 sm:space-y-4 mt-8">
|
||||
<div class="bg-slate-800/50 rounded-lg p-3 sm:p-4">
|
||||
<p class="text-slate-400 text-xs sm:text-sm"><span class="text-blue-400">You:</span> Fix the authentication bug in the login flow</p>
|
||||
</div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-3 sm:p-4">
|
||||
<p class="text-slate-400 text-xs sm:text-sm"><span class="text-amber-400">Claude:</span> I'd be happy to help! Can you tell me about your authentication setup? What framework are you using? How is the login flow structured?</p>
|
||||
</div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-3 sm:p-4">
|
||||
<p class="text-slate-400 text-xs sm:text-sm"><span class="text-blue-400">You:</span> We use NextAuth with Postgres. The flow goes through /api/auth/... We talked about this last week...</p>
|
||||
</div>
|
||||
<p class="text-red-400 text-xs sm:text-sm text-center pt-2"><i class="fas fa-clock mr-2"></i>5+ minutes of re-explaining context</p>
|
||||
</div>
|
||||
<div class="flex gap-1 mb-6">
|
||||
<button
|
||||
v-for="tab in installTabs"
|
||||
:key="tab.id"
|
||||
@click="activeTab = tab.id"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm rounded-lg transition-colors',
|
||||
activeTab === tab.id
|
||||
? 'bg-neutral-800 text-white'
|
||||
: 'text-neutral-500 hover:text-neutral-300'
|
||||
]"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- After -->
|
||||
<div class="glass rounded-2xl p-5 sm:p-6 relative border-green-500/20">
|
||||
<div class="absolute top-3 sm:top-4 right-3 sm:right-4 bg-green-500/20 text-green-400 px-2 sm:px-3 py-1 rounded-full text-xs font-medium">With Mnemonic</div>
|
||||
<div class="space-y-3 sm:space-y-4 mt-8">
|
||||
<div class="bg-slate-800/50 rounded-lg p-3 sm:p-4">
|
||||
<p class="text-slate-400 text-xs sm:text-sm"><span class="text-blue-400">You:</span> Fix the authentication bug in the login flow</p>
|
||||
</div>
|
||||
<div class="bg-slate-800/50 rounded-lg p-3 sm:p-4">
|
||||
<p class="text-slate-400 text-xs sm:text-sm"><span class="text-amber-400">Claude:</span> Looking at your NextAuth setup with Postgres, I see the issue is likely in the callback at <code class="text-amber-300 text-xs">/api/auth/[...nextauth].ts</code>. Based on the session validation pattern we established, the fix is...</p>
|
||||
</div>
|
||||
<p class="text-green-400 text-xs sm:text-sm text-center pt-2"><i class="fas fa-bolt mr-2"></i>Instant context. Immediate value.</p>
|
||||
</div>
|
||||
<div v-show="activeTab === 'macos'">
|
||||
<CodeBlock code="curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How It Works Section -->
|
||||
<section id="how-it-works" class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<SectionHeader title="Zero setup. Zero maintenance." subtitle="Install once, benefit forever. Mnemonic works silently in the background." />
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6 sm:gap-8 relative">
|
||||
<div class="hidden lg:block absolute top-10 left-[60px] right-[60px] h-0.5 bg-gradient-to-r from-amber-500 via-amber-400 to-amber-500 opacity-30"></div>
|
||||
<StepCard
|
||||
v-for="step in steps"
|
||||
:key="step.number"
|
||||
:number="step.number"
|
||||
:title="step.title"
|
||||
:description="step.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div v-show="activeTab === 'windows'">
|
||||
<CodeBlock code="irm https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.ps1 | iex" />
|
||||
</div>
|
||||
|
||||
<!-- Installation Section -->
|
||||
<section id="installation" class="py-20 lg:py-28 px-4 sm:px-6">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<SectionHeader title="Quick install" subtitle="One command. No configuration. No account required." />
|
||||
<div v-show="activeTab === 'source'">
|
||||
<CodeBlock code="git clone https://github.com/lukaszraczylo/claude-mnemonic.git cd claude-mnemonic make build && make install" />
|
||||
<p class="text-neutral-600 text-xs mt-2">Requires: Go 1.24+, Node.js 18+, CGO compiler</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-2 mb-6 sm:mb-8">
|
||||
<button
|
||||
v-for="tab in installTabs"
|
||||
:key="tab.id"
|
||||
@click="activeTab = tab.id"
|
||||
:class="[
|
||||
'px-4 sm:px-5 py-2 sm:py-2.5 rounded-lg text-xs sm:text-sm font-medium transition-all',
|
||||
activeTab === tab.id
|
||||
? 'bg-amber-500/15 border border-amber-500 text-amber-500'
|
||||
: 'glass text-slate-400 hover:border-slate-600'
|
||||
]"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'macos'">
|
||||
<CodeBlock :code="installCommands.macos">
|
||||
<span class="text-slate-500"># That's it. Seriously.</span>
|
||||
<br>
|
||||
<span class="text-amber-400">curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash</span>
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'windows'">
|
||||
<CodeBlock :code="installCommands.windows">
|
||||
<span class="text-slate-500"># PowerShell (as Administrator)</span>
|
||||
<br>
|
||||
<span class="text-amber-400">irm https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.ps1 | iex</span>
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'source'">
|
||||
<CodeBlock :code="installCommands.source">
|
||||
<span class="text-slate-500"># For contributors and tinkerers</span>
|
||||
<br>
|
||||
<span class="text-amber-400">git clone https://github.com/lukaszraczylo/claude-mnemonic.git</span>
|
||||
<br>
|
||||
<span class="text-amber-400">cd claude-mnemonic</span>
|
||||
<br>
|
||||
<span class="text-amber-400">make build && make install</span>
|
||||
<br><br>
|
||||
<span class="text-slate-500"># Requires: Go 1.24+, Node.js 18+, CGO compiler</span>
|
||||
</CodeBlock>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-slate-500 mt-6 sm:mt-8 text-xs sm:text-sm">
|
||||
After install, open <a href="http://localhost:37777" class="text-amber-400 hover:underline animated-underline">localhost:37777</a> to see the dashboard.
|
||||
Start a new Claude Code CLI session - memory is now active.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Requirements Section -->
|
||||
<section id="requirements" class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<SectionHeader title="Requirements" subtitle="Minimal dependencies. Everything else is built-in." />
|
||||
|
||||
<div class="max-w-xl mx-auto">
|
||||
<div class="glass rounded-2xl p-5 sm:p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<i class="fas fa-check-circle text-green-500"></i>
|
||||
<span class="text-white font-semibold text-sm sm:text-base">That's all you need</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div v-for="req in requiredDeps" :key="req.name" class="flex items-start gap-3 p-3 bg-slate-800/50 rounded-lg">
|
||||
<i :class="[req.icon, 'text-amber-500 mt-0.5']"></i>
|
||||
<div>
|
||||
<code class="text-white text-xs sm:text-sm font-semibold">{{ req.name }}</code>
|
||||
<p class="text-slate-400 text-xs mt-1">{{ req.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-slate-500 text-xs mt-4">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
No Python. No external services. Semantic search with local embeddings is built-in.
|
||||
<div class="mt-8 p-4 rounded-lg border border-neutral-800 bg-neutral-900/50 max-w-2xl">
|
||||
<p class="text-neutral-500 text-sm">
|
||||
<i class="fas fa-info-circle text-neutral-600 mr-1"></i>
|
||||
After install, open <a href="http://localhost:37777" class="text-amber-500 hover:text-amber-400">localhost:37777</a> for the dashboard. Start a new Claude Code session — memory is active.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- Configuration Section -->
|
||||
<section id="configuration" class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<SectionHeader title="Fully configurable" subtitle="Works out of the box, but adapts to your preferences" />
|
||||
<section id="requirements" class="py-20 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Requirements</h2>
|
||||
<p class="text-neutral-500 mb-8">Minimal dependencies. Everything else is built in.</p>
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-6 sm:gap-8">
|
||||
<!-- Config file example -->
|
||||
<div class="glass rounded-2xl p-5 sm:p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<i class="fas fa-cog text-amber-500"></i>
|
||||
<span class="text-white font-semibold text-sm sm:text-base">~/.claude-mnemonic/settings.json</span>
|
||||
<div class="grid sm:grid-cols-2 gap-4 max-w-xl">
|
||||
<div v-for="req in requiredDeps" :key="req.name" class="p-4 rounded-lg border border-neutral-800 bg-neutral-900/50">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<i :class="[req.icon, 'text-amber-500 text-sm']"></i>
|
||||
<code class="text-white text-sm font-semibold">{{ req.name }}</code>
|
||||
</div>
|
||||
<p class="text-neutral-500 text-xs pl-6">{{ req.description }}</p>
|
||||
</div>
|
||||
<pre class="text-xs sm:text-sm font-mono overflow-x-auto"><code><span class="text-slate-500">{</span>
|
||||
<span class="text-emerald-400">"CLAUDE_MNEMONIC_WORKER_PORT"</span><span class="text-slate-500">:</span> <span class="text-amber-400">37777</span><span class="text-slate-500">,</span>
|
||||
<span class="text-emerald-400">"CLAUDE_MNEMONIC_CONTEXT_OBSERVATIONS"</span><span class="text-slate-500">:</span> <span class="text-amber-400">100</span><span class="text-slate-500">,</span>
|
||||
<span class="text-emerald-400">"CLAUDE_MNEMONIC_CONTEXT_FULL_COUNT"</span><span class="text-slate-500">:</span> <span class="text-amber-400">25</span><span class="text-slate-500">,</span>
|
||||
<span class="text-emerald-400">"CLAUDE_MNEMONIC_MODEL"</span><span class="text-slate-500">:</span> <span class="text-sky-400">"haiku"</span>
|
||||
<span class="text-slate-500">}</span></code></pre>
|
||||
</div>
|
||||
<p class="text-neutral-600 text-sm mt-4">No Python. No external services. No API keys — uses your existing Claude Pro/Max subscription.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Config options list -->
|
||||
<div class="space-y-3 sm:space-y-4">
|
||||
<div v-for="config in configOptions" :key="config.name" class="glass rounded-xl p-4 hover:border-amber-500/20 transition-colors">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-8 h-8 bg-amber-500/15 rounded-lg flex items-center justify-center text-amber-500 flex-shrink-0 text-sm">
|
||||
<i :class="config.icon"></i>
|
||||
<section id="config" class="py-20 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Configuration</h2>
|
||||
<p class="text-neutral-500 mb-8">Works out of the box. Adjust if you want to.</p>
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-8">
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900/50 p-5">
|
||||
<p class="text-neutral-600 text-xs mb-3 font-mono">~/.claude-mnemonic/settings.json</p>
|
||||
<pre class="text-sm overflow-x-auto leading-relaxed"><code>{
|
||||
<span class="text-emerald-400">"CLAUDE_MNEMONIC_WORKER_PORT"</span>: <span class="text-amber-400">37777</span>,
|
||||
<span class="text-emerald-400">"CLAUDE_MNEMONIC_CONTEXT_OBSERVATIONS"</span>: <span class="text-amber-400">100</span>,
|
||||
<span class="text-emerald-400">"CLAUDE_MNEMONIC_CONTEXT_FULL_COUNT"</span>: <span class="text-amber-400">25</span>,
|
||||
<span class="text-emerald-400">"CLAUDE_MNEMONIC_RERANKING_ENABLED"</span>: <span class="text-amber-400">true</span>
|
||||
}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-for="config in configOptions" :key="config.name" class="flex items-start gap-3 p-3 rounded-lg border border-neutral-800/50">
|
||||
<div class="w-6 h-6 rounded bg-neutral-800 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<i class="fas fa-sliders text-neutral-500 text-xs"></i>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<code class="text-amber-400 text-xs sm:text-sm">{{ config.name }}</code>
|
||||
<p class="text-slate-400 text-xs sm:text-sm mt-1">{{ config.description }}</p>
|
||||
<div>
|
||||
<code class="text-amber-500 text-xs">{{ config.name }}</code>
|
||||
<p class="text-neutral-500 text-xs mt-0.5">{{ config.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-neutral-600 text-xs mt-6">
|
||||
All settings also work as environment variables. <a href="https://github.com/lukaszraczylo/claude-mnemonic#configuration" target="_blank" class="text-neutral-500 hover:text-neutral-300">Full docs</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="text-center text-slate-500 mt-6 sm:mt-8 text-xs sm:text-sm">
|
||||
All settings can also be set via environment variables. See <a href="https://github.com/lukaszraczylo/claude-mnemonic#configuration" target="_blank" class="text-amber-400 hover:underline">full documentation</a> for all options.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Technical Details -->
|
||||
<section class="py-20 lg:py-28 px-4 sm:px-6">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<SectionHeader title="Under the hood" subtitle="Built with simplicity and performance in mind" />
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 text-center">
|
||||
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
|
||||
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">Go</div>
|
||||
<p class="text-slate-400 text-xs sm:text-sm">Single binary. Fast startup, low memory. Zero runtime dependencies.</p>
|
||||
</div>
|
||||
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
|
||||
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">SQLite</div>
|
||||
<p class="text-slate-400 text-xs sm:text-sm">FTS5 full-text search. Single file database. Survives restarts.</p>
|
||||
</div>
|
||||
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
|
||||
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">sqlite-vec</div>
|
||||
<p class="text-slate-400 text-xs sm:text-sm">Hybrid vector storage with LEANN-inspired selective embeddings. 60-80% storage reduction.</p>
|
||||
</div>
|
||||
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
|
||||
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">BGE</div>
|
||||
<p class="text-slate-400 text-xs sm:text-sm">Two-stage retrieval: bi-encoder embeddings + cross-encoder reranking for high accuracy.</p>
|
||||
</div>
|
||||
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
|
||||
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">Tree-sitter</div>
|
||||
<p class="text-slate-400 text-xs sm:text-sm">AST-aware code chunking respects function boundaries for Go, Python, and TypeScript.</p>
|
||||
</div>
|
||||
<div class="glass rounded-2xl p-6 sm:p-8 hover:border-amber-500/30 transition-colors">
|
||||
<div class="text-3xl sm:text-4xl font-bold text-amber-500 mb-2">CSR Graph</div>
|
||||
<p class="text-slate-400 text-xs sm:text-sm">Memory-efficient observation relationship graph with edge detection and hub identification.</p>
|
||||
<section id="architecture" class="py-20 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">Under the hood</h2>
|
||||
<p class="text-neutral-500 mb-10">No magic. Here's what's running locally on your machine.</p>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<TechCard v-for="tech in techStack" :key="tech.name" :name="tech.name" :description="tech.description" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- Trust Section -->
|
||||
<section class="py-20 lg:py-28 px-4 sm:px-6 bg-slate-900/30">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<SectionHeader title="Open source. Real person." subtitle="Not another anonymous package - built by a developer you can verify" />
|
||||
|
||||
<div class="glass rounded-2xl p-6 sm:p-8 text-center">
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-6 sm:gap-8 mb-6">
|
||||
<a href="https://github.com/lukaszraczylo" target="_blank" class="group">
|
||||
<img
|
||||
src="https://github.com/lukaszraczylo.png"
|
||||
alt="Lukasz Raczylo"
|
||||
class="w-20 h-20 sm:w-24 sm:h-24 rounded-full border-2 border-slate-700 group-hover:border-amber-500 transition-colors"
|
||||
/>
|
||||
</a>
|
||||
<div class="text-center sm:text-left">
|
||||
<h3 class="text-white font-semibold text-lg sm:text-xl mb-1">Lukasz Raczylo</h3>
|
||||
<p class="text-slate-400 text-sm mb-3">Principal Engineer & Open Source Maintainer</p>
|
||||
<div class="flex flex-wrap justify-center sm:justify-start gap-3">
|
||||
<a href="https://github.com/lukaszraczylo" target="_blank" class="text-slate-500 hover:text-white transition-colors text-sm">
|
||||
<i class="fab fa-github mr-1"></i>GitHub
|
||||
</a>
|
||||
<a href="https://linkedin.com/in/lukaszraczylo" target="_blank" class="text-slate-500 hover:text-white transition-colors text-sm">
|
||||
<i class="fab fa-linkedin mr-1"></i>LinkedIn
|
||||
</a>
|
||||
<a href="https://raczylo.com" target="_blank" class="text-slate-500 hover:text-white transition-colors text-sm">
|
||||
<i class="fas fa-globe mr-1"></i>Website
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-3 gap-4 pt-6 border-t border-slate-700/50">
|
||||
<div class="text-center">
|
||||
<div class="text-amber-500 font-bold text-xl sm:text-2xl mb-1">100%</div>
|
||||
<p class="text-slate-400 text-xs sm:text-sm">Open Source</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-amber-500 font-bold text-xl sm:text-2xl mb-1">MIT</div>
|
||||
<p class="text-slate-400 text-xs sm:text-sm">License</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-amber-500 font-bold text-xl sm:text-2xl mb-1">0</div>
|
||||
<p class="text-slate-400 text-xs sm:text-sm">Data collection</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-slate-500 mt-6 sm:mt-8 text-xs sm:text-sm">
|
||||
Unlike anonymous packages, you can verify who built this, read every line of code, and reach out directly with questions.
|
||||
<br class="hidden sm:block">
|
||||
Your security matters - that's why transparency is non-negotiable.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ Section -->
|
||||
<section id="faq" class="py-20 lg:py-28 px-4 sm:px-6">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<SectionHeader title="Questions?" />
|
||||
<div class="grid sm:grid-cols-2 gap-4 sm:gap-6">
|
||||
<section id="faq" class="py-20 px-4 sm:px-6">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-10">FAQ</h2>
|
||||
<FaqItem
|
||||
v-for="faq in faqs"
|
||||
:key="faq.question"
|
||||
@@ -400,8 +155,21 @@
|
||||
:answer="faq.answer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="py-20 px-4 sm:px-6">
|
||||
<div class="max-w-3xl mx-auto text-center">
|
||||
<p class="text-neutral-600 text-sm mb-2">Open source, built by</p>
|
||||
<a href="https://github.com/lukaszraczylo" target="_blank" class="inline-flex items-center gap-3 text-neutral-400 hover:text-white transition-colors group">
|
||||
<img src="https://github.com/lukaszraczylo.png" alt="Lukasz Raczylo" class="w-10 h-10 rounded-full border border-neutral-700 group-hover:border-neutral-500 transition-colors" />
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-medium">Lukasz Raczylo</div>
|
||||
<div class="text-xs text-neutral-600">MIT License</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<FooterSection />
|
||||
</div>
|
||||
@@ -411,10 +179,8 @@
|
||||
import { ref } from 'vue'
|
||||
import NavBar from './components/NavBar.vue'
|
||||
import HeroSection from './components/HeroSection.vue'
|
||||
import SectionHeader from './components/SectionHeader.vue'
|
||||
import FeatureCard from './components/FeatureCard.vue'
|
||||
import StepCard from './components/StepCard.vue'
|
||||
import FlowItem from './components/FlowItem.vue'
|
||||
import FeatureItem from './components/FeatureItem.vue'
|
||||
import TechCard from './components/TechCard.vue'
|
||||
import CodeBlock from './components/CodeBlock.vue'
|
||||
import FaqItem from './components/FaqItem.vue'
|
||||
import FooterSection from './components/FooterSection.vue'
|
||||
@@ -423,22 +189,15 @@ const mobileMenuOpen = ref(false)
|
||||
const activeTab = ref('macos')
|
||||
|
||||
const features = [
|
||||
{ icon: 'fas fa-brain', title: 'Learns as you work', description: 'Every bug fix, every architecture decision, every "aha moment" - captured automatically without breaking your flow.' },
|
||||
{ icon: 'fas fa-search', title: 'Two-stage retrieval', description: 'Cross-encoder reranking delivers highly relevant results. Finds what you need even with vague queries like "that auth thing".' },
|
||||
{ icon: 'fas fa-project-diagram', title: 'Graph-based search', description: 'LEANN Phase 2: Graph relationships between observations (file overlap, semantic similarity, temporal proximity) for smarter context retrieval.' },
|
||||
{ icon: 'fas fa-microchip', title: 'AST-aware chunking', description: 'Intelligent code splitting respects function boundaries. Go, Python, and TypeScript code is chunked at semantic boundaries, not arbitrary line counts.' },
|
||||
{ icon: 'fas fa-database', title: 'Hybrid vector storage', description: 'LEANN-inspired selective storage: frequently-accessed "hub" observations store embeddings, others recompute on-demand. 60-80% storage savings with <50ms latency.' },
|
||||
{ icon: 'fas fa-folder-tree', title: 'Project-aware context', description: 'Your React knowledge stays in React projects. Your Go patterns stay in Go projects. No context pollution.' },
|
||||
{ icon: 'fas fa-chart-line', title: 'Smart scoring', description: 'Importance decay, pattern detection, and conflict resolution ensure the most valuable memories surface first.' },
|
||||
{ icon: 'fas fa-gauge-high', title: 'Auto-tuning', description: 'Dynamic hub threshold adjustment based on query performance. Automatically balances storage efficiency with search latency for your workload.' },
|
||||
{ icon: 'fas fa-lock', title: '100% private', description: 'Your code context never leaves your machine. No telemetry. No cloud sync. Your memories are yours.' },
|
||||
]
|
||||
|
||||
const steps = [
|
||||
{ number: 1, title: 'Install', description: 'One command. Hooks into Claude Code automatically.' },
|
||||
{ number: 2, title: 'Work normally', description: 'Code with Claude as usual. Mnemonic listens silently in the background.' },
|
||||
{ number: 3, title: 'Knowledge builds', description: 'Every session adds to your knowledge base. Bug fixes. Decisions. Patterns.' },
|
||||
{ number: 4, title: 'Context appears', description: 'Start a new session - relevant memories are already there. No re-explaining.' },
|
||||
{ icon: 'fas fa-brain', title: 'Session memory', description: 'Captures bug fixes, decisions, and patterns automatically from your Claude Code sessions.' },
|
||||
{ icon: 'fas fa-magnifying-glass-chart', title: 'Two-stage retrieval', description: 'Bi-encoder embeddings followed by cross-encoder reranking for accurate results.' },
|
||||
{ icon: 'fas fa-diagram-project', title: 'Knowledge graph', description: 'Automatic relationship detection between observations via file overlap and semantic similarity.' },
|
||||
{ icon: 'fas fa-folder-tree', title: 'Project isolation', description: 'Each project has its own memory. Your React knowledge doesn\'t leak into Go projects.' },
|
||||
{ icon: 'fas fa-code', title: 'AST-aware chunking', description: 'Tree-sitter splits Go, Python, and TypeScript code at semantic boundaries.' },
|
||||
{ icon: 'fas fa-database', title: 'Hybrid vector storage', description: 'Selective embedding caching. 60-80% storage reduction with minimal latency impact.' },
|
||||
{ icon: 'fas fa-lock', title: 'Local embeddings', description: 'ONNX Runtime with all-MiniLM-L6-v2. No external API calls, everything stays on your machine.' },
|
||||
{ icon: 'fas fa-gauge', title: 'Live statusline', description: 'Real-time metrics in Claude Code: [mnemonic] ● served:42 | project:28 memories' },
|
||||
{ icon: 'fas fa-arrows-rotate', title: 'Auto-updates', description: 'Checks for new versions on startup, downloads in the background, applies on restart.' },
|
||||
]
|
||||
|
||||
const installTabs = [
|
||||
@@ -447,36 +206,37 @@ const installTabs = [
|
||||
{ id: 'source', label: 'From Source' },
|
||||
]
|
||||
|
||||
const installCommands = {
|
||||
macos: `curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash`,
|
||||
windows: `irm https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.ps1 | iex`,
|
||||
source: `git clone https://github.com/lukaszraczylo/claude-mnemonic.git\ncd claude-mnemonic\nmake build && make install`,
|
||||
}
|
||||
|
||||
const configOptions = [
|
||||
{ name: 'CLAUDE_MNEMONIC_WORKER_PORT', description: 'HTTP port for the worker service (default: 37777)', icon: 'fas fa-network-wired' },
|
||||
{ name: 'CLAUDE_MNEMONIC_CONTEXT_OBSERVATIONS', description: 'Maximum observations injected per session (default: 100)', icon: 'fas fa-layer-group' },
|
||||
{ name: 'CLAUDE_MNEMONIC_RERANKING_ENABLED', description: 'Enable cross-encoder reranking for improved search relevance (default: true)', icon: 'fas fa-sort-amount-down' },
|
||||
{ name: 'CLAUDE_MNEMONIC_CONTEXT_RELEVANCE_THRESHOLD', description: 'Minimum similarity score for inclusion, 0.0-1.0 (default: 0.3)', icon: 'fas fa-filter' },
|
||||
{ name: 'CLAUDE_MNEMONIC_VECTOR_STORAGE_STRATEGY', description: 'Storage strategy: "hub" (default), "always", or "on_demand"', icon: 'fas fa-database' },
|
||||
{ name: 'CLAUDE_MNEMONIC_GRAPH_ENABLED', description: 'Enable graph-based search with observation relationships (default: true)', icon: 'fas fa-project-diagram' },
|
||||
{ name: 'CLAUDE_MNEMONIC_GRAPH_MAX_HOPS', description: 'Maximum graph traversal depth for search expansion (default: 2)', icon: 'fas fa-route' },
|
||||
{ name: 'CLAUDE_MNEMONIC_GRAPH_REBUILD_INTERVAL_MIN', description: 'How often to rebuild the observation graph in minutes (default: 60)', icon: 'fas fa-clock' },
|
||||
{ name: 'WORKER_PORT', description: 'Dashboard and API port (default: 37777)' },
|
||||
{ name: 'CONTEXT_OBSERVATIONS', description: 'Max memories injected per session (default: 100)' },
|
||||
{ name: 'CONTEXT_FULL_COUNT', description: 'Full-detail memories, rest condensed (default: 25)' },
|
||||
{ name: 'CONTEXT_RELEVANCE_THRESHOLD', description: 'Min similarity score 0.0-1.0 (default: 0.3)' },
|
||||
{ name: 'RERANKING_ENABLED', description: 'Cross-encoder reranking (default: true)' },
|
||||
{ name: 'VECTOR_STORAGE_STRATEGY', description: 'hub (default), always, or on_demand' },
|
||||
{ name: 'GRAPH_ENABLED', description: 'Graph-based search with relationships (default: true)' },
|
||||
{ name: 'EMBEDDING_MODEL', description: 'Embedding model for semantic search (default: bge-v1.5)' },
|
||||
]
|
||||
|
||||
const requiredDeps = [
|
||||
{ name: 'Claude Code CLI', description: 'Host application - this is a plugin for Claude Code. Uses your existing subscription (Pro/Max) instead of API keys.', icon: 'fas fa-terminal' },
|
||||
{ name: 'jq', description: 'JSON processor used during installation. Usually pre-installed on most systems.', icon: 'fas fa-code' },
|
||||
{ name: 'Claude Code CLI', description: 'Host application. Uses your existing subscription.', icon: 'fas fa-terminal' },
|
||||
{ name: 'jq', description: 'JSON processing during install. Usually pre-installed.', icon: 'fas fa-code' },
|
||||
]
|
||||
|
||||
const techStack = [
|
||||
{ name: 'Go', description: 'Single binary, fast startup, low memory, zero runtime dependencies.' },
|
||||
{ name: 'SQLite + FTS5', description: 'Full-text search in a single file. Survives restarts.' },
|
||||
{ name: 'sqlite-vec', description: 'Vector search embedded in SQLite with LEANN-inspired selective embeddings.' },
|
||||
{ name: 'BGE reranker', description: 'Cross-encoder for two-stage retrieval: bi-encoder + reranking.' },
|
||||
{ name: 'Tree-sitter', description: 'AST-aware code chunking for Go, Python, and TypeScript.' },
|
||||
{ name: 'CSR Graph', description: 'Memory-efficient observation relationship graph with edge detection.' },
|
||||
]
|
||||
|
||||
const faqs = [
|
||||
{ question: 'Will it confuse Claude with wrong context?', answer: 'No. Mnemonic uses project isolation and semantic relevance scoring. Only memories from the current project (or global best practices) are injected, and only when they\'re actually relevant to your prompt.' },
|
||||
{ question: 'What exactly gets saved?', answer: 'Bug fixes with context ("Fixed race condition by adding mutex"), architecture decisions ("Using repository pattern for data access"), conventions ("All API routes prefixed with /api/v1"), and learnings you want to preserve.' },
|
||||
{ question: 'How does hybrid vector storage work?', answer: 'LEANN-inspired selective storage: frequently-accessed "hub" observations (identified by access patterns and graph centrality) store embeddings. Infrequently-accessed observations recompute embeddings on-demand during search. This reduces storage by 60-80% with minimal latency impact (<50ms).' },
|
||||
{ question: 'Can I delete or edit memories?', answer: 'Yes. The web dashboard at localhost:37777 lets you browse, search, edit, and delete any memory. You can also view graph relationships, storage metrics, and performance analytics. You\'re always in control.' },
|
||||
{ question: 'Does it work with my existing Claude Code setup?', answer: 'Yes. Mnemonic installs as a Claude Code plugin with hooks. Your existing workflows, settings, and shortcuts remain unchanged.' },
|
||||
{ question: 'What if I switch between projects frequently?', answer: 'That\'s the point. Each project has isolated memories. Switch from your Python ML project to your TypeScript app - context switches automatically.' },
|
||||
{ question: 'Is there a performance impact?', answer: 'Minimal. The Go worker is lightweight (typically under 30MB RAM). Hybrid storage and auto-tuning optimize for your workload. Context injection at session start takes milliseconds for most projects.' },
|
||||
{ question: 'What is AST-aware chunking?', answer: 'When processing code observations, Mnemonic uses Tree-sitter parsers to respect function and class boundaries instead of arbitrary line limits. Go, Python, and TypeScript code is chunked at semantic boundaries for better search accuracy.' },
|
||||
{ question: 'Will it confuse Claude with wrong context?', answer: 'Project isolation and semantic relevance scoring prevent that. Only memories from the current project (or global best practices) are injected, and only when relevant to your prompt.' },
|
||||
{ question: 'What gets stored?', answer: 'Bug fixes with context, architecture decisions, project conventions, security patterns — things you\'d otherwise re-explain each session. No raw conversation dumps.' },
|
||||
{ question: 'Can I delete or edit memories?', answer: 'Yes. The dashboard at localhost:37777 lets you browse, search, edit, and delete anything. You can also view graph relationships and storage metrics.' },
|
||||
{ question: 'Performance impact?', answer: 'The Go worker uses ~30MB RAM. Context injection at session start takes milliseconds for most projects. Hybrid storage auto-tunes for your workload.' },
|
||||
{ question: 'Does it work with my existing setup?', answer: 'Yes. Installs as a Claude Code plugin with hooks. Your workflows, settings, and shortcuts stay the same.' },
|
||||
{ question: 'What if I switch between projects?', answer: 'Each project has isolated memories. Switch from your Python ML project to your TypeScript app — context switches automatically.' },
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="glass rounded-xl p-4 sm:p-6 relative max-w-4xl mx-auto glow-amber">
|
||||
<button @click="copyCode" class="absolute top-3 sm:top-4 right-3 sm:right-4 bg-slate-800/50 border border-slate-700/50 text-slate-500 hover:text-white hover:border-slate-600 px-2 sm:px-3 py-1 sm:py-1.5 rounded-md text-xs transition-all">
|
||||
{{ copied ? 'Copied!' : 'Copy' }}
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900/30 p-4 relative overflow-hidden">
|
||||
<button @click="copyCode" class="absolute top-3 right-3 text-neutral-600 hover:text-neutral-300 text-xs transition-colors px-2 py-1 rounded border border-neutral-800 hover:border-neutral-600">
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
<pre class="text-xs sm:text-sm whitespace-pre-wrap break-all font-mono pr-16 sm:pr-20"><slot></slot></pre>
|
||||
<pre class="text-sm whitespace-pre-wrap break-all font-mono text-neutral-300 pr-20 leading-relaxed"><code>{{ code }}</code></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,10 +11,7 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
code: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
code: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
<template>
|
||||
<div class="glass rounded-xl p-4 sm:p-6 hover:border-amber-500/20 transition-colors">
|
||||
<h3 class="text-white font-semibold text-sm sm:text-base mb-2 sm:mb-3">{{ question }}</h3>
|
||||
<p class="text-slate-400 text-xs sm:text-sm leading-relaxed">{{ answer }}</p>
|
||||
<div class="border-b border-neutral-800 last:border-0">
|
||||
<button @click="open = !open" class="w-full text-left flex items-center justify-between py-4">
|
||||
<span class="text-white text-sm font-medium pr-4">{{ question }}</span>
|
||||
<i :class="open ? 'fas fa-minus' : 'fas fa-plus'" class="text-neutral-600 text-xs flex-shrink-0"></i>
|
||||
</button>
|
||||
<div v-if="open" class="pb-4">
|
||||
<p class="text-neutral-500 text-sm leading-relaxed">{{ answer }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
question: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
answer: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
question: { type: String, required: true },
|
||||
answer: { type: String, required: true },
|
||||
})
|
||||
|
||||
const open = ref(false)
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
<footer class="border-t border-slate-800 py-10 sm:py-16 relative">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 flex flex-col md:flex-row justify-between items-center gap-4 sm:gap-6">
|
||||
<div class="flex flex-wrap justify-center gap-4 sm:gap-6">
|
||||
<footer class="border-t border-neutral-800/80 py-8 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<p class="text-neutral-600 text-xs">MIT License</p>
|
||||
<div class="flex gap-5">
|
||||
<a v-for="link in links" :key="link.href" :href="link.href" target="_blank"
|
||||
class="text-slate-500 hover:text-white text-xs sm:text-sm transition-colors animated-underline">
|
||||
class="text-neutral-600 hover:text-neutral-400 text-xs transition-colors">
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-slate-600 text-xs sm:text-sm text-center">{{ copyright }}</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -20,12 +20,8 @@ defineProps({
|
||||
{ label: 'GitHub', href: 'https://github.com/lukaszraczylo/claude-mnemonic' },
|
||||
{ label: 'Releases', href: 'https://github.com/lukaszraczylo/claude-mnemonic/releases' },
|
||||
{ label: 'Issues', href: 'https://github.com/lukaszraczylo/claude-mnemonic/issues' },
|
||||
{ label: 'MIT License', href: 'https://github.com/lukaszraczylo/claude-mnemonic/blob/main/LICENSE' },
|
||||
{ label: 'License', href: 'https://github.com/lukaszraczylo/claude-mnemonic/blob/main/LICENSE' },
|
||||
]
|
||||
},
|
||||
copyright: {
|
||||
type: String,
|
||||
default: 'Made with care for the Claude Code community'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,44 +1,35 @@
|
||||
<template>
|
||||
<section class="min-h-screen flex items-center relative pt-24 sm:pt-28 pb-16 sm:pb-20">
|
||||
<!-- Background Effects -->
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<div class="absolute top-[-50%] left-1/2 -translate-x-1/2 w-[150%] h-full bg-[radial-gradient(ellipse_at_center,rgba(245,158,11,0.12)_0%,transparent_60%)] opacity-60"></div>
|
||||
</div>
|
||||
<section class="pt-24 pb-8 sm:pt-32 sm:pb-12 px-4 sm:px-6">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<p class="text-amber-500 text-sm font-medium mb-4 tracking-wide">Persistent memory for Claude Code</p>
|
||||
|
||||
<div class="relative z-10 max-w-6xl mx-auto px-4 sm:px-6 text-center">
|
||||
<div class="inline-flex items-center gap-2 glass px-3 sm:px-4 py-1.5 sm:py-2 rounded-full text-xs sm:text-sm text-slate-400 mb-6 sm:mb-8 opacity-0 animate-fade-in-up">
|
||||
<span class="w-2 h-2 bg-amber-500 rounded-full animate-pulse-slow"></span>
|
||||
{{ badge }}
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold text-white mb-4 sm:mb-6 leading-tight opacity-0 animate-fade-in-up animation-delay-100">
|
||||
{{ titleBefore }}<br class="hidden sm:block">
|
||||
<span class="sm:hidden"> </span>
|
||||
<span class="text-gradient">{{ titleHighlight }}</span>
|
||||
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-[1.1] mb-6 max-w-3xl">
|
||||
Stop re-explaining your codebase every session.
|
||||
</h1>
|
||||
|
||||
<p class="text-base sm:text-lg md:text-xl text-slate-400 max-w-2xl mx-auto mb-8 sm:mb-10 opacity-0 animate-fade-in-up animation-delay-200 px-2">
|
||||
{{ subtitle }}
|
||||
<p class="text-neutral-400 text-lg sm:text-xl max-w-2xl mb-10 leading-relaxed">
|
||||
Claude Code forgets everything when your session ends. Mnemonic captures what you've already explained — bug fixes, architecture decisions, patterns — and brings it back when relevant.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center mb-12 sm:mb-16 opacity-0 animate-fade-in-up animation-delay-300 px-4 sm:px-0">
|
||||
<a :href="primaryCta.href" class="inline-flex items-center justify-center gap-2 bg-gradient-to-br from-amber-500 to-amber-400 text-slate-950 px-6 sm:px-8 py-3 sm:py-3.5 rounded-lg font-semibold hover:scale-105 hover:shadow-lg hover:shadow-amber-500/30 transition-all">
|
||||
<i :class="primaryCta.icon"></i>
|
||||
{{ primaryCta.label }}
|
||||
<div class="flex flex-col sm:flex-row gap-3 mb-12">
|
||||
<a href="#install" class="inline-flex items-center justify-center gap-2 bg-white text-neutral-900 px-6 py-3 rounded-lg font-semibold text-sm hover:bg-neutral-200 transition-colors">
|
||||
Install now
|
||||
</a>
|
||||
<a :href="secondaryCta.href" target="_blank" class="inline-flex items-center justify-center gap-2 glass text-white px-6 sm:px-8 py-3 sm:py-3.5 rounded-lg font-medium hover:border-amber-500/30 hover:bg-amber-500/10 transition-all">
|
||||
<i :class="secondaryCta.icon"></i>
|
||||
{{ secondaryCta.label }}
|
||||
<a href="https://github.com/lukaszraczylo/claude-mnemonic" target="_blank" class="inline-flex items-center justify-center gap-2 border border-neutral-700 text-neutral-400 px-6 py-3 rounded-lg font-medium text-sm hover:border-neutral-500 hover:text-white transition-colors">
|
||||
<i class="fab fa-github"></i> View source
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Install Command -->
|
||||
<div class="opacity-0 animate-fade-in-up animation-delay-400 px-2 sm:px-0">
|
||||
<div class="glass rounded-xl px-4 sm:px-6 py-4 sm:py-5 flex flex-col sm:flex-row items-center justify-between gap-3 sm:gap-4 max-w-4xl mx-auto glow-amber">
|
||||
<code class="text-amber-400 text-xs sm:text-sm md:text-base flex-1 text-center sm:text-left break-all font-mono">{{ installCommand }}</code>
|
||||
<button @click="copyCommand" class="text-slate-500 hover:text-white transition-colors p-2 flex-shrink-0 rounded-lg hover:bg-slate-800/50">
|
||||
<i :class="copied ? 'fas fa-check text-amber-500' : 'fas fa-copy'"></i>
|
||||
<span class="ml-2 text-sm sm:hidden">{{ copied ? 'Copied!' : 'Copy' }}</span>
|
||||
<div class="max-w-2xl">
|
||||
<div class="flex items-stretch bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<div class="flex items-center px-3 gap-1.5 border-r border-neutral-800">
|
||||
<span class="w-3 h-3 rounded-full bg-neutral-700"></span>
|
||||
<span class="w-3 h-3 rounded-full bg-neutral-700"></span>
|
||||
<span class="w-3 h-3 rounded-full bg-neutral-700"></span>
|
||||
</div>
|
||||
<code class="flex-1 text-neutral-300 text-xs sm:text-sm px-4 py-3 font-mono overflow-x-auto">{{ installCommand }}</code>
|
||||
<button @click="copyCommand" class="px-4 text-neutral-600 hover:text-neutral-300 transition-colors border-l border-neutral-800 text-sm" title="Copy">
|
||||
<i :class="copied ? 'fas fa-check text-emerald-500' : 'fas fa-copy'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,41 +40,11 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
badge: {
|
||||
type: String,
|
||||
default: 'Persistent Memory for Claude Code'
|
||||
},
|
||||
titleBefore: {
|
||||
type: String,
|
||||
default: 'Claude forgets.'
|
||||
},
|
||||
titleHighlight: {
|
||||
type: String,
|
||||
default: 'Make it remember.'
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: 'Capture learnings, decisions, and patterns from your coding sessions. Bring that knowledge back in every future conversation.'
|
||||
},
|
||||
primaryCta: {
|
||||
type: Object,
|
||||
default: () => ({ label: 'Install Now', href: '#installation', icon: 'fas fa-download' })
|
||||
},
|
||||
secondaryCta: {
|
||||
type: Object,
|
||||
default: () => ({ label: 'View Source', href: 'https://github.com/lukaszraczylo/claude-mnemonic', icon: 'fab fa-github' })
|
||||
},
|
||||
installCommand: {
|
||||
type: String,
|
||||
default: 'curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash'
|
||||
}
|
||||
})
|
||||
|
||||
const installCommand = 'curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash'
|
||||
const copied = ref(false)
|
||||
|
||||
function copyCommand() {
|
||||
navigator.clipboard.writeText('curl -sSL https://raw.githubusercontent.com/lukaszraczylo/claude-mnemonic/main/scripts/install.sh | bash')
|
||||
navigator.clipboard.writeText(installCommand)
|
||||
copied.value = true
|
||||
setTimeout(() => copied.value = false, 2000)
|
||||
}
|
||||
|
||||
@@ -1,70 +1,45 @@
|
||||
<template>
|
||||
<nav class="fixed top-0 left-0 right-0 z-50 bg-gradient-to-b from-slate-950 via-slate-950/95 to-transparent backdrop-blur-md">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-4 sm:py-5 flex justify-between items-center">
|
||||
<a href="#" class="flex items-center gap-2 sm:gap-3 text-white font-bold text-xl sm:text-2xl">
|
||||
<div class="w-8 h-8 sm:w-9 sm:h-9 bg-gradient-to-br from-amber-500 to-amber-400 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-brain text-slate-950 text-sm sm:text-base"></i>
|
||||
</div>
|
||||
{{ title }}
|
||||
</a>
|
||||
<nav class="border-b border-neutral-800/80 sticky top-0 z-50 bg-[#09090b]/90 backdrop-blur-sm">
|
||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 h-14 flex justify-between items-center">
|
||||
<a href="#" class="text-white font-bold text-base tracking-tight">Claude Mnemonic</a>
|
||||
|
||||
<ul class="hidden md:flex gap-6 lg:gap-8">
|
||||
<ul class="hidden md:flex items-center gap-6">
|
||||
<li v-for="link in links" :key="link.href">
|
||||
<a :href="link.href" class="text-slate-400 hover:text-white text-sm font-medium transition-colors animated-underline">
|
||||
{{ link.label }}
|
||||
</a>
|
||||
<a :href="link.href" class="text-neutral-500 hover:text-white text-sm transition-colors">{{ link.label }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="flex items-center gap-3 sm:gap-4">
|
||||
<a :href="githubUrl" target="_blank" class="text-slate-400 hover:text-white text-lg sm:text-xl transition-colors">
|
||||
<div class="flex items-center gap-4">
|
||||
<a :href="githubUrl" target="_blank" class="text-neutral-500 hover:text-white transition-colors text-lg">
|
||||
<i class="fab fa-github"></i>
|
||||
</a>
|
||||
<a href="#installation" class="hidden sm:inline-flex bg-gradient-to-br from-amber-500 to-amber-400 text-slate-950 px-4 sm:px-5 py-2 sm:py-2.5 rounded-lg font-semibold text-sm hover:scale-105 hover:shadow-lg hover:shadow-amber-500/30 transition-all">
|
||||
Get Started
|
||||
<a href="#install" class="hidden sm:inline-flex items-center gap-2 text-sm font-medium text-amber-500 hover:text-amber-400 transition-colors">
|
||||
Install <i class="fas fa-arrow-right text-xs"></i>
|
||||
</a>
|
||||
<button @click="$emit('toggle-menu')" class="md:hidden text-white text-xl p-2">
|
||||
<button @click="$emit('toggle-menu')" class="md:hidden text-neutral-500 hover:text-white p-1">
|
||||
<i :class="mobileMenuOpen ? 'fas fa-times' : 'fas fa-bars'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div v-if="mobileMenuOpen" class="md:hidden bg-slate-900/95 backdrop-blur-xl border-t border-slate-800 px-4 sm:px-6 py-4">
|
||||
<a v-for="link in links" :key="link.href" :href="link.href"
|
||||
@click="$emit('toggle-menu')"
|
||||
class="block py-3 text-slate-400 hover:text-white border-b border-slate-800 last:border-0 transition-colors">
|
||||
{{ link.label }}
|
||||
</a>
|
||||
<a href="#installation" @click="$emit('toggle-menu')"
|
||||
class="mt-4 block text-center bg-gradient-to-br from-amber-500 to-amber-400 text-slate-950 px-5 py-2.5 rounded-lg font-semibold text-sm">
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
</Transition>
|
||||
<div v-if="mobileMenuOpen" class="md:hidden border-t border-neutral-800/80 px-6 py-4 space-y-1">
|
||||
<a v-for="link in links" :key="link.href" :href="link.href"
|
||||
@click="$emit('toggle-menu')"
|
||||
class="block py-2 text-neutral-500 hover:text-white text-sm transition-colors">
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Mnemonic'
|
||||
},
|
||||
links: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ label: 'Features', href: '#features' },
|
||||
{ label: 'How It Works', href: '#how-it-works' },
|
||||
{ label: 'Install', href: '#installation' },
|
||||
{ label: 'Install', href: '#install' },
|
||||
{ label: 'Config', href: '#config' },
|
||||
{ label: 'FAQ', href: '#faq' },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
--font-mono: 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@@ -10,105 +13,14 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply font-sans text-slate-300;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-sans font-bold tracking-tight;
|
||||
@apply font-sans text-neutral-300 bg-[#09090b];
|
||||
}
|
||||
|
||||
code, pre {
|
||||
@apply font-mono;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Animated gradient background */
|
||||
.bg-animated-gradient {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(245, 158, 11, 0.1) 0%,
|
||||
transparent 50%,
|
||||
rgba(245, 158, 11, 0.05) 100%
|
||||
);
|
||||
background-size: 200% 200%;
|
||||
animation: gradientShift 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Floating particles */
|
||||
.particle {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: rgba(245, 158, 11, 0.4);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Glass effect */
|
||||
.glass {
|
||||
@apply bg-slate-900/60 backdrop-blur-xl border border-slate-700/50;
|
||||
}
|
||||
|
||||
/* Glow effect */
|
||||
.glow-amber {
|
||||
box-shadow: 0 0 40px rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
|
||||
/* Animated underline */
|
||||
.animated-underline {
|
||||
position: relative;
|
||||
}
|
||||
.animated-underline::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.animated-underline:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Animated border */
|
||||
.animated-border {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.animated-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, #f59e0b, transparent);
|
||||
animation: borderSlide 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes borderSlide {
|
||||
0% { left: -100%; }
|
||||
100% { left: 100%; }
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.animation-delay-100 { animation-delay: 0.1s; }
|
||||
.animation-delay-200 { animation-delay: 0.2s; }
|
||||
.animation-delay-300 { animation-delay: 0.3s; }
|
||||
.animation-delay-400 { animation-delay: 0.4s; }
|
||||
.animation-delay-500 { animation-delay: 0.5s; }
|
||||
|
||||
/* Text gradient */
|
||||
.text-gradient {
|
||||
@apply bg-gradient-to-r from-amber-400 to-amber-500 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
/* Opacity starting state for animations */
|
||||
.opacity-0-start {
|
||||
opacity: 0;
|
||||
::selection {
|
||||
@apply bg-amber-500/30 text-white;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -7,19 +7,18 @@ replace github.com/sugarme/tokenizer => github.com/clems4ever/tokenizer v0.0.0-2
|
||||
require (
|
||||
github.com/asg017/sqlite-vec-go-bindings v0.1.6
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/go-chi/chi/v5 v5.3.0
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.6
|
||||
github.com/goccy/go-json v0.10.6
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.1
|
||||
github.com/mattn/go-sqlite3 v1.14.44
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.3
|
||||
github.com/mattn/go-sqlite3 v1.14.48
|
||||
github.com/rs/zerolog v1.35.1
|
||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/sugarme/tokenizer v0.3.0
|
||||
github.com/yalue/onnxruntime_go v1.30.1
|
||||
golang.org/x/sync v0.20.0
|
||||
github.com/yalue/onnxruntime_go v1.31.0
|
||||
golang.org/x/sync v0.22.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -27,7 +26,7 @@ require (
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||
@@ -35,7 +34,7 @@ require (
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/schollz/progressbar/v2 v2.15.0 // indirect
|
||||
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -9,24 +9,24 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiIhS4/QTTa/SM8=
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.6 h1:VtX+l1Stj2v5RGubVQk0LS/8EPGXR+ldcOyCmlmKoyg=
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.6/go.mod h1:PZpedQc4tWaxn6kvXicwhinh3L0seLpMc5ReKRX5id4=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.1 h1:6ULyfzXplpdmIY/i01OPM1jeod9+L1RAhI0jtbVnJI0=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.1/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.3 h1:xoDtBqeZGmXj7IteiE1M5WMuzeoqag58qEleI0Cf2Ms=
|
||||
github.com/lukaszraczylo/oss-telemetry v0.2.3/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
|
||||
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
@@ -39,27 +39,25 @@ github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/schollz/progressbar/v2 v2.15.0 h1:dVzHQ8fHRmtPjD3K10jT3Qgn/+H+92jhPrhmxIJfDz8=
|
||||
github.com/schollz/progressbar/v2 v2.15.0/go.mod h1:UdPq3prGkfQ7MOzZKlDRpYKcFqEMczbD7YmbPgpzKMI=
|
||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4=
|
||||
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82/go.mod h1:xe4pgH49k4SsmkQq5OT8abwhWmnzkhpgnXeekbx2efw=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c h1:pwb4kNSHb4K89ymCaN+5lPH/MwnfSVg4rzGDh4d+iy4=
|
||||
github.com/sugarme/regexpset v0.0.0-20200920021344-4d4ec8eaf93c/go.mod h1:2gwkXLWbDGUQWeL3RtpCmcY4mzCtU13kb9UsAg9xMaw=
|
||||
github.com/yalue/onnxruntime_go v1.30.1 h1:NaEng5lWbsHZ/8X1dtaw1mIj7eV1ozyjbFo//g0ktl4=
|
||||
github.com/yalue/onnxruntime_go v1.30.1/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
github.com/yalue/onnxruntime_go v1.31.0 h1:1ln4YW1SFOFfGJZXe3jNOb2JUSt+l2pEneZfV8HdtFA=
|
||||
github.com/yalue/onnxruntime_go v1.31.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
// Package golang provides AST-aware chunking for Go source files.
|
||||
package golang
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
|
||||
)
|
||||
|
||||
// Chunker implements AST-aware chunking for Go files.
|
||||
type Chunker struct {
|
||||
options chunking.ChunkOptions
|
||||
}
|
||||
|
||||
// NewChunker creates a new Go chunker.
|
||||
func NewChunker(options chunking.ChunkOptions) *Chunker {
|
||||
return &Chunker{options: options}
|
||||
}
|
||||
|
||||
// Language returns the language this chunker supports.
|
||||
func (c *Chunker) Language() chunking.Language {
|
||||
return chunking.LanguageGo
|
||||
}
|
||||
|
||||
// SupportedExtensions returns the file extensions this chunker handles.
|
||||
func (c *Chunker) SupportedExtensions() []string {
|
||||
return []string{".go"}
|
||||
}
|
||||
|
||||
// Chunk parses a Go source file and returns semantic code chunks.
|
||||
func (c *Chunker) Chunk(ctx context.Context, filePath string) ([]chunking.Chunk, error) {
|
||||
// Read file content
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file: %w", err)
|
||||
}
|
||||
|
||||
// Parse the Go file
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, filePath, content, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse Go file: %w", err)
|
||||
}
|
||||
|
||||
chunks := make([]chunking.Chunk, 0)
|
||||
sourceLines := strings.Split(string(content), "\n")
|
||||
|
||||
// Extract chunks from declarations
|
||||
for _, decl := range file.Decls {
|
||||
switch d := decl.(type) {
|
||||
case *ast.FuncDecl:
|
||||
chunk := c.extractFunction(fset, d, sourceLines, filePath)
|
||||
if chunk != nil {
|
||||
chunks = append(chunks, *chunk)
|
||||
}
|
||||
case *ast.GenDecl:
|
||||
extracted := c.extractGenDecl(fset, d, sourceLines, filePath)
|
||||
chunks = append(chunks, extracted...)
|
||||
}
|
||||
}
|
||||
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
// extractFunction extracts a function or method declaration as a chunk.
|
||||
func (c *Chunker) extractFunction(fset *token.FileSet, fn *ast.FuncDecl, sourceLines []string, filePath string) *chunking.Chunk {
|
||||
// Skip unexported if configured
|
||||
if !c.options.IncludePrivate && !fn.Name.IsExported() {
|
||||
return nil
|
||||
}
|
||||
|
||||
startPos := fset.Position(fn.Pos())
|
||||
endPos := fset.Position(fn.End())
|
||||
|
||||
chunk := &chunking.Chunk{
|
||||
FilePath: filePath,
|
||||
Language: chunking.LanguageGo,
|
||||
Name: fn.Name.Name,
|
||||
StartLine: startPos.Line,
|
||||
EndLine: endPos.Line,
|
||||
}
|
||||
|
||||
// Determine if this is a method or a function
|
||||
if fn.Recv != nil && len(fn.Recv.List) > 0 {
|
||||
chunk.Type = chunking.ChunkTypeMethod
|
||||
chunk.ParentName = c.extractReceiverType(fn.Recv)
|
||||
} else {
|
||||
chunk.Type = chunking.ChunkTypeFunction
|
||||
}
|
||||
|
||||
// Extract content
|
||||
chunk.Content = c.extractLines(sourceLines, startPos.Line, endPos.Line)
|
||||
|
||||
// Extract signature (function declaration without body)
|
||||
chunk.Signature = c.extractFunctionSignature(fn, fset, sourceLines)
|
||||
|
||||
// Extract doc comment
|
||||
if c.options.IncludeDocComments && fn.Doc != nil {
|
||||
chunk.DocComment = strings.TrimSpace(fn.Doc.Text())
|
||||
}
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
// extractGenDecl extracts general declarations (type, const, var).
|
||||
func (c *Chunker) extractGenDecl(fset *token.FileSet, gd *ast.GenDecl, sourceLines []string, filePath string) []chunking.Chunk {
|
||||
var chunks []chunking.Chunk
|
||||
|
||||
for _, spec := range gd.Specs {
|
||||
switch s := spec.(type) {
|
||||
case *ast.TypeSpec:
|
||||
chunk := c.extractTypeSpec(fset, gd, s, sourceLines, filePath)
|
||||
if chunk != nil {
|
||||
chunks = append(chunks, *chunk)
|
||||
}
|
||||
case *ast.ValueSpec:
|
||||
// Handle const and var declarations
|
||||
chunk := c.extractValueSpec(fset, gd, s, sourceLines, filePath)
|
||||
if chunk != nil {
|
||||
chunks = append(chunks, *chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
// extractTypeSpec extracts a type declaration (struct, interface, type alias).
|
||||
func (c *Chunker) extractTypeSpec(fset *token.FileSet, gd *ast.GenDecl, ts *ast.TypeSpec, sourceLines []string, filePath string) *chunking.Chunk {
|
||||
// Skip unexported if configured
|
||||
if !c.options.IncludePrivate && !ts.Name.IsExported() {
|
||||
return nil
|
||||
}
|
||||
|
||||
startPos := fset.Position(gd.Pos())
|
||||
endPos := fset.Position(gd.End())
|
||||
|
||||
chunk := &chunking.Chunk{
|
||||
FilePath: filePath,
|
||||
Language: chunking.LanguageGo,
|
||||
Name: ts.Name.Name,
|
||||
StartLine: startPos.Line,
|
||||
EndLine: endPos.Line,
|
||||
Content: c.extractLines(sourceLines, startPos.Line, endPos.Line),
|
||||
}
|
||||
|
||||
// Determine chunk type based on type expression
|
||||
switch ts.Type.(type) {
|
||||
case *ast.StructType:
|
||||
chunk.Type = chunking.ChunkTypeClass // Treat struct as class
|
||||
case *ast.InterfaceType:
|
||||
chunk.Type = chunking.ChunkTypeInterface
|
||||
default:
|
||||
chunk.Type = chunking.ChunkTypeType
|
||||
}
|
||||
|
||||
// Extract doc comment
|
||||
if c.options.IncludeDocComments && gd.Doc != nil {
|
||||
chunk.DocComment = strings.TrimSpace(gd.Doc.Text())
|
||||
}
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
// extractValueSpec extracts const or var declarations.
|
||||
func (c *Chunker) extractValueSpec(fset *token.FileSet, gd *ast.GenDecl, vs *ast.ValueSpec, sourceLines []string, filePath string) *chunking.Chunk {
|
||||
// Skip if all names are unexported and we're excluding private
|
||||
if !c.options.IncludePrivate {
|
||||
allUnexported := true
|
||||
for _, name := range vs.Names {
|
||||
if name.IsExported() {
|
||||
allUnexported = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allUnexported {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
startPos := fset.Position(gd.Pos())
|
||||
endPos := fset.Position(gd.End())
|
||||
|
||||
// Use first name as the chunk name, join multiple if present
|
||||
names := make([]string, len(vs.Names))
|
||||
for i, name := range vs.Names {
|
||||
names[i] = name.Name
|
||||
}
|
||||
|
||||
chunk := &chunking.Chunk{
|
||||
FilePath: filePath,
|
||||
Language: chunking.LanguageGo,
|
||||
Name: strings.Join(names, ", "),
|
||||
StartLine: startPos.Line,
|
||||
EndLine: endPos.Line,
|
||||
Content: c.extractLines(sourceLines, startPos.Line, endPos.Line),
|
||||
}
|
||||
|
||||
// Set type based on token
|
||||
if gd.Tok == token.CONST {
|
||||
chunk.Type = chunking.ChunkTypeConst
|
||||
} else {
|
||||
chunk.Type = chunking.ChunkTypeVar
|
||||
}
|
||||
|
||||
// Extract doc comment
|
||||
if c.options.IncludeDocComments && gd.Doc != nil {
|
||||
chunk.DocComment = strings.TrimSpace(gd.Doc.Text())
|
||||
}
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
// extractReceiverType extracts the receiver type name from a method.
|
||||
func (c *Chunker) extractReceiverType(recv *ast.FieldList) string {
|
||||
if len(recv.List) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
field := recv.List[0]
|
||||
switch t := field.Type.(type) {
|
||||
case *ast.Ident:
|
||||
return t.Name
|
||||
case *ast.StarExpr:
|
||||
if ident, ok := t.X.(*ast.Ident); ok {
|
||||
return ident.Name
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractFunctionSignature extracts the function signature without the body.
|
||||
func (c *Chunker) extractFunctionSignature(fn *ast.FuncDecl, fset *token.FileSet, sourceLines []string) string {
|
||||
if fn.Body == nil {
|
||||
// No body, return entire declaration
|
||||
startPos := fset.Position(fn.Pos())
|
||||
endPos := fset.Position(fn.End())
|
||||
return c.extractLines(sourceLines, startPos.Line, endPos.Line)
|
||||
}
|
||||
|
||||
// Extract from start of function to just before body
|
||||
startPos := fset.Position(fn.Pos())
|
||||
bodyPos := fset.Position(fn.Body.Pos())
|
||||
|
||||
// If body is on the same line, extract just that line up to the opening brace
|
||||
if startPos.Line == bodyPos.Line {
|
||||
line := sourceLines[startPos.Line-1]
|
||||
// Find the opening brace position
|
||||
if idx := strings.Index(line[startPos.Column-1:], "{"); idx >= 0 {
|
||||
return strings.TrimSpace(line[startPos.Column-1 : startPos.Column-1+idx])
|
||||
}
|
||||
return strings.TrimSpace(line[startPos.Column-1:])
|
||||
}
|
||||
|
||||
// Get lines from start to the line containing the opening brace
|
||||
sig := c.extractLines(sourceLines, startPos.Line, bodyPos.Line)
|
||||
// Remove the opening brace and anything after it
|
||||
if idx := strings.Index(sig, "{"); idx >= 0 {
|
||||
sig = sig[:idx]
|
||||
}
|
||||
return strings.TrimSpace(sig)
|
||||
}
|
||||
|
||||
// extractLines extracts a range of lines from source (1-indexed, inclusive).
|
||||
func (c *Chunker) extractLines(lines []string, start, end int) string {
|
||||
if start < 1 || end < start || start > len(lines) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Adjust for 0-indexed array (start and end are 1-indexed)
|
||||
startIdx := start - 1
|
||||
endIdx := end
|
||||
if endIdx > len(lines) {
|
||||
endIdx = len(lines)
|
||||
}
|
||||
|
||||
return strings.Join(lines[startIdx:endIdx], "\n")
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package golang
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
|
||||
)
|
||||
|
||||
func TestGoChunker_BasicFunctions(t *testing.T) {
|
||||
// Create temp test file
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test.go")
|
||||
|
||||
testCode := `package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Greet prints a greeting message
|
||||
func Greet(name string) {
|
||||
fmt.Printf("Hello, %s!\n", name)
|
||||
}
|
||||
|
||||
// Add adds two numbers
|
||||
func Add(a, b int) int {
|
||||
return a + b
|
||||
}
|
||||
|
||||
// unexported function should be included by default
|
||||
func helper() string {
|
||||
return "helper"
|
||||
}
|
||||
`
|
||||
|
||||
if err := os.WriteFile(testFile, []byte(testCode), 0600); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Create chunker with default options
|
||||
chunker := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
// Chunk the file
|
||||
chunks, err := chunker.Chunk(context.Background(), testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Chunk() failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify we got all functions
|
||||
if len(chunks) != 3 {
|
||||
t.Errorf("Expected 3 chunks (Greet, Add, helper), got %d", len(chunks))
|
||||
}
|
||||
|
||||
// Verify chunk details
|
||||
expectedNames := map[string]bool{
|
||||
"Greet": false,
|
||||
"Add": false,
|
||||
"helper": false,
|
||||
}
|
||||
|
||||
for _, chunk := range chunks {
|
||||
if chunk.Type != chunking.ChunkTypeFunction {
|
||||
t.Errorf("Expected chunk type 'function', got '%s'", chunk.Type)
|
||||
}
|
||||
|
||||
if chunk.Language != chunking.LanguageGo {
|
||||
t.Errorf("Expected language 'go', got '%s'", chunk.Language)
|
||||
}
|
||||
|
||||
if _, ok := expectedNames[chunk.Name]; !ok {
|
||||
t.Errorf("Unexpected function name: %s", chunk.Name)
|
||||
} else {
|
||||
expectedNames[chunk.Name] = true
|
||||
}
|
||||
|
||||
// Verify content is non-empty
|
||||
if chunk.Content == "" {
|
||||
t.Errorf("Chunk %s has empty content", chunk.Name)
|
||||
}
|
||||
|
||||
// Verify signature is present for functions
|
||||
if chunk.Signature == "" {
|
||||
t.Errorf("Chunk %s has empty signature", chunk.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all expected functions were found
|
||||
for name, found := range expectedNames {
|
||||
if !found {
|
||||
t.Errorf("Expected function %s not found", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoChunker_StructsAndMethods(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test.go")
|
||||
|
||||
testCode := `package main
|
||||
|
||||
// User represents a user
|
||||
type User struct {
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
|
||||
// GetName returns the user's name
|
||||
func (u *User) GetName() string {
|
||||
return u.Name
|
||||
}
|
||||
|
||||
// SetName sets the user's name
|
||||
func (u *User) SetName(name string) {
|
||||
u.Name = name
|
||||
}
|
||||
`
|
||||
|
||||
if err := os.WriteFile(testFile, []byte(testCode), 0600); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
chunker := NewChunker(chunking.DefaultChunkOptions())
|
||||
chunks, err := chunker.Chunk(context.Background(), testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Chunk() failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have 1 struct + 2 methods = 3 chunks
|
||||
if len(chunks) != 3 {
|
||||
t.Errorf("Expected 3 chunks (User struct, GetName, SetName), got %d", len(chunks))
|
||||
}
|
||||
|
||||
// Find the struct and methods
|
||||
var structChunk, getNameChunk, setNameChunk *chunking.Chunk
|
||||
for i := range chunks {
|
||||
switch chunks[i].Name {
|
||||
case "User":
|
||||
structChunk = &chunks[i]
|
||||
case "GetName":
|
||||
getNameChunk = &chunks[i]
|
||||
case "SetName":
|
||||
setNameChunk = &chunks[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Verify struct
|
||||
if structChunk == nil {
|
||||
t.Fatal("User struct not found")
|
||||
}
|
||||
if structChunk.Type != chunking.ChunkTypeClass {
|
||||
t.Errorf("Expected User to be ChunkTypeClass, got %s", structChunk.Type)
|
||||
}
|
||||
|
||||
// Verify methods
|
||||
if getNameChunk == nil {
|
||||
t.Fatal("GetName method not found")
|
||||
}
|
||||
if getNameChunk.Type != chunking.ChunkTypeMethod {
|
||||
t.Errorf("Expected GetName to be ChunkTypeMethod, got %s", getNameChunk.Type)
|
||||
}
|
||||
if getNameChunk.ParentName != "User" {
|
||||
t.Errorf("Expected GetName parent to be 'User', got '%s'", getNameChunk.ParentName)
|
||||
}
|
||||
|
||||
if setNameChunk == nil {
|
||||
t.Fatal("SetName method not found")
|
||||
}
|
||||
if setNameChunk.Type != chunking.ChunkTypeMethod {
|
||||
t.Errorf("Expected SetName to be ChunkTypeMethod, got %s", setNameChunk.Type)
|
||||
}
|
||||
if setNameChunk.ParentName != "User" {
|
||||
t.Errorf("Expected SetName parent to be 'User', got '%s'", setNameChunk.ParentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoChunker_DocComments(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test.go")
|
||||
|
||||
testCode := `package main
|
||||
|
||||
// Calculate performs a calculation.
|
||||
// It takes two integers and returns their sum.
|
||||
func Calculate(a, b int) int {
|
||||
return a + b
|
||||
}
|
||||
`
|
||||
|
||||
if err := os.WriteFile(testFile, []byte(testCode), 0600); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
chunker := NewChunker(chunking.DefaultChunkOptions())
|
||||
chunks, err := chunker.Chunk(context.Background(), testFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Chunk() failed: %v", err)
|
||||
}
|
||||
|
||||
if len(chunks) != 1 {
|
||||
t.Fatalf("Expected 1 chunk, got %d", len(chunks))
|
||||
}
|
||||
|
||||
chunk := chunks[0]
|
||||
if chunk.DocComment == "" {
|
||||
t.Error("Expected doc comment to be present")
|
||||
}
|
||||
|
||||
// Doc comment should contain the comment text
|
||||
expectedComment := "Calculate performs a calculation.\nIt takes two integers and returns their sum."
|
||||
if chunk.DocComment != expectedComment {
|
||||
t.Errorf("Expected doc comment '%s', got '%s'", expectedComment, chunk.DocComment)
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package chunking
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Manager dispatches files to appropriate language-specific chunkers.
|
||||
type Manager struct {
|
||||
chunkers map[string]Chunker // extension -> chunker
|
||||
options ChunkOptions
|
||||
}
|
||||
|
||||
// NewManager creates a new chunking manager with the given chunkers.
|
||||
func NewManager(chunkers []Chunker, options ChunkOptions) *Manager {
|
||||
m := &Manager{
|
||||
chunkers: make(map[string]Chunker),
|
||||
options: options,
|
||||
}
|
||||
|
||||
// Register chunkers by their supported extensions
|
||||
for _, chunker := range chunkers {
|
||||
for _, ext := range chunker.SupportedExtensions() {
|
||||
m.chunkers[ext] = chunker
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// ChunkFile chunks a single file using the appropriate language chunker.
|
||||
// Returns an error if no chunker is found for the file extension.
|
||||
func (m *Manager) ChunkFile(ctx context.Context, filePath string) ([]Chunk, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
chunker, ok := m.chunkers[ext]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no chunker for extension %s", ext)
|
||||
}
|
||||
|
||||
chunks, err := chunker.Chunk(ctx, filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("chunk %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
// Apply options-based filtering
|
||||
filtered := make([]Chunk, 0, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
// Filter by minimum lines
|
||||
if m.options.MinLines > 0 {
|
||||
lineCount := chunk.EndLine - chunk.StartLine + 1
|
||||
if lineCount < m.options.MinLines {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by maximum chunk size
|
||||
if m.options.MaxChunkSize > 0 && len(chunk.Content) > m.options.MaxChunkSize {
|
||||
// TODO: Consider splitting large chunks intelligently
|
||||
// For now, skip chunks that are too large
|
||||
continue
|
||||
}
|
||||
|
||||
filtered = append(filtered, chunk)
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// ChunkFiles chunks multiple files in parallel.
|
||||
// Returns a map of file path to chunks, and any errors encountered.
|
||||
// Errors for individual files do not stop processing of other files.
|
||||
func (m *Manager) ChunkFiles(ctx context.Context, filePaths []string) (map[string][]Chunk, []error) {
|
||||
results := make(map[string][]Chunk)
|
||||
var errors []error
|
||||
|
||||
for _, filePath := range filePaths {
|
||||
chunks, err := m.ChunkFile(ctx, filePath)
|
||||
if err != nil {
|
||||
errors = append(errors, fmt.Errorf("%s: %w", filePath, err))
|
||||
continue
|
||||
}
|
||||
if len(chunks) > 0 {
|
||||
results[filePath] = chunks
|
||||
}
|
||||
}
|
||||
|
||||
return results, errors
|
||||
}
|
||||
|
||||
// SupportsFile checks if the manager can chunk the given file based on extension.
|
||||
func (m *Manager) SupportsFile(filePath string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
_, ok := m.chunkers[ext]
|
||||
return ok
|
||||
}
|
||||
|
||||
// SupportedExtensions returns all file extensions supported by registered chunkers.
|
||||
func (m *Manager) SupportedExtensions() []string {
|
||||
exts := make([]string, 0, len(m.chunkers))
|
||||
for ext := range m.chunkers {
|
||||
exts = append(exts, ext)
|
||||
}
|
||||
return exts
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package chunking
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mockChunker is a test chunker that returns dummy chunks
|
||||
type mockChunker struct{}
|
||||
|
||||
func (m *mockChunker) Chunk(ctx context.Context, filePath string) ([]Chunk, error) {
|
||||
// Just return an empty chunk for testing
|
||||
return []Chunk{
|
||||
{
|
||||
FilePath: filePath,
|
||||
Language: LanguageGo,
|
||||
Type: ChunkTypeFunction,
|
||||
Name: "TestFunc",
|
||||
StartLine: 1,
|
||||
EndLine: 1,
|
||||
Content: "test",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockChunker) Language() Language {
|
||||
return LanguageGo
|
||||
}
|
||||
|
||||
func (m *mockChunker) SupportedExtensions() []string {
|
||||
return []string{".go", ".py", ".ts"}
|
||||
}
|
||||
|
||||
func TestManager_ChunkMultipleFiles(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a Go file
|
||||
goFile := filepath.Join(tmpDir, "test.go")
|
||||
goCode := `package main
|
||||
|
||||
func Hello() string {
|
||||
return "hello"
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(goFile, []byte(goCode), 0600); err != nil {
|
||||
t.Fatalf("Failed to create Go file: %v", err)
|
||||
}
|
||||
|
||||
// Create a Python file
|
||||
pyFile := filepath.Join(tmpDir, "test.py")
|
||||
pyCode := `def greet(name):
|
||||
return f"Hello, {name}!"
|
||||
|
||||
class User:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
`
|
||||
if err := os.WriteFile(pyFile, []byte(pyCode), 0600); err != nil {
|
||||
t.Fatalf("Failed to create Python file: %v", err)
|
||||
}
|
||||
|
||||
// Create a TypeScript file
|
||||
tsFile := filepath.Join(tmpDir, "test.ts")
|
||||
tsCode := `function add(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
class Calculator {
|
||||
multiply(a: number, b: number): number {
|
||||
return a * b;
|
||||
}
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(tsFile, []byte(tsCode), 0600); err != nil {
|
||||
t.Fatalf("Failed to create TypeScript file: %v", err)
|
||||
}
|
||||
|
||||
// Create manager
|
||||
manager := NewManager([]Chunker{&mockChunker{}}, DefaultChunkOptions())
|
||||
|
||||
// Test SupportsFile
|
||||
if !manager.SupportsFile(goFile) {
|
||||
t.Error("Manager should support .go files")
|
||||
}
|
||||
if !manager.SupportsFile(pyFile) {
|
||||
t.Error("Manager should support .py files")
|
||||
}
|
||||
if !manager.SupportsFile(tsFile) {
|
||||
t.Error("Manager should support .ts files")
|
||||
}
|
||||
|
||||
unsupportedFile := filepath.Join(tmpDir, "test.txt")
|
||||
if manager.SupportsFile(unsupportedFile) {
|
||||
t.Error("Manager should not support .txt files")
|
||||
}
|
||||
|
||||
// Test ChunkFiles
|
||||
results, errs := manager.ChunkFiles(context.Background(), []string{goFile, pyFile, tsFile})
|
||||
if len(errs) > 0 {
|
||||
t.Errorf("ChunkFiles returned errors: %v", errs)
|
||||
}
|
||||
|
||||
if len(results) != 3 {
|
||||
t.Errorf("Expected results for 3 files, got %d", len(results))
|
||||
}
|
||||
|
||||
// Verify each file has chunks
|
||||
for _, file := range []string{goFile, pyFile, tsFile} {
|
||||
if chunks, ok := results[file]; !ok || len(chunks) == 0 {
|
||||
t.Errorf("No chunks found for file %s", file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mockChunkerWithExts is a test chunker with configurable extensions
|
||||
type mockChunkerWithExts struct {
|
||||
exts []string
|
||||
}
|
||||
|
||||
func (m *mockChunkerWithExts) Chunk(ctx context.Context, filePath string) ([]Chunk, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockChunkerWithExts) Language() Language {
|
||||
return LanguageGo
|
||||
}
|
||||
|
||||
func (m *mockChunkerWithExts) SupportedExtensions() []string {
|
||||
return m.exts
|
||||
}
|
||||
|
||||
func TestManager_SupportedExtensions(t *testing.T) {
|
||||
|
||||
// Create manager with mock chunkers
|
||||
manager := NewManager([]Chunker{
|
||||
&mockChunkerWithExts{exts: []string{".go"}},
|
||||
&mockChunkerWithExts{exts: []string{".py", ".pyw"}},
|
||||
}, DefaultChunkOptions())
|
||||
|
||||
exts := manager.SupportedExtensions()
|
||||
expectedExts := map[string]bool{
|
||||
".go": false,
|
||||
".py": false,
|
||||
".pyw": false,
|
||||
}
|
||||
|
||||
for _, ext := range exts {
|
||||
if _, ok := expectedExts[ext]; ok {
|
||||
expectedExts[ext] = true
|
||||
} else {
|
||||
t.Errorf("Unexpected extension: %s", ext)
|
||||
}
|
||||
}
|
||||
|
||||
for ext, found := range expectedExts {
|
||||
if !found {
|
||||
t.Errorf("Expected extension %s not found", ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
// Package python provides AST-aware chunking for Python source files using tree-sitter.
|
||||
package python
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
sitter "github.com/smacker/go-tree-sitter"
|
||||
"github.com/smacker/go-tree-sitter/python"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
|
||||
)
|
||||
|
||||
// Chunker implements AST-aware chunking for Python files.
|
||||
type Chunker struct {
|
||||
parser *sitter.Parser
|
||||
options chunking.ChunkOptions
|
||||
}
|
||||
|
||||
// NewChunker creates a new Python chunker.
|
||||
func NewChunker(options chunking.ChunkOptions) *Chunker {
|
||||
parser := sitter.NewParser()
|
||||
parser.SetLanguage(python.GetLanguage())
|
||||
|
||||
return &Chunker{
|
||||
options: options,
|
||||
parser: parser,
|
||||
}
|
||||
}
|
||||
|
||||
// Language returns the language this chunker supports.
|
||||
func (c *Chunker) Language() chunking.Language {
|
||||
return chunking.LanguagePython
|
||||
}
|
||||
|
||||
// SupportedExtensions returns the file extensions this chunker handles.
|
||||
func (c *Chunker) SupportedExtensions() []string {
|
||||
return []string{".py"}
|
||||
}
|
||||
|
||||
// Chunk parses a Python source file and returns semantic code chunks.
|
||||
func (c *Chunker) Chunk(ctx context.Context, filePath string) ([]chunking.Chunk, error) {
|
||||
// Read file content
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file: %w", err)
|
||||
}
|
||||
|
||||
// Parse the Python file
|
||||
tree, err := c.parser.ParseCtx(ctx, nil, content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse Python file: %w", err)
|
||||
}
|
||||
defer tree.Close()
|
||||
|
||||
sourceLines := strings.Split(string(content), "\n")
|
||||
chunks := make([]chunking.Chunk, 0)
|
||||
|
||||
// Walk the AST and extract chunks
|
||||
c.walkNode(tree.RootNode(), content, sourceLines, filePath, "", &chunks)
|
||||
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
// walkNode recursively walks the tree-sitter AST and extracts chunks.
|
||||
func (c *Chunker) walkNode(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string, chunks *[]chunking.Chunk) {
|
||||
nodeType := node.Type()
|
||||
|
||||
switch nodeType {
|
||||
case "function_definition":
|
||||
chunk := c.extractFunction(node, source, sourceLines, filePath, parentName)
|
||||
if chunk != nil {
|
||||
*chunks = append(*chunks, *chunk)
|
||||
}
|
||||
|
||||
case "class_definition":
|
||||
chunk := c.extractClass(node, source, sourceLines, filePath)
|
||||
if chunk != nil {
|
||||
*chunks = append(*chunks, *chunk)
|
||||
|
||||
// Walk class body to find methods
|
||||
for i := 0; i < int(node.ChildCount()); i++ {
|
||||
child := node.Child(i)
|
||||
if child.Type() == "block" {
|
||||
c.walkNode(child, source, sourceLines, filePath, chunk.Name, chunks)
|
||||
}
|
||||
}
|
||||
}
|
||||
return // Don't walk children again
|
||||
|
||||
case "block":
|
||||
// Walk statements in block
|
||||
for i := 0; i < int(node.ChildCount()); i++ {
|
||||
c.walkNode(node.Child(i), source, sourceLines, filePath, parentName, chunks)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Walk all children
|
||||
for i := 0; i < int(node.ChildCount()); i++ {
|
||||
c.walkNode(node.Child(i), source, sourceLines, filePath, parentName, chunks)
|
||||
}
|
||||
}
|
||||
|
||||
// extractFunction extracts a function definition chunk.
|
||||
func (c *Chunker) extractFunction(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string) *chunking.Chunk {
|
||||
// Find function name
|
||||
var nameNode *sitter.Node
|
||||
for i := 0; i < int(node.ChildCount()); i++ {
|
||||
child := node.Child(i)
|
||||
if child.Type() == "identifier" {
|
||||
nameNode = child
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if nameNode == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := nameNode.Content(source)
|
||||
|
||||
// Skip private functions if configured
|
||||
if !c.options.IncludePrivate && strings.HasPrefix(name, "_") && !strings.HasPrefix(name, "__") {
|
||||
return nil
|
||||
}
|
||||
|
||||
startLine := int(node.StartPoint().Row) + 1
|
||||
endLine := int(node.EndPoint().Row) + 1
|
||||
|
||||
chunk := &chunking.Chunk{
|
||||
FilePath: filePath,
|
||||
Language: chunking.LanguagePython,
|
||||
Name: name,
|
||||
ParentName: parentName,
|
||||
StartLine: startLine,
|
||||
EndLine: endLine,
|
||||
Content: c.extractLines(sourceLines, startLine, endLine),
|
||||
}
|
||||
|
||||
// Determine if this is a method or function
|
||||
if parentName != "" {
|
||||
chunk.Type = chunking.ChunkTypeMethod
|
||||
} else {
|
||||
chunk.Type = chunking.ChunkTypeFunction
|
||||
}
|
||||
|
||||
// Extract signature (def line)
|
||||
chunk.Signature = c.extractFunctionSignature(node, source, sourceLines)
|
||||
|
||||
// Extract docstring as doc comment
|
||||
if c.options.IncludeDocComments {
|
||||
chunk.DocComment = c.extractDocstring(node, source)
|
||||
}
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
// extractClass extracts a class definition chunk.
|
||||
func (c *Chunker) extractClass(node *sitter.Node, source []byte, sourceLines []string, filePath string) *chunking.Chunk {
|
||||
// Find class name
|
||||
var nameNode *sitter.Node
|
||||
for i := 0; i < int(node.ChildCount()); i++ {
|
||||
child := node.Child(i)
|
||||
if child.Type() == "identifier" {
|
||||
nameNode = child
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if nameNode == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := nameNode.Content(source)
|
||||
|
||||
// Skip private classes if configured
|
||||
if !c.options.IncludePrivate && strings.HasPrefix(name, "_") && !strings.HasPrefix(name, "__") {
|
||||
return nil
|
||||
}
|
||||
|
||||
startLine := int(node.StartPoint().Row) + 1
|
||||
endLine := int(node.EndPoint().Row) + 1
|
||||
|
||||
chunk := &chunking.Chunk{
|
||||
FilePath: filePath,
|
||||
Language: chunking.LanguagePython,
|
||||
Type: chunking.ChunkTypeClass,
|
||||
Name: name,
|
||||
StartLine: startLine,
|
||||
EndLine: endLine,
|
||||
Content: c.extractLines(sourceLines, startLine, endLine),
|
||||
}
|
||||
|
||||
// Extract class signature (class line)
|
||||
chunk.Signature = c.extractClassSignature(node, source, sourceLines)
|
||||
|
||||
// Extract docstring as doc comment
|
||||
if c.options.IncludeDocComments {
|
||||
chunk.DocComment = c.extractDocstring(node, source)
|
||||
}
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
// extractFunctionSignature extracts the function definition line.
|
||||
func (c *Chunker) extractFunctionSignature(node *sitter.Node, source []byte, sourceLines []string) string {
|
||||
startLine := int(node.StartPoint().Row) + 1
|
||||
|
||||
// Find the colon that ends the signature
|
||||
for i := 0; i < int(node.ChildCount()); i++ {
|
||||
child := node.Child(i)
|
||||
if child.Type() == ":" {
|
||||
endLine := int(child.EndPoint().Row) + 1
|
||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, endLine))
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: just return first line
|
||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, startLine))
|
||||
}
|
||||
|
||||
// extractClassSignature extracts the class definition line.
|
||||
func (c *Chunker) extractClassSignature(node *sitter.Node, source []byte, sourceLines []string) string {
|
||||
startLine := int(node.StartPoint().Row) + 1
|
||||
|
||||
// Find the colon that ends the signature
|
||||
for i := 0; i < int(node.ChildCount()); i++ {
|
||||
child := node.Child(i)
|
||||
if child.Type() == ":" {
|
||||
endLine := int(child.EndPoint().Row) + 1
|
||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, endLine))
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: just return first line
|
||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, startLine))
|
||||
}
|
||||
|
||||
// extractDocstring extracts the docstring from a function or class.
|
||||
func (c *Chunker) extractDocstring(node *sitter.Node, source []byte) string {
|
||||
// Find the block
|
||||
var blockNode *sitter.Node
|
||||
for i := 0; i < int(node.ChildCount()); i++ {
|
||||
child := node.Child(i)
|
||||
if child.Type() == "block" {
|
||||
blockNode = child
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if blockNode == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check if first statement in block is a string (docstring)
|
||||
for i := 0; i < int(blockNode.ChildCount()); i++ {
|
||||
child := blockNode.Child(i)
|
||||
if child.Type() == "expression_statement" {
|
||||
// Check if it contains a string
|
||||
for j := 0; j < int(child.ChildCount()); j++ {
|
||||
grandchild := child.Child(j)
|
||||
if grandchild.Type() == "string" {
|
||||
docstring := grandchild.Content(source)
|
||||
// Remove quotes
|
||||
docstring = strings.Trim(docstring, `"'`)
|
||||
return strings.TrimSpace(docstring)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractLines extracts a range of lines from source (1-indexed, inclusive).
|
||||
func (c *Chunker) extractLines(lines []string, start, end int) string {
|
||||
if start < 1 || end < start || start > len(lines) {
|
||||
return ""
|
||||
}
|
||||
|
||||
startIdx := start - 1
|
||||
endIdx := end
|
||||
if endIdx > len(lines) {
|
||||
endIdx = len(lines)
|
||||
}
|
||||
|
||||
return strings.Join(lines[startIdx:endIdx], "\n")
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
package python
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// TEST HELPERS
|
||||
// =============================================================================
|
||||
|
||||
func createTempPythonFile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
filePath := filepath.Join(tmpDir, "test.py")
|
||||
|
||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
return filePath
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TESTS FOR Chunker
|
||||
// =============================================================================
|
||||
|
||||
func TestNewChunker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := chunking.DefaultChunkOptions()
|
||||
c := NewChunker(opts)
|
||||
|
||||
assert.NotNil(t, c)
|
||||
assert.NotNil(t, c.parser)
|
||||
}
|
||||
|
||||
func TestChunker_Language(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
assert.Equal(t, chunking.LanguagePython, c.Language())
|
||||
}
|
||||
|
||||
func TestChunker_SupportedExtensions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
exts := c.SupportedExtensions()
|
||||
|
||||
assert.Contains(t, exts, ".py")
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_SimpleFunction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := `def greet(name):
|
||||
"""Greets a person by name."""
|
||||
return f"Hello, {name}!"
|
||||
`
|
||||
|
||||
filePath := createTempPythonFile(t, code)
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, chunks)
|
||||
|
||||
// Should find the greet function
|
||||
var foundGreet bool
|
||||
for _, chunk := range chunks {
|
||||
if chunk.Name == "greet" {
|
||||
foundGreet = true
|
||||
assert.Equal(t, chunking.ChunkTypeFunction, chunk.Type)
|
||||
assert.Equal(t, chunking.LanguagePython, chunk.Language)
|
||||
assert.Contains(t, chunk.Content, "def greet")
|
||||
}
|
||||
}
|
||||
assert.True(t, foundGreet, "Should find 'greet' function")
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_ClassWithMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := `class Calculator:
|
||||
"""A simple calculator class."""
|
||||
|
||||
def add(self, a, b):
|
||||
"""Adds two numbers."""
|
||||
return a + b
|
||||
|
||||
def multiply(self, a, b):
|
||||
"""Multiplies two numbers."""
|
||||
return a * b
|
||||
`
|
||||
|
||||
filePath := createTempPythonFile(t, code)
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, chunks)
|
||||
|
||||
// Should find the Calculator class and its methods
|
||||
var foundClass, foundAdd, foundMultiply bool
|
||||
for _, chunk := range chunks {
|
||||
switch chunk.Name {
|
||||
case "Calculator":
|
||||
foundClass = true
|
||||
assert.Equal(t, chunking.ChunkTypeClass, chunk.Type)
|
||||
case "add":
|
||||
foundAdd = true
|
||||
assert.Equal(t, chunking.ChunkTypeMethod, chunk.Type)
|
||||
assert.Equal(t, "Calculator", chunk.ParentName)
|
||||
case "multiply":
|
||||
foundMultiply = true
|
||||
assert.Equal(t, chunking.ChunkTypeMethod, chunk.Type)
|
||||
assert.Equal(t, "Calculator", chunk.ParentName)
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, foundClass, "Should find 'Calculator' class")
|
||||
assert.True(t, foundAdd, "Should find 'add' method")
|
||||
assert.True(t, foundMultiply, "Should find 'multiply' method")
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_MultipleFunctions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := `def first_function():
|
||||
pass
|
||||
|
||||
def second_function(x, y):
|
||||
return x + y
|
||||
|
||||
def third_function():
|
||||
"""Has a docstring."""
|
||||
return 42
|
||||
`
|
||||
|
||||
filePath := createTempPythonFile(t, code)
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should find all three functions
|
||||
functionNames := make(map[string]bool)
|
||||
for _, chunk := range chunks {
|
||||
if chunk.Type == chunking.ChunkTypeFunction {
|
||||
functionNames[chunk.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, functionNames["first_function"])
|
||||
assert.True(t, functionNames["second_function"])
|
||||
assert.True(t, functionNames["third_function"])
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_FileNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
_, err := c.Chunk(context.Background(), "/nonexistent/path/file.py")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "read file")
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_EmptyFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filePath := createTempPythonFile(t, "")
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, chunks)
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_OnlyComments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := `# This is a comment
|
||||
# Another comment
|
||||
"""
|
||||
This is a module docstring
|
||||
"""
|
||||
`
|
||||
|
||||
filePath := createTempPythonFile(t, code)
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
// Comments and docstrings without code should not produce chunks
|
||||
assert.Empty(t, chunks)
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_NestedClass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := `class Outer:
|
||||
class Inner:
|
||||
def inner_method(self):
|
||||
pass
|
||||
|
||||
def outer_method(self):
|
||||
pass
|
||||
`
|
||||
|
||||
filePath := createTempPythonFile(t, code)
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, chunks)
|
||||
|
||||
// Should find the Outer class at minimum
|
||||
var foundOuter bool
|
||||
for _, chunk := range chunks {
|
||||
if chunk.Name == "Outer" {
|
||||
foundOuter = true
|
||||
}
|
||||
}
|
||||
assert.True(t, foundOuter, "Should find 'Outer' class")
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_Decorators(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := `@staticmethod
|
||||
def static_func():
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def class_func(cls):
|
||||
pass
|
||||
|
||||
@property
|
||||
def my_property(self):
|
||||
return self._value
|
||||
`
|
||||
|
||||
filePath := createTempPythonFile(t, code)
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, chunks)
|
||||
|
||||
// Should find decorated functions
|
||||
functionNames := make(map[string]bool)
|
||||
for _, chunk := range chunks {
|
||||
functionNames[chunk.Name] = true
|
||||
}
|
||||
|
||||
assert.True(t, functionNames["static_func"])
|
||||
assert.True(t, functionNames["class_func"])
|
||||
assert.True(t, functionNames["my_property"])
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_AsyncFunction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := `async def fetch_data(url):
|
||||
"""Fetches data from URL asynchronously."""
|
||||
pass
|
||||
|
||||
async def process_items(items):
|
||||
for item in items:
|
||||
await process(item)
|
||||
`
|
||||
|
||||
filePath := createTempPythonFile(t, code)
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, chunks)
|
||||
|
||||
// Should find async functions
|
||||
functionNames := make(map[string]bool)
|
||||
for _, chunk := range chunks {
|
||||
functionNames[chunk.Name] = true
|
||||
}
|
||||
|
||||
assert.True(t, functionNames["fetch_data"])
|
||||
assert.True(t, functionNames["process_items"])
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
// Package chunking provides AST-aware code chunking for semantic code search.
|
||||
// Chunks code files into logical units (functions, classes, methods) that preserve
|
||||
// semantic boundaries for better vector embedding and retrieval.
|
||||
package chunking
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ChunkType represents the type of code chunk.
|
||||
type ChunkType string
|
||||
|
||||
const (
|
||||
// ChunkTypeFunction represents a standalone function.
|
||||
ChunkTypeFunction ChunkType = "function"
|
||||
// ChunkTypeMethod represents a method on a class/struct/type.
|
||||
ChunkTypeMethod ChunkType = "method"
|
||||
// ChunkTypeClass represents a class or struct definition.
|
||||
ChunkTypeClass ChunkType = "class"
|
||||
// ChunkTypeInterface represents an interface definition.
|
||||
ChunkTypeInterface ChunkType = "interface"
|
||||
// ChunkTypeType represents a type alias or type definition.
|
||||
ChunkTypeType ChunkType = "type"
|
||||
// ChunkTypeConst represents constant declarations.
|
||||
ChunkTypeConst ChunkType = "const"
|
||||
// ChunkTypeVar represents variable declarations.
|
||||
ChunkTypeVar ChunkType = "var"
|
||||
)
|
||||
|
||||
// Language represents a programming language.
|
||||
type Language string
|
||||
|
||||
const (
|
||||
// LanguageGo represents the Go programming language.
|
||||
LanguageGo Language = "go"
|
||||
// LanguagePython represents the Python programming language.
|
||||
LanguagePython Language = "python"
|
||||
// LanguageTypeScript represents the TypeScript programming language.
|
||||
LanguageTypeScript Language = "typescript"
|
||||
// LanguageJavaScript represents the JavaScript programming language.
|
||||
LanguageJavaScript Language = "javascript"
|
||||
)
|
||||
|
||||
// Chunk represents a semantic code chunk with AST-derived boundaries.
|
||||
type Chunk struct {
|
||||
Metadata map[string]interface{}
|
||||
FilePath string
|
||||
Language Language
|
||||
Type ChunkType
|
||||
Name string
|
||||
ParentName string
|
||||
Content string
|
||||
Signature string
|
||||
DocComment string
|
||||
StartLine int
|
||||
EndLine int
|
||||
}
|
||||
|
||||
// Identifier returns a human-readable identifier for this chunk.
|
||||
// Format: "ParentName.Name" for methods, "Name" for top-level.
|
||||
func (c *Chunk) Identifier() string {
|
||||
if c.ParentName != "" {
|
||||
return fmt.Sprintf("%s.%s", c.ParentName, c.Name)
|
||||
}
|
||||
return c.Name
|
||||
}
|
||||
|
||||
// LineRange returns a human-readable line range.
|
||||
// Format: "L123-L456"
|
||||
func (c *Chunk) LineRange() string {
|
||||
return fmt.Sprintf("L%d-L%d", c.StartLine, c.EndLine)
|
||||
}
|
||||
|
||||
// SearchableContent returns content optimized for semantic search.
|
||||
// Combines signature, doc comment, and content in a structured format.
|
||||
func (c *Chunk) SearchableContent() string {
|
||||
var parts []string
|
||||
|
||||
// Include signature for functions/methods
|
||||
if c.Signature != "" {
|
||||
parts = append(parts, c.Signature)
|
||||
}
|
||||
|
||||
// Include doc comment
|
||||
if c.DocComment != "" {
|
||||
parts = append(parts, c.DocComment)
|
||||
}
|
||||
|
||||
// Include actual content
|
||||
if c.Content != "" {
|
||||
parts = append(parts, c.Content)
|
||||
}
|
||||
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
// Chunker is the interface for language-specific code chunkers.
|
||||
type Chunker interface {
|
||||
// Chunk parses a source file and returns semantic code chunks.
|
||||
// Returns an error if the file cannot be parsed or read.
|
||||
Chunk(ctx context.Context, filePath string) ([]Chunk, error)
|
||||
|
||||
// Language returns the language this chunker supports.
|
||||
Language() Language
|
||||
|
||||
// SupportedExtensions returns file extensions this chunker handles.
|
||||
// Example: []string{".go"} for Go chunker
|
||||
SupportedExtensions() []string
|
||||
}
|
||||
|
||||
// ChunkOptions provides options for chunking behavior.
|
||||
type ChunkOptions struct {
|
||||
// MaxChunkSize is the maximum size of a chunk in bytes.
|
||||
// Chunks larger than this will be split (respecting boundaries where possible).
|
||||
// 0 means no limit.
|
||||
MaxChunkSize int
|
||||
|
||||
// IncludeDocComments controls whether to include documentation comments.
|
||||
IncludeDocComments bool
|
||||
|
||||
// IncludePrivate controls whether to include private/unexported symbols.
|
||||
IncludePrivate bool
|
||||
|
||||
// MinLines is the minimum number of lines for a chunk to be included.
|
||||
// Chunks smaller than this will be skipped.
|
||||
// 0 means no minimum.
|
||||
MinLines int
|
||||
}
|
||||
|
||||
// DefaultChunkOptions returns sensible default options.
|
||||
func DefaultChunkOptions() ChunkOptions {
|
||||
return ChunkOptions{
|
||||
MaxChunkSize: 8192, // ~8KB per chunk (well under token limit)
|
||||
IncludeDocComments: true,
|
||||
IncludePrivate: true, // Include all symbols for comprehensive search
|
||||
MinLines: 0, // No minimum - include even single-line functions
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package chunking
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// TESTS FOR Chunk METHODS
|
||||
// =============================================================================
|
||||
|
||||
func TestChunk_Identifier(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expected string
|
||||
chunk Chunk
|
||||
}{
|
||||
// ===== GOOD CASES =====
|
||||
{
|
||||
name: "top-level function",
|
||||
chunk: Chunk{
|
||||
Name: "MyFunction",
|
||||
ParentName: "",
|
||||
},
|
||||
expected: "MyFunction",
|
||||
},
|
||||
{
|
||||
name: "method with parent",
|
||||
chunk: Chunk{
|
||||
Name: "Process",
|
||||
ParentName: "Handler",
|
||||
},
|
||||
expected: "Handler.Process",
|
||||
},
|
||||
{
|
||||
name: "nested method",
|
||||
chunk: Chunk{
|
||||
Name: "Validate",
|
||||
ParentName: "UserService",
|
||||
},
|
||||
expected: "UserService.Validate",
|
||||
},
|
||||
|
||||
// ===== EDGE CASES =====
|
||||
{
|
||||
name: "empty name",
|
||||
chunk: Chunk{
|
||||
Name: "",
|
||||
ParentName: "",
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "parent but no name",
|
||||
chunk: Chunk{
|
||||
Name: "",
|
||||
ParentName: "Parent",
|
||||
},
|
||||
expected: "Parent.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.chunk.Identifier()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunk_LineRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expected string
|
||||
chunk Chunk
|
||||
}{
|
||||
// ===== GOOD CASES =====
|
||||
{
|
||||
name: "single line",
|
||||
chunk: Chunk{
|
||||
StartLine: 10,
|
||||
EndLine: 10,
|
||||
},
|
||||
expected: "L10-L10",
|
||||
},
|
||||
{
|
||||
name: "multi-line",
|
||||
chunk: Chunk{
|
||||
StartLine: 25,
|
||||
EndLine: 50,
|
||||
},
|
||||
expected: "L25-L50",
|
||||
},
|
||||
|
||||
// ===== EDGE CASES =====
|
||||
{
|
||||
name: "line 1",
|
||||
chunk: Chunk{
|
||||
StartLine: 1,
|
||||
EndLine: 5,
|
||||
},
|
||||
expected: "L1-L5",
|
||||
},
|
||||
{
|
||||
name: "large line numbers",
|
||||
chunk: Chunk{
|
||||
StartLine: 1000,
|
||||
EndLine: 2500,
|
||||
},
|
||||
expected: "L1000-L2500",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.chunk.LineRange()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunk_SearchableContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
contains []string
|
||||
chunk Chunk
|
||||
}{
|
||||
// ===== GOOD CASES =====
|
||||
{
|
||||
name: "full chunk with all fields",
|
||||
chunk: Chunk{
|
||||
Signature: "func ProcessData(input []byte) error",
|
||||
DocComment: "// ProcessData handles incoming data",
|
||||
Content: "func ProcessData(input []byte) error {\n\treturn nil\n}",
|
||||
},
|
||||
contains: []string{
|
||||
"func ProcessData(input []byte) error",
|
||||
"ProcessData handles incoming data",
|
||||
"return nil",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "only signature",
|
||||
chunk: Chunk{
|
||||
Signature: "func Hello()",
|
||||
},
|
||||
contains: []string{"func Hello()"},
|
||||
},
|
||||
{
|
||||
name: "only content",
|
||||
chunk: Chunk{
|
||||
Content: "some code here",
|
||||
},
|
||||
contains: []string{"some code here"},
|
||||
},
|
||||
|
||||
// ===== EDGE CASES =====
|
||||
{
|
||||
name: "empty chunk",
|
||||
chunk: Chunk{},
|
||||
contains: []string{},
|
||||
},
|
||||
{
|
||||
name: "only doc comment",
|
||||
chunk: Chunk{
|
||||
DocComment: "// Important documentation",
|
||||
},
|
||||
contains: []string{"Important documentation"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.chunk.SearchableContent()
|
||||
for _, expected := range tt.contains {
|
||||
assert.Contains(t, result, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultChunkOptions(t *testing.T) {
|
||||
opts := DefaultChunkOptions()
|
||||
|
||||
assert.Greater(t, opts.MaxChunkSize, 0, "MaxChunkSize should be positive")
|
||||
assert.True(t, opts.IncludeDocComments, "IncludeDocComments should be true by default")
|
||||
assert.True(t, opts.IncludePrivate, "IncludePrivate should be true by default")
|
||||
assert.Equal(t, 0, opts.MinLines, "MinLines should be 0 by default")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TESTS FOR ChunkType AND Language CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
func TestChunkType_Values(t *testing.T) {
|
||||
// Ensure all chunk types have expected values
|
||||
assert.Equal(t, ChunkType("function"), ChunkTypeFunction)
|
||||
assert.Equal(t, ChunkType("method"), ChunkTypeMethod)
|
||||
assert.Equal(t, ChunkType("class"), ChunkTypeClass)
|
||||
assert.Equal(t, ChunkType("interface"), ChunkTypeInterface)
|
||||
assert.Equal(t, ChunkType("type"), ChunkTypeType)
|
||||
assert.Equal(t, ChunkType("const"), ChunkTypeConst)
|
||||
assert.Equal(t, ChunkType("var"), ChunkTypeVar)
|
||||
}
|
||||
|
||||
func TestLanguage_Values(t *testing.T) {
|
||||
// Ensure all language types have expected values
|
||||
assert.Equal(t, Language("go"), LanguageGo)
|
||||
assert.Equal(t, Language("python"), LanguagePython)
|
||||
assert.Equal(t, Language("typescript"), LanguageTypeScript)
|
||||
assert.Equal(t, Language("javascript"), LanguageJavaScript)
|
||||
}
|
||||
@@ -1,403 +0,0 @@
|
||||
// Package typescript provides AST-aware chunking for TypeScript and JavaScript source files using tree-sitter.
|
||||
package typescript
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
sitter "github.com/smacker/go-tree-sitter"
|
||||
"github.com/smacker/go-tree-sitter/typescript/typescript"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
|
||||
)
|
||||
|
||||
// Chunker implements AST-aware chunking for TypeScript/JavaScript files.
|
||||
type Chunker struct {
|
||||
parser *sitter.Parser
|
||||
options chunking.ChunkOptions
|
||||
}
|
||||
|
||||
// NewChunker creates a new TypeScript chunker.
|
||||
func NewChunker(options chunking.ChunkOptions) *Chunker {
|
||||
parser := sitter.NewParser()
|
||||
parser.SetLanguage(typescript.GetLanguage())
|
||||
|
||||
return &Chunker{
|
||||
options: options,
|
||||
parser: parser,
|
||||
}
|
||||
}
|
||||
|
||||
// Language returns the language this chunker supports.
|
||||
func (c *Chunker) Language() chunking.Language {
|
||||
return chunking.LanguageTypeScript
|
||||
}
|
||||
|
||||
// SupportedExtensions returns the file extensions this chunker handles.
|
||||
func (c *Chunker) SupportedExtensions() []string {
|
||||
return []string{".ts", ".tsx", ".js", ".jsx"}
|
||||
}
|
||||
|
||||
// Chunk parses a TypeScript/JavaScript source file and returns semantic code chunks.
|
||||
func (c *Chunker) Chunk(ctx context.Context, filePath string) ([]chunking.Chunk, error) {
|
||||
// Read file content
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file: %w", err)
|
||||
}
|
||||
|
||||
// Parse the file
|
||||
tree, err := c.parser.ParseCtx(ctx, nil, content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse TypeScript file: %w", err)
|
||||
}
|
||||
defer tree.Close()
|
||||
|
||||
sourceLines := strings.Split(string(content), "\n")
|
||||
chunks := make([]chunking.Chunk, 0)
|
||||
|
||||
// Walk the AST and extract chunks
|
||||
c.walkNode(tree.RootNode(), content, sourceLines, filePath, "", &chunks)
|
||||
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
// walkNode recursively walks the tree-sitter AST and extracts chunks.
|
||||
func (c *Chunker) walkNode(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string, chunks *[]chunking.Chunk) {
|
||||
nodeType := node.Type()
|
||||
|
||||
switch nodeType {
|
||||
case "function_declaration":
|
||||
chunk := c.extractFunction(node, source, sourceLines, filePath, parentName)
|
||||
if chunk != nil {
|
||||
*chunks = append(*chunks, *chunk)
|
||||
}
|
||||
|
||||
case "method_definition":
|
||||
chunk := c.extractMethod(node, source, sourceLines, filePath, parentName)
|
||||
if chunk != nil {
|
||||
*chunks = append(*chunks, *chunk)
|
||||
}
|
||||
|
||||
case "arrow_function", "function_expression":
|
||||
// Handle arrow functions and function expressions assigned to variables
|
||||
chunk := c.extractFunctionExpression(node, source, sourceLines, filePath, parentName)
|
||||
if chunk != nil {
|
||||
*chunks = append(*chunks, *chunk)
|
||||
}
|
||||
|
||||
case "class_declaration":
|
||||
chunk := c.extractClass(node, source, sourceLines, filePath)
|
||||
if chunk != nil {
|
||||
*chunks = append(*chunks, *chunk)
|
||||
|
||||
// Walk class body to find methods
|
||||
for i := 0; i < int(node.ChildCount()); i++ {
|
||||
child := node.Child(i)
|
||||
if child.Type() == "class_body" {
|
||||
c.walkNode(child, source, sourceLines, filePath, chunk.Name, chunks)
|
||||
}
|
||||
}
|
||||
}
|
||||
return // Don't walk children again
|
||||
|
||||
case "interface_declaration":
|
||||
chunk := c.extractInterface(node, source, sourceLines, filePath)
|
||||
if chunk != nil {
|
||||
*chunks = append(*chunks, *chunk)
|
||||
}
|
||||
|
||||
case "type_alias_declaration":
|
||||
chunk := c.extractTypeAlias(node, source, sourceLines, filePath)
|
||||
if chunk != nil {
|
||||
*chunks = append(*chunks, *chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// Walk all children
|
||||
for i := 0; i < int(node.ChildCount()); i++ {
|
||||
c.walkNode(node.Child(i), source, sourceLines, filePath, parentName, chunks)
|
||||
}
|
||||
}
|
||||
|
||||
// extractFunction extracts a function declaration.
|
||||
func (c *Chunker) extractFunction(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string) *chunking.Chunk {
|
||||
name := c.findChildContent(node, "identifier", source)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
startLine := int(node.StartPoint().Row) + 1
|
||||
endLine := int(node.EndPoint().Row) + 1
|
||||
|
||||
chunk := &chunking.Chunk{
|
||||
FilePath: filePath,
|
||||
Language: chunking.LanguageTypeScript,
|
||||
Type: chunking.ChunkTypeFunction,
|
||||
Name: name,
|
||||
ParentName: parentName,
|
||||
StartLine: startLine,
|
||||
EndLine: endLine,
|
||||
Content: c.extractLines(sourceLines, startLine, endLine),
|
||||
Signature: c.extractFunctionSignature(node, source, sourceLines),
|
||||
}
|
||||
|
||||
// Extract JSDoc comment
|
||||
if c.options.IncludeDocComments {
|
||||
chunk.DocComment = c.extractComment(node, source)
|
||||
}
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
// extractMethod extracts a method definition from a class.
|
||||
func (c *Chunker) extractMethod(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string) *chunking.Chunk {
|
||||
name := c.findChildContent(node, "property_identifier", source)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip private methods if configured
|
||||
if !c.options.IncludePrivate && strings.HasPrefix(name, "_") {
|
||||
return nil
|
||||
}
|
||||
|
||||
startLine := int(node.StartPoint().Row) + 1
|
||||
endLine := int(node.EndPoint().Row) + 1
|
||||
|
||||
chunk := &chunking.Chunk{
|
||||
FilePath: filePath,
|
||||
Language: chunking.LanguageTypeScript,
|
||||
Type: chunking.ChunkTypeMethod,
|
||||
Name: name,
|
||||
ParentName: parentName,
|
||||
StartLine: startLine,
|
||||
EndLine: endLine,
|
||||
Content: c.extractLines(sourceLines, startLine, endLine),
|
||||
Signature: c.extractMethodSignature(node, source, sourceLines),
|
||||
}
|
||||
|
||||
// Extract JSDoc comment
|
||||
if c.options.IncludeDocComments {
|
||||
chunk.DocComment = c.extractComment(node, source)
|
||||
}
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
// extractFunctionExpression extracts arrow functions and function expressions.
|
||||
func (c *Chunker) extractFunctionExpression(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string) *chunking.Chunk {
|
||||
// Try to find the variable name from parent
|
||||
parent := node.Parent()
|
||||
if parent == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var name string
|
||||
if parent.Type() == "variable_declarator" {
|
||||
name = c.findChildContent(parent, "identifier", source)
|
||||
} else if parent.Type() == "assignment_expression" {
|
||||
// Handle const foo = () => {}
|
||||
for i := 0; i < int(parent.ChildCount()); i++ {
|
||||
child := parent.Child(i)
|
||||
if child.Type() == "identifier" || child.Type() == "member_expression" {
|
||||
name = child.Content(source)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
return nil // Anonymous function, skip
|
||||
}
|
||||
|
||||
startLine := int(node.StartPoint().Row) + 1
|
||||
endLine := int(node.EndPoint().Row) + 1
|
||||
|
||||
chunk := &chunking.Chunk{
|
||||
FilePath: filePath,
|
||||
Language: chunking.LanguageTypeScript,
|
||||
Type: chunking.ChunkTypeFunction,
|
||||
Name: name,
|
||||
ParentName: parentName,
|
||||
StartLine: startLine,
|
||||
EndLine: endLine,
|
||||
Content: c.extractLines(sourceLines, startLine, endLine),
|
||||
}
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
// extractClass extracts a class declaration.
|
||||
func (c *Chunker) extractClass(node *sitter.Node, source []byte, sourceLines []string, filePath string) *chunking.Chunk {
|
||||
name := c.findChildContent(node, "type_identifier", source)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
startLine := int(node.StartPoint().Row) + 1
|
||||
endLine := int(node.EndPoint().Row) + 1
|
||||
|
||||
chunk := &chunking.Chunk{
|
||||
FilePath: filePath,
|
||||
Language: chunking.LanguageTypeScript,
|
||||
Type: chunking.ChunkTypeClass,
|
||||
Name: name,
|
||||
StartLine: startLine,
|
||||
EndLine: endLine,
|
||||
Content: c.extractLines(sourceLines, startLine, endLine),
|
||||
Signature: c.extractClassSignature(node, source, sourceLines),
|
||||
}
|
||||
|
||||
// Extract JSDoc comment
|
||||
if c.options.IncludeDocComments {
|
||||
chunk.DocComment = c.extractComment(node, source)
|
||||
}
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
// extractInterface extracts an interface declaration.
|
||||
func (c *Chunker) extractInterface(node *sitter.Node, source []byte, sourceLines []string, filePath string) *chunking.Chunk {
|
||||
name := c.findChildContent(node, "type_identifier", source)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
startLine := int(node.StartPoint().Row) + 1
|
||||
endLine := int(node.EndPoint().Row) + 1
|
||||
|
||||
chunk := &chunking.Chunk{
|
||||
FilePath: filePath,
|
||||
Language: chunking.LanguageTypeScript,
|
||||
Type: chunking.ChunkTypeInterface,
|
||||
Name: name,
|
||||
StartLine: startLine,
|
||||
EndLine: endLine,
|
||||
Content: c.extractLines(sourceLines, startLine, endLine),
|
||||
}
|
||||
|
||||
// Extract JSDoc comment
|
||||
if c.options.IncludeDocComments {
|
||||
chunk.DocComment = c.extractComment(node, source)
|
||||
}
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
// extractTypeAlias extracts a type alias declaration.
|
||||
func (c *Chunker) extractTypeAlias(node *sitter.Node, source []byte, sourceLines []string, filePath string) *chunking.Chunk {
|
||||
name := c.findChildContent(node, "type_identifier", source)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
startLine := int(node.StartPoint().Row) + 1
|
||||
endLine := int(node.EndPoint().Row) + 1
|
||||
|
||||
chunk := &chunking.Chunk{
|
||||
FilePath: filePath,
|
||||
Language: chunking.LanguageTypeScript,
|
||||
Type: chunking.ChunkTypeType,
|
||||
Name: name,
|
||||
StartLine: startLine,
|
||||
EndLine: endLine,
|
||||
Content: c.extractLines(sourceLines, startLine, endLine),
|
||||
}
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
// findChildContent finds the first child of the given type and returns its content.
|
||||
func (c *Chunker) findChildContent(node *sitter.Node, childType string, source []byte) string {
|
||||
for i := 0; i < int(node.ChildCount()); i++ {
|
||||
child := node.Child(i)
|
||||
if child.Type() == childType {
|
||||
return child.Content(source)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractFunctionSignature extracts the function signature.
|
||||
func (c *Chunker) extractFunctionSignature(node *sitter.Node, source []byte, sourceLines []string) string {
|
||||
startLine := int(node.StartPoint().Row) + 1
|
||||
|
||||
// Find the opening brace of the body
|
||||
for i := 0; i < int(node.ChildCount()); i++ {
|
||||
child := node.Child(i)
|
||||
if child.Type() == "statement_block" {
|
||||
endLine := int(child.StartPoint().Row) + 1
|
||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, endLine-1))
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: just return first line
|
||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, startLine))
|
||||
}
|
||||
|
||||
// extractMethodSignature extracts the method signature.
|
||||
func (c *Chunker) extractMethodSignature(node *sitter.Node, source []byte, sourceLines []string) string {
|
||||
startLine := int(node.StartPoint().Row) + 1
|
||||
|
||||
// Find the opening brace of the body
|
||||
for i := 0; i < int(node.ChildCount()); i++ {
|
||||
child := node.Child(i)
|
||||
if child.Type() == "statement_block" {
|
||||
endLine := int(child.StartPoint().Row) + 1
|
||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, endLine-1))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, startLine))
|
||||
}
|
||||
|
||||
// extractClassSignature extracts the class declaration line.
|
||||
func (c *Chunker) extractClassSignature(node *sitter.Node, source []byte, sourceLines []string) string {
|
||||
startLine := int(node.StartPoint().Row) + 1
|
||||
|
||||
// Find the opening brace of the class body
|
||||
for i := 0; i < int(node.ChildCount()); i++ {
|
||||
child := node.Child(i)
|
||||
if child.Type() == "class_body" {
|
||||
endLine := int(child.StartPoint().Row) + 1
|
||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, endLine-1))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(c.extractLines(sourceLines, startLine, startLine))
|
||||
}
|
||||
|
||||
// extractComment extracts JSDoc or other comments from a node.
|
||||
func (c *Chunker) extractComment(node *sitter.Node, source []byte) string {
|
||||
// Check previous sibling for comment
|
||||
prevSibling := node.PrevSibling()
|
||||
if prevSibling != nil && prevSibling.Type() == "comment" {
|
||||
comment := prevSibling.Content(source)
|
||||
// Remove comment markers
|
||||
comment = strings.TrimPrefix(comment, "/**")
|
||||
comment = strings.TrimPrefix(comment, "/*")
|
||||
comment = strings.TrimSuffix(comment, "*/")
|
||||
comment = strings.TrimPrefix(comment, "//")
|
||||
return strings.TrimSpace(comment)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractLines extracts a range of lines from source (1-indexed, inclusive).
|
||||
func (c *Chunker) extractLines(lines []string, start, end int) string {
|
||||
if start < 1 || end < start || start > len(lines) {
|
||||
return ""
|
||||
}
|
||||
|
||||
startIdx := start - 1
|
||||
endIdx := end
|
||||
if endIdx > len(lines) {
|
||||
endIdx = len(lines)
|
||||
}
|
||||
|
||||
return strings.Join(lines[startIdx:endIdx], "\n")
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
package typescript
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// TEST HELPERS
|
||||
// =============================================================================
|
||||
|
||||
func createTempTSFile(t *testing.T, content string, ext string) string {
|
||||
t.Helper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
filePath := filepath.Join(tmpDir, "test"+ext)
|
||||
|
||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
return filePath
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TESTS FOR Chunker
|
||||
// =============================================================================
|
||||
|
||||
func TestNewChunker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := chunking.DefaultChunkOptions()
|
||||
c := NewChunker(opts)
|
||||
|
||||
assert.NotNil(t, c)
|
||||
assert.NotNil(t, c.parser)
|
||||
}
|
||||
|
||||
func TestChunker_Language(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
assert.Equal(t, chunking.LanguageTypeScript, c.Language())
|
||||
}
|
||||
|
||||
func TestChunker_SupportedExtensions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
exts := c.SupportedExtensions()
|
||||
|
||||
assert.Contains(t, exts, ".ts")
|
||||
assert.Contains(t, exts, ".tsx")
|
||||
assert.Contains(t, exts, ".js")
|
||||
assert.Contains(t, exts, ".jsx")
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_SimpleFunction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := `function greet(name: string): string {
|
||||
return "Hello, " + name + "!";
|
||||
}
|
||||
`
|
||||
|
||||
filePath := createTempTSFile(t, code, ".ts")
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, chunks)
|
||||
|
||||
// Should find the greet function
|
||||
var foundGreet bool
|
||||
for _, chunk := range chunks {
|
||||
if chunk.Name == "greet" {
|
||||
foundGreet = true
|
||||
assert.Equal(t, chunking.ChunkTypeFunction, chunk.Type)
|
||||
assert.Equal(t, chunking.LanguageTypeScript, chunk.Language)
|
||||
assert.Contains(t, chunk.Content, "function greet")
|
||||
}
|
||||
}
|
||||
assert.True(t, foundGreet, "Should find 'greet' function")
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_ClassWithMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := `class Calculator {
|
||||
add(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
multiply(a: number, b: number): number {
|
||||
return a * b;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
filePath := createTempTSFile(t, code, ".ts")
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, chunks)
|
||||
|
||||
// Should find the Calculator class and its methods
|
||||
var foundClass, foundAdd, foundMultiply bool
|
||||
for _, chunk := range chunks {
|
||||
switch chunk.Name {
|
||||
case "Calculator":
|
||||
foundClass = true
|
||||
assert.Equal(t, chunking.ChunkTypeClass, chunk.Type)
|
||||
case "add":
|
||||
foundAdd = true
|
||||
assert.Equal(t, chunking.ChunkTypeMethod, chunk.Type)
|
||||
assert.Equal(t, "Calculator", chunk.ParentName)
|
||||
case "multiply":
|
||||
foundMultiply = true
|
||||
assert.Equal(t, chunking.ChunkTypeMethod, chunk.Type)
|
||||
assert.Equal(t, "Calculator", chunk.ParentName)
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, foundClass, "Should find 'Calculator' class")
|
||||
assert.True(t, foundAdd, "Should find 'add' method")
|
||||
assert.True(t, foundMultiply, "Should find 'multiply' method")
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_Interface(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := `interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Authenticator {
|
||||
login(username: string, password: string): boolean;
|
||||
logout(): void;
|
||||
}
|
||||
`
|
||||
|
||||
filePath := createTempTSFile(t, code, ".ts")
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, chunks)
|
||||
|
||||
// Should find interfaces
|
||||
interfaceNames := make(map[string]bool)
|
||||
for _, chunk := range chunks {
|
||||
if chunk.Type == chunking.ChunkTypeInterface {
|
||||
interfaceNames[chunk.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, interfaceNames["User"])
|
||||
assert.True(t, interfaceNames["Authenticator"])
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_TypeAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := `type UserID = string;
|
||||
|
||||
type Handler = (event: Event) => void;
|
||||
|
||||
type Result<T> = { success: true; data: T } | { success: false; error: Error };
|
||||
`
|
||||
|
||||
filePath := createTempTSFile(t, code, ".ts")
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, chunks)
|
||||
|
||||
// Should find type aliases
|
||||
typeNames := make(map[string]bool)
|
||||
for _, chunk := range chunks {
|
||||
if chunk.Type == chunking.ChunkTypeType {
|
||||
typeNames[chunk.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, typeNames["UserID"])
|
||||
assert.True(t, typeNames["Handler"])
|
||||
assert.True(t, typeNames["Result"])
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_ArrowFunction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := `const add = (a: number, b: number): number => a + b;
|
||||
|
||||
const greet = (name: string): string => {
|
||||
return "Hello, " + name;
|
||||
};
|
||||
`
|
||||
|
||||
filePath := createTempTSFile(t, code, ".ts")
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
_, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
// Arrow functions may or may not be captured depending on AST structure
|
||||
// At minimum, no error should occur
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_FileNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
_, err := c.Chunk(context.Background(), "/nonexistent/path/file.ts")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "read file")
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_EmptyFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filePath := createTempTSFile(t, "", ".ts")
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, chunks)
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_OnlyComments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := `// This is a comment
|
||||
/* Another comment */
|
||||
/**
|
||||
* JSDoc comment
|
||||
*/
|
||||
`
|
||||
|
||||
filePath := createTempTSFile(t, code, ".ts")
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
// Comments without code should not produce chunks
|
||||
assert.Empty(t, chunks)
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_AsyncFunction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := `async function fetchData(url: string): Promise<any> {
|
||||
const response = await fetch(url);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function processItems(items: string[]): Promise<void> {
|
||||
for (const item of items) {
|
||||
await process(item);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
filePath := createTempTSFile(t, code, ".ts")
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, chunks)
|
||||
|
||||
// Should find async functions
|
||||
functionNames := make(map[string]bool)
|
||||
for _, chunk := range chunks {
|
||||
if chunk.Type == chunking.ChunkTypeFunction {
|
||||
functionNames[chunk.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, functionNames["fetchData"])
|
||||
assert.True(t, functionNames["processItems"])
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_ExportedFunction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := `export function publicFunction(): void {
|
||||
console.log("public");
|
||||
}
|
||||
|
||||
export default function defaultExport(): void {
|
||||
console.log("default");
|
||||
}
|
||||
`
|
||||
|
||||
filePath := createTempTSFile(t, code, ".ts")
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, chunks)
|
||||
|
||||
// Should find exported functions
|
||||
functionNames := make(map[string]bool)
|
||||
for _, chunk := range chunks {
|
||||
if chunk.Type == chunking.ChunkTypeFunction {
|
||||
functionNames[chunk.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, functionNames["publicFunction"])
|
||||
assert.True(t, functionNames["defaultExport"])
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_JSXFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := `function Button({ label }: { label: string }) {
|
||||
return <button>{label}</button>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<Button label="Click me" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`
|
||||
|
||||
filePath := createTempTSFile(t, code, ".tsx")
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, chunks)
|
||||
|
||||
// Should find JSX components as functions
|
||||
functionNames := make(map[string]bool)
|
||||
for _, chunk := range chunks {
|
||||
if chunk.Type == chunking.ChunkTypeFunction {
|
||||
functionNames[chunk.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, functionNames["Button"])
|
||||
assert.True(t, functionNames["App"])
|
||||
}
|
||||
|
||||
func TestChunker_Chunk_JavaScript(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := `function simpleFunc() {
|
||||
return 42;
|
||||
}
|
||||
|
||||
class MyClass {
|
||||
constructor() {
|
||||
this.value = 0;
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
filePath := createTempTSFile(t, code, ".js")
|
||||
c := NewChunker(chunking.DefaultChunkOptions())
|
||||
|
||||
chunks, err := c.Chunk(context.Background(), filePath)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, chunks)
|
||||
|
||||
// Should find JavaScript functions and classes
|
||||
var foundFunc, foundClass bool
|
||||
for _, chunk := range chunks {
|
||||
if chunk.Name == "simpleFunc" {
|
||||
foundFunc = true
|
||||
}
|
||||
if chunk.Name == "MyClass" {
|
||||
foundClass = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, foundFunc, "Should find 'simpleFunc' function")
|
||||
assert.True(t, foundClass, "Should find 'MyClass' class")
|
||||
}
|
||||
+51
-38
@@ -39,44 +39,46 @@ var CriticalConcepts = []string{
|
||||
// Config holds the application configuration.
|
||||
// Field order optimized for memory alignment (fieldalignment).
|
||||
type Config struct {
|
||||
ContextFullField string `json:"context_full_field"`
|
||||
DBPath string `json:"db_path"`
|
||||
Model string `json:"model"`
|
||||
ClaudeCodePath string `json:"claude_code_path"`
|
||||
EmbeddingModel string `json:"embedding_model"`
|
||||
VectorStorageStrategy string `json:"vector_storage_strategy"`
|
||||
ContextObsConcepts []string `json:"context_obs_concepts"`
|
||||
ContextObsTypes []string `json:"context_obs_types"`
|
||||
ContextFullCount int `json:"context_full_count"`
|
||||
GraphBranchFactor int `json:"graph_branch_factor"`
|
||||
GraphEdgeWeight float64 `json:"graph_edge_weight"`
|
||||
ContextRelevanceThreshold float64 `json:"context_relevance_threshold"`
|
||||
RerankingCandidates int `json:"reranking_candidates"`
|
||||
WorkerPort int `json:"worker_port"`
|
||||
DeduplicationThreshold float64 `json:"deduplication_threshold"`
|
||||
RerankingMinImprovement float64 `json:"reranking_min_improvement"`
|
||||
ContextObservations int `json:"context_observations"`
|
||||
ContextMaxPromptResults int `json:"context_max_prompt_results"`
|
||||
ContextSessionCount int `json:"context_session_count"`
|
||||
MaxConns int `json:"max_conns"`
|
||||
RerankingAlpha float64 `json:"reranking_alpha"`
|
||||
GraphMaxHops int `json:"graph_max_hops"`
|
||||
RerankingResults int `json:"reranking_results"`
|
||||
GraphRebuildIntervalMin int `json:"graph_rebuild_interval_min"`
|
||||
HubThreshold int `json:"hub_threshold"`
|
||||
ObservationRetentionDays int `json:"observation_retention_days"`
|
||||
MaintenanceIntervalHours int `json:"maintenance_interval_hours"`
|
||||
ContextMaxTokensStartup int `json:"context_max_tokens_startup"`
|
||||
ContextMaxTokensPrompt int `json:"context_max_tokens_prompt"`
|
||||
ContextShowWorkTokens bool `json:"context_show_work_tokens"`
|
||||
ContextShowReadTokens bool `json:"context_show_read_tokens"`
|
||||
RerankingPureMode bool `json:"reranking_pure_mode"`
|
||||
GraphEnabled bool `json:"graph_enabled"`
|
||||
DeduplicationEnabled bool `json:"deduplication_enabled"`
|
||||
MaintenanceEnabled bool `json:"maintenance_enabled"`
|
||||
RerankingEnabled bool `json:"reranking_enabled"`
|
||||
ContextShowLastSummary bool `json:"context_show_last_summary"`
|
||||
CleanupStaleObservations bool `json:"cleanup_stale_observations"`
|
||||
ContextFullField string `json:"context_full_field"`
|
||||
DBPath string `json:"db_path"`
|
||||
Model string `json:"model"`
|
||||
ClaudeCodePath string `json:"claude_code_path"`
|
||||
EmbeddingModel string `json:"embedding_model"`
|
||||
VectorStorageStrategy string `json:"vector_storage_strategy"`
|
||||
ContextObsConcepts []string `json:"context_obs_concepts"`
|
||||
ContextObsTypes []string `json:"context_obs_types"`
|
||||
ContextFullCount int `json:"context_full_count"`
|
||||
GraphBranchFactor int `json:"graph_branch_factor"`
|
||||
GraphEdgeWeight float64 `json:"graph_edge_weight"`
|
||||
ContextRelevanceThreshold float64 `json:"context_relevance_threshold"`
|
||||
RerankingCandidates int `json:"reranking_candidates"`
|
||||
WorkerPort int `json:"worker_port"`
|
||||
DeduplicationThreshold float64 `json:"deduplication_threshold"`
|
||||
RerankingMinImprovement float64 `json:"reranking_min_improvement"`
|
||||
ContextObservations int `json:"context_observations"`
|
||||
ContextMaxPromptResults int `json:"context_max_prompt_results"`
|
||||
ContextSessionCount int `json:"context_session_count"`
|
||||
MaxConns int `json:"max_conns"`
|
||||
RerankingAlpha float64 `json:"reranking_alpha"`
|
||||
GraphMaxHops int `json:"graph_max_hops"`
|
||||
RerankingResults int `json:"reranking_results"`
|
||||
GraphRebuildIntervalMin int `json:"graph_rebuild_interval_min"`
|
||||
HubThreshold int `json:"hub_threshold"`
|
||||
ObservationRetentionDays int `json:"observation_retention_days"`
|
||||
MaintenanceIntervalHours int `json:"maintenance_interval_hours"`
|
||||
WALCheckpointIntervalSeconds int `json:"wal_checkpoint_interval_seconds"`
|
||||
WALCheckpointThresholdBytes int64 `json:"wal_checkpoint_threshold_bytes"`
|
||||
ContextMaxTokensStartup int `json:"context_max_tokens_startup"`
|
||||
ContextMaxTokensPrompt int `json:"context_max_tokens_prompt"`
|
||||
ContextShowWorkTokens bool `json:"context_show_work_tokens"`
|
||||
ContextShowReadTokens bool `json:"context_show_read_tokens"`
|
||||
RerankingPureMode bool `json:"reranking_pure_mode"`
|
||||
GraphEnabled bool `json:"graph_enabled"`
|
||||
DeduplicationEnabled bool `json:"deduplication_enabled"`
|
||||
MaintenanceEnabled bool `json:"maintenance_enabled"`
|
||||
RerankingEnabled bool `json:"reranking_enabled"`
|
||||
ContextShowLastSummary bool `json:"context_show_last_summary"`
|
||||
CleanupStaleObservations bool `json:"cleanup_stale_observations"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -181,6 +183,10 @@ func Default() *Config {
|
||||
MaintenanceIntervalHours: 6, // Run every 6 hours
|
||||
ObservationRetentionDays: 0, // 0 = no age-based deletion (keep all)
|
||||
CleanupStaleObservations: false, // Don't auto-cleanup stale observations
|
||||
// WAL checkpoint loop tunables (issue #49). Defaults mirror the worker constants:
|
||||
// check the WAL every 60s and TRUNCATE-checkpoint once it reaches 4 MiB.
|
||||
WALCheckpointIntervalSeconds: 60,
|
||||
WALCheckpointThresholdBytes: 4 << 20, // 4 MiB
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +290,13 @@ func Load() (*Config, error) {
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_CONTEXT_MAX_TOKENS_PROMPT"].(float64); ok && v > 0 {
|
||||
cfg.ContextMaxTokensPrompt = int(v)
|
||||
}
|
||||
// WAL checkpoint loop tunables (issue #49)
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_WAL_CHECKPOINT_INTERVAL_SECONDS"].(float64); ok && v > 0 {
|
||||
cfg.WALCheckpointIntervalSeconds = int(v)
|
||||
}
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_WAL_CHECKPOINT_THRESHOLD_BYTES"].(float64); ok && v > 0 {
|
||||
cfg.WALCheckpointThresholdBytes = int64(v)
|
||||
}
|
||||
// Deduplication settings
|
||||
if v, ok := settings["CLAUDE_MNEMONIC_DEDUP_ENABLED"].(bool); ok {
|
||||
cfg.DeduplicationEnabled = v
|
||||
|
||||
@@ -618,7 +618,7 @@ func (s *ObservationStore) SearchObservationsFTS(ctx context.Context, query, pro
|
||||
// FTS failed, try LIKE fallback
|
||||
return s.searchObservationsLike(ctx, keywords, project, limit)
|
||||
}
|
||||
defer rows.Close()
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
observations, err := scanObservationRows(rows)
|
||||
if err != nil {
|
||||
|
||||
+145
-38
@@ -5,18 +5,80 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
sqlite_vec "github.com/asg017/sqlite-vec-go-bindings/cgo"
|
||||
_ "github.com/mattn/go-sqlite3" // Import SQLite driver with FTS5 support
|
||||
sqlite3 "github.com/mattn/go-sqlite3" // SQLite driver with FTS5 support
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// driverName is the name of the custom mattn/go-sqlite3 driver registered with a
|
||||
// ConnectHook that applies ALL connection pragmas (correctness + best-effort) to EVERY
|
||||
// pooled connection at open time. The stock "sqlite3" driver has no hook, so pragmas set
|
||||
// via a single post-open Exec only reach one arbitrary pooled connection (issue #49, F6).
|
||||
const driverName = "sqlite3_mnemonic"
|
||||
|
||||
// registerDriverOnce guards driver registration so it runs exactly once per process.
|
||||
// database/sql panics with "sql: Register called twice" on a duplicate name, and NewStore
|
||||
// may be called multiple times (e.g. after a config-change reinitialization).
|
||||
var registerDriverOnce sync.Once
|
||||
|
||||
// correctnessPragmas MUST succeed on every connection: getting any of them wrong changes
|
||||
// transactional/locking semantics, not just performance. A failure here aborts the open.
|
||||
var correctnessPragmas = []string{
|
||||
"PRAGMA foreign_keys=ON",
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA synchronous=NORMAL",
|
||||
"PRAGMA busy_timeout=5000",
|
||||
"PRAGMA cache_size=-64000",
|
||||
}
|
||||
|
||||
// bestEffortPragmas are per-connection or database-wide optimizations. A failure is logged
|
||||
// and tolerated: the connection is still correct, just less tuned. (page_size only takes
|
||||
// effect on an empty database / next VACUUM, but applying it per-connection is harmless.)
|
||||
var bestEffortPragmas = []string{
|
||||
"PRAGMA temp_store=MEMORY", // Store temp tables in memory
|
||||
"PRAGMA mmap_size=268435456", // 256MB memory-mapped I/O
|
||||
"PRAGMA page_size=4096", // 4KB pages (optimal for most systems)
|
||||
"PRAGMA wal_autocheckpoint=1000", // Auto-checkpoint (PASSIVE) every 1000 WAL frames
|
||||
"PRAGMA journal_size_limit=8388608", // Backstop: cap -wal at 8MiB (issue #49)
|
||||
}
|
||||
|
||||
// connectHook applies all pragmas to a freshly opened connection. mattn/go-sqlite3 calls
|
||||
// it at the very end of Open, after DSN params and extensions, so it is authoritative.
|
||||
func connectHook(c *sqlite3.SQLiteConn) error {
|
||||
for _, pragma := range correctnessPragmas {
|
||||
if _, err := c.Exec(pragma, nil); err != nil {
|
||||
return fmt.Errorf("apply correctness pragma %q: %w", pragma, err)
|
||||
}
|
||||
}
|
||||
for _, pragma := range bestEffortPragmas {
|
||||
if _, err := c.Exec(pragma, nil); err != nil {
|
||||
log.Warn().Str("pragma", pragma).Err(err).Msg("Failed to set pragma (non-fatal)")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerDriver registers the custom driver once. sqlite_vec.Auto() registers the vec
|
||||
// extension globally via sqlite3_auto_extension, which applies to connections from any
|
||||
// sqlite3-based driver, so the new driver still gets vec + FTS5.
|
||||
func registerDriver() {
|
||||
registerDriverOnce.Do(func() {
|
||||
sql.Register(driverName, &sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(c *sqlite3.SQLiteConn) error {
|
||||
return connectHook(c)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Store represents the GORM database connection with sqlite-vec support.
|
||||
type Store struct {
|
||||
healthCacheTime time.Time
|
||||
@@ -24,6 +86,7 @@ type Store struct {
|
||||
sqlDB *sql.DB
|
||||
metrics *PoolMetrics
|
||||
cachedHealth *HealthInfo
|
||||
path string
|
||||
healthCacheTTL time.Duration
|
||||
healthCacheMu sync.RWMutex
|
||||
}
|
||||
@@ -36,22 +99,32 @@ type Config struct {
|
||||
}
|
||||
|
||||
// NewStore creates a new Store with WAL mode enabled and sqlite-vec registered.
|
||||
// CRITICAL: WAL mode and foreign keys are enabled via pragmas for concurrent reads.
|
||||
// CRITICAL: all connection pragmas (WAL, foreign_keys, busy_timeout, etc.) are applied to
|
||||
// EVERY pooled connection via a driver ConnectHook (see registerDriver), so the pool is
|
||||
// uniformly configured and connections may be recycled safely (issue #49, F6).
|
||||
func NewStore(cfg Config) (*Store, error) {
|
||||
// 1. Register sqlite-vec extension (must be done before opening database)
|
||||
// 1. Register sqlite-vec extension (must be done before opening database).
|
||||
// sqlite_vec.Auto() uses sqlite3_auto_extension, which is global to all sqlite3-based
|
||||
// drivers, so connections from our custom driver also get the vec virtual table.
|
||||
sqlite_vec.Auto()
|
||||
|
||||
// 2. Build connection string (foreign keys enabled in DSN)
|
||||
// Use sqlite3 driver (mattn/go-sqlite3) which has FTS5 support
|
||||
// 2. Register the custom driver whose ConnectHook applies ALL pragmas to EVERY pooled
|
||||
// connection (issue #49, F6). Without this, pragmas set via a single post-open
|
||||
// sqlDB.Exec reach only one arbitrary pooled connection. The hook is authoritative.
|
||||
registerDriver()
|
||||
|
||||
// 3. Build a minimal DSN. _foreign_keys is kept as belt-and-suspenders (the hook sets
|
||||
// it too); all other pragmas are applied per-connection by the ConnectHook, so they no
|
||||
// longer need to live in the DSN.
|
||||
dsn := cfg.Path + "?_foreign_keys=ON"
|
||||
|
||||
// 3. Open raw database connection with mattn/go-sqlite3 (has FTS5 support)
|
||||
sqlDB, err := sql.Open("sqlite3", dsn)
|
||||
// 4. Open raw database connection with the custom driver (FTS5 + per-connection pragmas).
|
||||
sqlDB, err := sql.Open(driverName, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
// 4. Wrap with GORM using existing connection
|
||||
// 5. Wrap with GORM using existing connection
|
||||
db, err := gorm.Open(sqlite.Dialector{
|
||||
Conn: sqlDB,
|
||||
}, &gorm.Config{
|
||||
@@ -66,16 +139,25 @@ func NewStore(cfg Config) (*Store, error) {
|
||||
return nil, fmt.Errorf("open gorm: %w", err)
|
||||
}
|
||||
|
||||
// 5. Configure connection pool (same settings as current implementation)
|
||||
// 6. Configure connection pool.
|
||||
maxConns := cfg.MaxConns
|
||||
if maxConns <= 0 {
|
||||
maxConns = 4
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(maxConns)
|
||||
sqlDB.SetMaxIdleConns(maxConns)
|
||||
sqlDB.SetConnMaxLifetime(0) // Never expire (SQLite connections are cheap)
|
||||
// Finite recycling (issue #49): previously SetConnMaxLifetime(0) meant connections
|
||||
// NEVER recycled, so a long-lived read connection could pin an old WAL read-mark for the
|
||||
// whole process lifetime and block TRUNCATE checkpoints from reclaiming the -wal file.
|
||||
// Recycling is safe now because the ConnectHook reapplies every correctness pragma on
|
||||
// each new connection — a recycled connection comes back fully configured, not with
|
||||
// defaults. 1h lifetime bounds read-mark staleness without churning the pool; 30m idle
|
||||
// time reclaims connections that sit unused (e.g. between sessions) so the pool shrinks
|
||||
// back to one warm connection during quiet periods, dropping their WAL read-marks.
|
||||
sqlDB.SetConnMaxLifetime(1 * time.Hour)
|
||||
sqlDB.SetConnMaxIdleTime(30 * time.Minute)
|
||||
|
||||
// 6. Verify connection
|
||||
// 7. Verify connection
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
@@ -83,37 +165,18 @@ func NewStore(cfg Config) (*Store, error) {
|
||||
store := &Store{
|
||||
DB: db,
|
||||
sqlDB: sqlDB,
|
||||
path: cfg.Path,
|
||||
metrics: NewPoolMetrics(100), // Track last 100 latency samples
|
||||
healthCacheTTL: 5 * time.Second, // Cache health checks for 5 seconds
|
||||
}
|
||||
|
||||
// 7. Run migrations FIRST (before PRAGMA commands)
|
||||
// 8. Run migrations. All pragmas (correctness + best-effort) are applied per-connection
|
||||
// by the ConnectHook at open time, so there is no post-open pragma loop here anymore:
|
||||
// such a loop only ever reached one arbitrary pooled connection (issue #49, F6).
|
||||
if err := runMigrations(db, sqlDB); err != nil {
|
||||
return nil, fmt.Errorf("run migrations: %w", err)
|
||||
}
|
||||
|
||||
// 8. CRITICAL: Set WAL mode and other performance pragmas
|
||||
// Use raw sqlDB to avoid GORM transaction issues
|
||||
pragmas := []string{
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA synchronous=NORMAL",
|
||||
"PRAGMA cache_size=-64000", // 64MB cache (negative = KB)
|
||||
"PRAGMA temp_store=MEMORY", // Store temp tables in memory
|
||||
"PRAGMA mmap_size=268435456", // 256MB memory-mapped I/O
|
||||
"PRAGMA page_size=4096", // 4KB pages (optimal for most systems)
|
||||
"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
|
||||
store.WarmPool(maxConns)
|
||||
|
||||
@@ -148,11 +211,55 @@ func (s *Store) WarmPool(numConns int) {
|
||||
log.Debug().Int("connections", numConns).Msg("Connection pool warmed")
|
||||
}
|
||||
|
||||
// Close closes the database connection.
|
||||
// Close checkpoints the WAL (TRUNCATE) before closing the connection. Checkpointing on
|
||||
// shutdown prevents the WAL file from persisting in a large, dirty state across restarts
|
||||
// and config-change reinitializations, which otherwise leaves a multi-megabyte -wal file
|
||||
// on disk (issue #49). The checkpoint is best-effort: a failure is logged, not fatal.
|
||||
func (s *Store) Close() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := s.Checkpoint(ctx); err != nil {
|
||||
log.Warn().Err(err).Msg("WAL checkpoint on close failed (non-fatal)")
|
||||
}
|
||||
return s.sqlDB.Close()
|
||||
}
|
||||
|
||||
// Checkpoint runs a TRUNCATE WAL checkpoint: it flushes WAL frames into the main
|
||||
// database file and shrinks the -wal file back to zero. Unlike a PASSIVE checkpoint
|
||||
// (which never truncates the file and is all SQLite's auto-checkpoint ever performs), a
|
||||
// TRUNCATE checkpoint reclaims disk and is the mechanism that bounds WAL growth.
|
||||
// It waits up to the connection busy_timeout for the write lock and returns an error
|
||||
// rather than blocking indefinitely.
|
||||
func (s *Store) Checkpoint(ctx context.Context) error {
|
||||
if _, err := s.sqlDB.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||
return fmt.Errorf("wal checkpoint (truncate): %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WALSize returns the size in bytes of the SQLite WAL sidecar file (<db>-wal), or 0 if
|
||||
// it does not exist or cannot be stat'd. Used to decide when a checkpoint is worthwhile.
|
||||
func (s *Store) WALSize() int64 {
|
||||
if s.path == "" {
|
||||
return 0
|
||||
}
|
||||
info, err := os.Stat(s.path + "-wal")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
// CheckpointIfLarge performs a TRUNCATE checkpoint only when the WAL file has grown to or
|
||||
// beyond threshold bytes. Returns true if a checkpoint was performed. This keeps the
|
||||
// periodic checkpoint cheap: it does no work while the WAL is small.
|
||||
func (s *Store) CheckpointIfLarge(ctx context.Context, threshold int64) (bool, error) {
|
||||
if s.WALSize() < threshold {
|
||||
return false, nil
|
||||
}
|
||||
return true, s.Checkpoint(ctx)
|
||||
}
|
||||
|
||||
// Ping verifies the database connection is alive.
|
||||
func (s *Store) Ping() error {
|
||||
return s.sqlDB.Ping()
|
||||
@@ -193,8 +300,9 @@ func (s *Store) Optimize(ctx context.Context) error {
|
||||
log.Warn().Err(err).Msg("PRAGMA optimize failed (non-fatal)")
|
||||
}
|
||||
|
||||
// Passive WAL checkpoint — doesn't block readers/writers
|
||||
if _, err := s.sqlDB.ExecContext(ctx, "PRAGMA wal_checkpoint(PASSIVE)"); err != nil {
|
||||
// TRUNCATE WAL checkpoint — reclaims the -wal file during low-activity optimization.
|
||||
// (PASSIVE never shrinks the file, so it cannot bound WAL growth — see issue #49.)
|
||||
if err := s.Checkpoint(ctx); err != nil {
|
||||
log.Warn().Err(err).Msg("WAL checkpoint failed (non-fatal)")
|
||||
}
|
||||
|
||||
@@ -519,7 +627,6 @@ func (s *Store) ExecWithTimeout(ctx context.Context, timeout time.Duration, quer
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// TransactionWithTimeout wraps a transaction function with timeout handling.
|
||||
// The transaction is automatically rolled back if the context times out.
|
||||
func (s *Store) TransactionWithTimeout(ctx context.Context, timeout time.Duration, fn func(*gorm.DB) error) error {
|
||||
|
||||
@@ -5,9 +5,12 @@ package gorm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
@@ -239,3 +242,321 @@ func TestOptimize_RespectsContextCancellation(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,25 @@ func (r *ModelRegistry) List() []ModelMetadata {
|
||||
}
|
||||
|
||||
// DefaultRegistry is the global model registry with all available models.
|
||||
var DefaultRegistry = NewModelRegistry()
|
||||
// It is constructed eagerly at package load via newDefaultRegistry, which
|
||||
// registers the built-in BGE model — replacing the previous init()-based
|
||||
// mutate-after-init pattern while preserving identical behaviour.
|
||||
var DefaultRegistry = newDefaultRegistry()
|
||||
|
||||
// newDefaultRegistry builds a model registry pre-populated with the built-in
|
||||
// models (currently BGE). Registering at construction time keeps DefaultRegistry
|
||||
// ready-to-use without a separate init() mutating it after package load.
|
||||
func newDefaultRegistry() *ModelRegistry {
|
||||
r := NewModelRegistry()
|
||||
r.Register(ModelMetadata{
|
||||
Name: BGEModelName,
|
||||
Version: BGEModelVersion,
|
||||
Dimensions: EmbeddingDim,
|
||||
Description: "High-quality semantic search model",
|
||||
Default: true,
|
||||
}, newBGEModel)
|
||||
return r
|
||||
}
|
||||
|
||||
// RegisterModel adds a model to the default registry.
|
||||
func RegisterModel(meta ModelMetadata, factory ModelFactory) {
|
||||
|
||||
@@ -562,17 +562,6 @@ func (m *bgeModel) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Register the BGE model with the default registry at init time
|
||||
func init() {
|
||||
RegisterModel(ModelMetadata{
|
||||
Name: BGEModelName,
|
||||
Version: BGEModelVersion,
|
||||
Dimensions: EmbeddingDim,
|
||||
Description: "High-quality semantic search model",
|
||||
Default: true,
|
||||
}, newBGEModel)
|
||||
}
|
||||
|
||||
// Service provides thread-safe text embedding generation with model abstraction.
|
||||
type Service struct {
|
||||
model EmbeddingModel
|
||||
|
||||
@@ -331,8 +331,8 @@ func holdMutex(m *bgeModel) (ready <-chan struct{}, release func()) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
m.mu.Lock()
|
||||
close(ch) // signal: lock acquired
|
||||
<-done // wait for release signal
|
||||
close(ch) // signal: lock acquired
|
||||
<-done // wait for release signal
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
return ch, func() { close(done) }
|
||||
|
||||
@@ -83,8 +83,20 @@ func (s *Service) Start(ctx context.Context) {
|
||||
Bool("cleanup_stale", s.config.CleanupStaleObservations).
|
||||
Msg("Starting maintenance scheduler")
|
||||
|
||||
// Initial run after 5 minutes (allow system to stabilize)
|
||||
time.Sleep(5 * time.Minute)
|
||||
// Initial run after 5 minutes (allow system to stabilize).
|
||||
// Use a cancellable timer so shutdown (ctx cancel / Stop) is not blocked for up to 5m.
|
||||
initialDelay := time.NewTimer(5 * time.Minute)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
initialDelay.Stop()
|
||||
s.log.Info().Msg("Maintenance shutting down before initial run (context cancellation)")
|
||||
return
|
||||
case <-s.stopCh:
|
||||
initialDelay.Stop()
|
||||
s.log.Info().Msg("Maintenance shutting down before initial run (stop signal)")
|
||||
return
|
||||
case <-initialDelay.C:
|
||||
}
|
||||
s.runMaintenance(ctx)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
@@ -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) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -363,24 +363,24 @@ func TestRunNow_RetentionDays_DeletesExpiredObservations(t *testing.T) {
|
||||
|
||||
// Insert an observation and back-date it to 2 days ago.
|
||||
obs := &gormdb.Observation{
|
||||
SDKSessionID: "old-session",
|
||||
Project: "proj",
|
||||
Type: models.ObsTypeDiscovery,
|
||||
CreatedAt: "2000-01-01T00:00:00Z",
|
||||
CreatedAtEpoch: time.Now().AddDate(0, 0, -2).Unix(),
|
||||
Scope: models.ScopeProject,
|
||||
SDKSessionID: "old-session",
|
||||
Project: "proj",
|
||||
Type: models.ObsTypeDiscovery,
|
||||
CreatedAt: "2000-01-01T00:00:00Z",
|
||||
CreatedAtEpoch: time.Now().AddDate(0, 0, -2).Unix(),
|
||||
Scope: models.ScopeProject,
|
||||
ImportanceScore: 1.0,
|
||||
}
|
||||
require.NoError(t, store.GetDB().WithContext(ctx).Create(obs).Error)
|
||||
|
||||
// Insert a recent observation (should survive).
|
||||
recentObs := &gormdb.Observation{
|
||||
SDKSessionID: "new-session",
|
||||
Project: "proj",
|
||||
Type: models.ObsTypeDiscovery,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CreatedAtEpoch: time.Now().Unix(),
|
||||
Scope: models.ScopeProject,
|
||||
SDKSessionID: "new-session",
|
||||
Project: "proj",
|
||||
Type: models.ObsTypeDiscovery,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CreatedAtEpoch: time.Now().Unix(),
|
||||
Scope: models.ScopeProject,
|
||||
ImportanceScore: 1.0,
|
||||
}
|
||||
require.NoError(t, store.GetDB().WithContext(ctx).Create(recentObs).Error)
|
||||
@@ -434,12 +434,12 @@ func TestRunNow_RetentionDays_VectorCleanupCalled(t *testing.T) {
|
||||
|
||||
// Insert an expired observation directly.
|
||||
obs := &gormdb.Observation{
|
||||
SDKSessionID: "session-x",
|
||||
Project: "proj",
|
||||
Type: models.ObsTypeDiscovery,
|
||||
CreatedAt: "2000-01-01T00:00:00Z",
|
||||
CreatedAtEpoch: time.Now().AddDate(0, 0, -2).Unix(),
|
||||
Scope: models.ScopeProject,
|
||||
SDKSessionID: "session-x",
|
||||
Project: "proj",
|
||||
Type: models.ObsTypeDiscovery,
|
||||
CreatedAt: "2000-01-01T00:00:00Z",
|
||||
CreatedAtEpoch: time.Now().AddDate(0, 0, -2).Unix(),
|
||||
Scope: models.ScopeProject,
|
||||
ImportanceScore: 1.0,
|
||||
}
|
||||
require.NoError(t, store.GetDB().WithContext(ctx).Create(obs).Error)
|
||||
@@ -670,12 +670,12 @@ func TestRunNow_RetentionDays_BatchDeletion_MoreThan100Rows(t *testing.T) {
|
||||
// Insert 150 expired observations (forces 2 batches of 100).
|
||||
for i := 0; i < 150; i++ {
|
||||
obs := &gormdb.Observation{
|
||||
SDKSessionID: "session-old",
|
||||
Project: "proj",
|
||||
Type: models.ObsTypeDiscovery,
|
||||
CreatedAt: "2000-01-01T00:00:00Z",
|
||||
CreatedAtEpoch: time.Now().AddDate(0, 0, -2).Unix(),
|
||||
Scope: models.ScopeProject,
|
||||
SDKSessionID: "session-old",
|
||||
Project: "proj",
|
||||
Type: models.ObsTypeDiscovery,
|
||||
CreatedAt: "2000-01-01T00:00:00Z",
|
||||
CreatedAtEpoch: time.Now().AddDate(0, 0, -2).Unix(),
|
||||
Scope: models.ScopeProject,
|
||||
ImportanceScore: 1.0,
|
||||
}
|
||||
require.NoError(t, store.GetDB().WithContext(ctx).Create(obs).Error)
|
||||
@@ -706,14 +706,14 @@ func TestRunNow_CleanupStale_BatchDeletion_MoreThan100Rows(t *testing.T) {
|
||||
// Insert 120 superseded observations.
|
||||
for i := 0; i < 120; i++ {
|
||||
obs := &gormdb.Observation{
|
||||
SDKSessionID: "session-stale",
|
||||
Project: "proj",
|
||||
Type: models.ObsTypeDiscovery,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CreatedAtEpoch: time.Now().Unix(),
|
||||
Scope: models.ScopeProject,
|
||||
SDKSessionID: "session-stale",
|
||||
Project: "proj",
|
||||
Type: models.ObsTypeDiscovery,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CreatedAtEpoch: time.Now().Unix(),
|
||||
Scope: models.ScopeProject,
|
||||
ImportanceScore: 1.0,
|
||||
IsSuperseded: 1,
|
||||
IsSuperseded: 1,
|
||||
}
|
||||
require.NoError(t, store.GetDB().WithContext(ctx).Create(obs).Error)
|
||||
}
|
||||
|
||||
+103
-460
@@ -290,475 +290,65 @@ func (s *Server) handleToolsList(req *Request) *Response {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "decisions",
|
||||
Description: "Semantic shortcut for finding architectural, design, and implementation decisions.",
|
||||
Name: "observation",
|
||||
Description: "Manage individual observations. Set 'action' to choose the operation: get, edit, delete, supersede, boost, merge, related, similar, quality, relationships, scoring, tag, by_tag, batch_tag.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"query"},
|
||||
"required": []string{"action"},
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "Natural language query for finding decisions"},
|
||||
"dateStart": map[string]any{"type": []string{"string", "number"}},
|
||||
"dateEnd": map[string]any{"type": []string{"string", "number"}},
|
||||
"limit": map[string]any{"type": "number", "default": 20, "minimum": 1, "maximum": 100},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
"action": map[string]any{"type": "string", "enum": []string{"get", "edit", "delete", "supersede", "boost", "merge", "related", "similar", "quality", "relationships", "scoring", "tag", "by_tag", "batch_tag"}, "description": "Operation to perform"},
|
||||
"id": map[string]any{"type": "number", "description": "Observation ID (get, edit, quality, relationships, scoring, related)"},
|
||||
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "number"}, "description": "Observation IDs (delete, supersede, boost)"},
|
||||
"title": map[string]any{"type": "string", "description": "edit: new title"},
|
||||
"subtitle": map[string]any{"type": "string", "description": "edit: new subtitle"},
|
||||
"narrative": map[string]any{"type": "string", "description": "edit: new narrative"},
|
||||
"facts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "edit: new facts"},
|
||||
"concepts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "edit: new concept tags"},
|
||||
"files_read": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "edit: new files read"},
|
||||
"files_modified": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "edit: new files modified"},
|
||||
"scope": map[string]any{"type": "string", "enum": []string{"project", "global"}, "description": "edit: new scope"},
|
||||
"query": map[string]any{"type": "string", "description": "similar: text to find similar observations for"},
|
||||
"project": map[string]any{"type": "string", "description": "Filter by project (similar, by_tag, batch_tag)"},
|
||||
"min_confidence": map[string]any{"type": "number", "minimum": 0.0, "maximum": 1.0, "description": "related: minimum confidence (default 0.5)"},
|
||||
"min_similarity": map[string]any{"type": "number", "minimum": 0.0, "maximum": 1.0, "description": "similar: minimum similarity (default 0.7)"},
|
||||
"limit": map[string]any{"type": "number", "description": "Max results (related, similar, by_tag, batch_tag)"},
|
||||
"delete_vectors": map[string]any{"type": "boolean", "description": "delete: also delete vectors (default true)"},
|
||||
"boost": map[string]any{"type": "number", "minimum": -1.0, "maximum": 1.0, "description": "boost: amount; merge: target boost"},
|
||||
"source_id": map[string]any{"type": "number", "description": "merge: source observation ID (superseded)"},
|
||||
"target_id": map[string]any{"type": "number", "description": "merge: target observation ID (kept)"},
|
||||
"max_depth": map[string]any{"type": "number", "minimum": 1, "maximum": 5, "description": "relationships: traversal hops (default 2)"},
|
||||
"tag": map[string]any{"type": "string", "description": "by_tag: tag/concept to search for"},
|
||||
"tags": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "tag/batch_tag: tags to apply"},
|
||||
"mode": map[string]any{"type": "string", "enum": []string{"add", "remove", "set"}, "description": "tag: add/remove/set (default add)"},
|
||||
"pattern": map[string]any{"type": "string", "description": "batch_tag: search pattern to match"},
|
||||
"dry_run": map[string]any{"type": "boolean", "description": "batch_tag: preview only (default true)"},
|
||||
"max_matches": map[string]any{"type": "number", "description": "batch_tag: max observations to tag"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "changes",
|
||||
Description: "Semantic shortcut for finding code changes, refactorings, and modifications.",
|
||||
Name: "memory_admin",
|
||||
Description: "Memory system administration and analytics. Set 'action': stats, health, maintenance_stats, run_maintenance, importance, search_patterns, explain_ranking, temporal_trends, data_quality, export, suggest_consolidations, patterns.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"query"},
|
||||
"required": []string{"action"},
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "Natural language query for finding changes"},
|
||||
"dateStart": map[string]any{"type": []string{"string", "number"}},
|
||||
"dateEnd": map[string]any{"type": []string{"string", "number"}},
|
||||
"limit": map[string]any{"type": "number", "default": 20, "minimum": 1, "maximum": 100},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "how_it_works",
|
||||
Description: "Semantic shortcut for understanding system architecture, design patterns, and implementation details.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"query"},
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "Natural language query for understanding how something works"},
|
||||
"dateStart": map[string]any{"type": []string{"string", "number"}},
|
||||
"dateEnd": map[string]any{"type": []string{"string", "number"}},
|
||||
"limit": map[string]any{"type": "number", "default": 20, "minimum": 1, "maximum": 100},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "find_by_concept",
|
||||
Description: "Find observations tagged with specific concepts.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"concepts"},
|
||||
"properties": map[string]any{
|
||||
"concepts": map[string]any{"type": "string", "description": "Concept tag(s) to filter by"},
|
||||
"type": map[string]any{"type": "string"},
|
||||
"files": map[string]any{"type": "string"},
|
||||
"project": map[string]any{"type": "string"},
|
||||
"dateStart": map[string]any{"type": []string{"string", "number"}},
|
||||
"dateEnd": map[string]any{"type": []string{"string", "number"}},
|
||||
"orderBy": map[string]any{"type": "string", "enum": []string{"date_desc", "date_asc"}, "default": "date_desc"},
|
||||
"limit": map[string]any{"type": "number", "default": 20},
|
||||
"offset": map[string]any{"type": "number", "default": 0},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "find_by_file",
|
||||
Description: "Find observations related to specific file paths.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"files"},
|
||||
"properties": map[string]any{
|
||||
"files": map[string]any{"type": "string", "description": "File path(s) to filter by"},
|
||||
"type": map[string]any{"type": "string"},
|
||||
"concepts": map[string]any{"type": "string"},
|
||||
"project": map[string]any{"type": "string"},
|
||||
"dateStart": map[string]any{"type": []string{"string", "number"}},
|
||||
"dateEnd": map[string]any{"type": []string{"string", "number"}},
|
||||
"orderBy": map[string]any{"type": "string", "enum": []string{"date_desc", "date_asc"}, "default": "date_desc"},
|
||||
"limit": map[string]any{"type": "number", "default": 20},
|
||||
"offset": map[string]any{"type": "number", "default": 0},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "find_by_type",
|
||||
Description: "Find observations of specific types.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"type"},
|
||||
"properties": map[string]any{
|
||||
"type": map[string]any{"type": "string", "description": "Observation type(s) to filter by"},
|
||||
"concepts": map[string]any{"type": "string"},
|
||||
"files": map[string]any{"type": "string"},
|
||||
"project": map[string]any{"type": "string"},
|
||||
"dateStart": map[string]any{"type": []string{"string", "number"}},
|
||||
"dateEnd": map[string]any{"type": []string{"string", "number"}},
|
||||
"orderBy": map[string]any{"type": "string", "enum": []string{"date_desc", "date_asc"}, "default": "date_desc"},
|
||||
"limit": map[string]any{"type": "number", "default": 20},
|
||||
"offset": map[string]any{"type": "number", "default": 0},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_recent_context",
|
||||
Description: "Get recent session context for timeline display.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"project": map[string]any{"type": "string"},
|
||||
"type": map[string]any{"type": "string"},
|
||||
"concepts": map[string]any{"type": "string"},
|
||||
"files": map[string]any{"type": "string"},
|
||||
"dateStart": map[string]any{"type": []string{"string", "number"}},
|
||||
"dateEnd": map[string]any{"type": []string{"string", "number"}},
|
||||
"limit": map[string]any{"type": "number", "default": 30, "minimum": 1, "maximum": 100},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_context_timeline",
|
||||
Description: "Get timeline of observations around a specific observation ID.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"anchor_id"},
|
||||
"properties": map[string]any{
|
||||
"anchor_id": map[string]any{"type": "number", "description": "Observation ID to use as anchor point"},
|
||||
"before": map[string]any{"type": "number", "default": 10, "minimum": 0, "maximum": 100},
|
||||
"after": map[string]any{"type": "number", "default": 10, "minimum": 0, "maximum": 100},
|
||||
"project": map[string]any{"type": "string"},
|
||||
"type": map[string]any{"type": "string"},
|
||||
"concepts": map[string]any{"type": "string"},
|
||||
"files": map[string]any{"type": "string"},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_timeline_by_query",
|
||||
Description: "Combined search + timeline tool. First searches for observations matching the query, then returns timeline around the best match.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"query"},
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "Natural language query to find anchor observation"},
|
||||
"before": map[string]any{"type": "number", "default": 10, "minimum": 0, "maximum": 100},
|
||||
"after": map[string]any{"type": "number", "default": 10, "minimum": 0, "maximum": 100},
|
||||
"project": map[string]any{"type": "string"},
|
||||
"type": map[string]any{"type": "string"},
|
||||
"concepts": map[string]any{"type": "string"},
|
||||
"files": map[string]any{"type": "string"},
|
||||
"dateStart": map[string]any{"type": []string{"string", "number"}},
|
||||
"dateEnd": map[string]any{"type": []string{"string", "number"}},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "find_related_observations",
|
||||
Description: "Find observations related to a given observation ID filtered by confidence threshold. Returns related observations sorted by confidence score. Useful for discovering relevant context.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"id"},
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "number", "description": "Observation ID"},
|
||||
"min_confidence": map[string]any{"type": "number", "default": 0.5, "minimum": 0.0, "maximum": 1.0, "description": "Minimum confidence threshold"},
|
||||
"limit": map[string]any{"type": "number", "default": 20, "minimum": 1, "maximum": 100},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "find_similar_observations",
|
||||
Description: "Find observations semantically similar to a query or observation. Uses vector similarity search to find related content. Useful for detecting duplicates before creating new observations.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"query"},
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "Text to find similar observations for"},
|
||||
"project": map[string]any{"type": "string", "description": "Filter by project name"},
|
||||
"min_similarity": map[string]any{"type": "number", "default": 0.7, "minimum": 0.0, "maximum": 1.0, "description": "Minimum similarity threshold (0-1)"},
|
||||
"limit": map[string]any{"type": "number", "default": 10, "minimum": 1, "maximum": 50},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_patterns",
|
||||
Description: "Get detected patterns from observations. Patterns represent recurring themes, workflows, or practices discovered across observations.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"type": map[string]any{"type": "string", "enum": []string{"workflow", "preference", "best_practice", "anti_pattern", "tooling"}, "description": "Filter by pattern type"},
|
||||
"project": map[string]any{"type": "string", "description": "Filter by project"},
|
||||
"query": map[string]any{"type": "string", "description": "Search patterns by name/description"},
|
||||
"limit": map[string]any{"type": "number", "default": 20, "minimum": 1, "maximum": 100},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_memory_stats",
|
||||
Description: "Get statistics about the memory system including observation counts, vector stats, pattern counts, and search metrics.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "bulk_delete_observations",
|
||||
Description: "Delete multiple observations by their IDs. Returns count of successfully deleted observations.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"ids"},
|
||||
"properties": map[string]any{
|
||||
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "number"}, "description": "Array of observation IDs to delete"},
|
||||
"delete_vectors": map[string]any{"type": "boolean", "default": true, "description": "Also delete associated vectors"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "bulk_mark_superseded",
|
||||
Description: "Mark multiple observations as superseded (stale). Useful for cleanup without permanent deletion.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"ids"},
|
||||
"properties": map[string]any{
|
||||
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "number"}, "description": "Array of observation IDs to mark as superseded"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "bulk_boost_observations",
|
||||
Description: "Boost or reduce the importance score of multiple observations. Positive values increase importance, negative decrease.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"ids", "boost"},
|
||||
"properties": map[string]any{
|
||||
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "number"}, "description": "Array of observation IDs to boost"},
|
||||
"boost": map[string]any{"type": "number", "minimum": -1.0, "maximum": 1.0, "description": "Boost amount (-1.0 to 1.0)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "trigger_maintenance",
|
||||
Description: "Trigger an immediate maintenance run (cleanup old observations, optimize database).",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_maintenance_stats",
|
||||
Description: "Get statistics about the maintenance system including last run time, cleanup counts, and configuration.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "merge_observations",
|
||||
Description: "Merge two observations into one. The target observation is kept and boosted, the source is marked as superseded. Useful for deduplication without data loss.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"source_id", "target_id"},
|
||||
"properties": map[string]any{
|
||||
"source_id": map[string]any{"type": "number", "description": "ID of the observation to merge FROM (will be superseded)"},
|
||||
"target_id": map[string]any{"type": "number", "description": "ID of the observation to merge INTO (will be kept and boosted)"},
|
||||
"boost": map[string]any{"type": "number", "default": 0.1, "minimum": 0, "maximum": 0.5, "description": "Score boost for the target observation (default 0.1)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_observation",
|
||||
Description: "Get a single observation by its ID. Returns full observation details including all metadata.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"id"},
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "number", "description": "Observation ID to retrieve"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "edit_observation",
|
||||
Description: "Edit an existing observation. Only provided fields will be updated, others remain unchanged. Useful for correcting errors, adding details, or updating scope.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"id"},
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "number", "description": "Observation ID to edit"},
|
||||
"title": map[string]any{"type": "string", "description": "New title (optional)"},
|
||||
"subtitle": map[string]any{"type": "string", "description": "New subtitle (optional)"},
|
||||
"narrative": map[string]any{"type": "string", "description": "New narrative text (optional)"},
|
||||
"facts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "New facts array (optional)"},
|
||||
"concepts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "New concept tags (optional)"},
|
||||
"files_read": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "New files read list (optional)"},
|
||||
"files_modified": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "New files modified list (optional)"},
|
||||
"scope": map[string]any{"type": "string", "enum": []string{"project", "global"}, "description": "New scope (optional)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_observation_quality",
|
||||
Description: "Get quality metrics for an observation. Returns completeness score, usage stats, and improvement suggestions.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"id"},
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "number", "description": "Observation ID to analyze"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "suggest_consolidations",
|
||||
Description: "Find observations that could be merged or consolidated. Returns groups of similar observations with merge recommendations.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"project": map[string]any{"type": "string", "description": "Filter by project"},
|
||||
"min_similarity": map[string]any{"type": "number", "default": 0.8, "minimum": 0.5, "maximum": 1.0, "description": "Minimum similarity threshold for grouping"},
|
||||
"limit": map[string]any{"type": "number", "default": 10, "minimum": 1, "maximum": 50, "description": "Maximum groups to return"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "tag_observation",
|
||||
Description: "Add or remove concept tags from an observation. Tags help with organization and filtering. Use mode 'add' to add new tags, 'remove' to remove specific tags, or 'set' to replace all tags.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"id", "tags"},
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "number", "description": "Observation ID to tag"},
|
||||
"tags": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Tags to add, remove, or set"},
|
||||
"mode": map[string]any{"type": "string", "enum": []string{"add", "remove", "set"}, "default": "add", "description": "Operation mode: 'add' appends tags, 'remove' removes specific tags, 'set' replaces all tags"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_observations_by_tag",
|
||||
Description: "Find all observations that have a specific concept tag. Useful for browsing by category.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"tag"},
|
||||
"properties": map[string]any{
|
||||
"tag": map[string]any{"type": "string", "description": "Tag/concept to search for"},
|
||||
"project": map[string]any{"type": "string", "description": "Filter by project (optional)"},
|
||||
"limit": map[string]any{"type": "number", "default": 50, "minimum": 1, "maximum": 200, "description": "Maximum observations to return"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_temporal_trends",
|
||||
Description: "Analyze observation creation patterns over time. Returns daily counts, peak activity times, and trend insights. Useful for understanding work patterns.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"project": map[string]any{"type": "string", "description": "Filter by project (optional)"},
|
||||
"days": map[string]any{"type": "number", "default": 30, "minimum": 1, "maximum": 365, "description": "Number of days to analyze"},
|
||||
"group_by": map[string]any{"type": "string", "enum": []string{"day", "week", "hour_of_day"}, "default": "day", "description": "How to group the data"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_data_quality_report",
|
||||
Description: "Get a comprehensive quality assessment of observations. Shows completeness distribution, common issues, and improvement suggestions.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"project": map[string]any{"type": "string", "description": "Filter by project (optional)"},
|
||||
"limit": map[string]any{"type": "number", "default": 100, "minimum": 10, "maximum": 500, "description": "Number of observations to analyze"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "batch_tag_by_pattern",
|
||||
Description: "Apply tags to observations matching a pattern. Useful for retroactive organization and categorization.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"pattern", "tags"},
|
||||
"properties": map[string]any{
|
||||
"pattern": map[string]any{"type": "string", "description": "Search pattern to match (searches title, narrative, facts)"},
|
||||
"tags": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Tags to add to matching observations"},
|
||||
"project": map[string]any{"type": "string", "description": "Filter by project (optional)"},
|
||||
"dry_run": map[string]any{"type": "boolean", "default": true, "description": "If true, only preview matches without applying tags"},
|
||||
"max_matches": map[string]any{"type": "number", "default": 100, "minimum": 1, "maximum": 500, "description": "Maximum observations to tag"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "explain_search_ranking",
|
||||
Description: "Debug search results by showing score breakdown for top matches. Explains why each observation ranked where it did.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"query"},
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "Search query to analyze"},
|
||||
"project": map[string]any{"type": "string", "description": "Project context for search"},
|
||||
"top_n": map[string]any{"type": "number", "default": 5, "minimum": 1, "maximum": 20, "description": "Number of top results to explain"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "export_observations",
|
||||
Description: "Export observations in various formats for backup or analysis.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"format": map[string]any{"type": "string", "enum": []string{"json", "jsonl", "markdown"}, "default": "json", "description": "Export format"},
|
||||
"project": map[string]any{"type": "string", "description": "Filter by project (optional)"},
|
||||
"limit": map[string]any{"type": "number", "default": 100, "minimum": 1, "maximum": 1000, "description": "Maximum observations to export"},
|
||||
"date_start": map[string]any{"type": "number", "description": "Filter by creation date (epoch milliseconds)"},
|
||||
"date_end": map[string]any{"type": "number", "description": "Filter by creation date (epoch milliseconds)"},
|
||||
"obs_type": map[string]any{"type": "string", "description": "Filter by observation type"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "check_system_health",
|
||||
Description: "Comprehensive system health check. Returns status of all subsystems (database, vectors, cache, search) with actionable diagnostics.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "analyze_search_patterns",
|
||||
Description: "Analyze search query patterns to identify common searches, missed queries, and optimization opportunities.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"days": map[string]any{"type": "number", "default": 7, "minimum": 1, "maximum": 30, "description": "Number of days to analyze"},
|
||||
"top_n": map[string]any{"type": "number", "default": 10, "minimum": 1, "maximum": 50, "description": "Number of top patterns to return"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_observation_relationships",
|
||||
Description: "Get relationship graph for an observation. Shows how observations relate to each other (depends_on, extends, conflicts_with, supersedes). Useful for understanding dependencies and context.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"id"},
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "number", "description": "Observation ID to analyze relationships for"},
|
||||
"max_depth": map[string]any{"type": "number", "default": 2, "minimum": 1, "maximum": 5, "description": "How many hops to traverse (1=direct, 2=neighbors of neighbors)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_observation_scoring_breakdown",
|
||||
Description: "Get detailed scoring breakdown for an observation. Shows how importance scores are calculated including type weight, recency decay, feedback contribution, concept boost, and retrieval frequency. Useful for understanding why observations are ranked the way they are.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"id"},
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "number", "description": "Observation ID to get scoring breakdown for"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "analyze_observation_importance",
|
||||
Description: "Analyze observation importance patterns in a project. Returns statistics on feedback distribution, top-scoring observations, most-retrieved observations, and concept weights. Useful for understanding what makes observations valuable.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"project": map[string]any{"type": "string", "description": "Project to analyze (optional, analyzes all if omitted)"},
|
||||
"include_top_scored": map[string]any{"type": "boolean", "default": true, "description": "Include top-scoring observations"},
|
||||
"include_most_retrieved": map[string]any{"type": "boolean", "default": true, "description": "Include most-retrieved observations"},
|
||||
"include_concept_weights": map[string]any{"type": "boolean", "default": true, "description": "Include concept weight analysis"},
|
||||
"limit": map[string]any{"type": "number", "default": 10, "minimum": 1, "maximum": 50, "description": "Number of top observations to include"},
|
||||
"action": map[string]any{"type": "string", "enum": []string{"stats", "health", "maintenance_stats", "run_maintenance", "importance", "search_patterns", "explain_ranking", "temporal_trends", "data_quality", "export", "suggest_consolidations", "patterns"}, "description": "Operation to perform"},
|
||||
"project": map[string]any{"type": "string", "description": "Filter by project (importance, temporal_trends, data_quality, export, suggest_consolidations, patterns, explain_ranking)"},
|
||||
"query": map[string]any{"type": "string", "description": "explain_ranking: query to analyze; patterns: search by name/description"},
|
||||
"top_n": map[string]any{"type": "number", "description": "Top results (explain_ranking, search_patterns)"},
|
||||
"days": map[string]any{"type": "number", "description": "Days to analyze (search_patterns, temporal_trends)"},
|
||||
"group_by": map[string]any{"type": "string", "enum": []string{"day", "week", "hour_of_day"}, "description": "temporal_trends: grouping (default day)"},
|
||||
"limit": map[string]any{"type": "number", "description": "Max results (importance, data_quality, export, suggest_consolidations, patterns)"},
|
||||
"min_similarity": map[string]any{"type": "number", "minimum": 0.5, "maximum": 1.0, "description": "suggest_consolidations: grouping threshold (default 0.8)"},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"json", "jsonl", "markdown"}, "description": "export: output format (default json)"},
|
||||
"date_start": map[string]any{"type": "number", "description": "export: filter start (epoch ms)"},
|
||||
"date_end": map[string]any{"type": "number", "description": "export: filter end (epoch ms)"},
|
||||
"obs_type": map[string]any{"type": "string", "description": "export: filter by observation type"},
|
||||
"type": map[string]any{"type": "string", "enum": []string{"workflow", "preference", "best_practice", "anti_pattern", "tooling"}, "description": "patterns: filter by pattern type"},
|
||||
"include_top_scored": map[string]any{"type": "boolean", "description": "importance: include top-scoring (default true)"},
|
||||
"include_most_retrieved": map[string]any{"type": "boolean", "description": "importance: include most-retrieved (default true)"},
|
||||
"include_concept_weights": map[string]any{"type": "boolean", "description": "importance: include concept weights (default true)"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -833,6 +423,55 @@ type searchArgs struct {
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
// observationActions maps the 'observation' tool's action values to the internal tool name that implements them.
|
||||
var observationActions = map[string]string{
|
||||
"get": "get_observation",
|
||||
"edit": "edit_observation",
|
||||
"delete": "bulk_delete_observations",
|
||||
"supersede": "bulk_mark_superseded",
|
||||
"boost": "bulk_boost_observations",
|
||||
"merge": "merge_observations",
|
||||
"related": "find_related_observations",
|
||||
"similar": "find_similar_observations",
|
||||
"quality": "get_observation_quality",
|
||||
"relationships": "get_observation_relationships",
|
||||
"scoring": "get_observation_scoring_breakdown",
|
||||
"tag": "tag_observation",
|
||||
"by_tag": "get_observations_by_tag",
|
||||
"batch_tag": "batch_tag_by_pattern",
|
||||
}
|
||||
|
||||
// adminActions maps the 'memory_admin' tool's action values to the internal tool name that implements them.
|
||||
var adminActions = map[string]string{
|
||||
"stats": "get_memory_stats",
|
||||
"health": "check_system_health",
|
||||
"maintenance_stats": "get_maintenance_stats",
|
||||
"run_maintenance": "trigger_maintenance",
|
||||
"importance": "analyze_observation_importance",
|
||||
"search_patterns": "analyze_search_patterns",
|
||||
"explain_ranking": "explain_search_ranking",
|
||||
"temporal_trends": "get_temporal_trends",
|
||||
"data_quality": "get_data_quality_report",
|
||||
"export": "export_observations",
|
||||
"suggest_consolidations": "suggest_consolidations",
|
||||
"patterns": "get_patterns",
|
||||
}
|
||||
|
||||
// dispatchAction routes a multiplexed tool call to its underlying implementation by reading the "action" field.
|
||||
func (s *Server) dispatchAction(ctx context.Context, tool string, actions map[string]string, args json.RawMessage) (string, error) {
|
||||
var a struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return "", fmt.Errorf("%s: invalid arguments: %w", tool, err)
|
||||
}
|
||||
target, ok := actions[a.Action]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s: unknown action %q", tool, a.Action)
|
||||
}
|
||||
return s.callTool(ctx, target, args)
|
||||
}
|
||||
|
||||
// callTool dispatches to the appropriate tool handler by proxying to the worker HTTP API.
|
||||
func (s *Server) callTool(ctx context.Context, name string, args json.RawMessage) (string, error) {
|
||||
// Parse common search params used by many tools
|
||||
@@ -849,6 +488,10 @@ func (s *Server) callTool(ctx context.Context, name string, args json.RawMessage
|
||||
// --- Search-based tools: proxy to GET /api/context/search ---
|
||||
case "search":
|
||||
return s.handleSearchProxy(ctx, sa)
|
||||
case "observation":
|
||||
return s.dispatchAction(ctx, "observation", observationActions, args)
|
||||
case "memory_admin":
|
||||
return s.dispatchAction(ctx, "memory_admin", adminActions, args)
|
||||
case "decisions":
|
||||
sa.ObsType = "decision"
|
||||
return s.handleSearchProxy(ctx, sa)
|
||||
@@ -917,7 +560,7 @@ func (s *Server) callTool(ctx context.Context, name string, args json.RawMessage
|
||||
"project": s.project,
|
||||
})
|
||||
case "trigger_maintenance":
|
||||
return s.proxyPostRaw(ctx, "/api/scoring/recalculate", nil)
|
||||
return s.proxyPostRaw(ctx, "/api/maintenance/run", nil)
|
||||
case "analyze_observation_importance":
|
||||
return s.handleAnalyzeImportanceProxy(ctx, args)
|
||||
case "analyze_search_patterns":
|
||||
|
||||
+59
-44
@@ -434,12 +434,7 @@ func TestHandleToolsList(t *testing.T) {
|
||||
toolNames[tool.Name] = true
|
||||
}
|
||||
|
||||
expectedTools := []string{
|
||||
"search", "timeline", "decisions", "changes",
|
||||
"how_it_works", "find_by_concept", "find_by_file",
|
||||
"find_by_type", "get_recent_context", "get_context_timeline",
|
||||
"get_timeline_by_query",
|
||||
}
|
||||
expectedTools := []string{"search", "timeline", "observation", "memory_admin"}
|
||||
|
||||
for _, name := range expectedTools {
|
||||
assert.True(t, toolNames[name], "expected tool %s to be present", name)
|
||||
@@ -1707,43 +1702,10 @@ func TestCallTool_ToolNameRecognition(t *testing.T) {
|
||||
|
||||
// Verify all expected tools are registered
|
||||
expectedTools := map[string]bool{
|
||||
"search": true,
|
||||
"timeline": true,
|
||||
"decisions": true,
|
||||
"changes": true,
|
||||
"how_it_works": true,
|
||||
"find_by_concept": true,
|
||||
"find_by_file": true,
|
||||
"find_by_type": true,
|
||||
"get_recent_context": true,
|
||||
"get_context_timeline": true,
|
||||
"get_timeline_by_query": true,
|
||||
"find_related_observations": true,
|
||||
"find_similar_observations": true,
|
||||
"get_patterns": true,
|
||||
"get_memory_stats": true,
|
||||
"bulk_delete_observations": true,
|
||||
"bulk_mark_superseded": true,
|
||||
"bulk_boost_observations": true,
|
||||
"trigger_maintenance": true,
|
||||
"get_maintenance_stats": true,
|
||||
"merge_observations": true,
|
||||
"get_observation": true,
|
||||
"edit_observation": true,
|
||||
"get_observation_quality": true,
|
||||
"suggest_consolidations": true,
|
||||
"tag_observation": true,
|
||||
"get_observations_by_tag": true,
|
||||
"get_temporal_trends": true,
|
||||
"get_data_quality_report": true,
|
||||
"batch_tag_by_pattern": true,
|
||||
"explain_search_ranking": true,
|
||||
"export_observations": true,
|
||||
"check_system_health": true,
|
||||
"analyze_search_patterns": true,
|
||||
"get_observation_relationships": true,
|
||||
"get_observation_scoring_breakdown": true,
|
||||
"analyze_observation_importance": true,
|
||||
"search": true,
|
||||
"timeline": true,
|
||||
"observation": true,
|
||||
"memory_admin": true,
|
||||
}
|
||||
|
||||
foundTools := make(map[string]bool)
|
||||
@@ -3219,7 +3181,7 @@ func TestRun_SemaphoreDoesNotBlockMainLoop(t *testing.T) {
|
||||
_, _ = io.WriteString(w, `{}`)
|
||||
}))
|
||||
defer func() {
|
||||
handlerCancel() // unblock all handlers first
|
||||
handlerCancel() // unblock all handlers first
|
||||
ts.CloseClientConnections()
|
||||
ts.Close()
|
||||
}()
|
||||
@@ -3418,3 +3380,56 @@ func TestHandleToolsCall_ErrorUsesIsError(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, errText, "unknown tool: nonexistent_tool")
|
||||
}
|
||||
|
||||
// TestDispatchAction verifies the multiplexed tool action routing.
|
||||
// Only action-routing error cases are asserted: a nil HTTP client means valid
|
||||
// actions fail later at the HTTP layer, but unknown/invalid actions short-circuit
|
||||
// before any HTTP call.
|
||||
func TestDispatchAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := NewServer(nil, "", "", "1.0.0")
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tool string
|
||||
args string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "observation unknown action",
|
||||
tool: "observation",
|
||||
args: `{"action":"bogus"}`,
|
||||
wantErr: "unknown action",
|
||||
},
|
||||
{
|
||||
name: "memory_admin unknown action",
|
||||
tool: "memory_admin",
|
||||
args: `{"action":"bogus"}`,
|
||||
wantErr: "unknown action",
|
||||
},
|
||||
{
|
||||
name: "observation no action field",
|
||||
tool: "observation",
|
||||
args: `{}`,
|
||||
wantErr: "unknown action",
|
||||
},
|
||||
{
|
||||
name: "observation invalid action JSON",
|
||||
tool: "observation",
|
||||
args: `{"action": 123}`,
|
||||
wantErr: "invalid arguments",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := server.callTool(ctx, tt.tool, json.RawMessage(tt.args))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -85,23 +85,23 @@ func EnsureModel(assetName, url, expectedSHA256 string) (string, error) {
|
||||
return modelPath, nil
|
||||
}
|
||||
log.Warn().Str("model", assetName).Msg("Cached model corrupted, re-downloading")
|
||||
os.Remove(modelPath)
|
||||
_ = os.Remove(modelPath) // best-effort cleanup
|
||||
}
|
||||
} else if len(currentVersion) > 0 {
|
||||
log.Info().Str("old", string(currentVersion)).Str("new", ModelVersion).Msg("Model version updated, clearing cache")
|
||||
os.RemoveAll(dir)
|
||||
_ = os.RemoveAll(dir) // best-effort cleanup
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return "", fmt.Errorf("create model cache dir: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := downloadWithRetries(url, modelPath); err != nil {
|
||||
os.Remove(modelPath)
|
||||
_ = os.Remove(modelPath) // best-effort cleanup
|
||||
return "", fmt.Errorf("download %s: %w", assetName, err)
|
||||
}
|
||||
|
||||
if valid, err := verifyChecksum(modelPath, expectedSHA256); err != nil || !valid {
|
||||
os.Remove(modelPath)
|
||||
_ = os.Remove(modelPath) // best-effort cleanup
|
||||
return "", fmt.Errorf("downloaded model %s failed checksum verification — the file at %s may have changed", assetName, url)
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ func downloadFile(url, dest string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("HTTP GET: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }() // best-effort cleanup
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
@@ -148,7 +148,7 @@ func downloadFile(url, dest string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
cleanup := func() { f.Close(); os.Remove(tmp) }
|
||||
cleanup := func() { _ = f.Close(); _ = os.Remove(tmp) } // best-effort cleanup
|
||||
|
||||
name := filepath.Base(dest)
|
||||
reader := &progressReader{
|
||||
@@ -166,10 +166,10 @@ func downloadFile(url, dest string) error {
|
||||
cleanup()
|
||||
return fmt.Errorf("sync file: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
_ = f.Close() // best-effort cleanup; data already flushed via Sync above
|
||||
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
os.Remove(tmp)
|
||||
_ = os.Remove(tmp) // best-effort cleanup
|
||||
return fmt.Errorf("commit file: %w", err)
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ func verifyChecksum(path, expectedHex string) (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
defer func() { _ = f.Close() }() // best-effort cleanup
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
@@ -249,7 +249,7 @@ func CleanStale() error {
|
||||
tmpPath := filepath.Join(dir, e.Name())
|
||||
if info, err := e.Info(); err == nil {
|
||||
if time.Since(info.ModTime()) > time.Hour {
|
||||
os.Remove(tmpPath)
|
||||
_ = os.Remove(tmpPath) // best-effort cleanup
|
||||
log.Debug().Str("file", tmpPath).Msg("Cleaned stale temp file")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// Package models provides runtime model download and caching.
|
||||
package models
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// sha256Hex returns the hex-encoded SHA-256 of b.
|
||||
func sha256Hex(t *testing.T, b []byte) string {
|
||||
t.Helper()
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// isolateCacheDir points ModelsDir() at a fresh temp directory by overriding
|
||||
// the OS cache-dir resolution env vars (HOME and XDG_CACHE_HOME, which
|
||||
// os.UserCacheDir honors on Linux and macOS). It also clears
|
||||
// CLAUDE_MNEMONIC_MODEL_DIR so EnsureModel takes the download/cache path
|
||||
// rather than the local-dir shortcut. It returns the resulting models dir.
|
||||
func isolateCacheDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
|
||||
t.Setenv("CLAUDE_MNEMONIC_MODEL_DIR", "")
|
||||
|
||||
dir := ModelsDir()
|
||||
require.True(t, strings.HasPrefix(dir, home),
|
||||
"ModelsDir() %q must live under temp HOME %q for hermetic test", dir, home)
|
||||
return dir
|
||||
}
|
||||
|
||||
// fileServer returns an httptest server that serves body for any request and
|
||||
// records how many requests it received.
|
||||
func fileServer(t *testing.T, body []byte) (*httptest.Server, *int32) {
|
||||
t.Helper()
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &hits
|
||||
}
|
||||
|
||||
// TestEnsureModel_Download_CachesValidModel verifies the happy path: a model is
|
||||
// downloaded, passes checksum verification, and the version file is written.
|
||||
func TestEnsureModel_Download_CachesValidModel(t *testing.T) {
|
||||
dir := isolateCacheDir(t)
|
||||
body := []byte("model-bytes-happy-path")
|
||||
srv, hits := fileServer(t, body)
|
||||
|
||||
const asset = "test-model.onnx"
|
||||
path, err := EnsureModel(asset, srv.URL, sha256Hex(t, body))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, filepath.Join(dir, asset), path)
|
||||
got, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, body, got, "cached file content must match downloaded body")
|
||||
|
||||
ver, err := os.ReadFile(filepath.Join(dir, ".model_version"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ModelVersion, string(ver), "version file must record current ModelVersion")
|
||||
|
||||
assert.Equal(t, int32(1), atomic.LoadInt32(hits), "exactly one download on first fetch")
|
||||
}
|
||||
|
||||
// TestEnsureModel_ChecksumMismatch_FailsAndRemoves verifies that a downloaded
|
||||
// file whose content does not match the expected SHA-256 is rejected with a
|
||||
// verification error and is not left behind in the cache.
|
||||
func TestEnsureModel_ChecksumMismatch_FailsAndRemoves(t *testing.T) {
|
||||
dir := isolateCacheDir(t)
|
||||
body := []byte("the-actual-downloaded-bytes")
|
||||
srv, _ := fileServer(t, body)
|
||||
|
||||
const asset = "mismatch-model.onnx"
|
||||
wrongSum := sha256Hex(t, []byte("a-different-expected-payload"))
|
||||
|
||||
path, err := EnsureModel(asset, srv.URL, wrongSum)
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, path)
|
||||
assert.Contains(t, err.Error(), "checksum verification")
|
||||
|
||||
_, statErr := os.Stat(filepath.Join(dir, asset))
|
||||
assert.True(t, os.IsNotExist(statErr),
|
||||
"corrupt download must be removed from cache, got statErr=%v", statErr)
|
||||
}
|
||||
|
||||
// TestEnsureModel_VersionBump_InvalidatesCache verifies that when the on-disk
|
||||
// .model_version differs from ModelVersion, the cache directory is cleared and
|
||||
// the model is re-downloaded fresh.
|
||||
func TestEnsureModel_VersionBump_InvalidatesCache(t *testing.T) {
|
||||
dir := isolateCacheDir(t)
|
||||
require.NoError(t, os.MkdirAll(dir, 0700))
|
||||
|
||||
// Seed a stale cache: an old version file, a leftover model file, and an
|
||||
// unrelated sidecar file. Only the version-bump branch wipes the whole dir
|
||||
// (os.RemoveAll), so the sidecar's removal uniquely proves invalidation ran
|
||||
// — a plain checksum-driven re-download would leave the sidecar in place.
|
||||
const asset = "versioned-model.onnx"
|
||||
staleModel := filepath.Join(dir, asset)
|
||||
staleSidecar := filepath.Join(dir, "leftover-sidecar.bin")
|
||||
require.NoError(t, os.WriteFile(staleModel, []byte("stale-garbage"), 0600))
|
||||
require.NoError(t, os.WriteFile(staleSidecar, []byte("unrelated-leftover"), 0600))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, ".model_version"), []byte("OLD-VERSION"), 0600))
|
||||
|
||||
body := []byte("freshly-downloaded-model-bytes")
|
||||
srv, hits := fileServer(t, body)
|
||||
|
||||
path, err := EnsureModel(asset, srv.URL, sha256Hex(t, body))
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, body, got, "stale cached file must be replaced by fresh download")
|
||||
|
||||
ver, err := os.ReadFile(filepath.Join(dir, ".model_version"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ModelVersion, string(ver), "version file must be rewritten to current ModelVersion")
|
||||
|
||||
assert.Equal(t, int32(1), atomic.LoadInt32(hits),
|
||||
"version bump must force exactly one re-download, not serve the stale file")
|
||||
|
||||
_, sidecarErr := os.Stat(staleSidecar)
|
||||
assert.True(t, os.IsNotExist(sidecarErr),
|
||||
"version bump must wipe the whole cache dir, removing unrelated sidecar files")
|
||||
}
|
||||
|
||||
// TestEnsureModel_SameVersionValidCache_NoDownload verifies that a matching
|
||||
// version file plus a checksum-valid cached file short-circuits the download.
|
||||
func TestEnsureModel_SameVersionValidCache_NoDownload(t *testing.T) {
|
||||
dir := isolateCacheDir(t)
|
||||
require.NoError(t, os.MkdirAll(dir, 0700))
|
||||
|
||||
const asset = "cached-model.onnx"
|
||||
body := []byte("already-cached-valid-bytes")
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, asset), body, 0600))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, ".model_version"), []byte(ModelVersion), 0600))
|
||||
|
||||
// Server would record a hit if EnsureModel wrongly re-downloaded.
|
||||
srv, hits := fileServer(t, []byte("should-not-be-fetched"))
|
||||
|
||||
path, err := EnsureModel(asset, srv.URL, sha256Hex(t, body))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(dir, asset), path)
|
||||
assert.Equal(t, int32(0), atomic.LoadInt32(hits), "valid same-version cache must not trigger a download")
|
||||
}
|
||||
|
||||
// TestEnsureModel_RetryExhaustion_ReturnsError verifies that when every download
|
||||
// attempt fails (server always returns HTTP 500), EnsureModel exhausts its
|
||||
// retries and returns an error reporting the attempt count, leaving no model
|
||||
// file behind.
|
||||
//
|
||||
// Note: downloadWithRetries sleeps with exponential backoff between attempts,
|
||||
// so this test takes ~6s by design; the backoff is production behavior and is
|
||||
// intentionally not stubbed.
|
||||
func TestEnsureModel_RetryExhaustion_ReturnsError(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping retry-exhaustion test (real backoff sleeps) in -short mode")
|
||||
}
|
||||
|
||||
dir := isolateCacheDir(t)
|
||||
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
const asset = "unreachable-model.onnx"
|
||||
path, err := EnsureModel(asset, srv.URL, sha256Hex(t, []byte("never-served")))
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, path)
|
||||
assert.Contains(t, err.Error(), "attempts", "error must report retry exhaustion")
|
||||
|
||||
assert.Equal(t, int32(maxRetries), atomic.LoadInt32(&hits),
|
||||
"server must be hit exactly maxRetries times")
|
||||
|
||||
_, statErr := os.Stat(filepath.Join(dir, asset))
|
||||
assert.True(t, os.IsNotExist(statErr), "failed download must not leave a model file")
|
||||
}
|
||||
|
||||
// TestEnsureModel_LocalDir verifies the CLAUDE_MNEMONIC_MODEL_DIR shortcut:
|
||||
// a present, checksum-valid file is returned directly, and a checksum mismatch
|
||||
// or missing file is reported without any network access.
|
||||
func TestEnsureModel_LocalDir(t *testing.T) {
|
||||
body := []byte("local-dir-model-bytes")
|
||||
validSum := sha256Hex(t, body)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
writeFile bool
|
||||
expectSum string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "valid_local_model", writeFile: true, expectSum: validSum, wantErr: ""},
|
||||
{name: "checksum_mismatch", writeFile: true, expectSum: sha256Hex(t, []byte("other")), wantErr: "checksum mismatch"},
|
||||
{name: "missing_file", writeFile: false, expectSum: validSum, wantErr: "not found"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
localDir := t.TempDir()
|
||||
t.Setenv("CLAUDE_MNEMONIC_MODEL_DIR", localDir)
|
||||
|
||||
const asset = "local-model.onnx"
|
||||
if tt.writeFile {
|
||||
require.NoError(t, os.WriteFile(filepath.Join(localDir, asset), body, 0600))
|
||||
}
|
||||
|
||||
path, err := EnsureModel(asset, "http://127.0.0.1:0/should-not-be-used", tt.expectSum)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
assert.Empty(t, path)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(localDir, asset), path)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,10 @@ const (
|
||||
CrossEncoderAssetURL = "https://huggingface.co/cross-encoder/ms-marco-MiniLM-L6-v2/resolve/main/onnx/model.onnx"
|
||||
// CrossEncoderModelSHA256 is the expected SHA-256 of the cross-encoder model file.
|
||||
CrossEncoderModelSHA256 = "5d3e70fd0c9ff14b9b5169a51e957b7a9c74897afd0a35ce4bd318150c1d4d4a"
|
||||
|
||||
// DefaultAlpha is the default score-combining weight, favoring the
|
||||
// cross-encoder: combined = alpha*rerank + (1-alpha)*original.
|
||||
DefaultAlpha = 0.7
|
||||
)
|
||||
|
||||
// Candidate represents a search result candidate for reranking.
|
||||
@@ -79,7 +83,7 @@ type Config struct {
|
||||
// DefaultConfig returns sensible defaults for reranking.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Alpha: 0.7, // Favor cross-encoder by default
|
||||
Alpha: DefaultAlpha, // Favor cross-encoder by default
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +125,7 @@ func NewService(cfg Config) (*Service, error) {
|
||||
|
||||
alpha := cfg.Alpha
|
||||
if alpha <= 0 || alpha > 1 {
|
||||
alpha = 0.7
|
||||
alpha = DefaultAlpha
|
||||
}
|
||||
|
||||
return &Service{
|
||||
|
||||
@@ -8,6 +8,11 @@ import (
|
||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||
)
|
||||
|
||||
// retrievalLogFactor scales the diminishing-returns retrieval boost:
|
||||
// log2(retrieval_count + 1) is multiplied by this factor before applying
|
||||
// the configured retrieval weight.
|
||||
const retrievalLogFactor = 0.1
|
||||
|
||||
// Calculator computes importance scores for observations.
|
||||
type Calculator struct {
|
||||
config *models.ScoringConfig
|
||||
@@ -72,7 +77,7 @@ func (c *Calculator) CalculateComponents(obs *models.Observation, now time.Time)
|
||||
retrievalContrib := 0.0
|
||||
if obs.RetrievalCount > 0 {
|
||||
// log2(count + 1) gives diminishing returns: 1→1, 3→2, 7→3, 15→4, etc.
|
||||
retrievalBoost := math.Log2(float64(obs.RetrievalCount)+1) * 0.1
|
||||
retrievalBoost := math.Log2(float64(obs.RetrievalCount)+1) * retrievalLogFactor
|
||||
retrievalContrib = retrievalBoost * c.config.RetrievalWeight
|
||||
}
|
||||
|
||||
|
||||
@@ -254,12 +254,16 @@ func (u *Updater) ApplyUpdate(ctx context.Context, info *UpdateInfo) error {
|
||||
u.setStatus("verifying", 0.2, "Verifying signature...")
|
||||
|
||||
if info.ChecksumsURL != "" && info.BundleURL != "" {
|
||||
// Fail-closed: a signature bundle is published for this release, so a
|
||||
// verification failure (including cosign being unavailable) MUST abort
|
||||
// the update before any binary is replaced. Silently proceeding would
|
||||
// allow an unverified binary to overwrite the running one.
|
||||
if err := u.verifySigstoreBundle(ctx, checksumsPath, bundlePath); err != nil {
|
||||
// Log warning but continue - signature verification is optional if cosign isn't installed
|
||||
log.Warn().Err(err).Msg("Signature verification failed or skipped")
|
||||
} else {
|
||||
log.Info().Msg("Sigstore signature verification passed")
|
||||
u.setError(err)
|
||||
return fmt.Errorf("release signature verification failed; aborting update for safety, please update manually with: %s: %w",
|
||||
GetManualUpdateCommand("v"+info.LatestVersion), err)
|
||||
}
|
||||
log.Info().Msg("Sigstore signature verification passed")
|
||||
}
|
||||
|
||||
// Step 3: Download the archive
|
||||
|
||||
@@ -182,9 +182,9 @@ func TestGetManualUpdateCommand(t *testing.T) {
|
||||
wantNotContains []string
|
||||
}{
|
||||
{
|
||||
name: "empty_version_returns_latest",
|
||||
version: "",
|
||||
wantContains: []string{"curl -sSL", InstallScriptURL, "| bash"},
|
||||
name: "empty_version_returns_latest",
|
||||
version: "",
|
||||
wantContains: []string{"curl -sSL", InstallScriptURL, "| bash"},
|
||||
wantNotContains: []string{"bash -s --"},
|
||||
},
|
||||
{
|
||||
@@ -312,9 +312,9 @@ func makeTarGzFile(t *testing.T, dir, content string) (path string, checksum str
|
||||
|
||||
body := []byte(content)
|
||||
hdr := &tar.Header{
|
||||
Name: "worker",
|
||||
Mode: 0755,
|
||||
Size: int64(len(body)),
|
||||
Name: "worker",
|
||||
Mode: 0755,
|
||||
Size: int64(len(body)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
require.NoError(t, tw.WriteHeader(hdr))
|
||||
@@ -455,9 +455,9 @@ func makeTarGzArchive(t *testing.T, files map[string]string) []byte {
|
||||
for name, content := range files {
|
||||
body := []byte(content)
|
||||
hdr := &tar.Header{
|
||||
Name: name,
|
||||
Mode: 0755,
|
||||
Size: int64(len(body)),
|
||||
Name: name,
|
||||
Mode: 0755,
|
||||
Size: int64(len(body)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
require.NoError(t, tw.WriteHeader(hdr))
|
||||
|
||||
@@ -314,6 +314,49 @@ func (s *Service) handleTriggerRecalculation(w http.ResponseWriter, r *http.Requ
|
||||
writeJSON(w, map[string]string{"status": "recalculation triggered"})
|
||||
}
|
||||
|
||||
// handleRunMaintenance triggers an immediate, synchronous database maintenance run
|
||||
// (Optimize/TRUNCATE checkpoint + prompt cleanup + any enabled retention/stale cleanup)
|
||||
// and also kicks off an importance-score recalculation in the background so the behavior
|
||||
// of the previous trigger_maintenance tool is preserved (issue #49).
|
||||
func (s *Service) handleRunMaintenance(w http.ResponseWriter, r *http.Request) {
|
||||
// initMu.RLock held by requireReady middleware
|
||||
maintSvc := s.maintenanceSvc
|
||||
recalculator := s.recalculator
|
||||
|
||||
if maintSvc == nil {
|
||||
http.Error(w, "maintenance service not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Run maintenance synchronously with an independent, bounded context so the caller
|
||||
// receives a real completion status. Use context.Background so an HTTP client timeout
|
||||
// does not abort an in-progress DB maintenance pass.
|
||||
mctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
maintSvc.RunNowSync(mctx)
|
||||
|
||||
// Preserve prior trigger_maintenance behavior: also recalculate importance scores.
|
||||
recalcTriggered := false
|
||||
if recalculator != nil {
|
||||
recalcTriggered = true
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
if err := recalculator.RecalculateNow(ctx); err != nil {
|
||||
log.Error().Err(err).Msg("Background recalculation during maintenance failed")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{
|
||||
"status": "maintenance completed",
|
||||
"recalc_triggered": recalcTriggered,
|
||||
"maintenance_stats": maintSvc.Stats(),
|
||||
})
|
||||
}
|
||||
|
||||
// parseIntParam parses an integer query parameter with a default value.
|
||||
func parseIntParam(r *http.Request, name string, defaultVal int) int {
|
||||
if val := r.URL.Query().Get(name); val != "" {
|
||||
|
||||
@@ -258,7 +258,6 @@ func (s *Service) handleObservation(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Find session
|
||||
sess, err := s.sessionStore.FindAnySDKSession(r.Context(), req.ClaudeSessionID)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ package worker
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -121,6 +122,22 @@ func NewTokenAuth(enabled bool) (*TokenAuth, error) {
|
||||
return ta, nil
|
||||
}
|
||||
|
||||
// NewTokenAuthWithToken creates a TokenAuth using an explicitly supplied token.
|
||||
// It is used to enforce a caller-provided token (e.g. from an environment
|
||||
// variable) so the value is known to clients, unlike the random token produced
|
||||
// by NewTokenAuth. Authentication is enabled only when token is non-empty.
|
||||
func NewTokenAuthWithToken(token string) *TokenAuth {
|
||||
return &TokenAuth{
|
||||
enabled: token != "",
|
||||
token: token,
|
||||
ExemptPaths: map[string]bool{
|
||||
"/health": true,
|
||||
"/api/health": true,
|
||||
"/api/ready": true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Token returns the authentication token.
|
||||
// Returns empty string if authentication is disabled.
|
||||
func (ta *TokenAuth) Token() string {
|
||||
@@ -161,7 +178,8 @@ func (ta *TokenAuth) Middleware(next http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
if providedToken != token {
|
||||
// Constant-time comparison to avoid leaking the token via timing.
|
||||
if subtle.ConstantTimeCompare([]byte(providedToken), []byte(token)) != 1 {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
+105
-9
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/db/gorm"
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/maintenance"
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/pattern"
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/reranking"
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/scoring"
|
||||
@@ -44,6 +45,14 @@ const (
|
||||
// QueueProcessInterval is how often the background queue processor runs.
|
||||
QueueProcessInterval = 2 * time.Second
|
||||
|
||||
// WALCheckpointInterval is how often the worker checks whether the SQLite WAL needs a
|
||||
// TRUNCATE checkpoint to reclaim disk and prevent unbounded growth (issue #49).
|
||||
WALCheckpointInterval = 60 * time.Second
|
||||
|
||||
// WALCheckpointThreshold is the WAL file size at or above which the periodic check
|
||||
// performs a TRUNCATE checkpoint. Keeps the steady-state WAL bounded to a few MB.
|
||||
WALCheckpointThreshold = 4 << 20 // 4 MiB
|
||||
|
||||
// reinitializationDrainDelay is the delay after marking the service as not ready
|
||||
// to allow in-flight requests to complete before reinitializing.
|
||||
reinitializationDrainDelay = 200 * time.Millisecond
|
||||
@@ -121,6 +130,7 @@ type Service struct {
|
||||
patternStore *gorm.PatternStore
|
||||
relationStore *gorm.RelationStore
|
||||
patternDetector *pattern.Detector
|
||||
maintenanceSvc *maintenance.Service
|
||||
sessionManager *session.Manager
|
||||
sseBroadcaster *sse.Broadcaster
|
||||
processor *sdk.Processor
|
||||
@@ -146,7 +156,6 @@ type Service struct {
|
||||
updater *update.Updater
|
||||
rateLimiter *PerClientRateLimiter
|
||||
expensiveOpLimiter *ExpensiveOperationLimiter
|
||||
contextCache sync.Map
|
||||
version string
|
||||
recentQueriesBuf [maxRecentQueries]RecentSearchQuery
|
||||
wg sync.WaitGroup
|
||||
@@ -187,13 +196,6 @@ type staleVerifyRequest struct {
|
||||
observationID int64
|
||||
}
|
||||
|
||||
// contextCacheEntry caches clustering results for context injection.
|
||||
type contextCacheEntry struct {
|
||||
timestamp time.Time
|
||||
observations []*models.Observation
|
||||
obsCount int
|
||||
}
|
||||
|
||||
// RecentSearchQuery tracks a search query for analytics.
|
||||
type RecentSearchQuery struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
@@ -570,6 +572,34 @@ func (s *Service) initializeAsync() {
|
||||
go s.processQueue()
|
||||
}
|
||||
|
||||
// Start periodic WAL checkpoint loop to bound SQLite WAL file growth (issue #49).
|
||||
s.wg.Add(1)
|
||||
go s.walCheckpointLoop()
|
||||
|
||||
// Start the scheduled maintenance service (issue #49: was dead code, never instantiated).
|
||||
// vectorCleanupFn mirrors the observation store's cleanup hook so age/stale deletions done
|
||||
// directly via GORM still remove their vectors from sqlite-vec.
|
||||
var vectorCleanupFn func(ctx context.Context, deletedIDs []int64)
|
||||
if vectorSync != nil {
|
||||
vectorCleanupFn = func(ctx context.Context, deletedIDs []int64) {
|
||||
if err := retryWithBackoff(ctx, VectorSyncMaxRetries, VectorSyncInitialBackoff, func() error {
|
||||
return vectorSync.DeleteObservations(ctx, deletedIDs)
|
||||
}); err != nil {
|
||||
log.Warn().Err(err).Ints64("ids", deletedIDs).Msg("Failed to delete observations from sqlite-vec during maintenance")
|
||||
}
|
||||
}
|
||||
}
|
||||
maintSvc := maintenance.NewService(store, observationStore, summaryStore, promptStore, vectorCleanupFn, s.config, log.Logger)
|
||||
s.initMu.Lock()
|
||||
s.maintenanceSvc = maintSvc
|
||||
s.initMu.Unlock()
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
maintSvc.Start(s.ctx)
|
||||
}()
|
||||
log.Info().Msg("Maintenance scheduler started")
|
||||
|
||||
// Start file watchers for auto-recreation on deletion
|
||||
s.startWatchers()
|
||||
|
||||
@@ -1202,6 +1232,19 @@ func (s *Service) setupMiddleware() {
|
||||
s.router.Use(PerClientRateLimitMiddleware(s.rateLimiter))
|
||||
}
|
||||
|
||||
// Token authentication: opt-in and default-preserving.
|
||||
// When CLAUDE_MNEMONIC_AUTH_TOKEN is set, every request (except exempt
|
||||
// health/ready paths) must present that token via X-Auth-Token or
|
||||
// "Authorization: Bearer". When the env var is unset (the default), no
|
||||
// token is configured, auth stays disabled, and the server starts as
|
||||
// before. The worker binds loopback only, so this is defense-in-depth for
|
||||
// shared/multi-user hosts rather than a hard requirement.
|
||||
if authToken := os.Getenv("CLAUDE_MNEMONIC_AUTH_TOKEN"); authToken != "" {
|
||||
ta := NewTokenAuthWithToken(authToken)
|
||||
s.router.Use(ta.Middleware)
|
||||
log.Info().Msg("Token authentication enabled (CLAUDE_MNEMONIC_AUTH_TOKEN set)")
|
||||
}
|
||||
|
||||
// Note: Timeout middleware is applied per-route, not globally,
|
||||
// to avoid killing SSE connections which need to stay open indefinitely
|
||||
}
|
||||
@@ -1290,6 +1333,9 @@ func (s *Service) setupRoutes() {
|
||||
r.Put("/api/scoring/concepts/{concept}", s.handleUpdateConceptWeight)
|
||||
r.Post("/api/scoring/recalculate", s.handleTriggerRecalculation)
|
||||
|
||||
// Maintenance: run an immediate synchronous DB maintenance pass (issue #49)
|
||||
r.Post("/api/maintenance/run", s.handleRunMaintenance)
|
||||
|
||||
// Context injection
|
||||
r.Get("/api/context/count", s.handleContextCount)
|
||||
r.Get("/api/context/inject", s.handleContextInject)
|
||||
@@ -1547,7 +1593,8 @@ func (s *Service) Start() error {
|
||||
port := config.GetWorkerPort()
|
||||
|
||||
s.server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
// Bind loopback only so the worker is never exposed on external interfaces.
|
||||
Addr: fmt.Sprintf("127.0.0.1:%d", port),
|
||||
Handler: s.router,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
@@ -1621,6 +1668,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.
|
||||
// Messages are processed in parallel using goroutines, with concurrency
|
||||
// limited by a channel-based semaphore.
|
||||
@@ -1748,6 +1841,9 @@ func (s *Service) Shutdown(ctx context.Context) error {
|
||||
if s.patternDetector != nil {
|
||||
s.patternDetector.Stop()
|
||||
}
|
||||
if s.maintenanceSvc != nil {
|
||||
s.maintenanceSvc.Stop()
|
||||
}
|
||||
|
||||
// Phase 4: Shutdown sessions (flush pending work)
|
||||
log.Debug().Msg("Phase 4: Shutting down sessions...")
|
||||
|
||||
+14
-18
@@ -5,33 +5,28 @@ import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
// staticSubFS is the static subdirectory filesystem
|
||||
var staticSubFS fs.FS
|
||||
|
||||
// staticInitErr stores any error from static filesystem initialization
|
||||
var staticInitErr error
|
||||
|
||||
func init() {
|
||||
staticSubFS, staticInitErr = fs.Sub(staticFS, "static")
|
||||
if staticInitErr != nil {
|
||||
log.Warn().Err(staticInitErr).Msg("Static filesystem initialization failed - dashboard will be unavailable")
|
||||
}
|
||||
}
|
||||
// staticFiles lazily resolves the embedded "static" subdirectory exactly once.
|
||||
// Using sync.OnceValues avoids an init() and package-level mutable state while
|
||||
// preserving the previous behavior: the dashboard is served when resolution
|
||||
// succeeds and reports a clear error otherwise.
|
||||
var staticFiles = sync.OnceValues(func() (fs.FS, error) {
|
||||
return fs.Sub(staticFS, "static")
|
||||
})
|
||||
|
||||
// serveIndex serves the index.html file for the root path
|
||||
func serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if staticInitErr != nil {
|
||||
sub, err := staticFiles()
|
||||
if err != nil {
|
||||
http.Error(w, "Dashboard unavailable: static files not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
content, err := fs.ReadFile(staticSubFS, "index.html")
|
||||
content, err := fs.ReadFile(sub, "index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "Dashboard not found", http.StatusNotFound)
|
||||
return
|
||||
@@ -46,14 +41,15 @@ func serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// serveAssets serves static assets from the embedded filesystem
|
||||
func serveAssets(w http.ResponseWriter, r *http.Request) {
|
||||
if staticInitErr != nil {
|
||||
sub, err := staticFiles()
|
||||
if err != nil {
|
||||
http.Error(w, "Assets unavailable: static files not initialized", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
// Strip the /assets/ prefix and serve the file
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
|
||||
content, err := fs.ReadFile(staticSubFS, path)
|
||||
content, err := fs.ReadFile(sub, path)
|
||||
if err != nil {
|
||||
http.Error(w, "Asset not found", http.StatusNotFound)
|
||||
return
|
||||
|
||||
+10
-10
@@ -13,7 +13,7 @@
|
||||
{
|
||||
"name": "claude-mnemonic",
|
||||
"description": "Persistent memory system for Claude Code - Go implementation with SQLite and ChromaDB vector search",
|
||||
"version": "0.12.21",
|
||||
"version": "0.21.23",
|
||||
"author": {
|
||||
"name": "lukaszraczylo",
|
||||
"url": "https://github.com/lukaszraczylo"
|
||||
@@ -23,25 +23,25 @@
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/lukaszraczylo/claude-mnemonic",
|
||||
"releases": {
|
||||
"latest": "0.12.21",
|
||||
"latest": "0.21.23",
|
||||
"versions": {
|
||||
"0.12.21": {
|
||||
"releaseDate": "2026-05-26",
|
||||
"0.21.23": {
|
||||
"releaseDate": "2026-07-09",
|
||||
"downloads": {
|
||||
"darwin-arm64": {
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.12.21/claude-mnemonic_0.12.21_darwin_arm64.tar.gz",
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.23/claude-mnemonic_0.21.23_darwin_arm64.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"sha256": "3f4709de5fe129c1e6ad3fa4bc9347b650e06539ef89f10b77105ff1bc661dee"
|
||||
"sha256": "196ad6fbbcdef5bc9d7c5174261bab784ced59743cae03ba49145fa25020a05e"
|
||||
},
|
||||
"linux-amd64": {
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.12.21/claude-mnemonic_0.12.21_linux_amd64.tar.gz",
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.23/claude-mnemonic_0.21.23_linux_amd64.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"sha256": "0f761ec3eb6d34e51514d4f8f1ea6a62c1b7103ade23ce5ad057143515b8cc91"
|
||||
"sha256": "7888947c9c8185c4662b2b55d652cda0e8e5ae300652b28830f53c6d57c1d46b"
|
||||
},
|
||||
"windows-amd64": {
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.12.21/claude-mnemonic_0.12.21_windows_amd64.zip",
|
||||
"url": "https://github.com/lukaszraczylo/claude-mnemonic/releases/download/v0.21.23/claude-mnemonic_0.21.23_windows_amd64.zip",
|
||||
"format": "zip",
|
||||
"sha256": "34666da29446467bed14b3c6ab92e417bd1db9c8610bfea4edfe5325f69da690"
|
||||
"sha256": "16e3e4ce9e5e6cd2d13764e12d56fe77c1c5cbbe16262ac1b0159ad7703d1762"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,6 +564,44 @@ func POSTWithContext(ctx context.Context, port int, path string, body interface{
|
||||
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.
|
||||
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))
|
||||
@@ -584,6 +622,35 @@ func GET(port int, path string) (map[string]interface{}, error) {
|
||||
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.
|
||||
// Returns true if both versions share the same base version (ignoring -dirty, -dev, commit suffixes).
|
||||
// This prevents unnecessary restarts during development.
|
||||
|
||||
@@ -952,6 +952,203 @@ func TestGET_Timeout(t *testing.T) {
|
||||
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.
|
||||
func TestIsWorkerRunning_Timeout(t *testing.T) {
|
||||
// Non-existent port should quickly return false
|
||||
|
||||
@@ -5,6 +5,7 @@ package sanitize
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// systemXMLTags lists Claude Code internal XML tags that should be stripped
|
||||
@@ -30,16 +31,17 @@ var systemXMLTags = []string{
|
||||
"antml_function_calls",
|
||||
}
|
||||
|
||||
// systemXMLRegexps are compiled regexps for each tag, built once at init.
|
||||
var systemXMLRegexps []*regexp.Regexp
|
||||
|
||||
func init() {
|
||||
systemXMLRegexps = make([]*regexp.Regexp, len(systemXMLTags))
|
||||
// systemXMLRegexps lazily compiles a regexp for each tag exactly once on first
|
||||
// use. The result is immutable after compilation, so sync.OnceValue safely
|
||||
// shares it across callers without re-compiling at package init.
|
||||
var systemXMLRegexps = sync.OnceValue(func() []*regexp.Regexp {
|
||||
res := make([]*regexp.Regexp, len(systemXMLTags))
|
||||
for i, tag := range systemXMLTags {
|
||||
// Match opening tag (with optional attributes), content (including newlines), and closing tag
|
||||
systemXMLRegexps[i] = regexp.MustCompile(`(?s)<` + regexp.QuoteMeta(tag) + `[^>]*>.*?</` + regexp.QuoteMeta(tag) + `>`)
|
||||
res[i] = regexp.MustCompile(`(?s)<` + regexp.QuoteMeta(tag) + `[^>]*>.*?</` + regexp.QuoteMeta(tag) + `>`)
|
||||
}
|
||||
}
|
||||
return res
|
||||
})
|
||||
|
||||
// StripSystemXML removes known Claude Code internal XML blocks from text.
|
||||
// This prevents system artifacts like <task-notification>, <system-reminder>,
|
||||
@@ -50,7 +52,7 @@ func StripSystemXML(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
for _, re := range systemXMLRegexps {
|
||||
for _, re := range systemXMLRegexps() {
|
||||
s = re.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ func TestStripSystemXML(t *testing.T) {
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "task-notification block",
|
||||
input: "Before\n<task-notification>\n<task-id>abc123</task-id>\n<status>completed</status>\n<summary>Agent completed</summary>\n<result>Some result text</result>\n</task-notification>\nAfter",
|
||||
name: "task-notification block",
|
||||
input: "Before\n<task-notification>\n<task-id>abc123</task-id>\n<status>completed</status>\n<summary>Agent completed</summary>\n<result>Some result text</result>\n</task-notification>\nAfter",
|
||||
expected: "Before\n\nAfter",
|
||||
},
|
||||
{
|
||||
@@ -78,8 +78,8 @@ func TestStripSystemXML(t *testing.T) {
|
||||
expected: "Before\n\nAfter",
|
||||
},
|
||||
{
|
||||
name: "real-world task notification",
|
||||
input: "Here is the analysis:\n<task-notification>\n<task-id>a077fd04aed547ce1</task-id>\n<tool-use-id>toolu_01Kw2GwsArQQR1F9aV3VfzhP</tool-use-id>\n<status>completed</status>\n<summary>Agent \"Analyze agency-agents repo structure\" completed</summary>\n<result>Now I have all the information I need. Let me compile a comprehensive report.\n\n## Agency-Agents Directory Exploration Report\n\n### 1. Agent File Count and Organization\n\n**Total Agent Files (excluding strategy docs):** 61 agents across 9 categories</result>\n</task-notification>\nThe repo has 61 agents.",
|
||||
name: "real-world task notification",
|
||||
input: "Here is the analysis:\n<task-notification>\n<task-id>a077fd04aed547ce1</task-id>\n<tool-use-id>toolu_01Kw2GwsArQQR1F9aV3VfzhP</tool-use-id>\n<status>completed</status>\n<summary>Agent \"Analyze agency-agents repo structure\" completed</summary>\n<result>Now I have all the information I need. Let me compile a comprehensive report.\n\n## Agency-Agents Directory Exploration Report\n\n### 1. Agent File Count and Organization\n\n**Total Agent Files (excluding strategy docs):** 61 agents across 9 categories</result>\n</task-notification>\nThe repo has 61 agents.",
|
||||
expected: "Here is the analysis:\n\nThe repo has 61 agents.",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
+980
-1980
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -10,19 +10,19 @@
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"vis-data": "^7.1.9",
|
||||
"vis-network": "^9.1.9",
|
||||
"vue": "^3.5.13"
|
||||
"vis-data": "^8.0.4",
|
||||
"vis-network": "^10.1.0",
|
||||
"vue": "^3.5.34"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@types/node": "^22.10.2",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.2.0"
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.14",
|
||||
"vue-tsc": "^3.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -10,19 +10,19 @@
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"vis-data": "^7.1.9",
|
||||
"vis-network": "^9.1.9",
|
||||
"vue": "^3.5.13"
|
||||
"vis-data": "^8.0.4",
|
||||
"vis-network": "^10.1.0",
|
||||
"vue": "^3.5.34"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@types/node": "^22.10.2",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.2.0"
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.14",
|
||||
"vue-tsc": "^3.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
@config "../../tailwind.config.js";
|
||||
|
||||
@layer components {
|
||||
/* Glass morphism effect */
|
||||
|
||||
+1
-2
@@ -16,9 +16,8 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"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"}
|
||||
Reference in New Issue
Block a user