visualproof 0.0.0-stage → 0.2.0

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 (146) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -2
  3. package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
  4. package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
  5. package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
  6. package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
  7. package/dist/assets/latex/visualproof.sty +308 -0
  8. package/dist/assets/webfonts/LICENSE-Jost.txt +93 -0
  9. package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
  10. package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
  11. package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
  12. package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
  13. package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
  14. package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
  15. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
  16. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
  17. package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  18. package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  19. package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
  20. package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
  21. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
  22. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
  23. package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
  24. package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -0
  25. package/dist/extractor/Extract.lean +1180 -0
  26. package/dist/extractor/Usage.lean +900 -0
  27. package/dist/mcp/visualproof-mcp.mjs +476 -0
  28. package/dist/server/access.js +95 -0
  29. package/dist/server/agent.js +3270 -0
  30. package/dist/server/agentdocs.js +35 -0
  31. package/dist/server/agentlibrary.js +202 -0
  32. package/dist/server/agentnotes.js +701 -0
  33. package/dist/server/agentviews.js +703 -0
  34. package/dist/server/askfollow.js +286 -0
  35. package/dist/server/boardspots.js +159 -0
  36. package/dist/server/dirs.js +175 -0
  37. package/dist/server/doctor.js +186 -0
  38. package/dist/server/events.js +198 -0
  39. package/dist/server/export.js +420 -0
  40. package/dist/server/extract.js +204 -0
  41. package/dist/server/fetchurl.js +321 -0
  42. package/dist/server/figures.js +158 -0
  43. package/dist/server/history.js +780 -0
  44. package/dist/server/historyapi.js +304 -0
  45. package/dist/server/hover.js +595 -0
  46. package/dist/server/identity.js +114 -0
  47. package/dist/server/invites.js +241 -0
  48. package/dist/server/layoutthread.js +51 -0
  49. package/dist/server/library.js +600 -0
  50. package/dist/server/libraryroute.js +211 -0
  51. package/dist/server/live.js +1101 -0
  52. package/dist/server/lostfound.js +199 -0
  53. package/dist/server/main.js +1406 -0
  54. package/dist/server/mcptools.js +496 -0
  55. package/dist/server/pastdoc.js +73 -0
  56. package/dist/server/pipeline.js +159 -0
  57. package/dist/server/poppler.js +251 -0
  58. package/dist/server/presence.js +252 -0
  59. package/dist/server/project.js +222 -0
  60. package/dist/server/prompt.js +53 -0
  61. package/dist/server/readonly.js +152 -0
  62. package/dist/server/readonlygate.js +123 -0
  63. package/dist/server/renames.js +268 -0
  64. package/dist/server/review.js +334 -0
  65. package/dist/server/reviewapi.js +188 -0
  66. package/dist/server/snapshot.js +50 -0
  67. package/dist/server/sourcestore.js +473 -0
  68. package/dist/server/status.js +360 -0
  69. package/dist/server/talk.js +476 -0
  70. package/dist/server/tokens.js +221 -0
  71. package/dist/server/usage.js +501 -0
  72. package/dist/server/versions.js +82 -0
  73. package/dist/server/view.js +164 -0
  74. package/dist/server/views.js +1167 -0
  75. package/dist/shared/askfollow.js +46 -0
  76. package/dist/shared/board.js +332 -0
  77. package/dist/shared/build.js +2 -0
  78. package/dist/shared/collab.js +241 -0
  79. package/dist/shared/comment.js +285 -0
  80. package/dist/shared/context.js +157 -0
  81. package/dist/shared/doc/authors.js +98 -0
  82. package/dist/shared/doc/build.js +358 -0
  83. package/dist/shared/doc/latex.js +455 -0
  84. package/dist/shared/doc/markdown.js +159 -0
  85. package/dist/shared/doc/model.js +37 -0
  86. package/dist/shared/doc/order.js +124 -0
  87. package/dist/shared/doc/outline.js +45 -0
  88. package/dist/shared/doc/proof.js +272 -0
  89. package/dist/shared/doc/sections.js +93 -0
  90. package/dist/shared/doc/settings.js +75 -0
  91. package/dist/shared/edgecodec.js +102 -0
  92. package/dist/shared/editor.js +64 -0
  93. package/dist/shared/excerpt.js +87 -0
  94. package/dist/shared/graph.js +16 -0
  95. package/dist/shared/items.js +92 -0
  96. package/dist/shared/keys.js +203 -0
  97. package/dist/shared/layout.js +926 -0
  98. package/dist/shared/lexer.js +130 -0
  99. package/dist/shared/library.js +711 -0
  100. package/dist/shared/lostfound.js +150 -0
  101. package/dist/shared/moves.js +244 -0
  102. package/dist/shared/notecard.js +295 -0
  103. package/dist/shared/noteplace.js +505 -0
  104. package/dist/shared/palette.js +46 -0
  105. package/dist/shared/pastgraph.js +103 -0
  106. package/dist/shared/places.js +80 -0
  107. package/dist/shared/projectnotes.js +244 -0
  108. package/dist/shared/query.js +818 -0
  109. package/dist/shared/review.js +642 -0
  110. package/dist/shared/reviewqueue.js +333 -0
  111. package/dist/shared/ring.js +366 -0
  112. package/dist/shared/scope.js +255 -0
  113. package/dist/shared/scopeedit.js +81 -0
  114. package/dist/shared/search.js +191 -0
  115. package/dist/shared/separate.js +178 -0
  116. package/dist/shared/sharesnap.js +281 -0
  117. package/dist/shared/sticky.js +501 -0
  118. package/dist/shared/timeline.js +73 -0
  119. package/dist/shared/titles.js +300 -0
  120. package/dist/shared/viewgraph.js +157 -0
  121. package/dist/shared/views.js +859 -0
  122. package/dist/web/bundle/app.js +52671 -0
  123. package/dist/web/bundle/edges.js +407 -0
  124. package/dist/web/bundle/layout.js +1010 -0
  125. package/dist/web/bundle/pdfworker.js +48322 -0
  126. package/dist/web/index.html +257 -0
  127. package/dist/web/src/motion.js +430 -0
  128. package/dist/web/src/past.js +224 -0
  129. package/dist/web/src/scene.js +804 -0
  130. package/dist/web/style.css +2548 -0
  131. package/docs/agent-api/README.md +91 -0
  132. package/docs/agent-api/advice.md +108 -0
  133. package/docs/agent-api/export.md +120 -0
  134. package/docs/agent-api/following.md +140 -0
  135. package/docs/agent-api/history.md +142 -0
  136. package/docs/agent-api/library.md +65 -0
  137. package/docs/agent-api/mcp.md +104 -0
  138. package/docs/agent-api/notes.md +93 -0
  139. package/docs/agent-api/reading.md +166 -0
  140. package/docs/agent-api/requests.md +94 -0
  141. package/docs/agent-api/talking.md +100 -0
  142. package/docs/agent-api/tcb.md +135 -0
  143. package/docs/agent-api/views.md +125 -0
  144. package/docs/agent-api/words.md +56 -0
  145. package/package.json +39 -4
  146. package/test/toolchains.json +5 -0
@@ -0,0 +1,286 @@
1
+ // Asking others to follow (CollabSpec decision 10, AgentSpec decision 8; the owner's decisions of
2
+ // 2026-10-12). A reader with a name who is visible, or an agent at level advise or full, asks
3
+ // everyone on the server — private readers too, who are counted, never named — or one person to
4
+ // follow them. The server relays the request to each page asked as one of its live events (`ask`),
5
+ // and each page's answer to the asking page (`ask-answer`) or to the agent, which waits for them; it
6
+ // holds each asker to one request per 30 s, and checks who may ask. A page shows one request at a
7
+ // time: a newer one, from another asker, replaces it there, and the one replaced counts as not
8
+ // answered. A request ends when every page asked has answered or its 30 s are over (and a little
9
+ // more, for an answer on its way); its asker leaving, or going private, withdraws it. Nothing is
10
+ // kept past a request's life but when each asker last asked, for the 30 s that it counts.
11
+ //
12
+ // The server does not know who follows whom — following is the page's own (web/src/people.ts) — so
13
+ // a page asked whose reader follows the asker already answers so at once (`following`), shows
14
+ // nothing, and is not counted as asked.
15
+ //
16
+ // The agents' endpoint (POST /api/agent/ask-follow) is server/agent.ts's, in its shape
17
+ // (server/agentviews.ts, AgentEndpoint), as the notes' are (server/agentnotes.ts).
18
+ import { randomBytes } from "node:crypto";
19
+ import { sameName } from "../shared/collab.js";
20
+ import { ASK_GRACE_MS, ASK_MS, ASK_WORDS, askLine, cleanAnswer, foldAnswers } from "../shared/askfollow.js";
21
+ import { fail, whoOf } from "./agent.js";
22
+ import { agentKey } from "./presence.js";
23
+ /** The key a name counts under: whatever the case, however Unicode writes it (shared/collab.ts,
24
+ * sameName). */
25
+ const nameKey = (s) => s.trim().normalize("NFKC").toLowerCase();
26
+ export class Asks {
27
+ port;
28
+ ms;
29
+ grace;
30
+ requests = new Map();
31
+ /** the request each page shows, by its stream's secret, while it has not answered it */
32
+ showing = new Map();
33
+ /** when each asker last asked, kept only while it counts */
34
+ last = new Map();
35
+ now = () => Date.now();
36
+ constructor(port, opts = {}) {
37
+ this.port = port;
38
+ this.ms = opts.ms ?? ASK_MS;
39
+ this.grace = opts.grace ?? ASK_GRACE_MS;
40
+ }
41
+ /** How many requests are going on: none is kept past its life. */
42
+ get pending() {
43
+ return this.requests.size;
44
+ }
45
+ /** Seconds before `asker` may ask again; 0 when they may. What no longer counts is forgotten. */
46
+ retryAfter(asker) {
47
+ const now = this.now();
48
+ for (const [k, at] of this.last)
49
+ if (now - at >= this.ms)
50
+ this.last.delete(k);
51
+ const since = Math.max(-Infinity, ...asker.rate.map((k) => this.last.get(k) ?? -Infinity));
52
+ return Number.isFinite(since) ? Math.max(1, Math.ceil((since + this.ms - now) / 1000)) : 0;
53
+ }
54
+ /** Ask `whom` to follow `asker`: the request is told to every page of theirs at once, the one each
55
+ * showed replaced. Nobody there to ask is no request, and does not count for the rate. */
56
+ ask(asker, whom) {
57
+ const wait = this.retryAfter(asker);
58
+ if (wait)
59
+ return { status: 429, error: `You asked a moment ago: one request every ${Math.round(this.ms / 1000)} s. Ask again in ${wait} s.`, hint: `Wait ${wait} s.`, retryAfter: wait };
60
+ const found = this.whom(asker, whom);
61
+ if (!Array.isArray(found))
62
+ return found;
63
+ if (!found.length)
64
+ return { ok: { id: null, asked: [], private: 0, ms: this.ms, report: { id: null, pages: 0, people: [], private: { asked: 0 }, summary: "Nobody is on the page to ask." } } };
65
+ for (const k of asker.rate)
66
+ this.last.set(k, this.now());
67
+ const id = randomBytes(9).toString("base64url");
68
+ const r = { id, asker, targets: new Map(found.map((s) => [s.token, { was: s, answer: null }])), timer: setTimeout(() => this.expire(id), this.ms + this.grace), waiters: new Set() };
69
+ r.timer.unref?.();
70
+ this.requests.set(id, r);
71
+ const told = { id, from: asker.from, name: asker.name, ...(asker.agent ? { agent: true } : {}), ...(asker.verified ? { verified: true } : {}), ms: this.ms };
72
+ for (const s of found) {
73
+ // the request this page showed is replaced by this one, from the newest asker: not answered
74
+ const was = this.showing.get(s.token);
75
+ if (was && was !== id)
76
+ this.answered(was, s.token, "timeout");
77
+ this.showing.set(s.token, id);
78
+ this.port.send(s.token, "ask", told);
79
+ }
80
+ return { ok: { id, asked: found.filter((s) => !s.private).map((s) => s.id), private: found.filter((s) => s.private).length, ms: this.ms, report: this.report(r) } };
81
+ }
82
+ /** The pages `whom` names, but the asker's own; or why there are none to name. */
83
+ whom(asker, whom) {
84
+ const streams = this.port.streams();
85
+ // the asker's own pages: the one that asks, and the others of a person of their name
86
+ const own = (s) => s.token === asker.stream || (!asker.agent && !s.private && s.name !== null && sameName(s.name, asker.name) && s.verified === asker.verified);
87
+ const pagesOf = (s) => (s.name === null ? [s] : streams.filter((x) => !x.private && x.name !== null && sameName(x.name, s.name) && x.verified === s.verified));
88
+ const peers = this.port.peers();
89
+ if ("everyone" in whom)
90
+ return streams.filter((s) => !own(s));
91
+ if ("page" in whom) {
92
+ const p = peers.find((x) => x.id === whom.page);
93
+ if (p?.agent)
94
+ return { status: 409, error: `${p.name} is an agent: agents cannot be asked to follow.`, hint: "Ask a person." };
95
+ const s = streams.find((x) => x.id === whom.page && !x.private);
96
+ if (!p || !s)
97
+ return { status: 404, error: "That reader is not here any more.", hint: "Ask someone among the initials at the top right." };
98
+ if (own(s))
99
+ return { status: 400, error: "That is you.", hint: "Ask someone else." };
100
+ return pagesOf(s).filter((x) => !own(x));
101
+ }
102
+ const named = streams.filter((s) => !s.private && s.name !== null && sameName(s.name, whom.name));
103
+ if (!named.length) {
104
+ if (peers.some((p) => p.agent && p.name !== null && sameName(p.name, whom.name)))
105
+ return { status: 409, error: `${whom.name} is an agent: agents cannot be asked to follow.`, hint: "Ask a person." };
106
+ const names = [...new Set(streams.flatMap((s) => (!s.private && s.name !== null && !own(s) ? [s.name] : [])))].sort();
107
+ return { status: 404, error: `Nobody called ${whom.name} is on the page.`, hint: names.length ? `GET /api/agent/companion lists the people here: ${names.join(", ")}.` : "Nobody else is on the page now; GET /api/agent/companion?wait=60 tells you when someone comes.", didYouMean: names.slice(0, 5) };
108
+ }
109
+ const mine = named.filter((s) => !own(s));
110
+ return mine.length ? mine : { status: 400, error: "That is you.", hint: "Ask someone else." };
111
+ }
112
+ /** A page answers, by its stream's secret: once, a request it was asked and that goes on. */
113
+ answer(token, id, answer) {
114
+ const a = cleanAnswer(answer);
115
+ if (!a)
116
+ return { status: 400, body: { error: "answer is follow, decline, timeout or following." } };
117
+ const r = typeof id === "string" ? this.requests.get(id) : undefined;
118
+ if (!r)
119
+ return { status: 404, body: { error: "This request is over." } };
120
+ const t = r.targets.get(token);
121
+ if (!t)
122
+ return { status: 403, body: { error: "This page was not asked." } };
123
+ if (t.answer)
124
+ return { status: 409, body: { error: "This page answered already." } };
125
+ this.answered(r.id, token, a);
126
+ return { status: 200, body: { ok: true } };
127
+ }
128
+ /** Waits for request `id` to end, and resolves to what it came to; at once where it is over or
129
+ * unknown (null), or when `signal` aborts (what it came to so far). */
130
+ settled(id, signal) {
131
+ const r = this.requests.get(id);
132
+ if (!r)
133
+ return Promise.resolve(null);
134
+ return new Promise((done) => {
135
+ const end = (rep) => {
136
+ r.waiters.delete(end);
137
+ signal?.removeEventListener("abort", stop);
138
+ done(rep);
139
+ };
140
+ const stop = () => end(this.report(r));
141
+ r.waiters.add(end);
142
+ signal?.addEventListener("abort", stop, { once: true });
143
+ });
144
+ }
145
+ /** A page's event stream closed: what it was asked is not answered, and what it asked is withdrawn. */
146
+ closed(token) {
147
+ this.withdraw(token, false);
148
+ for (const r of [...this.requests.values()])
149
+ if (r.targets.get(token)?.answer === null)
150
+ this.answered(r.id, token, "timeout");
151
+ this.showing.delete(token);
152
+ }
153
+ /** The page that asked can no longer be followed — it went private, or closed (`told` false: it is
154
+ * not there to tell): its requests end, each page that has not answered is told so (`ask-end`),
155
+ * and the asker that it did not. */
156
+ withdraw(token, told = true) {
157
+ for (const r of [...this.requests.values()]) {
158
+ if (r.asker.stream !== token)
159
+ continue;
160
+ for (const [tok, t] of r.targets) {
161
+ if (t.answer)
162
+ continue;
163
+ if (this.showing.get(tok) === r.id)
164
+ this.showing.delete(tok);
165
+ this.port.send(tok, "ask-end", { id: r.id });
166
+ if (told)
167
+ this.answered(r.id, tok, "timeout");
168
+ }
169
+ this.finish(r);
170
+ }
171
+ }
172
+ /** Page `token`'s answer to request `id`: kept, and told to whoever asked. */
173
+ answered(id, token, answer) {
174
+ const r = this.requests.get(id);
175
+ const t = r?.targets.get(token);
176
+ if (!r || !t || t.answer)
177
+ return;
178
+ t.answer = answer;
179
+ if (this.showing.get(token) === id)
180
+ this.showing.delete(token);
181
+ if (r.asker.stream) {
182
+ // a page is named as the others know it now: a private one by nobody
183
+ const now = this.port.streams().find((s) => s.token === token) ?? t.was;
184
+ this.port.send(r.asker.stream, "ask-answer", { id, from: now.private ? null : now.id, answer });
185
+ }
186
+ if ([...r.targets.values()].every((x) => x.answer))
187
+ this.finish(r);
188
+ }
189
+ /** The request's time is over: the pages that have not answered did not, and are told it ended. */
190
+ expire(id) {
191
+ const r = this.requests.get(id);
192
+ if (!r)
193
+ return;
194
+ for (const [tok, t] of r.targets) {
195
+ if (t.answer)
196
+ continue;
197
+ this.port.send(tok, "ask-end", { id });
198
+ this.answered(id, tok, "timeout");
199
+ }
200
+ this.finish(r);
201
+ }
202
+ /** The request is over: whoever waits for it is told what it came to, and nothing of it is kept. */
203
+ finish(r) {
204
+ if (!this.requests.has(r.id))
205
+ return;
206
+ clearTimeout(r.timer);
207
+ this.requests.delete(r.id);
208
+ for (const [tok] of r.targets)
209
+ if (this.showing.get(tok) === r.id)
210
+ this.showing.delete(tok);
211
+ const rep = this.report(r);
212
+ for (const w of [...r.waiters])
213
+ w(rep);
214
+ }
215
+ /** What request `r` came to so far, person by person. */
216
+ report(r) {
217
+ const people = new Map();
218
+ const priv = [];
219
+ for (const [tok, t] of r.targets) {
220
+ if (t.was.private) {
221
+ priv.push(t.answer);
222
+ continue;
223
+ }
224
+ const key = t.was.name === null ? `s:${tok}` : `${t.was.verified ? "v" : "n"}:${nameKey(t.was.name)}`;
225
+ const p = people.get(key) ?? { name: t.was.name, verified: t.was.verified, answers: [] };
226
+ p.answers.push(t.answer);
227
+ people.set(key, p);
228
+ }
229
+ const states = [];
230
+ const out = [...people.values()].map((p) => {
231
+ const s = foldAnswers(p.answers);
232
+ states.push(s);
233
+ return { name: p.name, ...(p.verified ? { verified: true } : {}), answer: ASK_WORDS[s] };
234
+ });
235
+ const counts = {};
236
+ for (const a of priv) {
237
+ const s = foldAnswers([a]);
238
+ states.push(s);
239
+ counts[ASK_WORDS[s]] = (counts[ASK_WORDS[s]] ?? 0) + 1;
240
+ }
241
+ return { id: r.id, pages: r.targets.size, people: out, private: { asked: priv.length, ...counts }, summary: askLine(states) ?? (states.length ? "Everyone asked follows you already." : "Nobody was asked.") };
242
+ }
243
+ }
244
+ /** The agents' endpoint (AgentSpec decision 8): an agent at level advise or full asks everyone on
245
+ * the page, or one person, to follow it, and waits for their answers. */
246
+ export function askEndpoints(world, refuse) {
247
+ return [
248
+ {
249
+ method: "POST",
250
+ path: "/api/agent/ask-follow",
251
+ needs: "advise",
252
+ about: "Ask the people on the page to follow you (the owner's decision of 2026-10-12): everyone — private readers too, counted, never named — or one person (who). Each sees a banner for 30 s, \"<you> (agent) asks you to follow · Follow · Not now\", with your mark; following you opens your conversation. Those following you already are not asked. One request every 30 s. The answer waits for theirs, at most the 30 s the request lasts (wait: false answers at once): who follows you now, who declined, who did not answer. Agents cannot be asked to follow.",
253
+ params: { who: "optional: a person's name, as GET /api/agent/companion lists people; left out, everyone on the page", wait: "false: answer at once, with whom it asked, without their answers (default: wait for them, at most 30 s)" },
254
+ returns: "{ok, id, pages, people: [{name, verified?, answer: following|declined|no answer|already following|waiting}], private: {asked, following?, declined?, no answer?, already following?, waiting?}, summary, waited} — 429 with retryAfter within 30 s of your last request",
255
+ run: async (a) => {
256
+ const c = a.caller;
257
+ if (c.kind !== "agent")
258
+ return fail(400, "This API records what agents do: say you are one.", "Send X-VisualProof-Kind: agent with every request, beside X-VisualProof-Name.");
259
+ if (!c.name)
260
+ return fail(401, "Say who you are first: a request to follow says who asks.", "Send X-VisualProof-Name: <your name>, e.g. Claude.", { who: true });
261
+ const no = refuse(c, "advise");
262
+ if (no)
263
+ return no;
264
+ if (!world.asks)
265
+ return fail(404, "This server relays no requests to follow.", "Point people at what you look at with a note, or a link (POST /api/agent/view/link).");
266
+ // people follow the agent's entry in presence: one not listed could not be followed
267
+ const from = agentKey(whoOf(c));
268
+ if (!world.presence.list().some((p) => p.id === from))
269
+ return fail(409, "You are not among the people on the page, so nobody could follow you.", "Agents without a token of their own are listed 32 at most; ask again once one has gone, or ask for a token (Share, then AI agent, in the page).");
270
+ const b = a.body && typeof a.body === "object" && !Array.isArray(a.body) ? a.body : {};
271
+ if (b.who !== undefined && b.who !== null && (typeof b.who !== "string" || !b.who.trim()))
272
+ return fail(400, "who is a person's name.", "GET /api/agent/companion lists the people on the page by name; leave who out to ask everyone.");
273
+ if (b.wait !== undefined && typeof b.wait !== "boolean")
274
+ return fail(400, "wait is true or false.", "Leave it out to wait for the answers.");
275
+ const whom = typeof b.who === "string" ? { name: b.who.trim() } : { everyone: true };
276
+ const r = world.asks.ask({ name: c.name, verified: false, agent: true, from, stream: null, rate: [c.token ? `t:${c.token.id}` : `a:${nameKey(c.name)}`] }, whom);
277
+ if (!("ok" in r))
278
+ return fail(r.status, r.error, r.hint, { ...(r.retryAfter ? { retryAfter: r.retryAfter } : {}), ...(r.didYouMean ? { didYouMean: r.didYouMean } : {}) }, r.retryAfter ? { "retry-after": String(r.retryAfter) } : undefined);
279
+ const t0 = Date.now();
280
+ const sent = r.ok;
281
+ const rep = sent.id && b.wait !== false ? ((await world.asks.settled(sent.id, a.signal)) ?? sent.report) : sent.report;
282
+ return { status: 200, body: { ok: true, ...rep, waited: Math.round((Date.now() - t0) / 100) / 10 } };
283
+ },
284
+ },
285
+ ];
286
+ }
@@ -0,0 +1,159 @@
1
+ // Where a view's board draws what its exported document prints (the owner's decisions of 2026-10-12:
2
+ // the board is the outline of the document; shared/doc/order.ts orders by it), read on the server as
3
+ // a page draws the board at rest, so that an export from the page, from an agent or of a past build
4
+ // follows the board everyone on it sees.
5
+ //
6
+ // The board is laid out as the page lays it out (shared/board.ts): the server's layout for a board of
7
+ // the whole project, else the view's layout memory recalled — the server keeps it, as the pages hand
8
+ // it over — and, where no page has laid the board out for what it holds now (a view never opened, a
9
+ // new build, a review that changed what a scope holds), laid out from that memory by the same pure
10
+ // functions the page runs, which gives the picture the next page will show; nothing is handed over,
11
+ // since exporting only reads. A board of a past build is laid out as the Time Machine lays it out
12
+ // (web/src/timemachine.ts): from the view's memory of that build, else from its memory or the latest
13
+ // build's layout of the whole project, a declaration renamed since read under its name then. The
14
+ // view's pins are then applied by the page's own scene (web/src/scene.ts, which is pure): what is
15
+ // dropped stays where it was dropped and its neighbours make room, as the page draws them.
16
+ //
17
+ // What a page draws that the server does not read: folds, which gather a cluster's contents into a
18
+ // disc and slide its neighbours inward — the document prints everything whatever is folded, so its
19
+ // order is the board's with nothing folded; a reader's visitors and their temporary clusters, which
20
+ // are theirs alone; the easing of things still moving; and a drag not let go yet. A note on the ring
21
+ // is read where it points — the angle it was let go at, else that of what it is about — on the
22
+ // innermost ring, as when the board is zoomed in so far that one ring holds every note; one that
23
+ // points nowhere is at the top of the ring, in the order notes were written.
24
+ import { boardSet, edgesOf, layoutKey, pastSetKey, recalled, wholeLayout, boardLayout } from "../shared/board.js";
25
+ import { computeLayout, memoryOf, stabilize } from "../shared/layout.js";
26
+ import { declSpot } from "../shared/notecard.js";
27
+ import { aboutMembers, existsIn, itemSpot, itemWhy, linkedOn, linksByFile, noteBoard, viewSpot } from "../shared/noteplace.js";
28
+ import { aboutAngle, ringAround, ringRadians } from "../shared/ring.js";
29
+ import { fixedScope } from "../shared/views.js";
30
+ import { renamedMemory } from "../web/src/past.js";
31
+ import { Scene } from "../web/src/scene.js";
32
+ /** a declaration on the ring's edge, as the page counts it there (web/src/sticky.ts, ringNow) */
33
+ const LEAF_RING_R = 8;
34
+ /** the most declarations the centre of what a note on the ring is about is taken over, every so
35
+ * many of them beyond, as the page samples them (web/src/sticky.ts) */
36
+ const ABOUT_MOST = 4000;
37
+ /** The board of view `b.rec` as its page draws it at rest, nothing folded: where each declaration,
38
+ * circle and note is (shared/doc/order.ts). */
39
+ export function boardSpots(b) {
40
+ const { g, review, rec } = b;
41
+ const mode = rec.clustering;
42
+ const hidden = rec.hidden[mode] ?? [];
43
+ const bs = boardSet(g, review, rec.scope);
44
+ const G = bs.sub ? bs.sub.graph : g;
45
+ let L;
46
+ let fresh = false;
47
+ if (b.past) {
48
+ const key = pastSetKey(b.past.snapshot, layoutKey(bs.key, hidden));
49
+ const quick = recalled(G, mode, key, b.memory, hidden);
50
+ if (quick)
51
+ L = quick.layout;
52
+ else {
53
+ // laid out from the view's memory; with none, from the latest build's layout where the view's
54
+ // board of now is the whole project (the Time Machine's `wholeNow`), else afresh
55
+ const whole = !b.memory && !!b.whole && boardSet(b.past.now, b.past.nowReview, rec.scope).whole;
56
+ const base = b.memory ? b.memory.mem : whole ? memoryOf(wholeLayout(b.whole, b.past.now.nodes, mode, hidden), b.past.now.nodes) : null;
57
+ L = stabilize(computeLayout(mode, G.nodes, edgesOf(G), hidden), G.nodes, base && renamedMemory(base, b.past.nowName));
58
+ fresh = true;
59
+ }
60
+ }
61
+ else if (bs.whole && b.whole)
62
+ L = wholeLayout(b.whole, G.nodes, mode, hidden);
63
+ else {
64
+ const quick = recalled(G, mode, layoutKey(bs.key, hidden), b.memory, hidden);
65
+ L = quick ? quick.layout : boardLayout(G, mode, bs.key, b.memory, hidden).layout;
66
+ fresh = !quick;
67
+ }
68
+ // the pins applied as the page applies them, nothing folded
69
+ const S = new Scene(L);
70
+ S.restore(rec.pins[mode] ?? { nodes: {}, clusters: {} }, [], (i) => G.nodes[i].name);
71
+ const decls = new Map();
72
+ for (let i = 0; i < G.n; i++)
73
+ decls.set(G.nodes[i].name, [S.nwx[i], S.nwy[i]]);
74
+ const clusters = new Map();
75
+ L.clusters.forEach((c, k) => clusters.set(c.key, [S.cwx[k], S.cwy[k]]));
76
+ // the notes the view shows, each where the page draws its icon (web/src/sticky.ts, render)
77
+ const nb = noteBoard(L, mode, G.nodes);
78
+ const exists = existsIn(g.nodes);
79
+ const links = linksByFile(review.links);
80
+ const view = { all: fixedScope(rec), items: rec.items };
81
+ const index = new Map(L.clusters.map((c, k) => [c.key, k]));
82
+ const chain = (c) => {
83
+ const out = [];
84
+ for (let x = c; x >= 0; x = L.clusters[x].parent)
85
+ out.push(L.clusters[x].key);
86
+ return out;
87
+ };
88
+ let ring;
89
+ const ringOf = () => (ring ??= ringAround([...S.rootC.map((c) => ({ x: S.cwx[c], y: S.cwy[c], r: S.drawnR[c] })), ...S.rootN.map((i) => ({ x: S.nwx[i], y: S.nwy[i], r: LEAF_RING_R }))]));
90
+ /** Where a note on the ring is read: where it points on the innermost ring, else its top. */
91
+ const onRing = (spot) => {
92
+ const R = ringOf();
93
+ if (!R)
94
+ return [0, 0];
95
+ let a = spot && spot.angle !== null ? ringRadians(spot.angle) : null;
96
+ if (a === null && spot?.about) {
97
+ let about = aboutMembers(nb, spot.about);
98
+ if (about.length > ABOUT_MOST)
99
+ about = about.filter((_, k) => k % Math.ceil(about.length / ABOUT_MOST) === 0);
100
+ if (about.length) {
101
+ let x = 0, y = 0;
102
+ for (const j of about)
103
+ [x, y] = [x + S.nwx[j], y + S.nwy[j]];
104
+ a = aboutAngle(R, [x / about.length, y / about.length]);
105
+ }
106
+ }
107
+ const t = a ?? -Math.PI / 2;
108
+ return [R.x + R.r * Math.cos(t), R.y + R.r * Math.sin(t)];
109
+ };
110
+ const notes = [];
111
+ const seen = new Set();
112
+ const place = (note, kind, spot) => {
113
+ if (!spot || seen.has(note.id))
114
+ return;
115
+ seen.add(note.id);
116
+ // attached to a declaration the board holds: printed after it, its icon at its offset from the
117
+ // point, a place on the board in its units, as the page draws it
118
+ const on = note.decl && nb.index.get(note.decl.name);
119
+ if (note.decl && on !== undefined) {
120
+ notes.push({ note, kind, at: declSpot([S.nwx[on], S.nwy[on]], note.decl.at), in: chain(L.cluster[on]), on: note.decl.name });
121
+ return;
122
+ }
123
+ switch (spot.at) {
124
+ case "cluster": {
125
+ // a fraction of the radius drawn
126
+ const R = S.drawnR[spot.c];
127
+ notes.push({ note, kind, at: [S.cwx[spot.c] + spot.f[0] * R, S.cwy[spot.c] + spot.f[1] * R], in: chain(spot.c) });
128
+ return;
129
+ }
130
+ case "offset": {
131
+ // kept inside its circle, however the circle changed since it was put there
132
+ const R = S.drawnR[spot.c];
133
+ const len = Math.hypot(spot.d[0], spot.d[1]);
134
+ const f = len > R ? R / len : 1;
135
+ notes.push({ note, kind, at: [S.cwx[spot.c] + spot.d[0] * f, S.cwy[spot.c] + spot.d[1] * f], in: chain(spot.c) });
136
+ return;
137
+ }
138
+ case "world":
139
+ notes.push({ note, kind, at: [spot.x, spot.y], in: [] });
140
+ return;
141
+ case "ring":
142
+ notes.push({ note, kind, at: onRing(spot), in: [] });
143
+ return;
144
+ default:
145
+ // a place this reading does not know: at the start, at the top of the ring
146
+ notes.push({ note, kind, at: onRing(null), in: [] });
147
+ }
148
+ };
149
+ for (const n of b.notes) {
150
+ const why = itemWhy(n, view, nb, exists, links);
151
+ place(n, "project", why ? itemSpot(n, nb, exists, linkedOn(n, nb, links)) : null);
152
+ }
153
+ for (const n of rec.notes ?? [])
154
+ place(n, "view", viewSpot(n, nb, (key) => index.get(key) ?? -1));
155
+ // in the order they were written, which is the order of those level on the board: the notes that
156
+ // point nowhere on the ring, at its top
157
+ notes.sort((x, y) => (x.note.at < y.note.at ? -1 : x.note.at > y.note.at ? 1 : 0));
158
+ return { decls, clusters, notes, ...(fresh ? { fresh: true } : {}) };
159
+ }
@@ -0,0 +1,175 @@
1
+ // Where the server keeps what it writes, in two places (the owner's decision of 2026-10-13): the
2
+ // site and the cache. Nothing is written into the project, and nothing into this repository.
3
+ //
4
+ // site what one running VisualProof site holds, which cannot be derived again: the review's
5
+ // logs (`log/`, server/review.ts), the history of the builds it saw, the library's files,
6
+ // the views and their layouts (and the notes they keep), the timeline, the TCB baselines,
7
+ // conversations with agents, agents' tokens, personal links. Local: it holds secrets, and
8
+ // is backed up, never committed
9
+ // cache what a rebuild makes again: extractions, the usage pass's results, the prepared views,
10
+ // what was read of the library's files, typeset documents, Lean's language server's
11
+ // folder; and, shared by every project, the Lean programs compiled per toolchain and the
12
+ // page's bundle
13
+ //
14
+ // The site is one folder, `--site` (relative to the current directory, or absolute; by default
15
+ // `<data home>/sites/<key>`), made with a README that says what it is; one server at a time holds
16
+ // it (`.lock`). The caches are under one root, `--cache` (by default the cache home): the project's
17
+ // in `projects/<key>`, the Lean programs in `lean/`, the page's bundle in `web/`. The homes follow
18
+ // the platform, and `XDG_DATA_HOME` / `XDG_CACHE_HOME` when set, on any platform.
19
+ import { createHash } from "node:crypto";
20
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { basename, join, resolve } from "node:path";
23
+ const here = () => ({ platform: process.platform, env: process.env, home: homedir() });
24
+ /** Where VisualProof keeps its sites. */
25
+ export function dataHome(h = here()) {
26
+ if (h.env.XDG_DATA_HOME)
27
+ return join(h.env.XDG_DATA_HOME, "visualproof");
28
+ if (h.platform === "darwin")
29
+ return join(h.home, "Library", "Application Support", "visualproof");
30
+ if (h.platform === "win32")
31
+ return join(h.env.LOCALAPPDATA ?? join(h.home, "AppData", "Local"), "visualproof", "data");
32
+ return join(h.home, ".local", "share", "visualproof");
33
+ }
34
+ /** Where VisualProof keeps its caches. */
35
+ export function cacheHome(h = here()) {
36
+ if (h.env.XDG_CACHE_HOME)
37
+ return join(h.env.XDG_CACHE_HOME, "visualproof");
38
+ if (h.platform === "darwin")
39
+ return join(h.home, "Library", "Caches", "visualproof");
40
+ if (h.platform === "win32")
41
+ return join(h.env.LOCALAPPDATA ?? join(h.home, "AppData", "Local"), "visualproof", "cache");
42
+ return join(h.home, ".cache", "visualproof");
43
+ }
44
+ /** A project's name among the others: its folder's name and a hash of its path. */
45
+ export function projectKey(root) {
46
+ return `${basename(root)}-${createHash("sha1").update(root).digest("hex").slice(0, 8)}`;
47
+ }
48
+ /** The folders of the project at `root`: its site where `site` says (relative to the current
49
+ * directory, as `--cache` is: the owner's decision of 2026-10-14) or
50
+ * under the data home, its caches under `cacheRoot` or the cache home. */
51
+ export function projectDirs(root, site, cacheRoot, h = here()) {
52
+ const cache = cacheRoot ? resolve(cacheRoot) : cacheHome(h);
53
+ return {
54
+ site: site === null ? join(dataHome(h), "sites", projectKey(root)) : resolve(site),
55
+ cache: join(cache, "projects", projectKey(root)),
56
+ lean: join(cache, "lean"),
57
+ };
58
+ }
59
+ /** What the site's folder is, for whoever finds it in the project: written when the folder is
60
+ * made (by the server, or with the first log), one already there left as it is. */
61
+ export const SITE_README = `# This VisualProof site's folder
62
+
63
+ VisualProof keeps here what this site holds of the project: what reviewers said about its
64
+ declarations (\`log/\`), the history of the builds it saw, the library's files, the views and their
65
+ layouts, the timeline, conversations with agents, agents' tokens and personal links.
66
+
67
+ It is local. It holds secrets (agents' tokens, personal links): it is not meant to be committed.
68
+ Back it up: none of it can be derived again. What VisualProof can derive again is in its cache
69
+ (\`--cache\`), not here.
70
+
71
+ - \`log/\` holds one file per writer: \`<name>-<hash>.jsonl\` for a person,
72
+ \`<name>-<hash>.agent.jsonl\` for an AI agent: its opinions are advice, never counted; what else
73
+ it did (a target, a key declaration, a trusted prefix) counts as a person's would, and is shown
74
+ as an agent's. The hash is of the writer's exact name, which every line carries; the file's
75
+ name is only a container.
76
+ - Someone invited by a personal link writes to \`<id>.person.jsonl\`, the id being their link's:
77
+ every line carries it (\`person\`) beside the name it was written under, and a \`name\` line says
78
+ they go by another name from then on. All they wrote shows under the latest.
79
+ - \`log/visualproof.server.jsonl\` is VisualProof's own: the renames it found between two builds,
80
+ each a move that carries what was said of the declaration to its new name, and the links it
81
+ carried to a cluster a build renamed as a whole.
82
+ - Each line is one event: an opinion on a declaration (bound to its version), a target marked
83
+ or taken off, a prefix trusted without review or no longer, a key declaration of a target marked
84
+ or unmarked, a move of what was said of one declaration to another (or its undoing), a file
85
+ added to the project's library (named by its SHA-256: its bytes are kept beside, in \`files/\`)
86
+ and its title, archiving and restoring, a link from a declaration or a cluster to a place in one,
87
+ edited or removed.
88
+ - Files are only ever appended to, each writer to their own.
89
+ - The review is what replaying every file gives: one writer's events in the order of their file,
90
+ different writers' by time.
91
+ - Do not edit a line by hand. A line VisualProof cannot read (a line cut short) is reported;
92
+ nothing more is written to that file until it is fixed by hand.
93
+ `;
94
+ /** The site's folder, made if it is not there: then with its README. One already there, made by
95
+ * hand or by an earlier version, is left as it is. True when it was made now. */
96
+ export function openSite(dir) {
97
+ if (existsSync(dir))
98
+ return false;
99
+ mkdirSync(dir, { recursive: true });
100
+ writeFileSync(join(dir, "README.md"), SITE_README);
101
+ return true;
102
+ }
103
+ /** Another server holds the site. */
104
+ export class SiteBusy extends Error {
105
+ dir;
106
+ pid;
107
+ constructor(dir, pid) {
108
+ super(`another VisualProof server (process ${pid}) is using the site ${dir}; stop it first, or give this one a --site of its own`);
109
+ this.dir = dir;
110
+ this.pid = pid;
111
+ }
112
+ }
113
+ function alive(pid) {
114
+ try {
115
+ process.kill(pid, 0);
116
+ return true;
117
+ }
118
+ catch (e) {
119
+ // a process of another user's is there all the same
120
+ return e.code === "EPERM";
121
+ }
122
+ }
123
+ /** The living process that holds the site, if one does: what `lockSite` would refuse, without
124
+ * taking it (`visualproof doctor`). */
125
+ export function siteHolder(dir) {
126
+ try {
127
+ const held = Number(readFileSync(join(dir, ".lock"), "utf8").trim());
128
+ return Number.isInteger(held) && held > 0 && alive(held) ? held : null;
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
134
+ /** Takes the site for this process (`.lock`, holding its id), so that two servers never write the
135
+ * same site: SiteBusy while another living process holds it; the lock of one that died is taken
136
+ * over. The answer lets it go, as the process exits. */
137
+ export function lockSite(dir, pid = process.pid) {
138
+ const file = join(dir, ".lock");
139
+ for (let tries = 0;; tries++) {
140
+ try {
141
+ writeFileSync(file, `${pid}\n`, { flag: "wx" });
142
+ break;
143
+ }
144
+ catch (e) {
145
+ if (e.code !== "EEXIST" || tries > 3)
146
+ throw e;
147
+ let held = NaN;
148
+ try {
149
+ held = Number(readFileSync(file, "utf8").trim());
150
+ }
151
+ catch {
152
+ // let go meanwhile
153
+ }
154
+ if (held === pid)
155
+ break;
156
+ if (Number.isInteger(held) && held > 0 && alive(held))
157
+ throw new SiteBusy(dir, held);
158
+ try {
159
+ unlinkSync(file);
160
+ }
161
+ catch {
162
+ // taken over by another meanwhile: tried again
163
+ }
164
+ }
165
+ }
166
+ return () => {
167
+ try {
168
+ if (readFileSync(file, "utf8").trim() === String(pid))
169
+ unlinkSync(file);
170
+ }
171
+ catch {
172
+ // gone already
173
+ }
174
+ };
175
+ }