pi-weave 0.3.8 → 0.3.10

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 (80) hide show
  1. package/README.md +21 -17
  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/context/context.model.ts +5 -0
  31. package/src/web/client/dist/app.js +499 -129
  32. package/src/web/client/graph/Graph.tsx +166 -14
  33. package/src/web/client/graph/column.model.ts +59 -2
  34. package/src/web/client/graph/graph.model.ts +183 -7
  35. package/src/web/client/graph/renderer.ts +39 -9
  36. package/src/web/client/graph/spotlight.model.ts +472 -0
  37. package/src/web/client/note/Note.tsx +370 -66
  38. package/src/web/client/note/Replay.tsx +139 -0
  39. package/src/web/client/note/drafts.ts +115 -11
  40. package/src/web/client/note/editbar.model.ts +153 -0
  41. package/src/web/client/note/note.model.ts +82 -1
  42. package/src/web/client/note/replay.model.ts +248 -0
  43. package/src/web/client/search/SearchPalette.tsx +103 -34
  44. package/src/web/client/search/search.model.ts +533 -26
  45. package/src/web/client/shell/ContextRail.tsx +7 -0
  46. package/src/web/client/shell/Settings.tsx +21 -1
  47. package/src/web/client/shell/Shell.tsx +93 -27
  48. package/src/web/client/shell/StatusBar.tsx +1 -0
  49. package/src/web/client/shell/icons.model.ts +68 -1
  50. package/src/web/client/shell/keys.model.ts +31 -8
  51. package/src/web/client/shell/shell.model.ts +48 -1
  52. package/src/web/client/shell/theme.model.ts +3 -0
  53. package/src/web/client/shell/theme.ts +354 -32
  54. package/src/web/client/shell/workspace.css.ts +40 -9
  55. package/src/web/client/trail/TrailStrip.tsx +55 -0
  56. package/src/web/client/trail/trail.model.ts +195 -0
  57. package/src/web/client/trail/trail.ts +45 -0
  58. package/src/web/client/tree/Tree.tsx +7 -3
  59. package/src/web/client/tree/tree.model.ts +2 -5
  60. package/src/web/server/page.ts +1 -1
  61. package/src/web/server/routes.ts +83 -3
  62. package/src/web/shared/logo.ts +1 -1
  63. package/src/web/shared/themes.ts +1 -1
  64. package/src/web/shared/view.ts +10 -4
  65. package/src/web/shared/wire.ts +74 -1
  66. package/src/web/shared/workspace.ts +10 -2
  67. package/src/core/view/health.ts +0 -160
  68. package/src/pi/viewer/tui/bodyStore.ts +0 -95
  69. package/src/pi/viewer/tui/branding.ts +0 -14
  70. package/src/pi/viewer/tui/model.ts +0 -390
  71. package/src/pi/viewer/tui/openNote.ts +0 -11
  72. package/src/pi/viewer/tui/run.ts +0 -71
  73. package/src/pi/viewer/tui/surface/base.ts +0 -203
  74. package/src/pi/viewer/tui/surface/detail.ts +0 -315
  75. package/src/pi/viewer/tui/surface/explore.ts +0 -241
  76. package/src/pi/viewer/tui/surface/focus.ts +0 -139
  77. package/src/pi/viewer/tui/surface/health.ts +0 -102
  78. package/src/pi/viewer/tui/theme.ts +0 -90
  79. package/src/pi/viewer/tui/workspace.ts +0 -53
  80. package/src/pi/viewer/tui/workspaceRoot.ts +0 -389
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The recall trail, decided (issue #78).
3
+ *
4
+ * `GET /api/trail` lists the notes the agent searched, read and wrote. This
5
+ * module turns that list into the two things the workspace draws:
6
+ *
7
+ * - {@link trailMarks}: which nodes glow right now, how, and how strongly.
8
+ * The glow fades over {@link TRAIL_FADE_MS}; a replayed turn relights its
9
+ * steps one after another from the moment it was clicked.
10
+ * - {@link trailTurns}: the "This turn" strip — each turn's steps in order,
11
+ * worded as `searched "auth" → read Auth migration → appended Auth migration`.
12
+ *
13
+ * Pure, DOM-free and clock-free (every function takes `now`), like the other
14
+ * `*.model.ts` files, so the whole feature is covered by unit tests.
15
+ */
16
+
17
+ import type { GraphPayload, TrailPayload, WireTrailEvent, WireTrailKind } from "../../shared/wire";
18
+
19
+ /** How long a glow takes to fade out completely. */
20
+ export const TRAIL_FADE_MS = 60_000;
21
+
22
+ /** How often the fade repaints while something is glowing. */
23
+ export const TRAIL_TICK_MS = 1_000;
24
+
25
+ /** In a replay, the gap between one step lighting and the next. */
26
+ export const REPLAY_STEP_MS = 700;
27
+
28
+ /** How many turns the strip lists. */
29
+ export const TRAIL_TURNS_SHOWN = 5;
30
+
31
+ /** One glowing node. `strength` runs 1 (just happened) → 0 (faded). */
32
+ export interface TrailMark {
33
+ kind: WireTrailKind;
34
+ strength: number;
35
+ }
36
+
37
+ /** A replay in progress: which turn, and when it was clicked. */
38
+ export interface TrailReplay {
39
+ turn: number;
40
+ startedAt: number;
41
+ }
42
+
43
+ /** A write outranks a read, which outranks a search hit. */
44
+ const RANK: Readonly<Record<WireTrailKind, number>> = { searched: 0, read: 1, wrote: 2 };
45
+
46
+ const NO_MARKS: ReadonlyMap<string, TrailMark> = new Map();
47
+
48
+ /** When each event lights, or `null` if it is not part of what is shown. */
49
+ function lightTimes(trail: TrailPayload, replay: TrailReplay | null): Array<{ event: WireTrailEvent; at: number }> {
50
+ if (replay === null) return trail.events.map((event) => ({ event, at: Date.parse(event.at) }));
51
+ return trail.events
52
+ .filter((event) => event.turn === replay.turn)
53
+ .map((event, index) => ({ event, at: replay.startedAt + index * REPLAY_STEP_MS }));
54
+ }
55
+
56
+ /**
57
+ * Node id → glow. A node touched several ways in one turn shows the strongest
58
+ * kind (wrote > read > searched) at the freshest strength; a later turn's
59
+ * touch replaces an earlier turn's, so a note read now is not still shown as
60
+ * written a turn ago.
61
+ *
62
+ * Live, an event stamped slightly in the future (clock skew) glows at full
63
+ * strength; in a replay a step whose moment has not come yet stays dark.
64
+ */
65
+ export function trailMarks(trail: TrailPayload | null, now: number, replay: TrailReplay | null = null): ReadonlyMap<string, TrailMark> {
66
+ if (trail === null) return NO_MARKS;
67
+ const marks = new Map<string, TrailMark>();
68
+ const turnOf = new Map<string, number>();
69
+ for (const { event, at } of lightTimes(trail, replay)) {
70
+ const age = now - at;
71
+ if (replay !== null && age < 0) continue;
72
+ const strength = Math.min(1, 1 - age / TRAIL_FADE_MS);
73
+ if (!(strength > 0)) continue;
74
+ for (const slug of event.slugs) {
75
+ const id = `note:${slug}`;
76
+ // A newer turn's touch replaces an older turn's; within a turn the strongest kind wins.
77
+ const existing = turnOf.get(id) === event.turn ? marks.get(id) : undefined;
78
+ const kind = existing === undefined || RANK[event.kind] > RANK[existing.kind] ? event.kind : existing.kind;
79
+ marks.set(id, { kind, strength: Math.max(strength, existing?.strength ?? 0) });
80
+ turnOf.set(id, event.turn);
81
+ }
82
+ }
83
+ return marks.size === 0 ? NO_MARKS : marks;
84
+ }
85
+
86
+ /** Whether anything is still glowing or still to light — i.e. whether the fade clock should run. */
87
+ export function trailLive(trail: TrailPayload | null, now: number, replay: TrailReplay | null = null): boolean {
88
+ if (trail === null) return false;
89
+ return lightTimes(trail, replay).some(({ at }) => now - at < TRAIL_FADE_MS);
90
+ }
91
+
92
+ /** One note named in a step, ready to be a link. */
93
+ export interface TrailNoteView {
94
+ id: string;
95
+ label: string;
96
+ }
97
+
98
+ /** One step of a turn. */
99
+ export interface TrailStepView {
100
+ key: string;
101
+ kind: WireTrailKind;
102
+ /** `searched "auth"`, `read`, `appended`, … */
103
+ verb: string;
104
+ /** The notes to link. Empty for a search, which names its hit count in `detail` instead. */
105
+ notes: TrailNoteView[];
106
+ detail: string | null;
107
+ }
108
+
109
+ /** One turn in the strip, newest first. */
110
+ export interface TrailTurnView {
111
+ turn: number;
112
+ /** Local `HH:MM` of the turn's first step. */
113
+ time: string;
114
+ steps: TrailStepView[];
115
+ /** Distinct notes read or written. */
116
+ used: number;
117
+ }
118
+
119
+ const WROTE_VERB: Readonly<Record<string, string>> = {
120
+ add: "created",
121
+ append: "appended",
122
+ finalize: "finalized",
123
+ links: "fixed links in",
124
+ };
125
+
126
+ function stepView(event: WireTrailEvent, labels: ReadonlyMap<string, string>): TrailStepView {
127
+ const notes = event.slugs.map((slug) => ({ id: `note:${slug}`, label: labels.get(`note:${slug}`) ?? slug }));
128
+ if (event.kind === "searched") {
129
+ const hits = event.slugs.length;
130
+ return {
131
+ key: `${event.seq}:s`,
132
+ kind: "searched",
133
+ verb: event.query === undefined ? "searched" : `searched "${event.query}"`,
134
+ notes: [],
135
+ detail: hits === 0 ? "no hits" : `${hits} hit${hits === 1 ? "" : "s"}`,
136
+ };
137
+ }
138
+ return {
139
+ key: `${event.seq}:${event.kind}`,
140
+ kind: event.kind,
141
+ verb: event.kind === "read" ? "read" : WROTE_VERB[event.action] ?? "wrote",
142
+ notes,
143
+ detail: null,
144
+ };
145
+ }
146
+
147
+ function clock(iso: string, seconds = false): string {
148
+ const at = new Date(iso);
149
+ if (Number.isNaN(at.getTime())) return "";
150
+ const parts = [at.getHours(), at.getMinutes(), ...(seconds ? [at.getSeconds()] : [])];
151
+ return parts.map((part) => String(part).padStart(2, "0")).join(":");
152
+ }
153
+
154
+ /**
155
+ * The strip: the newest {@link TRAIL_TURNS_SHOWN} turns, each with its steps in order.
156
+ *
157
+ * When the harness has started a turn that has not touched a note yet (Pi's
158
+ * `agent_start` moves `trail.turn` ahead of the newest event), that empty
159
+ * turn leads the list, so the previous turn is never presented as current.
160
+ */
161
+ export function trailTurns(trail: TrailPayload | null, graph: GraphPayload | null): TrailTurnView[] {
162
+ if (trail === null || trail.events.length === 0) return [];
163
+ const labels = new Map<string, string>((graph?.model.nodes ?? []).map((node) => [node.id, node.label]));
164
+ const byTurn = new Map<number, WireTrailEvent[]>();
165
+ for (const event of trail.events) {
166
+ const events = byTurn.get(event.turn);
167
+ if (events === undefined) byTurn.set(event.turn, [event]);
168
+ else events.push(event);
169
+ }
170
+ const turns = [...byTurn.entries()].sort(([a], [b]) => b - a);
171
+ // Turns in the same minute would get identical chips, so those show seconds.
172
+ const minutes = turns.map(([, events]) => clock(events[0]!.at));
173
+ const views = turns.map(([turn, events], index) => {
174
+ const used = new Set(events.filter((event) => event.kind !== "searched").flatMap((event) => event.slugs));
175
+ const shared = minutes.filter((minute) => minute === minutes[index]).length > 1;
176
+ return { turn, time: clock(events[0]!.at, shared), steps: events.map((event) => stepView(event, labels)), used: used.size };
177
+ });
178
+ if (trail.turn > views[0]!.turn) views.unshift({ turn: trail.turn, time: "", steps: [], used: 0 });
179
+ return views.slice(0, TRAIL_TURNS_SHOWN);
180
+ }
181
+
182
+ /** The strip's heading for a turn: the latest is "This turn", older ones are their time. */
183
+ /** What the strip says for a turn with no steps yet. */
184
+ export const TRAIL_EMPTY_TURN = "Nothing recalled yet.";
185
+
186
+ export function turnHeading(view: TrailTurnView, latest: boolean): string {
187
+ return latest ? "This turn" : view.time === "" ? `Turn ${view.turn}` : view.time;
188
+ }
189
+
190
+ /** The tree row's class for a glow, or `""`. Strength is bucketed so the class list stays small. */
191
+ export function trailRowClass(mark: TrailMark | undefined): string {
192
+ if (mark === undefined) return "";
193
+ const level = mark.strength > 0.66 ? 3 : mark.strength > 0.33 ? 2 : 1;
194
+ return ` weave-row-trail weave-row-trail-${mark.kind} weave-row-trail-${level}`;
195
+ }
@@ -0,0 +1,45 @@
1
+ /** Polling `GET /api/trail`, alongside (not inside) the graph poll in `../workspace.ts`. */
2
+
3
+ import type { TrailPayload } from "../../shared/wire";
4
+ import type { FetchLike } from "../api";
5
+ import { fetchTrail } from "../api";
6
+ import { POLL_MS } from "../workspace";
7
+
8
+ export interface TrailPollOptions {
9
+ fetch: FetchLike;
10
+ onTrail: (trail: TrailPayload) => void;
11
+ repeat?: (fn: () => void, ms: number) => () => void;
12
+ }
13
+
14
+ /**
15
+ * Poll the trail every {@link POLL_MS}, revalidating on its stamp, and report
16
+ * only fresh payloads. A failed poll keeps what was there: the trail is an
17
+ * extra, and a blip must not blank it. Returns `stop`.
18
+ */
19
+ export function startTrailPoll(opts: TrailPollOptions): () => void {
20
+ let previous: TrailPayload | null = null;
21
+ let stopped = false;
22
+ let polling = false;
23
+ const poll = async (): Promise<void> => {
24
+ if (stopped || polling) return;
25
+ polling = true;
26
+ try {
27
+ const result = await fetchTrail(opts.fetch, previous);
28
+ if (stopped || !result.ok || result.cached) return;
29
+ previous = result.data;
30
+ opts.onTrail(result.data);
31
+ } finally {
32
+ polling = false;
33
+ }
34
+ };
35
+ const repeat = opts.repeat ?? ((fn, ms) => {
36
+ const timer = setInterval(fn, ms);
37
+ return () => clearInterval(timer);
38
+ });
39
+ const cancel = repeat(() => void poll(), POLL_MS);
40
+ void poll();
41
+ return () => {
42
+ stopped = true;
43
+ cancel();
44
+ };
45
+ }
@@ -1,5 +1,7 @@
1
1
  /** The vault and repository tree column. */
2
2
 
3
+ import type { TrailMark } from "../trail/trail.model";
4
+ import { trailRowClass } from "../trail/trail.model";
3
5
  import { NOTE_DRAG_TYPE } from "../shell/shell.model";
4
6
  import { useLayoutEffect, useRef, useState } from "preact/hooks";
5
7
  import { createFolder, deleteFolder, deleteNote, moveNote, renameFolder, renameNote } from "../api";
@@ -46,6 +48,8 @@ export interface TreeProps {
46
48
  graph: GraphPayload | null;
47
49
  selectedId: string | null;
48
50
  recentIds: ReadonlySet<string>;
51
+ /** Recall-trail glows by row id (`trail/trail.model.ts`). */
52
+ trail?: ReadonlyMap<string, TrailMark>;
49
53
  onSelect: (id: string, newTab?: boolean, keepSidebar?: boolean) => void;
50
54
  /**
51
55
  * Ask before a mutation that would invalidate an open draft. `false` means
@@ -76,13 +80,13 @@ export function Icon({ name, class: className }: { name: IconName; class?: strin
76
80
  );
77
81
  }
78
82
 
79
- function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMenu, onDrop }: { view: ReturnType<typeof rowViews>[number]; recentIds: ReadonlySet<string>; edit: string | null; onEdit: (value: string) => void; onRename: (value: string | null) => void; onSelect: (newTab: boolean) => void; onToggle: () => void; onMenu: (x: number, y: number) => void; onDrop: (id: string) => void }) {
83
+ function Row({ view, recentIds, trail, edit, onEdit, onRename, onSelect, onToggle, onMenu, onDrop }: { view: ReturnType<typeof rowViews>[number]; recentIds: ReadonlySet<string>; trail: TrailMark | undefined; edit: string | null; onEdit: (value: string) => void; onRename: (value: string | null) => void; onSelect: (newTab: boolean) => void; onToggle: () => void; onMenu: (x: number, y: number) => void; onDrop: (id: string) => void }) {
80
84
  const folder = dropFolder(view.id);
81
85
  return (
82
86
  <li
83
87
  id={view.domId}
84
88
  data-row-id={view.id}
85
- class={`weave-row weave-row-${view.kind}${view.selected ? " weave-row-on" : ""}${recentIds.has(view.id) ? " weave-row-new" : ""}`}
89
+ class={`weave-row weave-row-${view.kind}${view.selected ? " weave-row-on" : ""}${recentIds.has(view.id) ? " weave-row-new" : ""}${trailRowClass(trail)}`}
86
90
  role="treeitem"
87
91
  aria-level={view.level}
88
92
  aria-posinset={view.posinset}
@@ -297,7 +301,7 @@ export function Tree(props: TreeProps) {
297
301
  setMenu({ id: "vault", label: "Vault", x: event.clientX, y: event.clientY });
298
302
  }}
299
303
  >
300
- {rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={(newTab) => { props.onSelect(view.id, newTab); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) { const target = mutableTreeRow(dragged); if (target !== null && !props.onMutate(affectedNoteSlugs(props.graph, target))) return; void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); } }} />)}
304
+ {rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} trail={props.trail?.get(view.id)} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={(newTab) => { props.onSelect(view.id, newTab); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) { const target = mutableTreeRow(dragged); if (target !== null && !props.onMutate(affectedNoteSlugs(props.graph, target))) return; void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); } }} />)}
301
305
  </ul>
302
306
  ) : <p class="weave-tree-empty">{empty}</p>}
303
307
  <p class="weave-tree-count">{rowCountLabel(rows)}</p>
@@ -49,11 +49,8 @@ export type TreeViewState = TreeState;
49
49
  /**
50
50
  * The provenance filter cycle: all → human → agent → generated → all.
51
51
  *
52
- * The same order as the TUI's `p` key (`src/pi/viewer/tui/theme.ts`), and
53
- * declared again here because the client tier may not import `src/pi` and the
54
- * order is not part of `src/core/view`. That is a real, if small, duplication
55
- * — so it is one array with a test asserting its contents, rather than an
56
- * `if/else` chain in two places. If a fourth provenance is ever added, the
52
+ * The order is not part of `src/core/view`, so it is one array with a test
53
+ * asserting its contents, rather than an `if/else` chain in two places. If a fourth provenance is ever added, the
57
54
  * honest fix is to promote this into `src/core/view` and take it through the
58
55
  * door; today it is four literals and a promotion would be ceremony.
59
56
  */
@@ -243,7 +243,7 @@ export function renderPage(opts: RenderPageOptions): RenderedPage {
243
243
  "</head>",
244
244
  "<body>",
245
245
  `<div id="app" data-cwd="${escapeAttr(opts.bootstrap.cwd)}">`,
246
- "<noscript>pi-weave needs JavaScript. Use <code>/weave-view tui</code> instead.</noscript>",
246
+ "<noscript>pi-weave needs JavaScript to show the workspace.</noscript>",
247
247
  "</div>",
248
248
  `<script type="application/json" id="${escapeAttr(BOOTSTRAP_ELEMENT_ID)}" nonce="${escapeAttr(nonce)}">`,
249
249
  jsonScriptBody(opts.bootstrap),
@@ -6,6 +6,7 @@
6
6
  * | GET | `/` | the HTML shell, with its per-response nonce |
7
7
  * | GET | `/app.js` | the committed bundle, `Cache-Control: no-store` |
8
8
  * | GET | `/api/graph` | {@link GraphPayload}, ETag'd on `stamp` |
9
+ * | GET | `/api/trail` | {@link TrailPayload}, ETag'd on `stamp` |
9
10
  * | GET | `/api/note/:slug` | {@link NotePayload} |
10
11
  * | POST | `/api/note/:slug` | save a note's Markdown body |
11
12
  * | POST | `/api/notes` | create an empty human-authored vault note |
@@ -15,6 +16,9 @@
15
16
  * | POST | `/api/folder/:path` | create a vault folder |
16
17
  * | POST | `/api/folder/:path/rename` | rename a vault folder |
17
18
  * | DELETE | `/api/folder/:path` | delete a vault folder |
19
+ * | GET | `/api/history/:slug` | {@link HistoryWirePayload} |
20
+ * | GET | `/api/history-settings` | {@link HistorySettingsPayload} |
21
+ * | POST | `/api/history-settings` | turn note history on or off |
18
22
  * | GET | `/api/okf/:rel` | {@link OkfFilePayload} |
19
23
  * | GET | `/api/artifact/:rel` | sandboxed HTML artifact |
20
24
  * | GET | `/api/search?q=` | {@link SearchPayload} |
@@ -49,22 +53,28 @@ import { createReadStream } from "node:fs";
49
53
  import { pipeline } from "node:stream/promises";
50
54
  import { createVaultBackup } from "./backup";
51
55
  import { readFile } from "node:fs/promises";
56
+ import { resolve as resolvePath } from "node:path";
52
57
  import type { IncomingMessage, ServerResponse } from "node:http";
53
58
  import type { WorkspaceSnapshot } from "../../core/cache/workspace";
54
59
  import { WorkspaceCache } from "../../core/cache/workspace";
55
60
  import { readOkfFileForView } from "../../core/graph/current";
56
61
  import type { GraphModel as CoreGraphModel } from "../../core/graph/model";
62
+ import { encodeRevisions, historyEnabled, historyTrimmed, setHistoryEnabled } from "../../core/history";
57
63
  import { openNoteInEditor } from "../../core/openInEditor";
64
+ import { recallTrail, type RecallTrail } from "../../core/trail";
58
65
  import type { Note } from "../../core/types";
59
66
  import type { VaultMutationResult } from "../../core/vault";
60
- import { addNote, createFolder, deleteFolder, deleteNote, getNote, moveNote, renameFolder, renameNote, resolveHtmlPath, searchNotes, setNoteBody } from "../../core/vault";
67
+ import { addNote, createFolder, deleteFolder, deleteNote, getNote, moveNote, renameFolder, readNoteHistory, renameNote, resolveHtmlPath, searchNotes, setNoteBody } from "../../core/vault";
61
68
  import { deriveTagIndex, type TaggedNote } from "../../core/view/links";
62
69
  import type {
63
70
  GraphPayload,
71
+ HistoryWirePayload,
72
+ HistorySettingsPayload,
64
73
  NotePayload,
65
74
  OkfFilePayload,
66
75
  OpenResult,
67
76
  SearchPayload,
77
+ TrailPayload,
68
78
  ViewNote,
69
79
  } from "../shared/wire";
70
80
  import { WIRE_MODEL_OMITTED_KEYS } from "../shared/wire";
@@ -85,6 +95,8 @@ export interface RouteDeps {
85
95
  /** Test seam for `POST /api/open`; defaults to the real editor shell-out. */
86
96
  openNote?: ((slug: string) => Promise<boolean>) | undefined;
87
97
  workspaceState: WorkspaceStateStore;
98
+ /** The recall trail to serve. Defaults to the process-wide one the tools write. */
99
+ trail?: RecallTrail | undefined;
88
100
  }
89
101
 
90
102
  const JSON_TYPE = "application/json; charset=utf-8";
@@ -383,6 +395,7 @@ async function route(
383
395
  if (method === "GET" && path === "/") return sendShell(deps, res);
384
396
  if (method === "GET" && path === "/app.js") return sendBundle(deps, res);
385
397
  if (method === "GET" && path === "/api/graph") return sendGraph(deps, req, res);
398
+ if (method === "GET" && path === "/api/trail") return sendTrail(deps, req, res);
386
399
  if (method === "GET" && path === "/api/backup") {
387
400
  const backup = await createVaultBackup(deps.vaultRoot);
388
401
  try {
@@ -413,6 +426,8 @@ async function route(
413
426
  const handled = await routeNote(deps, method, path.slice("/api/note/".length), req, res);
414
427
  if (handled) return;
415
428
  }
429
+ if (method === "GET" && path.startsWith("/api/history/")) return sendHistory(deps, path.slice("/api/history/".length), res);
430
+ if (path === "/api/history-settings" && (method === "GET" || method === "POST")) return historySettings(deps, method, req, res);
416
431
  if (method === "GET" && path.startsWith("/api/okf/")) {
417
432
  return sendOkf(deps, path.slice("/api/okf/".length), res);
418
433
  }
@@ -558,6 +573,29 @@ async function sendGraph(deps: RouteDeps, req: IncomingMessage, res: ServerRespo
558
573
  res.end(body);
559
574
  }
560
575
 
576
+ /**
577
+ * `GET /api/trail`. The trail is process-wide, so it is filtered to this
578
+ * server's vault; the stamp is the buffer's own, which moves on every record.
579
+ */
580
+ function sendTrail(deps: RouteDeps, req: IncomingMessage, res: ServerResponse): void {
581
+ const snapshot = (deps.trail ?? recallTrail).snapshot();
582
+ const etag = `"${snapshot.stamp}"`;
583
+ const conditional = req.headers["if-none-match"];
584
+ if (matchesEtag(etag, typeof conditional === "string" ? conditional : undefined)) {
585
+ res.writeHead(304, { ...baseHeaders(), etag, "cache-control": "no-cache" });
586
+ res.end();
587
+ return;
588
+ }
589
+ const payload: TrailPayload = {
590
+ stamp: snapshot.stamp,
591
+ turn: snapshot.turn,
592
+ events: snapshot.events
593
+ .filter((event) => resolvePath(event.vault) === resolvePath(deps.vaultRoot))
594
+ .map(({ vault: _vault, ...event }) => event),
595
+ };
596
+ sendJson(res, 200, payload, { etag, "cache-control": "no-cache" });
597
+ }
598
+
561
599
  /**
562
600
  * `If-None-Match` matching, weak-comparison style.
563
601
  *
@@ -615,11 +653,17 @@ async function routeNote(
615
653
  sendJson(res, 400, { error: "expected JSON object" });
616
654
  return true;
617
655
  }
618
- const fields = body as { body?: unknown; name?: unknown; folder?: unknown };
656
+ const fields = body as { body?: unknown; raw?: unknown; name?: unknown; folder?: unknown };
619
657
  // The payload decides, not the suffix: slugs nest, so `archive/rename` is
620
658
  // a real note and only `{name}`/`{folder}` mean the action.
621
659
  if (typeof fields.body === "string") {
622
- sendMutation(res, await setNoteBody(deps.vaultRoot, target, fields.body));
660
+ // `raw: true` is the editor saying the person changed their own Raw
661
+ // words; without it the on-disk tail is kept, whatever the body says.
662
+ if (fields.raw !== undefined && typeof fields.raw !== "boolean") {
663
+ sendJson(res, 400, { error: "expected raw: boolean" });
664
+ return true;
665
+ }
666
+ sendMutation(res, await setNoteBody(deps.vaultRoot, target, fields.body, new Date(), "human", fields.raw === true ? "replace" : "keep"));
623
667
  return true;
624
668
  }
625
669
  if (action === null) {
@@ -700,6 +744,42 @@ function notePayload(note: Note): NotePayload {
700
744
  return { note: view };
701
745
  }
702
746
 
747
+ /**
748
+ * Every saved revision of a note. The note's current body is passed in so an
749
+ * edit made outside pi-weave since the last write shows as the final step.
750
+ */
751
+ async function sendHistory(deps: RouteDeps, slug: string, res: ServerResponse): Promise<void> {
752
+ const revisions = await readNoteHistory(deps.vaultRoot, slug);
753
+ if (revisions === null) {
754
+ sendJson(res, 404, { error: "no such note" });
755
+ return;
756
+ }
757
+ // Deltas, as on disk: full bodies for every revision would grow with the
758
+ // square of the note's length.
759
+ const trimmed = await historyTrimmed(deps.vaultRoot, slug);
760
+ const payload: HistoryWirePayload = {
761
+ slug,
762
+ enabled: await historyEnabled(deps.vaultRoot),
763
+ trimmed,
764
+ revisions: encodeRevisions(revisions, trimmed),
765
+ };
766
+ sendJson(res, 200, payload, { "cache-control": "no-store" });
767
+ }
768
+
769
+ async function historySettings(deps: RouteDeps, method: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
770
+ if (method === "POST") {
771
+ const body = await readJsonBody(req);
772
+ const enabled = typeof body === "object" && body !== null ? (body as { enabled?: unknown }).enabled : undefined;
773
+ if (typeof enabled !== "boolean") {
774
+ sendJson(res, 400, { error: "expected { enabled: boolean }" });
775
+ return;
776
+ }
777
+ await setHistoryEnabled(deps.vaultRoot, enabled);
778
+ }
779
+ const payload: HistorySettingsPayload = { enabled: await historyEnabled(deps.vaultRoot) };
780
+ sendJson(res, 200, payload, { "cache-control": "no-store" });
781
+ }
782
+
703
783
  async function sendOkf(deps: RouteDeps, rel: string, res: ServerResponse): Promise<void> {
704
784
  // Anchored under `<cwd>/.okf` by `readOkfFileForView`, which resolves and
705
785
  // then re-checks the prefix — the only correct order.
@@ -4,7 +4,7 @@
4
4
  * The web viewer is a single committed bundle behind a strict CSP
5
5
  * (img-src 'self' data:), so the brand mark is an inlined 48×48 PNG —
6
6
  * the transparent-background spider derived from docs/pi-weave-logo.png —
7
- * rather than a new static route. Same pattern as the TUI's branding.ts.
7
+ * rather than a new static route.
8
8
  */
9
9
  export const LOGO_MARK_MIME = "image/png";
10
10
  /** 48×48 transparent PNG, base64 — crisp at 2× for the 18px header mark. */
@@ -3,7 +3,7 @@
3
3
  * `light` and `dark` keep existing saved choices compatible.
4
4
  */
5
5
  const latte = {
6
- bg: "#eff1f5", panel: "#e6e9ef", page: "#dce0e8", raise: "#ccd0da",
6
+ bg: "#eff1f5", panel: "#e6e9ef", page: "#ffffff", raise: "#ccd0da",
7
7
  fg: "#4c4f69", dim: "#56586a", faint: "#606274", line: "#ccd0da", "line-strong": "#bcc0cc",
8
8
  accent: "#7113ec", ok: "#28641b", warn: "#7c4f10", bad: "#b20d30",
9
9
  };
@@ -61,10 +61,9 @@
61
61
  * ## Why this is a small list
62
62
  *
63
63
  * Deliberately not `export * from "../../core/view"`. The barrel pulls in
64
- * `health.ts` (a TUI surface) and `links.ts`'s tag index (derived server-side
65
- * into `GraphPayload.tags`), neither of which any browser column consumes
66
- * today — and each one it pulled in would be a module the purity guard then
67
- * has to defend forever.
64
+ * `links.ts`'s tag index (derived server-side into `GraphPayload.tags`),
65
+ * which no browser column consumes today — and each module it pulled in
66
+ * would be one the purity guard then has to defend forever.
68
67
  *
69
68
  * So this is an allowlist, on the same principle as the `npm` column in
70
69
  * `tests/web/tiers.test.ts`: the surface the columns actually use, and nothing
@@ -131,6 +130,13 @@ export type { TreeRow, TreeState } from "../../core/view/tree";
131
130
  */
132
131
  export { formatTreeMeta, relTime } from "../../core/view/time";
133
132
 
133
+ /**
134
+ * The note editor holds only the head of a body; the `## Raw` tail is shown
135
+ * read-only beneath it and rejoined on save. The vault splits with the same
136
+ * function, so the editor can never hide text the server would overwrite.
137
+ */
138
+ export { joinRawOpening, joinRawTail, splitRawOpening, splitRawTail } from "../../core/view/rawTail";
139
+
134
140
  // --- context rail (§1.2, P2) --------------------------------------------------
135
141
 
136
142
  /** Links, backlinks and the ordered meta block for the selected node. */
@@ -40,7 +40,7 @@
40
40
  * contract.
41
41
  */
42
42
 
43
- import type { WireGraphModel, WireNoteSearchHit, WireNoteSource, WireViewNote } from "./graph";
43
+ import type { WireGraphModel, WireNoteSearchHit, WireViewNote } from "./graph";
44
44
  import type { Point } from "./metrics";
45
45
 
46
46
  /**
@@ -140,10 +140,83 @@ export interface GraphPayload {
140
140
  }
141
141
 
142
142
  /** `GET /api/note/:slug`. */
143
+ /** How the agent touched a note — see `src/core/trail.ts`. */
144
+ export type WireTrailKind = "searched" | "read" | "wrote";
145
+
146
+ /** One recall-trail step. `at` is ISO; `slugs` may be empty for a search with no hits. */
147
+ export interface WireTrailEvent {
148
+ seq: number;
149
+ turn: number;
150
+ at: string;
151
+ kind: WireTrailKind;
152
+ action: string;
153
+ slugs: string[];
154
+ query?: string;
155
+ }
156
+
157
+ /**
158
+ * `GET /api/trail` — the notes the agent searched, read and wrote this
159
+ * session, oldest first, filtered to this workspace's vault. ETag'd on
160
+ * `stamp`, like the graph, so polling it is a `304` while the agent is idle.
161
+ */
162
+ export interface TrailPayload {
163
+ stamp: string;
164
+ turn: number;
165
+ events: WireTrailEvent[];
166
+ }
167
+
143
168
  export interface NotePayload {
144
169
  note: ViewNote;
145
170
  }
146
171
 
172
+ /** One saved revision of a note, body included. */
173
+ export interface HistoryRevision {
174
+ /** ISO-8601 time of the change. */
175
+ at: string;
176
+ /** Who made it: `outside` is any editor other than pi-weave. */
177
+ by: "human" | "agent" | "outside" | "system";
178
+ /** What kind of change: create, edit, append, raw, finalize, generate, repair, outside, baseline. */
179
+ op: string;
180
+ body: string;
181
+ }
182
+
183
+ /** A history payload as the client works with it: every body rebuilt. */
184
+ export interface HistoryPayload {
185
+ slug: string;
186
+ /** Whether new revisions are being recorded. */
187
+ enabled: boolean;
188
+ /** Older revisions were dropped to keep the note's history file small. */
189
+ trimmed: boolean;
190
+ revisions: HistoryRevision[];
191
+ }
192
+
193
+ /**
194
+ * One revision on the wire: a full body, or a delta against the one before
195
+ * (keep `keep[0]` characters from the start and `keep[1]` from the end, and
196
+ * put `text` between them). The first revision is always full.
197
+ */
198
+ export interface HistoryWireRevision {
199
+ at: string;
200
+ by: HistoryRevision["by"];
201
+ op: string;
202
+ full?: string;
203
+ keep?: [number, number];
204
+ text?: string;
205
+ }
206
+
207
+ /** `GET /api/history/:slug`. Oldest first; the last revision is the note as it is now. */
208
+ export interface HistoryWirePayload {
209
+ slug: string;
210
+ enabled: boolean;
211
+ trimmed: boolean;
212
+ revisions: HistoryWireRevision[];
213
+ }
214
+
215
+ /** `GET` and `POST /api/history-settings`. */
216
+ export interface HistorySettingsPayload {
217
+ enabled: boolean;
218
+ }
219
+
147
220
  /** `GET /api/okf/:rel`. */
148
221
  export interface OkfFilePayload {
149
222
  /** The requested path, relative to `<cwd>/.okf`. Echoed, never resolved. */
@@ -27,6 +27,11 @@ export interface WorkspaceLayout {
27
27
  readonly ratio: number;
28
28
  readonly treeVisible: boolean;
29
29
  readonly contextVisible: boolean;
30
+ /**
31
+ * Context as a docked column (true) or a card floating over the page's
32
+ * right margin (false, the default since the refresh).
33
+ */
34
+ readonly contextPinned: boolean;
30
35
  readonly treeWidth: number;
31
36
  readonly contextWidth: number;
32
37
  readonly contextGraphHeight: number;
@@ -50,6 +55,7 @@ export function initialLayout(): WorkspaceLayout {
50
55
  ratio: 0.5,
51
56
  treeVisible: true,
52
57
  contextVisible: true,
58
+ contextPinned: false,
53
59
  treeWidth: 240,
54
60
  contextWidth: 240,
55
61
  contextGraphHeight: 260,
@@ -246,11 +252,13 @@ function boundedId(value: unknown, prefix?: "pane" | "tab"): value is string {
246
252
  }
247
253
 
248
254
  export function parseWorkspaceLayout(value: unknown): WorkspaceLayout | null {
249
- if (!record(value) || !exactKeys(value, ["version", "panes", "activePane", "split", "ratio", "treeVisible", "contextVisible", "treeWidth", "contextWidth", "theme", ...["preferences", "contextGraphHeight", "contextTagsHeight"].filter(key => Object.hasOwn(value, key))])) return null;
255
+ if (!record(value) || !exactKeys(value, ["version", "panes", "activePane", "split", "ratio", "treeVisible", "contextVisible", "treeWidth", "contextWidth", "theme", ...["preferences", "contextGraphHeight", "contextTagsHeight", "contextPinned"].filter(key => Object.hasOwn(value, key))])) return null;
250
256
  if (value["version"] !== 1 || !Array.isArray(value["panes"]) || value["panes"].length < 1 || value["panes"].length > 2 || !boundedId(value["activePane"], "pane")) return null;
251
257
  if (value["split"] !== "right" && value["split"] !== "down") return null;
252
258
  if (typeof value["ratio"] !== "number" || !Number.isFinite(value["ratio"]) || value["ratio"] < 0.1 || value["ratio"] > 0.9) return null;
253
259
  if (typeof value["treeVisible"] !== "boolean" || typeof value["contextVisible"] !== "boolean") return null;
260
+ const contextPinned = Object.hasOwn(value, "contextPinned") ? value["contextPinned"] : false;
261
+ if (typeof contextPinned !== "boolean") return null;
254
262
  if (typeof value["treeWidth"] !== "number" || !Number.isFinite(value["treeWidth"]) || value["treeWidth"] < 120 || value["treeWidth"] > 800) return null;
255
263
  if (typeof value["contextWidth"] !== "number" || !Number.isFinite(value["contextWidth"]) || value["contextWidth"] < 120 || value["contextWidth"] > 800) return null;
256
264
  const contextGraphHeight = Object.hasOwn(value, "contextGraphHeight") ? value["contextGraphHeight"] : 260;
@@ -292,7 +300,7 @@ export function parseWorkspaceLayout(value: unknown): WorkspaceLayout | null {
292
300
  panes.push({ id: rawPane["id"], tabs, activeTab: rawPane["activeTab"], ...(typeof lastDocumentTab === "string" ? { lastDocumentTab } : {}) });
293
301
  }
294
302
  if (count > MAX_TABS || graphs > 1 || !panes.some((pane) => pane.id === value["activePane"])) return null;
295
- return { version: 1, panes, activePane: value["activePane"], split: value["split"], ratio: value["ratio"], treeVisible: value["treeVisible"], contextVisible: value["contextVisible"], treeWidth: value["treeWidth"], contextWidth: value["contextWidth"], contextGraphHeight, contextTagsHeight, theme: value["theme"], preferences };
303
+ return { version: 1, panes, activePane: value["activePane"], split: value["split"], ratio: value["ratio"], treeVisible: value["treeVisible"], contextVisible: value["contextVisible"], contextPinned, treeWidth: value["treeWidth"], contextWidth: value["contextWidth"], contextGraphHeight, contextTagsHeight, theme: value["theme"], preferences };
296
304
  }
297
305
 
298
306
  /** Startup choice resets navigation only; appearance and sidebar preferences survive. */