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.
Files changed (167) hide show
  1. package/README.md +84 -1107
  2. package/assets/architecture.png +0 -0
  3. package/bin/brainbank +8 -1
  4. package/bin/brainbank-mcp +19 -0
  5. package/dist/chunk-3UIWA32X.js +3341 -0
  6. package/dist/chunk-3UIWA32X.js.map +1 -0
  7. package/dist/chunk-3YBCD6DI.js +117 -0
  8. package/dist/chunk-3YBCD6DI.js.map +1 -0
  9. package/dist/chunk-DAGVUEXL.js +258 -0
  10. package/dist/chunk-DAGVUEXL.js.map +1 -0
  11. package/dist/chunk-DMFMTOHF.js +123 -0
  12. package/dist/chunk-DMFMTOHF.js.map +1 -0
  13. package/dist/chunk-FQYKWB2Q.js +136 -0
  14. package/dist/chunk-FQYKWB2Q.js.map +1 -0
  15. package/dist/chunk-IMJJ2VEM.js +74 -0
  16. package/dist/chunk-IMJJ2VEM.js.map +1 -0
  17. package/dist/chunk-M744PCJQ.js +43 -0
  18. package/dist/chunk-M744PCJQ.js.map +1 -0
  19. package/dist/chunk-NNDY7P2R.js +211 -0
  20. package/dist/chunk-NNDY7P2R.js.map +1 -0
  21. package/dist/chunk-O3J6ZIXK.js +82 -0
  22. package/dist/chunk-O3J6ZIXK.js.map +1 -0
  23. package/dist/chunk-RDQYDLYZ.js +69 -0
  24. package/dist/chunk-RDQYDLYZ.js.map +1 -0
  25. package/dist/chunk-WCQVDF3K.js +14 -0
  26. package/dist/cli.js +2713 -325
  27. package/dist/cli.js.map +1 -1
  28. package/dist/haiku-pruner-5KVT5AI2.js +8 -0
  29. package/dist/http-server-2ZQ6I43B.js +9 -0
  30. package/dist/index.d.ts +1886 -626
  31. package/dist/index.js +319 -46
  32. package/dist/index.js.map +1 -1
  33. package/dist/local-embedding-NZQTILGV.js +8 -0
  34. package/dist/mcp.d.ts +2 -0
  35. package/dist/mcp.js +386 -0
  36. package/dist/mcp.js.map +1 -0
  37. package/dist/openai-embedding-ZP5TSUJG.js +8 -0
  38. package/dist/perplexity-context-embedding-GI5PHE6X.js +9 -0
  39. package/dist/perplexity-context-embedding-GI5PHE6X.js.map +1 -0
  40. package/dist/perplexity-embedding-KZRYGJRC.js +10 -0
  41. package/dist/perplexity-embedding-KZRYGJRC.js.map +1 -0
  42. package/dist/plugin-IKQ6IRSJ.js +32 -0
  43. package/dist/plugin-IKQ6IRSJ.js.map +1 -0
  44. package/dist/resolve-ASGLBNUC.js +10 -0
  45. package/dist/resolve-ASGLBNUC.js.map +1 -0
  46. package/dist/stats-tui-AD3AMYGV.js +1904 -0
  47. package/dist/stats-tui-AD3AMYGV.js.map +1 -0
  48. package/package.json +38 -53
  49. package/src/brainbank.ts +617 -0
  50. package/src/cli/commands/collection.ts +77 -0
  51. package/src/cli/commands/context.ts +59 -0
  52. package/src/cli/commands/daemon.ts +100 -0
  53. package/src/cli/commands/docs.ts +71 -0
  54. package/src/cli/commands/files.ts +69 -0
  55. package/src/cli/commands/help.ts +82 -0
  56. package/src/cli/commands/index.ts +478 -0
  57. package/src/cli/commands/kv.ts +140 -0
  58. package/src/cli/commands/mcp-export.ts +273 -0
  59. package/src/cli/commands/mcp.ts +6 -0
  60. package/src/cli/commands/query.ts +167 -0
  61. package/src/cli/commands/reembed.ts +30 -0
  62. package/src/cli/commands/reindex.ts +40 -0
  63. package/src/cli/commands/scan.ts +336 -0
  64. package/src/cli/commands/search.ts +203 -0
  65. package/src/cli/commands/stats.ts +68 -0
  66. package/src/cli/commands/status.ts +47 -0
  67. package/src/cli/commands/watch.ts +47 -0
  68. package/src/cli/factory/brain-context.ts +43 -0
  69. package/src/cli/factory/builtin-registration.ts +87 -0
  70. package/src/cli/factory/config-loader.ts +77 -0
  71. package/src/cli/factory/index.ts +69 -0
  72. package/src/cli/factory/plugin-loader.ts +324 -0
  73. package/src/cli/index.ts +76 -0
  74. package/src/cli/server-client.ts +186 -0
  75. package/src/cli/tui/index-tui.tsx +667 -0
  76. package/src/cli/tui/stats-data.ts +523 -0
  77. package/src/cli/tui/stats-search.ts +262 -0
  78. package/src/cli/tui/stats-tui.tsx +1465 -0
  79. package/src/cli/tui/tree-scanner.ts +650 -0
  80. package/src/cli/utils.ts +137 -0
  81. package/src/config.ts +48 -0
  82. package/src/constants.ts +21 -0
  83. package/src/db/adapter.ts +112 -0
  84. package/src/db/metadata.ts +130 -0
  85. package/src/db/migrations.ts +66 -0
  86. package/src/db/sqlite-adapter.ts +218 -0
  87. package/src/db/tracker.ts +91 -0
  88. package/src/engine/index-api.ts +81 -0
  89. package/src/engine/reembed.ts +206 -0
  90. package/src/engine/search-api.ts +218 -0
  91. package/src/index.ts +150 -0
  92. package/src/lib/fts.ts +57 -0
  93. package/src/lib/languages.ts +179 -0
  94. package/src/lib/logger.ts +126 -0
  95. package/src/lib/math.ts +87 -0
  96. package/src/lib/provider-key.ts +20 -0
  97. package/src/lib/prune.ts +72 -0
  98. package/src/lib/rrf.ts +133 -0
  99. package/src/lib/write-lock.ts +108 -0
  100. package/src/mcp/mcp-server.ts +268 -0
  101. package/src/mcp/workspace-factory.ts +68 -0
  102. package/src/mcp/workspace-pool.ts +224 -0
  103. package/src/plugin.ts +381 -0
  104. package/src/providers/embeddings/embedding-worker-thread.ts +95 -0
  105. package/src/providers/embeddings/embedding-worker.ts +141 -0
  106. package/src/providers/embeddings/local-embedding.ts +115 -0
  107. package/src/providers/embeddings/openai-embedding.ts +167 -0
  108. package/src/providers/embeddings/perplexity-context-embedding.ts +195 -0
  109. package/src/providers/embeddings/perplexity-embedding.ts +165 -0
  110. package/src/providers/embeddings/resolve.ts +34 -0
  111. package/src/providers/pruners/haiku-expander.ts +178 -0
  112. package/src/providers/pruners/haiku-pruner.ts +263 -0
  113. package/src/providers/vector/hnsw-index.ts +174 -0
  114. package/src/providers/vector/hnsw-loader.ts +129 -0
  115. package/src/search/bm25-boost.ts +76 -0
  116. package/src/search/context-builder.ts +209 -0
  117. package/src/search/keyword/composite-bm25-search.ts +47 -0
  118. package/src/search/query-decomposer.ts +124 -0
  119. package/src/search/types.ts +37 -0
  120. package/src/search/vector/composite-vector-search.ts +105 -0
  121. package/src/search/vector/mmr.ts +64 -0
  122. package/src/services/collection.ts +384 -0
  123. package/src/services/daemon.ts +87 -0
  124. package/src/services/http-server.ts +344 -0
  125. package/src/services/kv-service.ts +64 -0
  126. package/src/services/plugin-registry.ts +77 -0
  127. package/src/services/watch.ts +340 -0
  128. package/src/services/webhook-server.ts +100 -0
  129. package/src/types.ts +509 -0
  130. package/dist/chunk-2P3EGY6S.js +0 -37
  131. package/dist/chunk-2P3EGY6S.js.map +0 -1
  132. package/dist/chunk-3GAIDXRW.js +0 -105
  133. package/dist/chunk-3GAIDXRW.js.map +0 -1
  134. package/dist/chunk-4ZKBQ33J.js +0 -56
  135. package/dist/chunk-4ZKBQ33J.js.map +0 -1
  136. package/dist/chunk-7QVYU63E.js +0 -7
  137. package/dist/chunk-GOUBW7UA.js +0 -373
  138. package/dist/chunk-GOUBW7UA.js.map +0 -1
  139. package/dist/chunk-MJ3Y24H6.js +0 -185
  140. package/dist/chunk-MJ3Y24H6.js.map +0 -1
  141. package/dist/chunk-N6ZMBFDE.js +0 -224
  142. package/dist/chunk-N6ZMBFDE.js.map +0 -1
  143. package/dist/chunk-RAEBYV75.js +0 -709
  144. package/dist/chunk-RAEBYV75.js.map +0 -1
  145. package/dist/chunk-TW5NTYYZ.js +0 -2066
  146. package/dist/chunk-TW5NTYYZ.js.map +0 -1
  147. package/dist/chunk-Z5SU54HP.js +0 -171
  148. package/dist/chunk-Z5SU54HP.js.map +0 -1
  149. package/dist/code.d.ts +0 -31
  150. package/dist/code.js +0 -8
  151. package/dist/docs.d.ts +0 -19
  152. package/dist/docs.js +0 -8
  153. package/dist/git.d.ts +0 -31
  154. package/dist/git.js +0 -8
  155. package/dist/memory.d.ts +0 -19
  156. package/dist/memory.js +0 -146
  157. package/dist/memory.js.map +0 -1
  158. package/dist/notes.d.ts +0 -19
  159. package/dist/notes.js +0 -57
  160. package/dist/notes.js.map +0 -1
  161. package/dist/openai-PCTYLOWI.js +0 -8
  162. package/dist/types-Da_zLLOl.d.ts +0 -474
  163. /package/dist/{chunk-7QVYU63E.js.map → chunk-WCQVDF3K.js.map} +0 -0
  164. /package/dist/{code.js.map → haiku-pruner-5KVT5AI2.js.map} +0 -0
  165. /package/dist/{docs.js.map → http-server-2ZQ6I43B.js.map} +0 -0
  166. /package/dist/{git.js.map → local-embedding-NZQTILGV.js.map} +0 -0
  167. /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
+ }