pi-weave 0.3.8 → 0.3.9

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.
Files changed (69) hide show
  1. package/README.md +17 -16
  2. package/package.json +4 -3
  3. package/skills/weave-explore/SKILL.md +3 -5
  4. package/skills/weave-notepad/SKILL.md +22 -2
  5. package/skills/weave-notepad/references/second-thoughts.md +57 -0
  6. package/src/core/graph/build.ts +1 -1
  7. package/src/core/graph/current.ts +10 -8
  8. package/src/core/history.ts +417 -0
  9. package/src/core/index.ts +19 -0
  10. package/src/core/links/repair.ts +5 -68
  11. package/src/core/noteAction.ts +72 -2
  12. package/src/core/openInEditor.ts +3 -8
  13. package/src/core/repoAction.ts +20 -61
  14. package/src/core/repoIndex.ts +38 -0
  15. package/src/core/trail.ts +124 -0
  16. package/src/core/types.ts +1 -3
  17. package/src/core/vault.ts +116 -29
  18. package/src/core/view/focus.ts +2 -2
  19. package/src/core/view/index.ts +2 -3
  20. package/src/core/view/rawTail.ts +153 -0
  21. package/src/core/workspace.ts +20 -26
  22. package/src/core/workspaceCommands.ts +14 -14
  23. package/src/opencode/tools.ts +3 -3
  24. package/src/opencode/tui.ts +1 -7
  25. package/src/pi/index.ts +68 -80
  26. package/src/pi/tools/noteTool.ts +1 -1
  27. package/src/pi/tools/repoTool.ts +3 -6
  28. package/src/pi/viewer/web/run.ts +6 -8
  29. package/src/web/client/api.ts +103 -5
  30. package/src/web/client/dist/app.js +183 -52
  31. package/src/web/client/graph/Graph.tsx +23 -2
  32. package/src/web/client/graph/column.model.ts +1 -1
  33. package/src/web/client/graph/graph.model.ts +69 -7
  34. package/src/web/client/graph/renderer.ts +19 -3
  35. package/src/web/client/note/Note.tsx +171 -20
  36. package/src/web/client/note/Replay.tsx +128 -0
  37. package/src/web/client/note/drafts.ts +50 -9
  38. package/src/web/client/note/note.model.ts +17 -0
  39. package/src/web/client/note/replay.model.ts +173 -0
  40. package/src/web/client/shell/ContextRail.tsx +7 -0
  41. package/src/web/client/shell/Settings.tsx +21 -1
  42. package/src/web/client/shell/Shell.tsx +34 -10
  43. package/src/web/client/shell/keys.model.ts +20 -7
  44. package/src/web/client/shell/theme.ts +118 -1
  45. package/src/web/client/shell/workspace.css.ts +4 -0
  46. package/src/web/client/trail/TrailStrip.tsx +55 -0
  47. package/src/web/client/trail/trail.model.ts +195 -0
  48. package/src/web/client/trail/trail.ts +45 -0
  49. package/src/web/client/tree/Tree.tsx +7 -3
  50. package/src/web/client/tree/tree.model.ts +2 -5
  51. package/src/web/server/page.ts +1 -1
  52. package/src/web/server/routes.ts +83 -3
  53. package/src/web/shared/logo.ts +1 -1
  54. package/src/web/shared/view.ts +10 -4
  55. package/src/web/shared/wire.ts +74 -1
  56. package/src/core/view/health.ts +0 -160
  57. package/src/pi/viewer/tui/bodyStore.ts +0 -95
  58. package/src/pi/viewer/tui/branding.ts +0 -14
  59. package/src/pi/viewer/tui/model.ts +0 -390
  60. package/src/pi/viewer/tui/openNote.ts +0 -11
  61. package/src/pi/viewer/tui/run.ts +0 -71
  62. package/src/pi/viewer/tui/surface/base.ts +0 -203
  63. package/src/pi/viewer/tui/surface/detail.ts +0 -315
  64. package/src/pi/viewer/tui/surface/explore.ts +0 -241
  65. package/src/pi/viewer/tui/surface/focus.ts +0 -139
  66. package/src/pi/viewer/tui/surface/health.ts +0 -102
  67. package/src/pi/viewer/tui/theme.ts +0 -90
  68. package/src/pi/viewer/tui/workspace.ts +0 -53
  69. package/src/pi/viewer/tui/workspaceRoot.ts +0 -389
@@ -1,21 +1,14 @@
1
- import {
2
- assessStaleness,
3
- buildRepoIndex,
4
- readRepoIndex,
5
- summarizeIndex,
6
- writeRepoIndex,
7
- } from "./repoIndex";
1
+ import { ensureRepoIndex, summarizeIndex } from "./repoIndex";
8
2
  import { findGitRoot } from "./git";
9
- import { repoIndexDir } from "./paths";
3
+ import { readSummaries } from "./summaries";
10
4
 
11
5
  export const WEAVE_REPO_DESCRIPTION =
12
6
  "Explore the current git repository through its pi-weave knowledge index (.okf). " +
13
- "Actions: status (index freshness vs git state), scan (build/refresh the index), " +
14
- "overview (read the indexed structure: languages, packages, modules, entry points). " +
15
- "The index is derived and rebuildable; scanning is always safe.";
7
+ "Action: overview (languages, packages, modules, entry points, and how many files have deep-scan summaries). " +
8
+ "The index is derived, rebuilt automatically when the repository changes, and never needs a manual scan.";
16
9
 
17
10
  export interface RepoActionInput {
18
- action: "status" | "scan" | "overview";
11
+ action: "overview";
19
12
  }
20
13
 
21
14
  export interface RepoActionResult {
@@ -23,11 +16,7 @@ export interface RepoActionResult {
23
16
  details: Record<string, unknown>;
24
17
  }
25
18
 
26
- export async function executeRepoAction(
27
- params: RepoActionInput,
28
- cwd: string,
29
- onProgress?: (text: string) => void | Promise<void>,
30
- ): Promise<RepoActionResult> {
19
+ export async function executeRepoAction(params: RepoActionInput, cwd: string): Promise<RepoActionResult> {
31
20
  const root = await findGitRoot(cwd);
32
21
  if (!root) {
33
22
  return {
@@ -36,49 +25,19 @@ export async function executeRepoAction(
36
25
  };
37
26
  }
38
27
 
39
- switch (params.action) {
40
- case "status": {
41
- const staleness = await assessStaleness(root);
42
- const index = staleness.state !== "missing" ? await readRepoIndex(root) : null;
43
- const lines = [`Index state: ${staleness.state}`];
44
- for (const reason of staleness.reasons) lines.push(`- ${reason}`);
45
- if (index) lines.push(`Indexed at: ${index.updated} by ${index.generator}`);
46
- return {
47
- text: `Repository ${root}\n${lines.join("\n")}`,
48
- details: { action: "status", inRepo: true, staleness, indexed: index !== null },
49
- };
50
- }
51
-
52
- case "scan": {
53
- await onProgress?.(`Scanning ${root}…`);
54
- const index = await buildRepoIndex(root);
55
- if (!index) {
56
- return {
57
- text: "Cannot build index: the repository has no commits yet.",
58
- details: { action: "scan", inRepo: true, scanned: false },
59
- };
60
- }
61
- const dir = await writeRepoIndex(root, index);
62
- return {
63
- text: `Knowledge index written to ${dir}\n\n${summarizeIndex(index).join("\n")}`,
64
- details: { action: "scan", inRepo: true, scanned: true, index },
65
- };
66
- }
67
-
68
- case "overview": {
69
- const index = await readRepoIndex(root);
70
- if (!index) {
71
- return {
72
- text: `No knowledge index at ${repoIndexDir(root)} yet. Use action=scan to build one.`,
73
- details: { action: "overview", inRepo: true, indexed: false },
74
- };
75
- }
76
- const staleness = await assessStaleness(root);
77
- const header = staleness.state === "fresh" ? "" : `⚠ index is ${staleness.state} (consider rescanning)\n`;
78
- return {
79
- text: header + summarizeIndex(index).join("\n"),
80
- details: { action: "overview", inRepo: true, indexed: true, staleness, index },
81
- };
82
- }
28
+ const ensured = await ensureRepoIndex(root);
29
+ if (!ensured) {
30
+ return {
31
+ text: "The repository has no commits yet, so there is no structure to describe.",
32
+ details: { action: "overview", inRepo: true, indexed: false },
33
+ };
83
34
  }
35
+ const summaryCount = (await readSummaries(root)).length;
36
+ const footer = summaryCount > 0
37
+ ? `Summaries: ${summaryCount} file(s) in .okf/repository/summaries/`
38
+ : "Summaries: none yet — /weave-scan writes them with the session model";
39
+ return {
40
+ text: [...summarizeIndex(ensured.index), footer].join("\n"),
41
+ details: { action: "overview", inRepo: true, indexed: true, index: ensured.index, summaryCount },
42
+ };
84
43
  }
@@ -368,6 +368,44 @@ export async function assessStaleness(repoRoot: string): Promise<StalenessReport
368
368
  return { state: reasons.length === 0 ? "fresh" : "stale", reasons };
369
369
  }
370
370
 
371
+ /**
372
+ * The index as plumbing: read it, and rebuild it on disk when it is missing
373
+ * or stale. Nothing asks the user to run a structure scan any more; whoever
374
+ * needs the index (deep scan, graph, `weave_repo overview`) calls this.
375
+ * Null when the repository has no commits yet.
376
+ */
377
+ export async function ensureRepoIndex(
378
+ root: string,
379
+ ): Promise<{ index: RepoIndex; staleness: StalenessReport } | null> {
380
+ const staleness = await assessStaleness(root);
381
+ if (staleness.state === "fresh") {
382
+ const index = await readRepoIndex(root);
383
+ // `.okf` is excluded from git, so summaries written or pruned by a deep
384
+ // scan never make the index stale; compare the derived-file inventory
385
+ // directly so the graph's `.okf` subtree keeps up.
386
+ if (index && sameFiles(index.structure.okFiles ?? [], await listOkfFiles(root))) return { index, staleness };
387
+ }
388
+ let index = await buildRepoIndex(root);
389
+ if (!index) return null;
390
+ try {
391
+ await writeRepoIndex(root, index);
392
+ // The first write creates the very files the inventory lists; capture
393
+ // them once more so the next call finds the index settled.
394
+ if (!sameFiles(index.structure.okFiles ?? [], await listOkfFiles(root))) {
395
+ index = (await buildRepoIndex(root)) ?? index;
396
+ await writeRepoIndex(root, index);
397
+ }
398
+ } catch {
399
+ // A read-only checkout still gets a current in-memory index; only the
400
+ // on-disk cache is skipped.
401
+ }
402
+ return { index, staleness: { state: "fresh", reasons: [] } };
403
+ }
404
+
405
+ function sameFiles(a: readonly string[], b: readonly string[]): boolean {
406
+ return a.length === b.length && a.every((file, i) => file === b[i]);
407
+ }
408
+
371
409
  /**
372
410
  * Read a manifest provenance field, defaulting unknown/missing values to
373
411
  * "generated": every repository index written before the field existed was
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The recall trail: which notes the agent searched, read and wrote, in order.
3
+ *
4
+ * Agent memory is normally invisible — you cannot tell whether the agent
5
+ * remembered, guessed, or missed the note you wrote last week. Every vault
6
+ * operation already passes through `executeNoteAction`, so recording there
7
+ * gives both harnesses (Pi and OpenCode) the same trail with no hooks.
8
+ *
9
+ * Deliberately **in memory only**. The trail is session presentation, not
10
+ * knowledge: nothing here touches the vault, and a restart forgets it. The
11
+ * browser workspace runs in the same process as the tools, so it reads the
12
+ * buffer directly (`GET /api/trail`).
13
+ *
14
+ * ## Turns
15
+ *
16
+ * A turn is one user prompt's worth of agent work. Pi says so explicitly
17
+ * (`agent_start` calls {@link RecallTrail.beginTurn}); a harness that never
18
+ * does falls back to a time gap, so a pause of {@link TURN_GAP_MS} between
19
+ * operations starts a new turn.
20
+ */
21
+
22
+ /** How the agent touched a note. */
23
+ export type TrailKind = "searched" | "read" | "wrote";
24
+
25
+ /** One recorded operation. `slugs` may be empty for a search with no hits. */
26
+ export interface TrailEvent {
27
+ /** Monotonic per buffer; the client's stable key. */
28
+ seq: number;
29
+ turn: number;
30
+ /** ISO timestamp. */
31
+ at: string;
32
+ kind: TrailKind;
33
+ /** The `weave_note` action that produced it (`search`, `append`, …). */
34
+ action: string;
35
+ slugs: string[];
36
+ query?: string;
37
+ /** The vault the operation ran against, so a server can filter to its own. */
38
+ vault: string;
39
+ }
40
+
41
+ /** What a reader gets: the buffer plus a stamp that moves whenever it does. */
42
+ export interface TrailSnapshot {
43
+ stamp: string;
44
+ turn: number;
45
+ events: TrailEvent[];
46
+ }
47
+
48
+ /** What a caller hands to {@link RecallTrail.record}. */
49
+ export interface TrailEntry {
50
+ kind: TrailKind;
51
+ action: string;
52
+ slugs: readonly string[];
53
+ query?: string | undefined;
54
+ vault: string;
55
+ at: Date;
56
+ }
57
+
58
+ /** Old events fall off the front; a few hundred is far more than any viewer shows. */
59
+ export const TRAIL_CAPACITY = 200;
60
+
61
+ /** Without explicit turn boundaries, this much quiet starts a new turn. */
62
+ export const TURN_GAP_MS = 120_000;
63
+
64
+ export class RecallTrail {
65
+ private events: TrailEvent[] = [];
66
+ private seq = 0;
67
+ private turn = 0;
68
+ private explicit = false;
69
+ private lastAt = Number.NEGATIVE_INFINITY;
70
+
71
+ constructor(
72
+ private readonly capacity = TRAIL_CAPACITY,
73
+ private readonly gapMs = TURN_GAP_MS,
74
+ ) {}
75
+
76
+ /** Start a new turn. Once called, the time-gap fallback is off for good. */
77
+ beginTurn(): void {
78
+ this.explicit = true;
79
+ this.turn += 1;
80
+ }
81
+
82
+ record(entry: TrailEntry): void {
83
+ const at = entry.at.getTime();
84
+ if (this.turn === 0 || (!this.explicit && at - this.lastAt > this.gapMs)) this.turn += 1;
85
+ this.lastAt = at;
86
+ this.seq += 1;
87
+ this.events.push({
88
+ seq: this.seq,
89
+ turn: this.turn,
90
+ at: entry.at.toISOString(),
91
+ kind: entry.kind,
92
+ action: entry.action,
93
+ slugs: [...new Set(entry.slugs)],
94
+ ...(entry.query !== undefined ? { query: entry.query } : {}),
95
+ vault: entry.vault,
96
+ });
97
+ if (this.events.length > this.capacity) this.events.splice(0, this.events.length - this.capacity);
98
+ }
99
+
100
+ /** Distinct notes read or written in the current turn — the "used N notes" count. */
101
+ usedThisTurn(): number {
102
+ const used = new Set<string>();
103
+ for (const event of this.events) {
104
+ if (event.turn === this.turn && event.kind !== "searched") for (const slug of event.slugs) used.add(`${event.vault}\0${slug}`);
105
+ }
106
+ return used.size;
107
+ }
108
+
109
+ snapshot(): TrailSnapshot {
110
+ return { stamp: `${this.turn}.${this.seq}`, turn: this.turn, events: this.events.map((event) => ({ ...event, slugs: [...event.slugs] })) };
111
+ }
112
+
113
+ /** Forget everything. For tests and a fresh session. */
114
+ clear(): void {
115
+ this.events = [];
116
+ this.seq = 0;
117
+ this.turn = 0;
118
+ this.explicit = false;
119
+ this.lastAt = Number.NEGATIVE_INFINITY;
120
+ }
121
+ }
122
+
123
+ /** The process-wide trail the tools write and the workspace server reads. */
124
+ export const recallTrail = new RecallTrail();
package/src/core/types.ts CHANGED
@@ -194,9 +194,7 @@ export interface VaultStatus {
194
194
  export interface RepoStatus {
195
195
  root: string;
196
196
  name: string;
197
- indexed: boolean;
198
- staleness: StalenessReport;
199
- /** Deep-scan summaries on disk (docs/scan-modes.md); 0 when none indexed. */
197
+ /** Deep-scan summaries on disk (docs/scan-modes.md); 0 before the first /weave-scan. */
200
198
  summaryCount: number;
201
199
  }
202
200
 
package/src/core/vault.ts CHANGED
@@ -10,7 +10,19 @@ import {
10
10
  unquoteField,
11
11
  upsertFrontMatterFields,
12
12
  } from "./frontmatter";
13
- import { auditLinks, fenceRanges, rawTailStart, rewriteLinks, RAW_NOTES_HEADING, type LinkAudit, type LinkFix } from "./links/repair";
13
+ import { auditLinks, rewriteLinks, RAW_NOTES_HEADING, type LinkAudit, type LinkFix } from "./links/repair";
14
+ import { joinRawTail, rawTailBoundary, splitRawTail } from "./view/rawTail";
15
+ import {
16
+ moveHistory,
17
+ readHistory,
18
+ moveHistoryFolder,
19
+ recordRevision,
20
+ removeHistory,
21
+ removeHistoryFolder,
22
+ type HistoryAuthor,
23
+ type HistoryOp,
24
+ type NoteRevision,
25
+ } from "./history";
14
26
  import { withMutationQueue } from "./mutex";
15
27
  import { NOTES_DIR, OKF_MANIFEST } from "./paths";
16
28
  import { slugify, uniqueSlug } from "./slug";
@@ -37,6 +49,14 @@ export interface AddNoteInput {
37
49
  body: string;
38
50
  tags?: string[];
39
51
  source?: NoteSource;
52
+ /**
53
+ * Start the note with `body` as its first verbatim block in the `## Raw`
54
+ * tail instead of as editorial body. Dictation starts here, so a later
55
+ * `finalize` cannot replace the user's opening words.
56
+ */
57
+ raw?: boolean;
58
+ /** Who is creating it, for note history. Defaults from `source`. */
59
+ by?: HistoryAuthor;
40
60
  /** Injectable clock for tests. */
41
61
  now?: Date;
42
62
  }
@@ -104,7 +124,8 @@ export function resolveNotePath(root: string, slug: string): string | null {
104
124
  export async function addNote(root: string, input: AddNoteInput): Promise<Note> {
105
125
  await ensureVault(root);
106
126
  return withVaultLock(root, async () => {
107
- const now = (input.now ?? new Date()).toISOString();
127
+ const date = input.now ?? new Date();
128
+ const now = date.toISOString();
108
129
  const base = slugify(input.title);
109
130
  const slug = uniqueSlug(base, (candidate) => existsSync(notePath(root, candidate)));
110
131
 
@@ -117,7 +138,12 @@ export async function addNote(root: string, input: AddNoteInput): Promise<Note>
117
138
  };
118
139
  // No `frontMatter`: a brand-new note has no prior layout to respect, so
119
140
  // the serializer writes its canonical block.
120
- return writeNote(notePath(root, slug), slug, meta, input.body, undefined);
141
+ const body = input.raw && input.body.trim() !== ""
142
+ ? `${rawTailOpening()}\n\n${formatRawAppend(input.body, date)}\n`
143
+ : input.body;
144
+ const note = await writeNote(notePath(root, slug), slug, meta, body, undefined);
145
+ await record(root, note, input.by ?? (meta.source === "human" ? "human" : "agent"), "create", null, now);
146
+ return note;
121
147
  });
122
148
  }
123
149
 
@@ -175,6 +201,24 @@ async function writeNote(
175
201
  return { slug, ...parsed.meta, body: parsed.body, frontMatter: parsed.frontMatter };
176
202
  }
177
203
 
204
+ /** The body a write is about to replace, and when it was last written. */
205
+ async function priorBody(path: string, note: Note): Promise<{ body: string; at: string }> {
206
+ const st = await fs.stat(path).catch(() => null);
207
+ return { body: note.body, at: st?.mtime.toISOString() ?? note.updated };
208
+ }
209
+
210
+ /** Add the written note to its history (see `history.ts`). */
211
+ function record(
212
+ root: string,
213
+ note: Note,
214
+ by: HistoryAuthor,
215
+ op: HistoryOp,
216
+ before: { body: string; at: string } | null,
217
+ at: string,
218
+ ): Promise<void> {
219
+ return recordRevision(root, note.slug, { by, op, before, after: note.body, at: new Date(at) });
220
+ }
221
+
178
222
  /**
179
223
  * Namespace prefix for this module's mutation-queue keys.
180
224
  *
@@ -213,6 +257,8 @@ export interface AppendToNoteOptions {
213
257
  * editorial body above the tail.
214
258
  */
215
259
  raw?: boolean;
260
+ /** Who is appending, for note history. Defaults to `agent`. */
261
+ by?: HistoryAuthor;
216
262
  }
217
263
 
218
264
  /** Append Markdown to an existing note and bump `updated`. */
@@ -228,6 +274,7 @@ export async function appendToNote(
228
274
  return withVaultLock(root, async () => {
229
275
  const note = await getNote(root, slug);
230
276
  if (!note) return null;
277
+ const before = await priorBody(path, note);
231
278
  const tail = extractRawTail(note.body);
232
279
  let body: string;
233
280
  if (options.raw) {
@@ -248,7 +295,9 @@ export async function appendToNote(
248
295
  const head = note.body.slice(0, idx).replace(/\s+$/, "");
249
296
  body = (head ? head + "\n\n" : "") + addition.trim() + "\n\n" + tail + "\n";
250
297
  }
251
- return writeNote(path, slug, { ...note, updated: now.toISOString() }, body, note.frontMatter);
298
+ const written = await writeNote(path, slug, { ...note, updated: now.toISOString() }, body, note.frontMatter);
299
+ await record(root, written, options.by ?? "agent", options.raw ? "raw" : "append", before, written.updated);
300
+ return written;
252
301
  });
253
302
  }
254
303
 
@@ -321,6 +370,8 @@ export interface FinalizeNoteInput {
321
370
  * entities, links). The `## Raw` tail is preserved verbatim beneath it.
322
371
  */
323
372
  body: string;
373
+ /** Who is finalizing, for note history. Defaults to `agent`. */
374
+ by?: HistoryAuthor;
324
375
  /** Injectable clock for tests. */
325
376
  now?: Date;
326
377
  }
@@ -354,7 +405,10 @@ export async function finalizeNote(
354
405
  ? ""
355
406
  : `\n\n${rawTailOpening()}\n\n${fenceFor(note.body)}\n${note.body.trim()}\n${fenceFor(note.body)}`);
356
407
  const meta: NoteMeta = { ...note, updated: (input.now ?? new Date()).toISOString() };
357
- return writeNote(path, slug, meta, body, note.frontMatter);
408
+ const before = await priorBody(path, note);
409
+ const written = await writeNote(path, slug, meta, body, note.frontMatter);
410
+ await record(root, written, input.by ?? "agent", "finalize", before, meta.updated);
411
+ return written;
358
412
  });
359
413
  }
360
414
 
@@ -373,7 +427,8 @@ export interface UpsertNoteInput {
373
427
  export async function upsertNote(root: string, input: UpsertNoteInput): Promise<Note> {
374
428
  await ensureVault(root);
375
429
  return withVaultLock(root, async () => {
376
- const now = (input.now ?? new Date()).toISOString();
430
+ const date = input.now ?? new Date();
431
+ const now = date.toISOString();
377
432
  const identity = input.identity;
378
433
  let existing = await getNote(root, input.slug);
379
434
  if (existing !== null && identity && frontMatterField(existing.frontMatter, identity.field) !== identity.value) {
@@ -400,7 +455,9 @@ export async function upsertNote(root: string, input: UpsertNoteInput): Promise<
400
455
  `source: ${meta.source}`,
401
456
  ...upsertFrontMatterFields([], fields),
402
457
  ];
403
- return writeNote(notePath(root, slug), slug, meta, input.body, frontMatter);
458
+ const created = await writeNote(notePath(root, slug), slug, meta, input.body, frontMatter);
459
+ await record(root, created, "agent", "generate", null, now);
460
+ return created;
404
461
  }
405
462
  const fields = safeGeneratedFields(input.fields);
406
463
  const frontMatter = upsertFrontMatterFields(existing.frontMatter ?? [], fields);
@@ -410,13 +467,16 @@ export async function upsertNote(root: string, input: UpsertNoteInput): Promise<
410
467
  // verbatim tail.
411
468
  const tail = extractRawTail(existing.body);
412
469
  const body = tail === "" ? input.body.trim() : `${input.body.trim()}\n\n${tail}`;
413
- return writeNote(
470
+ const before = await priorBody(notePath(root, input.slug), existing);
471
+ const refreshed = await writeNote(
414
472
  notePath(root, input.slug),
415
473
  input.slug,
416
474
  { ...existing, updated: now },
417
475
  body,
418
476
  frontMatter,
419
477
  );
478
+ await record(root, refreshed, "agent", "generate", before, now);
479
+ return refreshed;
420
480
  });
421
481
  }
422
482
 
@@ -515,6 +575,7 @@ async function rewriteVaultLinks(
515
575
  } catch {
516
576
  continue; // raced a delete
517
577
  }
578
+ const before = await priorBody(path, note);
518
579
  const at = original.lastIndexOf(note.body);
519
580
  if (at === -1) continue; // body not found verbatim; refuse rather than guess
520
581
  const next = original.slice(0, at) + body + original.slice(at + note.body.length);
@@ -532,6 +593,8 @@ async function rewriteVaultLinks(
532
593
  await fs.writeFile(tmp, next, "utf8");
533
594
  await fs.rename(tmp, path);
534
595
  }
596
+ const repaired = await getNote(root, slug);
597
+ if (repaired !== null) await record(root, repaired, "system", "repair", before, new Date().toISOString());
535
598
  touched.push(slug);
536
599
  links += changed;
537
600
  }
@@ -598,30 +661,24 @@ export async function repairVaultLinks(root: string, options: { apply?: boolean
598
661
 
599
662
  /**
600
663
  * Keep the on-disk `## Raw` tail whatever the body says — it is append-only.
601
- * `rawTailStart`, not `extractRawTail`: fence-aware, for untrusted input.
664
+ * `rawTailBoundary`, not `extractRawTail`: fence-aware, for untrusted input.
602
665
  */
603
666
  function preserveRawTail(currentBody: string, nextBody: string): string {
604
- const tail = currentBody.slice(tailBoundary(currentBody)).trimEnd();
605
- const head = nextBody.slice(0, tailBoundary(nextBody)).trim();
606
- if (tail === "") return head;
607
- // An emptied head leaves the tail alone, not a leading blank line.
608
- return head === "" ? tail : `${head}\n\n${tail}`;
667
+ const tail = currentBody.slice(rawTailBoundary(currentBody)).trimEnd();
668
+ return joinRawTail(nextBody.slice(0, rawTailBoundary(nextBody)).trim(), tail);
609
669
  }
610
670
 
611
- /** A `---` rule, alone on the line, with only blank space after it. */
612
- const TAIL_RULE_RE = /(?:^|\n)-{3,}[ \t]*\r?\n\s*$/;
613
-
614
671
  /**
615
- * Where the tail's text begins — the `---` rule, not the heading
616
- * `rawTailStart` returns. Splitting at the heading orphans the rule.
672
+ * What a body save does with the `## Raw` tail. `keep` (the default) holds
673
+ * the on-disk tail whatever the body says. `replace` writes the body's own
674
+ * tail: the person editing the note changed their own Raw words on purpose.
617
675
  */
618
- function tailBoundary(body: string): number {
619
- const heading = rawTailStart(body, fenceRanges(body));
620
- if (heading === body.length) return heading;
621
- const rule = TAIL_RULE_RE.exec(body.slice(0, heading));
622
- if (rule === null) return heading;
623
- // `index` is the newline before the rule when there is one; step over it.
624
- return rule.index === 0 && !body.startsWith("\n") ? 0 : rule.index + 1;
676
+ export type RawTailMode = "keep" | "replace";
677
+
678
+ /** The body as sent, its tail included, with the same spacing `preserveRawTail` writes. */
679
+ function normalizeRawTail(body: string): string {
680
+ const { head, tail } = splitRawTail(body);
681
+ return joinRawTail(head.trim(), tail);
625
682
  }
626
683
 
627
684
  /**
@@ -629,18 +686,43 @@ function tailBoundary(body: string): number {
629
686
  * `note.frontMatter` and only `updated` moves. Last write wins — no
630
687
  * `expectedRevision`; docs/weave-workspace.md §11 P5.3 argues the trade.
631
688
  */
632
- export async function setNoteBody(root: string, slug: string, body: string, now = new Date()): Promise<VaultMutationResult> {
689
+ export async function setNoteBody(
690
+ root: string,
691
+ slug: string,
692
+ body: string,
693
+ now = new Date(),
694
+ by: HistoryAuthor = "human",
695
+ raw: RawTailMode = "keep",
696
+ ): Promise<VaultMutationResult> {
633
697
  const path = resolveNotePath(root, slug);
634
698
  if (path === null) return { ok: false, reason: "invalid" };
635
699
  return withVaultLock(root, async () => {
636
700
  const note = await getNote(root, slug);
637
701
  if (note === null) return { ok: false, reason: "missing" };
638
- const next = preserveRawTail(note.body, body);
639
- await writeNote(path, slug, { ...note, updated: now.toISOString() }, next, note.frontMatter);
702
+ const next = raw === "replace" ? normalizeRawTail(body) : preserveRawTail(note.body, body);
703
+ const before = await priorBody(path, note);
704
+ const written = await writeNote(path, slug, { ...note, updated: now.toISOString() }, next, note.frontMatter);
705
+ await record(root, written, by, "edit", before, written.updated);
640
706
  return { ok: true, slug };
641
707
  });
642
708
  }
643
709
 
710
+ /**
711
+ * Every saved revision of a note, ending on what the note says now, or null
712
+ * when the note is missing. Under the vault lock, so a write cannot land
713
+ * between reading the note and comparing it with its log: that window would
714
+ * record the old body as an outside edit.
715
+ */
716
+ export async function readNoteHistory(root: string, slug: string): Promise<NoteRevision[] | null> {
717
+ const path = resolveNotePath(root, slug);
718
+ if (path === null) return null;
719
+ return withVaultLock(root, async () => {
720
+ const note = await getNote(root, slug);
721
+ if (note === null) return null;
722
+ return readHistory(root, slug, await priorBody(path, note));
723
+ });
724
+ }
725
+
644
726
  /** Rename a note in place and keep its front-matter title in sync. */
645
727
  export async function renameNote(root: string, slug: string, name: string, now = new Date()): Promise<VaultMutationResult> {
646
728
  const from = resolveNotePath(root, slug);
@@ -656,6 +738,7 @@ export async function renameNote(root: string, slug: string, name: string, now =
656
738
  if (from !== to && await exists(to)) return { ok: false, reason: "collision" };
657
739
  if (from !== to) await fs.rename(from, to);
658
740
  await writeNote(to, target, { ...note, title, updated: now.toISOString() }, note.body, note.frontMatter);
741
+ if (from !== to) await moveHistory(root, slug, target);
659
742
  if (from !== to) await repointBacklinks(root, new Map([[slug, target]]));
660
743
  return { ok: true, slug: target };
661
744
  });
@@ -675,6 +758,7 @@ export async function moveNote(root: string, slug: string, folder: string | null
675
758
  if (from === to) return { ok: true, slug };
676
759
  if (await exists(to)) return { ok: false, reason: "collision" };
677
760
  await fs.rename(from, to);
761
+ await moveHistory(root, slug, target);
678
762
  await repointBacklinks(root, new Map([[slug, target]]));
679
763
  return { ok: true, slug: target };
680
764
  });
@@ -687,6 +771,7 @@ export async function deleteNote(root: string, slug: string): Promise<VaultMutat
687
771
  return withVaultLock(root, async () => {
688
772
  if (!(await exists(path))) return { ok: false, reason: "missing" };
689
773
  await fs.unlink(path);
774
+ await removeHistory(root, slug);
690
775
  return { ok: true };
691
776
  });
692
777
  }
@@ -711,6 +796,7 @@ export async function renameFolder(root: string, folder: string, name: string):
711
796
  .filter((f) => isMarkdown(f) && f.startsWith(`${folder}/`))
712
797
  .map((f) => f.slice(0, -".md".length));
713
798
  await fs.rename(from, to);
799
+ await moveHistoryFolder(root, folder, target);
714
800
  await repointBacklinks(root, new Map(moved.map((s) => [s, `${target}/${s.slice(folder.length + 1)}`])));
715
801
  return { ok: true, path: target };
716
802
  });
@@ -723,6 +809,7 @@ export async function deleteFolder(root: string, folder: string): Promise<VaultM
723
809
  return withVaultLock(root, async () => {
724
810
  if (!(await isDirectory(path))) return { ok: false, reason: "missing" };
725
811
  await fs.rm(path, { recursive: true });
812
+ await removeHistoryFolder(root, folder);
726
813
  return { ok: true };
727
814
  });
728
815
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * focusModel — the 1-hop neighborhood of a node, grouped (weave-workspace §3).
3
3
  *
4
- * The TUI renders this as the Focus surface; the browser workspace uses
5
- * `focusNeighborhood` for the graph's neighborhood highlight.
4
+ * The browser workspace uses `focusNeighborhood` for the graph's
5
+ * neighborhood highlight.
6
6
  */
7
7
 
8
8
  import type { GraphEdge, GraphModel, GraphNode, NodeKind } from "../graph/model";
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * src/core/view — portable, renderer-agnostic view-models (weave-workspace §3).
3
3
  *
4
- * One implementation of the tree, detail, focus, and health projections, shared
5
- * by the pi TUI and the browser workspace so they can never drift. Pure
4
+ * One implementation of the tree, detail, focus, and cluster projections,
5
+ * written once so every surface that renders them agrees. Pure
6
6
  * functions over `GraphModel`; no harness, no DOM, no terminal concepts.
7
7
  */
8
8
  export * from "./types";
@@ -11,5 +11,4 @@ export * from "./tree";
11
11
  export * from "./detail";
12
12
  export * from "./focus";
13
13
  export * from "./links";
14
- export * from "./health";
15
14
  export * from "./cluster";