@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.
- package/dist/assets/{abnfDiagram-O67JEVCF-B8-8zzMS.js → abnfDiagram-O67JEVCF-CpuRvzQ3.js} +1 -1
- package/dist/assets/{arc-CSHQ9Dx3.js → arc-ko1e0R6h.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-BCHM9rXW.js → architectureDiagram-NJMV4G6O-OZ3SsOOa.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-B-5uxPFN.js → blockDiagram-BEXU5L5S-DLRkBkjs.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-CuKNM_MR.js → c4Diagram-YGBWAQC7-e8Q3qMnw.js} +1 -1
- package/dist/assets/channel-Lp7erhx-.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-BNF02GM4.js → chunk-2Q5K7J3B-hCDqaM4Q.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-uEWgBd4M.js → chunk-3FUC2YCW-Dy3Pahu-.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-eSZuXctY.js → chunk-5VM5RSS4-QMK3iFBF.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-CslS8nRS.js → chunk-GTNCS2PH-6fyp9ERp.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-w71-MeKj.js → chunk-GWA4HPMP-DWvOhTaO.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-Bjju7WGh.js → chunk-JWPE2WC7-DgohMW2S.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-HxrroD4M.js → chunk-NETBCI7D-sRGeD2Y7.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-D7Rz470x.js → chunk-WEXAMYUT-CZslD_VF.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-BWKnAeyM.js → chunk-XXDRQBXY-D7ocO3VN.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-CeBDRHmP.js → classDiagram-v2-NBCMYWYE-T1lLqHHW.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-BBbRKlvr.js → cose-bilkent-JH36ORCC-BdeLiJr3.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-W02cnJ0B.js → cynefin-OW5HDTMX-BydCA0pv.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-Cs47eaxd.js → cynefinDiagram-VND7K2PF-DBxckjwQ.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-8mG18hzT.js → dagre-6A5THRUB-CAsvq6Zk.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-DF4Qrw67.js → diagram-22UHCM2B-DTwH3rA_.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-3BAVoed1.js → diagram-3UASUU5V-BNYj1bey.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-CKO3pECE.js → diagram-ATOU4E4O-CihwYFxb.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-CZhrGvur.js → diagram-CDSNMT55-nouwk3s2.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-C07l9pdQ.js → diagram-MLGK6HIB-BoKewJeA.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-D3or4lkZ.js → diagram-MPIPVDR6-CrE1m9DQ.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-DV2epXp_.js → ebnfDiagram-ZINNZB2B-DRj0aU4Y.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-DlvEn4-1.js → elk-276RUBZZ-Do0JzCG5.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-CB6X7Wt5.js → erDiagram-OPXOYQCR-CRsI9pXb.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-C3bqz4lC.js → flowDiagram-KWPJA3E3-dtD6wm3G.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-yo_Evhab.js → ganttDiagram-FUAMR5RP-Cn7KyLCo.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-Cq2oUlap.js → gitGraphDiagram-X574FWY7-CGH_VWzq.js} +1 -1
- package/dist/assets/index-B2fJMh8N.css +1 -0
- package/dist/assets/{index-CMcr5k3Z.js → index-BIcziktH.js} +246 -246
- package/dist/assets/{index-CG4AFPb4.js → index-Bk8iAodB.js} +1 -1
- package/dist/assets/{infoDiagram-VRGFBTTK-Y3ZrY7qL.js → infoDiagram-VRGFBTTK-CQKsYTCL.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-BpOtSfyi.js → ishikawaDiagram-OU5B5YK6-D5catDe0.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-UQM727le.js → journeyDiagram-ZHPQQLJL-KjY8mki9.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-C3s5oHpY.js → kanban-definition-PNTS6WVX-BKCyJtge.js} +1 -1
- package/dist/assets/{linear-hX6nFOQr.js → linear-B-_obUkq.js} +1 -1
- package/dist/assets/{mermaid.core-DZObGLN7.js → mermaid.core-DlSzuDKt.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-Br-i2E3P.js → mindmap-definition-NLK3R4M7-BFQSaYKh.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-D5SERB-N.js → pegDiagram-GJSIUBJH-l51izlev.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-BPNf4J3D.js → pieDiagram-5QR66LMP-6NEXRoZX.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-Bm_Wh7Jr.js → quadrantDiagram-O4NWA36T-BCC9XlOe.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-D6bo0abD.js → railroadDiagram-XR7U4H2S-Dd4TmJ86.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP--1n5rkV7.js → requirementDiagram-PLB6GJNP-C1E5DaDb.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-Ct4tP2kK.js → sankeyDiagram-IPEJSGJF-Ci6r7abb.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-CCG7O5fR.js → sequenceDiagram-PO4LG4MO-BJ-I6WOh.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-CJJJra9c.js → sizeCapture-INFHLROL-Cs5QbLOP.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-BMdRvVEX.js → stateDiagram-v2-GCMORJYK-Bw--rdvq.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-B1wnogip.js → swimlanes-2SLR337P-B1O9IEz5.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-C6wGgYKP.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-l-3yhXUP.js → timeline-definition-EJHVYXUP-CTZ7DXaw.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-DtwtCOoJ.js → usecaseDiagram-POWQR4AR-DjwgNG0H.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-BZAEce1B.js → vennDiagram-UO4OBE2U-DrdJG55h.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-Na_BCMMO.js → wardleyDiagram-VNRHLVJA-HVq776nz.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-BW9dlUfE.js → xychartDiagram-PMCCYNJV-DPf9VEua.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +1 -1
- package/dist-server/index.js +3585 -3076
- package/dist-server/openmausbot.js +346 -159
- package/dist-server/pair-cli.js +346 -159
- package/dist-server/server/attachment-mimes.js +58 -0
- package/dist-server/server/attachments.js +28 -67
- package/dist-server/server/avatar-image.js +1 -0
- package/dist-server/server/avatar-subscription.js +24 -1
- package/dist-server/server/drivers/claude-message.js +12 -0
- package/dist-server/server/drivers/claude.js +9 -12
- package/dist-server/server/drivers/codex-image.js +141 -0
- package/dist-server/server/drivers/codex.js +6 -9
- package/dist-server/server/drivers/openai-chat.js +13 -2
- package/dist-server/server/engine-failover.js +40 -0
- package/dist-server/server/engine-failure.js +2 -2
- package/dist-server/server/harness-import/history.js +5 -5
- package/dist-server/server/harness-import/test-fixtures.js +1 -1
- package/dist-server/server/index.js +35 -20
- package/dist-server/server/message-reactions.js +11 -0
- package/dist-server/server/model-evidence-context.js +18 -0
- package/dist-server/server/model-evidence.js +145 -0
- package/dist-server/shared/emoji-catalog.json +1 -0
- package/dist-server/shared/emoji.js +27 -0
- package/dist-server/shared/mascot-bodies.js +9 -1
- package/dist-server/shared/model-evidence.js +25 -0
- package/dist-server/vps-container-mcp.js +1 -1
- package/package.json +1 -1
- package/dist/assets/channel-bPN03jIt.js +0 -1
- package/dist/assets/index-CZjRcUdn.css +0 -1
- 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
|
|
18
|
-
*
|
|
19
|
-
|
|
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
|
|
164
|
-
|
|
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
|
|
243
|
-
const available = ATTACHMENTS_MAX_BYTES - used - reservedAttachmentBytes;
|
|
212
|
+
const available = attachmentBytesAvailable();
|
|
244
213
|
if (available < required) {
|
|
245
|
-
throw statusError(507,
|
|
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
|
-
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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 = (
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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);
|