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.
Files changed (156) hide show
  1. package/README.md +76 -1398
  2. package/bin/brainbank +5 -1
  3. package/dist/{chunk-N2OJRXSB.js → chunk-3HVCONGF.js} +1 -1
  4. package/dist/{chunk-N2OJRXSB.js.map → chunk-3HVCONGF.js.map} +1 -1
  5. package/dist/{chunk-CCXVL56V.js → chunk-3JZIM5AU.js} +6 -3
  6. package/dist/chunk-3JZIM5AU.js.map +1 -0
  7. package/dist/{chunk-6XOXM7MI.js → chunk-5KU2PP34.js} +2 -2
  8. package/dist/{chunk-6XOXM7MI.js.map → chunk-5KU2PP34.js.map} +1 -1
  9. package/dist/chunk-7JDCHUJV.js +89 -0
  10. package/dist/chunk-7JDCHUJV.js.map +1 -0
  11. package/dist/{chunk-B77KABWH.js → chunk-7T2ZCZQA.js} +17 -15
  12. package/dist/chunk-7T2ZCZQA.js.map +1 -0
  13. package/dist/chunk-E3J37GDA.js +74 -0
  14. package/dist/chunk-E3J37GDA.js.map +1 -0
  15. package/dist/chunk-JEFWMS5Z.js +217 -0
  16. package/dist/chunk-JEFWMS5Z.js.map +1 -0
  17. package/dist/chunk-JRVYSTMP.js +3256 -0
  18. package/dist/chunk-JRVYSTMP.js.map +1 -0
  19. package/dist/chunk-OLDHLOMT.js +69 -0
  20. package/dist/chunk-OLDHLOMT.js.map +1 -0
  21. package/dist/{chunk-424UFCY7.js → chunk-QTZNB6AK.js} +6 -2
  22. package/dist/chunk-QTZNB6AK.js.map +1 -0
  23. package/dist/chunk-RFF7HMP6.js +109 -0
  24. package/dist/chunk-RFF7HMP6.js.map +1 -0
  25. package/dist/{chunk-ZNLN2VWV.js → chunk-TD3TEFI3.js} +1 -1
  26. package/dist/chunk-TD3TEFI3.js.map +1 -0
  27. package/dist/cli.js +1036 -341
  28. package/dist/cli.js.map +1 -1
  29. package/dist/haiku-expander-WOVJIVXD.js +8 -0
  30. package/dist/haiku-pruner-DB77ZQLJ.js +8 -0
  31. package/dist/http-server-GIRELCCL.js +9 -0
  32. package/dist/index.d.ts +1774 -611
  33. package/dist/index.js +282 -70
  34. package/dist/index.js.map +1 -1
  35. package/dist/{local-embedding-ZIMTK6PU.js → local-embedding-2RNCC5EU.js} +2 -2
  36. package/dist/{openai-embedding-VQZCZQYT.js → openai-embedding-Z5I4K4CN.js} +2 -2
  37. package/dist/perplexity-context-embedding-V5YUMXDR.js +9 -0
  38. package/dist/{perplexity-embedding-227WQY4R.js → perplexity-embedding-X2S72OAC.js} +2 -2
  39. package/dist/plugin-FF4Q34TI.js +32 -0
  40. package/dist/{qwen3-reranker-3MHEENT5.js → qwen3-reranker-HVIQOLKS.js} +2 -2
  41. package/dist/{resolve-CUJWY6HP.js → resolve-Q5D6HECY.js} +2 -2
  42. package/package.json +25 -52
  43. package/src/brainbank.ts +620 -0
  44. package/src/cli/commands/collection.ts +77 -0
  45. package/src/cli/commands/context.ts +171 -0
  46. package/src/cli/commands/daemon.ts +100 -0
  47. package/src/cli/commands/docs.ts +71 -0
  48. package/src/cli/commands/files.ts +69 -0
  49. package/src/cli/commands/help.ts +72 -0
  50. package/src/cli/commands/index.ts +282 -0
  51. package/src/cli/commands/kv.ts +140 -0
  52. package/src/cli/commands/mcp.ts +13 -0
  53. package/src/cli/commands/reembed.ts +30 -0
  54. package/src/cli/commands/scan.ts +365 -0
  55. package/src/cli/commands/search.ts +130 -0
  56. package/src/cli/commands/stats.ts +44 -0
  57. package/src/cli/commands/status.ts +47 -0
  58. package/src/cli/commands/watch.ts +43 -0
  59. package/src/cli/factory/brain-context.ts +43 -0
  60. package/src/cli/factory/builtin-registration.ts +123 -0
  61. package/src/cli/factory/config-loader.ts +72 -0
  62. package/src/cli/factory/index.ts +65 -0
  63. package/src/cli/factory/plugin-loader.ts +146 -0
  64. package/src/cli/index.ts +63 -0
  65. package/src/cli/server-client.ts +135 -0
  66. package/src/cli/utils.ts +121 -0
  67. package/src/config.ts +50 -0
  68. package/src/constants.ts +13 -0
  69. package/src/db/adapter.ts +112 -0
  70. package/src/db/metadata.ts +130 -0
  71. package/src/db/migrations.ts +66 -0
  72. package/src/db/sqlite-adapter.ts +208 -0
  73. package/src/db/tracker.ts +91 -0
  74. package/src/engine/index-api.ts +85 -0
  75. package/src/engine/reembed.ts +206 -0
  76. package/src/engine/search-api.ts +222 -0
  77. package/src/index.ts +159 -0
  78. package/src/lib/fts.ts +57 -0
  79. package/src/lib/languages.ts +180 -0
  80. package/src/lib/logger.ts +125 -0
  81. package/src/lib/math.ts +87 -0
  82. package/src/lib/provider-key.ts +20 -0
  83. package/src/lib/prune.ts +71 -0
  84. package/src/lib/rerank.ts +33 -0
  85. package/src/lib/rrf.ts +133 -0
  86. package/src/lib/write-lock.ts +108 -0
  87. package/src/plugin.ts +323 -0
  88. package/src/providers/embeddings/embedding-worker-thread.ts +95 -0
  89. package/src/providers/embeddings/embedding-worker.ts +141 -0
  90. package/src/providers/embeddings/local-embedding.ts +115 -0
  91. package/src/providers/embeddings/openai-embedding.ts +167 -0
  92. package/src/providers/embeddings/perplexity-context-embedding.ts +195 -0
  93. package/src/providers/embeddings/perplexity-embedding.ts +165 -0
  94. package/src/providers/embeddings/resolve.ts +34 -0
  95. package/src/providers/pruners/haiku-expander.ts +152 -0
  96. package/src/providers/pruners/haiku-pruner.ts +112 -0
  97. package/src/providers/rerankers/qwen3-reranker.ts +180 -0
  98. package/src/providers/vector/hnsw-index.ts +174 -0
  99. package/src/providers/vector/hnsw-loader.ts +129 -0
  100. package/src/search/bm25-boost.ts +61 -0
  101. package/src/search/context-builder.ts +298 -0
  102. package/src/search/keyword/composite-bm25-search.ts +62 -0
  103. package/src/search/types.ts +35 -0
  104. package/src/search/vector/composite-vector-search.ts +76 -0
  105. package/src/search/vector/mmr.ts +64 -0
  106. package/src/services/collection.ts +405 -0
  107. package/src/services/daemon.ts +87 -0
  108. package/src/services/http-server.ts +288 -0
  109. package/src/services/kv-service.ts +65 -0
  110. package/src/services/plugin-registry.ts +109 -0
  111. package/src/services/watch.ts +348 -0
  112. package/src/services/webhook-server.ts +100 -0
  113. package/src/types.ts +504 -0
  114. package/dist/base-3SNc_CeY.d.ts +0 -593
  115. package/dist/chunk-424UFCY7.js.map +0 -1
  116. package/dist/chunk-7EZR47JV.js +0 -232
  117. package/dist/chunk-7EZR47JV.js.map +0 -1
  118. package/dist/chunk-B77KABWH.js.map +0 -1
  119. package/dist/chunk-CCXVL56V.js.map +0 -1
  120. package/dist/chunk-DI3H6JVZ.js +0 -2432
  121. package/dist/chunk-DI3H6JVZ.js.map +0 -1
  122. package/dist/chunk-FGL32LUJ.js +0 -754
  123. package/dist/chunk-FGL32LUJ.js.map +0 -1
  124. package/dist/chunk-JRSKWF6K.js +0 -313
  125. package/dist/chunk-JRSKWF6K.js.map +0 -1
  126. package/dist/chunk-U2Q2XGPZ.js +0 -42
  127. package/dist/chunk-U2Q2XGPZ.js.map +0 -1
  128. package/dist/chunk-VQ27YUHH.js +0 -629
  129. package/dist/chunk-VQ27YUHH.js.map +0 -1
  130. package/dist/chunk-VVXYZIIB.js +0 -304
  131. package/dist/chunk-VVXYZIIB.js.map +0 -1
  132. package/dist/chunk-YOLKSYWK.js +0 -79
  133. package/dist/chunk-YOLKSYWK.js.map +0 -1
  134. package/dist/chunk-ZNLN2VWV.js.map +0 -1
  135. package/dist/code.d.ts +0 -33
  136. package/dist/code.js +0 -9
  137. package/dist/docs.d.ts +0 -21
  138. package/dist/docs.js +0 -9
  139. package/dist/git.d.ts +0 -33
  140. package/dist/git.js +0 -9
  141. package/dist/memory.d.ts +0 -17
  142. package/dist/memory.js +0 -9
  143. package/dist/notes.d.ts +0 -17
  144. package/dist/notes.js +0 -10
  145. package/dist/perplexity-context-embedding-KSVSZXMD.js +0 -9
  146. package/dist/resolve-CUJWY6HP.js.map +0 -1
  147. /package/dist/{code.js.map → haiku-expander-WOVJIVXD.js.map} +0 -0
  148. /package/dist/{docs.js.map → haiku-pruner-DB77ZQLJ.js.map} +0 -0
  149. /package/dist/{git.js.map → http-server-GIRELCCL.js.map} +0 -0
  150. /package/dist/{local-embedding-ZIMTK6PU.js.map → local-embedding-2RNCC5EU.js.map} +0 -0
  151. /package/dist/{memory.js.map → openai-embedding-Z5I4K4CN.js.map} +0 -0
  152. /package/dist/{notes.js.map → perplexity-context-embedding-V5YUMXDR.js.map} +0 -0
  153. /package/dist/{openai-embedding-VQZCZQYT.js.map → perplexity-embedding-X2S72OAC.js.map} +0 -0
  154. /package/dist/{perplexity-context-embedding-KSVSZXMD.js.map → plugin-FF4Q34TI.js.map} +0 -0
  155. /package/dist/{perplexity-embedding-227WQY4R.js.map → qwen3-reranker-HVIQOLKS.js.map} +0 -0
  156. /package/dist/{qwen3-reranker-3MHEENT5.js.map → resolve-Q5D6HECY.js.map} +0 -0
@@ -0,0 +1,129 @@
1
+ /**
2
+ * BrainBank — HNSW Loader
3
+ *
4
+ * Utilities for persisting and loading HNSW indexes to/from disk.
5
+ * Used by BrainBank._runInitialize() and PluginContext.loadVectors().
6
+ *
7
+ * Includes cross-process write locking and hot-reload support
8
+ * for multi-process coordination.
9
+ */
10
+
11
+ import type { DatabaseAdapter, CountRow } from '@/db/adapter.ts';
12
+ import type { HNSWIndex } from './hnsw-index.ts';
13
+
14
+ import { dirname, join } from 'node:path';
15
+ import { withLock } from '@/lib/write-lock.ts';
16
+
17
+ /** Derive the HNSW index file path from the DB path. */
18
+ export function hnswPath(dbPath: string, name: string): string {
19
+ return join(dirname(dbPath), `hnsw-${name}.index`);
20
+ }
21
+
22
+ /** Derive the lock directory from the DB path. */
23
+ export function lockDir(dbPath: string): string {
24
+ return dirname(dbPath);
25
+ }
26
+
27
+ /** Count rows in a vector table (fast, no data transfer). */
28
+ export function countRows(db: DatabaseAdapter, table: string): number {
29
+ const row = db.prepare(`SELECT COUNT(*) as c FROM ${table}`).get() as CountRow;
30
+ return row?.c ?? 0;
31
+ }
32
+
33
+ /**
34
+ * Save all HNSW indexes to disk with cross-process file locking.
35
+ * Prevents concurrent writes from corrupting `.index` files.
36
+ */
37
+ export async function saveAllHnsw(
38
+ dbPath: string,
39
+ kvHnsw: HNSWIndex,
40
+ sharedHnsw: Map<string, { hnsw: HNSWIndex; vecCache: Map<number, Float32Array> }>,
41
+ privateHnsw: Map<string, HNSWIndex>,
42
+ ): Promise<boolean> {
43
+ try {
44
+ await withLock(lockDir(dbPath), 'hnsw', () => {
45
+ kvHnsw.save(hnswPath(dbPath, 'kv'));
46
+ for (const [name, { hnsw }] of sharedHnsw) {
47
+ hnsw.save(hnswPath(dbPath, name));
48
+ }
49
+ for (const [name, hnsw] of privateHnsw) {
50
+ hnsw.save(hnswPath(dbPath, name));
51
+ }
52
+ });
53
+ return true;
54
+ } catch {
55
+ // Non-fatal: next startup rebuilds from SQLite (slower).
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /** Load vectors from SQLite into HNSW + cache. */
61
+ export function loadVectors(
62
+ db: DatabaseAdapter,
63
+ table: string,
64
+ idCol: string,
65
+ hnsw: HNSWIndex,
66
+ cache: Map<number, Float32Array>,
67
+ ): void {
68
+ const iter = db.prepare(`SELECT ${idCol}, embedding FROM ${table}`).iterate() as IterableIterator<{ embedding: Buffer; [key: string]: unknown }>;
69
+ for (const row of iter) {
70
+ const vec = new Float32Array(
71
+ row.embedding.buffer.slice(
72
+ row.embedding.byteOffset,
73
+ row.embedding.byteOffset + row.embedding.byteLength,
74
+ ),
75
+ );
76
+ hnsw.add(vec, row[idCol] as number);
77
+ cache.set(row[idCol] as number, vec);
78
+ }
79
+ }
80
+
81
+ /** Populate only the vecCache from SQLite (HNSW already loaded from file). */
82
+ export function loadVecCache(
83
+ db: DatabaseAdapter,
84
+ table: string,
85
+ idCol: string,
86
+ cache: Map<number, Float32Array>,
87
+ ): void {
88
+ const iter = db.prepare(`SELECT ${idCol}, embedding FROM ${table}`).iterate() as IterableIterator<{ embedding: Buffer; [key: string]: unknown }>;
89
+ for (const row of iter) {
90
+ const vec = new Float32Array(
91
+ row.embedding.buffer.slice(
92
+ row.embedding.byteOffset,
93
+ row.embedding.byteOffset + row.embedding.byteLength,
94
+ ),
95
+ );
96
+ cache.set(row[idCol] as number, vec);
97
+ }
98
+ }
99
+
100
+ /** Deps for reloading a single HNSW index from disk. */
101
+ interface ReloadDeps {
102
+ dbPath: string;
103
+ db: DatabaseAdapter;
104
+ name: string;
105
+ hnsw: HNSWIndex;
106
+ vecCache: Map<number, Float32Array>;
107
+ vectorTable: string;
108
+ idCol: string;
109
+ }
110
+
111
+ /**
112
+ * Reload a single HNSW index from disk after detecting a stale version.
113
+ * Reinitializes the in-memory HNSW, loads the saved index file, and
114
+ * refreshes the vector cache from SQLite.
115
+ */
116
+ export function reloadHnsw(deps: ReloadDeps): void {
117
+ const { dbPath, db, name, hnsw, vecCache, vectorTable, idCol } = deps;
118
+ const indexPath = hnswPath(dbPath, name);
119
+ const rowCount = countRows(db, vectorTable);
120
+
121
+ hnsw.reinit();
122
+ vecCache.clear();
123
+
124
+ if (hnsw.tryLoad(indexPath, rowCount)) {
125
+ loadVecCache(db, vectorTable, idCol, vecCache);
126
+ } else {
127
+ loadVectors(db, vectorTable, idCol, hnsw, vecCache);
128
+ }
129
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * BrainBank — BM25 Intersection Boost
3
+ *
4
+ * Pure functions for post-processing vector search results with BM25 keyword overlap.
5
+ * Extracted from ContextBuilder for single-responsibility and testability.
6
+ */
7
+
8
+ import type { SearchResult } from '@/types.ts';
9
+ import type { SearchStrategy } from './types.ts';
10
+
11
+ /** BM25 boost factor applied to vector results that also match keywords. */
12
+ export const BM25_BOOST = 0.15;
13
+
14
+ /**
15
+ * Boost vector results that also appear in BM25 keyword results.
16
+ * Does NOT add new results — only re-scores and re-sorts existing vector hits.
17
+ * This promotes keyword-relevant files without introducing BM25-only noise.
18
+ */
19
+ export async function boostWithBM25(
20
+ vectorResults: SearchResult[],
21
+ bm25: SearchStrategy,
22
+ query: string,
23
+ sources: Record<string, number>,
24
+ ): Promise<SearchResult[]> {
25
+ if (vectorResults.length === 0) return vectorResults;
26
+
27
+ const bm25Results = await bm25.search(query, { sources });
28
+ if (bm25Results.length === 0) return vectorResults;
29
+
30
+ // Build a set of BM25 hit keys for fast lookup
31
+ const bm25Keys = new Set<string>();
32
+ for (const r of bm25Results) {
33
+ bm25Keys.add(resultKey(r));
34
+ }
35
+
36
+ // Boost scores of vector results that also appear in BM25
37
+ const boosted = vectorResults.map(r => {
38
+ const k = resultKey(r);
39
+ if (bm25Keys.has(k)) {
40
+ return { ...r, score: r.score + BM25_BOOST };
41
+ }
42
+ return r;
43
+ });
44
+
45
+ // Re-sort by boosted score
46
+ boosted.sort((a, b) => b.score - a.score);
47
+ return boosted;
48
+ }
49
+
50
+ /** Filter results whose filePath starts with `prefix`. */
51
+ export function filterByPath(results: SearchResult[], prefix: string | undefined): SearchResult[] {
52
+ if (!prefix) return results;
53
+ return results.filter(r => r.filePath?.startsWith(prefix));
54
+ }
55
+
56
+ /** Generate a dedup key for a search result (file:startLine:endLine). */
57
+ export function resultKey(r: SearchResult): string {
58
+ const sl = 'startLine' in r.metadata ? r.metadata.startLine : '';
59
+ const el = 'endLine' in r.metadata ? r.metadata.endLine : '';
60
+ return `${r.filePath ?? ''}:${sl}:${el}`;
61
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * BrainBank — Context Builder
3
+ *
4
+ * Orchestrates the context-building pipeline:
5
+ * 1. Vector search (primary)
6
+ * 2. Path scoping (filter)
7
+ * 3. LLM noise pruning (optional)
8
+ * 4. Session dedup (filter)
9
+ * 5. LLM context expansion (optional — expander field)
10
+ * 6. Plugin formatters (output)
11
+ *
12
+ * All search post-processing lives in `bm25-boost.ts`.
13
+ * Plugin-agnostic — discovers formatters from ContextFormatterPlugin.
14
+ */
15
+
16
+ import type { ContextOptions, EmbeddingProvider, Expander, ExpanderManifestItem, Pruner, SearchResult } from '@/types.ts';
17
+ import type { SearchStrategy } from './types.ts';
18
+ import type { PluginRegistry } from '@/services/plugin-registry.ts';
19
+
20
+ import { isContextFormatterPlugin, isContextFieldPlugin, isExpandablePlugin, isSearchable } from '@/plugin.ts';
21
+ import { filterByPath } from './bm25-boost.ts';
22
+ import { pruneResults } from '@/lib/prune.ts';
23
+ import { logQuery } from '@/lib/logger.ts';
24
+ import type { QueryLogResult } from '@/lib/logger.ts';
25
+ import { providerKey } from '@/lib/provider-key.ts';
26
+
27
+ export class ContextBuilder {
28
+ constructor(
29
+ private _search: SearchStrategy | undefined,
30
+ private _registry: PluginRegistry,
31
+ private _pruner?: Pruner,
32
+ private _embedding?: EmbeddingProvider,
33
+ private _rerankerName?: string,
34
+ private _configFields: Record<string, unknown> = {},
35
+ private _expander?: Expander,
36
+ ) {}
37
+
38
+ /** Set config-level context field defaults (from config.json "context" section). */
39
+ set configFields(fields: Record<string, unknown>) {
40
+ this._configFields = fields;
41
+ }
42
+
43
+ /** Set the expander instance. */
44
+ set expander(expander: Expander | undefined) {
45
+ this._expander = expander;
46
+ }
47
+
48
+ /** Build a full context block for a task. Returns markdown for system prompt. */
49
+ async build(task: string, options: ContextOptions = {}): Promise<string> {
50
+ const t0 = Date.now();
51
+ const src = options.sources ?? {};
52
+ const { minScore = 0.25, useMMR = true, mmrLambda = 0.7 } = options;
53
+
54
+ // 1. Primary: vector search (includes per-repo BM25 fusion internally)
55
+ let results: SearchResult[] = this._search
56
+ ? await this._search.search(task, {
57
+ sources: src,
58
+ minScore, useMMR, mmrLambda,
59
+ })
60
+ : [];
61
+
62
+ // 2. Path scoping
63
+ results = filterByPath(results, options.pathPrefix);
64
+
65
+ // 3. LLM noise pruning (optional — per-request override or construction-time)
66
+ const pruner = options.pruner ?? this._pruner;
67
+ const beforePrune = results;
68
+ if (pruner && results.length > 1) {
69
+ results = await pruneResults(task, results, pruner);
70
+ }
71
+
72
+ // 4. Exclude already-returned files (session dedup)
73
+ if (options.excludeFiles && options.excludeFiles.size > 0) {
74
+ results = results.filter(r => !r.filePath || !options.excludeFiles!.has(r.filePath));
75
+ }
76
+
77
+ // 5. LLM context expansion (optional — only when expander field is enabled)
78
+ const resolvedFields = this._resolveFields(options);
79
+ let expanderNote: string | undefined;
80
+ if (resolvedFields.expander === true && this._expander && results.length > 0) {
81
+ const expansion = await this._expand(task, results);
82
+ if (expansion.results.length > 0) {
83
+ results = [...results, ...expansion.results];
84
+ }
85
+ expanderNote = expansion.note;
86
+ }
87
+
88
+ // 6. Format output
89
+ const parts: string[] = [`# Context for: "${task}"\n`];
90
+ this._appendFormatterResults(results, parts, options, resolvedFields);
91
+ await this._appendSearchableResults(task, src, minScore, parts);
92
+
93
+ // 7. Append expander note (last section)
94
+ if (expanderNote) {
95
+ parts.push(`\n## Expansion Notes\n\n${expanderNote}\n`);
96
+ }
97
+
98
+ // ── Log ──
99
+ const prunedResults = pruner
100
+ ? beforePrune.filter(r => !results.includes(r))
101
+ : [];
102
+ logQuery({
103
+ source: options.source ?? 'api',
104
+ method: 'getContext',
105
+ query: task,
106
+ embedding: this._embedding ? providerKey(this._embedding) : 'unknown',
107
+ pruner: pruner ? _prunerName(pruner) : null,
108
+ reranker: this._rerankerName ?? null,
109
+ options: {
110
+ sources: src,
111
+ pathPrefix: options.pathPrefix,
112
+ minScore,
113
+ affectedFiles: options.affectedFiles,
114
+ },
115
+ results: results.map(_toLogResult),
116
+ pruned: prunedResults.length > 0 ? prunedResults.map(_toLogResult) : undefined,
117
+ durationMs: Date.now() - t0,
118
+ });
119
+
120
+ return parts.join('\n');
121
+ }
122
+
123
+ /** Invoke ContextFormatterPlugins. Multi-repo: each plugin formats its own results. */
124
+ private _appendFormatterResults(
125
+ results: SearchResult[],
126
+ parts: string[],
127
+ options: ContextOptions,
128
+ resolvedFields?: Record<string, unknown>,
129
+ ): void {
130
+ const fields = resolvedFields ?? this._resolveFields(options);
131
+ const seenFormatters = new Set<string>();
132
+
133
+ for (const mod of this._registry.all) {
134
+ if (!isContextFormatterPlugin(mod)) continue;
135
+ const baseType = mod.name.split(':')[0];
136
+
137
+ // Multi-repo plugin (e.g. 'code:servicehub-frontend'):
138
+ // scope results to this repo and let each plugin format its own
139
+ const colonIdx = mod.name.indexOf(':');
140
+ if (colonIdx > 0) {
141
+ const repoPrefix = mod.name.slice(colonIdx + 1);
142
+ const scoped = results.filter(r =>
143
+ r.filePath?.startsWith(repoPrefix + '/'),
144
+ );
145
+ if (scoped.length > 0) {
146
+ mod.formatContext(scoped, parts, fields);
147
+ }
148
+ continue;
149
+ }
150
+
151
+ // Single-repo plugin: dedup by base type (old behavior)
152
+ if (seenFormatters.has(baseType)) continue;
153
+ seenFormatters.add(baseType);
154
+ mod.formatContext(results, parts, fields);
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Resolve context fields: plugin defaults ← config.json ← per-query.
160
+ * Returns a flat Record with the final value for each field.
161
+ */
162
+ private _resolveFields(options: ContextOptions): Record<string, unknown> {
163
+ // 1. Collect plugin defaults
164
+ const defaults: Record<string, unknown> = {};
165
+ for (const mod of this._registry.all) {
166
+ if (isContextFieldPlugin(mod)) {
167
+ for (const field of mod.contextFields()) {
168
+ defaults[field.name] = field.default;
169
+ }
170
+ }
171
+ }
172
+
173
+ // 2. Merge: defaults ← config ← per-query
174
+ return { ...defaults, ...this._configFields, ...(options.fields ?? {}) };
175
+ }
176
+
177
+ /**
178
+ * Run LLM expansion: build manifest of candidate chunks from files
179
+ * NOT already in search results, call expander, resolve selected IDs.
180
+ */
181
+ private async _expand(task: string, results: SearchResult[]): Promise<{ results: SearchResult[]; note?: string }> {
182
+ if (!this._expander) return { results: [] };
183
+
184
+ // Detect multi-repo prefix from results (e.g. 'servicehub-frontend')
185
+ // Results have prefixed filePaths like 'servicehub-frontend/src/...'
186
+ // but DB chunks have unprefixed paths like 'src/...'
187
+ const repoPrefix = this._detectRepoPrefix(results);
188
+
189
+ // Collect unique file paths already in results (strip repo prefix for DB match)
190
+ const excludeFilePaths = [...new Set(
191
+ results.filter(r => r.filePath).map(r => {
192
+ const fp = r.filePath as string;
193
+ return repoPrefix && fp.startsWith(repoPrefix + '/') ? fp.slice(repoPrefix.length + 1) : fp;
194
+ }),
195
+ )];
196
+
197
+ // Collect current chunk IDs (to exclude from manifest)
198
+ const excludeIds: number[] = [];
199
+ for (const r of results) {
200
+ const meta = r.metadata as Record<string, unknown> | undefined;
201
+ const id = meta?.id as number | undefined;
202
+ if (id !== undefined) excludeIds.push(id);
203
+ }
204
+
205
+ // Build manifest + resolve from the MATCHING ExpandablePlugin only
206
+ // In multi-repo, expand only from the plugin whose name matches the result set's repo
207
+ const manifest: ExpanderManifestItem[] = [];
208
+ let resolver: ((ids: number[]) => SearchResult[]) | undefined;
209
+ for (const mod of this._registry.all) {
210
+ if (!isExpandablePlugin(mod)) continue;
211
+
212
+ // In multi-repo, only expand from the plugin matching the result set's repo
213
+ // e.g. 'code:servicehub-frontend' matches repoPrefix 'servicehub-frontend'
214
+ const colonIdx = mod.name.indexOf(':');
215
+ const pluginRepo = colonIdx > 0 ? mod.name.slice(colonIdx + 1) : undefined;
216
+ if (repoPrefix && pluginRepo && pluginRepo !== repoPrefix) continue;
217
+
218
+ manifest.push(...mod.buildManifest(excludeFilePaths, excludeIds));
219
+ if (!resolver) {
220
+ const prefix = pluginRepo;
221
+ resolver = (ids: number[]) => {
222
+ const chunks = mod.resolveChunks(ids);
223
+ // Add repo prefix back to resolved chunk filePaths
224
+ if (prefix) {
225
+ for (const chunk of chunks) {
226
+ if (chunk.filePath) chunk.filePath = `${prefix}/${chunk.filePath}`;
227
+ const meta = chunk.metadata as Record<string, unknown>;
228
+ if (typeof meta.filePath === 'string') {
229
+ meta.filePath = `${prefix}/${meta.filePath}`;
230
+ }
231
+ }
232
+ }
233
+ return chunks;
234
+ };
235
+ }
236
+ }
237
+ if (manifest.length === 0 || !resolver) return { results: [] };
238
+
239
+ // Call expander
240
+ try {
241
+ const expandResult = await this._expander.expand(task, excludeIds, manifest);
242
+ if (expandResult.ids.length === 0) return { results: [], note: expandResult.note };
243
+ return { results: resolver(expandResult.ids), note: expandResult.note };
244
+ } catch {
245
+ // Fail-open: expansion errors are non-fatal
246
+ return { results: [] };
247
+ }
248
+ }
249
+
250
+ /** Detect the multi-repo prefix from existing results (e.g. 'servicehub-frontend'). */
251
+ private _detectRepoPrefix(results: SearchResult[]): string | undefined {
252
+ for (const r of results) {
253
+ if (!r.filePath) continue;
254
+ // Multi-repo filePaths look like 'servicehub-frontend/src/...'
255
+ // The repo prefix is the first path segment before 'src/'
256
+ const srcIdx = r.filePath.indexOf('/src/');
257
+ if (srcIdx > 0) return r.filePath.slice(0, srcIdx);
258
+ }
259
+ return undefined;
260
+ }
261
+
262
+ /** Collect results from SearchablePlugins that don't have their own formatter. */
263
+ private async _appendSearchableResults(
264
+ task: string,
265
+ sources: Record<string, number>,
266
+ minScore: number,
267
+ parts: string[],
268
+ ): Promise<void> {
269
+ for (const mod of this._registry.all) {
270
+ if (isContextFormatterPlugin(mod)) continue;
271
+ if (!isSearchable(mod)) continue;
272
+ const hits = await mod.search(task, { k: sources[mod.name.split(':')[0]] ?? 6, minScore });
273
+ if (hits.length > 0) {
274
+ parts.push(`## ${mod.name}\n`);
275
+ for (const r of hits) {
276
+ parts.push(`- [${Math.round(r.score * 100)}%] ${r.content.slice(0, 200)}`);
277
+ }
278
+ parts.push('');
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ // ── Helpers ──────────────────────────────────────────
285
+
286
+ function _toLogResult(r: SearchResult): QueryLogResult {
287
+ const meta = r.metadata as Record<string, unknown> | undefined;
288
+ return {
289
+ filePath: r.filePath ?? 'unknown',
290
+ score: r.score,
291
+ type: r.type,
292
+ name: (meta?.name as string | undefined) ?? undefined,
293
+ };
294
+ }
295
+
296
+ function _prunerName(pruner: Pruner): string {
297
+ return pruner.constructor?.name ?? 'custom';
298
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * BrainBank — Composite BM25 Search Strategy
3
+ *
4
+ * Generic BM25 coordinator that discovers BM25SearchPlugin instances
5
+ * from the registry and delegates per-source keyword search.
6
+ */
7
+
8
+ import type { SearchResult } from '@/types.ts';
9
+ import type { SearchStrategy, SearchOptions } from '@/search/types.ts';
10
+ import type { PluginRegistry } from '@/services/plugin-registry.ts';
11
+
12
+ import { isBM25SearchPlugin } from '@/plugin.ts';
13
+
14
+ const DEFAULT_K = 8;
15
+
16
+ export class CompositeBM25Search implements SearchStrategy {
17
+ constructor(private _registry: PluginRegistry) {}
18
+
19
+ /**
20
+ * Run BM25 keyword search across all plugins that implement BM25SearchPlugin.
21
+ * Each plugin searches its own FTS5 tables.
22
+ */
23
+ async search(query: string, options: SearchOptions = {}): Promise<SearchResult[]> {
24
+ const src = options.sources ?? {};
25
+ const results: SearchResult[] = [];
26
+
27
+ for (const plugin of this._registry.all) {
28
+ if (!isBM25SearchPlugin(plugin)) continue;
29
+
30
+ const baseType = plugin.name.split(':')[0];
31
+ const k = src[baseType] ?? DEFAULT_K;
32
+ if (k <= 0) continue;
33
+
34
+ const hits = plugin.searchBM25(query, k);
35
+
36
+ // Multi-repo: prefix filePaths with sub-repo name (same as CompositeVectorSearch)
37
+ const colonIdx = plugin.name.indexOf(':');
38
+ if (colonIdx > 0) {
39
+ const subRepo = plugin.name.slice(colonIdx + 1);
40
+ for (const hit of hits) {
41
+ if (hit.filePath) hit.filePath = `${subRepo}/${hit.filePath}`;
42
+ const meta = hit.metadata as Record<string, unknown>;
43
+ if (typeof meta.filePath === 'string') {
44
+ meta.filePath = `${subRepo}/${meta.filePath}`;
45
+ }
46
+ }
47
+ }
48
+
49
+ results.push(...hits);
50
+ }
51
+
52
+ return results.sort((a, b) => b.score - a.score);
53
+ }
54
+
55
+ /** Rebuild FTS5 indices across all BM25 plugins. */
56
+ rebuild(): void {
57
+ for (const plugin of this._registry.all) {
58
+ if (!isBM25SearchPlugin(plugin)) continue;
59
+ plugin.rebuildFTS?.();
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * BrainBank — Search Types
3
+ *
4
+ * Shared interface for all search strategies.
5
+ * Implement SearchStrategy to add a new search backend.
6
+ */
7
+
8
+ import type { SearchResult } from '@/types.ts';
9
+
10
+ /** Any search implementation follows this shape. */
11
+ export interface SearchStrategy {
12
+ search(query: string, options?: SearchOptions): Promise<SearchResult[]>;
13
+ /** Rebuild internal indices (e.g. FTS5). Optional. */
14
+ rebuild?(): void;
15
+ }
16
+
17
+ /** Pre-embedded vector search for a single domain (code, git, etc.). */
18
+ export interface DomainVectorSearch {
19
+ /** Search using a pre-computed query vector. Optional queryText enables BM25 fusion. */
20
+ search(queryVec: Float32Array, k: number, minScore: number, useMMR?: boolean, mmrLambda?: number, queryText?: string): SearchResult[];
21
+ }
22
+
23
+ export interface SearchOptions {
24
+ /** Per-source result limits. Built-in: 'code', 'git', 'memory'. Any other key = custom plugin or KV collection. */
25
+ sources?: Record<string, number>;
26
+ /** Minimum similarity score. Default: 0.25 */
27
+ minScore?: number;
28
+ /** Use MMR for diversity. Default: true */
29
+ useMMR?: boolean;
30
+ /** MMR lambda. Default: 0.7 */
31
+ mmrLambda?: number;
32
+ /** Caller origin for debug logging. */
33
+ source?: 'cli' | 'mcp' | 'daemon' | 'api';
34
+ }
35
+
@@ -0,0 +1,76 @@
1
+ /**
2
+ * BrainBank — Composite Vector Search
3
+ *
4
+ * Generic orchestrator for domain-specific vector searches.
5
+ * Embeds the query once, delegates to registered DomainVectorSearch strategies.
6
+ * Uses round-robin interleaving when multiple strategies exist to ensure
7
+ * balanced representation across repos/domains.
8
+ * Plugin-agnostic — strategies are discovered at wiring time.
9
+ */
10
+
11
+ import type { EmbeddingProvider, SearchResult } from '@/types.ts';
12
+ import type { SearchStrategy, SearchOptions, DomainVectorSearch } from '@/search/types.ts';
13
+
14
+ export interface CompositeVectorConfig {
15
+ strategies: Map<string, DomainVectorSearch>;
16
+ embedding: EmbeddingProvider;
17
+ /** Default K values per strategy name. Strategies not listed default to 0. */
18
+ defaults?: Record<string, number>;
19
+ }
20
+
21
+ export class CompositeVectorSearch implements SearchStrategy {
22
+ /** Default K when no source override is provided. */
23
+ private static readonly DEFAULT_K = 6;
24
+
25
+ constructor(private _c: CompositeVectorConfig) {}
26
+
27
+ /** Search across all registered domain strategies with score-based merge. */
28
+ async search(query: string, options: SearchOptions = {}): Promise<SearchResult[]> {
29
+ const src = options.sources ?? {};
30
+ const { minScore = 0.25, useMMR = true, mmrLambda = 0.7 } = options;
31
+
32
+ const queryVec = await this._c.embedding.embed(query);
33
+
34
+ // Each strategy gets full K for ranking quality, cap total after merge
35
+ const allResults: SearchResult[] = [];
36
+ let requestedK = 0;
37
+
38
+ for (const [name, strategy] of this._c.strategies) {
39
+ const baseName = name.split(':')[0];
40
+ const k = src[name] ?? src[baseName] ?? this._c.defaults?.[name] ?? CompositeVectorSearch.DEFAULT_K;
41
+ if (k <= 0) continue;
42
+ requestedK = Math.max(requestedK, k);
43
+ const hits = strategy.search(queryVec, k, minScore, useMMR, mmrLambda, query);
44
+
45
+ // Multi-repo: prefix filePaths with sub-repo name so path filtering works
46
+ // e.g. strategy 'code:servicehub-backend' → filePath 'servicehub-backend/src/app.ts'
47
+ const colonIdx = name.indexOf(':');
48
+ if (colonIdx > 0) {
49
+ const subRepo = name.slice(colonIdx + 1);
50
+ for (const hit of hits) {
51
+ if (hit.filePath) hit.filePath = `${subRepo}/${hit.filePath}`;
52
+ const meta = hit.metadata as Record<string, unknown>;
53
+ if (typeof meta.filePath === 'string') {
54
+ meta.filePath = `${subRepo}/${meta.filePath}`;
55
+ }
56
+ }
57
+ }
58
+
59
+ allResults.push(...hits);
60
+ }
61
+
62
+ if (allResults.length === 0) return [];
63
+
64
+ // Sort by raw rrfScore (comparable across repos), cap to requested K
65
+ allResults.sort((a, b) => b.score - a.score);
66
+ const capped = allResults.slice(0, requestedK);
67
+
68
+ // Normalize scores 0-1 globally
69
+ const maxScore = capped[0].score;
70
+ if (maxScore > 0) {
71
+ for (const r of capped) r.score = r.score / maxScore;
72
+ }
73
+
74
+ return capped;
75
+ }
76
+ }