@exaudeus/memory-mcp 1.2.0 → 1.3.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.
@@ -1,4 +1,4 @@
1
- import type { TopicScope } from './types.js';
1
+ import type { EphemeralSeverity, TopicScope } from './types.js';
2
2
  /** Run the TF-IDF classifier on a text. Returns probability 0-1 that content is ephemeral.
3
3
  * Supports both v1 (unigrams only) and v2 (bigrams + engineered features) models. */
4
4
  export declare function classifyEphemeral(title: string, content: string, topic?: string): number | null;
@@ -15,6 +15,10 @@ export interface EphemeralSignal {
15
15
  * Returns an array of matched signals, empty if content looks durable.
16
16
  * Pure function — no side effects, no I/O. */
17
17
  export declare function detectEphemeralSignals(title: string, content: string, topic: TopicScope): readonly EphemeralSignal[];
18
- /** Format ephemeral signals into a human-readable warning string.
18
+ /** Derive aggregate severity from a set of detected signals.
19
+ * Single source of truth for the threshold logic — both formatEphemeralWarning
20
+ * and store.ts call this so the thresholds can only diverge in one place. */
21
+ export declare function getEphemeralSeverity(signals: readonly EphemeralSignal[]): EphemeralSeverity | null;
22
+ /** Format ephemeral signals into a visually prominent warning block.
19
23
  * Returns undefined if no signals were detected. */
20
- export declare function formatEphemeralWarning(signals: readonly EphemeralSignal[]): string | undefined;
24
+ export declare function formatEphemeralWarning(signals: readonly EphemeralSignal[], entryId: string): string | undefined;
package/dist/ephemeral.js CHANGED
@@ -18,6 +18,7 @@
18
18
  import { readFileSync } from 'fs';
19
19
  import { dirname, join } from 'path';
20
20
  import { fileURLToPath } from 'url';
21
+ import { WARN_SEPARATOR } from './thresholds.js';
21
22
  let cachedModel = null;
22
23
  function loadModel() {
23
24
  if (cachedModel)
@@ -523,6 +524,7 @@ const SIGNALS = [
523
524
  test: (_title, content) => {
524
525
  const patterns = [
525
526
  /[,;]\s+also\b/i, // "X works this way, also Y does Z"
527
+ /[.!?]\s+also[,\s]/i, // ". Also, Y does Z" (sentence-start also)
526
528
  /[.!?]\s+additionally,/i, // ". Additionally, ..."
527
529
  /[.!?]\s+furthermore,/i, // ". Furthermore, ..."
528
530
  /\bunrelated:/i, // "Unrelated: ..."
@@ -589,30 +591,71 @@ export function detectEphemeralSignals(title, content, topic) {
589
591
  }
590
592
  return signals;
591
593
  }
592
- /** Format ephemeral signals into a human-readable warning string.
593
- * Returns undefined if no signals were detected. */
594
- export function formatEphemeralWarning(signals) {
594
+ /** Derive aggregate severity from a set of detected signals.
595
+ * Single source of truth for the threshold logic — both formatEphemeralWarning
596
+ * and store.ts call this so the thresholds can only diverge in one place. */
597
+ export function getEphemeralSeverity(signals) {
595
598
  if (signals.length === 0)
596
- return undefined;
599
+ return null;
597
600
  const highCount = signals.filter(s => s.confidence === 'high').length;
598
- const severity = highCount >= 2 ? 'likely contains' : highCount === 1 ? 'possibly contains' : 'may contain';
599
- const lines = [
600
- `This entry ${severity} ephemeral content:`,
601
- ...signals.map(s => ` - ${s.label}: ${s.detail}`),
602
- '',
603
- ];
604
- // Scale the guidance with confidence high-confidence gets direct advice,
605
- // low-confidence gets a softer suggestion to let the agent decide.
606
- // Always include the positive redirect: store state, not events.
607
- if (highCount >= 2) {
608
- lines.push('This is almost certainly session-specific. Consider deleting after your session.');
609
- lines.push('If there is a lasting insight here, rephrase it as a present-tense fact: what is now true about the codebase?');
610
- }
611
- else if (highCount === 1) {
612
- lines.push('If this is a lasting insight, rephrase it as a present-tense fact (what is now true) rather than an action report (what you did). If session-specific, consider deleting after your session.');
613
- }
614
- else {
615
- lines.push('If this is durable knowledge, rephrase as a present-tense fact: what is now true? If it describes what you did rather than what is, consider deleting after your session.');
601
+ if (highCount >= 2)
602
+ return 'high';
603
+ if (highCount === 1)
604
+ return 'medium';
605
+ return 'low';
606
+ }
607
+ /** Format ephemeral signals into a visually prominent warning block.
608
+ * Returns undefined if no signals were detected. */
609
+ export function formatEphemeralWarning(signals, entryId) {
610
+ const severity = getEphemeralSeverity(signals);
611
+ if (severity === null)
612
+ return undefined;
613
+ const signalLines = signals.map(s => ` - ${s.label}: ${s.detail}`).join('\n');
614
+ // Delete command without leading spaces — callsites add their own indentation.
615
+ const deleteCmd = `memory_correct(id: "${entryId}", action: "delete")`;
616
+ // Scale both the header and guidance text with severity.
617
+ // high: near-certain ephemeral → "ACTION REQUIRED" + prominent DELETE label.
618
+ // medium: likely ephemeral "REVIEW NEEDED" + delete as an option.
619
+ // low: uncertain → "CHECK BELOW" + softest framing.
620
+ switch (severity) {
621
+ case 'high':
622
+ return [
623
+ WARN_SEPARATOR,
624
+ '⚠ EPHEMERAL CONTENT — ACTION REQUIRED ⚠',
625
+ WARN_SEPARATOR,
626
+ signalLines,
627
+ '',
628
+ 'Will this still be true in 6 months? Almost certainly not.',
629
+ '',
630
+ `DELETE: ${deleteCmd}`,
631
+ '',
632
+ 'If there is a lasting insight here, extract and rephrase',
633
+ 'it as a present-tense fact before deleting this entry.',
634
+ WARN_SEPARATOR,
635
+ ].join('\n');
636
+ case 'medium':
637
+ return [
638
+ WARN_SEPARATOR,
639
+ '⚠ POSSIBLY EPHEMERAL CONTENT — REVIEW NEEDED ⚠',
640
+ WARN_SEPARATOR,
641
+ signalLines,
642
+ '',
643
+ 'Will this still be true in 6 months?',
644
+ ` - If not: ${deleteCmd}`,
645
+ ` - If so: rephrase as a present-tense fact (what is now true)`,
646
+ WARN_SEPARATOR,
647
+ ].join('\n');
648
+ case 'low':
649
+ return [
650
+ WARN_SEPARATOR,
651
+ '⚠ POSSIBLY EPHEMERAL CONTENT — CHECK BELOW ⚠',
652
+ WARN_SEPARATOR,
653
+ signalLines,
654
+ '',
655
+ 'Ask: will this still be true in 6 months?',
656
+ ` - If not: ${deleteCmd}`,
657
+ ` - If so: rephrase as a present-tense fact if possible`,
658
+ WARN_SEPARATOR,
659
+ ].join('\n');
616
660
  }
617
- return lines.join('\n');
618
661
  }
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Pure functions — no side effects, no state. Each takes structured data
4
4
  // and returns a formatted string for the tool response.
5
- import { DEFAULT_STALE_DAYS_STANDARD, DEFAULT_STALE_DAYS_PREFERENCES, DEFAULT_MAX_STALE_IN_BRIEFING, DEFAULT_MAX_DEDUP_SUGGESTIONS, DEFAULT_MAX_CONFLICT_PAIRS, MAX_FOOTER_TAGS, } from './thresholds.js';
5
+ import { DEFAULT_STALE_DAYS_STANDARD, DEFAULT_STALE_DAYS_PREFERENCES, DEFAULT_MAX_STALE_IN_BRIEFING, DEFAULT_MAX_DEDUP_SUGGESTIONS, DEFAULT_MAX_CONFLICT_PAIRS, MAX_FOOTER_TAGS, WARN_SEPARATOR, } from './thresholds.js';
6
6
  import { analyzeFilterGroups } from './text-analyzer.js';
7
7
  /** Format the stale entries section for briefing/context responses */
8
8
  export function formatStaleSection(staleDetails) {
@@ -17,23 +17,33 @@ export function formatStaleSection(staleDetails) {
17
17
  }
18
18
  /** Format the conflict detection warning for query/context responses */
19
19
  export function formatConflictWarning(conflicts) {
20
- const lines = ['⚠ Potential conflicts detected:'];
20
+ const lines = [
21
+ WARN_SEPARATOR,
22
+ '⚠ CONFLICTING ENTRIES DETECTED — ACTION NEEDED ⚠',
23
+ WARN_SEPARATOR,
24
+ ];
21
25
  for (const c of conflicts) {
22
- lines.push(` - ${c.a.id}: "${c.a.title}" (confidence: ${c.a.confidence}, created: ${c.a.created.substring(0, 10)})`);
23
- lines.push(` vs ${c.b.id}: "${c.b.title}" (confidence: ${c.b.confidence}, created: ${c.b.created.substring(0, 10)})`);
24
- lines.push(` Similarity: ${(c.similarity * 100).toFixed(0)}%`);
25
- // Guide the agent on which entry to trust
26
+ lines.push(` ${c.a.id}: "${c.a.title}" (confidence: ${c.a.confidence}, ${c.a.created.substring(0, 10)})`);
27
+ lines.push(` vs`);
28
+ lines.push(` ${c.b.id}: "${c.b.title}" (confidence: ${c.b.confidence}, ${c.b.created.substring(0, 10)})`);
29
+ lines.push(` Similarity: ${(c.similarity * 100).toFixed(0)}%`);
30
+ lines.push('');
31
+ // Pre-fill which entry to delete so the agent can act immediately.
26
32
  if (c.a.confidence !== c.b.confidence) {
27
- const higher = c.a.confidence > c.b.confidence ? c.a : c.b;
28
- lines.push(` Higher confidence: ${higher.id} (${higher.confidence})`);
33
+ const keep = c.a.confidence > c.b.confidence ? c.a : c.b;
34
+ const remove = c.a.confidence > c.b.confidence ? c.b : c.a;
35
+ lines.push(` Trust ${keep.id} (higher confidence). Delete the lower-confidence entry:`);
36
+ lines.push(` memory_correct(id: "${remove.id}", action: "delete")`);
29
37
  }
30
38
  else {
31
- const newer = c.a.created > c.b.created ? c.a : c.b;
32
- lines.push(` More recent: ${newer.id} may supersede the older entry`);
39
+ const keep = c.a.created > c.b.created ? c.a : c.b;
40
+ const remove = c.a.created > c.b.created ? c.b : c.a;
41
+ lines.push(` ${keep.id} is more recent — may supersede ${remove.id}:`);
42
+ lines.push(` memory_correct(id: "${remove.id}", action: "delete")`);
33
43
  }
44
+ lines.push('');
34
45
  }
35
- lines.push('');
36
- lines.push('Consider: memory_correct to consolidate or clarify the difference between these entries.');
46
+ lines.push(WARN_SEPARATOR);
37
47
  return lines.join('\n');
38
48
  }
39
49
  /** Format memory stats for a single lobe or global store */
package/dist/index.js CHANGED
@@ -17,8 +17,9 @@ import { ConfigManager } from './config-manager.js';
17
17
  import { normalizeArgs } from './normalize.js';
18
18
  import { buildCrashReport, writeCrashReport, writeCrashReportSync, readLatestCrash, readCrashHistory, clearLatestCrash, formatCrashReport, formatCrashSummary, markServerStarted, } from './crash-journal.js';
19
19
  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';
20
+ import { parseFilter } from './text-analyzer.js';
21
+ import { VOCABULARY_ECHO_LIMIT, WARN_SEPARATOR } from './thresholds.js';
22
+ import { matchRootsToLobeNames, buildLobeResolution } from './lobe-resolution.js';
22
23
  let serverMode = { kind: 'running' };
23
24
  const lobeHealth = new Map();
24
25
  const serverStartTime = Date.now();
@@ -154,6 +155,43 @@ function inferLobeFromPaths(paths) {
154
155
  return matchedLobes.size === 1 ? matchedLobes.values().next().value : undefined;
155
156
  }
156
157
  const server = new Server({ name: 'memory-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
158
+ // --- Lobe resolution for read operations ---
159
+ // When the agent doesn't specify a lobe, we determine which lobe(s) to search
160
+ // via a degradation ladder (see lobe-resolution.ts for the pure logic):
161
+ // 1. Single lobe configured → use it (unambiguous)
162
+ // 2. Multiple lobes → ask client for workspace roots via MCP roots/list
163
+ // 3. Fallback → global-only with a hint to specify the lobe
164
+ /** Resolve which lobes to search for a read operation when the agent omitted the lobe param.
165
+ * Wires the MCP server's listRoots into the pure resolution logic. */
166
+ async function resolveLobesForRead() {
167
+ const allLobeNames = configManager.getLobeNames();
168
+ // Short-circuit: single lobe is unambiguous
169
+ if (allLobeNames.length === 1) {
170
+ return buildLobeResolution(allLobeNames, allLobeNames);
171
+ }
172
+ // Multiple lobes — try MCP client roots
173
+ const clientCaps = server.getClientCapabilities();
174
+ if (clientCaps?.roots) {
175
+ try {
176
+ const { roots } = await server.listRoots();
177
+ if (roots && roots.length > 0) {
178
+ const lobeConfigs = allLobeNames
179
+ .map(name => {
180
+ const config = configManager.getLobeConfig(name);
181
+ return config ? { name, repoRoot: config.repoRoot } : undefined;
182
+ })
183
+ .filter((c) => c !== undefined);
184
+ const matched = matchRootsToLobeNames(roots, lobeConfigs);
185
+ return buildLobeResolution(allLobeNames, matched);
186
+ }
187
+ }
188
+ catch (err) {
189
+ process.stderr.write(`[memory-mcp] listRoots failed: ${err instanceof Error ? err.message : String(err)}\n`);
190
+ }
191
+ }
192
+ // Fallback — roots not available or no match
193
+ return buildLobeResolution(allLobeNames, []);
194
+ }
157
195
  /** Build the shared lobe property for tool schemas — called on each ListTools request
158
196
  * so the description and enum stay in sync after a hot-reload adds or removes lobes. */
159
197
  function buildLobeProperty(currentLobeNames) {
@@ -162,7 +200,7 @@ function buildLobeProperty(currentLobeNames) {
162
200
  type: 'string',
163
201
  description: isSingle
164
202
  ? `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(', ')}`,
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(', ')}`,
166
204
  enum: currentLobeNames.length > 1 ? [...currentLobeNames] : undefined,
167
205
  };
168
206
  }
@@ -302,7 +340,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
302
340
  },
303
341
  {
304
342
  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")',
343
+ 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
344
  inputSchema: {
307
345
  type: 'object',
308
346
  properties: {
@@ -456,11 +494,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
456
494
  }
457
495
  storedResults.push({ title, result });
458
496
  }
459
- // Build response header
497
+ // Build response header.
498
+ // For high-severity ephemeral detections, flag the success line itself so agents
499
+ // who anchor on line 1 still see the problem before reading the block below.
460
500
  const lines = [];
461
501
  if (storedResults.length === 1) {
462
502
  const { result } = storedResults[0];
463
- lines.push(`[${ctx.label}] Stored entry ${result.id} in ${result.topic} (confidence: ${result.confidence})`);
503
+ const ephemeralFlag = result.ephemeralSeverity === 'high' ? ' (⚠ ephemeral — see below)' : '';
504
+ lines.push(`[${ctx.label}] Stored entry ${result.id} in ${result.topic} (confidence: ${result.confidence})${ephemeralFlag}`);
464
505
  if (result.warning)
465
506
  lines.push(`Note: ${result.warning}`);
466
507
  }
@@ -468,7 +509,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
468
509
  const { result: first } = storedResults[0];
469
510
  lines.push(`[${ctx.label}] Stored ${storedResults.length} entries in ${first.topic} (confidence: ${first.confidence}):`);
470
511
  for (const { title, result } of storedResults) {
471
- lines.push(` - ${result.id}: "${title}"`);
512
+ const ephemeralFlag = result.ephemeralSeverity === 'high' ? ' ⚠' : '';
513
+ lines.push(` - ${result.id}: "${title}"${ephemeralFlag}`);
472
514
  }
473
515
  }
474
516
  // Limit to at most 2 hint sections per response to prevent hint fatigue.
@@ -478,23 +520,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
478
520
  let hintCount = 0;
479
521
  for (const { title, result } of storedResults) {
480
522
  const entryPrefix = storedResults.length > 1 ? `"${title}": ` : '';
481
- // Dedup: surface related entries in the same topic
523
+ // Dedup: surface related entries in the same topic.
524
+ // Fill in both actual IDs so the agent can act immediately without looking them up.
482
525
  if (result.relatedEntries && result.relatedEntries.length > 0 && hintCount < 2) {
483
526
  hintCount++;
527
+ const top = result.relatedEntries[0];
484
528
  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}`);
529
+ lines.push(WARN_SEPARATOR);
530
+ lines.push(`⚠ ${entryPrefix}SIMILAR ENTRY ALREADY EXISTS — CONSOLIDATE ⚠`);
531
+ lines.push(WARN_SEPARATOR);
532
+ lines.push(` ${top.id}: "${top.title}" (confidence: ${top.confidence})`);
533
+ lines.push(` ${top.content.length > 120 ? top.content.substring(0, 120) + '...' : top.content}`);
534
+ if (result.relatedEntries.length > 1) {
535
+ const extra = result.relatedEntries.length - 1;
536
+ lines.push(` ... and ${extra} more similar ${extra === 1 ? 'entry' : 'entries'}`);
489
537
  }
490
538
  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")');
539
+ lines.push('If these overlap, consolidate:');
540
+ lines.push(` KEEP+UPDATE: memory_correct(id: "${top.id}", action: "replace", correction: "<merged content>")`);
541
+ lines.push(` DELETE new: memory_correct(id: "${result.id}", action: "delete")`);
542
+ lines.push(WARN_SEPARATOR);
492
543
  }
493
- // Ephemeral content warning — soft nudge, never blocking
544
+ // Ephemeral content warning — the formatted block already contains visual borders
545
+ // and pre-filled delete command from formatEphemeralWarning.
494
546
  if (result.ephemeralWarning && hintCount < 2) {
495
547
  hintCount++;
496
548
  lines.push('');
497
- lines.push(`⏳ ${entryPrefix}${result.ephemeralWarning}`);
549
+ if (entryPrefix)
550
+ lines.push(`${entryPrefix}:`);
551
+ lines.push(result.ephemeralWarning);
498
552
  }
499
553
  // Preference surfacing: show relevant preferences for non-preference entries
500
554
  if (result.relevantPreferences && result.relevantPreferences.length > 0 && hintCount < 2) {
@@ -533,13 +587,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
533
587
  branch: z.string().optional(),
534
588
  }).parse(args ?? {});
535
589
  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.
590
+ // Resolve which lobes to search.
591
+ // Global topics always route to globalStore. Lobe topics follow the degradation ladder.
538
592
  let lobeEntries = [];
539
- const entryLobeMap = new Map(); // entry id → lobe name (for cross-lobe labeling)
593
+ const entryLobeMap = new Map(); // entry id → lobe name
540
594
  let label;
541
595
  let primaryStore;
542
- let isMultiLobe = false;
596
+ let queryGlobalOnlyHint;
543
597
  if (isGlobalQuery) {
544
598
  const ctx = resolveToolContext(rawLobe, { isGlobal: true });
545
599
  if (!ctx.ok)
@@ -561,20 +615,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
561
615
  lobeEntries = [...result.entries];
562
616
  }
563
617
  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);
618
+ const resolution = await resolveLobesForRead();
619
+ switch (resolution.kind) {
620
+ case 'resolved': {
621
+ label = resolution.label;
622
+ for (const lobeName of resolution.lobes) {
623
+ const store = configManager.getStore(lobeName);
624
+ if (!store)
625
+ continue;
626
+ if (!primaryStore)
627
+ primaryStore = store;
628
+ const result = await store.query(scope, detail, filter, branch);
629
+ if (resolution.lobes.length > 1) {
630
+ for (const e of result.entries)
631
+ entryLobeMap.set(e.id, lobeName);
632
+ }
633
+ lobeEntries.push(...result.entries);
634
+ }
635
+ break;
636
+ }
637
+ case 'global-only': {
638
+ label = 'global';
639
+ queryGlobalOnlyHint = resolution.hint;
640
+ break;
641
+ }
578
642
  }
579
643
  }
580
644
  // For wildcard queries on non-global topics, also include global store entries
@@ -596,22 +660,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
596
660
  })
597
661
  .sort((a, b) => b.relevanceScore - a.relevanceScore);
598
662
  // Build stores collection for tag frequency aggregation
663
+ // Only include stores that were actually searched
599
664
  const searchedStores = [];
600
665
  if (isGlobalQuery) {
601
666
  searchedStores.push(globalStore);
602
667
  }
603
- else if (rawLobe) {
604
- const store = configManager.getStore(rawLobe);
605
- if (store)
606
- searchedStores.push(store);
607
- }
608
668
  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
- }
669
+ if (primaryStore)
670
+ searchedStores.push(primaryStore);
615
671
  if (scope === '*')
616
672
  searchedStores.push(globalStore);
617
673
  }
@@ -620,23 +676,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
620
676
  const filterGroups = filter ? parseFilter(filter) : [];
621
677
  if (allEntries.length === 0) {
622
678
  const footer = buildQueryFooter({ filterGroups, rawFilter: filter, tagFreq, resultCount: 0, scope });
679
+ const noResultHint = queryGlobalOnlyHint ? `\n\n> ${queryGlobalOnlyHint}` : '';
623
680
  return {
624
681
  content: [{
625
682
  type: 'text',
626
- text: `[${label}] No entries found for scope "${scope}"${filter ? ` with filter "${filter}"` : ''}.\n\n---\n${footer}`,
683
+ text: `[${label}] No entries found for scope "${scope}"${filter ? ` with filter "${filter}"` : ''}.${noResultHint}\n\n---\n${footer}`,
627
684
  }],
628
685
  };
629
686
  }
687
+ const showQueryLobeLabels = entryLobeMap.size > 0;
630
688
  const lines = allEntries.map(e => {
631
689
  const freshIndicator = e.fresh ? '' : ' [stale]';
632
- const lobeTag = isMultiLobe ? ` [${entryLobeMap.get(e.id) ?? '?'}]` : '';
690
+ const lobeTag = showQueryLobeLabels ? ` [${entryLobeMap.get(e.id) ?? '?'}]` : '';
633
691
  if (detail === 'brief') {
634
692
  return `- **${e.title}** (${e.id}${lobeTag}, confidence: ${e.confidence})${freshIndicator}\n ${e.summary}`;
635
693
  }
636
694
  if (detail === 'full') {
637
695
  const meta = [
638
696
  `ID: ${e.id}`,
639
- isMultiLobe ? `Lobe: ${entryLobeMap.get(e.id) ?? '?'}` : null,
697
+ showQueryLobeLabels ? `Lobe: ${entryLobeMap.get(e.id) ?? '?'}` : null,
640
698
  `Confidence: ${e.confidence}`,
641
699
  `Trust: ${e.trust}`,
642
700
  `Fresh: ${e.fresh}`,
@@ -670,6 +728,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
670
728
  text += '\n\n' + formatConflictWarning(conflicts);
671
729
  }
672
730
  }
731
+ // Surface hint when we fell back to global-only
732
+ if (queryGlobalOnlyHint) {
733
+ text += `\n\n> ${queryGlobalOnlyHint}`;
734
+ }
673
735
  // Build footer with query mode, tag vocabulary, and syntax reference
674
736
  const footer = buildQueryFooter({ filterGroups, rawFilter: filter, tagFreq, resultCount: allEntries.length, scope });
675
737
  text += `\n\n---\n${footer}`;
@@ -822,7 +884,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
822
884
  const ctxEntryLobeMap = new Map(); // entry id → lobe name
823
885
  let label;
824
886
  let primaryStore;
825
- let isCtxMultiLobe = false;
887
+ let ctxGlobalOnlyHint;
826
888
  if (rawLobe) {
827
889
  const ctx = resolveToolContext(rawLobe);
828
890
  if (!ctx.ok)
@@ -833,34 +895,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
833
895
  allLobeResults.push(...lobeResults);
834
896
  }
835
897
  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 };
898
+ const resolution = await resolveLobesForRead();
899
+ switch (resolution.kind) {
900
+ case 'resolved': {
901
+ label = resolution.label;
902
+ for (const lobeName of resolution.lobes) {
903
+ const store = configManager.getStore(lobeName);
904
+ if (!store)
905
+ continue;
906
+ if (!primaryStore)
907
+ primaryStore = store;
908
+ const lobeResults = await store.contextSearch(context, max, undefined, threshold);
909
+ if (resolution.lobes.length > 1) {
910
+ for (const r of lobeResults)
911
+ ctxEntryLobeMap.set(r.entry.id, lobeName);
912
+ }
913
+ allLobeResults.push(...lobeResults);
914
+ }
915
+ break;
916
+ }
917
+ case 'global-only': {
918
+ label = 'global';
919
+ ctxGlobalOnlyHint = resolution.hint;
920
+ break;
864
921
  }
865
922
  }
866
923
  }
@@ -880,28 +937,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
880
937
  })
881
938
  .slice(0, max);
882
939
  // Build stores collection for tag frequency aggregation
940
+ // Only include stores that were actually searched (not all lobes)
883
941
  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
- }
942
+ if (primaryStore && primaryStore !== globalStore) {
943
+ ctxSearchedStores.push(primaryStore);
895
944
  }
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}`);
@@ -0,0 +1,30 @@
1
+ /** Outcome of resolving which lobes to search when the agent didn't specify one. */
2
+ export type LobeResolution = {
3
+ readonly kind: 'resolved';
4
+ readonly lobes: readonly string[];
5
+ readonly label: string;
6
+ } | {
7
+ readonly kind: 'global-only';
8
+ readonly hint: string;
9
+ };
10
+ /** A root URI from the MCP client (e.g. "file:///Users/me/projects/zillow"). */
11
+ export interface ClientRoot {
12
+ readonly uri: string;
13
+ }
14
+ /** Minimal lobe config needed for matching — just the repo root path. */
15
+ export interface LobeRootConfig {
16
+ readonly name: string;
17
+ readonly repoRoot: string;
18
+ }
19
+ /** Match MCP client workspace root URIs against known lobe repo roots.
20
+ * Returns matched lobe names, or empty array if none match.
21
+ *
22
+ * Matching rules:
23
+ * - file:// URIs are stripped to filesystem paths
24
+ * - Both paths are normalized via path.resolve
25
+ * - A match occurs when either path is equal to or nested inside the other,
26
+ * checked at path-separator boundaries (no partial-name false positives) */
27
+ export declare function matchRootsToLobeNames(clientRoots: readonly ClientRoot[], lobeConfigs: readonly LobeRootConfig[]): readonly string[];
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;
@@ -0,0 +1,67 @@
1
+ // Pure lobe resolution logic — extracted for testability.
2
+ //
3
+ // When the agent doesn't specify a lobe, we determine which lobe(s) to search
4
+ // via a degradation ladder:
5
+ // 1. Single lobe configured → use it (unambiguous)
6
+ // 2. Multiple lobes → match client workspace roots against lobe repo roots
7
+ // 3. Fallback → global-only with a hint to specify the lobe
8
+ //
9
+ // This prevents cross-lobe leakage (e.g. game design lore surfacing in an Android MR review).
10
+ import path from 'path';
11
+ /** Check if `child` is equal to or nested under `parent` with path-boundary awareness.
12
+ * Prevents false matches like "/projects/zillow-tools" matching "/projects/zillow". */
13
+ function isPathPrefixOf(parent, child) {
14
+ if (child === parent)
15
+ return true;
16
+ // Ensure the prefix ends at a path separator boundary
17
+ const withSep = parent.endsWith(path.sep) ? parent : parent + path.sep;
18
+ return child.startsWith(withSep);
19
+ }
20
+ /** Match MCP client workspace root URIs against known lobe repo roots.
21
+ * Returns matched lobe names, or empty array if none match.
22
+ *
23
+ * Matching rules:
24
+ * - file:// URIs are stripped to filesystem paths
25
+ * - Both paths are normalized via path.resolve
26
+ * - A match occurs when either path is equal to or nested inside the other,
27
+ * checked at path-separator boundaries (no partial-name false positives) */
28
+ export function matchRootsToLobeNames(clientRoots, lobeConfigs) {
29
+ if (clientRoots.length === 0 || lobeConfigs.length === 0)
30
+ return [];
31
+ const matchedLobes = new Set();
32
+ for (const root of clientRoots) {
33
+ // MCP roots use file:// URIs — strip the scheme to get the filesystem path
34
+ const rootPath = root.uri.startsWith('file://') ? root.uri.slice(7) : root.uri;
35
+ const normalizedRoot = path.resolve(rootPath);
36
+ for (const lobe of lobeConfigs) {
37
+ const normalizedLobe = path.resolve(lobe.repoRoot);
38
+ // Match if one path is equal to or nested inside the other
39
+ if (isPathPrefixOf(normalizedLobe, normalizedRoot) || isPathPrefixOf(normalizedRoot, normalizedLobe)) {
40
+ matchedLobes.add(lobe.name);
41
+ }
42
+ }
43
+ }
44
+ return Array.from(matchedLobes);
45
+ }
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) {
49
+ // Single lobe — always resolved, regardless of root matching
50
+ if (allLobeNames.length === 1) {
51
+ return { kind: 'resolved', lobes: allLobeNames, label: allLobeNames[0] };
52
+ }
53
+ // Multiple lobes with successful root match
54
+ if (matchedLobes.length > 0) {
55
+ return {
56
+ kind: 'resolved',
57
+ lobes: matchedLobes,
58
+ label: matchedLobes.length === 1 ? matchedLobes[0] : matchedLobes.join('+'),
59
+ };
60
+ }
61
+ // Fallback — no lobes could be determined
62
+ return {
63
+ kind: 'global-only',
64
+ hint: `Multiple lobes available (${allLobeNames.join(', ')}) but none could be inferred from client workspace roots. ` +
65
+ `Specify lobe parameter for lobe-specific results.`,
66
+ };
67
+ }
package/dist/store.js CHANGED
@@ -10,7 +10,7 @@ import { DEFAULT_CONFIDENCE, realClock, parseTopicScope, parseTrustLevel, parseT
10
10
  import { DEDUP_SIMILARITY_THRESHOLD, CONFLICT_SIMILARITY_THRESHOLD_SAME_TOPIC, CONFLICT_SIMILARITY_THRESHOLD_CROSS_TOPIC, CONFLICT_MIN_CONTENT_CHARS, OPPOSITION_PAIRS, PREFERENCE_SURFACE_THRESHOLD, REFERENCE_BOOST_MULTIPLIER, TOPIC_BOOST, MODULE_TOPIC_BOOST, USER_ALWAYS_INCLUDE_SCORE_FRACTION, DEFAULT_STALE_DAYS_STANDARD, DEFAULT_STALE_DAYS_PREFERENCES, DEFAULT_MAX_STALE_IN_BRIEFING, DEFAULT_MAX_DEDUP_SUGGESTIONS, DEFAULT_MAX_CONFLICT_PAIRS, DEFAULT_MAX_PREFERENCE_SUGGESTIONS, TAG_MATCH_BOOST, } from './thresholds.js';
11
11
  import { realGitService } from './git-service.js';
12
12
  import { extractKeywords, stem, similarity, matchesFilter, computeRelevanceScore, } from './text-analyzer.js';
13
- import { detectEphemeralSignals, formatEphemeralWarning } from './ephemeral.js';
13
+ import { detectEphemeralSignals, formatEphemeralWarning, getEphemeralSeverity } from './ephemeral.js';
14
14
  // Used only by bootstrap() for git log — not part of the GitService boundary
15
15
  // because bootstrap is a one-shot utility, not a recurring operation
16
16
  const execFileAsync = promisify(execFile);
@@ -89,9 +89,12 @@ export class MarkdownMemoryStore {
89
89
  const ephemeralSignals = topic !== 'recent-work'
90
90
  ? detectEphemeralSignals(title, content, topic)
91
91
  : [];
92
- const ephemeralWarning = formatEphemeralWarning(ephemeralSignals);
92
+ // getEphemeralSeverity is the single source of threshold logic shared with formatEphemeralWarning.
93
+ const ephemeralSeverity = getEphemeralSeverity(ephemeralSignals);
94
+ const ephemeralWarning = formatEphemeralWarning(ephemeralSignals, id);
93
95
  return {
94
96
  stored: true, id, topic, file, confidence, warning, ephemeralWarning,
97
+ ephemeralSeverity: ephemeralSeverity ?? undefined,
95
98
  relatedEntries: relatedEntries.length > 0 ? relatedEntries : undefined,
96
99
  relevantPreferences: relevantPreferences && relevantPreferences.length > 0 ? relevantPreferences : undefined,
97
100
  };
@@ -16,14 +16,6 @@ export declare const CONFLICT_MIN_CONTENT_CHARS = 50;
16
16
  export declare const OPPOSITION_PAIRS: ReadonlyArray<readonly [string, string]>;
17
17
  /** Score multiplier when a reference path basename matches the context keywords. */
18
18
  export declare const REFERENCE_BOOST_MULTIPLIER = 1.3;
19
- /** Score multiplier applied to weak cross-lobe results in multi-lobe context search.
20
- * Prevents generic software terms (e.g. "codebase", "structure") from surfacing
21
- * entries from unrelated repos with high confidence/topic-boost scores. */
22
- export declare const CROSS_LOBE_WEAK_SCORE_PENALTY = 0.5;
23
- /** Fraction of context keywords an entry must match to avoid the cross-lobe penalty.
24
- * E.g. 0.40 means an entry must match at least 40% of the context keywords (minimum 2)
25
- * to be treated as a strong cross-lobe match. */
26
- export declare const CROSS_LOBE_MIN_MATCH_RATIO = 0.4;
27
19
  /** Per-topic scoring boost factors for contextSearch().
28
20
  * Higher = more likely to surface for any given context. */
29
21
  export declare const TOPIC_BOOST: Record<string, number>;
@@ -51,3 +43,6 @@ export declare const TAG_MATCH_BOOST = 1.5;
51
43
  export declare const VOCABULARY_ECHO_LIMIT = 8;
52
44
  /** Maximum tags shown in query/context footer. */
53
45
  export declare const MAX_FOOTER_TAGS = 12;
46
+ /** Visual separator for warning blocks in tool responses.
47
+ * Width chosen to stand out as a block boundary in any terminal or chat rendering. */
48
+ export declare const WARN_SEPARATOR: string;
@@ -42,14 +42,6 @@ export const OPPOSITION_PAIRS = [
42
42
  ];
43
43
  /** Score multiplier when a reference path basename matches the context keywords. */
44
44
  export const REFERENCE_BOOST_MULTIPLIER = 1.30;
45
- /** Score multiplier applied to weak cross-lobe results in multi-lobe context search.
46
- * Prevents generic software terms (e.g. "codebase", "structure") from surfacing
47
- * entries from unrelated repos with high confidence/topic-boost scores. */
48
- export const CROSS_LOBE_WEAK_SCORE_PENALTY = 0.50;
49
- /** Fraction of context keywords an entry must match to avoid the cross-lobe penalty.
50
- * E.g. 0.40 means an entry must match at least 40% of the context keywords (minimum 2)
51
- * to be treated as a strong cross-lobe match. */
52
- export const CROSS_LOBE_MIN_MATCH_RATIO = 0.40;
53
45
  /** Per-topic scoring boost factors for contextSearch().
54
46
  * Higher = more likely to surface for any given context. */
55
47
  export const TOPIC_BOOST = {
@@ -87,3 +79,7 @@ export const TAG_MATCH_BOOST = 1.5;
87
79
  export const VOCABULARY_ECHO_LIMIT = 8;
88
80
  /** Maximum tags shown in query/context footer. */
89
81
  export const MAX_FOOTER_TAGS = 12;
82
+ // ─── Display formatting constants ───────────────────────────────────────────
83
+ /** Visual separator for warning blocks in tool responses.
84
+ * Width chosen to stand out as a block boundary in any terminal or chat rendering. */
85
+ export const WARN_SEPARATOR = '='.repeat(52);
package/dist/types.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  /** Trust levels for knowledge sources, ordered by reliability */
2
2
  export type TrustLevel = 'user' | 'agent-confirmed' | 'agent-inferred';
3
+ /** Ephemeral detection severity — three distinct levels so consumers can branch exhaustively.
4
+ * Separated from EphemeralSignal.confidence to represent the aggregate outcome of all signals. */
5
+ export type EphemeralSeverity = 'high' | 'medium' | 'low';
3
6
  /** Parse a raw string into a TrustLevel, returning null for invalid input */
4
7
  export declare function parseTrustLevel(raw: string): TrustLevel | null;
5
8
  /** Predefined topic scopes for organizing knowledge */
@@ -96,6 +99,8 @@ export type StoreResult = {
96
99
  readonly warning?: string;
97
100
  /** Soft warning when content looks ephemeral — informational, never blocking */
98
101
  readonly ephemeralWarning?: string;
102
+ /** Aggregate severity of all ephemeral signals that fired — absent when none fired */
103
+ readonly ephemeralSeverity?: EphemeralSeverity;
99
104
  readonly relatedEntries?: readonly RelatedEntry[];
100
105
  readonly relevantPreferences?: readonly RelatedEntry[];
101
106
  } | {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exaudeus/memory-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.3.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",