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/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/activity.js +147 -0
- package/embeddings.js +672 -0
- package/graph.js +340 -0
- package/handlers.js +871 -0
- package/helpers.js +855 -0
- package/index.js +498 -0
- package/package.json +63 -0
- package/sample-project/CLAUDE.md +193 -0
- package/templates/adr.md +52 -0
- package/templates/daily-note.md +19 -0
- package/templates/devlog.md +35 -0
- package/templates/fleeting-note.md +11 -0
- package/templates/literature-note.md +25 -0
- package/templates/meeting-notes.md +28 -0
- package/templates/moc.md +22 -0
- package/templates/permanent-note.md +26 -0
- package/templates/project-index.md +38 -0
- package/templates/research-note.md +35 -0
- package/templates/task.md +22 -0
- package/templates/troubleshooting-log.md +32 -0
- package/utils.js +31 -0
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
|
+
}
|