@exaudeus/memory-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +264 -0
  3. package/dist/__tests__/clock-and-validators.test.d.ts +1 -0
  4. package/dist/__tests__/clock-and-validators.test.js +237 -0
  5. package/dist/__tests__/config-manager.test.d.ts +1 -0
  6. package/dist/__tests__/config-manager.test.js +142 -0
  7. package/dist/__tests__/config.test.d.ts +1 -0
  8. package/dist/__tests__/config.test.js +236 -0
  9. package/dist/__tests__/crash-journal.test.d.ts +1 -0
  10. package/dist/__tests__/crash-journal.test.js +203 -0
  11. package/dist/__tests__/e2e.test.d.ts +1 -0
  12. package/dist/__tests__/e2e.test.js +788 -0
  13. package/dist/__tests__/ephemeral-benchmark.test.d.ts +1 -0
  14. package/dist/__tests__/ephemeral-benchmark.test.js +651 -0
  15. package/dist/__tests__/ephemeral.test.d.ts +1 -0
  16. package/dist/__tests__/ephemeral.test.js +435 -0
  17. package/dist/__tests__/git-service.test.d.ts +1 -0
  18. package/dist/__tests__/git-service.test.js +43 -0
  19. package/dist/__tests__/normalize.test.d.ts +1 -0
  20. package/dist/__tests__/normalize.test.js +161 -0
  21. package/dist/__tests__/store.test.d.ts +1 -0
  22. package/dist/__tests__/store.test.js +1153 -0
  23. package/dist/config-manager.d.ts +49 -0
  24. package/dist/config-manager.js +126 -0
  25. package/dist/config.d.ts +32 -0
  26. package/dist/config.js +162 -0
  27. package/dist/crash-journal.d.ts +38 -0
  28. package/dist/crash-journal.js +198 -0
  29. package/dist/ephemeral-weights.json +1847 -0
  30. package/dist/ephemeral.d.ts +20 -0
  31. package/dist/ephemeral.js +516 -0
  32. package/dist/formatters.d.ts +10 -0
  33. package/dist/formatters.js +92 -0
  34. package/dist/git-service.d.ts +5 -0
  35. package/dist/git-service.js +39 -0
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.js +1197 -0
  38. package/dist/normalize.d.ts +2 -0
  39. package/dist/normalize.js +69 -0
  40. package/dist/store.d.ts +84 -0
  41. package/dist/store.js +813 -0
  42. package/dist/text-analyzer.d.ts +32 -0
  43. package/dist/text-analyzer.js +190 -0
  44. package/dist/thresholds.d.ts +39 -0
  45. package/dist/thresholds.js +75 -0
  46. package/dist/types.d.ts +186 -0
  47. package/dist/types.js +33 -0
  48. package/package.json +57 -0
package/dist/index.js ADDED
@@ -0,0 +1,1197 @@
1
+ #!/usr/bin/env node
2
+ // Codebase Memory MCP Server
3
+ // Provides persistent, evolving knowledge for AI coding agents
4
+ // Supports multiple workspaces simultaneously
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
8
+ import { z } from 'zod';
9
+ import path from 'path';
10
+ import os from 'os';
11
+ import { existsSync, writeFileSync } from 'fs';
12
+ import { MarkdownMemoryStore } from './store.js';
13
+ import { DEFAULT_STORAGE_BUDGET_BYTES, parseTopicScope, parseTrustLevel } from './types.js';
14
+ import { getLobeConfigs } from './config.js';
15
+ import { ConfigManager } from './config-manager.js';
16
+ import { normalizeArgs } from './normalize.js';
17
+ import { buildCrashReport, writeCrashReport, writeCrashReportSync, readLatestCrash, readCrashHistory, clearLatestCrash, formatCrashReport, formatCrashSummary, markServerStarted, } from './crash-journal.js';
18
+ import { formatStaleSection, formatConflictWarning, formatStats, formatBehaviorConfigSection } from './formatters.js';
19
+ let serverMode = { kind: 'running' };
20
+ const lobeHealth = new Map();
21
+ const serverStartTime = Date.now();
22
+ /** Track the last tool call for crash context */
23
+ let lastToolCall;
24
+ // --- Configuration ---
25
+ const { configs: lobeConfigs, origin: configOrigin, behavior: configBehavior } = getLobeConfigs();
26
+ const configPath = configOrigin.source === 'file' ? configOrigin.path : '';
27
+ /** Build crash context from current server state */
28
+ function currentCrashContext(phase) {
29
+ return {
30
+ phase,
31
+ lastToolCall,
32
+ configSource: configManager.getConfigOrigin().source,
33
+ lobeCount: configManager.getLobeNames().length,
34
+ };
35
+ }
36
+ // --- Process-level crash protection ---
37
+ // Philosophy: fail fast with meaningful error messages.
38
+ // On uncaught exception: journal the crash to disk, then die.
39
+ // The crash journal persists so the NEXT startup can report what happened.
40
+ // Never zombie — unknown state is worse than no state.
41
+ process.on('uncaughtException', (error) => {
42
+ process.stderr.write(`[memory-mcp] FATAL: Uncaught exception — journaling and exiting.\n`);
43
+ process.stderr.write(`[memory-mcp] Error: ${error.message}\n`);
44
+ if (error.stack)
45
+ process.stderr.write(`[memory-mcp] Stack: ${error.stack}\n`);
46
+ const report = buildCrashReport(error, 'uncaught-exception', currentCrashContext('running'));
47
+ const filepath = writeCrashReportSync(report);
48
+ if (filepath) {
49
+ process.stderr.write(`[memory-mcp] Crash report saved: ${filepath}\n`);
50
+ }
51
+ process.exit(1);
52
+ });
53
+ process.on('unhandledRejection', (reason) => {
54
+ const error = reason instanceof Error ? reason : new Error(String(reason));
55
+ process.stderr.write(`[memory-mcp] FATAL: Unhandled rejection — journaling and exiting.\n`);
56
+ process.stderr.write(`[memory-mcp] Error: ${error.message}\n`);
57
+ if (error.stack)
58
+ process.stderr.write(`[memory-mcp] Stack: ${error.stack}\n`);
59
+ const report = buildCrashReport(error, 'unhandled-rejection', currentCrashContext('running'));
60
+ const filepath = writeCrashReportSync(report);
61
+ if (filepath) {
62
+ process.stderr.write(`[memory-mcp] Crash report saved: ${filepath}\n`);
63
+ }
64
+ process.exit(1);
65
+ });
66
+ // --- Server setup ---
67
+ const stores = new Map();
68
+ const lobeNames = Array.from(lobeConfigs.keys());
69
+ // ConfigManager will be initialized after stores are set up
70
+ let configManager;
71
+ // Global store for user identity + preferences (shared across all lobes)
72
+ const GLOBAL_TOPICS = new Set(['user', 'preferences']);
73
+ const globalMemoryPath = path.join(os.homedir(), '.memory-mcp', 'global');
74
+ const globalStore = new MarkdownMemoryStore({
75
+ repoRoot: os.homedir(),
76
+ memoryPath: globalMemoryPath,
77
+ storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES,
78
+ });
79
+ /** Resolve a raw lobe name to a validated store + display label.
80
+ * After this call, consumers use ctx.label — the raw lobe is not in scope. */
81
+ function resolveToolContext(rawLobe, opts) {
82
+ // Global topics always route to the global store
83
+ if (opts?.isGlobal) {
84
+ return { ok: true, store: globalStore, label: 'global' };
85
+ }
86
+ const lobeNames = configManager.getLobeNames();
87
+ // Default to single lobe when omitted
88
+ const lobe = rawLobe || (lobeNames.length === 1 ? lobeNames[0] : undefined);
89
+ if (!lobe) {
90
+ return { ok: false, error: `Lobe is required. Available: ${lobeNames.join(', ')}` };
91
+ }
92
+ // Check if lobe is degraded
93
+ const health = configManager.getLobeHealth(lobe);
94
+ if (health?.status === 'degraded') {
95
+ return {
96
+ ok: false,
97
+ error: `Lobe "${lobe}" is degraded: ${health.error}\n\n` +
98
+ `Recovery steps:\n${health.recovery.map(s => `- ${s}`).join('\n')}\n\n` +
99
+ `Use memory_diagnose for full diagnostics.`,
100
+ };
101
+ }
102
+ const store = configManager.getStore(lobe);
103
+ if (!store) {
104
+ const available = lobeNames.join(', ');
105
+ const configOrigin = configManager.getConfigOrigin();
106
+ let hint = '';
107
+ if (configOrigin.source === 'file') {
108
+ hint = `\n\nTo add lobe "${lobe}":\n` +
109
+ `1. Edit ${configOrigin.path}\n` +
110
+ `2. Add: "${lobe}": { "root": "$HOME/git/.../repo", "memoryDir": ".memory", "budgetMB": 2 }\n` +
111
+ `3. Restart the memory MCP server`;
112
+ }
113
+ else if (configOrigin.source === 'env') {
114
+ hint = `\n\nTo add lobe "${lobe}", update MEMORY_MCP_WORKSPACES env var or create memory-config.json`;
115
+ }
116
+ else {
117
+ hint = `\n\nTo add lobes, create memory-config.json next to the memory MCP server with lobe definitions.`;
118
+ }
119
+ return { ok: false, error: `Unknown lobe: "${lobe}". Available: ${available}${hint}` };
120
+ }
121
+ return { ok: true, store, label: lobe };
122
+ }
123
+ /** Helper to return an MCP error response from a failed context resolution */
124
+ function contextError(ctx) {
125
+ return {
126
+ content: [{ type: 'text', text: ctx.error }],
127
+ isError: true,
128
+ };
129
+ }
130
+ /** Infer lobe from file paths by matching against known repo roots.
131
+ * Returns the lobe name if exactly one lobe matches, undefined otherwise.
132
+ * Ambiguous matches (multiple lobes) return undefined — better to ask than guess wrong. */
133
+ function inferLobeFromPaths(paths) {
134
+ if (paths.length === 0)
135
+ return undefined;
136
+ const lobeNames = configManager.getLobeNames();
137
+ const matchedLobes = new Set();
138
+ for (const filePath of paths) {
139
+ // Resolve path to absolute for matching
140
+ const resolved = path.isAbsolute(filePath) ? filePath : filePath;
141
+ for (const lobeName of lobeNames) {
142
+ const config = configManager.getLobeConfig(lobeName);
143
+ if (!config)
144
+ continue;
145
+ // Check if the file path starts with or is inside the repo root
146
+ if (resolved.startsWith(config.repoRoot) || resolved.startsWith(path.basename(config.repoRoot))) {
147
+ matchedLobes.add(lobeName);
148
+ }
149
+ }
150
+ }
151
+ // Only return if unambiguous — exactly one lobe matched
152
+ return matchedLobes.size === 1 ? matchedLobes.values().next().value : undefined;
153
+ }
154
+ const server = new Server({ name: 'memory-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
155
+ // Shared lobe property for tool schemas
156
+ const isSingleLobe = lobeNames.length === 1;
157
+ const lobeProperty = {
158
+ type: 'string',
159
+ description: isSingleLobe
160
+ ? `Memory lobe name (defaults to "${lobeNames[0]}" if omitted)`
161
+ : `Memory lobe name. Optional for reads (query/context/briefing/stats search all lobes when omitted). Required for writes (store/correct/bootstrap). Available: ${lobeNames.join(', ')}`,
162
+ enum: lobeNames.length > 1 ? lobeNames : undefined,
163
+ };
164
+ /** Helper to format config file path for display */
165
+ function configFileDisplay() {
166
+ const origin = configManager.getConfigOrigin();
167
+ return origin.source === 'file' ? origin.path : '(not using config file)';
168
+ }
169
+ // --- Tool definitions ---
170
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
171
+ tools: [
172
+ // memory_list_lobes is hidden — lobe info is surfaced in memory_context() hints
173
+ // and memory_stats. The handler still works if called directly.
174
+ {
175
+ name: 'memory_store',
176
+ description: 'Store knowledge. "user" and "preferences" are global (no lobe needed). Example: memory_store(topic: "gotchas", title: "Build cache", content: "Must clean build after Tuist changes")',
177
+ inputSchema: {
178
+ type: 'object',
179
+ properties: {
180
+ lobe: lobeProperty,
181
+ topic: {
182
+ type: 'string',
183
+ description: 'user | preferences | architecture | conventions | gotchas | recent-work | modules/<name>',
184
+ enum: ['user', 'preferences', 'architecture', 'conventions', 'gotchas', 'recent-work'],
185
+ },
186
+ title: {
187
+ type: 'string',
188
+ description: 'Short title for this entry',
189
+ },
190
+ content: {
191
+ type: 'string',
192
+ description: 'The knowledge to store',
193
+ },
194
+ sources: {
195
+ type: 'array',
196
+ items: { type: 'string' },
197
+ description: 'File paths that informed this (provenance, for freshness tracking)',
198
+ default: [],
199
+ },
200
+ references: {
201
+ type: 'array',
202
+ items: { type: 'string' },
203
+ description: 'Files, classes, or symbols this knowledge is about (semantic pointers). Example: ["features/messaging/impl/MessagingReducer.kt"]',
204
+ default: [],
205
+ },
206
+ trust: {
207
+ type: 'string',
208
+ enum: ['user', 'agent-confirmed', 'agent-inferred'],
209
+ description: 'user (from human) > agent-confirmed > agent-inferred',
210
+ default: 'agent-inferred',
211
+ },
212
+ },
213
+ required: ['topic', 'title', 'content'],
214
+ },
215
+ },
216
+ {
217
+ name: 'memory_query',
218
+ description: 'Search stored knowledge. Searches all lobes when lobe is omitted. Example: memory_query(scope: "*", filter: "reducer sealed", detail: "full"). Use scope "*" to search everything. Use detail "full" for complete content.',
219
+ inputSchema: {
220
+ type: 'object',
221
+ properties: {
222
+ lobe: lobeProperty,
223
+ scope: {
224
+ type: 'string',
225
+ description: 'Optional. Defaults to "*" (all topics). Options: * | user | preferences | architecture | conventions | gotchas | recent-work | modules/<name>',
226
+ },
227
+ detail: {
228
+ type: 'string',
229
+ enum: ['brief', 'standard', 'full'],
230
+ description: 'brief = titles only, standard = summaries, full = complete content + metadata',
231
+ default: 'brief',
232
+ },
233
+ filter: {
234
+ type: 'string',
235
+ description: 'Search terms. "A B" = AND, "A|B" = OR, "-A" = NOT. Example: "reducer sealed -deprecated"',
236
+ },
237
+ branch: {
238
+ type: 'string',
239
+ description: 'Branch for recent-work. Omit = current branch, "*" = all branches.',
240
+ },
241
+ },
242
+ required: [],
243
+ },
244
+ },
245
+ {
246
+ name: 'memory_correct',
247
+ description: 'Fix or delete an entry. Example: memory_correct(id: "arch-3f7a", action: "replace", correction: "updated content")',
248
+ inputSchema: {
249
+ type: 'object',
250
+ properties: {
251
+ lobe: lobeProperty,
252
+ id: {
253
+ type: 'string',
254
+ description: 'Entry ID (e.g. arch-3f7a, pref-5c9b)',
255
+ },
256
+ correction: {
257
+ type: 'string',
258
+ description: 'New text (for append/replace). Not needed for delete.',
259
+ },
260
+ action: {
261
+ type: 'string',
262
+ enum: ['append', 'replace', 'delete'],
263
+ description: 'append | replace | delete',
264
+ },
265
+ },
266
+ required: ['id', 'action'],
267
+ },
268
+ },
269
+ {
270
+ name: 'memory_context',
271
+ description: 'Session start AND pre-task lookup. Call with no args at session start to get user identity, preferences, and stale entries. Call with context to get task-specific knowledge. Searches all lobes when lobe is omitted. Example: memory_context() or memory_context(context: "writing a Kotlin reducer")',
272
+ inputSchema: {
273
+ type: 'object',
274
+ properties: {
275
+ lobe: lobeProperty,
276
+ context: {
277
+ type: 'string',
278
+ description: 'Optional. What you are about to do, in natural language. Omit for session-start briefing (user + preferences + stale entries).',
279
+ },
280
+ maxResults: {
281
+ type: 'number',
282
+ description: 'Max results (default: 10)',
283
+ default: 10,
284
+ },
285
+ minMatch: {
286
+ type: 'number',
287
+ description: 'Min keyword match ratio 0-1 (default: 0.2). Higher = stricter.',
288
+ default: 0.2,
289
+ },
290
+ },
291
+ required: [],
292
+ },
293
+ },
294
+ // memory_stats is hidden — agents rarely need it proactively. Mentioned in
295
+ // hints when storage is running low. The handler still works if called directly.
296
+ {
297
+ name: 'memory_bootstrap',
298
+ description: 'First-time setup: scan repo structure, README, and build system to seed initial knowledge. Run once per new codebase.',
299
+ inputSchema: {
300
+ type: 'object',
301
+ properties: {
302
+ lobe: lobeProperty,
303
+ },
304
+ required: [],
305
+ },
306
+ },
307
+ // memory_diagnose is intentionally hidden from the tool list — it clutters
308
+ // agent tool discovery and should only be called when directed by error messages
309
+ // or crash reports. The handler still works if called directly.
310
+ ],
311
+ }));
312
+ // --- Tool handlers ---
313
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
314
+ const { name, arguments: rawArgs } = request.params;
315
+ lastToolCall = name; // track for crash context
316
+ const args = normalizeArgs(name, rawArgs, lobeNames);
317
+ // In safe mode, only memory_diagnose and memory_list_lobes work
318
+ if (serverMode.kind === 'safe-mode' && name !== 'memory_diagnose' && name !== 'memory_list_lobes') {
319
+ return {
320
+ content: [{
321
+ type: 'text',
322
+ text: [
323
+ `## ⚠ Memory MCP is in Safe Mode`,
324
+ ``,
325
+ `**Reason:** ${serverMode.error}`,
326
+ ``,
327
+ `The server is alive but cannot serve knowledge. Available tools in safe mode:`,
328
+ `- **memory_diagnose** — see crash details and recovery steps`,
329
+ `- **memory_list_lobes** — see server configuration`,
330
+ ``,
331
+ `### Recovery Steps`,
332
+ ...serverMode.recovery.map(s => `- ${s}`),
333
+ ].join('\n'),
334
+ }],
335
+ isError: true,
336
+ };
337
+ }
338
+ try {
339
+ // Ensure config is fresh before handling any tool
340
+ await configManager.ensureFresh();
341
+ switch (name) {
342
+ case 'memory_list_lobes': {
343
+ // Delegates to shared builder — same data as memory://lobes resource
344
+ const lobeInfo = await buildLobeInfo();
345
+ const globalStats = await globalStore.stats();
346
+ const result = {
347
+ serverMode: serverMode.kind,
348
+ globalStore: {
349
+ memoryPath: globalMemoryPath,
350
+ entries: globalStats.totalEntries,
351
+ storageUsed: globalStats.storageSize,
352
+ topics: 'user, preferences (shared across all lobes)',
353
+ },
354
+ lobes: lobeInfo,
355
+ configFile: configFileDisplay(),
356
+ configSource: configOrigin.source,
357
+ totalLobes: lobeInfo.length,
358
+ degradedLobes: lobeInfo.filter((l) => l.health === 'degraded').length,
359
+ };
360
+ return {
361
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
362
+ };
363
+ }
364
+ case 'memory_store': {
365
+ const { lobe: rawLobe, topic: rawTopic, title, content, sources, references, trust: rawTrust } = z.object({
366
+ lobe: z.string().optional(),
367
+ topic: z.string(),
368
+ title: z.string().min(1),
369
+ content: z.string().min(1),
370
+ sources: z.array(z.string()).default([]),
371
+ references: z.array(z.string()).default([]),
372
+ trust: z.enum(['user', 'agent-confirmed', 'agent-inferred']).default('agent-inferred'),
373
+ }).parse(args);
374
+ // Validate topic at boundary
375
+ const topic = parseTopicScope(rawTopic);
376
+ if (!topic) {
377
+ return {
378
+ content: [{ type: 'text', text: `Invalid topic: "${rawTopic}". Valid: user | preferences | architecture | conventions | gotchas | recent-work | modules/<name>` }],
379
+ isError: true,
380
+ };
381
+ }
382
+ // Trust is already validated by Zod enum, but use our parser for consistency
383
+ const trust = parseTrustLevel(rawTrust) ?? 'agent-inferred';
384
+ // Auto-detect lobe from file paths when lobe is omitted and multiple lobes exist
385
+ let effectiveLobe = rawLobe;
386
+ if (!effectiveLobe && configManager.getLobeNames().length > 1) {
387
+ const allPaths = [...sources, ...references];
388
+ effectiveLobe = inferLobeFromPaths(allPaths);
389
+ }
390
+ // Resolve store — after this point, rawLobe is never used again
391
+ const isGlobal = GLOBAL_TOPICS.has(topic);
392
+ const ctx = resolveToolContext(effectiveLobe, { isGlobal });
393
+ if (!ctx.ok)
394
+ return contextError(ctx);
395
+ const result = await ctx.store.store(topic, title, content, sources,
396
+ // User/preferences default to 'user' trust unless explicitly set otherwise
397
+ isGlobal && trust === 'agent-inferred' ? 'user' : trust, references);
398
+ if (!result.stored) {
399
+ return {
400
+ content: [{ type: 'text', text: `[${ctx.label}] Failed to store: ${result.warning}` }],
401
+ isError: true,
402
+ };
403
+ }
404
+ const lines = [
405
+ `[${ctx.label}] Stored entry ${result.id} in ${result.topic} (confidence: ${result.confidence})`,
406
+ ];
407
+ if (result.warning)
408
+ lines.push(`Note: ${result.warning}`);
409
+ // Limit to at most 2 hint sections per response to prevent hint fatigue.
410
+ // Priority: dedup > ephemeral > preferences (dedup is actionable and high-signal,
411
+ // ephemeral warnings affect entry quality, preferences are informational).
412
+ let hintCount = 0;
413
+ // Dedup: surface related entries in the same topic
414
+ if (result.relatedEntries && result.relatedEntries.length > 0 && hintCount < 2) {
415
+ hintCount++;
416
+ lines.push('');
417
+ lines.push('⚠ Similar entries found in the same topic:');
418
+ for (const r of result.relatedEntries) {
419
+ lines.push(` - ${r.id}: "${r.title}" (confidence: ${r.confidence})`);
420
+ lines.push(` Content: ${r.content.length > 120 ? r.content.substring(0, 120) + '...' : r.content}`);
421
+ }
422
+ lines.push('');
423
+ lines.push('To consolidate: memory_correct(id: "<old-id>", action: "replace", correction: "<merged content>") then memory_correct(id: "<new-id>", action: "delete")');
424
+ }
425
+ // Ephemeral content warning — soft nudge, never blocking
426
+ if (result.ephemeralWarning && hintCount < 2) {
427
+ hintCount++;
428
+ lines.push('');
429
+ lines.push(`⏳ ${result.ephemeralWarning}`);
430
+ }
431
+ // Preference surfacing: show relevant preferences for non-preference entries
432
+ if (result.relevantPreferences && result.relevantPreferences.length > 0 && hintCount < 2) {
433
+ hintCount++;
434
+ lines.push('');
435
+ lines.push('📌 Relevant preferences:');
436
+ for (const p of result.relevantPreferences) {
437
+ lines.push(` - [pref] ${p.title}: ${p.content.length > 120 ? p.content.substring(0, 120) + '...' : p.content}`);
438
+ }
439
+ lines.push('');
440
+ lines.push('Review the stored entry against these preferences for potential conflicts.');
441
+ }
442
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
443
+ }
444
+ case 'memory_query': {
445
+ const { lobe: rawLobe, scope, detail, filter, branch } = z.object({
446
+ lobe: z.string().optional(),
447
+ scope: z.string().default('*'),
448
+ detail: z.enum(['brief', 'standard', 'full']).default('brief'),
449
+ filter: z.string().optional(),
450
+ branch: z.string().optional(),
451
+ }).parse(args ?? {});
452
+ const isGlobalQuery = GLOBAL_TOPICS.has(scope);
453
+ // For global topics (user, preferences), always route to global store.
454
+ // For lobe topics: if lobe specified → single lobe. If omitted → ALL healthy lobes.
455
+ let lobeEntries = [];
456
+ const entryLobeMap = new Map(); // entry id → lobe name (for cross-lobe labeling)
457
+ let label;
458
+ let primaryStore;
459
+ let isMultiLobe = false;
460
+ if (isGlobalQuery) {
461
+ const ctx = resolveToolContext(rawLobe, { isGlobal: true });
462
+ if (!ctx.ok)
463
+ return contextError(ctx);
464
+ label = ctx.label;
465
+ primaryStore = ctx.store;
466
+ const result = await ctx.store.query(scope, detail, filter, branch);
467
+ for (const e of result.entries)
468
+ entryLobeMap.set(e.id, 'global');
469
+ lobeEntries = [...result.entries];
470
+ }
471
+ else if (rawLobe) {
472
+ const ctx = resolveToolContext(rawLobe);
473
+ if (!ctx.ok)
474
+ return contextError(ctx);
475
+ label = ctx.label;
476
+ primaryStore = ctx.store;
477
+ const result = await ctx.store.query(scope, detail, filter, branch);
478
+ lobeEntries = [...result.entries];
479
+ }
480
+ else {
481
+ // Search all healthy lobes — read operations shouldn't require lobe selection
482
+ const allLobeNames = configManager.getLobeNames();
483
+ isMultiLobe = allLobeNames.length > 1;
484
+ label = allLobeNames.length === 1 ? allLobeNames[0] : 'all';
485
+ for (const lobeName of allLobeNames) {
486
+ const store = configManager.getStore(lobeName);
487
+ if (!store)
488
+ continue;
489
+ if (!primaryStore)
490
+ primaryStore = store;
491
+ const result = await store.query(scope, detail, filter, branch);
492
+ for (const e of result.entries)
493
+ entryLobeMap.set(e.id, lobeName);
494
+ lobeEntries.push(...result.entries);
495
+ }
496
+ }
497
+ // For wildcard queries on non-global topics, also include global store entries
498
+ let globalEntries = [];
499
+ if (scope === '*' && !isGlobalQuery) {
500
+ const globalResult = await globalStore.query('*', detail, filter);
501
+ for (const e of globalResult.entries)
502
+ entryLobeMap.set(e.id, 'global');
503
+ globalEntries = [...globalResult.entries];
504
+ }
505
+ // Merge global + lobe entries, dedupe by id, sort by relevance score
506
+ const seenQueryIds = new Set();
507
+ const allEntries = [...globalEntries, ...lobeEntries]
508
+ .filter(e => {
509
+ if (seenQueryIds.has(e.id))
510
+ return false;
511
+ seenQueryIds.add(e.id);
512
+ return true;
513
+ })
514
+ .sort((a, b) => b.relevanceScore - a.relevanceScore);
515
+ if (allEntries.length === 0) {
516
+ const scopeHint = scope !== '*'
517
+ ? ` Try scope: "*" to search all topics, or use filter: "${filter ?? scope}" to search by keyword.`
518
+ : '';
519
+ return {
520
+ content: [{
521
+ type: 'text',
522
+ text: `[${label}] No entries found for scope "${scope}"${filter ? ` with filter "${filter}"` : ''}.${scopeHint}`,
523
+ }],
524
+ };
525
+ }
526
+ const lines = allEntries.map(e => {
527
+ const freshIndicator = e.fresh ? '' : ' [stale]';
528
+ const lobeTag = isMultiLobe ? ` [${entryLobeMap.get(e.id) ?? '?'}]` : '';
529
+ if (detail === 'brief') {
530
+ return `- **${e.title}** (${e.id}${lobeTag}, confidence: ${e.confidence})${freshIndicator}\n ${e.summary}`;
531
+ }
532
+ if (detail === 'full') {
533
+ const meta = [
534
+ `ID: ${e.id}`,
535
+ isMultiLobe ? `Lobe: ${entryLobeMap.get(e.id) ?? '?'}` : null,
536
+ `Confidence: ${e.confidence}`,
537
+ `Trust: ${e.trust}`,
538
+ `Fresh: ${e.fresh}`,
539
+ e.sources?.length ? `Sources: ${e.sources.join(', ')}` : null,
540
+ e.references?.length ? `References: ${e.references.join(', ')}` : null,
541
+ `Created: ${e.created}`,
542
+ `Last accessed: ${e.lastAccessed}`,
543
+ e.gitSha ? `Git SHA: ${e.gitSha}` : null,
544
+ ].filter(Boolean).join('\n');
545
+ return `### ${e.title}\n${meta}\n\n${e.content}`;
546
+ }
547
+ if (detail === 'standard' && e.references?.length) {
548
+ return `### ${e.title}\n*${e.id}${lobeTag} | confidence: ${e.confidence}${freshIndicator}*\nReferences: ${e.references.join(', ')}\n\n${e.summary}`;
549
+ }
550
+ return `### ${e.title}\n*${e.id}${lobeTag} | confidence: ${e.confidence}${freshIndicator}*\n\n${e.summary}`;
551
+ });
552
+ const totalCount = allEntries.length;
553
+ let text = `## [${label}] Query: ${scope} (${totalCount} entries)\n\n${lines.join('\n\n')}`;
554
+ // Conflict detection: compare entry pairs in the result set.
555
+ if (primaryStore) {
556
+ const rawEntries = primaryStore.getEntriesByIds(allEntries.map(e => e.id));
557
+ const conflicts = primaryStore.detectConflicts(rawEntries);
558
+ if (conflicts.length > 0) {
559
+ text += '\n\n' + formatConflictWarning(conflicts);
560
+ }
561
+ }
562
+ // Hints: teach the agent about capabilities it may not know about
563
+ const hints = [];
564
+ // Nudge: when searching all lobes, remind the agent to specify one for targeted results
565
+ const allQueryLobeNames = configManager.getLobeNames();
566
+ if (!rawLobe && !isGlobalQuery && allQueryLobeNames.length > 1) {
567
+ hints.push(`Searched all lobes. For targeted results use lobe: "${allQueryLobeNames[0]}" (available: ${allQueryLobeNames.join(', ')}).`);
568
+ }
569
+ if (detail !== 'full') {
570
+ hints.push('Use detail: "full" to see complete entry content.');
571
+ }
572
+ if (filter && !filter.includes(' ') && !filter.includes('|') && !filter.includes('-')) {
573
+ hints.push('Tip: combine terms with spaces (AND), | (OR), -term (NOT). Example: "reducer sealed -deprecated"');
574
+ }
575
+ if (!filter) {
576
+ hints.push('Tip: add filter: "keyword" to search within results.');
577
+ }
578
+ if (hints.length > 0) {
579
+ text += `\n\n---\n*${hints.join(' ')}*`;
580
+ }
581
+ return { content: [{ type: 'text', text }] };
582
+ }
583
+ case 'memory_correct': {
584
+ const { lobe: rawLobe, id, correction, action } = z.object({
585
+ lobe: z.string().optional(),
586
+ id: z.string().min(1),
587
+ correction: z.string().optional(),
588
+ action: z.enum(['append', 'replace', 'delete']),
589
+ }).parse(args);
590
+ // Replace requires non-empty content; append allows empty string (acts as a timestamp touch
591
+ // to refresh lastAccessed without changing content — useful for stale entry verification)
592
+ if (action === 'replace' && !correction) {
593
+ return {
594
+ content: [{ type: 'text', text: 'Correction text is required for replace action.' }],
595
+ isError: true,
596
+ };
597
+ }
598
+ if (action === 'append' && correction === undefined) {
599
+ return {
600
+ content: [{ type: 'text', text: 'Correction text is required for append action. Use "" to refresh lastAccessed without changing content.' }],
601
+ isError: true,
602
+ };
603
+ }
604
+ // Resolve store — route global entries (user-*, pref-*) to global store
605
+ const isGlobalEntry = id.startsWith('user-') || id.startsWith('pref-');
606
+ const ctx = resolveToolContext(rawLobe, { isGlobal: isGlobalEntry });
607
+ if (!ctx.ok)
608
+ return contextError(ctx);
609
+ const result = await ctx.store.correct(id, correction ?? '', action);
610
+ if (!result.corrected) {
611
+ // If not found in the targeted store, try the other one as fallback
612
+ if (isGlobalEntry) {
613
+ const lobeCtx = resolveToolContext(rawLobe);
614
+ if (lobeCtx.ok) {
615
+ const lobeResult = await lobeCtx.store.correct(id, correction ?? '', action);
616
+ if (lobeResult.corrected) {
617
+ const text = action === 'delete'
618
+ ? `[${lobeCtx.label}] Deleted entry ${id}.`
619
+ : `[${lobeCtx.label}] Corrected entry ${id} (action: ${action}, confidence: ${lobeResult.newConfidence}, trust: ${lobeResult.trust}).`;
620
+ return { content: [{ type: 'text', text }] };
621
+ }
622
+ }
623
+ }
624
+ return {
625
+ content: [{ type: 'text', text: `[${ctx.label}] Failed to correct: ${result.error}` }],
626
+ isError: true,
627
+ };
628
+ }
629
+ const lines = [];
630
+ if (action === 'delete') {
631
+ lines.push(`[${ctx.label}] Deleted entry ${id}.`);
632
+ }
633
+ else {
634
+ lines.push(`[${ctx.label}] Corrected entry ${id} (action: ${action}, confidence: ${result.newConfidence}, trust: ${result.trust}).`);
635
+ // Piggyback: suggest storing as a preference if the correction seems generalizable
636
+ if (correction && correction.length > 20) {
637
+ lines.push('');
638
+ lines.push('💡 If this correction reflects a general preference or rule (not just a one-time fix),');
639
+ lines.push(`consider: memory_store(topic: "preferences", title: "<short rule>", content: "${correction.length > 60 ? correction.substring(0, 60) + '...' : correction}", trust: "user")`);
640
+ }
641
+ }
642
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
643
+ }
644
+ case 'memory_context': {
645
+ const { lobe: rawLobe, context, maxResults, minMatch } = z.object({
646
+ lobe: z.string().optional(),
647
+ context: z.string().optional(),
648
+ maxResults: z.number().optional(),
649
+ minMatch: z.number().min(0).max(1).optional(),
650
+ }).parse(args ?? {});
651
+ // --- Briefing mode: no context provided → user + preferences + stale nudges ---
652
+ if (!context) {
653
+ // Surface previous crash report at the top if one exists
654
+ const previousCrash = await readLatestCrash();
655
+ const crashSection = previousCrash
656
+ ? `## ⚠ Previous Crash Detected\n${formatCrashSummary(previousCrash)}\nRun **memory_diagnose** for full details and recovery steps.\n`
657
+ : '';
658
+ if (previousCrash)
659
+ await clearLatestCrash();
660
+ // Surface degraded lobes warning
661
+ const allBriefingLobeNames = configManager.getLobeNames();
662
+ const degradedLobeNames = allBriefingLobeNames.filter(n => configManager.getLobeHealth(n)?.status === 'degraded');
663
+ const degradedSection = degradedLobeNames.length > 0
664
+ ? `## ⚠ Degraded Lobes: ${degradedLobeNames.join(', ')}\nRun **memory_diagnose** for details.\n`
665
+ : '';
666
+ // Global store holds user + preferences — always included
667
+ const globalBriefing = await globalStore.briefing(300);
668
+ const sections = [];
669
+ if (crashSection)
670
+ sections.push(crashSection);
671
+ if (degradedSection)
672
+ sections.push(degradedSection);
673
+ if (globalBriefing.entryCount > 0) {
674
+ sections.push(globalBriefing.briefing);
675
+ }
676
+ // Collect stale entries and entry counts across all lobes
677
+ const allStale = [];
678
+ if (globalBriefing.staleDetails)
679
+ allStale.push(...globalBriefing.staleDetails);
680
+ let totalEntries = globalBriefing.entryCount;
681
+ let totalStale = globalBriefing.staleEntries;
682
+ for (const lobeName of allBriefingLobeNames) {
683
+ const health = configManager.getLobeHealth(lobeName);
684
+ if (health?.status === 'degraded')
685
+ continue;
686
+ const store = configManager.getStore(lobeName);
687
+ if (!store)
688
+ continue;
689
+ const lobeBriefing = await store.briefing(100); // just enough for stale data + counts
690
+ if (lobeBriefing.staleDetails)
691
+ allStale.push(...lobeBriefing.staleDetails);
692
+ totalEntries += lobeBriefing.entryCount;
693
+ totalStale += lobeBriefing.staleEntries;
694
+ }
695
+ if (allStale.length > 0) {
696
+ sections.push(formatStaleSection(allStale));
697
+ }
698
+ if (sections.length === 0) {
699
+ sections.push('No knowledge stored yet. As you work, store observations with memory_store. Try memory_bootstrap to seed initial knowledge from the repo.');
700
+ }
701
+ const briefingHints = [];
702
+ briefingHints.push(`${totalEntries} entries${totalStale > 0 ? ` (${totalStale} stale)` : ''} across ${allBriefingLobeNames.length} ${allBriefingLobeNames.length === 1 ? 'lobe' : 'lobes'}.`);
703
+ briefingHints.push('Use memory_context(context: "what you are about to do") for task-specific knowledge.');
704
+ if (allBriefingLobeNames.length > 1) {
705
+ briefingHints.push(`Available lobes: ${allBriefingLobeNames.join(', ')}.`);
706
+ }
707
+ let text = sections.join('\n\n---\n\n');
708
+ text += `\n\n---\n*${briefingHints.join(' ')}*`;
709
+ return { content: [{ type: 'text', text }] };
710
+ }
711
+ // --- Search mode: context provided → keyword search across all topics ---
712
+ const max = maxResults ?? 10;
713
+ const threshold = minMatch ?? 0.2;
714
+ const allLobeResults = [];
715
+ const ctxEntryLobeMap = new Map(); // entry id → lobe name
716
+ let label;
717
+ let primaryStore;
718
+ let isCtxMultiLobe = false;
719
+ if (rawLobe) {
720
+ const ctx = resolveToolContext(rawLobe);
721
+ if (!ctx.ok)
722
+ return contextError(ctx);
723
+ label = ctx.label;
724
+ primaryStore = ctx.store;
725
+ const lobeResults = await ctx.store.contextSearch(context, max, undefined, threshold);
726
+ allLobeResults.push(...lobeResults);
727
+ }
728
+ else {
729
+ // Search all healthy lobes — read operations shouldn't require lobe selection
730
+ const allLobeNames = configManager.getLobeNames();
731
+ isCtxMultiLobe = allLobeNames.length > 1;
732
+ label = allLobeNames.length === 1 ? allLobeNames[0] : 'all';
733
+ for (const lobeName of allLobeNames) {
734
+ const store = configManager.getStore(lobeName);
735
+ if (!store)
736
+ continue;
737
+ if (!primaryStore)
738
+ primaryStore = store;
739
+ const lobeResults = await store.contextSearch(context, max, undefined, threshold);
740
+ for (const r of lobeResults)
741
+ ctxEntryLobeMap.set(r.entry.id, lobeName);
742
+ allLobeResults.push(...lobeResults);
743
+ }
744
+ }
745
+ // Always include global store (user + preferences)
746
+ const globalResults = await globalStore.contextSearch(context, max, undefined, threshold);
747
+ for (const r of globalResults)
748
+ ctxEntryLobeMap.set(r.entry.id, 'global');
749
+ // Merge, dedupe by entry id, re-sort by score, take top N
750
+ const seenIds = new Set();
751
+ const results = [...globalResults, ...allLobeResults]
752
+ .sort((a, b) => b.score - a.score)
753
+ .filter(r => {
754
+ if (seenIds.has(r.entry.id))
755
+ return false;
756
+ seenIds.add(r.entry.id);
757
+ return true;
758
+ })
759
+ .slice(0, max);
760
+ if (results.length === 0) {
761
+ return {
762
+ content: [{
763
+ type: 'text',
764
+ text: `[${label}] No relevant knowledge found for: "${context}"\n\nThis is fine — proceed without prior context. As you learn things worth remembering, store them with memory_store.\nTry memory_query(scope: "*") to browse all entries.`,
765
+ }],
766
+ };
767
+ }
768
+ const sections = [`## [${label}] Context: "${context}"\n`];
769
+ // Group results by topic for readability
770
+ const byTopic = new Map();
771
+ for (const r of results) {
772
+ const list = byTopic.get(r.entry.topic) ?? [];
773
+ list.push(r);
774
+ byTopic.set(r.entry.topic, list);
775
+ }
776
+ // Topic display order
777
+ const topicOrder = ['user', 'preferences', 'gotchas', 'conventions', 'architecture'];
778
+ const orderedTopics = [
779
+ ...topicOrder.filter(t => byTopic.has(t)),
780
+ ...Array.from(byTopic.keys()).filter(t => !topicOrder.includes(t)).sort(),
781
+ ];
782
+ for (const topic of orderedTopics) {
783
+ const topicResults = byTopic.get(topic);
784
+ const heading = topic === 'user' ? 'About You'
785
+ : topic === 'preferences' ? 'Your Preferences'
786
+ : topic === 'gotchas' ? 'Gotchas'
787
+ : topic.startsWith('modules/') ? `Module: ${topic.split('/')[1]}`
788
+ : topic.charAt(0).toUpperCase() + topic.slice(1);
789
+ sections.push(`### ${heading}`);
790
+ for (const r of topicResults) {
791
+ const marker = topic === 'gotchas' ? '[!] ' : topic === 'preferences' ? '[pref] ' : '';
792
+ const keywords = r.matchedKeywords.length > 0 ? ` (matched: ${r.matchedKeywords.join(', ')})` : '';
793
+ const lobeLabel = isCtxMultiLobe ? ` [${ctxEntryLobeMap.get(r.entry.id) ?? '?'}]` : '';
794
+ sections.push(`- **${marker}${r.entry.title}**${lobeLabel}: ${r.entry.content}${keywords}`);
795
+ }
796
+ sections.push('');
797
+ }
798
+ // Conflict detection on the result set (cross-topic — exactly when the agent needs it)
799
+ if (primaryStore) {
800
+ const ctxConflicts = primaryStore.detectConflicts(results.map(r => r.entry));
801
+ if (ctxConflicts.length > 0) {
802
+ sections.push(formatConflictWarning(ctxConflicts));
803
+ }
804
+ }
805
+ // Collect all matched keywords and topics for the dedup hint
806
+ const allMatchedKeywords = new Set();
807
+ const matchedTopics = new Set();
808
+ for (const r of results) {
809
+ for (const kw of r.matchedKeywords)
810
+ allMatchedKeywords.add(kw);
811
+ matchedTopics.add(r.entry.topic);
812
+ }
813
+ // Hints
814
+ const ctxHints = [];
815
+ // Nudge: when searching all lobes, remind the agent to specify one for targeted results
816
+ const allCtxLobeNames = configManager.getLobeNames();
817
+ if (!rawLobe && allCtxLobeNames.length > 1) {
818
+ ctxHints.push(`Searched all lobes. For faster, targeted results use lobe: "${allCtxLobeNames[0]}" (available: ${allCtxLobeNames.join(', ')}).`);
819
+ }
820
+ if (results.length >= max) {
821
+ ctxHints.push(`Showing top ${max} results. Increase maxResults or raise minMatch to refine.`);
822
+ }
823
+ if (threshold <= 0.2 && results.length > 5) {
824
+ ctxHints.push('Too many results? Use minMatch: 0.4 for stricter matching.');
825
+ }
826
+ // Session dedup hint — tell the agent not to re-call for these keywords
827
+ if (allMatchedKeywords.size > 0) {
828
+ const kwList = Array.from(allMatchedKeywords).sort().join(', ');
829
+ const topicList = Array.from(matchedTopics).sort().join(', ');
830
+ ctxHints.push(`Context loaded for: ${kwList} (${topicList}). ` +
831
+ `This knowledge is now in your conversation — no need to call memory_context again for these terms this session.`);
832
+ }
833
+ if (ctxHints.length > 0) {
834
+ sections.push(`---\n*${ctxHints.join(' ')}*`);
835
+ }
836
+ return { content: [{ type: 'text', text: sections.join('\n') }] };
837
+ }
838
+ case 'memory_stats': {
839
+ const { lobe: rawLobe } = z.object({
840
+ lobe: z.string().optional(),
841
+ }).parse(args ?? {});
842
+ // Always include global stats
843
+ const globalStats = await globalStore.stats();
844
+ // Single lobe stats
845
+ if (rawLobe) {
846
+ const ctx = resolveToolContext(rawLobe);
847
+ if (!ctx.ok)
848
+ return contextError(ctx);
849
+ const result = await ctx.store.stats();
850
+ const sections = [formatStats('global (user + preferences)', globalStats), formatStats(ctx.label, result)];
851
+ return { content: [{ type: 'text', text: sections.join('\n\n---\n\n') }] };
852
+ }
853
+ // Combined stats across all lobes
854
+ const sections = [formatStats('global (user + preferences)', globalStats)];
855
+ const allLobeNames = configManager.getLobeNames();
856
+ for (const lobeName of allLobeNames) {
857
+ const store = configManager.getStore(lobeName);
858
+ const result = await store.stats();
859
+ sections.push(formatStats(lobeName, result));
860
+ }
861
+ return { content: [{ type: 'text', text: sections.join('\n\n---\n\n') }] };
862
+ }
863
+ case 'memory_bootstrap': {
864
+ const { lobe: rawLobe } = z.object({
865
+ lobe: z.string().optional(),
866
+ }).parse(args);
867
+ // Resolve store — after this point, rawLobe is never used again
868
+ const ctx = resolveToolContext(rawLobe);
869
+ if (!ctx.ok)
870
+ return contextError(ctx);
871
+ const results = await ctx.store.bootstrap();
872
+ const stored = results.filter(r => r.stored);
873
+ const failed = results.filter(r => !r.stored);
874
+ let text = `## [${ctx.label}] Bootstrap Complete\n\nStored ${stored.length} entries:`;
875
+ for (const r of stored) {
876
+ text += `\n- ${r.id}: ${r.topic} (${r.file})`;
877
+ }
878
+ if (failed.length > 0) {
879
+ text += `\n\n${failed.length} entries failed:`;
880
+ for (const r of failed) {
881
+ text += `\n- ${r.warning}`;
882
+ }
883
+ }
884
+ return { content: [{ type: 'text', text }] };
885
+ }
886
+ case 'memory_diagnose': {
887
+ // Delegates to shared builder — same data as memory://diagnostics resource
888
+ const { showCrashHistory } = z.object({
889
+ showCrashHistory: z.boolean().default(false),
890
+ }).parse(args ?? {});
891
+ const text = await buildDiagnosticsText(showCrashHistory);
892
+ return { content: [{ type: 'text', text }] };
893
+ }
894
+ default:
895
+ return {
896
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
897
+ isError: true,
898
+ };
899
+ }
900
+ }
901
+ catch (error) {
902
+ const message = error instanceof Error ? error.message : String(error);
903
+ // Provide helpful hints for common Zod validation errors
904
+ let hint = '';
905
+ if (message.includes('"lobe"') && message.includes('Required')) {
906
+ const lobeNames = configManager.getLobeNames();
907
+ hint = `\n\nHint: lobe is required. Use memory_list_lobes to see available lobes. Available: ${lobeNames.join(', ')}`;
908
+ }
909
+ else if (message.includes('"topic"') || message.includes('"title"') || message.includes('"content"')) {
910
+ hint = '\n\nHint: memory_store requires: lobe, topic (architecture|conventions|gotchas|recent-work), title, content';
911
+ }
912
+ else if (message.includes('"scope"')) {
913
+ hint = '\n\nHint: memory_query requires: lobe, scope (architecture|conventions|gotchas|recent-work|modules/<name>|* for all)';
914
+ }
915
+ return {
916
+ content: [{ type: 'text', text: `Error: ${message}${hint}` }],
917
+ isError: true,
918
+ };
919
+ }
920
+ });
921
+ // --- Helpers ---
922
+ /** Build lobe info array — shared by memory_list_lobes tool and memory://lobes resource */
923
+ async function buildLobeInfo() {
924
+ const lobeNames = configManager.getLobeNames();
925
+ return Promise.all(lobeNames.map(async (name) => {
926
+ const config = configManager.getLobeConfig(name);
927
+ const health = configManager.getLobeHealth(name) ?? { status: 'healthy' };
928
+ const store = configManager.getStore(name);
929
+ if (health.status === 'degraded' || !store) {
930
+ return {
931
+ name,
932
+ root: config.repoRoot,
933
+ memoryPath: config.memoryPath,
934
+ health: 'degraded',
935
+ error: health.status === 'degraded' ? health.error : 'Store not initialized',
936
+ recovery: health.status === 'degraded' ? health.recovery : ['Toggle MCP to restart'],
937
+ };
938
+ }
939
+ const stats = await store.stats();
940
+ return {
941
+ name,
942
+ root: config.repoRoot,
943
+ memoryPath: config.memoryPath,
944
+ health: 'healthy',
945
+ entries: stats.totalEntries,
946
+ storageUsed: stats.storageSize,
947
+ storageBudget: `${Math.round(config.storageBudgetBytes / 1024 / 1024)}MB`,
948
+ };
949
+ }));
950
+ }
951
+ /** Build diagnostics text — shared by memory_diagnose tool and memory://diagnostics resource */
952
+ async function buildDiagnosticsText(showFullCrashHistory) {
953
+ const sections = [];
954
+ const lobeNames = configManager.getLobeNames();
955
+ const configOrigin = configManager.getConfigOrigin();
956
+ sections.push(`## Memory MCP Server Diagnostics`);
957
+ sections.push('');
958
+ sections.push(`**Server mode:** ${serverMode.kind}`);
959
+ sections.push(`**Uptime:** ${Math.round((Date.now() - serverStartTime) / 1000)}s`);
960
+ sections.push(`**Config source:** ${configOrigin.source}`);
961
+ sections.push(`**Lobes:** ${lobeNames.length} configured`);
962
+ sections.push('');
963
+ sections.push(`### Lobe Health`);
964
+ for (const lobeName of lobeNames) {
965
+ const health = configManager.getLobeHealth(lobeName) ?? { status: 'healthy' };
966
+ if (health.status === 'healthy') {
967
+ const store = configManager.getStore(lobeName);
968
+ if (store) {
969
+ const stats = await store.stats();
970
+ sections.push(`- **${lobeName}**: ✅ healthy (${stats.totalEntries} entries, ${stats.storageSize}${stats.corruptFiles > 0 ? `, ${stats.corruptFiles} corrupt files` : ''})`);
971
+ }
972
+ else {
973
+ sections.push(`- **${lobeName}**: ⚠ store not initialized`);
974
+ }
975
+ }
976
+ else {
977
+ sections.push(`- **${lobeName}**: ❌ degraded — ${health.error}`);
978
+ for (const step of health.recovery) {
979
+ sections.push(` - ${step}`);
980
+ }
981
+ }
982
+ }
983
+ sections.push('');
984
+ try {
985
+ const globalStats = await globalStore.stats();
986
+ sections.push(`- **global store**: ✅ healthy (${globalStats.totalEntries} entries, ${globalStats.storageSize})`);
987
+ }
988
+ catch (e) {
989
+ sections.push(`- **global store**: ❌ error — ${e instanceof Error ? e.message : e}`);
990
+ }
991
+ sections.push('');
992
+ // Active behavior config — shows effective values and highlights user overrides
993
+ sections.push('### Active Behavior Config');
994
+ sections.push(formatBehaviorConfigSection(configBehavior));
995
+ sections.push('');
996
+ const latestCrash = await readLatestCrash();
997
+ if (latestCrash) {
998
+ sections.push('### Latest Crash');
999
+ sections.push(formatCrashReport(latestCrash));
1000
+ sections.push('');
1001
+ await clearLatestCrash();
1002
+ }
1003
+ else {
1004
+ sections.push('### Crash History');
1005
+ sections.push('No recent crashes recorded. ✅');
1006
+ sections.push('');
1007
+ }
1008
+ if (showFullCrashHistory) {
1009
+ const history = await readCrashHistory(10);
1010
+ if (history.length > 0) {
1011
+ sections.push('### Full Crash History (last 10)');
1012
+ for (const crash of history) {
1013
+ sections.push(`- **${crash.timestamp}** [${crash.type}]: ${crash.error.substring(0, 100)}`);
1014
+ sections.push(` Phase: ${crash.context.phase}, Uptime: ${crash.serverUptime}s`);
1015
+ }
1016
+ sections.push('');
1017
+ }
1018
+ }
1019
+ if (serverMode.kind === 'safe-mode') {
1020
+ sections.push('### Safe Mode Recovery');
1021
+ sections.push('The server is in safe mode — knowledge tools are disabled.');
1022
+ for (const step of serverMode.recovery) {
1023
+ sections.push(`- ${step}`);
1024
+ }
1025
+ }
1026
+ else if (serverMode.kind === 'degraded') {
1027
+ sections.push('### Degraded Mode');
1028
+ sections.push(`Some lobes have issues: ${serverMode.reason}`);
1029
+ sections.push('Healthy lobes continue to work normally.');
1030
+ }
1031
+ return sections.join('\n');
1032
+ }
1033
+ // --- Startup ---
1034
+ async function main() {
1035
+ markServerStarted();
1036
+ // Check for crash report from a previous run
1037
+ const previousCrash = await readLatestCrash();
1038
+ if (previousCrash) {
1039
+ const age = Math.round((Date.now() - new Date(previousCrash.timestamp).getTime()) / 1000);
1040
+ process.stderr.write(`[memory-mcp] Previous crash detected (${age}s ago): ${previousCrash.type} — ${previousCrash.error}\n`);
1041
+ process.stderr.write(`[memory-mcp] Crash report will be shown in memory_context and memory_diagnose.\n`);
1042
+ }
1043
+ // Initialize global store (user + preferences, shared across all lobes)
1044
+ try {
1045
+ await globalStore.init();
1046
+ process.stderr.write(`[memory-mcp] Global store → ${globalMemoryPath}\n`);
1047
+ }
1048
+ catch (error) {
1049
+ const msg = error instanceof Error ? error.message : String(error);
1050
+ process.stderr.write(`[memory-mcp] WARNING: Global store init failed: ${msg}\n`);
1051
+ }
1052
+ // Initialize each lobe independently — a broken lobe shouldn't prevent others from working
1053
+ let healthyLobes = 0;
1054
+ for (const [name, config] of lobeConfigs) {
1055
+ try {
1056
+ const store = new MarkdownMemoryStore(config);
1057
+ await store.init();
1058
+ stores.set(name, store);
1059
+ lobeHealth.set(name, { status: 'healthy' });
1060
+ healthyLobes++;
1061
+ process.stderr.write(`[memory-mcp] ✅ Lobe "${name}" → ${config.repoRoot} (memory: ${config.memoryPath})\n`);
1062
+ }
1063
+ catch (error) {
1064
+ const msg = error instanceof Error ? error.message : String(error);
1065
+ process.stderr.write(`[memory-mcp] ❌ Lobe "${name}" failed to init: ${msg}\n`);
1066
+ lobeHealth.set(name, {
1067
+ status: 'degraded',
1068
+ error: msg,
1069
+ since: new Date().toISOString(),
1070
+ recovery: [
1071
+ `Verify the repo root exists: ${config.repoRoot}`,
1072
+ 'Check file permissions on the memory directory.',
1073
+ 'If the repo was moved, update memory-config.json.',
1074
+ 'Toggle the MCP off/on to retry initialization.',
1075
+ ],
1076
+ });
1077
+ const report = buildCrashReport(error, 'lobe-init-failure', {
1078
+ phase: 'startup',
1079
+ activeLobe: name,
1080
+ configSource: configOrigin.source,
1081
+ lobeCount: lobeConfigs.size,
1082
+ });
1083
+ await writeCrashReport(report).catch(() => { });
1084
+ }
1085
+ }
1086
+ // Determine server mode based on lobe health
1087
+ if (healthyLobes === 0) {
1088
+ serverMode = {
1089
+ kind: 'safe-mode',
1090
+ error: `All ${lobeConfigs.size} lobes failed to initialize.`,
1091
+ recovery: [
1092
+ 'Check that repo paths in memory-config.json exist and are accessible.',
1093
+ 'Verify git is installed and functional.',
1094
+ 'Check file permissions on ~/.memory-mcp/.',
1095
+ 'Toggle the MCP off/on to retry.',
1096
+ 'Call memory_diagnose for detailed error information.',
1097
+ ],
1098
+ };
1099
+ process.stderr.write(`[memory-mcp] ⚠ SAFE MODE: all lobes failed. Server alive but degraded.\n`);
1100
+ }
1101
+ else if (healthyLobes < lobeConfigs.size) {
1102
+ const degradedNames = lobeNames.filter(n => lobeHealth.get(n)?.status === 'degraded');
1103
+ serverMode = {
1104
+ kind: 'degraded',
1105
+ reason: `${degradedNames.length} lobe(s) degraded: ${degradedNames.join(', ')}`,
1106
+ };
1107
+ process.stderr.write(`[memory-mcp] ⚠ DEGRADED: ${healthyLobes}/${lobeConfigs.size} lobes healthy.\n`);
1108
+ }
1109
+ // Migrate: move user + preferences entries from lobe stores to global store.
1110
+ // State-driven guard: skip if already completed (marker file present).
1111
+ const migrationMarker = path.join(globalMemoryPath, '.migrated');
1112
+ if (!existsSync(migrationMarker)) {
1113
+ let migrated = 0;
1114
+ for (const [name, store] of stores) {
1115
+ for (const topic of ['user', 'preferences']) {
1116
+ try {
1117
+ const result = await store.query(topic, 'full');
1118
+ for (const entry of result.entries) {
1119
+ try {
1120
+ const globalResult = await globalStore.query(topic, 'full');
1121
+ const alreadyExists = globalResult.entries.some(g => g.title === entry.title);
1122
+ if (!alreadyExists && entry.content) {
1123
+ const trust = parseTrustLevel(entry.trust ?? 'user') ?? 'user';
1124
+ await globalStore.store(topic, entry.title, entry.content, [...(entry.sources ?? [])], trust);
1125
+ process.stderr.write(`[memory-mcp] Migrated ${entry.id} ("${entry.title}") from [${name}] → global\n`);
1126
+ migrated++;
1127
+ }
1128
+ await store.correct(entry.id, '', 'delete');
1129
+ process.stderr.write(`[memory-mcp] Removed ${entry.id} from [${name}] (now in global)\n`);
1130
+ }
1131
+ catch (entryError) {
1132
+ process.stderr.write(`[memory-mcp] Migration error for ${entry.id} in [${name}]: ${entryError}\n`);
1133
+ }
1134
+ }
1135
+ }
1136
+ catch (topicError) {
1137
+ process.stderr.write(`[memory-mcp] Migration error querying ${topic} in [${name}]: ${topicError}\n`);
1138
+ }
1139
+ }
1140
+ }
1141
+ // Write marker atomically — future startups skip this block entirely
1142
+ try {
1143
+ writeFileSync(migrationMarker, new Date().toISOString(), 'utf-8');
1144
+ if (migrated > 0)
1145
+ process.stderr.write(`[memory-mcp] Migration complete: ${migrated} entries moved to global store.\n`);
1146
+ }
1147
+ catch { /* marker write is best-effort */ }
1148
+ }
1149
+ // Initialize ConfigManager with current config state
1150
+ configManager = new ConfigManager(configPath, { configs: lobeConfigs, origin: configOrigin }, stores, lobeHealth);
1151
+ const transport = new StdioServerTransport();
1152
+ // Handle transport errors — journal and exit
1153
+ transport.onerror = (error) => {
1154
+ process.stderr.write(`[memory-mcp] Transport error: ${error}\n`);
1155
+ const report = buildCrashReport(error, 'transport-error', currentCrashContext('running'));
1156
+ writeCrashReportSync(report);
1157
+ };
1158
+ server.onerror = (error) => {
1159
+ process.stderr.write(`[memory-mcp] Server error: ${error}\n`);
1160
+ };
1161
+ // Handle stdin/stdout pipe breaks
1162
+ process.stdin.on('end', () => {
1163
+ process.stderr.write('[memory-mcp] stdin closed — host disconnected. Exiting.\n');
1164
+ process.exit(0);
1165
+ });
1166
+ process.stdin.on('close', () => {
1167
+ process.stderr.write('[memory-mcp] stdin closed. Exiting.\n');
1168
+ process.exit(0);
1169
+ });
1170
+ process.stdout.on('error', (error) => {
1171
+ process.stderr.write(`[memory-mcp] stdout error (pipe broken?): ${error.message}\n`);
1172
+ process.exit(0);
1173
+ });
1174
+ await server.connect(transport);
1175
+ const modeStr = serverMode.kind === 'running' ? '' : ` [${serverMode.kind.toUpperCase()}]`;
1176
+ process.stderr.write(`[memory-mcp] Server started${modeStr} with ${healthyLobes}/${lobeConfigs.size} lobe(s) + global store\n`);
1177
+ // Graceful shutdown on signals
1178
+ const shutdown = () => {
1179
+ process.stderr.write('[memory-mcp] Shutting down gracefully.\n');
1180
+ process.exit(0);
1181
+ };
1182
+ process.on('SIGINT', shutdown);
1183
+ process.on('SIGTERM', shutdown);
1184
+ }
1185
+ main().catch((error) => {
1186
+ process.stderr.write(`[memory-mcp] Fatal startup error: ${error}\n`);
1187
+ if (error instanceof Error && error.stack) {
1188
+ process.stderr.write(`[memory-mcp] Stack: ${error.stack}\n`);
1189
+ }
1190
+ const report = buildCrashReport(error, 'startup-failure', {
1191
+ phase: 'startup',
1192
+ configSource: configOrigin.source,
1193
+ lobeCount: lobeConfigs.size,
1194
+ });
1195
+ writeCrashReportSync(report);
1196
+ process.exit(1);
1197
+ });