visualproof 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -2
  3. package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
  4. package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
  5. package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
  6. package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
  7. package/dist/assets/latex/visualproof.sty +308 -0
  8. package/dist/extractor/Extract.lean +1180 -0
  9. package/dist/extractor/Usage.lean +900 -0
  10. package/dist/mcp/visualproof-mcp.mjs +476 -0
  11. package/dist/server/access.js +95 -0
  12. package/dist/server/agent.js +3235 -0
  13. package/dist/server/agentdocs.js +35 -0
  14. package/dist/server/agentlibrary.js +202 -0
  15. package/dist/server/agentnotes.js +701 -0
  16. package/dist/server/agentviews.js +703 -0
  17. package/dist/server/askfollow.js +286 -0
  18. package/dist/server/boardspots.js +159 -0
  19. package/dist/server/dirs.js +175 -0
  20. package/dist/server/doctor.js +186 -0
  21. package/dist/server/events.js +198 -0
  22. package/dist/server/export.js +420 -0
  23. package/dist/server/extract.js +204 -0
  24. package/dist/server/fetchurl.js +321 -0
  25. package/dist/server/figures.js +158 -0
  26. package/dist/server/history.js +741 -0
  27. package/dist/server/historyapi.js +304 -0
  28. package/dist/server/hover.js +595 -0
  29. package/dist/server/identity.js +114 -0
  30. package/dist/server/invites.js +241 -0
  31. package/dist/server/layoutthread.js +51 -0
  32. package/dist/server/library.js +600 -0
  33. package/dist/server/libraryroute.js +211 -0
  34. package/dist/server/live.js +1101 -0
  35. package/dist/server/lostfound.js +199 -0
  36. package/dist/server/main.js +1394 -0
  37. package/dist/server/mcptools.js +497 -0
  38. package/dist/server/pastdoc.js +73 -0
  39. package/dist/server/pipeline.js +156 -0
  40. package/dist/server/poppler.js +251 -0
  41. package/dist/server/presence.js +252 -0
  42. package/dist/server/project.js +222 -0
  43. package/dist/server/prompt.js +53 -0
  44. package/dist/server/readonly.js +152 -0
  45. package/dist/server/readonlygate.js +123 -0
  46. package/dist/server/renames.js +268 -0
  47. package/dist/server/review.js +334 -0
  48. package/dist/server/reviewapi.js +188 -0
  49. package/dist/server/snapshot.js +49 -0
  50. package/dist/server/sourcestore.js +473 -0
  51. package/dist/server/status.js +360 -0
  52. package/dist/server/talk.js +476 -0
  53. package/dist/server/tokens.js +221 -0
  54. package/dist/server/usage.js +501 -0
  55. package/dist/server/versions.js +82 -0
  56. package/dist/server/view.js +157 -0
  57. package/dist/server/views.js +1167 -0
  58. package/dist/shared/askfollow.js +46 -0
  59. package/dist/shared/board.js +332 -0
  60. package/dist/shared/build.js +2 -0
  61. package/dist/shared/collab.js +148 -0
  62. package/dist/shared/comment.js +285 -0
  63. package/dist/shared/context.js +157 -0
  64. package/dist/shared/doc/authors.js +98 -0
  65. package/dist/shared/doc/build.js +358 -0
  66. package/dist/shared/doc/latex.js +455 -0
  67. package/dist/shared/doc/markdown.js +159 -0
  68. package/dist/shared/doc/model.js +37 -0
  69. package/dist/shared/doc/order.js +107 -0
  70. package/dist/shared/doc/proof.js +272 -0
  71. package/dist/shared/doc/sections.js +93 -0
  72. package/dist/shared/doc/settings.js +75 -0
  73. package/dist/shared/edgecodec.js +102 -0
  74. package/dist/shared/editor.js +64 -0
  75. package/dist/shared/excerpt.js +87 -0
  76. package/dist/shared/graph.js +16 -0
  77. package/dist/shared/items.js +92 -0
  78. package/dist/shared/keys.js +203 -0
  79. package/dist/shared/layout.js +926 -0
  80. package/dist/shared/lexer.js +130 -0
  81. package/dist/shared/library.js +711 -0
  82. package/dist/shared/lostfound.js +150 -0
  83. package/dist/shared/moves.js +243 -0
  84. package/dist/shared/notecard.js +295 -0
  85. package/dist/shared/noteplace.js +505 -0
  86. package/dist/shared/palette.js +46 -0
  87. package/dist/shared/pastgraph.js +95 -0
  88. package/dist/shared/places.js +80 -0
  89. package/dist/shared/projectnotes.js +244 -0
  90. package/dist/shared/query.js +810 -0
  91. package/dist/shared/review.js +642 -0
  92. package/dist/shared/reviewqueue.js +333 -0
  93. package/dist/shared/ring.js +366 -0
  94. package/dist/shared/scope.js +255 -0
  95. package/dist/shared/scopeedit.js +81 -0
  96. package/dist/shared/search.js +191 -0
  97. package/dist/shared/separate.js +178 -0
  98. package/dist/shared/sharesnap.js +280 -0
  99. package/dist/shared/sticky.js +501 -0
  100. package/dist/shared/timeline.js +73 -0
  101. package/dist/shared/titles.js +298 -0
  102. package/dist/shared/viewgraph.js +139 -0
  103. package/dist/shared/views.js +859 -0
  104. package/dist/web/bundle/app.js +48733 -0
  105. package/dist/web/bundle/edges.js +409 -0
  106. package/dist/web/bundle/layout.js +1010 -0
  107. package/dist/web/bundle/pdfworker.js +48322 -0
  108. package/dist/web/index.html +239 -0
  109. package/dist/web/src/motion.js +430 -0
  110. package/dist/web/src/past.js +224 -0
  111. package/dist/web/src/scene.js +787 -0
  112. package/dist/web/style.css +2312 -0
  113. package/docs/agent-api/README.md +91 -0
  114. package/docs/agent-api/advice.md +108 -0
  115. package/docs/agent-api/export.md +120 -0
  116. package/docs/agent-api/following.md +140 -0
  117. package/docs/agent-api/history.md +142 -0
  118. package/docs/agent-api/library.md +65 -0
  119. package/docs/agent-api/mcp.md +104 -0
  120. package/docs/agent-api/notes.md +93 -0
  121. package/docs/agent-api/reading.md +166 -0
  122. package/docs/agent-api/requests.md +94 -0
  123. package/docs/agent-api/talking.md +100 -0
  124. package/docs/agent-api/tcb.md +135 -0
  125. package/docs/agent-api/views.md +124 -0
  126. package/docs/agent-api/words.md +56 -0
  127. package/package.json +39 -4
  128. package/test/toolchains.json +5 -0
@@ -0,0 +1,476 @@
1
+ // Talking with an agent in the page (AgentSpec decision 8): one conversation per agent token, shared
2
+ // by everyone on the server; the agent's inbox, which it waits on; and its state, inferred from what
3
+ // it does.
4
+ //
5
+ // Nothing is pushed to an agent: it listens by waiting on its inbox (GET /api/agent/inbox?wait=<s>,
6
+ // which the wait_for_message tool of VisualProof's MCP server, mcp/visualproof-mcp.mjs, holds open
7
+ // for it), so that any agent able to make an HTTP request can be talked to. A wait answers as soon
8
+ // as a person writes, with every message that came since the last one handed over, in order; or
9
+ // with none once its time is up. A message that comes while no wait is open waits for the next one.
10
+ //
11
+ // The state each agent is shown in comes from what it does, not from what it says: *listening*
12
+ // while a wait is open; *thinking* from the moment messages were handed to it until it replies, with
13
+ // what its requests show it doing (server/agent.ts, doingOf) and the line it may set itself;
14
+ // *working* when it makes requests with nothing to answer; *offline* once it has had no wait open
15
+ // and made no request for its longest wait plus a minute — the time an agent that listens takes at
16
+ // most to come back. Only an offline agent cannot be written to (409).
17
+ //
18
+ // Conversations live apart from the review's logs, in `<site>/chat/<token id>.jsonl`, one message per
19
+ // line, for 30 days; a message is at most a few thousand characters, and each sender may send only
20
+ // so many a minute. Revoking a token, or its expiry, ends its wait at once.
21
+ //
22
+ // A session that replaces another knows nothing of the conversation the page still shows (the
23
+ // owner's decisions of 2026-10-11). So the first wait after the agent was offline — it left, or went
24
+ // offline by the rule, or another session of its MCP server waits now — answers at once with the
25
+ // conversation so far, as one entry marked as earlier, before anything new. And anyone who can
26
+ // write to the agent can start a new conversation: a divider in the same file, which the agent is
27
+ // handed as a notice to disregard what came before, and before which a later session is told
28
+ // nothing.
29
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, renameSync } from "node:fs";
30
+ import { join } from "node:path";
31
+ import { utc } from "./mcptools.js";
32
+ /** how long a message is kept */
33
+ export const KEEP_MS = 30 * 24 * 3600 * 1000;
34
+ /** the longest message a person may send, and an agent's reply (it may explain at length) */
35
+ export const MAX_PERSON = 4000;
36
+ export const MAX_AGENT = 10_000;
37
+ /** what a page or an agent is given of a conversation at once */
38
+ export const THREAD = 200;
39
+ /** the longest one wait may last, in seconds: Claude Code gives up on a stdio tool call idle for 30
40
+ * minutes (progress resets it), and an agent that splits a longer wait asks again */
41
+ export const MAX_WAIT_S = 1800;
42
+ /** the longest status line an agent may set */
43
+ export const MAX_STATUS = 200;
44
+ /** what the first wait after an agent was offline hands it of the conversation so far: the newest
45
+ * messages, at most so many and so many characters of their text, whichever is less */
46
+ export const EARLIER_MESSAGES = 50;
47
+ export const EARLIER_CHARS = 20_000;
48
+ /** a sender's messages: a burst of BURST, then one every REFILL_MS (people), or faster (agents) */
49
+ const RATE = { person: { burst: 5, refillMs: 6000 }, agent: { burst: 10, refillMs: 2000 } };
50
+ const TIMING = { slackMs: 60_000, graceMs: 15_000, tickMs: 1000 };
51
+ export class Talk {
52
+ dir;
53
+ timing;
54
+ threads = new Map();
55
+ agents = new Map();
56
+ buckets = new Map();
57
+ ticks = 0;
58
+ /** told when an agent's state, what it does or its line changes (presence lists it so) */
59
+ onState = () => { };
60
+ /** told of every new message, to go to every page */
61
+ onMessage = () => { };
62
+ /** whether a token is still in force, looked at while its agent waits (an expiry between the
63
+ * minutes the tokens are looked at ends the wait) */
64
+ inForce = () => true;
65
+ /** when a token was last used, as the token file keeps it (server/tokens.ts), read when its agent
66
+ * is first known here: whether it was offline while the server was not running */
67
+ lastUsed = () => null;
68
+ constructor(siteDir, timing = {}) {
69
+ this.dir = join(siteDir, "chat");
70
+ this.timing = { ...TIMING, ...timing };
71
+ this.sweep();
72
+ const t = setInterval(() => this.tick(), this.timing.tickMs);
73
+ t.unref?.();
74
+ const daily = setInterval(() => this.sweep(), 24 * 3600 * 1000);
75
+ daily.unref?.();
76
+ }
77
+ // ---------------------------------------------------------------- the agent
78
+ agent(token) {
79
+ let a = this.agents.get(token);
80
+ if (!a) {
81
+ // what was written before the server started counts as read: it is never handed over as
82
+ // new, and an agent that was offline meanwhile is handed it as the conversation so far
83
+ const used = this.lastUsed(token);
84
+ a = {
85
+ wait: null, waitMs: 0, lastRequest: 0, lastSeen: null, graceUntil: 0, read: this.load(token).at(-1)?.id ?? 0, thinkingSince: null, doing: null, status: null, told: null,
86
+ away: false, before: used ? Date.parse(used) : null, session: null,
87
+ };
88
+ this.agents.set(token, a);
89
+ }
90
+ return a;
91
+ }
92
+ /** A request of the agent's, before it counts: one that finds it offline — it left, or had no
93
+ * wait open and made no request for its longest wait and a minute — may be a new session's. Its
94
+ * first request since the server started cannot tell, the server not having seen it go: the
95
+ * token's last use says — longer ago than the longest wait and a minute (or never), and it would
96
+ * have been shown offline; sooner, and it is the session that listened before the server stopped,
97
+ * coming back. */
98
+ contact(a, now) {
99
+ if (a.before === undefined) {
100
+ if (stateOf(a, now, this.timing) === "offline")
101
+ a.away = true;
102
+ return;
103
+ }
104
+ if (a.before === null || now - a.before > MAX_WAIT_S * 1000 + this.timing.slackMs)
105
+ a.away = true;
106
+ a.before = undefined;
107
+ }
108
+ /** The agent's state now, as pages are told it. */
109
+ talk(token, now = Date.now()) {
110
+ const a = this.agent(token);
111
+ const state = stateOf(a, now, this.timing);
112
+ const was = a.told;
113
+ const since = state === "thinking" ? a.thinkingSince : was && was.state === state ? Date.parse(was.since) : state === "listening" ? (a.wait?.at ?? now) : state === "offline" ? (a.lastSeen ?? now) : now;
114
+ const busy = state === "thinking" || state === "working";
115
+ return {
116
+ state,
117
+ since: new Date(since).toISOString(),
118
+ doing: busy ? a.doing : null,
119
+ status: busy ? a.status : null,
120
+ read: a.read,
121
+ lastSeen: a.lastSeen === null ? null : new Date(a.lastSeen).toISOString(),
122
+ };
123
+ }
124
+ /** Its state, by name: "offline" cannot be written to. */
125
+ state(token, now = Date.now()) {
126
+ return this.talk(token, now).state;
127
+ }
128
+ /** Tell presence when what pages show of the agent changed (not when it was last seen alone). */
129
+ update(token, now = Date.now()) {
130
+ const a = this.agent(token);
131
+ const t = this.talk(token, now);
132
+ const was = a.told;
133
+ a.told = t;
134
+ if (!was || was.state !== t.state || was.since !== t.since || was.doing !== t.doing || was.status !== t.status || was.read !== t.read)
135
+ this.onState(token, t);
136
+ }
137
+ /** States change with time alone (the grace after a wait, going offline); and a token that ended
138
+ * while its agent waited ends the wait. */
139
+ tick(now = Date.now()) {
140
+ const look = ++this.ticks % 15 === 0;
141
+ for (const token of [...this.agents.keys()]) {
142
+ if (look && !this.inForce(token))
143
+ this.close(token, "expired");
144
+ else
145
+ this.update(token, now);
146
+ }
147
+ }
148
+ /** The agent said it leaves (POST /api/agent/view {leave: true}; its MCP server does as it
149
+ * stops): offline at once, its wait ended, until it waits or asks again; its next wait is a new
150
+ * session's. */
151
+ left(token, now = Date.now()) {
152
+ const a = this.agent(token);
153
+ a.wait?.end({ gone: true });
154
+ a.lastRequest = 0;
155
+ a.graceUntil = 0;
156
+ a.thinkingSince = null;
157
+ a.lastSeen = now;
158
+ a.away = true;
159
+ a.before = undefined;
160
+ this.update(token, now);
161
+ }
162
+ /** A request of the agent's: it is not offline, and `doing` (when it says something) is what it
163
+ * does now. */
164
+ seen(token, doing, now = Date.now()) {
165
+ const a = this.agent(token);
166
+ this.contact(a, now);
167
+ a.lastRequest = now;
168
+ a.lastSeen = now;
169
+ if (doing !== null)
170
+ a.doing = doing;
171
+ this.update(token, now);
172
+ }
173
+ /** The agent's own line ("reading the TCB…"), shown with its state until it replies or waits
174
+ * again; empty clears it. */
175
+ setStatus(token, text, now = Date.now()) {
176
+ const a = this.agent(token);
177
+ this.contact(a, now);
178
+ a.status = text || null;
179
+ a.lastRequest = now;
180
+ a.lastSeen = now;
181
+ this.update(token, now);
182
+ return this.talk(token, now);
183
+ }
184
+ /** The agent waits for messages, at most `ms`; `maxMs` is the longest it waits in all where it
185
+ * splits a long wait into shorter requests. The answer comes at once with what is waiting, as
186
+ * soon as a person writes, or with nothing at the end; `signal` says the one waiting went away.
187
+ * The first wait after the agent was offline starts with the conversation so far, and so answers
188
+ * at once where there is any; `session` is the one its MCP server says it is, where it says. */
189
+ wait(token, ms, opts = {}, now = Date.now()) {
190
+ const a = this.agent(token);
191
+ this.contact(a, now);
192
+ // another session than the one that waited last knows nothing yet, though it never went
193
+ // offline: the one before ended without saying it left
194
+ if (opts.session) {
195
+ if (a.session !== null && a.session !== opts.session)
196
+ a.away = true;
197
+ a.session = opts.session;
198
+ }
199
+ a.lastRequest = now;
200
+ a.lastSeen = now;
201
+ a.waitMs = Math.max(ms, opts.maxMs ?? 0);
202
+ // a wait starts afresh: what the agent was doing and the line it set are over
203
+ a.doing = null;
204
+ a.status = null;
205
+ a.thinkingSince = null;
206
+ // a newer wait takes the place of an older one still open (a client that gave up on it without
207
+ // closing it, or a second client with the same token)
208
+ a.wait?.end({ messages: [], waitedMs: now - a.wait.at, replaced: true });
209
+ if (opts.signal?.aborted)
210
+ return Promise.resolve({ gone: true });
211
+ const queued = this.queued(token);
212
+ let handed = queued;
213
+ if (a.away) {
214
+ // once per return: the waits after it are as any
215
+ a.away = false;
216
+ handed = this.rejoin(token, queued, now);
217
+ // the dividers it was not handed are behind what it is told
218
+ if (!handed.length && queued.length)
219
+ a.read = queued.at(-1).id;
220
+ }
221
+ if (handed.length || ms <= 0) {
222
+ const r = this.hand(token, queued, handed, 0, now);
223
+ this.update(token, now);
224
+ return Promise.resolve(r);
225
+ }
226
+ return new Promise((done) => {
227
+ const w = {
228
+ at: now,
229
+ end: (r, timedOut = false) => {
230
+ if (a.wait !== w)
231
+ return;
232
+ clearTimeout(timer);
233
+ opts.signal?.removeEventListener("abort", away);
234
+ a.wait = null;
235
+ const t = Date.now();
236
+ a.lastSeen = t;
237
+ if (timedOut)
238
+ a.graceUntil = t + this.timing.graceMs;
239
+ done(r);
240
+ this.update(token, t);
241
+ },
242
+ };
243
+ const timer = setTimeout(() => w.end({ messages: [], waitedMs: Date.now() - now }, true), ms);
244
+ const away = () => w.end({ gone: true });
245
+ opts.signal?.addEventListener("abort", away);
246
+ a.wait = w;
247
+ a.graceUntil = 0;
248
+ this.update(token, now);
249
+ });
250
+ }
251
+ /** People's messages and dividers not yet handed to the agent, in order. */
252
+ queued(token) {
253
+ const a = this.agent(token);
254
+ return this.load(token).filter((m) => m.kind !== "agent" && m.id > a.read);
255
+ }
256
+ /** What the first wait after the agent was offline hands over: the conversation since its last
257
+ * divider, as one entry, then people's messages since that divider not handed over yet. A new
258
+ * session needs no notice of a divider: nothing before it is told. */
259
+ rejoin(token, queued, now) {
260
+ const thread = this.load(token);
261
+ const since = thread.slice(thread.findLastIndex((m) => m.kind === "divider") + 1);
262
+ const fresh = new Set(queued.map((m) => m.id));
263
+ const news = since.filter((m) => fresh.has(m.id));
264
+ const told = since.filter((m) => !fresh.has(m.id));
265
+ const earlier = earlierOf(told, news.length > 0, now);
266
+ return earlier ? [earlier, ...news] : news;
267
+ }
268
+ /** Hand over what a wait answers with: what is queued is the agent's from now on (`read`); people's
269
+ * messages among it it thinks about from now until it replies, and what it does is what it does
270
+ * from now (a request made while it waited, by another of its clients, is not). The conversation
271
+ * so far and a divider's notice need no answer. */
272
+ hand(token, queued, handed, waitedMs, now) {
273
+ const a = this.agent(token);
274
+ if (queued.length)
275
+ a.read = queued.at(-1).id;
276
+ if (handed.some((m) => m.kind === "person")) {
277
+ a.thinkingSince = now;
278
+ a.doing = null;
279
+ a.status = null;
280
+ }
281
+ return { messages: handed, waitedMs };
282
+ }
283
+ /** End a token's wait: it was revoked, or expired; and forget its agent. */
284
+ close(token, why) {
285
+ const a = this.agents.get(token);
286
+ if (!a)
287
+ return;
288
+ a.wait?.end({ ended: why, error: why === "revoked" ? "Your token was revoked: people can no longer talk to you here." : "Your token expired: people can no longer talk to you here." });
289
+ this.agents.delete(token);
290
+ }
291
+ /** How many agents are listening now, for the server's status. */
292
+ get count() {
293
+ let n = 0;
294
+ for (const a of this.agents.values())
295
+ if (a.wait)
296
+ n++;
297
+ return n;
298
+ }
299
+ // ---------------------------------------------------------------- the conversation
300
+ /** A conversation's messages, the last `limit`, of the last 30 days, dividers among them. */
301
+ thread(token, limit = THREAD) {
302
+ return this.load(token).slice(-limit);
303
+ }
304
+ /** The conversation as the agent reads it (GET /api/agent/conversation): the last `limit` messages
305
+ * since its last divider, with that divider and how many messages came before it; `all`, the last
306
+ * `limit` whatever conversation they were in, dividers among them. */
307
+ conversation(token, limit = THREAD, all = false) {
308
+ const thread = this.load(token);
309
+ const k = thread.findLastIndex((m) => m.kind === "divider");
310
+ return {
311
+ messages: (all ? thread : thread.slice(k + 1)).slice(-limit),
312
+ divider: k < 0 ? null : thread[k],
313
+ before: thread.slice(0, Math.max(k, 0)).filter((m) => m.kind !== "divider").length,
314
+ };
315
+ }
316
+ /** Whether nothing was said since the conversation last began (or ever): a divider then would
317
+ * divide nothing. */
318
+ fresh(token) {
319
+ const last = this.load(token).at(-1);
320
+ return !last || last.kind === "divider";
321
+ }
322
+ /** Start a new conversation (the owner's decision of 2026-10-11): a divider, written and sent to
323
+ * every page as a message is, which the agent is handed at its next wait, or at once where it
324
+ * listens, as a notice to disregard what came before; a later session is told only what comes
325
+ * after it. An offline agent's conversation is started anew too, so that its next session starts
326
+ * clean. Null, and nothing written, where it is new already. */
327
+ restart(token, from, now = Date.now()) {
328
+ if (this.fresh(token))
329
+ return null;
330
+ return this.post(token, { from, kind: "divider", text: `${from} started a new conversation: disregard what came before.`, where: null }, now);
331
+ }
332
+ /** Whether `sender` (kind and name) may send now; if not, the seconds to wait. Taking one. */
333
+ allow(kind, sender, now = Date.now()) {
334
+ const r = RATE[kind];
335
+ const key = `${kind}\u0000${sender}`;
336
+ const b = this.buckets.get(key) ?? { tokens: r.burst, at: now };
337
+ b.tokens = Math.min(r.burst, b.tokens + (now - b.at) / r.refillMs);
338
+ b.at = now;
339
+ this.buckets.set(key, b);
340
+ if (b.tokens < 1)
341
+ return Math.ceil(((1 - b.tokens) * r.refillMs) / 1000);
342
+ b.tokens -= 1;
343
+ return 0;
344
+ }
345
+ /** Add a message to a conversation: written, and sent to every page. A person's, or a divider,
346
+ * goes to the agent's open wait, if it has one, else waits for the next; the agent's answers
347
+ * what it was thinking about. */
348
+ post(token, m, now = Date.now()) {
349
+ const thread = this.load(token);
350
+ const msg = { id: (thread.at(-1)?.id ?? 0) + 1, at: new Date(now).toISOString(), ...m };
351
+ thread.push(msg);
352
+ if (thread.length > 4 * THREAD)
353
+ thread.splice(0, thread.length - 2 * THREAD);
354
+ mkdirSync(this.dir, { recursive: true });
355
+ appendFileSync(this.file(token), JSON.stringify(msg) + "\n");
356
+ this.onMessage(token, msg);
357
+ const a = this.agent(token);
358
+ if (msg.kind !== "agent") {
359
+ const w = a.wait;
360
+ if (w) {
361
+ const q = this.queued(token);
362
+ w.end(this.hand(token, q, q, now - w.at, now));
363
+ }
364
+ }
365
+ else {
366
+ this.contact(a, now);
367
+ a.lastRequest = now;
368
+ a.lastSeen = now;
369
+ a.thinkingSince = null;
370
+ a.status = null;
371
+ a.doing = null;
372
+ this.update(token, now);
373
+ }
374
+ return msg;
375
+ }
376
+ file(token) {
377
+ return join(this.dir, `${token.replace(/[^0-9a-f]/g, "")}.jsonl`);
378
+ }
379
+ /** A conversation as it is kept, read once: what is older than 30 days is left out, and the file
380
+ * written again without it. */
381
+ load(token) {
382
+ let t = this.threads.get(token);
383
+ if (t) {
384
+ const old = t.findIndex((m) => Date.now() - Date.parse(m.at) < KEEP_MS);
385
+ if (old > 0)
386
+ t.splice(0, old);
387
+ else if (old < 0)
388
+ t.length = 0;
389
+ return t;
390
+ }
391
+ t = [];
392
+ const f = this.file(token);
393
+ if (existsSync(f)) {
394
+ for (const line of readFileSync(f, "utf8").split("\n")) {
395
+ if (!line.trim())
396
+ continue;
397
+ try {
398
+ t.push(JSON.parse(line));
399
+ }
400
+ catch {
401
+ // a line cut short (the server stopped while writing): left out
402
+ }
403
+ }
404
+ const kept = t.filter((m) => Date.now() - Date.parse(m.at) < KEEP_MS);
405
+ if (kept.length !== t.length) {
406
+ t = kept;
407
+ rewrite(f, kept);
408
+ }
409
+ }
410
+ this.threads.set(token, t);
411
+ return t;
412
+ }
413
+ /** Conversations nobody wrote in for 30 days go; the others lose what is older (when read). */
414
+ sweep(now = Date.now()) {
415
+ if (!existsSync(this.dir))
416
+ return;
417
+ for (const name of readdirSync(this.dir)) {
418
+ const f = join(this.dir, name);
419
+ try {
420
+ if (now - statSync(f).mtimeMs > KEEP_MS) {
421
+ rmSync(f, { force: true });
422
+ this.threads.delete(name.replace(/\.jsonl$/, ""));
423
+ }
424
+ }
425
+ catch {
426
+ // gone meanwhile
427
+ }
428
+ }
429
+ }
430
+ }
431
+ /** The conversation so far as the entry a returning agent is handed first, or null where there is
432
+ * none: the newest of `told`, at most EARLIER_MESSAGES and EARLIER_CHARS characters of text, each
433
+ * with who wrote it (the agent's own as its own), where they were and when; then what to do, as
434
+ * `news` says whether anything new comes after it. */
435
+ export function earlierOf(told, news, now = Date.now()) {
436
+ let k = told.length;
437
+ let chars = 0;
438
+ while (k > 0 && told.length - k < EARLIER_MESSAGES && chars + told[k - 1].text.length <= EARLIER_CHARS)
439
+ chars += told[--k].text.length;
440
+ const kept = told.slice(k);
441
+ if (!kept.length)
442
+ return null;
443
+ const each = kept.map((m) => {
444
+ const who = m.kind === "agent" ? `You (${m.from})` : m.from;
445
+ const where = m.where?.inWords ? `, on ${m.where.inWords.replace(/\.$/, "")}` : "";
446
+ return `— ${who}${where}, ${utc(m.at)} (message ${m.id}):\n${m.text}`;
447
+ });
448
+ const cut = k ? ` (its last ${kept.length} messages; the conversation tool, GET /api/agent/conversation, gives the ${k} before them)` : "";
449
+ const then = news ? "What came since follows: answer that." : "Nothing came since: there is nothing to answer. Listen again.";
450
+ return {
451
+ id: 0,
452
+ at: new Date(now).toISOString(),
453
+ from: "VisualProof",
454
+ kind: "earlier",
455
+ text: `Earlier in this conversation, before you joined${cut} — read it as context, and do not answer it again:\n\n${each.join("\n\n")}\n\nEnd of the earlier messages. ${then}`,
456
+ count: kept.length,
457
+ omitted: k,
458
+ };
459
+ }
460
+ /** An agent's state at `now`, from what it did: a wait open (or one that ended at its time just
461
+ * now, which it is about to ask again); else offline after its longest wait and a minute without a
462
+ * request; else thinking about what was handed to it, or working. */
463
+ function stateOf(a, now, timing) {
464
+ if (a.wait || now < a.graceUntil)
465
+ return "listening";
466
+ if (!a.lastRequest || now - a.lastRequest > a.waitMs + timing.slackMs)
467
+ return "offline";
468
+ return a.thinkingSince !== null ? "thinking" : "working";
469
+ }
470
+ function rewrite(f, ms) {
471
+ if (!ms.length)
472
+ return void rmSync(f, { force: true });
473
+ const tmp = `${f}.tmp`;
474
+ writeFileSync(tmp, ms.map((m) => JSON.stringify(m)).join("\n") + "\n");
475
+ renameSync(tmp, f);
476
+ }