@danielmarbach/mnemonic-mcp 0.19.5 → 0.20.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/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ All notable changes to `mnemonic` will be documented in this file.
4
4
 
5
5
  The format is loosely based on Keep a Changelog and uses semver-style version headings.
6
6
 
7
+ ## [0.20.0] - 2026-04-04
8
+
9
+ ### Added
10
+
11
+ - Hybrid recall: semantic search is now enhanced with lightweight lexical reranking over note projections. When semantic results are weak, a bounded lexical rescue path scans projections for additional candidates, improving exact-match and partial-query recall without changing the storage model or adding new infrastructure.
12
+
13
+ ### Changed
14
+
15
+ - `recall` results now include a `lexicalScore` field in structured output when computed, showing the lexical overlap contribution alongside the semantic score.
16
+
7
17
  ## [0.19.5] - 2026-04-04
8
18
 
9
19
  ### Fixed
package/README.md CHANGED
@@ -308,6 +308,8 @@ Project identity derives from the **git remote URL**, normalized to a stable slu
308
308
 
309
309
  `recall` with `cwd` searches both vaults. Project notes get a **+0.15 similarity boost** — a soft signal, not a hard filter — so global memories remain accessible while project context floats to the top.
310
310
 
311
+ **Hybrid recall** enhances semantic search with lightweight lexical reranking over note projections. When semantic results are weak, a bounded lexical rescue path scans projections for additional candidates, improving exact-match and partial-query recall without changing the storage model or adding new infrastructure. Lexical scores act as tiebreakers — they cannot overcome a large semantic gap but can reorder close candidates.
312
+
311
313
  Temporal recall is opt-in via `mode: "temporal"`. It keeps semantic selection first, then enriches only the top matches with compact git-backed history so agents can inspect how a note evolved without turning recall into raw log or diff output.
312
314
 
313
315
  **What temporal mode shows:**
package/build/index.js CHANGED
@@ -10,10 +10,11 @@ import { embed, cosineSimilarity, embedModel } from "./embeddings.js";
10
10
  import { buildTemporalHistoryEntry, computeConfidence, getNoteProvenance } from "./provenance.js";
11
11
  import { enrichTemporalHistory } from "./temporal-interpretation.js";
12
12
  import { getOrBuildProjection } from "./projections.js";
13
- import { invalidateActiveProjectCache, getOrBuildVaultEmbeddings, getOrBuildVaultNoteList, getSessionCachedNote, } from "./cache.js";
13
+ import { invalidateActiveProjectCache, getOrBuildVaultEmbeddings, getOrBuildVaultNoteList, getSessionCachedNote, getSessionCachedProjection, setSessionCachedProjection, } from "./cache.js";
14
14
  import { performance } from "perf_hooks";
15
15
  import { filterRelationships, mergeRelationshipsFromNotes, normalizeMergePlanSourceIds, resolveEffectiveConsolidationMode, } from "./consolidate.js";
16
- import { computeRecallMetadataBoost, selectRecallResults } from "./recall.js";
16
+ import { computeRecallMetadataBoost, computeHybridScore, selectRecallResults, applyLexicalReranking } from "./recall.js";
17
+ import { shouldTriggerLexicalRescue, computeLexicalScore, LEXICAL_RESCUE_CANDIDATE_LIMIT, LEXICAL_RESCUE_THRESHOLD, LEXICAL_RESCUE_RESULT_LIMIT, } from "./lexical.js";
17
18
  import { getRelationshipPreview } from "./relationships.js";
18
19
  import { cleanMarkdown } from "./markdown.js";
19
20
  import { MnemonicConfigStore, readVaultSchemaVersion } from "./config.js";
@@ -1727,6 +1728,52 @@ server.registerTool("get_project_memory_policy", {
1727
1728
  structuredContent,
1728
1729
  };
1729
1730
  });
1731
+ // ── Lexical rescue helper ─────────────────────────────────────────────────────
1732
+ async function collectLexicalRescueCandidates(vaults, query, project, scope, tags, existingIds) {
1733
+ const existingIdSet = new Set(existingIds.map((c) => c.id));
1734
+ const candidates = [];
1735
+ for (const vault of vaults) {
1736
+ const notes = await vault.storage.listNotes().catch(() => []);
1737
+ for (const note of notes) {
1738
+ if (existingIdSet.has(note.id))
1739
+ continue;
1740
+ if (tags && tags.length > 0) {
1741
+ const noteTags = new Set(note.tags);
1742
+ if (!tags.every((t) => noteTags.has(t)))
1743
+ continue;
1744
+ }
1745
+ const isProjectNote = note.project !== undefined;
1746
+ const isCurrentProject = project && note.project === project.id;
1747
+ if (scope === "project" && !isCurrentProject)
1748
+ continue;
1749
+ if (scope === "global" && isProjectNote)
1750
+ continue;
1751
+ const projection = await getOrBuildProjection(vault.storage, note).catch(() => undefined);
1752
+ if (!projection)
1753
+ continue;
1754
+ const lexicalScore = computeLexicalScore(query, projection.projectionText);
1755
+ if (lexicalScore < LEXICAL_RESCUE_THRESHOLD)
1756
+ continue;
1757
+ const metadataBoost = computeRecallMetadataBoost(getEffectiveMetadata(note));
1758
+ const boost = (isCurrentProject ? 0.15 : 0) + metadataBoost;
1759
+ candidates.push({
1760
+ id: note.id,
1761
+ score: 0,
1762
+ boosted: boost,
1763
+ vault,
1764
+ isCurrentProject: Boolean(isCurrentProject),
1765
+ lexicalScore,
1766
+ });
1767
+ if (candidates.length >= LEXICAL_RESCUE_CANDIDATE_LIMIT)
1768
+ break;
1769
+ }
1770
+ if (candidates.length >= LEXICAL_RESCUE_CANDIDATE_LIMIT)
1771
+ break;
1772
+ }
1773
+ return candidates
1774
+ .sort((a, b) => computeHybridScore(b) - computeHybridScore(a))
1775
+ .slice(0, LEXICAL_RESCUE_RESULT_LIMIT);
1776
+ }
1730
1777
  // ── recall ────────────────────────────────────────────────────────────────────
1731
1778
  server.registerTool("recall", {
1732
1779
  title: "Recall",
@@ -1831,7 +1878,42 @@ server.registerTool("recall", {
1831
1878
  scored.push({ id: rec.id, score: rawScore, boosted: rawScore + boost, vault, isCurrentProject: Boolean(isCurrentProject) });
1832
1879
  }
1833
1880
  }
1834
- const top = selectRecallResults(scored, limit, scope);
1881
+ const projectionTexts = new Map();
1882
+ for (const candidate of scored) {
1883
+ const note = await readCachedNote(candidate.vault, candidate.id).catch(() => null);
1884
+ if (!note) {
1885
+ continue;
1886
+ }
1887
+ const projection = await getOrBuildProjection(candidate.vault.storage, note).catch(() => undefined);
1888
+ if (!projection) {
1889
+ continue;
1890
+ }
1891
+ projectionTexts.set(candidate.id, projection.projectionText);
1892
+ if (project) {
1893
+ setSessionCachedProjection(project.id, candidate.id, projection);
1894
+ }
1895
+ }
1896
+ // Apply lexical reranking over semantic candidates (fail-soft)
1897
+ const getProjectionText = (id) => {
1898
+ const inlineProjection = projectionTexts.get(id);
1899
+ if (inlineProjection) {
1900
+ return inlineProjection;
1901
+ }
1902
+ if (project) {
1903
+ const cached = getSessionCachedProjection(project.id, id);
1904
+ if (cached)
1905
+ return cached.projectionText;
1906
+ }
1907
+ return undefined;
1908
+ };
1909
+ const reranked = applyLexicalReranking(scored, query, getProjectionText);
1910
+ // Lexical rescue: when semantic results are weak, scan projections for additional candidates
1911
+ const topScore = reranked.length > 0 ? reranked[0].score : undefined;
1912
+ if (shouldTriggerLexicalRescue(topScore, reranked.length)) {
1913
+ const rescueCandidates = await collectLexicalRescueCandidates(vaults, query, project ?? undefined, scope, tags, reranked);
1914
+ reranked.push(...rescueCandidates);
1915
+ }
1916
+ const top = selectRecallResults(reranked, limit, scope);
1835
1917
  if (top.length === 0) {
1836
1918
  const structuredContent = { action: "recalled", query, scope: scope || "all", results: [] };
1837
1919
  return { content: [{ type: "text", text: "No memories found matching that query." }], structuredContent };