@exaudeus/memory-mcp 1.7.0 → 1.8.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,11 @@
1
1
  import type { MemoryStats, StaleEntry, ConflictPair, BehaviorConfig } 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;
4
9
  /** Format the stale entries section for briefing/context responses */
5
10
  export declare function formatStaleSection(staleDetails: readonly StaleEntry[]): string;
6
11
  /** Format the conflict detection warning for query/context responses */
@@ -4,6 +4,22 @@
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
+ }
7
23
  /** Format the stale entries section for briefing/context responses */
8
24
  export function formatStaleSection(staleDetails) {
9
25
  const lines = [
@@ -61,11 +77,12 @@ export function formatStats(lobe, result) {
61
77
  .join('\n')
62
78
  : ' (none)';
63
79
  const corruptLine = result.corruptFiles > 0 ? `\n**Corrupt files:** ${result.corruptFiles}` : '';
80
+ const vectorLine = `\n**Vectors:** ${result.vectorCount}/${result.totalEntries} entries vectorized`;
64
81
  return [
65
82
  `## [${lobe}] Memory Stats`,
66
83
  ``,
67
84
  `**Memory location:** ${result.memoryPath}`,
68
- `**Total entries:** ${result.totalEntries}${corruptLine}`,
85
+ `**Total entries:** ${result.totalEntries}${corruptLine}${vectorLine}`,
69
86
  `**Storage:** ${result.storageSize} / ${Math.round(result.storageBudgetBytes / 1024 / 1024)}MB budget`,
70
87
  ``,
71
88
  `### By Topic`,
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, buildBriefingTagPrimerSections } from './formatters.js';
17
+ import { formatStaleSection, formatConflictWarning, formatStats, formatBehaviorConfigSection, mergeTagFrequencies, buildQueryFooter, buildBriefingTagPrimerSections, formatSearchMode } 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';
@@ -398,6 +398,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
398
398
  // memory_diagnose is intentionally hidden from the tool list — it clutters
399
399
  // agent tool discovery and should only be called when directed by error messages
400
400
  // or crash reports. The handler still works if called directly.
401
+ // memory_reembed is hidden — utility for generating/regenerating embeddings.
402
+ // Surfaced via hint in memory_context when >50% of entries lack vectors.
401
403
  ] };
402
404
  });
403
405
  // --- Tool handlers ---
@@ -974,10 +976,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
974
976
  const noResultHint = ctxGlobalOnlyHint
975
977
  ? `\n\n> ${ctxGlobalOnlyHint}`
976
978
  : '\n\nThis is fine — proceed without prior context. As you learn things worth remembering, store them with memory_store.';
979
+ // Mode indicator on no-results path — helps diagnose why nothing was found
980
+ const modeHint = primaryStore
981
+ ? `\n${formatSearchMode(primaryStore.hasEmbedder, primaryStore.vectorCount, primaryStore.entryCount)}`
982
+ : '';
977
983
  return {
978
984
  content: [{
979
985
  type: 'text',
980
- text: `[${label}] No relevant knowledge found for: "${context}"${noResultHint}\n\n---\n${ctxFooter}`,
986
+ text: `[${label}] No relevant knowledge found for: "${context}"${noResultHint}${modeHint}\n\n---\n${ctxFooter}`,
981
987
  }],
982
988
  };
983
989
  }
@@ -1020,6 +1026,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1020
1026
  sections.push(formatConflictWarning(ctxConflicts));
1021
1027
  }
1022
1028
  }
1029
+ // Search mode indicator — lightweight getters, no extra disk reload
1030
+ if (primaryStore) {
1031
+ sections.push(formatSearchMode(primaryStore.hasEmbedder, primaryStore.vectorCount, primaryStore.entryCount));
1032
+ }
1023
1033
  // Collect all matched keywords and topics for the dedup hint
1024
1034
  const allMatchedKeywords = new Set();
1025
1035
  const matchedTopics = new Set();
@@ -1071,6 +1081,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1071
1081
  }
1072
1082
  return { content: [{ type: 'text', text: sections.join('\n\n---\n\n') }] };
1073
1083
  }
1084
+ case 'memory_reembed': {
1085
+ const { lobe: rawLobe } = z.object({
1086
+ lobe: z.string().optional(),
1087
+ }).parse(args ?? {});
1088
+ const lobeName = rawLobe ?? lobeNames[0];
1089
+ const ctx = resolveToolContext(lobeName);
1090
+ if (!ctx.ok)
1091
+ return contextError(ctx);
1092
+ const result = await ctx.store.reEmbed();
1093
+ if (result.error) {
1094
+ return { content: [{ type: 'text', text: `[${ctx.label}] Re-embed failed: ${result.error}` }] };
1095
+ }
1096
+ const parts = [
1097
+ `[${ctx.label}] Re-embedded ${result.embedded} entries`,
1098
+ `(${result.skipped} skipped, ${result.failed} failed).`,
1099
+ ];
1100
+ // Hint if many entries were vectorized
1101
+ if (result.embedded > 0) {
1102
+ parts.push('\nSemantic search is now active for these entries.');
1103
+ }
1104
+ return { content: [{ type: 'text', text: parts.join(' ') }] };
1105
+ }
1074
1106
  case 'memory_bootstrap': {
1075
1107
  const { lobe: rawLobe, root, budgetMB } = z.object({
1076
1108
  lobe: z.string().optional(),
package/dist/store.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { MemoryEntry, TopicScope, TrustLevel, DetailLevel, DurabilityDecision, QueryResult, StoreResult, CorrectResult, MemoryStats, BriefingResult, ConflictPair, MemoryConfig } from './types.js';
1
+ import type { MemoryEntry, TopicScope, TrustLevel, DetailLevel, DurabilityDecision, QueryResult, StoreResult, CorrectResult, MemoryStats, ReEmbedResult, BriefingResult, ConflictPair, MemoryConfig } from './types.js';
2
2
  import type { ScoredEntry } from './ranking.js';
3
3
  export declare class MarkdownMemoryStore {
4
4
  private readonly config;
@@ -13,6 +13,14 @@ export declare class MarkdownMemoryStore {
13
13
  /** Resolved behavior thresholds — user config merged over defaults.
14
14
  * Centralizes threshold resolution so every caller gets the same value. */
15
15
  private get behavior();
16
+ /** Whether an embedder is configured — for mode indicator display. Read-only. */
17
+ get hasEmbedder(): boolean;
18
+ /** Count of vectorized entries — lightweight, no disk reload.
19
+ * For mode indicator display. Use stats() for full diagnostics. */
20
+ get vectorCount(): number;
21
+ /** Count of total entries — lightweight, no disk reload.
22
+ * For mode indicator display. Use stats() for full diagnostics. */
23
+ get entryCount(): number;
16
24
  /** Initialize the store: create memory dir and load existing entries */
17
25
  init(): Promise<void>;
18
26
  /** Store a new knowledge entry */
@@ -26,6 +34,11 @@ export declare class MarkdownMemoryStore {
26
34
  hasEntry(id: string): Promise<boolean>;
27
35
  /** Correct an existing entry */
28
36
  correct(id: string, correction: string, action: 'append' | 'replace' | 'delete'): Promise<CorrectResult>;
37
+ /** Re-embed all entries that don't have vectors.
38
+ * Idempotent: entries already in the vectors map are skipped.
39
+ * Early-exit: if the first embed fails, returns immediately (avoids burning through
40
+ * all entries just to discover the embedder is unavailable). */
41
+ reEmbed(): Promise<ReEmbedResult>;
29
42
  /** Get memory health statistics */
30
43
  stats(): Promise<MemoryStats>;
31
44
  /** Bootstrap: scan repo structure and seed initial knowledge */
package/dist/store.js CHANGED
@@ -7,7 +7,7 @@ import crypto from 'crypto';
7
7
  import { execFile } from 'child_process';
8
8
  import { promisify } from 'util';
9
9
  import { DEFAULT_CONFIDENCE, realClock, parseTopicScope, parseTrustLevel, parseTags, asEmbeddingVector } from './types.js';
10
- import { DEDUP_SIMILARITY_THRESHOLD, DEDUP_SEMANTIC_THRESHOLD, SEMANTIC_MIN_SIMILARITY, CONFLICT_SIMILARITY_THRESHOLD_SAME_TOPIC, CONFLICT_SIMILARITY_THRESHOLD_CROSS_TOPIC, CONFLICT_MIN_CONTENT_CHARS, OPPOSITION_PAIRS, PREFERENCE_SURFACE_THRESHOLD, 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, } from './thresholds.js';
10
+ import { DEDUP_SIMILARITY_THRESHOLD, DEDUP_SEMANTIC_THRESHOLD, SEMANTIC_MIN_SIMILARITY, CONFLICT_SIMILARITY_THRESHOLD_SAME_TOPIC, CONFLICT_SIMILARITY_THRESHOLD_CROSS_TOPIC, CONFLICT_MIN_CONTENT_CHARS, OPPOSITION_PAIRS, PREFERENCE_SURFACE_THRESHOLD, TOPIC_BOOST, MODULE_TOPIC_BOOST, USER_ALWAYS_INCLUDE_SCORE_FRACTION, QUERY_EMBED_TIMEOUT_MS, 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, } from './thresholds.js';
11
11
  import { realGitService } from './git-service.js';
12
12
  import { extractKeywords, similarity, cosineSimilarity, matchesFilter, computeRelevanceScore, } from './text-analyzer.js';
13
13
  import { keywordRank, semanticRank, mergeRankings } from './ranking.js';
@@ -38,6 +38,20 @@ export class MarkdownMemoryStore {
38
38
  maxConflictPairs: b.maxConflictPairs ?? DEFAULT_MAX_CONFLICT_PAIRS,
39
39
  };
40
40
  }
41
+ /** Whether an embedder is configured — for mode indicator display. Read-only. */
42
+ get hasEmbedder() {
43
+ return this.embedder !== null;
44
+ }
45
+ /** Count of vectorized entries — lightweight, no disk reload.
46
+ * For mode indicator display. Use stats() for full diagnostics. */
47
+ get vectorCount() {
48
+ return this.vectors.size;
49
+ }
50
+ /** Count of total entries — lightweight, no disk reload.
51
+ * For mode indicator display. Use stats() for full diagnostics. */
52
+ get entryCount() {
53
+ return this.entries.size;
54
+ }
41
55
  /** Initialize the store: create memory dir and load existing entries */
42
56
  async init() {
43
57
  await fs.mkdir(this.memoryPath, { recursive: true });
@@ -336,6 +350,44 @@ export class MarkdownMemoryStore {
336
350
  }
337
351
  return { corrected: true, id, action, newConfidence: 1.0, trust: 'user' };
338
352
  }
353
+ /** Re-embed all entries that don't have vectors.
354
+ * Idempotent: entries already in the vectors map are skipped.
355
+ * Early-exit: if the first embed fails, returns immediately (avoids burning through
356
+ * all entries just to discover the embedder is unavailable). */
357
+ async reEmbed() {
358
+ if (!this.embedder) {
359
+ return { embedded: 0, skipped: 0, failed: 0, error: 'No embedder configured' };
360
+ }
361
+ await this.reloadFromDisk();
362
+ // Probe: try embedding a short text to check availability before iterating
363
+ const probe = await this.embedder.embed('probe');
364
+ if (!probe.ok) {
365
+ return { embedded: 0, skipped: 0, failed: 0, error: `Embedder unavailable: ${probe.failure.kind}` };
366
+ }
367
+ let embedded = 0;
368
+ let skipped = 0;
369
+ let failed = 0;
370
+ for (const entry of this.entries.values()) {
371
+ // Entry already has a vector with correct dimensions — skip
372
+ if (this.vectors.has(entry.id)) {
373
+ skipped++;
374
+ continue;
375
+ }
376
+ // Embed the entry
377
+ const embedText = `${entry.title}\n\n${entry.content}`;
378
+ const result = await this.embedder.embed(embedText);
379
+ if (result.ok) {
380
+ const file = this.entryToRelativePath(entry);
381
+ await this.persistVector(file, result.vector);
382
+ this.vectors.set(entry.id, result.vector);
383
+ embedded++;
384
+ }
385
+ else {
386
+ failed++;
387
+ }
388
+ }
389
+ return { embedded, skipped, failed };
390
+ }
339
391
  /** Get memory health statistics */
340
392
  async stats() {
341
393
  await this.reloadFromDisk();
@@ -368,6 +420,7 @@ export class MarkdownMemoryStore {
368
420
  return {
369
421
  totalEntries: allEntries.length,
370
422
  corruptFiles: this.corruptFileCount,
423
+ vectorCount: this.vectors.size,
371
424
  byTopic, byTrust, byFreshness, byTag,
372
425
  storageSize: this.formatBytes(storageSize ?? 0),
373
426
  storageBudgetBytes: this.config.storageBudgetBytes,
@@ -483,7 +536,8 @@ export class MarkdownMemoryStore {
483
536
  const debug = process.env.MEMORY_MCP_DEBUG === '1';
484
537
  let semanticResults = [];
485
538
  if (this.embedder) {
486
- const queryResult = await this.embedder.embed(context);
539
+ const querySignal = AbortSignal.timeout(QUERY_EMBED_TIMEOUT_MS);
540
+ const queryResult = await this.embedder.embed(context, querySignal);
487
541
  if (queryResult.ok) {
488
542
  // In debug mode, get ALL scores (threshold=0) for calibration logging
489
543
  const rawSemanticResults = semanticRank(allEntries, this.vectors, queryResult.vector, debug ? 0 : SEMANTIC_MIN_SIMILARITY, ctx);
@@ -30,6 +30,10 @@ export declare const SEMANTIC_MIN_SIMILARITY = 0.45;
30
30
  * disruptive than missing real ones. Two entries must be quite similar to be
31
31
  * flagged as potential duplicates. */
32
32
  export declare const DEDUP_SEMANTIC_THRESHOLD = 0.8;
33
+ /** Query-time embed timeout — tighter than store-time (5s) for responsiveness.
34
+ * Model-warm latency is ~10ms; 2s covers machine-under-load with margin.
35
+ * Cold starts handled by LazyEmbedder's probe (which uses the full 5s). */
36
+ export declare const QUERY_EMBED_TIMEOUT_MS = 2000;
33
37
  /** Score multiplier when a reference path basename matches the context keywords. */
34
38
  export declare const REFERENCE_BOOST_MULTIPLIER = 1.3;
35
39
  /** Per-topic scoring boost factors for contextSearch().
@@ -56,6 +56,10 @@ export const SEMANTIC_MIN_SIMILARITY = 0.45;
56
56
  * disruptive than missing real ones. Two entries must be quite similar to be
57
57
  * flagged as potential duplicates. */
58
58
  export const DEDUP_SEMANTIC_THRESHOLD = 0.80;
59
+ /** Query-time embed timeout — tighter than store-time (5s) for responsiveness.
60
+ * Model-warm latency is ~10ms; 2s covers machine-under-load with margin.
61
+ * Cold starts handled by LazyEmbedder's probe (which uses the full 5s). */
62
+ export const QUERY_EMBED_TIMEOUT_MS = 2000;
59
63
  /** Score multiplier when a reference path basename matches the context keywords. */
60
64
  export const REFERENCE_BOOST_MULTIPLIER = 1.30;
61
65
  /** Per-topic scoring boost factors for contextSearch().
package/dist/types.d.ts CHANGED
@@ -156,6 +156,7 @@ export type CorrectResult = {
156
156
  export interface MemoryStats {
157
157
  readonly totalEntries: number;
158
158
  readonly corruptFiles: number;
159
+ readonly vectorCount: number;
159
160
  readonly byTopic: Record<string, number>;
160
161
  readonly byTrust: Record<TrustLevel, number>;
161
162
  readonly byFreshness: {
@@ -222,6 +223,24 @@ export interface BehaviorConfig {
222
223
  /** Maximum conflict pairs shown per query/context response. Default: 2. Range: 1–5. */
223
224
  readonly maxConflictPairs?: number;
224
225
  }
226
+ /** Supported embedding providers — closed union for exhaustive handling. */
227
+ export type EmbedderProvider = 'ollama' | 'none';
228
+ /** Embedding configuration from memory-config.json "embedder" block.
229
+ * All fields optional except provider — defaults are sensible for nomic-embed-text on localhost. */
230
+ export interface EmbedderConfig {
231
+ readonly provider: EmbedderProvider;
232
+ readonly model?: string;
233
+ readonly baseUrl?: string;
234
+ readonly timeoutMs?: number;
235
+ readonly dimensions?: number;
236
+ }
237
+ /** Result of a re-embed operation */
238
+ export interface ReEmbedResult {
239
+ readonly embedded: number;
240
+ readonly skipped: number;
241
+ readonly failed: number;
242
+ readonly error?: string;
243
+ }
225
244
  /** Configuration for the memory MCP */
226
245
  export interface MemoryConfig {
227
246
  readonly repoRoot: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exaudeus/memory-mcp",
3
- "version": "1.7.0",
3
+ "version": "1.8.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",