@exaudeus/memory-mcp 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@ A Model Context Protocol (MCP) server that gives AI coding agents persistent, ev
8
8
  |------|-------------|
9
9
  | `memory_context` | Session start AND pre-task lookup. Call with no args for user + preferences + stale nudges; call with `context` for task-specific knowledge |
10
10
  | `memory_query` | Structured search with brief/standard/full detail levels and AND/OR/NOT filter syntax. Scope defaults to `"*"` (all topics) |
11
- | `memory_store` | Store a knowledge entry with dedup detection, preference surfacing, and lobe auto-detection from file paths |
11
+ | `memory_store` | Store a knowledge entry with dedup detection, preference surfacing, lobe auto-detection, and a review-required gate for likely-ephemeral content |
12
12
  | `memory_correct` | Correct, update, or delete an existing entry (suggests storing as preference) |
13
13
  | `memory_bootstrap` | First-use scan to seed knowledge from repo structure, README, and build files |
14
14
 
@@ -33,6 +33,7 @@ A Model Context Protocol (MCP) server that gives AI coding agents persistent, ev
33
33
 
34
34
  - **Dedup detection**: When you store an entry, the response shows similar existing entries in the same topic (>35% keyword overlap) with consolidation instructions
35
35
  - **Preference surfacing**: Storing a non-preference entry shows relevant preferences that might conflict
36
+ - **Ephemeral review gate**: Likely-ephemeral content is blocked before persistence by default. Re-run `memory_store(..., durabilityDecision: "store-anyway")` only when you intentionally want to keep it.
36
37
  - **Piggyback hints**: `memory_correct` suggests storing corrections as reusable preferences
37
38
  - **`memory_context`**: Describe your task in natural language and get ranked results across all topics with topic-based boosting (preferences 1.8x, gotchas 1.5x)
38
39
 
@@ -108,10 +109,12 @@ If no `memory-config.json` is found, the server falls back to environment variab
108
109
  1. **Edit `memory-config.json`** (create if it doesn't exist)
109
110
  2. **Add lobe entry:**
110
111
  ```json
112
+ {
111
113
  "my-project": {
112
114
  "root": "$HOME/git/my-project",
113
115
  "budgetMB": 2
114
116
  }
117
+ }
115
118
  ```
116
119
  3. **Restart the memory MCP server**
117
120
  4. **Verify:** Use `memory_list_lobes` to confirm it loaded
@@ -22,4 +22,6 @@ export declare function buildQueryFooter(opts: {
22
22
  readonly scope: string;
23
23
  }): string;
24
24
  /** Build tag primer section for session briefing — pure function */
25
- export declare function buildTagPrimerSection(tagFreq: ReadonlyMap<string, number>): string;
25
+ export declare function buildTagPrimerSection(tagFreq: ReadonlyMap<string, number>, lobeName?: string): string;
26
+ /** Build briefing tag primer sections without merging vocabularies across lobes. */
27
+ export declare function buildBriefingTagPrimerSections(lobeTagFrequencies: Iterable<readonly [string, ReadonlyMap<string, number>]>): readonly string[];
@@ -173,7 +173,7 @@ export function buildQueryFooter(opts) {
173
173
  return lines.join('\n');
174
174
  }
175
175
  /** Build tag primer section for session briefing — pure function */
176
- export function buildTagPrimerSection(tagFreq) {
176
+ export function buildTagPrimerSection(tagFreq, lobeName) {
177
177
  if (tagFreq.size === 0)
178
178
  return '';
179
179
  const allTags = [...tagFreq.entries()]
@@ -181,7 +181,9 @@ export function buildTagPrimerSection(tagFreq) {
181
181
  .map(([tag, count]) => `${tag}(${count})`)
182
182
  .join(', ');
183
183
  return [
184
- `### Tag Vocabulary (${tagFreq.size} tags)`,
184
+ lobeName
185
+ ? `### Tag Vocabulary — ${lobeName} (${tagFreq.size} tags)`
186
+ : `### Tag Vocabulary (${tagFreq.size} tags)`,
185
187
  allTags,
186
188
  ``,
187
189
  `Filter by tags: memory_query(filter: "#auth") — exact match`,
@@ -189,3 +191,10 @@ export function buildTagPrimerSection(tagFreq) {
189
191
  `Multiple: memory_query(filter: "#auth|#security") — OR logic`,
190
192
  ].join('\n');
191
193
  }
194
+ /** Build briefing tag primer sections without merging vocabularies across lobes. */
195
+ export function buildBriefingTagPrimerSections(lobeTagFrequencies) {
196
+ const nonEmpty = Array.from(lobeTagFrequencies)
197
+ .filter(([, tagFreq]) => tagFreq.size > 0);
198
+ const includeLobeNames = nonEmpty.length > 1;
199
+ return nonEmpty.map(([lobeName, tagFreq]) => buildTagPrimerSection(tagFreq, includeLobeNames ? lobeName : undefined));
200
+ }
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import { getLobeConfigs } from './config.js';
14
14
  import { ConfigManager } from './config-manager.js';
15
15
  import { normalizeArgs } from './normalize.js';
16
16
  import { buildCrashReport, writeCrashReport, writeCrashReportSync, readLatestCrash, readCrashHistory, clearLatestCrash, formatCrashReport, formatCrashSummary, markServerStarted, } from './crash-journal.js';
17
- import { formatStaleSection, formatConflictWarning, formatStats, formatBehaviorConfigSection, mergeTagFrequencies, buildQueryFooter, buildTagPrimerSection } from './formatters.js';
17
+ import { formatStaleSection, formatConflictWarning, formatStats, formatBehaviorConfigSection, mergeTagFrequencies, buildQueryFooter, buildBriefingTagPrimerSections } from './formatters.js';
18
18
  import { parseFilter } from './text-analyzer.js';
19
19
  import { VOCABULARY_ECHO_LIMIT, WARN_SEPARATOR } from './thresholds.js';
20
20
  import { matchRootsToLobeNames, buildLobeResolution } from './lobe-resolution.js';
@@ -215,7 +215,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
215
215
  // Example comes first — agents form their call shape from the first concrete pattern they see.
216
216
  // "entries" (not "content") signals a collection; fighting the "content = string" prior
217
217
  // is an architectural fix rather than patching the description after the fact.
218
- description: 'memory_store(topic: "gotchas", entries: [{title: "Build cache", fact: "Must clean build after Tuist changes"}, {title: "Tuist version", fact: "Project requires Tuist 4.x"}], tags: ["build"]). Stores enduring knowledge — (1) Codebase facts (architecture, conventions, gotchas, modules): what IS true now, not past actions. Wrong: "Completed migration to StateFlow." Right: "All ViewModels use StateFlow." (2) User knowledge (user, preferences): who the person is, how they work. "user" and "preferences" are stored in the alwaysInclude lobe (shared across projects). One insight per object; use multiple objects instead of bundling.',
218
+ description: 'memory_store(topic: "gotchas", entries: [{title: "Build cache", fact: "Must clean build after Tuist changes"}, {title: "Tuist version", fact: "Project requires Tuist 4.x"}], tags: ["build"]). Stores enduring knowledge — (1) Codebase facts (architecture, conventions, gotchas, modules): what IS true now, not past actions. Wrong: "Completed migration to StateFlow." Right: "All ViewModels use StateFlow." (2) User knowledge (user, preferences): who the person is, how they work. "user" and "preferences" are stored in the alwaysInclude lobe (shared across projects). One insight per object; use multiple objects instead of bundling. If content looks likely-ephemeral, the tool returns a review-required response; re-run with durabilityDecision: "store-anyway" only when deliberate.',
219
219
  inputSchema: {
220
220
  type: 'object',
221
221
  properties: {
@@ -272,6 +272,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
272
272
  description: 'Category labels for exact-match retrieval (lowercase slugs). Query with filter: "#tag". Example: ["auth", "critical-path", "mite-combat"]',
273
273
  default: [],
274
274
  },
275
+ durabilityDecision: {
276
+ type: 'string',
277
+ enum: ['default', 'store-anyway'],
278
+ description: 'Write intent for content that may require review. Use "default" normally. Use "store-anyway" only when intentionally persisting content after a review-required response.',
279
+ default: 'default',
280
+ },
275
281
  },
276
282
  required: ['topic', 'entries'],
277
283
  },
@@ -442,7 +448,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
442
448
  };
443
449
  }
444
450
  case 'memory_store': {
445
- const { lobe: rawLobe, topic: rawTopic, entries: rawEntries, sources, references, trust: rawTrust, tags: rawTags } = z.object({
451
+ const { lobe: rawLobe, topic: rawTopic, entries: rawEntries, sources, references, trust: rawTrust, tags: rawTags, durabilityDecision } = z.object({
446
452
  lobe: z.string().optional(),
447
453
  topic: z.string(),
448
454
  // Accept a bare {title, fact} object in addition to the canonical array form.
@@ -456,6 +462,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
456
462
  references: z.array(z.string()).default([]),
457
463
  trust: z.enum(['user', 'agent-confirmed', 'agent-inferred']).default('agent-inferred'),
458
464
  tags: z.array(z.string()).default([]),
465
+ durabilityDecision: z.enum(['default', 'store-anyway']).default('default'),
459
466
  }).parse(args);
460
467
  // Validate topic at boundary
461
468
  const topic = parseTopicScope(rawTopic);
@@ -494,8 +501,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
494
501
  : trust;
495
502
  const storedResults = [];
496
503
  for (const { title, fact } of rawEntries) {
497
- const result = await ctx.store.store(topic, title, fact, sources, effectiveTrust, references, rawTags);
498
- if (!result.stored) {
504
+ const result = await ctx.store.store(topic, title, fact, sources, effectiveTrust, references, rawTags, durabilityDecision);
505
+ if (result.kind === 'review-required') {
506
+ const lines = [
507
+ `[${ctx.label}] Review required before storing "${title}".`,
508
+ '',
509
+ `Severity: ${result.severity}`,
510
+ 'Signals:',
511
+ ...result.signals.map(signal => `- ${signal.label}: ${signal.detail}`),
512
+ '',
513
+ result.warning,
514
+ '',
515
+ 'If this knowledge is still worth persisting, re-run with:',
516
+ `memory_store(topic: "${topic}", entries: [{title: "${title}", fact: "${fact.replace(/"/g, '\\"')}"}], trust: "${effectiveTrust}", durabilityDecision: "store-anyway"${rawTags.length > 0 ? `, tags: ${JSON.stringify(rawTags)}` : ''}${sources.length > 0 ? `, sources: ${JSON.stringify(sources)}` : ''}${references.length > 0 ? `, references: ${JSON.stringify(references)}` : ''})`,
517
+ ];
518
+ return {
519
+ content: [{ type: 'text', text: lines.join('\n') }],
520
+ isError: true,
521
+ };
522
+ }
523
+ if (result.kind === 'rejected') {
499
524
  return {
500
525
  content: [{ type: 'text', text: `[${ctx.label}] Failed to store "${title}": ${result.warning}` }],
501
526
  isError: true,
@@ -861,17 +886,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
861
886
  if (sections.length === 0) {
862
887
  sections.push('No knowledge stored yet. As you work, store observations with memory_store. Try memory_bootstrap to seed initial knowledge from the repo.');
863
888
  }
864
- // Tag primer: show tag vocabulary if tags exist across any lobe
865
- const briefingStores = [];
866
- for (const lobeName of allBriefingLobeNames) {
889
+ // Tag primer: keep vocabularies lobe-local instead of merging them across lobes.
890
+ const briefingTagPrimers = buildBriefingTagPrimerSections(allBriefingLobeNames
891
+ .filter(lobeName => configManager.getLobeHealth(lobeName)?.status !== 'degraded')
892
+ .map((lobeName) => {
867
893
  const store = configManager.getStore(lobeName);
868
- if (store)
869
- briefingStores.push(store);
870
- }
871
- const briefingTagFreq = mergeTagFrequencies(briefingStores);
872
- const tagPrimer = buildTagPrimerSection(briefingTagFreq);
873
- if (tagPrimer) {
874
- sections.push(tagPrimer);
894
+ return [lobeName, store?.getTagFrequency() ?? new Map()];
895
+ }));
896
+ if (briefingTagPrimers.length > 0) {
897
+ sections.push(...briefingTagPrimers);
875
898
  }
876
899
  const briefingHints = [];
877
900
  briefingHints.push(`${totalEntries} entries${totalStale > 0 ? ` (${totalStale} stale)` : ''} across ${allBriefingLobeNames.length} ${allBriefingLobeNames.length === 1 ? 'lobe' : 'lobes'}.`);
@@ -1092,8 +1115,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1092
1115
  if (!ctx.ok)
1093
1116
  return contextError(ctx);
1094
1117
  const results = await ctx.store.bootstrap();
1095
- const stored = results.filter(r => r.stored);
1096
- const failed = results.filter(r => !r.stored);
1118
+ const stored = results.filter((r) => r.kind === 'stored');
1119
+ const failed = results.filter((r) => r.kind !== 'stored');
1097
1120
  let text = `## [${ctx.label}] Bootstrap Complete\n\nStored ${stored.length} entries:`;
1098
1121
  for (const r of stored) {
1099
1122
  text += `\n- ${r.id}: ${r.topic} (${r.file})`;
package/dist/store.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { MemoryEntry, TopicScope, TrustLevel, DetailLevel, QueryResult, StoreResult, CorrectResult, MemoryStats, BriefingResult, ConflictPair, MemoryConfig } from './types.js';
1
+ import type { MemoryEntry, TopicScope, TrustLevel, DetailLevel, DurabilityDecision, QueryResult, StoreResult, CorrectResult, MemoryStats, BriefingResult, ConflictPair, MemoryConfig } from './types.js';
2
2
  export declare class MarkdownMemoryStore {
3
3
  private readonly config;
4
4
  private readonly memoryPath;
@@ -13,7 +13,7 @@ export declare class MarkdownMemoryStore {
13
13
  /** Initialize the store: create memory dir and load existing entries */
14
14
  init(): Promise<void>;
15
15
  /** Store a new knowledge entry */
16
- store(topic: TopicScope, title: string, content: string, sources?: string[], trust?: TrustLevel, references?: string[], rawTags?: string[]): Promise<StoreResult>;
16
+ store(topic: TopicScope, title: string, content: string, sources?: string[], trust?: TrustLevel, references?: string[], rawTags?: string[], durabilityDecision?: DurabilityDecision): Promise<StoreResult>;
17
17
  /** Query knowledge by scope and detail level */
18
18
  query(scope: string, detail?: DetailLevel, filter?: string, branchFilter?: string): Promise<QueryResult>;
19
19
  /** Generate a session-start briefing */
package/dist/store.js CHANGED
@@ -41,15 +41,35 @@ export class MarkdownMemoryStore {
41
41
  await this.reloadFromDisk();
42
42
  }
43
43
  /** Store a new knowledge entry */
44
- async store(topic, title, content, sources = [], trust = 'agent-inferred', references = [], rawTags = []) {
44
+ async store(topic, title, content, sources = [], trust = 'agent-inferred', references = [], rawTags = [], durabilityDecision = 'default') {
45
45
  // Check storage budget — null means we can't measure, allow the write
46
46
  const currentSize = await this.getStorageSize();
47
47
  if (currentSize !== null && currentSize >= this.config.storageBudgetBytes) {
48
48
  return {
49
- stored: false, topic,
49
+ kind: 'rejected', stored: false, topic,
50
50
  warning: `Storage budget exceeded (${this.formatBytes(currentSize)} / ${this.formatBytes(this.config.storageBudgetBytes)}). Delete or correct existing entries to free space.`,
51
51
  };
52
52
  }
53
+ const ephemeralSignals = topic !== 'recent-work'
54
+ ? detectEphemeralSignals(title, content, topic)
55
+ : [];
56
+ const ephemeralSeverity = getEphemeralSeverity(ephemeralSignals);
57
+ const requiresReview = ephemeralSeverity === 'medium' || ephemeralSeverity === 'high';
58
+ if (durabilityDecision === 'default' && requiresReview) {
59
+ return {
60
+ kind: 'review-required',
61
+ stored: false,
62
+ topic,
63
+ severity: ephemeralSeverity,
64
+ warning: `Likely ephemeral content detected. Review the signals and re-run with durabilityDecision: "store-anyway" if this knowledge should still be persisted.`,
65
+ signals: ephemeralSignals.map(signal => ({
66
+ id: signal.id,
67
+ label: signal.label,
68
+ detail: signal.detail,
69
+ confidence: signal.confidence,
70
+ })),
71
+ };
72
+ }
53
73
  const id = this.generateId(topic);
54
74
  const now = this.clock.isoNow();
55
75
  const confidence = DEFAULT_CONFIDENCE[trust];
@@ -85,15 +105,9 @@ export class MarkdownMemoryStore {
85
105
  const relevantPreferences = (topic !== 'preferences' && topic !== 'user')
86
106
  ? this.findRelevantPreferences(entry)
87
107
  : undefined;
88
- // Soft ephemeral detection — warn but never block
89
- const ephemeralSignals = topic !== 'recent-work'
90
- ? detectEphemeralSignals(title, content, topic)
91
- : [];
92
- // getEphemeralSeverity is the single source of threshold logic shared with formatEphemeralWarning.
93
- const ephemeralSeverity = getEphemeralSeverity(ephemeralSignals);
94
108
  const ephemeralWarning = formatEphemeralWarning(ephemeralSignals, id);
95
109
  return {
96
- stored: true, id, topic, file, confidence, warning, ephemeralWarning,
110
+ kind: 'stored', stored: true, id, topic, file, confidence, warning, ephemeralWarning,
97
111
  ephemeralSeverity: ephemeralSeverity ?? undefined,
98
112
  relatedEntries: relatedEntries.length > 0 ? relatedEntries : undefined,
99
113
  relevantPreferences: relevantPreferences && relevantPreferences.length > 0 ? relevantPreferences : undefined,
@@ -384,7 +398,7 @@ export class MarkdownMemoryStore {
384
398
  catch { /* not a git repo or git not available */ }
385
399
  // 5. Fallback: if nothing was detected, store a minimal overview so the lobe
386
400
  // is never left completely empty after bootstrap (makes memory_context useful immediately).
387
- const storedCount = results.filter(r => r.stored).length;
401
+ const storedCount = results.filter(r => r.kind === 'stored').length;
388
402
  if (storedCount === 0) {
389
403
  try {
390
404
  const topLevel = await fs.readdir(repoRoot, { withFileTypes: true });
package/dist/types.d.ts CHANGED
@@ -89,8 +89,21 @@ export interface RelatedEntry {
89
89
  readonly confidence: number;
90
90
  readonly trust: TrustLevel;
91
91
  }
92
+ /** Caller intent for writes that may require a durability decision.
93
+ * Explicit domain type avoids booleanly-typed override semantics. */
94
+ export type DurabilityDecision = 'default' | 'store-anyway';
95
+ /** Structured signal attached to a review-required store outcome.
96
+ * Duplicated here (rather than importing from ephemeral.ts) to keep the store
97
+ * result contract independent of a concrete detection implementation. */
98
+ export interface ReviewSignal {
99
+ readonly id: string;
100
+ readonly label: string;
101
+ readonly detail: string;
102
+ readonly confidence: 'high' | 'medium' | 'low';
103
+ }
92
104
  /** Result of a memory store operation — discriminated union eliminates impossible states */
93
105
  export type StoreResult = {
106
+ readonly kind: 'stored';
94
107
  readonly stored: true;
95
108
  readonly id: string;
96
109
  readonly topic: TopicScope;
@@ -104,6 +117,14 @@ export type StoreResult = {
104
117
  readonly relatedEntries?: readonly RelatedEntry[];
105
118
  readonly relevantPreferences?: readonly RelatedEntry[];
106
119
  } | {
120
+ readonly kind: 'review-required';
121
+ readonly stored: false;
122
+ readonly topic: TopicScope;
123
+ readonly severity: EphemeralSeverity;
124
+ readonly warning: string;
125
+ readonly signals: readonly ReviewSignal[];
126
+ } | {
127
+ readonly kind: 'rejected';
107
128
  readonly stored: false;
108
129
  readonly topic: TopicScope;
109
130
  readonly warning: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exaudeus/memory-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "Codebase memory MCP server - persistent, evolving knowledge for AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",