@oomkapwn/enquire-mcp 0.10.6 → 0.13.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 +81 -0
- package/README.md +15 -4
- package/assets/social-preview.png +0 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +64 -6
- package/dist/index.js.map +1 -1
- package/dist/tools.d.ts +132 -5
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +506 -6
- package/dist/tools.js.map +1 -1
- package/dist/vault.d.ts +18 -0
- package/dist/vault.d.ts.map +1 -1
- package/dist/vault.js +71 -0
- package/dist/vault.js.map +1 -1
- package/docs/api.md +6 -0
- package/package.json +5 -3
package/dist/tools.js
CHANGED
|
@@ -33,7 +33,23 @@ export async function listNotes(vault, args) {
|
|
|
33
33
|
export async function readNote(vault, args) {
|
|
34
34
|
await vault.ensureExists();
|
|
35
35
|
const entry = await resolveTarget(vault, args);
|
|
36
|
-
const { parsed, mtimeMs } = await vault.readNote(entry.absPath, entry.mtimeMs);
|
|
36
|
+
const { content, parsed, mtimeMs } = await vault.readNote(entry.absPath, entry.mtimeMs);
|
|
37
|
+
if (args.format === "map") {
|
|
38
|
+
// Document-map projection — headings + frontmatter keys + counts. Lets an
|
|
39
|
+
// LLM plan a surgical edit without paying token cost for the full body.
|
|
40
|
+
return {
|
|
41
|
+
path: entry.relPath,
|
|
42
|
+
title: stripMd(entry.basename),
|
|
43
|
+
format: "map",
|
|
44
|
+
frontmatter_keys: Object.keys(parsed.frontmatter),
|
|
45
|
+
headings: extractHeadings(parsed.body),
|
|
46
|
+
wikilinks_count: parsed.wikilinks.length,
|
|
47
|
+
embeds_count: parsed.embeds.length,
|
|
48
|
+
tags: parsed.tags,
|
|
49
|
+
mtime: new Date(mtimeMs).toISOString(),
|
|
50
|
+
byte_size: Buffer.byteLength(content, "utf8")
|
|
51
|
+
};
|
|
52
|
+
}
|
|
37
53
|
return {
|
|
38
54
|
path: entry.relPath,
|
|
39
55
|
title: stripMd(entry.basename),
|
|
@@ -45,6 +61,28 @@ export async function readNote(vault, args) {
|
|
|
45
61
|
mtime: new Date(mtimeMs).toISOString()
|
|
46
62
|
};
|
|
47
63
|
}
|
|
64
|
+
/** Pull ATX headings (`#`, `##`, `###`, etc.) out of note body for the
|
|
65
|
+
* document-map projection. Skips ATX inside fenced code blocks via a simple
|
|
66
|
+
* line-by-line backtick toggle. */
|
|
67
|
+
function extractHeadings(body) {
|
|
68
|
+
const out = [];
|
|
69
|
+
const lines = body.split("\n");
|
|
70
|
+
let inFence = false;
|
|
71
|
+
for (let i = 0; i < lines.length; i++) {
|
|
72
|
+
const line = lines[i] ?? "";
|
|
73
|
+
if (/^\s*```/.test(line)) {
|
|
74
|
+
inFence = !inFence;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (inFence)
|
|
78
|
+
continue;
|
|
79
|
+
const m = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
80
|
+
if (m && m[1] && m[2]) {
|
|
81
|
+
out.push({ level: m[1].length, text: m[2], line: i + 1 });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
48
86
|
export async function resolveWikilink(vault, args) {
|
|
49
87
|
await vault.ensureExists();
|
|
50
88
|
const cleaned = args.wikilink.replace(/^!?\[\[|\]\]$/g, "");
|
|
@@ -376,6 +414,61 @@ function extractFrontmatterTagsLower(fm) {
|
|
|
376
414
|
: [];
|
|
377
415
|
return list.map((t) => t.replace(/^#+/, "").toLowerCase());
|
|
378
416
|
}
|
|
417
|
+
/** Resolve "today"/"daily"/"weekly"/"monthly" to today's periodic-note name
|
|
418
|
+
* using the standard Obsidian Daily-Notes-plugin formats. Custom formats are
|
|
419
|
+
* out of scope (users with non-default conventions address by exact name). */
|
|
420
|
+
function resolvePeriodicAlias(title) {
|
|
421
|
+
const lower = title.trim().toLowerCase();
|
|
422
|
+
if (lower !== "daily" && lower !== "today" && lower !== "weekly" && lower !== "monthly") {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
const now = new Date();
|
|
426
|
+
const yyyy = now.getFullYear();
|
|
427
|
+
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
|
428
|
+
const dd = String(now.getDate()).padStart(2, "0");
|
|
429
|
+
if (lower === "daily" || lower === "today")
|
|
430
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
431
|
+
if (lower === "monthly")
|
|
432
|
+
return `${yyyy}-${mm}`;
|
|
433
|
+
// ISO week number (Mon-based, ISO 8601). Weekly format: YYYY-Www.
|
|
434
|
+
const target = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
|
|
435
|
+
const dayNum = target.getUTCDay() || 7; // Mon=1..Sun=7
|
|
436
|
+
target.setUTCDate(target.getUTCDate() + 4 - dayNum); // Thursday of this week
|
|
437
|
+
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1));
|
|
438
|
+
const weekNo = Math.ceil(((target.valueOf() - yearStart.valueOf()) / 86400000 + 1) / 7);
|
|
439
|
+
return `${target.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
|
|
440
|
+
}
|
|
441
|
+
/** Up to 3 vault-relative paths whose basename or relPath looks similar to
|
|
442
|
+
* the missing target. Used to enrich `Note not found` errors with did-you-mean
|
|
443
|
+
* hints — meaningful for LLMs that mistype a note name. */
|
|
444
|
+
async function suggestSimilar(vault, target) {
|
|
445
|
+
try {
|
|
446
|
+
const all = await vault.listMarkdown();
|
|
447
|
+
const lower = target.toLowerCase().replace(/\.md$/i, "");
|
|
448
|
+
const ranked = all
|
|
449
|
+
.map((e) => {
|
|
450
|
+
const baseLower = stripMd(e.basename).toLowerCase();
|
|
451
|
+
const relLower = e.relPath.toLowerCase();
|
|
452
|
+
let score = 0;
|
|
453
|
+
if (baseLower === lower)
|
|
454
|
+
score = 100;
|
|
455
|
+
else if (baseLower.startsWith(lower) || lower.startsWith(baseLower))
|
|
456
|
+
score = 70;
|
|
457
|
+
else if (baseLower.includes(lower) || lower.includes(baseLower))
|
|
458
|
+
score = 50;
|
|
459
|
+
else if (relLower.includes(lower))
|
|
460
|
+
score = 30;
|
|
461
|
+
return { path: e.relPath, score };
|
|
462
|
+
})
|
|
463
|
+
.filter((r) => r.score > 0)
|
|
464
|
+
.sort((a, b) => b.score - a.score)
|
|
465
|
+
.slice(0, 3);
|
|
466
|
+
return ranked.map((r) => r.path);
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
}
|
|
379
472
|
async function resolveTarget(vault, args) {
|
|
380
473
|
if (args.path) {
|
|
381
474
|
const candidates = args.path.toLowerCase().endsWith(".md") ? [args.path] : [args.path, `${args.path}.md`];
|
|
@@ -395,16 +488,423 @@ async function resolveTarget(vault, args) {
|
|
|
395
488
|
lastErr = err;
|
|
396
489
|
}
|
|
397
490
|
}
|
|
398
|
-
|
|
491
|
+
const suggestions = await suggestSimilar(vault, args.path);
|
|
492
|
+
const hint = suggestions.length ? `. Did you mean: ${suggestions.join(", ")}?` : "";
|
|
493
|
+
throw lastErr instanceof Error
|
|
494
|
+
? new Error(`${lastErr.message}${hint}`)
|
|
495
|
+
: new Error(`Note not found: ${args.path}${hint}`);
|
|
399
496
|
}
|
|
400
497
|
if (args.title) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
498
|
+
// Try literal title first — a user may have an actual file named
|
|
499
|
+
// "Daily.md" / "Today.md" they meant to address. Only fall back to the
|
|
500
|
+
// periodic-note alias when the literal lookup misses.
|
|
501
|
+
const literal = await vault.findByTitle(args.title);
|
|
502
|
+
if (literal)
|
|
503
|
+
return literal;
|
|
504
|
+
const aliased = resolvePeriodicAlias(args.title);
|
|
505
|
+
if (aliased) {
|
|
506
|
+
const aliasMatch = await vault.findByTitle(aliased);
|
|
507
|
+
if (aliasMatch)
|
|
508
|
+
return aliasMatch;
|
|
509
|
+
}
|
|
510
|
+
const suggestions = await suggestSimilar(vault, args.title);
|
|
511
|
+
const hint = suggestions.length ? `. Did you mean: ${suggestions.join(", ")}?` : "";
|
|
512
|
+
const aliasNote = aliased ? ` (also tried periodic alias "${aliased}")` : "";
|
|
513
|
+
throw new Error(`No note found with title: ${args.title}${aliasNote}${hint}`);
|
|
405
514
|
}
|
|
406
515
|
throw new Error("Either path or title is required");
|
|
407
516
|
}
|
|
517
|
+
export async function validateNoteProposal(vault, args) {
|
|
518
|
+
await vault.ensureExists();
|
|
519
|
+
const mode = args.mode ?? "create";
|
|
520
|
+
const errors = [];
|
|
521
|
+
const warnings = [];
|
|
522
|
+
// 1. Path sanity. resolveInside throws on traversal — capture as error,
|
|
523
|
+
// don't let it propagate as a generic exception (the validator should
|
|
524
|
+
// return a structured result for ANY input).
|
|
525
|
+
let normalizedPath = args.path.toLowerCase().endsWith(".md") ? args.path : `${args.path}.md`;
|
|
526
|
+
let absPath = null;
|
|
527
|
+
try {
|
|
528
|
+
absPath = vault.resolveInside(normalizedPath);
|
|
529
|
+
normalizedPath = vault.toRel(absPath);
|
|
530
|
+
}
|
|
531
|
+
catch (err) {
|
|
532
|
+
errors.push({
|
|
533
|
+
kind: "path-traversal",
|
|
534
|
+
message: err instanceof Error ? err.message : String(err)
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
// 2. YAML parse via gray-matter (the same parser used at write time).
|
|
538
|
+
const yamlReport = { parsed: false, error: null, keys: [] };
|
|
539
|
+
let bodyAfterFm = args.content;
|
|
540
|
+
try {
|
|
541
|
+
const parsed = matter(args.content);
|
|
542
|
+
yamlReport.parsed = true;
|
|
543
|
+
yamlReport.keys = Object.keys(parsed.data ?? {});
|
|
544
|
+
bodyAfterFm = parsed.content;
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
yamlReport.error = err instanceof Error ? err.message : String(err);
|
|
548
|
+
errors.push({ kind: "yaml-invalid", message: `YAML frontmatter could not be parsed: ${yamlReport.error}` });
|
|
549
|
+
}
|
|
550
|
+
// 3. Wikilink resolution against the live vault.
|
|
551
|
+
const all = await vault.listMarkdown();
|
|
552
|
+
const wikilinkRe = /(?<!!)\[\[([^\]\n]+?)\]\]/g;
|
|
553
|
+
const wikilinks = [];
|
|
554
|
+
for (const m of bodyAfterFm.matchAll(wikilinkRe)) {
|
|
555
|
+
const raw = m[0];
|
|
556
|
+
const inner = (m[1] ?? "").trim();
|
|
557
|
+
if (!inner)
|
|
558
|
+
continue;
|
|
559
|
+
// Strip alias / section / block to get the bare target name.
|
|
560
|
+
const beforePipe = inner.split("|")[0] ?? "";
|
|
561
|
+
const beforeHash = beforePipe.split("#")[0] ?? "";
|
|
562
|
+
const target = beforeHash.split("^")[0]?.trim() ?? "";
|
|
563
|
+
if (!target)
|
|
564
|
+
continue;
|
|
565
|
+
const match = findBestMatch(all, target, normalizedPath);
|
|
566
|
+
if (match) {
|
|
567
|
+
wikilinks.push({
|
|
568
|
+
raw,
|
|
569
|
+
target,
|
|
570
|
+
status: "resolved",
|
|
571
|
+
resolved_path: match.relPath,
|
|
572
|
+
suggestions: []
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
const suggestions = await suggestSimilar(vault, target);
|
|
577
|
+
wikilinks.push({
|
|
578
|
+
raw,
|
|
579
|
+
target,
|
|
580
|
+
status: "broken",
|
|
581
|
+
resolved_path: null,
|
|
582
|
+
suggestions
|
|
583
|
+
});
|
|
584
|
+
warnings.push({
|
|
585
|
+
kind: "broken-wikilink",
|
|
586
|
+
message: `[[${target}]] does not resolve to any existing note`,
|
|
587
|
+
suggestion: suggestions.length ? `Closest matches: ${suggestions.join(", ")}` : undefined
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// 4. Tag pre-classification (existing vs new).
|
|
592
|
+
const existingTags = new Set((await listTags(vault, {})).map((t) => t.tag.toLowerCase()));
|
|
593
|
+
const proposedTagsRaw = new Set();
|
|
594
|
+
// Frontmatter tags.
|
|
595
|
+
const fmData = yamlReport.parsed ? matter(args.content).data : {};
|
|
596
|
+
const fmTags = fmData.tags ?? fmData.tag;
|
|
597
|
+
if (Array.isArray(fmTags)) {
|
|
598
|
+
for (const t of fmTags)
|
|
599
|
+
if (typeof t === "string" && t)
|
|
600
|
+
proposedTagsRaw.add(t.replace(/^#/, ""));
|
|
601
|
+
}
|
|
602
|
+
else if (typeof fmTags === "string" && fmTags) {
|
|
603
|
+
for (const t of fmTags.split(/[\s,]+/))
|
|
604
|
+
if (t)
|
|
605
|
+
proposedTagsRaw.add(t.replace(/^#/, ""));
|
|
606
|
+
}
|
|
607
|
+
// Inline tags.
|
|
608
|
+
const inlineTagRe = /(?:^|[\s([{>])#([\p{L}][\p{L}\p{N}_/-]*)/gu;
|
|
609
|
+
for (const m of bodyAfterFm.matchAll(inlineTagRe)) {
|
|
610
|
+
if (m[1])
|
|
611
|
+
proposedTagsRaw.add(m[1]);
|
|
612
|
+
}
|
|
613
|
+
const tags = [];
|
|
614
|
+
for (const t of proposedTagsRaw) {
|
|
615
|
+
const status = existingTags.has(t.toLowerCase()) ? "existing" : "new";
|
|
616
|
+
tags.push({ name: t, status });
|
|
617
|
+
if (status === "new") {
|
|
618
|
+
warnings.push({
|
|
619
|
+
kind: "new-tag",
|
|
620
|
+
message: `#${t} is new — won't fork an existing tag (case-insensitive check)`
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
// 5. Path collision check.
|
|
625
|
+
let collision = { kind: "none" };
|
|
626
|
+
if (absPath) {
|
|
627
|
+
try {
|
|
628
|
+
await vault.stat(absPath);
|
|
629
|
+
// Path exists.
|
|
630
|
+
if (mode === "create") {
|
|
631
|
+
errors.push({
|
|
632
|
+
kind: "path-collision",
|
|
633
|
+
message: `Note already exists at ${normalizedPath} (mode="create" refuses overwrite)`
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
collision = { kind: "path-exists", existing_path: normalizedPath };
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
// Path doesn't exist — try title collision (an existing note at a different path).
|
|
640
|
+
const titleFromBasename = stripMd(path.basename(normalizedPath));
|
|
641
|
+
const existing = await vault.findByTitle(titleFromBasename);
|
|
642
|
+
if (existing && existing.relPath !== normalizedPath) {
|
|
643
|
+
warnings.push({
|
|
644
|
+
kind: "title-collision",
|
|
645
|
+
message: `A note titled "${titleFromBasename}" already exists at ${existing.relPath} — proceeding will create a same-titled file at a different path`,
|
|
646
|
+
suggestion: existing.relPath
|
|
647
|
+
});
|
|
648
|
+
collision = { kind: "title-exists-elsewhere", existing_path: existing.relPath };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return {
|
|
653
|
+
ok: errors.length === 0,
|
|
654
|
+
proposed_path: normalizedPath,
|
|
655
|
+
mode,
|
|
656
|
+
errors,
|
|
657
|
+
warnings,
|
|
658
|
+
yaml: yamlReport,
|
|
659
|
+
wikilinks,
|
|
660
|
+
tags,
|
|
661
|
+
collision
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
export async function findSimilar(vault, args) {
|
|
665
|
+
await vault.ensureExists();
|
|
666
|
+
const limit = args.limit ?? 10;
|
|
667
|
+
const minScore = args.min_score ?? 0.05;
|
|
668
|
+
const target = await resolveTarget(vault, args);
|
|
669
|
+
const entries = await vault.listMarkdown();
|
|
670
|
+
const metas = new Map();
|
|
671
|
+
for (const e of entries) {
|
|
672
|
+
const { parsed } = await vault.readNote(e.absPath, e.mtimeMs);
|
|
673
|
+
const tags = new Set(parsed.tags.map((t) => t.toLowerCase()));
|
|
674
|
+
const title3grams = ngrams(stripMd(e.basename).toLowerCase(), 3);
|
|
675
|
+
const outbound = new Set();
|
|
676
|
+
for (const link of parsed.wikilinks) {
|
|
677
|
+
const m = findBestMatch(entries, link.target, e.relPath);
|
|
678
|
+
if (m)
|
|
679
|
+
outbound.add(m.relPath);
|
|
680
|
+
}
|
|
681
|
+
metas.set(e.relPath, { entry: e, tags, title3grams, outbound });
|
|
682
|
+
}
|
|
683
|
+
const targetMeta = metas.get(target.relPath);
|
|
684
|
+
if (!targetMeta) {
|
|
685
|
+
// The target was found by resolveTarget but may have been excluded from
|
|
686
|
+
// listMarkdown by --exclude-glob. Treat as zero results rather than crash.
|
|
687
|
+
return [];
|
|
688
|
+
}
|
|
689
|
+
// For co-backlink: build "who links to X?" for everyone we care about
|
|
690
|
+
// (target + all candidates). Single pass over outbound sets.
|
|
691
|
+
const inboundFor = new Map();
|
|
692
|
+
for (const [from, m] of metas) {
|
|
693
|
+
for (const to of m.outbound) {
|
|
694
|
+
const set = inboundFor.get(to) ?? new Set();
|
|
695
|
+
set.add(from);
|
|
696
|
+
inboundFor.set(to, set);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const targetInbound = inboundFor.get(target.relPath) ?? new Set();
|
|
700
|
+
const out = [];
|
|
701
|
+
for (const [relPath, m] of metas) {
|
|
702
|
+
if (relPath === target.relPath)
|
|
703
|
+
continue;
|
|
704
|
+
const tagJ = jaccard(targetMeta.tags, m.tags);
|
|
705
|
+
const titleJ = jaccard(targetMeta.title3grams, m.title3grams);
|
|
706
|
+
const candInbound = inboundFor.get(relPath) ?? new Set();
|
|
707
|
+
// shared_outbound: how much of A's outbound is also in B's
|
|
708
|
+
const sharedOut = targetMeta.outbound.size === 0 ? 0 : intersectionSize(targetMeta.outbound, m.outbound) / targetMeta.outbound.size;
|
|
709
|
+
// co_backlink: how many notes link to both target and candidate, over union
|
|
710
|
+
const coBack = jaccard(targetInbound, candInbound);
|
|
711
|
+
const score = 3.0 * tagJ + 1.5 * titleJ + 2.0 * sharedOut + 2.0 * coBack;
|
|
712
|
+
if (score < minScore)
|
|
713
|
+
continue;
|
|
714
|
+
const shared = [];
|
|
715
|
+
for (const t of targetMeta.tags)
|
|
716
|
+
if (m.tags.has(t))
|
|
717
|
+
shared.push(t);
|
|
718
|
+
shared.sort();
|
|
719
|
+
out.push({
|
|
720
|
+
path: m.entry.relPath,
|
|
721
|
+
title: stripMd(m.entry.basename),
|
|
722
|
+
score: Math.round(score * 10000) / 10000,
|
|
723
|
+
signals: {
|
|
724
|
+
tag_jaccard: Math.round(tagJ * 10000) / 10000,
|
|
725
|
+
title_3gram: Math.round(titleJ * 10000) / 10000,
|
|
726
|
+
shared_outbound: Math.round(sharedOut * 10000) / 10000,
|
|
727
|
+
co_backlink: Math.round(coBack * 10000) / 10000
|
|
728
|
+
},
|
|
729
|
+
shared_tags: shared,
|
|
730
|
+
mtime: new Date(m.entry.mtimeMs).toISOString()
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
out.sort((a, b) => b.score - a.score);
|
|
734
|
+
return out.slice(0, limit);
|
|
735
|
+
}
|
|
736
|
+
export async function getNoteNeighbors(vault, args) {
|
|
737
|
+
await vault.ensureExists();
|
|
738
|
+
const cap = args.max_per_bucket ?? 20;
|
|
739
|
+
const target = await resolveTarget(vault, args);
|
|
740
|
+
const entries = await vault.listMarkdown();
|
|
741
|
+
const { parsed: targetParsed } = await vault.readNote(target.absPath, target.mtimeMs);
|
|
742
|
+
const targetTagsLower = new Set(targetParsed.tags.map((t) => t.toLowerCase()));
|
|
743
|
+
// Outbound: resolved unique destinations from the target.
|
|
744
|
+
const seenOut = new Set();
|
|
745
|
+
const outbound = [];
|
|
746
|
+
for (const link of targetParsed.wikilinks) {
|
|
747
|
+
const m = findBestMatch(entries, link.target, target.relPath);
|
|
748
|
+
if (!m || seenOut.has(m.relPath))
|
|
749
|
+
continue;
|
|
750
|
+
seenOut.add(m.relPath);
|
|
751
|
+
const { parsed: nbrParsed } = await vault.readNote(m.absPath, m.mtimeMs);
|
|
752
|
+
outbound.push({ path: m.relPath, title: stripMd(m.basename), tags: nbrParsed.tags });
|
|
753
|
+
if (outbound.length >= cap)
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
// Inbound: notes that link to target, with backlink count.
|
|
757
|
+
const inboundCounts = new Map();
|
|
758
|
+
for (const e of entries) {
|
|
759
|
+
if (e.absPath === target.absPath)
|
|
760
|
+
continue;
|
|
761
|
+
const { parsed } = await vault.readNote(e.absPath, e.mtimeMs);
|
|
762
|
+
let cnt = 0;
|
|
763
|
+
for (const link of parsed.wikilinks) {
|
|
764
|
+
const m = findBestMatch(entries, link.target, e.relPath);
|
|
765
|
+
if (m && m.absPath === target.absPath)
|
|
766
|
+
cnt += 1;
|
|
767
|
+
}
|
|
768
|
+
if (cnt > 0)
|
|
769
|
+
inboundCounts.set(e.relPath, { entry: e, count: cnt, tags: parsed.tags });
|
|
770
|
+
}
|
|
771
|
+
const inbound = [...inboundCounts.values()]
|
|
772
|
+
.sort((a, b) => b.count - a.count)
|
|
773
|
+
.slice(0, cap)
|
|
774
|
+
.map((x) => ({ path: x.entry.relPath, title: stripMd(x.entry.basename), tags: x.tags, count: x.count }));
|
|
775
|
+
// Tag siblings: notes sharing ≥1 tag with target, excluding outbound/inbound.
|
|
776
|
+
const tag_siblings = [];
|
|
777
|
+
if (targetTagsLower.size > 0) {
|
|
778
|
+
const exclude = new Set([target.relPath, ...seenOut, ...inboundCounts.keys()]);
|
|
779
|
+
const candidates = [];
|
|
780
|
+
for (const e of entries) {
|
|
781
|
+
if (exclude.has(e.relPath))
|
|
782
|
+
continue;
|
|
783
|
+
const { parsed } = await vault.readNote(e.absPath, e.mtimeMs);
|
|
784
|
+
const shared = [];
|
|
785
|
+
for (const t of parsed.tags) {
|
|
786
|
+
if (targetTagsLower.has(t.toLowerCase()))
|
|
787
|
+
shared.push(t);
|
|
788
|
+
}
|
|
789
|
+
if (shared.length > 0) {
|
|
790
|
+
candidates.push({ path: e.relPath, title: stripMd(e.basename), shared });
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
candidates.sort((a, b) => b.shared.length - a.shared.length);
|
|
794
|
+
for (const c of candidates.slice(0, cap)) {
|
|
795
|
+
tag_siblings.push({ path: c.path, title: c.title, shared_tags: c.shared });
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return {
|
|
799
|
+
center: {
|
|
800
|
+
path: target.relPath,
|
|
801
|
+
title: stripMd(target.basename),
|
|
802
|
+
tags: targetParsed.tags,
|
|
803
|
+
mtime: new Date(target.mtimeMs).toISOString()
|
|
804
|
+
},
|
|
805
|
+
outbound,
|
|
806
|
+
inbound,
|
|
807
|
+
tag_siblings
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
export async function getVaultStats(vault, args) {
|
|
811
|
+
await vault.ensureExists();
|
|
812
|
+
const topTagsLimit = args.top_tags ?? 10;
|
|
813
|
+
const entries = await vault.listMarkdown();
|
|
814
|
+
const sevenDaysMs = Date.now() - 7 * 24 * 3600 * 1000;
|
|
815
|
+
let totalSize = 0;
|
|
816
|
+
let totalWords = 0;
|
|
817
|
+
let recent = 0;
|
|
818
|
+
let withFm = 0;
|
|
819
|
+
const tagCounts = new Map();
|
|
820
|
+
// Build inbound map in one pass so orphans and broken counts are O(N).
|
|
821
|
+
const inbound = new Map();
|
|
822
|
+
let broken = 0;
|
|
823
|
+
let outboundTotal = 0;
|
|
824
|
+
for (const e of entries) {
|
|
825
|
+
const { content, parsed } = await vault.readNote(e.absPath, e.mtimeMs);
|
|
826
|
+
totalSize += Buffer.byteLength(content, "utf8");
|
|
827
|
+
totalWords += content.trim() ? content.trim().split(/\s+/).length : 0;
|
|
828
|
+
if (e.mtimeMs >= sevenDaysMs)
|
|
829
|
+
recent += 1;
|
|
830
|
+
if (Object.keys(parsed.frontmatter).length > 0)
|
|
831
|
+
withFm += 1;
|
|
832
|
+
for (const t of parsed.tags) {
|
|
833
|
+
const key = t.toLowerCase();
|
|
834
|
+
tagCounts.set(key, (tagCounts.get(key) ?? 0) + 1);
|
|
835
|
+
}
|
|
836
|
+
for (const link of parsed.wikilinks) {
|
|
837
|
+
outboundTotal += 1;
|
|
838
|
+
const m = findBestMatch(entries, link.target, e.relPath);
|
|
839
|
+
if (!m) {
|
|
840
|
+
broken += 1;
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
inbound.set(m.relPath, (inbound.get(m.relPath) ?? 0) + 1);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// Orphan = no inbound AND no outbound. Need outbound-presence per file.
|
|
847
|
+
const outboundPresence = new Set();
|
|
848
|
+
for (const e of entries) {
|
|
849
|
+
const { parsed } = await vault.readNote(e.absPath, e.mtimeMs);
|
|
850
|
+
if (parsed.wikilinks.length > 0)
|
|
851
|
+
outboundPresence.add(e.relPath);
|
|
852
|
+
}
|
|
853
|
+
let orphans = 0;
|
|
854
|
+
for (const e of entries) {
|
|
855
|
+
if (!inbound.get(e.relPath) && !outboundPresence.has(e.relPath))
|
|
856
|
+
orphans += 1;
|
|
857
|
+
}
|
|
858
|
+
const top_tags = [...tagCounts.entries()]
|
|
859
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
860
|
+
.slice(0, topTagsLimit)
|
|
861
|
+
.map(([tag, count]) => ({ tag, count }));
|
|
862
|
+
// Sanity: outboundTotal isn't returned directly but is used to validate that
|
|
863
|
+
// the orphan/broken pass saw at least one link if any exist.
|
|
864
|
+
if (outboundTotal === 0 && broken !== 0)
|
|
865
|
+
broken = 0; // defensive — never reachable.
|
|
866
|
+
return {
|
|
867
|
+
total_notes: entries.length,
|
|
868
|
+
total_size_bytes: totalSize,
|
|
869
|
+
avg_note_words: entries.length === 0 ? 0 : Math.round(totalWords / entries.length),
|
|
870
|
+
recently_modified_7d: recent,
|
|
871
|
+
orphans,
|
|
872
|
+
broken_wikilinks: broken,
|
|
873
|
+
total_tags: tagCounts.size,
|
|
874
|
+
top_tags,
|
|
875
|
+
notes_with_frontmatter: withFm,
|
|
876
|
+
generated_at: new Date().toISOString()
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
// ─── small set / string helpers shared by find_similar / get_note_neighbors ─
|
|
880
|
+
function jaccard(a, b) {
|
|
881
|
+
if (a.size === 0 && b.size === 0)
|
|
882
|
+
return 0;
|
|
883
|
+
let inter = 0;
|
|
884
|
+
for (const x of a)
|
|
885
|
+
if (b.has(x))
|
|
886
|
+
inter += 1;
|
|
887
|
+
const union = a.size + b.size - inter;
|
|
888
|
+
return union === 0 ? 0 : inter / union;
|
|
889
|
+
}
|
|
890
|
+
function intersectionSize(a, b) {
|
|
891
|
+
let n = 0;
|
|
892
|
+
for (const x of a)
|
|
893
|
+
if (b.has(x))
|
|
894
|
+
n += 1;
|
|
895
|
+
return n;
|
|
896
|
+
}
|
|
897
|
+
function ngrams(s, n) {
|
|
898
|
+
const out = new Set();
|
|
899
|
+
if (s.length < n) {
|
|
900
|
+
if (s)
|
|
901
|
+
out.add(s);
|
|
902
|
+
return out;
|
|
903
|
+
}
|
|
904
|
+
for (let i = 0; i <= s.length - n; i++)
|
|
905
|
+
out.add(s.slice(i, i + n));
|
|
906
|
+
return out;
|
|
907
|
+
}
|
|
408
908
|
function findBestMatch(entries, target, fromNote) {
|
|
409
909
|
if (target.startsWith("./") || target.startsWith("../") || target.includes("/../")) {
|
|
410
910
|
if (fromNote) {
|