@exaudeus/memory-mcp 1.3.0 → 1.4.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.
@@ -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.',
228
219
  inputSchema: {
229
220
  type: 'object',
230
221
  properties: {
@@ -310,6 +301,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
310
301
  type: 'string',
311
302
  description: 'Branch for recent-work. Omit = current branch, "*" = all branches.',
312
303
  },
304
+ isFirstMemoryToolCall: {
305
+ type: 'boolean',
306
+ 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.',
307
+ default: true,
308
+ },
313
309
  },
314
310
  required: [],
315
311
  },
@@ -359,6 +355,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
359
355
  description: 'Min keyword match ratio 0-1 (default: 0.2). Higher = stricter.',
360
356
  default: 0.2,
361
357
  },
358
+ isFirstMemoryToolCall: {
359
+ type: 'boolean',
360
+ 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.',
361
+ default: true,
362
+ },
362
363
  },
363
364
  required: [],
364
365
  },
@@ -426,16 +427,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
426
427
  case 'memory_list_lobes': {
427
428
  // Delegates to shared builder — same data as memory://lobes resource
428
429
  const lobeInfo = await buildLobeInfo();
429
- const globalStats = await globalStore.stats();
430
+ const alwaysIncludeNames = configManager.getAlwaysIncludeLobes();
430
431
  const result = {
431
432
  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
433
  lobes: lobeInfo,
434
+ alwaysIncludeLobes: alwaysIncludeNames,
439
435
  configFile: configFileDisplay(),
440
436
  configSource: configOrigin.source,
441
437
  totalLobes: lobeInfo.length,
@@ -477,12 +473,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
477
473
  const allPaths = [...sources, ...references];
478
474
  effectiveLobe = inferLobeFromPaths(allPaths);
479
475
  }
476
+ // Auto-route user/preferences writes to the first alwaysInclude lobe when no lobe specified.
477
+ // This preserves the previous behavior where these topics auto-routed to the global store.
478
+ if (!effectiveLobe && ALWAYS_INCLUDE_WRITE_TOPICS.has(topic)) {
479
+ const alwaysIncludeLobes = configManager.getAlwaysIncludeLobes();
480
+ if (alwaysIncludeLobes.length > 0) {
481
+ effectiveLobe = alwaysIncludeLobes[0];
482
+ }
483
+ }
480
484
  // Resolve store — after this point, rawLobe is never used again
481
- const isGlobal = GLOBAL_TOPICS.has(topic);
482
- const ctx = resolveToolContext(effectiveLobe, { isGlobal });
485
+ const ctx = resolveToolContext(effectiveLobe);
483
486
  if (!ctx.ok)
484
487
  return contextError(ctx);
485
- const effectiveTrust = isGlobal && trust === 'agent-inferred' ? 'user' : trust;
488
+ // Auto-promote trust for global topics: agents writing user/preferences without explicit
489
+ // trust: "user" still get full confidence. Preserves pre-unification behavior where the
490
+ // global store always stored these at user trust — removing this would silently downgrade
491
+ // identity entries to confidence 0.70 (see philosophy: "Observability as a constraint").
492
+ const effectiveTrust = ALWAYS_INCLUDE_WRITE_TOPICS.has(topic) && trust === 'agent-inferred'
493
+ ? 'user'
494
+ : trust;
486
495
  const storedResults = [];
487
496
  for (const { title, fact } of rawEntries) {
488
497
  const result = await ctx.store.store(topic, title, fact, sources, effectiveTrust, references, rawTags);
@@ -579,33 +588,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
579
588
  return { content: [{ type: 'text', text: lines.join('\n') }] };
580
589
  }
581
590
  case 'memory_query': {
582
- const { lobe: rawLobe, scope, detail, filter, branch } = z.object({
591
+ const { lobe: rawLobe, scope, detail, filter, branch, isFirstMemoryToolCall: rawIsFirst } = z.object({
583
592
  lobe: z.string().optional(),
584
593
  scope: z.string().default('*'),
585
594
  detail: z.enum(['brief', 'standard', 'full']).default('brief'),
586
595
  filter: z.string().optional(),
587
596
  branch: z.string().optional(),
597
+ isFirstMemoryToolCall: z.boolean().default(true),
588
598
  }).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.
599
+ // Force-include alwaysInclude lobes when querying a global topic (user/preferences),
600
+ // regardless of isFirstMemoryToolCall the agent explicitly asked for this data.
601
+ // Philosophy: "Determinism over cleverness" same query produces same results.
602
+ const topicScope = parseTopicScope(scope);
603
+ const effectiveIsFirst = rawIsFirst || (topicScope !== null && ALWAYS_INCLUDE_WRITE_TOPICS.has(topicScope));
604
+ // Resolve which lobes to search — unified path for all topics.
592
605
  let lobeEntries = [];
593
606
  const entryLobeMap = new Map(); // entry id → lobe name
594
607
  let label;
595
608
  let primaryStore;
596
609
  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) {
610
+ if (rawLobe) {
609
611
  const ctx = resolveToolContext(rawLobe);
610
612
  if (!ctx.ok)
611
613
  return contextError(ctx);
@@ -615,7 +617,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
615
617
  lobeEntries = [...result.entries];
616
618
  }
617
619
  else {
618
- const resolution = await resolveLobesForRead();
620
+ const resolution = await resolveLobesForRead(effectiveIsFirst);
619
621
  switch (resolution.kind) {
620
622
  case 'resolved': {
621
623
  label = resolution.label;
@@ -641,17 +643,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
641
643
  }
642
644
  }
643
645
  }
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
646
+ // Dedupe by id, sort by relevance score
653
647
  const seenQueryIds = new Set();
654
- const allEntries = [...globalEntries, ...lobeEntries]
648
+ const allEntries = lobeEntries
655
649
  .filter(e => {
656
650
  if (seenQueryIds.has(e.id))
657
651
  return false;
@@ -660,17 +654,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
660
654
  })
661
655
  .sort((a, b) => b.relevanceScore - a.relevanceScore);
662
656
  // Build stores collection for tag frequency aggregation
663
- // Only include stores that were actually searched
664
657
  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
- }
658
+ if (primaryStore)
659
+ searchedStores.push(primaryStore);
674
660
  const tagFreq = mergeTagFrequencies(searchedStores);
675
661
  // Parse filter once for both filtering (already done) and footer display
676
662
  const filterGroups = filter ? parseFilter(filter) : [];
@@ -758,26 +744,45 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
758
744
  isError: true,
759
745
  };
760
746
  }
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 });
747
+ // Resolve store — if no lobe specified, probe alwaysInclude lobes first (read-only)
748
+ // to find where user/pref entries live, then apply the correction only to the owning store.
749
+ // Philosophy: "Prefer atomicity for correctness" never call correct() speculatively.
750
+ let effectiveCorrectLobe = rawLobe;
751
+ if (!effectiveCorrectLobe) {
752
+ const alwaysIncludeLobes = configManager.getAlwaysIncludeLobes();
753
+ for (const lobeName of alwaysIncludeLobes) {
754
+ const store = configManager.getStore(lobeName);
755
+ if (!store)
756
+ continue;
757
+ try {
758
+ if (await store.hasEntry(id)) {
759
+ effectiveCorrectLobe = lobeName;
760
+ break;
761
+ }
762
+ }
763
+ catch (err) {
764
+ process.stderr.write(`[memory-mcp] Warning: hasEntry probe failed for lobe "${lobeName}": ${err instanceof Error ? err.message : String(err)}\n`);
765
+ }
766
+ }
767
+ }
768
+ // If we probed alwaysInclude lobes and didn't find the entry, provide a richer error
769
+ // than the generic "Lobe is required" from resolveToolContext.
770
+ if (!effectiveCorrectLobe && !rawLobe) {
771
+ const searchedLobes = configManager.getAlwaysIncludeLobes();
772
+ const allLobes = configManager.getLobeNames();
773
+ const searchedNote = searchedLobes.length > 0
774
+ ? `Searched alwaysInclude lobes (${searchedLobes.join(', ')}) — entry not found. `
775
+ : '';
776
+ return {
777
+ content: [{ type: 'text', text: `Entry "${id}" not found. ${searchedNote}Specify the lobe that contains it. Available: ${allLobes.join(', ')}` }],
778
+ isError: true,
779
+ };
780
+ }
781
+ const ctx = resolveToolContext(effectiveCorrectLobe);
764
782
  if (!ctx.ok)
765
783
  return contextError(ctx);
766
784
  const result = await ctx.store.correct(id, correction ?? '', action);
767
785
  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
786
  return {
782
787
  content: [{ type: 'text', text: `[${ctx.label}] Failed to correct: ${result.error}` }],
783
788
  isError: true,
@@ -799,11 +804,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
799
804
  return { content: [{ type: 'text', text: lines.join('\n') }] };
800
805
  }
801
806
  case 'memory_context': {
802
- const { lobe: rawLobe, context, maxResults, minMatch } = z.object({
807
+ const { lobe: rawLobe, context, maxResults, minMatch, isFirstMemoryToolCall: rawIsFirst } = z.object({
803
808
  lobe: z.string().optional(),
804
809
  context: z.string().optional(),
805
810
  maxResults: z.number().optional(),
806
811
  minMatch: z.number().min(0).max(1).optional(),
812
+ isFirstMemoryToolCall: z.boolean().default(true),
807
813
  }).parse(args ?? {});
808
814
  // --- Briefing mode: no context provided → user + preferences + stale nudges ---
809
815
  if (!context) {
@@ -820,22 +826,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
820
826
  const degradedSection = degradedLobeNames.length > 0
821
827
  ? `## ⚠ Degraded Lobes: ${degradedLobeNames.join(', ')}\nRun **memory_diagnose** for details.\n`
822
828
  : '';
823
- // Global store holds user + preferences — always included
824
- const globalBriefing = await globalStore.briefing(300);
825
829
  const sections = [];
826
830
  if (crashSection)
827
831
  sections.push(crashSection);
828
832
  if (degradedSection)
829
833
  sections.push(degradedSection);
830
- if (globalBriefing.entryCount > 0) {
831
- sections.push(globalBriefing.briefing);
832
- }
833
- // Collect stale entries and entry counts across all lobes
834
+ // Collect briefing, stale entries, and entry counts across all lobes
835
+ // (alwaysInclude lobes are in the lobe list — no separate global store query needed)
834
836
  const allStale = [];
835
- if (globalBriefing.staleDetails)
836
- allStale.push(...globalBriefing.staleDetails);
837
- let totalEntries = globalBriefing.entryCount;
838
- let totalStale = globalBriefing.staleEntries;
837
+ let totalEntries = 0;
838
+ let totalStale = 0;
839
+ // Give alwaysInclude lobes a higher token budget (identity/preferences are high-value)
840
+ const alwaysIncludeSet = new Set(configManager.getAlwaysIncludeLobes());
839
841
  for (const lobeName of allBriefingLobeNames) {
840
842
  const health = configManager.getLobeHealth(lobeName);
841
843
  if (health?.status === 'degraded')
@@ -843,7 +845,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
843
845
  const store = configManager.getStore(lobeName);
844
846
  if (!store)
845
847
  continue;
846
- const lobeBriefing = await store.briefing(100); // just enough for stale data + counts
848
+ const budget = alwaysIncludeSet.has(lobeName) ? 300 : 100;
849
+ const lobeBriefing = await store.briefing(budget);
850
+ if (alwaysIncludeSet.has(lobeName) && lobeBriefing.entryCount > 0) {
851
+ sections.push(lobeBriefing.briefing);
852
+ }
847
853
  if (lobeBriefing.staleDetails)
848
854
  allStale.push(...lobeBriefing.staleDetails);
849
855
  totalEntries += lobeBriefing.entryCount;
@@ -856,7 +862,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
856
862
  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
863
  }
858
864
  // Tag primer: show tag vocabulary if tags exist across any lobe
859
- const briefingStores = [globalStore];
865
+ const briefingStores = [];
860
866
  for (const lobeName of allBriefingLobeNames) {
861
867
  const store = configManager.getStore(lobeName);
862
868
  if (store)
@@ -895,7 +901,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
895
901
  allLobeResults.push(...lobeResults);
896
902
  }
897
903
  else {
898
- const resolution = await resolveLobesForRead();
904
+ const resolution = await resolveLobesForRead(rawIsFirst);
899
905
  switch (resolution.kind) {
900
906
  case 'resolved': {
901
907
  label = resolution.label;
@@ -921,13 +927,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
921
927
  }
922
928
  }
923
929
  }
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
930
+ // Dedupe by entry id, re-sort by score, take top N
929
931
  const seenIds = new Set();
930
- const results = [...globalResults, ...allLobeResults]
932
+ const results = allLobeResults
931
933
  .sort((a, b) => b.score - a.score)
932
934
  .filter(r => {
933
935
  if (seenIds.has(r.entry.id))
@@ -937,11 +939,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
937
939
  })
938
940
  .slice(0, max);
939
941
  // 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) {
942
+ const ctxSearchedStores = [];
943
+ if (primaryStore)
943
944
  ctxSearchedStores.push(primaryStore);
944
- }
945
945
  const ctxTagFreq = mergeTagFrequencies(ctxSearchedStores);
946
946
  // Parse filter for footer (context search has no filter, pass empty)
947
947
  const ctxFilterGroups = [];
@@ -1023,24 +1023,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1023
1023
  const { lobe: rawLobe } = z.object({
1024
1024
  lobe: z.string().optional(),
1025
1025
  }).parse(args ?? {});
1026
- // Always include global stats
1027
- const globalStats = await globalStore.stats();
1028
1026
  // Single lobe stats
1029
1027
  if (rawLobe) {
1030
1028
  const ctx = resolveToolContext(rawLobe);
1031
1029
  if (!ctx.ok)
1032
1030
  return contextError(ctx);
1033
1031
  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') }] };
1032
+ const alwaysIncludeSet = new Set(configManager.getAlwaysIncludeLobes());
1033
+ const label = alwaysIncludeSet.has(rawLobe) ? `${ctx.label} (alwaysInclude)` : ctx.label;
1034
+ return { content: [{ type: 'text', text: formatStats(label, result) }] };
1036
1035
  }
1037
1036
  // Combined stats across all lobes
1038
- const sections = [formatStats('global (user + preferences)', globalStats)];
1037
+ const sections = [];
1039
1038
  const allLobeNames = configManager.getLobeNames();
1039
+ const alwaysIncludeSet = new Set(configManager.getAlwaysIncludeLobes());
1040
1040
  for (const lobeName of allLobeNames) {
1041
1041
  const store = configManager.getStore(lobeName);
1042
+ if (!store)
1043
+ continue;
1042
1044
  const result = await store.stats();
1043
- sections.push(formatStats(lobeName, result));
1045
+ const label = alwaysIncludeSet.has(lobeName) ? `${lobeName} (alwaysInclude)` : lobeName;
1046
+ sections.push(formatStats(label, result));
1044
1047
  }
1045
1048
  return { content: [{ type: 'text', text: sections.join('\n\n---\n\n') }] };
1046
1049
  }
@@ -1201,14 +1204,6 @@ async function buildDiagnosticsText(showFullCrashHistory) {
1201
1204
  }
1202
1205
  }
1203
1206
  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
1207
  // Active behavior config — shows effective values and highlights user overrides
1213
1208
  sections.push('### Active Behavior Config');
1214
1209
  sections.push(formatBehaviorConfigSection(configBehavior));
@@ -1260,15 +1255,6 @@ async function main() {
1260
1255
  process.stderr.write(`[memory-mcp] Previous crash detected (${age}s ago): ${previousCrash.type} — ${previousCrash.error}\n`);
1261
1256
  process.stderr.write(`[memory-mcp] Crash report will be shown in memory_context and memory_diagnose.\n`);
1262
1257
  }
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
1258
  // Initialize each lobe independently — a broken lobe shouldn't prevent others from working
1273
1259
  let healthyLobes = 0;
1274
1260
  for (const [name, config] of lobeConfigs) {
@@ -1326,46 +1312,6 @@ async function main() {
1326
1312
  };
1327
1313
  process.stderr.write(`[memory-mcp] ⚠ DEGRADED: ${healthyLobes}/${lobeConfigs.size} lobes healthy.\n`);
1328
1314
  }
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
1315
  // Initialize ConfigManager with current config state
1370
1316
  configManager = new ConfigManager(configPath, { configs: lobeConfigs, origin: configOrigin }, stores, lobeHealth);
1371
1317
  const transport = new StdioServerTransport();
@@ -1393,7 +1339,12 @@ async function main() {
1393
1339
  });
1394
1340
  await server.connect(transport);
1395
1341
  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`);
1342
+ const alwaysIncludeNames = configManager.getAlwaysIncludeLobes();
1343
+ const aiLabel = alwaysIncludeNames.length > 0 ? ` (alwaysInclude: ${alwaysIncludeNames.join(', ')})` : '';
1344
+ if (alwaysIncludeNames.length > 1) {
1345
+ 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`);
1346
+ }
1347
+ process.stderr.write(`[memory-mcp] Server started${modeStr} with ${healthyLobes}/${lobeConfigs.size} lobe(s)${aiLabel}\n`);
1397
1348
  // Graceful shutdown on signals
1398
1349
  const shutdown = () => {
1399
1350
  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
@@ -18,6 +18,9 @@ export declare class MarkdownMemoryStore {
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
@@ -256,6 +256,12 @@ export class MarkdownMemoryStore {
256
256
  suggestion,
257
257
  };
258
258
  }
259
+ /** Check if an entry exists by ID — read-only, no side effects beyond disk reload.
260
+ * Use this to probe for entry ownership before calling correct(). */
261
+ async hasEntry(id) {
262
+ await this.reloadFromDisk();
263
+ return this.entries.has(id);
264
+ }
259
265
  /** Correct an existing entry */
260
266
  async correct(id, correction, action) {
261
267
  // Reload to ensure we have the latest
package/dist/types.d.ts CHANGED
@@ -195,6 +195,7 @@ export interface MemoryConfig {
195
195
  readonly repoRoot: string;
196
196
  readonly memoryPath: string;
197
197
  readonly storageBudgetBytes: number;
198
+ readonly alwaysInclude: boolean;
198
199
  readonly behavior?: BehaviorConfig;
199
200
  readonly clock?: Clock;
200
201
  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.4.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",