chamba 0.3.0 → 0.4.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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -44
  3. package/bin/chamba.js +212 -0
  4. package/dist/commands/advanced.js +278 -0
  5. package/dist/commands/dev.js +619 -0
  6. package/dist/commands/doctor.js +29 -0
  7. package/dist/commands/menu.js +80 -0
  8. package/dist/commands/onboard.js +229 -0
  9. package/dist/commands/settings.js +349 -0
  10. package/dist/lib/agent-context.js +177 -0
  11. package/dist/lib/browser.js +40 -0
  12. package/dist/lib/chamba-yaml.js +191 -0
  13. package/dist/lib/constants.js +135 -0
  14. package/dist/lib/dockerfile-builder.js +267 -0
  15. package/dist/lib/env.js +78 -0
  16. package/dist/lib/global-config.js +66 -0
  17. package/dist/lib/pnpm-store.js +19 -0
  18. package/dist/lib/ports.js +210 -0
  19. package/dist/lib/safe-rm.js +26 -0
  20. package/dist/lib/sessions.js +34 -0
  21. package/dist/lib/shadows.js +174 -0
  22. package/dist/lib/webterm.js +490 -0
  23. package/dist/lib/workspace-identity.js +260 -0
  24. package/package.json +61 -24
  25. package/schema/chamba.schema.json +65 -0
  26. package/templates/.dockerignore +3 -0
  27. package/templates/Dockerfile +173 -0
  28. package/templates/claude-statusline.sh +120 -0
  29. package/templates/context/baseline.md +13 -0
  30. package/templates/context/context-usage.md +1 -0
  31. package/templates/context/git-mode-local.md +1 -0
  32. package/templates/context/git-mode-strict.md +1 -0
  33. package/templates/context/git-mode-unrestricted.md +1 -0
  34. package/templates/context/git-unavailable.md +1 -0
  35. package/templates/context/shadow-paths.md +3 -0
  36. package/templates/context-usage.sh +249 -0
  37. package/templates/git-readonly-wrapper.mjs +309 -0
  38. package/templates/npmrc +2 -0
  39. package/templates/pnpm-config.yaml +9 -0
  40. package/templates/runtime-constants.mjs +18 -0
  41. package/templates/skills/chamba-statusline/SKILL.md +79 -0
  42. package/templates/skills/context-usage/SKILL.md +53 -0
  43. package/templates/skills/web-pane/SKILL.md +62 -0
  44. package/templates/startup-git-mode.mjs +145 -0
  45. package/templates/startup.mjs +333 -0
  46. package/templates/webpane.sh +126 -0
  47. package/templates/webterm/README.md +157 -0
  48. package/templates/webterm/artifacts.js +583 -0
  49. package/templates/webterm/config.js +269 -0
  50. package/templates/webterm/context/claude.md +14 -0
  51. package/templates/webterm/conversation.js +248 -0
  52. package/templates/webterm/package-lock.json +884 -0
  53. package/templates/webterm/package.json +17 -0
  54. package/templates/webterm/pane.js +156 -0
  55. package/templates/webterm/proc.js +89 -0
  56. package/templates/webterm/public/app/alerts.js +472 -0
  57. package/templates/webterm/public/app/cards.js +123 -0
  58. package/templates/webterm/public/app/clipboard.js +229 -0
  59. package/templates/webterm/public/app/composer.js +226 -0
  60. package/templates/webterm/public/app/connection.js +342 -0
  61. package/templates/webterm/public/app/dictation.js +98 -0
  62. package/templates/webterm/public/app/dom.js +37 -0
  63. package/templates/webterm/public/app/drafts.js +244 -0
  64. package/templates/webterm/public/app/frames.js +166 -0
  65. package/templates/webterm/public/app/main.js +82 -0
  66. package/templates/webterm/public/app/new-session.js +188 -0
  67. package/templates/webterm/public/app/note.js +24 -0
  68. package/templates/webterm/public/app/pane-frame.js +166 -0
  69. package/templates/webterm/public/app/pane.js +353 -0
  70. package/templates/webterm/public/app/state.js +51 -0
  71. package/templates/webterm/public/app/status-strip.js +170 -0
  72. package/templates/webterm/public/app/tabs.js +475 -0
  73. package/templates/webterm/public/app/terminal.js +102 -0
  74. package/templates/webterm/public/app/theme.js +46 -0
  75. package/templates/webterm/public/favicon.svg +21 -0
  76. package/templates/webterm/public/index.html +105 -0
  77. package/templates/webterm/public/styles.css +1193 -0
  78. package/templates/webterm/server.js +1142 -0
  79. package/templates/webterm/sessions.js +515 -0
  80. package/templates/webterm/snapshot.js +135 -0
  81. package/templates/webterm.sh +167 -0
  82. package/dist/cli.js +0 -1691
  83. package/dist/server.js +0 -1919
  84. package/inject/annotate.js +0 -18
  85. package/skill/README.md +0 -12
  86. package/skill/SKILL.md +0 -93
  87. package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
  88. package/web/assets/index-B9DI4F1Z.js +0 -202
  89. package/web/assets/index-DK_n6CTo.css +0 -2
  90. package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
  91. package/web/index.html +0 -28
@@ -0,0 +1,583 @@
1
+ // artifacts.js - The web pane's files: the pages a session published, and the feedback submitted on them.
2
+ //
3
+ // The pane is a directory, not a protocol. A page is a standalone HTML file written into the conversation's
4
+ // own directory; the interface lists what is there and serves it. That is what makes the history survive a
5
+ // closed tab, a stopped container and a resumed conversation, and it is what lets anything that can write a
6
+ // file publish - the helper is a convenience over `cp`, not a gatekeeper.
7
+ //
8
+ // So this file owns the directory and nothing else: where a conversation's pages live, what a page is called,
9
+ // how big the whole thing may get, what a page is titled, which ones are new, and how a page is read back
10
+ // without ever leaving the root. The server owns who is asking and what to do about it.
11
+ //
12
+ // Two rules run through all of it. Nothing is ever deleted - not by age, not on session close, not when the
13
+ // cap is reached; past the cap a publish is refused and what exists stays. And nothing outside the root is
14
+ // ever read: names are checked against a narrow pattern, symlinks are refused rather than followed, and the
15
+ // resolved path is checked to still be inside the root before a byte is read.
16
+
17
+ import {
18
+ closeSync,
19
+ existsSync,
20
+ lstatSync,
21
+ mkdirSync,
22
+ openSync,
23
+ readdirSync,
24
+ readFileSync,
25
+ readSync,
26
+ realpathSync,
27
+ renameSync,
28
+ rmdirSync,
29
+ statSync,
30
+ writeFileSync,
31
+ } from "node:fs";
32
+ import { join } from "node:path";
33
+
34
+ // A conversation key is a directory name, so it is checked like one: it may not be "." or "..", may not hold
35
+ // a separator, and must start with something ordinary. Agent conversation ids (uuids, "ses_" ids) pass; the
36
+ // server's own provisional keys are minted to match.
37
+ const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
38
+
39
+ // A page is one HTML file directly inside a conversation's directory. Same reasoning as the key: no
40
+ // separators, no leading dot, nothing that could name a parent.
41
+ const PAGE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.html$/;
42
+
43
+ // Where the feedback for a conversation's pages is filed, inside that conversation's own directory.
44
+ const FEEDBACK_DIR = "feedback";
45
+
46
+ // How much of a page is read looking for its title. A title that is not in the first few kilobytes is not a
47
+ // title anyone wrote for the head of the document.
48
+ const TITLE_SCAN_BYTES = 8 * 1024;
49
+
50
+ // Longest title the pane will carry, in characters. It rides into a chip, into a log line, and into the
51
+ // nudge typed at an agent, so it is cut here once rather than by each of them.
52
+ const MAX_TITLE_LENGTH = 80;
53
+
54
+ // Longest slug in a filename. Long enough to stay readable in `ls`, short enough that the name is a name.
55
+ const MAX_SLUG_LENGTH = 48;
56
+
57
+ /** True when a value can name a conversation's directory. */
58
+ export function isPaneKey(value) {
59
+ return typeof value === "string" && KEY_PATTERN.test(value) && value !== "." && value !== "..";
60
+ }
61
+
62
+ /** True when a value can name a page inside one. */
63
+ export function isPageId(value) {
64
+ return typeof value === "string" && PAGE_PATTERN.test(value);
65
+ }
66
+
67
+ // Everything outside the two control blocks and DEL. One rule, so a line and a block of text agree on what
68
+ // a character is.
69
+ function isPrintable(code) {
70
+ return code >= 0x20 && code !== 0x7f && !(code >= 0x80 && code <= 0x9f);
71
+ }
72
+
73
+ /**
74
+ * One clean line of text, cut to a length: control characters (escape sequences included) dropped, runs of
75
+ * whitespace collapsed, ends trimmed. Everything a page or a caller supplies passes through here before it
76
+ * is shown, logged, or typed into a terminal - a title is text, and a terminal reads some text as commands.
77
+ */
78
+ export function cleanLine(raw, max = MAX_TITLE_LENGTH) {
79
+ if (typeof raw !== "string") return "";
80
+ let kept = "";
81
+ for (const ch of raw) {
82
+ const code = ch.codePointAt(0);
83
+ // Drop C0 controls, DEL and the C1 block; keep everything printable, other alphabets and emoji
84
+ // included. C1 goes because U+009B is CSI: no terminal this runs against decodes it from UTF-8 as an
85
+ // escape, but the whole point of this function is that nothing it returns is ever read as a command.
86
+ kept += isPrintable(code) ? ch : " ";
87
+ }
88
+ return kept.replace(/\s+/g, " ").trim().slice(0, max).trim();
89
+ }
90
+
91
+ /**
92
+ * The same cleaning for text that is allowed to be more than one line: a form's free-text box, a long answer
93
+ * typed into a textarea. Line breaks survive, every other control character does not, and the length is
94
+ * capped. Nothing typed into a terminal ever comes from here - this is what gets written to a file.
95
+ */
96
+ export function cleanText(raw, max) {
97
+ if (typeof raw !== "string") return "";
98
+ let kept = "";
99
+ for (const ch of raw) {
100
+ const code = ch.codePointAt(0);
101
+ if (ch === "\n" || isPrintable(code)) kept += ch;
102
+ else if (ch === "\r" || ch === "\t") kept += ch === "\t" ? " " : "";
103
+ else kept += " ";
104
+ }
105
+ return kept.trim().slice(0, max);
106
+ }
107
+
108
+ /** The filename part a title turns into: lowercase, words joined by dashes, nothing else. */
109
+ export function slugify(raw) {
110
+ const slug = cleanLine(raw, MAX_SLUG_LENGTH * 2)
111
+ .toLowerCase()
112
+ .replace(/[^a-z0-9]+/g, "-")
113
+ .replace(/^-+|-+$/g, "")
114
+ .slice(0, MAX_SLUG_LENGTH)
115
+ .replace(/-+$/, "");
116
+ return slug || "page";
117
+ }
118
+
119
+ /**
120
+ * The first `limit` bytes of a file, as text, or "" when it cannot be read.
121
+ *
122
+ * Bounded at the read rather than after it: a page put into the directory by hand is not held to the publish
123
+ * cap, so a listing that read whole files would stall the whole interface on one huge one - and a listing
124
+ * happens on a timer, for every page, in every pane.
125
+ */
126
+ function headOf(path, limit) {
127
+ let fd = null;
128
+ try {
129
+ fd = openSync(path, "r");
130
+ const buffer = Buffer.alloc(limit);
131
+ const read = readSync(fd, buffer, 0, limit, 0);
132
+ return buffer.subarray(0, read).toString("utf8");
133
+ } catch {
134
+ return "";
135
+ } finally {
136
+ if (fd !== null) closeSync(fd);
137
+ }
138
+ }
139
+
140
+ // The five entities a title can realistically carry. Titles are short human text, not markup, so this is not
141
+ // a parser - it is the difference between showing "Ports &amp; keys" and "Ports & keys".
142
+ const ENTITIES = { "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'" };
143
+
144
+ /**
145
+ * What a page calls itself: the text of its <title>, or the filename read back as words.
146
+ *
147
+ * The page's own title is used rather than anything recorded beside it, so a file dropped into the directory
148
+ * by hand is titled exactly like one the helper published, and there is no second copy of the truth to drift.
149
+ */
150
+ function titleFor(path, id) {
151
+ const head = headOf(path, TITLE_SCAN_BYTES);
152
+ const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(head);
153
+ if (match) {
154
+ const text = cleanLine(match[1].replace(/<[^>]*>/g, "").replace(/&(amp|lt|gt|quot|#39);/g, (entity) => ENTITIES[entity]));
155
+ if (text) return text;
156
+ }
157
+ return (
158
+ cleanLine(
159
+ id
160
+ .replace(/\.html$/i, "")
161
+ .replace(/^\d+-/, "")
162
+ .replace(/[-_]+/g, " "),
163
+ ) || id
164
+ );
165
+ }
166
+
167
+ /**
168
+ * The pane store over one artifacts root.
169
+ *
170
+ * `maxPageBytes` caps a single page, `maxTotalBytes` and `maxTotalFiles` the whole root, and
171
+ * `maxFeedbackPerPage` how many answers one page may file. All of them are enforced here rather than in the
172
+ * helper, because the helper is one way in and a file copied by hand is another.
173
+ */
174
+ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFiles, maxFeedbackPerPage }) {
175
+ // What the last scan of each key saw: id -> { mtime, unread }. This is where "new" lives. A key scanned
176
+ // for the first time has no new pages - they are history, and history does not badge; anything that
177
+ // turns up in a later scan is an arrival and does.
178
+ //
179
+ // Bounded, because a container that has been up for weeks has had every conversation it ever resumed
180
+ // through here. The oldest entry goes first, and all that is lost with it is which pages were unread in
181
+ // a conversation nobody has looked at for that long.
182
+ const scanned = new Map();
183
+ const MAX_SCANNED_KEYS = 256;
184
+
185
+ function remember(key, seen) {
186
+ scanned.set(key, seen);
187
+ while (scanned.size > MAX_SCANNED_KEYS) {
188
+ const oldest = scanned.keys().next();
189
+ if (oldest.done || oldest.value === key) break;
190
+ scanned.delete(oldest.value);
191
+ }
192
+ }
193
+
194
+ function keyDir(key) {
195
+ return join(root, key);
196
+ }
197
+
198
+ /**
199
+ * The directory for a key, created if needed, or null when the key is not one or the path is not ours.
200
+ * The realpath check is the containment rule at the directory level: a key directory that is a symlink,
201
+ * or that resolves anywhere but inside the root, is refused rather than followed.
202
+ */
203
+ function ensureDir(key) {
204
+ if (!isPaneKey(key)) return null;
205
+ const dir = keyDir(key);
206
+ try {
207
+ mkdirSync(dir, { recursive: true });
208
+ const real = realpathSync(dir);
209
+ const realRoot = realpathSync(root);
210
+ if (real !== join(realRoot, key)) return null;
211
+ return dir;
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+
217
+ /** The page files in a key's directory, oldest first, with their size and the number they carry. */
218
+ function pageFiles(key) {
219
+ if (!isPaneKey(key)) return [];
220
+ let names;
221
+ try {
222
+ names = readdirSync(keyDir(key));
223
+ } catch {
224
+ return []; // No directory yet, or nothing readable in it: an empty pane, not an error.
225
+ }
226
+ const found = [];
227
+ for (const id of names) {
228
+ if (!isPageId(id)) continue;
229
+ const path = join(keyDir(key), id);
230
+ let stat;
231
+ try {
232
+ stat = lstatSync(path);
233
+ } catch {
234
+ continue;
235
+ }
236
+ // A symlink is refused here as well as on read, so a link into the workspace is not even listed.
237
+ if (!stat.isFile()) continue;
238
+ const numberMatch = /^(\d+)-/.exec(id);
239
+ found.push({ id, path, bytes: stat.size, mtime: stat.mtimeMs, number: numberMatch ? Number(numberMatch[1]) : 0 });
240
+ }
241
+ return found.sort((a, b) => a.number - b.number || a.mtime - b.mtime);
242
+ }
243
+
244
+ /**
245
+ * What the whole root holds, pages and feedback together: bytes and file count. Walked per write, which
246
+ * is affordable only because the file count is capped too - both numbers come out of this one walk, and
247
+ * the count is what keeps the walk itself small.
248
+ */
249
+ function totals() {
250
+ let total = 0;
251
+ let files = 0;
252
+ let dirs;
253
+ try {
254
+ dirs = readdirSync(root, { withFileTypes: true });
255
+ } catch {
256
+ return { bytes: 0, files: 0 };
257
+ }
258
+ for (const entry of dirs) {
259
+ if (!entry.isDirectory() || !isPaneKey(entry.name)) continue;
260
+ for (const dir of [keyDir(entry.name), join(keyDir(entry.name), FEEDBACK_DIR)]) {
261
+ let names;
262
+ try {
263
+ names = readdirSync(dir);
264
+ } catch {
265
+ continue;
266
+ }
267
+ for (const name of names) {
268
+ try {
269
+ const stat = lstatSync(join(dir, name));
270
+ if (!stat.isFile()) continue;
271
+ total += stat.size;
272
+ files += 1;
273
+ } catch {
274
+ // Vanished between the listing and the stat.
275
+ }
276
+ }
277
+ }
278
+ }
279
+ return { bytes: total, files };
280
+ }
281
+
282
+ /** Bytes held by the whole root. The count that goes with it is a cap, not something a caller asks for. */
283
+ function totalBytes() {
284
+ return totals().bytes;
285
+ }
286
+
287
+ /** Room for one more file of this size, or the refusal that says which cap it met. */
288
+ function roomFor(bytes) {
289
+ const held = totals();
290
+ if (held.bytes + bytes > maxTotalBytes) return "full";
291
+ if (held.files + 1 > maxTotalFiles) return "full";
292
+ return null;
293
+ }
294
+
295
+ /**
296
+ * The pane's contents for a key, as the browser sees it: the id to ask for, what the page calls itself,
297
+ * when it arrived, and whether it is new since the window was last looking.
298
+ */
299
+ function list(key) {
300
+ const seen = scanned.get(key);
301
+ return pageFiles(key).map((file) => ({
302
+ id: file.id,
303
+ title: titleFor(file.path, file.id),
304
+ mtime: Math.round(file.mtime),
305
+ bytes: file.bytes,
306
+ unread: seen?.get(file.id)?.unread === true,
307
+ }));
308
+ }
309
+
310
+ /**
311
+ * Look at a key's directory again and report whether anything moved. Pages that turned up since the last
312
+ * look are new (unread), unless this is the first look at that key, when everything there is history.
313
+ * This is what makes a file copied in by hand appear in the pane with no helper involved.
314
+ */
315
+ function refresh(key) {
316
+ const files = pageFiles(key);
317
+ const before = scanned.get(key);
318
+ const after = new Map();
319
+ let changed = before === undefined && files.length > 0;
320
+ for (const file of files) {
321
+ const previous = before?.get(file.id);
322
+ if (previous === undefined) {
323
+ after.set(file.id, { mtime: file.mtime, unread: before !== undefined });
324
+ if (before !== undefined) changed = true;
325
+ } else {
326
+ after.set(file.id, { mtime: file.mtime, unread: previous.unread });
327
+ if (previous.mtime !== file.mtime) changed = true;
328
+ }
329
+ }
330
+ if (before !== undefined && before.size !== after.size) changed = true;
331
+ remember(key, after);
332
+ return changed;
333
+ }
334
+
335
+ /** The window opened this page, so it is no longer new. True when that actually changed something. */
336
+ function markRead(key, id) {
337
+ const entry = scanned.get(key)?.get(id);
338
+ if (!entry?.unread) return false;
339
+ entry.unread = false;
340
+ return true;
341
+ }
342
+
343
+ /**
344
+ * One page's content, or why it cannot be served. Every refusal is a containment rule:
345
+ * - "name": the key or the id is not a shape we file things under, so it never becomes a path.
346
+ * - "missing": there is no such page, or it is not a regular file - a directory, a device, a symlink.
347
+ * Symlinks are refused rather than followed, which is what stops a link inside the pane from serving
348
+ * the key file or anything else in the container.
349
+ * - "toobig": bigger than a page may be. A file put there by hand is not bound by the publish cap, and
350
+ * reading an arbitrarily large file into memory to answer a request is not something to allow.
351
+ */
352
+ function read(key, id) {
353
+ if (!isPaneKey(key) || !isPageId(id)) return { ok: false, error: "name" };
354
+ const path = join(keyDir(key), id);
355
+ let stat;
356
+ try {
357
+ stat = lstatSync(path);
358
+ } catch {
359
+ return { ok: false, error: "missing" };
360
+ }
361
+ if (!stat.isFile()) return { ok: false, error: "missing" };
362
+ if (stat.size > maxPageBytes) return { ok: false, error: "toobig" };
363
+ // Belt and braces over the name check: whatever the path turned out to be, it has to still be the
364
+ // file inside this key's directory.
365
+ try {
366
+ if (realpathSync(path) !== join(realpathSync(root), key, id)) return { ok: false, error: "missing" };
367
+ } catch {
368
+ return { ok: false, error: "missing" };
369
+ }
370
+ try {
371
+ return { ok: true, id, title: titleFor(path, id), html: readFileSync(path, "utf8") };
372
+ } catch {
373
+ return { ok: false, error: "missing" };
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Write a page into a key's directory and return what it became. `title` names it (and, slugified, names
379
+ * the file); the number in front of the name counts up per conversation, so the pane reads in the order
380
+ * the pages were published however they are sorted elsewhere.
381
+ *
382
+ * Refusals: "name" for a key that is not one, "toobig" for a page over the per-page cap, "full" when the
383
+ * root has reached its total - nothing is deleted to make room, because a page the user has not read yet
384
+ * is not the server's to throw away - and "write" when the filesystem said no.
385
+ */
386
+ function publish(key, { title, html }) {
387
+ if (!isPaneKey(key)) return { ok: false, error: "name" };
388
+ if (typeof html !== "string" || html === "") return { ok: false, error: "empty" };
389
+ const bytes = Buffer.byteLength(html);
390
+ if (bytes > maxPageBytes) return { ok: false, error: "toobig" };
391
+ const noRoom = roomFor(bytes);
392
+ if (noRoom !== null) return { ok: false, error: noRoom };
393
+ const dir = ensureDir(key);
394
+ if (dir === null) return { ok: false, error: "name" };
395
+ const files = pageFiles(key);
396
+ const next = files.reduce((highest, file) => Math.max(highest, file.number), 0) + 1;
397
+ const clean = cleanLine(title);
398
+ const id = `${String(next).padStart(2, "0")}-${slugify(clean)}.html`;
399
+ try {
400
+ writeFileSync(join(dir, id), html);
401
+ } catch {
402
+ return { ok: false, error: "write" };
403
+ }
404
+ // Straight into the scan as an arrival: a page nobody has opened is new, whoever wrote it.
405
+ const seen = scanned.get(key) ?? new Map();
406
+ seen.set(id, { mtime: Date.now(), unread: true });
407
+ remember(key, seen);
408
+ return { ok: true, id, title: clean || titleFor(join(dir, id), id), path: join(dir, id) };
409
+ }
410
+
411
+ /** The answers already filed for one page, by the name they all start with. */
412
+ function feedbackFiles(key, stem) {
413
+ let names;
414
+ try {
415
+ names = readdirSync(join(keyDir(key), FEEDBACK_DIR));
416
+ } catch {
417
+ return []; // No answers yet: an empty list, not an error.
418
+ }
419
+ return names.filter((name) => name.startsWith(`${stem}-`) && name.endsWith(".json"));
420
+ }
421
+
422
+ /**
423
+ * File one submission from a page's form. The page it belongs to is passed in already resolved against
424
+ * the listing, and the filename is built here from that page and the clock - nothing a submission carries
425
+ * decides where it lands.
426
+ */
427
+ function writeFeedback(key, id, { fields, text }) {
428
+ if (!isPaneKey(key) || !isPageId(id)) return { ok: false, error: "name" };
429
+ const dir = ensureDir(key);
430
+ if (dir === null) return { ok: false, error: "name" };
431
+ const feedbackDir = join(dir, FEEDBACK_DIR);
432
+ const stem = id.replace(/\.html$/i, "");
433
+ const body = {
434
+ page: stem,
435
+ submittedAt: new Date().toISOString(),
436
+ fields: fields ?? {},
437
+ text: text ?? "",
438
+ };
439
+ const contents = `${JSON.stringify(body, null, 2)}\n`;
440
+ // A form is submitted by whatever is inside the page - a person clicking, or a script in it - so an
441
+ // endless stream of them is a thing that can happen. Two caps stand in the way, and the count is the
442
+ // one that matters: answers are small, so bytes alone would allow a very large number of them, and
443
+ // every one of them also types a line into the agent's terminal.
444
+ if (feedbackFiles(key, stem).length + 1 > maxFeedbackPerPage) return { ok: false, error: "toomany" };
445
+ const noRoom = roomFor(Buffer.byteLength(contents));
446
+ if (noRoom !== null) return { ok: false, error: noRoom };
447
+ // The clock names the file, and a second submission in the same millisecond takes the next free name
448
+ // beside it. Nothing here ever overwrites: two answers are two answers, whatever the timing, and a
449
+ // millisecond with a hundred of them in it is refused rather than written over.
450
+ const at = Date.now();
451
+ let file = `${stem}-${at}.json`;
452
+ let attempt = 0;
453
+ while (existsSync(join(feedbackDir, file))) {
454
+ if (++attempt >= 100) return { ok: false, error: "write" };
455
+ file = `${stem}-${at}-${attempt}.json`;
456
+ }
457
+ try {
458
+ mkdirSync(feedbackDir, { recursive: true });
459
+ writeFileSync(join(feedbackDir, file), contents);
460
+ } catch {
461
+ return { ok: false, error: "write" };
462
+ }
463
+ return { ok: true, file, path: join(feedbackDir, file) };
464
+ }
465
+
466
+ /**
467
+ * Move everything published under one key to another - the moment a provisional key turns out to have a
468
+ * conversation id after all. A rename when the other side has no directory yet, which is the first time a
469
+ * conversation is seen; when it does exist - a conversation resumed after publishing something - the pages
470
+ * are carried over one at a time with fresh numbers, because the conversation's own numbering is the one
471
+ * that has to stay in order.
472
+ *
473
+ * What is new stays new: a page the user has not opened is still unread under its new name.
474
+ */
475
+ function adopt(fromKey, toKey) {
476
+ if (!isPaneKey(fromKey) || !isPaneKey(toKey)) return false;
477
+ if (fromKey === toKey) return true;
478
+ const fromDir = keyDir(fromKey);
479
+ const unread = scanned.get(fromKey) ?? new Map();
480
+ scanned.delete(fromKey);
481
+ if (!existsSync(fromDir)) {
482
+ refresh(toKey);
483
+ return true;
484
+ }
485
+ try {
486
+ if (!existsSync(keyDir(toKey))) {
487
+ renameSync(fromDir, keyDir(toKey));
488
+ const carried = new Map();
489
+ for (const file of pageFiles(toKey))
490
+ carried.set(file.id, { mtime: file.mtime, unread: unread.get(file.id)?.unread === true });
491
+ remember(toKey, carried);
492
+ return true;
493
+ }
494
+ } catch {
495
+ return false;
496
+ }
497
+ return merge(fromKey, toKey, unread);
498
+ }
499
+
500
+ // The rarer half of adopt(): both directories exist, so each page is moved across under the next free
501
+ // number and its feedback follows it. The source directory is removed only when it is empty, which is
502
+ // the same "nothing is deleted" rule as everywhere else.
503
+ function merge(fromKey, toKey, unread) {
504
+ const target = ensureDir(toKey);
505
+ if (target === null) return false;
506
+ // A key nobody has scanned yet has no unread pages, so the carry starts from what is already there.
507
+ // Without that seed, remembering only the moved pages would make the target's own history arrive
508
+ // badged at the next refresh - the very rule the scan map exists to hold.
509
+ const carried = scanned.get(toKey) ?? new Map(pageFiles(toKey).map((file) => [file.id, { mtime: file.mtime, unread: false }]));
510
+ let next = pageFiles(toKey).reduce((highest, file) => Math.max(highest, file.number), 0);
511
+ for (const file of pageFiles(fromKey)) {
512
+ next += 1;
513
+ const id = `${String(next).padStart(2, "0")}-${file.id.replace(/^\d+-/, "")}`;
514
+ try {
515
+ renameSync(file.path, join(target, id));
516
+ } catch {
517
+ continue; // Leave it where it is rather than lose it.
518
+ }
519
+ carried.set(id, { mtime: file.mtime, unread: unread.get(file.id)?.unread === true });
520
+ moveFeedback(fromKey, toKey, file.id, id);
521
+ }
522
+ remember(toKey, carried);
523
+ cleanupEmpty(fromKey);
524
+ return true;
525
+ }
526
+
527
+ // A page's feedback files travel with it, renamed to match the page's new number.
528
+ function moveFeedback(fromKey, toKey, fromId, toId) {
529
+ const fromDir = join(keyDir(fromKey), FEEDBACK_DIR);
530
+ const toDir = join(keyDir(toKey), FEEDBACK_DIR);
531
+ const fromStem = fromId.replace(/\.html$/i, "");
532
+ const toStem = toId.replace(/\.html$/i, "");
533
+ let names;
534
+ try {
535
+ names = readdirSync(fromDir);
536
+ } catch {
537
+ return;
538
+ }
539
+ for (const name of names) {
540
+ if (!name.startsWith(`${fromStem}-`) || !name.endsWith(".json")) continue;
541
+ try {
542
+ mkdirSync(toDir, { recursive: true });
543
+ renameSync(join(fromDir, name), join(toDir, `${toStem}-${name.slice(fromStem.length + 1)}`));
544
+ } catch {
545
+ // Leave it behind rather than lose it.
546
+ }
547
+ }
548
+ }
549
+
550
+ // Remove the provisional directory once everything in it has moved. Only ever an empty directory, and
551
+ // only one this store created.
552
+ function cleanupEmpty(key) {
553
+ for (const dir of [join(keyDir(key), FEEDBACK_DIR), keyDir(key)]) {
554
+ try {
555
+ if (statSync(dir).isDirectory() && readdirSync(dir).length === 0) rmdirSync(dir);
556
+ } catch {
557
+ // Not empty, or already gone: either way there is nothing to do.
558
+ }
559
+ }
560
+ }
561
+
562
+ return { adopt, ensureDir, keyDir, list, markRead, publish, read, refresh, totalBytes, writeFeedback };
563
+ }
564
+
565
+ /**
566
+ * What a submission is allowed to be. Free text from a page's form, so it is bounded on every axis before it
567
+ * is written: how many fields, how long a name, how long a value, how long the free-text box. Anything else
568
+ * (numbers, nested objects, a field called `__proto__`) is turned into text or dropped rather than argued
569
+ * with - this becomes a JSON file an agent reads, not a structure anything acts on.
570
+ */
571
+ export function cleanSubmission(body, { maxFields, maxFieldLength, maxTextLength }) {
572
+ const fields = {};
573
+ const raw = body && typeof body === "object" && typeof body.fields === "object" && body.fields !== null ? body.fields : {};
574
+ for (const [name, value] of Object.entries(raw)) {
575
+ if (Object.keys(fields).length >= maxFields) break;
576
+ const key = cleanLine(name, 64);
577
+ if (!key || key === "__proto__") continue;
578
+ if (value === null || typeof value === "object") continue;
579
+ fields[key] = cleanText(String(value), maxFieldLength);
580
+ }
581
+ const text = cleanText(body?.text, maxTextLength);
582
+ return { fields, text };
583
+ }