@danielmarbach/mnemonic-mcp 0.26.1 → 0.27.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/build/index.js CHANGED
@@ -12,13 +12,14 @@ import { enrichTemporalHistory } from "./temporal-interpretation.js";
12
12
  import { getOrBuildProjection } from "./projections.js";
13
13
  import { invalidateActiveProjectCache, getOrBuildVaultEmbeddings, getOrBuildVaultNoteList, getRecentSessionNoteAccesses, getRecentSessionAccessNote, getSessionCachedNote, getSessionCachedProjection, getSessionCachedProjectionTokens, recordSessionNoteAccess, setSessionCachedNote, setSessionCachedProjection, setSessionCachedProjectionTokens, } from "./cache.js";
14
14
  import { performance } from "perf_hooks";
15
- import { filterRelationships, mergeRelationshipsFromNotes, normalizeMergePlanSourceIds, resolveEffectiveConsolidationMode, } from "./consolidate.js";
15
+ import { aggregateMergeRisk, buildConsolidateNoteEvidence, buildGroupWarnings, filterRelationships, mergeRelationshipsFromNotes, normalizeMergePlanSourceIds, resolveEffectiveConsolidationMode, } from "./consolidate.js";
16
16
  import { suggestAutoRelationships } from "./auto-relate.js";
17
17
  import { computeRecallMetadataBoost, computeHybridScore, selectRecallResults, selectWorkflowResults, applyLexicalReranking, enrichRescueCandidateScores, resolveDiscoveredVaults, applyCanonicalExplanationPromotion, applyGraphSpreadingActivation, assignDenseRanks, detectTemporalQueryHint, computeTemporalRecencyBoost, shouldApplyTemporalFiltering, isWithinTemporalFilterWindow, } from "./recall.js";
18
18
  import { shouldTriggerLexicalRescue, prepareTfIdfCorpusFromTokenizedDocuments, rankDocumentsByTfIdf, LEXICAL_RESCUE_CANDIDATE_LIMIT, LEXICAL_RESCUE_THRESHOLD, LEXICAL_RESCUE_RESULT_LIMIT, tokenize, } from "./lexical.js";
19
19
  import { getRelationshipPreview } from "./relationships.js";
20
20
  import { MarkdownLintError, cleanMarkdown } from "./markdown.js";
21
21
  import { applySemanticPatches } from "./semantic-patch.js";
22
+ import { hasActualChanges, computeFieldsModified } from "./update-detect-changes.js";
22
23
  import { MnemonicConfigStore, readVaultSchemaVersion } from "./config.js";
23
24
  import { CONSOLIDATION_MODES, PROTECTED_BRANCH_BEHAVIORS, PROJECT_POLICY_SCOPES, WRITE_SCOPES, isProtectedBranch, resolveProtectedBranchBehavior, resolveProtectedBranchPatterns, resolveConsolidationMode, resolveWriteScope, } from "./project-memory-policy.js";
24
25
  import { classifyTheme, classifyThemeWithGraduation, computeThemesWithGraduation, summarizePreview, titleCaseTheme, daysSinceUpdate, withinThemeScore, anchorScore, computeConnectionDiversity, workingStateScore, extractNextAction, } from "./project-introspection.js";
@@ -438,6 +439,34 @@ function formatRelationshipPreview(preview) {
438
439
  : "";
439
440
  return `**related (${preview.totalDirectRelations}):** ${shown}${more}`;
440
441
  }
442
+ function toRecallFreshness(updatedAt) {
443
+ const updated = new Date(updatedAt);
444
+ if (Number.isNaN(updated.getTime())) {
445
+ return "older";
446
+ }
447
+ const ageDays = Math.max(0, (Date.now() - updated.getTime()) / (1000 * 60 * 60 * 24));
448
+ if (ageDays <= 1)
449
+ return "today";
450
+ if (ageDays <= 7)
451
+ return "thisWeek";
452
+ if (ageDays <= 31)
453
+ return "thisMonth";
454
+ return "older";
455
+ }
456
+ function toRecallRankBand(semanticRank) {
457
+ if (semanticRank !== undefined && semanticRank <= 3)
458
+ return "top3";
459
+ if (semanticRank !== undefined && semanticRank <= 10)
460
+ return "top10";
461
+ return "lower";
462
+ }
463
+ function formatRetrievalEvidenceHint(evidence, role) {
464
+ const rolePart = role ?? "untyped";
465
+ const supersedesPart = evidence.superseded
466
+ ? ` | supersedes ${evidence.supersededCount ?? 1} note${(evidence.supersededCount ?? 1) === 1 ? "" : "s"}`
467
+ : "";
468
+ return `${evidence.rankBand} | channels: ${evidence.channels.join(", ")} | ${evidence.projectRelevant ? "project-relevant" : "cross-project"}\n${rolePart}, ${evidence.freshness}${supersedesPart}`;
469
+ }
441
470
  // ── Git commit message helpers ────────────────────────────────────────────────
442
471
  /**
443
472
  * Extract a short human-readable summary from note content.
@@ -1899,7 +1928,8 @@ server.registerTool("recall", {
1899
1928
  "Returns:\n" +
1900
1929
  "- Ranked memory matches with scores, vault label, tags, lifecycle, and updated time\n" +
1901
1930
  "- Bounded 1-hop relationship previews automatically attached to top results\n" +
1902
- "- In temporal mode, optional compact history entries for top matches\n\n" +
1931
+ "- In temporal mode, optional compact history entries for top matches\n" +
1932
+ "- Optional retrieval evidence via `evidence: \"compact\"` for why a result ranked\n\n" +
1903
1933
  "Read-only.\n\n" +
1904
1934
  "Typical next step:\n" +
1905
1935
  "- Use `get`, `update`, `relate`, or `consolidate` based on the results.",
@@ -1916,6 +1946,7 @@ server.registerTool("recall", {
1916
1946
  minSimilarity: z.number().min(0).max(1).optional().default(DEFAULT_MIN_SIMILARITY),
1917
1947
  mode: z.enum(["default", "temporal", "workflow"]).optional().default("default").describe("Use `temporal` for compact git-backed history, or `workflow` for RPIR-oriented chain reconstruction."),
1918
1948
  verbose: z.boolean().optional().default(false).describe("Only meaningful with `mode: \"temporal\"`. Adds richer stats-based history context without returning raw diffs."),
1949
+ evidence: z.enum(["compact"]).optional().describe("Optional retrieval rationale. Omit for default output; use `compact` for bounded rank and lineage signals."),
1919
1950
  tags: z.array(z.string()).optional().describe("Filter results to notes with all of these tags."),
1920
1951
  scope: z
1921
1952
  .enum(["project", "global", "all"])
@@ -1927,7 +1958,7 @@ server.registerTool("recall", {
1927
1958
  lifecycle: z.enum(["temporary", "permanent"]).optional().describe("Filter results by lifecycle. Useful for recovering working-state with `lifecycle: temporary` after `project_memory_summary` orientation."),
1928
1959
  }),
1929
1960
  outputSchema: RecallResultSchema,
1930
- }, async ({ query, cwd, limit, minSimilarity, mode, verbose, tags, scope, lifecycle }) => {
1961
+ }, async ({ query, cwd, limit, minSimilarity, mode, verbose, evidence, tags, scope, lifecycle }) => {
1931
1962
  const t0Recall = performance.now();
1932
1963
  await ensureBranchSynced(cwd);
1933
1964
  const project = await resolveProject(cwd);
@@ -2048,12 +2079,14 @@ server.registerTool("recall", {
2048
2079
  };
2049
2080
  const strongestSemanticScore = scored.reduce((max, candidate) => max === undefined ? candidate.score : Math.max(max, candidate.score), undefined);
2050
2081
  const reranked = applyLexicalReranking(scored, query, getProjectionText);
2082
+ const semanticCandidateIds = new Set(reranked.map((candidate) => candidate.id));
2051
2083
  // Apply graph spreading activation: traverse related notes and boost their scores
2052
2084
  const preSpreadIds = new Set(reranked.map((c) => c.id));
2053
2085
  const getNoteRelationships = (id) => {
2054
2086
  return noteRelationships.get(id);
2055
2087
  };
2056
2088
  const withGraphSpread = applyGraphSpreadingActivation(reranked, getNoteRelationships);
2089
+ const graphDiscoveredIds = new Set(withGraphSpread.filter((candidate) => !semanticCandidateIds.has(candidate.id)).map((candidate) => candidate.id));
2057
2090
  // Resolve correct vault for graph-discovered candidates that inherited their
2058
2091
  // entry point's vault instead of their own.
2059
2092
  await resolveDiscoveredVaults(withGraphSpread, preSpreadIds, async (id) => {
@@ -2073,6 +2106,7 @@ server.registerTool("recall", {
2073
2106
  candidate.semanticRank = rank;
2074
2107
  });
2075
2108
  let promoted = applyCanonicalExplanationPromotion(withGraphSpread);
2109
+ let rescueCandidateIds = new Set();
2076
2110
  // Lexical rescue: when semantic results are weak, scan projections for additional candidates.
2077
2111
  // Skip rescue when the caller set a strict minSimilarity above the default,
2078
2112
  // because rescue candidates lack genuine semantic backing.
@@ -2080,6 +2114,7 @@ server.registerTool("recall", {
2080
2114
  if (rescueAllowed && shouldTriggerLexicalRescue(strongestSemanticScore, scored.length)) {
2081
2115
  const rescueCandidates = await collectLexicalRescueCandidates(vaults, query, temporalQueryHint, project ?? undefined, scope, tags, lifecycle, promoted);
2082
2116
  promoted.push(...rescueCandidates);
2117
+ rescueCandidateIds = new Set(rescueCandidates.map((candidate) => candidate.id));
2083
2118
  enrichRescueCandidateScores(promoted, query, getProjectionText);
2084
2119
  promoted = applyCanonicalExplanationPromotion(promoted);
2085
2120
  }
@@ -2098,7 +2133,7 @@ server.registerTool("recall", {
2098
2133
  // Determine how many top results get relationship expansion
2099
2134
  // Top 1 by default, top 3 if result count is small
2100
2135
  const recallRelationshipLimit = top.length <= 3 ? 3 : 1;
2101
- for (const [index, { id, score, vault, boosted }] of top.entries()) {
2136
+ for (const [index, { id, score, vault, boosted, semanticRank, lexicalRank, canonicalExplanationScore, metadata, isCurrentProject }] of top.entries()) {
2102
2137
  const note = await readCachedNote(vault, id);
2103
2138
  if (note) {
2104
2139
  const centrality = note.relatedTo?.length ?? 0;
@@ -2135,8 +2170,30 @@ server.registerTool("recall", {
2135
2170
  const provenanceLine = provenance || confidence
2136
2171
  ? `\n**confidence:** ${confidence ?? "medium"}${provenance?.recentlyChanged ? " | **recently changed**" : ""}`
2137
2172
  : "";
2173
+ const supersededRelations = (note.relatedTo ?? []).filter((rel) => rel.type === "supersedes");
2174
+ const retrievalEvidence = evidence === "compact"
2175
+ ? {
2176
+ channels: [
2177
+ semanticRank !== undefined ? "semantic" : undefined,
2178
+ lexicalRank !== undefined ? "lexical" : undefined,
2179
+ graphDiscoveredIds.has(id) ? "graph" : undefined,
2180
+ rescueCandidateIds.has(id) ? "rescue" : undefined,
2181
+ canonicalExplanationScore !== undefined && canonicalExplanationScore > 0 ? "canonical" : undefined,
2182
+ temporalQueryHint ? "temporal-boost" : undefined,
2183
+ ].filter((value) => value !== undefined),
2184
+ rankBand: toRecallRankBand(semanticRank),
2185
+ projectRelevant: isCurrentProject,
2186
+ freshness: toRecallFreshness(note.updatedAt),
2187
+ superseded: supersededRelations.length > 0,
2188
+ supersededBy: supersededRelations.length > 0 ? supersededRelations[0]?.id : undefined,
2189
+ supersededCount: supersededRelations.length > 0 ? supersededRelations.length : undefined,
2190
+ }
2191
+ : undefined;
2192
+ const evidenceLine = retrievalEvidence
2193
+ ? `\n${formatRetrievalEvidenceHint(retrievalEvidence, metadata?.role)}`
2194
+ : "";
2138
2195
  // Suppress raw related IDs when enriched preview is shown to avoid duplication
2139
- sections.push(`${formatNote(note, score, relationships === undefined)}${provenanceLine}${formattedHistory}${formattedRelationships}`);
2196
+ sections.push(`${formatNote(note, score, relationships === undefined)}${provenanceLine}${evidenceLine}${formattedHistory}${formattedRelationships}`);
2140
2197
  structuredResults.push({
2141
2198
  id,
2142
2199
  title: note.title,
@@ -2153,6 +2210,7 @@ server.registerTool("recall", {
2153
2210
  history,
2154
2211
  historySummary,
2155
2212
  relationships,
2213
+ retrievalEvidence,
2156
2214
  });
2157
2215
  if (project) {
2158
2216
  setSessionCachedNote(project.id, vault.storage.vaultPath, note);
@@ -2316,64 +2374,136 @@ server.registerTool("update", {
2316
2374
  }
2317
2375
  }
2318
2376
  const cleanedContent = content === undefined ? undefined : await cleanMarkdown(content);
2319
- const updated = {
2320
- ...note,
2321
- title: title ?? note.title,
2322
- content: patchedContent ?? cleanedContent ?? note.content,
2323
- tags: tags ?? note.tags,
2324
- lifecycle: lifecycle ?? note.lifecycle,
2325
- ...(role !== undefined ? { role } : (note.role ? { role: note.role } : {})),
2326
- alwaysLoad: alwaysLoad !== undefined ? alwaysLoad : note.alwaysLoad,
2327
- updatedAt: now,
2328
- };
2329
- if (updated.project) {
2330
- const accessCandidates = getRecentSessionNoteAccesses(updated.project)
2377
+ const resolvedTitle = title ?? note.title;
2378
+ const resolvedContent = patchedContent ?? cleanedContent ?? note.content;
2379
+ const resolvedTags = tags ?? note.tags;
2380
+ const resolvedLifecycle = lifecycle ?? note.lifecycle;
2381
+ const resolvedRole = role !== undefined ? role : (note.role ? note.role : undefined);
2382
+ const resolvedAlwaysLoad = alwaysLoad !== undefined ? alwaysLoad : note.alwaysLoad;
2383
+ let relatedToChanged = false;
2384
+ let resolvedRelatedTo = note.relatedTo;
2385
+ if (note.project) {
2386
+ const accessCandidates = getRecentSessionNoteAccesses(note.project)
2331
2387
  .map((entry) => {
2332
- const cachedNote = getSessionCachedNote(updated.project, entry.vaultPath, entry.noteId)
2333
- ?? getRecentSessionAccessNote(updated.project, entry.vaultPath, entry.noteId);
2388
+ const cachedNote = getSessionCachedNote(note.project, entry.vaultPath, entry.noteId)
2389
+ ?? getRecentSessionAccessNote(note.project, entry.vaultPath, entry.noteId);
2334
2390
  return cachedNote
2335
2391
  ? { note: cachedNote, accessedAt: entry.accessedAt, accessKind: entry.accessKind, score: entry.score }
2336
2392
  : null;
2337
2393
  })
2338
2394
  .filter((entry) => entry !== null);
2339
- const autoRelationships = suggestAutoRelationships(updated, accessCandidates);
2395
+ const autoRelationships = suggestAutoRelationships({
2396
+ ...note,
2397
+ title: resolvedTitle,
2398
+ content: resolvedContent,
2399
+ tags: resolvedTags,
2400
+ lifecycle: resolvedLifecycle,
2401
+ alwaysLoad: resolvedAlwaysLoad,
2402
+ }, accessCandidates);
2340
2403
  if (autoRelationships.length > 0) {
2341
- const existing = [...(updated.relatedTo ?? [])];
2404
+ const existing = [...(note.relatedTo ?? [])];
2342
2405
  for (const relationship of autoRelationships) {
2343
2406
  if (!existing.some((rel) => rel.id === relationship.id && rel.type === relationship.type)) {
2344
2407
  existing.push(relationship);
2345
2408
  }
2346
2409
  }
2347
- updated.relatedTo = existing;
2348
- }
2410
+ resolvedRelatedTo = existing;
2411
+ relatedToChanged = true;
2412
+ }
2413
+ }
2414
+ const changes = computeFieldsModified({
2415
+ patchedContent,
2416
+ originalContent: note.content,
2417
+ contentExplicitlyProvided: content !== undefined,
2418
+ semanticPatchProvided: semanticPatch !== undefined && semanticPatch.length > 0,
2419
+ newTitle: resolvedTitle,
2420
+ originalTitle: note.title,
2421
+ titleExplicitlyProvided: title !== undefined,
2422
+ newLifecycle: resolvedLifecycle,
2423
+ originalLifecycle: note.lifecycle,
2424
+ lifecycleExplicitlyProvided: lifecycle !== undefined,
2425
+ newRole: resolvedRole,
2426
+ originalRole: note.role,
2427
+ roleExplicitlySet: role !== undefined,
2428
+ newTags: resolvedTags,
2429
+ originalTags: note.tags,
2430
+ tagsExplicitlyProvided: tags !== undefined,
2431
+ newAlwaysLoad: resolvedAlwaysLoad,
2432
+ originalAlwaysLoad: note.alwaysLoad,
2433
+ alwaysLoadExplicitlyProvided: alwaysLoad !== undefined,
2434
+ relatedToChanged,
2435
+ });
2436
+ const hasChanges = hasActualChanges({
2437
+ content: cleanedContent,
2438
+ originalContent: note.content,
2439
+ title,
2440
+ originalTitle: note.title,
2441
+ tags,
2442
+ originalTags: note.tags,
2443
+ lifecycle,
2444
+ originalLifecycle: note.lifecycle,
2445
+ role,
2446
+ originalRole: note.role,
2447
+ roleExplicitlySet: role !== undefined,
2448
+ alwaysLoad,
2449
+ originalAlwaysLoad: note.alwaysLoad,
2450
+ semanticPatchApplied: semanticPatch !== undefined && semanticPatch.length > 0,
2451
+ relatedToChanged,
2452
+ });
2453
+ if (!hasChanges) {
2454
+ const noOpPersistence = {
2455
+ notePath: vault.storage.notePath(id),
2456
+ embeddingPath: vault.storage.embeddingPath(id),
2457
+ embedding: { status: "skipped", model: embedModel, reason: "no-changes" },
2458
+ git: {
2459
+ commit: "skipped",
2460
+ push: "skipped",
2461
+ commitReason: "no-changes",
2462
+ pushReason: "no-changes",
2463
+ },
2464
+ durability: "local-only",
2465
+ };
2466
+ return {
2467
+ content: [{ type: "text", text: `No changes to memory '${id}'` }],
2468
+ structuredContent: {
2469
+ action: "updated",
2470
+ id,
2471
+ title: note.title,
2472
+ fieldsModified: [],
2473
+ timestamp: note.updatedAt,
2474
+ project: noteProjectRef(note),
2475
+ lifecycle: note.lifecycle,
2476
+ role: note.role,
2477
+ persistence: noOpPersistence,
2478
+ },
2479
+ };
2349
2480
  }
2481
+ const updated = {
2482
+ ...note,
2483
+ title: resolvedTitle,
2484
+ content: resolvedContent,
2485
+ tags: resolvedTags,
2486
+ lifecycle: resolvedLifecycle,
2487
+ ...(role !== undefined ? { role: resolvedRole } : (note.role ? { role: note.role } : {})),
2488
+ alwaysLoad: resolvedAlwaysLoad,
2489
+ updatedAt: now,
2490
+ relatedTo: resolvedRelatedTo,
2491
+ };
2350
2492
  await vault.storage.writeNote(updated);
2351
- let embeddingStatus = { status: "written" };
2352
- try {
2353
- const text = await embedTextForNote(vault.storage, updated);
2354
- const vector = await embed(text);
2355
- await vault.storage.writeEmbedding({ id, model: embedModel, embedding: vector, updatedAt: now });
2493
+ const shouldReembed = patchedContent !== undefined || cleanedContent !== undefined;
2494
+ let embeddingStatus = { status: "skipped", reason: shouldReembed ? undefined : "metadata-only" };
2495
+ if (shouldReembed) {
2496
+ try {
2497
+ const text = await embedTextForNote(vault.storage, updated);
2498
+ const vector = await embed(text);
2499
+ await vault.storage.writeEmbedding({ id, model: embedModel, embedding: vector, updatedAt: now });
2500
+ embeddingStatus = { status: "written" };
2501
+ }
2502
+ catch (err) {
2503
+ embeddingStatus = { status: "skipped", reason: err instanceof Error ? err.message : String(err) };
2504
+ console.error(`[embedding] Re-embed failed for '${id}': ${err}`);
2505
+ }
2356
2506
  }
2357
- catch (err) {
2358
- embeddingStatus = { status: "skipped", reason: err instanceof Error ? err.message : String(err) };
2359
- console.error(`[embedding] Re-embed failed for '${id}': ${err}`);
2360
- }
2361
- // Build change summary (LLM-provided or auto-generated)
2362
- const changes = [];
2363
- if (title !== undefined && title !== note.title)
2364
- changes.push("title");
2365
- if (content !== undefined)
2366
- changes.push("content");
2367
- if (semanticPatch !== undefined)
2368
- changes.push("semanticPatch");
2369
- if (tags !== undefined)
2370
- changes.push("tags");
2371
- if (lifecycle !== undefined && lifecycle !== note.lifecycle)
2372
- changes.push("lifecycle");
2373
- if (role !== undefined && role !== note.role)
2374
- changes.push("role");
2375
- if (alwaysLoad !== undefined && alwaysLoad !== note.alwaysLoad)
2376
- changes.push("alwaysLoad");
2377
2507
  const changeDesc = changes.length > 0 ? `Updated ${changes.join(", ")}` : "No changes";
2378
2508
  const commitSummary = summary ?? changeDesc;
2379
2509
  const commitBody = formatCommitBody({
@@ -4260,7 +4390,8 @@ server.registerTool("consolidate", {
4260
4390
  "- The canonical memory, source ids, resulting relationships, and persistence status\n\n" +
4261
4391
  "Side effects: creates or updates the canonical note, modifies or removes source notes according to mode, git commits, and may push.\n\n" +
4262
4392
  "Typical next step:\n" +
4263
- "- Use `get` to inspect the canonical note and `recall` to confirm duplication is reduced.",
4393
+ "- Use `get` to inspect the canonical note and `recall` to confirm duplication is reduced.\n" +
4394
+ "- Evidence defaults on for consolidate analysis strategies and execute-merge (lifecycle, risk, warnings).",
4264
4395
  annotations: {
4265
4396
  readOnlyHint: false,
4266
4397
  destructiveHint: true,
@@ -4280,7 +4411,8 @@ server.registerTool("consolidate", {
4280
4411
  ])
4281
4412
  .describe("What to do: 'dry-run' = full analysis without changes, 'detect-duplicates' = find similar pairs, " +
4282
4413
  "'find-clusters' = group by theme and relationships, 'suggest-merges' = actionable merge recommendations, " +
4283
- "'execute-merge' = perform a merge (requires mergePlan), 'prune-superseded' = delete notes marked as superseded"),
4414
+ "'execute-merge' = perform a merge (requires mergePlan), 'prune-superseded' = delete notes marked as superseded. " +
4415
+ "Use `evidence: true` on analysis strategies for trust/risk signals."),
4284
4416
  mode: z
4285
4417
  .enum(CONSOLIDATION_MODES)
4286
4418
  .optional()
@@ -4292,6 +4424,10 @@ server.registerTool("consolidate", {
4292
4424
  .optional()
4293
4425
  .default(0.85)
4294
4426
  .describe("Cosine similarity threshold for duplicate detection (0.85 default)"),
4427
+ evidence: z
4428
+ .boolean()
4429
+ .optional()
4430
+ .describe("Confidence signals for analysis strategies and execute-merge (lifecycle, risk, warnings). Default true for safety."),
4295
4431
  mergePlan: z
4296
4432
  .object({
4297
4433
  sourceIds: z.array(z.string()).min(2).describe("Ids of notes to merge into one consolidated note"),
@@ -4310,7 +4446,7 @@ server.registerTool("consolidate", {
4310
4446
  "When true, consolidate can commit on a protected branch without changing project policy."),
4311
4447
  }),
4312
4448
  outputSchema: ConsolidateResultSchema,
4313
- }, async ({ cwd, strategy, mode, threshold, mergePlan, allowProtectedBranch = false }) => {
4449
+ }, async ({ cwd, strategy, mode, threshold, evidence = true, mergePlan, allowProtectedBranch = false }) => {
4314
4450
  await ensureBranchSynced(cwd);
4315
4451
  const project = await resolveProject(cwd);
4316
4452
  if (!project && cwd) {
@@ -4330,16 +4466,16 @@ server.registerTool("consolidate", {
4330
4466
  const defaultConsolidationMode = resolveConsolidationMode(policy);
4331
4467
  switch (strategy) {
4332
4468
  case "detect-duplicates":
4333
- return detectDuplicates(projectNotes, threshold, project);
4469
+ return detectDuplicates(projectNotes, threshold, project, evidence);
4334
4470
  case "find-clusters":
4335
4471
  return findClusters(projectNotes, project);
4336
4472
  case "suggest-merges":
4337
- return suggestMerges(projectNotes, threshold, defaultConsolidationMode, project, mode);
4473
+ return suggestMerges(projectNotes, threshold, defaultConsolidationMode, project, mode, evidence);
4338
4474
  case "execute-merge": {
4339
4475
  if (!mergePlan) {
4340
4476
  return { content: [{ type: "text", text: "execute-merge strategy requires a mergePlan with sourceIds and targetTitle." }], isError: true };
4341
4477
  }
4342
- const mergeResult = await executeMerge(entries, mergePlan, defaultConsolidationMode, project, cwd, mode, policy, allowProtectedBranch);
4478
+ const mergeResult = await executeMerge(entries, mergePlan, defaultConsolidationMode, project, cwd, mode, policy, allowProtectedBranch, evidence);
4343
4479
  invalidateActiveProjectCache();
4344
4480
  return mergeResult;
4345
4481
  }
@@ -4349,20 +4485,22 @@ server.registerTool("consolidate", {
4349
4485
  return pruneResult;
4350
4486
  }
4351
4487
  case "dry-run":
4352
- return dryRunAll(projectNotes, threshold, defaultConsolidationMode, project, mode);
4488
+ return dryRunAll(projectNotes, threshold, defaultConsolidationMode, project, mode, evidence);
4353
4489
  default:
4354
4490
  return { content: [{ type: "text", text: `Unknown strategy: ${strategy}` }], isError: true };
4355
4491
  }
4356
4492
  });
4357
4493
  // Consolidate helper functions
4358
- async function detectDuplicates(entries, threshold, project) {
4494
+ async function detectDuplicates(entries, threshold, project, evidence) {
4359
4495
  const lines = [];
4360
4496
  lines.push(`Duplicate detection for ${project?.name ?? "global"} (similarity > ${threshold}):`);
4361
4497
  lines.push("");
4362
4498
  const checked = new Set();
4363
4499
  let foundCount = 0;
4364
4500
  const duplicates = [];
4501
+ const duplicatePairs = [];
4365
4502
  const embeddings = await loadEmbeddingsByNoteId(entries);
4503
+ const allNotes = entries.map((entry) => entry.note);
4366
4504
  for (let i = 0; i < entries.length; i++) {
4367
4505
  const entryA = entries[i];
4368
4506
  if (checked.has(entryA.note.id))
@@ -4379,10 +4517,22 @@ async function detectDuplicates(entries, threshold, project) {
4379
4517
  continue;
4380
4518
  const similarity = cosineSimilarity(embeddingA, embeddingB);
4381
4519
  if (similarity >= threshold) {
4520
+ const noteAEvidence = buildConsolidateNoteEvidence(entryA.note, allNotes, entryA.note);
4521
+ const noteBEvidence = buildConsolidateNoteEvidence(entryB.note, allNotes, entryA.note);
4522
+ const groupWarnings = buildGroupWarnings([entryA.note, entryB.note], entryA.note);
4523
+ const pairRisk = aggregateMergeRisk([noteAEvidence.mergeRisk, noteBEvidence.mergeRisk]);
4382
4524
  foundCount++;
4383
4525
  lines.push(`${foundCount}. ${entryA.note.title} (${entryA.note.id})`);
4384
4526
  lines.push(` └── ${entryB.note.title} (${entryB.note.id})`);
4385
4527
  lines.push(` Similarity: ${similarity.toFixed(3)}`);
4528
+ if (evidence) {
4529
+ lines.push(` A: ${noteAEvidence.lifecycle}, ${noteAEvidence.role ?? "untyped"} | ${Math.round(noteAEvidence.ageDays)}d old | rel: ${noteAEvidence.relatedCount} | supersedes: ${noteAEvidence.supersededCount ?? 0} | risk: ${noteAEvidence.mergeRisk}`);
4530
+ lines.push(` B: ${noteBEvidence.lifecycle}, ${noteBEvidence.role ?? "untyped"} | ${Math.round(noteBEvidence.ageDays)}d old | rel: ${noteBEvidence.relatedCount} | supersedes: ${noteBEvidence.supersededCount ?? 0} | risk: ${noteBEvidence.mergeRisk}`);
4531
+ if (groupWarnings.length > 0) {
4532
+ lines.push(` Warnings: ${groupWarnings.join("; ")}`);
4533
+ }
4534
+ lines.push(` Merge risk: ${pairRisk}`);
4535
+ }
4386
4536
  lines.push("");
4387
4537
  checked.add(entryA.note.id);
4388
4538
  checked.add(entryB.note.id);
@@ -4391,6 +4541,15 @@ async function detectDuplicates(entries, threshold, project) {
4391
4541
  noteB: { id: entryB.note.id, title: entryB.note.title },
4392
4542
  similarity,
4393
4543
  });
4544
+ if (evidence) {
4545
+ duplicatePairs.push({
4546
+ similarity,
4547
+ noteA: noteAEvidence,
4548
+ noteB: noteBEvidence,
4549
+ warnings: groupWarnings.length > 0 ? groupWarnings : undefined,
4550
+ mergeRisk: pairRisk,
4551
+ });
4552
+ }
4394
4553
  }
4395
4554
  }
4396
4555
  }
@@ -4407,6 +4566,7 @@ async function detectDuplicates(entries, threshold, project) {
4407
4566
  project: toProjectRef(project),
4408
4567
  notesProcessed: entries.length,
4409
4568
  notesModified: 0,
4569
+ duplicatePairs: evidence ? duplicatePairs : undefined,
4410
4570
  };
4411
4571
  return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
4412
4572
  }
@@ -4499,7 +4659,7 @@ function findClusters(entries, project) {
4499
4659
  };
4500
4660
  return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
4501
4661
  }
4502
- async function suggestMerges(entries, threshold, defaultConsolidationMode, project, explicitMode) {
4662
+ async function suggestMerges(entries, threshold, defaultConsolidationMode, project, explicitMode, evidence = false) {
4503
4663
  const lines = [];
4504
4664
  const modeLabel = explicitMode ?? `${defaultConsolidationMode} (project/default; all-temporary merges auto-delete)`;
4505
4665
  lines.push(`Merge suggestions for ${project?.name ?? "global"} (mode: ${modeLabel}):`);
@@ -4507,7 +4667,9 @@ async function suggestMerges(entries, threshold, defaultConsolidationMode, proje
4507
4667
  const checked = new Set();
4508
4668
  let suggestionCount = 0;
4509
4669
  const suggestions = [];
4670
+ const mergeSuggestions = [];
4510
4671
  const embeddings = await loadEmbeddingsByNoteId(entries);
4672
+ const allNotes = entries.map((entry) => entry.note);
4511
4673
  for (let i = 0; i < entries.length; i++) {
4512
4674
  const entryA = entries[i];
4513
4675
  if (checked.has(entryA.note.id))
@@ -4552,7 +4714,19 @@ async function suggestMerges(entries, threshold, defaultConsolidationMode, proje
4552
4714
  }
4553
4715
  }
4554
4716
  })();
4717
+ const noteEvidence = sources.map((source) => buildConsolidateNoteEvidence(source.note, allNotes, entryA.note));
4718
+ const mergeWarnings = buildGroupWarnings(sources.map((source) => source.note), entryA.note);
4719
+ const mergeRisk = aggregateMergeRisk(noteEvidence.map((e) => e.mergeRisk));
4555
4720
  lines.push(` Mode: ${effectiveMode} (${modeDescription})`);
4721
+ if (evidence) {
4722
+ for (const note of noteEvidence) {
4723
+ lines.push(` Evidence: ${note.title} | ${note.lifecycle}, ${note.role ?? "untyped"} | ${Math.round(note.ageDays)}d | rel:${note.relatedCount} | risk:${note.mergeRisk}`);
4724
+ }
4725
+ if (mergeWarnings.length > 0) {
4726
+ lines.push(` Warnings: ${mergeWarnings.join("; ")}`);
4727
+ }
4728
+ lines.push(` Merge risk: ${mergeRisk}`);
4729
+ }
4556
4730
  lines.push(" To execute:");
4557
4731
  lines.push(` consolidate({ strategy: "execute-merge", mergePlan: {`);
4558
4732
  lines.push(` sourceIds: [${sources.map((s) => `"${s.note.id}"`).join(", ")}],`);
@@ -4564,6 +4738,16 @@ async function suggestMerges(entries, threshold, defaultConsolidationMode, proje
4564
4738
  sourceIds: sources.map((s) => s.note.id),
4565
4739
  similarities: similar.map((s) => ({ id: s.entry.note.id, similarity: s.similarity })),
4566
4740
  });
4741
+ if (evidence) {
4742
+ mergeSuggestions.push({
4743
+ targetTitle: `${entryA.note.title} (consolidated)`,
4744
+ sourceIds: sources.map((source) => source.note.id),
4745
+ mode: effectiveMode,
4746
+ notes: noteEvidence,
4747
+ warnings: mergeWarnings.length > 0 ? mergeWarnings : undefined,
4748
+ mergeRisk,
4749
+ });
4750
+ }
4567
4751
  checked.add(entryA.note.id);
4568
4752
  for (const s of similar)
4569
4753
  checked.add(s.entry.note.id);
@@ -4581,6 +4765,7 @@ async function suggestMerges(entries, threshold, defaultConsolidationMode, proje
4581
4765
  project: toProjectRef(project),
4582
4766
  notesProcessed: entries.length,
4583
4767
  notesModified: 0,
4768
+ mergeSuggestions: evidence ? mergeSuggestions : undefined,
4584
4769
  };
4585
4770
  return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
4586
4771
  }
@@ -4594,7 +4779,7 @@ async function loadEmbeddingsByNoteId(entries) {
4594
4779
  }));
4595
4780
  return embeddings;
4596
4781
  }
4597
- async function executeMerge(entries, mergePlan, defaultConsolidationMode, project, cwd, explicitMode, policy, allowProtectedBranch = false) {
4782
+ async function executeMerge(entries, mergePlan, defaultConsolidationMode, project, cwd, explicitMode, policy, allowProtectedBranch = false, evidence = true) {
4598
4783
  const sourceIds = normalizeMergePlanSourceIds(mergePlan.sourceIds);
4599
4784
  const targetTitle = mergePlan.targetTitle.trim();
4600
4785
  const { content: customContent, description, summary, tags } = mergePlan;
@@ -4872,12 +5057,33 @@ async function executeMerge(entries, mergePlan, defaultConsolidationMode, projec
4872
5057
  throw new Error(`Unknown consolidation mode: ${_exhaustive}`);
4873
5058
  }
4874
5059
  }
5060
+ let executeMergeEvidence;
5061
+ if (evidence) {
5062
+ const allNotes = entries.map((entry) => entry.note);
5063
+ const noteEvidence = sourceEntries.map((entry) => buildConsolidateNoteEvidence(entry.note, allNotes, sourceEntries[0]?.note));
5064
+ const mergeWarnings = buildGroupWarnings(sourceEntries.map((entry) => entry.note), sourceEntries[0]?.note);
5065
+ const mergeRisk = aggregateMergeRisk(noteEvidence.map((e) => e.mergeRisk));
5066
+ lines.push(" Evidence:");
5067
+ for (const note of noteEvidence) {
5068
+ lines.push(` ${note.title} | ${note.lifecycle}, ${note.role ?? "untyped"} | ${Math.round(note.ageDays)}d | rel:${note.relatedCount} | risk:${note.mergeRisk}`);
5069
+ }
5070
+ if (mergeWarnings.length > 0) {
5071
+ lines.push(` Warnings: ${mergeWarnings.join("; ")}`);
5072
+ }
5073
+ lines.push(` Merge risk: ${mergeRisk}`);
5074
+ executeMergeEvidence = {
5075
+ notes: noteEvidence,
5076
+ warnings: mergeWarnings.length > 0 ? mergeWarnings : undefined,
5077
+ mergeRisk,
5078
+ };
5079
+ }
4875
5080
  const structuredContent = {
4876
5081
  action: "consolidated",
4877
5082
  strategy: "execute-merge",
4878
5083
  project: toProjectRef(project),
4879
5084
  notesProcessed: entries.length,
4880
5085
  notesModified: vaultChanges.size,
5086
+ executeMergeEvidence,
4881
5087
  persistence,
4882
5088
  retry,
4883
5089
  };
@@ -5038,14 +5244,14 @@ async function pruneSuperseded(entries, consolidationMode, project, cwd, policy,
5038
5244
  };
5039
5245
  return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
5040
5246
  }
5041
- async function dryRunAll(entries, threshold, defaultConsolidationMode, project, explicitMode) {
5247
+ async function dryRunAll(entries, threshold, defaultConsolidationMode, project, explicitMode, evidence = false) {
5042
5248
  const lines = [];
5043
5249
  lines.push(`Consolidation analysis for ${project?.name ?? "global"}:`);
5044
5250
  const modeLabel = explicitMode ?? `${defaultConsolidationMode} (project/default; all-temporary merges auto-delete)`;
5045
5251
  lines.push(`Mode: ${modeLabel} | Threshold: ${threshold}`);
5046
5252
  lines.push("");
5047
5253
  // Run all analysis strategies
5048
- const dupes = await detectDuplicates(entries, threshold, project);
5254
+ const dupes = await detectDuplicates(entries, threshold, project, evidence);
5049
5255
  lines.push("=== DUPLICATE DETECTION ===");
5050
5256
  lines.push(dupes.content[0]?.text ?? "No output");
5051
5257
  lines.push("");
@@ -5053,7 +5259,7 @@ async function dryRunAll(entries, threshold, defaultConsolidationMode, project,
5053
5259
  lines.push("=== CLUSTER ANALYSIS ===");
5054
5260
  lines.push(clusters.content[0]?.text ?? "No output");
5055
5261
  lines.push("");
5056
- const merges = await suggestMerges(entries, threshold, defaultConsolidationMode, project, explicitMode);
5262
+ const merges = await suggestMerges(entries, threshold, defaultConsolidationMode, project, explicitMode, evidence);
5057
5263
  lines.push("=== MERGE SUGGESTIONS ===");
5058
5264
  lines.push(merges.content[0]?.text ?? "No output");
5059
5265
  const structuredContent = {
@@ -5062,6 +5268,10 @@ async function dryRunAll(entries, threshold, defaultConsolidationMode, project,
5062
5268
  project: toProjectRef(project),
5063
5269
  notesProcessed: entries.length,
5064
5270
  notesModified: 0,
5271
+ duplicatePairs: dupes.structuredContent.duplicatePairs,
5272
+ mergeSuggestions: merges.structuredContent.mergeSuggestions,
5273
+ themeGroups: clusters.structuredContent.themeGroups,
5274
+ relationshipClusters: clusters.structuredContent.relationshipClusters,
5065
5275
  };
5066
5276
  return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
5067
5277
  }
@@ -5104,6 +5314,7 @@ server.registerPrompt("mnemonic-workflow-hint", {
5104
5314
  "- When unsure, prefer `recall` over `remember`.\n" +
5105
5315
  "- For repo-related tasks, pass `cwd` so mnemonic can route project memories correctly.\n\n" +
5106
5316
  "Workflow: `recall`/`list` -> `get` -> `update` or `remember` -> `relate`/`consolidate`/`move_memory`. Use `discover_tags` only when tag choice is ambiguous.\n\n" +
5317
+ "When a merge/prune decision is uncertain, use optional evidence enrichment: `recall` with `evidence: \"compact\"` and `consolidate` analysis strategies with `evidence: true`. Evidence improves confidence but is not required.\n\n" +
5107
5318
  "Roles are optional prioritization hints, not schema. Lifecycle still governs durability. When `lifecycle` is omitted, `remember` applies soft defaults based on role: `research`, `plan`, and `review` default to `temporary`; `decision`, `summary`, and `reference` default to `permanent`. Explicit `lifecycle` always overrides the role-based default. Inferred roles are internal hints only. Prioritization is language-independent by default.\n\n" +
5108
5319
  "### Working-state continuity\n\n" +
5109
5320
  "Preserve in-progress work as temporary notes when continuation value is high. Recovery happens after project orientation.\n\n" +
@@ -5144,6 +5355,8 @@ server.registerPrompt("mnemonic-workflow-hint", {
5144
5355
  "- Existing bug note found by `recall` -> inspect with `get` -> refine with `update`.\n" +
5145
5356
  "- No matching note found by `recall` -> optional `discover_tags` with note context -> create with `remember`.\n" +
5146
5357
  "- Two notes overlap heavily -> inspect -> clean up with `consolidate`.\n" +
5358
+ "- Unsure why a recall hit ranked high -> rerun `recall` with `evidence: \"compact\"`.\n" +
5359
+ "- Unsure whether to merge/prune -> run `consolidate` analysis with `evidence: true` before `execute-merge` or `prune-superseded`.\n" +
5147
5360
  "- Resume work: `project_memory_summary` -> `recall` (lifecycle: temporary) -> continue from temporary notes.\n\n" +
5148
5361
  "### semanticPatch format\n\n" +
5149
5362
  "When using `update` with `semanticPatch`:\n" +