brainbank 0.1.0-beta.1
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/LICENSE +21 -0
- package/README.md +155 -0
- package/assets/architecture.png +0 -0
- package/bin/brainbank +18 -0
- package/bin/brainbank-mcp +19 -0
- package/dist/chunk-3YBCD6DI.js +117 -0
- package/dist/chunk-3YBCD6DI.js.map +1 -0
- package/dist/chunk-63GBCDS5.js +3249 -0
- package/dist/chunk-63GBCDS5.js.map +1 -0
- package/dist/chunk-DMFMTOHF.js +123 -0
- package/dist/chunk-DMFMTOHF.js.map +1 -0
- package/dist/chunk-FQYKWB2Q.js +136 -0
- package/dist/chunk-FQYKWB2Q.js.map +1 -0
- package/dist/chunk-IMJJ2VEM.js +74 -0
- package/dist/chunk-IMJJ2VEM.js.map +1 -0
- package/dist/chunk-M744PCJQ.js +43 -0
- package/dist/chunk-M744PCJQ.js.map +1 -0
- package/dist/chunk-O3J6ZIXK.js +82 -0
- package/dist/chunk-O3J6ZIXK.js.map +1 -0
- package/dist/chunk-OPH7GZ7U.js +124 -0
- package/dist/chunk-OPH7GZ7U.js.map +1 -0
- package/dist/chunk-PXEWQMN7.js +89 -0
- package/dist/chunk-PXEWQMN7.js.map +1 -0
- package/dist/chunk-RDQYDLYZ.js +69 -0
- package/dist/chunk-RDQYDLYZ.js.map +1 -0
- package/dist/chunk-VIIHPCC4.js +254 -0
- package/dist/chunk-VIIHPCC4.js.map +1 -0
- package/dist/chunk-WCQVDF3K.js +14 -0
- package/dist/chunk-WCQVDF3K.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +3076 -0
- package/dist/cli.js.map +1 -0
- package/dist/haiku-expander-YRSIPGKP.js +8 -0
- package/dist/haiku-expander-YRSIPGKP.js.map +1 -0
- package/dist/haiku-pruner-SHAXUPY6.js +8 -0
- package/dist/haiku-pruner-SHAXUPY6.js.map +1 -0
- package/dist/http-server-QUXHLWUM.js +9 -0
- package/dist/http-server-QUXHLWUM.js.map +1 -0
- package/dist/index.d.ts +2161 -0
- package/dist/index.js +357 -0
- package/dist/index.js.map +1 -0
- package/dist/local-embedding-NZQTILGV.js +8 -0
- package/dist/local-embedding-NZQTILGV.js.map +1 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +334 -0
- package/dist/mcp.js.map +1 -0
- package/dist/openai-embedding-ZP5TSUJG.js +8 -0
- package/dist/openai-embedding-ZP5TSUJG.js.map +1 -0
- package/dist/perplexity-context-embedding-GI5PHE6X.js +9 -0
- package/dist/perplexity-context-embedding-GI5PHE6X.js.map +1 -0
- package/dist/perplexity-embedding-KZRYGJRC.js +10 -0
- package/dist/perplexity-embedding-KZRYGJRC.js.map +1 -0
- package/dist/plugin-IKQ6IRSJ.js +32 -0
- package/dist/plugin-IKQ6IRSJ.js.map +1 -0
- package/dist/resolve-ASGLBNUC.js +10 -0
- package/dist/resolve-ASGLBNUC.js.map +1 -0
- package/dist/stats-tui-ZY2NQSEA.js +1904 -0
- package/dist/stats-tui-ZY2NQSEA.js.map +1 -0
- package/package.json +96 -0
- package/src/brainbank.ts +617 -0
- package/src/cli/commands/collection.ts +77 -0
- package/src/cli/commands/context.ts +179 -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 +77 -0
- package/src/cli/commands/index.ts +482 -0
- package/src/cli/commands/kv.ts +140 -0
- package/src/cli/commands/mcp-export.ts +273 -0
- package/src/cli/commands/mcp.ts +6 -0
- package/src/cli/commands/reembed.ts +30 -0
- package/src/cli/commands/scan.ts +336 -0
- package/src/cli/commands/search.ts +203 -0
- package/src/cli/commands/stats.ts +68 -0
- package/src/cli/commands/status.ts +47 -0
- package/src/cli/commands/watch.ts +47 -0
- package/src/cli/factory/brain-context.ts +43 -0
- package/src/cli/factory/builtin-registration.ts +87 -0
- package/src/cli/factory/config-loader.ts +77 -0
- package/src/cli/factory/index.ts +69 -0
- package/src/cli/factory/plugin-loader.ts +325 -0
- package/src/cli/index.ts +71 -0
- package/src/cli/server-client.ts +178 -0
- package/src/cli/tui/index-tui.tsx +667 -0
- package/src/cli/tui/stats-data.ts +523 -0
- package/src/cli/tui/stats-search.ts +262 -0
- package/src/cli/tui/stats-tui.tsx +1465 -0
- package/src/cli/tui/tree-scanner.ts +650 -0
- package/src/cli/utils.ts +137 -0
- package/src/config.ts +49 -0
- package/src/constants.ts +21 -0
- package/src/db/adapter.ts +112 -0
- package/src/db/metadata.ts +130 -0
- package/src/db/migrations.ts +66 -0
- package/src/db/sqlite-adapter.ts +218 -0
- package/src/db/tracker.ts +91 -0
- package/src/engine/index-api.ts +81 -0
- package/src/engine/reembed.ts +206 -0
- package/src/engine/search-api.ts +218 -0
- package/src/index.ts +154 -0
- package/src/lib/fts.ts +57 -0
- package/src/lib/languages.ts +180 -0
- package/src/lib/logger.ts +126 -0
- package/src/lib/math.ts +87 -0
- package/src/lib/provider-key.ts +20 -0
- package/src/lib/prune.ts +71 -0
- package/src/lib/rrf.ts +133 -0
- package/src/lib/write-lock.ts +108 -0
- package/src/mcp/mcp-server.ts +195 -0
- package/src/mcp/workspace-factory.ts +68 -0
- package/src/mcp/workspace-pool.ts +224 -0
- package/src/plugin.ts +381 -0
- package/src/providers/embeddings/embedding-worker-thread.ts +95 -0
- package/src/providers/embeddings/embedding-worker.ts +141 -0
- package/src/providers/embeddings/local-embedding.ts +115 -0
- package/src/providers/embeddings/openai-embedding.ts +167 -0
- package/src/providers/embeddings/perplexity-context-embedding.ts +195 -0
- package/src/providers/embeddings/perplexity-embedding.ts +165 -0
- package/src/providers/embeddings/resolve.ts +34 -0
- package/src/providers/pruners/haiku-expander.ts +166 -0
- package/src/providers/pruners/haiku-pruner.ts +112 -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 +69 -0
- package/src/search/context-builder.ts +251 -0
- package/src/search/keyword/composite-bm25-search.ts +47 -0
- package/src/search/types.ts +37 -0
- package/src/search/vector/composite-vector-search.ts +61 -0
- package/src/search/vector/mmr.ts +64 -0
- package/src/services/collection.ts +384 -0
- package/src/services/daemon.ts +87 -0
- package/src/services/http-server.ts +336 -0
- package/src/services/kv-service.ts +64 -0
- package/src/services/plugin-registry.ts +77 -0
- package/src/services/watch.ts +340 -0
- package/src/services/webhook-server.ts +100 -0
- package/src/types.ts +493 -0
|
@@ -0,0 +1,251 @@
|
|
|
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, filterByIgnore } 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 _configFields: Record<string, unknown> = {},
|
|
34
|
+
private _expander?: Expander,
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
/** Set config-level context field defaults (from config.json "context" section). */
|
|
38
|
+
set configFields(fields: Record<string, unknown>) {
|
|
39
|
+
this._configFields = fields;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Set the expander instance. */
|
|
43
|
+
set expander(expander: Expander | undefined) {
|
|
44
|
+
this._expander = expander;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Build a full context block for a task. Returns markdown for system prompt. */
|
|
48
|
+
async build(task: string, options: ContextOptions = {}): Promise<string> {
|
|
49
|
+
const t0 = Date.now();
|
|
50
|
+
const src = options.sources ?? {};
|
|
51
|
+
const { minScore = 0.25, useMMR = true, mmrLambda = 0.7 } = options;
|
|
52
|
+
|
|
53
|
+
// 1. Primary: vector search (includes per-repo BM25 fusion internally)
|
|
54
|
+
let results: SearchResult[] = this._search
|
|
55
|
+
? await this._search.search(task, {
|
|
56
|
+
sources: src,
|
|
57
|
+
minScore, useMMR, mmrLambda,
|
|
58
|
+
})
|
|
59
|
+
: [];
|
|
60
|
+
|
|
61
|
+
// 2. Path scoping + ignore filtering
|
|
62
|
+
results = filterByPath(results, options.pathPrefix);
|
|
63
|
+
results = filterByIgnore(results, options.ignorePaths);
|
|
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
|
+
const expanderEnabled = resolvedFields.expander === true;
|
|
103
|
+
const expandedResults = expanderNote !== undefined
|
|
104
|
+
? results.filter(r => !beforePrune.includes(r) && !prunedResults.includes(r))
|
|
105
|
+
: [];
|
|
106
|
+
logQuery({
|
|
107
|
+
source: options.source ?? 'api',
|
|
108
|
+
method: 'getContext',
|
|
109
|
+
query: task,
|
|
110
|
+
embedding: this._embedding ? providerKey(this._embedding) : 'unknown',
|
|
111
|
+
pruner: pruner ? _prunerName(pruner) : null,
|
|
112
|
+
expander: expanderEnabled ? (this._expander ? _expanderName(this._expander) : 'configured-no-instance') : null,
|
|
113
|
+
expandedCount: expandedResults.length > 0 ? expandedResults.length : undefined,
|
|
114
|
+
options: {
|
|
115
|
+
sources: src,
|
|
116
|
+
pathPrefix: options.pathPrefix,
|
|
117
|
+
ignorePaths: options.ignorePaths,
|
|
118
|
+
minScore,
|
|
119
|
+
affectedFiles: options.affectedFiles,
|
|
120
|
+
},
|
|
121
|
+
results: results.map(_toLogResult),
|
|
122
|
+
pruned: prunedResults.length > 0 ? prunedResults.map(_toLogResult) : undefined,
|
|
123
|
+
durationMs: Date.now() - t0,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return parts.join('\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Invoke ContextFormatterPlugins. */
|
|
130
|
+
private _appendFormatterResults(
|
|
131
|
+
results: SearchResult[],
|
|
132
|
+
parts: string[],
|
|
133
|
+
options: ContextOptions,
|
|
134
|
+
resolvedFields?: Record<string, unknown>,
|
|
135
|
+
): void {
|
|
136
|
+
const fields = resolvedFields ?? this._resolveFields(options);
|
|
137
|
+
const seenFormatters = new Set<string>();
|
|
138
|
+
|
|
139
|
+
for (const mod of this._registry.all) {
|
|
140
|
+
if (!isContextFormatterPlugin(mod)) continue;
|
|
141
|
+
|
|
142
|
+
if (seenFormatters.has(mod.name)) continue;
|
|
143
|
+
seenFormatters.add(mod.name);
|
|
144
|
+
mod.formatContext(results, parts, fields);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve context fields: plugin defaults ← config.json ← per-query.
|
|
150
|
+
* Returns a flat Record with the final value for each field.
|
|
151
|
+
*/
|
|
152
|
+
private _resolveFields(options: ContextOptions): Record<string, unknown> {
|
|
153
|
+
// 1. Collect plugin defaults
|
|
154
|
+
const defaults: Record<string, unknown> = {};
|
|
155
|
+
for (const mod of this._registry.all) {
|
|
156
|
+
if (isContextFieldPlugin(mod)) {
|
|
157
|
+
for (const field of mod.contextFields()) {
|
|
158
|
+
defaults[field.name] = field.default;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 2. Merge: defaults ← config ← per-query
|
|
164
|
+
return { ...defaults, ...this._configFields, ...(options.fields ?? {}) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Run LLM expansion: build manifest of candidate chunks from files
|
|
169
|
+
* NOT already in search results, call expander, resolve selected IDs.
|
|
170
|
+
*/
|
|
171
|
+
private async _expand(task: string, results: SearchResult[]): Promise<{ results: SearchResult[]; note?: string }> {
|
|
172
|
+
if (!this._expander) return { results: [] };
|
|
173
|
+
|
|
174
|
+
// Collect unique file paths already in results
|
|
175
|
+
const excludeFilePaths = [...new Set(
|
|
176
|
+
results.filter(r => r.filePath).map(r => r.filePath as string),
|
|
177
|
+
)];
|
|
178
|
+
|
|
179
|
+
// Collect current chunk IDs (to exclude from manifest)
|
|
180
|
+
const excludeIds: number[] = [];
|
|
181
|
+
for (const r of results) {
|
|
182
|
+
const meta = r.metadata as Record<string, unknown> | undefined;
|
|
183
|
+
const id = meta?.id as number | undefined;
|
|
184
|
+
if (id !== undefined) excludeIds.push(id);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Build manifest + resolve from ExpandablePlugins
|
|
188
|
+
const manifest: ExpanderManifestItem[] = [];
|
|
189
|
+
let resolver: ((ids: number[]) => SearchResult[]) | undefined;
|
|
190
|
+
for (const mod of this._registry.all) {
|
|
191
|
+
if (!isExpandablePlugin(mod)) continue;
|
|
192
|
+
|
|
193
|
+
manifest.push(...mod.buildManifest(excludeFilePaths, excludeIds));
|
|
194
|
+
if (!resolver) {
|
|
195
|
+
resolver = (ids: number[]) => mod.resolveChunks(ids);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (manifest.length === 0 || !resolver) return { results: [] };
|
|
199
|
+
|
|
200
|
+
// Call expander
|
|
201
|
+
try {
|
|
202
|
+
const expandResult = await this._expander.expand(task, excludeIds, manifest);
|
|
203
|
+
if (expandResult.ids.length === 0) return { results: [], note: expandResult.note };
|
|
204
|
+
return { results: resolver(expandResult.ids), note: expandResult.note };
|
|
205
|
+
} catch {
|
|
206
|
+
// Fail-open: expansion errors are non-fatal
|
|
207
|
+
return { results: [] };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Collect results from SearchablePlugins that don't have their own formatter. */
|
|
212
|
+
private async _appendSearchableResults(
|
|
213
|
+
task: string,
|
|
214
|
+
sources: Record<string, number>,
|
|
215
|
+
minScore: number,
|
|
216
|
+
parts: string[],
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
for (const mod of this._registry.all) {
|
|
219
|
+
if (isContextFormatterPlugin(mod)) continue;
|
|
220
|
+
if (!isSearchable(mod)) continue;
|
|
221
|
+
const hits = await mod.search(task, { k: sources[mod.name] ?? 6, minScore });
|
|
222
|
+
if (hits.length > 0) {
|
|
223
|
+
parts.push(`## ${mod.name}\n`);
|
|
224
|
+
for (const r of hits) {
|
|
225
|
+
parts.push(`- [${Math.round(r.score * 100)}%] ${r.content.slice(0, 200)}`);
|
|
226
|
+
}
|
|
227
|
+
parts.push('');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Helpers ──────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
function _toLogResult(r: SearchResult): QueryLogResult {
|
|
236
|
+
const meta = r.metadata as Record<string, unknown> | undefined;
|
|
237
|
+
return {
|
|
238
|
+
filePath: r.filePath ?? 'unknown',
|
|
239
|
+
score: r.score,
|
|
240
|
+
type: r.type,
|
|
241
|
+
name: (meta?.name as string | undefined) ?? undefined,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function _prunerName(pruner: Pruner): string {
|
|
246
|
+
return pruner.constructor?.name ?? 'custom';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function _expanderName(expander: Expander): string {
|
|
250
|
+
return expander.constructor?.name ?? 'custom';
|
|
251
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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 k = src[plugin.name] ?? DEFAULT_K;
|
|
31
|
+
if (k <= 0) continue;
|
|
32
|
+
|
|
33
|
+
const hits = plugin.searchBM25(query, k);
|
|
34
|
+
results.push(...hits);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return results.sort((a, b) => b.score - a.score);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Rebuild FTS5 indices across all BM25 plugins. */
|
|
41
|
+
rebuild(): void {
|
|
42
|
+
for (const plugin of this._registry.all) {
|
|
43
|
+
if (!isBM25SearchPlugin(plugin)) continue;
|
|
44
|
+
plugin.rebuildFTS?.();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
/** Filter results to files under these path prefixes. */
|
|
35
|
+
pathPrefix?: string | string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
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 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 k = src[name] ?? this._c.defaults?.[name] ?? CompositeVectorSearch.DEFAULT_K;
|
|
40
|
+
if (k <= 0) continue;
|
|
41
|
+
requestedK = Math.max(requestedK, k);
|
|
42
|
+
const hits = strategy.search(queryVec, k, minScore, useMMR, mmrLambda, query);
|
|
43
|
+
|
|
44
|
+
allResults.push(...hits);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (allResults.length === 0) return [];
|
|
48
|
+
|
|
49
|
+
// Sort by raw rrfScore, cap to requested K
|
|
50
|
+
allResults.sort((a, b) => b.score - a.score);
|
|
51
|
+
const capped = allResults.slice(0, requestedK);
|
|
52
|
+
|
|
53
|
+
// Normalize scores 0-1 globally
|
|
54
|
+
const maxScore = capped[0].score;
|
|
55
|
+
if (maxScore > 0) {
|
|
56
|
+
for (const r of capped) r.score = r.score / maxScore;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return capped;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrainBank — Maximum Marginal Relevance (MMR)
|
|
3
|
+
*
|
|
4
|
+
* Diversifies vector search results to avoid returning redundant items.
|
|
5
|
+
* λ=1.0 → pure relevance, λ=0.0 → pure diversity.
|
|
6
|
+
* Default λ=0.7 balances both.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { VectorIndex, SearchHit } from '@/types.ts';
|
|
10
|
+
import { cosineSimilarity } from '@/lib/math.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Search with Maximum Marginal Relevance for diversified results.
|
|
14
|
+
*
|
|
15
|
+
* Algorithm:
|
|
16
|
+
* 1. Get 3x candidates from HNSW
|
|
17
|
+
* 2. Greedily select items that maximize: λ * relevance - (1-λ) * max_sim_to_selected
|
|
18
|
+
*/
|
|
19
|
+
export function searchMMR(
|
|
20
|
+
index: VectorIndex,
|
|
21
|
+
query: Float32Array,
|
|
22
|
+
vectorCache: Map<number, Float32Array>,
|
|
23
|
+
k: number,
|
|
24
|
+
lambda: number = 0.7,
|
|
25
|
+
): SearchHit[] {
|
|
26
|
+
// Get more candidates than needed
|
|
27
|
+
const candidates = index.search(query, k * 3);
|
|
28
|
+
if (candidates.length <= k) return candidates;
|
|
29
|
+
|
|
30
|
+
const selected: SearchHit[] = [];
|
|
31
|
+
const remaining = [...candidates];
|
|
32
|
+
|
|
33
|
+
while (selected.length < k && remaining.length > 0) {
|
|
34
|
+
let bestScore = -Infinity;
|
|
35
|
+
let bestIdx = 0;
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
38
|
+
const relevance = remaining[i].score;
|
|
39
|
+
|
|
40
|
+
// Max similarity to any already-selected item
|
|
41
|
+
let maxSim = 0;
|
|
42
|
+
for (const sel of selected) {
|
|
43
|
+
const candidateVec = vectorCache.get(remaining[i].id);
|
|
44
|
+
const selectedVec = vectorCache.get(sel.id);
|
|
45
|
+
if (candidateVec && selectedVec) {
|
|
46
|
+
maxSim = Math.max(maxSim, cosineSimilarity(candidateVec, selectedVec));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// MMR score: balance relevance vs diversity
|
|
51
|
+
const mmrScore = lambda * relevance - (1 - lambda) * maxSim;
|
|
52
|
+
|
|
53
|
+
if (mmrScore > bestScore) {
|
|
54
|
+
bestScore = mmrScore;
|
|
55
|
+
bestIdx = i;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
selected.push(remaining[bestIdx]);
|
|
60
|
+
remaining.splice(bestIdx, 1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return selected;
|
|
64
|
+
}
|