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/helpers.js
ADDED
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
import { extractFrontmatter } from "./utils.js";
|
|
5
|
+
|
|
6
|
+
// Large-file thresholds (character counts, ~4 chars per token)
|
|
7
|
+
export const AUTO_REDIRECT_THRESHOLD = 80_000; // ~20k tokens
|
|
8
|
+
export const FORCE_HARD_CAP = 400_000; // ~100k tokens
|
|
9
|
+
export const CHUNK_SIZE = 80_000; // chars per chunk
|
|
10
|
+
|
|
11
|
+
const PRIORITY_RANKS = { urgent: 3, high: 2, normal: 1, low: 0 };
|
|
12
|
+
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
13
|
+
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compare two frontmatter values for sorting.
|
|
17
|
+
* Smart ordering: priority uses custom ranks, dates sort chronologically, strings use localeCompare.
|
|
18
|
+
* null/undefined always sort last.
|
|
19
|
+
*
|
|
20
|
+
* @param {*} a - first value
|
|
21
|
+
* @param {*} b - second value
|
|
22
|
+
* @param {string} field - frontmatter field name (used for priority detection)
|
|
23
|
+
* @returns {number} negative if a < b, positive if a > b, 0 if equal
|
|
24
|
+
*/
|
|
25
|
+
export function compareFrontmatterValues(a, b, field) {
|
|
26
|
+
// Normalize Date objects to YYYY-MM-DD strings
|
|
27
|
+
if (a instanceof Date) a = a.toISOString().split("T")[0];
|
|
28
|
+
if (b instanceof Date) b = b.toISOString().split("T")[0];
|
|
29
|
+
|
|
30
|
+
const aNull = a === null || a === undefined;
|
|
31
|
+
const bNull = b === null || b === undefined;
|
|
32
|
+
if (aNull && bNull) return 0;
|
|
33
|
+
if (aNull) return 1;
|
|
34
|
+
if (bNull) return -1;
|
|
35
|
+
|
|
36
|
+
const aStr = String(a);
|
|
37
|
+
const bStr = String(b);
|
|
38
|
+
|
|
39
|
+
// Priority field with known values
|
|
40
|
+
if (field === "priority" && aStr in PRIORITY_RANKS && bStr in PRIORITY_RANKS) {
|
|
41
|
+
return PRIORITY_RANKS[aStr] - PRIORITY_RANKS[bStr];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Date-like values (YYYY-MM-DD pattern)
|
|
45
|
+
if (DATE_PATTERN.test(aStr) && DATE_PATTERN.test(bStr)) {
|
|
46
|
+
return aStr < bStr ? -1 : aStr > bStr ? 1 : 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// String comparison
|
|
50
|
+
return aStr.localeCompare(bStr);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve a relative path against the vault root with directory traversal protection.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} relativePath - path relative to vault root
|
|
57
|
+
* @param {string} vaultPath - absolute vault root path
|
|
58
|
+
* @returns {string} absolute resolved path
|
|
59
|
+
* @throws {Error} if resolved path escapes the vault directory
|
|
60
|
+
*/
|
|
61
|
+
export function resolvePath(relativePath, vaultPath) {
|
|
62
|
+
const resolved = path.resolve(vaultPath, relativePath);
|
|
63
|
+
if (resolved !== vaultPath && !resolved.startsWith(vaultPath + path.sep)) {
|
|
64
|
+
throw new Error("Path escapes vault directory");
|
|
65
|
+
}
|
|
66
|
+
return resolved;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if note metadata matches a set of query filters.
|
|
71
|
+
*
|
|
72
|
+
* @param {Object|null} metadata - parsed YAML frontmatter
|
|
73
|
+
* @param {Object} filters
|
|
74
|
+
* @param {string} [filters.type] - exact type match
|
|
75
|
+
* @param {string} [filters.status] - exact status match
|
|
76
|
+
* @param {string[]} [filters.tags] - ALL must be present
|
|
77
|
+
* @param {string[]} [filters.tags_any] - ANY must be present
|
|
78
|
+
* @param {string} [filters.created_after] - YYYY-MM-DD lower bound
|
|
79
|
+
* @param {string} [filters.created_before] - YYYY-MM-DD upper bound
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
export function matchesFilters(metadata, filters) {
|
|
83
|
+
if (!metadata) return false;
|
|
84
|
+
|
|
85
|
+
if (filters.type && metadata.type !== filters.type) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (filters.status && metadata.status !== filters.status) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (filters.tags && filters.tags.length > 0) {
|
|
94
|
+
const noteTags = (metadata.tags || []).filter(Boolean).map(t => String(t).toLowerCase());
|
|
95
|
+
const allPresent = filters.tags.every(tag =>
|
|
96
|
+
noteTags.includes(tag.toLowerCase())
|
|
97
|
+
);
|
|
98
|
+
if (!allPresent) return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (filters.tags_any && filters.tags_any.length > 0) {
|
|
102
|
+
const noteTags = (metadata.tags || []).filter(Boolean).map(t => String(t).toLowerCase());
|
|
103
|
+
const anyPresent = filters.tags_any.some(tag =>
|
|
104
|
+
noteTags.includes(tag.toLowerCase())
|
|
105
|
+
);
|
|
106
|
+
if (!anyPresent) return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const createdStr = metadata.created instanceof Date
|
|
110
|
+
? metadata.created.toISOString().split("T")[0]
|
|
111
|
+
: String(metadata.created || "");
|
|
112
|
+
|
|
113
|
+
if (filters.created_after && createdStr < filters.created_after) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
if (filters.created_before && createdStr > filters.created_before) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (filters.custom_fields) {
|
|
121
|
+
for (const [key, value] of Object.entries(filters.custom_fields)) {
|
|
122
|
+
let metaValue = metadata[key];
|
|
123
|
+
if (metaValue instanceof Date) {
|
|
124
|
+
metaValue = metaValue.toISOString().split("T")[0];
|
|
125
|
+
}
|
|
126
|
+
if (value === null) {
|
|
127
|
+
if (metaValue !== undefined && metaValue !== null) return false;
|
|
128
|
+
} else {
|
|
129
|
+
if (String(metaValue ?? "") !== String(value)) return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Format metadata into a display-friendly summary and tag line.
|
|
139
|
+
*
|
|
140
|
+
* @param {Object} metadata - parsed YAML frontmatter
|
|
141
|
+
* @returns {{ summary: string, tagLine: string }}
|
|
142
|
+
*/
|
|
143
|
+
export function formatMetadata(metadata) {
|
|
144
|
+
const parts = [];
|
|
145
|
+
if (metadata.type) parts.push(`type: ${metadata.type}`);
|
|
146
|
+
if (metadata.status) parts.push(`status: ${metadata.status}`);
|
|
147
|
+
if (metadata.created) {
|
|
148
|
+
const dateStr = metadata.created instanceof Date
|
|
149
|
+
? metadata.created.toISOString().split("T")[0]
|
|
150
|
+
: metadata.created;
|
|
151
|
+
parts.push(`created: ${dateStr}`);
|
|
152
|
+
}
|
|
153
|
+
const tagLine = metadata.tags?.length > 0
|
|
154
|
+
? `tags: ${metadata.tags.join(", ")}`
|
|
155
|
+
: "";
|
|
156
|
+
return { summary: parts.join(" | "), tagLine };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Count non-overlapping occurrences of a substring.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} content - text to search
|
|
163
|
+
* @param {string} searchString - substring to find
|
|
164
|
+
* @returns {number}
|
|
165
|
+
*/
|
|
166
|
+
export function countOccurrences(content, searchString) {
|
|
167
|
+
if (searchString.length === 0) return 0;
|
|
168
|
+
let count = 0;
|
|
169
|
+
let position = 0;
|
|
170
|
+
while ((position = content.indexOf(searchString, position)) !== -1) {
|
|
171
|
+
count++;
|
|
172
|
+
position += searchString.length;
|
|
173
|
+
}
|
|
174
|
+
return count;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Extract a human-readable description from template content.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} content - raw template markdown
|
|
181
|
+
* @param {Object|null} frontmatter - parsed YAML frontmatter
|
|
182
|
+
* @returns {string} description (max 80 chars)
|
|
183
|
+
*/
|
|
184
|
+
export function extractTemplateDescription(content, frontmatter) {
|
|
185
|
+
if (frontmatter?.description) return frontmatter.description;
|
|
186
|
+
|
|
187
|
+
const lines = content.split("\n");
|
|
188
|
+
let inFrontmatter = false;
|
|
189
|
+
for (const line of lines) {
|
|
190
|
+
if (line.trim() === "---") {
|
|
191
|
+
inFrontmatter = !inFrontmatter;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (inFrontmatter) continue;
|
|
195
|
+
|
|
196
|
+
const trimmed = line.trim();
|
|
197
|
+
if (trimmed.startsWith("#")) {
|
|
198
|
+
return trimmed.replace(/^#+\s*/, "").replace(/<%[^%]+%>/g, "{title}").slice(0, 80);
|
|
199
|
+
}
|
|
200
|
+
if (trimmed && !trimmed.startsWith("<!--")) {
|
|
201
|
+
return trimmed.slice(0, 80);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return `Template for ${frontmatter?.type || "notes"}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Load all templates from the vault's 05-Templates/ directory.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} vaultPath - absolute vault root path
|
|
211
|
+
* @returns {Promise<Map<string, Object>>} template name -> { shortName, path, description, frontmatter, content }
|
|
212
|
+
*/
|
|
213
|
+
export async function loadTemplates(vaultPath) {
|
|
214
|
+
const templatesDir = resolvePath("05-Templates", vaultPath);
|
|
215
|
+
const templateMap = new Map();
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const files = await fs.readdir(templatesDir);
|
|
219
|
+
for (const file of files) {
|
|
220
|
+
if (!file.endsWith(".md")) continue;
|
|
221
|
+
|
|
222
|
+
const shortName = path.basename(file, ".md");
|
|
223
|
+
const content = await fs.readFile(path.join(templatesDir, file), "utf-8");
|
|
224
|
+
const frontmatter = extractFrontmatter(content);
|
|
225
|
+
|
|
226
|
+
templateMap.set(shortName, {
|
|
227
|
+
shortName,
|
|
228
|
+
path: `05-Templates/${file}`,
|
|
229
|
+
description: extractTemplateDescription(content, frontmatter),
|
|
230
|
+
frontmatter,
|
|
231
|
+
content
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
} catch (e) {
|
|
235
|
+
if (e.code === "ENOENT") {
|
|
236
|
+
console.error("Warning: 05-Templates/ not found in vault");
|
|
237
|
+
} else {
|
|
238
|
+
console.error(`Error loading templates: ${e.message}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return templateMap;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Substitute Templater-compatible variables and frontmatter fields in template content.
|
|
247
|
+
*
|
|
248
|
+
* @param {string} content - raw template content
|
|
249
|
+
* @param {Object} vars
|
|
250
|
+
* @param {string} [vars.title] - note title (for tp.file.title)
|
|
251
|
+
* @param {Object} [vars.custom] - custom variable key-value pairs
|
|
252
|
+
* @param {Object} [vars.frontmatter] - frontmatter fields to substitute
|
|
253
|
+
* @returns {string} content with variables replaced
|
|
254
|
+
*/
|
|
255
|
+
export function substituteTemplateVariables(content, vars) {
|
|
256
|
+
const now = new Date();
|
|
257
|
+
const dateFormats = {
|
|
258
|
+
"YYYY-MM-DD": now.toISOString().split("T")[0],
|
|
259
|
+
"YYYY-MM-DD HH:mm": now.toISOString().replace("T", " ").slice(0, 16),
|
|
260
|
+
"YYYY": now.getFullYear().toString(),
|
|
261
|
+
"MM": String(now.getMonth() + 1).padStart(2, "0"),
|
|
262
|
+
"DD": String(now.getDate()).padStart(2, "0")
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
let result = content;
|
|
266
|
+
|
|
267
|
+
result = result.replace(/<%\s*tp\.date\.now\("([^"]+)"\)\s*%>/g, (match, format) => {
|
|
268
|
+
return dateFormats[format] || now.toISOString().split("T")[0];
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
result = result.replace(/<%\s*tp\.file\.title\s*%>/g, vars.title || "Untitled");
|
|
272
|
+
|
|
273
|
+
if (vars.custom) {
|
|
274
|
+
for (const [key, value] of Object.entries(vars.custom)) {
|
|
275
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
276
|
+
const regex = new RegExp(`<%\\s*${escaped}\\s*%>`, "g");
|
|
277
|
+
result = result.replace(regex, value);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (vars.frontmatter && content.startsWith("---")) {
|
|
282
|
+
const endIndex = result.indexOf("\n---", 3);
|
|
283
|
+
if (endIndex !== -1) {
|
|
284
|
+
const frontmatterSection = result.slice(0, endIndex + 4);
|
|
285
|
+
const body = result.slice(endIndex + 4);
|
|
286
|
+
|
|
287
|
+
let updatedFrontmatter = frontmatterSection;
|
|
288
|
+
|
|
289
|
+
if (vars.frontmatter.tags && Array.isArray(vars.frontmatter.tags)) {
|
|
290
|
+
const tagsYaml = vars.frontmatter.tags.map(t => ` - ${t}`).join("\n");
|
|
291
|
+
let tagsReplaced = false;
|
|
292
|
+
|
|
293
|
+
if (updatedFrontmatter.match(/^tags:\s*\[.*\]/m)) {
|
|
294
|
+
updatedFrontmatter = updatedFrontmatter.replace(
|
|
295
|
+
/^tags:\s*\[.*\]/m,
|
|
296
|
+
`tags:\n${tagsYaml}`
|
|
297
|
+
);
|
|
298
|
+
tagsReplaced = true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!tagsReplaced && updatedFrontmatter.match(/tags:\s*\n(?:\s+-[^\n]*\n?)*/)) {
|
|
302
|
+
updatedFrontmatter = updatedFrontmatter.replace(
|
|
303
|
+
/tags:\s*\n(?:\s+-[^\n]*\n?)*/,
|
|
304
|
+
`tags:\n${tagsYaml}\n`
|
|
305
|
+
);
|
|
306
|
+
tagsReplaced = true;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!tagsReplaced) {
|
|
310
|
+
updatedFrontmatter = updatedFrontmatter.replace(
|
|
311
|
+
/\n---$/,
|
|
312
|
+
`\ntags:\n${tagsYaml}\n---`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (const [key, value] of Object.entries(vars.frontmatter)) {
|
|
318
|
+
if (key === "tags") continue;
|
|
319
|
+
if (DANGEROUS_KEYS.has(key)) {
|
|
320
|
+
throw new Error(`Disallowed frontmatter key: "${key}"`);
|
|
321
|
+
}
|
|
322
|
+
if (typeof value === "string") {
|
|
323
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(key)) {
|
|
324
|
+
throw new Error(`Invalid frontmatter key: "${key}". Keys must start with a letter and contain only letters, digits, hyphens, or underscores.`);
|
|
325
|
+
}
|
|
326
|
+
const fieldRegex = new RegExp(`^${key}:.*$`, "m");
|
|
327
|
+
if (updatedFrontmatter.match(fieldRegex)) {
|
|
328
|
+
updatedFrontmatter = updatedFrontmatter.replace(fieldRegex, `${key}: ${value}`);
|
|
329
|
+
} else {
|
|
330
|
+
updatedFrontmatter = updatedFrontmatter.replace(
|
|
331
|
+
/\n---$/,
|
|
332
|
+
`\n${key}: ${value}\n---`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
result = updatedFrontmatter + body;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Validate that rendered template content has all required frontmatter fields.
|
|
347
|
+
*
|
|
348
|
+
* @param {string} content - rendered template content
|
|
349
|
+
* @returns {{ valid: boolean, errors: string[], frontmatter: Object|null }}
|
|
350
|
+
*/
|
|
351
|
+
export function validateFrontmatterStrict(content) {
|
|
352
|
+
const frontmatter = extractFrontmatter(content);
|
|
353
|
+
const errors = [];
|
|
354
|
+
|
|
355
|
+
if (!frontmatter) {
|
|
356
|
+
return { valid: false, errors: ["No frontmatter found in template output"], frontmatter: null };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (!frontmatter.type) {
|
|
360
|
+
errors.push("Missing required field: type");
|
|
361
|
+
}
|
|
362
|
+
if (!frontmatter.created) {
|
|
363
|
+
errors.push("Missing required field: created");
|
|
364
|
+
}
|
|
365
|
+
if (!frontmatter.tags || !Array.isArray(frontmatter.tags) || frontmatter.tags.filter(Boolean).length === 0) {
|
|
366
|
+
errors.push("Missing required field: tags (must be non-empty array)");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const unsubstituted = content.match(/<%[^%]+%>/g);
|
|
370
|
+
if (unsubstituted) {
|
|
371
|
+
errors.push(`Unsubstituted template variables: ${unsubstituted.join(", ")}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
valid: errors.length === 0,
|
|
376
|
+
errors,
|
|
377
|
+
frontmatter
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Extract inline #tags from markdown body (excludes frontmatter, code blocks, headings).
|
|
383
|
+
*
|
|
384
|
+
* @param {string} content - full markdown content including frontmatter
|
|
385
|
+
* @returns {string[]} lowercase tag names
|
|
386
|
+
*/
|
|
387
|
+
export function extractInlineTags(content) {
|
|
388
|
+
let body = content;
|
|
389
|
+
|
|
390
|
+
if (content.startsWith("---")) {
|
|
391
|
+
const endIndex = content.indexOf("\n---", 3);
|
|
392
|
+
if (endIndex !== -1) {
|
|
393
|
+
body = content.slice(endIndex + 4);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
body = body.replace(/```[\s\S]*?```/g, "");
|
|
398
|
+
body = body.replace(/`[^`]+`/g, "");
|
|
399
|
+
body = body.replace(/^#+\s/gm, "");
|
|
400
|
+
|
|
401
|
+
const tags = new Set();
|
|
402
|
+
const tagRegex = /(?:^|[^a-zA-Z0-9&])#([a-zA-Z_][a-zA-Z0-9_/-]*)/g;
|
|
403
|
+
let match;
|
|
404
|
+
while ((match = tagRegex.exec(body)) !== null) {
|
|
405
|
+
tags.add(match[1].toLowerCase());
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return Array.from(tags);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Extract the heading level (1-6) from a markdown heading line, or 0 if not a heading.
|
|
413
|
+
*
|
|
414
|
+
* @param {string} line - a single line of text
|
|
415
|
+
* @returns {number} heading level 1-6, or 0
|
|
416
|
+
*/
|
|
417
|
+
export function parseHeadingLevel(line) {
|
|
418
|
+
const match = line.match(/^(#{1,6})\s/);
|
|
419
|
+
return match ? match[1].length : 0;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Find the byte-index range of a section under a given heading.
|
|
424
|
+
*
|
|
425
|
+
* @param {string} content - full file content
|
|
426
|
+
* @param {string} heading - exact heading line to find (e.g. "## Section One")
|
|
427
|
+
* @returns {{ headingStart: number, afterHeading: number, sectionEnd: number } | null}
|
|
428
|
+
*/
|
|
429
|
+
export function findSectionRange(content, heading) {
|
|
430
|
+
let headingStart = -1;
|
|
431
|
+
let searchFrom = 0;
|
|
432
|
+
while (searchFrom < content.length) {
|
|
433
|
+
const idx = content.indexOf(heading, searchFrom);
|
|
434
|
+
if (idx === -1) break;
|
|
435
|
+
// Only match at line boundaries: start of string or preceded by \n
|
|
436
|
+
if (idx === 0 || content[idx - 1] === "\n") {
|
|
437
|
+
headingStart = idx;
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
searchFrom = idx + 1;
|
|
441
|
+
}
|
|
442
|
+
if (headingStart === -1) return null;
|
|
443
|
+
|
|
444
|
+
const headingLineEnd = content.indexOf("\n", headingStart);
|
|
445
|
+
const afterHeading = headingLineEnd === -1 ? content.length : headingLineEnd + 1;
|
|
446
|
+
|
|
447
|
+
const level = parseHeadingLevel(heading);
|
|
448
|
+
let sectionEnd = content.length;
|
|
449
|
+
|
|
450
|
+
if (level > 0) {
|
|
451
|
+
const lines = content.slice(afterHeading).split("\n");
|
|
452
|
+
let offset = afterHeading;
|
|
453
|
+
for (const line of lines) {
|
|
454
|
+
const lineLevel = parseHeadingLevel(line);
|
|
455
|
+
if (lineLevel > 0 && lineLevel <= level) {
|
|
456
|
+
sectionEnd = offset;
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
offset += line.length + 1;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return { headingStart, afterHeading, sectionEnd };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Return all heading lines from markdown content, excluding those inside frontmatter.
|
|
468
|
+
*
|
|
469
|
+
* @param {string} content - full markdown content
|
|
470
|
+
* @returns {string[]} heading lines
|
|
471
|
+
*/
|
|
472
|
+
export function listHeadings(content) {
|
|
473
|
+
let body = content;
|
|
474
|
+
if (content.startsWith("---")) {
|
|
475
|
+
const endIndex = content.indexOf("\n---", 3);
|
|
476
|
+
if (endIndex !== -1) {
|
|
477
|
+
body = content.slice(endIndex + 4);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return body.split("\n").filter(line => /^#{1,6}\s/.test(line));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Extract frontmatter + last N sections at a given heading level.
|
|
486
|
+
*
|
|
487
|
+
* @param {string} content - full file content
|
|
488
|
+
* @param {number} n - number of sections to return
|
|
489
|
+
* @param {number} level - heading level (1-6)
|
|
490
|
+
* @returns {string} frontmatter + last N sections
|
|
491
|
+
*/
|
|
492
|
+
export function extractTailSections(content, n, level) {
|
|
493
|
+
// Extract frontmatter
|
|
494
|
+
let frontmatter = "";
|
|
495
|
+
let body = content;
|
|
496
|
+
if (content.startsWith("---")) {
|
|
497
|
+
const endIndex = content.indexOf("\n---", 3);
|
|
498
|
+
if (endIndex !== -1) {
|
|
499
|
+
frontmatter = content.slice(0, endIndex + 4);
|
|
500
|
+
body = content.slice(endIndex + 4);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Find all headings at exactly the given level
|
|
505
|
+
const lines = body.split("\n");
|
|
506
|
+
const headingPositions = [];
|
|
507
|
+
let offset = 0;
|
|
508
|
+
for (const line of lines) {
|
|
509
|
+
if (parseHeadingLevel(line) === level) {
|
|
510
|
+
headingPositions.push(offset);
|
|
511
|
+
}
|
|
512
|
+
offset += line.length + 1;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (headingPositions.length === 0) {
|
|
516
|
+
return content;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const startIdx = Math.max(0, headingPositions.length - n);
|
|
520
|
+
const sliceStart = headingPositions[startIdx];
|
|
521
|
+
const tail = body.slice(sliceStart);
|
|
522
|
+
|
|
523
|
+
return frontmatter + (frontmatter && !frontmatter.endsWith("\n") ? "\n" : "") + tail;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Match a tag against a glob-like pattern.
|
|
528
|
+
* Supports: hierarchical prefix ("pkm/*"), substring ("*mcp*"), prefix ("dev*"), suffix ("*fix"), exact.
|
|
529
|
+
*
|
|
530
|
+
* @param {string} tag - tag to test
|
|
531
|
+
* @param {string} [pattern] - glob-like pattern (returns true if omitted)
|
|
532
|
+
* @returns {boolean}
|
|
533
|
+
*/
|
|
534
|
+
export function matchesTagPattern(tag, pattern) {
|
|
535
|
+
if (!pattern) return true;
|
|
536
|
+
|
|
537
|
+
const t = tag.toLowerCase();
|
|
538
|
+
const p = pattern.toLowerCase();
|
|
539
|
+
|
|
540
|
+
if (p.endsWith("/*")) {
|
|
541
|
+
const prefix = p.slice(0, -2);
|
|
542
|
+
return t === prefix || t.startsWith(prefix + "/");
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (p.startsWith("*") && p.endsWith("*") && p.length > 2) {
|
|
546
|
+
return t.includes(p.slice(1, -1));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (p.endsWith("*")) {
|
|
550
|
+
return t.startsWith(p.slice(0, -1));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (p.startsWith("*")) {
|
|
554
|
+
return t.endsWith(p.slice(1));
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return t === p;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Build a lookup map from lowercase basename (without .md) to all matching vault-relative paths.
|
|
562
|
+
* Also returns a Set of all file paths for O(1) exact-match checks.
|
|
563
|
+
*
|
|
564
|
+
* @param {string[]} allFiles - vault-relative file paths
|
|
565
|
+
* @returns {{ basenameMap: Map<string, string[]>, allFilesSet: Set<string> }}
|
|
566
|
+
*/
|
|
567
|
+
export function buildBasenameMap(allFiles) {
|
|
568
|
+
const basenameMap = new Map();
|
|
569
|
+
const allFilesSet = new Set(allFiles);
|
|
570
|
+
for (const filePath of allFiles) {
|
|
571
|
+
const basename = path.basename(filePath, ".md").toLowerCase();
|
|
572
|
+
if (!basenameMap.has(basename)) {
|
|
573
|
+
basenameMap.set(basename, []);
|
|
574
|
+
}
|
|
575
|
+
basenameMap.get(basename).push(filePath);
|
|
576
|
+
}
|
|
577
|
+
return { basenameMap, allFilesSet };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Resolve a short or partial path to a full vault-relative path.
|
|
582
|
+
*
|
|
583
|
+
* Resolution order:
|
|
584
|
+
* 1. Exact match (path exists in allFilesSet as-is)
|
|
585
|
+
* 2. Exact match with .md appended
|
|
586
|
+
* 3. Basename match (with optional folder scoping)
|
|
587
|
+
*
|
|
588
|
+
* @param {string} inputPath - user-provided path (short name or full path)
|
|
589
|
+
* @param {Map<string, string[]>} basenameMap - from buildBasenameMap
|
|
590
|
+
* @param {Set<string>} allFilesSet - all vault-relative paths
|
|
591
|
+
* @param {string} [folderScope] - optional folder prefix to filter matches
|
|
592
|
+
* @returns {string} resolved vault-relative path
|
|
593
|
+
* @throws {Error} if path not found or ambiguous
|
|
594
|
+
*/
|
|
595
|
+
export function resolveFuzzyPath(inputPath, basenameMap, allFilesSet, folderScope) {
|
|
596
|
+
// 1. Exact match
|
|
597
|
+
if (allFilesSet.has(inputPath)) return inputPath;
|
|
598
|
+
|
|
599
|
+
// 2. Exact match with .md
|
|
600
|
+
if (!inputPath.endsWith(".md")) {
|
|
601
|
+
const withExt = inputPath + ".md";
|
|
602
|
+
if (allFilesSet.has(withExt)) return withExt;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// 3. Basename match
|
|
606
|
+
const basename = path.basename(inputPath, ".md").toLowerCase();
|
|
607
|
+
let matches = basenameMap.get(basename) || [];
|
|
608
|
+
|
|
609
|
+
// Apply folder scope if provided
|
|
610
|
+
if (folderScope && matches.length > 1) {
|
|
611
|
+
const scoped = matches.filter(p => p.startsWith(folderScope + "/"));
|
|
612
|
+
if (scoped.length > 0) matches = scoped;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (matches.length === 1) return matches[0];
|
|
616
|
+
if (matches.length === 0) {
|
|
617
|
+
throw new Error(`File not found: "${inputPath}". No matching file in vault.`);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const list = matches.map(p => ` - ${p}`).join("\n");
|
|
621
|
+
throw new Error(
|
|
622
|
+
`"${inputPath}" matches ${matches.length} files:\n${list}\nUse a more specific path or add folder param to narrow scope.`
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Resolve a partial folder name to a full vault-relative directory path.
|
|
628
|
+
*
|
|
629
|
+
* Resolution order:
|
|
630
|
+
* 1. Exact match — folder string is a known directory prefix of at least one file
|
|
631
|
+
* 2. Substring match — case-insensitive substring of known directory paths
|
|
632
|
+
*
|
|
633
|
+
* @param {string} folder - user-provided folder (full or partial)
|
|
634
|
+
* @param {string[]} allFiles - all vault-relative file paths
|
|
635
|
+
* @returns {string} resolved vault-relative directory path
|
|
636
|
+
* @throws {Error} if folder not found or ambiguous
|
|
637
|
+
*/
|
|
638
|
+
export function resolveFuzzyFolder(folder, allFiles) {
|
|
639
|
+
// Collect all unique directory paths from the file list
|
|
640
|
+
const dirs = new Set();
|
|
641
|
+
for (const file of allFiles) {
|
|
642
|
+
let dir = path.dirname(file);
|
|
643
|
+
while (dir && dir !== ".") {
|
|
644
|
+
dirs.add(dir);
|
|
645
|
+
dir = path.dirname(dir);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// 1. Exact match
|
|
650
|
+
if (dirs.has(folder)) return folder;
|
|
651
|
+
|
|
652
|
+
// 2. Substring match (case-insensitive)
|
|
653
|
+
const lowerFolder = folder.toLowerCase();
|
|
654
|
+
const matches = Array.from(dirs).filter(d => d.toLowerCase().includes(lowerFolder));
|
|
655
|
+
|
|
656
|
+
// Deduplicate: if both "01-Projects/Obsidian-MCP" and
|
|
657
|
+
// "01-Projects/Obsidian-MCP/development" match, prefer the shortest
|
|
658
|
+
// that ends with the search term (most likely the intended target).
|
|
659
|
+
if (matches.length > 1) {
|
|
660
|
+
const endsWith = matches.filter(d => d.toLowerCase().endsWith(lowerFolder));
|
|
661
|
+
if (endsWith.length === 1) return endsWith[0];
|
|
662
|
+
if (endsWith.length > 1) {
|
|
663
|
+
const list = endsWith.map(p => ` - ${p}`).join("\n");
|
|
664
|
+
throw new Error(
|
|
665
|
+
`"${folder}" matches ${endsWith.length} folders:\n${list}\nUse a more specific path.`
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (matches.length === 1) return matches[0];
|
|
671
|
+
if (matches.length === 0) {
|
|
672
|
+
throw new Error(`Folder not found: "${folder}". No matching directory in vault.`);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const list = matches.map(p => ` - ${p}`).join("\n");
|
|
676
|
+
throw new Error(
|
|
677
|
+
`"${folder}" matches ${matches.length} folders:\n${list}\nUse a more specific path.`
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Compute peek metadata for a file's content without returning the full body.
|
|
683
|
+
*
|
|
684
|
+
* @param {string} content - full file content
|
|
685
|
+
* @param {string} relativePath - vault-relative path (for display)
|
|
686
|
+
* @returns {{ path: string, sizeChars: number, sizeLines: number, frontmatter: Object|null, headings: Array<{heading: string, level: number, lineNumber: number, charCount: number}>, preview: string, totalChunks: number }}
|
|
687
|
+
*/
|
|
688
|
+
export function computePeek(content, relativePath) {
|
|
689
|
+
const sizeChars = content.length;
|
|
690
|
+
const lines = content.split("\n");
|
|
691
|
+
const sizeLines = lines.length;
|
|
692
|
+
const frontmatter = extractFrontmatter(content);
|
|
693
|
+
const totalChunks = sizeChars === 0 ? 0 : Math.ceil(sizeChars / CHUNK_SIZE);
|
|
694
|
+
|
|
695
|
+
// Determine where frontmatter ends (line index)
|
|
696
|
+
let bodyStartLine = 0;
|
|
697
|
+
if (content.startsWith("---")) {
|
|
698
|
+
for (let i = 1; i < lines.length; i++) {
|
|
699
|
+
if (lines[i] === "---") {
|
|
700
|
+
bodyStartLine = i + 1;
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Build heading outline with line numbers and char counts
|
|
707
|
+
const headings = [];
|
|
708
|
+
for (let i = bodyStartLine; i < lines.length; i++) {
|
|
709
|
+
const level = parseHeadingLevel(lines[i]);
|
|
710
|
+
if (level > 0) {
|
|
711
|
+
headings.push({ heading: lines[i], level, lineNumber: i + 1, charCount: 0 });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Compute charCount for each heading: chars from this heading to next same-or-higher-level heading (or EOF)
|
|
716
|
+
for (let h = 0; h < headings.length; h++) {
|
|
717
|
+
const startLine = headings[h].lineNumber - 1; // 0-indexed
|
|
718
|
+
let endLine = lines.length;
|
|
719
|
+
for (let next = h + 1; next < headings.length; next++) {
|
|
720
|
+
if (headings[next].level <= headings[h].level) {
|
|
721
|
+
endLine = headings[next].lineNumber - 1;
|
|
722
|
+
break;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
headings[h].charCount = lines.slice(startLine, endLine).join("\n").length;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Preview: first ~10 non-empty lines after frontmatter, truncated to 200 chars each
|
|
729
|
+
const bodyLines = lines.slice(bodyStartLine);
|
|
730
|
+
const previewLines = bodyLines.filter(l => l.trim() !== "").slice(0, 10)
|
|
731
|
+
.map(l => l.length > 200 ? l.slice(0, 200) + "..." : l);
|
|
732
|
+
const preview = previewLines.join("\n");
|
|
733
|
+
|
|
734
|
+
return { path: relativePath, sizeChars, sizeLines, frontmatter, headings, preview, totalChunks };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Format peek data into a human-readable string.
|
|
739
|
+
*
|
|
740
|
+
* @param {{ path: string, sizeChars: number, sizeLines: number, frontmatter: Object|null, headings: Array, preview: string, totalChunks: number }} peekData
|
|
741
|
+
* @param {{ redirected?: boolean }} options
|
|
742
|
+
* @returns {string}
|
|
743
|
+
*/
|
|
744
|
+
export function formatPeek(peekData, { redirected = false } = {}) {
|
|
745
|
+
const { path: filePath, sizeChars, sizeLines, frontmatter, headings, preview, totalChunks } = peekData;
|
|
746
|
+
const parts = [];
|
|
747
|
+
|
|
748
|
+
parts.push(`## File: ${filePath}`);
|
|
749
|
+
parts.push(`**Size:** ${sizeChars.toLocaleString()} chars, ${sizeLines.toLocaleString()} lines`);
|
|
750
|
+
if (totalChunks > 1) {
|
|
751
|
+
parts.push(`**Chunks:** ${totalChunks} (use \`chunk\` param to read by chunk)`);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (frontmatter) {
|
|
755
|
+
parts.push("");
|
|
756
|
+
parts.push("### Frontmatter");
|
|
757
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
758
|
+
const display = Array.isArray(value) ? `[${value.join(", ")}]` : String(value);
|
|
759
|
+
parts.push(`${key}: ${display}`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (headings.length > 0) {
|
|
764
|
+
parts.push("");
|
|
765
|
+
parts.push("### Heading Outline");
|
|
766
|
+
for (const h of headings) {
|
|
767
|
+
parts.push(`L${h.lineNumber} ${h.heading} (~${h.charCount} chars)`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (preview) {
|
|
772
|
+
parts.push("");
|
|
773
|
+
parts.push("### Preview");
|
|
774
|
+
parts.push(preview);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (redirected) {
|
|
778
|
+
parts.push("");
|
|
779
|
+
parts.push("---");
|
|
780
|
+
parts.push(`This file exceeds the auto-read threshold (~${(AUTO_REDIRECT_THRESHOLD / 1000).toFixed(0)}k chars). To read content, use one of:`);
|
|
781
|
+
parts.push('- `heading: "## Section Name"` - read a specific section');
|
|
782
|
+
parts.push("- `tail: N` - read last N lines");
|
|
783
|
+
parts.push("- `tail_sections: N` - read last N sections");
|
|
784
|
+
if (totalChunks > 1) {
|
|
785
|
+
parts.push(`- \`chunk: 1\` - read chunk 1 of ${totalChunks}`);
|
|
786
|
+
}
|
|
787
|
+
parts.push("- `lines: { start: 1, end: 200 }` - read a line range");
|
|
788
|
+
parts.push("- `force: true` - read full content (WARNING: may degrade model performance, hard-capped at ~400k chars)");
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return parts.join("\n");
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Update YAML frontmatter fields in a markdown file's content.
|
|
796
|
+
* Parses existing frontmatter, merges changes, re-serializes.
|
|
797
|
+
*
|
|
798
|
+
* @param {string} content - full file content with frontmatter
|
|
799
|
+
* @param {Object} fields - fields to update (null value = delete, non-null = set/create)
|
|
800
|
+
* @returns {{ content: string, frontmatter: Object }} updated content and resulting frontmatter
|
|
801
|
+
* @throws {Error} if no frontmatter, protected field deletion, invalid key, or empty tags
|
|
802
|
+
*/
|
|
803
|
+
export function updateFrontmatter(content, fields) {
|
|
804
|
+
if (!content.startsWith("---")) {
|
|
805
|
+
throw new Error("No frontmatter found in file");
|
|
806
|
+
}
|
|
807
|
+
const endIndex = content.indexOf("\n---", 3);
|
|
808
|
+
if (endIndex === -1) {
|
|
809
|
+
throw new Error("No frontmatter found in file (unclosed)");
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const yamlStr = content.slice(4, endIndex);
|
|
813
|
+
const body = content.slice(endIndex + 4);
|
|
814
|
+
|
|
815
|
+
let parsed;
|
|
816
|
+
try {
|
|
817
|
+
parsed = yaml.load(yamlStr, { schema: yaml.JSON_SCHEMA });
|
|
818
|
+
} catch (e) {
|
|
819
|
+
throw new Error(`Failed to parse frontmatter: ${e.message}`, { cause: e });
|
|
820
|
+
}
|
|
821
|
+
if (!parsed || typeof parsed !== "object") {
|
|
822
|
+
parsed = {};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const PROTECTED_FIELDS = ["type", "created", "tags"];
|
|
826
|
+
const KEY_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
827
|
+
|
|
828
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
829
|
+
if (DANGEROUS_KEYS.has(key)) {
|
|
830
|
+
throw new Error(`Disallowed frontmatter key: "${key}"`);
|
|
831
|
+
}
|
|
832
|
+
if (!KEY_REGEX.test(key)) {
|
|
833
|
+
throw new Error(`Invalid frontmatter key: "${key}". Keys must start with a letter and contain only letters, digits, hyphens, or underscores.`);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (value === null) {
|
|
837
|
+
if (PROTECTED_FIELDS.includes(key)) {
|
|
838
|
+
throw new Error(`Cannot remove required field: "${key}". Protected fields: ${PROTECTED_FIELDS.join(", ")}`);
|
|
839
|
+
}
|
|
840
|
+
delete parsed[key];
|
|
841
|
+
} else {
|
|
842
|
+
if (key === "tags") {
|
|
843
|
+
if (!Array.isArray(value) || value.filter(Boolean).length === 0) {
|
|
844
|
+
throw new Error("tags must be a non-empty array");
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
parsed[key] = value;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const newYaml = yaml.dump(parsed, { lineWidth: -1, noRefs: true, sortKeys: false, schema: yaml.JSON_SCHEMA });
|
|
852
|
+
const newContent = "---\n" + newYaml + "---" + body;
|
|
853
|
+
|
|
854
|
+
return { content: newContent, frontmatter: { ...parsed } };
|
|
855
|
+
}
|