visualproof 0.0.0-stage → 0.1.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 (128) 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/extractor/Extract.lean +1180 -0
  9. package/dist/extractor/Usage.lean +900 -0
  10. package/dist/mcp/visualproof-mcp.mjs +476 -0
  11. package/dist/server/access.js +95 -0
  12. package/dist/server/agent.js +3235 -0
  13. package/dist/server/agentdocs.js +35 -0
  14. package/dist/server/agentlibrary.js +202 -0
  15. package/dist/server/agentnotes.js +701 -0
  16. package/dist/server/agentviews.js +703 -0
  17. package/dist/server/askfollow.js +286 -0
  18. package/dist/server/boardspots.js +159 -0
  19. package/dist/server/dirs.js +175 -0
  20. package/dist/server/doctor.js +186 -0
  21. package/dist/server/events.js +198 -0
  22. package/dist/server/export.js +420 -0
  23. package/dist/server/extract.js +204 -0
  24. package/dist/server/fetchurl.js +321 -0
  25. package/dist/server/figures.js +158 -0
  26. package/dist/server/history.js +741 -0
  27. package/dist/server/historyapi.js +304 -0
  28. package/dist/server/hover.js +595 -0
  29. package/dist/server/identity.js +114 -0
  30. package/dist/server/invites.js +241 -0
  31. package/dist/server/layoutthread.js +51 -0
  32. package/dist/server/library.js +600 -0
  33. package/dist/server/libraryroute.js +211 -0
  34. package/dist/server/live.js +1101 -0
  35. package/dist/server/lostfound.js +199 -0
  36. package/dist/server/main.js +1394 -0
  37. package/dist/server/mcptools.js +497 -0
  38. package/dist/server/pastdoc.js +73 -0
  39. package/dist/server/pipeline.js +156 -0
  40. package/dist/server/poppler.js +251 -0
  41. package/dist/server/presence.js +252 -0
  42. package/dist/server/project.js +222 -0
  43. package/dist/server/prompt.js +53 -0
  44. package/dist/server/readonly.js +152 -0
  45. package/dist/server/readonlygate.js +123 -0
  46. package/dist/server/renames.js +268 -0
  47. package/dist/server/review.js +334 -0
  48. package/dist/server/reviewapi.js +188 -0
  49. package/dist/server/snapshot.js +49 -0
  50. package/dist/server/sourcestore.js +473 -0
  51. package/dist/server/status.js +360 -0
  52. package/dist/server/talk.js +476 -0
  53. package/dist/server/tokens.js +221 -0
  54. package/dist/server/usage.js +501 -0
  55. package/dist/server/versions.js +82 -0
  56. package/dist/server/view.js +157 -0
  57. package/dist/server/views.js +1167 -0
  58. package/dist/shared/askfollow.js +46 -0
  59. package/dist/shared/board.js +332 -0
  60. package/dist/shared/build.js +2 -0
  61. package/dist/shared/collab.js +148 -0
  62. package/dist/shared/comment.js +285 -0
  63. package/dist/shared/context.js +157 -0
  64. package/dist/shared/doc/authors.js +98 -0
  65. package/dist/shared/doc/build.js +358 -0
  66. package/dist/shared/doc/latex.js +455 -0
  67. package/dist/shared/doc/markdown.js +159 -0
  68. package/dist/shared/doc/model.js +37 -0
  69. package/dist/shared/doc/order.js +107 -0
  70. package/dist/shared/doc/proof.js +272 -0
  71. package/dist/shared/doc/sections.js +93 -0
  72. package/dist/shared/doc/settings.js +75 -0
  73. package/dist/shared/edgecodec.js +102 -0
  74. package/dist/shared/editor.js +64 -0
  75. package/dist/shared/excerpt.js +87 -0
  76. package/dist/shared/graph.js +16 -0
  77. package/dist/shared/items.js +92 -0
  78. package/dist/shared/keys.js +203 -0
  79. package/dist/shared/layout.js +926 -0
  80. package/dist/shared/lexer.js +130 -0
  81. package/dist/shared/library.js +711 -0
  82. package/dist/shared/lostfound.js +150 -0
  83. package/dist/shared/moves.js +243 -0
  84. package/dist/shared/notecard.js +295 -0
  85. package/dist/shared/noteplace.js +505 -0
  86. package/dist/shared/palette.js +46 -0
  87. package/dist/shared/pastgraph.js +95 -0
  88. package/dist/shared/places.js +80 -0
  89. package/dist/shared/projectnotes.js +244 -0
  90. package/dist/shared/query.js +810 -0
  91. package/dist/shared/review.js +642 -0
  92. package/dist/shared/reviewqueue.js +333 -0
  93. package/dist/shared/ring.js +366 -0
  94. package/dist/shared/scope.js +255 -0
  95. package/dist/shared/scopeedit.js +81 -0
  96. package/dist/shared/search.js +191 -0
  97. package/dist/shared/separate.js +178 -0
  98. package/dist/shared/sharesnap.js +280 -0
  99. package/dist/shared/sticky.js +501 -0
  100. package/dist/shared/timeline.js +73 -0
  101. package/dist/shared/titles.js +298 -0
  102. package/dist/shared/viewgraph.js +139 -0
  103. package/dist/shared/views.js +859 -0
  104. package/dist/web/bundle/app.js +48733 -0
  105. package/dist/web/bundle/edges.js +409 -0
  106. package/dist/web/bundle/layout.js +1010 -0
  107. package/dist/web/bundle/pdfworker.js +48322 -0
  108. package/dist/web/index.html +239 -0
  109. package/dist/web/src/motion.js +430 -0
  110. package/dist/web/src/past.js +224 -0
  111. package/dist/web/src/scene.js +787 -0
  112. package/dist/web/style.css +2312 -0
  113. package/docs/agent-api/README.md +91 -0
  114. package/docs/agent-api/advice.md +108 -0
  115. package/docs/agent-api/export.md +120 -0
  116. package/docs/agent-api/following.md +140 -0
  117. package/docs/agent-api/history.md +142 -0
  118. package/docs/agent-api/library.md +65 -0
  119. package/docs/agent-api/mcp.md +104 -0
  120. package/docs/agent-api/notes.md +93 -0
  121. package/docs/agent-api/reading.md +166 -0
  122. package/docs/agent-api/requests.md +94 -0
  123. package/docs/agent-api/talking.md +100 -0
  124. package/docs/agent-api/tcb.md +135 -0
  125. package/docs/agent-api/views.md +124 -0
  126. package/docs/agent-api/words.md +56 -0
  127. package/package.json +39 -4
  128. package/test/toolchains.json +5 -0
@@ -0,0 +1,268 @@
1
+ // Renames (HistorySpec.md, decided by the owner on 2026-10-09). On each new graph, the declarations
2
+ // that vanished since the build before are paired with those that appeared:
3
+ //
4
+ // - **High confidence**: the same version once names are mapped — the same content, with what it
5
+ // refers to the same, the declaration itself and what was renamed with it set aside (TCBSpec
6
+ // decision 3) — and the same kind, one declaration on each side. A pairing can make another
7
+ // (what refers to a renamed declaration is known once that one is), so it is found again until
8
+ // nothing more pairs. Against a build read before versions, the same kernel's hash stands for it.
9
+ // Such a rename is migrated by itself (server/live.ts): a move in the server's log where something
10
+ // was said of the declaration or of what refers to it, and its pins, its notes' mentions and its
11
+ // views' scopes moved on the server (server/views.ts) — each recorded here, with what undoes it.
12
+ // - **Medium confidence**: what is alike without being the same — the same last name, the same
13
+ // module, nearby, a statement that reads alike, or content the same as several others'. Offered as
14
+ // suggestions, never applied.
15
+ //
16
+ // What a module of either build that could not be read held is set aside: it did not vanish.
17
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { refIdentity, versionOf } from "./versions.js";
20
+ /** What stands for the declaration itself in its own references, so that a declaration can be
21
+ * paired before its own rename is known. */
22
+ const SELF = "\u0000self";
23
+ /** the least likeness a suggestion has */
24
+ export const SUGGEST_FROM = 0.6;
25
+ /** the most candidates a vanished declaration is compared with for a suggestion */
26
+ const MAX_CANDIDATES = 200;
27
+ const last = (name) => name.slice(name.lastIndexOf(".") + 1);
28
+ const tail = (module) => module.slice(module.lastIndexOf(".") + 1);
29
+ /** The words a statement is written with, dotted names by their last part: what two statements
30
+ * are compared by. */
31
+ function words(type) {
32
+ return new Set(type.replace(/[\p{L}_][\p{L}\p{N}_'!?.₀-₉]*/gu, (m) => last(m)).split(/[\s()[\]{}:,]+/).filter(Boolean));
33
+ }
34
+ function jaccard(a, b) {
35
+ if (!a.size && !b.size)
36
+ return 1;
37
+ let both = 0;
38
+ for (const x of a)
39
+ if (b.has(x))
40
+ both++;
41
+ return both / (a.size + b.size - both);
42
+ }
43
+ /** The pairs of `before`'s vanished declarations with `after`'s new ones, high confidence first.
44
+ * `identity`: the renames in force before these (server/versions.ts). */
45
+ export function findRenames(before, after, identity) {
46
+ const proj = (g) => g.nodes.slice(0, g.projectCount);
47
+ const inBefore = new Set(proj(before).map((n) => n.name));
48
+ const inAfter = new Set(proj(after).map((n) => n.name));
49
+ const unreadAfter = new Set(after.info.missing);
50
+ const unreadBefore = new Set(before.info.missing);
51
+ const gone = proj(before).filter((n) => !inAfter.has(n.name) && !unreadAfter.has(n.module));
52
+ const came = proj(after).filter((n) => !inBefore.has(n.name) && !unreadBefore.has(n.module));
53
+ if (!gone.length || !came.length)
54
+ return [];
55
+ // after's new names → the names they had, as found so far
56
+ const was = new Map();
57
+ const used = new Set();
58
+ const out = [];
59
+ const shapeKey = (n, map) => n.shape && n.refs ? `${n.kind}|${versionOf(n.shape, n.refs.map((r) => refIdentity(r, (u) => (u === n.name ? SELF : map(u)))))}` : null;
60
+ const pairUnique = (key, why) => {
61
+ const byKey = new Map();
62
+ const add = (n, side) => {
63
+ const k = key(n, side === "b" ? "before" : "after");
64
+ if (k === null)
65
+ return;
66
+ let e = byKey.get(k);
67
+ if (!e)
68
+ byKey.set(k, (e = { b: [], a: [] }));
69
+ e[side].push(n);
70
+ };
71
+ for (const n of gone)
72
+ if (!used.has(n.name))
73
+ add(n, "b");
74
+ for (const n of came)
75
+ if (!was.has(n.name))
76
+ add(n, "a");
77
+ let made = 0;
78
+ for (const { b, a } of byKey.values()) {
79
+ if (b.length !== 1 || a.length !== 1)
80
+ continue;
81
+ was.set(a[0].name, b[0].name);
82
+ used.add(b[0].name);
83
+ out.push({ from: b[0].name, to: a[0].name, confidence: "high", why });
84
+ made++;
85
+ }
86
+ return made;
87
+ };
88
+ // the same version, names aside, again as long as a pairing makes another
89
+ const afterIdentity = (u) => identity(was.get(u) ?? u);
90
+ while (pairUnique((n, side) => shapeKey(n, side === "before" ? identity : afterIdentity), "same version") > 0)
91
+ ;
92
+ // against a build read before versions: the same kernel's hash, which a rename of what it refers
93
+ // to changes, but a rename of the declaration alone does not where it does not mention itself
94
+ if (!(proj(before).some((n) => n.shape) && proj(after).some((n) => n.shape)))
95
+ pairUnique((n) => `${n.kind}|k:${n.hash}`, "same kernel hash");
96
+ // suggestions: alike, not the same
97
+ const rest = gone.filter((n) => !used.has(n.name));
98
+ const open = came.filter((n) => !was.has(n.name));
99
+ if (!rest.length || !open.length)
100
+ return out;
101
+ const byLast = new Map();
102
+ const byModule = new Map();
103
+ const byShape = new Map();
104
+ for (const n of open) {
105
+ for (const [m, k] of [[byLast, `${n.kind}|${last(n.name)}`], [byModule, `${n.kind}|${n.module}`]]) {
106
+ const xs = m.get(k);
107
+ if (xs)
108
+ xs.push(n);
109
+ else
110
+ m.set(k, [n]);
111
+ }
112
+ const sk = shapeKey(n, afterIdentity);
113
+ if (sk)
114
+ byShape.set(sk, [...(byShape.get(sk) ?? []), n]);
115
+ }
116
+ const text = new Map();
117
+ const wordsOf = (n) => text.get(n) ?? (text.set(n, words(n.type)), text.get(n));
118
+ const offers = [];
119
+ for (const v of rest) {
120
+ const sk = shapeKey(v, identity);
121
+ const sameContent = new Set(sk ? byShape.get(sk) ?? [] : []);
122
+ const cands = new Set([...(byLast.get(`${v.kind}|${last(v.name)}`) ?? []), ...(byModule.get(`${v.kind}|${v.module}`) ?? []), ...sameContent]);
123
+ let best = null;
124
+ let k = 0;
125
+ for (const w of cands) {
126
+ if (k++ >= MAX_CANDIDATES)
127
+ break;
128
+ const clues = [];
129
+ let score = 0;
130
+ if (last(w.name) === last(v.name))
131
+ (score += 0.4), clues.push("the same last name");
132
+ if (w.module === v.module) {
133
+ score += 0.15;
134
+ clues.push("the same module");
135
+ if (w.range && v.range && Math.abs(w.range[0] - v.range[0]) <= 30)
136
+ (score += 0.05), clues.push("nearby");
137
+ }
138
+ else if (tail(w.module) === tail(v.module))
139
+ (score += 0.1), clues.push("a file of the same name");
140
+ const sim = jaccard(wordsOf(v), wordsOf(w));
141
+ score += 0.4 * sim;
142
+ if (sim >= 0.8)
143
+ clues.push("a statement that reads alike");
144
+ if (sameContent.has(w))
145
+ (score = Math.max(score, 0.8)), clues.push("the same content as others");
146
+ score = Math.min(1, Math.round(score * 100) / 100);
147
+ if (score >= SUGGEST_FROM && (!best || score > best.score))
148
+ best = { from: v.name, to: w.name, confidence: "medium", why: clues.join(", "), score };
149
+ }
150
+ if (best)
151
+ offers.push(best);
152
+ }
153
+ // each new declaration offered once, to the one it is likest
154
+ offers.sort((p, q) => q.score - p.score || (p.from < q.from ? -1 : 1));
155
+ const taken = new Set();
156
+ for (const p of offers) {
157
+ if (taken.has(p.to))
158
+ continue;
159
+ taken.add(p.to);
160
+ out.push(p);
161
+ }
162
+ return out;
163
+ }
164
+ /** The migrations and suggestions, kept in `<site>/history/`: `migrations.jsonl`, one line per
165
+ * migration and one per undoing (`{undo, by, agent?, person?, at}`); `renames.jsonl`, one line per
166
+ * graph read with the pairs found against the build before (`{snap, at, pairs}`). Appended, never
167
+ * rewritten. */
168
+ export class Renames {
169
+ migFile;
170
+ pairFile;
171
+ migrations = [];
172
+ byId = new Map();
173
+ /** per snapshot, the pairs found at it (each read of its build adds its own) */
174
+ found = new Map();
175
+ constructor(historyDir) {
176
+ this.migFile = join(historyDir, "migrations.jsonl");
177
+ this.pairFile = join(historyDir, "renames.jsonl");
178
+ for (const l of lines(this.migFile)) {
179
+ const o = l;
180
+ if (typeof o.undo === "string") {
181
+ const m = this.byId.get(o.undo);
182
+ if (m && typeof o.by === "string" && typeof o.at === "string")
183
+ m.undone = { by: o.by, ...(o.agent ? { agent: true } : {}), ...(typeof o.person === "string" ? { person: o.person } : {}), at: o.at };
184
+ }
185
+ else if (typeof o.id === "string" && typeof o.from === "string" && typeof o.to === "string") {
186
+ this.migrations.push(o);
187
+ this.byId.set(o.id, o);
188
+ }
189
+ }
190
+ for (const l of lines(this.pairFile)) {
191
+ const o = l;
192
+ if (Number.isInteger(o.snap) && Array.isArray(o.pairs))
193
+ this.add(o.snap, o.pairs);
194
+ }
195
+ }
196
+ add(snap, pairs) {
197
+ const was = this.found.get(snap) ?? [];
198
+ const seen = new Set(was.map((p) => `${p.from}\u0000${p.to}`));
199
+ this.found.set(snap, [...was, ...pairs.filter((p) => !seen.has(`${p.from}\u0000${p.to}`))]);
200
+ }
201
+ /** The pairs found at a graph read of snapshot `snap`'s build. */
202
+ record(snap, pairs) {
203
+ if (!pairs.length)
204
+ return;
205
+ mkdirSync(join(this.pairFile, ".."), { recursive: true });
206
+ appendFileSync(this.pairFile, JSON.stringify({ snap, at: new Date().toISOString(), pairs }) + "\n");
207
+ this.add(snap, pairs);
208
+ }
209
+ get(id) {
210
+ return this.byId.get(id) ?? null;
211
+ }
212
+ /** A migration made: kept, under the next id. */
213
+ migrated(m) {
214
+ const full = { id: `m${this.migrations.length + 1}`, ...m };
215
+ mkdirSync(join(this.migFile, ".."), { recursive: true });
216
+ appendFileSync(this.migFile, JSON.stringify(full) + "\n");
217
+ this.migrations.push(full);
218
+ this.byId.set(full.id, full);
219
+ return full;
220
+ }
221
+ /** A migration undone (`person`: by someone invited by a personal link, the link's id). */
222
+ undone(id, by, agent, person) {
223
+ const m = this.byId.get(id);
224
+ if (!m)
225
+ return;
226
+ const at = new Date().toISOString();
227
+ const who = { by, ...(agent ? { agent: true } : {}), ...(person ? { person } : {}) };
228
+ appendFileSync(this.migFile, JSON.stringify({ undo: id, ...who, at }) + "\n");
229
+ m.undone = { ...who, at };
230
+ }
231
+ /** The renames of high confidence found from snapshot `from` to snapshot `to`, composed along the
232
+ * builds between: a name at `from` → its name at `to`. `order`: the snapshots' ids, oldest
233
+ * build first. */
234
+ between(from, to, order) {
235
+ const i = order.indexOf(from), j = order.indexOf(to);
236
+ const out = new Map();
237
+ if (i < 0 || j < 0 || j <= i)
238
+ return out;
239
+ for (const id of order.slice(i + 1, j + 1)) {
240
+ const step = new Map((this.found.get(id) ?? []).filter((p) => p.confidence === "high").map((p) => [p.from, p.to]));
241
+ // a name renamed before goes on from its name now; any other starts from its name at `from`
242
+ const now = new Set(out.values());
243
+ for (const [a, b] of out)
244
+ if (step.has(b))
245
+ out.set(a, step.get(b));
246
+ for (const [a, b] of step)
247
+ if (!now.has(a) && !out.has(a))
248
+ out.set(a, b);
249
+ }
250
+ return out;
251
+ }
252
+ }
253
+ function lines(file) {
254
+ if (!existsSync(file))
255
+ return [];
256
+ const out = [];
257
+ for (const l of readFileSync(file, "utf8").split("\n")) {
258
+ if (!l.trim())
259
+ continue;
260
+ try {
261
+ out.push(JSON.parse(l));
262
+ }
263
+ catch {
264
+ // a line cut short (the server stopped while writing): left out
265
+ }
266
+ }
267
+ return out;
268
+ }
@@ -0,0 +1,334 @@
1
+ // The review's storage (ReviewSpec.md, decision 5; the log design decided 2026-10-08): the site's
2
+ // `log/` (server/dirs.ts). The site is local,
3
+ // never committed (the owner's decision of 2026-10-13): the review is the site's, as its history is.
4
+ //
5
+ // One append-only log per writer, `log/<name>-<hash>.jsonl` (an agent's `….agent.jsonl`; someone
6
+ // invited by a personal link, `<id>.person.jsonl`, whatever name they go by), one event per line
7
+ // (shared/review.ts). A person's events go to their file, an agent's to its own —
8
+ // its advice, and at level full whatever else it does (AgentSpec decision 9), every line marked
9
+ // as an agent's. Nothing is ever rewritten: an event is appended, after checking that the file is the
10
+ // size last read, so that what was written meanwhile (by hand, a restored backup) is read first and
11
+ // never written over.
12
+ //
13
+ // The review is the replay of every log. The folder is looked at every second; only files whose
14
+ // size, time or inode changed are read again. A file that only grew is parsed from where the last
15
+ // read stopped; one that shrank or changed before that point (a rewrite, a checkout) is read whole.
16
+ // A line that cannot be read (a line cut short, a bad edit by hand) is reported, the
17
+ // file's other lines and the other files are read, and nothing more is appended to that file until
18
+ // someone fixes it by hand. A line of an event the review no longer records (an explanation, since
19
+ // 2026-10-08; a checkpoint, since 2026-10-09) is skipped, neither reported nor holding up its file.
20
+ //
21
+ // What changed is told as a change (`ReviewChange`): the events that came after everything replayed
22
+ // so far, which a page applies to its own review — or, when an event came in between (older events
23
+ // restored from a backup, a rewrite), the whole review again.
24
+ import { createHash, randomBytes } from "node:crypto";
25
+ import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, statSync, writeFileSync, writeSync } from "node:fs";
26
+ import { isAbsolute, join, resolve } from "node:path";
27
+ import { SITE_README } from "./dirs.js";
28
+ import { applyEvent, emptyReview, eventOf, invalid, logFileName, merged, nameEvent, noChange, personOfLog, problemsText, readEvent } from "../shared/review.js";
29
+ const LOG_DIR = "log";
30
+ /** The server's own log: the moves it made of the renames it found, and the links it carried with a
31
+ * cluster a build renamed (AttachmentSpec decision 6), and nothing else; a move made twice
32
+ * carries nothing the second time. */
33
+ export const SERVER_LOG = "visualproof.server.jsonl";
34
+ /** the name the server's moves are recorded under */
35
+ export const SERVER_WRITER = { name: "VisualProof", agent: false, server: true };
36
+ export class ReviewStore {
37
+ dir;
38
+ logDir;
39
+ data = emptyReview();
40
+ /** this run's id, and the revision of `data`: one more at every change */
41
+ epoch = randomBytes(6).toString("hex");
42
+ rev = 0;
43
+ /** told of every change, after it is made (server/live.ts sends it to the pages) */
44
+ onChange = null;
45
+ files = new Map();
46
+ /** the replay's order: what `data` was made from */
47
+ order = [];
48
+ /** what the replay reads an event as (`setReading`): the event, unless an opinion recorded under
49
+ * the kernel's content hash is bound to its version (server/versions.ts, `rebinder`). The same
50
+ * object each time for the same line, so that what was replayed is still told from what is new */
51
+ reading = null;
52
+ read_ = new WeakMap();
53
+ /** the next commit replays every event, as `reading` changed */
54
+ again = false;
55
+ /** `dir` relative to the project, or absolute. Nothing is created until something is written. */
56
+ constructor(projectRoot, dir) {
57
+ this.dir = isAbsolute(dir) ? dir : resolve(projectRoot, dir);
58
+ this.logDir = join(this.dir, LOG_DIR);
59
+ this.refresh();
60
+ }
61
+ /** Every line that cannot be read, file by file. */
62
+ get problems() {
63
+ return [...this.files.values()].flatMap((f) => f.problems);
64
+ }
65
+ /** The problems in words (the page, /api/status); null when every line reads. */
66
+ get error() {
67
+ return problemsText(this.problems);
68
+ }
69
+ /** The whole review, as a page takes it on (re)connecting. */
70
+ whole() {
71
+ return { epoch: this.epoch, rev: this.rev, data: this.data, problems: this.problems, error: this.error };
72
+ }
73
+ /** The file a writer's events go to (shared/review.ts, logFileName). */
74
+ fileOf(w) {
75
+ return w.server ? SERVER_LOG : logFileName(w.name, w.agent, w.person ?? null);
76
+ }
77
+ /** Read the logs that changed on disk since last read; the change, or null when nothing did. */
78
+ refresh() {
79
+ let names = [];
80
+ try {
81
+ names = readdirSync(this.logDir).filter((n) => n.endsWith(".jsonl")).sort();
82
+ }
83
+ catch {
84
+ // no folder yet: no review yet
85
+ }
86
+ let changed = false;
87
+ for (const gone of [...this.files.keys()].filter((f) => !names.includes(f))) {
88
+ this.files.delete(gone);
89
+ changed = true;
90
+ }
91
+ for (const name of names)
92
+ if (this.read(name))
93
+ changed = true;
94
+ return changed ? this.commit() : null;
95
+ }
96
+ /** Read one log again if its stamp changed: from where the last read stopped when it only grew,
97
+ * else whole. Whether anything was read. */
98
+ read(name) {
99
+ const path = join(this.logDir, name);
100
+ const was = this.files.get(name);
101
+ let buf;
102
+ let stamp;
103
+ try {
104
+ const st = statSync(path);
105
+ if (!st.isFile())
106
+ return this.files.delete(name);
107
+ stamp = `${st.size}:${st.mtimeMs}:${st.ino}`;
108
+ if (was?.stamp === stamp)
109
+ return false;
110
+ buf = readFileSync(path);
111
+ }
112
+ catch (e) {
113
+ // unreadable (permissions, gone between listing and reading): said, what was read of it is
114
+ // kept, nothing is written to it, and it is read again at the next look
115
+ const f = was ?? this.fresh(name, "");
116
+ f.problems = [...f.problems.filter((p) => p.line > 0), { file: name, line: 0, why: `the file cannot be read (${e instanceof Error ? e.message : String(e)})`, writer: null }];
117
+ f.stamp = "";
118
+ this.files.set(name, f);
119
+ return true;
120
+ }
121
+ // only grown: the bytes read before are still there, the same
122
+ if (was && buf.length >= was.offset && createHash("sha256").update(buf.subarray(0, was.offset)).digest("hex") === was.hash.copy().digest("hex")) {
123
+ was.stamp = stamp;
124
+ this.parse(was, buf);
125
+ return true;
126
+ }
127
+ const f = this.fresh(name, stamp);
128
+ this.files.set(name, f);
129
+ this.parse(f, buf);
130
+ return true;
131
+ }
132
+ fresh(name, stamp) {
133
+ return { file: name, agent: name.endsWith(".agent.jsonl"), person: personOfLog(name), server: name === SERVER_LOG, stamp, offset: 0, hash: createHash("sha256"), lines: 0, events: [], tail: null, problems: [] };
134
+ }
135
+ /** Parse what follows `f.offset` in `buf`: whole lines become events (or problems), the rest is
136
+ * the tail. */
137
+ parse(f, buf) {
138
+ // a tail read before is read again with what came after it, and a file that could not be read
139
+ // before is no longer said to be unreadable
140
+ f.tail = null;
141
+ f.problems = f.problems.filter((p) => p.line > 0 && p.line <= f.lines);
142
+ const take = (text, line, last) => {
143
+ let raw;
144
+ try {
145
+ raw = JSON.parse(text);
146
+ }
147
+ catch {
148
+ f.problems.push({ file: f.file, line, why: last ? `the last line is cut short (${clip(text)})` : `not an event (${clip(text)})`, writer: null });
149
+ return null;
150
+ }
151
+ let e = readEvent(raw, f.agent, f.person);
152
+ // the server writes the moves it found there, and the links it carried with a cluster a build
153
+ // renamed (AttachmentSpec decision 6); they are written nowhere else
154
+ if (e && typeof e !== "string" && f.server !== serversOwn(e))
155
+ e = f.server ? "the server's log holds the moves it found and the links it carried, nothing else" : "a move the server found, or a link it carried, outside its own log";
156
+ // an event of a kind no longer recorded is skipped: read, not a problem
157
+ if (e === null || typeof e !== "string")
158
+ return e;
159
+ f.problems.push({ file: f.file, line, why: `${e} (${clip(text)})`, writer: null });
160
+ return null;
161
+ };
162
+ let at = f.offset;
163
+ for (;;) {
164
+ const nl = buf.indexOf(10, at);
165
+ if (nl < 0)
166
+ break;
167
+ f.hash.update(buf.subarray(at, nl + 1));
168
+ const text = buf.subarray(at, nl).toString("utf8").replace(/\r$/, "");
169
+ f.lines++;
170
+ if (text.trim()) {
171
+ const e = take(text, f.lines, false);
172
+ if (e)
173
+ f.events.push(e);
174
+ }
175
+ at = nl + 1;
176
+ }
177
+ f.offset = at;
178
+ if (at < buf.length) {
179
+ const text = buf.subarray(at).toString("utf8");
180
+ f.tail = { bytes: buf.length - at, event: text.trim() ? take(text, f.lines + 1, true) : null };
181
+ }
182
+ // whose log it is, as its good lines say
183
+ const who = f.events[0]?.by ?? f.tail?.event?.by ?? null;
184
+ for (const p of f.problems)
185
+ p.writer = who;
186
+ }
187
+ /** Replay what was read, and say what changed: the new events alone where they all come after
188
+ * what was replayed before, else the whole review. A log read whole again has events of its own
189
+ * (new objects), and one gone has none: either way what was replayed is no longer where it was,
190
+ * and the review is replayed whole. */
191
+ commit() {
192
+ const logs = [...this.files.values()].map((f) => ({ file: f.file, events: f.tail?.event ? [...f.events, f.tail.event] : f.events }));
193
+ const next = merged(logs).map((x) => this.readAs(x.e));
194
+ const before = this.order;
195
+ const appended = !this.again && next.length >= before.length && before.every((e, k) => next[k] === e);
196
+ this.again = false;
197
+ const base = this.rev;
198
+ this.rev++;
199
+ this.order = next;
200
+ let events = null;
201
+ if (appended) {
202
+ events = next.slice(before.length);
203
+ for (const e of events)
204
+ applyEvent(this.data, e);
205
+ }
206
+ else {
207
+ this.data = emptyReview();
208
+ for (const e of next)
209
+ applyEvent(this.data, e);
210
+ }
211
+ const c = { epoch: this.epoch, base, rev: this.rev, events, problems: this.problems };
212
+ this.onChange?.(c);
213
+ return c;
214
+ }
215
+ readAs(e) {
216
+ if (!this.reading)
217
+ return e;
218
+ let r = this.read_.get(e);
219
+ if (!r)
220
+ this.read_.set(e, (r = this.reading(e)));
221
+ return r;
222
+ }
223
+ /** Read every event through `reading` from now on (a new graph, or new renames, change what an
224
+ * opinion recorded under the kernel's hash is bound to): the whole review is replayed, and the
225
+ * pages are told. */
226
+ setReading(reading) {
227
+ this.reading = reading;
228
+ this.read_ = new WeakMap();
229
+ this.again = true;
230
+ return this.commit();
231
+ }
232
+ /** When a new event is recorded: now — or, where the last event replayed is as late (another
233
+ * writer in the same millisecond), just after it, so that what is appended comes after what was
234
+ * replayed and pages are sent the new event alone. A clock far ahead is not followed. */
235
+ nextTime() {
236
+ const now = Date.now();
237
+ const last = this.order.length ? Date.parse(this.order[this.order.length - 1].at) : 0;
238
+ return new Date(last >= now && last - now < 1000 ? last + 1 : now).toISOString();
239
+ }
240
+ /** Record a (checked) request under its writer's name: one line appended to their log, never
241
+ * anything rewritten. Refused when their log has a line that cannot be read, until it is fixed
242
+ * by hand; a request that changes nothing (a target marked that is one) appends nothing. */
243
+ append(who, r, toolchain) {
244
+ const why = invalid(r);
245
+ if (why)
246
+ return { status: 400, error: why };
247
+ const w = { name: who.name.trim(), agent: who.agent, person: who.person ?? null, server: who.server };
248
+ if (r.by.trim() !== w.name || !!r.agent !== w.agent || (r.person ?? null) !== w.person)
249
+ return { status: 400, error: "an event is recorded under the name and kind of who asks" };
250
+ if (!!w.server !== serversOwn(r))
251
+ return { status: 400, error: "the server records the moves it found and the links it carried, and only it does" };
252
+ // what someone else wrote meanwhile comes first
253
+ this.refresh();
254
+ if (noChange(this.data, r))
255
+ return { change: null, unchanged: true };
256
+ return this.write(this.fileOf(w), () => eventOf(this.data, r, toolchain, this.nextTime()));
257
+ }
258
+ /** Someone invited by a personal link renamed themselves (CollabSpec decision 9): their log says
259
+ * so, a `name` line, so that everything they wrote shows under the new name on every replica.
260
+ * Nothing is written for someone who has no log yet — their first line will carry the name — nor
261
+ * when the log says that name already. */
262
+ named(person, name) {
263
+ this.refresh();
264
+ const file = logFileName(name, false, person);
265
+ const now = Object.hasOwn(this.data.people, person) ? this.data.people[person].name : null;
266
+ if (!this.files.has(file) || now === name.trim())
267
+ return { change: null, unchanged: true };
268
+ return this.write(file, () => nameEvent(person, name, this.nextTime()));
269
+ }
270
+ /** Append the event `make` gives to log `file`: one line, once the file is as it was last read —
271
+ * else what someone else wrote is read first, and the event made again. Refused while the file
272
+ * has a line that cannot be read. */
273
+ write(file, make) {
274
+ for (let tries = 0; tries < 3; tries++) {
275
+ const f = this.files.get(file);
276
+ if (f?.problems.length) {
277
+ const p = f.problems[0];
278
+ return {
279
+ status: 409,
280
+ error: `Your review log (log/${file} in ${this.dir}) has a line that cannot be read — line ${p.line}: ${p.why}. ` +
281
+ "Nothing more is written to it until it is fixed by hand; nothing in it was changed.",
282
+ };
283
+ }
284
+ const e = make();
285
+ const expected = f ? f.offset + (f.tail?.bytes ?? 0) : 0;
286
+ // a last line without its newline (written by hand) ends before this one starts
287
+ const line = Buffer.from((f?.tail?.bytes ? "\n" : "") + JSON.stringify(e) + "\n", "utf8");
288
+ if (!existsSync(this.logDir)) {
289
+ mkdirSync(this.logDir, { recursive: true });
290
+ // what the folder is, for whoever finds it (with the first log, where the
291
+ // server did not make the folder; one already there is left as it is)
292
+ try {
293
+ writeFileSync(join(this.dir, "README.md"), SITE_README, { flag: "wx" });
294
+ }
295
+ catch {
296
+ // there is one
297
+ }
298
+ }
299
+ const fd = openSync(join(this.logDir, file), "a");
300
+ let wrote = false;
301
+ try {
302
+ // the file is as last read, or someone else wrote to it: that is read first, then this
303
+ // is tried again
304
+ if (fstatSync(fd).size === expected) {
305
+ writeSync(fd, line);
306
+ wrote = true;
307
+ }
308
+ }
309
+ finally {
310
+ closeSync(fd);
311
+ }
312
+ if (!wrote) {
313
+ this.refresh();
314
+ continue;
315
+ }
316
+ const c = this.refresh();
317
+ if (!c)
318
+ return { status: 500, error: "the review log was written but could not be read back" };
319
+ return { change: c, event: e };
320
+ }
321
+ return { status: 409, error: `Your review log (log/${file}) kept changing while this was written; try again.` };
322
+ }
323
+ }
324
+ /** Whether an event is one only the server writes, in its own log: a move of a rename it found, or
325
+ * a link it carried with a cluster a build renamed as a whole (server/library.ts); each says why
326
+ * (`auto`). */
327
+ function serversOwn(e) {
328
+ return (e.op === "move" || e.op === "link-edit") && e.auto !== undefined;
329
+ }
330
+ /** A line's start, as a problem quotes it. */
331
+ function clip(text) {
332
+ const t = text.trim();
333
+ return t.length > 60 ? `${t.slice(0, 57)}…` : t;
334
+ }