pkm-mcp-server 1.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.
package/graph.js ADDED
@@ -0,0 +1,340 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import { getAllMarkdownFiles, extractFrontmatter } from "./utils.js";
4
+ import { buildBasenameMap } from "./helpers.js";
5
+
6
+ const WIKILINK_REGEX = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
7
+
8
+ /** Extract all wikilink targets from markdown content. */
9
+ export function extractWikilinks(content) {
10
+ const links = [];
11
+ const regex = new RegExp(WIKILINK_REGEX.source, WIKILINK_REGEX.flags);
12
+ let match;
13
+ while ((match = regex.exec(content)) !== null) {
14
+ links.push(match[1]);
15
+ }
16
+ return links;
17
+ }
18
+
19
+
20
+ /**
21
+ * Resolve a wikilink target to actual file paths.
22
+ *
23
+ * Resolution order:
24
+ * 1. Try exact relative path match (e.g., "folder/note" -> "folder/note.md")
25
+ * 2. Fall back to basename match (e.g., "note" -> any "note.md" in vault)
26
+ *
27
+ * @param {string} linkTarget - raw wikilink target text
28
+ * @param {Map<string, string[]>} resolutionMap - from buildLinkResolutionMap
29
+ * @param {Set<string>} allFilesSet - vault-relative file paths as a Set (for O(1) exact path matching)
30
+ * @returns {{ paths: string[], ambiguous: boolean }}
31
+ */
32
+ export function resolveLink(linkTarget, resolutionMap, allFilesSet) {
33
+ // Strip heading/block references: [[note#heading]] -> "note"
34
+ const cleaned = linkTarget.split("#")[0].split("^")[0].trim();
35
+ if (!cleaned) return { paths: [], ambiguous: false };
36
+
37
+ // 1. Try exact path match (with .md extension)
38
+ const withExt = cleaned.endsWith(".md") ? cleaned : cleaned + ".md";
39
+ if (allFilesSet.has(withExt)) {
40
+ return { paths: [withExt], ambiguous: false };
41
+ }
42
+
43
+ // 2. Basename match
44
+ const basename = path.basename(cleaned, ".md").toLowerCase();
45
+ const matches = resolutionMap.get(basename) || [];
46
+ return { paths: matches, ambiguous: matches.length > 1 };
47
+ }
48
+
49
+ /**
50
+ * Find all files that contain wikilinks resolving to a given target path.
51
+ * Returns file paths and their content for downstream processing (e.g., link rewriting).
52
+ *
53
+ * @param {string} targetPath - vault-relative path of the target file
54
+ * @param {string} vaultPath - absolute vault root
55
+ * @param {string[]} allFiles - all vault-relative file paths
56
+ * @param {Map<string, string[]>} resolutionMap - from buildBasenameMap
57
+ * @param {Set<string>} allFilesSet - all file paths as a Set
58
+ * @returns {Promise<Array<{file: string, content: string}>>}
59
+ */
60
+ export async function findFilesLinkingTo(targetPath, vaultPath, allFiles, resolutionMap, allFilesSet) {
61
+ const linking = [];
62
+ for (const file of allFiles) {
63
+ if (file === targetPath) continue;
64
+ let content;
65
+ try {
66
+ content = await fs.readFile(path.join(vaultPath, file), "utf-8");
67
+ } catch (e) {
68
+ if (e.code === "ENOENT") continue; // File deleted between listing and reading
69
+ throw e;
70
+ }
71
+ const links = extractWikilinks(content);
72
+ for (const link of links) {
73
+ const resolved = resolveLink(link, resolutionMap, allFilesSet);
74
+ if (resolved.paths.includes(targetPath)) {
75
+ linking.push({ file, content });
76
+ break;
77
+ }
78
+ }
79
+ }
80
+ return linking;
81
+ }
82
+
83
+ /**
84
+ * Rewrite wikilinks in content that match an old target to point to a new target.
85
+ * Preserves aliases (|text) and heading/block references (#heading, ^block).
86
+ *
87
+ * @param {string} content - file content
88
+ * @param {string} oldTarget - old link target (basename or path, without .md)
89
+ * @param {string} newTarget - new link target (basename or path, without .md)
90
+ * @returns {string} content with links rewritten
91
+ */
92
+ export function rewriteWikilinks(content, oldTarget, newTarget) {
93
+ // Match [[...]] links, capturing the full inner text
94
+ return content.replace(/\[\[([^\]]+)\]\]/g, (fullMatch, inner) => {
95
+ // Split on | first to separate alias
96
+ const pipeIdx = inner.indexOf("|");
97
+ const linkPart = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner;
98
+ const aliasPart = pipeIdx !== -1 ? inner.slice(pipeIdx) : ""; // includes the |
99
+
100
+ // Split link part on # to separate heading/block ref
101
+ const hashIdx = linkPart.indexOf("#");
102
+ const pathPart = hashIdx !== -1 ? linkPart.slice(0, hashIdx) : linkPart;
103
+ const fragPart = hashIdx !== -1 ? linkPart.slice(hashIdx) : ""; // includes the #
104
+
105
+ // Check if this link's path matches the old target
106
+ const pathTrimmed = pathPart.trim();
107
+ const pathNoExt = pathTrimmed.endsWith(".md") ? pathTrimmed.slice(0, -3) : pathTrimmed;
108
+
109
+ // Match by exact path or by basename
110
+ const oldNoExt = oldTarget.endsWith(".md") ? oldTarget.slice(0, -3) : oldTarget;
111
+ const oldBasename = oldNoExt.includes("/") ? oldNoExt.split("/").pop() : oldNoExt;
112
+
113
+ if (pathNoExt === oldNoExt || pathNoExt === oldBasename) {
114
+ return `[[${newTarget}${fragPart}${aliasPart}]]`;
115
+ }
116
+
117
+ return fullMatch;
118
+ });
119
+ }
120
+
121
+ // --- Link Discovery ---
122
+
123
+ /**
124
+ * Build an incoming link index: for each resolved target file, which source files link to it.
125
+ * Uses proper link resolution so ambiguous basenames are handled correctly.
126
+ *
127
+ * @param {string} vaultPath - absolute vault path
128
+ * @param {string[]} allFiles - vault-relative file paths
129
+ * @param {Map<string, string[]>} resolutionMap - from buildLinkResolutionMap
130
+ * @param {Set<string>} allFilesSet - vault-relative file paths as a Set
131
+ * @returns {Promise<Map<string, Set<string>>>} targetPath -> Set<sourcePath>
132
+ */
133
+ async function buildIncomingIndex(vaultPath, allFiles, resolutionMap, allFilesSet) {
134
+ const index = new Map(); // targetPath -> Set<sourcePath>
135
+
136
+ for (const file of allFiles) {
137
+ let content;
138
+ try {
139
+ content = await fs.readFile(path.join(vaultPath, file), "utf-8");
140
+ } catch (e) {
141
+ if (e.code === "ENOENT") continue; // File deleted between listing and reading
142
+ throw e;
143
+ }
144
+ const links = extractWikilinks(content);
145
+ for (const link of links) {
146
+ const resolved = resolveLink(link, resolutionMap, allFilesSet);
147
+ for (const targetPath of resolved.paths) {
148
+ if (targetPath === file) continue; // skip self-links
149
+ if (!index.has(targetPath)) {
150
+ index.set(targetPath, new Set());
151
+ }
152
+ index.get(targetPath).add(file);
153
+ }
154
+ }
155
+ }
156
+
157
+ return index;
158
+ }
159
+
160
+ /**
161
+ * Get incoming links for a specific file.
162
+ *
163
+ * @param {string} filePath - vault-relative path
164
+ * @param {Map<string, Set<string>>} incomingIndex - from buildIncomingIndex
165
+ * @returns {string[]} source file paths
166
+ */
167
+ function getIncomingLinks(filePath, incomingIndex) {
168
+ const sources = incomingIndex.get(filePath);
169
+ return sources ? Array.from(sources) : [];
170
+ }
171
+
172
+ // --- Graph Traversal ---
173
+
174
+ /**
175
+ * Explore the graph neighborhood around a note using BFS.
176
+ *
177
+ * @param {Object} options
178
+ * @param {string} options.startPath - vault-relative path to the starting note
179
+ * @param {string} options.vaultPath - absolute vault root path
180
+ * @param {number} [options.depth=2] - maximum traversal depth
181
+ * @param {"both"|"outgoing"|"incoming"} [options.direction="both"] - link direction to follow
182
+ * @returns {Promise<NeighborhoodResult>}
183
+ *
184
+ * @typedef {Object} NeighborhoodResult
185
+ * @property {Map<number, NodeInfo[]>} depthGroups - nodes grouped by hop distance
186
+ * @property {number} totalNodes - total nodes discovered
187
+ *
188
+ * @typedef {Object} NodeInfo
189
+ * @property {string} path - vault-relative file path
190
+ * @property {number} depth - hop distance from start
191
+ * @property {boolean} ambiguous - true if reached via an ambiguous link
192
+ * @property {Object} metadata - frontmatter metadata
193
+ * @property {string|null} metadata.type
194
+ * @property {string|null} metadata.status
195
+ * @property {string[]} metadata.tags
196
+ */
197
+ export async function exploreNeighborhood({
198
+ startPath,
199
+ vaultPath,
200
+ depth: maxDepth = 2,
201
+ direction = "both",
202
+ }) {
203
+ // Verify start file exists
204
+ const startFullPath = path.resolve(vaultPath, startPath);
205
+ if (startFullPath !== vaultPath && !startFullPath.startsWith(vaultPath + path.sep)) {
206
+ throw new Error("Path escapes vault directory");
207
+ }
208
+ await fs.access(startFullPath); // throws if missing
209
+
210
+ // Build indexes once
211
+ const allFiles = await getAllMarkdownFiles(vaultPath);
212
+ const { basenameMap: resolutionMap, allFilesSet } = buildBasenameMap(allFiles);
213
+
214
+ const needIncoming = direction === "both" || direction === "incoming";
215
+ const incomingIndex = needIncoming
216
+ ? await buildIncomingIndex(vaultPath, allFiles, resolutionMap, allFilesSet)
217
+ : null;
218
+
219
+ // BFS state
220
+ const visited = new Set();
221
+ const depthGroups = new Map();
222
+ let queue = [{ path: startPath, depth: 0, ambiguous: false }];
223
+
224
+ while (queue.length > 0) {
225
+ const nextQueue = [];
226
+
227
+ for (const { path: nodePath, depth, ambiguous } of queue) {
228
+ if (visited.has(nodePath)) continue;
229
+ visited.add(nodePath);
230
+
231
+ // Read file and extract metadata
232
+ let metadata = { type: null, status: null, tags: [] };
233
+ let content = null;
234
+ try {
235
+ content = await fs.readFile(path.join(vaultPath, nodePath), "utf-8");
236
+ const fm = extractFrontmatter(content);
237
+ if (fm) {
238
+ metadata = {
239
+ type: fm.type || null,
240
+ status: fm.status || null,
241
+ tags: Array.isArray(fm.tags) ? fm.tags.map(String) : [],
242
+ };
243
+ }
244
+ } catch (e) {
245
+ if (e.code !== "ENOENT") throw e;
246
+ // File deleted between listing and reading — record node but skip link discovery
247
+ }
248
+
249
+ // Record node
250
+ if (!depthGroups.has(depth)) {
251
+ depthGroups.set(depth, []);
252
+ }
253
+ depthGroups.get(depth).push({ path: nodePath, depth, ambiguous, metadata });
254
+
255
+ // Don't discover links beyond max depth
256
+ if (depth >= maxDepth) continue;
257
+ if (!content) continue;
258
+
259
+ // Discover neighbors
260
+ const needOutgoing = direction === "both" || direction === "outgoing";
261
+
262
+ if (needOutgoing) {
263
+ const outgoing = extractWikilinks(content);
264
+ for (const link of outgoing) {
265
+ const resolved = resolveLink(link, resolutionMap, allFilesSet);
266
+ for (const targetPath of resolved.paths) {
267
+ if (!visited.has(targetPath)) {
268
+ nextQueue.push({
269
+ path: targetPath,
270
+ depth: depth + 1,
271
+ ambiguous: resolved.ambiguous,
272
+ });
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
+ if (needIncoming && incomingIndex) {
279
+ const incoming = getIncomingLinks(nodePath, incomingIndex);
280
+ for (const sourcePath of incoming) {
281
+ if (!visited.has(sourcePath)) {
282
+ nextQueue.push({
283
+ path: sourcePath,
284
+ depth: depth + 1,
285
+ ambiguous: false,
286
+ });
287
+ }
288
+ }
289
+ }
290
+ }
291
+
292
+ queue = nextQueue;
293
+ }
294
+
295
+ return {
296
+ depthGroups,
297
+ totalNodes: visited.size,
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Format a neighborhood result as human-readable text.
303
+ *
304
+ * @param {NeighborhoodResult} result - from exploreNeighborhood
305
+ * @param {Object} options
306
+ * @param {string} options.startPath
307
+ * @param {number} options.depth
308
+ * @param {string} options.direction
309
+ * @returns {string}
310
+ */
311
+ export function formatNeighborhood(result, { startPath, depth, direction }) {
312
+ const { depthGroups, totalNodes } = result;
313
+
314
+ let output = `**Graph neighborhood for ${startPath}** (depth: ${depth}, direction: ${direction})\n`;
315
+ output += `Total: ${totalNodes} node${totalNodes === 1 ? "" : "s"}\n`;
316
+
317
+ // Sort depth keys
318
+ const depths = Array.from(depthGroups.keys()).sort((a, b) => a - b);
319
+
320
+ for (const d of depths) {
321
+ const nodes = depthGroups.get(d);
322
+ const label = d === 0 ? "Center" : `Depth ${d}`;
323
+ output += `\n**${label}** (${nodes.length} node${nodes.length === 1 ? "" : "s"})\n`;
324
+
325
+ for (const node of nodes) {
326
+ let line = `- ${node.path}`;
327
+ if (node.ambiguous) line += " [ambiguous]";
328
+
329
+ const meta = [];
330
+ if (node.metadata.type) meta.push(`type: ${node.metadata.type}`);
331
+ if (node.metadata.status) meta.push(`status: ${node.metadata.status}`);
332
+ if (node.metadata.tags.length > 0) meta.push(`tags: ${node.metadata.tags.join(", ")}`);
333
+ if (meta.length > 0) line += `\n ${meta.join(" | ")}`;
334
+
335
+ output += line + "\n";
336
+ }
337
+ }
338
+
339
+ return output;
340
+ }