@chibitek/chibi 0.1.158 → 0.1.160

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 (89) hide show
  1. package/dist/assets/{abnfDiagram-O67JEVCF-B8-8zzMS.js → abnfDiagram-O67JEVCF-CpuRvzQ3.js} +1 -1
  2. package/dist/assets/{arc-CSHQ9Dx3.js → arc-ko1e0R6h.js} +1 -1
  3. package/dist/assets/{architectureDiagram-NJMV4G6O-BCHM9rXW.js → architectureDiagram-NJMV4G6O-OZ3SsOOa.js} +1 -1
  4. package/dist/assets/{blockDiagram-BEXU5L5S-B-5uxPFN.js → blockDiagram-BEXU5L5S-DLRkBkjs.js} +1 -1
  5. package/dist/assets/{c4Diagram-YGBWAQC7-CuKNM_MR.js → c4Diagram-YGBWAQC7-e8Q3qMnw.js} +1 -1
  6. package/dist/assets/channel-Lp7erhx-.js +1 -0
  7. package/dist/assets/{chunk-2Q5K7J3B-BNF02GM4.js → chunk-2Q5K7J3B-hCDqaM4Q.js} +1 -1
  8. package/dist/assets/{chunk-3FUC2YCW-uEWgBd4M.js → chunk-3FUC2YCW-Dy3Pahu-.js} +2 -2
  9. package/dist/assets/{chunk-5VM5RSS4-eSZuXctY.js → chunk-5VM5RSS4-QMK3iFBF.js} +1 -1
  10. package/dist/assets/{chunk-GTNCS2PH-CslS8nRS.js → chunk-GTNCS2PH-6fyp9ERp.js} +1 -1
  11. package/dist/assets/{chunk-GWA4HPMP-w71-MeKj.js → chunk-GWA4HPMP-DWvOhTaO.js} +1 -1
  12. package/dist/assets/{chunk-JWPE2WC7-Bjju7WGh.js → chunk-JWPE2WC7-DgohMW2S.js} +1 -1
  13. package/dist/assets/{chunk-NETBCI7D-HxrroD4M.js → chunk-NETBCI7D-sRGeD2Y7.js} +1 -1
  14. package/dist/assets/{chunk-WEXAMYUT-D7Rz470x.js → chunk-WEXAMYUT-CZslD_VF.js} +1 -1
  15. package/dist/assets/{chunk-XXDRQBXY-BWKnAeyM.js → chunk-XXDRQBXY-D7ocO3VN.js} +1 -1
  16. package/dist/assets/{classDiagram-v2-NBCMYWYE-CeBDRHmP.js → classDiagram-v2-NBCMYWYE-T1lLqHHW.js} +1 -1
  17. package/dist/assets/{cose-bilkent-JH36ORCC-BBbRKlvr.js → cose-bilkent-JH36ORCC-BdeLiJr3.js} +1 -1
  18. package/dist/assets/{cynefin-OW5HDTMX-W02cnJ0B.js → cynefin-OW5HDTMX-BydCA0pv.js} +1 -1
  19. package/dist/assets/{cynefinDiagram-VND7K2PF-Cs47eaxd.js → cynefinDiagram-VND7K2PF-DBxckjwQ.js} +1 -1
  20. package/dist/assets/{dagre-6A5THRUB-8mG18hzT.js → dagre-6A5THRUB-CAsvq6Zk.js} +1 -1
  21. package/dist/assets/{diagram-22UHCM2B-DF4Qrw67.js → diagram-22UHCM2B-DTwH3rA_.js} +1 -1
  22. package/dist/assets/{diagram-3UASUU5V-3BAVoed1.js → diagram-3UASUU5V-BNYj1bey.js} +1 -1
  23. package/dist/assets/{diagram-ATOU4E4O-CKO3pECE.js → diagram-ATOU4E4O-CihwYFxb.js} +1 -1
  24. package/dist/assets/{diagram-CDSNMT55-CZhrGvur.js → diagram-CDSNMT55-nouwk3s2.js} +1 -1
  25. package/dist/assets/{diagram-MLGK6HIB-C07l9pdQ.js → diagram-MLGK6HIB-BoKewJeA.js} +1 -1
  26. package/dist/assets/{diagram-MPIPVDR6-D3or4lkZ.js → diagram-MPIPVDR6-CrE1m9DQ.js} +1 -1
  27. package/dist/assets/{ebnfDiagram-ZINNZB2B-DV2epXp_.js → ebnfDiagram-ZINNZB2B-DRj0aU4Y.js} +1 -1
  28. package/dist/assets/{elk-276RUBZZ-DlvEn4-1.js → elk-276RUBZZ-Do0JzCG5.js} +1 -1
  29. package/dist/assets/{erDiagram-OPXOYQCR-CB6X7Wt5.js → erDiagram-OPXOYQCR-CRsI9pXb.js} +1 -1
  30. package/dist/assets/{flowDiagram-KWPJA3E3-C3bqz4lC.js → flowDiagram-KWPJA3E3-dtD6wm3G.js} +1 -1
  31. package/dist/assets/{ganttDiagram-FUAMR5RP-yo_Evhab.js → ganttDiagram-FUAMR5RP-Cn7KyLCo.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-X574FWY7-Cq2oUlap.js → gitGraphDiagram-X574FWY7-CGH_VWzq.js} +1 -1
  33. package/dist/assets/index-B2fJMh8N.css +1 -0
  34. package/dist/assets/{index-CMcr5k3Z.js → index-BIcziktH.js} +246 -246
  35. package/dist/assets/{index-CG4AFPb4.js → index-Bk8iAodB.js} +1 -1
  36. package/dist/assets/{infoDiagram-VRGFBTTK-Y3ZrY7qL.js → infoDiagram-VRGFBTTK-CQKsYTCL.js} +1 -1
  37. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BpOtSfyi.js → ishikawaDiagram-OU5B5YK6-D5catDe0.js} +1 -1
  38. package/dist/assets/{journeyDiagram-ZHPQQLJL-UQM727le.js → journeyDiagram-ZHPQQLJL-KjY8mki9.js} +1 -1
  39. package/dist/assets/{kanban-definition-PNTS6WVX-C3s5oHpY.js → kanban-definition-PNTS6WVX-BKCyJtge.js} +1 -1
  40. package/dist/assets/{linear-hX6nFOQr.js → linear-B-_obUkq.js} +1 -1
  41. package/dist/assets/{mermaid.core-DZObGLN7.js → mermaid.core-DlSzuDKt.js} +5 -5
  42. package/dist/assets/{mindmap-definition-NLK3R4M7-Br-i2E3P.js → mindmap-definition-NLK3R4M7-BFQSaYKh.js} +1 -1
  43. package/dist/assets/{pegDiagram-GJSIUBJH-D5SERB-N.js → pegDiagram-GJSIUBJH-l51izlev.js} +1 -1
  44. package/dist/assets/{pieDiagram-5QR66LMP-BPNf4J3D.js → pieDiagram-5QR66LMP-6NEXRoZX.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-O4NWA36T-Bm_Wh7Jr.js → quadrantDiagram-O4NWA36T-BCC9XlOe.js} +1 -1
  46. package/dist/assets/{railroadDiagram-XR7U4H2S-D6bo0abD.js → railroadDiagram-XR7U4H2S-Dd4TmJ86.js} +1 -1
  47. package/dist/assets/{requirementDiagram-PLB6GJNP--1n5rkV7.js → requirementDiagram-PLB6GJNP-C1E5DaDb.js} +1 -1
  48. package/dist/assets/{sankeyDiagram-IPEJSGJF-Ct4tP2kK.js → sankeyDiagram-IPEJSGJF-Ci6r7abb.js} +1 -1
  49. package/dist/assets/{sequenceDiagram-PO4LG4MO-CCG7O5fR.js → sequenceDiagram-PO4LG4MO-BJ-I6WOh.js} +1 -1
  50. package/dist/assets/{sizeCapture-INFHLROL-CJJJra9c.js → sizeCapture-INFHLROL-Cs5QbLOP.js} +1 -1
  51. package/dist/assets/{stateDiagram-v2-GCMORJYK-BMdRvVEX.js → stateDiagram-v2-GCMORJYK-Bw--rdvq.js} +1 -1
  52. package/dist/assets/{swimlanes-2SLR337P-B1wnogip.js → swimlanes-2SLR337P-B1O9IEz5.js} +1 -1
  53. package/dist/assets/swimlanesDiagram-TC7HE7FX-C6wGgYKP.js +8 -0
  54. package/dist/assets/{timeline-definition-EJHVYXUP-l-3yhXUP.js → timeline-definition-EJHVYXUP-CTZ7DXaw.js} +1 -1
  55. package/dist/assets/{usecaseDiagram-POWQR4AR-DtwtCOoJ.js → usecaseDiagram-POWQR4AR-DjwgNG0H.js} +1 -1
  56. package/dist/assets/{vennDiagram-UO4OBE2U-BZAEce1B.js → vennDiagram-UO4OBE2U-DrdJG55h.js} +1 -1
  57. package/dist/assets/{wardleyDiagram-VNRHLVJA-Na_BCMMO.js → wardleyDiagram-VNRHLVJA-HVq776nz.js} +1 -1
  58. package/dist/assets/{xychartDiagram-PMCCYNJV-BW9dlUfE.js → xychartDiagram-PMCCYNJV-DPf9VEua.js} +1 -1
  59. package/dist/index.html +2 -2
  60. package/dist-server/container-mcp.js +1 -1
  61. package/dist-server/index.js +3585 -3076
  62. package/dist-server/openmausbot.js +346 -159
  63. package/dist-server/pair-cli.js +346 -159
  64. package/dist-server/server/attachment-mimes.js +58 -0
  65. package/dist-server/server/attachments.js +28 -67
  66. package/dist-server/server/avatar-image.js +1 -0
  67. package/dist-server/server/avatar-subscription.js +24 -1
  68. package/dist-server/server/drivers/claude-message.js +12 -0
  69. package/dist-server/server/drivers/claude.js +9 -12
  70. package/dist-server/server/drivers/codex-image.js +141 -0
  71. package/dist-server/server/drivers/codex.js +6 -9
  72. package/dist-server/server/drivers/openai-chat.js +13 -2
  73. package/dist-server/server/engine-failover.js +40 -0
  74. package/dist-server/server/engine-failure.js +2 -2
  75. package/dist-server/server/harness-import/history.js +5 -5
  76. package/dist-server/server/harness-import/test-fixtures.js +1 -1
  77. package/dist-server/server/index.js +35 -20
  78. package/dist-server/server/message-reactions.js +11 -0
  79. package/dist-server/server/model-evidence-context.js +18 -0
  80. package/dist-server/server/model-evidence.js +145 -0
  81. package/dist-server/shared/emoji-catalog.json +1 -0
  82. package/dist-server/shared/emoji.js +27 -0
  83. package/dist-server/shared/mascot-bodies.js +9 -1
  84. package/dist-server/shared/model-evidence.js +25 -0
  85. package/dist-server/vps-container-mcp.js +1 -1
  86. package/package.json +1 -1
  87. package/dist/assets/channel-bPN03jIt.js +0 -1
  88. package/dist/assets/index-CZjRcUdn.css +0 -1
  89. package/dist/assets/swimlanesDiagram-TC7HE7FX-DWyjdGGR.js +0 -8
@@ -0,0 +1,58 @@
1
+ /** Accepted upload mimes and the extensions stored on disk for them. */
2
+ /** Mimes the endpoint accepts, mapped to the extension stored on disk.
3
+ * Sniffing is not attempted — a lie here only changes the filename. */
4
+ export const IMAGE_MIMES = {
5
+ "image/png": ".png",
6
+ "image/jpeg": ".jpg",
7
+ "image/gif": ".gif",
8
+ "image/webp": ".webp",
9
+ };
10
+ /** Useful document and audio formats accepted by the upload endpoint.
11
+ * Generic archives, binaries, HTML, SVG, and executable/script mimes stay
12
+ * out. Office/OpenDocument packages are allowed because they are documents,
13
+ * despite using ZIP internally. The claimed mime determines the extension;
14
+ * an attacker-controlled filename never does. */
15
+ export const FILE_MIMES = {
16
+ "audio/opus": ".opus",
17
+ "audio/ogg": ".ogg",
18
+ "audio/mpeg": ".mp3",
19
+ "audio/mp4": ".m4a",
20
+ "audio/x-m4a": ".m4a",
21
+ "audio/aac": ".aac",
22
+ "audio/wav": ".wav",
23
+ "audio/x-wav": ".wav",
24
+ "audio/wave": ".wav",
25
+ "audio/flac": ".flac",
26
+ "audio/x-flac": ".flac",
27
+ "audio/webm": ".webm",
28
+ "text/plain": ".txt",
29
+ "text/markdown": ".md",
30
+ "text/csv": ".csv",
31
+ "text/tab-separated-values": ".tsv",
32
+ "application/json": ".json",
33
+ "application/pdf": ".pdf",
34
+ "application/rtf": ".rtf",
35
+ "text/rtf": ".rtf",
36
+ "application/msword": ".doc",
37
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
38
+ "application/vnd.ms-excel": ".xls",
39
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
40
+ "application/vnd.ms-powerpoint": ".ppt",
41
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
42
+ "application/vnd.oasis.opendocument.text": ".odt",
43
+ "application/vnd.oasis.opendocument.spreadsheet": ".ods",
44
+ "application/vnd.oasis.opendocument.presentation": ".odp",
45
+ };
46
+ export function extensionForMime(mime) {
47
+ if (!mime)
48
+ return null;
49
+ return IMAGE_MIMES[mime.split(";")[0].trim().toLowerCase()] ?? null;
50
+ }
51
+ export function normalizedMime(mime) {
52
+ const value = mime?.split(";")[0]?.trim().toLowerCase();
53
+ return value || null;
54
+ }
55
+ export function extensionForFileMime(mime) {
56
+ const value = normalizedMime(mime);
57
+ return value ? FILE_MIMES[value] ?? null : null;
58
+ }
@@ -2,10 +2,12 @@
2
2
  // ~/.openmausbot/attachments so every CLI engine can open them by path —
3
3
  // the app never ships image bytes through the prompt itself.
4
4
  import { createHash, randomUUID } from "node:crypto";
5
- import { createReadStream, linkSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
5
+ import { createReadStream, linkSync, mkdirSync, readFileSync, readdirSync, statSync, statfsSync, unlinkSync, writeFileSync, } from "node:fs";
6
6
  import { link, open, unlink } from "node:fs/promises";
7
7
  import { join, extname } from "node:path";
8
8
  import { DATA_DIR } from "./config.js";
9
+ import { FILE_MIMES, IMAGE_MIMES, extensionForFileMime, extensionForMime, normalizedMime } from "./attachment-mimes.js";
10
+ export { extensionForFileMime, extensionForMime };
9
11
  export const ATTACHMENTS_DIR = join(DATA_DIR, "attachments");
10
12
  /** The spec's ceiling: a screenshot bigger than this is rejected before it
11
13
  * is ever buffered, matching the composer's existing size discipline. */
@@ -14,9 +16,14 @@ export const IMAGE_MAX_BYTES = 10 * 1024 * 1024;
14
16
  * ceiling. This is a local inbox, not unbounded remote storage. */
15
17
  export const FILE_MAX_BYTES = 25 * 1024 * 1024;
16
18
  /** Attachments are durable because prompts refer to their paths. Never
17
- * silently evict them: once this ceiling is reached, a new upload gets an
18
- * explicit 507 and the person can decide what to remove. */
19
- export const ATTACHMENTS_MAX_BYTES = 512 * 1024 * 1024;
19
+ * silently evict them. There is no fixed quota by default: the store grows
20
+ * until the disk itself runs low (see ATTACHMENTS_DISK_RESERVE_BYTES), then a
21
+ * new upload gets an explicit 507 and the person can decide what to remove.
22
+ * OMB_ATTACHMENTS_MAX_BYTES sets an optional hard ceiling (tests use it). */
23
+ export const ATTACHMENTS_MAX_BYTES = Number(process.env.OMB_ATTACHMENTS_MAX_BYTES) || Number.POSITIVE_INFINITY;
24
+ /** Free space always left on the attachments volume, so uploads can never
25
+ * fill the disk the app, its database and the OS depend on. */
26
+ export const ATTACHMENTS_DISK_RESERVE_BYTES = 2 * 1024 * 1024 * 1024;
20
27
  /** Interrupted streamed uploads use private partial files. A crash can leave
21
28
  * one behind, so future uploads remove only our stale partials — never a
22
29
  * committed attachment that a transcript may still reference. */
@@ -47,55 +54,6 @@ function invalidateAttachmentAccounting() {
47
54
  committedAttachmentBytes = null;
48
55
  attachmentAccountingVersion += 1;
49
56
  }
50
- /** Mimes the endpoint accepts, mapped to the extension stored on disk.
51
- * Sniffing is not attempted — a lie here only changes the filename. */
52
- const IMAGE_MIMES = {
53
- "image/png": ".png",
54
- "image/jpeg": ".jpg",
55
- "image/gif": ".gif",
56
- "image/webp": ".webp",
57
- };
58
- /** Useful document and audio formats accepted by the upload endpoint.
59
- * Generic archives, binaries, HTML, SVG, and executable/script mimes stay
60
- * out. Office/OpenDocument packages are allowed because they are documents,
61
- * despite using ZIP internally. The claimed mime determines the extension;
62
- * an attacker-controlled filename never does. */
63
- const FILE_MIMES = {
64
- "audio/opus": ".opus",
65
- "audio/ogg": ".ogg",
66
- "audio/mpeg": ".mp3",
67
- "audio/mp4": ".m4a",
68
- "audio/x-m4a": ".m4a",
69
- "audio/aac": ".aac",
70
- "audio/wav": ".wav",
71
- "audio/x-wav": ".wav",
72
- "audio/wave": ".wav",
73
- "audio/flac": ".flac",
74
- "audio/x-flac": ".flac",
75
- "audio/webm": ".webm",
76
- "text/plain": ".txt",
77
- "text/markdown": ".md",
78
- "text/csv": ".csv",
79
- "text/tab-separated-values": ".tsv",
80
- "application/json": ".json",
81
- "application/pdf": ".pdf",
82
- "application/rtf": ".rtf",
83
- "text/rtf": ".rtf",
84
- "application/msword": ".doc",
85
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
86
- "application/vnd.ms-excel": ".xls",
87
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
88
- "application/vnd.ms-powerpoint": ".ppt",
89
- "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
90
- "application/vnd.oasis.opendocument.text": ".odt",
91
- "application/vnd.oasis.opendocument.spreadsheet": ".ods",
92
- "application/vnd.oasis.opendocument.presentation": ".odp",
93
- };
94
- export function extensionForMime(mime) {
95
- if (!mime)
96
- return null;
97
- return IMAGE_MIMES[mime.split(";")[0].trim().toLowerCase()] ?? null;
98
- }
99
57
  export function ensureAttachmentsDir() {
100
58
  mkdirSync(ATTACHMENTS_DIR, { recursive: true, mode: 0o700 });
101
59
  }
@@ -160,11 +118,23 @@ function addCommittedBytes(delta, version = attachmentAccountingVersion) {
160
118
  }
161
119
  committedAttachmentBytes += delta;
162
120
  }
163
- /** Bytes that count against ATTACHMENTS_MAX_BYTES right now, for callers
164
- * that plan a batch (the Grok Bot history import) before saving any of it. */
165
- export function attachmentBytesUsed() {
121
+ /** Bytes that count against ATTACHMENTS_MAX_BYTES right now. */
122
+ function attachmentBytesUsed() {
166
123
  return committedBytes() + reservedAttachmentBytes;
167
124
  }
125
+ /** Bytes the disk can still take before dropping to the reserve. Committed
126
+ * files are already off the free count; reservations are not written yet. */
127
+ function diskHeadroomBytes() {
128
+ ensureAttachmentsDir();
129
+ const { bavail, bsize } = statfsSync(ATTACHMENTS_DIR);
130
+ return Number(bavail) * Number(bsize) - ATTACHMENTS_DISK_RESERVE_BYTES - reservedAttachmentBytes;
131
+ }
132
+ /** How many more bytes uploads may add right now: the smaller of the
133
+ * optional ceiling and the disk headroom. Callers that plan a batch (the
134
+ * Grok Bot history import) read this before saving any of it. */
135
+ export function attachmentBytesAvailable() {
136
+ return Math.max(0, Math.min(ATTACHMENTS_MAX_BYTES - attachmentBytesUsed(), diskHeadroomBytes()));
137
+ }
168
138
  /** Remove only abandoned temporary uploads, and refresh the cached usage
169
139
  * total from the same fresh scan. Outside accounting recovery, this is the
170
140
  * only full directory walk after start-up; nothing on the upload path calls it
@@ -239,10 +209,9 @@ class AttachmentReservation {
239
209
  this.reserveAtLeast(bytes, bytes);
240
210
  }
241
211
  reserveAtLeast(required, preferred) {
242
- const used = committedBytes();
243
- const available = ATTACHMENTS_MAX_BYTES - used - reservedAttachmentBytes;
212
+ const available = attachmentBytesAvailable();
244
213
  if (available < required) {
245
- throw statusError(507, `attachments storage is full (limit ${ATTACHMENTS_MAX_BYTES} bytes)`);
214
+ throw statusError(507, "attachments storage is full (the disk is nearly out of free space)");
246
215
  }
247
216
  // Unknown-length HTTP bodies reserve in bounded chunks. Taking whatever
248
217
  // remains below the preferred increment avoids falsely rejecting a small
@@ -328,14 +297,6 @@ async function digestFile(path) {
328
297
  function statusError(status, message) {
329
298
  return Object.assign(new Error(message), { status });
330
299
  }
331
- function normalizedMime(mime) {
332
- const value = mime?.split(";")[0]?.trim().toLowerCase();
333
- return value || null;
334
- }
335
- export function extensionForFileMime(mime) {
336
- const value = normalizedMime(mime);
337
- return value ? FILE_MIMES[value] ?? null : null;
338
- }
339
300
  /** Strip platform-reserved characters and replace any sender-provided
340
301
  * extension with the canonical one for the accepted mime. Separators and
341
302
  * encoded separators are rejected rather than massaged so traversal attempts
@@ -6,6 +6,7 @@ export const AVATAR_IMAGE_TIMEOUT_MS = 120_000;
6
6
  const MAX_UPSTREAM_RESPONSE_BYTES = 15 * 1024 * 1024;
7
7
  export const avatarGenerationRequestSchema = z.object({
8
8
  prompt: z.string().trim().max(AVATAR_DIRECTION_MAX_CHARS).default(""),
9
+ source: z.enum(["auto", "subscription", "api"]).default("auto"),
9
10
  });
10
11
  const generatedImageResponseSchema = z.object({
11
12
  data: z.array(z.object({ b64_json: z.string().min(1).optional(), url: z.string().optional() })).min(1),
@@ -1,5 +1,28 @@
1
1
  import { decodeGeneratedImage } from "./generated-image.js";
2
- import { avatarGenerationPrompt, AVATAR_IMAGE_TIMEOUT_MS } from "./avatar-image.js";
2
+ import { avatarGenerationPrompt, generateAvatarImage, AVATAR_IMAGE_TIMEOUT_MS } from "./avatar-image.js";
3
+ import { PASS, ROUTES } from "./routes/table.js";
4
+ export async function generateRoutedAvatar(cfg, instances, bot, direction, source = "auto") {
5
+ if (source !== "api") {
6
+ const subscription = await avatarSubscription(instances);
7
+ if (subscription)
8
+ return generateSubscriptionAvatar(subscription, bot, direction);
9
+ if (source === "subscription" || instances.some(instance => instance.enabled && instance.generateImage)) {
10
+ throw Object.assign(new Error("Sign in to an image-capable subscription in Settings, or explicitly choose an image API connection."), { status: 409 });
11
+ }
12
+ }
13
+ return generateAvatarImage(cfg, bot, direction);
14
+ }
15
+ export function installAvatarSubscriptionRoute(instances) {
16
+ ROUTES.push(async (context) => {
17
+ if (context.path !== "/api/avatar-subscription" || context.method !== "GET")
18
+ return PASS;
19
+ const available = instances();
20
+ const subscription = await avatarSubscription(available);
21
+ context.res.setHeader("Cache-Control", "no-store");
22
+ context.json(context.res, 200, { offered: available.some(instance => instance.enabled && instance.generateImage),
23
+ available: Boolean(subscription), label: subscription?.displayName ?? "Signed-in subscription" });
24
+ });
25
+ }
3
26
  /** Capability and current billing are both required. An authenticated text
4
27
  * engine is not evidence that its subscription can generate images. */
5
28
  export async function avatarSubscription(instances) {
@@ -0,0 +1,12 @@
1
+ // Extract text from the native Claude response before transcript normalization.
2
+ export function firstText(content) {
3
+ if (typeof content === "string")
4
+ return content;
5
+ if (Array.isArray(content)) {
6
+ return content
7
+ .filter((b) => b?.type === "text" && b.text)
8
+ .map((b) => b.text)
9
+ .join("");
10
+ }
11
+ return "";
12
+ }
@@ -1,3 +1,4 @@
1
+ import { firstText } from "./claude-message.js";
1
2
  // Claude driver — upstream ClaudeDriver skeleton over agentcal's
2
3
  // drivers/claude.js runtime (stream-json both directions, prompt over
3
4
  // stdin, completion from a real `result` event — verified against
@@ -811,17 +812,6 @@ function decodeConfig(raw) {
811
812
  ...(disallowedTools !== undefined ? { disallowedTools } : {}),
812
813
  };
813
814
  }
814
- function firstText(content) {
815
- if (typeof content === "string")
816
- return content;
817
- if (Array.isArray(content)) {
818
- return content
819
- .filter((b) => b?.type === "text" && b.text)
820
- .map((b) => b.text)
821
- .join("");
822
- }
823
- return "";
824
- }
825
815
  /** Claude's stream-json input accepts the same image source blocks as the
826
816
  * Anthropic Messages API. Keep the old string form for text-only turns so a
827
817
  * CLI update cannot disturb the overwhelmingly common path. */
@@ -1739,7 +1729,7 @@ export const ClaudeDriver = {
1739
1729
  delivered.sessionId = o.session_id;
1740
1730
  session.sessionId = o.session_id;
1741
1731
  }
1742
- emit({ ...base(threadId, currentTurnId()), type: "session.started", sessionId: o.session_id, model: o.model, ...(retry.rebuilt ? { rebuilt: true } : {}) });
1732
+ emit({ ...base(threadId, currentTurnId()), type: "session.started", sessionId: o.session_id, model: o.model, modelEvidenceLevel: "session-reported", ...(retry.rebuilt ? { rebuilt: true } : {}) });
1743
1733
  }
1744
1734
  else if (o.subtype === "compact_boundary") {
1745
1735
  // The CLI summarized the history, notes included: the next
@@ -1773,6 +1763,13 @@ export const ClaudeDriver = {
1773
1763
  }
1774
1764
  case "assistant": {
1775
1765
  const msg = o.message ?? {};
1766
+ if (o.is_api_error_message !== true && typeof o.error !== "string") {
1767
+ emit({ ...base(threadId, currentTurnId()), type: "model.evidence",
1768
+ callId: typeof msg.id === "string" ? msg.id : newId(),
1769
+ responseId: typeof msg.id === "string" ? msg.id : undefined,
1770
+ model: typeof msg.model === "string" ? msg.model : null,
1771
+ level: typeof msg.model === "string" ? "response-reported" : "unknown" });
1772
+ }
1776
1773
  const text = firstText(msg.content);
1777
1774
  // CLI auth diagnostics enter the shared fallback path, never assistant text.
1778
1775
  if (claudeAuthFailure(o, text)) {
@@ -0,0 +1,141 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { killCliTree, spawnCli } from "../procs.js";
5
+ import { codexConfigMcpServers, codexUserMcpOffArgs } from "./codex-mcp-names.js";
6
+ import { codexPersonalSetupOffArgs } from "./codex-personal.js";
7
+ import { codexToolSurfaceArgs } from "./codex-tool-surface.js";
8
+ /** A separate ephemeral app-server: no chat bus, history, mounted integrations,
9
+ * API billing fallback or provider-supplied file reads. Prompt travels on stdin. */
10
+ export async function codexImage(cli, env, prompt, signal, diagnostic, model) {
11
+ signal.throwIfAborted();
12
+ const cwd = await mkdtemp(join(tmpdir(), "atomic-avatar-"));
13
+ const args = ["app-server", ...codexToolSurfaceArgs(),
14
+ ...codexUserMcpOffArgs(codexConfigMcpServers(env, cwd)), ...codexPersonalSetupOffArgs(env, cwd),
15
+ ...["shell_tool", "unified_exec", "view_image", "apps", "multi_agent", "multi_agent_v2", "code_mode"]
16
+ .flatMap(name => ["-c", `features.${name}=false`]),
17
+ "-c", "features.image_generation=true", "-c", 'web_search="disabled"',
18
+ "-c", 'model_provider="openai"', "-c", 'forced_login_method="chatgpt"',
19
+ "-c", "project_doc_max_bytes=0",
20
+ ];
21
+ let child;
22
+ try {
23
+ child = spawnCli(cli, args, { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
24
+ }
25
+ catch {
26
+ await rm(cwd, { recursive: true, force: true });
27
+ throw new Error("Could not start image generation.");
28
+ }
29
+ const pending = new Map();
30
+ let nextId = 0, buffer = "", image, done = false;
31
+ let resolveImage;
32
+ let rejectImage;
33
+ const result = new Promise((resolve, reject) => { resolveImage = resolve; rejectImage = reject; });
34
+ // The handshake may still be awaiting an RPC when an abort/exit occurs.
35
+ void result.catch(() => { });
36
+ const fail = () => {
37
+ const error = new Error("Codex image generation did not complete.");
38
+ for (const waiter of pending.values())
39
+ waiter.reject(error);
40
+ pending.clear();
41
+ if (!done)
42
+ rejectImage(error);
43
+ done = true;
44
+ };
45
+ const send = (message) => { child.stdin.write(JSON.stringify(message) + "\n", error => { if (error)
46
+ fail(); }); };
47
+ const request = (method, params) => new Promise((resolve, reject) => {
48
+ if (done) {
49
+ reject(new Error("Image session ended."));
50
+ return;
51
+ }
52
+ const id = ++nextId;
53
+ pending.set(id, { resolve, reject });
54
+ send({ id, method, params });
55
+ });
56
+ child.on("error", fail);
57
+ child.on("exit", fail);
58
+ child.stdin.on("error", fail);
59
+ child.stderr.resume();
60
+ child.stdout.setEncoding("utf8");
61
+ child.stdout.on("data", (chunk) => {
62
+ buffer += chunk;
63
+ if (Buffer.byteLength(buffer) > 32 * 1024 * 1024) {
64
+ fail();
65
+ return;
66
+ }
67
+ for (;;) {
68
+ const newline = buffer.indexOf("\n");
69
+ if (newline < 0)
70
+ break;
71
+ const line = buffer.slice(0, newline);
72
+ buffer = buffer.slice(newline + 1);
73
+ try {
74
+ const message = JSON.parse(line);
75
+ if (message.method === "item/completed" || message.method === "turn/completed")
76
+ diagnostic?.({ method: message.method,
77
+ itemType: message.params?.item?.type, status: message.params?.turn?.status ?? message.params?.item?.status,
78
+ imageBytes: message.params?.item?.type === "imageGeneration" ? message.params.item.result?.length ?? 0 : undefined,
79
+ errorTerms: message.params?.turn?.error?.message?.match(/\b(?:usage|limit|quota|expired|auth|model|configuration|tools?|sandbox|rate|429|401|403|400|unsupported|invalid|image_generation|reasoning|summary|does not exist|not supported|not available|gpt-[\w.-]+)\b/gi) ?? undefined });
80
+ if (message.method && message.id !== undefined) {
81
+ // No permission, tool or user-input request can widen this session.
82
+ send({ id: message.id, error: { code: -32601, message: "Not available in avatar generation." } });
83
+ fail();
84
+ return;
85
+ }
86
+ if (message.id !== undefined) {
87
+ const waiter = pending.get(message.id);
88
+ pending.delete(message.id);
89
+ if (message.error)
90
+ waiter?.reject(new Error("Codex image request failed."));
91
+ else
92
+ waiter?.resolve(message.result);
93
+ }
94
+ else if (message.method === "item/completed" && message.params?.item?.type === "imageGeneration") {
95
+ const item = message.params.item;
96
+ if (item.status !== "failed" && typeof item.result === "string")
97
+ image = item.result;
98
+ }
99
+ else if (message.method === "turn/completed") {
100
+ if (message.params?.turn?.status !== "completed" || !image) {
101
+ fail();
102
+ return;
103
+ }
104
+ done = true;
105
+ resolveImage(image);
106
+ }
107
+ }
108
+ catch {
109
+ fail();
110
+ return;
111
+ }
112
+ }
113
+ });
114
+ signal.addEventListener("abort", fail, { once: true });
115
+ try {
116
+ if (signal.aborted)
117
+ fail();
118
+ await request("initialize", { clientInfo: { name: "atomic_avatar", version: "1" }, capabilities: { experimentalApi: true } });
119
+ send({ method: "initialized", params: {} });
120
+ const catalog = await request("model/list", { limit: 100 });
121
+ const choices = Array.isArray(catalog.data) ? catalog.data.filter(item => item && item.hidden !== true &&
122
+ (typeof item.model === "string" || typeof item.id === "string")) : [];
123
+ const choice = choices.find(item => item.id === model || item.model === model) ?? choices.find(item => item.isDefault) ?? choices[0];
124
+ if (!choice)
125
+ throw new Error("No subscription image model is available.");
126
+ const started = await request("thread/start", { cwd, model: choice.model ?? choice.id, modelProvider: "openai", ephemeral: true, approvalPolicy: "never", sandbox: "read-only",
127
+ developerInstructions: "Generate exactly one image with the native image generation tool. Do not run commands, read files, use other tools or ask for credentials." });
128
+ if (typeof started.thread?.id !== "string")
129
+ throw new Error("Missing image session.");
130
+ await request("turn/start", { threadId: started.thread.id, input: [{ type: "text", text: prompt }],
131
+ approvalPolicy: "never", sandboxPolicy: { type: "readOnly" } });
132
+ return await result;
133
+ }
134
+ finally {
135
+ signal.removeEventListener("abort", fail);
136
+ fail();
137
+ const stopped = await killCliTree(child);
138
+ if (stopped)
139
+ await rm(cwd, { recursive: true, force: true });
140
+ }
141
+ }
@@ -1,12 +1,8 @@
1
1
  import { codexToolSurfaceArgs } from "./codex-tool-surface.js";
2
- // Codex driver — upstream CodexDriver skeleton over agentcal's
3
- // drivers/codex.js runtime: the official `codex` CLI headless over its
4
- // app-server JSON-RPC protocol (newline-delimited JSON on stdio).
5
- // Completion is a real `turn/completed` notification; approval requests
6
- // arrive as in-process server→client JSON-RPC requests and surface as
7
- // canonical request.opened events (answered via respondToRequest — no MCP
8
- // proxy or unix socket needed, unlike claude). Verified against
9
- // codex-cli 0.144.4 by agentcal.
2
+ import { codexImage } from "./codex-image.js";
3
+ // Official Codex app-server over newline-delimited JSON-RPC on stdio.
4
+ // Completion comes from turn/completed; native approval requests become
5
+ // canonical request.opened events answered through respondToRequest.
10
6
  //
11
7
  // resumeCursor is the codex thread id; a later turn tries thread/resume
12
8
  // and preserves that history or reports a failed resume.
@@ -1557,7 +1553,7 @@ export const CodexDriver = {
1557
1553
  if (!codexThreadId)
1558
1554
  throw new Error("Codex did not return a native thread id");
1559
1555
  await syncCodexInstructions(threadId, codexThreadId, developerInstructions, resumedNativeThread, request);
1560
- emit({ ...base(threadId, turnId), type: "session.started", sessionId: codexThreadId, model: startedModel ?? turn.model ?? null, ...(rebuiltFromReplay ? { rebuilt: true } : {}) });
1556
+ emit({ ...base(threadId, turnId), type: "session.started", sessionId: codexThreadId, model: startedModel ?? turn.model ?? null, modelEvidenceLevel: startedModel ? "session-reported" : "requested-only", ...(rebuiltFromReplay ? { rebuilt: true } : {}) });
1561
1557
  const note = turn.systemStable !== undefined
1562
1558
  ? volatileNoteFor(threadId, codexThreadId, !resumedNativeThread || rebuiltFromReplay, turn)
1563
1559
  : "";
@@ -1700,6 +1696,7 @@ export const CodexDriver = {
1700
1696
  cancelAuthentication: () => authentication.cancel(),
1701
1697
  signOut: () => authentication.signOut(),
1702
1698
  snapshot,
1699
+ ...(!config.managed ? { generateImage: (prompt, options) => codexImage(config.cli, childEnv(), prompt, options.signal, undefined, decodeCodexSelection(models.default).model ?? undefined) } : {}),
1703
1700
  adapter: {
1704
1701
  provider: DRIVER_KIND,
1705
1702
  capabilities: {
@@ -42,7 +42,7 @@ export function createOpenAIChatRuntime(options) {
42
42
  turnId,
43
43
  createdAt: new Date().toISOString(),
44
44
  });
45
- const complete = async (messages, model, stream, signal, onDelta, tools = []) => {
45
+ const complete = async (messages, model, stream, signal, onDelta, tools = [], onIdentity) => {
46
46
  // Idle timer that is renewed on every received chunk during streaming
47
47
  const timeoutController = new AbortController();
48
48
  let idleTimer = null;
@@ -77,6 +77,7 @@ export function createOpenAIChatRuntime(options) {
77
77
  const bodyError = providerError(json);
78
78
  if (bodyError)
79
79
  throw new ChatProtocolError(`provider returned a completion error: ${bodyError.slice(0, 200)}`);
80
+ onIdentity?.(typeof json.model === "string" ? json.model : null, typeof json.id === "string" ? json.id : undefined);
80
81
  const message = json.choices?.[0]?.message;
81
82
  if (!message || !object(message) || !["content", "reasoning_content", "reasoning", "reasoning_details", "tool_calls", "function_call"].some((key) => key in message)) {
82
83
  throw new ChatProtocolError("provider returned no completion message");
@@ -141,6 +142,7 @@ export function createOpenAIChatRuntime(options) {
141
142
  const chunkError = providerError(chunk);
142
143
  if (chunkError)
143
144
  throw new ChatProtocolError(`provider returned a streaming completion error: ${chunkError.slice(0, 200)}`);
145
+ onIdentity?.(typeof chunk.model === "string" ? chunk.model : null, typeof chunk.id === "string" ? chunk.id : undefined);
144
146
  const choice = chunk.choices?.find((row) => row.index === undefined || row.index === 0);
145
147
  const delta = choice?.delta;
146
148
  if (object(delta))
@@ -329,10 +331,19 @@ export function createOpenAIChatRuntime(options) {
329
331
  emit({ ...base(turn.threadId, turnId), type: "content.delta", streamKind, delta: visible });
330
332
  };
331
333
  try {
334
+ const callId = newId();
335
+ emit({ ...base(turn.threadId, turnId), type: "model.call", callId });
336
+ let previousIdentity = "";
332
337
  completion = await complete(messages, model, true, abort.signal, (text, streamKind) => {
333
338
  streamed = true;
334
339
  delta(text, streamKind);
335
- }, tools.definitions);
340
+ }, tools.definitions, (reportedModel, responseId) => {
341
+ const identity = JSON.stringify([reportedModel, responseId]);
342
+ if (identity === previousIdentity)
343
+ return;
344
+ previousIdentity = identity;
345
+ emit({ ...base(turn.threadId, turnId), type: "model.evidence", callId, model: reportedModel, responseId, level: reportedModel ? "response-reported" : "unknown" });
346
+ });
336
347
  delta("", "assistant_text", true);
337
348
  delta("", "reasoning_text", true);
338
349
  break;
@@ -1,3 +1,37 @@
1
+ // Silent automatic failover (docs/chibitek/engine-tiers.md, step 2).
2
+ //
3
+ // A turn whose engine runs out of its usage allowance, turns out to be
4
+ // signed out, goes fully silent, or fails in any other way, is not failed:
5
+ // the engine rests and the same turn runs on the next engine of the bot's
6
+ // ladder at the same level, at most two tries per turn. No chat message is
7
+ // added; the client shows a small banner that fades (src/lib/engine-switch.ts).
8
+ //
9
+ // Modelled on OpenRouter's routing (docs/chibitek/engine-tiers.md): any
10
+ // error is a reason to try the next candidate, not only the ones we have
11
+ // learned to recognise. Recognised failures (a usage limit, a failed or
12
+ // dead sign-in, a stall, a rate limit, a reasonless 400) rest the engine
13
+ // for as long as that cause lasts; anything else is `provider_error`, which
14
+ // rests it briefly (a circuit breaker) so it is not hammered. Every error an
15
+ // engine reports while its turn is watched is held, so the person never sees
16
+ // a provider's raw words for a failure the next engine recovers from.
17
+ // This module is the part that watches a turn's events:
18
+ //
19
+ // - While a turn is tracked, the engine's own report of the limit (a
20
+ // runtime.error, or the text a CLI prints in place of a reply, which the
21
+ // drivers mark `synthetic`) is held back instead of delivered. So is a
22
+ // reply that starts the way an agent's billing error does (Hermes answers
23
+ // "Billing or credits exhausted: HTTP 402: ..." as ordinary text and ends
24
+ // the turn as a success): it waits until the reply is complete, and if it
25
+ // is that error, with no tool run, the turn counts as failed.
26
+ // - When the turn completes failed with a held (or recognisable) limit,
27
+ // `onFailure` decides: it either moves the turn to another engine, and
28
+ // returns the one record that says so (turn.engine_failover), or it stops
29
+ // the turn with one plain line naming the resting engines.
30
+ // - A turn that ends any other way gets everything it held, in order.
31
+ //
32
+ // The server wires `onFailure` to the ladder, the resting store and
33
+ // startTurn (server/index.ts); everything here is pure bookkeeping.
34
+ import { newEventId } from "./contracts.js";
1
35
  import { classifyEngineFailure, isCredentialDead, mayBeReplyFailure, replyEngineFailure, statedReset } from "./engine-failure.js";
2
36
  /** The two-try cap: at most this many engines take part in one turn, the
3
37
  * first and one switch. Every failed attempt counts toward it, whatever the
@@ -224,3 +258,9 @@ export function restingLine(engines, formatTime, computer, options = {}) {
224
258
  return `${parts[0]}, and no other engine at the same level is free, so this turn stopped.${hint}`;
225
259
  return `Every engine this bot can use is resting, so this turn stopped: ${parts.join(", ")}.${hint}`;
226
260
  }
261
+ /** A new harness event about the same provider turn as `event`. */
262
+ export const eventBase = (event) => ({
263
+ eventId: newEventId(), provider: event.provider, threadId: event.threadId, createdAt: new Date().toISOString(),
264
+ ...(event.providerInstanceId ? { providerInstanceId: event.providerInstanceId } : {}), ...(event.turnId ? { turnId: event.turnId } : {}),
265
+ });
266
+ export const restTime = (at) => new Date(at).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
@@ -160,9 +160,9 @@ export function classifyEngineFailure(text, now = Date.now()) {
160
160
  return null;
161
161
  }
162
162
  // Hermes returns billing and expired-login refusals as successful reply text.
163
- const REPLY_FAILURE_HEADS = ["login expired", "error: nonretriableerror:", "billing or credits exhausted", "http 402:", "error: http 402:", "api error: http 402:"];
163
+ const REPLY_FAILURE_HEADS = ["login expired", "error: nonretriableerror:", "billing or credits exhausted", "http 402:", "error: http 402:", "api error: http 402:", "usage limit reached"];
164
164
  const REPLY_FAILURE = [
165
- /^billing or credits exhausted\b/i,
165
+ /^billing or credits exhausted\b|^usage limit reached\b[\s\S]*\b(?:quota|limit will reset|resets? (?:in|at|on))\b/i, // the second: a quota page as the reply (2026-10-26)
166
166
  // A bare "HTTP 402:" at the head counts only with the provider's own
167
167
  // billing words after it.
168
168
  /^(?:(?:api )?error:\s*)?http 402:[\s\S]*\b(?:positive balance|insufficient (?:balance|credits?|funds)|add (?:credits|balance|funds)|top-?up|credits? (?:exhausted|depleted))\b/i,
@@ -11,7 +11,7 @@ import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { DatabaseSync } from "node:sqlite";
13
13
  import { pathToFileURL } from "node:url";
14
- import { attachmentBytesUsed, ATTACHMENTS_DIR, ATTACHMENTS_MAX_BYTES, deleteAttachment, extensionForFileMime, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, saveFile, saveImage, } from "../attachments.js";
14
+ import { attachmentBytesAvailable, ATTACHMENTS_DIR, deleteAttachment, extensionForFileMime, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, saveFile, saveImage, } from "../attachments.js";
15
15
  import { redactSecretsInText } from "../redact.js";
16
16
  import { existingBots, importIdentity, mergedBotOf, sourceRefFor } from "./apply.js";
17
17
  import { GROKBOT_HISTORY_ALLOWLIST } from "./grokbot.js";
@@ -31,7 +31,7 @@ const MAX_STORE_BYTES = 2 * 1024 * 1024 * 1024;
31
31
  /** The real attachment directory, through the same save calls uploads use. */
32
32
  export function historyAttachmentStore() {
33
33
  return {
34
- usedBytes: attachmentBytesUsed,
34
+ availableBytes: attachmentBytesAvailable,
35
35
  has(uploadId, extension) {
36
36
  try {
37
37
  return statSync(join(ATTACHMENTS_DIR, `${uploadId}${extension}`)).isFile();
@@ -421,7 +421,7 @@ export function planBotHistory(sourceId, entries, ctx) {
421
421
  hidden: cleaner.hidden(),
422
422
  };
423
423
  }
424
- export const defaultHistoryBudget = (usedBytes) => Math.max(0, ATTACHMENTS_MAX_BYTES - usedBytes - HISTORY_QUOTA_MARGIN_BYTES);
424
+ export const defaultHistoryBudget = (availableBytes) => Math.max(0, availableBytes - HISTORY_QUOTA_MARGIN_BYTES);
425
425
  /** Which files to copy: images first, then documents, newest first, until
426
426
  * the budget is spent. A file already in Atomic costs nothing. */
427
427
  export function allocateFiles(input, budget, stored) {
@@ -608,7 +608,7 @@ const storedCheck = (store) => (file) => {
608
608
  export function previewHistory(root, result, deps, options) {
609
609
  const source = options.source ?? GROKBOT_HISTORY;
610
610
  const targets = historyTargets(result, deps.bots());
611
- const budget = options.budget ?? defaultHistoryBudget(deps.attachments.usedBytes());
611
+ const budget = options.budget ?? defaultHistoryBudget(deps.attachments.availableBytes());
612
612
  const empty = { threads: 0, messages: 0, files: { copied: 0, copiedBytes: 0, skipped: 0, skippedBytes: 0 } };
613
613
  if (!options.requested) {
614
614
  return { requested: false, available: targets.filter((target) => source.available(root, target.sourceId, options.fs)).length, bots: [], totals: empty, budgetBytes: budget };
@@ -698,7 +698,7 @@ export async function applyHistory(root, result, deps, options = {}) {
698
698
  out.hiddenMessages += plan.hidden;
699
699
  }
700
700
  }
701
- const budget = options.budget ?? defaultHistoryBudget(deps.attachments.usedBytes());
701
+ const budget = options.budget ?? defaultHistoryBudget(deps.attachments.availableBytes());
702
702
  const stored = storedCheck(deps.attachments);
703
703
  const allocation = allocateFiles(work.flatMap((item) => item.threads.flatMap(({ thread }) => thread.messages.flatMap((message) => message.files))), budget, stored);
704
704
  out.files.tooLarge = allocation.tooLarge.size;
@@ -276,7 +276,7 @@ export function fakeHistoryDeps(options = {}) {
276
276
  },
277
277
  deleteThread: (_botId, threadId) => state.threads.delete(threadId),
278
278
  attachments: {
279
- usedBytes: () => state.used,
279
+ availableBytes: () => 512 * 1024 * 1024 - state.used,
280
280
  has: (uploadId, extension) => state.files.has(`/attachments/${uploadId}${extension}`),
281
281
  saveImage: (bytes, mime, uploadId) => {
282
282
  const path = pathFor(uploadId, mime);