@exaudeus/memory-mcp 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/config-manager.d.ts +6 -0
- package/dist/config-manager.js +13 -1
- package/dist/config.js +45 -3
- package/dist/index.js +155 -179
- package/dist/lobe-resolution.d.ts +6 -2
- package/dist/lobe-resolution.js +29 -7
- package/dist/store.d.ts +5 -2
- package/dist/store.js +30 -10
- package/dist/types.d.ts +22 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ A Model Context Protocol (MCP) server that gives AI coding agents persistent, ev
|
|
|
8
8
|
|------|-------------|
|
|
9
9
|
| `memory_context` | Session start AND pre-task lookup. Call with no args for user + preferences + stale nudges; call with `context` for task-specific knowledge |
|
|
10
10
|
| `memory_query` | Structured search with brief/standard/full detail levels and AND/OR/NOT filter syntax. Scope defaults to `"*"` (all topics) |
|
|
11
|
-
| `memory_store` | Store a knowledge entry with dedup detection, preference surfacing,
|
|
11
|
+
| `memory_store` | Store a knowledge entry with dedup detection, preference surfacing, lobe auto-detection, and a review-required gate for likely-ephemeral content |
|
|
12
12
|
| `memory_correct` | Correct, update, or delete an existing entry (suggests storing as preference) |
|
|
13
13
|
| `memory_bootstrap` | First-use scan to seed knowledge from repo structure, README, and build files |
|
|
14
14
|
|
|
@@ -33,6 +33,7 @@ A Model Context Protocol (MCP) server that gives AI coding agents persistent, ev
|
|
|
33
33
|
|
|
34
34
|
- **Dedup detection**: When you store an entry, the response shows similar existing entries in the same topic (>35% keyword overlap) with consolidation instructions
|
|
35
35
|
- **Preference surfacing**: Storing a non-preference entry shows relevant preferences that might conflict
|
|
36
|
+
- **Ephemeral review gate**: Likely-ephemeral content is blocked before persistence by default. Re-run `memory_store(..., durabilityDecision: "store-anyway")` only when you intentionally want to keep it.
|
|
36
37
|
- **Piggyback hints**: `memory_correct` suggests storing corrections as reusable preferences
|
|
37
38
|
- **`memory_context`**: Describe your task in natural language and get ranked results across all topics with topic-based boosting (preferences 1.8x, gotchas 1.5x)
|
|
38
39
|
|
|
@@ -108,10 +109,12 @@ If no `memory-config.json` is found, the server falls back to environment variab
|
|
|
108
109
|
1. **Edit `memory-config.json`** (create if it doesn't exist)
|
|
109
110
|
2. **Add lobe entry:**
|
|
110
111
|
```json
|
|
112
|
+
{
|
|
111
113
|
"my-project": {
|
|
112
114
|
"root": "$HOME/git/my-project",
|
|
113
115
|
"budgetMB": 2
|
|
114
116
|
}
|
|
117
|
+
}
|
|
115
118
|
```
|
|
116
119
|
3. **Restart the memory MCP server**
|
|
117
120
|
4. **Verify:** Use `memory_list_lobes` to confirm it loaded
|
package/dist/config-manager.d.ts
CHANGED
|
@@ -27,10 +27,14 @@ export declare class ConfigManager {
|
|
|
27
27
|
private stores;
|
|
28
28
|
private lobeHealth;
|
|
29
29
|
private configMtime;
|
|
30
|
+
/** Cached alwaysInclude lobe names — recomputed atomically on reload. */
|
|
31
|
+
private cachedAlwaysIncludeLobes;
|
|
30
32
|
protected statFile(path: string): Promise<{
|
|
31
33
|
mtimeMs: number;
|
|
32
34
|
}>;
|
|
33
35
|
constructor(configPath: string, initial: LoadedConfig, initialStores: Map<string, MarkdownMemoryStore>, initialHealth: Map<string, LobeHealth>);
|
|
36
|
+
/** Derive alwaysInclude lobe names from config — pure, no side effects. */
|
|
37
|
+
private static computeAlwaysIncludeLobes;
|
|
34
38
|
/**
|
|
35
39
|
* Ensure config is fresh. Call at the start of every tool handler.
|
|
36
40
|
* Stats config file, reloads if mtime changed. Graceful on all errors.
|
|
@@ -46,4 +50,6 @@ export declare class ConfigManager {
|
|
|
46
50
|
getLobeHealth(lobe: string): LobeHealth | undefined;
|
|
47
51
|
getConfigOrigin(): ConfigOrigin;
|
|
48
52
|
getLobeConfig(lobe: string): MemoryConfig | undefined;
|
|
53
|
+
/** Returns lobe names where alwaysInclude is true. Cached; rebuilt atomically on hot-reload. */
|
|
54
|
+
getAlwaysIncludeLobes(): readonly string[];
|
|
49
55
|
}
|
package/dist/config-manager.js
CHANGED
|
@@ -28,6 +28,13 @@ export class ConfigManager {
|
|
|
28
28
|
this.stores = initialStores;
|
|
29
29
|
this.lobeHealth = initialHealth;
|
|
30
30
|
this.configMtime = Date.now(); // Initial mtime (will be updated on first stat)
|
|
31
|
+
this.cachedAlwaysIncludeLobes = ConfigManager.computeAlwaysIncludeLobes(this.lobeConfigs);
|
|
32
|
+
}
|
|
33
|
+
/** Derive alwaysInclude lobe names from config — pure, no side effects. */
|
|
34
|
+
static computeAlwaysIncludeLobes(configs) {
|
|
35
|
+
return Array.from(configs.entries())
|
|
36
|
+
.filter(([, config]) => config.alwaysInclude === true)
|
|
37
|
+
.map(([name]) => name);
|
|
31
38
|
}
|
|
32
39
|
/**
|
|
33
40
|
* Ensure config is fresh. Call at the start of every tool handler.
|
|
@@ -90,12 +97,13 @@ export class ConfigManager {
|
|
|
90
97
|
});
|
|
91
98
|
}
|
|
92
99
|
}
|
|
93
|
-
// Atomic swap
|
|
100
|
+
// Atomic swap — all derived state recomputed together
|
|
94
101
|
this.configOrigin = newConfig.origin;
|
|
95
102
|
this.lobeConfigs = newConfig.configs;
|
|
96
103
|
this.stores = newStores;
|
|
97
104
|
this.lobeHealth = newHealth;
|
|
98
105
|
this.configMtime = newMtime;
|
|
106
|
+
this.cachedAlwaysIncludeLobes = ConfigManager.computeAlwaysIncludeLobes(newConfig.configs);
|
|
99
107
|
const lobeCount = newConfig.configs.size;
|
|
100
108
|
const degradedCount = Array.from(newHealth.values()).filter(h => h.status === 'degraded').length;
|
|
101
109
|
const timestamp = new Date().toISOString();
|
|
@@ -123,4 +131,8 @@ export class ConfigManager {
|
|
|
123
131
|
getLobeConfig(lobe) {
|
|
124
132
|
return this.lobeConfigs.get(lobe);
|
|
125
133
|
}
|
|
134
|
+
/** Returns lobe names where alwaysInclude is true. Cached; rebuilt atomically on hot-reload. */
|
|
135
|
+
getAlwaysIncludeLobes() {
|
|
136
|
+
return this.cachedAlwaysIncludeLobes;
|
|
137
|
+
}
|
|
126
138
|
}
|
package/dist/config.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Priority: memory-config.json → env vars → single-repo default
|
|
4
4
|
// Graceful degradation: each source falls through to the next on failure.
|
|
5
|
-
import { readFileSync } from 'fs';
|
|
5
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
6
6
|
import { execFileSync } from 'child_process';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import os from 'os';
|
|
@@ -81,6 +81,42 @@ function resolveMemoryPath(repoRoot, workspaceName, explicitMemoryDir) {
|
|
|
81
81
|
}
|
|
82
82
|
return path.join(os.homedir(), '.memory-mcp', workspaceName);
|
|
83
83
|
}
|
|
84
|
+
/** If no lobe has alwaysInclude: true AND the legacy global store directory has actual entries,
|
|
85
|
+
* auto-create a "global" lobe pointing to it. Protects existing users who haven't updated their config.
|
|
86
|
+
* Only fires when the dir contains .md files — an empty dir doesn't trigger creation. */
|
|
87
|
+
function ensureAlwaysIncludeLobe(configs, behavior) {
|
|
88
|
+
const hasAlwaysInclude = Array.from(configs.values()).some(c => c.alwaysInclude);
|
|
89
|
+
if (hasAlwaysInclude)
|
|
90
|
+
return;
|
|
91
|
+
// Don't overwrite a user-defined "global" lobe — warn instead.
|
|
92
|
+
// Philosophy: "Make illegal states unrepresentable" — silently replacing config is a hidden state.
|
|
93
|
+
if (configs.has('global')) {
|
|
94
|
+
process.stderr.write(`[memory-mcp] Lobe "global" exists but has no alwaysInclude flag. ` +
|
|
95
|
+
`Add "alwaysInclude": true to your global lobe config to include it in all reads.\n`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const globalPath = path.join(os.homedir(), '.memory-mcp', 'global');
|
|
99
|
+
if (!existsSync(globalPath))
|
|
100
|
+
return;
|
|
101
|
+
// Only auto-create if the dir has actual memory entries (not just an empty directory)
|
|
102
|
+
try {
|
|
103
|
+
const files = readdirSync(globalPath);
|
|
104
|
+
if (!files.some(f => f.endsWith('.md')))
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
process.stderr.write(`[memory-mcp] Warning: could not read legacy global store at ${globalPath}: ${err}\n`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
configs.set('global', {
|
|
112
|
+
repoRoot: os.homedir(),
|
|
113
|
+
memoryPath: globalPath,
|
|
114
|
+
storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES,
|
|
115
|
+
alwaysInclude: true,
|
|
116
|
+
behavior,
|
|
117
|
+
});
|
|
118
|
+
process.stderr.write(`[memory-mcp] Auto-created "global" lobe (alwaysInclude) from existing ${globalPath}\n`);
|
|
119
|
+
}
|
|
84
120
|
/** Load lobe configs with priority: memory-config.json -> env vars -> single-repo default */
|
|
85
121
|
export function getLobeConfigs() {
|
|
86
122
|
const configs = new Map();
|
|
@@ -105,13 +141,15 @@ export function getLobeConfigs() {
|
|
|
105
141
|
repoRoot,
|
|
106
142
|
memoryPath: resolveMemoryPath(repoRoot, name, config.memoryDir),
|
|
107
143
|
storageBudgetBytes: (config.budgetMB ?? 2) * 1024 * 1024,
|
|
144
|
+
alwaysInclude: config.alwaysInclude ?? false,
|
|
108
145
|
behavior,
|
|
109
146
|
});
|
|
110
147
|
}
|
|
111
148
|
if (configs.size > 0) {
|
|
149
|
+
// Reuse the already-parsed behavior config for the alwaysInclude fallback
|
|
150
|
+
const resolvedBehavior = external.behavior ? behavior : undefined;
|
|
151
|
+
ensureAlwaysIncludeLobe(configs, resolvedBehavior);
|
|
112
152
|
process.stderr.write(`[memory-mcp] Loaded ${configs.size} lobe(s) from memory-config.json\n`);
|
|
113
|
-
// Pass resolved behavior at the top-level so diagnostics can surface active values
|
|
114
|
-
const resolvedBehavior = external.behavior ? parseBehaviorConfig(external.behavior) : undefined;
|
|
115
153
|
return { configs, origin: { source: 'file', path: configPath }, behavior: resolvedBehavior };
|
|
116
154
|
}
|
|
117
155
|
}
|
|
@@ -137,9 +175,11 @@ export function getLobeConfigs() {
|
|
|
137
175
|
repoRoot,
|
|
138
176
|
memoryPath: resolveMemoryPath(repoRoot, name, explicitDir),
|
|
139
177
|
storageBudgetBytes: storageBudget,
|
|
178
|
+
alwaysInclude: false,
|
|
140
179
|
});
|
|
141
180
|
}
|
|
142
181
|
if (configs.size > 0) {
|
|
182
|
+
ensureAlwaysIncludeLobe(configs);
|
|
143
183
|
process.stderr.write(`[memory-mcp] Loaded ${configs.size} lobe(s) from MEMORY_MCP_WORKSPACES env var\n`);
|
|
144
184
|
return { configs, origin: { source: 'env' } };
|
|
145
185
|
}
|
|
@@ -156,7 +196,9 @@ export function getLobeConfigs() {
|
|
|
156
196
|
repoRoot,
|
|
157
197
|
memoryPath: resolveMemoryPath(repoRoot, 'default', explicitDir),
|
|
158
198
|
storageBudgetBytes: storageBudget,
|
|
199
|
+
alwaysInclude: false,
|
|
159
200
|
});
|
|
201
|
+
// No ensureAlwaysIncludeLobe here — single-repo default users have everything in one lobe
|
|
160
202
|
process.stderr.write(`[memory-mcp] Using single-lobe default mode (cwd: ${repoRoot})\n`);
|
|
161
203
|
return { configs, origin: { source: 'default' } };
|
|
162
204
|
}
|
package/dist/index.js
CHANGED
|
@@ -7,11 +7,9 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
7
7
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import path from 'path';
|
|
10
|
-
import os from 'os';
|
|
11
|
-
import { existsSync, writeFileSync } from 'fs';
|
|
12
10
|
import { readFile, writeFile } from 'fs/promises';
|
|
13
11
|
import { MarkdownMemoryStore } from './store.js';
|
|
14
|
-
import {
|
|
12
|
+
import { parseTopicScope, parseTrustLevel } from './types.js';
|
|
15
13
|
import { getLobeConfigs } from './config.js';
|
|
16
14
|
import { ConfigManager } from './config-manager.js';
|
|
17
15
|
import { normalizeArgs } from './normalize.js';
|
|
@@ -72,21 +70,12 @@ const stores = new Map();
|
|
|
72
70
|
const lobeNames = Array.from(lobeConfigs.keys());
|
|
73
71
|
// ConfigManager will be initialized after stores are set up
|
|
74
72
|
let configManager;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
const globalStore = new MarkdownMemoryStore({
|
|
79
|
-
repoRoot: os.homedir(),
|
|
80
|
-
memoryPath: globalMemoryPath,
|
|
81
|
-
storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES,
|
|
82
|
-
});
|
|
73
|
+
/** Topics that auto-route to the first alwaysInclude lobe when no lobe is specified on writes.
|
|
74
|
+
* This is a backwards-compat shim — agents historically wrote these without specifying a lobe. */
|
|
75
|
+
const ALWAYS_INCLUDE_WRITE_TOPICS = new Set(['user', 'preferences']);
|
|
83
76
|
/** Resolve a raw lobe name to a validated store + display label.
|
|
84
77
|
* After this call, consumers use ctx.label — the raw lobe is not in scope. */
|
|
85
|
-
function resolveToolContext(rawLobe
|
|
86
|
-
// Global topics always route to the global store
|
|
87
|
-
if (opts?.isGlobal) {
|
|
88
|
-
return { ok: true, store: globalStore, label: 'global' };
|
|
89
|
-
}
|
|
78
|
+
function resolveToolContext(rawLobe) {
|
|
90
79
|
const lobeNames = configManager.getLobeNames();
|
|
91
80
|
// Default to single lobe when omitted
|
|
92
81
|
const lobe = rawLobe || (lobeNames.length === 1 ? lobeNames[0] : undefined);
|
|
@@ -163,11 +152,13 @@ const server = new Server({ name: 'memory-mcp', version: '0.1.0' }, { capabiliti
|
|
|
163
152
|
// 3. Fallback → global-only with a hint to specify the lobe
|
|
164
153
|
/** Resolve which lobes to search for a read operation when the agent omitted the lobe param.
|
|
165
154
|
* Wires the MCP server's listRoots into the pure resolution logic. */
|
|
166
|
-
async function resolveLobesForRead() {
|
|
155
|
+
async function resolveLobesForRead(isFirstMemoryToolCall = true) {
|
|
167
156
|
const allLobeNames = configManager.getLobeNames();
|
|
168
|
-
|
|
157
|
+
const alwaysIncludeLobes = configManager.getAlwaysIncludeLobes();
|
|
158
|
+
// Short-circuit: single lobe is unambiguous — no need for root matching.
|
|
159
|
+
// Handles both plain single-lobe and single-lobe-that-is-alwaysInclude cases.
|
|
169
160
|
if (allLobeNames.length === 1) {
|
|
170
|
-
return buildLobeResolution(allLobeNames, allLobeNames);
|
|
161
|
+
return buildLobeResolution(allLobeNames, allLobeNames, alwaysIncludeLobes, isFirstMemoryToolCall);
|
|
171
162
|
}
|
|
172
163
|
// Multiple lobes — try MCP client roots
|
|
173
164
|
const clientCaps = server.getClientCapabilities();
|
|
@@ -182,7 +173,7 @@ async function resolveLobesForRead() {
|
|
|
182
173
|
})
|
|
183
174
|
.filter((c) => c !== undefined);
|
|
184
175
|
const matched = matchRootsToLobeNames(roots, lobeConfigs);
|
|
185
|
-
return buildLobeResolution(allLobeNames, matched);
|
|
176
|
+
return buildLobeResolution(allLobeNames, matched, alwaysIncludeLobes, isFirstMemoryToolCall);
|
|
186
177
|
}
|
|
187
178
|
}
|
|
188
179
|
catch (err) {
|
|
@@ -190,7 +181,7 @@ async function resolveLobesForRead() {
|
|
|
190
181
|
}
|
|
191
182
|
}
|
|
192
183
|
// Fallback — roots not available or no match
|
|
193
|
-
return buildLobeResolution(allLobeNames, []);
|
|
184
|
+
return buildLobeResolution(allLobeNames, [], alwaysIncludeLobes, isFirstMemoryToolCall);
|
|
194
185
|
}
|
|
195
186
|
/** Build the shared lobe property for tool schemas — called on each ListTools request
|
|
196
187
|
* so the description and enum stay in sync after a hot-reload adds or removes lobes. */
|
|
@@ -200,7 +191,7 @@ function buildLobeProperty(currentLobeNames) {
|
|
|
200
191
|
type: 'string',
|
|
201
192
|
description: isSingle
|
|
202
193
|
? `Memory lobe name (defaults to "${currentLobeNames[0]}" if omitted)`
|
|
203
|
-
: `Memory lobe name. When omitted for reads, the server uses the client's workspace roots to select the matching lobe. If roots are unavailable
|
|
194
|
+
: `Memory lobe name. When omitted for reads, the server uses the client's workspace roots to select the matching lobe. If roots are unavailable and no alwaysInclude lobes are configured, specify a lobe explicitly to access lobe-specific knowledge. Required for writes. Available: ${currentLobeNames.join(', ')}`,
|
|
204
195
|
enum: currentLobeNames.length > 1 ? [...currentLobeNames] : undefined,
|
|
205
196
|
};
|
|
206
197
|
}
|
|
@@ -224,7 +215,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
224
215
|
// Example comes first — agents form their call shape from the first concrete pattern they see.
|
|
225
216
|
// "entries" (not "content") signals a collection; fighting the "content = string" prior
|
|
226
217
|
// is an architectural fix rather than patching the description after the fact.
|
|
227
|
-
description: 'memory_store(topic: "gotchas", entries: [{title: "Build cache", fact: "Must clean build after Tuist changes"}, {title: "Tuist version", fact: "Project requires Tuist 4.x"}], tags: ["build"]). Stores enduring knowledge — (1) Codebase facts (architecture, conventions, gotchas, modules): what IS true now, not past actions. Wrong: "Completed migration to StateFlow." Right: "All ViewModels use StateFlow." (2) User knowledge (user, preferences): who the person is, how they work. "user" and "preferences" are
|
|
218
|
+
description: 'memory_store(topic: "gotchas", entries: [{title: "Build cache", fact: "Must clean build after Tuist changes"}, {title: "Tuist version", fact: "Project requires Tuist 4.x"}], tags: ["build"]). Stores enduring knowledge — (1) Codebase facts (architecture, conventions, gotchas, modules): what IS true now, not past actions. Wrong: "Completed migration to StateFlow." Right: "All ViewModels use StateFlow." (2) User knowledge (user, preferences): who the person is, how they work. "user" and "preferences" are stored in the alwaysInclude lobe (shared across projects). One insight per object; use multiple objects instead of bundling. If content looks likely-ephemeral, the tool returns a review-required response; re-run with durabilityDecision: "store-anyway" only when deliberate.',
|
|
228
219
|
inputSchema: {
|
|
229
220
|
type: 'object',
|
|
230
221
|
properties: {
|
|
@@ -281,6 +272,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
281
272
|
description: 'Category labels for exact-match retrieval (lowercase slugs). Query with filter: "#tag". Example: ["auth", "critical-path", "mite-combat"]',
|
|
282
273
|
default: [],
|
|
283
274
|
},
|
|
275
|
+
durabilityDecision: {
|
|
276
|
+
type: 'string',
|
|
277
|
+
enum: ['default', 'store-anyway'],
|
|
278
|
+
description: 'Write intent for content that may require review. Use "default" normally. Use "store-anyway" only when intentionally persisting content after a review-required response.',
|
|
279
|
+
default: 'default',
|
|
280
|
+
},
|
|
284
281
|
},
|
|
285
282
|
required: ['topic', 'entries'],
|
|
286
283
|
},
|
|
@@ -310,6 +307,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
310
307
|
type: 'string',
|
|
311
308
|
description: 'Branch for recent-work. Omit = current branch, "*" = all branches.',
|
|
312
309
|
},
|
|
310
|
+
isFirstMemoryToolCall: {
|
|
311
|
+
type: 'boolean',
|
|
312
|
+
description: 'Set true on first memory call in a conversation to include identity/preferences from alwaysInclude lobes. Set false on subsequent calls to skip redundant global knowledge.',
|
|
313
|
+
default: true,
|
|
314
|
+
},
|
|
313
315
|
},
|
|
314
316
|
required: [],
|
|
315
317
|
},
|
|
@@ -359,6 +361,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
359
361
|
description: 'Min keyword match ratio 0-1 (default: 0.2). Higher = stricter.',
|
|
360
362
|
default: 0.2,
|
|
361
363
|
},
|
|
364
|
+
isFirstMemoryToolCall: {
|
|
365
|
+
type: 'boolean',
|
|
366
|
+
description: 'Set true on first memory call in a conversation to include identity/preferences from alwaysInclude lobes. Set false on subsequent calls to skip redundant global knowledge.',
|
|
367
|
+
default: true,
|
|
368
|
+
},
|
|
362
369
|
},
|
|
363
370
|
required: [],
|
|
364
371
|
},
|
|
@@ -426,16 +433,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
426
433
|
case 'memory_list_lobes': {
|
|
427
434
|
// Delegates to shared builder — same data as memory://lobes resource
|
|
428
435
|
const lobeInfo = await buildLobeInfo();
|
|
429
|
-
const
|
|
436
|
+
const alwaysIncludeNames = configManager.getAlwaysIncludeLobes();
|
|
430
437
|
const result = {
|
|
431
438
|
serverMode: serverMode.kind,
|
|
432
|
-
globalStore: {
|
|
433
|
-
memoryPath: globalMemoryPath,
|
|
434
|
-
entries: globalStats.totalEntries,
|
|
435
|
-
storageUsed: globalStats.storageSize,
|
|
436
|
-
topics: 'user, preferences (shared across all lobes)',
|
|
437
|
-
},
|
|
438
439
|
lobes: lobeInfo,
|
|
440
|
+
alwaysIncludeLobes: alwaysIncludeNames,
|
|
439
441
|
configFile: configFileDisplay(),
|
|
440
442
|
configSource: configOrigin.source,
|
|
441
443
|
totalLobes: lobeInfo.length,
|
|
@@ -446,7 +448,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
446
448
|
};
|
|
447
449
|
}
|
|
448
450
|
case 'memory_store': {
|
|
449
|
-
const { lobe: rawLobe, topic: rawTopic, entries: rawEntries, sources, references, trust: rawTrust, tags: rawTags } = z.object({
|
|
451
|
+
const { lobe: rawLobe, topic: rawTopic, entries: rawEntries, sources, references, trust: rawTrust, tags: rawTags, durabilityDecision } = z.object({
|
|
450
452
|
lobe: z.string().optional(),
|
|
451
453
|
topic: z.string(),
|
|
452
454
|
// Accept a bare {title, fact} object in addition to the canonical array form.
|
|
@@ -460,6 +462,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
460
462
|
references: z.array(z.string()).default([]),
|
|
461
463
|
trust: z.enum(['user', 'agent-confirmed', 'agent-inferred']).default('agent-inferred'),
|
|
462
464
|
tags: z.array(z.string()).default([]),
|
|
465
|
+
durabilityDecision: z.enum(['default', 'store-anyway']).default('default'),
|
|
463
466
|
}).parse(args);
|
|
464
467
|
// Validate topic at boundary
|
|
465
468
|
const topic = parseTopicScope(rawTopic);
|
|
@@ -477,16 +480,47 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
477
480
|
const allPaths = [...sources, ...references];
|
|
478
481
|
effectiveLobe = inferLobeFromPaths(allPaths);
|
|
479
482
|
}
|
|
483
|
+
// Auto-route user/preferences writes to the first alwaysInclude lobe when no lobe specified.
|
|
484
|
+
// This preserves the previous behavior where these topics auto-routed to the global store.
|
|
485
|
+
if (!effectiveLobe && ALWAYS_INCLUDE_WRITE_TOPICS.has(topic)) {
|
|
486
|
+
const alwaysIncludeLobes = configManager.getAlwaysIncludeLobes();
|
|
487
|
+
if (alwaysIncludeLobes.length > 0) {
|
|
488
|
+
effectiveLobe = alwaysIncludeLobes[0];
|
|
489
|
+
}
|
|
490
|
+
}
|
|
480
491
|
// Resolve store — after this point, rawLobe is never used again
|
|
481
|
-
const
|
|
482
|
-
const ctx = resolveToolContext(effectiveLobe, { isGlobal });
|
|
492
|
+
const ctx = resolveToolContext(effectiveLobe);
|
|
483
493
|
if (!ctx.ok)
|
|
484
494
|
return contextError(ctx);
|
|
485
|
-
|
|
495
|
+
// Auto-promote trust for global topics: agents writing user/preferences without explicit
|
|
496
|
+
// trust: "user" still get full confidence. Preserves pre-unification behavior where the
|
|
497
|
+
// global store always stored these at user trust — removing this would silently downgrade
|
|
498
|
+
// identity entries to confidence 0.70 (see philosophy: "Observability as a constraint").
|
|
499
|
+
const effectiveTrust = ALWAYS_INCLUDE_WRITE_TOPICS.has(topic) && trust === 'agent-inferred'
|
|
500
|
+
? 'user'
|
|
501
|
+
: trust;
|
|
486
502
|
const storedResults = [];
|
|
487
503
|
for (const { title, fact } of rawEntries) {
|
|
488
|
-
const result = await ctx.store.store(topic, title, fact, sources, effectiveTrust, references, rawTags);
|
|
489
|
-
if (
|
|
504
|
+
const result = await ctx.store.store(topic, title, fact, sources, effectiveTrust, references, rawTags, durabilityDecision);
|
|
505
|
+
if (result.kind === 'review-required') {
|
|
506
|
+
const lines = [
|
|
507
|
+
`[${ctx.label}] Review required before storing "${title}".`,
|
|
508
|
+
'',
|
|
509
|
+
`Severity: ${result.severity}`,
|
|
510
|
+
'Signals:',
|
|
511
|
+
...result.signals.map(signal => `- ${signal.label}: ${signal.detail}`),
|
|
512
|
+
'',
|
|
513
|
+
result.warning,
|
|
514
|
+
'',
|
|
515
|
+
'If this knowledge is still worth persisting, re-run with:',
|
|
516
|
+
`memory_store(topic: "${topic}", entries: [{title: "${title}", fact: "${fact.replace(/"/g, '\\"')}"}], trust: "${effectiveTrust}", durabilityDecision: "store-anyway"${rawTags.length > 0 ? `, tags: ${JSON.stringify(rawTags)}` : ''}${sources.length > 0 ? `, sources: ${JSON.stringify(sources)}` : ''}${references.length > 0 ? `, references: ${JSON.stringify(references)}` : ''})`,
|
|
517
|
+
];
|
|
518
|
+
return {
|
|
519
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
520
|
+
isError: true,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
if (result.kind === 'rejected') {
|
|
490
524
|
return {
|
|
491
525
|
content: [{ type: 'text', text: `[${ctx.label}] Failed to store "${title}": ${result.warning}` }],
|
|
492
526
|
isError: true,
|
|
@@ -579,33 +613,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
579
613
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
580
614
|
}
|
|
581
615
|
case 'memory_query': {
|
|
582
|
-
const { lobe: rawLobe, scope, detail, filter, branch } = z.object({
|
|
616
|
+
const { lobe: rawLobe, scope, detail, filter, branch, isFirstMemoryToolCall: rawIsFirst } = z.object({
|
|
583
617
|
lobe: z.string().optional(),
|
|
584
618
|
scope: z.string().default('*'),
|
|
585
619
|
detail: z.enum(['brief', 'standard', 'full']).default('brief'),
|
|
586
620
|
filter: z.string().optional(),
|
|
587
621
|
branch: z.string().optional(),
|
|
622
|
+
isFirstMemoryToolCall: z.boolean().default(true),
|
|
588
623
|
}).parse(args ?? {});
|
|
589
|
-
|
|
590
|
-
//
|
|
591
|
-
//
|
|
624
|
+
// Force-include alwaysInclude lobes when querying a global topic (user/preferences),
|
|
625
|
+
// regardless of isFirstMemoryToolCall — the agent explicitly asked for this data.
|
|
626
|
+
// Philosophy: "Determinism over cleverness" — same query produces same results.
|
|
627
|
+
const topicScope = parseTopicScope(scope);
|
|
628
|
+
const effectiveIsFirst = rawIsFirst || (topicScope !== null && ALWAYS_INCLUDE_WRITE_TOPICS.has(topicScope));
|
|
629
|
+
// Resolve which lobes to search — unified path for all topics.
|
|
592
630
|
let lobeEntries = [];
|
|
593
631
|
const entryLobeMap = new Map(); // entry id → lobe name
|
|
594
632
|
let label;
|
|
595
633
|
let primaryStore;
|
|
596
634
|
let queryGlobalOnlyHint;
|
|
597
|
-
if (
|
|
598
|
-
const ctx = resolveToolContext(rawLobe, { isGlobal: true });
|
|
599
|
-
if (!ctx.ok)
|
|
600
|
-
return contextError(ctx);
|
|
601
|
-
label = ctx.label;
|
|
602
|
-
primaryStore = ctx.store;
|
|
603
|
-
const result = await ctx.store.query(scope, detail, filter, branch);
|
|
604
|
-
for (const e of result.entries)
|
|
605
|
-
entryLobeMap.set(e.id, 'global');
|
|
606
|
-
lobeEntries = [...result.entries];
|
|
607
|
-
}
|
|
608
|
-
else if (rawLobe) {
|
|
635
|
+
if (rawLobe) {
|
|
609
636
|
const ctx = resolveToolContext(rawLobe);
|
|
610
637
|
if (!ctx.ok)
|
|
611
638
|
return contextError(ctx);
|
|
@@ -615,7 +642,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
615
642
|
lobeEntries = [...result.entries];
|
|
616
643
|
}
|
|
617
644
|
else {
|
|
618
|
-
const resolution = await resolveLobesForRead();
|
|
645
|
+
const resolution = await resolveLobesForRead(effectiveIsFirst);
|
|
619
646
|
switch (resolution.kind) {
|
|
620
647
|
case 'resolved': {
|
|
621
648
|
label = resolution.label;
|
|
@@ -641,17 +668,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
641
668
|
}
|
|
642
669
|
}
|
|
643
670
|
}
|
|
644
|
-
//
|
|
645
|
-
let globalEntries = [];
|
|
646
|
-
if (scope === '*' && !isGlobalQuery) {
|
|
647
|
-
const globalResult = await globalStore.query('*', detail, filter);
|
|
648
|
-
for (const e of globalResult.entries)
|
|
649
|
-
entryLobeMap.set(e.id, 'global');
|
|
650
|
-
globalEntries = [...globalResult.entries];
|
|
651
|
-
}
|
|
652
|
-
// Merge global + lobe entries, dedupe by id, sort by relevance score
|
|
671
|
+
// Dedupe by id, sort by relevance score
|
|
653
672
|
const seenQueryIds = new Set();
|
|
654
|
-
const allEntries =
|
|
673
|
+
const allEntries = lobeEntries
|
|
655
674
|
.filter(e => {
|
|
656
675
|
if (seenQueryIds.has(e.id))
|
|
657
676
|
return false;
|
|
@@ -660,17 +679,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
660
679
|
})
|
|
661
680
|
.sort((a, b) => b.relevanceScore - a.relevanceScore);
|
|
662
681
|
// Build stores collection for tag frequency aggregation
|
|
663
|
-
// Only include stores that were actually searched
|
|
664
682
|
const searchedStores = [];
|
|
665
|
-
if (
|
|
666
|
-
searchedStores.push(
|
|
667
|
-
}
|
|
668
|
-
else {
|
|
669
|
-
if (primaryStore)
|
|
670
|
-
searchedStores.push(primaryStore);
|
|
671
|
-
if (scope === '*')
|
|
672
|
-
searchedStores.push(globalStore);
|
|
673
|
-
}
|
|
683
|
+
if (primaryStore)
|
|
684
|
+
searchedStores.push(primaryStore);
|
|
674
685
|
const tagFreq = mergeTagFrequencies(searchedStores);
|
|
675
686
|
// Parse filter once for both filtering (already done) and footer display
|
|
676
687
|
const filterGroups = filter ? parseFilter(filter) : [];
|
|
@@ -758,26 +769,45 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
758
769
|
isError: true,
|
|
759
770
|
};
|
|
760
771
|
}
|
|
761
|
-
// Resolve store —
|
|
762
|
-
|
|
763
|
-
|
|
772
|
+
// Resolve store — if no lobe specified, probe alwaysInclude lobes first (read-only)
|
|
773
|
+
// to find where user/pref entries live, then apply the correction only to the owning store.
|
|
774
|
+
// Philosophy: "Prefer atomicity for correctness" — never call correct() speculatively.
|
|
775
|
+
let effectiveCorrectLobe = rawLobe;
|
|
776
|
+
if (!effectiveCorrectLobe) {
|
|
777
|
+
const alwaysIncludeLobes = configManager.getAlwaysIncludeLobes();
|
|
778
|
+
for (const lobeName of alwaysIncludeLobes) {
|
|
779
|
+
const store = configManager.getStore(lobeName);
|
|
780
|
+
if (!store)
|
|
781
|
+
continue;
|
|
782
|
+
try {
|
|
783
|
+
if (await store.hasEntry(id)) {
|
|
784
|
+
effectiveCorrectLobe = lobeName;
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
catch (err) {
|
|
789
|
+
process.stderr.write(`[memory-mcp] Warning: hasEntry probe failed for lobe "${lobeName}": ${err instanceof Error ? err.message : String(err)}\n`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
// If we probed alwaysInclude lobes and didn't find the entry, provide a richer error
|
|
794
|
+
// than the generic "Lobe is required" from resolveToolContext.
|
|
795
|
+
if (!effectiveCorrectLobe && !rawLobe) {
|
|
796
|
+
const searchedLobes = configManager.getAlwaysIncludeLobes();
|
|
797
|
+
const allLobes = configManager.getLobeNames();
|
|
798
|
+
const searchedNote = searchedLobes.length > 0
|
|
799
|
+
? `Searched alwaysInclude lobes (${searchedLobes.join(', ')}) — entry not found. `
|
|
800
|
+
: '';
|
|
801
|
+
return {
|
|
802
|
+
content: [{ type: 'text', text: `Entry "${id}" not found. ${searchedNote}Specify the lobe that contains it. Available: ${allLobes.join(', ')}` }],
|
|
803
|
+
isError: true,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
const ctx = resolveToolContext(effectiveCorrectLobe);
|
|
764
807
|
if (!ctx.ok)
|
|
765
808
|
return contextError(ctx);
|
|
766
809
|
const result = await ctx.store.correct(id, correction ?? '', action);
|
|
767
810
|
if (!result.corrected) {
|
|
768
|
-
// If not found in the targeted store, try the other one as fallback
|
|
769
|
-
if (isGlobalEntry) {
|
|
770
|
-
const lobeCtx = resolveToolContext(rawLobe);
|
|
771
|
-
if (lobeCtx.ok) {
|
|
772
|
-
const lobeResult = await lobeCtx.store.correct(id, correction ?? '', action);
|
|
773
|
-
if (lobeResult.corrected) {
|
|
774
|
-
const text = action === 'delete'
|
|
775
|
-
? `[${lobeCtx.label}] Deleted entry ${id}.`
|
|
776
|
-
: `[${lobeCtx.label}] Corrected entry ${id} (action: ${action}, confidence: ${lobeResult.newConfidence}, trust: ${lobeResult.trust}).`;
|
|
777
|
-
return { content: [{ type: 'text', text }] };
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
811
|
return {
|
|
782
812
|
content: [{ type: 'text', text: `[${ctx.label}] Failed to correct: ${result.error}` }],
|
|
783
813
|
isError: true,
|
|
@@ -799,11 +829,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
799
829
|
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
800
830
|
}
|
|
801
831
|
case 'memory_context': {
|
|
802
|
-
const { lobe: rawLobe, context, maxResults, minMatch } = z.object({
|
|
832
|
+
const { lobe: rawLobe, context, maxResults, minMatch, isFirstMemoryToolCall: rawIsFirst } = z.object({
|
|
803
833
|
lobe: z.string().optional(),
|
|
804
834
|
context: z.string().optional(),
|
|
805
835
|
maxResults: z.number().optional(),
|
|
806
836
|
minMatch: z.number().min(0).max(1).optional(),
|
|
837
|
+
isFirstMemoryToolCall: z.boolean().default(true),
|
|
807
838
|
}).parse(args ?? {});
|
|
808
839
|
// --- Briefing mode: no context provided → user + preferences + stale nudges ---
|
|
809
840
|
if (!context) {
|
|
@@ -820,22 +851,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
820
851
|
const degradedSection = degradedLobeNames.length > 0
|
|
821
852
|
? `## ⚠ Degraded Lobes: ${degradedLobeNames.join(', ')}\nRun **memory_diagnose** for details.\n`
|
|
822
853
|
: '';
|
|
823
|
-
// Global store holds user + preferences — always included
|
|
824
|
-
const globalBriefing = await globalStore.briefing(300);
|
|
825
854
|
const sections = [];
|
|
826
855
|
if (crashSection)
|
|
827
856
|
sections.push(crashSection);
|
|
828
857
|
if (degradedSection)
|
|
829
858
|
sections.push(degradedSection);
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
}
|
|
833
|
-
// Collect stale entries and entry counts across all lobes
|
|
859
|
+
// Collect briefing, stale entries, and entry counts across all lobes
|
|
860
|
+
// (alwaysInclude lobes are in the lobe list — no separate global store query needed)
|
|
834
861
|
const allStale = [];
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
862
|
+
let totalEntries = 0;
|
|
863
|
+
let totalStale = 0;
|
|
864
|
+
// Give alwaysInclude lobes a higher token budget (identity/preferences are high-value)
|
|
865
|
+
const alwaysIncludeSet = new Set(configManager.getAlwaysIncludeLobes());
|
|
839
866
|
for (const lobeName of allBriefingLobeNames) {
|
|
840
867
|
const health = configManager.getLobeHealth(lobeName);
|
|
841
868
|
if (health?.status === 'degraded')
|
|
@@ -843,7 +870,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
843
870
|
const store = configManager.getStore(lobeName);
|
|
844
871
|
if (!store)
|
|
845
872
|
continue;
|
|
846
|
-
const
|
|
873
|
+
const budget = alwaysIncludeSet.has(lobeName) ? 300 : 100;
|
|
874
|
+
const lobeBriefing = await store.briefing(budget);
|
|
875
|
+
if (alwaysIncludeSet.has(lobeName) && lobeBriefing.entryCount > 0) {
|
|
876
|
+
sections.push(lobeBriefing.briefing);
|
|
877
|
+
}
|
|
847
878
|
if (lobeBriefing.staleDetails)
|
|
848
879
|
allStale.push(...lobeBriefing.staleDetails);
|
|
849
880
|
totalEntries += lobeBriefing.entryCount;
|
|
@@ -856,7 +887,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
856
887
|
sections.push('No knowledge stored yet. As you work, store observations with memory_store. Try memory_bootstrap to seed initial knowledge from the repo.');
|
|
857
888
|
}
|
|
858
889
|
// Tag primer: show tag vocabulary if tags exist across any lobe
|
|
859
|
-
const briefingStores = [
|
|
890
|
+
const briefingStores = [];
|
|
860
891
|
for (const lobeName of allBriefingLobeNames) {
|
|
861
892
|
const store = configManager.getStore(lobeName);
|
|
862
893
|
if (store)
|
|
@@ -895,7 +926,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
895
926
|
allLobeResults.push(...lobeResults);
|
|
896
927
|
}
|
|
897
928
|
else {
|
|
898
|
-
const resolution = await resolveLobesForRead();
|
|
929
|
+
const resolution = await resolveLobesForRead(rawIsFirst);
|
|
899
930
|
switch (resolution.kind) {
|
|
900
931
|
case 'resolved': {
|
|
901
932
|
label = resolution.label;
|
|
@@ -921,13 +952,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
921
952
|
}
|
|
922
953
|
}
|
|
923
954
|
}
|
|
924
|
-
//
|
|
925
|
-
const globalResults = await globalStore.contextSearch(context, max, undefined, threshold);
|
|
926
|
-
for (const r of globalResults)
|
|
927
|
-
ctxEntryLobeMap.set(r.entry.id, 'global');
|
|
928
|
-
// Merge, dedupe by entry id, re-sort by score, take top N
|
|
955
|
+
// Dedupe by entry id, re-sort by score, take top N
|
|
929
956
|
const seenIds = new Set();
|
|
930
|
-
const results =
|
|
957
|
+
const results = allLobeResults
|
|
931
958
|
.sort((a, b) => b.score - a.score)
|
|
932
959
|
.filter(r => {
|
|
933
960
|
if (seenIds.has(r.entry.id))
|
|
@@ -937,11 +964,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
937
964
|
})
|
|
938
965
|
.slice(0, max);
|
|
939
966
|
// Build stores collection for tag frequency aggregation
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
if (primaryStore && primaryStore !== globalStore) {
|
|
967
|
+
const ctxSearchedStores = [];
|
|
968
|
+
if (primaryStore)
|
|
943
969
|
ctxSearchedStores.push(primaryStore);
|
|
944
|
-
}
|
|
945
970
|
const ctxTagFreq = mergeTagFrequencies(ctxSearchedStores);
|
|
946
971
|
// Parse filter for footer (context search has no filter, pass empty)
|
|
947
972
|
const ctxFilterGroups = [];
|
|
@@ -1023,24 +1048,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1023
1048
|
const { lobe: rawLobe } = z.object({
|
|
1024
1049
|
lobe: z.string().optional(),
|
|
1025
1050
|
}).parse(args ?? {});
|
|
1026
|
-
// Always include global stats
|
|
1027
|
-
const globalStats = await globalStore.stats();
|
|
1028
1051
|
// Single lobe stats
|
|
1029
1052
|
if (rawLobe) {
|
|
1030
1053
|
const ctx = resolveToolContext(rawLobe);
|
|
1031
1054
|
if (!ctx.ok)
|
|
1032
1055
|
return contextError(ctx);
|
|
1033
1056
|
const result = await ctx.store.stats();
|
|
1034
|
-
const
|
|
1035
|
-
|
|
1057
|
+
const alwaysIncludeSet = new Set(configManager.getAlwaysIncludeLobes());
|
|
1058
|
+
const label = alwaysIncludeSet.has(rawLobe) ? `${ctx.label} (alwaysInclude)` : ctx.label;
|
|
1059
|
+
return { content: [{ type: 'text', text: formatStats(label, result) }] };
|
|
1036
1060
|
}
|
|
1037
1061
|
// Combined stats across all lobes
|
|
1038
|
-
const sections = [
|
|
1062
|
+
const sections = [];
|
|
1039
1063
|
const allLobeNames = configManager.getLobeNames();
|
|
1064
|
+
const alwaysIncludeSet = new Set(configManager.getAlwaysIncludeLobes());
|
|
1040
1065
|
for (const lobeName of allLobeNames) {
|
|
1041
1066
|
const store = configManager.getStore(lobeName);
|
|
1067
|
+
if (!store)
|
|
1068
|
+
continue;
|
|
1042
1069
|
const result = await store.stats();
|
|
1043
|
-
|
|
1070
|
+
const label = alwaysIncludeSet.has(lobeName) ? `${lobeName} (alwaysInclude)` : lobeName;
|
|
1071
|
+
sections.push(formatStats(label, result));
|
|
1044
1072
|
}
|
|
1045
1073
|
return { content: [{ type: 'text', text: sections.join('\n\n---\n\n') }] };
|
|
1046
1074
|
}
|
|
@@ -1089,8 +1117,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1089
1117
|
if (!ctx.ok)
|
|
1090
1118
|
return contextError(ctx);
|
|
1091
1119
|
const results = await ctx.store.bootstrap();
|
|
1092
|
-
const stored = results.filter(r => r.stored);
|
|
1093
|
-
const failed = results.filter(r =>
|
|
1120
|
+
const stored = results.filter((r) => r.kind === 'stored');
|
|
1121
|
+
const failed = results.filter((r) => r.kind !== 'stored');
|
|
1094
1122
|
let text = `## [${ctx.label}] Bootstrap Complete\n\nStored ${stored.length} entries:`;
|
|
1095
1123
|
for (const r of stored) {
|
|
1096
1124
|
text += `\n- ${r.id}: ${r.topic} (${r.file})`;
|
|
@@ -1201,14 +1229,6 @@ async function buildDiagnosticsText(showFullCrashHistory) {
|
|
|
1201
1229
|
}
|
|
1202
1230
|
}
|
|
1203
1231
|
sections.push('');
|
|
1204
|
-
try {
|
|
1205
|
-
const globalStats = await globalStore.stats();
|
|
1206
|
-
sections.push(`- **global store**: ✅ healthy (${globalStats.totalEntries} entries, ${globalStats.storageSize})`);
|
|
1207
|
-
}
|
|
1208
|
-
catch (e) {
|
|
1209
|
-
sections.push(`- **global store**: ❌ error — ${e instanceof Error ? e.message : e}`);
|
|
1210
|
-
}
|
|
1211
|
-
sections.push('');
|
|
1212
1232
|
// Active behavior config — shows effective values and highlights user overrides
|
|
1213
1233
|
sections.push('### Active Behavior Config');
|
|
1214
1234
|
sections.push(formatBehaviorConfigSection(configBehavior));
|
|
@@ -1260,15 +1280,6 @@ async function main() {
|
|
|
1260
1280
|
process.stderr.write(`[memory-mcp] Previous crash detected (${age}s ago): ${previousCrash.type} — ${previousCrash.error}\n`);
|
|
1261
1281
|
process.stderr.write(`[memory-mcp] Crash report will be shown in memory_context and memory_diagnose.\n`);
|
|
1262
1282
|
}
|
|
1263
|
-
// Initialize global store (user + preferences, shared across all lobes)
|
|
1264
|
-
try {
|
|
1265
|
-
await globalStore.init();
|
|
1266
|
-
process.stderr.write(`[memory-mcp] Global store → ${globalMemoryPath}\n`);
|
|
1267
|
-
}
|
|
1268
|
-
catch (error) {
|
|
1269
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
1270
|
-
process.stderr.write(`[memory-mcp] WARNING: Global store init failed: ${msg}\n`);
|
|
1271
|
-
}
|
|
1272
1283
|
// Initialize each lobe independently — a broken lobe shouldn't prevent others from working
|
|
1273
1284
|
let healthyLobes = 0;
|
|
1274
1285
|
for (const [name, config] of lobeConfigs) {
|
|
@@ -1326,46 +1337,6 @@ async function main() {
|
|
|
1326
1337
|
};
|
|
1327
1338
|
process.stderr.write(`[memory-mcp] ⚠ DEGRADED: ${healthyLobes}/${lobeConfigs.size} lobes healthy.\n`);
|
|
1328
1339
|
}
|
|
1329
|
-
// Migrate: move user + preferences entries from lobe stores to global store.
|
|
1330
|
-
// State-driven guard: skip if already completed (marker file present).
|
|
1331
|
-
const migrationMarker = path.join(globalMemoryPath, '.migrated');
|
|
1332
|
-
if (!existsSync(migrationMarker)) {
|
|
1333
|
-
let migrated = 0;
|
|
1334
|
-
for (const [name, store] of stores) {
|
|
1335
|
-
for (const topic of ['user', 'preferences']) {
|
|
1336
|
-
try {
|
|
1337
|
-
const result = await store.query(topic, 'full');
|
|
1338
|
-
for (const entry of result.entries) {
|
|
1339
|
-
try {
|
|
1340
|
-
const globalResult = await globalStore.query(topic, 'full');
|
|
1341
|
-
const alreadyExists = globalResult.entries.some(g => g.title === entry.title);
|
|
1342
|
-
if (!alreadyExists && entry.content) {
|
|
1343
|
-
const trust = parseTrustLevel(entry.trust ?? 'user') ?? 'user';
|
|
1344
|
-
await globalStore.store(topic, entry.title, entry.content, [...(entry.sources ?? [])], trust);
|
|
1345
|
-
process.stderr.write(`[memory-mcp] Migrated ${entry.id} ("${entry.title}") from [${name}] → global\n`);
|
|
1346
|
-
migrated++;
|
|
1347
|
-
}
|
|
1348
|
-
await store.correct(entry.id, '', 'delete');
|
|
1349
|
-
process.stderr.write(`[memory-mcp] Removed ${entry.id} from [${name}] (now in global)\n`);
|
|
1350
|
-
}
|
|
1351
|
-
catch (entryError) {
|
|
1352
|
-
process.stderr.write(`[memory-mcp] Migration error for ${entry.id} in [${name}]: ${entryError}\n`);
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
catch (topicError) {
|
|
1357
|
-
process.stderr.write(`[memory-mcp] Migration error querying ${topic} in [${name}]: ${topicError}\n`);
|
|
1358
|
-
}
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
|
-
// Write marker atomically — future startups skip this block entirely
|
|
1362
|
-
try {
|
|
1363
|
-
writeFileSync(migrationMarker, new Date().toISOString(), 'utf-8');
|
|
1364
|
-
if (migrated > 0)
|
|
1365
|
-
process.stderr.write(`[memory-mcp] Migration complete: ${migrated} entries moved to global store.\n`);
|
|
1366
|
-
}
|
|
1367
|
-
catch { /* marker write is best-effort */ }
|
|
1368
|
-
}
|
|
1369
1340
|
// Initialize ConfigManager with current config state
|
|
1370
1341
|
configManager = new ConfigManager(configPath, { configs: lobeConfigs, origin: configOrigin }, stores, lobeHealth);
|
|
1371
1342
|
const transport = new StdioServerTransport();
|
|
@@ -1393,7 +1364,12 @@ async function main() {
|
|
|
1393
1364
|
});
|
|
1394
1365
|
await server.connect(transport);
|
|
1395
1366
|
const modeStr = serverMode.kind === 'running' ? '' : ` [${serverMode.kind.toUpperCase()}]`;
|
|
1396
|
-
|
|
1367
|
+
const alwaysIncludeNames = configManager.getAlwaysIncludeLobes();
|
|
1368
|
+
const aiLabel = alwaysIncludeNames.length > 0 ? ` (alwaysInclude: ${alwaysIncludeNames.join(', ')})` : '';
|
|
1369
|
+
if (alwaysIncludeNames.length > 1) {
|
|
1370
|
+
process.stderr.write(`[memory-mcp] Warning: ${alwaysIncludeNames.length} lobes have alwaysInclude: true (${alwaysIncludeNames.join(', ')}). Writes to user/preferences will route to the first one ("${alwaysIncludeNames[0]}"). This is likely a misconfiguration — typically only one lobe should be alwaysInclude.\n`);
|
|
1371
|
+
}
|
|
1372
|
+
process.stderr.write(`[memory-mcp] Server started${modeStr} with ${healthyLobes}/${lobeConfigs.size} lobe(s)${aiLabel}\n`);
|
|
1397
1373
|
// Graceful shutdown on signals
|
|
1398
1374
|
const shutdown = () => {
|
|
1399
1375
|
process.stderr.write('[memory-mcp] Shutting down gracefully.\n');
|
|
@@ -26,5 +26,9 @@ export interface LobeRootConfig {
|
|
|
26
26
|
* checked at path-separator boundaries (no partial-name false positives) */
|
|
27
27
|
export declare function matchRootsToLobeNames(clientRoots: readonly ClientRoot[], lobeConfigs: readonly LobeRootConfig[]): readonly string[];
|
|
28
28
|
/** Build a LobeResolution from the available lobe names and matched lobes.
|
|
29
|
-
* Encodes the degradation ladder as a pure function.
|
|
30
|
-
|
|
29
|
+
* Encodes the degradation ladder as a pure function.
|
|
30
|
+
*
|
|
31
|
+
* When isFirstMemoryToolCall is true (default), alwaysIncludeLobes are appended
|
|
32
|
+
* to the resolved set (deduped). When false, they are excluded — the agent has
|
|
33
|
+
* already loaded global knowledge in this conversation. */
|
|
34
|
+
export declare function buildLobeResolution(allLobeNames: readonly string[], matchedLobes: readonly string[], alwaysIncludeLobes?: readonly string[], isFirstMemoryToolCall?: boolean): LobeResolution;
|
package/dist/lobe-resolution.js
CHANGED
|
@@ -44,18 +44,40 @@ export function matchRootsToLobeNames(clientRoots, lobeConfigs) {
|
|
|
44
44
|
return Array.from(matchedLobes);
|
|
45
45
|
}
|
|
46
46
|
/** Build a LobeResolution from the available lobe names and matched lobes.
|
|
47
|
-
* Encodes the degradation ladder as a pure function.
|
|
48
|
-
|
|
47
|
+
* Encodes the degradation ladder as a pure function.
|
|
48
|
+
*
|
|
49
|
+
* When isFirstMemoryToolCall is true (default), alwaysIncludeLobes are appended
|
|
50
|
+
* to the resolved set (deduped). When false, they are excluded — the agent has
|
|
51
|
+
* already loaded global knowledge in this conversation. */
|
|
52
|
+
export function buildLobeResolution(allLobeNames, matchedLobes, alwaysIncludeLobes = [], isFirstMemoryToolCall = true) {
|
|
49
53
|
// Single lobe — always resolved, regardless of root matching
|
|
50
|
-
if (allLobeNames.length === 1) {
|
|
54
|
+
if (allLobeNames.length === 1 && alwaysIncludeLobes.length === 0) {
|
|
51
55
|
return { kind: 'resolved', lobes: allLobeNames, label: allLobeNames[0] };
|
|
52
56
|
}
|
|
53
|
-
//
|
|
54
|
-
|
|
57
|
+
// Build the base resolved set
|
|
58
|
+
let baseLobes;
|
|
59
|
+
if (allLobeNames.length === 1) {
|
|
60
|
+
baseLobes = allLobeNames;
|
|
61
|
+
}
|
|
62
|
+
else if (matchedLobes.length > 0) {
|
|
63
|
+
baseLobes = matchedLobes;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
baseLobes = [];
|
|
67
|
+
}
|
|
68
|
+
// Append alwaysInclude lobes when isFirstMemoryToolCall is true (deduped)
|
|
69
|
+
const resolvedSet = new Set(baseLobes);
|
|
70
|
+
if (isFirstMemoryToolCall) {
|
|
71
|
+
for (const lobe of alwaysIncludeLobes) {
|
|
72
|
+
resolvedSet.add(lobe);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (resolvedSet.size > 0) {
|
|
76
|
+
const lobes = Array.from(resolvedSet);
|
|
55
77
|
return {
|
|
56
78
|
kind: 'resolved',
|
|
57
|
-
lobes
|
|
58
|
-
label:
|
|
79
|
+
lobes,
|
|
80
|
+
label: lobes.length === 1 ? lobes[0] : lobes.join('+'),
|
|
59
81
|
};
|
|
60
82
|
}
|
|
61
83
|
// Fallback — no lobes could be determined
|
package/dist/store.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MemoryEntry, TopicScope, TrustLevel, DetailLevel, QueryResult, StoreResult, CorrectResult, MemoryStats, BriefingResult, ConflictPair, MemoryConfig } from './types.js';
|
|
1
|
+
import type { MemoryEntry, TopicScope, TrustLevel, DetailLevel, DurabilityDecision, QueryResult, StoreResult, CorrectResult, MemoryStats, BriefingResult, ConflictPair, MemoryConfig } from './types.js';
|
|
2
2
|
export declare class MarkdownMemoryStore {
|
|
3
3
|
private readonly config;
|
|
4
4
|
private readonly memoryPath;
|
|
@@ -13,11 +13,14 @@ export declare class MarkdownMemoryStore {
|
|
|
13
13
|
/** Initialize the store: create memory dir and load existing entries */
|
|
14
14
|
init(): Promise<void>;
|
|
15
15
|
/** Store a new knowledge entry */
|
|
16
|
-
store(topic: TopicScope, title: string, content: string, sources?: string[], trust?: TrustLevel, references?: string[], rawTags?: string[]): Promise<StoreResult>;
|
|
16
|
+
store(topic: TopicScope, title: string, content: string, sources?: string[], trust?: TrustLevel, references?: string[], rawTags?: string[], durabilityDecision?: DurabilityDecision): Promise<StoreResult>;
|
|
17
17
|
/** Query knowledge by scope and detail level */
|
|
18
18
|
query(scope: string, detail?: DetailLevel, filter?: string, branchFilter?: string): Promise<QueryResult>;
|
|
19
19
|
/** Generate a session-start briefing */
|
|
20
20
|
briefing(maxTokens?: number): Promise<BriefingResult>;
|
|
21
|
+
/** Check if an entry exists by ID — read-only, no side effects beyond disk reload.
|
|
22
|
+
* Use this to probe for entry ownership before calling correct(). */
|
|
23
|
+
hasEntry(id: string): Promise<boolean>;
|
|
21
24
|
/** Correct an existing entry */
|
|
22
25
|
correct(id: string, correction: string, action: 'append' | 'replace' | 'delete'): Promise<CorrectResult>;
|
|
23
26
|
/** Get memory health statistics */
|
package/dist/store.js
CHANGED
|
@@ -41,15 +41,35 @@ export class MarkdownMemoryStore {
|
|
|
41
41
|
await this.reloadFromDisk();
|
|
42
42
|
}
|
|
43
43
|
/** Store a new knowledge entry */
|
|
44
|
-
async store(topic, title, content, sources = [], trust = 'agent-inferred', references = [], rawTags = []) {
|
|
44
|
+
async store(topic, title, content, sources = [], trust = 'agent-inferred', references = [], rawTags = [], durabilityDecision = 'default') {
|
|
45
45
|
// Check storage budget — null means we can't measure, allow the write
|
|
46
46
|
const currentSize = await this.getStorageSize();
|
|
47
47
|
if (currentSize !== null && currentSize >= this.config.storageBudgetBytes) {
|
|
48
48
|
return {
|
|
49
|
-
stored: false, topic,
|
|
49
|
+
kind: 'rejected', stored: false, topic,
|
|
50
50
|
warning: `Storage budget exceeded (${this.formatBytes(currentSize)} / ${this.formatBytes(this.config.storageBudgetBytes)}). Delete or correct existing entries to free space.`,
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
|
+
const ephemeralSignals = topic !== 'recent-work'
|
|
54
|
+
? detectEphemeralSignals(title, content, topic)
|
|
55
|
+
: [];
|
|
56
|
+
const ephemeralSeverity = getEphemeralSeverity(ephemeralSignals);
|
|
57
|
+
const requiresReview = ephemeralSeverity === 'medium' || ephemeralSeverity === 'high';
|
|
58
|
+
if (durabilityDecision === 'default' && requiresReview) {
|
|
59
|
+
return {
|
|
60
|
+
kind: 'review-required',
|
|
61
|
+
stored: false,
|
|
62
|
+
topic,
|
|
63
|
+
severity: ephemeralSeverity,
|
|
64
|
+
warning: `Likely ephemeral content detected. Review the signals and re-run with durabilityDecision: "store-anyway" if this knowledge should still be persisted.`,
|
|
65
|
+
signals: ephemeralSignals.map(signal => ({
|
|
66
|
+
id: signal.id,
|
|
67
|
+
label: signal.label,
|
|
68
|
+
detail: signal.detail,
|
|
69
|
+
confidence: signal.confidence,
|
|
70
|
+
})),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
53
73
|
const id = this.generateId(topic);
|
|
54
74
|
const now = this.clock.isoNow();
|
|
55
75
|
const confidence = DEFAULT_CONFIDENCE[trust];
|
|
@@ -85,15 +105,9 @@ export class MarkdownMemoryStore {
|
|
|
85
105
|
const relevantPreferences = (topic !== 'preferences' && topic !== 'user')
|
|
86
106
|
? this.findRelevantPreferences(entry)
|
|
87
107
|
: undefined;
|
|
88
|
-
// Soft ephemeral detection — warn but never block
|
|
89
|
-
const ephemeralSignals = topic !== 'recent-work'
|
|
90
|
-
? detectEphemeralSignals(title, content, topic)
|
|
91
|
-
: [];
|
|
92
|
-
// getEphemeralSeverity is the single source of threshold logic shared with formatEphemeralWarning.
|
|
93
|
-
const ephemeralSeverity = getEphemeralSeverity(ephemeralSignals);
|
|
94
108
|
const ephemeralWarning = formatEphemeralWarning(ephemeralSignals, id);
|
|
95
109
|
return {
|
|
96
|
-
stored: true, id, topic, file, confidence, warning, ephemeralWarning,
|
|
110
|
+
kind: 'stored', stored: true, id, topic, file, confidence, warning, ephemeralWarning,
|
|
97
111
|
ephemeralSeverity: ephemeralSeverity ?? undefined,
|
|
98
112
|
relatedEntries: relatedEntries.length > 0 ? relatedEntries : undefined,
|
|
99
113
|
relevantPreferences: relevantPreferences && relevantPreferences.length > 0 ? relevantPreferences : undefined,
|
|
@@ -256,6 +270,12 @@ export class MarkdownMemoryStore {
|
|
|
256
270
|
suggestion,
|
|
257
271
|
};
|
|
258
272
|
}
|
|
273
|
+
/** Check if an entry exists by ID — read-only, no side effects beyond disk reload.
|
|
274
|
+
* Use this to probe for entry ownership before calling correct(). */
|
|
275
|
+
async hasEntry(id) {
|
|
276
|
+
await this.reloadFromDisk();
|
|
277
|
+
return this.entries.has(id);
|
|
278
|
+
}
|
|
259
279
|
/** Correct an existing entry */
|
|
260
280
|
async correct(id, correction, action) {
|
|
261
281
|
// Reload to ensure we have the latest
|
|
@@ -378,7 +398,7 @@ export class MarkdownMemoryStore {
|
|
|
378
398
|
catch { /* not a git repo or git not available */ }
|
|
379
399
|
// 5. Fallback: if nothing was detected, store a minimal overview so the lobe
|
|
380
400
|
// is never left completely empty after bootstrap (makes memory_context useful immediately).
|
|
381
|
-
const storedCount = results.filter(r => r.stored).length;
|
|
401
|
+
const storedCount = results.filter(r => r.kind === 'stored').length;
|
|
382
402
|
if (storedCount === 0) {
|
|
383
403
|
try {
|
|
384
404
|
const topLevel = await fs.readdir(repoRoot, { withFileTypes: true });
|
package/dist/types.d.ts
CHANGED
|
@@ -89,8 +89,21 @@ export interface RelatedEntry {
|
|
|
89
89
|
readonly confidence: number;
|
|
90
90
|
readonly trust: TrustLevel;
|
|
91
91
|
}
|
|
92
|
+
/** Caller intent for writes that may require a durability decision.
|
|
93
|
+
* Explicit domain type avoids booleanly-typed override semantics. */
|
|
94
|
+
export type DurabilityDecision = 'default' | 'store-anyway';
|
|
95
|
+
/** Structured signal attached to a review-required store outcome.
|
|
96
|
+
* Duplicated here (rather than importing from ephemeral.ts) to keep the store
|
|
97
|
+
* result contract independent of a concrete detection implementation. */
|
|
98
|
+
export interface ReviewSignal {
|
|
99
|
+
readonly id: string;
|
|
100
|
+
readonly label: string;
|
|
101
|
+
readonly detail: string;
|
|
102
|
+
readonly confidence: 'high' | 'medium' | 'low';
|
|
103
|
+
}
|
|
92
104
|
/** Result of a memory store operation — discriminated union eliminates impossible states */
|
|
93
105
|
export type StoreResult = {
|
|
106
|
+
readonly kind: 'stored';
|
|
94
107
|
readonly stored: true;
|
|
95
108
|
readonly id: string;
|
|
96
109
|
readonly topic: TopicScope;
|
|
@@ -104,6 +117,14 @@ export type StoreResult = {
|
|
|
104
117
|
readonly relatedEntries?: readonly RelatedEntry[];
|
|
105
118
|
readonly relevantPreferences?: readonly RelatedEntry[];
|
|
106
119
|
} | {
|
|
120
|
+
readonly kind: 'review-required';
|
|
121
|
+
readonly stored: false;
|
|
122
|
+
readonly topic: TopicScope;
|
|
123
|
+
readonly severity: EphemeralSeverity;
|
|
124
|
+
readonly warning: string;
|
|
125
|
+
readonly signals: readonly ReviewSignal[];
|
|
126
|
+
} | {
|
|
127
|
+
readonly kind: 'rejected';
|
|
107
128
|
readonly stored: false;
|
|
108
129
|
readonly topic: TopicScope;
|
|
109
130
|
readonly warning: string;
|
|
@@ -195,6 +216,7 @@ export interface MemoryConfig {
|
|
|
195
216
|
readonly repoRoot: string;
|
|
196
217
|
readonly memoryPath: string;
|
|
197
218
|
readonly storageBudgetBytes: number;
|
|
219
|
+
readonly alwaysInclude: boolean;
|
|
198
220
|
readonly behavior?: BehaviorConfig;
|
|
199
221
|
readonly clock?: Clock;
|
|
200
222
|
readonly git?: GitService;
|