pi-weave 0.1.12 → 0.1.13

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 (71) hide show
  1. package/README.md +8 -37
  2. package/package.json +1 -2
  3. package/src/core/concurrency.ts +3 -6
  4. package/src/core/frontmatter.ts +0 -53
  5. package/src/core/graph/build.ts +6 -7
  6. package/src/core/graph/current.ts +2 -4
  7. package/src/core/graph/model.ts +1 -1
  8. package/src/core/graph/wikilinks.ts +3 -3
  9. package/src/core/index.ts +26 -27
  10. package/src/core/paths.ts +0 -7
  11. package/src/core/vault.ts +16 -681
  12. package/src/core/view/detail.ts +1 -1
  13. package/src/core/view/health.ts +1 -1
  14. package/src/core/view/tree.ts +1 -1
  15. package/src/pi/index.ts +6 -85
  16. package/src/pi/summarize.ts +2 -2
  17. package/src/pi/viewer/tui/bodyStore.ts +4 -7
  18. package/src/pi/viewer/tui/branding.ts +7 -148
  19. package/src/pi/viewer/tui/run.ts +3 -17
  20. package/src/pi/viewer/tui/surface/base.ts +24 -3
  21. package/src/pi/viewer/tui/surface/explore.ts +41 -6
  22. package/src/pi/viewer/tui/workspace.ts +23 -351
  23. package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
  24. package/src/pi/viewer/web/run.ts +7 -117
  25. package/src/web/client/api.dom.ts +2 -2
  26. package/src/web/client/api.ts +14 -223
  27. package/src/web/client/bootstrap.ts +5 -14
  28. package/src/web/client/context/context.model.ts +9 -11
  29. package/src/web/client/dist/app.js +93 -219
  30. package/src/web/client/graph/dynamics.ts +5 -65
  31. package/src/web/client/graph/renderer.dom.ts +7 -8
  32. package/src/web/client/graph/renderer.ts +9 -35
  33. package/src/web/client/main.tsx +1 -1
  34. package/src/web/client/note/Note.tsx +21 -63
  35. package/src/web/client/search/SearchPalette.tsx +45 -36
  36. package/src/web/client/search/search.model.ts +33 -454
  37. package/src/web/client/shell/Columns.tsx +13 -83
  38. package/src/web/client/shell/Header.tsx +2 -10
  39. package/src/web/client/shell/Shell.tsx +50 -125
  40. package/src/web/client/shell/StatusBar.tsx +1 -4
  41. package/src/web/client/shell/icons.model.ts +4 -7
  42. package/src/web/client/shell/keys.model.ts +5 -42
  43. package/src/web/client/shell/keys.ts +2 -2
  44. package/src/web/client/shell/shell.model.ts +10 -133
  45. package/src/web/client/shell/theme.model.ts +2 -2
  46. package/src/web/client/shell/theme.ts +33 -157
  47. package/src/web/client/state.ts +9 -89
  48. package/src/web/client/tree/Tree.tsx +25 -575
  49. package/src/web/client/tree/tree.model.ts +8 -162
  50. package/src/web/client/workspace.ts +72 -242
  51. package/src/web/server/page.ts +8 -10
  52. package/src/web/server/routes.ts +30 -563
  53. package/src/web/server/server.ts +6 -145
  54. package/src/web/shared/layout.ts +72 -624
  55. package/src/web/shared/wire.ts +10 -196
  56. package/src/core/sessions.ts +0 -929
  57. package/src/pi/sessionScan.ts +0 -104
  58. package/src/pi/viewer/tui/explorer.ts +0 -586
  59. package/src/web/client/live.model.ts +0 -275
  60. package/src/web/client/live.ts +0 -151
  61. package/src/web/client/note/Editor.tsx +0 -109
  62. package/src/web/client/note/editor.controller.ts +0 -151
  63. package/src/web/client/note/editor.model.ts +0 -686
  64. package/src/web/client/search/search.ts +0 -107
  65. package/src/web/client/shell/Divider.tsx +0 -44
  66. package/src/web/client/shell/cssvars.ts +0 -70
  67. package/src/web/client/shell/drag.model.ts +0 -170
  68. package/src/web/client/shell/layout.model.ts +0 -500
  69. package/src/web/client/shell/viewport.ts +0 -29
  70. package/src/web/server/sse.ts +0 -321
  71. package/src/web/server/watcher.ts +0 -507
@@ -1,321 +0,0 @@
1
- /**
2
- * The SSE hub — the "tell the browser" half of liveness (weave-workspace §6).
3
- *
4
- * A hand-rolled `text/event-stream` over `node:http`. No npm package: the
5
- * server tier's allowlist is empty (§2), and the wire format is four lines of
6
- * `res.write`.
7
- *
8
- * ## Why `EventSource` and not a WebSocket
9
- *
10
- * The traffic is one-directional and tiny — `{scope, stamp}`, at human edit
11
- * rates. `EventSource` reconnects natively, survives a server restart without
12
- * client code, and rides the `__Host-weave` cookie automatically, which
13
- * matters because `EventSource` cannot set request headers (§5.1). A
14
- * WebSocket would need a framing library, a reconnect loop, and its own
15
- * authentication path to buy nothing.
16
- *
17
- * ## Reconnect: refetch-everything, not `Last-Event-ID`
18
- *
19
- * §6 offers a choice and this hub takes the simpler branch, deliberately.
20
- * Frames still carry `id:` (the stamp), because it costs one line and gives
21
- * the browser a `lastEventId` worth logging — but **the server keeps no
22
- * replay buffer and ignores `Last-Event-ID` on reconnect**. The client
23
- * refetches everything when the stream reopens, which is the behaviour §6
24
- * describes.
25
- *
26
- * A replay buffer would be a *second* source of truth about what changed, and
27
- * a strictly worse one: frames are coalesced hints, not deltas (§6), so
28
- * replaying the three frames a client missed is no more informative than the
29
- * client simply noticing it reconnected. What it actually needs after any gap
30
- * is one `If-None-Match` refetch of `/api/graph` — a 304 when nothing moved,
31
- * and correct whether it missed one frame or a thousand. Buffering would add
32
- * per-client retention, an eviction policy and a resume path to arrive at the
33
- * same fetch.
34
- *
35
- * ## Dedupe
36
- *
37
- * A client that already holds the current stamp is not sent it again. The
38
- * watcher's debounce can still emit two frames for one logical edit (a save
39
- * that lands either side of the 80 ms window), and the stamp is a **content
40
- * digest** of the graph payload (§5.3, §15.6), so an identical stamp provably
41
- * means identical content. Dedupe is **per client**, not global: a client that
42
- * connected after the last broadcast has not seen it.
43
- *
44
- * The digest replaced `generatedAt` here for a reason this hub cannot see but
45
- * depends on: a timestamp max is unchanged by an edit that does not advance
46
- * it, so dropping such a frame as a duplicate silently withheld a real change
47
- * from the client. The frames are only as trustworthy as the key they dedupe
48
- * on.
49
- */
50
-
51
- import type { IncomingMessage, ServerResponse } from "node:http";
52
- import { CHANGE_EVENT_NAME, type ChangeEvent } from "../shared/wire";
53
-
54
- /** §6: a comment frame every 20 s to defeat proxy buffering. */
55
- export const DEFAULT_HEARTBEAT_MS = 20_000;
56
-
57
- /**
58
- * The heartbeat is an SSE comment: a line starting with `:` that carries no
59
- * event and is discarded by every conforming client. It exists to push bytes
60
- * through an idle connection so that (a) an intermediary does not buffer or
61
- * reap the stream, and (b) a socket whose peer vanished without a FIN fails
62
- * its write and is reaped here.
63
- */
64
- const HEARTBEAT_FRAME = ":ping\n\n";
65
-
66
- /** Recurring-timer injection, so tests never wait 20 s. Mirrors the watcher's. */
67
- export interface IntervalScheduler {
68
- /** Run `fn` every `ms`; the returned closure cancels it. */
69
- repeat(fn: () => void, ms: number): () => void;
70
- }
71
-
72
- /** `setInterval`, `unref`'d so a live stream cannot outlive the pi session. */
73
- export const realIntervalScheduler: IntervalScheduler = {
74
- repeat(fn, ms) {
75
- const handle = setInterval(fn, ms);
76
- handle.unref?.();
77
- return () => clearInterval(handle);
78
- },
79
- };
80
-
81
- /**
82
- * The half of `ServerResponse` this hub uses.
83
- *
84
- * Narrower than `ServerResponse` on purpose: it documents the entire contract
85
- * ("headers, writes, an end, and two lifecycle events") and lets a test pass a
86
- * recording double without constructing a socket. A real `ServerResponse`
87
- * satisfies it structurally.
88
- */
89
- export interface SseSink {
90
- writeHead(status: number, headers: Record<string, string>): unknown;
91
- flushHeaders?: () => void;
92
- write(chunk: string): boolean;
93
- end(): unknown;
94
- on(event: "close" | "error", listener: () => void): unknown;
95
- readonly writableEnded?: boolean;
96
- }
97
-
98
- /**
99
- * The response headers, as a table so the route test can assert them
100
- * literally.
101
- *
102
- * | Header | Why |
103
- * | --- | --- |
104
- * | `Content-Type: text/event-stream` | The format. Without it `EventSource` rejects the response outright. |
105
- * | `Cache-Control: no-cache, no-transform` | `no-transform` additionally forbids a proxy from gzipping, which would introduce its own buffer. |
106
- * | `Connection: keep-alive` | HTTP/1.1 explicitness; harmless under HTTP/2. |
107
- * | `X-Accel-Buffering: no` | nginx-specific, and the one that actually matters in practice: without it nginx buffers the stream and every event arrives in a batch when the connection closes. |
108
- */
109
- export const SSE_HEADERS: Readonly<Record<string, string>> = {
110
- "Content-Type": "text/event-stream; charset=utf-8",
111
- "Cache-Control": "no-cache, no-transform",
112
- Connection: "keep-alive",
113
- "X-Accel-Buffering": "no",
114
- };
115
-
116
- /**
117
- * Serialize one event.
118
- *
119
- * `id:` carries the stamp so the browser exposes `lastEventId` (useful in a
120
- * log, and the seam if we ever *do* want replay); the server itself keeps no
121
- * history — see the module header. `retry:` is omitted, leaving the browser's
122
- * own backoff in charge, which is the behaviour §6 describes.
123
- */
124
- export function formatFrame(event: ChangeEvent): string {
125
- return `id: ${event.stamp}\nevent: ${CHANGE_EVENT_NAME}\ndata: ${JSON.stringify(event)}\n\n`;
126
- }
127
-
128
- export interface SseHubOptions {
129
- /**
130
- * The stamp a newly attached client should be told about, if any.
131
- *
132
- * This is what replaces a replay buffer: rather than reconstructing what a
133
- * reconnecting client missed, hand it the current stamp and let its
134
- * `If-None-Match` refetch decide whether anything actually moved. Returning
135
- * `null` (no graph built yet) simply sends nothing.
136
- */
137
- currentStamp?: () => string | null;
138
- heartbeatMs?: number;
139
- scheduler?: IntervalScheduler;
140
- }
141
-
142
- /** One attached browser. */
143
- interface Client {
144
- sink: SseSink;
145
- /** Last stamp written to this client; `null` before its first frame. */
146
- lastStamp: string | null;
147
- /** Guards against `close` and `error` both firing for the same socket. */
148
- detached: boolean;
149
- }
150
-
151
- /**
152
- * The hub. Owns every open stream and exactly one heartbeat timer.
153
- *
154
- * One timer for all clients rather than one per client: the heartbeat is a
155
- * liveness probe, not a per-connection schedule, and N timers would be N
156
- * things to leak. It runs only while at least one client is attached, so an
157
- * idle server holds no timers at all — which is what lets the process exit
158
- * cleanly when the pi session ends.
159
- */
160
- export class SseHub {
161
- private readonly clients = new Set<Client>();
162
- private readonly currentStamp: (() => string | null) | undefined;
163
- private readonly heartbeatMs: number;
164
- private readonly scheduler: IntervalScheduler;
165
-
166
- private cancelHeartbeat: (() => void) | null = null;
167
- private closed = false;
168
-
169
- constructor(opts: SseHubOptions = {}) {
170
- this.currentStamp = opts.currentStamp;
171
- this.heartbeatMs = opts.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
172
- this.scheduler = opts.scheduler ?? realIntervalScheduler;
173
- }
174
-
175
- /**
176
- * Adopt a request as an SSE stream.
177
- *
178
- * `req` is unused beyond its lifecycle: authentication happened in
179
- * `security.ts` before the route dispatched here, and `Last-Event-ID` is
180
- * deliberately ignored (module header). It stays in the signature because
181
- * that is the shape a route handler has, and because a future replay
182
- * implementation would need it.
183
- *
184
- * After {@link close}, a late request is answered `503` rather than left
185
- * hanging — a browser retrying into a shutting-down server should learn
186
- * that immediately.
187
- */
188
- attach(req: IncomingMessage, res: SseSink): void {
189
- if (this.closed) {
190
- res.writeHead(503, { "Content-Type": "text/plain; charset=utf-8" });
191
- res.end();
192
- return;
193
- }
194
- res.writeHead(200, { ...SSE_HEADERS });
195
- // Without this the first frame can sit in Node's header buffer until
196
- // enough body accumulates, so `EventSource` stays in CONNECTING and the
197
- // client's status bar lies.
198
- res.flushHeaders?.();
199
-
200
- const client: Client = { sink: res, lastStamp: null, detached: false };
201
- this.clients.add(client);
202
-
203
- const detach = (): void => this.detach(client);
204
- res.on("close", detach);
205
- res.on("error", detach);
206
- void req;
207
-
208
- this.startHeartbeat();
209
-
210
- // The reconnect strategy in one line: tell the fresh client where we are
211
- // and let its conditional refetch work out whether that means anything.
212
- const stamp = this.currentStamp?.() ?? null;
213
- if (stamp !== null) this.send(client, { scope: "vault", stamp });
214
- }
215
-
216
- /** Fan out one event, skipping clients that already hold its stamp. */
217
- broadcast(event: ChangeEvent): void {
218
- if (this.closed) return;
219
- for (const client of [...this.clients]) {
220
- if (client.lastStamp === event.stamp) continue;
221
- this.send(client, event);
222
- }
223
- }
224
-
225
- /** Attached client count. `server.ts`'s idle shutdown reads this. */
226
- clientCount(): number {
227
- return this.clients.size;
228
- }
229
-
230
- /**
231
- * End every stream and stop the heartbeat. Idempotent.
232
- *
233
- * Ending rather than destroying: the browser sees a clean EOF and applies
234
- * its normal reconnect backoff, which is right for a restart and harmless
235
- * for a shutdown (the port is gone and the retry fails fast). A stream left
236
- * open would keep its socket — and, without the `unref` above, the whole
237
- * process — alive after the pi session exited.
238
- */
239
- close(): void {
240
- if (this.closed) return;
241
- this.closed = true;
242
- for (const client of [...this.clients]) {
243
- client.detached = true;
244
- endQuietly(client.sink);
245
- }
246
- this.clients.clear();
247
- this.stopHeartbeat();
248
- }
249
-
250
- // --- internals --------------------------------------------------------------
251
-
252
- /**
253
- * Write one frame, recording the stamp so the next broadcast can dedupe.
254
- *
255
- * A failed write means the peer is gone in a way `close` has not reported
256
- * yet, so the client is dropped rather than retried — the alternative is
257
- * writing into a dead socket every 20 s forever.
258
- */
259
- private send(client: Client, event: ChangeEvent): void {
260
- if (writeQuietly(client.sink, formatFrame(event))) {
261
- client.lastStamp = event.stamp;
262
- } else {
263
- this.detach(client);
264
- }
265
- }
266
-
267
- private detach(client: Client): void {
268
- if (client.detached) return;
269
- client.detached = true;
270
- this.clients.delete(client);
271
- if (this.clients.size === 0) this.stopHeartbeat();
272
- }
273
-
274
- private startHeartbeat(): void {
275
- if (this.cancelHeartbeat !== null) return;
276
- this.cancelHeartbeat = this.scheduler.repeat(() => this.beat(), this.heartbeatMs);
277
- }
278
-
279
- private stopHeartbeat(): void {
280
- this.cancelHeartbeat?.();
281
- this.cancelHeartbeat = null;
282
- }
283
-
284
- /** One heartbeat round; also the reaper for sockets that died silently. */
285
- private beat(): void {
286
- for (const client of [...this.clients]) {
287
- if (!writeQuietly(client.sink, HEARTBEAT_FRAME)) this.detach(client);
288
- }
289
- }
290
- }
291
-
292
- /** `write`, reporting failure as `false` instead of throwing. */
293
- function writeQuietly(sink: SseSink, chunk: string): boolean {
294
- if (sink.writableEnded === true) return false;
295
- try {
296
- // `write` returning false is backpressure, not failure — these frames are
297
- // tens of bytes, so the buffer absorbing them is a success.
298
- sink.write(chunk);
299
- return true;
300
- } catch {
301
- return false;
302
- }
303
- }
304
-
305
- function endQuietly(sink: SseSink): void {
306
- try {
307
- sink.end();
308
- } catch {
309
- // The socket died between the liveness check and the write. Shutdown must
310
- // not fail because a client left first.
311
- }
312
- }
313
-
314
- /**
315
- * Structural check that a real `ServerResponse` satisfies {@link SseSink}.
316
- *
317
- * Purely a compile-time assertion: if `SseSink` ever drifts from the API
318
- * `node:http` actually offers, `npm run typecheck` fails here rather than at
319
- * the call site in `server.ts`, which is owned by someone else.
320
- */
321
- export type SseSinkIsServerResponse = ServerResponse extends SseSink ? true : never;