@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.
- package/.opencode/command/nano-brain-init.md +13 -0
- package/.opencode/command/nano-brain-reindex.md +11 -0
- package/.opencode/command/nano-brain-status.md +12 -0
- package/AGENTS.md +41 -0
- package/AGENTS_SNIPPET.md +44 -0
- package/CHANGELOG.md +186 -0
- package/README.md +298 -0
- package/SKILL.md +109 -0
- package/bin/cli.js +29 -0
- package/commands/nano-brain-init.md +36 -0
- package/commands/nano-brain-reindex.md +31 -0
- package/commands/nano-brain-status.md +32 -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/changes/fix-session-harvest-workspace-scoping/.openspec.yaml +2 -0
- package/openspec/changes/fix-session-harvest-workspace-scoping/design.md +84 -0
- package/openspec/changes/fix-session-harvest-workspace-scoping/proposal.md +26 -0
- package/openspec/changes/fix-session-harvest-workspace-scoping/specs/workspace-scoping/spec.md +65 -0
- package/openspec/changes/fix-session-harvest-workspace-scoping/tasks.md +33 -0
- package/openspec/changes/performance-and-search-quality/.openspec.yaml +2 -0
- package/openspec/changes/performance-and-search-quality/proposal.md +37 -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 +37 -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 +425 -0
- package/src/collections.ts +217 -0
- package/src/embeddings.ts +325 -0
- package/src/expansion.ts +79 -0
- package/src/harvester.ts +306 -0
- package/src/index.ts +778 -0
- package/src/reranker.ts +103 -0
- package/src/search.ts +294 -0
- package/src/server.ts +876 -0
- package/src/storage.ts +221 -0
- package/src/store.ts +653 -0
- package/src/types.ts +215 -0
- package/src/watcher.ts +389 -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 +219 -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 +530 -0
- package/test/watcher.test.ts +717 -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,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
|
+
}
|