@nano-step/nano-brain 2026.1.14

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 (93) hide show
  1. package/.opencode/command/nano-brain-init.md +13 -0
  2. package/.opencode/command/nano-brain-reindex.md +11 -0
  3. package/.opencode/command/nano-brain-status.md +12 -0
  4. package/AGENTS.md +41 -0
  5. package/AGENTS_SNIPPET.md +44 -0
  6. package/CHANGELOG.md +186 -0
  7. package/README.md +298 -0
  8. package/SKILL.md +109 -0
  9. package/bin/cli.js +29 -0
  10. package/commands/nano-brain-init.md +36 -0
  11. package/commands/nano-brain-reindex.md +31 -0
  12. package/commands/nano-brain-status.md +32 -0
  13. package/index.html +929 -0
  14. package/nano-brain +4 -0
  15. package/opencode-mcp.json +9 -0
  16. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/.openspec.yaml +2 -0
  17. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/design.md +68 -0
  18. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/proposal.md +27 -0
  19. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/mcp-integration-testing/spec.md +50 -0
  20. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/mcp-server/spec.md +40 -0
  21. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/search-pipeline/spec.md +29 -0
  22. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/tasks.md +37 -0
  23. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/.openspec.yaml +2 -0
  24. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/design.md +111 -0
  25. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/proposal.md +30 -0
  26. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/mcp-server/spec.md +33 -0
  27. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/storage-limits/spec.md +90 -0
  28. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/workspace-scoping/spec.md +66 -0
  29. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/tasks.md +199 -0
  30. package/openspec/changes/codebase-indexing/.openspec.yaml +2 -0
  31. package/openspec/changes/codebase-indexing/design.md +169 -0
  32. package/openspec/changes/codebase-indexing/proposal.md +30 -0
  33. package/openspec/changes/codebase-indexing/specs/codebase-collection/spec.md +187 -0
  34. package/openspec/changes/codebase-indexing/specs/mcp-server/spec.md +36 -0
  35. package/openspec/changes/codebase-indexing/tasks.md +56 -0
  36. package/openspec/changes/fix-session-harvest-workspace-scoping/.openspec.yaml +2 -0
  37. package/openspec/changes/fix-session-harvest-workspace-scoping/design.md +84 -0
  38. package/openspec/changes/fix-session-harvest-workspace-scoping/proposal.md +26 -0
  39. package/openspec/changes/fix-session-harvest-workspace-scoping/specs/workspace-scoping/spec.md +65 -0
  40. package/openspec/changes/fix-session-harvest-workspace-scoping/tasks.md +33 -0
  41. package/openspec/changes/performance-and-search-quality/.openspec.yaml +2 -0
  42. package/openspec/changes/performance-and-search-quality/proposal.md +37 -0
  43. package/openspec/specs/mcp-integration-testing/spec.md +50 -0
  44. package/openspec/specs/mcp-server/spec.md +75 -0
  45. package/openspec/specs/search-pipeline/spec.md +29 -0
  46. package/openspec/specs/storage-limits/spec.md +94 -0
  47. package/openspec/specs/workspace-scoping/spec.md +70 -0
  48. package/package.json +37 -0
  49. package/site/build.js +66 -0
  50. package/site/partials/_api.html +83 -0
  51. package/site/partials/_compare.html +100 -0
  52. package/site/partials/_config.html +23 -0
  53. package/site/partials/_features.html +43 -0
  54. package/site/partials/_footer.html +6 -0
  55. package/site/partials/_hero.html +9 -0
  56. package/site/partials/_how-it-works.html +26 -0
  57. package/site/partials/_models.html +18 -0
  58. package/site/partials/_quick-start.html +15 -0
  59. package/site/partials/_stats.html +1 -0
  60. package/site/partials/_tech-stack.html +13 -0
  61. package/site/script.js +12 -0
  62. package/site/shell.html +44 -0
  63. package/site/styles.css +548 -0
  64. package/src/chunker.ts +427 -0
  65. package/src/codebase.ts +425 -0
  66. package/src/collections.ts +217 -0
  67. package/src/embeddings.ts +325 -0
  68. package/src/expansion.ts +79 -0
  69. package/src/harvester.ts +306 -0
  70. package/src/index.ts +778 -0
  71. package/src/reranker.ts +103 -0
  72. package/src/search.ts +294 -0
  73. package/src/server.ts +876 -0
  74. package/src/storage.ts +221 -0
  75. package/src/store.ts +653 -0
  76. package/src/types.ts +215 -0
  77. package/src/watcher.ts +389 -0
  78. package/test/chunker.test.ts +479 -0
  79. package/test/cli.test.ts +309 -0
  80. package/test/codebase-chunker.test.ts +446 -0
  81. package/test/codebase.test.ts +678 -0
  82. package/test/collections.test.ts +571 -0
  83. package/test/harvester.test.ts +636 -0
  84. package/test/integration.test.ts +219 -0
  85. package/test/llm.test.ts +322 -0
  86. package/test/search.test.ts +572 -0
  87. package/test/server.test.ts +541 -0
  88. package/test/storage.test.ts +302 -0
  89. package/test/store.test.ts +530 -0
  90. package/test/watcher.test.ts +717 -0
  91. package/test/workspace.test.ts +239 -0
  92. package/tsconfig.json +19 -0
  93. package/vitest.config.ts +16 -0
@@ -0,0 +1,425 @@
1
+ import type { Store, CodebaseConfig, CodebaseIndexResult } from './types.js'
2
+ import { computeHash } from './store.js'
3
+ import { chunkSourceCode } from './chunker.js'
4
+ import { parseSize } from './storage.js'
5
+ import * as fs from 'fs'
6
+ import * as path from 'path'
7
+ import fg from 'fast-glob'
8
+
9
+ const DEFAULT_MAX_FILE_SIZE = 5 * 1024 * 1024
10
+ const DEFAULT_CODEBASE_MAX_SIZE = 2 * 1024 * 1024 * 1024
11
+
12
+ const BUILTIN_EXCLUDE_PATTERNS = [
13
+ // Version control
14
+ '**/.git/**',
15
+ '**/.svn/**',
16
+ '**/.hg/**',
17
+
18
+ // JS/TS — dependencies
19
+ '**/node_modules/**',
20
+ '**/.pnpm-store/**',
21
+ '**/.yarn/**',
22
+ '**/bower_components/**',
23
+
24
+ // JS/TS — build outputs
25
+ '**/dist/**',
26
+ '**/build/**',
27
+ '**/out/**',
28
+ '**/output/**',
29
+ '**/.next/**',
30
+ '**/.nuxt/**',
31
+ '**/.svelte-kit/**',
32
+ '**/.astro/**',
33
+ '**/.remix/**',
34
+ '**/.turbo/**',
35
+ '**/.vercel/**',
36
+ '**/.output/**',
37
+ '**/.cache/**',
38
+ '**/.parcel-cache/**',
39
+ '**/.vite/**',
40
+ '**/storybook-static/**',
41
+
42
+ // JS/TS — generated files
43
+ '**/*.min.js',
44
+ '**/*.min.css',
45
+ '**/*.map',
46
+ '**/*.lock',
47
+ '**/*.tsbuildinfo',
48
+ '**/.eslintcache',
49
+
50
+ // Python
51
+ '**/__pycache__/**',
52
+ '**/.venv/**',
53
+ '**/venv/**',
54
+ '**/env/**',
55
+ '**/.env/**',
56
+ '**/.conda/**',
57
+ '**/*.egg-info/**',
58
+ '**/.mypy_cache/**',
59
+ '**/.ruff_cache/**',
60
+ '**/.pytest_cache/**',
61
+ '**/htmlcov/**',
62
+ '**/.tox/**',
63
+
64
+ // Go
65
+ '**/vendor/**',
66
+
67
+ // Rust
68
+ '**/target/**',
69
+
70
+ // Java/Kotlin/JVM
71
+ '**/.gradle/**',
72
+ '**/.mvn/**',
73
+ '**/gradle/wrapper/**',
74
+ '**/*.class',
75
+ '**/*.jar',
76
+ '**/*.war',
77
+
78
+ // Ruby
79
+ '**/gems/**',
80
+ '**/.bundle/**',
81
+
82
+ // PHP
83
+ '**/storage/framework/**',
84
+ '**/bootstrap/cache/**',
85
+
86
+ // Mobile — iOS
87
+ '**/Pods/**',
88
+ '**/*.xcworkspace/**',
89
+ '**/DerivedData/**',
90
+
91
+ // Mobile — Android
92
+ '**/.gradle/**',
93
+ '**/generated/**',
94
+
95
+ // DevOps / infra
96
+ '**/.terraform/**',
97
+ '**/.terraform.lock.hcl',
98
+ '**/terraform.tfstate*',
99
+
100
+ // Editors & IDEs
101
+ '**/.idea/**',
102
+ '**/.vscode/extensions/**',
103
+
104
+ // Logs & tmp
105
+ '**/logs/**',
106
+ '**/log/**',
107
+ '**/tmp/**',
108
+ '**/temp/**',
109
+ '**/*.log',
110
+
111
+ // Test coverage
112
+ '**/coverage/**',
113
+ '**/.nyc_output/**',
114
+ '**/lcov-report/**',
115
+
116
+ // Misc large binary/generated
117
+ '**/*.sum',
118
+ '**/*.snap',
119
+ '**/docker-data/**',
120
+ ]
121
+
122
+ const PROJECT_TYPE_MARKERS: Record<string, string[]> = {
123
+ 'package.json': ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json'],
124
+ 'pyproject.toml': ['.py', '.pyi'],
125
+ 'setup.py': ['.py', '.pyi'],
126
+ 'requirements.txt': ['.py', '.pyi'],
127
+ 'go.mod': ['.go'],
128
+ 'Cargo.toml': ['.rs'],
129
+ 'pom.xml': ['.java', '.kt', '.kts'],
130
+ 'build.gradle': ['.java', '.kt', '.kts'],
131
+ 'build.gradle.kts': ['.java', '.kt', '.kts'],
132
+ 'Gemfile': ['.rb', '.erb'],
133
+ }
134
+
135
+ export function detectProjectType(workspaceRoot: string): string[] {
136
+ const extensions = new Set<string>()
137
+
138
+ for (const [marker, exts] of Object.entries(PROJECT_TYPE_MARKERS)) {
139
+ const markerPath = path.join(workspaceRoot, marker)
140
+ if (fs.existsSync(markerPath)) {
141
+ for (const ext of exts) {
142
+ extensions.add(ext)
143
+ }
144
+ }
145
+ }
146
+
147
+ extensions.add('.md')
148
+
149
+ if (extensions.size === 1) {
150
+ return ['.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs', '.java', '.rb', '.md']
151
+ }
152
+
153
+ return Array.from(extensions)
154
+ }
155
+
156
+ export function loadGitignorePatterns(workspaceRoot: string): string[] {
157
+ const gitignorePath = path.join(workspaceRoot, '.gitignore')
158
+
159
+ if (!fs.existsSync(gitignorePath)) {
160
+ return []
161
+ }
162
+
163
+ try {
164
+ const content = fs.readFileSync(gitignorePath, 'utf-8')
165
+ const patterns: string[] = []
166
+
167
+ for (const line of content.split('\n')) {
168
+ const trimmed = line.trim()
169
+ if (trimmed && !trimmed.startsWith('#')) {
170
+ patterns.push(trimmed)
171
+ }
172
+ }
173
+
174
+ return patterns
175
+ } catch {
176
+ return []
177
+ }
178
+ }
179
+
180
+ export function mergeExcludePatterns(config: CodebaseConfig, workspaceRoot: string): string[] {
181
+ const patterns = new Set<string>(BUILTIN_EXCLUDE_PATTERNS)
182
+
183
+ const gitignorePatterns = loadGitignorePatterns(workspaceRoot)
184
+ for (const pattern of gitignorePatterns) {
185
+ patterns.add(pattern)
186
+ }
187
+
188
+ if (config.exclude) {
189
+ for (const pattern of config.exclude) {
190
+ patterns.add(pattern)
191
+ }
192
+ }
193
+
194
+ return Array.from(patterns)
195
+ }
196
+
197
+ export function resolveExtensions(config: CodebaseConfig, workspaceRoot: string): string[] {
198
+ if (config.extensions && config.extensions.length > 0) {
199
+ return config.extensions
200
+ }
201
+
202
+ return detectProjectType(workspaceRoot)
203
+ }
204
+
205
+ export async function scanCodebaseFiles(
206
+ workspaceRoot: string,
207
+ config: CodebaseConfig
208
+ ): Promise<{ files: string[]; skippedTooLarge: number }> {
209
+ const extensions = resolveExtensions(config, workspaceRoot)
210
+ const excludePatterns = mergeExcludePatterns(config, workspaceRoot)
211
+
212
+ const maxFileSize = config.maxFileSize
213
+ ? parseSize(config.maxFileSize)
214
+ : DEFAULT_MAX_FILE_SIZE
215
+
216
+ const effectiveMaxSize = maxFileSize > 0 ? maxFileSize : DEFAULT_MAX_FILE_SIZE
217
+
218
+ const patterns = extensions.map(ext => `**/*${ext}`)
219
+
220
+ const allFiles = await fg(patterns, {
221
+ cwd: workspaceRoot,
222
+ absolute: true,
223
+ onlyFiles: true,
224
+ ignore: excludePatterns,
225
+ })
226
+
227
+ const files: string[] = []
228
+ let skippedTooLarge = 0
229
+
230
+ for (const filePath of allFiles) {
231
+ try {
232
+ const stats = fs.statSync(filePath)
233
+ if (stats.size <= effectiveMaxSize) {
234
+ files.push(filePath)
235
+ } else {
236
+ skippedTooLarge++
237
+ }
238
+ } catch {
239
+ continue
240
+ }
241
+ }
242
+
243
+ return { files, skippedTooLarge }
244
+ }
245
+
246
+ export async function indexCodebase(
247
+ store: Store,
248
+ workspaceRoot: string,
249
+ config: CodebaseConfig,
250
+ projectHash: string,
251
+ _embedder?: { embed(text: string): Promise<{ embedding: number[] }> } | null
252
+ ): Promise<CodebaseIndexResult> {
253
+ const { files, skippedTooLarge } = await scanCodebaseFiles(workspaceRoot, config)
254
+ const maxSizeBytes = config.maxSize
255
+ ? parseSize(config.maxSize)
256
+ : DEFAULT_CODEBASE_MAX_SIZE
257
+ const effectiveMaxSize = maxSizeBytes > 0 ? maxSizeBytes : DEFAULT_CODEBASE_MAX_SIZE
258
+ const batchSize = config.batchSize ?? 50
259
+ let currentStorageUsed = store.getCollectionStorageSize('codebase')
260
+ let filesIndexed = 0
261
+ let filesSkippedUnchanged = 0
262
+ let filesSkippedBudget = 0
263
+ let chunksCreated = 0
264
+ const activePaths: string[] = []
265
+ let batchNum = 0
266
+
267
+ for (let i = 0; i < files.length; i++) {
268
+ const filePath = files[i]
269
+ try {
270
+ const content = fs.readFileSync(filePath, 'utf-8')
271
+ const contentSize = Buffer.byteLength(content, 'utf-8')
272
+ const hash = computeHash(content)
273
+ const existingDoc = store.findDocument(filePath)
274
+ if (existingDoc && existingDoc.hash === hash) {
275
+ filesSkippedUnchanged++
276
+ activePaths.push(filePath)
277
+ continue
278
+ }
279
+ const existingSize = existingDoc ? Buffer.byteLength(store.getDocumentBody(existingDoc.hash) ?? '', 'utf-8') : 0
280
+ const netIncrease = contentSize - existingSize
281
+ if (currentStorageUsed + netIncrease > effectiveMaxSize) {
282
+ filesSkippedBudget++
283
+ if (existingDoc) {
284
+ activePaths.push(filePath)
285
+ }
286
+ continue
287
+ }
288
+ store.insertContent(hash, content)
289
+ const chunks = chunkSourceCode(content, hash, filePath, workspaceRoot)
290
+ chunksCreated += chunks.length
291
+ const title = path.basename(filePath)
292
+ const now = new Date().toISOString()
293
+ store.insertDocument({
294
+ collection: 'codebase',
295
+ path: filePath,
296
+ title,
297
+ hash,
298
+ createdAt: existingDoc?.createdAt ?? now,
299
+ modifiedAt: now,
300
+ active: true,
301
+ projectHash,
302
+ })
303
+ currentStorageUsed += netIncrease
304
+ filesIndexed++
305
+ activePaths.push(filePath)
306
+ } catch {
307
+ continue
308
+ }
309
+
310
+ if ((i + 1) % batchSize === 0) {
311
+ batchNum++
312
+ console.error(`[codebase] Batch ${batchNum}: indexed ${i + 1}/${files.length} files`)
313
+ await new Promise(resolve => setImmediate(resolve))
314
+ }
315
+ }
316
+
317
+ store.bulkDeactivateExcept('codebase', activePaths)
318
+ const finalStorageUsed = store.getCollectionStorageSize('codebase')
319
+ return {
320
+ filesScanned: files.length,
321
+ filesIndexed,
322
+ filesSkippedUnchanged,
323
+ filesSkippedTooLarge: skippedTooLarge,
324
+ filesSkippedBudget,
325
+ chunksCreated,
326
+ storageUsedBytes: finalStorageUsed,
327
+ maxSizeBytes: effectiveMaxSize,
328
+ }
329
+ }
330
+
331
+ const MAX_EMBED_CHARS = 1800
332
+
333
+ function truncateForEmbedding(text: string): string {
334
+ if (text.length <= MAX_EMBED_CHARS) return text
335
+ return text.substring(0, MAX_EMBED_CHARS)
336
+ }
337
+
338
+ export async function embedPendingCodebase(
339
+ store: Store,
340
+ embedder: { embed(text: string): Promise<{ embedding: number[] }>; embedBatch?(texts: string[]): Promise<Array<{ embedding: number[] }>> },
341
+ batchSize: number = 10,
342
+ projectHash?: string
343
+ ): Promise<number> {
344
+ let embedded = 0
345
+ while (true) {
346
+ const batch: Array<{ hash: string; body: string; path: string }> = []
347
+ for (let i = 0; i < batchSize; i++) {
348
+ const row = store.getNextHashNeedingEmbedding(projectHash)
349
+ if (!row) break
350
+ batch.push(row)
351
+ }
352
+ if (batch.length === 0) break
353
+
354
+ const texts = batch.map(row => truncateForEmbedding(row.body))
355
+
356
+ console.error(`[embed] Batch ${batch.length} docs: ${batch.map((b, i) => `${b.path.split('/').pop()}(${texts[i].length}ch)`).join(', ')}`)
357
+ try {
358
+ if (embedder.embedBatch && batch.length > 1) {
359
+ const results = await embedder.embedBatch(texts)
360
+ for (let i = 0; i < batch.length; i++) {
361
+ store.insertEmbedding(batch[i].hash, 0, 0, results[i].embedding, 'nomic-embed-text-v1.5')
362
+ }
363
+ embedded += batch.length
364
+ } else {
365
+ for (let i = 0; i < batch.length; i++) {
366
+ try {
367
+ const result = await embedder.embed(texts[i])
368
+ store.insertEmbedding(batch[i].hash, 0, 0, result.embedding, 'nomic-embed-text-v1.5')
369
+ embedded++
370
+ } catch {
371
+ console.warn(`[embed] Failed to embed ${batch[i].path}, skipping`)
372
+ continue
373
+ }
374
+ }
375
+ }
376
+ } catch (err) {
377
+ console.warn('[embed] Batch failed, falling back to sequential:', err)
378
+ for (const item of batch) {
379
+ try {
380
+ const result = await embedder.embed(truncateForEmbedding(item.body))
381
+ store.insertEmbedding(item.hash, 0, 0, result.embedding, 'nomic-embed-text-v1.5')
382
+ embedded++
383
+ } catch {
384
+ console.warn(`[embed] Skipping ${item.path}`)
385
+ continue
386
+ }
387
+ }
388
+ }
389
+
390
+ if (embedded > 0 && embedded % 50 === 0) {
391
+ console.log(`[embed] Embedded ${embedded} document(s)...`)
392
+ }
393
+
394
+ await new Promise(resolve => setImmediate(resolve))
395
+ }
396
+ return embedded
397
+ }
398
+
399
+ export function getCodebaseStats(
400
+ store: Store,
401
+ config: CodebaseConfig | undefined,
402
+ workspaceRoot: string
403
+ ): { enabled: boolean; documents: number; chunks: number; extensions: string[]; excludeCount: number; storageUsed: number; maxSize: number } | undefined {
404
+ if (!config?.enabled) {
405
+ return undefined
406
+ }
407
+ const health = store.getIndexHealth()
408
+ const codebaseCollection = health.collections.find(c => c.name === 'codebase')
409
+ const extensions = resolveExtensions(config, workspaceRoot)
410
+ const excludePatterns = mergeExcludePatterns(config, workspaceRoot)
411
+ const storageUsed = store.getCollectionStorageSize('codebase')
412
+ const maxSize = config.maxSize
413
+ ? parseSize(config.maxSize)
414
+ : DEFAULT_CODEBASE_MAX_SIZE
415
+ const effectiveMaxSize = maxSize > 0 ? maxSize : DEFAULT_CODEBASE_MAX_SIZE
416
+ return {
417
+ enabled: true,
418
+ documents: codebaseCollection?.documentCount ?? 0,
419
+ chunks: 0,
420
+ extensions,
421
+ excludeCount: excludePatterns.length,
422
+ storageUsed,
423
+ maxSize: effectiveMaxSize,
424
+ }
425
+ }
@@ -0,0 +1,217 @@
1
+ import type { Collection, CollectionConfig, WorkspaceConfig } from './types.js';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ import { parse, stringify } from 'yaml';
6
+ import fg from 'fast-glob';
7
+
8
+ export function loadCollectionConfig(configPath: string): CollectionConfig | null {
9
+ if (!fs.existsSync(configPath)) {
10
+ return null;
11
+ }
12
+
13
+ const content = fs.readFileSync(configPath, 'utf-8');
14
+ const config = parse(content) as CollectionConfig;
15
+ return config;
16
+ }
17
+
18
+ export function saveCollectionConfig(configPath: string, config: CollectionConfig): void {
19
+ const dir = path.dirname(configPath);
20
+ if (!fs.existsSync(dir)) {
21
+ fs.mkdirSync(dir, { recursive: true });
22
+ }
23
+
24
+ const yaml = stringify(config);
25
+ fs.writeFileSync(configPath, yaml, 'utf-8');
26
+ }
27
+
28
+ export function getCollections(config: CollectionConfig): Collection[] {
29
+ const collections: Collection[] = [];
30
+
31
+ for (const [name, collectionData] of Object.entries(config.collections)) {
32
+ collections.push({
33
+ name,
34
+ path: collectionData.path,
35
+ pattern: collectionData.pattern || '**/*.md',
36
+ context: collectionData.context,
37
+ });
38
+ }
39
+
40
+ return collections;
41
+ }
42
+
43
+ export function addCollection(
44
+ configPath: string,
45
+ name: string,
46
+ collectionPath: string,
47
+ pattern?: string
48
+ ): CollectionConfig {
49
+ let config = loadCollectionConfig(configPath);
50
+
51
+ if (!config) {
52
+ config = {
53
+ collections: {},
54
+ };
55
+ }
56
+
57
+ config.collections[name] = {
58
+ path: collectionPath,
59
+ pattern: pattern || '**/*.md',
60
+ update: 'auto',
61
+ };
62
+
63
+ saveCollectionConfig(configPath, config);
64
+ return config;
65
+ }
66
+
67
+ export function removeCollection(configPath: string, name: string): CollectionConfig {
68
+ const config = loadCollectionConfig(configPath);
69
+
70
+ if (!config) {
71
+ throw new Error('Config file not found');
72
+ }
73
+
74
+ delete config.collections[name];
75
+
76
+ saveCollectionConfig(configPath, config);
77
+ return config;
78
+ }
79
+
80
+ export function listCollections(config: CollectionConfig): string[] {
81
+ return Object.keys(config.collections);
82
+ }
83
+
84
+ export function renameCollection(
85
+ configPath: string,
86
+ oldName: string,
87
+ newName: string
88
+ ): CollectionConfig {
89
+ const config = loadCollectionConfig(configPath);
90
+
91
+ if (!config) {
92
+ throw new Error('Config file not found');
93
+ }
94
+
95
+ if (!config.collections[oldName]) {
96
+ throw new Error(`Collection "${oldName}" not found`);
97
+ }
98
+
99
+ config.collections[newName] = config.collections[oldName];
100
+ delete config.collections[oldName];
101
+
102
+ saveCollectionConfig(configPath, config);
103
+ return config;
104
+ }
105
+
106
+ export function addContext(
107
+ configPath: string,
108
+ collectionName: string,
109
+ pathPrefix: string,
110
+ description: string
111
+ ): CollectionConfig {
112
+ const config = loadCollectionConfig(configPath);
113
+
114
+ if (!config) {
115
+ throw new Error('Config file not found');
116
+ }
117
+
118
+ if (!config.collections[collectionName]) {
119
+ throw new Error(`Collection "${collectionName}" not found`);
120
+ }
121
+
122
+ if (!config.collections[collectionName].context) {
123
+ config.collections[collectionName].context = {};
124
+ }
125
+
126
+ config.collections[collectionName].context![pathPrefix] = description;
127
+
128
+ saveCollectionConfig(configPath, config);
129
+ return config;
130
+ }
131
+
132
+ export function findContextForPath(config: CollectionConfig, filePath: string): string | null {
133
+ let longestMatch: { prefix: string; description: string } | null = null;
134
+
135
+ for (const collectionData of Object.values(config.collections)) {
136
+ if (!collectionData.context) {
137
+ continue;
138
+ }
139
+
140
+ for (const [prefix, description] of Object.entries(collectionData.context)) {
141
+ if (filePath.includes(prefix)) {
142
+ if (!longestMatch || prefix.length > longestMatch.prefix.length) {
143
+ longestMatch = { prefix, description };
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ return longestMatch ? longestMatch.description : null;
150
+ }
151
+
152
+ export function listAllContexts(
153
+ config: CollectionConfig
154
+ ): Array<{ collection: string; prefix: string; description: string }> {
155
+ const contexts: Array<{ collection: string; prefix: string; description: string }> = [];
156
+
157
+ for (const [collectionName, collectionData] of Object.entries(config.collections)) {
158
+ if (!collectionData.context) {
159
+ continue;
160
+ }
161
+
162
+ for (const [prefix, description] of Object.entries(collectionData.context)) {
163
+ contexts.push({
164
+ collection: collectionName,
165
+ prefix,
166
+ description,
167
+ });
168
+ }
169
+ }
170
+
171
+ return contexts;
172
+ }
173
+
174
+ export async function scanCollectionFiles(collection: Collection): Promise<string[]> {
175
+ const expandedPath = collection.path.replace(/^~/, os.homedir());
176
+
177
+ if (!fs.existsSync(expandedPath)) {
178
+ return [];
179
+ }
180
+
181
+ const files = await fg(collection.pattern, {
182
+ cwd: expandedPath,
183
+ absolute: true,
184
+ onlyFiles: true,
185
+ });
186
+
187
+ return files;
188
+ }
189
+
190
+ export function resolveCollectionPath(collection: Collection, basePath: string): string {
191
+ return collection.path;
192
+ }
193
+
194
+ export function getWorkspaceConfig(config: CollectionConfig | null, workspaceRoot: string): WorkspaceConfig {
195
+ // 1. Check workspaces map for exact match
196
+ if (config?.workspaces?.[workspaceRoot]) {
197
+ return config.workspaces[workspaceRoot]
198
+ }
199
+ // 2. Fall back to top-level codebase (backward compat)
200
+ if (config?.codebase) {
201
+ return { codebase: config.codebase }
202
+ }
203
+ // 3. Default: codebase enabled, auto-detect everything
204
+ return { codebase: { enabled: true } }
205
+ }
206
+
207
+ export function setWorkspaceConfig(configPath: string, workspaceRoot: string, wsConfig: WorkspaceConfig): void {
208
+ let config = loadCollectionConfig(configPath)
209
+ if (!config) {
210
+ config = { collections: {} }
211
+ }
212
+ if (!config.workspaces) {
213
+ config.workspaces = {}
214
+ }
215
+ config.workspaces[workspaceRoot] = wsConfig
216
+ saveCollectionConfig(configPath, config)
217
+ }