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,1167 @@
1
+ // The views on a server (ViewSpec.md), kept in `<site>/views.json`: the book (shared/views.ts) of
2
+ // every kept view — the Project, the views people made, arranged or linked to targets —
3
+ // with its scope, clustering, and pins, folds and hidden levels per way of clustering; the views
4
+ // nobody keeps live here only while a page has them open. Each board's layout memory
5
+ // (shared/board.ts) is kept beside it, one file per view in `<site>/view-memory/`.
6
+ //
7
+ // Every change goes out live, per view and with who made it: `views` (the list, and what changed),
8
+ // `view-ops` (pins, folds, hidden levels, notes and the build shown as operations, as `places` did),
9
+ // `view-memory` (a board was laid out for a new set). The timeline records views saved for everyone,
10
+ // renamed, deleted, laid out afresh and kept at a past build (HistorySpec decision 19), and notes
11
+ // written, edited and deleted (shared/sticky.ts), each under who did it as the note records its
12
+ // author: the attribution pins have.
13
+ //
14
+ // A target's link is kept in the record of the view it is linked to (ViewSpec decision 29). A view
15
+ // nobody keeps is not written down, so a link a click made to it goes with it, at a start too; and
16
+ // since marking a target makes no view (the owner's decision of 2026-10-12), nothing is kept of the
17
+ // targets that have none. A `viewless` list an earlier version wrote is not read.
18
+ //
19
+ // The project's notes (AttachmentSpec decisions 13–19, shared/projectnotes.ts) are kept in the same
20
+ // file, beside the views: they go out as `note-ops`, as a view's notes go out in `view-ops`, and the
21
+ // timeline records them written, edited and deleted (their `view` ""), and carried by a build to
22
+ // the cluster their declarations moved to, as an edit by VisualProof; a note attached to a
23
+ // declaration follows it to another name (moveDeclaration). What a note of either kind
24
+ // carries (AttachmentSpec decision 12) is checked against the library as it is written: a file or a
25
+ // clip a note is to carry and did not before must be a file the library has, not archived.
26
+ //
27
+ // A view's items (the owner's decision of 2026-10-11, shared/items.ts) are kept in its record: the
28
+ // notes of the project added to it and taken out of it, changed by the `item` operation, which goes
29
+ // out in `view-ops` as pins do and is an event of the timeline as a scope change is (`items`). A
30
+ // note written in a view is added to it (`arrangeNotes`, `written`), with no event of its own beside
31
+ // the note's; a note deleted, or made a view's own, leaves every list.
32
+ //
33
+ // Who dropped a pin is kept per view beside its pins (shared/views.ts, PinAuthor) and never sent
34
+ // to the pages. What a private reader arranges is recorded without a name (CollabSpec decision 8):
35
+ // `handle` takes its author from identity.ts (`layoutAuthor`), and so does whatever arranges a board
36
+ // through it.
37
+ import { createHash } from "node:crypto";
38
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
39
+ import { join } from "node:path";
40
+ import { CLUSTER_MODES, emptyLayouts } from "../shared/places.js";
41
+ import { cleanQuery, invalidQuery, printQuery, QueryError, parseQuery } from "../shared/query.js";
42
+ import { validPersonId } from "../shared/review.js";
43
+ import { cleanItems, itemIn, itemShown } from "../shared/items.js";
44
+ import { addedNames, filesOf, formerNames, validSha } from "../shared/library.js";
45
+ import { carryProblem, cleanNotes, invalidPlace, MAX_NOTE_TEXT, newCarries, newNoteId, sameCarries } from "../shared/sticky.js";
46
+ import { renameInNote, renameInQuery } from "../shared/moves.js";
47
+ import { carriedNotes } from "../shared/noteplace.js";
48
+ import { applyProjectNoteOp, cleanProjectNotes, invalidClusterPlace, invalidProjectNoteOps, MAX_PROJECT_NOTES } from "../shared/projectnotes.js";
49
+ import { cleanAuthors, invalidViewOps, PROJECT, summary, validVersion, ViewBook, viewTitle } from "../shared/views.js";
50
+ import { refusal } from "./agent.js";
51
+ import { layoutAuthor } from "./identity.js";
52
+ /** Whether an edit changed note `n` as it was before: its text, or what it carries (an attachment
53
+ * added or taken away is an edit of the note, as its text is). */
54
+ const edited = (n, o) => !!n && (n.text !== o.text || (o.carries !== undefined && !sameCarries(n.carries, o.carries)));
55
+ /** how long a view nobody keeps outlives the last page that had it open: a reload comes back */
56
+ const GRACE_MS = 8000;
57
+ /** how long a private page's holds outlive its last event stream (a reload is a new page) */
58
+ const HOLD_MS = 60_000;
59
+ /** the most views one private page holds */
60
+ const MAX_HOLDS = 100;
61
+ /** The most a board's layout memory may be when a page hands it over: room for a board of a large
62
+ * project's declarations many times over (Sphere-Packing-Lean's whole board, 7,851 declarations, is
63
+ * 1.8 MB; 50 MB is the owner's decision of 2026-10-14). */
64
+ export const MAX_MEMORY_BYTES = 50 << 20;
65
+ const isPins = (p) => {
66
+ const o = p;
67
+ const places = (x) => !!x && typeof x === "object" && !Array.isArray(x) && Object.values(x).every((a) => Array.isArray(a) && a.length === 2 && a.every((v) => typeof v === "number" && Number.isFinite(v)));
68
+ return !!o && typeof o === "object" && places(o.nodes) && places(o.clusters);
69
+ };
70
+ /** A kept view as views.json holds it, checked: the view, or why it cannot be used. A field a file
71
+ * written by an older version lacks takes its default. */
72
+ export function readView(raw) {
73
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
74
+ return "it is not a view's record";
75
+ const r = raw;
76
+ if (typeof r.id !== "string" || !r.id)
77
+ return "it has no id";
78
+ const bad = invalidQuery(r.scope);
79
+ if (bad)
80
+ return `its scope cannot be read: ${bad}`;
81
+ const clustering = r.clustering ?? "namespace";
82
+ if (!CLUSTER_MODES.includes(clustering))
83
+ return "its clustering is neither namespace nor module";
84
+ const pins = (r.pins ?? emptyLayouts());
85
+ if (!CLUSTER_MODES.every((m) => isPins(pins[m])))
86
+ return "its pins are not pins";
87
+ const folds = (r.folds ?? { namespace: [], module: [] });
88
+ if (!CLUSTER_MODES.every((m) => Array.isArray(folds[m]) && folds[m].every((k) => typeof k === "string")))
89
+ return "its folds are not lists of clusters";
90
+ const hidden = (r.hidden ?? { namespace: [], module: [] });
91
+ if (!CLUSTER_MODES.every((m) => Array.isArray(hidden[m]) && hidden[m].every((k) => typeof k === "string")))
92
+ return "its hidden levels are not lists of clusters";
93
+ if (r.name !== null && r.name !== undefined && typeof r.name !== "string")
94
+ return "its name is not text";
95
+ // a note that cannot be read is left out; the others are the view's
96
+ const notes = cleanNotes(r.notes);
97
+ // the notes of the project added to it and taken out of it; the Project keeps none, holding all
98
+ const items = r.id === PROJECT ? {} : cleanItems(r.items);
99
+ return {
100
+ id: r.id,
101
+ name: r.name ?? null,
102
+ scope: cleanQuery(r.scope),
103
+ clustering: clustering,
104
+ pins: pins,
105
+ folds: folds,
106
+ ...(notes.length ? { notes } : {}),
107
+ ...items,
108
+ hidden: hidden,
109
+ by: typeof r.by === "string" ? r.by : null,
110
+ at: typeof r.at === "string" ? r.at : new Date(0).toISOString(),
111
+ // who made it: an agent, or someone invited by a personal link (shared/views.ts, marksOf)
112
+ ...(r.agent === true ? { agent: true } : typeof r.by === "string" && validPersonId(r.person) ? { person: r.person } : {}),
113
+ kept: true,
114
+ // the targets linked to it, in the order they were linked; a record written before a view could
115
+ // be several targets' (the owner's decision of 2026-10-11) names its one as `target`, and is read
116
+ // so, that the links people set are not lost
117
+ ...linkedOf(r),
118
+ // the view's focus as set (ViewSpec decision 28): a name, or null for none
119
+ ...(r.focus === null || (typeof r.focus === "string" && r.focus.length > 0 && r.focus.length <= 2000) ? { focus: r.focus } : {}),
120
+ // the build it is kept at (HistorySpec decision 19); a value that is no snapshot's id is dropped
121
+ ...(validVersion(r.version) ? { version: r.version } : {}),
122
+ };
123
+ }
124
+ /** The targets linked to a view as its record on disk says them (ViewData.targets): names, each once. */
125
+ function linkedOf(r) {
126
+ const raw = Array.isArray(r.targets) ? r.targets : typeof r.target === "string" ? [r.target] : [];
127
+ const targets = [...new Set(raw.filter((t) => typeof t === "string" && t.length > 0 && t.length <= 2000))];
128
+ return targets.length ? { targets } : {};
129
+ }
130
+ /** An answer as the page's endpoints give one: `{error}`, and `who` when a name is needed. */
131
+ const no = (status, error, more = {}) => ({ status, body: { error, ...more } });
132
+ const ok = (body) => ({ status: 200, body });
133
+ const NEED_NAME = no(401, "Say who you are first.", { who: true });
134
+ /** A query from a request: its JSON form or its text; why not, when it is neither. */
135
+ export function readQuery(x) {
136
+ if (typeof x === "string") {
137
+ try {
138
+ return parseQuery(x);
139
+ }
140
+ catch (e) {
141
+ return e instanceof QueryError ? `The scope cannot be read at ${e.at}: ${e.message}.` : String(e);
142
+ }
143
+ }
144
+ return cleanQuery(x) ?? `The scope cannot be used: ${invalidQuery(x)}. Send its text (e.g. tcb(A.b) except trusted) or its JSON form.`;
145
+ }
146
+ /** A layout's memory as a page sent it, checked; null when it is not one. */
147
+ function cleanMemory(x) {
148
+ const m = x;
149
+ if (!m || typeof m.set !== "string" || m.set.length > 200 || !m.mem || typeof m.mem !== "object")
150
+ return null;
151
+ const nodes = m.mem.nodes, clusters = m.mem.clusters;
152
+ if (!nodes || !clusters || typeof nodes !== "object" || typeof clusters !== "object")
153
+ return null;
154
+ const num = (v) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) < 1e8;
155
+ let n = 0;
156
+ for (const v of Object.values(nodes))
157
+ if (++n > 200000 || !Array.isArray(v) || v.length !== 3 || !num(v[0]) || !num(v[1]) || typeof v[2] !== "string")
158
+ return null;
159
+ for (const v of Object.values(clusters))
160
+ if (++n > 200000 || !Array.isArray(v) || v.length !== 3 || !v.every(num))
161
+ return null;
162
+ return { set: m.set, mem: { nodes, clusters } };
163
+ }
164
+ export class Views {
165
+ book;
166
+ file;
167
+ memDir;
168
+ layoutVersion;
169
+ send;
170
+ /** what records views made, renamed, deleted and laid out afresh; none for an export */
171
+ timeline;
172
+ /** the Project view laid out afresh: the server lays the graph out anew (server/live.ts) */
173
+ onProjectRelayout = () => { };
174
+ /** who is here now (server/presence.ts): a view nobody keeps lives while one of them has it open */
175
+ peers = () => [];
176
+ /** which page a page's secret stands for (server/live.ts): the `from` of the operations it sends,
177
+ * so that it skips its own when they come back; null when it is no open page's */
178
+ pageOf = () => null;
179
+ /** why a layout's memory does not fit the board it is handed over for, with the status to answer
180
+ * (server/agentviews.ts, `memoryProblem`); the live server reads the board on its graph */
181
+ checkMemory = () => null;
182
+ /** the kept views views.json holds that cannot be read now (UnreadableView) */
183
+ unreadable = [];
184
+ unreadableAuthors = {};
185
+ /** The views private pages hold (CollabSpec decision 8), by the session each page calls itself:
186
+ * such a page says nothing of what it has open, so a view nobody keeps lives while a page that
187
+ * read or opened it while private is open, as it does while it is in the others' tabs. Nobody is
188
+ * told of them. */
189
+ holds = new Map();
190
+ /** whether a page of this session has its event stream open (server/live.ts) */
191
+ sessionOpen = () => false;
192
+ /** when snapshot `id` was built (server/history.ts), or null where there is no such snapshot: a
193
+ * view is kept only at a build the history has (HistorySpec decision 19); none without a history */
194
+ snapshotAt = () => null;
195
+ /** what a view's title is told with (shared/views.ts, viewTitle): the graph on show's names, the
196
+ * history's builds (server/live.ts) */
197
+ titles = () => ({});
198
+ /** the latest build's snapshot, the graph on show's (server/history.ts); null without a history */
199
+ latest = () => null;
200
+ /** whether a name is a target of the review now (server/live.ts): what a view is focused on */
201
+ isTarget = () => true;
202
+ /** the library's record of a file (server/live.ts reads it from the review), against which what a
203
+ * note is to carry is checked; null where nothing checks it (a book read for an export) */
204
+ carriedFile = null;
205
+ /** The files of the library a project note was written for as they came, or that a note carried
206
+ * already then (AttachmentSpec decision 12, the owner's of 2026-10-11): each is considered once,
207
+ * so that a restart writes nothing again and a note deleted stays deleted. */
208
+ filesNoted = new Set();
209
+ /** Whether any note, the project's or a view's, carries file `sha`. */
210
+ carried(sha) {
211
+ const has = (n) => !!n.carries?.some((c) => c.file === sha);
212
+ return this.book.notes.some(has) || this.book.list().some((v) => (v.notes ?? []).some(has));
213
+ }
214
+ /** A file came into the library (its first record, by upload or by URL, from a person or an agent):
215
+ * every item shows on the Project (the owner's decision of 2026-10-11), so a note of the project
216
+ * carries it — collapsed, its text the file's title, written by whoever added the file, at the
217
+ * top level under both clusterings, with no place: on the Project's ring, in the order notes were
218
+ * written, and on another view's only where a link or an add brings it (decisions 16–17). `note` false where the page writes the note itself, at the spot
219
+ * a file was dropped or pasted on the canvas. Nothing where a note carries the file already, or
220
+ * it was considered before. Whether a note was written. */
221
+ fileAdded(f, who, note = true) {
222
+ // its title as notes follow it (followTitles), written down with what follows
223
+ if (this.fileTitles && !this.fileTitles.has(f.sha))
224
+ this.fileTitles.set(f.sha, f.title);
225
+ if (this.filesNoted.has(f.sha))
226
+ return false;
227
+ this.filesNoted.add(f.sha);
228
+ const write = note && !this.carried(f.sha) && this.book.notes.length < MAX_PROJECT_NOTES;
229
+ const n = { id: newNoteId(), text: noteTitle(f.title), places: {}, open: false, by: who.by, at: new Date().toISOString(), carries: [{ file: f.sha }] };
230
+ // a note refused (a file archived meanwhile) leaves the file considered all the same
231
+ if (!write || this.arrangeNotes([{ op: "pnote", note: n }], null, who.by, who.agent, who.person) !== null || !this.book.notes.some((x) => x.id === n.id)) {
232
+ this.save();
233
+ return false;
234
+ }
235
+ return true;
236
+ }
237
+ /** Once, at the start: every file the library has a record of, not archived, that was never
238
+ * considered — the files added before the notes carried them (the owner's decision of
239
+ * 2026-10-11), and those whose records came through the review's logs since the last start —
240
+ * gets its note as one just added would, written by `by` (VisualProof), unless a note carries it
241
+ * already. How many notes were written. */
242
+ noteLibraryFiles(files, by) {
243
+ let made = 0;
244
+ for (const f of files)
245
+ if (!f.archived && this.fileAdded(f, { by, agent: false, person: null }))
246
+ made++;
247
+ return made;
248
+ }
249
+ /** Each file's title as the notes last followed it, by hash (views.json `fileTitles`); null until
250
+ * the notes were first set to their files' titles, once (followTitles). */
251
+ fileTitles = null;
252
+ /** The library's records as the review has them now (server/live.ts calls it at the start and at
253
+ * each change of the review, wherever the change came from): a note's text follows its file's title
254
+ * (the owner's decision of 2026-10-11). Where a file's title is not the one the notes last
255
+ * followed — renamed in the library since, by anyone, on this server or another — every note that
256
+ * carries that file alone and whose text is the title it had, or the name the file was added under,
257
+ * takes the new title, as an edit by `by` (VisualProof); a text someone wrote stays. Once, the first
258
+ * time (no `fileTitles` in views.json): every note that carries one file alone and whose text is any
259
+ * name that file went by before — the name it was added under, the title it was added with, a title
260
+ * an edit took from it (formerNames) — takes its title now, so that the notes made of files renamed
261
+ * before notes followed their titles say them. How many notes followed. */
262
+ followTitles(d, by) {
263
+ const files = filesOf(d);
264
+ let made = 0;
265
+ let first = false;
266
+ if (!this.fileTitles) {
267
+ made += this.retitle(files.map((f) => ({ sha: f.sha, from: formerNames(d, f.sha), to: f.title })), by);
268
+ this.fileTitles = new Map();
269
+ first = true;
270
+ }
271
+ const renamed = [];
272
+ for (const f of files) {
273
+ const was = this.fileTitles.get(f.sha);
274
+ if (was === f.title)
275
+ continue;
276
+ if (was !== undefined)
277
+ renamed.push({ sha: f.sha, from: [was, ...addedNames(f)], to: f.title });
278
+ this.fileTitles.set(f.sha, f.title);
279
+ }
280
+ made += this.retitle(renamed, by);
281
+ // written down where it was done for the first time or a file was renamed, so that the same old
282
+ // title is never followed again; a file seen for the first time is written with whatever is
283
+ // written next (one added here, with the note that carries it: fileAdded)
284
+ if (first || renamed.length)
285
+ this.save();
286
+ return made;
287
+ }
288
+ /** The notes, the project's and the views' own, that carry one of the files `changes` names alone
289
+ * — one file, whole or a clip of it, as their icons show no count — and whose text is one of the
290
+ * names it went by (`from`), each given its title now (`to`), as an edit by `by`. How many. */
291
+ retitle(changes, by) {
292
+ if (!changes.length)
293
+ return 0;
294
+ const of = new Map(changes.map((c) => [c.sha, c]));
295
+ const follows = (n) => {
296
+ const c = n.carries?.length === 1 ? of.get(n.carries[0].file) : undefined;
297
+ if (!c)
298
+ return null;
299
+ const to = noteTitle(c.to);
300
+ return n.text !== to && c.from.some((t) => t === n.text || noteTitle(t) === n.text) ? to : null;
301
+ };
302
+ let made = 0;
303
+ const edits = [];
304
+ for (const n of this.book.notes) {
305
+ const text = follows(n);
306
+ if (text !== null)
307
+ edits.push({ op: "pnote-edit", id: n.id, text });
308
+ }
309
+ if (edits.length && this.arrangeNotes(edits, null, by) === null)
310
+ made += edits.length;
311
+ for (const v of this.book.list()) {
312
+ const ops = [];
313
+ for (const n of v.notes ?? []) {
314
+ const text = follows(n);
315
+ if (text !== null)
316
+ ops.push({ op: "note-edit", id: n.id, text });
317
+ }
318
+ if (ops.length && this.arrange(v.id, ops, null, by) === null)
319
+ made += ops.length;
320
+ }
321
+ return made;
322
+ }
323
+ /** Why operations on notes cannot be taken for what they would make a note carry, or null: each
324
+ * file or clip a note is to carry and did not carry before (`had`, by the note's id) is checked
325
+ * against the library — a carry it has already stays, whatever became of its file since. */
326
+ carriesRefused(ops, had) {
327
+ const file = this.carriedFile;
328
+ if (!file)
329
+ return null;
330
+ for (const o of ops) {
331
+ const fresh = o.op === "pnote" || o.op === "note" ? newCarries(o.note.carries, undefined) : o.op === "pnote-edit" || o.op === "note-edit" ? newCarries(o.carries, had(o.id)) : [];
332
+ for (const c of fresh) {
333
+ const why = carryProblem(c, file(c.file));
334
+ if (why)
335
+ return why;
336
+ }
337
+ }
338
+ return null;
339
+ }
340
+ /** The past build view `v` shows: the snapshot it is kept at, unless that is the latest build's;
341
+ * null for the graph on show. Its board is that build's graph (HistorySpec decision 19). */
342
+ pastOf(v) {
343
+ return v.version === undefined || v.version === this.latest() ? null : v.version;
344
+ }
345
+ constructor(siteDir, layoutVersion, send, timeline = null) {
346
+ this.file = join(siteDir, "views.json");
347
+ this.memDir = join(siteDir, "view-memory");
348
+ this.layoutVersion = layoutVersion;
349
+ this.send = send;
350
+ this.timeline = timeline;
351
+ let views = [];
352
+ let authors = {};
353
+ let notes = [];
354
+ if (existsSync(this.file)) {
355
+ try {
356
+ const raw = JSON.parse(readFileSync(this.file, "utf8"));
357
+ // a view that cannot be read is kept aside, not dropped: the next save writes it back
358
+ for (const r of Array.isArray(raw.views) ? raw.views : []) {
359
+ const v = readView(r);
360
+ if (typeof v !== "string")
361
+ views.push(v);
362
+ else {
363
+ const o = (r && typeof r === "object" ? r : { record: r });
364
+ this.unreadable.push({ id: typeof o.id === "string" ? o.id : null, name: typeof o.name === "string" ? o.name : null, why: v, raw: o });
365
+ }
366
+ }
367
+ // pins dropped under another way of laying out mean nothing in this one (CanvasSpec)
368
+ if (raw.layoutVersion !== layoutVersion) {
369
+ for (const v of views)
370
+ v.pins = emptyLayouts();
371
+ for (const u of this.unreadable)
372
+ if (u.raw.pins !== undefined)
373
+ u.raw.pins = emptyLayouts();
374
+ }
375
+ authors = raw.authors && typeof raw.authors === "object" ? raw.authors : {};
376
+ notes = cleanProjectNotes(raw.notes);
377
+ if (Array.isArray(raw.filesNoted))
378
+ for (const sha of raw.filesNoted)
379
+ if (typeof sha === "string" && /^[0-9a-f]{64}$/.test(sha))
380
+ this.filesNoted.add(sha);
381
+ if (raw.fileTitles && typeof raw.fileTitles === "object" && !Array.isArray(raw.fileTitles))
382
+ this.fileTitles = new Map(Object.entries(raw.fileTitles).filter((e) => validSha(e[0]) && typeof e[1] === "string"));
383
+ }
384
+ catch {
385
+ // unreadable: the views start afresh, the file is kept aside to look at
386
+ renameSync(this.file, join(siteDir, `views.broken-${Date.now()}.json`));
387
+ }
388
+ }
389
+ for (const u of this.unreadable) {
390
+ if (u.id && authors[u.id] !== undefined)
391
+ this.unreadableAuthors[u.id] = authors[u.id];
392
+ console.error(`views: ${u.name ?? u.id ?? "a view"} in ${this.file} cannot be read (${u.why}); it is kept there as it is`);
393
+ }
394
+ this.book = new ViewBook(views, notes);
395
+ for (const v of this.book.kept())
396
+ if (authors[v.id])
397
+ this.book.authors.set(v.id, cleanAuthors(authors[v.id], v.pins));
398
+ for (const v of this.book.kept()) {
399
+ try {
400
+ this.book.memory.set(v.id, JSON.parse(readFileSync(this.memFile(v.id), "utf8")));
401
+ }
402
+ catch {
403
+ // no memory yet: the board is laid out afresh the first time it is shown
404
+ }
405
+ }
406
+ setInterval(() => this.sweep(), GRACE_MS / 2).unref();
407
+ }
408
+ /** The views nobody keeps and no page has open go: what is open now, not only what changed last
409
+ * (a page that sits on a view says nothing new), and what private pages hold. */
410
+ sweep(now = Date.now()) {
411
+ this.seenPeers(this.peers(), now);
412
+ this.book.seen(this.held(now), now);
413
+ this.changed(this.book.sweep(now, GRACE_MS));
414
+ }
415
+ /** A private page (by its session) has these views open: they live while it does. */
416
+ hold(session, ids) {
417
+ const h = this.holds.get(session) ?? { ids: new Set(), at: 0 };
418
+ for (const id of ids)
419
+ if (h.ids.size < MAX_HOLDS && this.book.get(id)?.kept === false)
420
+ h.ids.add(id);
421
+ h.at = Date.now();
422
+ this.holds.set(session, h);
423
+ this.book.seen(h.ids);
424
+ }
425
+ /** A page showed itself again: what it has open, its heartbeats say. */
426
+ release(session) {
427
+ this.holds.delete(session);
428
+ }
429
+ /** The views held by private pages that are open, or were a moment ago. */
430
+ held(now) {
431
+ const out = [];
432
+ for (const [session, h] of this.holds) {
433
+ if (!this.sessionOpen(session) && now - h.at > HOLD_MS) {
434
+ this.holds.delete(session);
435
+ continue;
436
+ }
437
+ out.push(...h.ids);
438
+ }
439
+ return out;
440
+ }
441
+ /** The Project view's pins (ViewPlaces). */
442
+ get places() {
443
+ return this.placesOf(PROJECT);
444
+ }
445
+ /** A view's pins (the Project's, by default), read and changed with who dropped each. The
446
+ * pages are told (`view-ops`). */
447
+ placesOf(id = PROJECT) {
448
+ const book = this.book;
449
+ const version = this.layoutVersion;
450
+ return {
451
+ get data() {
452
+ return { layoutVersion: version, layouts: book.get(id)?.pins ?? emptyLayouts() };
453
+ },
454
+ apply: (ops, who) => {
455
+ const r = book.arrange(id, ops, who?.by ?? null, who);
456
+ if (r === null || typeof r === "string")
457
+ return false;
458
+ // an agent's pins keep an open view too
459
+ if (r.length)
460
+ this.changed(r, !!who?.agent);
461
+ else
462
+ this.save();
463
+ return true;
464
+ },
465
+ authorOf: (mode, kind, key) => book.authorOf(id, mode, kind, key),
466
+ };
467
+ }
468
+ list() {
469
+ return this.book.list().map(summary);
470
+ }
471
+ /** The kept views that cannot be read now, as the lists say them: which, and why. */
472
+ unreadableList() {
473
+ return this.unreadable.filter((u) => !u.id || !this.book.get(u.id)).map(({ id, name, why }) => ({ id, name, why }));
474
+ }
475
+ /** Pages have these views open (presence: each page's view and tabs). */
476
+ seenPeers(peers, now = Date.now()) {
477
+ const ids = [];
478
+ for (const p of peers) {
479
+ if (!p.view)
480
+ continue;
481
+ if (p.view.view)
482
+ ids.push(p.view.view);
483
+ ids.push(...p.view.tabs);
484
+ }
485
+ this.book.seen(ids, now);
486
+ }
487
+ /** The review's targets changed: a view whose target was unmarked stays, unlinked, and one a move
488
+ * brought from another name (`formerly`) takes that name's view over (ViewSpec decision 29); a
489
+ * target marked makes no view (the owner's decision of 2026-10-12). The timeline has the targets
490
+ * already. */
491
+ syncTargets(targets, formerly) {
492
+ this.changed(this.book.syncTargets(targets, formerly), false, null, false);
493
+ }
494
+ /** The view a click on target `target` opens, by `by` (ViewSpec decision 29, the owner's decision
495
+ * of 2026-10-11; shared/views.ts, `openTarget`): the one it is linked to, else a view nobody keeps
496
+ * of its TCB, linked to it while it lasts. The pages are told; the timeline is not, as it is not
497
+ * of a view opened. Whether `target` is a target is the caller's to say. */
498
+ openTarget(target, by, agent = false, person = null) {
499
+ const r = this.book.openTarget(target, by, Date.now(), { agent, person });
500
+ this.changed(r.changes, agent, person);
501
+ return r.view;
502
+ }
503
+ /** Declaration `from` is `to` now (a move, HistorySpec.md): on every view, its pins are moved to
504
+ * `to` where `to` has none, its full name in backticks in a note's text is written as `to`, a note
505
+ * attached to it is attached to `to` (the owner's decision of 2026-10-12: the attachment follows
506
+ * renames, at the same offset), and a scope that names it names `to` — through the views' own
507
+ * operations, recorded under `by` (VisualProof for a rename it found), as anyone's changes are.
508
+ * What each view changed: its operations, and its scope before and after (`unmoveDeclaration`
509
+ * undoes them). */
510
+ moveDeclaration(from, to, by, agent = false) {
511
+ const out = [];
512
+ for (const v of this.book.list()) {
513
+ const ops = [];
514
+ for (const mode of CLUSTER_MODES) {
515
+ const nodes = v.pins[mode].nodes;
516
+ if (!Object.hasOwn(nodes, from) || Object.hasOwn(nodes, to))
517
+ continue;
518
+ ops.push({ op: "pin", mode, kind: "node", key: to, at: nodes[from] }, { op: "unpin", mode, kind: "node", key: from });
519
+ }
520
+ for (const n of v.notes ?? []) {
521
+ const text = renameInNote(n.text, from, to);
522
+ if (text !== n.text)
523
+ ops.push({ op: "note-edit", id: n.id, text });
524
+ if (n.decl?.name === from)
525
+ ops.push({ op: "note-decl", id: n.id, decl: { name: to, at: n.decl.at } });
526
+ }
527
+ if (ops.length)
528
+ this.arrange(v.id, ops, null, by, agent);
529
+ // a target's view too: its scope can be changed as any view's (ViewSpec decision 28), and the
530
+ // view goes with its target (ViewBook.retarget)
531
+ let scope;
532
+ const q = renameInQuery(v.scope, from, to);
533
+ if (q !== v.scope) {
534
+ scope = [printQuery(v.scope), printQuery(q)];
535
+ const c = this.book.setScope(v.id, q, by, false);
536
+ if (c && typeof c !== "string")
537
+ this.changed(c, agent);
538
+ }
539
+ // a view focused on it is focused on it under its new name
540
+ const focus = v.focus === from;
541
+ if (focus) {
542
+ const c = this.book.setFocus(v.id, to, by, false);
543
+ if (c && typeof c !== "string")
544
+ this.changed(c, agent);
545
+ }
546
+ if (ops.length || scope || focus)
547
+ out.push({ view: v.id, ops, ...(scope ? { scope } : {}), ...(focus ? { focus: true } : {}) });
548
+ }
549
+ // the project's notes name it under its new name too, and those attached to it are attached to
550
+ // it under its new name
551
+ const edits = [];
552
+ for (const n of this.book.notes) {
553
+ const text = renameInNote(n.text, from, to);
554
+ if (text !== n.text)
555
+ edits.push({ op: "pnote-edit", id: n.id, text });
556
+ if (n.decl?.name === from)
557
+ edits.push({ op: "pnote-decl", id: n.id, decl: { name: to, at: n.decl.at } });
558
+ }
559
+ if (edits.length) {
560
+ this.arrangeNotes(structuredClone(edits), null, by, agent);
561
+ out.push({ view: "", ops: edits });
562
+ }
563
+ return out;
564
+ }
565
+ /** A move of a declaration on the views undone, as a move back, on what it changed alone: on each
566
+ * view it changed, a pin it moved to `to` goes back to `from` (where `from` has none since), a
567
+ * note it edited names `from` again where it names `to` — what else was edited since stays —, a
568
+ * note it attached to `to` is attached to `from` again where it is still attached to `to`, and a
569
+ * scope it changed names `from` again, and a focus `from` again; the view of a target that went
570
+ * with it comes back with it (`target`). */
571
+ unmoveDeclaration(from, to, views, by, agent = false, target = false) {
572
+ if (target)
573
+ this.changed(this.book.retarget(to, from, by), agent);
574
+ for (const e of views) {
575
+ if (e.view === "") {
576
+ // the project's notes it edited name `from` again, where they name `to`
577
+ const back = [];
578
+ for (const o of e.ops) {
579
+ if (o.op === "pnote-decl") {
580
+ const n = this.book.notes.find((x) => x.id === o.id);
581
+ if (n?.decl?.name === to)
582
+ back.push({ op: "pnote-decl", id: n.id, decl: { name: from, at: n.decl.at } });
583
+ continue;
584
+ }
585
+ const n = o.op === "pnote-edit" ? this.book.notes.find((x) => x.id === o.id) : undefined;
586
+ const text = n ? renameInNote(n.text, to, from) : null;
587
+ if (n && text !== n.text)
588
+ back.push({ op: "pnote-edit", id: n.id, text: text });
589
+ }
590
+ if (back.length)
591
+ this.arrangeNotes(back, null, by, agent);
592
+ continue;
593
+ }
594
+ const v = this.book.get(e.view);
595
+ if (!v)
596
+ continue;
597
+ const ops = [];
598
+ for (const o of e.ops) {
599
+ if (o.op === "pin" && o.kind === "node") {
600
+ const nodes = v.pins[o.mode].nodes;
601
+ if (Object.hasOwn(nodes, to) && !Object.hasOwn(nodes, from))
602
+ ops.push({ op: "pin", mode: o.mode, kind: "node", key: from, at: nodes[to] }, { op: "unpin", mode: o.mode, kind: "node", key: to });
603
+ }
604
+ else if (o.op === "note-edit") {
605
+ const n = v.notes?.find((x) => x.id === o.id);
606
+ const text = n ? renameInNote(n.text, to, from) : null;
607
+ if (n && text !== n.text)
608
+ ops.push({ op: "note-edit", id: n.id, text: text });
609
+ }
610
+ else if (o.op === "note-decl") {
611
+ const n = v.notes?.find((x) => x.id === o.id);
612
+ if (n?.decl?.name === to)
613
+ ops.push({ op: "note-decl", id: n.id, decl: { name: from, at: n.decl.at } });
614
+ }
615
+ }
616
+ if (ops.length)
617
+ this.arrange(v.id, ops, null, by, agent);
618
+ if (e.scope) {
619
+ const q = renameInQuery(v.scope, to, from);
620
+ if (q !== v.scope) {
621
+ const c = this.book.setScope(v.id, q, by, false);
622
+ if (c && typeof c !== "string")
623
+ this.changed(c, agent);
624
+ }
625
+ }
626
+ if (e.focus && v.focus === to) {
627
+ const c = this.book.setFocus(v.id, from, by, false);
628
+ if (c && typeof c !== "string")
629
+ this.changed(c, agent);
630
+ }
631
+ }
632
+ }
633
+ /** Pins, folds and notes of view `id`, from page `from` (a session) or an agent, under `by` (and
634
+ * `person`, the personal link of whoever it is, CollabSpec decision 9). A note written, edited or
635
+ * deleted is an event of the timeline, under the name its note records. */
636
+ arrange(id, ops, from, by, agent = false, person = null) {
637
+ const unknown = this.unknownVersion(ops.map((o) => (o.op === "version" ? o.snapshot : null)));
638
+ if (unknown)
639
+ return unknown;
640
+ const before = new Map((this.book.get(id)?.notes ?? []).map((n) => [n.id, { ...n }]));
641
+ const carries = this.carriesRefused(ops, (n) => before.get(n)?.carries);
642
+ if (carries)
643
+ return carries;
644
+ // which list held each note an item operation names, for the timeline
645
+ const listed = structuredClone(this.book.get(id)?.items);
646
+ const who = { by, agent, person: by ? person : null };
647
+ const r = this.book.arrange(id, ops, by, who);
648
+ if (typeof r === "string")
649
+ return r;
650
+ if (r === null)
651
+ return null;
652
+ this.send("view-ops", { id, ops, from, by });
653
+ this.changed(r, agent, who.person);
654
+ if (!r.length)
655
+ this.save();
656
+ this.noted(id, ops, before, who);
657
+ this.itemsNoted(id, ops, listed, who);
658
+ return null;
659
+ }
660
+ /** The timeline's events of the notes of the project item operations `ops` added to view `id` or
661
+ * took out of it (`listed`: its lists as they were), as a scope changed is: under who did it, with
662
+ * the note's text then and whether the view shows it by it. */
663
+ itemsNoted(id, ops, listed, who) {
664
+ const v = this.book.get(id);
665
+ if (!v || !this.timeline)
666
+ return;
667
+ let was = listed;
668
+ for (const o of ops) {
669
+ if (o.op !== "item")
670
+ continue;
671
+ const from = itemIn(was, o.note);
672
+ if (from === o.in)
673
+ continue;
674
+ // each operation read against the lists as the ones before it left them
675
+ was = structuredClone(was) ?? { add: [], remove: [] };
676
+ if (from)
677
+ was[from] = was[from].filter((x) => x !== o.note);
678
+ if (o.in)
679
+ was[o.in].push(o.note);
680
+ const n = this.book.notes.find((x) => x.id === o.note);
681
+ this.timeline.add({ kind: "view", by: who.by, ...(who.agent ? { agent: true } : {}), ...(who.person ? { person: who.person } : {}), what: "items", view: id, name: v.name, scope: printQuery(v.scope), note: o.note, text: n?.text ?? "", shown: itemShown(from, o) });
682
+ }
683
+ }
684
+ /** Taken off every view's lists, for the pages: what `drops` did (a note no longer the project's,
685
+ * shared/views.ts dropItems). Nobody's change of the view — no event of the timeline, no view kept
686
+ * by it, no word on the board: the note's own event says what happened. */
687
+ dropped(drops) {
688
+ for (const d of drops)
689
+ this.send("view-ops", { id: d.view, ops: [d.op], from: null, by: null });
690
+ }
691
+ /** The project's notes changed by `ops` (shared/projectnotes.ts), from page `from` or an agent,
692
+ * under `by` (and `person`): every page is told (`note-ops`), and a note written, edited or
693
+ * deleted is an event of the timeline under the name its note records, as a view's is. A note
694
+ * written in view `written` (the board on show where it was written, or the view an agent names)
695
+ * is added to it (the owner's decision of 2026-10-11); a note deleted leaves every view's lists.
696
+ * Why the operations were refused, or null (taken, whether or not they changed anything). */
697
+ arrangeNotes(ops, from, by, agent = false, person = null, written = null) {
698
+ // held to what a page's request is held to, whichever path it came by: a place a file could
699
+ // not read back would lose the note at the next start
700
+ const why = invalidProjectNoteOps(ops);
701
+ if (why)
702
+ return why;
703
+ const before = new Map(this.book.notes.map((n) => [n.id, { ...n }]));
704
+ const carries = this.carriesRefused(ops, (n) => before.get(n)?.carries);
705
+ if (carries)
706
+ return carries;
707
+ const who = { by, agent, person: by ? person : null };
708
+ if (!this.book.noteOps(ops, who))
709
+ return null;
710
+ this.send("note-ops", { ops, from, by });
711
+ const now = new Set(this.book.notes.map((n) => n.id));
712
+ // a note deleted is in no view's lists any more
713
+ for (const o of ops)
714
+ if (o.op === "pnote-delete" && before.has(o.id) && !now.has(o.id))
715
+ this.dropped(this.book.dropItems(o.id));
716
+ // what was written in a view is that view's, wherever it is put: added to it, as the page that
717
+ // wrote it has done already (`from`)
718
+ const fresh = ops.flatMap((o) => (o.op === "pnote" && now.has(o.note.id) && !before.has(o.note.id) ? [o.note.id] : []));
719
+ const w = written && fresh.length ? this.book.written(written, fresh, by, who) : null;
720
+ if (w) {
721
+ this.send("view-ops", { id: written, ops: w.ops, from, by });
722
+ this.changed(w.changes, agent, who.person);
723
+ }
724
+ this.save();
725
+ if (!this.timeline)
726
+ return null;
727
+ for (const o of ops) {
728
+ const e = o.op === "pnote" && now.has(o.note.id) && !before.has(o.note.id)
729
+ ? { what: "created", note: o.note.id, text: o.note.text }
730
+ : o.op === "pnote-edit" && now.has(o.id) && edited(before.get(o.id), o)
731
+ ? { what: "edited", note: o.id, text: o.text }
732
+ : o.op === "pnote-delete" && before.has(o.id) && !now.has(o.id)
733
+ ? { what: "deleted", note: o.id, text: before.get(o.id).text }
734
+ : null;
735
+ if (e)
736
+ this.timeline.add({ kind: "note", ...e, by: who.by, ...(who.agent ? { agent: true } : {}), ...(who.person ? { person: who.person } : {}), view: "", title: "" });
737
+ }
738
+ return null;
739
+ }
740
+ /** A note of the project made view `view`'s own, or view `view`'s made the project's (`to`), at
741
+ * `place` where it is on that view's board clustered by `mode` (shared/views.ts, switchNote):
742
+ * every page is told on both sides, and nobody becomes its author by it. Why not, or null. */
743
+ switchNote(id, view, to, place, mode, from, by, agent = false, person = null) {
744
+ const r = this.book.switchNote(id, view, to, place, mode, by);
745
+ if (typeof r === "string")
746
+ return r;
747
+ this.send("note-ops", { ops: r.notes, from, by });
748
+ this.send("view-ops", { id: view, ops: r.view, from, by });
749
+ this.dropped(r.drops);
750
+ this.changed(r.changes, agent, person);
751
+ if (!r.changes.length)
752
+ this.save();
753
+ return null;
754
+ }
755
+ /** A new build moved whole clusters (AttachmentSpec decision 18; shared/noteplace.ts,
756
+ * carriedNotes): each project note in a cluster that went, all of it, to another — a folder or a
757
+ * namespace renamed — goes with it, as an edit by `by` (VisualProof) that the timeline records
758
+ * with where it was carried from and to. `before` and `after` are the two builds' declarations,
759
+ * `renamed` the renames of high confidence found between them. How many notes were carried. */
760
+ carryNotes(before, after, renamed, by) {
761
+ const moves = carriedNotes(this.book.notes, before, after, renamed);
762
+ if (!moves.length)
763
+ return 0;
764
+ const at = new Date().toISOString();
765
+ const ops = moves.map((m) => ({ op: "pnote-move", id: m.id, mode: m.mode, place: m.place, by, at }));
766
+ // applied as they are: a carry is the server's own edit, stamped here, not taken from whoever asks
767
+ let changed = false;
768
+ for (const o of ops)
769
+ if (applyProjectNoteOp(this.book.notes, o))
770
+ changed = true;
771
+ if (!changed)
772
+ return 0;
773
+ this.send("note-ops", { ops, from: null, by });
774
+ this.save();
775
+ for (const m of moves) {
776
+ const n = this.book.notes.find((x) => x.id === m.id);
777
+ if (n)
778
+ this.timeline?.add({ kind: "note", what: "edited", note: n.id, text: n.text, by, view: "", title: "", carried: { mode: m.mode, from: m.from, to: m.to } });
779
+ }
780
+ return moves.length;
781
+ }
782
+ /** Why a version cannot be kept, of those asked for (null: the latest), or null. */
783
+ unknownVersion(snaps) {
784
+ for (const s of snaps)
785
+ if (typeof s === "number" && this.snapshotAt(s) === null)
786
+ return `There is no snapshot ${s}: GET /api/history lists them.`;
787
+ return null;
788
+ }
789
+ /** The timeline's events of the notes `ops` wrote, edited or deleted on view `id` (`before`: its
790
+ * notes as they were), under who did it as the notes record it; opening, collapsing and moving a
791
+ * note are not events, as a drag is not. */
792
+ noted(id, ops, before, who) {
793
+ const v = this.book.get(id);
794
+ if (!v || !this.timeline)
795
+ return;
796
+ const now = new Set((v.notes ?? []).map((n) => n.id));
797
+ const title = viewTitle(v, this.titles());
798
+ for (const o of ops) {
799
+ const e = o.op === "note" && now.has(o.note.id) && !before.has(o.note.id)
800
+ ? { what: "created", note: o.note.id, text: o.note.text }
801
+ : o.op === "note-edit" && now.has(o.id) && edited(before.get(o.id), o)
802
+ ? { what: "edited", note: o.id, text: o.text }
803
+ : o.op === "note-delete" && before.has(o.id) && !now.has(o.id)
804
+ ? { what: "deleted", note: o.id, text: before.get(o.id).text }
805
+ : null;
806
+ if (e)
807
+ this.timeline.add({ kind: "note", ...e, by: who.by, ...(who.agent ? { agent: true } : {}), ...(who.person ? { person: who.person } : {}), view: id, title });
808
+ }
809
+ }
810
+ /** Tell the pages, write down what is kept, record what the timeline keeps (`person`: the
811
+ * personal link of whoever made the changes; `record` false for what the server does by itself
812
+ * with the targets, which the timeline has already). */
813
+ changed(changes, agent = false, person = null, record = true) {
814
+ if (!changes.length)
815
+ return;
816
+ for (const c of changes) {
817
+ const v = this.book.get(c.id);
818
+ if (c.what === "kept" && v)
819
+ this.saveMemory(v.id);
820
+ if (c.what === "deleted" || c.what === "gone")
821
+ rmSync(this.memFile(c.id), { force: true });
822
+ // what the timeline keeps: views saved for everyone, renamed, rescoped, deleted, laid out
823
+ // afresh, under a name or by someone without one (a private reader, CollabSpec decision 8); a
824
+ // target's view's scope changed is said as any view's (ViewSpec decision 28)
825
+ const what = c.what === "kept" ? "created" : c.what;
826
+ const rescoped = what === "scope" && !!v;
827
+ // kept at a past build: which one, and when it was built, so that the timeline says it alone
828
+ const version = what === "version" ? { snapshot: c.snapshot ?? null, ...(typeof c.snapshot === "number" && this.snapshotAt(c.snapshot) ? { built: this.snapshotAt(c.snapshot) } : {}) } : {};
829
+ // a target linked to a view (ViewSpec decision 29), by someone: said once, of the view it is
830
+ // linked to now; the view it left only stops being the target's. A click on a target with no
831
+ // view links it to a view nobody keeps, which is no more an event than opening one is. A view
832
+ // deleted that targets were linked to says those targets, which have no view now, in the same
833
+ // event (the owner's decisions of 2026-10-11)
834
+ const linked = what === "linked" && typeof c.target === "string" && !c.temporary && record;
835
+ const unlinked = what === "deleted" && !!c.targets?.length;
836
+ if (what === "created" || what === "renamed" || rescoped || what === "deleted" || what === "relayout" || what === "version" || linked)
837
+ this.timeline?.add({ kind: "view", by: c.by, ...(agent ? { agent: true } : {}), ...(person && c.by ? { person } : {}), what: what, view: c.id, name: v?.name ?? null, ...(c.was !== undefined ? { was: c.was } : {}), ...(linked ? { target: c.target } : {}), ...(unlinked ? { targets: c.targets } : {}), scope: v ? printQuery(v.scope) : c.scope ?? "", ...version });
838
+ }
839
+ this.save();
840
+ this.send("views", { views: this.list(), changes });
841
+ }
842
+ save() {
843
+ const kept = this.book.kept();
844
+ const authors = Object.fromEntries(kept.flatMap((v) => (this.book.authors.has(v.id) ? [[v.id, this.book.authors.get(v.id)]] : [])));
845
+ // the views that could not be read go back as they came, unless one of the same id took their
846
+ // place since
847
+ const aside = this.unreadable.filter((u) => !u.id || !this.book.get(u.id));
848
+ for (const u of aside)
849
+ if (u.id && this.unreadableAuthors[u.id] !== undefined)
850
+ authors[u.id] = this.unreadableAuthors[u.id];
851
+ const data = {
852
+ layoutVersion: this.layoutVersion,
853
+ views: [...kept, ...aside.map((u) => ({ ...u.raw, unreadable: u.why }))],
854
+ authors,
855
+ ...(this.book.notes.length ? { notes: this.book.notes } : {}),
856
+ ...(this.filesNoted.size ? { filesNoted: [...this.filesNoted] } : {}),
857
+ ...(this.fileTitles ? { fileTitles: Object.fromEntries(this.fileTitles) } : {}),
858
+ };
859
+ const tmp = `${this.file}.tmp-${process.pid}`;
860
+ writeFileSync(tmp, JSON.stringify(data) + "\n");
861
+ renameSync(tmp, this.file);
862
+ }
863
+ memFile(id) {
864
+ return join(this.memDir, createHash("sha1").update(id).digest("hex").slice(0, 20) + ".json");
865
+ }
866
+ saveMemory(id) {
867
+ const m = this.book.memory.get(id);
868
+ if (!m || !this.book.get(id)?.kept)
869
+ return;
870
+ mkdirSync(this.memDir, { recursive: true });
871
+ const tmp = `${this.memFile(id)}.tmp-${process.pid}`;
872
+ writeFileSync(tmp, JSON.stringify(m));
873
+ renameSync(tmp, this.memFile(id));
874
+ }
875
+ /** A board's layout memory, handed over by a page or the agents' API (compare and set). */
876
+ setMemory(id, mode, base, next) {
877
+ const r = this.book.setMemory(id, mode, base, next);
878
+ if (typeof r === "string" || !r.accepted)
879
+ return r;
880
+ this.saveMemory(id);
881
+ this.send("view-memory", { id, mode, base, set: next.set });
882
+ return r;
883
+ }
884
+ /** Lay a view out afresh: its pins and its layout's memory go; the Project's graph is laid out
885
+ * anew by the server too. The timeline says who did it, and whether an agent did. */
886
+ relayout(id, by, agent = false, person = null) {
887
+ const c = this.book.relayout(id, by);
888
+ if (typeof c === "string")
889
+ return c;
890
+ rmSync(this.memFile(id), { force: true });
891
+ this.send("view-ops", { id, ops: [{ op: "clear" }], from: null, by });
892
+ for (const mode of CLUSTER_MODES)
893
+ this.send("view-memory", { id, mode, base: null, set: null });
894
+ this.changed([c], agent, person);
895
+ if (id === PROJECT)
896
+ this.onProjectRelayout();
897
+ return null;
898
+ }
899
+ // ---------------------------------------------------------------- the page's endpoints
900
+ /** A request under /api/views, read by main.ts: what the page asks, under the name it gives. An
901
+ * agent may use them too, within what `--agents` allows (AgentSpec decision 9): opening a view
902
+ * is reading; everything else, deleting a view and laying one out afresh included, is level
903
+ * full, recorded as the agent's. A private reader's changes are recorded without a name, and
904
+ * need none (CollabSpec decision 8). */
905
+ handle(a, level) {
906
+ // who what is done here is recorded under: the caller's name, or none for a private reader
907
+ // (identity.ts, layoutAuthor); what needs a name is what records one
908
+ const me = layoutAuthor(a.caller);
909
+ const nameless = !me && !a.caller.private;
910
+ // the personal link of whoever it is (CollabSpec decision 9), with their name only: never with
911
+ // what a private reader arranges
912
+ const person = me ? a.caller.person ?? null : null;
913
+ // a private page holds the views it reads or opens: it says nothing else of what it has open
914
+ const session = a.caller.private?.session ?? null;
915
+ const holds = (id) => {
916
+ if (session)
917
+ this.hold(session, [id]);
918
+ };
919
+ const agent = a.caller.kind === "agent";
920
+ const b = (a.body && typeof a.body === "object" ? a.body : {});
921
+ const id = typeof b.id === "string" ? b.id : a.query.get("id") ?? "";
922
+ const mode = (typeof b.mode === "string" ? b.mode : a.query.get("mode") ?? "namespace");
923
+ const arranging = () => (agent ? (me ? refusal(level, "full") : NEED_NAME) : null);
924
+ const result = (r, body = () => ({ ok: true })) => {
925
+ if (typeof r === "string")
926
+ return no(r.startsWith("There is no view") ? 404 : 400, r);
927
+ this.changed(r === null ? [] : Array.isArray(r) ? r : [r], agent, person);
928
+ return ok(body());
929
+ };
930
+ const path = a.path.replace(/\/+$/, "");
931
+ if (a.method === "GET") {
932
+ if (path === "/api/views")
933
+ return ok({ views: this.list(), ...(this.unreadable.length ? { unreadable: this.unreadableList() } : {}) });
934
+ if (path === "/api/views/view") {
935
+ const v = this.book.get(id);
936
+ if (!v)
937
+ return no(404, `There is no view ${id}.`);
938
+ holds(v.id);
939
+ const memory = {};
940
+ for (const m of CLUSTER_MODES) {
941
+ const s = this.book.memoryOf(id, m)?.set;
942
+ if (s)
943
+ memory[m] = s;
944
+ }
945
+ return ok({ view: v, memory });
946
+ }
947
+ // the project's notes, every one (AttachmentSpec decision 13): each page decides which its
948
+ // board shows
949
+ if (path === "/api/views/notes")
950
+ return ok({ notes: this.book.notes });
951
+ if (path === "/api/views/memory") {
952
+ if (!this.book.get(id))
953
+ return no(404, `There is no view ${id}.`);
954
+ if (!CLUSTER_MODES.includes(mode))
955
+ return no(400, "mode is namespace or module.");
956
+ return ok({ memory: this.book.memoryOf(id, mode) });
957
+ }
958
+ return no(404, "not found");
959
+ }
960
+ if (a.method !== "POST")
961
+ return no(405, `${a.path} answers GET and POST.`);
962
+ switch (path) {
963
+ case "/api/views/open": {
964
+ const q = readQuery(b.scope);
965
+ if (typeof q === "string")
966
+ return no(400, q);
967
+ // a view made from a board of a past build shows that build (the owner's decision of
968
+ // 2026-10-09): found or made by its query and the build together
969
+ if (b.version !== undefined && b.version !== null && !validVersion(b.version))
970
+ return no(400, "version is a snapshot's id (GET /api/history lists them), or null for the latest build.");
971
+ const unknown = this.unknownVersion([b.version]);
972
+ if (unknown)
973
+ return no(404, unknown);
974
+ if (b.clustering !== undefined && !CLUSTER_MODES.includes(b.clustering))
975
+ return no(400, "The clustering is namespace or module.");
976
+ // a new view's focus (ViewSpec decision 28): the target it is made from, or the focus of the
977
+ // view a Zoom in came from
978
+ if (b.focus !== undefined && b.focus !== null && (typeof b.focus !== "string" || !this.isTarget(b.focus)))
979
+ return no(400, "focus is a target's full name.");
980
+ const { view, change } = this.book.open(q, me, Date.now(), { agent, person, ...(typeof b.version === "number" ? { version: b.version } : {}), ...(b.clustering ? { clustering: b.clustering } : {}), ...(typeof b.focus === "string" ? { focus: b.focus } : {}) });
981
+ holds(view.id);
982
+ return result(change, () => ({ view }));
983
+ }
984
+ case "/api/views/create": {
985
+ const refused = arranging();
986
+ if (refused)
987
+ return refused;
988
+ if (nameless)
989
+ return NEED_NAME;
990
+ const q = b.scope === undefined ? undefined : readQuery(b.scope);
991
+ if (typeof q === "string")
992
+ return no(400, q);
993
+ if (b.clustering !== undefined && !CLUSTER_MODES.includes(b.clustering))
994
+ return no(400, "The clustering is namespace or module.");
995
+ // Save as new view (HistorySpec decision 19): the build it shows, a snapshot the history has
996
+ if (b.version !== undefined && b.version !== null && !validVersion(b.version))
997
+ return no(400, "version is a snapshot's id (GET /api/history lists them), or null for the latest build.");
998
+ const unknown = this.unknownVersion([b.version]);
999
+ if (unknown)
1000
+ return no(404, unknown);
1001
+ const v = this.book.create(typeof b.name === "string" ? b.name.trim() : "", me, { scope: q, clustering: b.clustering, from: typeof b.from === "string" ? b.from : undefined, version: b.version, agent, person });
1002
+ if (typeof v === "string")
1003
+ return no(400, v);
1004
+ return result({ id: v.id, what: "kept", by: me }, () => ({ view: v }));
1005
+ }
1006
+ case "/api/views/rename": {
1007
+ const refused = arranging();
1008
+ if (refused)
1009
+ return refused;
1010
+ if (nameless)
1011
+ return NEED_NAME;
1012
+ const name = b.name === null ? null : typeof b.name === "string" ? b.name.trim() : "";
1013
+ return result(this.book.rename(id, name === "" ? null : name, me));
1014
+ }
1015
+ case "/api/views/delete": {
1016
+ const refused = arranging();
1017
+ if (refused)
1018
+ return refused;
1019
+ if (nameless)
1020
+ return NEED_NAME;
1021
+ return result(this.book.remove(id, me));
1022
+ }
1023
+ case "/api/views/scope": {
1024
+ const refused = arranging();
1025
+ if (refused)
1026
+ return refused;
1027
+ const q = readQuery(b.scope);
1028
+ if (typeof q === "string")
1029
+ return no(400, q);
1030
+ return result(this.book.setScope(id, q, me), () => ({ ok: true, scope: printQuery(q) }));
1031
+ }
1032
+ case "/api/views/focus": {
1033
+ // the view's own focus, for everyone on it (ViewSpec decision 28): a target, or null for none
1034
+ const refused = arranging();
1035
+ if (refused)
1036
+ return refused;
1037
+ if (b.focus !== null && (typeof b.focus !== "string" || !this.isTarget(b.focus)))
1038
+ return no(400, "focus is a target's full name, or null for none.");
1039
+ return result(this.book.setFocus(id, b.focus, me));
1040
+ }
1041
+ case "/api/views/link": {
1042
+ // a target linked to this view, for everyone (ViewSpec decision 29): its clicks open it; a
1043
+ // view linked to other targets takes it too (the owner's decision of 2026-10-11)
1044
+ const refused = arranging();
1045
+ if (refused)
1046
+ return refused;
1047
+ if (nameless)
1048
+ return NEED_NAME;
1049
+ if (typeof b.target !== "string" || !this.isTarget(b.target))
1050
+ return no(400, "target is a target's full name.");
1051
+ return result(this.book.link(id, b.target, me));
1052
+ }
1053
+ case "/api/views/target": {
1054
+ // the view a click on a target opens (ViewSpec decision 29, the owner's decision of
1055
+ // 2026-10-11): the one it is linked to, else a view nobody keeps of its TCB, linked to it
1056
+ // for as long as that view lasts. Opening a view is reading, as `open` is
1057
+ if (typeof b.target !== "string" || !this.isTarget(b.target))
1058
+ return no(400, "target is a target's full name.");
1059
+ const r = this.openTarget(b.target, me, agent, person);
1060
+ holds(r.id);
1061
+ return ok({ view: r });
1062
+ }
1063
+ case "/api/views/clustering": {
1064
+ const refused = arranging();
1065
+ if (refused)
1066
+ return refused;
1067
+ return result(this.book.setClustering(id, b.clustering, me));
1068
+ }
1069
+ case "/api/views/arrange": {
1070
+ const refused = arranging();
1071
+ if (refused)
1072
+ return refused;
1073
+ const why = invalidViewOps(b.ops);
1074
+ if (why)
1075
+ return no(400, why);
1076
+ // a clear is a re-layout's, which says so on the timeline
1077
+ const ops = b.ops.filter((o) => o.op !== "clear");
1078
+ // a note written, edited or deleted records who did it, as a view made does; so does a view
1079
+ // kept at a build, for everyone on it, as a re-layout does
1080
+ if (nameless && ops.some((o) => o.op === "note" || o.op === "note-edit" || o.op === "note-delete" || o.op === "version"))
1081
+ return NEED_NAME;
1082
+ // the page says which it is by its stream's secret: the others are told its public id
1083
+ const err = this.arrange(id, ops, this.pageOf(b.token), me, agent, person);
1084
+ return err ? no(this.book.get(id) ? 400 : 404, err) : ok({ ok: true });
1085
+ }
1086
+ case "/api/views/notes": {
1087
+ // the project's notes, as a view's are arranged: writing, editing and deleting one is
1088
+ // recorded under a name; opening, collapsing and moving one need none
1089
+ const refused = arranging();
1090
+ if (refused)
1091
+ return refused;
1092
+ const why = invalidProjectNoteOps(b.ops);
1093
+ if (why)
1094
+ return no(400, why);
1095
+ const ops = b.ops;
1096
+ if (nameless && ops.some((o) => o.op === "pnote" || o.op === "pnote-edit" || o.op === "pnote-delete"))
1097
+ return NEED_NAME;
1098
+ // the view they were written in, which they are added to (the owner's decision of 2026-10-11)
1099
+ if (b.in !== undefined && b.in !== null && (typeof b.in !== "string" || !this.book.get(b.in)))
1100
+ return no(404, `There is no view ${String(b.in).slice(0, 80)} to write in.`);
1101
+ // what a note is to carry is checked against the library there
1102
+ const err = this.arrangeNotes(ops, this.pageOf(b.token), me, agent, person, typeof b.in === "string" ? b.in : null);
1103
+ return err ? no(400, err) : ok({ ok: true });
1104
+ }
1105
+ case "/api/views/note-kind": {
1106
+ // a note made a view's own, or the project's again (AttachmentSpec decision 14), keeping
1107
+ // where it is on the board it was switched on
1108
+ const refused = arranging();
1109
+ if (refused)
1110
+ return refused;
1111
+ if (nameless)
1112
+ return NEED_NAME;
1113
+ if (typeof b.note !== "string" || (b.to !== "view" && b.to !== "project"))
1114
+ return no(400, "Send {id, note, to: view | project, place, mode}: the view, the note, and where it is on that view's board.");
1115
+ if (!CLUSTER_MODES.includes(mode))
1116
+ return no(400, "mode is namespace or module.");
1117
+ if (b.to === "view" ? invalidPlace(b.place) : invalidClusterPlace(b.place))
1118
+ return no(400, "place is where the note is on the board: a view note's {mode, in, world}, or a project note's {in} or {ring}.");
1119
+ const err = this.switchNote(b.note, id, b.to, b.place, mode, this.pageOf(b.token), me, agent, person);
1120
+ return err ? no(this.book.get(id) ? 400 : 404, err) : ok({ ok: true });
1121
+ }
1122
+ case "/api/views/relayout": {
1123
+ const refused = arranging();
1124
+ if (refused)
1125
+ return refused;
1126
+ if (nameless)
1127
+ return NEED_NAME;
1128
+ const err = this.relayout(id, me, agent, person);
1129
+ return err ? no(404, err) : ok({ ok: true });
1130
+ }
1131
+ case "/api/views/memory": {
1132
+ // the memory decides where everything on the board is for everyone on it: handing it over
1133
+ // is laying the board out, under a name and within what agents may do, as arranging is
1134
+ const refused = arranging();
1135
+ if (refused)
1136
+ return refused;
1137
+ if (nameless)
1138
+ return NEED_NAME;
1139
+ if (!CLUSTER_MODES.includes(mode))
1140
+ return no(400, "mode is namespace or module.");
1141
+ const v = this.book.get(id);
1142
+ if (!v)
1143
+ return no(404, `There is no view ${id}.`);
1144
+ const next = cleanMemory(b.memory);
1145
+ if (!next)
1146
+ return no(400, "memory is {set, mem: {nodes, clusters}} as shared/layout.ts memoryOf makes it.");
1147
+ // for the set the board holds now, of what is on it: a page that read another graph or
1148
+ // review lays the board out for itself, and nothing else gets in
1149
+ const unfit = this.checkMemory(v, mode, next);
1150
+ if (unfit)
1151
+ return no(unfit.status, unfit.error);
1152
+ const r = this.setMemory(id, mode, typeof b.base === "string" ? b.base : null, next);
1153
+ if (typeof r === "string")
1154
+ return no(404, r);
1155
+ // the memory that won, unless it is the one sent: the page lays the board out from it
1156
+ return ok({ accepted: r.accepted, current: r.accepted ? { set: next.set } : r.current });
1157
+ }
1158
+ }
1159
+ return no(404, "not found");
1160
+ }
1161
+ }
1162
+ /** A file's title as a note's text: a note needs some, within its limit and without control
1163
+ * characters (a title read from a PDF may carry any). */
1164
+ function noteTitle(title) {
1165
+ const t = title.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim() || "A file of the library";
1166
+ return t.length > MAX_NOTE_TEXT ? t.slice(0, MAX_NOTE_TEXT - 1) + "…" : t;
1167
+ }