@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/server.ts ADDED
@@ -0,0 +1,876 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { z } from 'zod';
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import * as os from 'os';
7
+ import * as crypto from 'crypto';
8
+ import * as http from 'http';
9
+ import type { Store, SearchResult, IndexHealth, Collection, StorageConfig, CodebaseConfig, EmbeddingConfig, WatcherConfig } from './types.js'
10
+ import type { SearchProviders } from './search.js';
11
+ import { hybridSearch } from './search.js';
12
+ import { createStore, extractProjectHashFromPath } from './store.js';
13
+ import { loadCollectionConfig, getCollections, scanCollectionFiles, getWorkspaceConfig } from './collections.js';
14
+ import { createEmbeddingProvider, detectOllamaUrl, checkOllamaHealth } from './embeddings.js';
15
+ import { createReranker } from './reranker.js';
16
+ import { startWatcher } from './watcher.js';
17
+ import { parseStorageConfig } from './storage.js';
18
+ import { indexCodebase, getCodebaseStats, embedPendingCodebase } from './codebase.js'
19
+
20
+ export interface ServerOptions {
21
+ dbPath: string;
22
+ configPath?: string;
23
+ httpPort?: number;
24
+ daemon?: boolean;
25
+ }
26
+
27
+ export interface ServerDeps {
28
+ store: Store
29
+ providers: SearchProviders
30
+ collections: Collection[]
31
+ configPath: string
32
+ outputDir: string
33
+ storageConfig?: StorageConfig
34
+ currentProjectHash: string
35
+ codebaseConfig?: CodebaseConfig
36
+ workspaceRoot: string
37
+ embeddingConfig?: EmbeddingConfig
38
+ }
39
+
40
+ export function formatSearchResults(results: SearchResult[]): string {
41
+ if (results.length === 0) {
42
+ return 'No results found.';
43
+ }
44
+
45
+ return results.map((r, i) =>
46
+ `### ${i + 1}. ${r.title} (${r.docid})\n` +
47
+ `**Path:** ${r.path} | **Score:** ${r.score.toFixed(3)} | **Lines:** ${r.startLine}-${r.endLine}\n\n` +
48
+ `${r.snippet}\n`
49
+ ).join('\n---\n\n');
50
+ }
51
+
52
+ export function formatStatus(
53
+ health: IndexHealth,
54
+ codebaseStats?: { enabled: boolean; documents: number; chunks: number; extensions: string[]; excludeCount: number; storageUsed: number; maxSize: number },
55
+ embeddingHealth?: { provider: string; url: string; model: string; reachable: boolean; models?: string[]; error?: string }
56
+ ): string {
57
+ const lines = [
58
+ `📊 **Memory Index Status**`,
59
+ `Documents: ${health.documentCount} | Embedded: ${health.embeddedCount} | Pending embeddings: ${health.pendingEmbeddings}`,
60
+ `Database size: ${(health.databaseSize / 1024 / 1024).toFixed(1)} MB`,
61
+ ``,
62
+ `**Collections:**`,
63
+ ...health.collections.map(c => ` - ${c.name}: ${c.documentCount} docs (${c.path})`),
64
+ ``,
65
+ `**Models:**`,
66
+ ` - Embedding: ${health.modelStatus.embedding}`,
67
+ ` - Reranker: ${health.modelStatus.reranker}`,
68
+ ` - Expander: ${health.modelStatus.expander}`,
69
+ ]
70
+ if (embeddingHealth) {
71
+ lines.push(``)
72
+ lines.push(`**Embedding Server:**`)
73
+ lines.push(` - Provider: ${embeddingHealth.provider}`)
74
+ lines.push(` - URL: ${embeddingHealth.url}`)
75
+ lines.push(` - Model: ${embeddingHealth.model}`)
76
+ if (embeddingHealth.reachable) {
77
+ const hasModel = embeddingHealth.models?.some(m => m.startsWith(embeddingHealth.model))
78
+ lines.push(` - Status: ✅ connected`)
79
+ lines.push(` - Model available: ${hasModel ? '✅ yes' : '❌ not found — run: ollama pull ' + embeddingHealth.model}`)
80
+ } else {
81
+ lines.push(` - Status: ❌ unreachable (${embeddingHealth.error})`)
82
+ lines.push(` - Fallback: local GGUF (node-llama-cpp)`)
83
+ }
84
+ }
85
+ if (codebaseStats) {
86
+ const usedMB = (codebaseStats.storageUsed / 1024 / 1024).toFixed(1)
87
+ const maxMB = (codebaseStats.maxSize / 1024 / 1024).toFixed(0)
88
+ lines.push(``)
89
+ lines.push(`**Codebase:**`)
90
+ lines.push(` - Enabled: ${codebaseStats.enabled}`)
91
+ lines.push(` - Documents: ${codebaseStats.documents}`)
92
+ lines.push(` - Storage: ${usedMB}MB / ${maxMB}MB`)
93
+ lines.push(` - Extensions: ${codebaseStats.extensions.join(', ')}`)
94
+ lines.push(` - Exclude patterns: ${codebaseStats.excludeCount}`)
95
+ }
96
+ if (health.workspaceStats && health.workspaceStats.length > 0) {
97
+ lines.push(``)
98
+ lines.push(`**Workspaces:**`)
99
+ for (const ws of health.workspaceStats) {
100
+ lines.push(` - ${ws.projectHash}: ${ws.count} docs`)
101
+ }
102
+ }
103
+ return lines.join('\n')
104
+ }
105
+
106
+ export function createMcpServer(deps: ServerDeps): McpServer {
107
+ const { store, providers, collections, configPath, outputDir, currentProjectHash, workspaceRoot } = deps;
108
+
109
+ const server = new McpServer(
110
+ {
111
+ name: 'nano-brain',
112
+ version: '0.1.0',
113
+ },
114
+ {
115
+ capabilities: {
116
+ tools: {},
117
+ },
118
+ }
119
+ );
120
+
121
+ server.tool(
122
+ 'memory_search',
123
+ 'BM25 full-text keyword search across indexed documents',
124
+ {
125
+ query: z.string().describe('Search query'),
126
+ limit: z.number().optional().default(10).describe('Max results'),
127
+ collection: z.string().optional().describe('Filter by collection name'),
128
+ workspace: z.string().optional().describe('Filter by workspace hash. Omit for current workspace, "all" for cross-workspace search'),
129
+ },
130
+ async ({ query, limit, collection, workspace }) => {
131
+ const effectiveWorkspace = workspace === 'all' ? 'all' : (workspace || currentProjectHash);
132
+ const results = store.searchFTS(query, limit, collection, effectiveWorkspace);
133
+ return {
134
+ content: [
135
+ {
136
+ type: 'text',
137
+ text: formatSearchResults(results),
138
+ },
139
+ ],
140
+ };
141
+ }
142
+ );
143
+
144
+ server.tool(
145
+ 'memory_vsearch',
146
+ 'Semantic vector search using embeddings',
147
+ {
148
+ query: z.string().describe('Search query'),
149
+ limit: z.number().optional().default(10).describe('Max results'),
150
+ collection: z.string().optional().describe('Filter by collection name'),
151
+ workspace: z.string().optional().describe('Filter by workspace hash. Omit for current workspace, "all" for cross-workspace search'),
152
+ },
153
+ async ({ query, limit, collection, workspace }) => {
154
+ const effectiveWorkspace = workspace === 'all' ? 'all' : (workspace || currentProjectHash);
155
+ if (providers.embedder) {
156
+ try {
157
+ const { embedding } = await providers.embedder.embed(query);
158
+ const results = store.searchVec(query, embedding, limit, collection, effectiveWorkspace);
159
+ return {
160
+ content: [
161
+ {
162
+ type: 'text',
163
+ text: formatSearchResults(results),
164
+ },
165
+ ],
166
+ };
167
+ } catch (err) {
168
+ const fallbackResults = store.searchFTS(query, limit, collection, effectiveWorkspace);
169
+ return {
170
+ content: [
171
+ {
172
+ type: 'text',
173
+ text: `⚠️ Vector search failed, falling back to FTS: ${err instanceof Error ? err.message : String(err)}\n\n${formatSearchResults(fallbackResults)}`,
174
+ },
175
+ ],
176
+ };
177
+ }
178
+ } else {
179
+ const fallbackResults = store.searchFTS(query, limit, collection, effectiveWorkspace);
180
+ return {
181
+ content: [
182
+ {
183
+ type: 'text',
184
+ text: `⚠️ Embedder not available, falling back to FTS\n\n${formatSearchResults(fallbackResults)}`,
185
+ },
186
+ ],
187
+ };
188
+ }
189
+ }
190
+ );
191
+
192
+ server.tool(
193
+ 'memory_query',
194
+ 'Full hybrid search with query expansion, RRF fusion, and LLM reranking',
195
+ {
196
+ query: z.string().describe('Search query'),
197
+ limit: z.number().optional().default(10).describe('Max results'),
198
+ collection: z.string().optional().describe('Filter by collection name'),
199
+ minScore: z.number().optional().default(0).describe('Minimum score threshold'),
200
+ workspace: z.string().optional().describe('Filter by workspace hash. Omit for current workspace, "all" for cross-workspace search'),
201
+ },
202
+ async ({ query, limit, collection, minScore, workspace }) => {
203
+ const effectiveWorkspace = workspace === 'all' ? 'all' : (workspace || currentProjectHash);
204
+ const results = await hybridSearch(
205
+ store,
206
+ { query, limit, collection, minScore, projectHash: effectiveWorkspace },
207
+ providers
208
+ );
209
+
210
+ return {
211
+ content: [
212
+ {
213
+ type: 'text',
214
+ text: formatSearchResults(results),
215
+ },
216
+ ],
217
+ };
218
+ }
219
+ );
220
+
221
+ server.tool(
222
+ 'memory_get',
223
+ 'Retrieve a document by path or docid (#abc123)',
224
+ {
225
+ id: z.string().describe('Document path or docid (6-char hash prefix with # prefix)'),
226
+ fromLine: z.number().optional().describe('Start line number'),
227
+ maxLines: z.number().optional().describe('Maximum number of lines to return'),
228
+ },
229
+ async ({ id, fromLine, maxLines }) => {
230
+ const docid = id.startsWith('#') ? id.slice(1) : id;
231
+ const doc = store.findDocument(docid);
232
+
233
+ if (!doc) {
234
+ return {
235
+ content: [
236
+ {
237
+ type: 'text',
238
+ text: `Document not found: ${id}`,
239
+ },
240
+ ],
241
+ isError: true,
242
+ };
243
+ }
244
+
245
+ const body = store.getDocumentBody(doc.hash, fromLine, maxLines);
246
+ return {
247
+ content: [
248
+ {
249
+ type: 'text',
250
+ text: body ?? '',
251
+ },
252
+ ],
253
+ };
254
+ }
255
+ );
256
+
257
+ server.tool(
258
+ 'memory_multi_get',
259
+ 'Batch retrieve documents by glob pattern or comma-separated list',
260
+ {
261
+ pattern: z.string().describe('Glob pattern or comma-separated docids/paths'),
262
+ maxBytes: z.number().optional().default(50000).describe('Maximum total bytes to return'),
263
+ },
264
+ async ({ pattern, maxBytes }) => {
265
+ const ids = pattern.split(',').map(s => s.trim());
266
+
267
+ let totalBytes = 0;
268
+ const results: string[] = [];
269
+
270
+ for (const id of ids) {
271
+ const docid = id.startsWith('#') ? id.slice(1) : id;
272
+ const doc = store.findDocument(docid);
273
+
274
+ if (!doc) {
275
+ results.push(`### Document not found: ${id}\n`);
276
+ continue;
277
+ }
278
+
279
+ const body = store.getDocumentBody(doc.hash);
280
+ if (!body) {
281
+ results.push(`### Document body not found: ${id}\n`);
282
+ continue;
283
+ }
284
+
285
+ const docText = `### ${doc.title} (${doc.path})\n\n${body}\n\n---\n\n`;
286
+
287
+ if (totalBytes + docText.length > maxBytes) {
288
+ results.push(`\n⚠️ Reached maxBytes limit (${maxBytes}), truncating results.\n`);
289
+ break;
290
+ }
291
+
292
+ results.push(docText);
293
+ totalBytes += docText.length;
294
+ }
295
+
296
+ return {
297
+ content: [
298
+ {
299
+ type: 'text',
300
+ text: results.join(''),
301
+ },
302
+ ],
303
+ };
304
+ }
305
+ );
306
+
307
+ server.tool(
308
+ 'memory_write',
309
+ 'Write content to daily log with workspace context',
310
+ {
311
+ content: z.string().describe('Content to write'),
312
+ },
313
+ async ({ content }) => {
314
+ const date = new Date().toISOString().split('T')[0];
315
+ const memoryDir = path.join(outputDir, 'memory');
316
+ fs.mkdirSync(memoryDir, { recursive: true });
317
+ const targetPath = path.join(memoryDir, `${date}.md`);
318
+ const timestamp = new Date().toISOString();
319
+ const workspaceName = path.basename(workspaceRoot);
320
+ const entry = `\n## ${timestamp}\n\n**Workspace:** ${workspaceName} (${currentProjectHash})\n\n${content}\n`;
321
+
322
+ fs.appendFileSync(targetPath, entry, 'utf-8');
323
+
324
+ return {
325
+ content: [
326
+ {
327
+ type: 'text',
328
+ text: `✅ Written to ${targetPath} [${workspaceName}]`,
329
+ },
330
+ ],
331
+ };
332
+ }
333
+ );
334
+
335
+ server.tool(
336
+ 'memory_set',
337
+ 'Set or update a keyed memory entry (overwrites existing)',
338
+ {
339
+ key: z.string().describe('Unique key for this memory entry'),
340
+ content: z.string().describe('Content to store'),
341
+ },
342
+ async ({ key, content }) => {
343
+ const slug = key.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
344
+ const keysDir = path.join(outputDir, 'memory', 'keys');
345
+ fs.mkdirSync(keysDir, { recursive: true });
346
+ const targetPath = path.join(keysDir, `${slug}.md`);
347
+ const timestamp = new Date().toISOString();
348
+ const workspaceName = path.basename(workspaceRoot);
349
+ const fileContent = `# ${key}\n\nUpdated: ${timestamp}\nWorkspace: ${workspaceName} (${currentProjectHash})\n\n${content}\n`;
350
+
351
+ fs.writeFileSync(targetPath, fileContent, 'utf-8');
352
+
353
+ return {
354
+ content: [
355
+ {
356
+ type: 'text',
357
+ text: `✅ Set keyed memory '${key}' at ${targetPath}`,
358
+ },
359
+ ],
360
+ };
361
+ }
362
+ );
363
+
364
+ server.tool(
365
+ 'memory_delete',
366
+ 'Delete a keyed memory entry',
367
+ {
368
+ key: z.string().describe('Key of the memory entry to delete'),
369
+ },
370
+ async ({ key }) => {
371
+ const slug = key.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
372
+ const keysDir = path.join(outputDir, 'memory', 'keys');
373
+ const targetPath = path.join(keysDir, `${slug}.md`);
374
+
375
+ if (!fs.existsSync(targetPath)) {
376
+ return {
377
+ content: [
378
+ {
379
+ type: 'text',
380
+ text: `ℹ️ Keyed memory '${key}' does not exist`,
381
+ },
382
+ ],
383
+ };
384
+ }
385
+
386
+ fs.unlinkSync(targetPath);
387
+ store.deactivateDocument('memory', targetPath);
388
+
389
+ return {
390
+ content: [
391
+ {
392
+ type: 'text',
393
+ text: `✅ Deleted keyed memory '${key}'`,
394
+ },
395
+ ],
396
+ };
397
+ }
398
+ );
399
+
400
+ server.tool(
401
+ 'memory_keys',
402
+ 'List all keyed memory entries',
403
+ {},
404
+ async () => {
405
+ const keysDir = path.join(outputDir, 'memory', 'keys');
406
+
407
+ if (!fs.existsSync(keysDir)) {
408
+ return {
409
+ content: [
410
+ {
411
+ type: 'text',
412
+ text: 'No keyed memories found (directory does not exist)',
413
+ },
414
+ ],
415
+ };
416
+ }
417
+
418
+ const files = fs.readdirSync(keysDir).filter((f: string) => f.endsWith('.md'));
419
+
420
+ if (files.length === 0) {
421
+ return {
422
+ content: [
423
+ {
424
+ type: 'text',
425
+ text: 'No keyed memories found',
426
+ },
427
+ ],
428
+ };
429
+ }
430
+
431
+ const entries = files.map((f: string) => {
432
+ const filePath = path.join(keysDir, f);
433
+ const stats = fs.statSync(filePath);
434
+ const name = f.replace(/\.md$/, '');
435
+ return `- ${name} (modified: ${stats.mtime.toISOString()})`;
436
+ });
437
+
438
+ return {
439
+ content: [
440
+ {
441
+ type: 'text',
442
+ text: `**Keyed Memories (${files.length}):**\n${entries.join('\n')}`,
443
+ },
444
+ ],
445
+ };
446
+ }
447
+ );
448
+
449
+ server.tool(
450
+ 'memory_status',
451
+ 'Show index health, collection info, and model status',
452
+ {
453
+ root: z.string().optional().describe('Workspace root path for codebase stats'),
454
+ },
455
+ async ({ root }) => {
456
+ const health = store.getIndexHealth()
457
+ const effectiveRoot = root || deps.workspaceRoot
458
+ const codebaseStats = getCodebaseStats(store, deps.codebaseConfig, effectiveRoot)
459
+ // Probe embedding server connectivity
460
+ const embeddingConfig = deps.embeddingConfig
461
+ const ollamaUrl = embeddingConfig?.url || detectOllamaUrl()
462
+ const ollamaModel = embeddingConfig?.model || 'nomic-embed-text'
463
+ const provider = embeddingConfig?.provider || 'ollama'
464
+ let embeddingHealth: { provider: string; url: string; model: string; reachable: boolean; models?: string[]; error?: string } | undefined
465
+
466
+ if (provider !== 'local') {
467
+ const ollamaHealth = await checkOllamaHealth(ollamaUrl)
468
+ embeddingHealth = { provider, url: ollamaUrl, model: ollamaModel, ...ollamaHealth }
469
+ } else {
470
+ embeddingHealth = { provider, url: 'n/a', model: ollamaModel, reachable: true }
471
+ }
472
+ return {
473
+ content: [
474
+ {
475
+ type: 'text',
476
+ text: formatStatus(health, codebaseStats, embeddingHealth),
477
+ },
478
+ ],
479
+ }
480
+ }
481
+ )
482
+ server.tool(
483
+ 'memory_index_codebase',
484
+ 'Index codebase files in the current workspace',
485
+ {
486
+ root: z.string().optional().describe('Workspace root path to index. Defaults to configured root or server cwd.'),
487
+ },
488
+ async ({ root }) => {
489
+ if (!deps.codebaseConfig?.enabled) {
490
+ return {
491
+ content: [
492
+ {
493
+ type: 'text',
494
+ text: '❌ Codebase indexing is not enabled. Add `codebase: { enabled: true }` to your collections.yaml',
495
+ },
496
+ ],
497
+ isError: true,
498
+ }
499
+ }
500
+
501
+ ;(async () => {
502
+ try {
503
+ const effectiveRoot = root || deps.workspaceRoot
504
+ const effectiveProjectHash = crypto.createHash('sha256').update(effectiveRoot).digest('hex').substring(0, 12)
505
+ const result = await indexCodebase(
506
+ store,
507
+ effectiveRoot,
508
+ deps.codebaseConfig!,
509
+ effectiveProjectHash,
510
+ providers.embedder
511
+ )
512
+ console.error(`[codebase] Indexing complete: ${result.filesScanned} scanned, ${result.filesIndexed} indexed, ${result.filesSkippedUnchanged} unchanged`)
513
+ if (providers.embedder) {
514
+ const embedded = await embedPendingCodebase(store, providers.embedder, 10, effectiveProjectHash)
515
+ console.error(`[codebase] Embedding complete: ${embedded} chunks embedded`)
516
+ }
517
+ } catch (err) {
518
+ console.error(`[codebase] Indexing failed:`, err)
519
+ }
520
+ })()
521
+
522
+ return {
523
+ content: [
524
+ {
525
+ type: 'text',
526
+ text: `🔄 Codebase indexing started in background for ${root || deps.workspaceRoot}`,
527
+ },
528
+ ],
529
+ }
530
+ }
531
+ )
532
+
533
+ server.tool(
534
+ 'memory_update',
535
+ 'Trigger immediate reindex of all collections',
536
+ {},
537
+ async () => {
538
+ let totalAdded = 0;
539
+ let totalUpdated = 0;
540
+
541
+ const freshConfig = loadCollectionConfig(deps.configPath);
542
+ const freshCollections = freshConfig ? getCollections(freshConfig) : deps.collections;
543
+
544
+ for (const collection of freshCollections) {
545
+ const files = await scanCollectionFiles(collection);
546
+
547
+ for (const filePath of files) {
548
+ const existing = store.findDocument(filePath);
549
+ const stats = fs.statSync(filePath);
550
+ const content = fs.readFileSync(filePath, 'utf-8');
551
+ const hash = crypto.createHash('sha256').update(content).digest('hex');
552
+
553
+ if (existing && existing.hash === hash) {
554
+ continue;
555
+ }
556
+
557
+ if (existing) {
558
+ store.deactivateDocument(collection.name, filePath);
559
+ totalUpdated++;
560
+ } else {
561
+ totalAdded++;
562
+ }
563
+
564
+ const effectiveProjectHash = collection.name === 'sessions'
565
+ ? extractProjectHashFromPath(filePath, path.join(outputDir, 'sessions'))
566
+ : currentProjectHash;
567
+ const title = path.basename(filePath, path.extname(filePath));
568
+ store.insertContent(hash, content);
569
+ store.insertDocument({
570
+ collection: collection.name,
571
+ path: filePath,
572
+ title,
573
+ hash,
574
+ createdAt: stats.birthtime.toISOString(),
575
+ modifiedAt: stats.mtime.toISOString(),
576
+ active: true,
577
+ projectHash: effectiveProjectHash,
578
+ });
579
+ }
580
+ }
581
+
582
+ return {
583
+ content: [
584
+ {
585
+ type: 'text',
586
+ text: `✅ Reindex complete: ${totalAdded} added, ${totalUpdated} updated`,
587
+ },
588
+ ],
589
+ };
590
+ }
591
+ );
592
+
593
+ return server;
594
+ }
595
+
596
+ function writePidFile(pidPath: string): void {
597
+ const dir = path.dirname(pidPath);
598
+ fs.mkdirSync(dir, { recursive: true });
599
+ fs.writeFileSync(pidPath, String(process.pid), 'utf-8');
600
+ }
601
+
602
+ function removePidFile(pidPath: string): void {
603
+ try {
604
+ fs.unlinkSync(pidPath);
605
+ } catch {
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Singleton guard using PID file.
611
+ * 1. Read old PID from file (if exists)
612
+ * 2. Write our PID immediately
613
+ * 3. After delay, kill the old PID if it's still alive
614
+ * 4. Periodically check if someone overwrote our PID — if so, exit
615
+ */
616
+ function setupSingletonGuard(pidPath: string, store: Store, stopWatcher: () => void): void {
617
+ // Read previous PID before overwriting
618
+ let oldPid: number | null = null;
619
+ try {
620
+ const pidStr = fs.readFileSync(pidPath, 'utf-8').trim();
621
+ const pid = parseInt(pidStr, 10);
622
+ if (!isNaN(pid) && pid !== process.pid) oldPid = pid;
623
+ } catch { /* no previous PID file */ }
624
+
625
+ // Write our PID
626
+ writePidFile(pidPath);
627
+
628
+ // After startup settles, kill the old process
629
+ if (oldPid) {
630
+ setTimeout(() => {
631
+ try {
632
+ process.kill(oldPid!, 0); // Still alive?
633
+ console.error(`[memory] Killing previous nano-brain process (PID ${oldPid})`);
634
+ process.kill(oldPid!, 'SIGTERM');
635
+ } catch { /* already dead */ }
636
+ }, 2000);
637
+ }
638
+
639
+ // Periodically check if a newer instance took over
640
+ const ownerCheck = setInterval(() => {
641
+ try {
642
+ const currentPid = parseInt(fs.readFileSync(pidPath, 'utf-8').trim(), 10);
643
+ if (currentPid !== process.pid) {
644
+ console.error(`[memory] Newer instance detected (PID ${currentPid}), shutting down`);
645
+ clearInterval(ownerCheck);
646
+ stopWatcher();
647
+ store.close();
648
+ process.exit(0);
649
+ }
650
+ } catch { /* PID file gone — continue running */ }
651
+ }, 5000);
652
+ ownerCheck.unref();
653
+ }
654
+
655
+ export async function startServer(options: ServerOptions): Promise<void> {
656
+ const { dbPath, configPath, httpPort, daemon } = options;
657
+
658
+ const homeDir = os.homedir();
659
+ const nanoBrainHome = path.join(homeDir, '.nano-brain');
660
+ const outputDir = nanoBrainHome;
661
+ const pidPath = path.join(nanoBrainHome, 'mcp.pid');
662
+
663
+ // PID file path — singleton guard set up after server starts
664
+ const finalConfigPath = configPath || path.join(outputDir, 'collections.yaml');
665
+ const config = loadCollectionConfig(finalConfigPath);
666
+ const collections = config ? getCollections(config) : [];
667
+ const storageConfig = parseStorageConfig(config?.storage);
668
+ const resolvedWorkspaceRoot = process.cwd();
669
+ const wsConfig = getWorkspaceConfig(config, resolvedWorkspaceRoot);
670
+ const resolvedCodebaseConfig = wsConfig.codebase;
671
+ const currentProjectHash = crypto.createHash('sha256').update(resolvedWorkspaceRoot).digest('hex').substring(0, 12);
672
+ // Use per-workspace database: {dirName}-{hash}.sqlite instead of default.sqlite
673
+ const isDefaultDb = dbPath.endsWith('/default.sqlite') || dbPath.endsWith('\\default.sqlite');
674
+ const workspaceDirName = path.basename(resolvedWorkspaceRoot).replace(/[^a-zA-Z0-9_-]/g, '_');
675
+ const effectiveDbPath = isDefaultDb ? path.join(path.dirname(dbPath), `${workspaceDirName}-${currentProjectHash}.sqlite`) : dbPath;
676
+ console.error(`[memory] Workspace: ${resolvedWorkspaceRoot} (${currentProjectHash})`);
677
+ console.error(`[memory] Database: ${effectiveDbPath}`);
678
+ const store = createStore(effectiveDbPath);
679
+
680
+ let embedder: SearchProviders['embedder'] = null;
681
+ let reranker: SearchProviders['reranker'] = null;
682
+
683
+ const providers: SearchProviders = {
684
+ embedder,
685
+ reranker,
686
+ expander: null,
687
+ };
688
+
689
+ store.modelStatus = {
690
+ embedding: 'loading...',
691
+ reranker: 'loading...',
692
+ expander: 'disabled',
693
+ };
694
+
695
+ const deps: ServerDeps = {
696
+ store,
697
+ providers,
698
+ collections,
699
+ configPath: finalConfigPath,
700
+ outputDir,
701
+ storageConfig,
702
+ currentProjectHash,
703
+ codebaseConfig: resolvedCodebaseConfig,
704
+ embeddingConfig: config?.embedding,
705
+ workspaceRoot: resolvedWorkspaceRoot,
706
+ };
707
+
708
+ const server = createMcpServer(deps);
709
+
710
+ let watcher: ReturnType<typeof startWatcher> | null = null;
711
+ const startFileWatcher = () => {
712
+ if (watcher) {
713
+ return;
714
+ }
715
+ const watcherConfig: WatcherConfig | undefined = config?.watcher;
716
+ watcher = startWatcher({
717
+ store,
718
+ collections,
719
+ embedder: providers.embedder,
720
+ debounceMs: watcherConfig?.debounceMs ?? 2000,
721
+ pollIntervalMs: watcherConfig?.pollIntervalMs ?? 120000,
722
+ sessionPollMs: watcherConfig?.sessionPollMs ?? 120000,
723
+ embedIntervalMs: watcherConfig?.embedIntervalMs ?? 60000,
724
+ sessionStorageDir: path.join(homeDir, '.local/share/opencode/storage'),
725
+ outputDir: path.join(outputDir, 'sessions'),
726
+ storageConfig,
727
+ dbPath,
728
+ onUpdate: (filePath) => {
729
+ if (!daemon) {
730
+ console.error(`[watcher] File changed: ${filePath}`);
731
+ }
732
+ },
733
+ codebaseConfig: resolvedCodebaseConfig,
734
+ workspaceRoot: resolvedWorkspaceRoot,
735
+ projectHash: currentProjectHash,
736
+ });
737
+ };
738
+
739
+ // Cleanup on exit (all modes, not just daemon)
740
+ const cleanup = () => {
741
+ if (watcher) watcher.stop();
742
+ // Only remove PID file if it's still ours
743
+ try {
744
+ const currentPid = parseInt(fs.readFileSync(pidPath, 'utf-8').trim(), 10);
745
+ if (currentPid === process.pid) removePidFile(pidPath);
746
+ } catch { }
747
+ store.close();
748
+ process.exit(0);
749
+ };
750
+ process.on('SIGTERM', cleanup);
751
+ process.on('SIGINT', cleanup);
752
+
753
+ if (httpPort) {
754
+ const httpServer = http.createServer((req, res) => {
755
+ if (req.method === 'GET' && req.url === '/health') {
756
+ res.writeHead(200, { 'Content-Type': 'application/json' });
757
+ res.end(JSON.stringify({ status: 'ok', uptime: process.uptime() }));
758
+ return;
759
+ }
760
+
761
+ if (req.method === 'POST' && req.url === '/mcp') {
762
+ let body = '';
763
+ req.on('data', chunk => {
764
+ body += chunk.toString();
765
+ });
766
+ req.on('end', async () => {
767
+ try {
768
+ const request = JSON.parse(body);
769
+ const response = await server.request(request, {});
770
+ res.writeHead(200, { 'Content-Type': 'application/json' });
771
+ res.end(JSON.stringify(response));
772
+ } catch (err) {
773
+ res.writeHead(500, { 'Content-Type': 'application/json' });
774
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
775
+ }
776
+ });
777
+ return;
778
+ }
779
+
780
+ res.writeHead(404);
781
+ res.end('Not Found');
782
+ });
783
+
784
+ httpServer.listen(httpPort, () => {
785
+ console.error(`MCP server listening on http://localhost:${httpPort}`);
786
+ });
787
+ } else {
788
+ const transport = new StdioServerTransport();
789
+ await server.connect(transport);
790
+ console.error('MCP server started on stdio');
791
+ }
792
+
793
+ // Singleton guard: write PID, kill old process, monitor for newer instances
794
+ setupSingletonGuard(pidPath, store, () => { if (watcher) watcher.stop(); });
795
+
796
+ Promise.all([
797
+ createEmbeddingProvider({ embeddingConfig: config?.embedding })
798
+ .then((loadedEmbedder) => {
799
+ providers.embedder = loadedEmbedder;
800
+ store.modelStatus.embedding = loadedEmbedder ? loadedEmbedder.getModel() : 'missing';
801
+ if (loadedEmbedder) {
802
+ store.ensureVecTable(loadedEmbedder.getDimensions());
803
+ }
804
+ console.error(`[memory] Embedding model: ${store.modelStatus.embedding}`);
805
+ startFileWatcher();
806
+ })
807
+ .catch((err) => {
808
+ store.modelStatus.embedding = 'failed';
809
+ console.error('[memory] Embedding model failed:', err);
810
+ startFileWatcher();
811
+ }),
812
+ createReranker()
813
+ .then((loadedReranker) => {
814
+ providers.reranker = loadedReranker;
815
+ store.modelStatus.reranker = loadedReranker ? 'bge-reranker-v2-m3' : 'missing';
816
+ console.error(`[memory] Reranker model: ${store.modelStatus.reranker}`);
817
+ })
818
+ .catch((err) => {
819
+ store.modelStatus.reranker = 'failed';
820
+ console.error('[memory] Reranker model failed:', err);
821
+ }),
822
+ ]);
823
+
824
+ // Ollama reconnect — retry if fell back to local GGUF at startup
825
+ const embeddingConfig = config?.embedding;
826
+ if (!embeddingConfig || embeddingConfig.provider !== 'local') {
827
+ const ollamaUrl = embeddingConfig?.url || detectOllamaUrl();
828
+ const ollamaModel = embeddingConfig?.model || 'nomic-embed-text';
829
+ let startedWithLocalGGUF = false;
830
+
831
+ // Check after initial provider loads whether we're using local GGUF
832
+ setTimeout(() => {
833
+ // Local GGUF model is 'nomic-embed-text-v1.5', Ollama is 'nomic-embed-text'
834
+ if (store.modelStatus.embedding === 'nomic-embed-text-v1.5') {
835
+ startedWithLocalGGUF = true;
836
+ }
837
+ }, 5000);
838
+
839
+ const reconnectTimer = setInterval(async () => {
840
+ if (!startedWithLocalGGUF) {
841
+ clearInterval(reconnectTimer);
842
+ return;
843
+ }
844
+ // Already reconnected?
845
+ if (store.modelStatus.embedding === ollamaModel) {
846
+ clearInterval(reconnectTimer);
847
+ return;
848
+ }
849
+ try {
850
+ const health = await checkOllamaHealth(ollamaUrl);
851
+ if (health.reachable) {
852
+ const newProvider = await createEmbeddingProvider({ embeddingConfig: { provider: 'ollama', url: ollamaUrl, model: ollamaModel } });
853
+ if (newProvider) {
854
+ const oldProvider = providers.embedder;
855
+ providers.embedder = newProvider;
856
+ store.modelStatus.embedding = newProvider.getModel();
857
+ store.ensureVecTable(newProvider.getDimensions());
858
+ if (oldProvider && 'dispose' in oldProvider) (oldProvider as { dispose(): void }).dispose();
859
+ console.error(`[memory] Reconnected to Ollama at ${ollamaUrl} — switched from local GGUF`);
860
+ startedWithLocalGGUF = false;
861
+ clearInterval(reconnectTimer);
862
+ }
863
+ }
864
+ } catch {
865
+ // Silent retry — don't spam logs
866
+ }
867
+ }, 60000);
868
+
869
+ // Don't prevent process exit
870
+ reconnectTimer.unref();
871
+ }
872
+
873
+ if (!resolvedCodebaseConfig?.enabled) {
874
+ startFileWatcher();
875
+ }
876
+ }