brainbank 0.7.0 → 0.8.0
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 +76 -1398
- package/bin/brainbank +5 -1
- package/dist/{chunk-N2OJRXSB.js → chunk-3HVCONGF.js} +1 -1
- package/dist/{chunk-N2OJRXSB.js.map → chunk-3HVCONGF.js.map} +1 -1
- package/dist/{chunk-CCXVL56V.js → chunk-3JZIM5AU.js} +6 -3
- package/dist/chunk-3JZIM5AU.js.map +1 -0
- package/dist/{chunk-6XOXM7MI.js → chunk-5KU2PP34.js} +2 -2
- package/dist/{chunk-6XOXM7MI.js.map → chunk-5KU2PP34.js.map} +1 -1
- package/dist/chunk-7JDCHUJV.js +89 -0
- package/dist/chunk-7JDCHUJV.js.map +1 -0
- package/dist/{chunk-B77KABWH.js → chunk-7T2ZCZQA.js} +17 -15
- package/dist/chunk-7T2ZCZQA.js.map +1 -0
- package/dist/chunk-E3J37GDA.js +74 -0
- package/dist/chunk-E3J37GDA.js.map +1 -0
- package/dist/chunk-JEFWMS5Z.js +217 -0
- package/dist/chunk-JEFWMS5Z.js.map +1 -0
- package/dist/chunk-JRVYSTMP.js +3256 -0
- package/dist/chunk-JRVYSTMP.js.map +1 -0
- package/dist/chunk-OLDHLOMT.js +69 -0
- package/dist/chunk-OLDHLOMT.js.map +1 -0
- package/dist/{chunk-424UFCY7.js → chunk-QTZNB6AK.js} +6 -2
- package/dist/chunk-QTZNB6AK.js.map +1 -0
- package/dist/chunk-RFF7HMP6.js +109 -0
- package/dist/chunk-RFF7HMP6.js.map +1 -0
- package/dist/{chunk-ZNLN2VWV.js → chunk-TD3TEFI3.js} +1 -1
- package/dist/chunk-TD3TEFI3.js.map +1 -0
- package/dist/cli.js +1036 -341
- package/dist/cli.js.map +1 -1
- package/dist/haiku-expander-WOVJIVXD.js +8 -0
- package/dist/haiku-pruner-DB77ZQLJ.js +8 -0
- package/dist/http-server-GIRELCCL.js +9 -0
- package/dist/index.d.ts +1774 -611
- package/dist/index.js +282 -70
- package/dist/index.js.map +1 -1
- package/dist/{local-embedding-ZIMTK6PU.js → local-embedding-2RNCC5EU.js} +2 -2
- package/dist/{openai-embedding-VQZCZQYT.js → openai-embedding-Z5I4K4CN.js} +2 -2
- package/dist/perplexity-context-embedding-V5YUMXDR.js +9 -0
- package/dist/{perplexity-embedding-227WQY4R.js → perplexity-embedding-X2S72OAC.js} +2 -2
- package/dist/plugin-FF4Q34TI.js +32 -0
- package/dist/{qwen3-reranker-3MHEENT5.js → qwen3-reranker-HVIQOLKS.js} +2 -2
- package/dist/{resolve-CUJWY6HP.js → resolve-Q5D6HECY.js} +2 -2
- package/package.json +25 -52
- package/src/brainbank.ts +620 -0
- package/src/cli/commands/collection.ts +77 -0
- package/src/cli/commands/context.ts +171 -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 +72 -0
- package/src/cli/commands/index.ts +282 -0
- package/src/cli/commands/kv.ts +140 -0
- package/src/cli/commands/mcp.ts +13 -0
- package/src/cli/commands/reembed.ts +30 -0
- package/src/cli/commands/scan.ts +365 -0
- package/src/cli/commands/search.ts +130 -0
- package/src/cli/commands/stats.ts +44 -0
- package/src/cli/commands/status.ts +47 -0
- package/src/cli/commands/watch.ts +43 -0
- package/src/cli/factory/brain-context.ts +43 -0
- package/src/cli/factory/builtin-registration.ts +123 -0
- package/src/cli/factory/config-loader.ts +72 -0
- package/src/cli/factory/index.ts +65 -0
- package/src/cli/factory/plugin-loader.ts +146 -0
- package/src/cli/index.ts +63 -0
- package/src/cli/server-client.ts +135 -0
- package/src/cli/utils.ts +121 -0
- package/src/config.ts +50 -0
- package/src/constants.ts +13 -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 +208 -0
- package/src/db/tracker.ts +91 -0
- package/src/engine/index-api.ts +85 -0
- package/src/engine/reembed.ts +206 -0
- package/src/engine/search-api.ts +222 -0
- package/src/index.ts +159 -0
- package/src/lib/fts.ts +57 -0
- package/src/lib/languages.ts +180 -0
- package/src/lib/logger.ts +125 -0
- package/src/lib/math.ts +87 -0
- package/src/lib/provider-key.ts +20 -0
- package/src/lib/prune.ts +71 -0
- package/src/lib/rerank.ts +33 -0
- package/src/lib/rrf.ts +133 -0
- package/src/lib/write-lock.ts +108 -0
- package/src/plugin.ts +323 -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 +152 -0
- package/src/providers/pruners/haiku-pruner.ts +112 -0
- package/src/providers/rerankers/qwen3-reranker.ts +180 -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 +61 -0
- package/src/search/context-builder.ts +298 -0
- package/src/search/keyword/composite-bm25-search.ts +62 -0
- package/src/search/types.ts +35 -0
- package/src/search/vector/composite-vector-search.ts +76 -0
- package/src/search/vector/mmr.ts +64 -0
- package/src/services/collection.ts +405 -0
- package/src/services/daemon.ts +87 -0
- package/src/services/http-server.ts +288 -0
- package/src/services/kv-service.ts +65 -0
- package/src/services/plugin-registry.ts +109 -0
- package/src/services/watch.ts +348 -0
- package/src/services/webhook-server.ts +100 -0
- package/src/types.ts +504 -0
- package/dist/base-3SNc_CeY.d.ts +0 -593
- package/dist/chunk-424UFCY7.js.map +0 -1
- package/dist/chunk-7EZR47JV.js +0 -232
- package/dist/chunk-7EZR47JV.js.map +0 -1
- package/dist/chunk-B77KABWH.js.map +0 -1
- package/dist/chunk-CCXVL56V.js.map +0 -1
- package/dist/chunk-DI3H6JVZ.js +0 -2432
- package/dist/chunk-DI3H6JVZ.js.map +0 -1
- package/dist/chunk-FGL32LUJ.js +0 -754
- package/dist/chunk-FGL32LUJ.js.map +0 -1
- package/dist/chunk-JRSKWF6K.js +0 -313
- package/dist/chunk-JRSKWF6K.js.map +0 -1
- package/dist/chunk-U2Q2XGPZ.js +0 -42
- package/dist/chunk-U2Q2XGPZ.js.map +0 -1
- package/dist/chunk-VQ27YUHH.js +0 -629
- package/dist/chunk-VQ27YUHH.js.map +0 -1
- package/dist/chunk-VVXYZIIB.js +0 -304
- package/dist/chunk-VVXYZIIB.js.map +0 -1
- package/dist/chunk-YOLKSYWK.js +0 -79
- package/dist/chunk-YOLKSYWK.js.map +0 -1
- package/dist/chunk-ZNLN2VWV.js.map +0 -1
- package/dist/code.d.ts +0 -33
- package/dist/code.js +0 -9
- package/dist/docs.d.ts +0 -21
- package/dist/docs.js +0 -9
- package/dist/git.d.ts +0 -33
- package/dist/git.js +0 -9
- package/dist/memory.d.ts +0 -17
- package/dist/memory.js +0 -9
- package/dist/notes.d.ts +0 -17
- package/dist/notes.js +0 -10
- package/dist/perplexity-context-embedding-KSVSZXMD.js +0 -9
- package/dist/resolve-CUJWY6HP.js.map +0 -1
- /package/dist/{code.js.map → haiku-expander-WOVJIVXD.js.map} +0 -0
- /package/dist/{docs.js.map → haiku-pruner-DB77ZQLJ.js.map} +0 -0
- /package/dist/{git.js.map → http-server-GIRELCCL.js.map} +0 -0
- /package/dist/{local-embedding-ZIMTK6PU.js.map → local-embedding-2RNCC5EU.js.map} +0 -0
- /package/dist/{memory.js.map → openai-embedding-Z5I4K4CN.js.map} +0 -0
- /package/dist/{notes.js.map → perplexity-context-embedding-V5YUMXDR.js.map} +0 -0
- /package/dist/{openai-embedding-VQZCZQYT.js.map → perplexity-embedding-X2S72OAC.js.map} +0 -0
- /package/dist/{perplexity-context-embedding-KSVSZXMD.js.map → plugin-FF4Q34TI.js.map} +0 -0
- /package/dist/{perplexity-embedding-227WQY4R.js.map → qwen3-reranker-HVIQOLKS.js.map} +0 -0
- /package/dist/{qwen3-reranker-3MHEENT5.js.map → resolve-Q5D6HECY.js.map} +0 -0
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
|
|
26
|
+
export interface HaikuExpanderOptions {
|
|
27
|
+
/** Anthropic API key. Falls back to ANTHROPIC_API_KEY env var. */
|
|
28
|
+
apiKey?: string;
|
|
29
|
+
/** Model to use. Default: claude-haiku-4-5-20251001 */
|
|
30
|
+
model?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class HaikuExpander implements Expander {
|
|
34
|
+
private readonly _apiKey: string;
|
|
35
|
+
private readonly _model: string;
|
|
36
|
+
|
|
37
|
+
constructor(options: HaikuExpanderOptions = {}) {
|
|
38
|
+
this._apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY ?? '';
|
|
39
|
+
this._model = options.model ?? DEFAULT_MODEL;
|
|
40
|
+
|
|
41
|
+
if (!this._apiKey) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
'HaikuExpander: No API key provided. Set ANTHROPIC_API_KEY env var or pass apiKey option.',
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async expand(
|
|
49
|
+
query: string,
|
|
50
|
+
currentIds: number[],
|
|
51
|
+
manifest: ExpanderManifestItem[],
|
|
52
|
+
): Promise<ExpanderResult> {
|
|
53
|
+
if (manifest.length === 0) return { ids: [] };
|
|
54
|
+
|
|
55
|
+
// Filter out chunks already in results
|
|
56
|
+
const currentSet = new Set(currentIds);
|
|
57
|
+
const available = manifest.filter(m => !currentSet.has(m.id));
|
|
58
|
+
if (available.length === 0) return { ids: [] };
|
|
59
|
+
|
|
60
|
+
// Build compact manifest string
|
|
61
|
+
const manifestLines = available.map(m =>
|
|
62
|
+
`#${m.id} ${m.filePath} | ${m.chunkType} ${m.name} ${m.lines}`
|
|
63
|
+
).join('\n');
|
|
64
|
+
|
|
65
|
+
const currentSummary = manifest
|
|
66
|
+
.filter(m => currentSet.has(m.id))
|
|
67
|
+
.map(m => `#${m.id} ${m.filePath} | ${m.chunkType} ${m.name}`)
|
|
68
|
+
.join('\n');
|
|
69
|
+
|
|
70
|
+
const prompt =
|
|
71
|
+
`Task: "${query}"\n\n` +
|
|
72
|
+
`Already included chunks:\n${currentSummary}\n\n` +
|
|
73
|
+
`Available chunks NOT yet included:\n${manifestLines}\n\n` +
|
|
74
|
+
`You are a code context expander. The search already found the "included" chunks above.\n` +
|
|
75
|
+
`Review the "available" chunks and select any that would help an AI agent complete the task.\n\n` +
|
|
76
|
+
`Rules:\n` +
|
|
77
|
+
`- Select chunks that are structurally related (called by, imported by, or extend included code)\n` +
|
|
78
|
+
`- Select type definitions, interfaces, or configs needed to understand included code\n` +
|
|
79
|
+
`- Select initialization or setup code if the task involves debugging or modifying a feature\n` +
|
|
80
|
+
`- Do NOT select test files, documentation, or unrelated utilities\n` +
|
|
81
|
+
`- Be selective: only include chunks that fill clear gaps. Quality over quantity.\n` +
|
|
82
|
+
`- If nothing useful is available, return an empty ids array\n\n` +
|
|
83
|
+
`Respond with ONLY a JSON object:\n` +
|
|
84
|
+
`{ "ids": [42, 17, 89], "note": "Brief 1-2 sentence observation about the codebase relevant to the task" }\n\n` +
|
|
85
|
+
`The "note" is optional — use it to mention things like missing files, architectural patterns, ` +
|
|
86
|
+
`deprecated modules, or important relationships you noticed. If nothing notable, omit it.\n` +
|
|
87
|
+
`If nothing to add: { "ids": [] }`;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: {
|
|
93
|
+
'Content-Type': 'application/json',
|
|
94
|
+
'x-api-key': this._apiKey,
|
|
95
|
+
'anthropic-version': '2023-06-01',
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
model: this._model,
|
|
99
|
+
max_tokens: 512,
|
|
100
|
+
messages: [{
|
|
101
|
+
role: 'user',
|
|
102
|
+
content: prompt,
|
|
103
|
+
}],
|
|
104
|
+
}),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
return { ids: [] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const data = await response.json() as {
|
|
112
|
+
content: { type: string; text: string }[];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const text = data.content?.[0]?.text ?? '';
|
|
116
|
+
return this._parseResponse(text, available);
|
|
117
|
+
} catch {
|
|
118
|
+
return { ids: [] };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Parse Haiku response — handles both `{ ids, note }` and bare `[...]` formats. */
|
|
123
|
+
private _parseResponse(text: string, available: ExpanderManifestItem[]): ExpanderResult {
|
|
124
|
+
const validIds = new Set(available.map(m => m.id));
|
|
125
|
+
|
|
126
|
+
// Try JSON object first: { "ids": [...], "note": "..." }
|
|
127
|
+
const objMatch = text.match(/\{[\s\S]*"ids"\s*:\s*\[[\d\s,]*\][\s\S]*\}/);
|
|
128
|
+
if (objMatch) {
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(objMatch[0]) as { ids: number[]; note?: string };
|
|
131
|
+
const ids = parsed.ids.filter(id => validIds.has(id));
|
|
132
|
+
const note = parsed.note?.trim() || undefined;
|
|
133
|
+
return { ids, note };
|
|
134
|
+
} catch {
|
|
135
|
+
// Fall through to array parsing
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Fallback: bare array [42, 17, 89]
|
|
140
|
+
const arrMatch = text.match(/\[[\d\s,]*\]/);
|
|
141
|
+
if (arrMatch) {
|
|
142
|
+
const ids = (JSON.parse(arrMatch[0]) as number[]).filter(id => validIds.has(id));
|
|
143
|
+
return { ids };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { ids: [] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async close(): Promise<void> {
|
|
150
|
+
// No resources to release (stateless HTTP)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrainBank — Haiku Pruner
|
|
3
|
+
*
|
|
4
|
+
* LLM-based noise filter using Anthropic's Haiku 4.5 model.
|
|
5
|
+
* Binary classification: for each search result, Haiku 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.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Pruner, PrunerItem } from '@/types.ts';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
|
|
15
|
+
|
|
16
|
+
export interface HaikuPrunerOptions {
|
|
17
|
+
/** Anthropic API key. Falls back to ANTHROPIC_API_KEY env var. */
|
|
18
|
+
apiKey?: string;
|
|
19
|
+
/** Model to use. Default: claude-haiku-4-5-20251001 */
|
|
20
|
+
model?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class HaikuPruner implements Pruner {
|
|
24
|
+
private readonly _apiKey: string;
|
|
25
|
+
private readonly _model: string;
|
|
26
|
+
|
|
27
|
+
constructor(options: HaikuPrunerOptions = {}) {
|
|
28
|
+
this._apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY ?? '';
|
|
29
|
+
this._model = options.model ?? DEFAULT_MODEL;
|
|
30
|
+
|
|
31
|
+
if (!this._apiKey) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
'HaikuPruner: No API key provided. Set ANTHROPIC_API_KEY env var or pass apiKey option.',
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async prune(query: string, items: PrunerItem[]): Promise<number[]> {
|
|
39
|
+
if (items.length === 0) return [];
|
|
40
|
+
if (items.length === 1) return [items[0].id];
|
|
41
|
+
|
|
42
|
+
const itemLines = items.map(item => {
|
|
43
|
+
// Only show useful metadata fields (skip raw scores, IDs, large arrays)
|
|
44
|
+
const SKIP_KEYS = new Set(['id', 'chunkIds', 'rrfScore', 'filePath']);
|
|
45
|
+
const meta = Object.entries(item.metadata)
|
|
46
|
+
.filter(([k, v]) => v !== undefined && v !== null && !SKIP_KEYS.has(k))
|
|
47
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
48
|
+
.join(' | ');
|
|
49
|
+
return `#${item.id} ${item.filePath} | ${meta}\n${item.preview}`;
|
|
50
|
+
}).join('\n---\n');
|
|
51
|
+
|
|
52
|
+
const prompt =
|
|
53
|
+
`Query: "${query}"\n\nSearch results (full file content):\n${itemLines}\n\n` +
|
|
54
|
+
`You are a precision search filter and ranker. You have the FULL source code of each file.\n` +
|
|
55
|
+
`Return a JSON array of #IDs to KEEP, ordered by relevance (most relevant FIRST).\n\n` +
|
|
56
|
+
`Rules:\n` +
|
|
57
|
+
`- Understand the SPECIFIC system/feature the query targets. Don't match on shared vocabulary alone.\n` +
|
|
58
|
+
` Example: "snackbar toast notification" targets the toast popup system, NOT a notification center/bell icon.\n` +
|
|
59
|
+
`- KEEP files that directly implement, define types for, or configure the queried system.\n` +
|
|
60
|
+
`- KEEP files where the queried system is mounted, initialized, or composed into a workflow.\n` +
|
|
61
|
+
`- DROP files that only CONSUME the system (e.g. a component that calls showNotification once but has 400 lines of unrelated logic).\n` +
|
|
62
|
+
`- DROP files that implement a DIFFERENT system sharing similar vocabulary.\n` +
|
|
63
|
+
`- DROP infrastructure/boilerplate (font loaders, theme definitions, CSS-only layout shells) unless they directly configure the queried feature.\n` +
|
|
64
|
+
`- Aim for 40-70% keep rate. Returning fewer, highly relevant files is BETTER than returning many tangential ones.\n` +
|
|
65
|
+
`- ORDER: core implementation → types/config → mount points → peripheral.\n\n` +
|
|
66
|
+
`Respond with ONLY the JSON array. Example: [3, 0, 5, 1]`;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: {
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
'x-api-key': this._apiKey,
|
|
74
|
+
'anthropic-version': '2023-06-01',
|
|
75
|
+
},
|
|
76
|
+
body: JSON.stringify({
|
|
77
|
+
model: this._model,
|
|
78
|
+
max_tokens: 512,
|
|
79
|
+
messages: [{
|
|
80
|
+
role: 'user',
|
|
81
|
+
content: prompt,
|
|
82
|
+
}],
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
// API error → fail-open, return all
|
|
88
|
+
return items.map(i => i.id);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const data = await response.json() as {
|
|
92
|
+
content: { type: string; text: string }[];
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const text = data.content?.[0]?.text ?? '';
|
|
96
|
+
// Haiku may wrap in ```json ... ``` — extract any JSON array
|
|
97
|
+
const match = text.match(/\[[\d\s,]+\]/);
|
|
98
|
+
if (!match) return items.map(i => i.id);
|
|
99
|
+
|
|
100
|
+
const keepIds = JSON.parse(match[0]) as number[];
|
|
101
|
+
const validIds = new Set(items.map(i => i.id));
|
|
102
|
+
return keepIds.filter(id => validIds.has(id));
|
|
103
|
+
} catch {
|
|
104
|
+
// Network error → fail-open, return all
|
|
105
|
+
return items.map(i => i.id);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async close(): Promise<void> {
|
|
110
|
+
// No resources to release (stateless HTTP)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrainBank — Qwen3 Local Reranker
|
|
3
|
+
*
|
|
4
|
+
* Cross-encoder reranker using Qwen3-Reranker-0.6B via node-llama-cpp.
|
|
5
|
+
* Auto-downloads the GGUF model from HuggingFace (~640MB, cached).
|
|
6
|
+
*
|
|
7
|
+
* Based on QMD's reranker architecture:
|
|
8
|
+
* - Lazy model loading (loads on first rank() call)
|
|
9
|
+
* - Flash attention for 20× less VRAM
|
|
10
|
+
* - Document deduplication (identical texts scored once)
|
|
11
|
+
* - Tokenizer-based truncation for oversized documents
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Reranker } from '@/types.ts';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
18
|
+
|
|
19
|
+
/** Minimal interface for node-llama-cpp model methods used by the reranker. */
|
|
20
|
+
interface LlamaModel {
|
|
21
|
+
tokenize(text: string): number[];
|
|
22
|
+
detokenize(tokens: number[]): string;
|
|
23
|
+
createRankingContext(opts: { contextSize: number; flashAttention?: boolean }): Promise<LlamaRankingContext>;
|
|
24
|
+
dispose?(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Minimal interface for node-llama-cpp ranking context. */
|
|
28
|
+
interface LlamaRankingContext {
|
|
29
|
+
rankAll(query: string, documents: string[]): Promise<number[]>;
|
|
30
|
+
dispose(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Engine returned by getLlama(). */
|
|
34
|
+
interface LlamaEngine {
|
|
35
|
+
loadModel(opts: { modelPath: string }): Promise<LlamaModel>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Shape of the node-llama-cpp module used here. */
|
|
39
|
+
interface NodeLlamaCppModule {
|
|
40
|
+
getLlama(): Promise<LlamaEngine>;
|
|
41
|
+
resolveModelFile(uri: string, cacheDir: string): Promise<string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Default model — Qwen3-Reranker-0.6B quantized to Q8_0 (~640MB)
|
|
45
|
+
const DEFAULT_MODEL_URI = 'hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf';
|
|
46
|
+
|
|
47
|
+
// Context size for reranking (Qwen3 template ~200 tokens overhead + query + doc)
|
|
48
|
+
const CONTEXT_SIZE = 2048;
|
|
49
|
+
|
|
50
|
+
// Cache directory for downloaded models
|
|
51
|
+
const MODEL_CACHE_DIR = join(homedir(), '.cache', 'brainbank', 'models');
|
|
52
|
+
|
|
53
|
+
export interface Qwen3RerankerOptions {
|
|
54
|
+
/** HuggingFace model URI. Default: Qwen3-Reranker-0.6B-Q8_0 */
|
|
55
|
+
modelUri?: string;
|
|
56
|
+
/** Model cache directory. Default: ~/.cache/brainbank/models/ */
|
|
57
|
+
cacheDir?: string;
|
|
58
|
+
/** Context size for ranking. Default: 2048 */
|
|
59
|
+
contextSize?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class Qwen3Reranker implements Reranker {
|
|
63
|
+
private readonly _modelUri: string;
|
|
64
|
+
private readonly _cacheDir: string;
|
|
65
|
+
private readonly _contextSize: number;
|
|
66
|
+
|
|
67
|
+
private _model: LlamaModel | null = null;
|
|
68
|
+
private _context: LlamaRankingContext | null = null;
|
|
69
|
+
private _loadPromise: Promise<void> | null = null;
|
|
70
|
+
|
|
71
|
+
constructor(options: Qwen3RerankerOptions = {}) {
|
|
72
|
+
this._modelUri = options.modelUri ?? DEFAULT_MODEL_URI;
|
|
73
|
+
this._cacheDir = options.cacheDir ?? MODEL_CACHE_DIR;
|
|
74
|
+
this._contextSize = options.contextSize ?? CONTEXT_SIZE;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Lazy-load the reranker model and create a ranking context.
|
|
79
|
+
* Model is auto-downloaded from HuggingFace on first use.
|
|
80
|
+
*/
|
|
81
|
+
private async _ensureLoaded(): Promise<void> {
|
|
82
|
+
if (this._context) return;
|
|
83
|
+
if (this._loadPromise) {
|
|
84
|
+
await this._loadPromise;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this._loadPromise = (async () => {
|
|
89
|
+
try {
|
|
90
|
+
// Dynamic import — node-llama-cpp is an optional peer dependency.
|
|
91
|
+
// String indirection prevents DTS from resolving the specifier at build time.
|
|
92
|
+
const llamaModule = 'node-llama-cpp';
|
|
93
|
+
const { getLlama, resolveModelFile } = await import(/* webpackIgnore: true */ llamaModule) as NodeLlamaCppModule;
|
|
94
|
+
|
|
95
|
+
// Ensure cache directory exists
|
|
96
|
+
if (!existsSync(this._cacheDir)) {
|
|
97
|
+
mkdirSync(this._cacheDir, { recursive: true });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Download model if needed (resolveModelFile handles caching)
|
|
101
|
+
const modelPath = await resolveModelFile(this._modelUri, this._cacheDir);
|
|
102
|
+
|
|
103
|
+
// Initialize llama engine
|
|
104
|
+
const llama = await getLlama();
|
|
105
|
+
|
|
106
|
+
// Load model
|
|
107
|
+
this._model = await llama.loadModel({ modelPath });
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
this._context = await this._model!.createRankingContext({
|
|
111
|
+
contextSize: this._contextSize,
|
|
112
|
+
flashAttention: true,
|
|
113
|
+
});
|
|
114
|
+
} catch {
|
|
115
|
+
// Flash attention might not be supported — retry without it
|
|
116
|
+
this._context = await this._model!.createRankingContext({
|
|
117
|
+
contextSize: this._contextSize,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
this._loadPromise = null;
|
|
122
|
+
}
|
|
123
|
+
})();
|
|
124
|
+
|
|
125
|
+
await this._loadPromise;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Score each document's relevance to the query.
|
|
130
|
+
* Returns scores (0.0 - 1.0) in same order as input documents.
|
|
131
|
+
*
|
|
132
|
+
* Deduplicates identical documents to avoid redundant computation.
|
|
133
|
+
*/
|
|
134
|
+
async rank(query: string, documents: string[]): Promise<number[]> {
|
|
135
|
+
if (documents.length === 0) return [];
|
|
136
|
+
|
|
137
|
+
await this._ensureLoaded();
|
|
138
|
+
|
|
139
|
+
// Deduplicate — identical texts get scored once
|
|
140
|
+
const uniqueTexts = [...new Set(documents)];
|
|
141
|
+
const textToScore = new Map<string, number>();
|
|
142
|
+
|
|
143
|
+
// Truncate documents that exceed context size
|
|
144
|
+
const truncated = uniqueTexts.map(text => {
|
|
145
|
+
if (this._model) {
|
|
146
|
+
const tokens = this._model.tokenize(text);
|
|
147
|
+
// Budget: contextSize - ~200 overhead - query tokens
|
|
148
|
+
const queryTokens = this._model.tokenize(query).length;
|
|
149
|
+
const maxDocTokens = this._contextSize - 200 - queryTokens;
|
|
150
|
+
if (tokens.length > maxDocTokens && maxDocTokens > 0) {
|
|
151
|
+
return this._model.detokenize(tokens.slice(0, maxDocTokens));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return text;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Rank all unique documents at once
|
|
158
|
+
const scores: number[] = await this._context!.rankAll(query, truncated);
|
|
159
|
+
|
|
160
|
+
// Map scores back
|
|
161
|
+
for (let i = 0; i < uniqueTexts.length; i++) {
|
|
162
|
+
textToScore.set(uniqueTexts[i], scores[i] ?? 0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Return scores in original document order
|
|
166
|
+
return documents.map(doc => textToScore.get(doc) ?? 0);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Release model resources. */
|
|
170
|
+
async close(): Promise<void> {
|
|
171
|
+
if (this._context) {
|
|
172
|
+
await this._context.dispose();
|
|
173
|
+
this._context = null;
|
|
174
|
+
}
|
|
175
|
+
if (this._model) {
|
|
176
|
+
await this._model.dispose?.();
|
|
177
|
+
this._model = null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -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
|
+
}
|