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.
- package/AGENTS_SNIPPET.md +36 -0
- package/CHANGELOG.md +68 -0
- package/README.md +281 -0
- package/SKILL.md +153 -0
- package/bin/cli.js +18 -0
- package/index.html +929 -0
- package/nano-brain +4 -0
- package/opencode-mcp.json +9 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/.openspec.yaml +2 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/design.md +68 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/proposal.md +27 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/mcp-integration-testing/spec.md +50 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/mcp-server/spec.md +40 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/search-pipeline/spec.md +29 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/tasks.md +37 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/.openspec.yaml +2 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/design.md +111 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/proposal.md +30 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/mcp-server/spec.md +33 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/storage-limits/spec.md +90 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/workspace-scoping/spec.md +66 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/tasks.md +199 -0
- package/openspec/changes/codebase-indexing/.openspec.yaml +2 -0
- package/openspec/changes/codebase-indexing/design.md +169 -0
- package/openspec/changes/codebase-indexing/proposal.md +30 -0
- package/openspec/changes/codebase-indexing/specs/codebase-collection/spec.md +187 -0
- package/openspec/changes/codebase-indexing/specs/mcp-server/spec.md +36 -0
- package/openspec/changes/codebase-indexing/tasks.md +56 -0
- package/openspec/specs/mcp-integration-testing/spec.md +50 -0
- package/openspec/specs/mcp-server/spec.md +75 -0
- package/openspec/specs/search-pipeline/spec.md +29 -0
- package/openspec/specs/storage-limits/spec.md +94 -0
- package/openspec/specs/workspace-scoping/spec.md +70 -0
- package/package.json +34 -0
- package/site/build.js +66 -0
- package/site/partials/_api.html +83 -0
- package/site/partials/_compare.html +100 -0
- package/site/partials/_config.html +23 -0
- package/site/partials/_features.html +43 -0
- package/site/partials/_footer.html +6 -0
- package/site/partials/_hero.html +9 -0
- package/site/partials/_how-it-works.html +26 -0
- package/site/partials/_models.html +18 -0
- package/site/partials/_quick-start.html +15 -0
- package/site/partials/_stats.html +1 -0
- package/site/partials/_tech-stack.html +13 -0
- package/site/script.js +12 -0
- package/site/shell.html +44 -0
- package/site/styles.css +548 -0
- package/src/chunker.ts +427 -0
- package/src/codebase.ts +331 -0
- package/src/collections.ts +192 -0
- package/src/embeddings.ts +293 -0
- package/src/expansion.ts +79 -0
- package/src/harvester.ts +306 -0
- package/src/index.ts +503 -0
- package/src/reranker.ts +103 -0
- package/src/search.ts +294 -0
- package/src/server.ts +664 -0
- package/src/storage.ts +221 -0
- package/src/store.ts +623 -0
- package/src/types.ts +202 -0
- package/src/watcher.ts +384 -0
- package/test/chunker.test.ts +479 -0
- package/test/cli.test.ts +309 -0
- package/test/codebase-chunker.test.ts +446 -0
- package/test/codebase.test.ts +678 -0
- package/test/collections.test.ts +571 -0
- package/test/harvester.test.ts +636 -0
- package/test/integration.test.ts +150 -0
- package/test/llm.test.ts +322 -0
- package/test/search.test.ts +572 -0
- package/test/server.test.ts +541 -0
- package/test/storage.test.ts +302 -0
- package/test/store.test.ts +465 -0
- package/test/watcher.test.ts +656 -0
- package/test/workspace.test.ts +239 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +16 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
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 } from './types.js'
|
|
10
|
+
import type { SearchProviders } from './search.js';
|
|
11
|
+
import { hybridSearch } from './search.js';
|
|
12
|
+
import { createStore } from './store.js';
|
|
13
|
+
import { loadCollectionConfig, getCollections, scanCollectionFiles } from './collections.js';
|
|
14
|
+
import { createEmbeddingProvider } 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
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatSearchResults(results: SearchResult[]): string {
|
|
40
|
+
if (results.length === 0) {
|
|
41
|
+
return 'No results found.';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return results.map((r, i) =>
|
|
45
|
+
`### ${i + 1}. ${r.title} (${r.docid})\n` +
|
|
46
|
+
`**Path:** ${r.path} | **Score:** ${r.score.toFixed(3)} | **Lines:** ${r.startLine}-${r.endLine}\n\n` +
|
|
47
|
+
`${r.snippet}\n`
|
|
48
|
+
).join('\n---\n\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatStatus(health: IndexHealth, codebaseStats?: { enabled: boolean; documents: number; chunks: number; extensions: string[]; excludeCount: number; storageUsed: number; maxSize: number }): string {
|
|
52
|
+
const lines = [
|
|
53
|
+
`📊 **Memory Index Status**`,
|
|
54
|
+
`Documents: ${health.documentCount} | Chunks: ${health.chunkCount} | Pending embeddings: ${health.pendingEmbeddings}`,
|
|
55
|
+
`Database size: ${(health.databaseSize / 1024 / 1024).toFixed(1)} MB`,
|
|
56
|
+
``,
|
|
57
|
+
`**Collections:**`,
|
|
58
|
+
...health.collections.map(c => ` - ${c.name}: ${c.documentCount} docs (${c.path})`),
|
|
59
|
+
``,
|
|
60
|
+
`**Models:**`,
|
|
61
|
+
` - Embedding: ${health.modelStatus.embedding}`,
|
|
62
|
+
` - Reranker: ${health.modelStatus.reranker}`,
|
|
63
|
+
` - Expander: ${health.modelStatus.expander}`,
|
|
64
|
+
]
|
|
65
|
+
if (codebaseStats) {
|
|
66
|
+
const usedMB = (codebaseStats.storageUsed / 1024 / 1024).toFixed(1)
|
|
67
|
+
const maxMB = (codebaseStats.maxSize / 1024 / 1024).toFixed(0)
|
|
68
|
+
lines.push(``)
|
|
69
|
+
lines.push(`**Codebase:**`)
|
|
70
|
+
lines.push(` - Enabled: ${codebaseStats.enabled}`)
|
|
71
|
+
lines.push(` - Documents: ${codebaseStats.documents}`)
|
|
72
|
+
lines.push(` - Storage: ${usedMB}MB / ${maxMB}MB`)
|
|
73
|
+
lines.push(` - Extensions: ${codebaseStats.extensions.join(', ')}`)
|
|
74
|
+
lines.push(` - Exclude patterns: ${codebaseStats.excludeCount}`)
|
|
75
|
+
}
|
|
76
|
+
if (health.workspaceStats && health.workspaceStats.length > 0) {
|
|
77
|
+
lines.push(``)
|
|
78
|
+
lines.push(`**Workspaces:**`)
|
|
79
|
+
for (const ws of health.workspaceStats) {
|
|
80
|
+
lines.push(` - ${ws.projectHash}: ${ws.count} docs`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return lines.join('\n')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createMcpServer(deps: ServerDeps): McpServer {
|
|
88
|
+
const { store, providers, collections, configPath, outputDir, currentProjectHash } = deps;
|
|
89
|
+
|
|
90
|
+
const server = new McpServer(
|
|
91
|
+
{
|
|
92
|
+
name: 'nano-brain',
|
|
93
|
+
version: '0.1.0',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
capabilities: {
|
|
97
|
+
tools: {},
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
server.tool(
|
|
103
|
+
'memory_search',
|
|
104
|
+
'BM25 full-text keyword search across indexed documents',
|
|
105
|
+
{
|
|
106
|
+
query: z.string().describe('Search query'),
|
|
107
|
+
limit: z.number().optional().default(10).describe('Max results'),
|
|
108
|
+
collection: z.string().optional().describe('Filter by collection name'),
|
|
109
|
+
workspace: z.string().optional().describe('Filter by workspace hash. Omit for current workspace, "all" for cross-workspace search'),
|
|
110
|
+
},
|
|
111
|
+
async ({ query, limit, collection, workspace }) => {
|
|
112
|
+
const effectiveWorkspace = workspace === 'all' ? 'all' : (workspace || currentProjectHash);
|
|
113
|
+
const results = store.searchFTS(query, limit, collection, effectiveWorkspace);
|
|
114
|
+
return {
|
|
115
|
+
content: [
|
|
116
|
+
{
|
|
117
|
+
type: 'text',
|
|
118
|
+
text: formatSearchResults(results),
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
server.tool(
|
|
126
|
+
'memory_vsearch',
|
|
127
|
+
'Semantic vector search using embeddings',
|
|
128
|
+
{
|
|
129
|
+
query: z.string().describe('Search query'),
|
|
130
|
+
limit: z.number().optional().default(10).describe('Max results'),
|
|
131
|
+
collection: z.string().optional().describe('Filter by collection name'),
|
|
132
|
+
workspace: z.string().optional().describe('Filter by workspace hash. Omit for current workspace, "all" for cross-workspace search'),
|
|
133
|
+
},
|
|
134
|
+
async ({ query, limit, collection, workspace }) => {
|
|
135
|
+
const effectiveWorkspace = workspace === 'all' ? 'all' : (workspace || currentProjectHash);
|
|
136
|
+
if (providers.embedder) {
|
|
137
|
+
try {
|
|
138
|
+
const { embedding } = await providers.embedder.embed(query);
|
|
139
|
+
const results = store.searchVec(query, embedding, limit, collection, effectiveWorkspace);
|
|
140
|
+
return {
|
|
141
|
+
content: [
|
|
142
|
+
{
|
|
143
|
+
type: 'text',
|
|
144
|
+
text: formatSearchResults(results),
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
} catch (err) {
|
|
149
|
+
const fallbackResults = store.searchFTS(query, limit, collection, effectiveWorkspace);
|
|
150
|
+
return {
|
|
151
|
+
content: [
|
|
152
|
+
{
|
|
153
|
+
type: 'text',
|
|
154
|
+
text: `⚠️ Vector search failed, falling back to FTS: ${err instanceof Error ? err.message : String(err)}\n\n${formatSearchResults(fallbackResults)}`,
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
const fallbackResults = store.searchFTS(query, limit, collection, effectiveWorkspace);
|
|
161
|
+
return {
|
|
162
|
+
content: [
|
|
163
|
+
{
|
|
164
|
+
type: 'text',
|
|
165
|
+
text: `⚠️ Embedder not available, falling back to FTS\n\n${formatSearchResults(fallbackResults)}`,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
server.tool(
|
|
174
|
+
'memory_query',
|
|
175
|
+
'Full hybrid search with query expansion, RRF fusion, and LLM reranking',
|
|
176
|
+
{
|
|
177
|
+
query: z.string().describe('Search query'),
|
|
178
|
+
limit: z.number().optional().default(10).describe('Max results'),
|
|
179
|
+
collection: z.string().optional().describe('Filter by collection name'),
|
|
180
|
+
minScore: z.number().optional().default(0).describe('Minimum score threshold'),
|
|
181
|
+
workspace: z.string().optional().describe('Filter by workspace hash. Omit for current workspace, "all" for cross-workspace search'),
|
|
182
|
+
},
|
|
183
|
+
async ({ query, limit, collection, minScore, workspace }) => {
|
|
184
|
+
const effectiveWorkspace = workspace === 'all' ? 'all' : (workspace || currentProjectHash);
|
|
185
|
+
const results = await hybridSearch(
|
|
186
|
+
store,
|
|
187
|
+
{ query, limit, collection, minScore, projectHash: effectiveWorkspace },
|
|
188
|
+
providers
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
content: [
|
|
193
|
+
{
|
|
194
|
+
type: 'text',
|
|
195
|
+
text: formatSearchResults(results),
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
server.tool(
|
|
203
|
+
'memory_get',
|
|
204
|
+
'Retrieve a document by path or docid (#abc123)',
|
|
205
|
+
{
|
|
206
|
+
id: z.string().describe('Document path or docid (6-char hash prefix with # prefix)'),
|
|
207
|
+
fromLine: z.number().optional().describe('Start line number'),
|
|
208
|
+
maxLines: z.number().optional().describe('Maximum number of lines to return'),
|
|
209
|
+
},
|
|
210
|
+
async ({ id, fromLine, maxLines }) => {
|
|
211
|
+
const docid = id.startsWith('#') ? id.slice(1) : id;
|
|
212
|
+
const doc = store.findDocument(docid);
|
|
213
|
+
|
|
214
|
+
if (!doc) {
|
|
215
|
+
return {
|
|
216
|
+
content: [
|
|
217
|
+
{
|
|
218
|
+
type: 'text',
|
|
219
|
+
text: `Document not found: ${id}`,
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
isError: true,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const body = store.getDocumentBody(doc.hash, fromLine, maxLines);
|
|
227
|
+
return {
|
|
228
|
+
content: [
|
|
229
|
+
{
|
|
230
|
+
type: 'text',
|
|
231
|
+
text: body ?? '',
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
server.tool(
|
|
239
|
+
'memory_multi_get',
|
|
240
|
+
'Batch retrieve documents by glob pattern or comma-separated list',
|
|
241
|
+
{
|
|
242
|
+
pattern: z.string().describe('Glob pattern or comma-separated docids/paths'),
|
|
243
|
+
maxBytes: z.number().optional().default(50000).describe('Maximum total bytes to return'),
|
|
244
|
+
},
|
|
245
|
+
async ({ pattern, maxBytes }) => {
|
|
246
|
+
const ids = pattern.split(',').map(s => s.trim());
|
|
247
|
+
|
|
248
|
+
let totalBytes = 0;
|
|
249
|
+
const results: string[] = [];
|
|
250
|
+
|
|
251
|
+
for (const id of ids) {
|
|
252
|
+
const docid = id.startsWith('#') ? id.slice(1) : id;
|
|
253
|
+
const doc = store.findDocument(docid);
|
|
254
|
+
|
|
255
|
+
if (!doc) {
|
|
256
|
+
results.push(`### Document not found: ${id}\n`);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const body = store.getDocumentBody(doc.hash);
|
|
261
|
+
if (!body) {
|
|
262
|
+
results.push(`### Document body not found: ${id}\n`);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const docText = `### ${doc.title} (${doc.path})\n\n${body}\n\n---\n\n`;
|
|
267
|
+
|
|
268
|
+
if (totalBytes + docText.length > maxBytes) {
|
|
269
|
+
results.push(`\n⚠️ Reached maxBytes limit (${maxBytes}), truncating results.\n`);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
results.push(docText);
|
|
274
|
+
totalBytes += docText.length;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
content: [
|
|
279
|
+
{
|
|
280
|
+
type: 'text',
|
|
281
|
+
text: results.join(''),
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
server.tool(
|
|
289
|
+
'memory_write',
|
|
290
|
+
'Write content to daily log or MEMORY.md',
|
|
291
|
+
{
|
|
292
|
+
content: z.string().describe('Content to write'),
|
|
293
|
+
target: z.string().optional().default('daily').describe('Target: "daily" for daily log, "memory" for MEMORY.md'),
|
|
294
|
+
},
|
|
295
|
+
async ({ content, target }) => {
|
|
296
|
+
let targetPath: string;
|
|
297
|
+
|
|
298
|
+
if (target === 'daily') {
|
|
299
|
+
const date = new Date().toISOString().split('T')[0];
|
|
300
|
+
const memoryDir = path.join(outputDir, 'memory');
|
|
301
|
+
fs.mkdirSync(memoryDir, { recursive: true });
|
|
302
|
+
targetPath = path.join(memoryDir, `${date}.md`);
|
|
303
|
+
} else if (target === 'memory') {
|
|
304
|
+
targetPath = path.join(outputDir, 'MEMORY.md');
|
|
305
|
+
} else {
|
|
306
|
+
return {
|
|
307
|
+
content: [
|
|
308
|
+
{
|
|
309
|
+
type: 'text',
|
|
310
|
+
text: `Invalid target: ${target}. Use "daily" or "memory".`,
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
isError: true,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const timestamp = new Date().toISOString();
|
|
318
|
+
const entry = `\n## ${timestamp}\n\n${content}\n`;
|
|
319
|
+
|
|
320
|
+
fs.appendFileSync(targetPath, entry, 'utf-8');
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
content: [
|
|
324
|
+
{
|
|
325
|
+
type: 'text',
|
|
326
|
+
text: `✅ Written to ${targetPath}`,
|
|
327
|
+
},
|
|
328
|
+
],
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
server.tool(
|
|
334
|
+
'memory_status',
|
|
335
|
+
'Show index health, collection info, and model status',
|
|
336
|
+
{
|
|
337
|
+
root: z.string().optional().describe('Workspace root path for codebase stats'),
|
|
338
|
+
},
|
|
339
|
+
async ({ root }) => {
|
|
340
|
+
const health = store.getIndexHealth()
|
|
341
|
+
const effectiveRoot = root || deps.workspaceRoot
|
|
342
|
+
const codebaseStats = getCodebaseStats(store, deps.codebaseConfig, effectiveRoot)
|
|
343
|
+
return {
|
|
344
|
+
content: [
|
|
345
|
+
{
|
|
346
|
+
type: 'text',
|
|
347
|
+
text: formatStatus(health, codebaseStats),
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
)
|
|
353
|
+
server.tool(
|
|
354
|
+
'memory_index_codebase',
|
|
355
|
+
'Index codebase files in the current workspace',
|
|
356
|
+
{
|
|
357
|
+
root: z.string().optional().describe('Workspace root path to index. Defaults to configured root or server cwd.'),
|
|
358
|
+
},
|
|
359
|
+
async ({ root }) => {
|
|
360
|
+
if (!deps.codebaseConfig?.enabled) {
|
|
361
|
+
return {
|
|
362
|
+
content: [
|
|
363
|
+
{
|
|
364
|
+
type: 'text',
|
|
365
|
+
text: '❌ Codebase indexing is not enabled. Add `codebase: { enabled: true }` to your collections.yaml',
|
|
366
|
+
},
|
|
367
|
+
],
|
|
368
|
+
isError: true,
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
;(async () => {
|
|
373
|
+
try {
|
|
374
|
+
const effectiveRoot = root || deps.workspaceRoot
|
|
375
|
+
const effectiveProjectHash = crypto.createHash('sha256').update(effectiveRoot).digest('hex').substring(0, 12)
|
|
376
|
+
const result = await indexCodebase(
|
|
377
|
+
store,
|
|
378
|
+
effectiveRoot,
|
|
379
|
+
deps.codebaseConfig!,
|
|
380
|
+
effectiveProjectHash,
|
|
381
|
+
providers.embedder
|
|
382
|
+
)
|
|
383
|
+
console.error(`[codebase] Indexing complete: ${result.filesScanned} scanned, ${result.filesIndexed} indexed, ${result.filesSkippedUnchanged} unchanged`)
|
|
384
|
+
if (providers.embedder) {
|
|
385
|
+
const embedded = await embedPendingCodebase(store, providers.embedder, 10, effectiveProjectHash)
|
|
386
|
+
console.error(`[codebase] Embedding complete: ${embedded} chunks embedded`)
|
|
387
|
+
}
|
|
388
|
+
} catch (err) {
|
|
389
|
+
console.error(`[codebase] Indexing failed:`, err)
|
|
390
|
+
}
|
|
391
|
+
})()
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
content: [
|
|
395
|
+
{
|
|
396
|
+
type: 'text',
|
|
397
|
+
text: `🔄 Codebase indexing started in background for ${root || deps.workspaceRoot}`,
|
|
398
|
+
},
|
|
399
|
+
],
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
server.tool(
|
|
405
|
+
'memory_update',
|
|
406
|
+
'Trigger immediate reindex of all collections',
|
|
407
|
+
{},
|
|
408
|
+
async () => {
|
|
409
|
+
let totalAdded = 0;
|
|
410
|
+
let totalUpdated = 0;
|
|
411
|
+
|
|
412
|
+
const freshConfig = loadCollectionConfig(deps.configPath);
|
|
413
|
+
const freshCollections = freshConfig ? getCollections(freshConfig) : deps.collections;
|
|
414
|
+
|
|
415
|
+
for (const collection of freshCollections) {
|
|
416
|
+
const files = await scanCollectionFiles(collection);
|
|
417
|
+
|
|
418
|
+
for (const filePath of files) {
|
|
419
|
+
const existing = store.findDocument(filePath);
|
|
420
|
+
const stats = fs.statSync(filePath);
|
|
421
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
422
|
+
const hash = crypto.createHash('sha256').update(content).digest('hex');
|
|
423
|
+
|
|
424
|
+
if (existing && existing.hash === hash) {
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (existing) {
|
|
429
|
+
store.deactivateDocument(collection.name, filePath);
|
|
430
|
+
totalUpdated++;
|
|
431
|
+
} else {
|
|
432
|
+
totalAdded++;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const title = path.basename(filePath, path.extname(filePath));
|
|
436
|
+
store.insertContent(hash, content);
|
|
437
|
+
store.insertDocument({
|
|
438
|
+
collection: collection.name,
|
|
439
|
+
path: filePath,
|
|
440
|
+
title,
|
|
441
|
+
hash,
|
|
442
|
+
createdAt: stats.birthtime.toISOString(),
|
|
443
|
+
modifiedAt: stats.mtime.toISOString(),
|
|
444
|
+
active: true,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
content: [
|
|
451
|
+
{
|
|
452
|
+
type: 'text',
|
|
453
|
+
text: `✅ Reindex complete: ${totalAdded} added, ${totalUpdated} updated`,
|
|
454
|
+
},
|
|
455
|
+
],
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
return server;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function writePidFile(pidPath: string): void {
|
|
464
|
+
const dir = path.dirname(pidPath);
|
|
465
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
466
|
+
fs.writeFileSync(pidPath, String(process.pid), 'utf-8');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function removePidFile(pidPath: string): void {
|
|
470
|
+
try {
|
|
471
|
+
fs.unlinkSync(pidPath);
|
|
472
|
+
} catch {
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function checkStalePid(pidPath: string): void {
|
|
477
|
+
if (!fs.existsSync(pidPath)) {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const pidStr = fs.readFileSync(pidPath, 'utf-8').trim();
|
|
482
|
+
const pid = parseInt(pidStr, 10);
|
|
483
|
+
|
|
484
|
+
if (isNaN(pid)) {
|
|
485
|
+
fs.unlinkSync(pidPath);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
process.kill(pid, 0);
|
|
491
|
+
console.error(`Server already running with PID ${pid}`);
|
|
492
|
+
process.exit(1);
|
|
493
|
+
} catch {
|
|
494
|
+
console.warn(`Removing stale PID file (PID ${pid} not running)`);
|
|
495
|
+
fs.unlinkSync(pidPath);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export async function startServer(options: ServerOptions): Promise<void> {
|
|
500
|
+
const { dbPath, configPath, httpPort, daemon } = options;
|
|
501
|
+
|
|
502
|
+
const homeDir = os.homedir();
|
|
503
|
+
const outputDir = path.join(homeDir, '.nano-brain');
|
|
504
|
+
const cacheDir = path.join(homeDir, '.cache', 'nano-brain');
|
|
505
|
+
const pidPath = path.join(cacheDir, 'mcp.pid');
|
|
506
|
+
const finalConfigPath = configPath || path.join(outputDir, 'collections.yaml');
|
|
507
|
+
const config = loadCollectionConfig(finalConfigPath);
|
|
508
|
+
const collections = config ? getCollections(config) : [];
|
|
509
|
+
const storageConfig = parseStorageConfig(config?.storage);
|
|
510
|
+
const resolvedWorkspaceRoot = config?.codebase?.root || process.cwd();
|
|
511
|
+
const currentProjectHash = crypto.createHash('sha256').update(resolvedWorkspaceRoot).digest('hex').substring(0, 12);
|
|
512
|
+
// Use per-workspace database: {dirName}-{hash}.sqlite instead of default.sqlite
|
|
513
|
+
const isDefaultDb = dbPath.endsWith('/default.sqlite') || dbPath.endsWith('\\default.sqlite');
|
|
514
|
+
const workspaceDirName = path.basename(resolvedWorkspaceRoot).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
515
|
+
const effectiveDbPath = isDefaultDb ? path.join(path.dirname(dbPath), `${workspaceDirName}-${currentProjectHash}.sqlite`) : dbPath;
|
|
516
|
+
console.error(`[memory] Workspace: ${resolvedWorkspaceRoot} (${currentProjectHash})`);
|
|
517
|
+
console.error(`[memory] Database: ${effectiveDbPath}`);
|
|
518
|
+
const store = createStore(effectiveDbPath);
|
|
519
|
+
|
|
520
|
+
let embedder: SearchProviders['embedder'] = null;
|
|
521
|
+
let reranker: SearchProviders['reranker'] = null;
|
|
522
|
+
|
|
523
|
+
const providers: SearchProviders = {
|
|
524
|
+
embedder,
|
|
525
|
+
reranker,
|
|
526
|
+
expander: null,
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
store.modelStatus = {
|
|
530
|
+
embedding: 'loading...',
|
|
531
|
+
reranker: 'loading...',
|
|
532
|
+
expander: 'disabled',
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const deps: ServerDeps = {
|
|
536
|
+
store,
|
|
537
|
+
providers,
|
|
538
|
+
collections,
|
|
539
|
+
configPath: finalConfigPath,
|
|
540
|
+
outputDir,
|
|
541
|
+
storageConfig,
|
|
542
|
+
currentProjectHash,
|
|
543
|
+
codebaseConfig: config?.codebase,
|
|
544
|
+
workspaceRoot: resolvedWorkspaceRoot,
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const server = createMcpServer(deps);
|
|
548
|
+
|
|
549
|
+
let watcher: ReturnType<typeof startWatcher> | null = null;
|
|
550
|
+
const startFileWatcher = () => {
|
|
551
|
+
if (watcher) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
watcher = startWatcher({
|
|
555
|
+
store,
|
|
556
|
+
collections,
|
|
557
|
+
embedder: providers.embedder,
|
|
558
|
+
debounceMs: 2000,
|
|
559
|
+
pollIntervalMs: 300000,
|
|
560
|
+
sessionPollMs: 120000,
|
|
561
|
+
sessionStorageDir: path.join(homeDir, '.local/share/opencode/storage'),
|
|
562
|
+
outputDir: path.join(outputDir, 'sessions'),
|
|
563
|
+
storageConfig,
|
|
564
|
+
dbPath,
|
|
565
|
+
onUpdate: (filePath) => {
|
|
566
|
+
if (!daemon) {
|
|
567
|
+
console.error(`[watcher] File changed: ${filePath}`);
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
codebaseConfig: config?.codebase,
|
|
571
|
+
workspaceRoot: resolvedWorkspaceRoot,
|
|
572
|
+
projectHash: currentProjectHash,
|
|
573
|
+
});
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
if (daemon) {
|
|
577
|
+
checkStalePid(pidPath);
|
|
578
|
+
writePidFile(pidPath);
|
|
579
|
+
|
|
580
|
+
const cleanup = () => {
|
|
581
|
+
if (watcher) {
|
|
582
|
+
watcher.stop();
|
|
583
|
+
}
|
|
584
|
+
removePidFile(pidPath);
|
|
585
|
+
store.close();
|
|
586
|
+
process.exit(0);
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
process.on('SIGTERM', cleanup);
|
|
590
|
+
process.on('SIGINT', cleanup);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (httpPort) {
|
|
594
|
+
const httpServer = http.createServer((req, res) => {
|
|
595
|
+
if (req.method === 'GET' && req.url === '/health') {
|
|
596
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
597
|
+
res.end(JSON.stringify({ status: 'ok', uptime: process.uptime() }));
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (req.method === 'POST' && req.url === '/mcp') {
|
|
602
|
+
let body = '';
|
|
603
|
+
req.on('data', chunk => {
|
|
604
|
+
body += chunk.toString();
|
|
605
|
+
});
|
|
606
|
+
req.on('end', async () => {
|
|
607
|
+
try {
|
|
608
|
+
const request = JSON.parse(body);
|
|
609
|
+
const response = await server.request(request, {});
|
|
610
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
611
|
+
res.end(JSON.stringify(response));
|
|
612
|
+
} catch (err) {
|
|
613
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
614
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
res.writeHead(404);
|
|
621
|
+
res.end('Not Found');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
httpServer.listen(httpPort, () => {
|
|
625
|
+
console.error(`MCP server listening on http://localhost:${httpPort}`);
|
|
626
|
+
});
|
|
627
|
+
} else {
|
|
628
|
+
const transport = new StdioServerTransport();
|
|
629
|
+
await server.connect(transport);
|
|
630
|
+
console.error('MCP server started on stdio');
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
Promise.all([
|
|
634
|
+
createEmbeddingProvider({ embeddingConfig: config?.embedding })
|
|
635
|
+
.then((loadedEmbedder) => {
|
|
636
|
+
providers.embedder = loadedEmbedder;
|
|
637
|
+
store.modelStatus.embedding = loadedEmbedder ? loadedEmbedder.getModel() : 'missing';
|
|
638
|
+
if (loadedEmbedder) {
|
|
639
|
+
store.ensureVecTable(loadedEmbedder.getDimensions());
|
|
640
|
+
}
|
|
641
|
+
console.error(`[memory] Embedding model: ${store.modelStatus.embedding}`);
|
|
642
|
+
startFileWatcher();
|
|
643
|
+
})
|
|
644
|
+
.catch((err) => {
|
|
645
|
+
store.modelStatus.embedding = 'failed';
|
|
646
|
+
console.error('[memory] Embedding model failed:', err);
|
|
647
|
+
startFileWatcher();
|
|
648
|
+
}),
|
|
649
|
+
createReranker()
|
|
650
|
+
.then((loadedReranker) => {
|
|
651
|
+
providers.reranker = loadedReranker;
|
|
652
|
+
store.modelStatus.reranker = loadedReranker ? 'bge-reranker-v2-m3' : 'missing';
|
|
653
|
+
console.error(`[memory] Reranker model: ${store.modelStatus.reranker}`);
|
|
654
|
+
})
|
|
655
|
+
.catch((err) => {
|
|
656
|
+
store.modelStatus.reranker = 'failed';
|
|
657
|
+
console.error('[memory] Reranker model failed:', err);
|
|
658
|
+
}),
|
|
659
|
+
]);
|
|
660
|
+
|
|
661
|
+
if (!config?.codebase?.enabled) {
|
|
662
|
+
startFileWatcher();
|
|
663
|
+
}
|
|
664
|
+
}
|