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/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
+ }