@danielmarbach/mnemonic-mcp 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ 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.16.0] - Unreleased
8
+
9
+ ### Added
10
+
11
+ - Bounded 1-hop relationship expansion layer (`src/relationships.ts`): `recall`, `project_memory_summary`, and `get` now surface direct related notes as compact previews, scored by same-project priority, anchor status, recency, and confidence.
12
+ - `recall` automatically attaches relationship previews to top results (top 1 by default, top 3 when result count is small). Previews appear in both text and structured output.
13
+ - `project_memory_summary` orientation entries (`primaryEntry` and `suggestedNext`) include bounded relationship previews, including the fallback primaryEntry when no anchor notes exist.
14
+ - `get` accepts an optional `includeRelationships` parameter; when true, each returned note includes a bounded 1-hop relationship preview in both text and structured output.
15
+
16
+ ### Changed
17
+
18
+ - `RecallResult.results` entries now include an optional `relationships` field (`RelationshipPreview`).
19
+ - `GetResult.notes` entries now include an optional `relationships` field (`RelationshipPreview`).
20
+ - `OrientationNote` now includes an optional `relationships` field (`RelationshipPreview`).
21
+ - `discover_tags` now defaults to note-oriented tag suggestions using title/content/query context, returning bounded `recommendedTags`; broader inventory output is now explicit via `mode: "browse"`.
22
+
7
23
  ## [0.15.0] - 2026-03-23
8
24
 
9
25
  ### Added
package/README.md CHANGED
@@ -429,10 +429,10 @@ Imported notes are written to the main vault with `lifecycle: permanent` and `sc
429
429
  |-----------------------------|--------------------------------------------------------------------------|
430
430
  | `consolidate` | Merge multiple notes into one with relationship to sources |
431
431
  | `detect_project` | Resolve `cwd` to stable project id via git remote URL |
432
- | `discover_tags` | List existing tags with usage counts and examples for consistent terminology |
432
+ | `discover_tags` | Suggest canonical tags for a note using title/content/query context; `mode: "browse"` opts into broader inventory output |
433
433
  | `execute_migration` | Execute a named migration (supports dry-run) |
434
434
  | `forget` | Delete note + embedding, git commit + push, cleanup relationships |
435
- | `get` | Fetch one or more notes by exact id |
435
+ | `get` | Fetch one or more notes by exact id; `includeRelationships: true` adds bounded 1-hop previews |
436
436
  | `get_project_identity` | Show effective project identity and remote override |
437
437
  | `get_project_memory_policy` | Show saved write scope, consolidation mode, and protected-branch settings |
438
438
  | `list` | List notes filtered by scope/tags/storage |
package/build/index.js CHANGED
@@ -11,6 +11,7 @@ import { buildTemporalHistoryEntry, computeConfidence, getNoteProvenance } from
11
11
  import { getOrBuildProjection } from "./projections.js";
12
12
  import { filterRelationships, mergeRelationshipsFromNotes, normalizeMergePlanSourceIds, resolveEffectiveConsolidationMode, } from "./consolidate.js";
13
13
  import { selectRecallResults } from "./recall.js";
14
+ import { getRelationshipPreview } from "./relationships.js";
14
15
  import { cleanMarkdown } from "./markdown.js";
15
16
  import { MnemonicConfigStore, readVaultSchemaVersion } from "./config.js";
16
17
  import { CONSOLIDATION_MODES, PROTECTED_BRANCH_BEHAVIORS, PROJECT_POLICY_SCOPES, WRITE_SCOPES, isProtectedBranch, resolveProtectedBranchBehavior, resolveProtectedBranchPatterns, resolveConsolidationMode, resolveWriteScope, } from "./project-memory-policy.js";
@@ -382,10 +383,10 @@ function formatProjectIdentityText(identity) {
382
383
  function describeLifecycle(lifecycle) {
383
384
  return `lifecycle: ${lifecycle}`;
384
385
  }
385
- function formatNote(note, score) {
386
+ function formatNote(note, score, showRawRelated = true) {
386
387
  const scoreStr = score !== undefined ? ` | similarity: ${score.toFixed(3)}` : "";
387
388
  const projectStr = note.project ? ` | project: ${note.projectName ?? note.project}` : " | global";
388
- const relStr = note.relatedTo && note.relatedTo.length > 0
389
+ const relStr = showRawRelated && note.relatedTo && note.relatedTo.length > 0
389
390
  ? `\n**related:** ${note.relatedTo.map((r) => `\`${r.id}\` (${r.type})`).join(", ")}`
390
391
  : "";
391
392
  return (`## ${note.title}\n` +
@@ -404,6 +405,15 @@ function formatTemporalHistory(history) {
404
405
  }
405
406
  return lines.join("\n");
406
407
  }
408
+ function formatRelationshipPreview(preview) {
409
+ const shown = preview.shown
410
+ .map(r => `${r.title} (\`${r.id}\`) [${r.relationType ?? "related-to"}]`)
411
+ .join(", ");
412
+ const more = preview.truncated
413
+ ? ` [+${preview.totalDirectRelations - preview.shown.length} more]`
414
+ : "";
415
+ return `**related (${preview.totalDirectRelations}):** ${shown}${more}`;
416
+ }
407
417
  // ── Git commit message helpers ────────────────────────────────────────────────
408
418
  /**
409
419
  * Extract a short human-readable summary from note content.
@@ -1636,6 +1646,7 @@ server.registerTool("recall", {
1636
1646
  "- You just want to browse by tags or scope; use `list`\n\n" +
1637
1647
  "Returns:\n" +
1638
1648
  "- Ranked memory matches with scores, vault label, tags, lifecycle, and updated time\n" +
1649
+ "- Bounded 1-hop relationship previews automatically attached to top results\n" +
1639
1650
  "- In temporal mode, optional compact history entries for top matches\n\n" +
1640
1651
  "Read-only.\n\n" +
1641
1652
  "Typical next step:\n" +
@@ -1723,6 +1734,9 @@ server.registerTool("recall", {
1723
1734
  : `Recall results (global):`;
1724
1735
  const sections = [];
1725
1736
  const structuredResults = [];
1737
+ // Determine how many top results get relationship expansion
1738
+ // Top 1 by default, top 3 if result count is small
1739
+ const recallRelationshipLimit = top.length <= 3 ? 3 : 1;
1726
1740
  for (const [index, { id, score, vault, boosted }] of top.entries()) {
1727
1741
  const note = await readCachedNote(vault, id);
1728
1742
  if (note) {
@@ -1740,10 +1754,19 @@ server.registerTool("recall", {
1740
1754
  }));
1741
1755
  }
1742
1756
  }
1757
+ // Add relationship preview for top N results (fail-soft)
1758
+ let relationships;
1759
+ if (index < recallRelationshipLimit) {
1760
+ relationships = await getRelationshipPreview(note, vaultManager.allKnownVaults(), { activeProjectId: project?.id, limit: 3 });
1761
+ }
1743
1762
  const formattedHistory = mode === "temporal" && history !== undefined
1744
1763
  ? `\n\n${formatTemporalHistory(history)}`
1745
1764
  : "";
1746
- sections.push(`${formatNote(note, score)}${formattedHistory}`);
1765
+ const formattedRelationships = relationships !== undefined
1766
+ ? `\n\n${formatRelationshipPreview(relationships)}`
1767
+ : "";
1768
+ // Suppress raw related IDs when enriched preview is shown to avoid duplication
1769
+ sections.push(`${formatNote(note, score, relationships === undefined)}${formattedHistory}${formattedRelationships}`);
1747
1770
  structuredResults.push({
1748
1771
  id,
1749
1772
  title: note.title,
@@ -1758,6 +1781,7 @@ server.registerTool("recall", {
1758
1781
  provenance,
1759
1782
  confidence,
1760
1783
  history,
1784
+ relationships,
1761
1785
  });
1762
1786
  }
1763
1787
  }
@@ -2044,7 +2068,8 @@ server.registerTool("get", {
2044
2068
  "- You are still searching by topic; use `recall`\n" +
2045
2069
  "- You want to browse many notes; use `list`\n\n" +
2046
2070
  "Returns:\n" +
2047
- "- Full note content and metadata for the requested ids, including storage label\n\n" +
2071
+ "- Full note content and metadata for the requested ids, including storage label\n" +
2072
+ "- Bounded 1-hop relationship previews when `includeRelationships` is true (max 3 shown)\n\n" +
2048
2073
  "Read-only.\n\n" +
2049
2074
  "Typical next step:\n" +
2050
2075
  "- Use `update`, `forget`, `move_memory`, or `relate` after inspection.",
@@ -2056,10 +2081,12 @@ server.registerTool("get", {
2056
2081
  inputSchema: z.object({
2057
2082
  ids: z.array(z.string()).min(1).describe("One or more memory ids to fetch"),
2058
2083
  cwd: projectParam,
2084
+ includeRelationships: z.boolean().optional().default(false).describe("Include bounded direct relationship previews (1-hop expansion, max 3 shown)"),
2059
2085
  }),
2060
2086
  outputSchema: GetResultSchema,
2061
- }, async ({ ids, cwd }) => {
2087
+ }, async ({ ids, cwd, includeRelationships }) => {
2062
2088
  await ensureBranchSynced(cwd);
2089
+ const project = await resolveProject(cwd);
2063
2090
  const found = [];
2064
2091
  const notFound = [];
2065
2092
  for (const id of ids) {
@@ -2069,6 +2096,10 @@ server.registerTool("get", {
2069
2096
  continue;
2070
2097
  }
2071
2098
  const { note, vault } = result;
2099
+ let relationships;
2100
+ if (includeRelationships) {
2101
+ relationships = await getRelationshipPreview(note, vaultManager.allKnownVaults(), { activeProjectId: project?.id, limit: 3 });
2102
+ }
2072
2103
  found.push({
2073
2104
  id: note.id,
2074
2105
  title: note.title,
@@ -2081,6 +2112,7 @@ server.registerTool("get", {
2081
2112
  createdAt: note.createdAt,
2082
2113
  updatedAt: note.updatedAt,
2083
2114
  vault: storageLabel(vault),
2115
+ relationships,
2084
2116
  });
2085
2117
  }
2086
2118
  const lines = [];
@@ -2091,6 +2123,10 @@ server.registerTool("get", {
2091
2123
  lines.push(`tags: ${note.tags.join(", ")}`);
2092
2124
  lines.push("");
2093
2125
  lines.push(note.content);
2126
+ if (note.relationships) {
2127
+ lines.push("");
2128
+ lines.push(formatRelationshipPreview(note.relationships));
2129
+ }
2094
2130
  lines.push("");
2095
2131
  }
2096
2132
  if (notFound.length > 0) {
@@ -2247,24 +2283,42 @@ server.registerTool("list", {
2247
2283
  };
2248
2284
  return { content: [{ type: "text", text: textContent }], structuredContent };
2249
2285
  });
2250
- // ── discover_tags ───────────────────────────────────────────────────────────
2286
+ function tokenizeTagDiscoveryText(value) {
2287
+ return value
2288
+ .toLowerCase()
2289
+ .split(/[^a-z0-9]+/)
2290
+ .filter(Boolean);
2291
+ }
2292
+ function countTokenOverlap(tokens, other) {
2293
+ let matches = 0;
2294
+ for (const token of other) {
2295
+ if (tokens.has(token)) {
2296
+ matches++;
2297
+ }
2298
+ }
2299
+ return matches;
2300
+ }
2301
+ function hasExactTagContextMatch(tag, values) {
2302
+ const normalizedTag = tag.toLowerCase();
2303
+ return values.some(value => value?.toLowerCase().includes(normalizedTag) ?? false);
2304
+ }
2251
2305
  server.registerTool("discover_tags", {
2252
2306
  title: "Discover Tags",
2253
- description: "Discover existing tags across vaults with usage statistics and examples.\n\n" +
2307
+ description: "Suggest canonical tags for a specific note before `remember` when tag choice is ambiguous.\n\n" +
2254
2308
  "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" +
2309
+ "- You have a note title, content, or query and want compact tag suggestions\n" +
2310
+ "- You want canonical project terminology without exposing lots of unrelated tags\n" +
2311
+ "- You want to demote temporary-only tags unless they fit the note\n\n" +
2258
2312
  "Do not use this when:\n" +
2259
2313
  "- 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" +
2314
+ "- You already know the exact tags you want to use\n" +
2315
+ "- You want broad inventory output but are not explicitly requesting `mode: \"browse\"`\n\n" +
2261
2316
  "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" +
2317
+ "- Default: bounded `recommendedTags` ranked by note relevance first and usage count second\n" +
2318
+ "- Each suggestion includes canonicality and lifecycle signals plus one compact example\n" +
2319
+ "- Optional `mode: \"browse\"` returns broader inventory output\n\n" +
2266
2320
  "Typical next step:\n" +
2267
- "- Use canonical tags from discover_tags when appropriate, or create new tags when genuinely novel.\n\n" +
2321
+ "- Reuse suggested canonical tags when they fit, or create a new tag only when genuinely novel.\n\n" +
2268
2322
  "Performance: O(n) where n = total notes scanned. Expect 100-200ms for 500 notes.\n\n" +
2269
2323
  "Read-only.",
2270
2324
  annotations: {
@@ -2274,6 +2328,12 @@ server.registerTool("discover_tags", {
2274
2328
  },
2275
2329
  inputSchema: z.object({
2276
2330
  cwd: projectParam,
2331
+ mode: z.enum(["suggest", "browse"]).optional().default("suggest"),
2332
+ title: z.string().optional(),
2333
+ content: z.string().optional(),
2334
+ query: z.string().optional(),
2335
+ candidateTags: z.array(z.string()).optional(),
2336
+ lifecycle: z.enum(NOTE_LIFECYCLES).optional(),
2277
2337
  scope: z
2278
2338
  .enum(["project", "global", "all"])
2279
2339
  .optional()
@@ -2286,65 +2346,170 @@ server.registerTool("discover_tags", {
2286
2346
  .optional()
2287
2347
  .default("any")
2288
2348
  .describe("Filter by vault storage label like list tool."),
2349
+ limit: z.number().int().min(1).max(50).optional(),
2289
2350
  }),
2290
2351
  outputSchema: DiscoverTagsResultSchema,
2291
- }, async ({ cwd, scope, storedIn }) => {
2352
+ }, async ({ cwd, mode, title, content, query, candidateTags, lifecycle, scope, storedIn, limit }) => {
2292
2353
  await ensureBranchSynced(cwd);
2293
2354
  const startTime = Date.now();
2294
2355
  const { project, entries } = await collectVisibleNotes(cwd, scope, undefined, storedIn);
2295
2356
  const tagStats = new Map();
2357
+ const candidateTagSet = new Set((candidateTags || []).map(tag => tag.toLowerCase()));
2358
+ const contextValues = [title, content, query, ...(candidateTags || [])];
2359
+ const contextTokens = new Set(tokenizeTagDiscoveryText(contextValues.filter(Boolean).join(" ")));
2360
+ const isTemporaryTarget = lifecycle === "temporary";
2361
+ const effectiveLimit = limit ?? (mode === "browse" ? 20 : 10);
2296
2362
  for (const { note } of entries) {
2363
+ const noteTokens = new Set(tokenizeTagDiscoveryText(`${note.title} ${note.content} ${note.tags.join(" ")}`));
2364
+ const contextMatches = contextTokens.size > 0 ? countTokenOverlap(contextTokens, noteTokens) : 0;
2297
2365
  for (const tag of note.tags) {
2298
- const stats = tagStats.get(tag) || { count: 0, examples: [], lifecycles: new Set() };
2366
+ const stats = tagStats.get(tag) || {
2367
+ count: 0,
2368
+ examples: [],
2369
+ lifecycles: new Set(),
2370
+ contextMatches: 0,
2371
+ exactCandidateMatch: false,
2372
+ };
2299
2373
  stats.count++;
2300
2374
  if (stats.examples.length < 3) {
2301
2375
  stats.examples.push(note.title);
2302
2376
  }
2303
2377
  stats.lifecycles.add(note.lifecycle);
2378
+ stats.contextMatches += contextMatches;
2379
+ if (candidateTagSet.has(tag.toLowerCase())) {
2380
+ stats.exactCandidateMatch = true;
2381
+ }
2304
2382
  tagStats.set(tag, stats);
2305
2383
  }
2306
2384
  }
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);
2385
+ const rawTags = Array.from(tagStats.entries())
2386
+ .map(([tag, stats]) => {
2387
+ const lifecycleTypes = Array.from(stats.lifecycles);
2388
+ const isTemporaryOnly = stats.lifecycles.size === 1 && stats.lifecycles.has("temporary");
2389
+ const tagTokens = tokenizeTagDiscoveryText(tag);
2390
+ const exactContextMatch = stats.exactCandidateMatch || hasExactTagContextMatch(tag, contextValues);
2391
+ const tagTokenOverlap = contextTokens.size > 0
2392
+ ? countTokenOverlap(contextTokens, tagTokens)
2393
+ : 0;
2394
+ const averageContextMatch = stats.count > 0 ? stats.contextMatches / stats.count : 0;
2395
+ const reason = exactContextMatch
2396
+ ? "matches a candidate tag already present in the note context"
2397
+ : tagTokenOverlap > 0 || stats.contextMatches > 0
2398
+ ? "matches the note context and existing project usage"
2399
+ : "high-usage canonical tag from existing project notes";
2400
+ return {
2401
+ tag,
2402
+ usageCount: stats.count,
2403
+ examples: stats.examples,
2404
+ example: stats.examples[0],
2405
+ reason,
2406
+ lifecycleTypes,
2407
+ isTemporaryOnly,
2408
+ exactContextMatch,
2409
+ tagTokenOverlap,
2410
+ averageContextMatch,
2411
+ isBroadSingleToken: tagTokens.length === 1,
2412
+ isHighFrequency: stats.count >= 4,
2413
+ specificityBoost: tagTokens.length > 1 ? 4 : 0,
2414
+ };
2415
+ });
2416
+ const hasStrongSpecificCandidate = rawTags.some(tag => tag.exactContextMatch && (!tag.isBroadSingleToken || tag.usageCount > 1));
2417
+ const tags = rawTags
2418
+ .map((tag) => {
2419
+ const hasWeakDirectMatch = tag.tagTokenOverlap <= 1 && tag.averageContextMatch <= 2;
2420
+ const genericPenalty = hasStrongSpecificCandidate && tag.isBroadSingleToken && tag.isHighFrequency && hasWeakDirectMatch
2421
+ ? 10
2422
+ : 0;
2423
+ const score = hasStrongSpecificCandidate
2424
+ ? (tag.exactContextMatch ? 12 : 0) +
2425
+ (tag.tagTokenOverlap * 5) +
2426
+ tag.specificityBoost +
2427
+ (tag.averageContextMatch * 3) +
2428
+ (tag.usageCount * 0.1) -
2429
+ genericPenalty -
2430
+ (!isTemporaryTarget && tag.isTemporaryOnly ? 2 : 0)
2431
+ : (tag.usageCount * 1.5) +
2432
+ (tag.isTemporaryOnly ? -3 : 1) -
2433
+ (!isTemporaryTarget && tag.isTemporaryOnly ? 2 : 0);
2434
+ return {
2435
+ ...tag,
2436
+ score,
2437
+ };
2438
+ })
2439
+ .sort((a, b) => b.score - a.score || b.usageCount - a.usageCount || a.tag.localeCompare(b.tag));
2316
2440
  const durationMs = Date.now() - startTime;
2441
+ const vaultsSearched = new Set(entries.map(e => storageLabel(e.vault))).size;
2317
2442
  const lines = [];
2318
- if (project && scope !== "global") {
2319
- lines.push(`Tags for ${project.name} (scope: ${scope}):`);
2443
+ if (mode === "browse") {
2444
+ if (project && scope !== "global") {
2445
+ lines.push(`Tags for ${project.name} (scope: ${scope}):`);
2446
+ }
2447
+ else {
2448
+ lines.push(`Tags (scope: ${scope}):`);
2449
+ }
2450
+ lines.push("");
2451
+ lines.push(`Total: ${tags.length} unique tags across ${entries.length} notes (${durationMs}ms)`);
2452
+ lines.push("");
2453
+ lines.push("Tags sorted by usage:");
2454
+ for (const t of tags.slice(0, effectiveLimit)) {
2455
+ const lifecycleMark = t.isTemporaryOnly ? " [temp-only]" : "";
2456
+ lines.push(` ${t.tag} (${t.usageCount})${lifecycleMark}`);
2457
+ if (t.examples.length > 0) {
2458
+ lines.push(` Example: "${t.examples[0]}"`);
2459
+ }
2460
+ }
2461
+ if (tags.length > effectiveLimit) {
2462
+ lines.push(` ... and ${tags.length - effectiveLimit} more`);
2463
+ }
2320
2464
  }
2321
2465
  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]}"`);
2466
+ const suggestedTags = tags.slice(0, effectiveLimit);
2467
+ if (project && scope !== "global") {
2468
+ lines.push(`Suggested tags for ${project.name} (scope: ${scope}):`);
2469
+ }
2470
+ else {
2471
+ lines.push(`Suggested tags (scope: ${scope}):`);
2472
+ }
2473
+ lines.push("");
2474
+ lines.push(`Considered ${tags.length} unique tags across ${entries.length} notes (${durationMs}ms)`);
2475
+ lines.push("");
2476
+ if (contextTokens.size === 0) {
2477
+ lines.push("No note context provided; showing the most canonical existing tags.");
2478
+ lines.push("");
2479
+ }
2480
+ lines.push("Recommended tags:");
2481
+ for (const t of suggestedTags) {
2482
+ const lifecycleMark = t.isTemporaryOnly ? " [temp-only]" : "";
2483
+ lines.push(` ${t.tag} (${t.usageCount})${lifecycleMark}`);
2484
+ if (t.example) {
2485
+ lines.push(` Example: "${t.example}"`);
2486
+ }
2487
+ lines.push(` Why: ${t.reason}`);
2333
2488
  }
2334
2489
  }
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
- };
2490
+ const structuredContent = mode === "browse"
2491
+ ? {
2492
+ action: "tags_discovered",
2493
+ project: project ? { id: project.id, name: project.name } : undefined,
2494
+ mode,
2495
+ scope: scope || "all",
2496
+ tags: tags.slice(0, effectiveLimit).map(({ score, example, reason, ...tag }) => tag),
2497
+ totalTags: tags.length,
2498
+ totalNotes: entries.length,
2499
+ vaultsSearched,
2500
+ durationMs,
2501
+ }
2502
+ : {
2503
+ action: "tags_discovered",
2504
+ project: project ? { id: project.id, name: project.name } : undefined,
2505
+ mode,
2506
+ scope: scope || "all",
2507
+ recommendedTags: tags.slice(0, effectiveLimit).map(({ score, examples, ...tag }) => tag),
2508
+ totalTags: tags.length,
2509
+ totalNotes: entries.length,
2510
+ vaultsSearched,
2511
+ durationMs,
2512
+ };
2348
2513
  return { content: [{ type: "text", text: lines.join("\n") }], structuredContent };
2349
2514
  });
2350
2515
  // ── recent_memories ───────────────────────────────────────────────────────────
@@ -2505,7 +2670,8 @@ server.registerTool("project_memory_summary", {
2505
2670
  "- You need exact note contents; use `get`\n" +
2506
2671
  "- You need direct semantic matches for a query; use `recall`\n\n" +
2507
2672
  "Returns:\n" +
2508
- "- A synthesized project-level summary based on stored memories\n\n" +
2673
+ "- A synthesized project-level summary based on stored memories\n" +
2674
+ "- Bounded 1-hop relationship previews on orientation entry points (primaryEntry and suggestedNext)\n\n" +
2509
2675
  "Read-only.\n\n" +
2510
2676
  "Typical next step:\n" +
2511
2677
  "- Use `recall` or `list` to drill down into specific areas.",
@@ -2729,10 +2895,24 @@ server.registerTool("project_memory_summary", {
2729
2895
  const confidence = computeConfidence("permanent", anchor.updatedAt, anchor.centrality);
2730
2896
  return { provenance, confidence };
2731
2897
  };
2898
+ // Helper to enrich an anchor with relationships (1-hop expansion)
2899
+ const enrichOrientationNoteWithRelationships = async (anchor) => {
2900
+ const vault = noteVaultMap.get(anchor.id);
2901
+ if (!vault)
2902
+ return {};
2903
+ const note = await vault.storage.readNote(anchor.id);
2904
+ if (!note)
2905
+ return {};
2906
+ const relationships = await getRelationshipPreview(note, vaultManager.allKnownVaults(), { activeProjectId: project.id, limit: 3 });
2907
+ return { relationships };
2908
+ };
2732
2909
  const primaryEnriched = primaryAnchor ? await enrichOrientationNote(primaryAnchor) : {};
2910
+ const primaryRelationships = primaryAnchor ? await enrichOrientationNoteWithRelationships(primaryAnchor) : {};
2733
2911
  const suggestedEnriched = await Promise.all(anchors.slice(1, 4).map(enrichOrientationNote));
2912
+ const suggestedRelationships = await Promise.all(anchors.slice(1, 4).map(enrichOrientationNoteWithRelationships));
2734
2913
  // Enrich fallback primaryEntry when no anchors exist
2735
2914
  let fallbackEnriched = {};
2915
+ let fallbackRelationships = {};
2736
2916
  if (!primaryAnchor && recent[0]) {
2737
2917
  const fallbackNote = recent[0].note;
2738
2918
  const vault = noteVaultMap.get(fallbackNote.id);
@@ -2742,6 +2922,9 @@ server.registerTool("project_memory_summary", {
2742
2922
  const confidence = computeConfidence(fallbackNote.lifecycle, fallbackNote.updatedAt, 0);
2743
2923
  fallbackEnriched = { provenance, confidence };
2744
2924
  }
2925
+ const preview = await getRelationshipPreview(fallbackNote, vaultManager.allKnownVaults(), { activeProjectId: project.id, limit: 3 });
2926
+ if (preview)
2927
+ fallbackRelationships = { relationships: preview };
2745
2928
  }
2746
2929
  const orientation = {
2747
2930
  primaryEntry: primaryAnchor
@@ -2750,6 +2933,7 @@ server.registerTool("project_memory_summary", {
2750
2933
  title: primaryAnchor.title,
2751
2934
  rationale: `Centrality ${primaryAnchor.centrality}, connects ${primaryAnchor.connectionDiversity} themes`,
2752
2935
  ...primaryEnriched,
2936
+ ...primaryRelationships,
2753
2937
  }
2754
2938
  : {
2755
2939
  id: recent[0]?.note.id ?? projectEntries[0]?.note.id ?? "",
@@ -2758,12 +2942,14 @@ server.registerTool("project_memory_summary", {
2758
2942
  ? "Most recent note — no high-centrality anchors found"
2759
2943
  : "Only note available",
2760
2944
  ...fallbackEnriched,
2945
+ ...fallbackRelationships,
2761
2946
  },
2762
2947
  suggestedNext: anchors.slice(1, 4).map((a, i) => ({
2763
2948
  id: a.id,
2764
2949
  title: a.title,
2765
2950
  rationale: `Centrality ${a.centrality}, connects ${a.connectionDiversity} themes`,
2766
2951
  ...suggestedEnriched[i],
2952
+ ...suggestedRelationships[i],
2767
2953
  })),
2768
2954
  };
2769
2955
  // Warning for taxonomy dilution
@@ -2782,10 +2968,16 @@ server.registerTool("project_memory_summary", {
2782
2968
  if (orientation.primaryEntry.confidence) {
2783
2969
  sections.push(` Confidence: ${orientation.primaryEntry.confidence}`);
2784
2970
  }
2971
+ if (orientation.primaryEntry.relationships) {
2972
+ sections.push(` ${formatRelationshipPreview(orientation.primaryEntry.relationships)}`);
2973
+ }
2785
2974
  if (orientation.suggestedNext.length > 0) {
2786
2975
  sections.push(`Then check:`);
2787
2976
  for (const next of orientation.suggestedNext) {
2788
2977
  sections.push(` - ${next.title} (\`${next.id}\`) — ${next.rationale}${next.confidence ? ` [${next.confidence}]` : ""}`);
2978
+ if (next.relationships) {
2979
+ sections.push(` ${formatRelationshipPreview(next.relationships)}`);
2980
+ }
2789
2981
  }
2790
2982
  }
2791
2983
  if (orientation.warnings && orientation.warnings.length > 0) {
@@ -4268,7 +4460,7 @@ server.registerPrompt("mnemonic-workflow-hint", {
4268
4460
  "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
4461
  "2. If `recall` or `list` returns matching ids, use `get` to inspect the best match.\n" +
4270
4462
  "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" +
4463
+ "4. If no memory covers the topic and tag choice is ambiguous, call `discover_tags` with note context, then call `remember`.\n" +
4272
4464
  "5. After storing or updating, use `relate` for strong connections, `consolidate` for overlap, and `move_memory` for wrong storage location.\n\n" +
4273
4465
  "### Anti-patterns\n\n" +
4274
4466
  "- Bad: call `remember` immediately because the user said 'remember'.\n" +
@@ -4286,7 +4478,7 @@ server.registerPrompt("mnemonic-workflow-hint", {
4286
4478
  "- project memory policy lookup\n\n" +
4287
4479
  "### Tiny examples\n\n" +
4288
4480
  "- 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" +
4481
+ "- No matching note found by `recall` -> optional `discover_tags` with note context -> create with `remember`.\n" +
4290
4482
  "- Two notes overlap heavily -> inspect -> clean up with `consolidate`.",
4291
4483
  },
4292
4484
  },