nano-brain 2026.1.0

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 (79) hide show
  1. package/AGENTS_SNIPPET.md +36 -0
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +281 -0
  4. package/SKILL.md +153 -0
  5. package/bin/cli.js +18 -0
  6. package/index.html +929 -0
  7. package/nano-brain +4 -0
  8. package/opencode-mcp.json +9 -0
  9. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/.openspec.yaml +2 -0
  10. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/design.md +68 -0
  11. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/proposal.md +27 -0
  12. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/mcp-integration-testing/spec.md +50 -0
  13. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/mcp-server/spec.md +40 -0
  14. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/search-pipeline/spec.md +29 -0
  15. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/tasks.md +37 -0
  16. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/.openspec.yaml +2 -0
  17. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/design.md +111 -0
  18. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/proposal.md +30 -0
  19. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/mcp-server/spec.md +33 -0
  20. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/storage-limits/spec.md +90 -0
  21. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/workspace-scoping/spec.md +66 -0
  22. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/tasks.md +199 -0
  23. package/openspec/changes/codebase-indexing/.openspec.yaml +2 -0
  24. package/openspec/changes/codebase-indexing/design.md +169 -0
  25. package/openspec/changes/codebase-indexing/proposal.md +30 -0
  26. package/openspec/changes/codebase-indexing/specs/codebase-collection/spec.md +187 -0
  27. package/openspec/changes/codebase-indexing/specs/mcp-server/spec.md +36 -0
  28. package/openspec/changes/codebase-indexing/tasks.md +56 -0
  29. package/openspec/specs/mcp-integration-testing/spec.md +50 -0
  30. package/openspec/specs/mcp-server/spec.md +75 -0
  31. package/openspec/specs/search-pipeline/spec.md +29 -0
  32. package/openspec/specs/storage-limits/spec.md +94 -0
  33. package/openspec/specs/workspace-scoping/spec.md +70 -0
  34. package/package.json +34 -0
  35. package/site/build.js +66 -0
  36. package/site/partials/_api.html +83 -0
  37. package/site/partials/_compare.html +100 -0
  38. package/site/partials/_config.html +23 -0
  39. package/site/partials/_features.html +43 -0
  40. package/site/partials/_footer.html +6 -0
  41. package/site/partials/_hero.html +9 -0
  42. package/site/partials/_how-it-works.html +26 -0
  43. package/site/partials/_models.html +18 -0
  44. package/site/partials/_quick-start.html +15 -0
  45. package/site/partials/_stats.html +1 -0
  46. package/site/partials/_tech-stack.html +13 -0
  47. package/site/script.js +12 -0
  48. package/site/shell.html +44 -0
  49. package/site/styles.css +548 -0
  50. package/src/chunker.ts +427 -0
  51. package/src/codebase.ts +331 -0
  52. package/src/collections.ts +192 -0
  53. package/src/embeddings.ts +293 -0
  54. package/src/expansion.ts +79 -0
  55. package/src/harvester.ts +306 -0
  56. package/src/index.ts +503 -0
  57. package/src/reranker.ts +103 -0
  58. package/src/search.ts +294 -0
  59. package/src/server.ts +664 -0
  60. package/src/storage.ts +221 -0
  61. package/src/store.ts +623 -0
  62. package/src/types.ts +202 -0
  63. package/src/watcher.ts +384 -0
  64. package/test/chunker.test.ts +479 -0
  65. package/test/cli.test.ts +309 -0
  66. package/test/codebase-chunker.test.ts +446 -0
  67. package/test/codebase.test.ts +678 -0
  68. package/test/collections.test.ts +571 -0
  69. package/test/harvester.test.ts +636 -0
  70. package/test/integration.test.ts +150 -0
  71. package/test/llm.test.ts +322 -0
  72. package/test/search.test.ts +572 -0
  73. package/test/server.test.ts +541 -0
  74. package/test/storage.test.ts +302 -0
  75. package/test/store.test.ts +465 -0
  76. package/test/watcher.test.ts +656 -0
  77. package/test/workspace.test.ts +239 -0
  78. package/tsconfig.json +19 -0
  79. package/vitest.config.ts +16 -0
package/src/types.ts ADDED
@@ -0,0 +1,202 @@
1
+ export interface SearchResult {
2
+ id: string;
3
+ path: string;
4
+ collection: string;
5
+ title: string;
6
+ snippet: string;
7
+ score: number;
8
+ startLine: number;
9
+ endLine: number;
10
+ docid: string;
11
+ agent?: string;
12
+ }
13
+
14
+ export interface Document {
15
+ id: number;
16
+ collection: string;
17
+ path: string;
18
+ title: string;
19
+ hash: string;
20
+ agent?: string;
21
+ createdAt: string;
22
+ modifiedAt: string;
23
+ active: boolean;
24
+ projectHash?: string;
25
+ }
26
+
27
+ export interface MemoryChunk {
28
+ hash: string;
29
+ seq: number;
30
+ pos: number;
31
+ text: string;
32
+ startLine: number;
33
+ endLine: number;
34
+ }
35
+
36
+ export interface BreakPoint {
37
+ pos: number;
38
+ score: number;
39
+ type: string;
40
+ lineNo: number;
41
+ }
42
+
43
+ export interface CodeFenceRegion {
44
+ start: number;
45
+ end: number;
46
+ }
47
+
48
+ export interface Collection {
49
+ name: string;
50
+ path: string;
51
+ pattern: string;
52
+ context?: Record<string, string>;
53
+ }
54
+
55
+ export interface CollectionConfig {
56
+ globalContext?: string
57
+ collections: Record<string, {
58
+ path: string
59
+ pattern?: string
60
+ context?: Record<string, string>
61
+ update?: string
62
+ }>
63
+ storage?: {
64
+ maxSize?: string
65
+ retention?: string
66
+ minFreeDisk?: string
67
+ }
68
+ codebase?: CodebaseConfig
69
+ embedding?: EmbeddingConfig
70
+ }
71
+
72
+ export interface CodebaseConfig {
73
+ enabled: boolean
74
+ root?: string
75
+ exclude?: string[]
76
+ extensions?: string[]
77
+ maxFileSize?: string
78
+ maxSize?: string
79
+ batchSize?: number
80
+ }
81
+
82
+ export interface EmbeddingConfig {
83
+ provider?: 'ollama' | 'local'
84
+ url?: string
85
+ model?: string
86
+ }
87
+
88
+ export interface CodebaseIndexResult {
89
+ filesScanned: number
90
+ filesIndexed: number
91
+ filesSkippedUnchanged: number
92
+ filesSkippedTooLarge: number
93
+ filesSkippedBudget: number
94
+ chunksCreated: number
95
+ storageUsedBytes: number
96
+ maxSizeBytes: number
97
+ }
98
+
99
+ export interface StorageConfig {
100
+ maxSize: number;
101
+ retention: number;
102
+ minFreeDisk: number;
103
+ }
104
+
105
+ export interface EmbeddingResult {
106
+ embedding: number[];
107
+ model: string;
108
+ dimensions: number;
109
+ }
110
+
111
+ export interface RerankResult {
112
+ results: Array<{
113
+ file: string;
114
+ score: number;
115
+ index: number;
116
+ }>;
117
+ model: string;
118
+ }
119
+
120
+ export interface RerankDocument {
121
+ text: string;
122
+ file: string;
123
+ index: number;
124
+ }
125
+
126
+ export interface HarvestedSession {
127
+ sessionId: string;
128
+ slug: string;
129
+ title: string;
130
+ agent: string;
131
+ date: string;
132
+ project: string;
133
+ projectHash: string;
134
+ messages: Array<{
135
+ role: 'user' | 'assistant';
136
+ agent?: string;
137
+ text: string;
138
+ }>;
139
+ }
140
+
141
+ export interface IndexHealth {
142
+ documentCount: number
143
+ chunkCount: number
144
+ pendingEmbeddings: number
145
+ collections: Array<{
146
+ name: string
147
+ documentCount: number
148
+ path: string
149
+ }>
150
+ databaseSize: number
151
+ modelStatus: {
152
+ embedding: string
153
+ reranker: string
154
+ expander: string
155
+ }
156
+ workspaceStats?: Array<{ projectHash: string; count: number }>
157
+ codebase?: {
158
+ enabled: boolean
159
+ documents: number
160
+ chunks: number
161
+ extensions: string[]
162
+ excludeCount: number
163
+ storageUsed: number
164
+ maxSize: number
165
+ }
166
+ }
167
+
168
+ export interface Store {
169
+ close(): void;
170
+
171
+ insertDocument(doc: Omit<Document, 'id'>): number;
172
+ findDocument(pathOrDocid: string): Document | null;
173
+ getDocumentBody(hash: string, fromLine?: number, maxLines?: number): string | null;
174
+ deactivateDocument(collection: string, path: string): void;
175
+ bulkDeactivateExcept(collection: string, activePaths: string[]): number;
176
+
177
+ insertContent(hash: string, body: string): void;
178
+
179
+ insertEmbedding(hash: string, seq: number, pos: number, embedding: number[], model: string): void;
180
+ ensureVecTable(dimensions: number): void;
181
+
182
+ searchFTS(query: string, limit?: number, collection?: string, projectHash?: string): SearchResult[];
183
+ searchVec(query: string, embedding: number[], limit?: number, collection?: string, projectHash?: string): SearchResult[];
184
+
185
+ getCachedResult(hash: string): string | null;
186
+ setCachedResult(hash: string, result: string): void;
187
+
188
+ getIndexHealth(): IndexHealth;
189
+ getHashesNeedingEmbedding(projectHash?: string): Array<{ hash: string; body: string; path: string }>;
190
+ getNextHashNeedingEmbedding(projectHash?: string): { hash: string; body: string; path: string } | null;
191
+ getWorkspaceStats(): Array<{ projectHash: string; count: number }>;
192
+
193
+ deleteDocumentsByPath(filePath: string): number;
194
+ cleanOrphanedEmbeddings(): number;
195
+ getCollectionStorageSize(collection: string): number;
196
+
197
+ modelStatus: {
198
+ embedding: string;
199
+ reranker: string;
200
+ expander: string;
201
+ };
202
+ }
package/src/watcher.ts ADDED
@@ -0,0 +1,384 @@
1
+ import { watch, type FSWatcher } from 'chokidar';
2
+ import type { Store, Collection, StorageConfig, CodebaseConfig } from './types.js'
3
+ import { scanCollectionFiles } from './collections.js';
4
+ import { indexDocument, computeHash } from './store.js';
5
+ import { harvestSessions } from './harvester.js';
6
+ import { checkDiskSpace, evictExpiredSessions, evictBySize } from './storage.js';
7
+ import { indexCodebase, mergeExcludePatterns, resolveExtensions, embedPendingCodebase } from './codebase.js'
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import * as os from 'os';
11
+
12
+ export interface WatcherOptions {
13
+ store: Store
14
+ collections: Collection[]
15
+ embedder?: { embed(text: string): Promise<{ embedding: number[] }> } | null
16
+ onUpdate?: (path: string) => void
17
+ debounceMs?: number
18
+ pollIntervalMs?: number
19
+ sessionPollMs?: number
20
+ sessionStorageDir?: string
21
+ outputDir?: string
22
+ storageConfig?: StorageConfig
23
+ dbPath?: string
24
+ codebaseConfig?: CodebaseConfig
25
+ workspaceRoot?: string
26
+ projectHash?: string
27
+ }
28
+
29
+ export interface Watcher {
30
+ stop(): void;
31
+ isDirty(): boolean;
32
+ triggerReindex(): Promise<void>;
33
+ getStats(): WatcherStats;
34
+ }
35
+
36
+ export interface WatcherStats {
37
+ filesWatched: number;
38
+ lastReindexAt: number | null;
39
+ pendingChanges: number;
40
+ isReindexing: boolean;
41
+ }
42
+
43
+ export function startWatcher(options: WatcherOptions): Watcher {
44
+ const {
45
+ store,
46
+ collections,
47
+ embedder,
48
+ onUpdate,
49
+ debounceMs = 2000,
50
+ pollIntervalMs = 300000,
51
+ sessionPollMs = 120000,
52
+ sessionStorageDir = path.join(os.homedir(), '.local/share/opencode/storage'),
53
+ outputDir = path.join(os.homedir(), '.nano-brain/sessions'),
54
+ storageConfig,
55
+ dbPath,
56
+ codebaseConfig,
57
+ workspaceRoot = process.cwd(),
58
+ projectHash = 'global',
59
+ } = options
60
+
61
+ const codebaseExtensions = codebaseConfig?.enabled
62
+ ? new Set(resolveExtensions(codebaseConfig, workspaceRoot))
63
+ : new Set<string>()
64
+
65
+ let dirty = false;
66
+ const pendingPaths = new Set<string>();
67
+ let lastReindexAt: number | null = null;
68
+ let isReindexing = false;
69
+ let stopped = false;
70
+ let debounceTimer: NodeJS.Timeout | null = null;
71
+ let pollInterval: NodeJS.Timeout | null = null;
72
+ let sessionPollInterval: NodeJS.Timeout | null = null;
73
+ let watcher: FSWatcher | null = null;
74
+ let harvestCycleCount = 0;
75
+ const watchedPaths = new Set<string>();
76
+ let embeddingInterval: NodeJS.Timeout | null = null;
77
+ let isEmbedding = false;
78
+
79
+ const handleFileChange = (filePath: string) => {
80
+ if (stopped) return
81
+
82
+ dirty = true
83
+ pendingPaths.add(filePath)
84
+ if (debounceTimer) {
85
+ clearTimeout(debounceTimer)
86
+ }
87
+ debounceTimer = setTimeout(() => {
88
+ if (onUpdate) {
89
+ for (const p of pendingPaths) {
90
+ onUpdate(p)
91
+ }
92
+ }
93
+ }, debounceMs)
94
+ }
95
+
96
+ const isCodebaseFile = (filePath: string): boolean => {
97
+ if (!codebaseConfig?.enabled) return false
98
+ const ext = path.extname(filePath).toLowerCase()
99
+ return codebaseExtensions.has(ext)
100
+ }
101
+
102
+ const triggerReindex = async (): Promise<void> => {
103
+ if (isReindexing || stopped) return
104
+
105
+ isReindexing = true
106
+
107
+ try {
108
+ for (const collection of collections) {
109
+ const files = await scanCollectionFiles(collection)
110
+ const activePaths: string[] = []
111
+ for (const filePath of files) {
112
+ if (!fs.existsSync(filePath)) continue
113
+
114
+ const content = fs.readFileSync(filePath, 'utf-8')
115
+ const hash = computeHash(content)
116
+
117
+ const existingDoc = store.findDocument(filePath)
118
+ if (!existingDoc || existingDoc.hash !== hash) {
119
+ const title = extractTitle(content)
120
+ indexDocument(store, collection.name, filePath, content, title, projectHash)
121
+ }
122
+
123
+ activePaths.push(filePath)
124
+ }
125
+
126
+ store.bulkDeactivateExcept(collection.name, activePaths)
127
+ }
128
+
129
+ if (codebaseConfig?.enabled) {
130
+ await indexCodebase(store, workspaceRoot, codebaseConfig, projectHash, embedder)
131
+ }
132
+ if (embedder) {
133
+ await embedPendingCodebase(store, embedder, 10, projectHash)
134
+ }
135
+
136
+ dirty = false
137
+ pendingPaths.clear()
138
+ lastReindexAt = Date.now()
139
+ } finally {
140
+ isReindexing = false
141
+ }
142
+ }
143
+
144
+ const startupIntegrityCheck = async () => {
145
+ const health = store.getIndexHealth();
146
+ let mismatches = 0;
147
+
148
+ for (const collectionInfo of health.collections) {
149
+ const collection = collections.find(c => c.name === collectionInfo.name);
150
+ if (!collection) continue;
151
+
152
+ const files = await scanCollectionFiles(collection);
153
+
154
+ for (const filePath of files) {
155
+ if (!fs.existsSync(filePath)) continue;
156
+
157
+ const existingDoc = store.findDocument(filePath);
158
+ if (!existingDoc) continue;
159
+
160
+ const content = fs.readFileSync(filePath, 'utf-8');
161
+ const hash = computeHash(content);
162
+
163
+ if (existingDoc.hash !== hash) {
164
+ mismatches++;
165
+ dirty = true;
166
+ pendingPaths.add(filePath);
167
+ }
168
+ }
169
+ }
170
+
171
+ if (mismatches > 0) {
172
+ console.log(`Integrity check: ${mismatches} file(s) need re-indexing`);
173
+ }
174
+ };
175
+
176
+ const setupWatcher = () => {
177
+ const pathsToWatch: string[] = []
178
+ const ignoredPatterns: (string | RegExp)[] = [/(^|[\/])\../]
179
+ for (const collection of collections) {
180
+ const expandedPath = collection.path.replace(/^~/, os.homedir())
181
+ if (fs.existsSync(expandedPath)) {
182
+ pathsToWatch.push(expandedPath)
183
+ watchedPaths.add(expandedPath)
184
+ }
185
+ }
186
+ if (codebaseConfig?.enabled && fs.existsSync(workspaceRoot)) {
187
+ pathsToWatch.push(workspaceRoot)
188
+ watchedPaths.add(workspaceRoot)
189
+ const excludePatterns = mergeExcludePatterns(codebaseConfig, workspaceRoot)
190
+ for (const pattern of excludePatterns) {
191
+ // Convert glob patterns to regex for chokidar directory-level matching
192
+ // e.g. 'node_modules' -> /[\/]node_modules([\/]|$)/
193
+ // e.g. '*.min.js' -> /\.min\.js$/
194
+ if (pattern.startsWith('*')) {
195
+ const escaped = pattern.slice(1).replace(/\./g, '\\.').replace(/\*/g, '.*')
196
+ ignoredPatterns.push(new RegExp(`${escaped}$`))
197
+ } else {
198
+ const escaped = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*')
199
+ ignoredPatterns.push(new RegExp(`[\\/]${escaped}([\\/]|$)`))
200
+ }
201
+ }
202
+ }
203
+ if (pathsToWatch.length === 0) return
204
+ watcher = watch(pathsToWatch, {
205
+ ignored: ignoredPatterns,
206
+ persistent: true,
207
+ ignoreInitial: true,
208
+ awaitWriteFinish: {
209
+ stabilityThreshold: 100,
210
+ pollInterval: 100,
211
+ },
212
+ })
213
+ watcher.on('error', (err: unknown) => {
214
+ console.error(`[watcher] Error: ${err instanceof Error ? err.message : String(err)}`)
215
+ })
216
+ watcher.on('add', (filePath) => {
217
+ if (filePath.endsWith('.md') || isCodebaseFile(filePath)) {
218
+ handleFileChange(filePath)
219
+ }
220
+ })
221
+ watcher.on('change', (filePath) => {
222
+ if (filePath.endsWith('.md') || isCodebaseFile(filePath)) {
223
+ handleFileChange(filePath)
224
+ }
225
+ })
226
+ watcher.on('unlink', (filePath) => {
227
+ if (filePath.endsWith('.md') || isCodebaseFile(filePath)) {
228
+ handleFileChange(filePath)
229
+ }
230
+ })
231
+ }
232
+
233
+ const setupPolling = () => {
234
+ pollInterval = setInterval(async () => {
235
+ if (dirty && !isReindexing) {
236
+ await triggerReindex();
237
+ }
238
+ }, pollIntervalMs);
239
+
240
+ sessionPollInterval = setInterval(async () => {
241
+ if (stopped) return;
242
+ if (storageConfig) {
243
+ const diskCheck = checkDiskSpace(outputDir, storageConfig.minFreeDisk);
244
+ if (!diskCheck.ok) {
245
+ console.warn(`[storage] Disk space critically low (<${Math.round(storageConfig.minFreeDisk / 1024 / 1024)}MB free), skipping writes`);
246
+ return;
247
+ }
248
+ }
249
+
250
+ try {
251
+ const sessions = await harvestSessions({
252
+ sessionDir: sessionStorageDir,
253
+ outputDir,
254
+ });
255
+
256
+ if (sessions.length > 0) {
257
+ await triggerReindex();
258
+ }
259
+
260
+ if (storageConfig && dbPath) {
261
+ const expiredCount = evictExpiredSessions(outputDir, storageConfig.retention, store);
262
+ if (expiredCount > 0) {
263
+ console.log(`[storage] Evicted ${expiredCount} expired session(s)`);
264
+ }
265
+
266
+ const sizeEvictedCount = evictBySize(outputDir, dbPath, storageConfig.maxSize, store);
267
+ if (sizeEvictedCount > 0) {
268
+ console.log(`[storage] Evicted ${sizeEvictedCount} session(s) due to size limit`);
269
+ }
270
+ }
271
+
272
+ harvestCycleCount++;
273
+ if (harvestCycleCount % 10 === 0) {
274
+ const orphansDeleted = store.cleanOrphanedEmbeddings();
275
+ if (orphansDeleted > 0) {
276
+ console.log(`[storage] Cleaned ${orphansDeleted} orphaned embedding(s)`);
277
+ }
278
+ }
279
+ } catch (err) {
280
+ console.warn('Session harvest failed:', err);
281
+ }
282
+ }, sessionPollMs);
283
+
284
+ if (embedder) {
285
+ embeddingInterval = setInterval(async () => {
286
+ if (stopped || isEmbedding) return;
287
+ isEmbedding = true;
288
+ try {
289
+ const count = await embedPendingCodebase(store, embedder, 10, projectHash);
290
+ if (count > 0) {
291
+ console.log(`[embed] Embedded ${count} document(s)`);
292
+ }
293
+ } catch (err) {
294
+ console.warn('[embed] Embedding cycle failed:', err);
295
+ } finally {
296
+ isEmbedding = false;
297
+ }
298
+ }, 60000);
299
+ }
300
+ };
301
+
302
+ setupWatcher();
303
+ setupPolling();
304
+ startupIntegrityCheck().catch(err => {
305
+ console.warn('Startup integrity check failed:', err);
306
+ });
307
+
308
+ if (embedder) {
309
+ setTimeout(async () => {
310
+ isEmbedding = true;
311
+ try {
312
+ const count = await embedPendingCodebase(store, embedder, 10, projectHash);
313
+ if (count > 0) {
314
+ console.log(`[embed] Initial embedding: ${count} document(s)`);
315
+ }
316
+ } catch (err) {
317
+ console.warn('[embed] Initial embedding failed:', err);
318
+ } finally {
319
+ isEmbedding = false;
320
+ }
321
+ }, 5000);
322
+ }
323
+
324
+ return {
325
+ stop() {
326
+ stopped = true;
327
+
328
+ if (debounceTimer) {
329
+ clearTimeout(debounceTimer);
330
+ debounceTimer = null;
331
+ }
332
+
333
+ if (pollInterval) {
334
+ clearInterval(pollInterval);
335
+ pollInterval = null;
336
+ }
337
+
338
+ if (sessionPollInterval) {
339
+ clearInterval(sessionPollInterval);
340
+ sessionPollInterval = null;
341
+ }
342
+
343
+ if (embeddingInterval) {
344
+ clearInterval(embeddingInterval);
345
+ embeddingInterval = null;
346
+ }
347
+
348
+ if (watcher) {
349
+ watcher.close();
350
+ watcher = null;
351
+ }
352
+ },
353
+
354
+ isDirty() {
355
+ return dirty;
356
+ },
357
+
358
+ async triggerReindex() {
359
+ await triggerReindex();
360
+ },
361
+
362
+ getStats(): WatcherStats {
363
+ return {
364
+ filesWatched: watchedPaths.size,
365
+ lastReindexAt,
366
+ pendingChanges: pendingPaths.size,
367
+ isReindexing,
368
+ };
369
+ },
370
+ };
371
+ }
372
+
373
+ function extractTitle(content: string): string {
374
+ const lines = content.split('\n');
375
+
376
+ for (const line of lines) {
377
+ const trimmed = line.trim();
378
+ if (trimmed.startsWith('# ')) {
379
+ return trimmed.substring(2).trim();
380
+ }
381
+ }
382
+
383
+ return 'Untitled';
384
+ }