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,501 @@
1
+ // Notes on a board (ViewSpec decision 23, CanvasSpec "Notes", the owner's decisions of 2026-10-09):
2
+ // short texts people and agents stick on a board. Since 2026-10-11 a note is the project's unless it
3
+ // is made a view's own (AttachmentSpec decisions 13–19): the project's notes are
4
+ // shared/projectnotes.ts's, and where either kind is on a board is shared/noteplace.ts's. What this
5
+ // file holds is a view note — ViewSpec decision 23's note, kept with its view on the server and
6
+ // changed by the view's operations (shared/views.ts), so that everyone on the board sees it at once —
7
+ // with its checks, and the rules both kinds share of how a note shows; it is drawn by the pages
8
+ // (web/src/sticky.ts).
9
+ //
10
+ // Where a view note is: attached to the most specific cluster at the spot where it was put, at an
11
+ // offset in that cluster's local coordinates, so that it moves with the cluster; on the canvas
12
+ // itself, in world coordinates, where no cluster is there. A note keeps the offsets of the spot in
13
+ // every cluster that held it, and in the world, so that when its cluster is gone (a new graph, a
14
+ // scope that no longer holds it) it goes to the nearest one that is still there, where the spot was
15
+ // in it, or to the canvas. Under the other clustering than the one it was put under it is on the
16
+ // ring around the board (AttachmentSpec decision 16), never in a cluster that happens to have the
17
+ // same key. Either kind may be attached to a declaration instead (the owner's decision of
18
+ // 2026-10-12, NoteDecl): that is its place under every clustering, and the place it has besides is
19
+ // where it falls back to when the declaration is gone. What is here runs the same on the server, in
20
+ // the pages and in the tests.
21
+ import { validPersonId } from "./review.js";
22
+ /** the most a note carries: a note is a few words and what they point at, not a folder */
23
+ export const MAX_CARRIES = 8;
24
+ /** the farthest a card's centre is kept from its icon along either axis, in pixels of the screen:
25
+ * further, it would be off a smaller screen than the one it was put on, whichever way it flips */
26
+ export const CARD_REACH = 600;
27
+ /** where a note attached to a declaration sits by default, in the board's units: up and to the right
28
+ * of its point, about three declarations away — at the zoom a click on a declaration flies to (1.1),
29
+ * 37 px on the screen, its leader showing */
30
+ export const DECL_AT = [24, -24];
31
+ /** the nearest an attached note sits to its declaration's point, centre to centre, in the board's
32
+ * units: a little more than a point across, so that it always has a side */
33
+ export const DECL_NEAR = 12;
34
+ /** the reach, in the board's units — about six declarations away: the radius of the circle round a
35
+ * declaration in which a note attached to it, dragged, stays attached, where that is between
36
+ * ZONE_MIN_PX and ZONE_MAX_PX on the screen (shared/notecard.ts, zonePx); and the furthest a drop
37
+ * pushes a note out from the point to clear it, so that a note let go on its point zoomed out stays
38
+ * near it on the board */
39
+ export const DECL_REACH = 64;
40
+ /** The circle in which a dragged note stays attached to its declaration (the owner's decision of
41
+ * 2026-10-12), by its radius on the screen, in pixels: never smaller than ZONE_MIN_PX, so that zoomed
42
+ * out it shows well round the icon (24 px across), a short move does not leave it, and it holds an
43
+ * icon pushed out to clear the point (shared/notecard.ts, DECL_CLEAR_PX, 40 px); never larger than
44
+ * ZONE_MAX_PX, so that zoomed in a short move takes a note off its declaration too, and it still holds
45
+ * DECL_NEAR at the most zoom (144 px at 12). */
46
+ export const ZONE_MIN_PX = 48;
47
+ export const ZONE_MAX_PX = 160;
48
+ /** the least and the most zoom the canvas goes to (web/src/canvas.ts) */
49
+ export const ZOOM_LEAST = 0.02;
50
+ export const ZOOM_MOST = 12;
51
+ /** the farthest an attached note sits from its declaration's point, in the board's units: as far as
52
+ * the circle in which it stays attached reaches at the least zoom, so that wherever its icon is let
53
+ * go inside the circle, at any zoom, it is kept there */
54
+ export const DECL_FAR = ZONE_MIN_PX / ZOOM_LEAST;
55
+ export const NOTE_OPS = ["note", "note-edit", "note-move", "note-open", "note-card", "note-decl", "note-delete"];
56
+ /** the longest a note is, in characters */
57
+ export const MAX_NOTE_TEXT = 2000;
58
+ /** the most notes a view holds: a board, not a document */
59
+ export const MAX_NOTES = 300;
60
+ /** A cluster's radius on the screen below which a note in it shows collapsed, whatever its own
61
+ * state (CanvasSpec, "Notes"): an expanded note is some 240 px wide, and a cluster smaller than
62
+ * that on the screen would be covered by it, its labels and its neighbours with it. */
63
+ export const AUTO_COLLAPSE_PX = 120;
64
+ const CLUSTER_MODES = ["namespace", "module"];
65
+ const finite = (v, most = 1e7) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) < most;
66
+ export function validNoteId(x) {
67
+ return typeof x === "string" && /^[A-Za-z0-9_-]{1,32}$/.test(x);
68
+ }
69
+ /** Why a note's text cannot be taken, or null. */
70
+ export function invalidNoteText(x) {
71
+ if (typeof x !== "string" || !x.trim())
72
+ return "A note needs text.";
73
+ if (x.length > MAX_NOTE_TEXT)
74
+ return `A note is at most ${MAX_NOTE_TEXT.toLocaleString("en")} characters.`;
75
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(x))
76
+ return "A note's text has control characters.";
77
+ return null;
78
+ }
79
+ /** Why what a note carries cannot be taken, or null: absent, or a list of at most MAX_CARRIES files
80
+ * of the library and clips of their pages (NoteCarry), none twice. A region is a box of a page, so
81
+ * it comes with the page. Whether the library has the files is the server's to say (`carryProblem`). */
82
+ export function invalidCarries(x) {
83
+ if (x === undefined)
84
+ return null;
85
+ if (!Array.isArray(x))
86
+ return "carries";
87
+ if (x.length > MAX_CARRIES)
88
+ return `A note carries at most ${MAX_CARRIES} files and clips.`;
89
+ const seen = new Set();
90
+ for (const c of x) {
91
+ if (!c || typeof c !== "object" || typeof c.file !== "string" || !/^[0-9a-f]{64}$/.test(c.file))
92
+ return "carries.file";
93
+ if (c.page !== undefined && !(Number.isInteger(c.page) && c.page >= 1 && c.page < 1e6))
94
+ return "carries.page";
95
+ if (c.region !== undefined) {
96
+ const r = c.region;
97
+ if (!Array.isArray(r) || r.length !== 4 || !r.every((v) => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 1) || !(r[2] > r[0] && r[3] > r[1]))
98
+ return "carries.region";
99
+ if (c.page === undefined)
100
+ return "carries.region: a clip is a region of a page, and comes with the page";
101
+ }
102
+ const key = carryKey(c);
103
+ if (seen.has(key))
104
+ return "A note carries a file or a clip once.";
105
+ seen.add(key);
106
+ }
107
+ return null;
108
+ }
109
+ /** What a note carries with `c` attached (a note's Attach…): the list, `c` last; or why not — it
110
+ * carries that already, or as much as a note carries. */
111
+ export function withCarry(had, c) {
112
+ const list = had ?? [];
113
+ if (list.some((x) => carryKey(x) === carryKey(c)))
114
+ return "The note carries it already.";
115
+ if (list.length >= MAX_CARRIES)
116
+ return `A note carries at most ${MAX_CARRIES} files and clips: detach one first.`;
117
+ return [...list, c];
118
+ }
119
+ /** The text of a note made of a clip (Clip to a note, in the reference window): what the field says
120
+ * where the reader wrote in it; left as it came (`label`, the link's label by default), the text
121
+ * selected, quoted in full with its page, where text was selected; else that label. At most
122
+ * MAX_NOTE_TEXT, cut with an ellipsis. */
123
+ export function clipNoteText(field, label, quote, page) {
124
+ const typed = field.replace(/\s+/g, " ").trim();
125
+ const text = typed && typed !== label ? typed : quote ? `“${quote}” — p. ${page}` : typed || label;
126
+ return text.length > MAX_NOTE_TEXT ? text.slice(0, MAX_NOTE_TEXT - 1) + "…" : text;
127
+ }
128
+ /** A carry as one key: its file, page and region, what tells two carries of a note apart. */
129
+ export const carryKey = (c) => `${c.file}:${c.page ?? ""}:${c.region?.join(",") ?? ""}`;
130
+ /** Whether two notes carry the same, in the same order (nothing and an empty list alike). */
131
+ export function sameCarries(a, b) {
132
+ const x = a ?? [], y = b ?? [];
133
+ return x.length === y.length && x.every((c, k) => carryKey(c) === carryKey(y[k]));
134
+ }
135
+ /** What a note is to carry that it did not carry before (`had`): what the library checks. A carry it
136
+ * has already stays, whatever became of its file since — archived, or this server never had it. */
137
+ export function newCarries(next, had) {
138
+ const old = new Set((had ?? []).map(carryKey));
139
+ return (next ?? []).filter((c) => !old.has(carryKey(c)));
140
+ }
141
+ /** Why a note cannot take carry `c`, the library's record of its file being `file` (null where the
142
+ * library has none), or null: the file is in the library and not archived; an image is carried
143
+ * whole; a page of a PDF is one it has. */
144
+ export function carryProblem(c, file) {
145
+ if (!file)
146
+ return `The library has no file ${c.file.slice(0, 12)}…: add it to the library first.`;
147
+ if (file.archived)
148
+ return `${file.title} is archived: restore it from the library's archive to attach it.`;
149
+ if (file.type !== "pdf")
150
+ return c.page !== undefined || c.region ? `${file.title} is an image: a note carries it whole, with no page or region.` : null;
151
+ if (c.page !== undefined && file.pages && c.page > file.pages)
152
+ return `${file.title} has ${file.pages} page${file.pages === 1 ? "" : "s"}: page ${c.page} is not one of them.`;
153
+ return null;
154
+ }
155
+ /** What a note carries as a record keeps it: only the fields a carry has, none where it is empty. */
156
+ export function cleanCarries(x) {
157
+ if (invalidCarries(x) || !Array.isArray(x) || !x.length)
158
+ return {};
159
+ return { carries: x.map((c) => ({ file: c.file, ...(c.page !== undefined ? { page: c.page } : {}), ...(c.region ? { region: [...c.region] } : {}) })) };
160
+ }
161
+ /** Why a card's offset cannot be taken, or null: a pair of numbers within CARD_REACH, or null (the
162
+ * default) where `none` allows it. */
163
+ export function invalidCard(x, none = true) {
164
+ if (x === null && none)
165
+ return null;
166
+ return Array.isArray(x) && x.length === 2 && x.every((v) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) <= CARD_REACH) ? null : "card";
167
+ }
168
+ /** A card's offset as a record keeps it: whole pixels, none where it cannot be read. */
169
+ export function cleanCard(x) {
170
+ return x !== undefined && !invalidCard(x, false) ? { card: [Math.round(x[0]), Math.round(x[1])] } : {};
171
+ }
172
+ /** Why the declaration a note is attached to cannot be taken, or null: a full name — some text on
173
+ * one line, at most 2,000 characters — and an offset within DECL_FAR (`reach`: the store reads any
174
+ * offset, and brings it within); or null (none) where `none` allows it. Whether the graph has that
175
+ * declaration is not asked: a note attached on a board of a past build may name one the latest
176
+ * build lacks. */
177
+ export function invalidDecl(x, none = true, reach = true) {
178
+ if (x === null && none)
179
+ return null;
180
+ const d = x;
181
+ if (!d || typeof d !== "object" || Array.isArray(d))
182
+ return "decl";
183
+ if (typeof d.name !== "string" || !d.name.trim() || d.name.length > 2000 || /[\u0000-\u001f\u007f]/.test(d.name))
184
+ return "decl.name";
185
+ const at = d.at;
186
+ if (!Array.isArray(at) || at.length !== 2 || !at.every((v) => typeof v === "number" && Number.isFinite(v)))
187
+ return "decl.at";
188
+ // within its reach, a tenth of a unit rounded either way
189
+ if (reach && Math.hypot(at[0], at[1]) > DECL_FAR + 0.1)
190
+ return "decl.at";
191
+ return null;
192
+ }
193
+ /** An offset of an attached note from its declaration's point (NoteDecl.at, the board's units) as it
194
+ * is kept: along its own direction, at least `near` (DECL_NEAR, or more where a drop asks it: never
195
+ * beyond DECL_REACH) and at most DECL_FAR from the point, to a tenth of a unit; DECL_AT where it has
196
+ * no direction. */
197
+ export function keptDecl(d, near = DECL_NEAR) {
198
+ const len = Math.hypot(d[0], d[1]);
199
+ if (!(len >= 0.1))
200
+ return [DECL_AT[0], DECL_AT[1]];
201
+ const least = Math.min(DECL_REACH, Math.max(DECL_NEAR, near));
202
+ const f = len < least ? least / len : len > DECL_FAR ? DECL_FAR / len : 1;
203
+ return [Math.round(d[0] * f * 10) / 10, Math.round(d[1] * f * 10) / 10];
204
+ }
205
+ /** The declaration a note is attached to as a record keeps it: its name, and its offset kept as
206
+ * keptDecl keeps it, brought within DECL_FAR, so that an attachment kept with an offset beyond it
207
+ * stays attached; none where it cannot be read. */
208
+ export function cleanDecl(x) {
209
+ if (x === undefined || invalidDecl(x, false, false))
210
+ return {};
211
+ const d = x;
212
+ return { decl: { name: d.name, at: keptDecl(d.at) } };
213
+ }
214
+ /** Note `n` attached to `decl` (null: to none); whether that changed it. */
215
+ export function setDecl(n, decl) {
216
+ const d = decl ? cleanDecl(decl).decl : undefined;
217
+ if (d ? !!n.decl && n.decl.name === d.name && n.decl.at[0] === d.at[0] && n.decl.at[1] === d.at[1] : !n.decl)
218
+ return false;
219
+ if (d)
220
+ n.decl = d;
221
+ else
222
+ delete n.decl;
223
+ return true;
224
+ }
225
+ /** Note `n`'s card made to hang at `card` (null: as by default); whether that changed it. */
226
+ export function setCard(n, card) {
227
+ const c = card ? cleanCard(card).card : undefined;
228
+ if (c ? !!n.card && n.card[0] === c[0] && n.card[1] === c[1] : !n.card)
229
+ return false;
230
+ if (c)
231
+ n.card = c;
232
+ else
233
+ delete n.card;
234
+ return true;
235
+ }
236
+ /** Why a place cannot be taken, or null. */
237
+ export function invalidPlace(x) {
238
+ const p = x;
239
+ if (!p || typeof p !== "object")
240
+ return "place";
241
+ if (!CLUSTER_MODES.includes(p.mode))
242
+ return "place.mode";
243
+ if (!Array.isArray(p.in) || p.in.length > 64)
244
+ return "place.in";
245
+ for (const e of p.in)
246
+ if (!Array.isArray(e) || e.length !== 3 || typeof e[0] !== "string" || !e[0] || e[0].length > 2000 || !finite(e[1]) || !finite(e[2]))
247
+ return "place.in";
248
+ if (!Array.isArray(p.world) || p.world.length !== 2 || !p.world.every((v) => finite(v)))
249
+ return "place.world";
250
+ return null;
251
+ }
252
+ /** Why a note operation is refused, or null. */
253
+ export function invalidNoteOp(o) {
254
+ if (o.op === "note") {
255
+ const n = o.note;
256
+ if (!n || typeof n !== "object")
257
+ return "note";
258
+ if (!validNoteId(n.id))
259
+ return "note.id";
260
+ const t = invalidNoteText(n.text);
261
+ if (t)
262
+ return t;
263
+ if (typeof n.open !== "boolean")
264
+ return "note.open";
265
+ if (n.card !== undefined && invalidCard(n.card, false))
266
+ return "note.card";
267
+ if (n.decl !== undefined && invalidDecl(n.decl, false))
268
+ return "note.decl";
269
+ return invalidPlace(n.place) ?? invalidCarries(n.carries);
270
+ }
271
+ if (!validNoteId(o.id))
272
+ return "id";
273
+ if (o.op === "note-edit")
274
+ return invalidNoteText(o.text) ?? invalidCarries(o.carries);
275
+ if (o.op === "note-move")
276
+ return invalidPlace(o.place);
277
+ if (o.op === "note-open")
278
+ return typeof o.open === "boolean" ? null : "open";
279
+ if (o.op === "note-card")
280
+ return invalidCard(o.card);
281
+ if (o.op === "note-decl")
282
+ return invalidDecl(o.decl);
283
+ return o.op === "note-delete" ? null : "op";
284
+ }
285
+ const round = (v) => Math.round(v * 10) / 10;
286
+ /** Apply one note operation to a view's notes in place; true when anything changed. A note
287
+ * beyond MAX_NOTES, or one whose id is taken, is not added. */
288
+ export function applyNoteOp(notes, o) {
289
+ if (o.op === "note") {
290
+ if (notes.length >= MAX_NOTES || notes.some((n) => n.id === o.note.id))
291
+ return false;
292
+ const n = structuredClone(o.note);
293
+ if (n.decl)
294
+ n.decl = cleanDecl(n.decl).decl;
295
+ if (!n.decl)
296
+ delete n.decl;
297
+ notes.push(n);
298
+ return true;
299
+ }
300
+ const k = notes.findIndex((n) => n.id === o.id);
301
+ if (k < 0)
302
+ return false;
303
+ const n = notes[k];
304
+ switch (o.op) {
305
+ case "note-edit":
306
+ if (n.text === o.text && (o.carries === undefined || sameCarries(n.carries, o.carries)))
307
+ return false;
308
+ n.text = o.text;
309
+ if (o.carries !== undefined)
310
+ setCarries(n, o.carries);
311
+ if (o.at)
312
+ n.edited = { by: o.by ?? null, ...(o.agent ? { agent: true } : {}), ...(o.person ? { person: o.person } : {}), at: o.at };
313
+ return true;
314
+ case "note-move":
315
+ n.place = structuredClone(o.place);
316
+ return true;
317
+ case "note-open":
318
+ if (n.open === o.open)
319
+ return false;
320
+ n.open = o.open;
321
+ return true;
322
+ case "note-card":
323
+ return setCard(n, o.card);
324
+ case "note-decl":
325
+ return setDecl(n, o.decl);
326
+ case "note-delete":
327
+ notes.splice(k, 1);
328
+ return true;
329
+ }
330
+ }
331
+ /** What note `n` carries made `carries`: a list of its own, none where it is empty. A new array
332
+ * each time, so that what was kept of the note before (the timeline's `before`) keeps the old one. */
333
+ export function setCarries(n, carries) {
334
+ const c = cleanCarries(carries).carries;
335
+ if (c)
336
+ n.carries = c;
337
+ else
338
+ delete n.carries;
339
+ }
340
+ /** Mark `x` (a new note, or an edit) as done by `who` at `at`: the name, the agent's mark, and the
341
+ * personal link they came by — never with a note without a name (a private reader's). */
342
+ export function stampWho(x, who, at) {
343
+ x.by = who?.by ?? null;
344
+ if (who?.agent)
345
+ x.agent = true;
346
+ else
347
+ delete x.agent;
348
+ if (x.by && who?.person && !who.agent)
349
+ x.person = who.person;
350
+ else
351
+ delete x.person;
352
+ x.at = at;
353
+ }
354
+ /** Stamp who does a note operation, and when, as the server records who dropped a pin: the
355
+ * author of a new note, the editor of an edit. */
356
+ export function stampNoteOp(o, who, at) {
357
+ if (o.op === "note") {
358
+ stampWho(o.note, who, at);
359
+ delete o.note.edited;
360
+ }
361
+ else if (o.op === "note-edit")
362
+ stampWho(o, who, at);
363
+ }
364
+ /** A view's notes as views.json holds them, checked: one that cannot be read is left out. */
365
+ export function cleanNotes(raw) {
366
+ if (!Array.isArray(raw))
367
+ return [];
368
+ const out = [];
369
+ for (const n of raw) {
370
+ if (!n || typeof n !== "object" || !validNoteId(n.id) || invalidNoteText(n.text) || invalidPlace(n.place) || typeof n.open !== "boolean")
371
+ continue;
372
+ if (out.some((x) => x.id === n.id) || out.length >= MAX_NOTES)
373
+ continue;
374
+ const ed = n.edited;
375
+ out.push({
376
+ id: n.id,
377
+ text: n.text,
378
+ place: n.place,
379
+ open: n.open,
380
+ by: typeof n.by === "string" ? n.by : null,
381
+ ...(n.agent === true ? { agent: true } : {}),
382
+ ...(validPersonId(n.person) ? { person: n.person } : {}),
383
+ at: typeof n.at === "string" ? n.at : new Date(0).toISOString(),
384
+ ...cleanEdited(ed),
385
+ ...cleanCarries(n.carries),
386
+ ...cleanCard(n.card),
387
+ ...cleanDecl(n.decl),
388
+ });
389
+ }
390
+ return out;
391
+ }
392
+ /** A note's last edit as a file holds it, checked: none where it cannot be read. */
393
+ export function cleanEdited(ed) {
394
+ const e = ed;
395
+ return e && typeof e === "object" && typeof e.at === "string"
396
+ ? { edited: { by: typeof e.by === "string" ? e.by : null, ...(e.agent ? { agent: true } : {}), ...(validPersonId(e.person) ? { person: e.person } : {}), at: e.at } }
397
+ : {};
398
+ }
399
+ /** The place of a spot of a board: the most specific open cluster whose circle holds it, and every
400
+ * cluster around that one, each with the spot's offset from its centre, and the spot itself. */
401
+ export function placeAt(mode, world, clusters) {
402
+ const [wx, wy] = world;
403
+ let best = -1;
404
+ clusters.forEach((c, k) => {
405
+ if (!c.open || (c.x - wx) ** 2 + (c.y - wy) ** 2 > c.r * c.r)
406
+ return;
407
+ if (best < 0 || c.depth > clusters[best].depth)
408
+ best = k;
409
+ });
410
+ const chain = [];
411
+ for (let k = best; k >= 0; k = clusters[k].parent)
412
+ chain.push([clusters[k].key, round(wx - clusters[k].x), round(wy - clusters[k].y)]);
413
+ return { mode, in: chain, world: [round(wx), round(wy)] };
414
+ }
415
+ /** A board's clusters as placing a note reads them, at rest: where its layout puts each, or its
416
+ * pin (in its parent's coordinates) where it has one, summed down the tree; folds close what they
417
+ * hold. The pages read their scene instead, which is where things are drawn. */
418
+ export function boardClusters(L, pins, folds) {
419
+ const folded = new Set(folds);
420
+ const out = [];
421
+ const at = (c) => {
422
+ if (out[c])
423
+ return out[c];
424
+ const cl = L.clusters[c];
425
+ const [lx, ly] = pins.clusters[cl.key] ?? [cl.lx, cl.ly];
426
+ const up = cl.parent >= 0 ? at(cl.parent) : null;
427
+ return (out[c] = { key: cl.key, parent: cl.parent, depth: cl.depth, x: (up?.x ?? 0) + lx, y: (up?.y ?? 0) + ly, r: cl.r, open: !folded.has(cl.key) && (up?.open ?? true) });
428
+ };
429
+ L.clusters.forEach((_, c) => at(c));
430
+ return out;
431
+ }
432
+ /** Where declaration `i` (named `name`) is at rest on that board, in world coordinates. */
433
+ export function nodeSpot(L, pins, clusters, i, name) {
434
+ const [lx, ly] = pins.nodes[name] ?? [L.lx[i], L.ly[i]];
435
+ const c = L.cluster[i];
436
+ return c >= 0 ? [clusters[c].x + lx, clusters[c].y + ly] : [lx, ly];
437
+ }
438
+ /** The place of a spot in cluster `c` and every cluster around it, whatever is folded: a note an
439
+ * agent puts beside a declaration is in the declaration's own cluster. */
440
+ export function placeIn(mode, world, clusters, c) {
441
+ const chain = [];
442
+ for (let k = c; k >= 0; k = clusters[k].parent)
443
+ chain.push([clusters[k].key, round(world[0] - clusters[k].x), round(world[1] - clusters[k].y)]);
444
+ return { mode, in: chain, world: [round(world[0]), round(world[1])] };
445
+ }
446
+ /** Where a view note is on a layout clustered by `mode`: in cluster `c` at its offset (dx, dy) from
447
+ * the centre — the first cluster of its place the layout has, by key (`index`) — or on the canvas
448
+ * (`c` -1) at its world spot. Null under the other clustering than the one it was put under: it is
449
+ * on the ring there (shared/noteplace.ts), never matched by key, since a namespace and a folder
450
+ * can have the same key and be nothing alike (AttachmentSpec decision 18). */
451
+ export function resolvePlace(place, mode, index) {
452
+ if (place.mode !== mode)
453
+ return null;
454
+ for (const [key, dx, dy] of place.in) {
455
+ const c = index(key);
456
+ if (c >= 0)
457
+ return { c, dx, dy };
458
+ }
459
+ return { c: -1, dx: place.world[0], dy: place.world[1] };
460
+ }
461
+ /** How a note shows: expanded only when it is open and its cluster is large enough on the screen to
462
+ * hold it (`clusterPx`, its radius in pixels; null for a note on the canvas, which is never
463
+ * collapsed for the zoom). Zoomed out, every note is collapsed; zoomed in again, it is as its
464
+ * state says. */
465
+ export function noteLook(open, clusterPx) {
466
+ return open && (clusterPx === null || clusterPx >= AUTO_COLLAPSE_PX) ? "open" : "shut";
467
+ }
468
+ /** The folded clusters that hide notes, and how many each hides: a note in a cluster that is
469
+ * folded, or inside one that is, is hidden, and counted on the outermost folded cluster around it
470
+ * (`shownAs`: that cluster, or -1 where the note's cluster is drawn open). */
471
+ export function noteMarkers(clustersOf, shownAs) {
472
+ const out = new Map();
473
+ for (const c of clustersOf) {
474
+ if (c < 0)
475
+ continue;
476
+ const r = shownAs(c);
477
+ if (r >= 0)
478
+ out.set(r, (out.get(r) ?? 0) + 1);
479
+ }
480
+ return out;
481
+ }
482
+ /** A note's first words, for its collapsed look: the Markdown marks left out. */
483
+ export function noteExcerpt(text, most = 18) {
484
+ const plain = text.replace(/```[\s\S]*?```/g, " ").replace(/[`*_]/g, "").replace(/\s+/g, " ").trim();
485
+ return plain.length > most ? plain.slice(0, most - 1).trimEnd() + "…" : plain;
486
+ }
487
+ /** The declarations' names a note's text mentions, as the page links them: what it writes in
488
+ * backticks, and dotted names written bare (web/src/talk.ts, renderMarkdown). Activity and the
489
+ * agents' timeline say a note is about each. */
490
+ export function noteNames(text) {
491
+ const out = new Set();
492
+ for (const m of text.matchAll(/`([^`\n]+)`/g))
493
+ out.add(m[1].trim());
494
+ for (const m of text.replace(/`[^`\n]*`/g, " ").matchAll(/(?<![\w.'])[A-Za-z_\u00c0-\u024f][\w'!?\u00c0-\u024f]*(?:\.[\w'!?\u00c0-\u024f]+)+/g))
495
+ out.add(m[0]);
496
+ return [...out];
497
+ }
498
+ /** A new note's id. */
499
+ export function newNoteId() {
500
+ return "n" + Math.random().toString(36).slice(2, 10);
501
+ }
@@ -0,0 +1,73 @@
1
+ // The timeline (CollabSpec decision 5): what happened on a server. The server stores builds, TCB
2
+ // changes, re-layouts, targets taken off and prefixes trusted or no longer trusted; opinions,
3
+ // targets and key marks are read from the review's logs by the page itself, so the logs bring their
4
+ // own history. Since M6 (AgentSpec.md) it also stores the layouts agents made — each request one
5
+ // batch, with what it replaced, so it can be undone — and their undoing. What an agent did carries
6
+ // `agent`: since an agent at level full may do what a person does (AgentSpec decision 9), that is
7
+ // also a target it took off and a prefix it trusted or no longer trusted. Since M7 (ViewSpec.md),
8
+ // the views saved for everyone, renamed, deleted and laid out afresh; since 2026-10-09, the notes
9
+ // on a view written, edited and deleted, by people and agents alike (ViewSpec decision 23), and a
10
+ // view's scope changed (ExportSpec decision 15: who left a trace on a view authors its document). A note
11
+ // event of the kind agents left about a declaration before is not read. What someone invited by a
12
+ // personal link did carries `person`, their link's id (CollabSpec decision 9), so that the page
13
+ // shows it under the name they go by now, with its mark.
14
+ import { SITE_BODY, SITE_STMT } from "./graph.js";
15
+ import { byName } from "./review.js";
16
+ /** at most this many names are listed in one event; the counts stay exact */
17
+ export const NAMES_LISTED = 100;
18
+ /** The graph as the TCB needs it: names, content hashes, statement and body edges. */
19
+ export class TcbIndex {
20
+ names;
21
+ hashes;
22
+ byName = new Map();
23
+ start;
24
+ dst;
25
+ /** `edges`: (source, target, site) triples, as the server serves them. */
26
+ constructor(nodes, edges) {
27
+ const n = nodes.length;
28
+ this.names = nodes.map((v) => v.name);
29
+ this.hashes = nodes.map((v) => v.hash ?? "");
30
+ this.names.forEach((name, i) => this.byName.set(name, i));
31
+ const keep = SITE_STMT | SITE_BODY;
32
+ this.start = new Int32Array(n + 1);
33
+ for (let e = 0; e + 2 < edges.length; e += 3)
34
+ if (edges[e + 2] & keep)
35
+ this.start[edges[e] + 1]++;
36
+ for (let i = 0; i < n; i++)
37
+ this.start[i + 1] += this.start[i];
38
+ this.dst = new Int32Array(this.start[n]);
39
+ const at = this.start.slice(0, n);
40
+ for (let e = 0; e + 2 < edges.length; e += 3)
41
+ if (edges[e + 2] & keep)
42
+ this.dst[at[edges[e]]++] = edges[e + 1];
43
+ }
44
+ /** A declaration's TCB, itself included: name → content hash ("" where there is none), as the
45
+ * timeline compares a target's from one graph to the next (server/events.ts). */
46
+ tcb(root) {
47
+ const seen = new Uint8Array(this.names.length);
48
+ const stack = [root];
49
+ seen[root] = 1;
50
+ // keyed by names: no prototype, so that `toString` is a name like any (shared/review.ts)
51
+ const out = byName();
52
+ while (stack.length) {
53
+ const u = stack.pop();
54
+ out[this.names[u]] = this.hashes[u];
55
+ for (let e = this.start[u]; e < this.start[u + 1]; e++) {
56
+ const v = this.dst[e];
57
+ if (!seen[v]) {
58
+ seen[v] = 1;
59
+ stack.push(v);
60
+ }
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+ }
66
+ /** What changed between two TCBs (`TcbIndex.tcb`): the names added, removed, and whose content hash
67
+ * changed, each sorted. Own keys only: `toString` is a name like any. */
68
+ export function tcbDiff(then, now) {
69
+ const added = Object.keys(now).filter((k) => !Object.hasOwn(then, k)).sort();
70
+ const removed = Object.keys(then).filter((k) => !Object.hasOwn(now, k)).sort();
71
+ const changed = Object.keys(now).filter((k) => Object.hasOwn(then, k) && then[k] !== now[k]).sort();
72
+ return { added, removed, changed };
73
+ }