@exaudeus/memory-mcp 1.1.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)
@@ -228,11 +229,66 @@ const SIGNALS = [
228
229
  /\b(doesn'?t|don'?t) (handle|support|implement).*\byet\b/,
229
230
  /\bis (underway|not finished|not complete)\b/,
230
231
  /\bblocked on\b/,
232
+ // Work-remaining section markers (colon required to avoid matching prose)
233
+ /\bremaining\s*:/, /\bopen items?\s*:/, /\bstill to do\s*:/,
234
+ /\bpending (tasks?|items?|work)\s*:/,
231
235
  ];
232
236
  const m = firstMatch(content, patterns);
233
237
  return m ? `contains "${m[0]}" — task tracking doesn't belong in long-term memory` : undefined;
234
238
  },
235
239
  },
240
+ // ── Completed task / work summary ──────────────────────────────────────
241
+ // Catches session work summaries stored as permanent knowledge.
242
+ // "Documentation updates complete", "Completed the migration", "All X validated".
243
+ {
244
+ id: 'completed-task',
245
+ label: 'Completed task announcement',
246
+ confidence: 'high',
247
+ test: (title, content) => {
248
+ const titlePatterns = [
249
+ // "Documentation updates complete", "migration done", "task finished"
250
+ /\b(update|migration|refactor|implementation|task|feature|docs?|documentation|sync|work|changes?|setup)\s+(complete[d]?|done|finished?)\b/,
251
+ // "Complete: X" or "Done - X" as a status prefix in the title
252
+ /^(complete[d]?|done|finished?)\s*[-–—:]/,
253
+ ];
254
+ const contentPatterns = [
255
+ // Content that opens with a completion verb — strongest signal
256
+ /^completed?\s+\w/,
257
+ // "Successfully completed/deployed/merged/migrated..."
258
+ /\bsuccessfully\s+(completed?|deployed?|merged?|migrated?|updated?|implemented?|refactored?)\b/,
259
+ // "All cross-references validated", "All tests done"
260
+ // [\w-] (no \s) restricts to single-word or hyphenated nouns, preventing
261
+ // "Every code fix is validated" (a durable policy) from matching
262
+ /\b(all|every)\s+\S[\w-]{0,25}\s+(validated?|complete[d]?|verified?|done)\b/,
263
+ // "has been completed/finished/merged/deployed"
264
+ /\b(has|have)\s+been\s+(completed?|finished?|merged?|deployed?)\b/,
265
+ ];
266
+ const tm = firstMatch(title, titlePatterns);
267
+ if (tm)
268
+ return `title contains "${tm[0]}" — task completion announcements aren't durable knowledge`;
269
+ const cm = firstMatch(content, contentPatterns);
270
+ return cm ? `contains "${cm[0]}" — task completion announcements aren't durable knowledge` : undefined;
271
+ },
272
+ },
273
+ // ── Diff stats / work metrics ──────────────────────────────────────────
274
+ // Quantitative work summaries ("14 docs modified", "508 additions, 173 deletions")
275
+ // never appear in durable knowledge — they're always session activity reports.
276
+ {
277
+ id: 'diff-stats',
278
+ label: 'Diff stats or work metrics',
279
+ confidence: 'high',
280
+ test: (_title, _content, raw) => {
281
+ const content = raw.content.toLowerCase();
282
+ const patterns = [
283
+ // Git-style: "(508 additions, 173 deletions)" or "508 additions, 173 deletions"
284
+ /\b\d+\s+additions?,\s*\d+\s+deletions?\b/,
285
+ // Work quantity: "14 docs modified", "5 files changed"
286
+ /\b\d+\s+(docs?|files?|tests?|endpoints?|functions?|modules?|classes?|pages?)\s+(modified|changed|updated|added|created|deleted)\b/,
287
+ ];
288
+ const m = firstMatch(content, patterns);
289
+ return m ? `"${m[0]}" — quantitative work metrics are session activity, not lasting knowledge` : undefined;
290
+ },
291
+ },
236
292
  // ── Stack traces / debug logs ──────────────────────────────────────────
237
293
  {
238
294
  id: 'stack-trace',
@@ -386,6 +442,27 @@ const SIGNALS = [
386
442
  return m ? `"${m[0]}" — store the decision or fact, not the meeting reference` : undefined;
387
443
  },
388
444
  },
445
+ // ── Shipped / released / merged ────────────────────────────────────────
446
+ // Positive deployment/release language not already caught by temporal.
447
+ // ("just deployed" is caught by temporal; this catches statements without "just".)
448
+ {
449
+ id: 'shipped',
450
+ label: 'Deployed, released, or merged',
451
+ confidence: 'medium',
452
+ test: (_title, content) => {
453
+ const patterns = [
454
+ // "was/got/has been deployed to production/staging/main" — past-tense anchor prevents
455
+ // firing on policy conventions like "is deployed to production before release"
456
+ /\b(?:was|were|got|has been)\s+deployed?\s+(?:to|into)\s+(production|prod|staging|main|master)\b/,
457
+ // "was/got/has been merged into main/master/develop/trunk"
458
+ /\b(?:was|were|got|has been)\s+merged?\s+(?:to|into)\s+(main|master|develop|trunk)\b/,
459
+ // "released to production" or "released as v1.2" or "released as version 1.2"
460
+ /\breleased?\s+(?:to\s+(?:production|prod)|(?:as\s+)?(?:version\s+)?v?\d+\.\d+)\b/,
461
+ ];
462
+ const m = firstMatch(content, patterns);
463
+ return m ? `"${m[0]}" — deployment/release events are ephemeral; store the resulting behavior instead` : undefined;
464
+ },
465
+ },
389
466
  // ── Pending decision / under evaluation ─────────────────────────────────
390
467
  {
391
468
  id: 'pending-decision',
@@ -435,6 +512,31 @@ const SIGNALS = [
435
512
  return m ? `"${m[0]}" — metric changes are often transient observations` : undefined;
436
513
  },
437
514
  },
515
+ // ── Bundling conjunctions ──────────────────────────────────────────────
516
+ // Catches agents that put multiple unrelated facts in a single entry using
517
+ // explicit linking language. Only fires on sentence-boundary connectors
518
+ // (comma or start of new sentence) to avoid false positives on prose like
519
+ // "X and Y work together" or "also useful for Z".
520
+ {
521
+ id: 'bundling-conjunction',
522
+ label: 'Multiple facts bundled',
523
+ confidence: 'low',
524
+ test: (_title, content) => {
525
+ const patterns = [
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)
528
+ /[.!?]\s+additionally,/i, // ". Additionally, ..."
529
+ /[.!?]\s+furthermore,/i, // ". Furthermore, ..."
530
+ /\bunrelated:/i, // "Unrelated: ..."
531
+ /\bseparately:/i, // "Separately: ..."
532
+ /[.!?]\s+on a (separate|different|unrelated) note[,:]?/i,
533
+ ];
534
+ const m = firstMatch(content, patterns);
535
+ return m
536
+ ? `"${m[0]}" — consider splitting into separate entries (one insight per entry)`
537
+ : undefined;
538
+ },
539
+ },
438
540
  // ── Very short content ─────────────────────────────────────────────────
439
541
  {
440
542
  id: 'too-short',
@@ -489,28 +591,71 @@ export function detectEphemeralSignals(title, content, topic) {
489
591
  }
490
592
  return signals;
491
593
  }
492
- /** Format ephemeral signals into a human-readable warning string.
493
- * Returns undefined if no signals were detected. */
494
- 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) {
495
598
  if (signals.length === 0)
496
- return undefined;
599
+ return null;
497
600
  const highCount = signals.filter(s => s.confidence === 'high').length;
498
- const severity = highCount >= 2 ? 'likely contains' : highCount === 1 ? 'possibly contains' : 'may contain';
499
- const lines = [
500
- `This entry ${severity} ephemeral content:`,
501
- ...signals.map(s => ` - ${s.label}: ${s.detail}`),
502
- '',
503
- ];
504
- // Scale the guidance with confidence high-confidence gets direct advice,
505
- // low-confidence gets a softer suggestion to let the agent decide
506
- if (highCount >= 2) {
507
- lines.push('This is almost certainly session-specific. Consider deleting after your session.');
508
- }
509
- else if (highCount === 1) {
510
- lines.push('If this is a lasting insight, keep it. If session-specific, consider deleting after your session.');
511
- }
512
- else {
513
- lines.push('This might still be valid long-term knowledge use your judgment.');
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');
514
660
  }
515
- return lines.join('\n');
516
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
  }
@@ -183,7 +221,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
183
221
  // and memory_stats. The handler still works if called directly.
184
222
  {
185
223
  name: 'memory_store',
186
- description: 'Store knowledge. "user" and "preferences" are global (no lobe needed). Use tags for exact-match categorization. Add a shared tag (e.g., "test-entry") for bulk operations. Example: memory_store(topic: "gotchas", title: "Build cache", content: "Must clean build after Tuist changes", tags: ["build", "ios"])',
224
+ // Example comes first agents form their call shape from the first concrete pattern they see.
225
+ // "entries" (not "content") signals a collection; fighting the "content = string" prior
226
+ // 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.',
187
228
  inputSchema: {
188
229
  type: 'object',
189
230
  properties: {
@@ -196,13 +237,25 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
196
237
  description: 'Predefined: user | preferences | architecture | conventions | gotchas | recent-work. Custom namespace: modules/<name> (e.g. modules/brainstorm, modules/game-design, modules/api-notes). Use modules/<name> for any domain that doesn\'t fit the built-in topics.',
197
238
  enum: ['user', 'preferences', 'architecture', 'conventions', 'gotchas', 'recent-work'],
198
239
  },
199
- title: {
200
- type: 'string',
201
- description: 'Short title for this entry',
202
- },
203
- content: {
204
- type: 'string',
205
- description: 'The knowledge to store',
240
+ entries: {
241
+ type: 'array',
242
+ // Type annotation first agents trained on code read type signatures before prose.
243
+ description: 'Array<{title: string, fact: string}> — not a string. One object per insight. title: short label (2-5 words). fact: codebase topics → present-tense state ("X uses Y", not "Completed X"); user/preferences → what the person expressed. Wrong: one object bundling two facts. Right: two objects.',
244
+ items: {
245
+ type: 'object',
246
+ properties: {
247
+ title: {
248
+ type: 'string',
249
+ description: 'Short label for this insight (2-5 words)',
250
+ },
251
+ fact: {
252
+ type: 'string',
253
+ description: 'The insight itself — one focused fact or observation',
254
+ },
255
+ },
256
+ required: ['title', 'fact'],
257
+ },
258
+ minItems: 1,
206
259
  },
207
260
  sources: {
208
261
  type: 'array',
@@ -229,7 +282,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
229
282
  default: [],
230
283
  },
231
284
  },
232
- required: ['topic', 'title', 'content'],
285
+ required: ['topic', 'entries'],
233
286
  },
234
287
  },
235
288
  {
@@ -287,7 +340,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
287
340
  },
288
341
  {
289
342
  name: 'memory_context',
290
- 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")',
291
344
  inputSchema: {
292
345
  type: 'object',
293
346
  properties: {
@@ -393,11 +446,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
393
446
  };
394
447
  }
395
448
  case 'memory_store': {
396
- const { lobe: rawLobe, topic: rawTopic, title, content, sources, references, trust: rawTrust, tags: rawTags } = z.object({
449
+ const { lobe: rawLobe, topic: rawTopic, entries: rawEntries, sources, references, trust: rawTrust, tags: rawTags } = z.object({
397
450
  lobe: z.string().optional(),
398
451
  topic: z.string(),
399
- title: z.string().min(1),
400
- content: z.string().min(1),
452
+ // Accept a bare {title, fact} object in addition to the canonical array form.
453
+ // Only objects are auto-wrapped — strings and other primitives still fail with
454
+ // a type error, preserving the "validate at boundaries" invariant.
455
+ entries: z.preprocess((val) => (val !== null && !Array.isArray(val) && typeof val === 'object' ? [val] : val), z.array(z.object({
456
+ title: z.string().min(1),
457
+ fact: z.string().min(1),
458
+ })).min(1)),
401
459
  sources: z.array(z.string()).default([]),
402
460
  references: z.array(z.string()).default([]),
403
461
  trust: z.enum(['user', 'agent-confirmed', 'agent-inferred']).default('agent-inferred'),
@@ -424,54 +482,87 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
424
482
  const ctx = resolveToolContext(effectiveLobe, { isGlobal });
425
483
  if (!ctx.ok)
426
484
  return contextError(ctx);
427
- const result = await ctx.store.store(topic, title, content, sources,
428
- // User/preferences default to 'user' trust unless explicitly set otherwise
429
- isGlobal && trust === 'agent-inferred' ? 'user' : trust, references, rawTags);
430
- if (!result.stored) {
431
- return {
432
- content: [{ type: 'text', text: `[${ctx.label}] Failed to store: ${result.warning}` }],
433
- isError: true,
434
- };
485
+ const effectiveTrust = isGlobal && trust === 'agent-inferred' ? 'user' : trust;
486
+ const storedResults = [];
487
+ for (const { title, fact } of rawEntries) {
488
+ const result = await ctx.store.store(topic, title, fact, sources, effectiveTrust, references, rawTags);
489
+ if (!result.stored) {
490
+ return {
491
+ content: [{ type: 'text', text: `[${ctx.label}] Failed to store "${title}": ${result.warning}` }],
492
+ isError: true,
493
+ };
494
+ }
495
+ storedResults.push({ title, result });
496
+ }
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.
500
+ const lines = [];
501
+ if (storedResults.length === 1) {
502
+ const { result } = storedResults[0];
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}`);
505
+ if (result.warning)
506
+ lines.push(`Note: ${result.warning}`);
507
+ }
508
+ else {
509
+ const { result: first } = storedResults[0];
510
+ lines.push(`[${ctx.label}] Stored ${storedResults.length} entries in ${first.topic} (confidence: ${first.confidence}):`);
511
+ for (const { title, result } of storedResults) {
512
+ const ephemeralFlag = result.ephemeralSeverity === 'high' ? ' ⚠' : '';
513
+ lines.push(` - ${result.id}: "${title}"${ephemeralFlag}`);
514
+ }
435
515
  }
436
- const lines = [
437
- `[${ctx.label}] Stored entry ${result.id} in ${result.topic} (confidence: ${result.confidence})`,
438
- ];
439
- if (result.warning)
440
- lines.push(`Note: ${result.warning}`);
441
516
  // Limit to at most 2 hint sections per response to prevent hint fatigue.
442
517
  // Priority: dedup > ephemeral > preferences (dedup is actionable and high-signal,
443
518
  // ephemeral warnings affect entry quality, preferences are informational).
519
+ // For multi-entry batches, hints reference the first triggering entry.
444
520
  let hintCount = 0;
445
- // Dedup: surface related entries in the same topic
446
- if (result.relatedEntries && result.relatedEntries.length > 0 && hintCount < 2) {
447
- hintCount++;
448
- lines.push('');
449
- lines.push('⚠ Similar entries found in the same topic:');
450
- for (const r of result.relatedEntries) {
451
- lines.push(` - ${r.id}: "${r.title}" (confidence: ${r.confidence})`);
452
- lines.push(` Content: ${r.content.length > 120 ? r.content.substring(0, 120) + '...' : r.content}`);
521
+ for (const { title, result } of storedResults) {
522
+ const entryPrefix = storedResults.length > 1 ? `"${title}": ` : '';
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.
525
+ if (result.relatedEntries && result.relatedEntries.length > 0 && hintCount < 2) {
526
+ hintCount++;
527
+ const top = result.relatedEntries[0];
528
+ lines.push('');
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'}`);
537
+ }
538
+ lines.push('');
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);
453
543
  }
454
- lines.push('');
455
- lines.push('To consolidate: memory_correct(id: "<old-id>", action: "replace", correction: "<merged content>") then memory_correct(id: "<new-id>", action: "delete")');
456
- }
457
- // Ephemeral content warning — soft nudge, never blocking
458
- if (result.ephemeralWarning && hintCount < 2) {
459
- hintCount++;
460
- lines.push('');
461
- lines.push(`⏳ ${result.ephemeralWarning}`);
462
- }
463
- // Preference surfacing: show relevant preferences for non-preference entries
464
- if (result.relevantPreferences && result.relevantPreferences.length > 0 && hintCount < 2) {
465
- hintCount++;
466
- lines.push('');
467
- lines.push('📌 Relevant preferences:');
468
- for (const p of result.relevantPreferences) {
469
- lines.push(` - [pref] ${p.title}: ${p.content.length > 120 ? p.content.substring(0, 120) + '...' : p.content}`);
544
+ // Ephemeral content warning — the formatted block already contains visual borders
545
+ // and pre-filled delete command from formatEphemeralWarning.
546
+ if (result.ephemeralWarning && hintCount < 2) {
547
+ hintCount++;
548
+ lines.push('');
549
+ if (entryPrefix)
550
+ lines.push(`${entryPrefix}:`);
551
+ lines.push(result.ephemeralWarning);
552
+ }
553
+ // Preference surfacing: show relevant preferences for non-preference entries
554
+ if (result.relevantPreferences && result.relevantPreferences.length > 0 && hintCount < 2) {
555
+ hintCount++;
556
+ lines.push('');
557
+ lines.push(`📌 ${entryPrefix}Relevant preferences:`);
558
+ for (const p of result.relevantPreferences) {
559
+ lines.push(` - [pref] ${p.title}: ${p.content.length > 120 ? p.content.substring(0, 120) + '...' : p.content}`);
560
+ }
561
+ lines.push('');
562
+ lines.push('Review the stored entry against these preferences for potential conflicts.');
470
563
  }
471
- lines.push('');
472
- lines.push('Review the stored entry against these preferences for potential conflicts.');
473
564
  }
474
- // Vocabulary echo: show existing tags to drive convergence
565
+ // Vocabulary echo: show existing tags to drive convergence (once per response)
475
566
  if (hintCount < 2) {
476
567
  const tagFreq = ctx.store.getTagFrequency();
477
568
  if (tagFreq.size > 0) {
@@ -496,13 +587,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
496
587
  branch: z.string().optional(),
497
588
  }).parse(args ?? {});
498
589
  const isGlobalQuery = GLOBAL_TOPICS.has(scope);
499
- // For global topics (user, preferences), always route to global store.
500
- // 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.
501
592
  let lobeEntries = [];
502
- const entryLobeMap = new Map(); // entry id → lobe name (for cross-lobe labeling)
593
+ const entryLobeMap = new Map(); // entry id → lobe name
503
594
  let label;
504
595
  let primaryStore;
505
- let isMultiLobe = false;
596
+ let queryGlobalOnlyHint;
506
597
  if (isGlobalQuery) {
507
598
  const ctx = resolveToolContext(rawLobe, { isGlobal: true });
508
599
  if (!ctx.ok)
@@ -524,20 +615,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
524
615
  lobeEntries = [...result.entries];
525
616
  }
526
617
  else {
527
- // Search all healthy lobes — read operations shouldn't require lobe selection
528
- const allLobeNames = configManager.getLobeNames();
529
- isMultiLobe = allLobeNames.length > 1;
530
- label = allLobeNames.length === 1 ? allLobeNames[0] : 'all';
531
- for (const lobeName of allLobeNames) {
532
- const store = configManager.getStore(lobeName);
533
- if (!store)
534
- continue;
535
- if (!primaryStore)
536
- primaryStore = store;
537
- const result = await store.query(scope, detail, filter, branch);
538
- for (const e of result.entries)
539
- entryLobeMap.set(e.id, lobeName);
540
- 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
+ }
541
642
  }
542
643
  }
543
644
  // For wildcard queries on non-global topics, also include global store entries
@@ -559,22 +660,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
559
660
  })
560
661
  .sort((a, b) => b.relevanceScore - a.relevanceScore);
561
662
  // Build stores collection for tag frequency aggregation
663
+ // Only include stores that were actually searched
562
664
  const searchedStores = [];
563
665
  if (isGlobalQuery) {
564
666
  searchedStores.push(globalStore);
565
667
  }
566
- else if (rawLobe) {
567
- const store = configManager.getStore(rawLobe);
568
- if (store)
569
- searchedStores.push(store);
570
- }
571
668
  else {
572
- // All lobes + global when doing wildcard search
573
- for (const lobeName of configManager.getLobeNames()) {
574
- const store = configManager.getStore(lobeName);
575
- if (store)
576
- searchedStores.push(store);
577
- }
669
+ if (primaryStore)
670
+ searchedStores.push(primaryStore);
578
671
  if (scope === '*')
579
672
  searchedStores.push(globalStore);
580
673
  }
@@ -583,23 +676,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
583
676
  const filterGroups = filter ? parseFilter(filter) : [];
584
677
  if (allEntries.length === 0) {
585
678
  const footer = buildQueryFooter({ filterGroups, rawFilter: filter, tagFreq, resultCount: 0, scope });
679
+ const noResultHint = queryGlobalOnlyHint ? `\n\n> ${queryGlobalOnlyHint}` : '';
586
680
  return {
587
681
  content: [{
588
682
  type: 'text',
589
- 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}`,
590
684
  }],
591
685
  };
592
686
  }
687
+ const showQueryLobeLabels = entryLobeMap.size > 0;
593
688
  const lines = allEntries.map(e => {
594
689
  const freshIndicator = e.fresh ? '' : ' [stale]';
595
- const lobeTag = isMultiLobe ? ` [${entryLobeMap.get(e.id) ?? '?'}]` : '';
690
+ const lobeTag = showQueryLobeLabels ? ` [${entryLobeMap.get(e.id) ?? '?'}]` : '';
596
691
  if (detail === 'brief') {
597
692
  return `- **${e.title}** (${e.id}${lobeTag}, confidence: ${e.confidence})${freshIndicator}\n ${e.summary}`;
598
693
  }
599
694
  if (detail === 'full') {
600
695
  const meta = [
601
696
  `ID: ${e.id}`,
602
- isMultiLobe ? `Lobe: ${entryLobeMap.get(e.id) ?? '?'}` : null,
697
+ showQueryLobeLabels ? `Lobe: ${entryLobeMap.get(e.id) ?? '?'}` : null,
603
698
  `Confidence: ${e.confidence}`,
604
699
  `Trust: ${e.trust}`,
605
700
  `Fresh: ${e.fresh}`,
@@ -633,6 +728,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
633
728
  text += '\n\n' + formatConflictWarning(conflicts);
634
729
  }
635
730
  }
731
+ // Surface hint when we fell back to global-only
732
+ if (queryGlobalOnlyHint) {
733
+ text += `\n\n> ${queryGlobalOnlyHint}`;
734
+ }
636
735
  // Build footer with query mode, tag vocabulary, and syntax reference
637
736
  const footer = buildQueryFooter({ filterGroups, rawFilter: filter, tagFreq, resultCount: allEntries.length, scope });
638
737
  text += `\n\n---\n${footer}`;
@@ -785,7 +884,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
785
884
  const ctxEntryLobeMap = new Map(); // entry id → lobe name
786
885
  let label;
787
886
  let primaryStore;
788
- let isCtxMultiLobe = false;
887
+ let ctxGlobalOnlyHint;
789
888
  if (rawLobe) {
790
889
  const ctx = resolveToolContext(rawLobe);
791
890
  if (!ctx.ok)
@@ -796,34 +895,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
796
895
  allLobeResults.push(...lobeResults);
797
896
  }
798
897
  else {
799
- // Search all healthy lobes — read operations shouldn't require lobe selection
800
- const allLobeNames = configManager.getLobeNames();
801
- isCtxMultiLobe = allLobeNames.length > 1;
802
- label = allLobeNames.length === 1 ? allLobeNames[0] : 'all';
803
- for (const lobeName of allLobeNames) {
804
- const store = configManager.getStore(lobeName);
805
- if (!store)
806
- continue;
807
- if (!primaryStore)
808
- primaryStore = store;
809
- const lobeResults = await store.contextSearch(context, max, undefined, threshold);
810
- for (const r of lobeResults)
811
- ctxEntryLobeMap.set(r.entry.id, lobeName);
812
- allLobeResults.push(...lobeResults);
813
- }
814
- }
815
- // Cross-lobe weak-match penalty: demote results from other repos that only matched
816
- // on generic software terms (e.g. "codebase", "structure"). Without this, a high-
817
- // confidence entry from an unrelated repo can outrank genuinely relevant knowledge
818
- // simply because popular terms appear in it.
819
- // Applied only in multi-lobe mode; single-lobe and global results are never penalized.
820
- if (isCtxMultiLobe) {
821
- const contextKwCount = extractKeywords(context).size;
822
- // Minimum keyword matches required to avoid the penalty (at least 40% of context, min 2)
823
- const minMatchCount = Math.max(2, Math.ceil(contextKwCount * CROSS_LOBE_MIN_MATCH_RATIO));
824
- for (let i = 0; i < allLobeResults.length; i++) {
825
- if (allLobeResults[i].matchedKeywords.length < minMatchCount) {
826
- 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;
827
921
  }
828
922
  }
829
923
  }
@@ -843,28 +937,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
843
937
  })
844
938
  .slice(0, max);
845
939
  // Build stores collection for tag frequency aggregation
940
+ // Only include stores that were actually searched (not all lobes)
846
941
  const ctxSearchedStores = [globalStore];
847
- if (rawLobe) {
848
- const store = configManager.getStore(rawLobe);
849
- if (store)
850
- ctxSearchedStores.push(store);
851
- }
852
- else {
853
- for (const lobeName of configManager.getLobeNames()) {
854
- const store = configManager.getStore(lobeName);
855
- if (store)
856
- ctxSearchedStores.push(store);
857
- }
942
+ if (primaryStore && primaryStore !== globalStore) {
943
+ ctxSearchedStores.push(primaryStore);
858
944
  }
859
945
  const ctxTagFreq = mergeTagFrequencies(ctxSearchedStores);
860
946
  // Parse filter for footer (context search has no filter, pass empty)
861
947
  const ctxFilterGroups = [];
862
948
  if (results.length === 0) {
863
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.';
864
953
  return {
865
954
  content: [{
866
955
  type: 'text',
867
- 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}`,
868
957
  }],
869
958
  };
870
959
  }
@@ -882,6 +971,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
882
971
  ...topicOrder.filter(t => byTopic.has(t)),
883
972
  ...Array.from(byTopic.keys()).filter(t => !topicOrder.includes(t)).sort(),
884
973
  ];
974
+ const showCtxLobeLabels = ctxEntryLobeMap.size > 0;
885
975
  for (const topic of orderedTopics) {
886
976
  const topicResults = byTopic.get(topic);
887
977
  const heading = topic === 'user' ? 'About You'
@@ -893,7 +983,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
893
983
  for (const r of topicResults) {
894
984
  const marker = topic === 'gotchas' ? '[!] ' : topic === 'preferences' ? '[pref] ' : '';
895
985
  const keywords = r.matchedKeywords.length > 0 ? ` (matched: ${r.matchedKeywords.join(', ')})` : '';
896
- const lobeLabel = isCtxMultiLobe ? ` [${ctxEntryLobeMap.get(r.entry.id) ?? '?'}]` : '';
986
+ const lobeLabel = showCtxLobeLabels ? ` [${ctxEntryLobeMap.get(r.entry.id) ?? '?'}]` : '';
897
987
  const tagsSuffix = r.entry.tags?.length ? ` [tags: ${r.entry.tags.join(', ')}]` : '';
898
988
  sections.push(`- **${marker}${r.entry.title}**${lobeLabel}: ${r.entry.content}${keywords}${tagsSuffix}`);
899
989
  }
@@ -920,6 +1010,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
920
1010
  sections.push(`---\n*Context loaded for: ${kwList} (${topicList}). ` +
921
1011
  `This knowledge is now in your conversation — no need to call memory_context again for these terms this session.*`);
922
1012
  }
1013
+ // Surface hint when we fell back to global-only
1014
+ if (ctxGlobalOnlyHint) {
1015
+ sections.push(`> ${ctxGlobalOnlyHint}`);
1016
+ }
923
1017
  // Build footer (context search has no filter — it's natural language keyword matching)
924
1018
  const ctxFooter = buildQueryFooter({ filterGroups: ctxFilterGroups, rawFilter: undefined, tagFreq: ctxTagFreq, resultCount: results.length, scope: 'context search' });
925
1019
  sections.push(`---\n${ctxFooter}`);
@@ -1032,8 +1126,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1032
1126
  const lobeNames = configManager.getLobeNames();
1033
1127
  hint = `\n\nHint: lobe is required. Use memory_list_lobes to see available lobes. Available: ${lobeNames.join(', ')}`;
1034
1128
  }
1035
- else if (message.includes('"topic"') || message.includes('"title"') || message.includes('"content"')) {
1036
- hint = '\n\nHint: memory_store requires: topic (architecture|conventions|gotchas|recent-work|modules/<name>), title, content. Use modules/<name> for custom namespaces (e.g. modules/brainstorm, modules/game-design).';
1129
+ else if (message.includes('"topic"') || message.includes('"entries"')) {
1130
+ hint = '\n\nHint: memory_store requires: topic (architecture|conventions|gotchas|recent-work|modules/<name>) and entries (Array<{title, fact}>). Example: entries: [{title: "Build cache", fact: "Must clean build after Tuist changes"}]. Use modules/<name> for custom namespaces.';
1037
1131
  }
1038
1132
  else if (message.includes('"scope"')) {
1039
1133
  hint = '\n\nHint: memory_query requires: lobe, scope (architecture|conventions|gotchas|recent-work|modules/<name>|* for all)';
@@ -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/normalize.js CHANGED
@@ -6,11 +6,6 @@
6
6
  /** Canonical param name aliases — maps guessed names to their correct form */
7
7
  const PARAM_ALIASES = {
8
8
  // memory_store aliases
9
- key: 'title',
10
- name: 'title',
11
- value: 'content',
12
- body: 'content',
13
- text: 'content',
14
9
  refs: 'references',
15
10
  // memory_query aliases
16
11
  query: 'filter',
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.1.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",