pi-weave 0.1.21 → 0.1.23
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/README.md +19 -0
- package/package.json +1 -1
- package/skills/weave-notepad/SKILL.md +42 -8
- package/skills/weave-notepad/references/link-repair.md +90 -0
- package/src/core/cache/workspace.ts +7 -13
- package/src/core/graph/build.ts +13 -35
- package/src/core/graph/current.ts +4 -5
- package/src/core/index.ts +18 -0
- package/src/core/links/repair.ts +330 -0
- package/src/core/links/similar.ts +279 -0
- package/src/core/vault.ts +147 -3
- package/src/core/view/health.ts +4 -0
- package/src/pi/tools/noteTool.ts +144 -4
- package/src/web/client/dist/app.js +42 -42
- package/src/web/client/graph/Graph.tsx +15 -0
- package/src/web/client/graph/column.model.ts +49 -33
- package/src/web/client/graph/graph.model.ts +205 -8
- package/src/web/client/graph/groups.ts +4 -4
- package/src/web/client/graph/renderer.dom.ts +5 -1
- package/src/web/client/graph/renderer.ts +115 -5
package/src/core/vault.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
unquoteField,
|
|
11
11
|
upsertFrontMatterFields,
|
|
12
12
|
} from "./frontmatter";
|
|
13
|
+
import { auditLinks, rewriteLinks, RAW_NOTES_HEADING, type LinkAudit, type LinkFix } from "./links/repair";
|
|
13
14
|
import { withMutationQueue } from "./mutex";
|
|
14
15
|
import { NOTES_DIR, OKF_MANIFEST } from "./paths";
|
|
15
16
|
import { slugify, uniqueSlug } from "./slug";
|
|
@@ -281,8 +282,13 @@ export function formatRawAppend(rawText: string, date: Date = new Date()): strin
|
|
|
281
282
|
return `<!-- appended ${timestamp} -->\n${fence}\n${rawText.trim()}\n${fence}`;
|
|
282
283
|
}
|
|
283
284
|
|
|
284
|
-
/**
|
|
285
|
-
|
|
285
|
+
/**
|
|
286
|
+
* The append-only tail where verbatim user scribbles live.
|
|
287
|
+
*
|
|
288
|
+
* Defined in `./links/repair` — the module that must never write past it —
|
|
289
|
+
* and re-exported here, where every caller already looks for it.
|
|
290
|
+
*/
|
|
291
|
+
export { RAW_NOTES_HEADING };
|
|
286
292
|
|
|
287
293
|
/** The never-edit notice comment at the top of a raw tail (skill format). */
|
|
288
294
|
export const RAW_TAIL_NOTICE =
|
|
@@ -462,6 +468,134 @@ async function isDirectory(path: string): Promise<boolean> {
|
|
|
462
468
|
}
|
|
463
469
|
}
|
|
464
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Rewrite every wiki-link in the vault through `resolve`, which maps a stale
|
|
473
|
+
* target to its replacement slug (or null to leave it alone).
|
|
474
|
+
*
|
|
475
|
+
* Two properties matter and are easy to get wrong:
|
|
476
|
+
*
|
|
477
|
+
* - **`updated` is not bumped.** A link repair is bookkeeping, not an edit to
|
|
478
|
+
* what the note says. Bumping it would reorder the entire vault by recency
|
|
479
|
+
* on the first repair pass and make "what changed lately" useless.
|
|
480
|
+
* - **Lock-free.** Every caller already holds the vault lock (the queue is
|
|
481
|
+
* non-reentrant — see {@link LOCK_NS} — so taking it again here would wait
|
|
482
|
+
* on itself forever).
|
|
483
|
+
*
|
|
484
|
+
* Returns the note slugs that changed and the total number of links rewritten.
|
|
485
|
+
*/
|
|
486
|
+
async function rewriteVaultLinks(
|
|
487
|
+
root: string,
|
|
488
|
+
resolve: (target: string, noteSlug: string) => string | null,
|
|
489
|
+
): Promise<{ notes: string[]; links: number }> {
|
|
490
|
+
const files = (await listNoteFiles(root)).filter(isMarkdown);
|
|
491
|
+
const touched: string[] = [];
|
|
492
|
+
let links = 0;
|
|
493
|
+
for (const file of files) {
|
|
494
|
+
const slug = file.slice(0, -".md".length);
|
|
495
|
+
const path = resolveNotePath(root, slug);
|
|
496
|
+
if (path === null) continue;
|
|
497
|
+
const note = await getNote(root, slug);
|
|
498
|
+
if (note === null) continue;
|
|
499
|
+
const { body, changed } = rewriteLinks(note.body, (target) => resolve(target, slug));
|
|
500
|
+
if (changed === 0) continue;
|
|
501
|
+
|
|
502
|
+
// Splice the new body into the ORIGINAL file text rather than going
|
|
503
|
+
// through `writeNote`.
|
|
504
|
+
//
|
|
505
|
+
// `writeNote` reserializes from the parsed note, and serialization is
|
|
506
|
+
// lossy in ways that are harmless for an edit and unacceptable here: it
|
|
507
|
+
// normalizes line endings and trailing whitespace. Those bytes can lie
|
|
508
|
+
// inside the append-only `## Raw` tail, so a *link repair* — which must
|
|
509
|
+
// not touch the tail at all — would rewrite a user's verbatim dictation.
|
|
510
|
+
// `rewriteLinks` already guarantees it only edits offsets above the tail,
|
|
511
|
+
// so replacing exactly that span preserves every other byte in the file.
|
|
512
|
+
let original: string;
|
|
513
|
+
try {
|
|
514
|
+
original = await fs.readFile(path, "utf8");
|
|
515
|
+
} catch {
|
|
516
|
+
continue; // raced a delete
|
|
517
|
+
}
|
|
518
|
+
const at = original.lastIndexOf(note.body);
|
|
519
|
+
if (at === -1) continue; // body not found verbatim; refuse rather than guess
|
|
520
|
+
const next = original.slice(0, at) + body + original.slice(at + note.body.length);
|
|
521
|
+
// Atomic replace where it is safe: rename cannot truncate a note on a
|
|
522
|
+
// crash. A *file* symlink is the exception — renaming over it would
|
|
523
|
+
// silently replace the link with a regular file and orphan the real
|
|
524
|
+
// note — so those are written in place, through the link, as every other
|
|
525
|
+
// vault write already does. (A symlinked *directory* is unaffected:
|
|
526
|
+
// `path` then names a real file inside it.)
|
|
527
|
+
const link = await fs.lstat(path).then((s) => s.isSymbolicLink(), () => false);
|
|
528
|
+
if (link) {
|
|
529
|
+
await fs.writeFile(path, next, "utf8");
|
|
530
|
+
} else {
|
|
531
|
+
const tmp = `${path}.weave-${process.pid}.tmp`;
|
|
532
|
+
await fs.writeFile(tmp, next, "utf8");
|
|
533
|
+
await fs.rename(tmp, path);
|
|
534
|
+
}
|
|
535
|
+
touched.push(slug);
|
|
536
|
+
links += changed;
|
|
537
|
+
}
|
|
538
|
+
return { notes: touched, links };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Point inbound links at a note's new home after a rename or move.
|
|
543
|
+
*
|
|
544
|
+
* This is the root cause of stale links: before this existed, every rename
|
|
545
|
+
* silently broke every backlink pointing at the old slug, and the only repair
|
|
546
|
+
* was an agent rereading the vault. One helper, called by all three movers.
|
|
547
|
+
*/
|
|
548
|
+
async function repointBacklinks(root: string, moves: ReadonlyMap<string, string>): Promise<void> {
|
|
549
|
+
if (moves.size === 0) return;
|
|
550
|
+
await rewriteVaultLinks(root, (target) => moves.get(target) ?? null);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** The outcome of a vault-wide link repair. */
|
|
554
|
+
export interface LinkRepairResult {
|
|
555
|
+
/** The audit the repair acted on (or would have, for a dry run). */
|
|
556
|
+
audit: LinkAudit;
|
|
557
|
+
/** Fixes actually written. Empty for a dry run. */
|
|
558
|
+
applied: LinkFix[];
|
|
559
|
+
/** Note slugs rewritten. */
|
|
560
|
+
notes: string[];
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Audit the vault's wiki-links and, when `apply` is set, repair every
|
|
565
|
+
* unambiguous one.
|
|
566
|
+
*
|
|
567
|
+
* Idempotent: a second run finds nothing, because the first turned each
|
|
568
|
+
* stale target into a real slug. Ambiguous and unresolvable links are
|
|
569
|
+
* reported and left exactly as they are — this function never guesses and
|
|
570
|
+
* never invents a note.
|
|
571
|
+
*/
|
|
572
|
+
export async function repairVaultLinks(root: string, options: { apply?: boolean } = {}): Promise<LinkRepairResult> {
|
|
573
|
+
// A dry run needs no lock: it writes nothing, and a report of a vault that
|
|
574
|
+
// changed a millisecond later is no less true than one taken under a lock.
|
|
575
|
+
if (options.apply !== true) {
|
|
576
|
+
return { audit: auditLinks(await readVault(root)), applied: [], notes: [] };
|
|
577
|
+
}
|
|
578
|
+
// Applying does, and the audit has to happen *inside* it. Auditing first
|
|
579
|
+
// and locking second leaves a window in which a concurrent rename or edit
|
|
580
|
+
// invalidates a decision — a target that was unique when audited may be
|
|
581
|
+
// ambiguous by the time it is written — and the stale fix would be applied
|
|
582
|
+
// anyway, then reported as if it had been checked.
|
|
583
|
+
return withVaultLock(root, async () => {
|
|
584
|
+
const audit = auditLinks(await readVault(root));
|
|
585
|
+
if (audit.fixable.length === 0) return { audit, applied: [], notes: [] };
|
|
586
|
+
// Keyed by note, because the audit already decided per-note; the rewrite
|
|
587
|
+
// obeys that decision rather than re-deriving it.
|
|
588
|
+
const byNote = new Map<string, Map<string, string>>();
|
|
589
|
+
for (const fix of audit.fixable) {
|
|
590
|
+
const map = byNote.get(fix.slug) ?? new Map<string, string>();
|
|
591
|
+
map.set(fix.from, fix.to);
|
|
592
|
+
byNote.set(fix.slug, map);
|
|
593
|
+
}
|
|
594
|
+
const { notes } = await rewriteVaultLinks(root, (target, noteSlug) => byNote.get(noteSlug)?.get(target) ?? null);
|
|
595
|
+
return { audit, applied: audit.fixable, notes };
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
465
599
|
/** Rename a note in place and keep its front-matter title in sync. */
|
|
466
600
|
export async function renameNote(root: string, slug: string, name: string, now = new Date()): Promise<VaultMutationResult> {
|
|
467
601
|
const from = resolveNotePath(root, slug);
|
|
@@ -477,6 +611,7 @@ export async function renameNote(root: string, slug: string, name: string, now =
|
|
|
477
611
|
if (from !== to && await exists(to)) return { ok: false, reason: "collision" };
|
|
478
612
|
if (from !== to) await fs.rename(from, to);
|
|
479
613
|
await writeNote(to, target, { ...note, title, updated: now.toISOString() }, note.body, note.frontMatter);
|
|
614
|
+
if (from !== to) await repointBacklinks(root, new Map([[slug, target]]));
|
|
480
615
|
return { ok: true, slug: target };
|
|
481
616
|
});
|
|
482
617
|
}
|
|
@@ -495,6 +630,7 @@ export async function moveNote(root: string, slug: string, folder: string | null
|
|
|
495
630
|
if (from === to) return { ok: true, slug };
|
|
496
631
|
if (await exists(to)) return { ok: false, reason: "collision" };
|
|
497
632
|
await fs.rename(from, to);
|
|
633
|
+
await repointBacklinks(root, new Map([[slug, target]]));
|
|
498
634
|
return { ok: true, slug: target };
|
|
499
635
|
});
|
|
500
636
|
}
|
|
@@ -522,7 +658,15 @@ export async function renameFolder(root: string, folder: string, name: string):
|
|
|
522
658
|
return withVaultLock(root, async () => {
|
|
523
659
|
if (!(await isDirectory(from))) return { ok: false, reason: "missing" };
|
|
524
660
|
if (from !== to && await exists(to)) return { ok: false, reason: "collision" };
|
|
525
|
-
if (from
|
|
661
|
+
if (from === to) return { ok: true, path: target };
|
|
662
|
+
// Every note under the folder changes slug, so the whole subtree's
|
|
663
|
+
// backlinks move with it — collected before the rename, while the old
|
|
664
|
+
// paths still exist.
|
|
665
|
+
const moved = (await listNoteFiles(root))
|
|
666
|
+
.filter((f) => isMarkdown(f) && f.startsWith(`${folder}/`))
|
|
667
|
+
.map((f) => f.slice(0, -".md".length));
|
|
668
|
+
await fs.rename(from, to);
|
|
669
|
+
await repointBacklinks(root, new Map(moved.map((s) => [s, `${target}/${s.slice(folder.length + 1)}`])));
|
|
526
670
|
return { ok: true, path: target };
|
|
527
671
|
});
|
|
528
672
|
}
|
package/src/core/view/health.ts
CHANGED
|
@@ -120,6 +120,10 @@ export function healthModel(model: GraphModel): HealthModel {
|
|
|
120
120
|
if (dangling.length > HEALTH_LIST_CAP) {
|
|
121
121
|
linkRows.push({ id: "health:link:dangling:more", text: ` … and ${dangling.length - HEALTH_LIST_CAP} more` });
|
|
122
122
|
}
|
|
123
|
+
// How many are *repairable* is not knowable from the graph: resolution
|
|
124
|
+
// needs note bodies and titles, which the model flattens away. Point at
|
|
125
|
+
// the tool that does know rather than re-deriving a worse answer here.
|
|
126
|
+
linkRows.push({ id: "health:link:dangling:repair", text: " repair: weave_note action=links (fix: true to apply)" });
|
|
123
127
|
}
|
|
124
128
|
if (hubs.length > 0) {
|
|
125
129
|
linkRows.push({ id: "health:link:hubs-h", text: `top hubs (by degree):` });
|
package/src/pi/tools/noteTool.ts
CHANGED
|
@@ -12,12 +12,100 @@ import {
|
|
|
12
12
|
getNote,
|
|
13
13
|
listNotes,
|
|
14
14
|
NOTES_DIR,
|
|
15
|
+
repairVaultLinks,
|
|
15
16
|
resolveNotePath,
|
|
16
17
|
withMutationQueue,
|
|
17
18
|
resolveVaultRoot,
|
|
18
19
|
searchNotes,
|
|
20
|
+
suggestLinks,
|
|
21
|
+
readVault,
|
|
22
|
+
type LinkRepairResult,
|
|
23
|
+
type SuggestionReport,
|
|
19
24
|
} from "../../core";
|
|
20
25
|
|
|
26
|
+
/** Cap on how many rows of each link-audit category get printed. */
|
|
27
|
+
const LINK_REPORT_CAP = 20;
|
|
28
|
+
|
|
29
|
+
/** Cap on notes printed by `list`; `details.notes` still carries them all. */
|
|
30
|
+
const LIST_CAP = 50;
|
|
31
|
+
|
|
32
|
+
function capped<T>(items: readonly T[], render: (item: T) => string): string[] {
|
|
33
|
+
const lines = items.slice(0, LINK_REPORT_CAP).map(render);
|
|
34
|
+
if (items.length > LINK_REPORT_CAP) lines.push(` … and ${items.length - LINK_REPORT_CAP} more`);
|
|
35
|
+
return lines;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Render suggestions as the text the model reads.
|
|
40
|
+
*
|
|
41
|
+
* Every row carries the shared terms that earned it. A bare score is
|
|
42
|
+
* unreviewable; the evidence is what lets a human accept or reject a
|
|
43
|
+
* suggestion without opening both notes.
|
|
44
|
+
*/
|
|
45
|
+
function formatSuggestions(report: SuggestionReport, focus: string | undefined): string {
|
|
46
|
+
if (report.suggestions.length === 0) {
|
|
47
|
+
return focus === undefined
|
|
48
|
+
? `No unlinked notes share enough distinctive vocabulary to suggest a connection (${report.considered} note(s) considered).`
|
|
49
|
+
: `Nothing unlinked looks related to '${focus}' (${report.considered} note(s) considered).`;
|
|
50
|
+
}
|
|
51
|
+
const head = focus === undefined
|
|
52
|
+
? `${report.suggestions.length} suggested connection(s) across ${report.considered} note(s):`
|
|
53
|
+
: `${report.suggestions.length} note(s) look related to '${focus}':`;
|
|
54
|
+
const rows = report.suggestions.map((s) => {
|
|
55
|
+
const pair = focus === undefined ? `${s.a} ↔ ${s.b}` : s.a === focus ? s.b : s.a;
|
|
56
|
+
return ` ${s.score.toFixed(3)} ${pair}\n shared: ${s.shared.join(", ")}`;
|
|
57
|
+
});
|
|
58
|
+
return [
|
|
59
|
+
head,
|
|
60
|
+
...rows,
|
|
61
|
+
"",
|
|
62
|
+
"These are suggestions, not links — nothing was written. Add a [[wikilink]] to any pair worth keeping.",
|
|
63
|
+
].join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Render a link audit (and any repair) as the text the model reads. */
|
|
67
|
+
function formatLinkReport(result: LinkRepairResult, applied: boolean): string {
|
|
68
|
+
const { audit } = result;
|
|
69
|
+
const stale = audit.total - audit.resolved;
|
|
70
|
+
// Occurrences, not rows. `total`/`resolved` count every `[[…]]` in the
|
|
71
|
+
// vault, while `fixable`/`unresolvable` are grouped (per note+target, per
|
|
72
|
+
// target). Reporting "70 stale" beside "40 unresolvable" with no unit
|
|
73
|
+
// invites the reader to subtract them and find 30 phantom links; saying
|
|
74
|
+
// what each number counts is the whole fix.
|
|
75
|
+
const lines = [
|
|
76
|
+
`${audit.total} wiki-link(s): ${audit.resolved} resolved, ${stale} stale.`,
|
|
77
|
+
];
|
|
78
|
+
if (applied) {
|
|
79
|
+
lines.push(
|
|
80
|
+
result.applied.length === 0
|
|
81
|
+
? "Nothing to repair automatically."
|
|
82
|
+
: `Repaired ${result.applied.length} link(s) across ${result.notes.length} note(s):`,
|
|
83
|
+
...capped(result.applied, (f) => ` ${f.slug}: [[${f.from}]] → [[${f.to}]] (${f.rule})`),
|
|
84
|
+
);
|
|
85
|
+
} else if (audit.fixable.length > 0) {
|
|
86
|
+
lines.push(
|
|
87
|
+
`${audit.fixable.length} auto-fixable (re-run with fix: true):`,
|
|
88
|
+
...capped(audit.fixable, (f) => ` ${f.slug}: [[${f.from}]] → [[${f.to}]] (${f.rule})`),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (audit.ambiguous.length > 0) {
|
|
92
|
+
lines.push(
|
|
93
|
+
`${audit.ambiguous.length} ambiguous link(s) (several candidates — pick one and edit the note):`,
|
|
94
|
+
...capped(audit.ambiguous, (a) => ` ${a.slug}: [[${a.target}]] → ${a.candidates.join(" | ")}`),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (audit.unresolvable.length > 0) {
|
|
98
|
+
lines.push(
|
|
99
|
+
`${audit.unresolvable.length} unresolvable target(s) (no such note — write it or drop the link):`,
|
|
100
|
+
...capped(audit.unresolvable, (u) => ` [[${u.target}]] ← ${u.notes.join(", ")}`),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (audit.fixable.length === 0 && audit.ambiguous.length === 0 && audit.unresolvable.length === 0) {
|
|
104
|
+
lines.push("Every link resolves.");
|
|
105
|
+
}
|
|
106
|
+
return lines.join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
21
109
|
/**
|
|
22
110
|
* `weave_note` — the smart-notepad tool (design §1: vault knowledge).
|
|
23
111
|
*
|
|
@@ -30,17 +118,21 @@ export function registerNoteTool(pi: ExtensionAPI): void {
|
|
|
30
118
|
label: "Weave Note",
|
|
31
119
|
description:
|
|
32
120
|
"Read and write notes in the pi-weave vault — a persistent, human-readable knowledge base " +
|
|
33
|
-
"of Markdown notes. Actions: list (all notes), get (one note by slug), add (new note), " +
|
|
121
|
+
"of Markdown notes. Actions: list (all notes — avoid on large vaults, prefer search), get (one note by slug), add (new note), " +
|
|
34
122
|
"append (extend a note; raw=true appends verbatim dictation into the ## Raw tail), " +
|
|
35
|
-
"finalize (restructure a note above its raw tail), search (title/tags/body)
|
|
123
|
+
"finalize (restructure a note above its raw tail), search (title/tags/body), " +
|
|
124
|
+
"links (audit stale [[wiki-links]]; fix=true repairs the unambiguous ones), " +
|
|
125
|
+
"suggest (rank unlinked notes that share distinctive vocabulary; reports only, never writes). " +
|
|
36
126
|
"Use it to remember decisions, facts, and user preferences across sessions.",
|
|
37
127
|
promptSnippet: "Remember and retrieve durable knowledge in the pi-weave vault",
|
|
38
128
|
promptGuidelines: [
|
|
39
129
|
"Use weave_note to store durable knowledge (decisions, preferences, key facts) that should survive the session, marking source as agent-written knowledge.",
|
|
40
130
|
"Use weave_note with action=search before answering questions about past decisions, people, or projects; generated notes under sessions/ carry takeaways from earlier sessions.",
|
|
131
|
+
"Use weave_note with action=links to find and repair stale [[wiki-links]] deterministically instead of rereading the vault to reconnect notes by hand; add fix=true to apply the unambiguous repairs.",
|
|
132
|
+
"Use weave_note with action=suggest to discover notes that belong together but are not linked (optionally scoped to one slug); it only reports — propose the links to the user rather than writing them.",
|
|
41
133
|
],
|
|
42
134
|
parameters: Type.Object({
|
|
43
|
-
action: StringEnum(["list", "get", "add", "append", "finalize", "search"] as const),
|
|
135
|
+
action: StringEnum(["list", "get", "add", "append", "finalize", "search", "links", "suggest"] as const),
|
|
44
136
|
title: Type.Optional(Type.String({ description: "Note title (add)" })),
|
|
45
137
|
text: Type.Optional(Type.String({ description: "Markdown body (add), addition (append), or restructured body above the raw tail (finalize)" })),
|
|
46
138
|
tags: Type.Optional(Type.Array(Type.String(), { description: "Tags (add)" })),
|
|
@@ -48,6 +140,8 @@ export function registerNoteTool(pi: ExtensionAPI): void {
|
|
|
48
140
|
raw: Type.Optional(Type.Boolean({ description: "append: add text as verbatim dictation to the ## Raw tail (timestamped fenced block; tail created if missing). Use for dictation/scribbles; omit for structured Markdown additions" })),
|
|
49
141
|
source: Type.Optional(StringEnum(["human", "agent"] as const, { description: "Provenance (add): human for user-scribbled notes, agent for Pi-drafted (default agent)" })),
|
|
50
142
|
query: Type.Optional(Type.String({ description: "Search query (search)" })),
|
|
143
|
+
fix: Type.Optional(Type.Boolean({ description: "links: apply the unambiguous repairs. Omit for a read-only report" })),
|
|
144
|
+
limit: Type.Optional(Type.Number({ description: "suggest: how many suggestions to return (default 20)" })),
|
|
51
145
|
}),
|
|
52
146
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
53
147
|
const vault = resolveVaultRoot();
|
|
@@ -61,9 +155,21 @@ export function registerNoteTool(pi: ExtensionAPI): void {
|
|
|
61
155
|
details: { action: "list", notes: [] },
|
|
62
156
|
};
|
|
63
157
|
}
|
|
64
|
-
|
|
158
|
+
// Truncated, because the whole list is rarely the answer and on a
|
|
159
|
+
// large vault it is actively harmful: hundreds of lines of slugs
|
|
160
|
+
// crowd out the conversation that prompted the call. Newest first
|
|
161
|
+
// (`listNotes` order), so the cap keeps what is most likely wanted,
|
|
162
|
+
// and the footer names `search` — the action that answers "is there
|
|
163
|
+
// a note about X" without reading the vault aloud.
|
|
164
|
+
const shown = notes.slice(0, LIST_CAP);
|
|
165
|
+
const lines = shown.map(
|
|
65
166
|
(n) => `- ${n.slug}: ${n.title}${n.tags.length > 0 ? ` [${n.tags.join(", ")}]` : ""} (updated ${n.updated}, source: ${n.source})`,
|
|
66
167
|
);
|
|
168
|
+
if (notes.length > shown.length) {
|
|
169
|
+
lines.push(
|
|
170
|
+
`… and ${notes.length - shown.length} more (newest ${shown.length} shown) — use action=search to find a specific note.`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
67
173
|
return {
|
|
68
174
|
content: [{ type: "text", text: `${notes.length} note(s) in ${vault}:\n${lines.join("\n")}` }],
|
|
69
175
|
details: { action: "list", notes },
|
|
@@ -182,6 +288,40 @@ export function registerNoteTool(pi: ExtensionAPI): void {
|
|
|
182
288
|
details: { action: "search", hits },
|
|
183
289
|
};
|
|
184
290
|
}
|
|
291
|
+
|
|
292
|
+
case "suggest": {
|
|
293
|
+
const { notes } = await readVault(vault);
|
|
294
|
+
const report = suggestLinks(
|
|
295
|
+
{ notes },
|
|
296
|
+
{
|
|
297
|
+
...(params.slug ? { slug: params.slug } : {}),
|
|
298
|
+
...(params.limit !== undefined ? { limit: params.limit } : {}),
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
return {
|
|
302
|
+
content: [{ type: "text", text: formatSuggestions(report, params.slug) }],
|
|
303
|
+
details: { action: "suggest", considered: report.considered, suggestions: report.suggestions },
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
case "links": {
|
|
308
|
+
const apply = params.fix === true;
|
|
309
|
+
const result = await repairVaultLinks(vault, apply ? { apply: true } : {});
|
|
310
|
+
return {
|
|
311
|
+
content: [{ type: "text", text: formatLinkReport(result, apply) }],
|
|
312
|
+
details: {
|
|
313
|
+
action: "links",
|
|
314
|
+
fixed: apply,
|
|
315
|
+
total: result.audit.total,
|
|
316
|
+
resolved: result.audit.resolved,
|
|
317
|
+
fixable: result.audit.fixable,
|
|
318
|
+
ambiguous: result.audit.ambiguous,
|
|
319
|
+
unresolvable: result.audit.unresolvable,
|
|
320
|
+
applied: result.applied,
|
|
321
|
+
notes: result.notes,
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
185
325
|
}
|
|
186
326
|
},
|
|
187
327
|
});
|