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
@@ -0,0 +1,417 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative } from "node:path";
3
+ import { OKF_MANIFEST } from "./paths";
4
+
5
+ /**
6
+ * Note history: a copy of every note after every change, so the viewer can
7
+ * replay how a note was written from its first words to now.
8
+ *
9
+ * ## Why a log file and not git
10
+ *
11
+ * The vault is not guaranteed to be a git repository, and when it is, the
12
+ * repository belongs to the user: committing on every append would bury their
13
+ * own history under ours. A plain append-only file per note keeps the
14
+ * project's rule that knowledge lives in readable files, needs no binary, and
15
+ * travels with the vault (the backup ZIP walks the whole vault root).
16
+ *
17
+ * ## Format
18
+ *
19
+ * `<vault>/history/<slug>.jsonl`, one JSON object per revision, oldest first.
20
+ * A revision is either a full copy of the body (`full`) or a delta against
21
+ * the revision before it: keep `keep[0]` characters from the start and
22
+ * `keep[1]` from the end, and put `text` between them. Appends and small
23
+ * edits cost a few bytes. Every {@link KEYFRAME_EVERY}th revision is full, so
24
+ * one damaged line cannot take the rest of the log with it.
25
+ *
26
+ * Bodies only, not front matter: the `updated` stamp changes on every write
27
+ * and would turn every delta into a copy of the whole file.
28
+ *
29
+ * ## Who wrote it
30
+ *
31
+ * Every write path in `vault.ts` says who is writing. An edit made outside
32
+ * pi-weave (an editor, a sync tool) is noticed the next time pi-weave writes
33
+ * or replays the note: the body on disk no longer matches the last revision,
34
+ * so it is recorded as an `outside` revision before anything else.
35
+ *
36
+ * ## Size limits
37
+ *
38
+ * A note's log is compacted once it holds more than {@link MAX_REVISIONS}
39
+ * revisions or {@link MAX_LOG_BYTES} bytes. Compaction drops the oldest
40
+ * revisions until the log is back under {@link COMPACT_TO} of both limits
41
+ * (so it does not run again on the next write), rewrites what is left with
42
+ * a full copy first, and marks that line `trimmed`. The newest revision is
43
+ * always kept, however large the note.
44
+ *
45
+ * ## Off switch
46
+ *
47
+ * `"history": false` in the vault's `okf.json` stops recording. Existing logs
48
+ * are kept, and nothing is recorded while it is off — including outside edits.
49
+ */
50
+
51
+ export const HISTORY_DIR = "history";
52
+ const LOG_EXT = ".jsonl";
53
+ /** A full copy every this many revisions bounds the damage of a bad line. */
54
+ export const KEYFRAME_EVERY = 32;
55
+ /** Most revisions one note's log keeps. */
56
+ export const MAX_REVISIONS = 500;
57
+ /** Largest one note's log may grow, in bytes, before it is compacted. */
58
+ export const MAX_LOG_BYTES = 4 * 1024 * 1024;
59
+ /** Compaction brings a log back to this share of both limits. */
60
+ export const COMPACT_TO = 0.8;
61
+
62
+ /** Who made a change. `outside` is any editor other than pi-weave. */
63
+ export type HistoryAuthor = "human" | "agent" | "outside" | "system";
64
+ export const HISTORY_AUTHORS: readonly HistoryAuthor[] = ["human", "agent", "outside", "system"];
65
+
66
+ /** What kind of change it was. `baseline` is a note as history first found it. */
67
+ export type HistoryOp =
68
+ | "create"
69
+ | "edit"
70
+ | "append"
71
+ | "raw"
72
+ | "finalize"
73
+ | "generate"
74
+ | "repair"
75
+ | "outside"
76
+ | "baseline";
77
+ export const HISTORY_OPS: readonly HistoryOp[] = [
78
+ "create",
79
+ "edit",
80
+ "append",
81
+ "raw",
82
+ "finalize",
83
+ "generate",
84
+ "repair",
85
+ "outside",
86
+ "baseline",
87
+ ];
88
+
89
+ /** One revision of a note, with its body reconstructed. */
90
+ export interface NoteRevision {
91
+ /** ISO-8601 time of the change. */
92
+ at: string;
93
+ by: HistoryAuthor;
94
+ op: HistoryOp;
95
+ body: string;
96
+ }
97
+
98
+ /** A line of the log, as stored, and the shape the viewer receives. */
99
+ export interface LogEntry {
100
+ at: string;
101
+ by: HistoryAuthor;
102
+ op: HistoryOp;
103
+ full?: string;
104
+ keep?: [number, number];
105
+ text?: string;
106
+ /** On the first line only: older revisions were dropped to keep the log small. */
107
+ trimmed?: true;
108
+ }
109
+
110
+ /** The note body as it stood before a write, and when it got that way. */
111
+ export interface PriorBody {
112
+ body: string;
113
+ at: string;
114
+ }
115
+
116
+ export interface RecordInput {
117
+ by: HistoryAuthor;
118
+ op: HistoryOp;
119
+ /** The body before this write; null for a brand-new note. */
120
+ before: PriorBody | null;
121
+ /** The body after this write. */
122
+ after: string;
123
+ at: Date;
124
+ }
125
+
126
+ /** Resolve a note's log path, or null when the slug would escape the log directory. */
127
+ export function historyPath(root: string, slug: string): string | null {
128
+ if (slug.trim().length === 0) return null;
129
+ const dir = join(root, HISTORY_DIR);
130
+ const candidate = join(dir, `${slug}${LOG_EXT}`);
131
+ const rel = relative(dir, candidate);
132
+ if (rel.startsWith("..") || isAbsolute(rel)) return null;
133
+ return candidate;
134
+ }
135
+
136
+ function folderPath(root: string, folder: string): string | null {
137
+ const parts = folder.split("/");
138
+ if (parts.some((part) => part === "" || part === "." || part === "..")) return null;
139
+ return join(root, HISTORY_DIR, ...parts);
140
+ }
141
+
142
+ // --- settings ----------------------------------------------------------------
143
+
144
+ async function readManifest(root: string): Promise<Record<string, unknown> | null> {
145
+ try {
146
+ const parsed: unknown = JSON.parse(await fs.readFile(join(root, OKF_MANIFEST), "utf8"));
147
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ /** Whether revisions are being recorded. On unless `okf.json` says `"history": false`. */
154
+ export async function historyEnabled(root: string): Promise<boolean> {
155
+ return (await readManifest(root))?.["history"] !== false;
156
+ }
157
+
158
+ /** Turn recording on or off, keeping every other manifest field. */
159
+ export async function setHistoryEnabled(root: string, enabled: boolean): Promise<void> {
160
+ const manifest = (await readManifest(root)) ?? { okfVersion: 1, scope: "vault" };
161
+ manifest["history"] = enabled;
162
+ await fs.mkdir(root, { recursive: true });
163
+ await fs.writeFile(join(root, OKF_MANIFEST), JSON.stringify(manifest, null, 2) + "\n", "utf8");
164
+ }
165
+
166
+ // --- the log -------------------------------------------------------------------
167
+
168
+ function isEntry(value: unknown): value is LogEntry {
169
+ if (typeof value !== "object" || value === null) return false;
170
+ const entry = value as Record<string, unknown>;
171
+ if (typeof entry["at"] !== "string") return false;
172
+ if (!HISTORY_AUTHORS.includes(entry["by"] as HistoryAuthor)) return false;
173
+ if (!HISTORY_OPS.includes(entry["op"] as HistoryOp)) return false;
174
+ if (typeof entry["full"] === "string") return true;
175
+ const keep = entry["keep"];
176
+ return (
177
+ Array.isArray(keep) &&
178
+ keep.length === 2 &&
179
+ keep.every((n) => Number.isInteger(n) && (n as number) >= 0) &&
180
+ typeof entry["text"] === "string"
181
+ );
182
+ }
183
+
184
+ /**
185
+ * Replay a log into revisions. A line that is not JSON, or a delta that does
186
+ * not fit the body before it, is skipped, and so is every delta after it up
187
+ * to the next full copy: guessing would show the user text nobody wrote.
188
+ *
189
+ * With `lastOnly`, only the newest revision is kept. Recording a write needs
190
+ * nothing else, and keeping every body would cost the sum of all of them.
191
+ */
192
+ function replay(text: string, lastOnly = false): NoteRevision[] {
193
+ const revisions: NoteRevision[] = [];
194
+ let body: string | null = null;
195
+ for (const line of text.split("\n")) {
196
+ if (line.trim() === "") continue;
197
+ let entry: unknown;
198
+ try {
199
+ entry = JSON.parse(line);
200
+ } catch {
201
+ body = null;
202
+ continue;
203
+ }
204
+ if (!isEntry(entry)) {
205
+ body = null;
206
+ continue;
207
+ }
208
+ if (entry.full !== undefined) {
209
+ body = entry.full;
210
+ } else if (body !== null) {
211
+ const [head, tail] = entry.keep as [number, number];
212
+ if (head + tail > body.length) {
213
+ body = null;
214
+ continue;
215
+ }
216
+ body = body.slice(0, head) + (entry.text as string) + body.slice(body.length - tail);
217
+ } else {
218
+ continue;
219
+ }
220
+ if (lastOnly) revisions.length = 0;
221
+ revisions.push({ at: entry.at, by: entry.by, op: entry.op, body });
222
+ }
223
+ return revisions;
224
+ }
225
+
226
+ async function readLog(path: string, lastOnly = false): Promise<{ revisions: NoteRevision[]; lines: number }> {
227
+ let text: string;
228
+ try {
229
+ text = await fs.readFile(path, "utf8");
230
+ } catch {
231
+ return { revisions: [], lines: 0 };
232
+ }
233
+ return { revisions: replay(text, lastOnly), lines: text.split("\n").filter((line) => line.trim() !== "").length };
234
+ }
235
+
236
+ /** The smallest prefix/suffix-preserving edit that turns `from` into `to`. */
237
+ export function diffBodies(from: string, to: string): { keep: [number, number]; text: string } {
238
+ const max = Math.min(from.length, to.length);
239
+ let head = 0;
240
+ while (head < max && from.charCodeAt(head) === to.charCodeAt(head)) head++;
241
+ let tail = 0;
242
+ while (tail < max - head && from.charCodeAt(from.length - 1 - tail) === to.charCodeAt(to.length - 1 - tail)) tail++;
243
+ return { keep: [head, tail], text: to.slice(head, to.length - tail) };
244
+ }
245
+
246
+ function entryFor(
247
+ meta: { at: string; by: HistoryAuthor; op: HistoryOp },
248
+ previous: string | null,
249
+ body: string,
250
+ index: number,
251
+ ): LogEntry {
252
+ if (previous === null || index % KEYFRAME_EVERY === 0) return { ...meta, full: body };
253
+ return { ...meta, ...diffBodies(previous, body) };
254
+ }
255
+
256
+ /** Times never run backwards in a log, even when a clock or mtime does. */
257
+ function notBefore(at: string, last: NoteRevision | undefined): string {
258
+ return last !== undefined && at < last.at ? last.at : at;
259
+ }
260
+
261
+ async function appendEntries(
262
+ path: string,
263
+ log: { revisions: NoteRevision[]; lines: number },
264
+ pending: Array<{ at: string; by: HistoryAuthor; op: HistoryOp; body: string }>,
265
+ ): Promise<void> {
266
+ if (pending.length === 0) return;
267
+ let previous = log.revisions.at(-1)?.body ?? null;
268
+ let index = log.lines;
269
+ const lines: string[] = [];
270
+ for (const { body, ...meta } of pending) {
271
+ lines.push(JSON.stringify(entryFor(meta, previous, body, index)));
272
+ previous = body;
273
+ index++;
274
+ }
275
+ await fs.mkdir(dirname(path), { recursive: true });
276
+ await fs.appendFile(path, lines.join("\n") + "\n", "utf8");
277
+ if (index > MAX_REVISIONS || (await fs.stat(path)).size > MAX_LOG_BYTES) await compact(path);
278
+ }
279
+
280
+ /**
281
+ * Encode revisions as log lines: a full copy first, deltas after, a full
282
+ * copy every {@link KEYFRAME_EVERY}. The viewer receives the same shape, so
283
+ * the download is about as small as the file.
284
+ */
285
+ export function encodeRevisions(revisions: readonly NoteRevision[], trimmed = false): LogEntry[] {
286
+ let previous: string | null = null;
287
+ return revisions.map(({ body, ...meta }, index) => {
288
+ const entry = entryFor(meta, previous, body, index);
289
+ previous = body;
290
+ return index === 0 && trimmed ? { ...entry, trimmed: true } : entry;
291
+ });
292
+ }
293
+
294
+ /** Whether a log's oldest revisions were dropped by compaction. */
295
+ function isTrimmed(text: string): boolean {
296
+ const first = text.slice(0, text.indexOf("\n") + 1 || text.length);
297
+ try {
298
+ return (JSON.parse(first) as { trimmed?: unknown }).trimmed === true;
299
+ } catch {
300
+ return false;
301
+ }
302
+ }
303
+
304
+ /** Drop the oldest revisions until the log fits {@link COMPACT_TO} of both limits. */
305
+ async function compact(path: string): Promise<void> {
306
+ const revisions = replay(await fs.readFile(path, "utf8"));
307
+ const maxBytes = MAX_LOG_BYTES * COMPACT_TO;
308
+ let keep = Math.min(revisions.length, Math.floor(MAX_REVISIONS * COMPACT_TO));
309
+ let text = "";
310
+ for (;;) {
311
+ text = encodeRevisions(revisions.slice(-keep), true).map((entry) => JSON.stringify(entry)).join("\n") + "\n";
312
+ if (keep === 1 || Buffer.byteLength(text, "utf8") <= maxBytes) break;
313
+ keep = Math.max(1, Math.floor(keep * 0.75));
314
+ }
315
+ // A temporary file and a rename: a crash mid-write leaves the old log, not half a new one.
316
+ const temp = `${path}.compact`;
317
+ await fs.writeFile(temp, text, "utf8");
318
+ await fs.rename(temp, path);
319
+ }
320
+
321
+ /** Whether a note's history has had its oldest revisions dropped. */
322
+ export async function historyTrimmed(root: string, slug: string): Promise<boolean> {
323
+ const path = historyPath(root, slug);
324
+ if (path === null) return false;
325
+ try {
326
+ return isTrimmed(await fs.readFile(path, "utf8"));
327
+ } catch {
328
+ return false;
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Record one write. Called by `vault.ts` while it holds the vault lock, after
334
+ * the note file has been written. A failure here never fails the write that
335
+ * caused it: losing a revision is better than losing the user's edit.
336
+ */
337
+ export async function recordRevision(root: string, slug: string, input: RecordInput): Promise<void> {
338
+ try {
339
+ const path = historyPath(root, slug);
340
+ if (path === null || !(await historyEnabled(root))) return;
341
+ const log = await readLog(path, true);
342
+ const pending: Array<{ at: string; by: HistoryAuthor; op: HistoryOp; body: string }> = [];
343
+ let last = log.revisions.at(-1);
344
+ if (input.before !== null && last?.body !== input.before.body) {
345
+ const op: HistoryOp = last === undefined ? "baseline" : "outside";
346
+ const outside = { at: notBefore(input.before.at, last), by: "outside" as const, op, body: input.before.body };
347
+ pending.push(outside);
348
+ last = outside;
349
+ }
350
+ if (last?.body !== input.after) {
351
+ pending.push({ at: notBefore(input.at.toISOString(), last), by: input.by, op: input.op, body: input.after });
352
+ }
353
+ await appendEntries(path, log, pending);
354
+ } catch {
355
+ // Deliberately silent; see the doc comment.
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Every revision of a note, oldest first. When `current` is given and differs
361
+ * from the last revision, the difference was made outside pi-weave; it is
362
+ * recorded (if history is on) and returned as the final revision either way,
363
+ * so the slider always ends on what the note says now.
364
+ */
365
+ export async function readHistory(root: string, slug: string, current?: PriorBody): Promise<NoteRevision[]> {
366
+ const path = historyPath(root, slug);
367
+ if (path === null) return [];
368
+ const log = await readLog(path);
369
+ const last = log.revisions.at(-1);
370
+ if (current === undefined || last?.body === current.body) return log.revisions;
371
+ const outside: NoteRevision = {
372
+ at: notBefore(current.at, last),
373
+ by: "outside",
374
+ op: last === undefined ? "baseline" : "outside",
375
+ body: current.body,
376
+ };
377
+ if (await historyEnabled(root)) {
378
+ try {
379
+ await appendEntries(path, log, [outside]);
380
+ } catch {
381
+ // Still shown; recording is best effort.
382
+ }
383
+ }
384
+ return [...log.revisions, outside];
385
+ }
386
+
387
+ async function move(from: string | null, to: string | null): Promise<void> {
388
+ if (from === null || to === null || from === to) return;
389
+ try {
390
+ await fs.mkdir(dirname(to), { recursive: true });
391
+ await fs.rename(from, to);
392
+ } catch {
393
+ // No log yet, or nothing to move.
394
+ }
395
+ }
396
+
397
+ /** Carry a note's log to its new slug after a rename or move. */
398
+ export async function moveHistory(root: string, from: string, to: string): Promise<void> {
399
+ await move(historyPath(root, from), historyPath(root, to));
400
+ }
401
+
402
+ /** Carry a folder's logs along when the folder is renamed. */
403
+ export async function moveHistoryFolder(root: string, from: string, to: string): Promise<void> {
404
+ await move(folderPath(root, from), folderPath(root, to));
405
+ }
406
+
407
+ /** Drop a deleted note's log, so a new note with the same name starts clean. */
408
+ export async function removeHistory(root: string, slug: string): Promise<void> {
409
+ const path = historyPath(root, slug);
410
+ if (path !== null) await fs.rm(path, { force: true });
411
+ }
412
+
413
+ /** Drop the logs of every note in a deleted folder. */
414
+ export async function removeHistoryFolder(root: string, folder: string): Promise<void> {
415
+ const path = folderPath(root, folder);
416
+ if (path !== null) await fs.rm(path, { recursive: true, force: true });
417
+ }
package/src/core/index.ts CHANGED
@@ -9,6 +9,7 @@ export { findGitRoot } from "./git";
9
9
  export {
10
10
  assessStaleness,
11
11
  buildRepoIndex,
12
+ ensureRepoIndex,
12
13
  readRepoIndex,
13
14
  summarizeIndex,
14
15
  writeRepoIndex,
@@ -92,6 +93,14 @@ export {
92
93
  type LinkFix,
93
94
  type UnresolvableLink,
94
95
  } from "./links/repair";
96
+ export {
97
+ historyEnabled,
98
+ readHistory,
99
+ setHistoryEnabled,
100
+ type HistoryAuthor,
101
+ type HistoryOp,
102
+ type NoteRevision,
103
+ } from "./history";
95
104
  export type { HtmlArtifact } from "./types";
96
105
  export { withMutationQueue } from "./mutex";
97
106
  export { formatDashboard, formatStatusLine, getWorkspaceStatus } from "./workspace";
@@ -102,6 +111,16 @@ export {
102
111
  type NoteActionInput,
103
112
  type NoteActionResult,
104
113
  } from "./noteAction";
114
+ export {
115
+ RecallTrail,
116
+ recallTrail,
117
+ TRAIL_CAPACITY,
118
+ TURN_GAP_MS,
119
+ type TrailEntry,
120
+ type TrailEvent,
121
+ type TrailKind,
122
+ type TrailSnapshot,
123
+ } from "./trail";
105
124
  export {
106
125
  executeRepoAction,
107
126
  WEAVE_REPO_DESCRIPTION,
@@ -42,15 +42,14 @@
42
42
 
43
43
  import { slugify } from "../slug";
44
44
  import type { HtmlArtifact, Note } from "../types";
45
+ import { fenceRanges, rawTailStart } from "../view/rawTail";
45
46
 
46
47
  /**
47
- * The append-only tail heading.
48
- *
49
- * Defined here rather than in `vault.ts` (which re-exports it, so its public
50
- * API is unchanged) because repair is the module that must *not* cross it,
51
- * and vault imports repair. One definition, no cycle.
48
+ * The tail helpers live in `../view/rawTail`, a node-free module the browser
49
+ * editor can reach through `src/web/shared/view.ts`. Re-exported here so
50
+ * repair's public surface is unchanged.
52
51
  */
53
- export const RAW_NOTES_HEADING = "## Raw";
52
+ export { RAW_NOTES_HEADING, fenceRanges, rawTailStart } from "../view/rawTail";
54
53
 
55
54
  /** One `[[wiki-link]]` found in a body, with enough context to rewrite it. */
56
55
  export interface LinkOccurrence {
@@ -85,68 +84,6 @@ export function normalizeTarget(raw: string): string {
85
84
  }
86
85
 
87
86
  const WIKILINK_RE = /\[\[([^\][|]+)(?:\|([^\]]*))?\]\]/g;
88
- const FENCE_RE = /^[ \t]*(`{3,}|~{3,})(.*)$/gm;
89
- /** A `## Raw` heading on a line of its own — not `## Rawhide`. */
90
- const RAW_HEADING_RE = new RegExp(`^${RAW_NOTES_HEADING}[ \\t]*\\r?$`, "m");
91
-
92
- /**
93
- * Half-open `[start, end)` ranges covering fenced code blocks.
94
- *
95
- * CommonMark, not "every third-backtick-line toggles": a fence is closed only
96
- * by the **same marker character**, at **at least the opening length**, and
97
- * with no info string. Treating fences as a blind alternation gets two cases
98
- * backwards — a `~~~` nested inside a ``` block would close it, and a ```
99
- * inside a ```` block would too — and each mistake ends the protected region
100
- * early, exposing example links in documentation to rewriting.
101
- */
102
- export function fenceRanges(body: string): [number, number][] {
103
- const ranges: [number, number][] = [];
104
- let open: { at: number; marker: string; length: number } | null = null;
105
- for (const match of body.matchAll(FENCE_RE)) {
106
- const run = match[1]!;
107
- // Group 2 is `(.*)`, which always participates: the info string.
108
- const info = match[2]!;
109
- if (open === null) {
110
- open = { at: match.index, marker: run[0]!, length: run.length };
111
- } else if (run[0] === open.marker && run.length >= open.length && info.trim() === "") {
112
- ranges.push([open.at, match.index + match[0].length]);
113
- open = null;
114
- }
115
- }
116
- // An unterminated fence runs to the end of the body, the way a renderer
117
- // treats it.
118
- if (open !== null) ranges.push([open.at, body.length]);
119
- return ranges;
120
- }
121
-
122
- /**
123
- * The byte offset where the `## Raw` tail begins, or the body length when
124
- * there is none.
125
- *
126
- * The tail is append-only and verbatim — "NEVER edit below this line" is
127
- * written into every note that has one. A link inside a user's dictation is
128
- * *their* text, quoted; repairing it would edit words the vault promises not
129
- * to touch.
130
- *
131
- * Fence-aware, and for the opposite reason to everything else here: a note
132
- * *documenting* the raw-tail convention contains `## Raw` inside a code
133
- * sample, and treating that as the real tail would silently protect — and so
134
- * refuse to repair — every link below it. Matching a whole heading line also
135
- * keeps `## Rawhide` from starting a tail.
136
- */
137
- export function rawTailStart(body: string, fences: readonly [number, number][]): number {
138
- let from = 0;
139
- for (;;) {
140
- const rest = body.slice(from);
141
- const hit = RAW_HEADING_RE.exec(rest);
142
- if (hit === null) return body.length;
143
- const at = from + hit.index;
144
- const fence = fences.find(([a, b]) => at >= a && at < b);
145
- if (fence === undefined) return at;
146
- from = fence[1]; // skip the whole fenced block and keep looking
147
- }
148
- }
149
-
150
87
  /**
151
88
  * Every repairable `[[wiki-link]]` in a body, in order.
152
89
  *
@@ -16,12 +16,13 @@ import {
16
16
  import { relatedNotes, suggestLinks, type RelatedNote, type SuggestionReport } from "./links/similar";
17
17
  import { NOTES_DIR, resolveVaultRoot } from "./paths";
18
18
  import { withMutationQueue } from "./mutex";
19
+ import { recallTrail, type RecallTrail, type TrailEntry } from "./trail";
19
20
  import type { Note, NoteSearchHit } from "./types";
20
21
 
21
22
  export const WEAVE_NOTE_DESCRIPTION =
22
23
  "Read and write notes in the pi-weave vault — a persistent, human-readable knowledge base " +
23
24
  "of Markdown notes. Actions: list (all notes — avoid on large vaults, prefer search), get (one note by slug), add (new note), " +
24
- "append (extend a note; raw=true appends verbatim dictation into the ## Raw tail), " +
25
+ "append (extend a note; raw=true appends verbatim dictation into the ## Raw tail; add with raw=true starts the note there), " +
25
26
  "finalize (restructure a note above its raw tail), search (ranked slug/title/tags/body matches plus linked, tagged, and lexically related notes; returns the full note when one result or one exact identity resolves), " +
26
27
  "links (audit stale [[wiki-links]]; fix=true repairs the unambiguous ones), " +
27
28
  "suggest (rank unlinked notes that share distinctive vocabulary; reports only, never writes). " +
@@ -186,10 +187,71 @@ function formatLinkReport(result: LinkRepairResult, applied: boolean): string {
186
187
  return lines.join("\n");
187
188
  }
188
189
 
190
+ /**
191
+ * Run one `weave_note` action and record what it touched on the recall trail
192
+ * (`./trail.ts`). Recording never fails the action: the trail is presentation,
193
+ * and a tool call must not break because a viewer would have liked to know.
194
+ */
189
195
  export async function executeNoteAction(
190
196
  params: NoteActionInput,
191
197
  vault = resolveVaultRoot(),
192
198
  now: () => Date = () => new Date(),
199
+ trail: RecallTrail | null = recallTrail,
200
+ ): Promise<NoteActionResult> {
201
+ const result = await runNoteAction(params, vault, now);
202
+ if (trail !== null) {
203
+ const at = now();
204
+ for (const step of trailSteps(params, result)) trail.record({ ...step, vault, at });
205
+ }
206
+ return result;
207
+ }
208
+
209
+ type TrailStep = Omit<TrailEntry, "vault" | "at">;
210
+
211
+ function slugOf(value: unknown): string | null {
212
+ return typeof value === "object" && value !== null && typeof (value as { slug?: unknown }).slug === "string"
213
+ ? (value as { slug: string }).slug
214
+ : null;
215
+ }
216
+
217
+ /** What an action's result says it touched. List and suggest touch nothing worth showing. */
218
+ export function trailSteps(params: NoteActionInput, result: NoteActionResult): TrailStep[] {
219
+ const { details } = result;
220
+ switch (params.action) {
221
+ case "get": {
222
+ const slug = slugOf(details.note);
223
+ return slug === null ? [] : [{ kind: "read", action: "get", slugs: [slug] }];
224
+ }
225
+ case "search": {
226
+ const hits = Array.isArray(details.hits) ? details.hits : [];
227
+ const slugs = hits.flatMap((hit: unknown) => {
228
+ const slug = slugOf((hit as { summary?: unknown }).summary);
229
+ return slug === null ? [] : [slug];
230
+ });
231
+ const steps: TrailStep[] = [{ kind: "searched", action: "search", slugs, query: params.query }];
232
+ const resolved = slugOf(details.resolved);
233
+ if (resolved !== null) steps.push({ kind: "read", action: "search", slugs: [resolved] });
234
+ return steps;
235
+ }
236
+ case "add":
237
+ case "append":
238
+ case "finalize": {
239
+ const slug = slugOf(details.note);
240
+ return slug === null ? [] : [{ kind: "wrote", action: params.action, slugs: [slug] }];
241
+ }
242
+ case "links": {
243
+ const notes = Array.isArray(details.notes) ? details.notes.filter((slug): slug is string => typeof slug === "string") : [];
244
+ return notes.length === 0 ? [] : [{ kind: "wrote", action: "links", slugs: notes }];
245
+ }
246
+ default:
247
+ return [];
248
+ }
249
+ }
250
+
251
+ async function runNoteAction(
252
+ params: NoteActionInput,
253
+ vault: string,
254
+ now: () => Date,
193
255
  ): Promise<NoteActionResult> {
194
256
  switch (params.action) {
195
257
  case "list": {
@@ -222,9 +284,17 @@ export async function executeNoteAction(
222
284
  body: params.text!,
223
285
  ...(params.tags ? { tags: params.tags } : {}),
224
286
  ...(params.source ? { source: params.source } : {}),
287
+ ...(params.raw ? { raw: true, now: now() } : {}),
288
+ // The agent wrote the file even when the words are the user's.
289
+ by: "agent",
225
290
  }),
226
291
  );
227
- return { text: `Note created: ${note.slug} (${vault})`, details: { action: "add", note } };
292
+ return {
293
+ text: params.raw
294
+ ? `Note created: ${note.slug} (${vault}), text preserved verbatim in its ## Raw tail.`
295
+ : `Note created: ${note.slug} (${vault})`,
296
+ details: { action: "add", note },
297
+ };
228
298
  }
229
299
 
230
300
  case "append": {
@@ -1,14 +1,9 @@
1
1
  /**
2
2
  * Open a vault note in the user's editor.
3
3
  *
4
- * Originally `src/pi/viewer/tui/openNote.ts`, promoted into core because the
5
- * browser workspace needs it too and `src/web/server/**` may not import
6
- * `src/pi` (weave-workspace §2). It belongs here on the merits regardless:
7
- * it is a pure fan-out over `core/vault` plus one `execFile`, with no
8
- * harness and no terminal concept in it, and the same reasoning that put
9
- * `readNoteForView` in core applies unchanged.
10
- *
11
- * The old path re-exports these two names, so no TUI file changed.
4
+ * Lives in core because the browser workspace's server uses it and
5
+ * `src/web/server/**` may not import `src/pi`. It is a pure fan-out over
6
+ * `core/vault` plus one `execFile`, with no harness in it.
12
7
  *
13
8
  * Security: the slug is validated against the vault (traversal-safe) before
14
9
  * any shell-out, and the command is invoked with `execFile`, not a shell, so