brainbank 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -1107
- package/assets/architecture.png +0 -0
- package/bin/brainbank +8 -1
- package/bin/brainbank-mcp +19 -0
- package/dist/chunk-3UIWA32X.js +3341 -0
- package/dist/chunk-3UIWA32X.js.map +1 -0
- package/dist/chunk-3YBCD6DI.js +117 -0
- package/dist/chunk-3YBCD6DI.js.map +1 -0
- package/dist/chunk-DAGVUEXL.js +258 -0
- package/dist/chunk-DAGVUEXL.js.map +1 -0
- package/dist/chunk-DMFMTOHF.js +123 -0
- package/dist/chunk-DMFMTOHF.js.map +1 -0
- package/dist/chunk-FQYKWB2Q.js +136 -0
- package/dist/chunk-FQYKWB2Q.js.map +1 -0
- package/dist/chunk-IMJJ2VEM.js +74 -0
- package/dist/chunk-IMJJ2VEM.js.map +1 -0
- package/dist/chunk-M744PCJQ.js +43 -0
- package/dist/chunk-M744PCJQ.js.map +1 -0
- package/dist/chunk-NNDY7P2R.js +211 -0
- package/dist/chunk-NNDY7P2R.js.map +1 -0
- package/dist/chunk-O3J6ZIXK.js +82 -0
- package/dist/chunk-O3J6ZIXK.js.map +1 -0
- package/dist/chunk-RDQYDLYZ.js +69 -0
- package/dist/chunk-RDQYDLYZ.js.map +1 -0
- package/dist/chunk-WCQVDF3K.js +14 -0
- package/dist/cli.js +2713 -325
- package/dist/cli.js.map +1 -1
- package/dist/haiku-pruner-5KVT5AI2.js +8 -0
- package/dist/http-server-2ZQ6I43B.js +9 -0
- package/dist/index.d.ts +1886 -626
- package/dist/index.js +319 -46
- package/dist/index.js.map +1 -1
- package/dist/local-embedding-NZQTILGV.js +8 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +386 -0
- package/dist/mcp.js.map +1 -0
- package/dist/openai-embedding-ZP5TSUJG.js +8 -0
- package/dist/perplexity-context-embedding-GI5PHE6X.js +9 -0
- package/dist/perplexity-context-embedding-GI5PHE6X.js.map +1 -0
- package/dist/perplexity-embedding-KZRYGJRC.js +10 -0
- package/dist/perplexity-embedding-KZRYGJRC.js.map +1 -0
- package/dist/plugin-IKQ6IRSJ.js +32 -0
- package/dist/plugin-IKQ6IRSJ.js.map +1 -0
- package/dist/resolve-ASGLBNUC.js +10 -0
- package/dist/resolve-ASGLBNUC.js.map +1 -0
- package/dist/stats-tui-AD3AMYGV.js +1904 -0
- package/dist/stats-tui-AD3AMYGV.js.map +1 -0
- package/package.json +38 -53
- package/src/brainbank.ts +617 -0
- package/src/cli/commands/collection.ts +77 -0
- package/src/cli/commands/context.ts +59 -0
- package/src/cli/commands/daemon.ts +100 -0
- package/src/cli/commands/docs.ts +71 -0
- package/src/cli/commands/files.ts +69 -0
- package/src/cli/commands/help.ts +82 -0
- package/src/cli/commands/index.ts +478 -0
- package/src/cli/commands/kv.ts +140 -0
- package/src/cli/commands/mcp-export.ts +273 -0
- package/src/cli/commands/mcp.ts +6 -0
- package/src/cli/commands/query.ts +167 -0
- package/src/cli/commands/reembed.ts +30 -0
- package/src/cli/commands/reindex.ts +40 -0
- package/src/cli/commands/scan.ts +336 -0
- package/src/cli/commands/search.ts +203 -0
- package/src/cli/commands/stats.ts +68 -0
- package/src/cli/commands/status.ts +47 -0
- package/src/cli/commands/watch.ts +47 -0
- package/src/cli/factory/brain-context.ts +43 -0
- package/src/cli/factory/builtin-registration.ts +87 -0
- package/src/cli/factory/config-loader.ts +77 -0
- package/src/cli/factory/index.ts +69 -0
- package/src/cli/factory/plugin-loader.ts +324 -0
- package/src/cli/index.ts +76 -0
- package/src/cli/server-client.ts +186 -0
- package/src/cli/tui/index-tui.tsx +667 -0
- package/src/cli/tui/stats-data.ts +523 -0
- package/src/cli/tui/stats-search.ts +262 -0
- package/src/cli/tui/stats-tui.tsx +1465 -0
- package/src/cli/tui/tree-scanner.ts +650 -0
- package/src/cli/utils.ts +137 -0
- package/src/config.ts +48 -0
- package/src/constants.ts +21 -0
- package/src/db/adapter.ts +112 -0
- package/src/db/metadata.ts +130 -0
- package/src/db/migrations.ts +66 -0
- package/src/db/sqlite-adapter.ts +218 -0
- package/src/db/tracker.ts +91 -0
- package/src/engine/index-api.ts +81 -0
- package/src/engine/reembed.ts +206 -0
- package/src/engine/search-api.ts +218 -0
- package/src/index.ts +150 -0
- package/src/lib/fts.ts +57 -0
- package/src/lib/languages.ts +179 -0
- package/src/lib/logger.ts +126 -0
- package/src/lib/math.ts +87 -0
- package/src/lib/provider-key.ts +20 -0
- package/src/lib/prune.ts +72 -0
- package/src/lib/rrf.ts +133 -0
- package/src/lib/write-lock.ts +108 -0
- package/src/mcp/mcp-server.ts +268 -0
- package/src/mcp/workspace-factory.ts +68 -0
- package/src/mcp/workspace-pool.ts +224 -0
- package/src/plugin.ts +381 -0
- package/src/providers/embeddings/embedding-worker-thread.ts +95 -0
- package/src/providers/embeddings/embedding-worker.ts +141 -0
- package/src/providers/embeddings/local-embedding.ts +115 -0
- package/src/providers/embeddings/openai-embedding.ts +167 -0
- package/src/providers/embeddings/perplexity-context-embedding.ts +195 -0
- package/src/providers/embeddings/perplexity-embedding.ts +165 -0
- package/src/providers/embeddings/resolve.ts +34 -0
- package/src/providers/pruners/haiku-expander.ts +178 -0
- package/src/providers/pruners/haiku-pruner.ts +263 -0
- package/src/providers/vector/hnsw-index.ts +174 -0
- package/src/providers/vector/hnsw-loader.ts +129 -0
- package/src/search/bm25-boost.ts +76 -0
- package/src/search/context-builder.ts +209 -0
- package/src/search/keyword/composite-bm25-search.ts +47 -0
- package/src/search/query-decomposer.ts +124 -0
- package/src/search/types.ts +37 -0
- package/src/search/vector/composite-vector-search.ts +105 -0
- package/src/search/vector/mmr.ts +64 -0
- package/src/services/collection.ts +384 -0
- package/src/services/daemon.ts +87 -0
- package/src/services/http-server.ts +344 -0
- package/src/services/kv-service.ts +64 -0
- package/src/services/plugin-registry.ts +77 -0
- package/src/services/watch.ts +340 -0
- package/src/services/webhook-server.ts +100 -0
- package/src/types.ts +509 -0
- package/dist/chunk-2P3EGY6S.js +0 -37
- package/dist/chunk-2P3EGY6S.js.map +0 -1
- package/dist/chunk-3GAIDXRW.js +0 -105
- package/dist/chunk-3GAIDXRW.js.map +0 -1
- package/dist/chunk-4ZKBQ33J.js +0 -56
- package/dist/chunk-4ZKBQ33J.js.map +0 -1
- package/dist/chunk-7QVYU63E.js +0 -7
- package/dist/chunk-GOUBW7UA.js +0 -373
- package/dist/chunk-GOUBW7UA.js.map +0 -1
- package/dist/chunk-MJ3Y24H6.js +0 -185
- package/dist/chunk-MJ3Y24H6.js.map +0 -1
- package/dist/chunk-N6ZMBFDE.js +0 -224
- package/dist/chunk-N6ZMBFDE.js.map +0 -1
- package/dist/chunk-RAEBYV75.js +0 -709
- package/dist/chunk-RAEBYV75.js.map +0 -1
- package/dist/chunk-TW5NTYYZ.js +0 -2066
- package/dist/chunk-TW5NTYYZ.js.map +0 -1
- package/dist/chunk-Z5SU54HP.js +0 -171
- package/dist/chunk-Z5SU54HP.js.map +0 -1
- package/dist/code.d.ts +0 -31
- package/dist/code.js +0 -8
- package/dist/docs.d.ts +0 -19
- package/dist/docs.js +0 -8
- package/dist/git.d.ts +0 -31
- package/dist/git.js +0 -8
- package/dist/memory.d.ts +0 -19
- package/dist/memory.js +0 -146
- package/dist/memory.js.map +0 -1
- package/dist/notes.d.ts +0 -19
- package/dist/notes.js +0 -57
- package/dist/notes.js.map +0 -1
- package/dist/openai-PCTYLOWI.js +0 -8
- package/dist/types-Da_zLLOl.d.ts +0 -474
- /package/dist/{chunk-7QVYU63E.js.map → chunk-WCQVDF3K.js.map} +0 -0
- /package/dist/{code.js.map → haiku-pruner-5KVT5AI2.js.map} +0 -0
- /package/dist/{docs.js.map → http-server-2ZQ6I43B.js.map} +0 -0
- /package/dist/{git.js.map → local-embedding-NZQTILGV.js.map} +0 -0
- /package/dist/{openai-PCTYLOWI.js.map → openai-embedding-ZP5TSUJG.js.map} +0 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrainBank — Haiku Expander
|
|
3
|
+
*
|
|
4
|
+
* LLM-powered context expansion using Anthropic's Haiku 4.5 model.
|
|
5
|
+
* After search + pruning, reviews a manifest of available chunks
|
|
6
|
+
* and requests additional IDs to include.
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. Receives lightweight manifest (~20 chars per chunk)
|
|
10
|
+
* 2. Haiku selects additional chunk IDs (just numbers, fast)
|
|
11
|
+
* 3. Caller fetches those chunks from DB and splices into results
|
|
12
|
+
*
|
|
13
|
+
* Designed for minimal token usage:
|
|
14
|
+
* - Input: ~2,000-3,000 tokens (manifest)
|
|
15
|
+
* - Output: ~50-100 tokens (ID array)
|
|
16
|
+
* - Cost: ~$0.001 per call
|
|
17
|
+
* - Latency: ~300-600ms
|
|
18
|
+
*
|
|
19
|
+
* Fail-open: any error returns empty array (no expansion).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Expander, ExpanderManifestItem, ExpanderResult } from '@/types.ts';
|
|
23
|
+
|
|
24
|
+
const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
|
|
25
|
+
const _debug = !!process.env.BRAINBANK_DEBUG;
|
|
26
|
+
function dbg(msg: string): void { if (_debug) console.error(msg); }
|
|
27
|
+
|
|
28
|
+
export interface HaikuExpanderOptions {
|
|
29
|
+
/** Anthropic API key. Falls back to ANTHROPIC_API_KEY env var. */
|
|
30
|
+
apiKey?: string;
|
|
31
|
+
/** Model to use. Default: claude-haiku-4-5-20251001 */
|
|
32
|
+
model?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class HaikuExpander implements Expander {
|
|
36
|
+
private readonly _apiKey: string;
|
|
37
|
+
private readonly _model: string;
|
|
38
|
+
|
|
39
|
+
constructor(options: HaikuExpanderOptions = {}) {
|
|
40
|
+
this._apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY ?? '';
|
|
41
|
+
this._model = options.model ?? DEFAULT_MODEL;
|
|
42
|
+
|
|
43
|
+
if (!this._apiKey) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
'HaikuExpander: No API key provided. Set ANTHROPIC_API_KEY env var or pass apiKey option.',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async expand(
|
|
51
|
+
query: string,
|
|
52
|
+
currentIds: number[],
|
|
53
|
+
manifest: ExpanderManifestItem[],
|
|
54
|
+
context?: string,
|
|
55
|
+
): Promise<ExpanderResult> {
|
|
56
|
+
if (manifest.length === 0) return { ids: [] };
|
|
57
|
+
|
|
58
|
+
// Filter out chunks already in results
|
|
59
|
+
const currentSet = new Set(currentIds);
|
|
60
|
+
const available = manifest.filter(m => !currentSet.has(m.id));
|
|
61
|
+
if (available.length === 0) return { ids: [] };
|
|
62
|
+
|
|
63
|
+
// Split manifest into priority (import-graph neighbors) and general
|
|
64
|
+
const priorityChunks = available.filter(m => m.priority);
|
|
65
|
+
const otherChunks = available.filter(m => !m.priority);
|
|
66
|
+
|
|
67
|
+
const currentSummary = manifest
|
|
68
|
+
.filter(m => currentSet.has(m.id))
|
|
69
|
+
.map(m => `#${m.id} ${m.filePath} | ${m.chunkType} ${m.name}`)
|
|
70
|
+
.join('\n');
|
|
71
|
+
|
|
72
|
+
// Build manifest sections
|
|
73
|
+
let manifestSection = '';
|
|
74
|
+
if (priorityChunks.length > 0) {
|
|
75
|
+
const prioLines = priorityChunks.map(m => {
|
|
76
|
+
const base = `#${m.id} ${m.filePath} | ${m.chunkType} ${m.name} ${m.lines}`;
|
|
77
|
+
return m.synopsis ? `${base} — ${m.synopsis}` : base;
|
|
78
|
+
}).join('\n');
|
|
79
|
+
manifestSection += `DEPENDENCY chunks (imported by or importing the search result files):\n${prioLines}\n\n`;
|
|
80
|
+
const synopsisCount = priorityChunks.filter(m => m.synopsis).length;
|
|
81
|
+
dbg(`[HaikuExpander] ${priorityChunks.length} priority chunks (${synopsisCount} with synopsis)`);
|
|
82
|
+
}
|
|
83
|
+
if (otherChunks.length > 0) {
|
|
84
|
+
const otherLines = otherChunks.map(m =>
|
|
85
|
+
`#${m.id} ${m.filePath} | ${m.chunkType} ${m.name} ${m.lines}`
|
|
86
|
+
).join('\n');
|
|
87
|
+
manifestSection += `Other available chunks:\n${otherLines}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const contextSection = context
|
|
91
|
+
? `\nTask Context:\n"""\n${context.slice(0, 2000)}\n"""\n\n`
|
|
92
|
+
: '';
|
|
93
|
+
|
|
94
|
+
const prompt =
|
|
95
|
+
`Task: "${query}"\n${contextSection}` +
|
|
96
|
+
`Already included chunks:\n${currentSummary}\n\n` +
|
|
97
|
+
`${manifestSection}\n\n` +
|
|
98
|
+
`You are a code context expander. The search already found the "included" chunks above.\n` +
|
|
99
|
+
`Review the available chunks and select any that would help an AI agent complete the task.\n\n` +
|
|
100
|
+
`Rules:\n` +
|
|
101
|
+
`- STRONGLY PREFER dependency chunks — they are structurally connected to the search results via imports\n` +
|
|
102
|
+
`- Select type definitions, interfaces, models, or configs needed to understand included code\n` +
|
|
103
|
+
`- Select initialization or setup code if the task involves debugging or modifying a feature\n` +
|
|
104
|
+
`- Do NOT select test files, documentation, or unrelated utilities\n` +
|
|
105
|
+
`- Be selective: only include chunks that fill clear gaps. Quality over quantity.\n` +
|
|
106
|
+
`- If nothing useful is available, return an empty ids array\n\n` +
|
|
107
|
+
`Respond with ONLY a JSON object:\n` +
|
|
108
|
+
`{ "ids": [42, 17, 89], "note": "Brief 1-2 sentence observation about the codebase relevant to the task" }\n\n` +
|
|
109
|
+
`The "note" is optional — use it to mention things like missing files, architectural patterns, ` +
|
|
110
|
+
`deprecated modules, or important relationships you noticed. If nothing notable, omit it.\n` +
|
|
111
|
+
`If nothing to add: { "ids": [] }`;
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: {
|
|
117
|
+
'Content-Type': 'application/json',
|
|
118
|
+
'x-api-key': this._apiKey,
|
|
119
|
+
'anthropic-version': '2023-06-01',
|
|
120
|
+
},
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
model: this._model,
|
|
123
|
+
max_tokens: 512,
|
|
124
|
+
messages: [{
|
|
125
|
+
role: 'user',
|
|
126
|
+
content: prompt,
|
|
127
|
+
}],
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
return { ids: [] };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const data = await response.json() as {
|
|
136
|
+
content: { type: string; text: string }[];
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const text = data.content?.[0]?.text ?? '';
|
|
140
|
+
dbg(`[HaikuExpander] Raw response: ${text.slice(0, 300)}${text.length > 300 ? '...' : ''}`);
|
|
141
|
+
return this._parseResponse(text, available);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
dbg(`[HaikuExpander] Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
144
|
+
return { ids: [] };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Parse Haiku response — handles both `{ ids, note }` and bare `[...]` formats. */
|
|
149
|
+
private _parseResponse(text: string, available: ExpanderManifestItem[]): ExpanderResult {
|
|
150
|
+
const validIds = new Set(available.map(m => m.id));
|
|
151
|
+
|
|
152
|
+
// Try JSON object first: { "ids": [...], "note": "..." }
|
|
153
|
+
const objMatch = text.match(/\{[\s\S]*"ids"\s*:\s*\[[\d\s,]*\][\s\S]*\}/);
|
|
154
|
+
if (objMatch) {
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(objMatch[0]) as { ids: number[]; note?: string };
|
|
157
|
+
const ids = parsed.ids.filter(id => validIds.has(id));
|
|
158
|
+
const note = parsed.note?.trim() || undefined;
|
|
159
|
+
return { ids, note };
|
|
160
|
+
} catch {
|
|
161
|
+
// Fall through to array parsing
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Fallback: bare array [42, 17, 89]
|
|
166
|
+
const arrMatch = text.match(/\[[\d\s,]*\]/);
|
|
167
|
+
if (arrMatch) {
|
|
168
|
+
const ids = (JSON.parse(arrMatch[0]) as number[]).filter(id => validIds.has(id));
|
|
169
|
+
return { ids };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { ids: [] };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async close(): Promise<void> {
|
|
176
|
+
// No resources to release (stateless HTTP)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrainBank — LLM Pruner
|
|
3
|
+
*
|
|
4
|
+
* LLM-based noise filter using Anthropic models (Haiku 4.5 or Sonnet 4.6).
|
|
5
|
+
* Binary classification: for each search result, the model decides
|
|
6
|
+
* "relevant" or "noise" based on filePath, metadata, and full
|
|
7
|
+
* file content (capped at ~8K chars per item by prune.ts).
|
|
8
|
+
*
|
|
9
|
+
* Latency: ~300-600ms (Haiku), ~500-1200ms (Sonnet).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Pruner, PrunerItem } from '@/types.ts';
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
|
|
16
|
+
|
|
17
|
+
/** Budget map keyed by model prefix. Sonnet 4.6 has a larger context window. */
|
|
18
|
+
const MODEL_BUDGETS: Record<string, number> = {
|
|
19
|
+
'claude-haiku': 700_000, // ~180K tokens ≈ ~720K chars
|
|
20
|
+
'claude-sonnet': 900_000, // ~200K tokens ≈ ~900K chars (Sonnet 4.6)
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function _getBudget(model: string): number {
|
|
24
|
+
for (const [prefix, budget] of Object.entries(MODEL_BUDGETS)) {
|
|
25
|
+
if (model.startsWith(prefix)) return budget;
|
|
26
|
+
}
|
|
27
|
+
return 700_000; // Default: conservative budget
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const _debug = !!process.env.BRAINBANK_DEBUG;
|
|
31
|
+
function dbg(msg: string): void { if (_debug) console.error(msg); }
|
|
32
|
+
|
|
33
|
+
export interface HaikuPrunerOptions {
|
|
34
|
+
/** Anthropic API key. Falls back to ANTHROPIC_API_KEY env var. */
|
|
35
|
+
apiKey?: string;
|
|
36
|
+
/** Model to use. Default: claude-haiku-4-5-20251001 */
|
|
37
|
+
model?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class HaikuPruner implements Pruner {
|
|
41
|
+
private readonly _apiKey: string;
|
|
42
|
+
private readonly _model: string;
|
|
43
|
+
|
|
44
|
+
constructor(options: HaikuPrunerOptions = {}) {
|
|
45
|
+
this._apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY ?? '';
|
|
46
|
+
this._model = options.model ?? DEFAULT_MODEL;
|
|
47
|
+
|
|
48
|
+
if (!this._apiKey) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
'HaikuPruner: No API key provided. Set ANTHROPIC_API_KEY env var or pass apiKey option.',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async prune(query: string, items: PrunerItem[], context?: string): Promise<number[]> {
|
|
56
|
+
if (items.length === 0) return [];
|
|
57
|
+
if (items.length === 1) return [items[0].id];
|
|
58
|
+
|
|
59
|
+
// ── Adaptive preview truncation ──
|
|
60
|
+
// Estimate total chars and truncate previews if we'd exceed token budget
|
|
61
|
+
const budget = _getBudget(this._model);
|
|
62
|
+
let totalChars = _estimatePromptChars(query, items);
|
|
63
|
+
if (totalChars > budget && items.length > 1) {
|
|
64
|
+
// Calculate max preview chars per item to fit budget
|
|
65
|
+
const overheadPerItem = 200; // filePath + metadata + separators
|
|
66
|
+
const promptOverhead = 1500 + (context ? context.length : 0); // system prompt + rules + context
|
|
67
|
+
const availableForPreviews = budget - promptOverhead - (items.length * overheadPerItem);
|
|
68
|
+
const maxPreviewPerItem = Math.max(1000, Math.floor(availableForPreviews / items.length));
|
|
69
|
+
|
|
70
|
+
dbg(`[Pruner:${this._model}] Budget overflow: ${totalChars} chars for ${items.length} items (budget: ${budget}). Truncating previews to ${maxPreviewPerItem} chars each.`);
|
|
71
|
+
|
|
72
|
+
for (const item of items) {
|
|
73
|
+
if (item.preview.length > maxPreviewPerItem) {
|
|
74
|
+
item.preview = _truncatePreview(item.preview, maxPreviewPerItem);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
totalChars = _estimatePromptChars(query, items);
|
|
79
|
+
dbg(`[Pruner:${this._model}] After truncation: ${totalChars} chars`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const itemLines = items.map(item => {
|
|
83
|
+
// Only show useful metadata fields (skip raw scores, IDs, large arrays)
|
|
84
|
+
const SKIP_KEYS = new Set(['id', 'chunkIds', 'rrfScore', 'filePath']);
|
|
85
|
+
const meta = Object.entries(item.metadata)
|
|
86
|
+
.filter(([k, v]) => v !== undefined && v !== null && !SKIP_KEYS.has(k))
|
|
87
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
88
|
+
.join(' | ');
|
|
89
|
+
return `#${item.id} ${item.filePath} | ${meta}\n${item.preview}`;
|
|
90
|
+
}).join('\n---\n');
|
|
91
|
+
|
|
92
|
+
// Build context section if provided
|
|
93
|
+
const contextSection = context
|
|
94
|
+
? `\nTask Context:\n"""\n${context.slice(0, 2000)}\n"""\n\n`
|
|
95
|
+
: '';
|
|
96
|
+
|
|
97
|
+
const prompt =
|
|
98
|
+
`Query: "${query}"\n${contextSection}Search results (full file content):\n${itemLines}\n\n` +
|
|
99
|
+
`You are a precision search filter. Remove noise but NEVER drop core implementation files.\n` +
|
|
100
|
+
`Return a JSON array of #IDs to KEEP, ordered by relevance (most relevant FIRST).\n` +
|
|
101
|
+
`${context ? 'Use the Task Context to understand EXACTLY what the user needs — keep files that would be needed to implement the described change.\n' : ''}` +
|
|
102
|
+
`\nKEEP (any ONE of these is enough):\n` +
|
|
103
|
+
`- File contains the method/handler/function that performs the queried action.\n` +
|
|
104
|
+
`- File defines the entity, types, or state machine for the queried system.\n` +
|
|
105
|
+
`- File is the controller/route that exposes the queried feature.\n` +
|
|
106
|
+
`- File orchestrates or composes the queried workflow (even if it also handles other workflows).\n` +
|
|
107
|
+
`- Service files that handle the queried action alongside other actions → KEEP (they contain the implementation).\n\n` +
|
|
108
|
+
`DROP (any ONE of these triggers a drop):\n` +
|
|
109
|
+
`- File is from a DIFFERENT domain that shares vocabulary with the query.\n` +
|
|
110
|
+
` Example: query "job encounter time" → DROP priority.entity.ts, notification.worker.ts\n` +
|
|
111
|
+
`- Database seeders, migrations, test fixtures — unless query asks about seeding/migration.\n` +
|
|
112
|
+
`- Generic infrastructure: loggers, config factories, decorators — unless query targets that infra.\n` +
|
|
113
|
+
`- File where the queried concept appears only as an import, foreign key, or one-line reference.\n` +
|
|
114
|
+
`- Permission configs — unless query is about permissions/auth.\n` +
|
|
115
|
+
`- DTOs that only mirror entity fields without adding logic.\n\n` +
|
|
116
|
+
`Target: 25-50% keep rate. Drop infrastructure/boilerplate aggressively, but NEVER drop a service that implements the queried action.\n` +
|
|
117
|
+
`Order: core implementation → entity/types → controller/service → peripheral.\n\n` +
|
|
118
|
+
`Respond with ONLY the JSON array. Example: [3, 0, 5]`;
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: {
|
|
124
|
+
'Content-Type': 'application/json',
|
|
125
|
+
'x-api-key': this._apiKey,
|
|
126
|
+
'anthropic-version': '2023-06-01',
|
|
127
|
+
},
|
|
128
|
+
body: JSON.stringify({
|
|
129
|
+
model: this._model,
|
|
130
|
+
max_tokens: 512,
|
|
131
|
+
messages: [{
|
|
132
|
+
role: 'user',
|
|
133
|
+
content: prompt,
|
|
134
|
+
}],
|
|
135
|
+
}),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
const body = await response.text().catch(() => '');
|
|
140
|
+
// API error → fail-open, return all — but LOG IT
|
|
141
|
+
console.error(`[Pruner:${this._model}] API error: ${response.status} ${response.statusText} (${items.length} items, ~${Math.round(totalChars / 1000)}K chars) — keeping all. ${body.slice(0, 200)}`);
|
|
142
|
+
return items.map(i => i.id);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const data = await response.json() as {
|
|
146
|
+
content: { type: string; text: string }[];
|
|
147
|
+
usage?: {
|
|
148
|
+
input_tokens: number;
|
|
149
|
+
output_tokens: number;
|
|
150
|
+
cache_creation_input_tokens?: number;
|
|
151
|
+
cache_read_input_tokens?: number;
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// ── Cost tracking (authoritative: these are Anthropic's exact billing counts) ──
|
|
156
|
+
const usage = data.usage;
|
|
157
|
+
if (usage) {
|
|
158
|
+
const cost = _estimateCost(this._model, usage);
|
|
159
|
+
const parts = [
|
|
160
|
+
`${usage.input_tokens} in`,
|
|
161
|
+
`${usage.output_tokens} out`,
|
|
162
|
+
];
|
|
163
|
+
if (usage.cache_creation_input_tokens) parts.push(`${usage.cache_creation_input_tokens} cache_write`);
|
|
164
|
+
if (usage.cache_read_input_tokens) parts.push(`${usage.cache_read_input_tokens} cache_read`);
|
|
165
|
+
const costLine = `[Pruner] ${this._model} — ${parts.join(' + ')} = $${cost.toFixed(4)}`;
|
|
166
|
+
dbg(costLine);
|
|
167
|
+
// Append to brainbank.log (daemon stdio is 'ignore' so console.error is lost)
|
|
168
|
+
try { fs.appendFileSync('/tmp/brainbank.log', costLine + '\n'); } catch { /* best effort */ }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const text = data.content?.[0]?.text ?? '';
|
|
172
|
+
dbg(`[Pruner:${this._model}] Raw response: ${text}`);
|
|
173
|
+
// Haiku may wrap in ```json ... ``` — extract any JSON array
|
|
174
|
+
const match = text.match(/\[[\d\s,]+\]/);
|
|
175
|
+
if (!match) {
|
|
176
|
+
console.error(`[Pruner:${this._model}] No JSON array found in response — keeping all ${items.length} items. Raw: ${text.slice(0, 200)}`);
|
|
177
|
+
return items.map(i => i.id);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const keepIds = JSON.parse(match[0]) as number[];
|
|
181
|
+
const validIds = new Set(items.map(i => i.id));
|
|
182
|
+
const filtered = keepIds.filter(id => validIds.has(id));
|
|
183
|
+
dbg(`[HaikuPruner] Keep IDs: [${filtered.join(', ')}] (${items.length - filtered.length} dropped)`);
|
|
184
|
+
return filtered;
|
|
185
|
+
} catch (err) {
|
|
186
|
+
// Network error → fail-open, return all — but LOG IT
|
|
187
|
+
console.error(`[Pruner:${this._model}] Error: ${err instanceof Error ? err.message : String(err)} (${items.length} items) — keeping all`);
|
|
188
|
+
return items.map(i => i.id);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async close(): Promise<void> {
|
|
193
|
+
// No resources to release (stateless HTTP)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Helpers ──────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/** Per-million-token pricing for supported models (May 2026). */
|
|
200
|
+
const MODEL_PRICING: Record<string, { input: number; output: number }> = {
|
|
201
|
+
'claude-haiku': { input: 1.00, output: 5.00 }, // Haiku 4.5
|
|
202
|
+
'claude-sonnet': { input: 3.00, output: 15.00 }, // Sonnet 4.6
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
interface UsageInfo {
|
|
206
|
+
input_tokens: number;
|
|
207
|
+
output_tokens: number;
|
|
208
|
+
cache_creation_input_tokens?: number;
|
|
209
|
+
cache_read_input_tokens?: number;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Calculate USD cost from the API's authoritative usage counts.
|
|
214
|
+
* Cache pricing: writes cost 1.25x input, reads cost 0.1x input.
|
|
215
|
+
*/
|
|
216
|
+
function _estimateCost(model: string, usage: UsageInfo): number {
|
|
217
|
+
let pricing = { input: 3.00, output: 15.00 }; // Default to Sonnet
|
|
218
|
+
for (const [prefix, p] of Object.entries(MODEL_PRICING)) {
|
|
219
|
+
if (model.startsWith(prefix)) { pricing = p; break; }
|
|
220
|
+
}
|
|
221
|
+
const inputCost = usage.input_tokens * pricing.input;
|
|
222
|
+
const outputCost = usage.output_tokens * pricing.output;
|
|
223
|
+
const cacheWriteCost = (usage.cache_creation_input_tokens ?? 0) * pricing.input * 1.25;
|
|
224
|
+
const cacheReadCost = (usage.cache_read_input_tokens ?? 0) * pricing.input * 0.1;
|
|
225
|
+
return (inputCost + outputCost + cacheWriteCost + cacheReadCost) / 1_000_000;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Estimate total prompt chars for budget checking. */
|
|
229
|
+
function _estimatePromptChars(query: string, items: PrunerItem[]): number {
|
|
230
|
+
const promptOverhead = 1500;
|
|
231
|
+
let total = promptOverhead + query.length;
|
|
232
|
+
for (const item of items) {
|
|
233
|
+
total += item.filePath.length + item.preview.length + 200; // 200 for metadata + separators
|
|
234
|
+
}
|
|
235
|
+
return total;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Truncate a preview to fit within a character budget.
|
|
240
|
+
* Keeps top 60% + bottom 25% of lines (same strategy as prune.ts _buildPreview).
|
|
241
|
+
*/
|
|
242
|
+
function _truncatePreview(content: string, maxChars: number): string {
|
|
243
|
+
if (content.length <= maxChars) return content;
|
|
244
|
+
|
|
245
|
+
const lines = content.split('\n');
|
|
246
|
+
const totalLines = lines.length;
|
|
247
|
+
|
|
248
|
+
// Keep ~60% from top, ~25% from bottom
|
|
249
|
+
const topCount = Math.floor(totalLines * 0.6);
|
|
250
|
+
const bottomCount = Math.floor(totalLines * 0.25);
|
|
251
|
+
const omitted = totalLines - topCount - bottomCount;
|
|
252
|
+
|
|
253
|
+
let result = lines.slice(0, topCount).join('\n') +
|
|
254
|
+
`\n\n// [... ${omitted} lines omitted ...]\n\n` +
|
|
255
|
+
lines.slice(totalLines - bottomCount).join('\n');
|
|
256
|
+
|
|
257
|
+
// If still too long, hard-truncate
|
|
258
|
+
if (result.length > maxChars) {
|
|
259
|
+
result = result.slice(0, maxChars) + '\n// [truncated]';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return result;
|
|
263
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrainBank — HNSW Vector Index
|
|
3
|
+
*
|
|
4
|
+
* Wraps hnswlib-node for O(log n) approximate nearest neighbor search.
|
|
5
|
+
* M=16 connections, ef=200 construction, ef=50 search by default.
|
|
6
|
+
* 150x faster than brute force at 1M vectors.
|
|
7
|
+
*
|
|
8
|
+
* Supports disk persistence: save(path) / tryLoad(path, count)
|
|
9
|
+
* to skip costly vector-by-vector rebuild on startup.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { VectorIndex, SearchHit } from '@/types.ts';
|
|
13
|
+
|
|
14
|
+
import { existsSync } from 'node:fs';
|
|
15
|
+
|
|
16
|
+
/** Shape of the HNSW index from hnswlib-node. */
|
|
17
|
+
interface HnswlibIndex {
|
|
18
|
+
initIndex(maxElements: number, M: number, efConstruction: number): void;
|
|
19
|
+
setEf(ef: number): void;
|
|
20
|
+
addPoint(vector: number[], id: number): void;
|
|
21
|
+
markDelete(id: number): void;
|
|
22
|
+
searchKnn(vector: number[], k: number): { neighbors: number[]; distances: number[] };
|
|
23
|
+
writeIndexSync(path: string): void;
|
|
24
|
+
readIndexSync(path: string): void;
|
|
25
|
+
getCurrentCount(): number;
|
|
26
|
+
getIdsList(): number[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Shape of the hnswlib-node module (supports default + named exports). */
|
|
30
|
+
interface HnswlibModule {
|
|
31
|
+
default?: { HierarchicalNSW: new (space: 'cosine' | 'l2' | 'ip', dims: number) => HnswlibIndex };
|
|
32
|
+
HierarchicalNSW?: new (space: 'cosine' | 'l2' | 'ip', dims: number) => HnswlibIndex;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class HNSWIndex implements VectorIndex {
|
|
36
|
+
private _index: HnswlibIndex | null = null;
|
|
37
|
+
private _lib: HnswlibModule | null = null;
|
|
38
|
+
private _ids = new Set<number>();
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
private _dims: number,
|
|
42
|
+
private _maxElements: number = 2_000_000,
|
|
43
|
+
private _M: number = 16,
|
|
44
|
+
private _efConstruction: number = 200,
|
|
45
|
+
private _efSearch: number = 50,
|
|
46
|
+
) {}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Initialize the HNSW index.
|
|
50
|
+
* Must be called before add/search.
|
|
51
|
+
*/
|
|
52
|
+
async init(): Promise<this> {
|
|
53
|
+
this._lib = await import('hnswlib-node');
|
|
54
|
+
this._createIndex();
|
|
55
|
+
return this;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reinitialize the index in-place, clearing all vectors.
|
|
60
|
+
* Required after reembed or full re-index to avoid duplicate IDs.
|
|
61
|
+
* init() must have been called first.
|
|
62
|
+
*/
|
|
63
|
+
reinit(): void {
|
|
64
|
+
if (!this._lib) throw new Error('HNSW not initialized — call init() first');
|
|
65
|
+
this._createIndex();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private _createIndex(): void {
|
|
69
|
+
if (!this._lib) throw new Error('HNSW lib not loaded');
|
|
70
|
+
const HNSW = this._lib.default?.HierarchicalNSW ?? this._lib.HierarchicalNSW;
|
|
71
|
+
if (!HNSW) throw new Error('HierarchicalNSW not found in hnswlib-node module');
|
|
72
|
+
this._index = new HNSW('cosine', this._dims);
|
|
73
|
+
this._index.initIndex(this._maxElements, this._M, this._efConstruction);
|
|
74
|
+
this._index.setEf(this._efSearch);
|
|
75
|
+
this._ids = new Set();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Maximum capacity of this index. */
|
|
79
|
+
get maxElements(): number { return this._maxElements; }
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Add a vector with an integer ID.
|
|
83
|
+
* The vector should be pre-normalized for cosine distance.
|
|
84
|
+
*/
|
|
85
|
+
add(vector: Float32Array, id: number): void {
|
|
86
|
+
if (!this._index) throw new Error('HNSW index not initialized — call init() first');
|
|
87
|
+
if (this._ids.has(id)) return; // idempotent: skip duplicates
|
|
88
|
+
if (this._ids.size >= this._maxElements) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`HNSW index full (${this._maxElements} elements). ` +
|
|
91
|
+
`Increase maxElements in config or prune old data.`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
this._index.addPoint(Array.from(vector), id);
|
|
95
|
+
this._ids.add(id);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Mark a vector as deleted so it no longer appears in searches.
|
|
100
|
+
* Uses hnswlib-node markDelete under the hood.
|
|
101
|
+
* Safe to call with an ID that doesn't exist.
|
|
102
|
+
*/
|
|
103
|
+
remove(id: number): void {
|
|
104
|
+
if (!this._index || this._ids.size === 0) return;
|
|
105
|
+
if (!this._ids.has(id)) return;
|
|
106
|
+
try {
|
|
107
|
+
this._index.markDelete(id);
|
|
108
|
+
this._ids.delete(id);
|
|
109
|
+
} catch {
|
|
110
|
+
// ID not found — ignore silently
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Search for the k nearest neighbors.
|
|
116
|
+
* Returns results sorted by score (highest first).
|
|
117
|
+
* Score is 1 - cosine_distance (1.0 = identical).
|
|
118
|
+
*/
|
|
119
|
+
search(query: Float32Array, k: number): SearchHit[] {
|
|
120
|
+
if (!this._index || this._ids.size === 0) return [];
|
|
121
|
+
|
|
122
|
+
const actualK = Math.min(k, this._ids.size);
|
|
123
|
+
const result = this._index.searchKnn(Array.from(query), actualK);
|
|
124
|
+
|
|
125
|
+
return result.neighbors.map((id: number, i: number) => ({
|
|
126
|
+
id,
|
|
127
|
+
score: 1 - result.distances[i],
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Number of vectors in the index. */
|
|
132
|
+
get size(): number {
|
|
133
|
+
return this._ids.size;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Save the HNSW graph to disk.
|
|
138
|
+
* The file can be loaded later with tryLoad() to skip vector-by-vector insertion.
|
|
139
|
+
*/
|
|
140
|
+
save(path: string): void {
|
|
141
|
+
if (!this._index || this._ids.size === 0) return;
|
|
142
|
+
this._index.writeIndexSync(path);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Try to load a previously saved HNSW index from disk.
|
|
147
|
+
* Returns true if loaded successfully, false if stale or missing.
|
|
148
|
+
* @param path File path to the saved index
|
|
149
|
+
* @param expectedCount Expected number of vectors (from SQLite) — used to detect staleness
|
|
150
|
+
*/
|
|
151
|
+
tryLoad(path: string, expectedCount: number): boolean {
|
|
152
|
+
if (!this._index || !existsSync(path)) return false;
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
this._index.readIndexSync(path);
|
|
156
|
+
const loadedCount = this._index.getCurrentCount();
|
|
157
|
+
|
|
158
|
+
// Stale: vector count in DB differs from saved index
|
|
159
|
+
if (loadedCount !== expectedCount) {
|
|
160
|
+
this.reinit();
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Rebuild _ids set from the loaded index
|
|
165
|
+
const ids = this._index.getIdsList();
|
|
166
|
+
this._ids = new Set(ids);
|
|
167
|
+
this._index.setEf(this._efSearch);
|
|
168
|
+
return true;
|
|
169
|
+
} catch {
|
|
170
|
+
this.reinit();
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|