@danielmarbach/mnemonic-mcp 0.19.4 → 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,23 @@ 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
+
17
+ ## [0.19.5] - 2026-04-04
18
+
19
+ ### Fixed
20
+
21
+ - `discover_tags` now returns schema-compliant structured output in both suggest and browse modes. Previously, internal ranking fields leaked into tag items, causing MCP callers to fail with `-32602` schema validation errors.
22
+ - `discover_tags` exact context matching now respects tag boundaries, so short tags like `io` no longer get boosted from substring hits inside larger words such as `mnemonic`.
23
+
7
24
  ## [0.19.4] - 2026-04-02
8
25
 
9
26
  ### Changed
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 };
@@ -2441,9 +2523,13 @@ function countTokenOverlap(tokens, other) {
2441
2523
  }
2442
2524
  return matches;
2443
2525
  }
2526
+ function escapeRegex(value) {
2527
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2528
+ }
2444
2529
  function hasExactTagContextMatch(tag, values) {
2445
2530
  const normalizedTag = tag.toLowerCase();
2446
- return values.some(value => value?.toLowerCase().includes(normalizedTag) ?? false);
2531
+ const pattern = new RegExp(`(^|[^a-z0-9_-])${escapeRegex(normalizedTag)}([^a-z0-9_-]|$)`);
2532
+ return values.some(value => value ? pattern.test(value.toLowerCase()) : false);
2447
2533
  }
2448
2534
  server.registerTool("discover_tags", {
2449
2535
  title: "Discover Tags",
@@ -2572,7 +2658,10 @@ server.registerTool("discover_tags", {
2572
2658
  (tag.usageCount * 0.1) -
2573
2659
  genericPenalty -
2574
2660
  (!isTemporaryTarget && tag.isTemporaryOnly ? 2 : 0)
2575
- : (tag.usageCount * 1.5) +
2661
+ : (tag.exactContextMatch ? 6 : 0) +
2662
+ (tag.tagTokenOverlap * 3) +
2663
+ (tag.averageContextMatch * 2) +
2664
+ (tag.usageCount * 1.5) +
2576
2665
  (tag.isTemporaryOnly ? -3 : 1) -
2577
2666
  (!isTemporaryTarget && tag.isTemporaryOnly ? 2 : 0);
2578
2667
  return {
@@ -2637,7 +2726,13 @@ server.registerTool("discover_tags", {
2637
2726
  project: project ? { id: project.id, name: project.name } : undefined,
2638
2727
  mode,
2639
2728
  scope: scope || "all",
2640
- tags: tags.slice(0, effectiveLimit).map(({ score, example, reason, ...tag }) => tag),
2729
+ tags: tags.slice(0, effectiveLimit).map(tag => ({
2730
+ tag: tag.tag,
2731
+ usageCount: tag.usageCount,
2732
+ examples: tag.examples,
2733
+ lifecycleTypes: tag.lifecycleTypes,
2734
+ isTemporaryOnly: tag.isTemporaryOnly,
2735
+ })),
2641
2736
  totalTags: tags.length,
2642
2737
  totalNotes: entries.length,
2643
2738
  vaultsSearched,
@@ -2648,7 +2743,14 @@ server.registerTool("discover_tags", {
2648
2743
  project: project ? { id: project.id, name: project.name } : undefined,
2649
2744
  mode,
2650
2745
  scope: scope || "all",
2651
- recommendedTags: tags.slice(0, effectiveLimit).map(({ score, examples, ...tag }) => tag),
2746
+ recommendedTags: tags.slice(0, effectiveLimit).map(tag => ({
2747
+ tag: tag.tag,
2748
+ usageCount: tag.usageCount,
2749
+ example: tag.example,
2750
+ reason: tag.reason,
2751
+ lifecycleTypes: tag.lifecycleTypes,
2752
+ isTemporaryOnly: tag.isTemporaryOnly,
2753
+ })),
2652
2754
  totalTags: tags.length,
2653
2755
  totalNotes: entries.length,
2654
2756
  vaultsSearched,