@exaudeus/memory-mcp 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { MemoryConfig, BehaviorConfig } from './types.js';
1
+ import type { MemoryConfig, BehaviorConfig, EmbedderConfig } from './types.js';
2
+ import type { Embedder } from './embedder.js';
2
3
  /** How the config was loaded — discriminated union so configFilePath
3
4
  * only exists when source is 'file' (illegal states unrepresentable) */
4
5
  export type ConfigOrigin = {
@@ -15,6 +16,8 @@ export interface LoadedConfig {
15
16
  readonly origin: ConfigOrigin;
16
17
  /** Resolved behavior config — present when a "behavior" block was found in memory-config.json */
17
18
  readonly behavior?: BehaviorConfig;
19
+ /** Resolved embedder — shared across all lobes. Constructed from config or auto-detected. */
20
+ readonly embedder?: Embedder;
18
21
  }
19
22
  interface MemoryConfigFileBehavior {
20
23
  staleDaysStandard?: number;
@@ -23,10 +26,26 @@ interface MemoryConfigFileBehavior {
23
26
  maxDedupSuggestions?: number;
24
27
  maxConflictPairs?: number;
25
28
  }
29
+ interface MemoryConfigFileEmbedder {
30
+ provider?: string;
31
+ model?: string;
32
+ baseUrl?: string;
33
+ timeoutMs?: number;
34
+ dimensions?: number;
35
+ }
26
36
  /** Parse and validate a behavior config block, falling back to defaults for each field.
27
37
  * Warns to stderr for unknown keys (likely typos) and out-of-range values.
28
38
  * Exported for testing — validates and clamps all fields. */
29
39
  export declare function parseBehaviorConfig(raw?: MemoryConfigFileBehavior): BehaviorConfig;
40
+ /** Parse and validate an embedder config block.
41
+ * Returns undefined when block is absent (auto-detect mode).
42
+ * Exported for testing. */
43
+ export declare function parseEmbedderConfig(raw?: MemoryConfigFileEmbedder): EmbedderConfig | undefined;
44
+ /** Create an Embedder from config.
45
+ * - provider "none" → null (keyword-only)
46
+ * - provider "ollama" → LazyEmbedder wrapping OllamaEmbedder with config params
47
+ * - No config (auto-detect) → LazyEmbedder wrapping default OllamaEmbedder */
48
+ export declare function createEmbedderFromConfig(config?: EmbedderConfig): Embedder | undefined;
30
49
  /** Load lobe configs with priority: memory-config.json -> env vars -> single-repo default */
31
50
  export declare function getLobeConfigs(): LoadedConfig;
32
51
  export {};
package/dist/config.js CHANGED
@@ -7,6 +7,7 @@ import { execFileSync } from 'child_process';
7
7
  import path from 'path';
8
8
  import os from 'os';
9
9
  import { DEFAULT_STORAGE_BUDGET_BYTES } from './types.js';
10
+ import { OllamaEmbedder, LazyEmbedder } from './embedder.js';
10
11
  import { DEFAULT_STALE_DAYS_STANDARD, DEFAULT_STALE_DAYS_PREFERENCES, DEFAULT_MAX_STALE_IN_BRIEFING, DEFAULT_MAX_DEDUP_SUGGESTIONS, DEFAULT_MAX_CONFLICT_PAIRS, } from './thresholds.js';
11
12
  /** Validate and clamp a numeric threshold to a given range.
12
13
  * Returns the default if the value is missing, NaN, or out of range. */
@@ -46,6 +47,61 @@ export function parseBehaviorConfig(raw) {
46
47
  maxConflictPairs: clampThreshold(raw.maxConflictPairs, DEFAULT_MAX_CONFLICT_PAIRS, 1, 5),
47
48
  };
48
49
  }
50
+ /** Known embedder config keys — used to warn on typos/unknown fields. */
51
+ const KNOWN_EMBEDDER_KEYS = new Set([
52
+ 'provider', 'model', 'baseUrl', 'timeoutMs', 'dimensions',
53
+ ]);
54
+ const VALID_PROVIDERS = new Set(['ollama', 'none']);
55
+ /** Parse and validate an embedder config block.
56
+ * Returns undefined when block is absent (auto-detect mode).
57
+ * Exported for testing. */
58
+ export function parseEmbedderConfig(raw) {
59
+ if (!raw)
60
+ return undefined;
61
+ // Warn on unrecognized keys
62
+ for (const key of Object.keys(raw)) {
63
+ if (!KNOWN_EMBEDDER_KEYS.has(key)) {
64
+ process.stderr.write(`[memory-mcp] Unknown embedder config key "${key}" — ignored. ` +
65
+ `Valid keys: ${Array.from(KNOWN_EMBEDDER_KEYS).join(', ')}\n`);
66
+ }
67
+ }
68
+ // Validate provider — default to 'ollama' if present but not set
69
+ const provider = raw.provider && VALID_PROVIDERS.has(raw.provider)
70
+ ? raw.provider
71
+ : 'ollama';
72
+ if (raw.provider && !VALID_PROVIDERS.has(raw.provider)) {
73
+ process.stderr.write(`[memory-mcp] Unknown embedder provider "${raw.provider}" — using "ollama". Valid: ${Array.from(VALID_PROVIDERS).join(', ')}\n`);
74
+ }
75
+ return {
76
+ provider,
77
+ model: raw.model,
78
+ baseUrl: raw.baseUrl,
79
+ timeoutMs: raw.timeoutMs !== undefined
80
+ ? clampThreshold(raw.timeoutMs, 5000, 500, 30000)
81
+ : undefined,
82
+ dimensions: raw.dimensions !== undefined
83
+ ? clampThreshold(raw.dimensions, 384, 64, 4096)
84
+ : undefined,
85
+ };
86
+ }
87
+ /** Create an Embedder from config.
88
+ * - provider "none" → null (keyword-only)
89
+ * - provider "ollama" → LazyEmbedder wrapping OllamaEmbedder with config params
90
+ * - No config (auto-detect) → LazyEmbedder wrapping default OllamaEmbedder */
91
+ export function createEmbedderFromConfig(config) {
92
+ // Explicit opt-out
93
+ if (config?.provider === 'none')
94
+ return undefined;
95
+ // Explicit or default Ollama config
96
+ const candidate = new OllamaEmbedder({
97
+ model: config?.model,
98
+ baseUrl: config?.baseUrl,
99
+ timeoutMs: config?.timeoutMs,
100
+ dimensions: config?.dimensions,
101
+ });
102
+ // Both explicit "ollama" and auto-detect use LazyEmbedder for fast startup
103
+ return new LazyEmbedder(candidate);
104
+ }
49
105
  function resolveRoot(root) {
50
106
  return root
51
107
  .replace(/^\$HOME\b/, process.env.HOME ?? '')
@@ -84,7 +140,7 @@ function resolveMemoryPath(repoRoot, workspaceName, explicitMemoryDir) {
84
140
  /** If no lobe has alwaysInclude: true AND the legacy global store directory has actual entries,
85
141
  * auto-create a "global" lobe pointing to it. Protects existing users who haven't updated their config.
86
142
  * Only fires when the dir contains .md files — an empty dir doesn't trigger creation. */
87
- function ensureAlwaysIncludeLobe(configs, behavior) {
143
+ function ensureAlwaysIncludeLobe(configs, behavior, embedder) {
88
144
  const hasAlwaysInclude = Array.from(configs.values()).some(c => c.alwaysInclude);
89
145
  if (hasAlwaysInclude)
90
146
  return;
@@ -114,6 +170,7 @@ function ensureAlwaysIncludeLobe(configs, behavior) {
114
170
  storageBudgetBytes: DEFAULT_STORAGE_BUDGET_BYTES,
115
171
  alwaysInclude: true,
116
172
  behavior,
173
+ embedder,
117
174
  });
118
175
  process.stderr.write(`[memory-mcp] Auto-created "global" lobe (alwaysInclude) from existing ${globalPath}\n`);
119
176
  }
@@ -131,6 +188,8 @@ export function getLobeConfigs() {
131
188
  else {
132
189
  // Parse global behavior config once — applies to all lobes
133
190
  const behavior = parseBehaviorConfig(external.behavior);
191
+ const embedderConfig = parseEmbedderConfig(external.embedder);
192
+ const embedder = createEmbedderFromConfig(embedderConfig);
134
193
  for (const [name, config] of Object.entries(external.lobes)) {
135
194
  if (!config.root) {
136
195
  process.stderr.write(`[memory-mcp] Skipping lobe "${name}": missing "root" field\n`);
@@ -143,14 +202,15 @@ export function getLobeConfigs() {
143
202
  storageBudgetBytes: (config.budgetMB ?? 2) * 1024 * 1024,
144
203
  alwaysInclude: config.alwaysInclude ?? false,
145
204
  behavior,
205
+ embedder,
146
206
  });
147
207
  }
148
208
  if (configs.size > 0) {
149
209
  // Reuse the already-parsed behavior config for the alwaysInclude fallback
150
210
  const resolvedBehavior = external.behavior ? behavior : undefined;
151
- ensureAlwaysIncludeLobe(configs, resolvedBehavior);
211
+ ensureAlwaysIncludeLobe(configs, resolvedBehavior, embedder);
152
212
  process.stderr.write(`[memory-mcp] Loaded ${configs.size} lobe(s) from memory-config.json\n`);
153
- return { configs, origin: { source: 'file', path: configPath }, behavior: resolvedBehavior };
213
+ return { configs, origin: { source: 'file', path: configPath }, behavior: resolvedBehavior, embedder };
154
214
  }
155
215
  }
156
216
  }
@@ -162,6 +222,8 @@ export function getLobeConfigs() {
162
222
  process.stderr.write(`[memory-mcp] Failed to parse memory-config.json: ${message}\n`);
163
223
  }
164
224
  }
225
+ // Auto-detect embedder for env var and default modes (no config file)
226
+ const autoEmbedder = createEmbedderFromConfig(undefined);
165
227
  // 2. Try env var multi-repo mode
166
228
  const workspacesJson = process.env.MEMORY_MCP_WORKSPACES;
167
229
  if (workspacesJson) {
@@ -176,12 +238,13 @@ export function getLobeConfigs() {
176
238
  memoryPath: resolveMemoryPath(repoRoot, name, explicitDir),
177
239
  storageBudgetBytes: storageBudget,
178
240
  alwaysInclude: false,
241
+ embedder: autoEmbedder,
179
242
  });
180
243
  }
181
244
  if (configs.size > 0) {
182
- ensureAlwaysIncludeLobe(configs);
245
+ ensureAlwaysIncludeLobe(configs, undefined, autoEmbedder);
183
246
  process.stderr.write(`[memory-mcp] Loaded ${configs.size} lobe(s) from MEMORY_MCP_WORKSPACES env var\n`);
184
- return { configs, origin: { source: 'env' } };
247
+ return { configs, origin: { source: 'env' }, embedder: autoEmbedder };
185
248
  }
186
249
  }
187
250
  catch (e) {
@@ -197,8 +260,9 @@ export function getLobeConfigs() {
197
260
  memoryPath: resolveMemoryPath(repoRoot, 'default', explicitDir),
198
261
  storageBudgetBytes: storageBudget,
199
262
  alwaysInclude: false,
263
+ embedder: autoEmbedder,
200
264
  });
201
265
  // No ensureAlwaysIncludeLobe here — single-repo default users have everything in one lobe
202
266
  process.stderr.write(`[memory-mcp] Using single-lobe default mode (cwd: ${repoRoot})\n`);
203
- return { configs, origin: { source: 'default' } };
267
+ return { configs, origin: { source: 'default' }, embedder: autoEmbedder };
204
268
  }
@@ -60,6 +60,28 @@ export declare class FakeEmbedder implements Embedder {
60
60
  constructor(dimensions?: number);
61
61
  embed(text: string, _signal?: AbortSignal): Promise<EmbedResult>;
62
62
  }
63
+ /** Lazy auto-detecting embedder — probes on first use, caches the result.
64
+ * Re-probes on failure after a TTL window so the system recovers if
65
+ * Ollama starts after MCP startup.
66
+ *
67
+ * Implements the same Embedder interface — the store never knows it's lazy.
68
+ * The probe uses the candidate's own timeout (5s for cold starts).
69
+ * The caller's signal is only forwarded to the actual embed call, not the probe. */
70
+ export declare class LazyEmbedder implements Embedder {
71
+ readonly dimensions: number;
72
+ private inner;
73
+ private lastProbeTime;
74
+ private hasLoggedUnavailable;
75
+ private readonly candidate;
76
+ private readonly reprobeIntervalMs;
77
+ private readonly now;
78
+ constructor(candidate: Embedder, opts?: {
79
+ readonly reprobeIntervalMs?: number;
80
+ /** Injectable clock for testing — default Date.now */
81
+ readonly now?: () => number;
82
+ });
83
+ embed(text: string, signal?: AbortSignal): Promise<EmbedResult>;
84
+ }
63
85
  /** Batch embed texts sequentially. Pure composition over Embedder.embed().
64
86
  * Sequential because local Ollama benefits from serialized requests (single GPU/CPU).
65
87
  * Not on the interface — interface segregation. */
package/dist/embedder.js CHANGED
@@ -130,6 +130,56 @@ function trigramHash(trigram, buckets) {
130
130
  }
131
131
  return ((hash % buckets) + buckets) % buckets;
132
132
  }
133
+ // ─── LazyEmbedder ─────────────────────────────────────────────────────────
134
+ /** Default reprobe interval — how long to wait before retrying after a failed probe.
135
+ * 2 minutes balances responsiveness (recovery after Ollama starts) with
136
+ * avoiding excessive probes when Ollama isn't installed. */
137
+ const DEFAULT_REPROBE_INTERVAL_MS = 2 * 60 * 1000;
138
+ /** Lazy auto-detecting embedder — probes on first use, caches the result.
139
+ * Re-probes on failure after a TTL window so the system recovers if
140
+ * Ollama starts after MCP startup.
141
+ *
142
+ * Implements the same Embedder interface — the store never knows it's lazy.
143
+ * The probe uses the candidate's own timeout (5s for cold starts).
144
+ * The caller's signal is only forwarded to the actual embed call, not the probe. */
145
+ export class LazyEmbedder {
146
+ constructor(candidate, opts) {
147
+ this.inner = null;
148
+ this.lastProbeTime = -Infinity;
149
+ this.hasLoggedUnavailable = false;
150
+ this.candidate = candidate;
151
+ this.dimensions = candidate.dimensions;
152
+ this.reprobeIntervalMs = opts?.reprobeIntervalMs ?? DEFAULT_REPROBE_INTERVAL_MS;
153
+ this.now = opts?.now ?? Date.now;
154
+ }
155
+ async embed(text, signal) {
156
+ const now = this.now();
157
+ const shouldProbe = !this.inner && (now - this.lastProbeTime >= this.reprobeIntervalMs);
158
+ if (shouldProbe) {
159
+ this.lastProbeTime = now;
160
+ // Probe without caller's signal — use candidate's default timeout (5s)
161
+ // so cold model loads aren't aborted by a tight query-time timeout
162
+ const probe = await this.candidate.embed('probe');
163
+ if (probe.ok) {
164
+ this.inner = this.candidate;
165
+ if (this.hasLoggedUnavailable) {
166
+ // Recovery after previous failure — notify
167
+ process.stderr.write('[memory-mcp] Embedding provider recovered — semantic search active\n');
168
+ this.hasLoggedUnavailable = false;
169
+ }
170
+ }
171
+ else if (!this.hasLoggedUnavailable) {
172
+ // Only log first failure — avoid noisy repeated warnings
173
+ process.stderr.write(`[memory-mcp] Embedding provider not available — using keyword-only search (will retry in ${Math.round(this.reprobeIntervalMs / 1000)}s)\n`);
174
+ this.hasLoggedUnavailable = true;
175
+ }
176
+ }
177
+ if (!this.inner) {
178
+ return { ok: false, failure: { kind: 'provider-unavailable', reason: 'auto-detect: provider not available' } };
179
+ }
180
+ return this.inner.embed(text, signal);
181
+ }
182
+ }
133
183
  // ─── Batch utility ────────────────────────────────────────────────────────
134
184
  /** Batch embed texts sequentially. Pure composition over Embedder.embed().
135
185
  * Sequential because local Ollama benefits from serialized requests (single GPU/CPU).
@@ -1,6 +1,15 @@
1
- import type { MemoryStats, StaleEntry, ConflictPair, BehaviorConfig } from './types.js';
1
+ import type { MemoryStats, StaleEntry, ConflictPair, BehaviorConfig, RelatedEntry } from './types.js';
2
2
  import { type FilterGroup } from './text-analyzer.js';
3
3
  import type { MarkdownMemoryStore } from './store.js';
4
+ /** Format the search mode indicator for context/recall responses.
5
+ * Pure function — no I/O, no state.
6
+ *
7
+ * Shows whether semantic search is active and vector coverage. */
8
+ export declare function formatSearchMode(embedderAvailable: boolean, vectorCount: number, totalCount: number): string;
9
+ /** Format the loot-drop section — related entries shown after a storage operation.
10
+ * Transforms storage from "chore for the future" into "immediate value exchange."
11
+ * Pure function. */
12
+ export declare function formatLootDrop(related: readonly RelatedEntry[]): string;
4
13
  /** Format the stale entries section for briefing/context responses */
5
14
  export declare function formatStaleSection(staleDetails: readonly StaleEntry[]): string;
6
15
  /** Format the conflict detection warning for query/context responses */
@@ -4,6 +4,31 @@
4
4
  // and returns a formatted string for the tool response.
5
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
+ /** Format the search mode indicator for context/recall responses.
8
+ * Pure function — no I/O, no state.
9
+ *
10
+ * Shows whether semantic search is active and vector coverage. */
11
+ export function formatSearchMode(embedderAvailable, vectorCount, totalCount) {
12
+ if (!embedderAvailable) {
13
+ return '*Search: keyword-only (install Ollama for semantic search)*';
14
+ }
15
+ if (vectorCount === 0 && totalCount > 0) {
16
+ return `*Search: semantic + keyword (0/${totalCount} entries vectorized — run memory_reembed)*`;
17
+ }
18
+ if (totalCount === 0) {
19
+ return '*Search: semantic + keyword (no entries yet)*';
20
+ }
21
+ return `*Search: semantic + keyword (${vectorCount}/${totalCount} entries vectorized)*`;
22
+ }
23
+ /** Format the loot-drop section — related entries shown after a storage operation.
24
+ * Transforms storage from "chore for the future" into "immediate value exchange."
25
+ * Pure function. */
26
+ export function formatLootDrop(related) {
27
+ if (related.length === 0)
28
+ return '';
29
+ const lines = related.map(r => `- [${r.id}] "${r.title}" (confidence: ${r.confidence.toFixed(2)})`);
30
+ return `\n**Related knowledge:**\n${lines.join('\n')}`;
31
+ }
7
32
  /** Format the stale entries section for briefing/context responses */
8
33
  export function formatStaleSection(staleDetails) {
9
34
  const lines = [
@@ -61,11 +86,12 @@ export function formatStats(lobe, result) {
61
86
  .join('\n')
62
87
  : ' (none)';
63
88
  const corruptLine = result.corruptFiles > 0 ? `\n**Corrupt files:** ${result.corruptFiles}` : '';
89
+ const vectorLine = `\n**Vectors:** ${result.vectorCount}/${result.totalEntries} entries vectorized`;
64
90
  return [
65
91
  `## [${lobe}] Memory Stats`,
66
92
  ``,
67
93
  `**Memory location:** ${result.memoryPath}`,
68
- `**Total entries:** ${result.totalEntries}${corruptLine}`,
94
+ `**Total entries:** ${result.totalEntries}${corruptLine}${vectorLine}`,
69
95
  `**Storage:** ${result.storageSize} / ${Math.round(result.storageBudgetBytes / 1024 / 1024)}MB budget`,
70
96
  ``,
71
97
  `### By Topic`,