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.
- package/README.md +17 -16
- package/package.json +4 -3
- package/skills/weave-explore/SKILL.md +3 -5
- package/skills/weave-notepad/SKILL.md +30 -4
- package/skills/weave-notepad/references/second-thoughts.md +57 -0
- package/src/core/graph/build.ts +1 -1
- package/src/core/graph/current.ts +10 -8
- package/src/core/history.ts +417 -0
- package/src/core/index.ts +19 -0
- package/src/core/links/repair.ts +5 -68
- package/src/core/noteAction.ts +72 -2
- package/src/core/openInEditor.ts +3 -8
- package/src/core/repoAction.ts +20 -61
- package/src/core/repoIndex.ts +38 -0
- package/src/core/trail.ts +124 -0
- package/src/core/types.ts +1 -3
- package/src/core/vault.ts +116 -29
- package/src/core/view/focus.ts +2 -2
- package/src/core/view/index.ts +2 -3
- package/src/core/view/rawTail.ts +153 -0
- package/src/core/workspace.ts +20 -26
- package/src/core/workspaceCommands.ts +14 -14
- package/src/opencode/tools.ts +3 -3
- package/src/opencode/tui.ts +1 -7
- package/src/pi/index.ts +68 -80
- package/src/pi/tools/noteTool.ts +1 -1
- package/src/pi/tools/repoTool.ts +3 -6
- package/src/pi/viewer/web/run.ts +6 -8
- package/src/web/client/api.ts +103 -5
- package/src/web/client/dist/app.js +183 -52
- package/src/web/client/graph/Graph.tsx +23 -2
- package/src/web/client/graph/column.model.ts +1 -1
- package/src/web/client/graph/graph.model.ts +69 -7
- package/src/web/client/graph/renderer.ts +19 -3
- package/src/web/client/note/Note.tsx +171 -20
- package/src/web/client/note/Replay.tsx +128 -0
- package/src/web/client/note/drafts.ts +50 -9
- package/src/web/client/note/note.model.ts +17 -0
- package/src/web/client/note/replay.model.ts +173 -0
- package/src/web/client/shell/ContextRail.tsx +7 -0
- package/src/web/client/shell/Settings.tsx +21 -1
- package/src/web/client/shell/Shell.tsx +34 -10
- package/src/web/client/shell/keys.model.ts +20 -7
- package/src/web/client/shell/theme.ts +118 -1
- package/src/web/client/shell/workspace.css.ts +4 -0
- package/src/web/client/trail/TrailStrip.tsx +55 -0
- package/src/web/client/trail/trail.model.ts +195 -0
- package/src/web/client/trail/trail.ts +45 -0
- package/src/web/client/tree/Tree.tsx +7 -3
- package/src/web/client/tree/tree.model.ts +2 -5
- package/src/web/server/page.ts +1 -1
- package/src/web/server/routes.ts +83 -3
- package/src/web/shared/logo.ts +1 -1
- package/src/web/shared/view.ts +10 -4
- package/src/web/shared/wire.ts +74 -1
- package/src/core/view/health.ts +0 -160
- package/src/pi/viewer/tui/bodyStore.ts +0 -95
- package/src/pi/viewer/tui/branding.ts +0 -14
- package/src/pi/viewer/tui/model.ts +0 -390
- package/src/pi/viewer/tui/openNote.ts +0 -11
- package/src/pi/viewer/tui/run.ts +0 -71
- package/src/pi/viewer/tui/surface/base.ts +0 -203
- package/src/pi/viewer/tui/surface/detail.ts +0 -315
- package/src/pi/viewer/tui/surface/explore.ts +0 -241
- package/src/pi/viewer/tui/surface/focus.ts +0 -139
- package/src/pi/viewer/tui/surface/health.ts +0 -102
- package/src/pi/viewer/tui/theme.ts +0 -90
- package/src/pi/viewer/tui/workspace.ts +0 -53
- 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
|
|
53
|
-
*
|
|
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
|
*/
|
package/src/web/server/page.ts
CHANGED
|
@@ -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
|
|
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),
|
package/src/web/server/routes.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/web/shared/logo.ts
CHANGED
|
@@ -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.
|
|
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. */
|
package/src/web/shared/view.ts
CHANGED
|
@@ -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
|
-
* `
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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. */
|
package/src/web/shared/wire.ts
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
* contract.
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
|
-
import type { WireGraphModel, WireNoteSearchHit,
|
|
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. */
|
package/src/core/view/health.ts
DELETED
|
@@ -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
|
-
}
|