@danielmarbach/mnemonic-mcp 0.17.0 → 0.19.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +33 -1
  3. package/build/index.js +160 -62
  4. package/build/index.js.map +1 -1
  5. package/build/project-introspection.d.ts +17 -2
  6. package/build/project-introspection.d.ts.map +1 -1
  7. package/build/project-introspection.js +152 -4
  8. package/build/project-introspection.js.map +1 -1
  9. package/build/provenance.d.ts +1 -0
  10. package/build/provenance.d.ts.map +1 -1
  11. package/build/provenance.js.map +1 -1
  12. package/build/recall.d.ts +2 -0
  13. package/build/recall.d.ts.map +1 -1
  14. package/build/recall.js +23 -0
  15. package/build/recall.js.map +1 -1
  16. package/build/relationships.d.ts +3 -2
  17. package/build/relationships.d.ts.map +1 -1
  18. package/build/relationships.js +46 -5
  19. package/build/relationships.js.map +1 -1
  20. package/build/role-suggestions.d.ts +20 -0
  21. package/build/role-suggestions.d.ts.map +1 -0
  22. package/build/role-suggestions.js +125 -0
  23. package/build/role-suggestions.js.map +1 -0
  24. package/build/storage.d.ts +7 -0
  25. package/build/storage.d.ts.map +1 -1
  26. package/build/storage.js +29 -0
  27. package/build/storage.js.map +1 -1
  28. package/build/structured-content.d.ts +272 -11
  29. package/build/structured-content.d.ts.map +1 -1
  30. package/build/structured-content.js +20 -1
  31. package/build/structured-content.js.map +1 -1
  32. package/build/temporal-interpretation.d.ts +40 -0
  33. package/build/temporal-interpretation.d.ts.map +1 -0
  34. package/build/temporal-interpretation.js +180 -0
  35. package/build/temporal-interpretation.js.map +1 -0
  36. package/build/theme-validation.d.ts +14 -0
  37. package/build/theme-validation.d.ts.map +1 -0
  38. package/build/theme-validation.js +46 -0
  39. package/build/theme-validation.js.map +1 -0
  40. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ 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.19.0] - 2026-03-28
8
+
9
+ ### Added
10
+
11
+ - Role and importance inference (`src/role-suggestions.ts`): notes are automatically assigned a suggested `role` and `importance` from structural signals; inference is language-independent and never overwrites explicit frontmatter.
12
+ - `alwaysLoad: true` in note frontmatter marks a note as an explicit session anchor with highest recall and relationship-expansion priority.
13
+ - `recall` and relationship expansion now factor in effective role and importance, so summary and decision notes surface higher without manual tagging.
14
+ - Temporal recall history entries now include a `changeDescription` and notes include a `historySummary` capturing the overall evolution pattern; classification uses structural signals only and degrades gracefully when stats are unavailable.
15
+
16
+ ### Changed
17
+
18
+ - The workflow hint and user-facing docs now state that roles are optional prioritization hints, inferred roles stay internal-only, lifecycle remains the durability control, and default prioritization is language-independent.
19
+
20
+ ## [0.18.0] - 2026-03-27
21
+
22
+ ### Added
23
+
24
+ - Theme graduation from "other": keywords appearing across multiple notes become named themes automatically.
25
+ - Keyword extraction with synonym normalization for theme classification.
26
+ - `analyzeThemeQuality()` utility for checking theme distribution metrics.
27
+
28
+ ### Changed
29
+
30
+ - All 23 MCP tools now include complete SDK tool hints (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`). Previously, read-only tools were missing `destructiveHint: false`.
31
+ - Mutating tool retry results now return explicit recovery guidance instead of vague `Retry: safe` text, including recovery kind and no-inference instructions for partial-persistence failures.
32
+ - Recovery precedence is now encoded in retry metadata: deterministic tool reconciliation is preferred where available, and same-vault retries must be replayed serially.
33
+ - Retry metadata now uses `attemptedCommit.subject` instead of `attemptedCommit.message`.
34
+ - `classifyTheme` now uses keyword-based graduation in addition to tag matching.
35
+ - `project_memory_summary` includes both fixed themes and dynamically promoted keywords.
36
+
7
37
  ## [0.17.0] - 2026-03-25
8
38
 
9
39
  ### Added
package/README.md CHANGED
@@ -310,6 +310,16 @@ Project identity derives from the **git remote URL**, normalized to a stable slu
310
310
 
311
311
  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
312
 
313
+ **What temporal mode shows:**
314
+
315
+ - **Per-change descriptions** (`changeDescription`): human-readable summaries like "Expanded the note with additional detail" or "Minor refinement to existing content."
316
+ - **Note-level history summaries** (`historySummary`): overall patterns like "The core decision remained stable while rationale and examples expanded." or "The note was connected to related work through incremental updates."
317
+ - **Semantic change categories**: create, refine, expand, clarify, connect, restructure, reverse, unknown
318
+
319
+ **How it works:**
320
+
321
+ mnemonic interprets change semantically using structural and statistical signals (size ratios, heading changes, section movements) rather than language-dependent analysis. Raw diffs are intentionally NOT part of default temporal output—you get interpretive summaries that explain what kind of change happened, not patch noise.
322
+
313
323
  Use `verbose: true` together with temporal mode when you want richer change stats such as additions, deletions, files changed, and change classification. Those stats describe the whole commit that touched the note, not a raw diff excerpt, so recall stays bounded and does not return full diffs.
314
324
 
315
325
  The `scope` parameter on `recall` narrows results:
@@ -325,6 +335,14 @@ Each note carries a `lifecycle`:
325
335
  - `"permanent"` *(default)* — durable knowledge for future sessions
326
336
  - `"temporary"` — working-state scaffolding (plans, WIP checkpoints) that can be cleaned up once consolidated
327
337
 
338
+ ### Roles and lifecycle
339
+
340
+ Roles are optional prioritization hints, not required schema. mnemonic infers a `role` and `importance` from structural signals (heading count, bullet density, inbound references, relationship types) — inference is language-independent and never overwrites explicit frontmatter. Valid roles: `summary`, `decision`, `plan`, `log`, `reference`. Valid importance values: `high`, `normal`.
341
+
342
+ Set `alwaysLoad: true` in a note's frontmatter to mark it as an explicit session anchor; it receives the highest recall and relationship-expansion priority regardless of inferred role.
343
+
344
+ mnemonic works without roles. Inferred roles stay internal-only, prioritization is language-independent by default, and lifecycle remains the separate durability axis. A note with `role: plan` can still be either `temporary` or `permanent`.
345
+
328
346
  ### Note format
329
347
 
330
348
  Notes are standard markdown with YAML frontmatter:
@@ -422,7 +440,7 @@ Imported notes are written to the main vault with `lifecycle: permanent` and `sc
422
440
 
423
441
  | Prompt | Description |
424
442
  |--------|-------------|
425
- | `mnemonic-workflow-hint` | Optional. Returns an imperative decision protocol for weaker and stronger models: use `recall` or `list` first, inspect with `get`, update existing memories, remember only when nothing matches, then organize with `relate`, `consolidate`, or `move_memory`. Not auto-injected request it on demand. |
443
+ | `mnemonic-workflow-hint` | Optional. Returns a compact decision protocol: use `recall` or `list` first, inspect with `get`, update existing memories, remember only when nothing matches, then organize with `relate`, `consolidate`, or `move_memory`. Also reminds models that roles are optional prioritization hints, inferred roles are internal-only, prioritization is language-independent by default, and lifecycle stays separate. |
426
444
 
427
445
  ## Tools
428
446
 
@@ -452,6 +470,18 @@ Imported notes are written to the main vault with `lifecycle: permanent` and `sc
452
470
  | `update` | Update note content/title/tags/lifecycle, re-embeds always |
453
471
  | `where_is_memory` | Show note's project association and storage location |
454
472
 
473
+ ### Theme emergence
474
+
475
+ `project_memory_summary` categorizes notes by theme. Themes **emerge automatically** from your notes:
476
+
477
+ - **Tag-based classification** — notes with matching tags (e.g., `["decisions"]`, `["bugs"]`) are grouped immediately
478
+ - **Keyword graduation** — keywords that appear across multiple notes become named themes over time
479
+ - **"other" bucket** — notes that don't match any theme are grouped here; this shrinks as themes emerge
480
+
481
+ No predefined schema required. The system adapts to your project's vocabulary.
482
+
483
+ **Language handling:** The system degrades gracefully for non-English notes. Stopwords and synonyms are optional English enhancements; keywords that don't match pass through unchanged, allowing non-English keywords to graduate if they meet frequency thresholds.
484
+
455
485
  ### Relationships
456
486
 
457
487
  Notes can be linked with typed edges stored in frontmatter:
@@ -544,6 +574,8 @@ mnemonic and Beads address complementary concerns. mnemonic is a **knowledge gra
544
574
 
545
575
  mnemonic distinguishes between two lifecycle states. `temporary` notes capture evolving working-state: hypotheses, in-progress plans, experiment results, draft reasoning. `permanent` notes capture durable knowledge: decisions, root cause explanations, architectural guidance, lessons learned. As an investigation progresses, a cluster of temporary notes is typically `consolidate`d into one or more permanent notes, and the scaffolding is discarded. This two-phase lifecycle keeps exploratory thinking from polluting long-term memory while still giving agents a place to reason incrementally before committing to a conclusion.
546
576
 
577
+ Roles, when present, are separate from lifecycle: they help prioritization and retrieval, not retention policy. mnemonic still works without roles, and any inferred role metadata remains an internal hint rather than part of the user-facing note contract.
578
+
547
579
  ## Contributing
548
580
 
549
581
  See [`CONTRIBUTING.md`](CONTRIBUTING.md) for development setup, dogfooding workflow, testing requirements, and pull request guidelines.
package/build/index.js CHANGED
@@ -8,16 +8,18 @@ import { promises as fs } from "fs";
8
8
  import { NOTE_LIFECYCLES } from "./storage.js";
9
9
  import { embed, cosineSimilarity, embedModel } from "./embeddings.js";
10
10
  import { buildTemporalHistoryEntry, computeConfidence, getNoteProvenance } from "./provenance.js";
11
+ import { enrichTemporalHistory } from "./temporal-interpretation.js";
11
12
  import { getOrBuildProjection } from "./projections.js";
12
13
  import { invalidateActiveProjectCache, getOrBuildVaultEmbeddings, getOrBuildVaultNoteList, getSessionCachedNote, } from "./cache.js";
13
14
  import { performance } from "perf_hooks";
14
15
  import { filterRelationships, mergeRelationshipsFromNotes, normalizeMergePlanSourceIds, resolveEffectiveConsolidationMode, } from "./consolidate.js";
15
- import { selectRecallResults } from "./recall.js";
16
+ import { computeRecallMetadataBoost, selectRecallResults } from "./recall.js";
16
17
  import { getRelationshipPreview } from "./relationships.js";
17
18
  import { cleanMarkdown } from "./markdown.js";
18
19
  import { MnemonicConfigStore, readVaultSchemaVersion } from "./config.js";
19
20
  import { CONSOLIDATION_MODES, PROTECTED_BRANCH_BEHAVIORS, PROJECT_POLICY_SCOPES, WRITE_SCOPES, isProtectedBranch, resolveProtectedBranchBehavior, resolveProtectedBranchPatterns, resolveConsolidationMode, resolveWriteScope, } from "./project-memory-policy.js";
20
- import { classifyTheme, summarizePreview, titleCaseTheme, withinThemeScore, anchorScore, buildThemeCache, computeConnectionDiversity, } from "./project-introspection.js";
21
+ import { classifyTheme, classifyThemeWithGraduation, computeThemesWithGraduation, summarizePreview, titleCaseTheme, withinThemeScore, anchorScore, computeConnectionDiversity, } from "./project-introspection.js";
22
+ import { getEffectiveMetadata } from "./role-suggestions.js";
21
23
  import { detectProject, getCurrentGitBranch, resolveProjectIdentity } from "./project.js";
22
24
  import { VaultManager } from "./vault.js";
23
25
  import { checkBranchChange } from "./branch-tracker.js";
@@ -405,7 +407,8 @@ function formatTemporalHistory(history) {
405
407
  const lines = ["**history:**"];
406
408
  for (const entry of history) {
407
409
  const summary = entry.summary ? ` — ${entry.summary}` : "";
408
- lines.push(`- \`${entry.commitHash.slice(0, 7)}\` ${entry.timestamp} ${entry.message}${summary}`);
410
+ const changeDesc = entry.changeDescription ? ` (${entry.changeDescription})` : "";
411
+ lines.push(`- \`${entry.commitHash.slice(0, 7)}\` ${entry.timestamp} — ${entry.message}${summary}${changeDesc}`);
409
412
  }
410
413
  return lines.join("\n");
411
414
  }
@@ -693,9 +696,22 @@ function buildMutationRetryContract(args) {
693
696
  if (args.commit.status !== "failed") {
694
697
  return undefined;
695
698
  }
699
+ const recoveryKind = args.preferredRecovery ?? (args.mutationApplied
700
+ ? "manual-exact-git-recovery"
701
+ : "no-manual-recovery");
702
+ const recoveryReason = recoveryKind === "rerun-tool-call-serial"
703
+ ? "Tool-level reconciliation exists for this mutation; rerun the same tool call serially for the affected vault."
704
+ : recoveryKind === "manual-exact-git-recovery"
705
+ ? "Mutation is already persisted on disk; manual git recovery is allowed only with the exact attemptedCommit values."
706
+ : "Mutation was not applied deterministically; manual git recovery is not authorized.";
696
707
  return {
708
+ recovery: {
709
+ kind: recoveryKind,
710
+ allowed: recoveryKind !== "no-manual-recovery",
711
+ reason: recoveryReason,
712
+ },
697
713
  attemptedCommit: {
698
- message: args.commitMessage,
714
+ subject: args.commitMessage,
699
715
  body: args.commitBody,
700
716
  files: args.files,
701
717
  cwd: args.cwd,
@@ -708,19 +724,69 @@ function buildMutationRetryContract(args) {
708
724
  rationale: args.mutationApplied
709
725
  ? "Mutation is already persisted on disk; commit can be retried deterministically."
710
726
  : "Mutation was not applied; retry may require re-running the operation.",
727
+ instructions: {
728
+ sourceOfTruth: recoveryKind === "manual-exact-git-recovery" ? "attemptedCommit" : "tool-response",
729
+ useExactSubject: recoveryKind === "manual-exact-git-recovery",
730
+ useExactBody: recoveryKind === "manual-exact-git-recovery",
731
+ useExactFiles: recoveryKind === "manual-exact-git-recovery",
732
+ forbidInferenceFromHistory: true,
733
+ forbidInferenceFromTitleOrSummary: true,
734
+ forbidParallelSameVaultRetries: true,
735
+ preferToolReconciliation: recoveryKind === "rerun-tool-call-serial",
736
+ rerunSameToolCallSerially: recoveryKind === "rerun-tool-call-serial",
737
+ },
711
738
  };
712
739
  }
713
740
  function formatRetrySummary(retry) {
714
741
  if (!retry) {
715
742
  return undefined;
716
743
  }
717
- const safety = retry.retrySafe ? "safe" : "requires review";
718
744
  const opLabel = retry.attemptedCommit.operation === "add" ? "add" : "commit";
719
745
  const error = retry.attemptedCommit.error;
720
- return [
721
- `Retry: ${safety} | vault=${retry.attemptedCommit.vault} | files=${retry.attemptedCommit.files.length}`,
722
- `Git ${opLabel} error: ${error}`,
723
- ].join("\n");
746
+ const lines = [];
747
+ switch (retry.recovery.kind) {
748
+ case "rerun-tool-call-serial":
749
+ lines.push("Recovery: rerun same tool call serially");
750
+ lines.push(retry.recovery.reason);
751
+ lines.push("Rerun the same mnemonic tool call one time for the affected vault.");
752
+ lines.push("Do not replay same-vault mutations in parallel.");
753
+ lines.push("Manual git recovery is not authorized for this failure.");
754
+ lines.push("Git failure:");
755
+ lines.push(`${opLabel}: ${error}`);
756
+ break;
757
+ case "manual-exact-git-recovery":
758
+ lines.push("Recovery: manual exact git recovery allowed");
759
+ lines.push(retry.recovery.reason);
760
+ lines.push("Use only the exact values below. Do not infer from git history, note title, summary, or repo state.");
761
+ lines.push("");
762
+ lines.push("Commit subject:");
763
+ lines.push(retry.attemptedCommit.subject);
764
+ if (retry.attemptedCommit.body) {
765
+ lines.push("");
766
+ lines.push("Commit body:");
767
+ lines.push(retry.attemptedCommit.body);
768
+ }
769
+ lines.push("");
770
+ lines.push("Files:");
771
+ for (const file of retry.attemptedCommit.files) {
772
+ lines.push(`- ${file}`);
773
+ }
774
+ lines.push("");
775
+ lines.push("Git failure:");
776
+ lines.push(`${opLabel}: ${error}`);
777
+ break;
778
+ case "no-manual-recovery":
779
+ lines.push("Recovery: no manual recovery authorized");
780
+ lines.push(retry.recovery.reason);
781
+ lines.push("Git failure:");
782
+ lines.push(`${opLabel}: ${error}`);
783
+ break;
784
+ default: {
785
+ const _exhaustive = retry.recovery.kind;
786
+ throw new Error(`Unknown recovery kind: ${_exhaustive}`);
787
+ }
788
+ }
789
+ return lines.join("\n");
724
790
  }
725
791
  function formatPersistenceSummary(persistence) {
726
792
  const parts = [
@@ -731,14 +797,14 @@ function formatPersistenceSummary(persistence) {
731
797
  if (persistence.embedding.reason) {
732
798
  lines[0] += ` | embedding reason=${persistence.embedding.reason}`;
733
799
  }
734
- if (persistence.git.commit === "failed" && persistence.git.commitError) {
800
+ const retrySummary = formatRetrySummary(persistence.retry);
801
+ if (!retrySummary && persistence.git.commit === "failed" && persistence.git.commitError) {
735
802
  const opLabel = persistence.git.commitOperation === "add" ? "add" : "commit";
736
803
  lines.push(`Git ${opLabel} error: ${persistence.git.commitError}`);
737
804
  }
738
805
  if (persistence.git.push === "failed" && persistence.git.pushError) {
739
806
  lines.push(`Git push error: ${persistence.git.pushError}`);
740
807
  }
741
- const retrySummary = formatRetrySummary(persistence.retry);
742
808
  if (retrySummary) {
743
809
  lines.push(retrySummary);
744
810
  }
@@ -969,6 +1035,7 @@ server.registerTool("detect_project", {
969
1035
  "- Use `recall` or `project_memory_summary` to orient on existing memory.",
970
1036
  annotations: {
971
1037
  readOnlyHint: true,
1038
+ destructiveHint: false,
972
1039
  idempotentHint: true,
973
1040
  openWorldHint: false,
974
1041
  },
@@ -1023,6 +1090,7 @@ server.registerTool("get_project_identity", {
1023
1090
  "- Use `set_project_identity` only if the wrong remote is defining identity.",
1024
1091
  annotations: {
1025
1092
  readOnlyHint: true,
1093
+ destructiveHint: false,
1026
1094
  idempotentHint: true,
1027
1095
  openWorldHint: false,
1028
1096
  },
@@ -1179,6 +1247,7 @@ server.registerTool("list_migrations", {
1179
1247
  "- Run `execute_migration` with `dryRun: true` first.",
1180
1248
  annotations: {
1181
1249
  readOnlyHint: true,
1250
+ destructiveHint: false,
1182
1251
  idempotentHint: true,
1183
1252
  openWorldHint: false,
1184
1253
  },
@@ -1600,6 +1669,7 @@ server.registerTool("get_project_memory_policy", {
1600
1669
  "- Call `remember` with explicit `scope` for a one-off override, or `set_project_memory_policy` to change defaults.",
1601
1670
  annotations: {
1602
1671
  readOnlyHint: true,
1672
+ destructiveHint: false,
1603
1673
  idempotentHint: true,
1604
1674
  openWorldHint: false,
1605
1675
  },
@@ -1673,6 +1743,7 @@ server.registerTool("recall", {
1673
1743
  "- Use `get`, `update`, `relate`, or `consolidate` based on the results.",
1674
1744
  annotations: {
1675
1745
  readOnlyHint: true,
1746
+ destructiveHint: false,
1676
1747
  idempotentHint: true,
1677
1748
  openWorldHint: true,
1678
1749
  },
@@ -1749,7 +1820,8 @@ server.registerTool("recall", {
1749
1820
  if (isProjectNote)
1750
1821
  continue;
1751
1822
  }
1752
- const boost = isCurrentProject ? 0.15 : 0;
1823
+ const metadataBoost = computeRecallMetadataBoost(getEffectiveMetadata(note));
1824
+ const boost = (isCurrentProject ? 0.15 : 0) + metadataBoost;
1753
1825
  scored.push({ id: rec.id, score: rawScore, boosted: rawScore + boost, vault, isCurrentProject: Boolean(isCurrentProject) });
1754
1826
  }
1755
1827
  }
@@ -1774,13 +1846,17 @@ server.registerTool("recall", {
1774
1846
  const provenance = await getNoteProvenance(vault.git, filePath);
1775
1847
  const confidence = computeConfidence(note.lifecycle, note.updatedAt, centrality);
1776
1848
  let history;
1849
+ let historySummary;
1777
1850
  if (mode === "temporal") {
1778
1851
  if (index < TEMPORAL_HISTORY_NOTE_LIMIT) {
1779
1852
  const commits = await vault.git.getFileHistory(filePath, TEMPORAL_HISTORY_COMMIT_LIMIT);
1780
- history = await Promise.all(commits.map(async (commit) => {
1853
+ const rawHistory = await Promise.all(commits.map(async (commit) => {
1781
1854
  const stats = await vault.git.getCommitStats(filePath, commit.hash);
1782
1855
  return buildTemporalHistoryEntry(commit, stats, verbose);
1783
1856
  }));
1857
+ const enriched = enrichTemporalHistory(rawHistory);
1858
+ history = enriched.interpretedHistory;
1859
+ historySummary = enriched.historySummary;
1784
1860
  }
1785
1861
  }
1786
1862
  // Add relationship preview for top N results (fail-soft)
@@ -1810,6 +1886,7 @@ server.registerTool("recall", {
1810
1886
  provenance,
1811
1887
  confidence,
1812
1888
  history,
1889
+ historySummary,
1813
1890
  relationships,
1814
1891
  });
1815
1892
  }
@@ -2107,6 +2184,7 @@ server.registerTool("get", {
2107
2184
  "- Use `update`, `forget`, `move_memory`, or `relate` after inspection.",
2108
2185
  annotations: {
2109
2186
  readOnlyHint: true,
2187
+ destructiveHint: false,
2110
2188
  idempotentHint: true,
2111
2189
  openWorldHint: false,
2112
2190
  },
@@ -2204,6 +2282,7 @@ server.registerTool("where_is_memory", {
2204
2282
  "- Use `move_memory` if the storage location is wrong, or `get` for full inspection.",
2205
2283
  annotations: {
2206
2284
  readOnlyHint: true,
2285
+ destructiveHint: false,
2207
2286
  idempotentHint: true,
2208
2287
  openWorldHint: false,
2209
2288
  },
@@ -2260,6 +2339,7 @@ server.registerTool("list", {
2260
2339
  "- Use `get` for exact inspection or `update` / `consolidate` for cleanup.",
2261
2340
  annotations: {
2262
2341
  readOnlyHint: true,
2342
+ destructiveHint: false,
2263
2343
  idempotentHint: true,
2264
2344
  openWorldHint: false,
2265
2345
  },
@@ -2370,6 +2450,7 @@ server.registerTool("discover_tags", {
2370
2450
  "Read-only.",
2371
2451
  annotations: {
2372
2452
  readOnlyHint: true,
2453
+ destructiveHint: false,
2373
2454
  idempotentHint: true,
2374
2455
  openWorldHint: false,
2375
2456
  },
@@ -2576,6 +2657,7 @@ server.registerTool("recent_memories", {
2576
2657
  "- Use `get` for exact inspection or `update` to continue refining a recent note.",
2577
2658
  annotations: {
2578
2659
  readOnlyHint: true,
2660
+ destructiveHint: false,
2579
2661
  idempotentHint: true,
2580
2662
  openWorldHint: false,
2581
2663
  },
@@ -2645,6 +2727,7 @@ server.registerTool("memory_graph", {
2645
2727
  "- Use `get`, `relate`, `unrelate`, or `consolidate` based on what the graph reveals.",
2646
2728
  annotations: {
2647
2729
  readOnlyHint: true,
2730
+ destructiveHint: false,
2648
2731
  idempotentHint: true,
2649
2732
  openWorldHint: false,
2650
2733
  },
@@ -2724,6 +2807,7 @@ server.registerTool("project_memory_summary", {
2724
2807
  "- Use `recall` or `list` to drill down into specific areas.",
2725
2808
  annotations: {
2726
2809
  readOnlyHint: true,
2810
+ destructiveHint: false,
2727
2811
  idempotentHint: true,
2728
2812
  openWorldHint: false,
2729
2813
  },
@@ -2764,18 +2848,50 @@ server.registerTool("project_memory_summary", {
2764
2848
  return { content: [{ type: "text", text: `No memories found for project ${project.name}.` }], structuredContent };
2765
2849
  }
2766
2850
  const policyLine = await formatProjectPolicyLine(project.id);
2767
- // Build theme cache for connection diversity scoring (project-scoped only)
2768
- const themeCache = buildThemeCache(projectEntries.map(e => e.note));
2769
- // Categorize by theme (project-scoped only)
2851
+ const projectNoteIds = new Set(projectEntries.map(e => e.note.id));
2852
+ // Compute promoted themes from keywords (graduation system)
2853
+ const graduationResult = computeThemesWithGraduation(projectEntries.map(e => e.note));
2854
+ const promotedThemes = new Set(graduationResult.promotedThemes);
2855
+ const themeCache = graduationResult.themeAssignments;
2856
+ const inboundReferences = new Map();
2857
+ const linkedByPermanentNotes = new Map();
2858
+ for (const entry of projectEntries) {
2859
+ for (const rel of entry.note.relatedTo ?? []) {
2860
+ if (!projectNoteIds.has(rel.id)) {
2861
+ continue;
2862
+ }
2863
+ inboundReferences.set(rel.id, (inboundReferences.get(rel.id) ?? 0) + 1);
2864
+ if (entry.note.lifecycle === "permanent") {
2865
+ linkedByPermanentNotes.set(rel.id, (linkedByPermanentNotes.get(rel.id) ?? 0) + 1);
2866
+ }
2867
+ }
2868
+ }
2869
+ const effectiveMetadataById = new Map(projectEntries.map((entry) => {
2870
+ const inbound = inboundReferences.get(entry.note.id) ?? 0;
2871
+ const visibleOutbound = (entry.note.relatedTo ?? []).filter((rel) => projectNoteIds.has(rel.id)).length;
2872
+ const metadata = getEffectiveMetadata(entry.note, {
2873
+ inboundReferences: inbound,
2874
+ linkedByPermanentNotes: linkedByPermanentNotes.get(entry.note.id) ?? 0,
2875
+ anchorCandidate: entry.note.lifecycle === "permanent" && (visibleOutbound > 0 || inbound > 0),
2876
+ });
2877
+ return [entry.note.id, {
2878
+ metadata,
2879
+ inbound,
2880
+ visibleOutbound,
2881
+ }];
2882
+ }));
2883
+ // Categorize by theme with graduation (project-scoped only)
2770
2884
  const themed = new Map();
2771
2885
  for (const entry of projectEntries) {
2772
- const theme = classifyTheme(entry.note);
2886
+ const theme = classifyThemeWithGraduation(entry.note, promotedThemes);
2773
2887
  const bucket = themed.get(theme) ?? [];
2774
2888
  bucket.push(entry);
2775
2889
  themed.set(theme, bucket);
2776
2890
  }
2777
- // Theme order for display
2778
- const themeOrder = ["overview", "decisions", "tooling", "bugs", "architecture", "quality", "other"];
2891
+ // Theme order: fixed themes first, then promoted themes alphabetically, then "other"
2892
+ const fixedThemes = ["overview", "decisions", "tooling", "bugs", "architecture", "quality"];
2893
+ const dynamicThemes = graduationResult.promotedThemes.filter(t => !fixedThemes.includes(t));
2894
+ const themeOrder = [...fixedThemes, ...dynamicThemes.sort(), "other"];
2779
2895
  // Calculate notes distribution (project-scoped only)
2780
2896
  const projectVaultCount = projectEntries.filter(e => e.vault.isProject).length;
2781
2897
  const mainVaultProjectEntries = projectEntries.filter(e => !e.vault.isProject);
@@ -2796,7 +2912,7 @@ server.registerTool("project_memory_summary", {
2796
2912
  if (!bucket || bucket.length === 0)
2797
2913
  continue;
2798
2914
  // Sort by within-theme score
2799
- const sorted = [...bucket].sort((a, b) => withinThemeScore(b.note) - withinThemeScore(a.note));
2915
+ const sorted = [...bucket].sort((a, b) => withinThemeScore(b.note, effectiveMetadataById.get(b.note.id)?.metadata) - withinThemeScore(a.note, effectiveMetadataById.get(a.note.id)?.metadata));
2800
2916
  const top = sorted.slice(0, maxPerTheme);
2801
2917
  sections.push(`\n${titleCaseTheme(theme)}:`);
2802
2918
  sections.push(...top.map(e => `- ${e.note.title} (\`${e.note.id}\`)`));
@@ -2816,49 +2932,32 @@ server.registerTool("project_memory_summary", {
2816
2932
  sections.push(`\nRecent activity (start here):`);
2817
2933
  sections.push(...recent.map(e => `- ${e.note.updatedAt} — ${e.note.title}`));
2818
2934
  // Anchor notes with diversity constraint (project-scoped only)
2819
- // Separate tagged anchors (can have no relationships) from scored anchors (need relationships)
2820
- const taggedAnchorEntries = projectEntries.filter(e => e.note.lifecycle === "permanent" &&
2821
- e.note.tags.some(t => t.toLowerCase() === "anchor" || t.toLowerCase() === "alwaysload"));
2822
2935
  const scoredAnchorCandidates = projectEntries
2823
- .filter(e => e.note.lifecycle === "permanent" && (e.note.relatedTo?.length ?? 0) > 0)
2824
- .map(e => ({
2825
- entry: e,
2826
- score: anchorScore(e.note, themeCache),
2827
- theme: classifyTheme(e.note),
2828
- }))
2829
- .filter(x => x.score > -Infinity)
2830
- .sort((a, b) => b.score - a.score);
2831
- // Score tagged anchors too, so they can compete for primaryEntry
2832
- const scoredTaggedAnchors = taggedAnchorEntries
2833
- .map(e => ({
2834
- entry: e,
2835
- score: anchorScore(e.note, themeCache),
2836
- theme: classifyTheme(e.note),
2837
- }))
2838
- .sort((a, b) => b.score - a.score);
2936
+ .map(e => {
2937
+ const baselineContext = effectiveMetadataById.get(e.note.id);
2938
+ const metadata = baselineContext?.metadata;
2939
+ return {
2940
+ entry: e,
2941
+ metadata,
2942
+ score: anchorScore(e.note, themeCache, metadata),
2943
+ theme: themeCache.get(e.note.id) ?? "other",
2944
+ alwaysLoad: metadata?.alwaysLoad === true,
2945
+ explicitOrientationRole: metadata?.roleSource === "explicit" &&
2946
+ (metadata.role === "summary" || metadata.role === "decision"),
2947
+ hasVisibleGraphParticipation: (baselineContext?.visibleOutbound ?? 0) > 0 || (baselineContext?.inbound ?? 0) > 0,
2948
+ };
2949
+ })
2950
+ .filter(candidate => candidate.score > -Infinity)
2951
+ .filter(candidate => candidate.alwaysLoad || candidate.explicitOrientationRole || candidate.hasVisibleGraphParticipation)
2952
+ .sort((a, b) => b.score - a.score || a.entry.note.title.localeCompare(b.entry.note.title));
2839
2953
  // Enforce max 2 per theme for scored anchors
2840
2954
  const anchorThemeCounts = new Map();
2841
2955
  const anchors = [];
2842
2956
  const anchorIds = new Set();
2843
- // Add tagged anchors first (capped at 10 total across all themes), scored by anchorScore
2844
- for (const candidate of scoredTaggedAnchors.slice(0, 10)) {
2845
- if (anchors.length >= 10)
2846
- break;
2847
- anchors.push({
2848
- id: candidate.entry.note.id,
2849
- title: candidate.entry.note.title,
2850
- centrality: candidate.entry.note.relatedTo?.length ?? 0,
2851
- connectionDiversity: computeConnectionDiversity(candidate.entry.note, themeCache),
2852
- updatedAt: candidate.entry.note.updatedAt,
2853
- });
2854
- anchorIds.add(candidate.entry.note.id);
2855
- }
2856
2957
  // Add scored anchors with theme diversity constraint
2857
2958
  for (const candidate of scoredAnchorCandidates) {
2858
2959
  if (anchors.length >= 10)
2859
2960
  break;
2860
- if (anchorIds.has(candidate.entry.note.id))
2861
- continue;
2862
2961
  const theme = candidate.theme;
2863
2962
  const themeCount = anchorThemeCounts.get(theme) ?? 0;
2864
2963
  if (themeCount >= 2)
@@ -3051,7 +3150,7 @@ server.registerTool("project_memory_summary", {
3051
3150
  id: e.note.id,
3052
3151
  title: e.note.title,
3053
3152
  updatedAt: e.note.updatedAt,
3054
- theme: classifyTheme(e.note),
3153
+ theme: classifyThemeWithGraduation(e.note, promotedThemes),
3055
3154
  })),
3056
3155
  anchors,
3057
3156
  orientation,
@@ -3403,6 +3502,7 @@ server.registerTool("relate", {
3403
3502
  cwd,
3404
3503
  vault,
3405
3504
  mutationApplied: true,
3505
+ preferredRecovery: "rerun-tool-call-serial",
3406
3506
  });
3407
3507
  const structuredContent = {
3408
3508
  action: "related",
@@ -3452,6 +3552,7 @@ server.registerTool("relate", {
3452
3552
  cwd,
3453
3553
  vault,
3454
3554
  mutationApplied: true,
3555
+ preferredRecovery: "rerun-tool-call-serial",
3455
3556
  });
3456
3557
  }
3457
3558
  if (commitStatus.status === "committed") {
@@ -3574,6 +3675,7 @@ server.registerTool("unrelate", {
3574
3675
  cwd,
3575
3676
  vault,
3576
3677
  mutationApplied: true,
3678
+ preferredRecovery: "rerun-tool-call-serial",
3577
3679
  });
3578
3680
  const structuredContent = {
3579
3681
  action: "unrelated",
@@ -3617,6 +3719,7 @@ server.registerTool("unrelate", {
3617
3719
  cwd,
3618
3720
  vault,
3619
3721
  mutationApplied: true,
3722
+ preferredRecovery: "rerun-tool-call-serial",
3620
3723
  });
3621
3724
  }
3622
3725
  if (commitStatus.status === "committed") {
@@ -4512,18 +4615,13 @@ server.registerPrompt("mnemonic-workflow-hint", {
4512
4615
  type: "text",
4513
4616
  text: "## Mnemonic MCP workflow hints\n\n" +
4514
4617
  "Avoid duplicate memories. Prefer inspecting and updating existing memories before creating new ones.\n\n" +
4515
- "### Hard rules\n\n" +
4516
4618
  "- REQUIRES: Before `remember`, call `recall` or `list` first.\n" +
4517
4619
  "- If `recall` or `list` returns a plausible match, call `get` before deciding whether to `update` or `remember`.\n" +
4518
4620
  "- If an existing memory already covers the topic, use `update`, not `remember`.\n" +
4519
4621
  "- When unsure, prefer `recall` over `remember`.\n" +
4520
4622
  "- For repo-related tasks, pass `cwd` so mnemonic can route project memories correctly.\n\n" +
4521
- "### Decision protocol\n\n" +
4522
- "1. Need to store, refine, or connect knowledge about a topic? Start with `recall`, or use `list` when you need deterministic browsing by scope, storage, or tags.\n" +
4523
- "2. If `recall` or `list` returns matching ids, use `get` to inspect the best match.\n" +
4524
- "3. If one memory should be refined, call `update`.\n" +
4525
- "4. If no memory covers the topic and tag choice is ambiguous, call `discover_tags` with note context, then call `remember`.\n" +
4526
- "5. After storing or updating, use `relate` for strong connections, `consolidate` for overlap, and `move_memory` for wrong storage location.\n\n" +
4623
+ "Workflow: `recall`/`list` -> `get` -> `update` or `remember` -> `relate`/`consolidate`/`move_memory`. Use `discover_tags` only when tag choice is ambiguous.\n\n" +
4624
+ "Roles are optional prioritization hints, not schema. Lifecycle still governs durability. `role: plan` does not imply `temporary`. Inferred roles are internal hints only. Prioritization is language-independent by default.\n\n" +
4527
4625
  "### Anti-patterns\n\n" +
4528
4626
  "- Bad: call `remember` immediately because the user said 'remember'.\n" +
4529
4627
  "- Good: `recall` or `list` first, then `get`, then `update` or `remember`.\n" +