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.
Files changed (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +155 -0
  3. package/assets/architecture.png +0 -0
  4. package/bin/brainbank +18 -0
  5. package/bin/brainbank-mcp +19 -0
  6. package/dist/chunk-3YBCD6DI.js +117 -0
  7. package/dist/chunk-3YBCD6DI.js.map +1 -0
  8. package/dist/chunk-63GBCDS5.js +3249 -0
  9. package/dist/chunk-63GBCDS5.js.map +1 -0
  10. package/dist/chunk-DMFMTOHF.js +123 -0
  11. package/dist/chunk-DMFMTOHF.js.map +1 -0
  12. package/dist/chunk-FQYKWB2Q.js +136 -0
  13. package/dist/chunk-FQYKWB2Q.js.map +1 -0
  14. package/dist/chunk-IMJJ2VEM.js +74 -0
  15. package/dist/chunk-IMJJ2VEM.js.map +1 -0
  16. package/dist/chunk-M744PCJQ.js +43 -0
  17. package/dist/chunk-M744PCJQ.js.map +1 -0
  18. package/dist/chunk-O3J6ZIXK.js +82 -0
  19. package/dist/chunk-O3J6ZIXK.js.map +1 -0
  20. package/dist/chunk-OPH7GZ7U.js +124 -0
  21. package/dist/chunk-OPH7GZ7U.js.map +1 -0
  22. package/dist/chunk-PXEWQMN7.js +89 -0
  23. package/dist/chunk-PXEWQMN7.js.map +1 -0
  24. package/dist/chunk-RDQYDLYZ.js +69 -0
  25. package/dist/chunk-RDQYDLYZ.js.map +1 -0
  26. package/dist/chunk-VIIHPCC4.js +254 -0
  27. package/dist/chunk-VIIHPCC4.js.map +1 -0
  28. package/dist/chunk-WCQVDF3K.js +14 -0
  29. package/dist/chunk-WCQVDF3K.js.map +1 -0
  30. package/dist/cli.d.ts +1 -0
  31. package/dist/cli.js +3076 -0
  32. package/dist/cli.js.map +1 -0
  33. package/dist/haiku-expander-YRSIPGKP.js +8 -0
  34. package/dist/haiku-expander-YRSIPGKP.js.map +1 -0
  35. package/dist/haiku-pruner-SHAXUPY6.js +8 -0
  36. package/dist/haiku-pruner-SHAXUPY6.js.map +1 -0
  37. package/dist/http-server-QUXHLWUM.js +9 -0
  38. package/dist/http-server-QUXHLWUM.js.map +1 -0
  39. package/dist/index.d.ts +2161 -0
  40. package/dist/index.js +357 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/local-embedding-NZQTILGV.js +8 -0
  43. package/dist/local-embedding-NZQTILGV.js.map +1 -0
  44. package/dist/mcp.d.ts +2 -0
  45. package/dist/mcp.js +334 -0
  46. package/dist/mcp.js.map +1 -0
  47. package/dist/openai-embedding-ZP5TSUJG.js +8 -0
  48. package/dist/openai-embedding-ZP5TSUJG.js.map +1 -0
  49. package/dist/perplexity-context-embedding-GI5PHE6X.js +9 -0
  50. package/dist/perplexity-context-embedding-GI5PHE6X.js.map +1 -0
  51. package/dist/perplexity-embedding-KZRYGJRC.js +10 -0
  52. package/dist/perplexity-embedding-KZRYGJRC.js.map +1 -0
  53. package/dist/plugin-IKQ6IRSJ.js +32 -0
  54. package/dist/plugin-IKQ6IRSJ.js.map +1 -0
  55. package/dist/resolve-ASGLBNUC.js +10 -0
  56. package/dist/resolve-ASGLBNUC.js.map +1 -0
  57. package/dist/stats-tui-ZY2NQSEA.js +1904 -0
  58. package/dist/stats-tui-ZY2NQSEA.js.map +1 -0
  59. package/package.json +96 -0
  60. package/src/brainbank.ts +617 -0
  61. package/src/cli/commands/collection.ts +77 -0
  62. package/src/cli/commands/context.ts +179 -0
  63. package/src/cli/commands/daemon.ts +100 -0
  64. package/src/cli/commands/docs.ts +71 -0
  65. package/src/cli/commands/files.ts +69 -0
  66. package/src/cli/commands/help.ts +77 -0
  67. package/src/cli/commands/index.ts +482 -0
  68. package/src/cli/commands/kv.ts +140 -0
  69. package/src/cli/commands/mcp-export.ts +273 -0
  70. package/src/cli/commands/mcp.ts +6 -0
  71. package/src/cli/commands/reembed.ts +30 -0
  72. package/src/cli/commands/scan.ts +336 -0
  73. package/src/cli/commands/search.ts +203 -0
  74. package/src/cli/commands/stats.ts +68 -0
  75. package/src/cli/commands/status.ts +47 -0
  76. package/src/cli/commands/watch.ts +47 -0
  77. package/src/cli/factory/brain-context.ts +43 -0
  78. package/src/cli/factory/builtin-registration.ts +87 -0
  79. package/src/cli/factory/config-loader.ts +77 -0
  80. package/src/cli/factory/index.ts +69 -0
  81. package/src/cli/factory/plugin-loader.ts +325 -0
  82. package/src/cli/index.ts +71 -0
  83. package/src/cli/server-client.ts +178 -0
  84. package/src/cli/tui/index-tui.tsx +667 -0
  85. package/src/cli/tui/stats-data.ts +523 -0
  86. package/src/cli/tui/stats-search.ts +262 -0
  87. package/src/cli/tui/stats-tui.tsx +1465 -0
  88. package/src/cli/tui/tree-scanner.ts +650 -0
  89. package/src/cli/utils.ts +137 -0
  90. package/src/config.ts +49 -0
  91. package/src/constants.ts +21 -0
  92. package/src/db/adapter.ts +112 -0
  93. package/src/db/metadata.ts +130 -0
  94. package/src/db/migrations.ts +66 -0
  95. package/src/db/sqlite-adapter.ts +218 -0
  96. package/src/db/tracker.ts +91 -0
  97. package/src/engine/index-api.ts +81 -0
  98. package/src/engine/reembed.ts +206 -0
  99. package/src/engine/search-api.ts +218 -0
  100. package/src/index.ts +154 -0
  101. package/src/lib/fts.ts +57 -0
  102. package/src/lib/languages.ts +180 -0
  103. package/src/lib/logger.ts +126 -0
  104. package/src/lib/math.ts +87 -0
  105. package/src/lib/provider-key.ts +20 -0
  106. package/src/lib/prune.ts +71 -0
  107. package/src/lib/rrf.ts +133 -0
  108. package/src/lib/write-lock.ts +108 -0
  109. package/src/mcp/mcp-server.ts +195 -0
  110. package/src/mcp/workspace-factory.ts +68 -0
  111. package/src/mcp/workspace-pool.ts +224 -0
  112. package/src/plugin.ts +381 -0
  113. package/src/providers/embeddings/embedding-worker-thread.ts +95 -0
  114. package/src/providers/embeddings/embedding-worker.ts +141 -0
  115. package/src/providers/embeddings/local-embedding.ts +115 -0
  116. package/src/providers/embeddings/openai-embedding.ts +167 -0
  117. package/src/providers/embeddings/perplexity-context-embedding.ts +195 -0
  118. package/src/providers/embeddings/perplexity-embedding.ts +165 -0
  119. package/src/providers/embeddings/resolve.ts +34 -0
  120. package/src/providers/pruners/haiku-expander.ts +166 -0
  121. package/src/providers/pruners/haiku-pruner.ts +112 -0
  122. package/src/providers/vector/hnsw-index.ts +174 -0
  123. package/src/providers/vector/hnsw-loader.ts +129 -0
  124. package/src/search/bm25-boost.ts +69 -0
  125. package/src/search/context-builder.ts +251 -0
  126. package/src/search/keyword/composite-bm25-search.ts +47 -0
  127. package/src/search/types.ts +37 -0
  128. package/src/search/vector/composite-vector-search.ts +61 -0
  129. package/src/search/vector/mmr.ts +64 -0
  130. package/src/services/collection.ts +384 -0
  131. package/src/services/daemon.ts +87 -0
  132. package/src/services/http-server.ts +336 -0
  133. package/src/services/kv-service.ts +64 -0
  134. package/src/services/plugin-registry.ts +77 -0
  135. package/src/services/watch.ts +340 -0
  136. package/src/services/webhook-server.ts +100 -0
  137. package/src/types.ts +493 -0
@@ -0,0 +1,482 @@
1
+ /**
2
+ * brainbank index [path] — Interactive scan → select → index
3
+ *
4
+ * Scans the repo first, shows an interactive TUI with directory tree
5
+ * for folder selection, then indexes. Use --yes to skip the TUI.
6
+ */
7
+
8
+ import type { ScanResult, ScanModule } from './scan.ts';
9
+ import type { PreviewLine } from '@/cli/tui/tree-scanner.ts';
10
+
11
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+ import { c, args, getFlag, hasFlag, stripFlags } from '@/cli/utils.ts';
14
+ import { createBrain, getConfig, registerConfigCollections, contextFromCLI } from '@/cli/factory/index.ts';
15
+ import { discoverExternalPlugins } from '@/cli/factory/plugin-loader.ts';
16
+ import { findDocsPlugin } from '@/cli/utils.ts';
17
+ import { autoExportMcp } from './mcp-export.ts';
18
+ import { scanRepo } from './scan.ts';
19
+ import { runIndexTui } from '@/cli/tui/index-tui.tsx';
20
+
21
+ export async function cmdIndex(): Promise<void> {
22
+ const positional = stripFlags(args);
23
+ const repoPath = positional[1] || '.';
24
+ const force = hasFlag('force');
25
+ const depth = parseInt(getFlag('depth') || '500', 10);
26
+ const onlyRaw = getFlag('only');
27
+ const docsPath = getFlag('docs');
28
+ const skipPrompt = hasFlag('yes') || hasFlag('y');
29
+ const forceSetup = hasFlag('setup');
30
+
31
+
32
+ const scan = scanRepo(repoPath);
33
+
34
+ // Discover external (non-built-in) plugins from config and .brainbank/plugins/
35
+ const configPlugins = scan.config.plugins ?? [];
36
+ const externalDiscovery = await discoverExternalPlugins(repoPath, configPlugins);
37
+ let externalPreviews: Map<string, PreviewLine[]> = externalDiscovery.previews;
38
+
39
+ // Merge external modules into scan result
40
+ if (externalDiscovery.modules.length > 0) {
41
+ scan.modules = [...scan.modules, ...externalDiscovery.modules];
42
+ }
43
+
44
+ let modules: string[];
45
+ let tuiInclude: string[] = [];
46
+ let tuiIgnore: string[] = [];
47
+ let tuiConfig: { embedding: string; pruner: string; expander: string } | undefined;
48
+
49
+ if (onlyRaw) {
50
+ // --only flag: explicit module selection
51
+ printIndexHeader(scan, depth);
52
+ modules = onlyRaw.split(',').map(s => s.trim());
53
+ } else if (scan.config.plugins && scan.config.plugins.length > 0 && !forceSetup) {
54
+ // Config exists with plugins field — skip TUI, index directly
55
+ printIndexHeader(scan, depth);
56
+ modules = scan.config.plugins;
57
+ } else if (skipPrompt) {
58
+ printIndexHeader(scan, depth);
59
+ modules = buildDefaultModules(scan);
60
+ } else {
61
+ // ── Interactive TUI ──
62
+ const selection = await runIndexTui(scan, externalPreviews);
63
+ if (!selection) {
64
+ console.log(c.dim('\n Cancelled. Exiting.\n'));
65
+ return;
66
+ }
67
+ modules = selection.modules;
68
+ tuiInclude = selection.include;
69
+ tuiIgnore = selection.ignore;
70
+ tuiConfig = selection.config;
71
+
72
+ if (modules.length === 0) {
73
+ console.log(c.dim('\n Nothing selected. Exiting.\n'));
74
+ return;
75
+ }
76
+
77
+ // ── Deindex removed modules ──
78
+ const oldPlugins = scan.config.plugins ?? [];
79
+ const removed = oldPlugins.filter(p => !modules.includes(p));
80
+ if (removed.length > 0) {
81
+ console.log(c.bold('\n━━━ Deindexing ━━━\n'));
82
+ for (const mod of removed) {
83
+ console.log(` ${c.yellow('✗')} Removing ${mod} data...`);
84
+ deindexModule(scan.repoPath, mod);
85
+ console.log(` ${c.green('✓')} ${mod} data cleared`);
86
+ }
87
+ }
88
+
89
+ // Show selection summary
90
+ console.log(c.bold('\n━━━ BrainBank ━━━\n'));
91
+ console.log(' Selected modules:');
92
+ for (const m of modules) {
93
+ console.log(` ${c.green('✓')} ${m}`);
94
+ }
95
+ if (tuiInclude.length > 0) {
96
+ console.log(` Include: ${c.cyan(tuiInclude.join(', '))}`);
97
+ }
98
+ if (tuiIgnore.length > 0) {
99
+ console.log(` Ignore: ${c.yellow(tuiIgnore.join(', '))}`);
100
+ }
101
+ console.log('');
102
+ }
103
+
104
+ // If --docs is passed, auto-include 'docs' in modules
105
+ if (docsPath && !modules.includes('docs')) {
106
+ modules.push('docs');
107
+ }
108
+
109
+ // Save config from TUI selection — only when TUI actually ran
110
+ // NEVER rewrite config on headless runs (existing config + no --setup)
111
+ if (tuiConfig) {
112
+ // New config (first run) — save everything
113
+ saveConfigFromTui(scan.repoPath, modules, tuiConfig.embedding, tuiConfig.pruner, tuiConfig.expander, tuiInclude, tuiIgnore);
114
+ } else if (tuiInclude.length > 0 || tuiIgnore.length > 0) {
115
+ // TUI ran with existing config and user changed selections — update patterns only
116
+ updateConfigPlugins(scan.repoPath, modules, tuiInclude, tuiIgnore);
117
+ }
118
+ // If neither condition is true, config already exists and TUI didn't run — don't touch it
119
+
120
+
121
+ console.log(c.bold(`\n━━━ Indexing: ${modules.join(', ')} ━━━`));
122
+
123
+ // Build brain context, injecting TUI-selected include/ignore patterns
124
+ const ctx = contextFromCLI(repoPath);
125
+ if (tuiInclude.length > 0 && !ctx.flags?.include) {
126
+ ctx.flags = { ...ctx.flags, include: tuiInclude.join(',') };
127
+ }
128
+ if (tuiIgnore.length > 0 && !ctx.flags?.ignore) {
129
+ ctx.flags = { ...ctx.flags, ignore: tuiIgnore.join(',') };
130
+ }
131
+ const brain = await createBrain(ctx);
132
+ await brain.initialize();
133
+
134
+ const config = await getConfig(repoPath);
135
+ await registerConfigCollections(brain, repoPath, config);
136
+
137
+ if (docsPath) {
138
+ const absDocsPath = path.resolve(docsPath);
139
+ const collName = path.basename(absDocsPath);
140
+ try {
141
+ const docsPlugin = findDocsPlugin(brain);
142
+ await docsPlugin?.addCollection({
143
+ name: collName,
144
+ path: absDocsPath,
145
+ pattern: '**/*.md',
146
+ ignore: ['deprecated/**', 'node_modules/**'],
147
+ });
148
+ console.log(c.dim(` Registered docs collection: ${collName}`));
149
+ } catch {
150
+ console.log(c.yellow(` Warning: docs module not loaded, skipping --docs`));
151
+ }
152
+ }
153
+
154
+ const result = await brain.index({
155
+ modules,
156
+ forceReindex: force,
157
+ pluginOptions: { depth },
158
+ onProgress: (stage, msg) => {
159
+ process.stdout.write(`\r ${c.cyan(stage.toUpperCase())} ${msg} `);
160
+ },
161
+ });
162
+
163
+ console.log('\n');
164
+
165
+ // ── Changes summary ──
166
+ console.log(c.bold('\n━━━ Changes ━━━\n'));
167
+ let hasChanges = false;
168
+ for (const [name, value] of Object.entries(result)) {
169
+ if (!value) continue;
170
+ const v = value as Record<string, unknown>;
171
+ if (typeof v.indexed !== 'number') { console.log(` ${c.green('✓')} ${name}: done`); continue; }
172
+
173
+ const indexed = v.indexed as number;
174
+ const skipped = (v.skipped ?? 0) as number;
175
+ const removed = (v.removed ?? 0) as number;
176
+ const chunks = (v.chunks ?? 0) as number;
177
+
178
+ if (indexed > 0 || removed > 0) hasChanges = true;
179
+
180
+ const parts: string[] = [];
181
+ if (indexed > 0) parts.push(c.green(`+${indexed} files (${chunks} chunks)`));
182
+ if (removed > 0) parts.push(c.red(`−${removed} files`));
183
+ if (skipped > 0) parts.push(c.dim(`${skipped} unchanged`));
184
+
185
+ console.log(` ${c.bold(name)}: ${parts.join(' ')}`);
186
+ }
187
+ if (!hasChanges) {
188
+ console.log(c.dim(' No changes — everything up to date'));
189
+ }
190
+
191
+ const stats = brain.stats();
192
+ console.log(`\n ${c.bold('Totals')}:`);
193
+ for (const [name, s] of Object.entries(stats)) {
194
+ if (!s || typeof s !== 'object') continue;
195
+ const entries = Object.entries(s as Record<string, unknown>)
196
+ .map(([k, v]) => `${k}: ${v}`)
197
+ .join(', ');
198
+ console.log(` ${name}: ${entries}`);
199
+ }
200
+
201
+ brain.close();
202
+
203
+ // Auto-export MCP config to Antigravity if detected and not already configured
204
+ await autoExportMcp(repoPath);
205
+ }
206
+
207
+
208
+ /** Compact header for headless (non-TUI) index runs. Validates include/ignore paths. */
209
+ function printIndexHeader(scan: ScanResult, _depth: number): void {
210
+ console.log(c.bold('\n━━━ BrainBank ━━━'));
211
+ console.log(c.dim(` ${scan.repoPath}\n`));
212
+
213
+ // Show plugins
214
+ const plugins = scan.config.plugins ?? [];
215
+ console.log(` Plugins: ${c.cyan(plugins.join(', '))}`);
216
+
217
+ // Validate and show include patterns
218
+ if (scan.config.include?.length) {
219
+ console.log('');
220
+ for (const pattern of scan.config.include) {
221
+ const exists = validatePattern(scan.repoPath, pattern);
222
+ const icon = exists ? c.green('✓') : c.red('✗');
223
+ const label = exists ? c.dim(pattern) : c.red(pattern);
224
+ console.log(` ${icon} ${label}`);
225
+ }
226
+ }
227
+
228
+ // Validate and show ignore patterns
229
+ if (scan.config.ignore?.length) {
230
+ console.log('');
231
+ console.log(c.dim(' Ignore:'));
232
+ for (const pattern of scan.config.ignore) {
233
+ console.log(` ${c.yellow('─')} ${c.dim(pattern)}`);
234
+ }
235
+ }
236
+
237
+ // DB info
238
+ if (scan.db?.exists) {
239
+ const ago = scan.db.lastModified ? timeSince(scan.db.lastModified) : '';
240
+ console.log(c.dim(`\n DB: ${scan.db.sizeMB} MB${ago ? `, last indexed ${ago}` : ''}`));
241
+ }
242
+ console.log('');
243
+ }
244
+
245
+ /** Check if a glob pattern's base directory exists on disk. */
246
+ function validatePattern(repoPath: string, pattern: string): boolean {
247
+ // Strip trailing /** or /* or glob chars
248
+ const base = pattern.replace(/\/\*\*$/, '').replace(/\/\*$/, '');
249
+ const absPath = path.join(repoPath, base);
250
+ try {
251
+ fs.statSync(absPath);
252
+ return true;
253
+ } catch {
254
+ return false;
255
+ }
256
+ }
257
+
258
+
259
+ /** Build the default list of available modules based on scan. */
260
+ function buildDefaultModules(scan: ScanResult): string[] {
261
+ return scan.modules.filter(m => m.available && m.checked).map(m => m.name);
262
+ }
263
+
264
+
265
+ function timeSince(date: Date): string {
266
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
267
+ if (seconds < 60) return 'just now';
268
+ const minutes = Math.floor(seconds / 60);
269
+ if (minutes < 60) return `${minutes}m ago`;
270
+ const hours = Math.floor(minutes / 60);
271
+ if (hours < 24) return `${hours}h ago`;
272
+ const days = Math.floor(hours / 24);
273
+ return `${days}d ago`;
274
+ }
275
+
276
+ /** Capitalize first letter. */
277
+ function capitalizeFirst(s: string): string {
278
+ return s.charAt(0).toUpperCase() + s.slice(1);
279
+ }
280
+
281
+ /** Common doc folder names to auto-detect for docs plugin. */
282
+ const DOC_FOLDERS = ['docs', 'doc', 'wiki', 'documentation', 'guides', 'notes'];
283
+
284
+ /** Auto-detect document collections in a repo. Scans for common doc folders + README. */
285
+ function autoDetectDocCollections(repoPath: string): { name: string; path: string; pattern: string; context?: string }[] {
286
+ const resolved = path.resolve(repoPath);
287
+ const collections: { name: string; path: string; pattern: string; context?: string }[] = [];
288
+
289
+ for (const folder of DOC_FOLDERS) {
290
+ const absPath = path.join(resolved, folder);
291
+ try {
292
+ const stat = fs.statSync(absPath);
293
+ if (stat.isDirectory()) {
294
+ // Check it actually contains markdown files
295
+ const entries = fs.readdirSync(absPath, { recursive: true }) as string[];
296
+ const hasMd = entries.some(e => typeof e === 'string' && /\.md$/i.test(e));
297
+ if (hasMd) {
298
+ collections.push({
299
+ name: folder,
300
+ path: folder,
301
+ pattern: '**/*.md',
302
+ context: `${folder} directory`,
303
+ });
304
+ }
305
+ }
306
+ } catch {
307
+ // Folder doesn't exist — skip
308
+ }
309
+ }
310
+
311
+ return collections;
312
+ }
313
+
314
+ /** Save config.json from TUI selections (no interactive prompts). */
315
+ function saveConfigFromTui(
316
+ repoPath: string, modules: string[], embedding: string, pruner: string, expander: string,
317
+ include: string[], ignore: string[],
318
+ ): void {
319
+ const configDir = path.join(repoPath, '.brainbank');
320
+ const configPath = path.join(configDir, 'config.json');
321
+
322
+ const config: Record<string, unknown> = {
323
+ plugins: modules,
324
+ embedding,
325
+ };
326
+
327
+ if (pruner !== 'none') {
328
+ config.pruner = pruner;
329
+ }
330
+
331
+ if (expander !== 'none') {
332
+ config.expander = expander;
333
+ }
334
+
335
+ // Save include/ignore from tree selection
336
+ if (include.length > 0) {
337
+ config.include = include;
338
+ }
339
+ if (ignore.length > 0) {
340
+ config.ignore = ignore;
341
+ }
342
+
343
+ // Auto-detect doc collections when docs plugin is selected
344
+ if (modules.includes('docs')) {
345
+ const collections = autoDetectDocCollections(repoPath);
346
+ if (collections.length > 0) {
347
+ config.docs = { collections };
348
+ console.log(c.dim(` Auto-detected docs: ${collections.map(dc => dc.name).join(', ')}`));
349
+ }
350
+ }
351
+
352
+ // Auto-detect API keys from environment
353
+ const detectedKeys: Record<string, string> = {};
354
+ const needsPerplexity = embedding.startsWith('perplexity');
355
+ const needsAnthropic = pruner === 'haiku' || expander === 'haiku';
356
+ const needsOpenai = embedding === 'openai';
357
+
358
+ if (needsPerplexity && process.env.PERPLEXITY_API_KEY) {
359
+ detectedKeys.perplexity = process.env.PERPLEXITY_API_KEY;
360
+ }
361
+ if (needsAnthropic && process.env.ANTHROPIC_API_KEY) {
362
+ detectedKeys.anthropic = process.env.ANTHROPIC_API_KEY;
363
+ }
364
+ if (needsOpenai && process.env.OPENAI_API_KEY) {
365
+ detectedKeys.openai = process.env.OPENAI_API_KEY;
366
+ }
367
+
368
+ if (Object.keys(detectedKeys).length > 0) {
369
+ config.keys = detectedKeys;
370
+ }
371
+
372
+ fs.mkdirSync(configDir, { recursive: true });
373
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
374
+ console.log(c.green(` ✓ Saved ${path.relative(process.cwd(), configPath)}`));
375
+ }
376
+
377
+
378
+ /** Update plugins, include, and ignore in an existing config.json. */
379
+ function updateConfigPlugins(repoPath: string, modules: string[], include: string[], ignore: string[]): void {
380
+ const configPath = path.join(repoPath, '.brainbank', 'config.json');
381
+ try {
382
+ const raw = fs.readFileSync(configPath, 'utf-8');
383
+ const config = JSON.parse(raw) as Record<string, unknown>;
384
+ config.plugins = modules;
385
+
386
+ // Update include/ignore — set if present, remove if empty
387
+ if (include.length > 0) {
388
+ config.include = include;
389
+ } else {
390
+ delete config.include;
391
+ }
392
+ if (ignore.length > 0) {
393
+ config.ignore = ignore;
394
+ } else {
395
+ delete config.ignore;
396
+ }
397
+
398
+ // Auto-detect doc collections when docs is newly added and none exist
399
+ if (modules.includes('docs')) {
400
+ const existing = config.docs as Record<string, unknown> | undefined;
401
+ const hasCollections = existing && Array.isArray(existing.collections) && existing.collections.length > 0;
402
+ if (!hasCollections) {
403
+ const collections = autoDetectDocCollections(repoPath);
404
+ if (collections.length > 0) {
405
+ config.docs = { ...(existing ?? {}), collections };
406
+ console.log(c.dim(` Auto-detected docs: ${collections.map(dc => dc.name).join(', ')}`));
407
+ }
408
+ }
409
+ }
410
+
411
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
412
+ console.log(c.green(` ✓ Updated config.json`));
413
+ } catch {
414
+ // Config doesn't exist or is corrupt — skip
415
+ }
416
+ }
417
+
418
+
419
+ /**
420
+ * Clear all indexed data for a specific module from the DB.
421
+ * Opens the SQLite database directly and drops module-specific rows.
422
+ */
423
+ function deindexModule(repoPath: string, moduleName: string): void {
424
+ const dbPath = path.join(repoPath, '.brainbank', 'data', 'brainbank.db');
425
+ if (!fs.existsSync(dbPath)) return;
426
+
427
+ // Simple interface — we only need exec() and close()
428
+ interface SimpleDB { exec(sql: string): void; close(): void }
429
+
430
+ let db: SimpleDB | undefined;
431
+ try {
432
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
433
+ const sqlite = require('node:sqlite') as { DatabaseSync: new (path: string) => SimpleDB };
434
+ db = new sqlite.DatabaseSync(dbPath);
435
+ } catch {
436
+ console.log(c.yellow(` Could not open DB — skip deindex for ${moduleName}`));
437
+ return;
438
+ }
439
+
440
+ if (!db) return;
441
+
442
+ const tables: Record<string, string[]> = {
443
+ code: [
444
+ 'DELETE FROM code_call_edges',
445
+ 'DELETE FROM code_refs',
446
+ 'DELETE FROM code_symbols',
447
+ 'DELETE FROM code_imports',
448
+ 'DELETE FROM code_vectors',
449
+ 'DELETE FROM code_chunks',
450
+ 'DELETE FROM indexed_files',
451
+ "DELETE FROM plugin_tracking WHERE plugin = 'code'",
452
+ ],
453
+ docs: [
454
+ 'DELETE FROM doc_vectors',
455
+ 'DELETE FROM doc_chunks',
456
+ 'DELETE FROM path_contexts',
457
+ 'DELETE FROM collections',
458
+ "DELETE FROM plugin_tracking WHERE plugin = 'docs'",
459
+ ],
460
+ git: [
461
+ 'DELETE FROM git_vectors',
462
+ 'DELETE FROM git_commits',
463
+ "DELETE FROM plugin_tracking WHERE plugin = 'git'",
464
+ ],
465
+ };
466
+
467
+ const statements = tables[moduleName];
468
+ if (!statements) {
469
+ console.log(c.dim(` No known tables for ${moduleName}`));
470
+ try { db.close(); } catch { /* ignore */ }
471
+ return;
472
+ }
473
+
474
+ for (const sql of statements) {
475
+ try { db.exec(sql); }
476
+ catch { /* Table might not exist — that's fine */ }
477
+ }
478
+
479
+ try { db.close(); } catch { /* ignore */ }
480
+ }
481
+
482
+
@@ -0,0 +1,140 @@
1
+ /**
2
+ * brainbank kv add|search|list|trim|clear — Dynamic KV collection management
3
+ */
4
+
5
+ import { c, args, getFlag, stripFlags } from '@/cli/utils.ts';
6
+ import { createBrain } from '@/cli/factory/index.ts';
7
+
8
+ export async function cmdKv(): Promise<void> {
9
+ const pos = stripFlags(args);
10
+ const sub = pos[1];
11
+
12
+ if (sub === 'add') {
13
+ const collName = pos[2];
14
+ const content = pos.slice(3).join(' ');
15
+ const metaRaw = getFlag('meta');
16
+
17
+ if (!collName || !content) {
18
+ console.log(c.red("Usage: brainbank kv add <collection> <content> [--meta '{\"key\":\"val\"}']"));
19
+ process.exit(1);
20
+ }
21
+
22
+ const brain = await createBrain();
23
+ await brain.initialize();
24
+ const coll = brain.collection(collName);
25
+ const meta = metaRaw ? JSON.parse(metaRaw) : {};
26
+ const id = await coll.add(content, meta);
27
+ console.log(c.green(`✓ Added item #${id} to '${collName}'`));
28
+ brain.close();
29
+ return;
30
+ }
31
+
32
+ if (sub === 'search') {
33
+ const collName = pos[2];
34
+ const query = pos.slice(3).join(' ');
35
+ const k = parseInt(getFlag('k') || '5', 10);
36
+ const mode = (getFlag('mode') || 'hybrid') as 'hybrid' | 'vector' | 'keyword';
37
+
38
+ if (!collName || !query) {
39
+ console.log(c.red('Usage: brainbank kv search <collection> <query> [--k 5] [--mode hybrid|keyword|vector]'));
40
+ process.exit(1);
41
+ }
42
+
43
+ const brain = await createBrain();
44
+ await brain.initialize();
45
+ const coll = brain.collection(collName);
46
+ const results = await coll.search(query, { k, mode });
47
+
48
+ if (results.length === 0) {
49
+ console.log(c.yellow(' No results found.'));
50
+ } else {
51
+ console.log(c.bold(`\n━━━ ${collName}: "${query}" ━━━\n`));
52
+ for (const r of results) {
53
+ const score = Math.round((r.score ?? 0) * 100);
54
+ console.log(` ${c.cyan(`[${score}%]`)} ${r.content}`);
55
+ if (Object.keys(r.metadata).length > 0) {
56
+ console.log(` ${c.dim(JSON.stringify(r.metadata))}`);
57
+ }
58
+ }
59
+ }
60
+ brain.close();
61
+ return;
62
+ }
63
+
64
+ if (sub === 'list') {
65
+ const collName = pos[2];
66
+ const limit = parseInt(getFlag('limit') || '20', 10);
67
+
68
+ if (!collName) {
69
+ const brain = await createBrain();
70
+ await brain.initialize();
71
+ const names = brain.listCollectionNames();
72
+ if (names.length === 0) {
73
+ console.log(c.yellow(' No KV collections found.'));
74
+ } else {
75
+ console.log(c.bold('\n━━━ KV Collections ━━━\n'));
76
+ for (const n of names) {
77
+ const coll = brain.collection(n);
78
+ console.log(` ${c.cyan(n)} — ${coll.count()} items`);
79
+ }
80
+ }
81
+ brain.close();
82
+ return;
83
+ }
84
+
85
+ const brain = await createBrain();
86
+ await brain.initialize();
87
+ const coll = brain.collection(collName);
88
+ const items = coll.list({ limit });
89
+ if (items.length === 0) {
90
+ console.log(c.yellow(` Collection '${collName}' is empty.`));
91
+ } else {
92
+ console.log(c.bold(`\n━━━ ${collName} (${coll.count()} items) ━━━\n`));
93
+ for (const item of items) {
94
+ const age = Math.round((Date.now() / 1000 - item.createdAt) / 60);
95
+ console.log(` #${item.id} ${c.dim(`(${age}m ago)`)} ${item.content.slice(0, 80)}`);
96
+ }
97
+ }
98
+ brain.close();
99
+ return;
100
+ }
101
+
102
+ if (sub === 'trim') {
103
+ const collName = pos[2];
104
+ const keep = parseInt(getFlag('keep') || '0', 10);
105
+
106
+ if (!collName || keep <= 0) {
107
+ console.log(c.red('Usage: brainbank kv trim <collection> --keep <n>'));
108
+ process.exit(1);
109
+ }
110
+
111
+ const brain = await createBrain();
112
+ await brain.initialize();
113
+ const coll = brain.collection(collName);
114
+ const result = await coll.trim({ keep });
115
+ console.log(c.green(`✓ Trimmed ${result.removed} items from '${collName}' (kept ${keep})`));
116
+ brain.close();
117
+ return;
118
+ }
119
+
120
+ if (sub === 'clear') {
121
+ const collName = pos[2];
122
+ if (!collName) {
123
+ console.log(c.red('Usage: brainbank kv clear <collection>'));
124
+ process.exit(1);
125
+ }
126
+
127
+ const brain = await createBrain();
128
+ await brain.initialize();
129
+ const coll = brain.collection(collName);
130
+ const before = coll.count();
131
+ coll.clear();
132
+ console.log(c.green(`✓ Cleared ${before} items from '${collName}'`));
133
+ brain.close();
134
+ return;
135
+ }
136
+
137
+ console.log(c.red('Usage: brainbank kv <add|search|list|trim|clear>'));
138
+ process.exit(1);
139
+ }
140
+