pi-weave 0.3.7 → 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 +30 -4
  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,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. */
@@ -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. */
@@ -1,160 +0,0 @@
1
- /**
2
- * healthModel — staleness + link health, derived exclusively from the
3
- * GraphModel (weave-workspace §3).
4
- *
5
- * Zero new server/core fields: everything here is a projection of nodes,
6
- * edges, and `model.staleness`.
7
- */
8
-
9
- import type { GraphModel, GraphNode } from "../graph/model";
10
- import { deriveBacklinks } from "./links";
11
- import { listLabel } from "./tree";
12
- import type { SelectableRow } from "./types";
13
-
14
- export interface HealthRow extends SelectableRow {
15
- text: string;
16
- }
17
-
18
- export interface HealthSection {
19
- heading: string;
20
- rows: HealthRow[];
21
- }
22
-
23
- export interface HealthModel {
24
- sections: HealthSection[];
25
- }
26
-
27
- const HEALTH_LIST_CAP = 10;
28
-
29
- export function healthModel(model: GraphModel): HealthModel {
30
- const byId = new Map<string, GraphNode>();
31
- for (const n of model.nodes) byId.set(n.id, n);
32
- const sections: HealthSection[] = [];
33
-
34
- // Repository section
35
- const repo = byId.get("repository");
36
- if (repo) {
37
- const rows: HealthRow[] = [];
38
- const staleness = model.staleness;
39
- if (staleness) {
40
- rows.push({ id: "health:repo:state", text: `state: ${staleness.state}` });
41
- for (let i = 0; i < staleness.reasons.length; i++) {
42
- rows.push({ id: `health:repo:reason:${i}`, text: ` ${staleness.reasons[i]}` });
43
- }
44
- }
45
- if (repo.detail.files) rows.push({ id: "health:repo:files", text: `files: ${repo.detail.files}` });
46
- if (repo.detail.languages) rows.push({ id: "health:repo:langs", text: `languages: ${repo.detail.languages}` });
47
- let summarized = 0;
48
- for (const n of model.nodes) {
49
- if (n.kind !== "module") continue;
50
- const count = n.detail["summarized files"];
51
- if (count) summarized += Number(count);
52
- }
53
- if (summarized > 0) {
54
- rows.push({ id: "health:repo:summarized", text: `summarized files: ${summarized} (run /weave-scan deep)` });
55
- }
56
- sections.push({ heading: "Repository", rows });
57
- }
58
-
59
- // Vault section
60
- const vault = byId.get("vault");
61
- if (vault) {
62
- const rows: HealthRow[] = [];
63
- const noteCount = Number(vault.detail.notes ?? "0");
64
- rows.push({ id: "health:vault:notes", text: `notes: ${noteCount}` });
65
- const prov = countProvenance(model.nodes);
66
- rows.push({
67
- id: "health:vault:provenance",
68
- text: `provenance: ● human ${prov.human} · ◐ agent ${prov.agent} · ○ generated ${prov.generated}`,
69
- });
70
- if (vault.detail.warning) {
71
- rows.push({ id: "health:vault:warning", text: vault.detail.warning });
72
- }
73
- sections.push({ heading: "Vault", rows });
74
- }
75
-
76
- // Link health
77
- const backlinks = deriveBacklinks(model.edges);
78
- const orphans: GraphNode[] = [];
79
- const dangling: { node: GraphNode; count: number }[] = [];
80
- for (const n of model.nodes) {
81
- if (n.kind !== "note") continue;
82
- if (!backlinks.has(n.id)) orphans.push(n);
83
- const dl = n.detail["dangling links"];
84
- if (dl && Number(dl) > 0) dangling.push({ node: n, count: Number(dl) });
85
- }
86
- const degree = new Map<string, number>();
87
- for (const e of model.edges) {
88
- degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
89
- degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
90
- }
91
- const hubs = [...model.nodes]
92
- .map((n) => ({ n, d: degree.get(n.id) ?? 0 }))
93
- .filter((x) => x.d > 0)
94
- .sort((a, b) => b.d - a.d)
95
- .slice(0, HEALTH_LIST_CAP);
96
-
97
- const linkRows: HealthRow[] = [];
98
- if (orphans.length > 0) {
99
- linkRows.push({ id: "health:link:orphans-h", text: `orphans (${orphans.length}):` });
100
- const shown = orphans.slice(0, HEALTH_LIST_CAP);
101
- for (let i = 0; i < shown.length; i++) {
102
- linkRows.push({ id: `health:link:orphan:${shown[i]!.id}`, text: ` ${listLabel(shown[i]!)}`, target: shown[i]!.id });
103
- }
104
- if (orphans.length > HEALTH_LIST_CAP) {
105
- linkRows.push({ id: "health:link:orphan:more", text: ` … and ${orphans.length - HEALTH_LIST_CAP} more` });
106
- }
107
- } else {
108
- linkRows.push({ id: "health:link:orphans-h", text: "orphans: none" });
109
- }
110
- if (dangling.length > 0) {
111
- linkRows.push({ id: "health:link:dangling-h", text: `dangling links (${dangling.length}):` });
112
- const shown = dangling.slice(0, HEALTH_LIST_CAP);
113
- for (let i = 0; i < shown.length; i++) {
114
- linkRows.push({
115
- id: `health:link:dangling:${shown[i]!.node.id}`,
116
- text: ` ${listLabel(shown[i]!.node)} (${shown[i]!.count})`,
117
- target: shown[i]!.node.id,
118
- });
119
- }
120
- if (dangling.length > HEALTH_LIST_CAP) {
121
- linkRows.push({ id: "health:link:dangling:more", text: ` … and ${dangling.length - HEALTH_LIST_CAP} more` });
122
- }
123
- // How many are *repairable* is not knowable from the graph: resolution
124
- // needs note bodies and titles, which the model flattens away. Point at
125
- // the tool that does know rather than re-deriving a worse answer here.
126
- linkRows.push({ id: "health:link:dangling:repair", text: " repair: weave_note action=links (fix: true to apply)" });
127
- }
128
- if (hubs.length > 0) {
129
- linkRows.push({ id: "health:link:hubs-h", text: `top hubs (by degree):` });
130
- for (let i = 0; i < hubs.length; i++) {
131
- linkRows.push({
132
- id: `health:link:hub:${hubs[i]!.n.id}`,
133
- text: ` ${listLabel(hubs[i]!.n)} (${hubs[i]!.d})`,
134
- target: hubs[i]!.n.id,
135
- });
136
- }
137
- }
138
- sections.push({ heading: "Link health", rows: linkRows });
139
-
140
- return { sections };
141
- }
142
-
143
- export interface ProvenanceCounts {
144
- total: number;
145
- human: number;
146
- agent: number;
147
- generated: number;
148
- structural: number;
149
- }
150
-
151
- export function countProvenance(nodes: readonly GraphNode[]): ProvenanceCounts {
152
- const c: ProvenanceCounts = { total: nodes.length, human: 0, agent: 0, generated: 0, structural: 0 };
153
- for (const n of nodes) {
154
- if (n.provenance === "human") c.human++;
155
- else if (n.provenance === "agent") c.agent++;
156
- else if (n.provenance === "generated") c.generated++;
157
- else c.structural++;
158
- }
159
- return c;
160
- }
@@ -1,95 +0,0 @@
1
- /**
2
- * BodyStore — shared note/.okf body cache (weave-view-tui-v2 §6, §9.1).
3
- *
4
- * Every pane (e.g. two Detail panes reading the same note) shares one fetch
5
- * per node id. A body load is kicked off once per id, in-flight loads are
6
- * deduped, and a refresh busts the cache so the next read re-fetches.
7
- *
8
- * The store is harness-free (takes injected loaders + an optional
9
- * onChange callback).
10
- */
11
-
12
- import type { ViewNote } from "../../../core/graph/current";
13
-
14
- /** The body loaders a BodyStore is bound to (bound to vault/cwd by run.ts). */
15
- export interface BodyLoaders {
16
- loadNote: (slug: string) => Promise<ViewNote | null>;
17
- loadOkf: (rel: string) => Promise<{ path: string; body: string } | null>;
18
- }
19
-
20
- export interface BodyStoreOptions {
21
- loaders: BodyLoaders;
22
- /** Invoked (no args) when an async load resolves so the owner can re-render. */
23
- onChange?: () => void;
24
- }
25
-
26
- /**
27
- * A cache+dedup keyed by node id. `get` never throws and never double-loads.
28
- *
29
- * - `null` body = the node exists but has no loadable body (or it loaded to
30
- * null); the pane renders nothing for it.
31
- * - `undefined` = not yet requested (the pane shows a placeholder and asks the
32
- * store to load, mirroring v1's `bodyLinesFor`).
33
- * - `isLoading(id)` = an in-flight request is outstanding (pane shows "loading").
34
- */
35
- export class BodyStore {
36
- private readonly loaders: BodyLoaders;
37
- private readonly onChange: (() => void) | undefined;
38
- private cache = new Map<string, string | null>();
39
- private loading = new Set<string>();
40
-
41
- constructor(opts: BodyStoreOptions) {
42
- this.loaders = opts.loaders;
43
- this.onChange = opts.onChange;
44
- }
45
-
46
- /** True when the id is mid-load. */
47
- isLoading(id: string): boolean {
48
- return this.loading.has(id);
49
- }
50
-
51
- /** Cached body for the id, or undefined when not yet requested. */
52
- get(id: string): string | null | undefined {
53
- return this.cache.get(id);
54
- }
55
-
56
- /** True once a body has been requested for the id (cached or failed). */
57
- has(id: string): boolean {
58
- return this.cache.has(id);
59
- }
60
-
61
- /**
62
- * Kick off a load for `id` if not already requested/in-flight. Returns true
63
- * when a load was started (so the caller can show a placeholder). The load
64
- * uses `kind` (note/file) to pick the loader and `ref` (slug/rel path).
65
- */
66
- load(id: string, kind: "note" | "file", ref: string | undefined): boolean {
67
- if (ref === undefined) return false;
68
- if (this.cache.has(id) || this.loading.has(id)) return false;
69
- this.loading.add(id);
70
- if (kind === "note") {
71
- void this.loaders
72
- .loadNote(ref)
73
- .then((note) => this.finish(id, note?.body ?? null))
74
- .catch(() => this.finish(id, null));
75
- } else {
76
- void this.loaders
77
- .loadOkf(ref)
78
- .then((file) => this.finish(id, file?.body ?? null))
79
- .catch(() => this.finish(id, null));
80
- }
81
- return true;
82
- }
83
-
84
- /** Drop all cached bodies and in-flight markers (the `r` refresh). */
85
- clear(): void {
86
- this.cache.clear();
87
- this.loading.clear();
88
- }
89
-
90
- private finish(id: string, body: string | null): void {
91
- this.cache.set(id, body);
92
- this.loading.delete(id);
93
- this.onChange?.();
94
- }
95
- }