@exaudeus/memory-mcp 1.6.0 → 1.7.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/index.js CHANGED
@@ -909,6 +909,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
909
909
  // --- Search mode: context provided → keyword search across all topics ---
910
910
  const max = maxResults ?? 10;
911
911
  const threshold = minMatch ?? 0.2;
912
+ // Resolve which lobes to search — follows the degradation ladder via resolveLobesForRead().
912
913
  const allLobeResults = [];
913
914
  const ctxEntryLobeMap = new Map(); // entry id → lobe name
914
915
  let label;
@@ -0,0 +1,57 @@
1
+ import type { MemoryEntry, EmbeddingVector } from './types.js';
2
+ /** Which ranking signal produced this result. */
3
+ export type RankSource = 'keyword' | 'semantic' | 'merged';
4
+ /** A scored search result. Shared return type for all ranking functions. */
5
+ export interface ScoredEntry {
6
+ readonly entry: MemoryEntry;
7
+ readonly score: number;
8
+ readonly matchedKeywords: readonly string[];
9
+ readonly source: RankSource;
10
+ /** Raw cosine similarity before boost multiplication.
11
+ * Present for semantic and merged results; absent for keyword-only.
12
+ * Used for debug logging (threshold calibration) and display. */
13
+ readonly semanticSimilarity?: number;
14
+ }
15
+ /** Shared context for ranking functions — pure data, no callbacks.
16
+ * Groups the parameters that keywordRank and semanticRank need,
17
+ * keeping function signatures tight.
18
+ *
19
+ * freshEntryIds is precomputed by the store — the ranking function checks
20
+ * set membership rather than calling into the store's private staleness logic.
21
+ * This keeps the function provably pure.
22
+ *
23
+ * defaultModuleBoost is the fallback boost for modules/* topics not in topicBoost.
24
+ * Injected here so ranking.ts needs zero direct threshold imports. */
25
+ export interface RankContext {
26
+ readonly currentBranch: string;
27
+ readonly branchFilter: string | undefined;
28
+ readonly topicBoost: Readonly<Record<string, number>>;
29
+ readonly freshEntryIds: ReadonlySet<string>;
30
+ readonly defaultModuleBoost: number;
31
+ }
32
+ /** Rank entries by keyword overlap with context keywords.
33
+ * Pure extraction of the ranking logic from contextSearch — identical scoring.
34
+ *
35
+ * Filter + rank in one pass for efficiency (~200 entries, not worth two iterations).
36
+ * Branch filtering for recent-work is applied here because it's a pre-condition
37
+ * for ranking, not a separate pipeline stage.
38
+ *
39
+ * Does NOT include the "always include user entries" policy — that's an
40
+ * orchestration concern that stays in contextSearch. */
41
+ export declare function keywordRank(entries: readonly MemoryEntry[], contextKeywords: ReadonlySet<string>, minMatch: number, ctx: RankContext): readonly ScoredEntry[];
42
+ /** Rank entries by cosine similarity between query embedding and stored vectors.
43
+ * Pure function — no I/O, no side effects.
44
+ *
45
+ * Entries without vectors are silently skipped — they participate via keyword ranking only.
46
+ * Branch filtering applied (recent-work scoped to current branch).
47
+ *
48
+ * @param minSimilarity Minimum cosine similarity to include. Caller provides the
49
+ * threshold (SEMANTIC_MIN_SIMILARITY for production, 0 for debug mode to see all scores). */
50
+ export declare function semanticRank(entries: readonly MemoryEntry[], vectors: ReadonlyMap<string, EmbeddingVector>, queryVector: EmbeddingVector, minSimilarity: number, ctx: RankContext): readonly ScoredEntry[];
51
+ /** Merge keyword and semantic ranking results using max-score strategy.
52
+ * For each entry: final score = max(keywordScore, semanticScore).
53
+ * Entries in both lists get source: 'merged', preserving matchedKeywords from
54
+ * keyword result and semanticSimilarity from semantic result.
55
+ *
56
+ * No weighted fusion, no magic constants. Deterministic — same inputs, same output. */
57
+ export declare function mergeRankings(keywordResults: readonly ScoredEntry[], semanticResults: readonly ScoredEntry[]): readonly ScoredEntry[];
@@ -0,0 +1,140 @@
1
+ // Domain ranking functions — scoring MemoryEntries using text analysis primitives.
2
+ // Pure functions: no I/O, no side effects, deterministic.
3
+ //
4
+ // Separated from text-analyzer.ts (which works on strings/sets, not domain types)
5
+ // and from store.ts (which handles orchestration and persistence).
6
+ // This module is the ranking pipeline seam for keyword, semantic, and merged ranking.
7
+ import { REFERENCE_BOOST_MULTIPLIER, TAG_MATCH_BOOST, } from './thresholds.js';
8
+ import { extractKeywords, stem, cosineSimilarity } from './text-analyzer.js';
9
+ // ─── Shared helpers ────────────────────────────────────────────────────────
10
+ /** Resolve topic boost for an entry. Falls back to defaultModuleBoost for
11
+ * modules/* topics, or 1.0 for unknown topics. */
12
+ function getTopicBoost(topic, ctx) {
13
+ return ctx.topicBoost[topic] ?? (topic.startsWith('modules/') ? ctx.defaultModuleBoost : 1.0);
14
+ }
15
+ /** Check whether a recent-work entry should be filtered out by branch. */
16
+ function isBranchFiltered(entry, ctx) {
17
+ return entry.topic === 'recent-work'
18
+ && ctx.branchFilter !== '*'
19
+ && !!entry.branch
20
+ && entry.branch !== ctx.currentBranch;
21
+ }
22
+ // ─── Keyword ranking ───────────────────────────────────────────────────────
23
+ /** Rank entries by keyword overlap with context keywords.
24
+ * Pure extraction of the ranking logic from contextSearch — identical scoring.
25
+ *
26
+ * Filter + rank in one pass for efficiency (~200 entries, not worth two iterations).
27
+ * Branch filtering for recent-work is applied here because it's a pre-condition
28
+ * for ranking, not a separate pipeline stage.
29
+ *
30
+ * Does NOT include the "always include user entries" policy — that's an
31
+ * orchestration concern that stays in contextSearch. */
32
+ export function keywordRank(entries, contextKeywords, minMatch, ctx) {
33
+ const results = [];
34
+ for (const entry of entries) {
35
+ if (isBranchFiltered(entry, ctx))
36
+ continue;
37
+ // Include tag values as keywords so tagged entries surface in context search
38
+ const tagKeywordPart = entry.tags ? ` ${entry.tags.join(' ')}` : '';
39
+ const entryKeywords = extractKeywords(`${entry.title} ${entry.content}${tagKeywordPart}`);
40
+ const matchedKeywords = [];
41
+ for (const kw of contextKeywords) {
42
+ if (entryKeywords.has(kw))
43
+ matchedKeywords.push(kw);
44
+ }
45
+ if (matchedKeywords.length === 0)
46
+ continue;
47
+ // Enforce minimum match threshold
48
+ const matchRatio = matchedKeywords.length / contextKeywords.size;
49
+ if (matchRatio < minMatch)
50
+ continue;
51
+ // Score = keyword match ratio × confidence × topic boost × freshness × reference boost × tag boost
52
+ const boost = getTopicBoost(entry.topic, ctx);
53
+ const freshnessMultiplier = ctx.freshEntryIds.has(entry.id) ? 1.0 : 0.7;
54
+ // Reference boost: exact class/file name match in references gets a 1.3x multiplier
55
+ const referenceBoost = entry.references?.some(ref => {
56
+ const basename = ref.split('/').pop()?.replace(/\.\w+$/, '') ?? ref;
57
+ return contextKeywords.has(stem(basename.toLowerCase()));
58
+ }) ? REFERENCE_BOOST_MULTIPLIER : 1.0;
59
+ // Tag boost: if any tag exactly matches a context keyword, boost the entry
60
+ const tagBoost = entry.tags?.some(tag => contextKeywords.has(tag))
61
+ ? TAG_MATCH_BOOST : 1.0;
62
+ const score = matchRatio * entry.confidence * boost * freshnessMultiplier * referenceBoost * tagBoost;
63
+ results.push({ entry, score, matchedKeywords, source: 'keyword' });
64
+ }
65
+ return results.sort((a, b) => b.score - a.score);
66
+ }
67
+ // ─── Semantic ranking ──────────────────────────────────────────────────────
68
+ /** Rank entries by cosine similarity between query embedding and stored vectors.
69
+ * Pure function — no I/O, no side effects.
70
+ *
71
+ * Entries without vectors are silently skipped — they participate via keyword ranking only.
72
+ * Branch filtering applied (recent-work scoped to current branch).
73
+ *
74
+ * @param minSimilarity Minimum cosine similarity to include. Caller provides the
75
+ * threshold (SEMANTIC_MIN_SIMILARITY for production, 0 for debug mode to see all scores). */
76
+ export function semanticRank(entries, vectors, queryVector, minSimilarity, ctx) {
77
+ const results = [];
78
+ for (const entry of entries) {
79
+ if (isBranchFiltered(entry, ctx))
80
+ continue;
81
+ const entryVector = vectors.get(entry.id);
82
+ if (!entryVector)
83
+ continue;
84
+ const similarity = cosineSimilarity(queryVector, entryVector);
85
+ if (similarity < minSimilarity)
86
+ continue;
87
+ // Score = cosine similarity × confidence × topic boost × freshness
88
+ // No reference/tag boost — those are keyword-domain signals captured by keywordRank
89
+ const boost = getTopicBoost(entry.topic, ctx);
90
+ const freshnessMultiplier = ctx.freshEntryIds.has(entry.id) ? 1.0 : 0.7;
91
+ const score = similarity * entry.confidence * boost * freshnessMultiplier;
92
+ results.push({
93
+ entry,
94
+ score,
95
+ matchedKeywords: [],
96
+ source: 'semantic',
97
+ semanticSimilarity: similarity,
98
+ });
99
+ }
100
+ return results.sort((a, b) => b.score - a.score);
101
+ }
102
+ // ─── Merge ─────────────────────────────────────────────────────────────────
103
+ /** Merge keyword and semantic ranking results using max-score strategy.
104
+ * For each entry: final score = max(keywordScore, semanticScore).
105
+ * Entries in both lists get source: 'merged', preserving matchedKeywords from
106
+ * keyword result and semanticSimilarity from semantic result.
107
+ *
108
+ * No weighted fusion, no magic constants. Deterministic — same inputs, same output. */
109
+ export function mergeRankings(keywordResults, semanticResults) {
110
+ // Index keyword results by entry ID for O(1) lookup during merge
111
+ const keywordById = new Map();
112
+ for (const r of keywordResults) {
113
+ keywordById.set(r.entry.id, r);
114
+ }
115
+ const merged = new Map();
116
+ // Process semantic results — check for keyword counterpart
117
+ for (const sem of semanticResults) {
118
+ const kw = keywordById.get(sem.entry.id);
119
+ if (kw) {
120
+ // Entry in both lists — use max score, merge signals
121
+ merged.set(sem.entry.id, {
122
+ entry: sem.entry,
123
+ score: Math.max(sem.score, kw.score),
124
+ matchedKeywords: kw.matchedKeywords, // from keyword (semantic has none)
125
+ source: 'merged',
126
+ semanticSimilarity: sem.semanticSimilarity, // from semantic
127
+ });
128
+ keywordById.delete(sem.entry.id); // consumed
129
+ }
130
+ else {
131
+ // Semantic-only
132
+ merged.set(sem.entry.id, sem);
133
+ }
134
+ }
135
+ // Remaining keyword-only results
136
+ for (const kw of keywordById.values()) {
137
+ merged.set(kw.entry.id, kw);
138
+ }
139
+ return Array.from(merged.values()).sort((a, b) => b.score - a.score);
140
+ }
package/dist/store.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { MemoryEntry, TopicScope, TrustLevel, DetailLevel, DurabilityDecision, QueryResult, StoreResult, CorrectResult, MemoryStats, BriefingResult, ConflictPair, MemoryConfig } from './types.js';
2
+ import type { ScoredEntry } from './ranking.js';
2
3
  export declare class MarkdownMemoryStore {
3
4
  private readonly config;
4
5
  private readonly memoryPath;
@@ -29,13 +30,14 @@ export declare class MarkdownMemoryStore {
29
30
  stats(): Promise<MemoryStats>;
30
31
  /** Bootstrap: scan repo structure and seed initial knowledge */
31
32
  bootstrap(): Promise<StoreResult[]>;
32
- /** Search across all topics using keyword matching with topic-based boosting.
33
+ /** Search across all topics using keyword + semantic ranking with topic-based boosting.
34
+ * Orchestrator: reload, extract keywords, embed query, rank, merge, apply policies.
35
+ *
36
+ * Graceful degradation: when embedder is null or embed fails, semantic results
37
+ * are empty and merge produces keyword-only results — identical to pre-embedding behavior.
38
+ *
33
39
  * @param minMatch Minimum ratio of context keywords that must match (0-1, default 0.2) */
34
- contextSearch(context: string, maxResults?: number, branchFilter?: string, minMatch?: number): Promise<Array<{
35
- entry: MemoryEntry;
36
- score: number;
37
- matchedKeywords: string[];
38
- }>>;
40
+ contextSearch(context: string, maxResults?: number, branchFilter?: string, minMatch?: number): Promise<readonly ScoredEntry[]>;
39
41
  /** Generate a collision-resistant ID: {prefix}-{8 random hex chars} */
40
42
  private generateId;
41
43
  /** Compute relative file path for an entry within the memory directory */
@@ -93,6 +95,13 @@ export declare class MarkdownMemoryStore {
93
95
  /** Find entries in the same topic with significant overlap (dedup detection).
94
96
  * Uses hybrid jaccard+containment similarity. */
95
97
  private findRelatedEntries;
98
+ /** Find semantic duplicates by cosine similarity against stored vectors.
99
+ * Same-topic only. Returns entries above DEDUP_SEMANTIC_THRESHOLD.
100
+ * Returns empty when no embedder or no vectors available. */
101
+ private findSemanticDuplicates;
102
+ /** Merge keyword-based and semantic-based dedup results, dedup by ID,
103
+ * keeping the entry with higher similarity score. */
104
+ private mergeRelatedEntries;
96
105
  /** Tag frequency across all entries — for vocabulary echo in store responses.
97
106
  * Returns tags sorted by frequency (descending). O(N) over entries. */
98
107
  getTagFrequency(): ReadonlyMap<string, number>;
package/dist/store.js CHANGED
@@ -7,9 +7,10 @@ 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, 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';
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';
11
11
  import { realGitService } from './git-service.js';
12
- import { extractKeywords, stem, similarity, matchesFilter, computeRelevanceScore, } from './text-analyzer.js';
12
+ import { extractKeywords, similarity, cosineSimilarity, matchesFilter, computeRelevanceScore, } from './text-analyzer.js';
13
+ import { keywordRank, semanticRank, mergeRankings } from './ranking.js';
13
14
  import { detectEphemeralSignals, formatEphemeralWarning, getEphemeralSeverity } from './ephemeral.js';
14
15
  // Used only by bootstrap() for git log — not part of the GitService boundary
15
16
  // because bootstrap is a one-shot utility, not a recurring operation
@@ -101,8 +102,22 @@ export class MarkdownMemoryStore {
101
102
  this.entries.set(id, entry);
102
103
  const file = this.entryToRelativePath(entry);
103
104
  await this.persistEntry(entry);
104
- // Dedup: find related entries in the same topic (excluding the one just stored and any overwritten)
105
- const relatedEntries = this.findRelatedEntries(entry, existing?.id);
105
+ // Embed and persist vector awaited so .vec exists when store() returns
106
+ if (this.embedder) {
107
+ const embedText = `${title}\n\n${content}`;
108
+ const embedResult = await this.embedder.embed(embedText);
109
+ if (embedResult.ok) {
110
+ await this.persistVector(file, embedResult.vector);
111
+ this.vectors.set(entry.id, embedResult.vector);
112
+ }
113
+ else {
114
+ process.stderr.write(`[memory-mcp] Embedding failed for ${entry.id}: ${embedResult.failure.kind}\n`);
115
+ }
116
+ }
117
+ // Dedup: merge keyword-based and semantic-based duplicate detection
118
+ const keywordDupes = this.findRelatedEntries(entry, existing?.id);
119
+ const semanticDupes = this.findSemanticDuplicates(entry.id, topic);
120
+ const relatedEntries = this.mergeRelatedEntries(keywordDupes, semanticDupes);
106
121
  // Surface relevant preferences if storing a non-preference entry
107
122
  const relevantPreferences = (topic !== 'preferences' && topic !== 'user')
108
123
  ? this.findRelevantPreferences(entry)
@@ -306,6 +321,19 @@ export class MarkdownMemoryStore {
306
321
  };
307
322
  this.entries.set(id, updated);
308
323
  await this.persistEntry(updated);
324
+ // Re-embed: content changed, old vector is stale
325
+ if (this.embedder) {
326
+ const embedText = `${updated.title}\n\n${updated.content}`;
327
+ const embedResult = await this.embedder.embed(embedText);
328
+ if (embedResult.ok) {
329
+ const updatedFile = this.entryToRelativePath(updated);
330
+ await this.persistVector(updatedFile, embedResult.vector);
331
+ this.vectors.set(updated.id, embedResult.vector);
332
+ }
333
+ else {
334
+ process.stderr.write(`[memory-mcp] Re-embedding failed for ${updated.id}: ${embedResult.failure.kind}\n`);
335
+ }
336
+ }
309
337
  return { corrected: true, id, action, newConfidence: 1.0, trust: 'user' };
310
338
  }
311
339
  /** Get memory health statistics */
@@ -417,59 +445,77 @@ export class MarkdownMemoryStore {
417
445
  return results;
418
446
  }
419
447
  // --- Contextual search (memory_context) ---
420
- /** Search across all topics using keyword matching with topic-based boosting.
448
+ /** Search across all topics using keyword + semantic ranking with topic-based boosting.
449
+ * Orchestrator: reload, extract keywords, embed query, rank, merge, apply policies.
450
+ *
451
+ * Graceful degradation: when embedder is null or embed fails, semantic results
452
+ * are empty and merge produces keyword-only results — identical to pre-embedding behavior.
453
+ *
421
454
  * @param minMatch Minimum ratio of context keywords that must match (0-1, default 0.2) */
422
455
  async contextSearch(context, maxResults = 10, branchFilter, minMatch = 0.2) {
423
456
  // Reload from disk to pick up changes from other processes
424
457
  await this.reloadFromDisk();
425
458
  const contextKeywords = extractKeywords(context);
426
- if (contextKeywords.size === 0)
459
+ // Only bail on zero keywords when there's no embedder to fall back on.
460
+ // Stopword-heavy queries produce zero keywords but can yield semantic results.
461
+ if (contextKeywords.size === 0 && !this.embedder)
427
462
  return [];
428
463
  const currentBranch = branchFilter || await this.getCurrentBranch();
429
- // Topic boost factors — higher = more likely to surface
430
- const topicBoost = TOPIC_BOOST;
431
- const results = [];
432
- for (const entry of this.entries.values()) {
433
- // Filter recent-work by branch (unless branchFilter is "*")
434
- if (entry.topic === 'recent-work' && branchFilter !== '*' && entry.branch && entry.branch !== currentBranch) {
435
- continue;
436
- }
437
- // Include tag values as keywords so tagged entries surface in context search
438
- const tagKeywordPart = entry.tags ? ` ${entry.tags.join(' ')}` : '';
439
- const entryKeywords = extractKeywords(`${entry.title} ${entry.content}${tagKeywordPart}`);
440
- const matchedKeywords = [];
441
- for (const kw of contextKeywords) {
442
- if (entryKeywords.has(kw))
443
- matchedKeywords.push(kw);
464
+ const allEntries = Array.from(this.entries.values());
465
+ // Precompute freshness set — keeps ranking functions provably pure (no callbacks)
466
+ const freshEntryIds = new Set();
467
+ for (const entry of allEntries) {
468
+ if (this.isFresh(entry))
469
+ freshEntryIds.add(entry.id);
470
+ }
471
+ const ctx = {
472
+ currentBranch,
473
+ branchFilter,
474
+ topicBoost: TOPIC_BOOST,
475
+ freshEntryIds,
476
+ defaultModuleBoost: MODULE_TOPIC_BOOST,
477
+ };
478
+ // Keyword ranking (may be empty for stopword-heavy queries)
479
+ const keywordResults = contextKeywords.size > 0
480
+ ? keywordRank(allEntries, contextKeywords, minMatch, ctx)
481
+ : [];
482
+ // Semantic ranking (only if embedder available and query embeds successfully)
483
+ const debug = process.env.MEMORY_MCP_DEBUG === '1';
484
+ let semanticResults = [];
485
+ if (this.embedder) {
486
+ const queryResult = await this.embedder.embed(context);
487
+ if (queryResult.ok) {
488
+ // In debug mode, get ALL scores (threshold=0) for calibration logging
489
+ const rawSemanticResults = semanticRank(allEntries, this.vectors, queryResult.vector, debug ? 0 : SEMANTIC_MIN_SIMILARITY, ctx);
490
+ if (debug) {
491
+ for (const r of rawSemanticResults) {
492
+ const included = (r.semanticSimilarity ?? 0) >= SEMANTIC_MIN_SIMILARITY;
493
+ process.stderr.write(`[memory-mcp:debug] semantic ${(r.semanticSimilarity ?? 0).toFixed(3)} ${r.entry.id} "${r.entry.title}"${included ? '' : ' ← below threshold'}\n`);
494
+ }
495
+ // Filter to threshold after logging all scores
496
+ semanticResults = rawSemanticResults.filter(r => (r.semanticSimilarity ?? 0) >= SEMANTIC_MIN_SIMILARITY);
497
+ }
498
+ else {
499
+ semanticResults = rawSemanticResults;
500
+ }
444
501
  }
445
- if (matchedKeywords.length === 0)
446
- continue;
447
- // Enforce minimum match threshold
448
- const matchRatio = matchedKeywords.length / contextKeywords.size;
449
- if (matchRatio < minMatch)
450
- continue;
451
- // Score = keyword match ratio x confidence x topic boost x reference boost
452
- const boost = topicBoost[entry.topic] ?? (entry.topic.startsWith('modules/') ? MODULE_TOPIC_BOOST : 1.0);
453
- const freshnessMultiplier = this.isFresh(entry) ? 1.0 : 0.7;
454
- // Reference boost: exact class/file name match in references gets a 1.3x multiplier.
455
- // Extracts the basename (without extension) from each reference path and stems it,
456
- // then checks for overlap with the context keywords.
457
- const referenceBoost = entry.references?.some(ref => {
458
- const basename = ref.split('/').pop()?.replace(/\.\w+$/, '') ?? ref;
459
- return contextKeywords.has(stem(basename.toLowerCase()));
460
- }) ? REFERENCE_BOOST_MULTIPLIER : 1.0;
461
- // Tag boost: if any tag exactly matches a context keyword, boost the entry
462
- const tagBoost = entry.tags?.some(tag => contextKeywords.has(tag))
463
- ? TAG_MATCH_BOOST : 1.0;
464
- const score = matchRatio * entry.confidence * boost * freshnessMultiplier * referenceBoost * tagBoost;
465
- results.push({ entry, score, matchedKeywords });
502
+ // If embed fails: semanticResults stays empty, keyword results used alone
466
503
  }
467
- // Always include user entries even if no keyword match (they're always relevant)
504
+ // Merge keyword + semantic results using max-score strategy
505
+ const merged = mergeRankings(keywordResults, semanticResults);
506
+ // Policy: always include user entries even if no keyword/semantic match
507
+ const results = [...merged];
468
508
  for (const entry of this.entries.values()) {
469
509
  if (entry.topic === 'user' && !results.find(r => r.entry.id === entry.id)) {
470
- results.push({ entry, score: entry.confidence * USER_ALWAYS_INCLUDE_SCORE_FRACTION, matchedKeywords: [] });
510
+ results.push({
511
+ entry,
512
+ score: entry.confidence * USER_ALWAYS_INCLUDE_SCORE_FRACTION,
513
+ matchedKeywords: [],
514
+ source: 'keyword',
515
+ });
471
516
  }
472
517
  }
518
+ // Re-sort after policy injections to maintain score-descending invariant
473
519
  return results
474
520
  .sort((a, b) => b.score - a.score)
475
521
  .slice(0, maxResults);
@@ -914,6 +960,51 @@ export class MarkdownMemoryStore {
914
960
  trust: r.entry.trust,
915
961
  }));
916
962
  }
963
+ /** Find semantic duplicates by cosine similarity against stored vectors.
964
+ * Same-topic only. Returns entries above DEDUP_SEMANTIC_THRESHOLD.
965
+ * Returns empty when no embedder or no vectors available. */
966
+ findSemanticDuplicates(excludeId, topic) {
967
+ const newVector = this.vectors.get(excludeId);
968
+ if (!newVector)
969
+ return [];
970
+ const related = [];
971
+ for (const entry of this.entries.values()) {
972
+ if (entry.id === excludeId)
973
+ continue;
974
+ if (entry.topic !== topic)
975
+ continue;
976
+ const entryVector = this.vectors.get(entry.id);
977
+ if (!entryVector)
978
+ continue;
979
+ const sim = cosineSimilarity(newVector, entryVector);
980
+ if (sim > DEDUP_SEMANTIC_THRESHOLD) {
981
+ related.push({ entry, similarity: sim });
982
+ }
983
+ }
984
+ return related
985
+ .sort((a, b) => b.similarity - a.similarity)
986
+ .slice(0, this.behavior.maxDedupSuggestions)
987
+ .map(r => ({
988
+ id: r.entry.id,
989
+ title: r.entry.title,
990
+ content: r.entry.content,
991
+ confidence: r.entry.confidence,
992
+ trust: r.entry.trust,
993
+ }));
994
+ }
995
+ /** Merge keyword-based and semantic-based dedup results, dedup by ID,
996
+ * keeping the entry with higher similarity score. */
997
+ mergeRelatedEntries(keywordDupes, semanticDupes) {
998
+ const byId = new Map();
999
+ for (const r of keywordDupes)
1000
+ byId.set(r.id, r);
1001
+ for (const r of semanticDupes) {
1002
+ if (!byId.has(r.id))
1003
+ byId.set(r.id, r);
1004
+ // If already present from keyword dedup, keep whichever — both indicate duplication
1005
+ }
1006
+ return Array.from(byId.values()).slice(0, this.behavior.maxDedupSuggestions);
1007
+ }
917
1008
  /** Tag frequency across all entries — for vocabulary echo in store responses.
918
1009
  * Returns tags sorted by frequency (descending). O(N) over entries. */
919
1010
  getTagFrequency() {
@@ -14,6 +14,22 @@ export declare const CONFLICT_MIN_CONTENT_CHARS = 50;
14
14
  /** Opposition keyword pairs for enhanced conflict detection.
15
15
  * When entries overlap AND use opposing terms, boost the conflict signal. */
16
16
  export declare const OPPOSITION_PAIRS: ReadonlyArray<readonly [string, string]>;
17
+ /** Minimum cosine similarity for semantic search results.
18
+ * Below this, entries are noise — embedding models produce non-zero similarity
19
+ * even for unrelated text.
20
+ *
21
+ * CALIBRATION NOTE: 0.45 is a strict starting point. With nomic-embed-text,
22
+ * unrelated text pairs routinely score 0.2-0.4 because the model produces
23
+ * non-orthogonal embeddings for any English text. Starting strict and loosening
24
+ * with data is safer than starting loose.
25
+ *
26
+ * Use MEMORY_MCP_DEBUG=1 env var to see raw cosine scores for calibration. */
27
+ export declare const SEMANTIC_MIN_SIMILARITY = 0.45;
28
+ /** Minimum cosine similarity for semantic dedup at store time.
29
+ * Higher than SEMANTIC_MIN_SIMILARITY because flagging false duplicates is more
30
+ * disruptive than missing real ones. Two entries must be quite similar to be
31
+ * flagged as potential duplicates. */
32
+ export declare const DEDUP_SEMANTIC_THRESHOLD = 0.8;
17
33
  /** Score multiplier when a reference path basename matches the context keywords. */
18
34
  export declare const REFERENCE_BOOST_MULTIPLIER = 1.3;
19
35
  /** Per-topic scoring boost factors for contextSearch().
@@ -40,6 +40,22 @@ export const OPPOSITION_PAIRS = [
40
40
  ['throw', 'return'], // exceptions vs Result types
41
41
  ['imperative', 'declarative'],
42
42
  ];
43
+ /** Minimum cosine similarity for semantic search results.
44
+ * Below this, entries are noise — embedding models produce non-zero similarity
45
+ * even for unrelated text.
46
+ *
47
+ * CALIBRATION NOTE: 0.45 is a strict starting point. With nomic-embed-text,
48
+ * unrelated text pairs routinely score 0.2-0.4 because the model produces
49
+ * non-orthogonal embeddings for any English text. Starting strict and loosening
50
+ * with data is safer than starting loose.
51
+ *
52
+ * Use MEMORY_MCP_DEBUG=1 env var to see raw cosine scores for calibration. */
53
+ export const SEMANTIC_MIN_SIMILARITY = 0.45;
54
+ /** Minimum cosine similarity for semantic dedup at store time.
55
+ * Higher than SEMANTIC_MIN_SIMILARITY because flagging false duplicates is more
56
+ * disruptive than missing real ones. Two entries must be quite similar to be
57
+ * flagged as potential duplicates. */
58
+ export const DEDUP_SEMANTIC_THRESHOLD = 0.80;
43
59
  /** Score multiplier when a reference path basename matches the context keywords. */
44
60
  export const REFERENCE_BOOST_MULTIPLIER = 1.30;
45
61
  /** Per-topic scoring boost factors for contextSearch().
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exaudeus/memory-mcp",
3
- "version": "1.6.0",
3
+ "version": "1.7.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",