/** * BrainBank — Context Builder * * Orchestrates the context-building pipeline: * 1. Vector search (primary) * 2. Path scoping (filter) * 3. LLM noise pruning (optional) * 4. Session dedup (filter) * 5. Plugin formatters (output) * * All search post-processing lives in `bm25-boost.ts`. * Plugin-agnostic — discovers formatters from ContextFormatterPlugin. */ import type { ContextOptions, EmbeddingProvider, Pruner, SearchResult } from '@/types.ts'; import type { SearchStrategy } from './types.ts'; import type { PluginRegistry } from '@/services/plugin-registry.ts'; import { isContextFormatterPlugin, isContextFieldPlugin, isSearchable } from '@/plugin.ts'; import { filterByPath, filterByIgnore } from './bm25-boost.ts'; import { pruneResults } from '@/lib/prune.ts'; import { logQuery } from '@/lib/logger.ts'; import type { QueryLogResult } from '@/lib/logger.ts'; import { providerKey } from '@/lib/provider-key.ts'; const _debug = !!process.env.BRAINBANK_DEBUG; function dbg(msg: string): void { if (_debug) console.error(msg); } export class ContextBuilder { constructor( private _search: SearchStrategy | undefined, private _registry: PluginRegistry, private _pruner?: Pruner, private _embedding?: EmbeddingProvider, private _configFields: Record = {}, ) {} /** Set config-level context field defaults (from config.json "context" section). */ set configFields(fields: Record) { this._configFields = fields; } /** Build a full context block for a task. Returns markdown for system prompt. */ async build(task: string, options: ContextOptions = {}): Promise { const t0 = Date.now(); const src = options.sources ?? {}; const { minScore = 0.25, useMMR = true, mmrLambda = 0.7 } = options; // 1. Primary: vector search (includes per-repo BM25 fusion internally) let results: SearchResult[] = this._search ? await this._search.search(task, { sources: src, minScore, useMMR, mmrLambda, }) : []; // 2. Path scoping + ignore filtering results = filterByPath(results, options.pathPrefix); results = filterByIgnore(results, options.ignorePaths); // 3. LLM noise pruning (optional — per-request override or construction-time) const pruner = options.pruner ?? this._pruner; const beforePrune = results; // 3a. Pre-score filtering: when many results, drop the weakest before LLM pruning // This keeps the pruner's input manageable and improves accuracy. if (pruner && results.length > 40) { const topScore = Math.max(...results.map(r => r.score)); const threshold = topScore * 0.35; // Keep results scoring >= 35% of top const preFiltered = results.filter(r => r.score >= threshold); if (preFiltered.length < results.length && preFiltered.length >= 3) { dbg(`[pre-filter] Dropped ${results.length - preFiltered.length} low-score results (threshold: ${threshold.toFixed(3)}, top: ${topScore.toFixed(3)})`); results = preFiltered; } } if (pruner && results.length > 1) { dbg(`[pruner] Running ${_prunerName(pruner)} on ${results.length} results...`); const pruneT0 = Date.now(); // Merge context + prunerContext into a single pruner description. // Auto-mode: when no explicit context/pruner is given, inject a default // instruction so the pruner always filters noise aggressively. const explicitDesc = [options.context, options.prunerContext].filter(Boolean).join('\n\n'); const prunerDesc = explicitDesc || 'Auto-mode: aggressively drop files that are not directly related to the query. Keep only core implementation, types, and orchestration files. Drop infrastructure, config, unrelated services, and boilerplate.'; results = await pruneResults(task, results, pruner, prunerDesc); const pruneMs = Date.now() - pruneT0; const dropped = beforePrune.filter(r => !results.includes(r)); dbg(`[pruner] ${beforePrune.length} → ${results.length} in ${pruneMs}ms (${dropped.length} dropped)`); if (results.length > 0) { dbg(`[pruner] Kept: ${results.map(r => r.filePath ?? '?').join(', ')}`); } if (dropped.length > 0) { dbg(`[pruner] Dropped: ${dropped.map(r => r.filePath ?? '?').join(', ')}`); } } else if (!pruner) { dbg(`[pruner] No pruner configured — skipping`); } else { dbg(`[pruner] Only ${results.length} result(s) — skipping pruner (need >1)`); } // 4. Exclude already-returned files (session dedup) if (options.excludeFiles && options.excludeFiles.size > 0) { results = results.filter(r => !r.filePath || !options.excludeFiles!.has(r.filePath)); } // 5. Format output const resolvedFields = this._resolveFields(options); const parts: string[] = [`# Context for: "${task}"\n`]; this._appendFormatterResults(results, parts, options, resolvedFields); await this._appendSearchableResults(task, src, minScore, parts); // ── Log ── const prunedResults = pruner ? beforePrune.filter(r => !results.includes(r)) : []; logQuery({ source: options.source ?? 'api', method: 'getContext', query: task, embedding: this._embedding ? providerKey(this._embedding) : 'unknown', pruner: pruner ? _prunerName(pruner) : null, expander: null, options: { sources: src, pathPrefix: options.pathPrefix, ignorePaths: options.ignorePaths, minScore, affectedFiles: options.affectedFiles, }, results: results.map(_toLogResult), pruned: prunedResults.length > 0 ? prunedResults.map(_toLogResult) : undefined, durationMs: Date.now() - t0, }); return parts.join('\n'); } /** Invoke ContextFormatterPlugins. */ private _appendFormatterResults( results: SearchResult[], parts: string[], options: ContextOptions, resolvedFields?: Record, ): void { const fields = resolvedFields ?? this._resolveFields(options); const seenFormatters = new Set(); for (const mod of this._registry.all) { if (!isContextFormatterPlugin(mod)) continue; if (seenFormatters.has(mod.name)) continue; seenFormatters.add(mod.name); mod.formatContext(results, parts, fields); } } /** * Resolve context fields: plugin defaults ← config.json ← per-query. * Returns a flat Record with the final value for each field. */ private _resolveFields(options: ContextOptions): Record { // 1. Collect plugin defaults const defaults: Record = {}; for (const mod of this._registry.all) { if (isContextFieldPlugin(mod)) { for (const field of mod.contextFields()) { defaults[field.name] = field.default; } } } // 2. Merge: defaults ← config ← per-query return { ...defaults, ...this._configFields, ...(options.fields ?? {}) }; } /** Collect results from SearchablePlugins that don't have their own formatter. */ private async _appendSearchableResults( task: string, sources: Record, minScore: number, parts: string[], ): Promise { for (const mod of this._registry.all) { if (isContextFormatterPlugin(mod)) continue; if (!isSearchable(mod)) continue; const hits = await mod.search(task, { k: sources[mod.name] ?? 6, minScore }); if (hits.length > 0) { parts.push(`## ${mod.name}\n`); for (const r of hits) { parts.push(`- [${Math.round(r.score * 100)}%] ${r.content.slice(0, 200)}`); } parts.push(''); } } } } // ── Helpers ────────────────────────────────────────── function _toLogResult(r: SearchResult): QueryLogResult { const meta = r.metadata as Record | undefined; return { filePath: r.filePath ?? 'unknown', score: r.score, type: r.type, name: (meta?.name as string | undefined) ?? undefined, }; } function _prunerName(pruner: Pruner): string { return pruner.constructor?.name ?? 'custom'; }