@exaudeus/memory-mcp 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,10 +27,14 @@ export declare class ConfigManager {
27
27
  private stores;
28
28
  private lobeHealth;
29
29
  private configMtime;
30
+ /** Cached alwaysInclude lobe names — recomputed atomically on reload. */
31
+ private cachedAlwaysIncludeLobes;
30
32
  protected statFile(path: string): Promise<{
31
33
  mtimeMs: number;
32
34
  }>;
33
35
  constructor(configPath: string, initial: LoadedConfig, initialStores: Map<string, MarkdownMemoryStore>, initialHealth: Map<string, LobeHealth>);
36
+ /** Derive alwaysInclude lobe names from config — pure, no side effects. */
37
+ private static computeAlwaysIncludeLobes;
34
38
  /**
35
39
  * Ensure config is fresh. Call at the start of every tool handler.
36
40
  * Stats config file, reloads if mtime changed. Graceful on all errors.
@@ -46,4 +50,6 @@ export declare class ConfigManager {
46
50
  getLobeHealth(lobe: string): LobeHealth | undefined;
47
51
  getConfigOrigin(): ConfigOrigin;
48
52
  getLobeConfig(lobe: string): MemoryConfig | undefined;
53
+ /** Returns lobe names where alwaysInclude is true. Cached; rebuilt atomically on hot-reload. */
54
+ getAlwaysIncludeLobes(): readonly string[];
49
55
  }
@@ -28,6 +28,13 @@ export class ConfigManager {
28
28
  this.stores = initialStores;
29
29
  this.lobeHealth = initialHealth;
30
30
  this.configMtime = Date.now(); // Initial mtime (will be updated on first stat)
31
+ this.cachedAlwaysIncludeLobes = ConfigManager.computeAlwaysIncludeLobes(this.lobeConfigs);
32
+ }
33
+ /** Derive alwaysInclude lobe names from config — pure, no side effects. */
34
+ static computeAlwaysIncludeLobes(configs) {
35
+ return Array.from(configs.entries())
36
+ .filter(([, config]) => config.alwaysInclude === true)
37
+ .map(([name]) => name);
31
38
  }
32
39
  /**
33
40
  * Ensure config is fresh. Call at the start of every tool handler.
@@ -90,12 +97,13 @@ export class ConfigManager {
90
97
  });
91
98
  }
92
99
  }
93
- // Atomic swap
100
+ // Atomic swap — all derived state recomputed together
94
101
  this.configOrigin = newConfig.origin;
95
102
  this.lobeConfigs = newConfig.configs;
96
103
  this.stores = newStores;
97
104
  this.lobeHealth = newHealth;
98
105
  this.configMtime = newMtime;
106
+ this.cachedAlwaysIncludeLobes = ConfigManager.computeAlwaysIncludeLobes(newConfig.configs);
99
107
  const lobeCount = newConfig.configs.size;
100
108
  const degradedCount = Array.from(newHealth.values()).filter(h => h.status === 'degraded').length;
101
109
  const timestamp = new Date().toISOString();
@@ -123,4 +131,8 @@ export class ConfigManager {
123
131
  getLobeConfig(lobe) {
124
132
  return this.lobeConfigs.get(lobe);
125
133
  }
134
+ /** Returns lobe names where alwaysInclude is true. Cached; rebuilt atomically on hot-reload. */
135
+ getAlwaysIncludeLobes() {
136
+ return this.cachedAlwaysIncludeLobes;
137
+ }
126
138
  }
package/dist/config.js CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Priority: memory-config.json → env vars → single-repo default
4
4
  // Graceful degradation: each source falls through to the next on failure.
5
- import { readFileSync } from 'fs';
5
+ import { readFileSync, existsSync, readdirSync } from 'fs';
6
6
  import { execFileSync } from 'child_process';
7
7
  import path from 'path';
8
8
  import os from 'os';
@@ -81,6 +81,42 @@ function resolveMemoryPath(repoRoot, workspaceName, explicitMemoryDir) {
81
81
  }
82
82
  return path.join(os.homedir(), '.memory-mcp', workspaceName);
83
83
  }
84
+ /** If no lobe has alwaysInclude: true AND the legacy global store directory has actual entries,
85
+ * auto-create a "global" lobe pointing to it. Protects existing users who haven't updated their config.
86
+ * Only fires when the dir contains .md files — an empty dir doesn't trigger creation. */
87
+ function ensureAlwaysIncludeLobe(configs, behavior) {
88
+ const hasAlwaysInclude = Array.from(configs.values()).some(c => c.alwaysInclude);
89
+ if (hasAlwaysInclude)
90
+ return;
91
+ // Don't overwrite a user-defined "global" lobe — warn instead.
92
+ // Philosophy: "Make illegal states unrepresentable" — silently replacing config is a hidden state.
93
+ if (configs.has('global')) {
94
+ process.stderr.write(`[memory-mcp] Lobe "global" exists but has no alwaysInclude flag. ` +
95
+ `Add "alwaysInclude": true to your global lobe config to include it in all reads.\n`);
96
+ return;
97
+ }
98
+ const globalPath = path.join(os.homedir(), '.memory-mcp', 'global');
99
+ if (!existsSync(globalPath))
100
+ return;
101
+ // Only auto-create if the dir has actual memory entries (not just an empty directory)
102
+ try {
103
+ const files = readdirSync(globalPath);
104
+ if (!files.some(f => f.endsWith('.md')))
105
+ return;
106
+ }
107
+ catch (err) {
108
+ process.stderr.write(`[memory-mcp] Warning: could not read legacy global store at ${globalPath}: ${err}\n`);
109
+ return;
110
+ }
111
+ configs.set('global', {
112
+ repoRoot: os.homedir(),
113
+ memoryPath: globalPath,
114
+ storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES,
115
+ alwaysInclude: true,
116
+ behavior,
117
+ });
118
+ process.stderr.write(`[memory-mcp] Auto-created "global" lobe (alwaysInclude) from existing ${globalPath}\n`);
119
+ }
84
120
  /** Load lobe configs with priority: memory-config.json -> env vars -> single-repo default */
85
121
  export function getLobeConfigs() {
86
122
  const configs = new Map();
@@ -105,13 +141,15 @@ export function getLobeConfigs() {
105
141
  repoRoot,
106
142
  memoryPath: resolveMemoryPath(repoRoot, name, config.memoryDir),
107
143
  storageBudgetBytes: (config.budgetMB ?? 2) * 1024 * 1024,
144
+ alwaysInclude: config.alwaysInclude ?? false,
108
145
  behavior,
109
146
  });
110
147
  }
111
148
  if (configs.size > 0) {
149
+ // Reuse the already-parsed behavior config for the alwaysInclude fallback
150
+ const resolvedBehavior = external.behavior ? behavior : undefined;
151
+ ensureAlwaysIncludeLobe(configs, resolvedBehavior);
112
152
  process.stderr.write(`[memory-mcp] Loaded ${configs.size} lobe(s) from memory-config.json\n`);
113
- // Pass resolved behavior at the top-level so diagnostics can surface active values
114
- const resolvedBehavior = external.behavior ? parseBehaviorConfig(external.behavior) : undefined;
115
153
  return { configs, origin: { source: 'file', path: configPath }, behavior: resolvedBehavior };
116
154
  }
117
155
  }
@@ -137,9 +175,11 @@ export function getLobeConfigs() {
137
175
  repoRoot,
138
176
  memoryPath: resolveMemoryPath(repoRoot, name, explicitDir),
139
177
  storageBudgetBytes: storageBudget,
178
+ alwaysInclude: false,
140
179
  });
141
180
  }
142
181
  if (configs.size > 0) {
182
+ ensureAlwaysIncludeLobe(configs);
143
183
  process.stderr.write(`[memory-mcp] Loaded ${configs.size} lobe(s) from MEMORY_MCP_WORKSPACES env var\n`);
144
184
  return { configs, origin: { source: 'env' } };
145
185
  }
@@ -156,7 +196,9 @@ export function getLobeConfigs() {
156
196
  repoRoot,
157
197
  memoryPath: resolveMemoryPath(repoRoot, 'default', explicitDir),
158
198
  storageBudgetBytes: storageBudget,
199
+ alwaysInclude: false,
159
200
  });
201
+ // No ensureAlwaysIncludeLobe here — single-repo default users have everything in one lobe
160
202
  process.stderr.write(`[memory-mcp] Using single-lobe default mode (cwd: ${repoRoot})\n`);
161
203
  return { configs, origin: { source: 'default' } };
162
204
  }
@@ -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 */