obsidian-mcp-pro 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +15 -7
  2. package/build/http-server.d.ts.map +1 -1
  3. package/build/http-server.js +68 -32
  4. package/build/http-server.js.map +1 -1
  5. package/build/index.d.ts.map +1 -1
  6. package/build/index.js +22 -14
  7. package/build/index.js.map +1 -1
  8. package/build/install.d.ts.map +1 -1
  9. package/build/install.js +1 -1
  10. package/build/install.js.map +1 -1
  11. package/build/lib/bases.d.ts.map +1 -1
  12. package/build/lib/bases.js +26 -11
  13. package/build/lib/bases.js.map +1 -1
  14. package/build/lib/chunker.js +81 -2
  15. package/build/lib/chunker.js.map +1 -1
  16. package/build/lib/dates.d.ts +2 -0
  17. package/build/lib/dates.d.ts.map +1 -1
  18. package/build/lib/dates.js +24 -0
  19. package/build/lib/dates.js.map +1 -1
  20. package/build/lib/embedding-providers.js +1 -1
  21. package/build/lib/embedding-providers.js.map +1 -1
  22. package/build/lib/embedding-store.d.ts +1 -0
  23. package/build/lib/embedding-store.d.ts.map +1 -1
  24. package/build/lib/embedding-store.js +110 -18
  25. package/build/lib/embedding-store.js.map +1 -1
  26. package/build/lib/errors.d.ts.map +1 -1
  27. package/build/lib/errors.js +13 -1
  28. package/build/lib/errors.js.map +1 -1
  29. package/build/lib/fs-ops.d.ts +2 -0
  30. package/build/lib/fs-ops.d.ts.map +1 -0
  31. package/build/lib/fs-ops.js +26 -0
  32. package/build/lib/fs-ops.js.map +1 -0
  33. package/build/lib/index-cache.d.ts +10 -0
  34. package/build/lib/index-cache.d.ts.map +1 -1
  35. package/build/lib/index-cache.js +36 -10
  36. package/build/lib/index-cache.js.map +1 -1
  37. package/build/lib/link-rewriter.d.ts.map +1 -1
  38. package/build/lib/link-rewriter.js +35 -3
  39. package/build/lib/link-rewriter.js.map +1 -1
  40. package/build/lib/logger.d.ts.map +1 -1
  41. package/build/lib/logger.js +88 -7
  42. package/build/lib/logger.js.map +1 -1
  43. package/build/lib/markdown.js.map +1 -1
  44. package/build/lib/progress.js +1 -1
  45. package/build/lib/progress.js.map +1 -1
  46. package/build/lib/sections.d.ts.map +1 -1
  47. package/build/lib/sections.js +2 -0
  48. package/build/lib/sections.js.map +1 -1
  49. package/build/lib/tag-rewriter.d.ts +1 -0
  50. package/build/lib/tag-rewriter.d.ts.map +1 -1
  51. package/build/lib/tag-rewriter.js +6 -1
  52. package/build/lib/tag-rewriter.js.map +1 -1
  53. package/build/lib/tool-output.d.ts +2 -0
  54. package/build/lib/tool-output.d.ts.map +1 -0
  55. package/build/lib/tool-output.js +5 -0
  56. package/build/lib/tool-output.js.map +1 -0
  57. package/build/lib/vault.d.ts +32 -2
  58. package/build/lib/vault.d.ts.map +1 -1
  59. package/build/lib/vault.js +201 -91
  60. package/build/lib/vault.js.map +1 -1
  61. package/build/tools/attachments.d.ts.map +1 -1
  62. package/build/tools/attachments.js +117 -46
  63. package/build/tools/attachments.js.map +1 -1
  64. package/build/tools/bases.d.ts.map +1 -1
  65. package/build/tools/bases.js +14 -12
  66. package/build/tools/bases.js.map +1 -1
  67. package/build/tools/canvas.d.ts.map +1 -1
  68. package/build/tools/canvas.js +118 -59
  69. package/build/tools/canvas.js.map +1 -1
  70. package/build/tools/links.d.ts.map +1 -1
  71. package/build/tools/links.js +183 -124
  72. package/build/tools/links.js.map +1 -1
  73. package/build/tools/prompts.d.ts.map +1 -1
  74. package/build/tools/prompts.js +14 -12
  75. package/build/tools/prompts.js.map +1 -1
  76. package/build/tools/read.d.ts.map +1 -1
  77. package/build/tools/read.js +105 -87
  78. package/build/tools/read.js.map +1 -1
  79. package/build/tools/sections.d.ts.map +1 -1
  80. package/build/tools/sections.js +84 -20
  81. package/build/tools/sections.js.map +1 -1
  82. package/build/tools/semantic.d.ts.map +1 -1
  83. package/build/tools/semantic.js +45 -49
  84. package/build/tools/semantic.js.map +1 -1
  85. package/build/tools/tags.d.ts.map +1 -1
  86. package/build/tools/tags.js +100 -56
  87. package/build/tools/tags.js.map +1 -1
  88. package/build/tools/write.d.ts.map +1 -1
  89. package/build/tools/write.js +27 -23
  90. package/build/tools/write.js.map +1 -1
  91. package/package.json +22 -13
@@ -1,11 +1,16 @@
1
+ import path from "path";
1
2
  import { z } from "zod";
2
- import { listNotes, getNoteStats } from "../lib/vault.js";
3
+ import { listNotes, getNoteStats, getVaultRootRealPath } from "../lib/vault.js";
3
4
  import { readAllCached } from "../lib/index-cache.js";
4
- import { extractWikilinks, resolveWikilink, extractAliases } from "../lib/markdown.js";
5
- import { sanitizeError } from "../lib/errors.js";
5
+ import { extractWikilinks, extractAliases } from "../lib/markdown.js";
6
+ import { escapeControlChars, sanitizeError } from "../lib/errors.js";
6
7
  import { log } from "../lib/logger.js";
7
8
  const GRAPH_CACHE_TTL_MS = 30_000;
8
9
  const GRAPH_CACHE_MAX_ENTRIES = 32;
10
+ const GRAPH_FINGERPRINT_CONCURRENCY = 64;
11
+ function displayLinkValue(value) {
12
+ return escapeControlChars(value);
13
+ }
9
14
  const graphCache = new Map();
10
15
  // Map iteration order = insertion order; delete+set to refresh recency.
11
16
  function setGraphCache(key, entry) {
@@ -19,26 +24,122 @@ function setGraphCache(key, entry) {
19
24
  graphCache.delete(oldest);
20
25
  }
21
26
  }
22
- async function fingerprintVault(vaultPath, notes) {
27
+ function fingerprintFromMtimes(notes, mtimes) {
23
28
  // Accumulate a 32-bit FNV-1a hash over "<sortedPath>|<mtimeMs>;" per note.
24
29
  // Catches add+delete churn and mtime-restoring edits that count+max-mtime
25
30
  // alone would miss.
26
31
  const sorted = [...notes].sort();
27
32
  let hash = 0x811c9dc5;
28
- const CONCURRENCY = 16;
29
- for (let i = 0; i < sorted.length; i += CONCURRENCY) {
30
- const slice = sorted.slice(i, i + CONCURRENCY);
31
- const stats = await Promise.all(slice.map((n) => getNoteStats(vaultPath, n).catch(() => null)));
33
+ for (const note of sorted) {
34
+ const mtime = mtimes.get(note) ?? 0;
35
+ const entry = `${note}|${mtime};`;
36
+ for (let k = 0; k < entry.length; k++) {
37
+ hash ^= entry.charCodeAt(k);
38
+ hash = Math.imul(hash, 0x01000193);
39
+ }
40
+ }
41
+ return `${sorted.length}:${(hash >>> 0).toString(16)}`;
42
+ }
43
+ async function fingerprintVault(vaultPath, notes) {
44
+ const mtimes = new Map();
45
+ const sorted = [...notes].sort();
46
+ const realVaultRoot = await getVaultRootRealPath(vaultPath);
47
+ for (let i = 0; i < sorted.length; i += GRAPH_FINGERPRINT_CONCURRENCY) {
48
+ const slice = sorted.slice(i, i + GRAPH_FINGERPRINT_CONCURRENCY);
49
+ const stats = await Promise.all(slice.map((n) => getNoteStats(vaultPath, n, { realVaultRoot }).catch(() => null)));
32
50
  for (let j = 0; j < slice.length; j++) {
33
51
  const mtime = stats[j]?.modified?.getTime() ?? 0;
34
- const entry = `${slice[j]}|${mtime};`;
35
- for (let k = 0; k < entry.length; k++) {
36
- hash ^= entry.charCodeAt(k);
37
- hash = Math.imul(hash, 0x01000193);
52
+ mtimes.set(slice[j], mtime);
53
+ }
54
+ }
55
+ return fingerprintFromMtimes(sorted, mtimes);
56
+ }
57
+ function buildSourceLookup(notes) {
58
+ const lookup = new Map();
59
+ for (const notePath of notes) {
60
+ const normalized = notePath.replace(/\.md$/i, "").toLowerCase();
61
+ const exactKey = `exact:${normalized}`;
62
+ if (!lookup.has(exactKey))
63
+ lookup.set(exactKey, notePath);
64
+ const basename = normalized.split("/").pop() ?? normalized;
65
+ if (!lookup.has(`base:${basename}`)) {
66
+ lookup.set(`base:${basename}`, notePath);
67
+ }
68
+ }
69
+ return lookup;
70
+ }
71
+ function sharedPathDepth(a, b) {
72
+ const as = a.toLowerCase().split("/");
73
+ const bs = b.toLowerCase().split("/");
74
+ let i = 0;
75
+ const max = Math.min(as.length, bs.length);
76
+ while (i < max && as[i] === bs[i])
77
+ i++;
78
+ return i;
79
+ }
80
+ function buildNotePathIndex(notes) {
81
+ const exact = new Map();
82
+ const basename = new Map();
83
+ for (const notePath of notes) {
84
+ const normalized = notePath.replace(/\.md$/i, "").toLowerCase();
85
+ if (!exact.has(normalized))
86
+ exact.set(normalized, notePath);
87
+ const noteBasename = path
88
+ .basename(notePath, path.extname(notePath))
89
+ .toLowerCase();
90
+ const matches = basename.get(noteBasename);
91
+ if (matches) {
92
+ matches.push(notePath);
93
+ }
94
+ else {
95
+ basename.set(noteBasename, [notePath]);
96
+ }
97
+ }
98
+ return { exact, basename };
99
+ }
100
+ function resolveWikilinkWithIndex(link, currentNotePath, allNotePaths, index, aliasMap) {
101
+ const cleanLink = link.split("#")[0].split("^")[0].trim();
102
+ if (!cleanLink)
103
+ return null;
104
+ const normalizedLink = cleanLink.replace(/\.md$/i, "");
105
+ const normalizedLinkLower = normalizedLink.toLowerCase();
106
+ const exact = index.exact.get(normalizedLinkLower);
107
+ if (exact)
108
+ return exact;
109
+ if (normalizedLink.includes("/")) {
110
+ const suffixCandidates = [];
111
+ for (const notePath of allNotePaths) {
112
+ const withoutExt = notePath.replace(/\.md$/i, "").toLowerCase();
113
+ if (withoutExt.endsWith(normalizedLinkLower)) {
114
+ const prefix = withoutExt.slice(0, withoutExt.length - normalizedLinkLower.length);
115
+ if (prefix === "" || prefix.endsWith("/"))
116
+ suffixCandidates.push(notePath);
38
117
  }
39
118
  }
119
+ if (suffixCandidates.length === 1)
120
+ return suffixCandidates[0];
121
+ if (suffixCandidates.length > 1) {
122
+ return nearestNotePath(currentNotePath, suffixCandidates);
123
+ }
40
124
  }
41
- return `${sorted.length}:${(hash >>> 0).toString(16)}`;
125
+ const linkBasename = path.basename(normalizedLink).toLowerCase();
126
+ const basenameCandidates = index.basename.get(linkBasename) ?? [];
127
+ if (basenameCandidates.length === 1)
128
+ return basenameCandidates[0];
129
+ if (basenameCandidates.length > 1) {
130
+ return nearestNotePath(currentNotePath, basenameCandidates);
131
+ }
132
+ return aliasMap.get(normalizedLinkLower) ?? null;
133
+ }
134
+ function nearestNotePath(currentNotePath, candidates) {
135
+ const sourceDir = path.dirname(currentNotePath).replace(/\\/g, "/");
136
+ return [...candidates].sort((a, b) => {
137
+ const da = sharedPathDepth(sourceDir, path.dirname(a).replace(/\\/g, "/"));
138
+ const db = sharedPathDepth(sourceDir, path.dirname(b).replace(/\\/g, "/"));
139
+ if (da !== db)
140
+ return db - da;
141
+ return a.length - b.length;
142
+ })[0];
42
143
  }
43
144
  async function buildLinkGraph(vaultPath, folder) {
44
145
  const cacheKey = `${vaultPath}::${folder ?? ""}`;
@@ -56,6 +157,8 @@ async function buildLinkGraph(vaultPath, folder) {
56
157
  const backlinks = new Map();
57
158
  const rawLinks = new Map();
58
159
  const noteLines = new Map();
160
+ const brokenLinks = new Map();
161
+ const outlinkDetails = new Map();
59
162
  // Initialize sets for all notes
60
163
  for (const notePath of allNotes) {
61
164
  outlinks.set(notePath, new Set());
@@ -63,7 +166,7 @@ async function buildLinkGraph(vaultPath, folder) {
63
166
  }
64
167
  // Read notes via the shared mtime cache so repeated graph builds (and
65
168
  // overlapping search_notes / get_tags scans) skip re-reads.
66
- const { contents: noteContents } = await readAllCached(vaultPath, allNotes, (note, err) => {
169
+ const { contents: noteContents, mtimes } = await readAllCached(vaultPath, allNotes, (note, err) => {
67
170
  log.warn("link graph: note read failed", { note, err });
68
171
  });
69
172
  // Build alias map first so any note can link to any other by alias
@@ -81,16 +184,18 @@ async function buildLinkGraph(vaultPath, folder) {
81
184
  continue;
82
185
  const prior = aliasMap.get(key);
83
186
  if (prior && prior !== notePath) {
84
- log.warn("Duplicate alias", { alias, a: prior, b: notePath });
187
+ log.warn("Duplicate alias", { alias, notes: [prior, notePath] });
85
188
  }
86
189
  aliasMap.set(key, notePath);
87
190
  }
88
191
  }
192
+ const pathIndex = buildNotePathIndex(allNotes);
89
193
  for (const notePath of allNotes) {
90
194
  const content = noteContents.get(notePath);
91
195
  if (content === undefined)
92
196
  continue;
93
- noteLines.set(notePath, content.split("\n"));
197
+ const lines = content.split("\n");
198
+ noteLines.set(notePath, lines);
94
199
  const links = extractWikilinks(content);
95
200
  // Fill in source for each link
96
201
  for (const link of links) {
@@ -98,12 +203,19 @@ async function buildLinkGraph(vaultPath, folder) {
98
203
  }
99
204
  rawLinks.set(notePath, links);
100
205
  const outSet = outlinks.get(notePath) ?? new Set();
206
+ const details = [];
101
207
  for (const link of links) {
102
208
  // Strip heading/block refs for resolution (e.g., "note#heading" -> "note")
103
209
  const targetBase = link.target.split("#")[0].trim();
104
210
  if (!targetBase)
105
211
  continue;
106
- const resolved = resolveWikilink(targetBase, notePath, allNotes, { aliasMap });
212
+ const resolved = resolveWikilinkWithIndex(targetBase, notePath, allNotes, pathIndex, aliasMap);
213
+ details.push({
214
+ target: link.target,
215
+ resolvedPath: resolved,
216
+ isValid: resolved !== null,
217
+ isEmbed: link.isEmbed,
218
+ });
107
219
  if (resolved) {
108
220
  outSet.add(resolved);
109
221
  // Ensure backlinks set exists for target
@@ -112,8 +224,21 @@ async function buildLinkGraph(vaultPath, folder) {
112
224
  }
113
225
  backlinks.get(resolved).add(notePath);
114
226
  }
227
+ else {
228
+ const lineInfo = findLineWithLink(lines, link.target);
229
+ const broken = {
230
+ sourcePath: notePath,
231
+ targetLink: link.target,
232
+ line: lineInfo.line,
233
+ };
234
+ if (!brokenLinks.has(notePath)) {
235
+ brokenLinks.set(notePath, []);
236
+ }
237
+ brokenLinks.get(notePath).push(broken);
238
+ }
115
239
  }
116
240
  outlinks.set(notePath, outSet);
241
+ outlinkDetails.set(notePath, details);
117
242
  }
118
243
  const data = {
119
244
  allNotes,
@@ -122,8 +247,12 @@ async function buildLinkGraph(vaultPath, folder) {
122
247
  rawLinks,
123
248
  noteLines,
124
249
  aliasMap,
250
+ brokenLinks,
251
+ sourceLookup: buildSourceLookup(allNotes),
252
+ pathIndex,
253
+ outlinkDetails,
125
254
  };
126
- const fingerprint = await fingerprintVault(vaultPath, allNotes);
255
+ const fingerprint = fingerprintFromMtimes(allNotes, mtimes);
127
256
  setGraphCache(cacheKey, { data, fingerprint, cachedAt: Date.now() });
128
257
  return data;
129
258
  }
@@ -212,7 +341,7 @@ export function registerLinkTools(server, vaultPath) {
212
341
  }
213
342
  }
214
343
  if (!resolvedTarget) {
215
- return errorResult(`No note found matching path: ${targetPath}`);
344
+ return errorResult(`No note found matching path: ${displayLinkValue(targetPath)}`);
216
345
  }
217
346
  const backlinkSources = graph.backlinks.get(resolvedTarget);
218
347
  if (!backlinkSources || backlinkSources.size === 0) {
@@ -220,7 +349,7 @@ export function registerLinkTools(server, vaultPath) {
220
349
  content: [
221
350
  {
222
351
  type: "text",
223
- text: `No backlinks found for: ${resolvedTarget}`,
352
+ text: `No backlinks found for: ${displayLinkValue(resolvedTarget)}`,
224
353
  },
225
354
  ],
226
355
  };
@@ -237,9 +366,7 @@ export function registerLinkTools(server, vaultPath) {
237
366
  // resolve here exactly as they did during graph build. Without
238
367
  // it, the source slipped into the backlink set during build but
239
368
  // produced an empty line/context in this display pass.
240
- const resolved = resolveWikilink(base, sourcePath, graph.allNotes, {
241
- aliasMap: graph.aliasMap,
242
- });
369
+ const resolved = resolveWikilinkWithIndex(base, sourcePath, graph.allNotes, graph.pathIndex, graph.aliasMap);
243
370
  return resolved === resolvedTarget;
244
371
  });
245
372
  if (relevantLinks.length > 0) {
@@ -266,12 +393,12 @@ export function registerLinkTools(server, vaultPath) {
266
393
  return true;
267
394
  });
268
395
  const output = [
269
- `Backlinks to: ${resolvedTarget}`,
396
+ `Backlinks to: ${displayLinkValue(resolvedTarget)}`,
270
397
  `Found: ${deduped.length} backlink(s)\n`,
271
398
  ...deduped.map((r) => {
272
399
  const lineStr = r.line > 0 ? `:${r.line}` : "";
273
- const contextStr = r.context ? ` → ${r.context}` : "";
274
- return `- ${r.source}${lineStr}${contextStr}`;
400
+ const contextStr = r.context ? ` → ${displayLinkValue(r.context)}` : "";
401
+ return `- ${displayLinkValue(r.source)}${lineStr}${contextStr}`;
275
402
  }),
276
403
  ].join("\n");
277
404
  return { content: [{ type: "text", text: output }] };
@@ -309,47 +436,19 @@ export function registerLinkTools(server, vaultPath) {
309
436
  // get_backlinks does).
310
437
  const targetNormalized = notePath.replace(/\.md$/i, "").toLowerCase();
311
438
  const targetBasename = targetNormalized.split("/").pop() ?? targetNormalized;
312
- let resolvedSource = null;
313
- for (const p of graph.allNotes) {
314
- if (p.replace(/\.md$/i, "").toLowerCase() === targetNormalized) {
315
- resolvedSource = p;
316
- break;
317
- }
318
- }
439
+ const resolvedSource = graph.sourceLookup.get(`exact:${targetNormalized}`) ??
440
+ graph.sourceLookup.get(`base:${targetBasename}`) ??
441
+ null;
319
442
  if (!resolvedSource) {
320
- for (const p of graph.allNotes) {
321
- const base = p.replace(/\.md$/i, "").split("/").pop()?.toLowerCase();
322
- if (base === targetBasename) {
323
- resolvedSource = p;
324
- break;
325
- }
326
- }
327
- }
328
- if (!resolvedSource) {
329
- return errorResult(`No note found matching path: ${notePath}`);
330
- }
331
- const links = graph.rawLinks.get(resolvedSource) ?? [];
332
- const results = [];
333
- for (const link of links) {
334
- const targetBase = link.target.split("#")[0].trim();
335
- if (!targetBase)
336
- continue;
337
- const resolved = resolveWikilink(targetBase, resolvedSource, graph.allNotes, {
338
- aliasMap: graph.aliasMap,
339
- });
340
- results.push({
341
- target: link.target,
342
- resolvedPath: resolved,
343
- isValid: resolved !== null,
344
- isEmbed: link.isEmbed,
345
- });
443
+ return errorResult(`No note found matching path: ${displayLinkValue(notePath)}`);
346
444
  }
445
+ const results = graph.outlinkDetails.get(resolvedSource) ?? [];
347
446
  if (results.length === 0) {
348
447
  return {
349
448
  content: [
350
449
  {
351
450
  type: "text",
352
- text: `No outgoing links found in: ${resolvedSource}`,
451
+ text: `No outgoing links found in: ${displayLinkValue(resolvedSource)}`,
353
452
  },
354
453
  ],
355
454
  };
@@ -357,21 +456,21 @@ export function registerLinkTools(server, vaultPath) {
357
456
  const valid = results.filter((r) => r.isValid);
358
457
  const broken = results.filter((r) => !r.isValid);
359
458
  const lines = [
360
- `Outgoing links from: ${resolvedSource}`,
459
+ `Outgoing links from: ${displayLinkValue(resolvedSource)}`,
361
460
  `Total: ${results.length} (${valid.length} valid, ${broken.length} broken)\n`,
362
461
  ];
363
462
  if (valid.length > 0) {
364
463
  lines.push("Valid links:");
365
464
  for (const r of valid) {
366
465
  const embedPrefix = r.isEmbed ? "📎 " : "";
367
- lines.push(` ${embedPrefix}[[${r.target}]] → ${r.resolvedPath}`);
466
+ lines.push(` ${embedPrefix}[[${displayLinkValue(r.target)}]] → ${displayLinkValue(r.resolvedPath ?? "")}`);
368
467
  }
369
468
  }
370
469
  if (broken.length > 0) {
371
470
  lines.push("\nBroken links:");
372
471
  for (const r of broken) {
373
472
  const embedPrefix = r.isEmbed ? "📎 " : "";
374
- lines.push(` ${embedPrefix}[[${r.target}]] → (not found)`);
473
+ lines.push(` ${embedPrefix}[[${displayLinkValue(r.target)}]] → (not found)`);
375
474
  }
376
475
  }
377
476
  return { content: [{ type: "text", text: lines.join("\n") }] };
@@ -436,14 +535,14 @@ export function registerLinkTools(server, vaultPath) {
436
535
  ];
437
536
  lines.push(`Fully isolated (no links in or out): ${fullyIsolated.length}`);
438
537
  for (const note of cappedIsolated) {
439
- lines.push(` - ${note.path}`);
538
+ lines.push(` - ${displayLinkValue(note.path)}`);
440
539
  }
441
540
  if (cappedIsolated.length < fullyIsolated.length) {
442
541
  lines.push(` ... and ${fullyIsolated.length - cappedIsolated.length} more`);
443
542
  }
444
543
  lines.push(`\nNo backlinks (not linked by any note): ${noBacklinks.length}`);
445
544
  for (const note of cappedNoBacklinks) {
446
- lines.push(` - ${note.path}`);
545
+ lines.push(` - ${displayLinkValue(note.path)}`);
447
546
  }
448
547
  if (cappedNoBacklinks.length < noBacklinks.length) {
449
548
  lines.push(` ... and ${noBacklinks.length - cappedNoBacklinks.length} more`);
@@ -451,7 +550,7 @@ export function registerLinkTools(server, vaultPath) {
451
550
  if (includeOutlinksCheck) {
452
551
  lines.push(`\nNo outlinks (links to no other notes): ${noOutlinks.length}`);
453
552
  for (const note of cappedNoOutlinks) {
454
- lines.push(` - ${note.path}`);
553
+ lines.push(` - ${displayLinkValue(note.path)}`);
455
554
  }
456
555
  if (cappedNoOutlinks.length < noOutlinks.length) {
457
556
  lines.push(` ... and ${noOutlinks.length - cappedNoOutlinks.length} more`);
@@ -492,60 +591,20 @@ export function registerLinkTools(server, vaultPath) {
492
591
  },
493
592
  }, async ({ folder, maxResults }) => {
494
593
  try {
495
- // Get all notes in vault for resolution, but only scan the folder.
496
- // Reads go through the shared mtime cache (`readAllCached`) so the
497
- // scan parallelizes within the cache's concurrency cap and re-runs
498
- // hit the cache instead of stat+read every file again - matching
499
- // the pattern search_notes uses for similar full-vault sweeps.
500
- const allNotes = await listNotes(vaultPath);
501
- const scanNotes = folder ? await listNotes(vaultPath, folder) : allNotes;
502
- // Build the alias map from the full vault so cross-folder alias
503
- // resolution still works when scoping to a single folder.
504
- const { contents: allContents } = await readAllCached(vaultPath, allNotes, (note, err) => {
505
- log.warn("find_broken_links: note read failed", { note, err });
506
- });
507
- const aliasMap = new Map();
508
- for (const notePath of allNotes) {
509
- const content = allContents.get(notePath);
510
- if (content === undefined)
511
- continue;
512
- for (const alias of extractAliases(content)) {
513
- const key = alias.toLowerCase();
514
- if (!key)
515
- continue;
516
- aliasMap.set(key, notePath);
517
- }
518
- }
594
+ // Validate and capture folder scope before the whole-vault graph work
595
+ // so a missing folder fails fast. Resolution still uses the full graph.
596
+ const scanNotes = folder ? await listNotes(vaultPath, folder) : null;
597
+ const graph = await buildLinkGraph(vaultPath);
598
+ const notesToScan = scanNotes ?? graph.allNotes;
519
599
  const brokenBySource = new Map();
520
- for (const notePath of scanNotes) {
521
- const content = allContents.get(notePath);
522
- if (content === undefined)
600
+ for (const notePath of notesToScan) {
601
+ const brokenLinks = graph.brokenLinks.get(notePath);
602
+ if (!brokenLinks || brokenLinks.length === 0)
523
603
  continue;
524
- const lines = content.split("\n");
525
- const links = extractWikilinks(content);
526
- for (const link of links) {
527
- const targetBase = link.target.split("#")[0].trim();
528
- if (!targetBase)
529
- continue;
530
- const resolved = resolveWikilink(targetBase, notePath, allNotes, {
531
- aliasMap,
532
- });
533
- if (!resolved) {
534
- const lineInfo = findLineWithLink(lines, link.target);
535
- const broken = {
536
- sourcePath: notePath,
537
- targetLink: link.target,
538
- line: lineInfo.line,
539
- };
540
- if (!brokenBySource.has(notePath)) {
541
- brokenBySource.set(notePath, []);
542
- }
543
- brokenBySource.get(notePath).push(broken);
544
- }
545
- }
604
+ brokenBySource.set(notePath, brokenLinks);
546
605
  }
547
606
  if (brokenBySource.size === 0) {
548
- const scopeStr = folder ? ` in folder: ${folder}` : "";
607
+ const scopeStr = folder ? ` in folder: ${displayLinkValue(folder)}` : "";
549
608
  return {
550
609
  content: [
551
610
  {
@@ -560,18 +619,18 @@ export function registerLinkTools(server, vaultPath) {
560
619
  totalBroken += brokenLinks.length;
561
620
  }
562
621
  const lines = [];
563
- const scopeStr = folder ? ` (folder: ${folder})` : "";
622
+ const scopeStr = folder ? ` (folder: ${displayLinkValue(folder)})` : "";
564
623
  lines.push(`Broken links report${scopeStr}\n`);
565
624
  let shown = 0;
566
625
  for (const [sourcePath, brokenLinks] of brokenBySource) {
567
626
  if (shown >= maxResults)
568
627
  break;
569
- lines.push(`${sourcePath}:`);
628
+ lines.push(`${displayLinkValue(sourcePath)}:`);
570
629
  for (const bl of brokenLinks) {
571
630
  if (shown >= maxResults)
572
631
  break;
573
632
  const lineStr = bl.line > 0 ? ` (line ${bl.line})` : "";
574
- lines.push(` - [[${bl.targetLink}]]${lineStr}`);
633
+ lines.push(` - [[${displayLinkValue(bl.targetLink)}]]${lineStr}`);
575
634
  shown++;
576
635
  }
577
636
  lines.push("");
@@ -653,7 +712,7 @@ export function registerLinkTools(server, vaultPath) {
653
712
  }
654
713
  }
655
714
  if (!resolvedStart) {
656
- return errorResult(`No note found matching path: ${startPath}`);
715
+ return errorResult(`No note found matching path: ${displayLinkValue(startPath)}`);
657
716
  }
658
717
  // BFS traversal with maxResults cap to prevent explosion at higher depths
659
718
  const visited = new Map();
@@ -715,7 +774,7 @@ export function registerLinkTools(server, vaultPath) {
715
774
  content: [
716
775
  {
717
776
  type: "text",
718
- text: `No neighbors found for: ${resolvedStart} (depth: ${depth}, direction: ${direction})`,
777
+ text: `No neighbors found for: ${displayLinkValue(resolvedStart)} (depth: ${depth}, direction: ${direction})`,
719
778
  },
720
779
  ],
721
780
  };
@@ -730,9 +789,9 @@ export function registerLinkTools(server, vaultPath) {
730
789
  }
731
790
  const truncatedStr = truncated ? " (TRUNCATED)" : "";
732
791
  const lines = [
733
- `Graph neighbors of: ${resolvedStart}`,
792
+ `Graph neighbors of: ${displayLinkValue(resolvedStart)}`,
734
793
  `Direction: ${direction} | Max depth: ${depth} | Found: ${visited.size} note(s)${truncatedStr}\n`,
735
- resolvedStart,
794
+ displayLinkValue(resolvedStart),
736
795
  ];
737
796
  const sortedDepths = [...byDepth.keys()].sort((a, b) => a - b);
738
797
  for (const d of sortedDepths) {
@@ -745,7 +804,7 @@ export function registerLinkTools(server, vaultPath) {
745
804
  : neighbor.direction === "outbound"
746
805
  ? "→"
747
806
  : "↔";
748
- lines.push(`${indent}${arrow} ${neighbor.path} (depth ${d})`);
807
+ lines.push(`${indent}${arrow} ${displayLinkValue(neighbor.path)} (depth ${d})`);
749
808
  }
750
809
  }
751
810
  if (truncated) {