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/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
- /** The append-only tail where verbatim user scribbles live. */
285
- export const RAW_NOTES_HEADING = "## Raw";
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 !== to) await fs.rename(from, to);
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
  }
@@ -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):` });
@@ -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
- const lines = notes.map(
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
  });