@exaudeus/memory-mcp 1.2.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.
package/dist/index.js CHANGED
@@ -7,18 +7,17 @@ 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';
18
16
  import { buildCrashReport, writeCrashReport, writeCrashReportSync, readLatestCrash, readCrashHistory, clearLatestCrash, formatCrashReport, formatCrashSummary, markServerStarted, } from './crash-journal.js';
19
17
  import { formatStaleSection, formatConflictWarning, formatStats, formatBehaviorConfigSection, mergeTagFrequencies, buildQueryFooter, buildTagPrimerSection } from './formatters.js';
20
- import { extractKeywords, parseFilter } from './text-analyzer.js';
21
- import { CROSS_LOBE_WEAK_SCORE_PENALTY, CROSS_LOBE_MIN_MATCH_RATIO, VOCABULARY_ECHO_LIMIT } from './thresholds.js';
18
+ import { parseFilter } from './text-analyzer.js';
19
+ import { VOCABULARY_ECHO_LIMIT, WARN_SEPARATOR } from './thresholds.js';
20
+ import { matchRootsToLobeNames, buildLobeResolution } from './lobe-resolution.js';
22
21
  let serverMode = { kind: 'running' };
23
22
  const lobeHealth = new Map();
24
23
  const serverStartTime = Date.now();
@@ -71,21 +70,12 @@ const stores = new Map();
71
70
  const lobeNames = Array.from(lobeConfigs.keys());
72
71
  // ConfigManager will be initialized after stores are set up
73
72
  let configManager;
74
- // Global store for user identity + preferences (shared across all lobes)
75
- const GLOBAL_TOPICS = new Set(['user', 'preferences']);
76
- const globalMemoryPath = path.join(os.homedir(), '.memory-mcp', 'global');
77
- const globalStore = new MarkdownMemoryStore({
78
- repoRoot: os.homedir(),
79
- memoryPath: globalMemoryPath,
80
- storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES,
81
- });
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']);
82
76
  /** Resolve a raw lobe name to a validated store + display label.
83
77
  * After this call, consumers use ctx.label — the raw lobe is not in scope. */
84
- function resolveToolContext(rawLobe, opts) {
85
- // Global topics always route to the global store
86
- if (opts?.isGlobal) {
87
- return { ok: true, store: globalStore, label: 'global' };
88
- }
78
+ function resolveToolContext(rawLobe) {
89
79
  const lobeNames = configManager.getLobeNames();
90
80
  // Default to single lobe when omitted
91
81
  const lobe = rawLobe || (lobeNames.length === 1 ? lobeNames[0] : undefined);
@@ -154,6 +144,45 @@ function inferLobeFromPaths(paths) {
154
144
  return matchedLobes.size === 1 ? matchedLobes.values().next().value : undefined;
155
145
  }
156
146
  const server = new Server({ name: 'memory-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
147
+ // --- Lobe resolution for read operations ---
148
+ // When the agent doesn't specify a lobe, we determine which lobe(s) to search
149
+ // via a degradation ladder (see lobe-resolution.ts for the pure logic):
150
+ // 1. Single lobe configured → use it (unambiguous)
151
+ // 2. Multiple lobes → ask client for workspace roots via MCP roots/list
152
+ // 3. Fallback → global-only with a hint to specify the lobe
153
+ /** Resolve which lobes to search for a read operation when the agent omitted the lobe param.
154
+ * Wires the MCP server's listRoots into the pure resolution logic. */
155
+ async function resolveLobesForRead(isFirstMemoryToolCall = true) {
156
+ const allLobeNames = configManager.getLobeNames();
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.
160
+ if (allLobeNames.length === 1) {
161
+ return buildLobeResolution(allLobeNames, allLobeNames, alwaysIncludeLobes, isFirstMemoryToolCall);
162
+ }
163
+ // Multiple lobes — try MCP client roots
164
+ const clientCaps = server.getClientCapabilities();
165
+ if (clientCaps?.roots) {
166
+ try {
167
+ const { roots } = await server.listRoots();
168
+ if (roots && roots.length > 0) {
169
+ const lobeConfigs = allLobeNames
170
+ .map(name => {
171
+ const config = configManager.getLobeConfig(name);
172
+ return config ? { name, repoRoot: config.repoRoot } : undefined;
173
+ })
174
+ .filter((c) => c !== undefined);
175
+ const matched = matchRootsToLobeNames(roots, lobeConfigs);
176
+ return buildLobeResolution(allLobeNames, matched, alwaysIncludeLobes, isFirstMemoryToolCall);
177
+ }
178
+ }
179
+ catch (err) {
180
+ process.stderr.write(`[memory-mcp] listRoots failed: ${err instanceof Error ? err.message : String(err)}\n`);
181
+ }
182
+ }
183
+ // Fallback — roots not available or no match
184
+ return buildLobeResolution(allLobeNames, [], alwaysIncludeLobes, isFirstMemoryToolCall);
185
+ }
157
186
  /** Build the shared lobe property for tool schemas — called on each ListTools request
158
187
  * so the description and enum stay in sync after a hot-reload adds or removes lobes. */
159
188
  function buildLobeProperty(currentLobeNames) {
@@ -162,7 +191,7 @@ function buildLobeProperty(currentLobeNames) {
162
191
  type: 'string',
163
192
  description: isSingle
164
193
  ? `Memory lobe name (defaults to "${currentLobeNames[0]}" if omitted)`
165
- : `Memory lobe name. Optional for reads (query/context/briefing/stats search all lobes when omitted). Required for writes (store/correct/bootstrap). 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(', ')}`,
166
195
  enum: currentLobeNames.length > 1 ? [...currentLobeNames] : undefined,
167
196
  };
168
197
  }
@@ -186,7 +215,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
186
215
  // Example comes first — agents form their call shape from the first concrete pattern they see.
187
216
  // "entries" (not "content") signals a collection; fighting the "content = string" prior
188
217
  // is an architectural fix rather than patching the description after the fact.
189
- 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.',
190
219
  inputSchema: {
191
220
  type: 'object',
192
221
  properties: {
@@ -272,6 +301,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
272
301
  type: 'string',
273
302
  description: 'Branch for recent-work. Omit = current branch, "*" = all branches.',
274
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
+ },
275
309
  },
276
310
  required: [],
277
311
  },
@@ -302,7 +336,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
302
336
  },
303
337
  {
304
338
  name: 'memory_context',
305
- description: 'Session start AND pre-task lookup. Call with no args at session start to get user identity, preferences, and stale entries. Call with context to get task-specific knowledge. Searches all lobes when lobe is omitted. Example: memory_context() or memory_context(context: "writing a Kotlin reducer")',
339
+ description: 'Session start AND pre-task lookup. Call with no args at session start to get user identity, preferences, and stale entries. Call with context to get task-specific knowledge. When lobe is omitted, uses client workspace roots to select the matching lobe; falls back to global-only if roots are unavailable. Example: memory_context() or memory_context(context: "writing a Kotlin reducer")',
306
340
  inputSchema: {
307
341
  type: 'object',
308
342
  properties: {
@@ -321,6 +355,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
321
355
  description: 'Min keyword match ratio 0-1 (default: 0.2). Higher = stricter.',
322
356
  default: 0.2,
323
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
+ },
324
363
  },
325
364
  required: [],
326
365
  },
@@ -388,16 +427,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
388
427
  case 'memory_list_lobes': {
389
428
  // Delegates to shared builder — same data as memory://lobes resource
390
429
  const lobeInfo = await buildLobeInfo();
391
- const globalStats = await globalStore.stats();
430
+ const alwaysIncludeNames = configManager.getAlwaysIncludeLobes();
392
431
  const result = {
393
432
  serverMode: serverMode.kind,
394
- globalStore: {
395
- memoryPath: globalMemoryPath,
396
- entries: globalStats.totalEntries,
397
- storageUsed: globalStats.storageSize,
398
- topics: 'user, preferences (shared across all lobes)',
399
- },
400
433
  lobes: lobeInfo,
434
+ alwaysIncludeLobes: alwaysIncludeNames,
401
435
  configFile: configFileDisplay(),
402
436
  configSource: configOrigin.source,
403
437
  totalLobes: lobeInfo.length,
@@ -439,12 +473,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
439
473
  const allPaths = [...sources, ...references];
440
474
  effectiveLobe = inferLobeFromPaths(allPaths);
441
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
+ }
442
484
  // Resolve store — after this point, rawLobe is never used again
443
- const isGlobal = GLOBAL_TOPICS.has(topic);
444
- const ctx = resolveToolContext(effectiveLobe, { isGlobal });
485
+ const ctx = resolveToolContext(effectiveLobe);
445
486
  if (!ctx.ok)
446
487
  return contextError(ctx);
447
- 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;
448
495
  const storedResults = [];
449
496
  for (const { title, fact } of rawEntries) {
450
497
  const result = await ctx.store.store(topic, title, fact, sources, effectiveTrust, references, rawTags);
@@ -456,11 +503,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
456
503
  }
457
504
  storedResults.push({ title, result });
458
505
  }
459
- // Build response header
506
+ // Build response header.
507
+ // For high-severity ephemeral detections, flag the success line itself so agents
508
+ // who anchor on line 1 still see the problem before reading the block below.
460
509
  const lines = [];
461
510
  if (storedResults.length === 1) {
462
511
  const { result } = storedResults[0];
463
- lines.push(`[${ctx.label}] Stored entry ${result.id} in ${result.topic} (confidence: ${result.confidence})`);
512
+ const ephemeralFlag = result.ephemeralSeverity === 'high' ? ' (⚠ ephemeral — see below)' : '';
513
+ lines.push(`[${ctx.label}] Stored entry ${result.id} in ${result.topic} (confidence: ${result.confidence})${ephemeralFlag}`);
464
514
  if (result.warning)
465
515
  lines.push(`Note: ${result.warning}`);
466
516
  }
@@ -468,7 +518,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
468
518
  const { result: first } = storedResults[0];
469
519
  lines.push(`[${ctx.label}] Stored ${storedResults.length} entries in ${first.topic} (confidence: ${first.confidence}):`);
470
520
  for (const { title, result } of storedResults) {
471
- lines.push(` - ${result.id}: "${title}"`);
521
+ const ephemeralFlag = result.ephemeralSeverity === 'high' ? ' ⚠' : '';
522
+ lines.push(` - ${result.id}: "${title}"${ephemeralFlag}`);
472
523
  }
473
524
  }
474
525
  // Limit to at most 2 hint sections per response to prevent hint fatigue.
@@ -478,23 +529,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
478
529
  let hintCount = 0;
479
530
  for (const { title, result } of storedResults) {
480
531
  const entryPrefix = storedResults.length > 1 ? `"${title}": ` : '';
481
- // Dedup: surface related entries in the same topic
532
+ // Dedup: surface related entries in the same topic.
533
+ // Fill in both actual IDs so the agent can act immediately without looking them up.
482
534
  if (result.relatedEntries && result.relatedEntries.length > 0 && hintCount < 2) {
483
535
  hintCount++;
536
+ const top = result.relatedEntries[0];
484
537
  lines.push('');
485
- lines.push(`⚠ ${entryPrefix}Similar entries found in the same topic:`);
486
- for (const r of result.relatedEntries) {
487
- lines.push(` - ${r.id}: "${r.title}" (confidence: ${r.confidence})`);
488
- lines.push(` Content: ${r.content.length > 120 ? r.content.substring(0, 120) + '...' : r.content}`);
538
+ lines.push(WARN_SEPARATOR);
539
+ lines.push(`⚠ ${entryPrefix}SIMILAR ENTRY ALREADY EXISTS — CONSOLIDATE ⚠`);
540
+ lines.push(WARN_SEPARATOR);
541
+ lines.push(` ${top.id}: "${top.title}" (confidence: ${top.confidence})`);
542
+ lines.push(` ${top.content.length > 120 ? top.content.substring(0, 120) + '...' : top.content}`);
543
+ if (result.relatedEntries.length > 1) {
544
+ const extra = result.relatedEntries.length - 1;
545
+ lines.push(` ... and ${extra} more similar ${extra === 1 ? 'entry' : 'entries'}`);
489
546
  }
490
547
  lines.push('');
491
- lines.push('To consolidate: memory_correct(id: "<old-id>", action: "replace", correction: "<merged content>") then memory_correct(id: "<new-id>", action: "delete")');
548
+ lines.push('If these overlap, consolidate:');
549
+ lines.push(` KEEP+UPDATE: memory_correct(id: "${top.id}", action: "replace", correction: "<merged content>")`);
550
+ lines.push(` DELETE new: memory_correct(id: "${result.id}", action: "delete")`);
551
+ lines.push(WARN_SEPARATOR);
492
552
  }
493
- // Ephemeral content warning — soft nudge, never blocking
553
+ // Ephemeral content warning — the formatted block already contains visual borders
554
+ // and pre-filled delete command from formatEphemeralWarning.
494
555
  if (result.ephemeralWarning && hintCount < 2) {
495
556
  hintCount++;
496
557
  lines.push('');
497
- lines.push(`⏳ ${entryPrefix}${result.ephemeralWarning}`);
558
+ if (entryPrefix)
559
+ lines.push(`${entryPrefix}:`);
560
+ lines.push(result.ephemeralWarning);
498
561
  }
499
562
  // Preference surfacing: show relevant preferences for non-preference entries
500
563
  if (result.relevantPreferences && result.relevantPreferences.length > 0 && hintCount < 2) {
@@ -525,33 +588,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
525
588
  return { content: [{ type: 'text', text: lines.join('\n') }] };
526
589
  }
527
590
  case 'memory_query': {
528
- const { lobe: rawLobe, scope, detail, filter, branch } = z.object({
591
+ const { lobe: rawLobe, scope, detail, filter, branch, isFirstMemoryToolCall: rawIsFirst } = z.object({
529
592
  lobe: z.string().optional(),
530
593
  scope: z.string().default('*'),
531
594
  detail: z.enum(['brief', 'standard', 'full']).default('brief'),
532
595
  filter: z.string().optional(),
533
596
  branch: z.string().optional(),
597
+ isFirstMemoryToolCall: z.boolean().default(true),
534
598
  }).parse(args ?? {});
535
- const isGlobalQuery = GLOBAL_TOPICS.has(scope);
536
- // For global topics (user, preferences), always route to global store.
537
- // For lobe topics: if lobe specified single lobe. If omitted → ALL healthy lobes.
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.
538
605
  let lobeEntries = [];
539
- const entryLobeMap = new Map(); // entry id → lobe name (for cross-lobe labeling)
606
+ const entryLobeMap = new Map(); // entry id → lobe name
540
607
  let label;
541
608
  let primaryStore;
542
- let isMultiLobe = false;
543
- if (isGlobalQuery) {
544
- const ctx = resolveToolContext(rawLobe, { isGlobal: true });
545
- if (!ctx.ok)
546
- return contextError(ctx);
547
- label = ctx.label;
548
- primaryStore = ctx.store;
549
- const result = await ctx.store.query(scope, detail, filter, branch);
550
- for (const e of result.entries)
551
- entryLobeMap.set(e.id, 'global');
552
- lobeEntries = [...result.entries];
553
- }
554
- else if (rawLobe) {
609
+ let queryGlobalOnlyHint;
610
+ if (rawLobe) {
555
611
  const ctx = resolveToolContext(rawLobe);
556
612
  if (!ctx.ok)
557
613
  return contextError(ctx);
@@ -561,33 +617,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
561
617
  lobeEntries = [...result.entries];
562
618
  }
563
619
  else {
564
- // Search all healthy lobes — read operations shouldn't require lobe selection
565
- const allLobeNames = configManager.getLobeNames();
566
- isMultiLobe = allLobeNames.length > 1;
567
- label = allLobeNames.length === 1 ? allLobeNames[0] : 'all';
568
- for (const lobeName of allLobeNames) {
569
- const store = configManager.getStore(lobeName);
570
- if (!store)
571
- continue;
572
- if (!primaryStore)
573
- primaryStore = store;
574
- const result = await store.query(scope, detail, filter, branch);
575
- for (const e of result.entries)
576
- entryLobeMap.set(e.id, lobeName);
577
- lobeEntries.push(...result.entries);
620
+ const resolution = await resolveLobesForRead(effectiveIsFirst);
621
+ switch (resolution.kind) {
622
+ case 'resolved': {
623
+ label = resolution.label;
624
+ for (const lobeName of resolution.lobes) {
625
+ const store = configManager.getStore(lobeName);
626
+ if (!store)
627
+ continue;
628
+ if (!primaryStore)
629
+ primaryStore = store;
630
+ const result = await store.query(scope, detail, filter, branch);
631
+ if (resolution.lobes.length > 1) {
632
+ for (const e of result.entries)
633
+ entryLobeMap.set(e.id, lobeName);
634
+ }
635
+ lobeEntries.push(...result.entries);
636
+ }
637
+ break;
638
+ }
639
+ case 'global-only': {
640
+ label = 'global';
641
+ queryGlobalOnlyHint = resolution.hint;
642
+ break;
643
+ }
578
644
  }
579
645
  }
580
- // For wildcard queries on non-global topics, also include global store entries
581
- let globalEntries = [];
582
- if (scope === '*' && !isGlobalQuery) {
583
- const globalResult = await globalStore.query('*', detail, filter);
584
- for (const e of globalResult.entries)
585
- entryLobeMap.set(e.id, 'global');
586
- globalEntries = [...globalResult.entries];
587
- }
588
- // Merge global + lobe entries, dedupe by id, sort by relevance score
646
+ // Dedupe by id, sort by relevance score
589
647
  const seenQueryIds = new Set();
590
- const allEntries = [...globalEntries, ...lobeEntries]
648
+ const allEntries = lobeEntries
591
649
  .filter(e => {
592
650
  if (seenQueryIds.has(e.id))
593
651
  return false;
@@ -597,46 +655,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
597
655
  .sort((a, b) => b.relevanceScore - a.relevanceScore);
598
656
  // Build stores collection for tag frequency aggregation
599
657
  const searchedStores = [];
600
- if (isGlobalQuery) {
601
- searchedStores.push(globalStore);
602
- }
603
- else if (rawLobe) {
604
- const store = configManager.getStore(rawLobe);
605
- if (store)
606
- searchedStores.push(store);
607
- }
608
- else {
609
- // All lobes + global when doing wildcard search
610
- for (const lobeName of configManager.getLobeNames()) {
611
- const store = configManager.getStore(lobeName);
612
- if (store)
613
- searchedStores.push(store);
614
- }
615
- if (scope === '*')
616
- searchedStores.push(globalStore);
617
- }
658
+ if (primaryStore)
659
+ searchedStores.push(primaryStore);
618
660
  const tagFreq = mergeTagFrequencies(searchedStores);
619
661
  // Parse filter once for both filtering (already done) and footer display
620
662
  const filterGroups = filter ? parseFilter(filter) : [];
621
663
  if (allEntries.length === 0) {
622
664
  const footer = buildQueryFooter({ filterGroups, rawFilter: filter, tagFreq, resultCount: 0, scope });
665
+ const noResultHint = queryGlobalOnlyHint ? `\n\n> ${queryGlobalOnlyHint}` : '';
623
666
  return {
624
667
  content: [{
625
668
  type: 'text',
626
- text: `[${label}] No entries found for scope "${scope}"${filter ? ` with filter "${filter}"` : ''}.\n\n---\n${footer}`,
669
+ text: `[${label}] No entries found for scope "${scope}"${filter ? ` with filter "${filter}"` : ''}.${noResultHint}\n\n---\n${footer}`,
627
670
  }],
628
671
  };
629
672
  }
673
+ const showQueryLobeLabels = entryLobeMap.size > 0;
630
674
  const lines = allEntries.map(e => {
631
675
  const freshIndicator = e.fresh ? '' : ' [stale]';
632
- const lobeTag = isMultiLobe ? ` [${entryLobeMap.get(e.id) ?? '?'}]` : '';
676
+ const lobeTag = showQueryLobeLabels ? ` [${entryLobeMap.get(e.id) ?? '?'}]` : '';
633
677
  if (detail === 'brief') {
634
678
  return `- **${e.title}** (${e.id}${lobeTag}, confidence: ${e.confidence})${freshIndicator}\n ${e.summary}`;
635
679
  }
636
680
  if (detail === 'full') {
637
681
  const meta = [
638
682
  `ID: ${e.id}`,
639
- isMultiLobe ? `Lobe: ${entryLobeMap.get(e.id) ?? '?'}` : null,
683
+ showQueryLobeLabels ? `Lobe: ${entryLobeMap.get(e.id) ?? '?'}` : null,
640
684
  `Confidence: ${e.confidence}`,
641
685
  `Trust: ${e.trust}`,
642
686
  `Fresh: ${e.fresh}`,
@@ -670,6 +714,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
670
714
  text += '\n\n' + formatConflictWarning(conflicts);
671
715
  }
672
716
  }
717
+ // Surface hint when we fell back to global-only
718
+ if (queryGlobalOnlyHint) {
719
+ text += `\n\n> ${queryGlobalOnlyHint}`;
720
+ }
673
721
  // Build footer with query mode, tag vocabulary, and syntax reference
674
722
  const footer = buildQueryFooter({ filterGroups, rawFilter: filter, tagFreq, resultCount: allEntries.length, scope });
675
723
  text += `\n\n---\n${footer}`;
@@ -696,26 +744,45 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
696
744
  isError: true,
697
745
  };
698
746
  }
699
- // Resolve store — route global entries (user-*, pref-*) to global store
700
- const isGlobalEntry = id.startsWith('user-') || id.startsWith('pref-');
701
- 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);
702
782
  if (!ctx.ok)
703
783
  return contextError(ctx);
704
784
  const result = await ctx.store.correct(id, correction ?? '', action);
705
785
  if (!result.corrected) {
706
- // If not found in the targeted store, try the other one as fallback
707
- if (isGlobalEntry) {
708
- const lobeCtx = resolveToolContext(rawLobe);
709
- if (lobeCtx.ok) {
710
- const lobeResult = await lobeCtx.store.correct(id, correction ?? '', action);
711
- if (lobeResult.corrected) {
712
- const text = action === 'delete'
713
- ? `[${lobeCtx.label}] Deleted entry ${id}.`
714
- : `[${lobeCtx.label}] Corrected entry ${id} (action: ${action}, confidence: ${lobeResult.newConfidence}, trust: ${lobeResult.trust}).`;
715
- return { content: [{ type: 'text', text }] };
716
- }
717
- }
718
- }
719
786
  return {
720
787
  content: [{ type: 'text', text: `[${ctx.label}] Failed to correct: ${result.error}` }],
721
788
  isError: true,
@@ -737,11 +804,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
737
804
  return { content: [{ type: 'text', text: lines.join('\n') }] };
738
805
  }
739
806
  case 'memory_context': {
740
- const { lobe: rawLobe, context, maxResults, minMatch } = z.object({
807
+ const { lobe: rawLobe, context, maxResults, minMatch, isFirstMemoryToolCall: rawIsFirst } = z.object({
741
808
  lobe: z.string().optional(),
742
809
  context: z.string().optional(),
743
810
  maxResults: z.number().optional(),
744
811
  minMatch: z.number().min(0).max(1).optional(),
812
+ isFirstMemoryToolCall: z.boolean().default(true),
745
813
  }).parse(args ?? {});
746
814
  // --- Briefing mode: no context provided → user + preferences + stale nudges ---
747
815
  if (!context) {
@@ -758,22 +826,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
758
826
  const degradedSection = degradedLobeNames.length > 0
759
827
  ? `## ⚠ Degraded Lobes: ${degradedLobeNames.join(', ')}\nRun **memory_diagnose** for details.\n`
760
828
  : '';
761
- // Global store holds user + preferences — always included
762
- const globalBriefing = await globalStore.briefing(300);
763
829
  const sections = [];
764
830
  if (crashSection)
765
831
  sections.push(crashSection);
766
832
  if (degradedSection)
767
833
  sections.push(degradedSection);
768
- if (globalBriefing.entryCount > 0) {
769
- sections.push(globalBriefing.briefing);
770
- }
771
- // 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)
772
836
  const allStale = [];
773
- if (globalBriefing.staleDetails)
774
- allStale.push(...globalBriefing.staleDetails);
775
- let totalEntries = globalBriefing.entryCount;
776
- 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());
777
841
  for (const lobeName of allBriefingLobeNames) {
778
842
  const health = configManager.getLobeHealth(lobeName);
779
843
  if (health?.status === 'degraded')
@@ -781,7 +845,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
781
845
  const store = configManager.getStore(lobeName);
782
846
  if (!store)
783
847
  continue;
784
- 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
+ }
785
853
  if (lobeBriefing.staleDetails)
786
854
  allStale.push(...lobeBriefing.staleDetails);
787
855
  totalEntries += lobeBriefing.entryCount;
@@ -794,7 +862,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
794
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.');
795
863
  }
796
864
  // Tag primer: show tag vocabulary if tags exist across any lobe
797
- const briefingStores = [globalStore];
865
+ const briefingStores = [];
798
866
  for (const lobeName of allBriefingLobeNames) {
799
867
  const store = configManager.getStore(lobeName);
800
868
  if (store)
@@ -822,7 +890,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
822
890
  const ctxEntryLobeMap = new Map(); // entry id → lobe name
823
891
  let label;
824
892
  let primaryStore;
825
- let isCtxMultiLobe = false;
893
+ let ctxGlobalOnlyHint;
826
894
  if (rawLobe) {
827
895
  const ctx = resolveToolContext(rawLobe);
828
896
  if (!ctx.ok)
@@ -833,44 +901,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
833
901
  allLobeResults.push(...lobeResults);
834
902
  }
835
903
  else {
836
- // Search all healthy lobes — read operations shouldn't require lobe selection
837
- const allLobeNames = configManager.getLobeNames();
838
- isCtxMultiLobe = allLobeNames.length > 1;
839
- label = allLobeNames.length === 1 ? allLobeNames[0] : 'all';
840
- for (const lobeName of allLobeNames) {
841
- const store = configManager.getStore(lobeName);
842
- if (!store)
843
- continue;
844
- if (!primaryStore)
845
- primaryStore = store;
846
- const lobeResults = await store.contextSearch(context, max, undefined, threshold);
847
- for (const r of lobeResults)
848
- ctxEntryLobeMap.set(r.entry.id, lobeName);
849
- allLobeResults.push(...lobeResults);
850
- }
851
- }
852
- // Cross-lobe weak-match penalty: demote results from other repos that only matched
853
- // on generic software terms (e.g. "codebase", "structure"). Without this, a high-
854
- // confidence entry from an unrelated repo can outrank genuinely relevant knowledge
855
- // simply because popular terms appear in it.
856
- // Applied only in multi-lobe mode; single-lobe and global results are never penalized.
857
- if (isCtxMultiLobe) {
858
- const contextKwCount = extractKeywords(context).size;
859
- // Minimum keyword matches required to avoid the penalty (at least 40% of context, min 2)
860
- const minMatchCount = Math.max(2, Math.ceil(contextKwCount * CROSS_LOBE_MIN_MATCH_RATIO));
861
- for (let i = 0; i < allLobeResults.length; i++) {
862
- if (allLobeResults[i].matchedKeywords.length < minMatchCount) {
863
- allLobeResults[i] = { ...allLobeResults[i], score: allLobeResults[i].score * CROSS_LOBE_WEAK_SCORE_PENALTY };
904
+ const resolution = await resolveLobesForRead(rawIsFirst);
905
+ switch (resolution.kind) {
906
+ case 'resolved': {
907
+ label = resolution.label;
908
+ for (const lobeName of resolution.lobes) {
909
+ const store = configManager.getStore(lobeName);
910
+ if (!store)
911
+ continue;
912
+ if (!primaryStore)
913
+ primaryStore = store;
914
+ const lobeResults = await store.contextSearch(context, max, undefined, threshold);
915
+ if (resolution.lobes.length > 1) {
916
+ for (const r of lobeResults)
917
+ ctxEntryLobeMap.set(r.entry.id, lobeName);
918
+ }
919
+ allLobeResults.push(...lobeResults);
920
+ }
921
+ break;
922
+ }
923
+ case 'global-only': {
924
+ label = 'global';
925
+ ctxGlobalOnlyHint = resolution.hint;
926
+ break;
864
927
  }
865
928
  }
866
929
  }
867
- // Always include global store (user + preferences)
868
- const globalResults = await globalStore.contextSearch(context, max, undefined, threshold);
869
- for (const r of globalResults)
870
- ctxEntryLobeMap.set(r.entry.id, 'global');
871
- // Merge, dedupe by entry id, re-sort by score, take top N
930
+ // Dedupe by entry id, re-sort by score, take top N
872
931
  const seenIds = new Set();
873
- const results = [...globalResults, ...allLobeResults]
932
+ const results = allLobeResults
874
933
  .sort((a, b) => b.score - a.score)
875
934
  .filter(r => {
876
935
  if (seenIds.has(r.entry.id))
@@ -880,28 +939,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
880
939
  })
881
940
  .slice(0, max);
882
941
  // Build stores collection for tag frequency aggregation
883
- const ctxSearchedStores = [globalStore];
884
- if (rawLobe) {
885
- const store = configManager.getStore(rawLobe);
886
- if (store)
887
- ctxSearchedStores.push(store);
888
- }
889
- else {
890
- for (const lobeName of configManager.getLobeNames()) {
891
- const store = configManager.getStore(lobeName);
892
- if (store)
893
- ctxSearchedStores.push(store);
894
- }
895
- }
942
+ const ctxSearchedStores = [];
943
+ if (primaryStore)
944
+ ctxSearchedStores.push(primaryStore);
896
945
  const ctxTagFreq = mergeTagFrequencies(ctxSearchedStores);
897
946
  // Parse filter for footer (context search has no filter, pass empty)
898
947
  const ctxFilterGroups = [];
899
948
  if (results.length === 0) {
900
949
  const ctxFooter = buildQueryFooter({ filterGroups: ctxFilterGroups, rawFilter: undefined, tagFreq: ctxTagFreq, resultCount: 0, scope: 'context search' });
950
+ const noResultHint = ctxGlobalOnlyHint
951
+ ? `\n\n> ${ctxGlobalOnlyHint}`
952
+ : '\n\nThis is fine — proceed without prior context. As you learn things worth remembering, store them with memory_store.';
901
953
  return {
902
954
  content: [{
903
955
  type: 'text',
904
- text: `[${label}] No relevant knowledge found for: "${context}"\n\nThis is fine — proceed without prior context. As you learn things worth remembering, store them with memory_store.\n\n---\n${ctxFooter}`,
956
+ text: `[${label}] No relevant knowledge found for: "${context}"${noResultHint}\n\n---\n${ctxFooter}`,
905
957
  }],
906
958
  };
907
959
  }
@@ -919,6 +971,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
919
971
  ...topicOrder.filter(t => byTopic.has(t)),
920
972
  ...Array.from(byTopic.keys()).filter(t => !topicOrder.includes(t)).sort(),
921
973
  ];
974
+ const showCtxLobeLabels = ctxEntryLobeMap.size > 0;
922
975
  for (const topic of orderedTopics) {
923
976
  const topicResults = byTopic.get(topic);
924
977
  const heading = topic === 'user' ? 'About You'
@@ -930,7 +983,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
930
983
  for (const r of topicResults) {
931
984
  const marker = topic === 'gotchas' ? '[!] ' : topic === 'preferences' ? '[pref] ' : '';
932
985
  const keywords = r.matchedKeywords.length > 0 ? ` (matched: ${r.matchedKeywords.join(', ')})` : '';
933
- const lobeLabel = isCtxMultiLobe ? ` [${ctxEntryLobeMap.get(r.entry.id) ?? '?'}]` : '';
986
+ const lobeLabel = showCtxLobeLabels ? ` [${ctxEntryLobeMap.get(r.entry.id) ?? '?'}]` : '';
934
987
  const tagsSuffix = r.entry.tags?.length ? ` [tags: ${r.entry.tags.join(', ')}]` : '';
935
988
  sections.push(`- **${marker}${r.entry.title}**${lobeLabel}: ${r.entry.content}${keywords}${tagsSuffix}`);
936
989
  }
@@ -957,6 +1010,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
957
1010
  sections.push(`---\n*Context loaded for: ${kwList} (${topicList}). ` +
958
1011
  `This knowledge is now in your conversation — no need to call memory_context again for these terms this session.*`);
959
1012
  }
1013
+ // Surface hint when we fell back to global-only
1014
+ if (ctxGlobalOnlyHint) {
1015
+ sections.push(`> ${ctxGlobalOnlyHint}`);
1016
+ }
960
1017
  // Build footer (context search has no filter — it's natural language keyword matching)
961
1018
  const ctxFooter = buildQueryFooter({ filterGroups: ctxFilterGroups, rawFilter: undefined, tagFreq: ctxTagFreq, resultCount: results.length, scope: 'context search' });
962
1019
  sections.push(`---\n${ctxFooter}`);
@@ -966,24 +1023,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
966
1023
  const { lobe: rawLobe } = z.object({
967
1024
  lobe: z.string().optional(),
968
1025
  }).parse(args ?? {});
969
- // Always include global stats
970
- const globalStats = await globalStore.stats();
971
1026
  // Single lobe stats
972
1027
  if (rawLobe) {
973
1028
  const ctx = resolveToolContext(rawLobe);
974
1029
  if (!ctx.ok)
975
1030
  return contextError(ctx);
976
1031
  const result = await ctx.store.stats();
977
- const sections = [formatStats('global (user + preferences)', globalStats), formatStats(ctx.label, result)];
978
- 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) }] };
979
1035
  }
980
1036
  // Combined stats across all lobes
981
- const sections = [formatStats('global (user + preferences)', globalStats)];
1037
+ const sections = [];
982
1038
  const allLobeNames = configManager.getLobeNames();
1039
+ const alwaysIncludeSet = new Set(configManager.getAlwaysIncludeLobes());
983
1040
  for (const lobeName of allLobeNames) {
984
1041
  const store = configManager.getStore(lobeName);
1042
+ if (!store)
1043
+ continue;
985
1044
  const result = await store.stats();
986
- sections.push(formatStats(lobeName, result));
1045
+ const label = alwaysIncludeSet.has(lobeName) ? `${lobeName} (alwaysInclude)` : lobeName;
1046
+ sections.push(formatStats(label, result));
987
1047
  }
988
1048
  return { content: [{ type: 'text', text: sections.join('\n\n---\n\n') }] };
989
1049
  }
@@ -1144,14 +1204,6 @@ async function buildDiagnosticsText(showFullCrashHistory) {
1144
1204
  }
1145
1205
  }
1146
1206
  sections.push('');
1147
- try {
1148
- const globalStats = await globalStore.stats();
1149
- sections.push(`- **global store**: ✅ healthy (${globalStats.totalEntries} entries, ${globalStats.storageSize})`);
1150
- }
1151
- catch (e) {
1152
- sections.push(`- **global store**: ❌ error — ${e instanceof Error ? e.message : e}`);
1153
- }
1154
- sections.push('');
1155
1207
  // Active behavior config — shows effective values and highlights user overrides
1156
1208
  sections.push('### Active Behavior Config');
1157
1209
  sections.push(formatBehaviorConfigSection(configBehavior));
@@ -1203,15 +1255,6 @@ async function main() {
1203
1255
  process.stderr.write(`[memory-mcp] Previous crash detected (${age}s ago): ${previousCrash.type} — ${previousCrash.error}\n`);
1204
1256
  process.stderr.write(`[memory-mcp] Crash report will be shown in memory_context and memory_diagnose.\n`);
1205
1257
  }
1206
- // Initialize global store (user + preferences, shared across all lobes)
1207
- try {
1208
- await globalStore.init();
1209
- process.stderr.write(`[memory-mcp] Global store → ${globalMemoryPath}\n`);
1210
- }
1211
- catch (error) {
1212
- const msg = error instanceof Error ? error.message : String(error);
1213
- process.stderr.write(`[memory-mcp] WARNING: Global store init failed: ${msg}\n`);
1214
- }
1215
1258
  // Initialize each lobe independently — a broken lobe shouldn't prevent others from working
1216
1259
  let healthyLobes = 0;
1217
1260
  for (const [name, config] of lobeConfigs) {
@@ -1269,46 +1312,6 @@ async function main() {
1269
1312
  };
1270
1313
  process.stderr.write(`[memory-mcp] ⚠ DEGRADED: ${healthyLobes}/${lobeConfigs.size} lobes healthy.\n`);
1271
1314
  }
1272
- // Migrate: move user + preferences entries from lobe stores to global store.
1273
- // State-driven guard: skip if already completed (marker file present).
1274
- const migrationMarker = path.join(globalMemoryPath, '.migrated');
1275
- if (!existsSync(migrationMarker)) {
1276
- let migrated = 0;
1277
- for (const [name, store] of stores) {
1278
- for (const topic of ['user', 'preferences']) {
1279
- try {
1280
- const result = await store.query(topic, 'full');
1281
- for (const entry of result.entries) {
1282
- try {
1283
- const globalResult = await globalStore.query(topic, 'full');
1284
- const alreadyExists = globalResult.entries.some(g => g.title === entry.title);
1285
- if (!alreadyExists && entry.content) {
1286
- const trust = parseTrustLevel(entry.trust ?? 'user') ?? 'user';
1287
- await globalStore.store(topic, entry.title, entry.content, [...(entry.sources ?? [])], trust);
1288
- process.stderr.write(`[memory-mcp] Migrated ${entry.id} ("${entry.title}") from [${name}] → global\n`);
1289
- migrated++;
1290
- }
1291
- await store.correct(entry.id, '', 'delete');
1292
- process.stderr.write(`[memory-mcp] Removed ${entry.id} from [${name}] (now in global)\n`);
1293
- }
1294
- catch (entryError) {
1295
- process.stderr.write(`[memory-mcp] Migration error for ${entry.id} in [${name}]: ${entryError}\n`);
1296
- }
1297
- }
1298
- }
1299
- catch (topicError) {
1300
- process.stderr.write(`[memory-mcp] Migration error querying ${topic} in [${name}]: ${topicError}\n`);
1301
- }
1302
- }
1303
- }
1304
- // Write marker atomically — future startups skip this block entirely
1305
- try {
1306
- writeFileSync(migrationMarker, new Date().toISOString(), 'utf-8');
1307
- if (migrated > 0)
1308
- process.stderr.write(`[memory-mcp] Migration complete: ${migrated} entries moved to global store.\n`);
1309
- }
1310
- catch { /* marker write is best-effort */ }
1311
- }
1312
1315
  // Initialize ConfigManager with current config state
1313
1316
  configManager = new ConfigManager(configPath, { configs: lobeConfigs, origin: configOrigin }, stores, lobeHealth);
1314
1317
  const transport = new StdioServerTransport();
@@ -1336,7 +1339,12 @@ async function main() {
1336
1339
  });
1337
1340
  await server.connect(transport);
1338
1341
  const modeStr = serverMode.kind === 'running' ? '' : ` [${serverMode.kind.toUpperCase()}]`;
1339
- 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`);
1340
1348
  // Graceful shutdown on signals
1341
1349
  const shutdown = () => {
1342
1350
  process.stderr.write('[memory-mcp] Shutting down gracefully.\n');