@danielmarbach/mnemonic-mcp 0.15.0 → 0.17.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
@@ -9,8 +9,11 @@ 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
11
  import { getOrBuildProjection } from "./projections.js";
12
+ import { invalidateActiveProjectCache, getOrBuildVaultEmbeddings, getOrBuildVaultNoteList, getSessionCachedNote, } from "./cache.js";
13
+ import { performance } from "perf_hooks";
12
14
  import { filterRelationships, mergeRelationshipsFromNotes, normalizeMergePlanSourceIds, resolveEffectiveConsolidationMode, } from "./consolidate.js";
13
15
  import { selectRecallResults } from "./recall.js";
16
+ import { getRelationshipPreview } from "./relationships.js";
14
17
  import { cleanMarkdown } from "./markdown.js";
15
18
  import { MnemonicConfigStore, readVaultSchemaVersion } from "./config.js";
16
19
  import { CONSOLIDATION_MODES, PROTECTED_BRANCH_BEHAVIORS, PROJECT_POLICY_SCOPES, WRITE_SCOPES, isProtectedBranch, resolveProtectedBranchBehavior, resolveProtectedBranchPatterns, resolveConsolidationMode, resolveWriteScope, } from "./project-memory-policy.js";
@@ -359,6 +362,8 @@ async function ensureBranchSynced(cwd) {
359
362
  }
360
363
  const mainBackfill = await backfillEmbeddingsAfterSync(vaultManager.main.storage, "main vault", [], true);
361
364
  console.error(`[branch] Main vault embedded ${mainBackfill.embedded} notes`);
365
+ // Vault contents changed — discard session cache so next access rebuilds from fresh state
366
+ invalidateActiveProjectCache();
362
367
  return true;
363
368
  }
364
369
  function formatProjectIdentityText(identity) {
@@ -382,10 +387,10 @@ function formatProjectIdentityText(identity) {
382
387
  function describeLifecycle(lifecycle) {
383
388
  return `lifecycle: ${lifecycle}`;
384
389
  }
385
- function formatNote(note, score) {
390
+ function formatNote(note, score, showRawRelated = true) {
386
391
  const scoreStr = score !== undefined ? ` | similarity: ${score.toFixed(3)}` : "";
387
392
  const projectStr = note.project ? ` | project: ${note.projectName ?? note.project}` : " | global";
388
- const relStr = note.relatedTo && note.relatedTo.length > 0
393
+ const relStr = showRawRelated && note.relatedTo && note.relatedTo.length > 0
389
394
  ? `\n**related:** ${note.relatedTo.map((r) => `\`${r.id}\` (${r.type})`).join(", ")}`
390
395
  : "";
391
396
  return (`## ${note.title}\n` +
@@ -404,6 +409,15 @@ function formatTemporalHistory(history) {
404
409
  }
405
410
  return lines.join("\n");
406
411
  }
412
+ function formatRelationshipPreview(preview) {
413
+ const shown = preview.shown
414
+ .map(r => `${r.title} (\`${r.id}\`) [${r.relationType ?? "related-to"}]`)
415
+ .join(", ");
416
+ const more = preview.truncated
417
+ ? ` [+${preview.totalDirectRelations - preview.shown.length} more]`
418
+ : "";
419
+ return `**related (${preview.totalDirectRelations}):** ${shown}${more}`;
420
+ }
407
421
  // ── Git commit message helpers ────────────────────────────────────────────────
408
422
  /**
409
423
  * Extract a short human-readable summary from note content.
@@ -772,7 +786,7 @@ function vaultMatchesStorageScope(vault, storedIn) {
772
786
  // "project-vault" covers the primary project vault and all submodule vaults.
773
787
  return vault.isProject;
774
788
  }
775
- async function collectVisibleNotes(cwd, scope = "all", tags, storedIn = "any") {
789
+ async function collectVisibleNotes(cwd, scope = "all", tags, storedIn = "any", sessionProjectId) {
776
790
  const project = await resolveProject(cwd);
777
791
  const vaults = await vaultManager.searchOrder(cwd);
778
792
  let filterProject = undefined;
@@ -783,8 +797,23 @@ async function collectVisibleNotes(cwd, scope = "all", tags, storedIn = "any") {
783
797
  const seen = new Set();
784
798
  const entries = [];
785
799
  for (const vault of vaults) {
786
- const vaultNotes = await vault.storage.listNotes(filterProject !== undefined ? { project: filterProject } : undefined);
787
- for (const note of vaultNotes) {
800
+ let rawNotes;
801
+ if (sessionProjectId) {
802
+ const cached = await getOrBuildVaultNoteList(sessionProjectId, vault);
803
+ if (cached !== undefined) {
804
+ // Apply project filter on the full cached list
805
+ rawNotes = filterProject !== undefined
806
+ ? cached.filter((n) => filterProject === null ? !n.project : n.project === filterProject)
807
+ : cached;
808
+ }
809
+ else {
810
+ rawNotes = await vault.storage.listNotes(filterProject !== undefined ? { project: filterProject } : undefined);
811
+ }
812
+ }
813
+ else {
814
+ rawNotes = await vault.storage.listNotes(filterProject !== undefined ? { project: filterProject } : undefined);
815
+ }
816
+ for (const note of rawNotes) {
788
817
  if (seen.has(note.id)) {
789
818
  continue;
790
819
  }
@@ -1432,6 +1461,7 @@ server.registerTool("remember", {
1432
1461
  timestamp: now,
1433
1462
  persistence,
1434
1463
  };
1464
+ invalidateActiveProjectCache();
1435
1465
  return {
1436
1466
  content: [{ type: "text", text: textContent }],
1437
1467
  structuredContent,
@@ -1636,6 +1666,7 @@ server.registerTool("recall", {
1636
1666
  "- You just want to browse by tags or scope; use `list`\n\n" +
1637
1667
  "Returns:\n" +
1638
1668
  "- Ranked memory matches with scores, vault label, tags, lifecycle, and updated time\n" +
1669
+ "- Bounded 1-hop relationship previews automatically attached to top results\n" +
1639
1670
  "- In temporal mode, optional compact history entries for top matches\n\n" +
1640
1671
  "Read-only.\n\n" +
1641
1672
  "Typical next step:\n" +
@@ -1663,6 +1694,7 @@ server.registerTool("recall", {
1663
1694
  }),
1664
1695
  outputSchema: RecallResultSchema,
1665
1696
  }, async ({ query, cwd, limit, minSimilarity, mode, verbose, tags, scope }) => {
1697
+ const t0Recall = performance.now();
1666
1698
  await ensureBranchSynced(cwd);
1667
1699
  const project = await resolveProject(cwd);
1668
1700
  const queryVec = await embed(query);
@@ -1670,6 +1702,12 @@ server.registerTool("recall", {
1670
1702
  const noteCache = new Map();
1671
1703
  const noteCacheKey = (vault, id) => `${vault.storage.vaultPath}::${id}`;
1672
1704
  const readCachedNote = async (vault, id) => {
1705
+ // Check session cache first (populated when getOrBuildVaultEmbeddings was called)
1706
+ if (project) {
1707
+ const sessionNote = getSessionCachedNote(project.id, vault.storage.vaultPath, id);
1708
+ if (sessionNote !== undefined)
1709
+ return sessionNote;
1710
+ }
1673
1711
  const key = noteCacheKey(vault, id);
1674
1712
  const cached = noteCache.get(key);
1675
1713
  if (cached) {
@@ -1686,7 +1724,9 @@ server.registerTool("recall", {
1686
1724
  }
1687
1725
  const scored = [];
1688
1726
  for (const vault of vaults) {
1689
- const embeddings = await vault.storage.listEmbeddings();
1727
+ const embeddings = project
1728
+ ? (await getOrBuildVaultEmbeddings(project.id, vault)) ?? await vault.storage.listEmbeddings()
1729
+ : await vault.storage.listEmbeddings();
1690
1730
  for (const rec of embeddings) {
1691
1731
  const rawScore = cosineSimilarity(queryVec, rec.embedding);
1692
1732
  if (rawScore < minSimilarity)
@@ -1723,6 +1763,9 @@ server.registerTool("recall", {
1723
1763
  : `Recall results (global):`;
1724
1764
  const sections = [];
1725
1765
  const structuredResults = [];
1766
+ // Determine how many top results get relationship expansion
1767
+ // Top 1 by default, top 3 if result count is small
1768
+ const recallRelationshipLimit = top.length <= 3 ? 3 : 1;
1726
1769
  for (const [index, { id, score, vault, boosted }] of top.entries()) {
1727
1770
  const note = await readCachedNote(vault, id);
1728
1771
  if (note) {
@@ -1740,10 +1783,19 @@ server.registerTool("recall", {
1740
1783
  }));
1741
1784
  }
1742
1785
  }
1786
+ // Add relationship preview for top N results (fail-soft)
1787
+ let relationships;
1788
+ if (index < recallRelationshipLimit) {
1789
+ relationships = await getRelationshipPreview(note, vaultManager.allKnownVaults(), { activeProjectId: project?.id, limit: 3 });
1790
+ }
1743
1791
  const formattedHistory = mode === "temporal" && history !== undefined
1744
1792
  ? `\n\n${formatTemporalHistory(history)}`
1745
1793
  : "";
1746
- sections.push(`${formatNote(note, score)}${formattedHistory}`);
1794
+ const formattedRelationships = relationships !== undefined
1795
+ ? `\n\n${formatRelationshipPreview(relationships)}`
1796
+ : "";
1797
+ // Suppress raw related IDs when enriched preview is shown to avoid duplication
1798
+ sections.push(`${formatNote(note, score, relationships === undefined)}${formattedHistory}${formattedRelationships}`);
1747
1799
  structuredResults.push({
1748
1800
  id,
1749
1801
  title: note.title,
@@ -1758,6 +1810,7 @@ server.registerTool("recall", {
1758
1810
  provenance,
1759
1811
  confidence,
1760
1812
  history,
1813
+ relationships,
1761
1814
  });
1762
1815
  }
1763
1816
  }
@@ -1768,6 +1821,7 @@ server.registerTool("recall", {
1768
1821
  scope: scope || "all",
1769
1822
  results: structuredResults,
1770
1823
  };
1824
+ console.error(`[recall:timing] ${(performance.now() - t0Recall).toFixed(1)}ms`);
1771
1825
  return {
1772
1826
  content: [{ type: "text", text: textContent }],
1773
1827
  structuredContent,
@@ -1918,6 +1972,7 @@ server.registerTool("update", {
1918
1972
  lifecycle: updated.lifecycle,
1919
1973
  persistence,
1920
1974
  };
1975
+ invalidateActiveProjectCache();
1921
1976
  return { content: [{ type: "text", text: `Updated memory '${id}'\n${formatPersistenceSummary(persistence)}` }], structuredContent };
1922
1977
  });
1923
1978
  // ── forget ────────────────────────────────────────────────────────────────────
@@ -2024,6 +2079,7 @@ server.registerTool("forget", {
2024
2079
  retry,
2025
2080
  };
2026
2081
  const retrySummary = formatRetrySummary(retry);
2082
+ invalidateActiveProjectCache();
2027
2083
  return {
2028
2084
  content: [{
2029
2085
  type: "text",
@@ -2044,7 +2100,8 @@ server.registerTool("get", {
2044
2100
  "- You are still searching by topic; use `recall`\n" +
2045
2101
  "- You want to browse many notes; use `list`\n\n" +
2046
2102
  "Returns:\n" +
2047
- "- Full note content and metadata for the requested ids, including storage label\n\n" +
2103
+ "- Full note content and metadata for the requested ids, including storage label\n" +
2104
+ "- Bounded 1-hop relationship previews when `includeRelationships` is true (max 3 shown)\n\n" +
2048
2105
  "Read-only.\n\n" +
2049
2106
  "Typical next step:\n" +
2050
2107
  "- Use `update`, `forget`, `move_memory`, or `relate` after inspection.",
@@ -2056,19 +2113,39 @@ server.registerTool("get", {
2056
2113
  inputSchema: z.object({
2057
2114
  ids: z.array(z.string()).min(1).describe("One or more memory ids to fetch"),
2058
2115
  cwd: projectParam,
2116
+ includeRelationships: z.boolean().optional().default(false).describe("Include bounded direct relationship previews (1-hop expansion, max 3 shown)"),
2059
2117
  }),
2060
2118
  outputSchema: GetResultSchema,
2061
- }, async ({ ids, cwd }) => {
2119
+ }, async ({ ids, cwd, includeRelationships }) => {
2120
+ const t0Get = performance.now();
2062
2121
  await ensureBranchSynced(cwd);
2122
+ const project = await resolveProject(cwd);
2063
2123
  const found = [];
2064
2124
  const notFound = [];
2065
2125
  for (const id of ids) {
2066
- const result = await vaultManager.findNote(id, cwd);
2126
+ // Check session cache before hitting storage
2127
+ let result = null;
2128
+ if (project) {
2129
+ for (const vault of vaultManager.allKnownVaults()) {
2130
+ const cached = getSessionCachedNote(project.id, vault.storage.vaultPath, id);
2131
+ if (cached !== undefined) {
2132
+ result = { note: cached, vault };
2133
+ break;
2134
+ }
2135
+ }
2136
+ }
2137
+ if (!result) {
2138
+ result = await vaultManager.findNote(id, cwd);
2139
+ }
2067
2140
  if (!result) {
2068
2141
  notFound.push(id);
2069
2142
  continue;
2070
2143
  }
2071
2144
  const { note, vault } = result;
2145
+ let relationships;
2146
+ if (includeRelationships) {
2147
+ relationships = await getRelationshipPreview(note, vaultManager.allKnownVaults(), { activeProjectId: project?.id, limit: 3 });
2148
+ }
2072
2149
  found.push({
2073
2150
  id: note.id,
2074
2151
  title: note.title,
@@ -2081,6 +2158,7 @@ server.registerTool("get", {
2081
2158
  createdAt: note.createdAt,
2082
2159
  updatedAt: note.updatedAt,
2083
2160
  vault: storageLabel(vault),
2161
+ relationships,
2084
2162
  });
2085
2163
  }
2086
2164
  const lines = [];
@@ -2091,6 +2169,10 @@ server.registerTool("get", {
2091
2169
  lines.push(`tags: ${note.tags.join(", ")}`);
2092
2170
  lines.push("");
2093
2171
  lines.push(note.content);
2172
+ if (note.relationships) {
2173
+ lines.push("");
2174
+ lines.push(formatRelationshipPreview(note.relationships));
2175
+ }
2094
2176
  lines.push("");
2095
2177
  }
2096
2178
  if (notFound.length > 0) {
@@ -2102,6 +2184,7 @@ server.registerTool("get", {
2102
2184
  notes: found,
2103
2185
  notFound,
2104
2186
  };
2187
+ console.error(`[get:timing] ${(performance.now() - t0Get).toFixed(1)}ms`);
2105
2188
  return { content: [{ type: "text", text: lines.join("\n").trim() }], structuredContent };
2106
2189
  });
2107
2190
  // ── where_is_memory ───────────────────────────────────────────────────────────
@@ -2247,24 +2330,42 @@ server.registerTool("list", {
2247
2330
  };
2248
2331
  return { content: [{ type: "text", text: textContent }], structuredContent };
2249
2332
  });
2250
- // ── discover_tags ───────────────────────────────────────────────────────────
2333
+ function tokenizeTagDiscoveryText(value) {
2334
+ return value
2335
+ .toLowerCase()
2336
+ .split(/[^a-z0-9]+/)
2337
+ .filter(Boolean);
2338
+ }
2339
+ function countTokenOverlap(tokens, other) {
2340
+ let matches = 0;
2341
+ for (const token of other) {
2342
+ if (tokens.has(token)) {
2343
+ matches++;
2344
+ }
2345
+ }
2346
+ return matches;
2347
+ }
2348
+ function hasExactTagContextMatch(tag, values) {
2349
+ const normalizedTag = tag.toLowerCase();
2350
+ return values.some(value => value?.toLowerCase().includes(normalizedTag) ?? false);
2351
+ }
2251
2352
  server.registerTool("discover_tags", {
2252
2353
  title: "Discover Tags",
2253
- description: "Discover existing tags across vaults with usage statistics and examples.\n\n" +
2354
+ description: "Suggest canonical tags for a specific note before `remember` when tag choice is ambiguous.\n\n" +
2254
2355
  "Use this when:\n" +
2255
- "- Before `remember` to find canonical tag names for consistent terminology\n" +
2256
- "- Starting a new topic and unsure which tags exist (e.g., 'bug' vs 'bugs')\n" +
2257
- "- Identifying tags only on temporary notes (cleanup candidates)\n\n" +
2356
+ "- You have a note title, content, or query and want compact tag suggestions\n" +
2357
+ "- You want canonical project terminology without exposing lots of unrelated tags\n" +
2358
+ "- You want to demote temporary-only tags unless they fit the note\n\n" +
2258
2359
  "Do not use this when:\n" +
2259
2360
  "- You need to browse notes by tag; use `list` with `tags` filter instead\n" +
2260
- "- You already know the exact tags you want to use\n\n" +
2361
+ "- You already know the exact tags you want to use\n" +
2362
+ "- You want broad inventory output but are not explicitly requesting `mode: \"browse\"`\n\n" +
2261
2363
  "Returns:\n" +
2262
- "- Tags sorted by usageCount (canonical tags first)\n" +
2263
- "- Example note titles (up to 3 per tag) showing usage context\n" +
2264
- "- lifecycleTypes showing temporary vs permanent distribution\n" +
2265
- "- isTemporaryOnly flag identifying cleanup candidates\n\n" +
2364
+ "- Default: bounded `recommendedTags` ranked by note relevance first and usage count second\n" +
2365
+ "- Each suggestion includes canonicality and lifecycle signals plus one compact example\n" +
2366
+ "- Optional `mode: \"browse\"` returns broader inventory output\n\n" +
2266
2367
  "Typical next step:\n" +
2267
- "- Use canonical tags from discover_tags when appropriate, or create new tags when genuinely novel.\n\n" +
2368
+ "- Reuse suggested canonical tags when they fit, or create a new tag only when genuinely novel.\n\n" +
2268
2369
  "Performance: O(n) where n = total notes scanned. Expect 100-200ms for 500 notes.\n\n" +
2269
2370
  "Read-only.",
2270
2371
  annotations: {
@@ -2274,6 +2375,12 @@ server.registerTool("discover_tags", {
2274
2375
  },
2275
2376
  inputSchema: z.object({
2276
2377
  cwd: projectParam,
2378
+ mode: z.enum(["suggest", "browse"]).optional().default("suggest"),
2379
+ title: z.string().optional(),
2380
+ content: z.string().optional(),
2381
+ query: z.string().optional(),
2382
+ candidateTags: z.array(z.string()).optional(),
2383
+ lifecycle: z.enum(NOTE_LIFECYCLES).optional(),
2277
2384
  scope: z
2278
2385
  .enum(["project", "global", "all"])
2279
2386
  .optional()
@@ -2286,65 +2393,170 @@ server.registerTool("discover_tags", {
2286
2393
  .optional()
2287
2394
  .default("any")
2288
2395
  .describe("Filter by vault storage label like list tool."),
2396
+ limit: z.number().int().min(1).max(50).optional(),
2289
2397
  }),
2290
2398
  outputSchema: DiscoverTagsResultSchema,
2291
- }, async ({ cwd, scope, storedIn }) => {
2399
+ }, async ({ cwd, mode, title, content, query, candidateTags, lifecycle, scope, storedIn, limit }) => {
2292
2400
  await ensureBranchSynced(cwd);
2293
2401
  const startTime = Date.now();
2294
2402
  const { project, entries } = await collectVisibleNotes(cwd, scope, undefined, storedIn);
2295
2403
  const tagStats = new Map();
2404
+ const candidateTagSet = new Set((candidateTags || []).map(tag => tag.toLowerCase()));
2405
+ const contextValues = [title, content, query, ...(candidateTags || [])];
2406
+ const contextTokens = new Set(tokenizeTagDiscoveryText(contextValues.filter(Boolean).join(" ")));
2407
+ const isTemporaryTarget = lifecycle === "temporary";
2408
+ const effectiveLimit = limit ?? (mode === "browse" ? 20 : 10);
2296
2409
  for (const { note } of entries) {
2410
+ const noteTokens = new Set(tokenizeTagDiscoveryText(`${note.title} ${note.content} ${note.tags.join(" ")}`));
2411
+ const contextMatches = contextTokens.size > 0 ? countTokenOverlap(contextTokens, noteTokens) : 0;
2297
2412
  for (const tag of note.tags) {
2298
- const stats = tagStats.get(tag) || { count: 0, examples: [], lifecycles: new Set() };
2413
+ const stats = tagStats.get(tag) || {
2414
+ count: 0,
2415
+ examples: [],
2416
+ lifecycles: new Set(),
2417
+ contextMatches: 0,
2418
+ exactCandidateMatch: false,
2419
+ };
2299
2420
  stats.count++;
2300
2421
  if (stats.examples.length < 3) {
2301
2422
  stats.examples.push(note.title);
2302
2423
  }
2303
2424
  stats.lifecycles.add(note.lifecycle);
2425
+ stats.contextMatches += contextMatches;
2426
+ if (candidateTagSet.has(tag.toLowerCase())) {
2427
+ stats.exactCandidateMatch = true;
2428
+ }
2304
2429
  tagStats.set(tag, stats);
2305
2430
  }
2306
2431
  }
2307
- const tags = Array.from(tagStats.entries())
2308
- .map(([tag, stats]) => ({
2309
- tag,
2310
- usageCount: stats.count,
2311
- examples: stats.examples,
2312
- lifecycleTypes: Array.from(stats.lifecycles),
2313
- isTemporaryOnly: stats.lifecycles.size === 1 && stats.lifecycles.has("temporary"),
2314
- }))
2315
- .sort((a, b) => b.usageCount - a.usageCount);
2432
+ const rawTags = Array.from(tagStats.entries())
2433
+ .map(([tag, stats]) => {
2434
+ const lifecycleTypes = Array.from(stats.lifecycles);
2435
+ const isTemporaryOnly = stats.lifecycles.size === 1 && stats.lifecycles.has("temporary");
2436
+ const tagTokens = tokenizeTagDiscoveryText(tag);
2437
+ const exactContextMatch = stats.exactCandidateMatch || hasExactTagContextMatch(tag, contextValues);
2438
+ const tagTokenOverlap = contextTokens.size > 0
2439
+ ? countTokenOverlap(contextTokens, tagTokens)
2440
+ : 0;
2441
+ const averageContextMatch = stats.count > 0 ? stats.contextMatches / stats.count : 0;
2442
+ const reason = exactContextMatch
2443
+ ? "matches a candidate tag already present in the note context"
2444
+ : tagTokenOverlap > 0 || stats.contextMatches > 0
2445
+ ? "matches the note context and existing project usage"
2446
+ : "high-usage canonical tag from existing project notes";
2447
+ return {
2448
+ tag,
2449
+ usageCount: stats.count,
2450
+ examples: stats.examples,
2451
+ example: stats.examples[0],
2452
+ reason,
2453
+ lifecycleTypes,
2454
+ isTemporaryOnly,
2455
+ exactContextMatch,
2456
+ tagTokenOverlap,
2457
+ averageContextMatch,
2458
+ isBroadSingleToken: tagTokens.length === 1,
2459
+ isHighFrequency: stats.count >= 4,
2460
+ specificityBoost: tagTokens.length > 1 ? 4 : 0,
2461
+ };
2462
+ });
2463
+ const hasStrongSpecificCandidate = rawTags.some(tag => tag.exactContextMatch && (!tag.isBroadSingleToken || tag.usageCount > 1));
2464
+ const tags = rawTags
2465
+ .map((tag) => {
2466
+ const hasWeakDirectMatch = tag.tagTokenOverlap <= 1 && tag.averageContextMatch <= 2;
2467
+ const genericPenalty = hasStrongSpecificCandidate && tag.isBroadSingleToken && tag.isHighFrequency && hasWeakDirectMatch
2468
+ ? 10
2469
+ : 0;
2470
+ const score = hasStrongSpecificCandidate
2471
+ ? (tag.exactContextMatch ? 12 : 0) +
2472
+ (tag.tagTokenOverlap * 5) +
2473
+ tag.specificityBoost +
2474
+ (tag.averageContextMatch * 3) +
2475
+ (tag.usageCount * 0.1) -
2476
+ genericPenalty -
2477
+ (!isTemporaryTarget && tag.isTemporaryOnly ? 2 : 0)
2478
+ : (tag.usageCount * 1.5) +
2479
+ (tag.isTemporaryOnly ? -3 : 1) -
2480
+ (!isTemporaryTarget && tag.isTemporaryOnly ? 2 : 0);
2481
+ return {
2482
+ ...tag,
2483
+ score,
2484
+ };
2485
+ })
2486
+ .sort((a, b) => b.score - a.score || b.usageCount - a.usageCount || a.tag.localeCompare(b.tag));
2316
2487
  const durationMs = Date.now() - startTime;
2488
+ const vaultsSearched = new Set(entries.map(e => storageLabel(e.vault))).size;
2317
2489
  const lines = [];
2318
- if (project && scope !== "global") {
2319
- lines.push(`Tags for ${project.name} (scope: ${scope}):`);
2490
+ if (mode === "browse") {
2491
+ if (project && scope !== "global") {
2492
+ lines.push(`Tags for ${project.name} (scope: ${scope}):`);
2493
+ }
2494
+ else {
2495
+ lines.push(`Tags (scope: ${scope}):`);
2496
+ }
2497
+ lines.push("");
2498
+ lines.push(`Total: ${tags.length} unique tags across ${entries.length} notes (${durationMs}ms)`);
2499
+ lines.push("");
2500
+ lines.push("Tags sorted by usage:");
2501
+ for (const t of tags.slice(0, effectiveLimit)) {
2502
+ const lifecycleMark = t.isTemporaryOnly ? " [temp-only]" : "";
2503
+ lines.push(` ${t.tag} (${t.usageCount})${lifecycleMark}`);
2504
+ if (t.examples.length > 0) {
2505
+ lines.push(` Example: "${t.examples[0]}"`);
2506
+ }
2507
+ }
2508
+ if (tags.length > effectiveLimit) {
2509
+ lines.push(` ... and ${tags.length - effectiveLimit} more`);
2510
+ }
2320
2511
  }
2321
2512
  else {
2322
- lines.push(`Tags (scope: ${scope}):`);
2323
- }
2324
- lines.push("");
2325
- lines.push(`Total: ${tags.length} unique tags across ${entries.length} notes (${durationMs}ms)`);
2326
- lines.push("");
2327
- lines.push("Tags sorted by usage:");
2328
- for (const t of tags.slice(0, 20)) {
2329
- const lifecycleMark = t.isTemporaryOnly ? " [temp-only]" : "";
2330
- lines.push(` ${t.tag} (${t.usageCount})${lifecycleMark}`);
2331
- if (t.examples.length > 0) {
2332
- lines.push(` Example: "${t.examples[0]}"`);
2513
+ const suggestedTags = tags.slice(0, effectiveLimit);
2514
+ if (project && scope !== "global") {
2515
+ lines.push(`Suggested tags for ${project.name} (scope: ${scope}):`);
2516
+ }
2517
+ else {
2518
+ lines.push(`Suggested tags (scope: ${scope}):`);
2519
+ }
2520
+ lines.push("");
2521
+ lines.push(`Considered ${tags.length} unique tags across ${entries.length} notes (${durationMs}ms)`);
2522
+ lines.push("");
2523
+ if (contextTokens.size === 0) {
2524
+ lines.push("No note context provided; showing the most canonical existing tags.");
2525
+ lines.push("");
2526
+ }
2527
+ lines.push("Recommended tags:");
2528
+ for (const t of suggestedTags) {
2529
+ const lifecycleMark = t.isTemporaryOnly ? " [temp-only]" : "";
2530
+ lines.push(` ${t.tag} (${t.usageCount})${lifecycleMark}`);
2531
+ if (t.example) {
2532
+ lines.push(` Example: "${t.example}"`);
2533
+ }
2534
+ lines.push(` Why: ${t.reason}`);
2333
2535
  }
2334
2536
  }
2335
- if (tags.length > 20) {
2336
- lines.push(` ... and ${tags.length - 20} more`);
2337
- }
2338
- const structuredContent = {
2339
- action: "tags_discovered",
2340
- project: project ? { id: project.id, name: project.name } : undefined,
2341
- scope: scope || "all",
2342
- tags,
2343
- totalTags: tags.length,
2344
- totalNotes: entries.length,
2345
- vaultsSearched: new Set(entries.map(e => storageLabel(e.vault))).size,
2346
- durationMs,
2347
- };
2537
+ const structuredContent = mode === "browse"
2538
+ ? {
2539
+ action: "tags_discovered",
2540
+ project: project ? { id: project.id, name: project.name } : undefined,
2541
+ mode,
2542
+ scope: scope || "all",
2543
+ tags: tags.slice(0, effectiveLimit).map(({ score, example, reason, ...tag }) => tag),
2544
+ totalTags: tags.length,
2545
+ totalNotes: entries.length,
2546
+ vaultsSearched,
2547
+ durationMs,
2548
+ }
2549
+ : {
2550
+ action: "tags_discovered",
2551
+ project: project ? { id: project.id, name: project.name } : undefined,
2552
+ mode,
2553
+ scope: scope || "all",
2554
+ recommendedTags: tags.slice(0, effectiveLimit).map(({ score, examples, ...tag }) => tag),
2555
+ totalTags: tags.length,
2556
+ totalNotes: entries.length,
2557
+ vaultsSearched,
2558
+ durationMs,
2559
+ };
2348
2560
  return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
2349
2561
  });
2350
2562
  // ── recent_memories ───────────────────────────────────────────────────────────
@@ -2505,7 +2717,8 @@ server.registerTool("project_memory_summary", {
2505
2717
  "- You need exact note contents; use `get`\n" +
2506
2718
  "- You need direct semantic matches for a query; use `recall`\n\n" +
2507
2719
  "Returns:\n" +
2508
- "- A synthesized project-level summary based on stored memories\n\n" +
2720
+ "- A synthesized project-level summary based on stored memories\n" +
2721
+ "- Bounded 1-hop relationship previews on orientation entry points (primaryEntry and suggestedNext)\n\n" +
2509
2722
  "Read-only.\n\n" +
2510
2723
  "Typical next step:\n" +
2511
2724
  "- Use `recall` or `list` to drill down into specific areas.",
@@ -2524,8 +2737,11 @@ server.registerTool("project_memory_summary", {
2524
2737
  }),
2525
2738
  outputSchema: ProjectSummaryResultSchema,
2526
2739
  }, async ({ cwd, maxPerTheme, recentLimit, anchorLimit, includeRelatedGlobal, relatedGlobalLimit }) => {
2740
+ const t0Summary = performance.now();
2527
2741
  await ensureBranchSynced(cwd);
2528
- const { project, entries } = await collectVisibleNotes(cwd, "all");
2742
+ // Pre-resolve project so we can pass its id to collectVisibleNotes for session caching
2743
+ const preProject = await resolveProject(cwd);
2744
+ const { project, entries } = await collectVisibleNotes(cwd, "all", undefined, "any", preProject?.id);
2529
2745
  if (!project) {
2530
2746
  return { content: [{ type: "text", text: `Could not detect a project for: ${cwd}` }], isError: true };
2531
2747
  }
@@ -2729,10 +2945,24 @@ server.registerTool("project_memory_summary", {
2729
2945
  const confidence = computeConfidence("permanent", anchor.updatedAt, anchor.centrality);
2730
2946
  return { provenance, confidence };
2731
2947
  };
2948
+ // Helper to enrich an anchor with relationships (1-hop expansion)
2949
+ const enrichOrientationNoteWithRelationships = async (anchor) => {
2950
+ const vault = noteVaultMap.get(anchor.id);
2951
+ if (!vault)
2952
+ return {};
2953
+ const note = await vault.storage.readNote(anchor.id);
2954
+ if (!note)
2955
+ return {};
2956
+ const relationships = await getRelationshipPreview(note, vaultManager.allKnownVaults(), { activeProjectId: project.id, limit: 3 });
2957
+ return { relationships };
2958
+ };
2732
2959
  const primaryEnriched = primaryAnchor ? await enrichOrientationNote(primaryAnchor) : {};
2960
+ const primaryRelationships = primaryAnchor ? await enrichOrientationNoteWithRelationships(primaryAnchor) : {};
2733
2961
  const suggestedEnriched = await Promise.all(anchors.slice(1, 4).map(enrichOrientationNote));
2962
+ const suggestedRelationships = await Promise.all(anchors.slice(1, 4).map(enrichOrientationNoteWithRelationships));
2734
2963
  // Enrich fallback primaryEntry when no anchors exist
2735
2964
  let fallbackEnriched = {};
2965
+ let fallbackRelationships = {};
2736
2966
  if (!primaryAnchor && recent[0]) {
2737
2967
  const fallbackNote = recent[0].note;
2738
2968
  const vault = noteVaultMap.get(fallbackNote.id);
@@ -2742,6 +2972,9 @@ server.registerTool("project_memory_summary", {
2742
2972
  const confidence = computeConfidence(fallbackNote.lifecycle, fallbackNote.updatedAt, 0);
2743
2973
  fallbackEnriched = { provenance, confidence };
2744
2974
  }
2975
+ const preview = await getRelationshipPreview(fallbackNote, vaultManager.allKnownVaults(), { activeProjectId: project.id, limit: 3 });
2976
+ if (preview)
2977
+ fallbackRelationships = { relationships: preview };
2745
2978
  }
2746
2979
  const orientation = {
2747
2980
  primaryEntry: primaryAnchor
@@ -2750,6 +2983,7 @@ server.registerTool("project_memory_summary", {
2750
2983
  title: primaryAnchor.title,
2751
2984
  rationale: `Centrality ${primaryAnchor.centrality}, connects ${primaryAnchor.connectionDiversity} themes`,
2752
2985
  ...primaryEnriched,
2986
+ ...primaryRelationships,
2753
2987
  }
2754
2988
  : {
2755
2989
  id: recent[0]?.note.id ?? projectEntries[0]?.note.id ?? "",
@@ -2758,12 +2992,14 @@ server.registerTool("project_memory_summary", {
2758
2992
  ? "Most recent note — no high-centrality anchors found"
2759
2993
  : "Only note available",
2760
2994
  ...fallbackEnriched,
2995
+ ...fallbackRelationships,
2761
2996
  },
2762
2997
  suggestedNext: anchors.slice(1, 4).map((a, i) => ({
2763
2998
  id: a.id,
2764
2999
  title: a.title,
2765
3000
  rationale: `Centrality ${a.centrality}, connects ${a.connectionDiversity} themes`,
2766
3001
  ...suggestedEnriched[i],
3002
+ ...suggestedRelationships[i],
2767
3003
  })),
2768
3004
  };
2769
3005
  // Warning for taxonomy dilution
@@ -2782,10 +3018,16 @@ server.registerTool("project_memory_summary", {
2782
3018
  if (orientation.primaryEntry.confidence) {
2783
3019
  sections.push(` Confidence: ${orientation.primaryEntry.confidence}`);
2784
3020
  }
3021
+ if (orientation.primaryEntry.relationships) {
3022
+ sections.push(` ${formatRelationshipPreview(orientation.primaryEntry.relationships)}`);
3023
+ }
2785
3024
  if (orientation.suggestedNext.length > 0) {
2786
3025
  sections.push(`Then check:`);
2787
3026
  for (const next of orientation.suggestedNext) {
2788
3027
  sections.push(` - ${next.title} (\`${next.id}\`) — ${next.rationale}${next.confidence ? ` [${next.confidence}]` : ""}`);
3028
+ if (next.relationships) {
3029
+ sections.push(` ${formatRelationshipPreview(next.relationships)}`);
3030
+ }
2789
3031
  }
2790
3032
  }
2791
3033
  if (orientation.warnings && orientation.warnings.length > 0) {
@@ -2815,6 +3057,7 @@ server.registerTool("project_memory_summary", {
2815
3057
  orientation,
2816
3058
  relatedGlobal,
2817
3059
  };
3060
+ console.error(`[summary:timing] ${(performance.now() - t0Summary).toFixed(1)}ms`);
2818
3061
  return { content: [{ type: "text", text: sections.join("\n") }], structuredContent };
2819
3062
  });
2820
3063
  // ── sync ──────────────────────────────────────────────────────────────────────
@@ -2901,6 +3144,8 @@ server.registerTool("sync", {
2901
3144
  action: "synced",
2902
3145
  vaults: vaultResults,
2903
3146
  };
3147
+ // Vault contents may have changed via pull — discard session cache
3148
+ invalidateActiveProjectCache();
2904
3149
  return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
2905
3150
  });
2906
3151
  // ── move_memory ───────────────────────────────────────────────────────────────
@@ -3052,6 +3297,7 @@ server.registerTool("move_memory", {
3052
3297
  const associationText = metadataRewritten
3053
3298
  ? `Project association is now ${associationValue}.`
3054
3299
  : `Project association remains ${associationValue}.`;
3300
+ invalidateActiveProjectCache();
3055
3301
  return {
3056
3302
  content: [{
3057
3303
  type: "text",
@@ -3224,6 +3470,7 @@ server.registerTool("relate", {
3224
3470
  retry,
3225
3471
  };
3226
3472
  const retrySummary = formatRetrySummary(retry);
3473
+ invalidateActiveProjectCache();
3227
3474
  return {
3228
3475
  content: [{
3229
3476
  type: "text",
@@ -3390,6 +3637,7 @@ server.registerTool("unrelate", {
3390
3637
  retry,
3391
3638
  };
3392
3639
  const retrySummary = formatRetrySummary(retry);
3640
+ invalidateActiveProjectCache();
3393
3641
  return {
3394
3642
  content: [{
3395
3643
  type: "text",
@@ -3488,13 +3736,19 @@ server.registerTool("consolidate", {
3488
3736
  return findClusters(projectNotes, project);
3489
3737
  case "suggest-merges":
3490
3738
  return suggestMerges(projectNotes, threshold, defaultConsolidationMode, project, mode);
3491
- case "execute-merge":
3739
+ case "execute-merge": {
3492
3740
  if (!mergePlan) {
3493
3741
  return { content: [{ type: "text", text: "execute-merge strategy requires a mergePlan with sourceIds and targetTitle." }], isError: true };
3494
3742
  }
3495
- return executeMerge(entries, mergePlan, defaultConsolidationMode, project, cwd, mode, policy, allowProtectedBranch);
3496
- case "prune-superseded":
3497
- return pruneSuperseded(projectNotes, mode ?? defaultConsolidationMode, project, cwd, policy, allowProtectedBranch);
3743
+ const mergeResult = await executeMerge(entries, mergePlan, defaultConsolidationMode, project, cwd, mode, policy, allowProtectedBranch);
3744
+ invalidateActiveProjectCache();
3745
+ return mergeResult;
3746
+ }
3747
+ case "prune-superseded": {
3748
+ const pruneResult = await pruneSuperseded(projectNotes, mode ?? defaultConsolidationMode, project, cwd, policy, allowProtectedBranch);
3749
+ invalidateActiveProjectCache();
3750
+ return pruneResult;
3751
+ }
3498
3752
  case "dry-run":
3499
3753
  return dryRunAll(projectNotes, threshold, defaultConsolidationMode, project, mode);
3500
3754
  default:
@@ -4268,7 +4522,7 @@ server.registerPrompt("mnemonic-workflow-hint", {
4268
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" +
4269
4523
  "2. If `recall` or `list` returns matching ids, use `get` to inspect the best match.\n" +
4270
4524
  "3. If one memory should be refined, call `update`.\n" +
4271
- "4. If no memory covers the topic, call `discover_tags` if tags matter, then call `remember`.\n" +
4525
+ "4. If no memory covers the topic and tag choice is ambiguous, call `discover_tags` with note context, then call `remember`.\n" +
4272
4526
  "5. After storing or updating, use `relate` for strong connections, `consolidate` for overlap, and `move_memory` for wrong storage location.\n\n" +
4273
4527
  "### Anti-patterns\n\n" +
4274
4528
  "- Bad: call `remember` immediately because the user said 'remember'.\n" +
@@ -4286,7 +4540,7 @@ server.registerPrompt("mnemonic-workflow-hint", {
4286
4540
  "- project memory policy lookup\n\n" +
4287
4541
  "### Tiny examples\n\n" +
4288
4542
  "- Existing bug note found by `recall` -> inspect with `get` -> refine with `update`.\n" +
4289
- "- No matching note found by `recall` -> optional `discover_tags` -> create with `remember`.\n" +
4543
+ "- No matching note found by `recall` -> optional `discover_tags` with note context -> create with `remember`.\n" +
4290
4544
  "- Two notes overlap heavily -> inspect -> clean up with `consolidate`.",
4291
4545
  },
4292
4546
  },