brainbank 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +84 -1107
  2. package/assets/architecture.png +0 -0
  3. package/bin/brainbank +8 -1
  4. package/bin/brainbank-mcp +19 -0
  5. package/dist/chunk-3UIWA32X.js +3341 -0
  6. package/dist/chunk-3UIWA32X.js.map +1 -0
  7. package/dist/chunk-3YBCD6DI.js +117 -0
  8. package/dist/chunk-3YBCD6DI.js.map +1 -0
  9. package/dist/chunk-DAGVUEXL.js +258 -0
  10. package/dist/chunk-DAGVUEXL.js.map +1 -0
  11. package/dist/chunk-DMFMTOHF.js +123 -0
  12. package/dist/chunk-DMFMTOHF.js.map +1 -0
  13. package/dist/chunk-FQYKWB2Q.js +136 -0
  14. package/dist/chunk-FQYKWB2Q.js.map +1 -0
  15. package/dist/chunk-IMJJ2VEM.js +74 -0
  16. package/dist/chunk-IMJJ2VEM.js.map +1 -0
  17. package/dist/chunk-M744PCJQ.js +43 -0
  18. package/dist/chunk-M744PCJQ.js.map +1 -0
  19. package/dist/chunk-NNDY7P2R.js +211 -0
  20. package/dist/chunk-NNDY7P2R.js.map +1 -0
  21. package/dist/chunk-O3J6ZIXK.js +82 -0
  22. package/dist/chunk-O3J6ZIXK.js.map +1 -0
  23. package/dist/chunk-RDQYDLYZ.js +69 -0
  24. package/dist/chunk-RDQYDLYZ.js.map +1 -0
  25. package/dist/chunk-WCQVDF3K.js +14 -0
  26. package/dist/cli.js +2713 -325
  27. package/dist/cli.js.map +1 -1
  28. package/dist/haiku-pruner-5KVT5AI2.js +8 -0
  29. package/dist/http-server-2ZQ6I43B.js +9 -0
  30. package/dist/index.d.ts +1886 -626
  31. package/dist/index.js +319 -46
  32. package/dist/index.js.map +1 -1
  33. package/dist/local-embedding-NZQTILGV.js +8 -0
  34. package/dist/mcp.d.ts +2 -0
  35. package/dist/mcp.js +386 -0
  36. package/dist/mcp.js.map +1 -0
  37. package/dist/openai-embedding-ZP5TSUJG.js +8 -0
  38. package/dist/perplexity-context-embedding-GI5PHE6X.js +9 -0
  39. package/dist/perplexity-context-embedding-GI5PHE6X.js.map +1 -0
  40. package/dist/perplexity-embedding-KZRYGJRC.js +10 -0
  41. package/dist/perplexity-embedding-KZRYGJRC.js.map +1 -0
  42. package/dist/plugin-IKQ6IRSJ.js +32 -0
  43. package/dist/plugin-IKQ6IRSJ.js.map +1 -0
  44. package/dist/resolve-ASGLBNUC.js +10 -0
  45. package/dist/resolve-ASGLBNUC.js.map +1 -0
  46. package/dist/stats-tui-AD3AMYGV.js +1904 -0
  47. package/dist/stats-tui-AD3AMYGV.js.map +1 -0
  48. package/package.json +38 -53
  49. package/src/brainbank.ts +617 -0
  50. package/src/cli/commands/collection.ts +77 -0
  51. package/src/cli/commands/context.ts +59 -0
  52. package/src/cli/commands/daemon.ts +100 -0
  53. package/src/cli/commands/docs.ts +71 -0
  54. package/src/cli/commands/files.ts +69 -0
  55. package/src/cli/commands/help.ts +82 -0
  56. package/src/cli/commands/index.ts +478 -0
  57. package/src/cli/commands/kv.ts +140 -0
  58. package/src/cli/commands/mcp-export.ts +273 -0
  59. package/src/cli/commands/mcp.ts +6 -0
  60. package/src/cli/commands/query.ts +167 -0
  61. package/src/cli/commands/reembed.ts +30 -0
  62. package/src/cli/commands/reindex.ts +40 -0
  63. package/src/cli/commands/scan.ts +336 -0
  64. package/src/cli/commands/search.ts +203 -0
  65. package/src/cli/commands/stats.ts +68 -0
  66. package/src/cli/commands/status.ts +47 -0
  67. package/src/cli/commands/watch.ts +47 -0
  68. package/src/cli/factory/brain-context.ts +43 -0
  69. package/src/cli/factory/builtin-registration.ts +87 -0
  70. package/src/cli/factory/config-loader.ts +77 -0
  71. package/src/cli/factory/index.ts +69 -0
  72. package/src/cli/factory/plugin-loader.ts +324 -0
  73. package/src/cli/index.ts +76 -0
  74. package/src/cli/server-client.ts +186 -0
  75. package/src/cli/tui/index-tui.tsx +667 -0
  76. package/src/cli/tui/stats-data.ts +523 -0
  77. package/src/cli/tui/stats-search.ts +262 -0
  78. package/src/cli/tui/stats-tui.tsx +1465 -0
  79. package/src/cli/tui/tree-scanner.ts +650 -0
  80. package/src/cli/utils.ts +137 -0
  81. package/src/config.ts +48 -0
  82. package/src/constants.ts +21 -0
  83. package/src/db/adapter.ts +112 -0
  84. package/src/db/metadata.ts +130 -0
  85. package/src/db/migrations.ts +66 -0
  86. package/src/db/sqlite-adapter.ts +218 -0
  87. package/src/db/tracker.ts +91 -0
  88. package/src/engine/index-api.ts +81 -0
  89. package/src/engine/reembed.ts +206 -0
  90. package/src/engine/search-api.ts +218 -0
  91. package/src/index.ts +150 -0
  92. package/src/lib/fts.ts +57 -0
  93. package/src/lib/languages.ts +179 -0
  94. package/src/lib/logger.ts +126 -0
  95. package/src/lib/math.ts +87 -0
  96. package/src/lib/provider-key.ts +20 -0
  97. package/src/lib/prune.ts +72 -0
  98. package/src/lib/rrf.ts +133 -0
  99. package/src/lib/write-lock.ts +108 -0
  100. package/src/mcp/mcp-server.ts +268 -0
  101. package/src/mcp/workspace-factory.ts +68 -0
  102. package/src/mcp/workspace-pool.ts +224 -0
  103. package/src/plugin.ts +381 -0
  104. package/src/providers/embeddings/embedding-worker-thread.ts +95 -0
  105. package/src/providers/embeddings/embedding-worker.ts +141 -0
  106. package/src/providers/embeddings/local-embedding.ts +115 -0
  107. package/src/providers/embeddings/openai-embedding.ts +167 -0
  108. package/src/providers/embeddings/perplexity-context-embedding.ts +195 -0
  109. package/src/providers/embeddings/perplexity-embedding.ts +165 -0
  110. package/src/providers/embeddings/resolve.ts +34 -0
  111. package/src/providers/pruners/haiku-expander.ts +178 -0
  112. package/src/providers/pruners/haiku-pruner.ts +263 -0
  113. package/src/providers/vector/hnsw-index.ts +174 -0
  114. package/src/providers/vector/hnsw-loader.ts +129 -0
  115. package/src/search/bm25-boost.ts +76 -0
  116. package/src/search/context-builder.ts +209 -0
  117. package/src/search/keyword/composite-bm25-search.ts +47 -0
  118. package/src/search/query-decomposer.ts +124 -0
  119. package/src/search/types.ts +37 -0
  120. package/src/search/vector/composite-vector-search.ts +105 -0
  121. package/src/search/vector/mmr.ts +64 -0
  122. package/src/services/collection.ts +384 -0
  123. package/src/services/daemon.ts +87 -0
  124. package/src/services/http-server.ts +344 -0
  125. package/src/services/kv-service.ts +64 -0
  126. package/src/services/plugin-registry.ts +77 -0
  127. package/src/services/watch.ts +340 -0
  128. package/src/services/webhook-server.ts +100 -0
  129. package/src/types.ts +509 -0
  130. package/dist/chunk-2P3EGY6S.js +0 -37
  131. package/dist/chunk-2P3EGY6S.js.map +0 -1
  132. package/dist/chunk-3GAIDXRW.js +0 -105
  133. package/dist/chunk-3GAIDXRW.js.map +0 -1
  134. package/dist/chunk-4ZKBQ33J.js +0 -56
  135. package/dist/chunk-4ZKBQ33J.js.map +0 -1
  136. package/dist/chunk-7QVYU63E.js +0 -7
  137. package/dist/chunk-GOUBW7UA.js +0 -373
  138. package/dist/chunk-GOUBW7UA.js.map +0 -1
  139. package/dist/chunk-MJ3Y24H6.js +0 -185
  140. package/dist/chunk-MJ3Y24H6.js.map +0 -1
  141. package/dist/chunk-N6ZMBFDE.js +0 -224
  142. package/dist/chunk-N6ZMBFDE.js.map +0 -1
  143. package/dist/chunk-RAEBYV75.js +0 -709
  144. package/dist/chunk-RAEBYV75.js.map +0 -1
  145. package/dist/chunk-TW5NTYYZ.js +0 -2066
  146. package/dist/chunk-TW5NTYYZ.js.map +0 -1
  147. package/dist/chunk-Z5SU54HP.js +0 -171
  148. package/dist/chunk-Z5SU54HP.js.map +0 -1
  149. package/dist/code.d.ts +0 -31
  150. package/dist/code.js +0 -8
  151. package/dist/docs.d.ts +0 -19
  152. package/dist/docs.js +0 -8
  153. package/dist/git.d.ts +0 -31
  154. package/dist/git.js +0 -8
  155. package/dist/memory.d.ts +0 -19
  156. package/dist/memory.js +0 -146
  157. package/dist/memory.js.map +0 -1
  158. package/dist/notes.d.ts +0 -19
  159. package/dist/notes.js +0 -57
  160. package/dist/notes.js.map +0 -1
  161. package/dist/openai-PCTYLOWI.js +0 -8
  162. package/dist/types-Da_zLLOl.d.ts +0 -474
  163. /package/dist/{chunk-7QVYU63E.js.map → chunk-WCQVDF3K.js.map} +0 -0
  164. /package/dist/{code.js.map → haiku-pruner-5KVT5AI2.js.map} +0 -0
  165. /package/dist/{docs.js.map → http-server-2ZQ6I43B.js.map} +0 -0
  166. /package/dist/{git.js.map → local-embedding-NZQTILGV.js.map} +0 -0
  167. /package/dist/{openai-PCTYLOWI.js.map → openai-embedding-ZP5TSUJG.js.map} +0 -0
@@ -0,0 +1,336 @@
1
+ /**
2
+ * brainbank scan — Lightweight repo scanner for the interactive index flow.
3
+ *
4
+ * Scans the filesystem WITHOUT initializing BrainBank. Returns a ScanResult
5
+ * describing what's available to index via dynamic ScanModule descriptors.
6
+ */
7
+
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import { execSync } from 'node:child_process';
11
+ import picomatch from 'picomatch';
12
+ import { SUPPORTED_EXTENSIONS, isIgnoredDir, isIgnoredFile } from '@/lib/languages.ts';
13
+
14
+
15
+ /** A single scannable module (plugin). */
16
+ export interface ScanModule {
17
+ /** Plugin name (e.g. 'code', 'git', 'docs'). */
18
+ name: string;
19
+ /** Whether there's content available to index. */
20
+ available: boolean;
21
+ /** Human-readable summary (e.g. '1243 files (5 languages)'). */
22
+ summary: string;
23
+ /** Emoji icon for display. */
24
+ icon: string;
25
+ /** Whether checked by default in the prompt. */
26
+ checked: boolean;
27
+ /** Reason this module is disabled (shown in prompt). */
28
+ disabled?: string;
29
+ /** Detail lines for the scan tree (e.g. per-language breakdown). */
30
+ details?: string[];
31
+ }
32
+
33
+ export interface ScanResult {
34
+ repoPath: string;
35
+ modules: ScanModule[];
36
+ config: { exists: boolean; ignore?: string[]; include?: string[]; plugins?: string[] };
37
+ db: { exists: boolean; sizeMB: number; lastModified?: Date } | null;
38
+ }
39
+
40
+
41
+ /** Scan a repo path and return what's available to index. */
42
+ export function scanRepo(repoPath: string): ScanResult {
43
+ const resolved = path.resolve(repoPath);
44
+ const config = scanConfig(resolved);
45
+
46
+ return {
47
+ repoPath: resolved,
48
+ modules: scanModules(resolved, config),
49
+ config,
50
+ db: scanDb(resolved),
51
+ };
52
+ }
53
+
54
+ /** Produce ScanModule descriptors for known plugin types. */
55
+ function scanModules(repoPath: string, config: ScanResult['config']): ScanModule[] {
56
+ return [
57
+ scanCodeModule(repoPath, config.include, config.ignore),
58
+ scanGitModule(repoPath),
59
+ scanDocsModule(repoPath),
60
+ ];
61
+ }
62
+
63
+
64
+ /** Scan for indexable code files. Respects include/ignore from config. */
65
+ function scanCodeModule(repoPath: string, include?: string[], ignore?: string[]): ScanModule {
66
+ const byLanguage = new Map<string, number>();
67
+ let total = 0;
68
+
69
+ // Build matchers from config patterns
70
+ let isIncluded: ((p: string) => boolean) | null = null;
71
+ let isIgnoredPat: ((p: string) => boolean) | null = null;
72
+ let includeBases: string[] | null = null;
73
+ if (include?.length) {
74
+ isIncluded = picomatch(include, { dot: true });
75
+ includeBases = include.map(p => picomatch.scan(p).base).filter(b => b && b !== '.');
76
+ }
77
+ if (ignore?.length) isIgnoredPat = picomatch(ignore, { dot: true });
78
+
79
+ function walk(dir: string): void {
80
+ let entries: fs.Dirent[];
81
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
82
+ catch { return; }
83
+
84
+ for (const entry of entries) {
85
+ const fullPath = path.join(dir, entry.name);
86
+ const isDir = entry.isDirectory() || (entry.isSymbolicLink() && (() => { try { return fs.statSync(fullPath).isDirectory(); } catch { return false; } })());
87
+ if (isDir) {
88
+ if (isIgnoredDir(entry.name)) continue;
89
+ // Early prune: if include bases are set, skip dirs that can't match
90
+ if (includeBases && includeBases.length > 0) {
91
+ const relDir = path.relative(repoPath, fullPath);
92
+ const canMatch = includeBases.some(base =>
93
+ relDir.startsWith(base) || base.startsWith(relDir),
94
+ );
95
+ if (!canMatch) continue;
96
+ }
97
+ walk(fullPath);
98
+ } else if (entry.isFile()) {
99
+ if (isIgnoredFile(entry.name)) continue;
100
+ const ext = path.extname(entry.name).toLowerCase();
101
+ const lang = SUPPORTED_EXTENSIONS[ext];
102
+ if (!lang) continue;
103
+
104
+ // Apply include/ignore filters using relative path
105
+ const rel = path.relative(repoPath, fullPath);
106
+ if (isIncluded && !isIncluded(rel)) continue;
107
+ if (isIgnoredPat && isIgnoredPat(rel)) continue;
108
+
109
+ byLanguage.set(lang, (byLanguage.get(lang) ?? 0) + 1);
110
+ total++;
111
+ }
112
+ }
113
+ }
114
+
115
+ walk(repoPath);
116
+
117
+ if (total === 0) {
118
+ return { name: 'code', available: false, summary: 'no supported source files found', icon: '📁', checked: false, disabled: 'nothing to index' };
119
+ }
120
+
121
+ const langCount = byLanguage.size;
122
+ const sorted = [...byLanguage.entries()].sort((a, b) => b[1] - a[1]);
123
+ const maxShow = 7;
124
+ const shown = sorted.slice(0, maxShow);
125
+ const remaining = sorted.length - maxShow;
126
+
127
+ const details: string[] = [];
128
+ for (let i = 0; i < shown.length; i++) {
129
+ const [lang, count] = shown[i];
130
+ const isLast = i === shown.length - 1 && remaining <= 0;
131
+ const prefix = isLast ? '└──' : '├──';
132
+ details.push(`${prefix} ${lang.padEnd(14)} ${count} files`);
133
+ }
134
+ if (remaining > 0) {
135
+ details.push(`└── ...and ${remaining} more`);
136
+ }
137
+
138
+ return {
139
+ name: 'code',
140
+ available: true,
141
+ summary: `${total} files (${langCount} language${langCount > 1 ? 's' : ''})`,
142
+ icon: '📁',
143
+ checked: true,
144
+ details,
145
+ };
146
+ }
147
+
148
+ /** Scan for git history. */
149
+ function scanGitModule(repoPath: string): ScanModule {
150
+ const stats = scanGitStats(repoPath);
151
+
152
+ if (!stats) {
153
+ return { name: 'git', available: false, summary: 'no .git directory found', icon: '📜', checked: false, disabled: 'not a git repo' };
154
+ }
155
+
156
+ const details: string[] = [];
157
+ if (stats.lastMessage) {
158
+ details.push(`Last: ${stats.lastMessage} (${stats.lastDate})`);
159
+ }
160
+
161
+ return {
162
+ name: 'git',
163
+ available: true,
164
+ summary: `${stats.commitCount.toLocaleString()} commits`,
165
+ icon: '📜',
166
+ checked: true,
167
+ details,
168
+ };
169
+ }
170
+
171
+ /** Scan for document collections. */
172
+ function scanDocsModule(repoPath: string): ScanModule {
173
+ const collections = scanDocsCollections(repoPath);
174
+
175
+ if (collections.length === 0) {
176
+ return { name: 'docs', available: false, summary: 'no documents found', icon: '📄', checked: false, disabled: 'no .md/.mdx files' };
177
+ }
178
+
179
+ const totalFiles = collections.reduce((s, d) => s + d.fileCount, 0);
180
+ const details = collections.map((d, i) => {
181
+ const isLast = i === collections.length - 1;
182
+ const prefix = isLast ? '└──' : '├──';
183
+ return `${prefix} ${d.name.padEnd(10)} → ${d.path} (${d.fileCount} files)`;
184
+ });
185
+
186
+ return {
187
+ name: 'docs',
188
+ available: true,
189
+ summary: `${collections.length} collection${collections.length > 1 ? 's' : ''} (${totalFiles} files)`,
190
+ icon: '📄',
191
+ checked: true,
192
+ details,
193
+ };
194
+ }
195
+
196
+
197
+ /** Get git stats for this repo. */
198
+ function scanGitStats(repoPath: string): { commitCount: number; lastMessage: string; lastDate: string } | null {
199
+ if (!fs.existsSync(path.join(repoPath, '.git'))) return null;
200
+ return gitStats(repoPath);
201
+ }
202
+
203
+ /** Get git stats for a single directory. */
204
+ function gitStats(dir: string): { commitCount: number; lastMessage: string; lastDate: string } | null {
205
+ try {
206
+ const count = parseInt(execSync('git rev-list --count HEAD', { cwd: dir, encoding: 'utf-8' }).trim(), 10);
207
+ const log = execSync('git log -1 --format="%s|%ar"', { cwd: dir, encoding: 'utf-8' }).trim();
208
+ const [lastMessage, lastDate] = log.split('|');
209
+ return { commitCount: count, lastMessage: lastMessage ?? '', lastDate: lastDate ?? '' };
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ /** Scan for document collections (config + auto-detect). */
216
+ function scanDocsCollections(repoPath: string): { name: string; path: string; fileCount: number }[] {
217
+ const results: { name: string; path: string; fileCount: number }[] = [];
218
+ const seen = new Set<string>();
219
+
220
+ // 1. Read explicit collections from config.json
221
+ const configPath = path.join(repoPath, '.brainbank', 'config.json');
222
+ try {
223
+ if (fs.existsSync(configPath)) {
224
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
225
+ const docsCfg = config?.docs as Record<string, unknown> | undefined;
226
+ const collections = docsCfg?.collections as { name: string; path: string }[] | undefined;
227
+ if (collections) {
228
+ for (const coll of collections) {
229
+ const absPath = path.resolve(repoPath, coll.path);
230
+ results.push({ name: coll.name, path: coll.path, fileCount: countDocs(absPath) });
231
+ seen.add(absPath);
232
+ }
233
+ }
234
+ }
235
+ } catch {}
236
+
237
+ // 2. Auto-detect .md/.mdx in the repo root and top-level dirs
238
+ const rootDocs = countDocsShallow(repoPath);
239
+ if (rootDocs > 0) {
240
+ results.push({ name: '(root)', path: '.', fileCount: rootDocs });
241
+ }
242
+
243
+ try {
244
+ for (const entry of fs.readdirSync(repoPath, { withFileTypes: true })) {
245
+ if (!entry.isDirectory()) continue;
246
+ if (isIgnoredDir(entry.name)) continue;
247
+ if (entry.name.startsWith('.')) continue;
248
+
249
+ const dirPath = path.join(repoPath, entry.name);
250
+ if (seen.has(dirPath)) continue;
251
+
252
+ const count = countDocs(dirPath);
253
+ if (count > 0) {
254
+ results.push({ name: entry.name, path: `./${entry.name}`, fileCount: count });
255
+ }
256
+ }
257
+ } catch {}
258
+
259
+ return results;
260
+ }
261
+
262
+ /** Count .md/.mdx files recursively in a directory. */
263
+ function countDocs(dir: string): number {
264
+ let count = 0;
265
+ try {
266
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
267
+ const ePath = path.join(dir, e.name);
268
+ const isDir = e.isDirectory() || (e.isSymbolicLink() && (() => { try { return fs.statSync(ePath).isDirectory(); } catch { return false; } })());
269
+ if (isDir) {
270
+ if (isIgnoredDir(e.name)) continue;
271
+ count += countDocs(ePath);
272
+ } else if ((e.isFile() || e.isSymbolicLink()) && /\.mdx?$/i.test(e.name)) {
273
+ count++;
274
+ }
275
+ }
276
+ } catch {}
277
+ return count;
278
+ }
279
+
280
+ /** Count .md/.mdx files in a directory (non-recursive, root level only). */
281
+ function countDocsShallow(dir: string): number {
282
+ let count = 0;
283
+ try {
284
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
285
+ if (e.isFile() && /\.mdx?$/i.test(e.name)) count++;
286
+ }
287
+ } catch {}
288
+ return count;
289
+ }
290
+
291
+ /** Check if config.json exists and read key fields. */
292
+ function scanConfig(repoPath: string): ScanResult['config'] {
293
+ const configPath = path.join(repoPath, '.brainbank', 'config.json');
294
+ if (!fs.existsSync(configPath)) return { exists: false };
295
+
296
+ try {
297
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
298
+ const codeCfg = config?.code as Record<string, unknown> | undefined;
299
+
300
+ // Merge root-level and per-plugin include/ignore
301
+ const rootInclude = config?.include as string[] | undefined;
302
+ const rootIgnore = config?.ignore as string[] | undefined;
303
+ const pluginInclude = codeCfg?.include as string[] | undefined;
304
+ const pluginIgnore = codeCfg?.ignore as string[] | undefined;
305
+
306
+ const include = [...(rootInclude ?? []), ...(pluginInclude ?? [])];
307
+ const ignore = [...(rootIgnore ?? []), ...(pluginIgnore ?? [])];
308
+
309
+ return {
310
+ exists: true,
311
+ ignore: ignore.length > 0 ? ignore : undefined,
312
+ include: include.length > 0 ? include : undefined,
313
+ plugins: config?.plugins as string[] | undefined,
314
+ };
315
+ } catch {
316
+ return { exists: false };
317
+ }
318
+ }
319
+
320
+ /** Check DB existence and size. */
321
+ function scanDb(repoPath: string): ScanResult['db'] {
322
+ const dbPath = path.join(repoPath, '.brainbank', 'data', 'brainbank.db');
323
+ if (!fs.existsSync(dbPath)) return { exists: false, sizeMB: 0 };
324
+
325
+ try {
326
+ const stat = fs.statSync(dbPath);
327
+ return {
328
+ exists: true,
329
+ sizeMB: Math.round(stat.size / 1024 / 1024 * 10) / 10,
330
+ lastModified: stat.mtime,
331
+ };
332
+ } catch {
333
+ return { exists: false, sizeMB: 0 };
334
+ }
335
+ }
336
+
@@ -0,0 +1,203 @@
1
+ /**
2
+ * brainbank search — Semantic search (vector)
3
+ * brainbank hsearch — Hybrid search (vector + BM25)
4
+ * brainbank ksearch — Keyword search (BM25)
5
+ *
6
+ * Source filtering:
7
+ * --code 10 Max code results
8
+ * --git 0 Skip git results
9
+ * --docs 5 Max document results
10
+ * --notes 10 Custom plugin results
11
+ * --slack_messages 5 Custom collection results
12
+ *
13
+ * Any --<name> <number> flag is treated as a source filter.
14
+ */
15
+
16
+ import { c, args, getFlag, stripFlags, printResults } from '@/cli/utils.ts';
17
+ import { createBrain } from '@/cli/factory/index.ts';
18
+ import { tryServerSearch } from '@/cli/server-client.ts';
19
+
20
+ /**
21
+ * Parse dynamic source flags: each `--<name> <number>` becomes `{ name: number }`.
22
+ *
23
+ * Known non-source flags (--repo, --depth, etc.) are excluded.
24
+ * Returns sources map + the query string (positional args).
25
+ */
26
+ function parseSourceFlags(): { sources: Record<string, number>; query: string } {
27
+ const NON_SOURCE_FLAGS = new Set([
28
+ 'repo', 'depth', 'collection', 'pattern', 'context', 'name',
29
+ 'keep', 'pruner', 'only', 'docs-path', 'mode', 'limit',
30
+ 'ignore', 'include', 'meta', 'k', 'yes', 'y', 'force', 'verbose', 'path',
31
+ ]);
32
+
33
+ const sources: Record<string, number> = {};
34
+ const positional: string[] = [];
35
+
36
+ for (let i = 0; i < args.length; i++) {
37
+ if (args[i].startsWith('--')) {
38
+ const name = args[i].slice(2);
39
+
40
+ // Skip boolean flags
41
+ if (name === 'yes' || name === 'force' || name === 'verbose') continue;
42
+
43
+ // If next arg is a number and flag is not a known non-source flag
44
+ const next = args[i + 1];
45
+ if (next !== undefined && /^\d+$/.test(next) && !NON_SOURCE_FLAGS.has(name)) {
46
+ sources[name] = parseInt(next, 10);
47
+ i++; // skip the value
48
+ continue;
49
+ }
50
+
51
+ // Known value flag — skip its value
52
+ if (NON_SOURCE_FLAGS.has(name) && next !== undefined && !next.startsWith('--')) {
53
+ i++;
54
+ }
55
+ continue;
56
+ }
57
+ positional.push(args[i]);
58
+ }
59
+
60
+ const query = positional.slice(1).join(' '); // skip command name
61
+ return { sources, query };
62
+ }
63
+
64
+ /** Parse --path as comma-separated list of path prefixes. */
65
+ function parsePaths(): string | string[] | undefined {
66
+ const raw = getFlag('path');
67
+ if (!raw) return undefined;
68
+ const paths = raw.split(',').map(p => p.trim()).filter(Boolean);
69
+ return paths.length === 1 ? paths[0] : paths;
70
+ }
71
+
72
+ /** Print active source and path filters. */
73
+ function printFilterInfo(sources: Record<string, number>, pathPrefix?: string | string[]): void {
74
+ const parts: string[] = [];
75
+ const entries = Object.entries(sources);
76
+ if (entries.length > 0) parts.push(...entries.map(([k, v]) => `${k}=${v}`));
77
+ if (pathPrefix) {
78
+ const paths = Array.isArray(pathPrefix) ? pathPrefix : [pathPrefix];
79
+ parts.push(`path=${paths.join(',')}`);
80
+ }
81
+ if (parts.length > 0) console.log(c.dim(` Filters: ${parts.join(', ')}`));
82
+ }
83
+
84
+ /** Build search options from sources map + optional path prefix(es). */
85
+ function buildSearchOptions(sources: Record<string, number>, pathPrefix?: string | string[]): { sources: Record<string, number>; source: 'cli'; pathPrefix?: string | string[] } {
86
+ const opts: { sources: Record<string, number>; source: 'cli'; pathPrefix?: string | string[] } = {
87
+ sources: Object.keys(sources).length > 0 ? sources : {},
88
+ source: 'cli',
89
+ };
90
+ if (pathPrefix) opts.pathPrefix = pathPrefix;
91
+ return opts;
92
+ }
93
+
94
+ export async function cmdSearch(): Promise<void> {
95
+ const { sources, query } = parseSourceFlags();
96
+ if (!query) {
97
+ console.log(c.red('Usage: brainbank search <query> [--repo <path>] [--path <dir>] [--code <n>] [--git <n>]'));
98
+ process.exit(1);
99
+ }
100
+
101
+ const pathPrefix = parsePaths();
102
+ const repo = getFlag('repo') ?? process.cwd();
103
+
104
+ // Try daemon delegation first (hot HNSW)
105
+ const delegated = await tryServerSearch('search', {
106
+ query, repo,
107
+ sources: Object.keys(sources).length > 0 ? sources : undefined,
108
+ pathPrefix,
109
+ });
110
+ if (delegated) {
111
+ console.log(c.bold(`\n━━━ BrainBank Search: "${query}" ━━━\n`));
112
+ printFilterInfo(sources, pathPrefix);
113
+ printResults(delegated);
114
+ return;
115
+ }
116
+
117
+ // Fall back to local
118
+ const brain = await createBrain();
119
+ console.log(c.bold(`\n━━━ BrainBank Search: "${query}" ━━━\n`));
120
+ printFilterInfo(sources, pathPrefix);
121
+
122
+ const opts = buildSearchOptions(sources, pathPrefix);
123
+ const results = await brain.search(query, opts);
124
+ printResults(results);
125
+ brain.close();
126
+ }
127
+
128
+ export async function cmdHybridSearch(): Promise<void> {
129
+ const { sources, query } = parseSourceFlags();
130
+ if (!query) {
131
+ console.log(c.red('Usage: brainbank hsearch <query> [--repo <path>] [--path <dir>] [--code <n>] [--git <n>] [--docs <n>]'));
132
+ process.exit(1);
133
+ }
134
+
135
+ const pathPrefix = parsePaths();
136
+ const repo = getFlag('repo') ?? process.cwd();
137
+
138
+ // Try daemon delegation first (hot HNSW)
139
+ const delegated = await tryServerSearch('hybrid', {
140
+ query, repo,
141
+ sources: Object.keys(sources).length > 0 ? sources : undefined,
142
+ pathPrefix,
143
+ });
144
+ if (delegated) {
145
+ console.log(c.bold(`\n━━━ BrainBank Hybrid Search: "${query}" ━━━`));
146
+ console.log(c.dim(` Mode: vector + BM25 → Reciprocal Rank Fusion`));
147
+ printFilterInfo(sources, pathPrefix);
148
+ console.log('');
149
+ printResults(delegated);
150
+ return;
151
+ }
152
+
153
+ // Fall back to local
154
+ const brain = await createBrain();
155
+ console.log(c.bold(`\n━━━ BrainBank Hybrid Search: "${query}" ━━━`));
156
+ console.log(c.dim(` Mode: vector + BM25 → Reciprocal Rank Fusion`));
157
+ printFilterInfo(sources, pathPrefix);
158
+ console.log('');
159
+
160
+ const opts = buildSearchOptions(sources, pathPrefix);
161
+ const results = await brain.hybridSearch(query, opts);
162
+ printResults(results);
163
+ brain.close();
164
+ }
165
+
166
+ export async function cmdKeywordSearch(): Promise<void> {
167
+ const { sources, query } = parseSourceFlags();
168
+ if (!query) {
169
+ console.log(c.red('Usage: brainbank ksearch <query> [--repo <path>] [--path <dir>] [--code <n>] [--git <n>]'));
170
+ process.exit(1);
171
+ }
172
+
173
+ const pathPrefix = parsePaths();
174
+ const repo = getFlag('repo') ?? process.cwd();
175
+
176
+ // Try daemon delegation first (hot HNSW)
177
+ const delegated = await tryServerSearch('keyword', {
178
+ query, repo,
179
+ sources: Object.keys(sources).length > 0 ? sources : undefined,
180
+ pathPrefix,
181
+ });
182
+ if (delegated) {
183
+ console.log(c.bold(`\n━━━ BrainBank Keyword Search: "${query}" ━━━`));
184
+ console.log(c.dim(` Mode: BM25 full-text (instant)`));
185
+ printFilterInfo(sources, pathPrefix);
186
+ console.log('');
187
+ printResults(delegated, 0.40);
188
+ return;
189
+ }
190
+
191
+ // Fall back to local
192
+ const brain = await createBrain();
193
+ await brain.initialize();
194
+ console.log(c.bold(`\n━━━ BrainBank Keyword Search: "${query}" ━━━`));
195
+ console.log(c.dim(` Mode: BM25 full-text (instant)`));
196
+ printFilterInfo(sources, pathPrefix);
197
+ console.log('');
198
+
199
+ const opts = buildSearchOptions(sources, pathPrefix);
200
+ const results = await brain.searchBM25(query, opts);
201
+ printResults(results, 0.40);
202
+ brain.close();
203
+ }
@@ -0,0 +1,68 @@
1
+ /** brainbank stats — Show index statistics (interactive TUI or plain text). */
2
+
3
+ import * as path from 'node:path';
4
+ import { c, args, getFlag } from '@/cli/utils.ts';
5
+ import { createBrain } from '@/cli/factory/index.ts';
6
+
7
+ /** Convert camelCase/snake_case stat keys to human-readable labels. */
8
+ function formatStatKey(key: string): string {
9
+ return key
10
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
11
+ .replace(/_/g, ' ')
12
+ .replace(/\b\w/g, c => c.toUpperCase())
13
+ .padEnd(16);
14
+ }
15
+
16
+ export async function cmdStats(): Promise<void> {
17
+ const plain = args.includes('--plain');
18
+
19
+ if (!plain) {
20
+ // Interactive TUI mode
21
+ try {
22
+ const repoPath = path.resolve(getFlag('repo') ?? process.cwd());
23
+ const dbPath = path.join(repoPath, '.brainbank', 'data', 'brainbank.db');
24
+ const configPath = path.join(repoPath, '.brainbank', 'config.json');
25
+
26
+ const { runStatsTui } = await import('@/cli/tui/stats-tui.tsx');
27
+ await runStatsTui(dbPath, repoPath, configPath);
28
+ return;
29
+ } catch (err: unknown) {
30
+ // Fall back to plain mode if TUI fails (e.g., no DB, piped output)
31
+ if (err instanceof Error && err.message.includes('ENOENT')) {
32
+ console.log(c.yellow('No BrainBank database found. Run `brainbank index` first.\n'));
33
+ return;
34
+ }
35
+ // For other errors, fall through to plain mode
36
+ }
37
+ }
38
+
39
+ // Plain text mode (original output)
40
+ const brain = await createBrain();
41
+ await brain.initialize();
42
+
43
+ const s = brain.stats();
44
+
45
+ console.log(c.bold('\n━━━ BrainBank Stats ━━━\n'));
46
+ console.log(` ${c.cyan('Plugins')}: ${brain.plugins.join(', ')}\n`);
47
+
48
+ for (const [name, pluginStats] of Object.entries(s)) {
49
+ if (!pluginStats) continue;
50
+ console.log(` ${c.cyan(name)}`);
51
+ for (const [key, value] of Object.entries(pluginStats)) {
52
+ console.log(` ${formatStatKey(key)}${value}`);
53
+ }
54
+ console.log('');
55
+ }
56
+
57
+ const kvNames = brain.listCollectionNames();
58
+ if (kvNames.length > 0) {
59
+ console.log(` ${c.cyan('KV Collections')}`);
60
+ for (const name of kvNames) {
61
+ const coll = brain.collection(name);
62
+ console.log(` ${name}: ${coll.count()} items`);
63
+ }
64
+ console.log('');
65
+ }
66
+
67
+ brain.close();
68
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Status Command — Show BrainBank server status.
3
+ *
4
+ * Usage: brainbank status
5
+ */
6
+
7
+ import { c } from '@/cli/utils.ts';
8
+ import { serverHealth } from '@/cli/server-client.ts';
9
+ import { isServerRunning } from '@/services/daemon.ts';
10
+
11
+ function formatUptime(seconds: number): string {
12
+ if (seconds < 60) return `${seconds}s`;
13
+ const minutes = Math.floor(seconds / 60);
14
+ if (minutes < 60) return `${minutes}m`;
15
+ const hours = Math.floor(minutes / 60);
16
+ const remainMinutes = minutes % 60;
17
+ return remainMinutes > 0 ? `${hours}h ${remainMinutes}m` : `${hours}h`;
18
+ }
19
+
20
+ export async function cmdStatus(): Promise<void> {
21
+ const info = isServerRunning();
22
+
23
+ if (!info) {
24
+ console.log(`\n ${c.dim('HTTP Server:')} ${c.yellow('stopped')}\n`);
25
+ console.log(c.dim(' Start with: brainbank daemon'));
26
+ console.log('');
27
+ return;
28
+ }
29
+
30
+ // Try to get detailed health from the server
31
+ const health = await serverHealth();
32
+
33
+ if (health) {
34
+ const uptime = formatUptime(health.uptime);
35
+ console.log(`\n ${c.dim('HTTP Server:')} ${c.green('running')}`);
36
+ console.log(` ${c.dim('PID:')} ${health.pid}`);
37
+ console.log(` ${c.dim('Port:')} ${health.port}`);
38
+ console.log(` ${c.dim('Uptime:')} ${uptime}`);
39
+ console.log(` ${c.dim('Workspaces:')} ${health.workspaces}`);
40
+ console.log('');
41
+ } else {
42
+ // PID file exists but server not responding
43
+ console.log(`\n ${c.dim('HTTP Server:')} ${c.yellow('stale')} (PID ${info.pid} not responding)`);
44
+ console.log(c.dim(' The PID file may be stale. Restart with: brainbank daemon'));
45
+ console.log('');
46
+ }
47
+ }