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