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,650 @@
1
+ /**
2
+ * tree-scanner.ts — Filesystem scanner for the index TUI.
3
+ *
4
+ * Builds interactive file tree data (dirs + files) for navigation.
5
+ * Pure functions, no React, no state. Reuses existing language filters.
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 } from '@/lib/languages.ts';
13
+
14
+
15
+ // ── Types ─────────────────────────────────────────────
16
+
17
+ /** A single item in the interactive file tree (dir or file). */
18
+ export interface FileTreeItem {
19
+ /** Relative path from repo root. */
20
+ path: string;
21
+ /** Display name (basename). */
22
+ name: string;
23
+ /** Nesting depth (0 = top-level). */
24
+ depth: number;
25
+ /** Is this a directory? */
26
+ isDir: boolean;
27
+ /** File extension (e.g. '.ts'). Empty for dirs. */
28
+ ext: string;
29
+ /** Whether included for indexing. Only togglable on dirs. */
30
+ checked: boolean;
31
+ /** Whether children are visible (dirs only). */
32
+ expanded: boolean;
33
+ /** Has indexable content below (dirs only). */
34
+ hasChildren: boolean;
35
+ /** Indexable file count (dirs only, recursive). */
36
+ fileCount: number;
37
+ }
38
+
39
+
40
+ // ── Extension colors (VSCode-inspired) ────────────────
41
+
42
+ const EXT_COLORS: Record<string, string> = {
43
+ '.ts': '#519ABA',
44
+ '.tsx': '#519ABA',
45
+ '.js': '#CBCB41',
46
+ '.jsx': '#61DAFB',
47
+ '.mjs': '#CBCB41',
48
+ '.py': '#4B8BBE',
49
+ '.go': '#7FD5EA',
50
+ '.rs': '#DEA584',
51
+ '.rb': '#CC3E44',
52
+ '.java': '#CC3E44',
53
+ '.c': '#599EFF',
54
+ '.cpp': '#599EFF',
55
+ '.h': '#926BD4',
56
+ '.cs': '#68217A',
57
+ '.php': '#777BB3',
58
+ '.swift': '#F05138',
59
+ '.kt': '#7F52FF',
60
+ '.css': '#42A5F5',
61
+ '.scss': '#F06292',
62
+ '.html': '#E44D26',
63
+ '.vue': '#8DC149',
64
+ '.svelte': '#FF3E00',
65
+ '.json': '#CBCB41',
66
+ '.yaml': '#F44336',
67
+ '.yml': '#F44336',
68
+ '.md': '#519ABA',
69
+ '.sql': '#E0B040',
70
+ '.sh': '#89E051',
71
+ '.bash': '#89E051',
72
+ '.zsh': '#89E051',
73
+ '.lua': '#51A0CF',
74
+ '.zig': '#F69A1B',
75
+ };
76
+
77
+ /** Get the display color for a file extension. */
78
+ export function getExtColor(ext: string): string {
79
+ return EXT_COLORS[ext] ?? '#7C8DA6';
80
+ }
81
+
82
+ /** Get a short icon-like label for the extension. */
83
+ export function getExtIcon(ext: string): string {
84
+ switch (ext) {
85
+ case '.ts': case '.tsx': return '⬡';
86
+ case '.js': case '.jsx': case '.mjs': return '⬡';
87
+ case '.py': return '◆';
88
+ case '.go': return '◇';
89
+ case '.rs': return '⛭';
90
+ case '.md': return '◎';
91
+ case '.json': case '.yaml': case '.yml': return '◉';
92
+ case '.css': case '.scss': return '◈';
93
+ case '.html': case '.vue': case '.svelte': return '◇';
94
+ case '.sh': case '.bash': case '.zsh': return '⚙';
95
+ default: return '○';
96
+ }
97
+ }
98
+
99
+
100
+ // ── Build file tree ───────────────────────────────────
101
+
102
+ /**
103
+ * Build the initial interactive tree — top-level dirs expanded,
104
+ * showing both dirs and files. Returns a flat list.
105
+ */
106
+ export function buildFileTree(repoPath: string, include?: string[]): FileTreeItem[] {
107
+ const items: FileTreeItem[] = [];
108
+ const entries = readSortedEntries(repoPath);
109
+
110
+ // Build a matcher to determine initial checked state
111
+ const hasInclude = include && include.length > 0;
112
+ const isIncluded = hasInclude ? picomatch(include, { dot: true }) : null;
113
+ // Extract base prefixes for dir-level checks (e.g. 'apps/admin/app' from 'apps/admin/app/**')
114
+ const includeBases = hasInclude
115
+ ? include.map(p => picomatch.scan(p).base).filter(b => b && b !== '.')
116
+ : null;
117
+
118
+ /** Check if a relative path (dir or file) should be checked based on include patterns. */
119
+ function shouldCheck(relPath: string, isDir: boolean): boolean {
120
+ if (!hasInclude) return true; // no include filter → check everything
121
+ // For files: match against the include patterns directly
122
+ if (!isDir) return isIncluded!(relPath);
123
+ // For dirs: check if this dir is a prefix of any include base, or vice versa
124
+ if (includeBases) {
125
+ return includeBases.some(base =>
126
+ relPath.startsWith(base) || base.startsWith(relPath),
127
+ );
128
+ }
129
+ return true;
130
+ }
131
+
132
+ for (const entry of entries) {
133
+ if (entry.isDir) {
134
+ const dirPath = path.join(repoPath, entry.name);
135
+ const stats = scanDirStats(dirPath);
136
+ if (stats.total === 0) continue;
137
+
138
+ const dirChecked = shouldCheck(entry.name, true);
139
+
140
+ // Top-level dir — auto-expanded
141
+ items.push({
142
+ path: entry.name,
143
+ name: entry.name,
144
+ depth: 0,
145
+ isDir: true,
146
+ ext: '',
147
+ checked: dirChecked,
148
+ expanded: true,
149
+ hasChildren: true,
150
+ fileCount: stats.total,
151
+ });
152
+
153
+ // Add children (depth 1)
154
+ const children = readSortedEntries(dirPath);
155
+ for (const child of children) {
156
+ const childRel = `${entry.name}/${child.name}`;
157
+
158
+ if (child.isDir) {
159
+ const childAbs = path.join(dirPath, child.name);
160
+ const cs = scanDirStats(childAbs);
161
+ if (cs.total === 0) continue;
162
+
163
+ items.push({
164
+ path: childRel,
165
+ name: child.name,
166
+ depth: 1,
167
+ isDir: true,
168
+ ext: '',
169
+ checked: shouldCheck(childRel, true),
170
+ expanded: false,
171
+ hasChildren: cs.hasSubdirs || cs.total > 0,
172
+ fileCount: cs.total,
173
+ });
174
+ } else {
175
+ const ext = path.extname(child.name).toLowerCase();
176
+ if (!SUPPORTED_EXTENSIONS[ext]) continue;
177
+
178
+ items.push({
179
+ path: childRel,
180
+ name: child.name,
181
+ depth: 1,
182
+ isDir: false,
183
+ ext,
184
+ checked: shouldCheck(childRel, false),
185
+ expanded: false,
186
+ hasChildren: false,
187
+ fileCount: 0,
188
+ });
189
+ }
190
+ }
191
+ } else {
192
+ // Root-level file
193
+ const ext = path.extname(entry.name).toLowerCase();
194
+ if (!SUPPORTED_EXTENSIONS[ext]) continue;
195
+
196
+ items.push({
197
+ path: entry.name,
198
+ name: entry.name,
199
+ depth: 0,
200
+ isDir: false,
201
+ ext,
202
+ checked: shouldCheck(entry.name, false),
203
+ expanded: false,
204
+ hasChildren: false,
205
+ fileCount: 0,
206
+ });
207
+ }
208
+ }
209
+
210
+ return items;
211
+ }
212
+
213
+
214
+ /** Expand a directory — insert its children after it. Returns new array. */
215
+ export function expandDir(items: FileTreeItem[], index: number, repoPath: string): FileTreeItem[] {
216
+ const node = items[index];
217
+ if (!node || !node.isDir || node.expanded) return items;
218
+
219
+ const absDir = path.join(repoPath, node.path);
220
+ const entries = readSortedEntries(absDir);
221
+ const children: FileTreeItem[] = [];
222
+
223
+ for (const entry of entries) {
224
+ const childRel = `${node.path}/${entry.name}`;
225
+
226
+ if (entry.isDir) {
227
+ const childAbs = path.join(absDir, entry.name);
228
+ const stats = scanDirStats(childAbs);
229
+ if (stats.total === 0) continue;
230
+
231
+ children.push({
232
+ path: childRel,
233
+ name: entry.name,
234
+ depth: node.depth + 1,
235
+ isDir: true,
236
+ ext: '',
237
+ checked: node.checked,
238
+ expanded: false,
239
+ hasChildren: stats.hasSubdirs || stats.total > 0,
240
+ fileCount: stats.total,
241
+ });
242
+ } else {
243
+ const ext = path.extname(entry.name).toLowerCase();
244
+ if (!SUPPORTED_EXTENSIONS[ext]) continue;
245
+
246
+ children.push({
247
+ path: childRel,
248
+ name: entry.name,
249
+ depth: node.depth + 1,
250
+ isDir: false,
251
+ ext,
252
+ checked: node.checked,
253
+ expanded: false,
254
+ hasChildren: false,
255
+ fileCount: 0,
256
+ });
257
+ }
258
+ }
259
+
260
+ const next = [...items];
261
+ next[index] = { ...node, expanded: true };
262
+ next.splice(index + 1, 0, ...children);
263
+ return next;
264
+ }
265
+
266
+
267
+ /** Collapse a directory — remove all deeper items after it. Returns new array. */
268
+ export function collapseDir(items: FileTreeItem[], index: number): FileTreeItem[] {
269
+ const node = items[index];
270
+ if (!node || !node.isDir || !node.expanded) return items;
271
+
272
+ let removeCount = 0;
273
+ for (let i = index + 1; i < items.length; i++) {
274
+ if (items[i]!.depth <= node.depth) break;
275
+ removeCount++;
276
+ }
277
+
278
+ const next = [...items];
279
+ next[index] = { ...node, expanded: false };
280
+ next.splice(index + 1, removeCount);
281
+ return next;
282
+ }
283
+
284
+
285
+ /** Toggle a directory's checked state, cascading to visible children. */
286
+ export function toggleDir(items: FileTreeItem[], index: number): FileTreeItem[] {
287
+ const node = items[index];
288
+ if (!node || !node.isDir) return items;
289
+
290
+ const newChecked = !node.checked;
291
+ const next = [...items];
292
+ next[index] = { ...node, checked: newChecked };
293
+
294
+ // Cascade DOWN to children only
295
+ for (let i = index + 1; i < next.length; i++) {
296
+ if (next[i]!.depth <= node.depth) break;
297
+ next[i] = { ...next[i]!, checked: newChecked };
298
+ }
299
+
300
+ return next;
301
+ }
302
+
303
+
304
+ /** Toggle an individual file's checked state. */
305
+ export function toggleFile(items: FileTreeItem[], index: number): FileTreeItem[] {
306
+ const node = items[index];
307
+ if (!node || node.isDir) return items;
308
+
309
+ const next = [...items];
310
+ next[index] = { ...node, checked: !node.checked };
311
+ return next;
312
+ }
313
+
314
+
315
+ /** Set all dirs to checked or unchecked. */
316
+ export function setAllDirs(items: FileTreeItem[], checked: boolean): FileTreeItem[] {
317
+ return items.map(item => item.isDir ? { ...item, checked } : { ...item, checked });
318
+ }
319
+
320
+
321
+ /** Generate include/ignore patterns from tree state. */
322
+ export function generatePatternsFromTree(
323
+ items: FileTreeItem[],
324
+ originalInclude?: string[],
325
+ ): { include: string[]; ignore: string[] } {
326
+ const include: string[] = [];
327
+ const ignore: string[] = [];
328
+
329
+ const allDirs = items.filter(i => i.isDir);
330
+ const topDirs = allDirs.filter(i => i.depth === 0);
331
+
332
+ // If everything is checked, no filtering needed
333
+ if (topDirs.every(d => d.checked)) {
334
+ const uncheckedSubs = allDirs.filter(d => !d.checked && d.depth > 0);
335
+ if (uncheckedSubs.length === 0) return { include: [], ignore: [] };
336
+ for (const item of uncheckedSubs) {
337
+ ignore.push(`${item.path}/**`);
338
+ }
339
+ return { include, ignore };
340
+ }
341
+
342
+ // If nothing is checked, return empty
343
+ if (allDirs.every(d => !d.checked)) {
344
+ return { include: [], ignore: [] };
345
+ }
346
+
347
+ // Build lookup: for each dir path, which original patterns applied to it
348
+ const originalByDir = new Map<string, string[]>();
349
+ if (originalInclude && originalInclude.length > 0) {
350
+ for (const pattern of originalInclude) {
351
+ // Extract the base directory from the pattern
352
+ const base = pattern.replace(/\/\*\*$/, '').replace(/\/\*$/, '');
353
+ // Find which top-level (or depth-1) dir this pattern falls under
354
+ const parts = base.split('/');
355
+ // Map to all ancestor dirs
356
+ for (let i = 1; i <= parts.length; i++) {
357
+ const dirPath = parts.slice(0, i).join('/');
358
+ const existing = originalByDir.get(dirPath) ?? [];
359
+ existing.push(pattern);
360
+ originalByDir.set(dirPath, existing);
361
+ }
362
+ }
363
+ }
364
+
365
+ /** Get visible children of a dir in the flat list */
366
+ function getVisibleChildren(parentIdx: number): FileTreeItem[] {
367
+ const parent = items[parentIdx]!;
368
+ const children: FileTreeItem[] = [];
369
+ for (let i = parentIdx + 1; i < items.length; i++) {
370
+ if (items[i]!.depth <= parent.depth) break;
371
+ if (items[i]!.depth === parent.depth + 1) children.push(items[i]!);
372
+ }
373
+ return children;
374
+ }
375
+
376
+ /** Check if a dir is a "full inclusion" — all its visible children are checked */
377
+ function isFullInclusion(idx: number): boolean {
378
+ const children = getVisibleChildren(idx);
379
+ if (children.length === 0) return true;
380
+ return children.filter(c => c.isDir).every(c => c.checked);
381
+ }
382
+
383
+ // Walk checked dirs and determine include patterns
384
+ for (let i = 0; i < items.length; i++) {
385
+ const item = items[i]!;
386
+ if (!item.isDir || !item.checked) continue;
387
+
388
+ // Skip if a REAL ancestor is also checked and is a full inclusion
389
+ let coveredByParent = false;
390
+ for (let j = i - 1; j >= 0; j--) {
391
+ const ancestor = items[j]!;
392
+ if (ancestor.isDir && ancestor.depth < item.depth
393
+ && item.path.startsWith(ancestor.path + '/')
394
+ && ancestor.checked) {
395
+ if (isFullInclusion(j)) {
396
+ coveredByParent = true;
397
+ }
398
+ break;
399
+ }
400
+ }
401
+ if (coveredByParent) continue;
402
+
403
+ // Check if original patterns exist for this dir — preserve them
404
+ const origPatterns = originalByDir.get(item.path);
405
+ if (origPatterns && origPatterns.length > 0) {
406
+ // Use original patterns that are scoped to or under this dir
407
+ for (const p of origPatterns) {
408
+ if (!include.includes(p)) {
409
+ include.push(p);
410
+ }
411
+ }
412
+ } else {
413
+ // New selection — generate fresh pattern
414
+ include.push(`${item.path}/**`);
415
+ }
416
+ }
417
+
418
+ // Build a set of included dir prefixes for coverage checks
419
+ const includedPrefixes = new Set(include);
420
+
421
+ // Handle individually checked files whose parent dir is NOT in the include set
422
+ for (const item of items) {
423
+ if (item.isDir || !item.checked) continue;
424
+ // Check if this file is already covered by an included directory
425
+ const covered = [...includedPrefixes].some(p => {
426
+ const base = p.replace(/\/\*\*$/, '').replace(/\/\*$/, '');
427
+ return item.path.startsWith(base + '/') || item.path === base;
428
+ });
429
+ if (!covered) {
430
+ include.push(item.path);
431
+ }
432
+ }
433
+
434
+ // Handle individually unchecked files inside checked directories
435
+ for (const item of items) {
436
+ if (item.isDir || item.checked) continue;
437
+ const parentPath = item.path.split('/').slice(0, -1).join('/');
438
+ const parentIncluded = includedPrefixes.has(`${parentPath}/**`);
439
+ if (parentIncluded) {
440
+ ignore.push(item.path);
441
+ }
442
+ }
443
+
444
+ return { include, ignore };
445
+ }
446
+
447
+
448
+ /** Count total selected files in the tree. */
449
+ export function countSelectedFiles(items: FileTreeItem[]): number {
450
+ let total = 0;
451
+ for (const item of items) {
452
+ if (!item.isDir && item.checked) total++;
453
+ // Count expanded dirs' direct file count only if not expanded (avoid double count)
454
+ if (item.isDir && item.checked && !item.expanded) total += item.fileCount;
455
+ }
456
+ return total;
457
+ }
458
+
459
+
460
+ /** Count total files in the tree. */
461
+ export function countTotalFiles(items: FileTreeItem[]): number {
462
+ return items.filter(i => i.depth === 0 && i.isDir).reduce((s, i) => s + i.fileCount, 0)
463
+ + items.filter(i => i.depth === 0 && !i.isDir).length;
464
+ }
465
+
466
+
467
+ // ── Internal helpers ──────────────────────────────────
468
+
469
+ interface DirStats {
470
+ total: number;
471
+ byLang: Map<string, number>;
472
+ hasSubdirs: boolean;
473
+ }
474
+
475
+ function scanDirStats(dirPath: string): DirStats {
476
+ const byLang = new Map<string, number>();
477
+ let total = 0;
478
+ let hasSubdirs = false;
479
+
480
+ function walk(dir: string): void {
481
+ for (const entry of readDirSafe(dir)) {
482
+ if (isDirEntry(dir, entry)) {
483
+ if (isIgnoredDir(entry.name) || entry.name.startsWith('.')) continue;
484
+ hasSubdirs = true;
485
+ walk(path.join(dir, entry.name));
486
+ } else if (entry.isFile()) {
487
+ const ext = path.extname(entry.name).toLowerCase();
488
+ const lang = SUPPORTED_EXTENSIONS[ext];
489
+ if (lang) { byLang.set(lang, (byLang.get(lang) ?? 0) + 1); total++; }
490
+ }
491
+ }
492
+ }
493
+
494
+ walk(dirPath);
495
+ return { total, byLang, hasSubdirs };
496
+ }
497
+
498
+ interface SortedEntry { name: string; isDir: boolean }
499
+
500
+ function readSortedEntries(dir: string): SortedEntry[] {
501
+ const raw = readDirSafe(dir);
502
+ const entries: SortedEntry[] = [];
503
+
504
+ for (const e of raw) {
505
+ if (e.name.startsWith('.')) continue;
506
+ if (isDirEntry(dir, e)) {
507
+ if (isIgnoredDir(e.name)) continue;
508
+ entries.push({ name: e.name, isDir: true });
509
+ } else if (e.isFile()) {
510
+ entries.push({ name: e.name, isDir: false });
511
+ }
512
+ }
513
+
514
+ return entries.sort((a, b) => {
515
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
516
+ return a.name.localeCompare(b.name);
517
+ });
518
+ }
519
+
520
+ function readDirSafe(dir: string): fs.Dirent[] {
521
+ try { return fs.readdirSync(dir, { withFileTypes: true }); }
522
+ catch { return []; }
523
+ }
524
+
525
+ function isDirEntry(parentDir: string, entry: fs.Dirent): boolean {
526
+ if (entry.isDirectory()) return true;
527
+ if (entry.isSymbolicLink()) {
528
+ try { return fs.statSync(path.join(parentDir, entry.name)).isDirectory(); }
529
+ catch { return false; }
530
+ }
531
+ return false;
532
+ }
533
+
534
+
535
+ // ── Docs & Git preview ────────────────────────────────
536
+
537
+ export interface PreviewLine {
538
+ text: string;
539
+ color?: string;
540
+ bold?: boolean;
541
+ dim?: boolean;
542
+ }
543
+
544
+ /** Scan all markdown files and return preview lines. */
545
+ export function scanDocsPreview(repoPath: string): PreviewLine[] {
546
+ const mdFiles: string[] = [];
547
+
548
+ function walk(dir: string, rel: string): void {
549
+ for (const entry of readDirSafe(dir)) {
550
+ if (entry.name.startsWith('.')) continue;
551
+ const fullPath = path.join(dir, entry.name);
552
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name;
553
+
554
+ if (isDirEntry(dir, entry)) {
555
+ if (isIgnoredDir(entry.name)) continue;
556
+ walk(fullPath, relPath);
557
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
558
+ mdFiles.push(relPath);
559
+ }
560
+ }
561
+ }
562
+
563
+ walk(repoPath, '');
564
+ mdFiles.sort();
565
+
566
+ if (mdFiles.length === 0) {
567
+ return [{ text: ' No markdown files found', dim: true }];
568
+ }
569
+
570
+ const lines: PreviewLine[] = [
571
+ { text: `📄 ${mdFiles.length} markdown files`, bold: true },
572
+ { text: '' },
573
+ ];
574
+
575
+ // Group by top-level dir
576
+ const groups = new Map<string, string[]>();
577
+ for (const f of mdFiles) {
578
+ const parts = f.split('/');
579
+ const group = parts.length > 1 ? parts[0]! : '(root)';
580
+ const list = groups.get(group) || [];
581
+ list.push(f);
582
+ groups.set(group, list);
583
+ }
584
+
585
+ for (const [group, files] of groups) {
586
+ if (group !== '(root)') {
587
+ lines.push({ text: ` ${group}/`, bold: true, color: '#E0AF68' });
588
+ }
589
+ for (const f of files) {
590
+ const name = group === '(root)' ? f : f.slice(group.length + 1);
591
+ lines.push({ text: ` MD ${name}`, color: '#519ABA' });
592
+ }
593
+ lines.push({ text: '' });
594
+ }
595
+
596
+ return lines;
597
+ }
598
+
599
+ /** Scan recent git commits and return preview lines. */
600
+ export function scanGitPreview(repoPath: string): PreviewLine[] {
601
+ const gitDir = path.join(repoPath, '.git');
602
+ if (!fs.existsSync(gitDir)) {
603
+ return [{ text: ' No .git directory found', dim: true }];
604
+ }
605
+
606
+ try {
607
+ const raw = execSync(
608
+ 'git log --oneline --format="%h %ar %s" -n 20',
609
+ { cwd: repoPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
610
+ ).trim();
611
+
612
+ const commits = raw.split('\n').filter(Boolean);
613
+ if (commits.length === 0) {
614
+ return [{ text: ' No commits found', dim: true }];
615
+ }
616
+
617
+ // Count total commits
618
+ let totalStr = '';
619
+ try {
620
+ totalStr = execSync('git rev-list --count HEAD',
621
+ { cwd: repoPath, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
622
+ ).trim();
623
+ } catch { /* ignore */ }
624
+
625
+ const lines: PreviewLine[] = [
626
+ { text: `📜 ${totalStr || '?'} commits`, bold: true },
627
+ { text: '' },
628
+ ];
629
+
630
+ for (const line of commits) {
631
+ const spaceIdx = line.indexOf(' ');
632
+ const hash = line.slice(0, spaceIdx);
633
+ const rest = line.slice(spaceIdx + 1);
634
+ // Split "X ago message" — find second space after time
635
+ const timeMatch = rest.match(/^(.+? ago) (.+)$/);
636
+ if (timeMatch) {
637
+ lines.push({
638
+ text: ` ${hash} ${timeMatch[2]}`,
639
+ color: '#C0CAF5',
640
+ });
641
+ } else {
642
+ lines.push({ text: ` ${hash} ${rest}`, dim: true });
643
+ }
644
+ }
645
+
646
+ return lines;
647
+ } catch {
648
+ return [{ text: ' Failed to read git log', dim: true }];
649
+ }
650
+ }