@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 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, and lobe auto-detection from file paths |
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
@@ -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
  }
@@ -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 { DEFAULT_STORAGE_BUDGET_BYTES, parseTopicScope, parseTrustLevel } from './types.js';
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
- // Global store for user identity + preferences (shared across all lobes)
76
- const GLOBAL_TOPICS = new Set(['user', 'preferences']);
77
- const globalMemoryPath = path.join(os.homedir(), '.memory-mcp', 'global');
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, opts) {
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
- // Short-circuit: single lobe is unambiguous
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, only global knowledge (user/preferences) is returned specify a lobe explicitly to access lobe-specific knowledge. Required for writes. Available: ${currentLobeNames.join(', ')}`,
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 global. One insight per object; use multiple objects instead of bundling.',
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 globalStats = await globalStore.stats();
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 isGlobal = GLOBAL_TOPICS.has(topic);
482
- const ctx = resolveToolContext(effectiveLobe, { isGlobal });
492
+ const ctx = resolveToolContext(effectiveLobe);
483
493
  if (!ctx.ok)
484
494
  return contextError(ctx);
485
- const effectiveTrust = isGlobal && trust === 'agent-inferred' ? 'user' : trust;
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 (!result.stored) {
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
- const isGlobalQuery = GLOBAL_TOPICS.has(scope);
590
- // Resolve which lobes to search.
591
- // Global topics always route to globalStore. Lobe topics follow the degradation ladder.
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 (isGlobalQuery) {
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
- // For wildcard queries on non-global topics, also include global store entries
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 = [...globalEntries, ...lobeEntries]
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 (isGlobalQuery) {
666
- searchedStores.push(globalStore);
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 — route global entries (user-*, pref-*) to global store
762
- const isGlobalEntry = id.startsWith('user-') || id.startsWith('pref-');
763
- const ctx = resolveToolContext(rawLobe, { isGlobal: isGlobalEntry });
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
- if (globalBriefing.entryCount > 0) {
831
- sections.push(globalBriefing.briefing);
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
- if (globalBriefing.staleDetails)
836
- allStale.push(...globalBriefing.staleDetails);
837
- let totalEntries = globalBriefing.entryCount;
838
- let totalStale = globalBriefing.staleEntries;
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 lobeBriefing = await store.briefing(100); // just enough for stale data + counts
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 = [globalStore];
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
- // Always include global store (user + preferences)
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 = [...globalResults, ...allLobeResults]
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
- // Only include stores that were actually searched (not all lobes)
941
- const ctxSearchedStores = [globalStore];
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 sections = [formatStats('global (user + preferences)', globalStats), formatStats(ctx.label, result)];
1035
- return { content: [{ type: 'text', text: sections.join('\n\n---\n\n') }] };
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 = [formatStats('global (user + preferences)', globalStats)];
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
- sections.push(formatStats(lobeName, result));
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 => !r.stored);
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
- process.stderr.write(`[memory-mcp] Server started${modeStr} with ${healthyLobes}/${lobeConfigs.size} lobe(s) + global store\n`);
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
- export declare function buildLobeResolution(allLobeNames: readonly string[], matchedLobes: readonly string[]): LobeResolution;
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;
@@ -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
- export function buildLobeResolution(allLobeNames, matchedLobes) {
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
- // Multiple lobes with successful root match
54
- if (matchedLobes.length > 0) {
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: matchedLobes,
58
- label: matchedLobes.length === 1 ? matchedLobes[0] : matchedLobes.join('+'),
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exaudeus/memory-mcp",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Codebase memory MCP server - persistent, evolving knowledge for AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",