@milpa/comal 0.1.0 → 0.1.2
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/README.md +30 -28
- package/backend/contracts/remote-commands-v1.json +125 -0
- package/bin/comal.mjs +16 -5
- package/docs/investigation.md +2 -2
- package/governance/chrome-task-v2.json +67 -0
- package/node_modules/@jitl/quickjs-ffi-types/LICENSE +21 -0
- package/node_modules/@jitl/quickjs-ffi-types/README.md +5 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.d.mts +549 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.d.ts +549 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-ffi-types/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-ffi-types/package.json +36 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/LICENSE +47 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/README.md +93 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.browser.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.browser.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cloudflare.cjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cloudflare.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.wasm +0 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.wasm.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.d.mts +94 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.d.ts +94 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.d.mts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.d.ts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/package.json +61 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/LICENSE +47 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/README.md +88 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.browser.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.browser.mjs +5430 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cjs +5485 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cloudflare.cjs +5424 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cloudflare.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.mjs +5541 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.wasm +0 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.wasm.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.d.mts +85 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.d.ts +85 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.d.mts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.d.ts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-debug-sync/package.json +61 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/LICENSE +47 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/README.md +89 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.browser.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.browser.mjs +26 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cjs +25 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cloudflare.cjs +25 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cloudflare.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.d.ts +15 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.mjs +30 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.wasm +0 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.d.mts +94 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.d.ts +94 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.d.mts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.d.ts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/package.json +61 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/LICENSE +47 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/README.md +82 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.browser.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.browser.mjs +22 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cjs +21 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cloudflare.cjs +21 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cloudflare.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.d.ts +11 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.mjs +25 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm +0 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.d.mts +85 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.d.ts +85 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.d.mts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.d.ts +20 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.js +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.js.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.mjs +2 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.mjs.map +1 -0
- package/node_modules/@jitl/quickjs-wasmfile-release-sync/package.json +61 -0
- package/node_modules/quickjs-emscripten/LICENSE +21 -0
- package/node_modules/quickjs-emscripten/README.md +928 -0
- package/node_modules/quickjs-emscripten/dist/chunk-OHAYRCBA.mjs +2 -0
- package/node_modules/quickjs-emscripten/dist/chunk-OHAYRCBA.mjs.map +1 -0
- package/node_modules/quickjs-emscripten/dist/index.d.mts +57 -0
- package/node_modules/quickjs-emscripten/dist/index.d.ts +57 -0
- package/node_modules/quickjs-emscripten/dist/index.global.js +7309 -0
- package/node_modules/quickjs-emscripten/dist/index.js +2 -0
- package/node_modules/quickjs-emscripten/dist/index.js.map +1 -0
- package/node_modules/quickjs-emscripten/dist/index.mjs +2 -0
- package/node_modules/quickjs-emscripten/dist/index.mjs.map +1 -0
- package/node_modules/quickjs-emscripten/dist/variants.d.mts +39 -0
- package/node_modules/quickjs-emscripten/dist/variants.d.ts +39 -0
- package/node_modules/quickjs-emscripten/dist/variants.js +2 -0
- package/node_modules/quickjs-emscripten/dist/variants.js.map +1 -0
- package/node_modules/quickjs-emscripten/dist/variants.mjs +2 -0
- package/node_modules/quickjs-emscripten/dist/variants.mjs.map +1 -0
- package/node_modules/quickjs-emscripten/package.json +69 -0
- package/node_modules/quickjs-emscripten-core/LICENSE +21 -0
- package/node_modules/quickjs-emscripten-core/README.md +341 -0
- package/node_modules/quickjs-emscripten-core/README.template.md +70 -0
- package/node_modules/quickjs-emscripten-core/dist/chunk-TAV5CUKK.mjs +2 -0
- package/node_modules/quickjs-emscripten-core/dist/chunk-TAV5CUKK.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/chunk-V2S4ZYJR.mjs +7 -0
- package/node_modules/quickjs-emscripten-core/dist/chunk-V2S4ZYJR.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/index.d.mts +2033 -0
- package/node_modules/quickjs-emscripten-core/dist/index.d.ts +2033 -0
- package/node_modules/quickjs-emscripten-core/dist/index.js +7 -0
- package/node_modules/quickjs-emscripten-core/dist/index.js.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/index.mjs +2 -0
- package/node_modules/quickjs-emscripten-core/dist/index.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/module-ES6BEMUI.mjs +2 -0
- package/node_modules/quickjs-emscripten-core/dist/module-ES6BEMUI.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/dist/module-asyncify-2EFITU5U.mjs +2 -0
- package/node_modules/quickjs-emscripten-core/dist/module-asyncify-2EFITU5U.mjs.map +1 -0
- package/node_modules/quickjs-emscripten-core/package.json +49 -0
- package/package.json +6 -2
- package/src/agent.mjs +17 -6
- package/src/artifact-tools.mjs +3 -2
- package/src/auto-guard.mjs +5 -1
- package/src/cli.mjs +267 -201
- package/src/cloud-client.mjs +58 -8
- package/src/code-run-worker.mjs +70 -0
- package/src/code-run.mjs +97 -0
- package/src/codex-provider.mjs +28 -28
- package/src/compaction.mjs +1 -1
- package/src/goals.mjs +2 -2
- package/src/house.mjs +24 -24
- package/src/i18n/cli-es.mjs +97 -0
- package/src/i18n/es.mjs +227 -0
- package/src/i18n/index.mjs +48 -0
- package/src/image-attachments.mjs +263 -0
- package/src/model-controls.mjs +4 -4
- package/src/onboarding.mjs +91 -0
- package/src/openai-provider.mjs +24 -24
- package/src/peer-tools.mjs +4 -3
- package/src/permissions.mjs +53 -17
- package/src/provider-auth.mjs +30 -30
- package/src/provider-profiles.mjs +27 -27
- package/src/provider.mjs +30 -30
- package/src/remote-commands.mjs +11 -0
- package/src/remote-control.mjs +180 -0
- package/src/sessions.mjs +3 -3
- package/src/subagents.mjs +7 -3
- package/src/tool-packs.mjs +5 -2
- package/src/tools.mjs +92 -34
- package/src/tui/brand.mjs +2 -1
- package/src/tui/clipboard.mjs +5 -4
- package/src/tui/composer.mjs +77 -11
- package/src/tui/controller.mjs +111 -29
- package/src/tui/discovery.mjs +105 -100
- package/src/tui/markdown.mjs +10 -9
- package/src/tui/message-queue.mjs +27 -9
- package/src/tui/screen.mjs +209 -118
- package/src/vision.mjs +31 -3
- package/src/work-assertions.mjs +7 -7
- package/src/work-plan.mjs +189 -21
- package/src/work-recovery-proof.mjs +13 -1
- package/src/workflows/chrome-task.mjs +24 -7
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import { auditJournal } from './audit.mjs';
|
|
8
|
+
|
|
9
|
+
export const HUMAN_IMAGE_LIMIT = 2 * 1024 * 1024;
|
|
10
|
+
export const HUMAN_IMAGE_COUNT = 2;
|
|
11
|
+
const staged = new WeakMap();
|
|
12
|
+
const fail = (message, code = 'IMAGE_ATTACHMENT_INVALID') => Object.assign(new Error(message), { code });
|
|
13
|
+
const isHuman = actor => actor?.kind === 'human' && actor.id === 'local-user';
|
|
14
|
+
const imageExtension = /\.(?:png|jpe?g|webp|gif|bmp|tiff?|svg|avif|heic)$/iu;
|
|
15
|
+
|
|
16
|
+
// The terminal supplies path text, not native OS drag events. Parse a small literal
|
|
17
|
+
// token grammar only: no shell, interpolation, globbing, command or environment eval.
|
|
18
|
+
export function findImageCandidates(text, { cwd = process.cwd(), home = os.homedir() } = {}) {
|
|
19
|
+
if (typeof text !== 'string') return [];
|
|
20
|
+
const result = [];
|
|
21
|
+
for (let i = 0; i < text.length;) {
|
|
22
|
+
if (/\s/u.test(text[i])) { i++; continue; }
|
|
23
|
+
const start = i, windowsLiteral = /^["']?[A-Za-z]:[\\/]/u.test(text.slice(i)); let token = '', quote = null, invalid = false;
|
|
24
|
+
while (i < text.length && (quote || !/\s/u.test(text[i]))) {
|
|
25
|
+
const char = text[i++];
|
|
26
|
+
if (char === "'" && quote !== '"') { quote = quote === "'" ? null : "'"; continue; }
|
|
27
|
+
if (char === '"' && quote !== "'") { quote = quote === '"' ? null : '"'; continue; }
|
|
28
|
+
if (char === '\\' && windowsLiteral) { token += char; continue; }
|
|
29
|
+
if (char === '\\' && quote !== "'") {
|
|
30
|
+
if (i === text.length) { invalid = true; break; }
|
|
31
|
+
// Standard terminal-escaped spaces and quote characters. Dollar signs
|
|
32
|
+
// remain literal and can never cause command substitution.
|
|
33
|
+
token += text[i++]; continue;
|
|
34
|
+
}
|
|
35
|
+
token += char;
|
|
36
|
+
}
|
|
37
|
+
if (quote || invalid || !token || token.includes('\0')) continue;
|
|
38
|
+
if (/^[A-Za-z]:[\\/]/u.test(token) && process.platform !== 'win32') continue;
|
|
39
|
+
let candidatePath = token;
|
|
40
|
+
try {
|
|
41
|
+
if (/^file:/i.test(candidatePath)) {
|
|
42
|
+
const url = new URL(candidatePath);
|
|
43
|
+
if (url.protocol !== 'file:' || url.host && url.host !== 'localhost' || url.search || url.hash) continue;
|
|
44
|
+
candidatePath = fileURLToPath(url);
|
|
45
|
+
} else if (candidatePath.startsWith('~/')) candidatePath = path.join(home, candidatePath.slice(2));
|
|
46
|
+
if (!imageExtension.test(candidatePath)) continue;
|
|
47
|
+
result.push({ start, end: i, raw: text.slice(start, i), candidate: token, path: path.resolve(cwd, candidatePath) });
|
|
48
|
+
} catch { /* Invalid URIs are never treated as filesystem authorization. */ }
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const HUMAN_IMAGE_MAX_DIMENSION = 16384;
|
|
54
|
+
export const HUMAN_IMAGE_MAX_PIXELS = 40_000_000;
|
|
55
|
+
const dimensionsAllowed = (width, height) => width > 0 && height > 0 && width <= HUMAN_IMAGE_MAX_DIMENSION && height <= HUMAN_IMAGE_MAX_DIMENSION && width * height <= HUMAN_IMAGE_MAX_PIXELS;
|
|
56
|
+
const crcTable = Array.from({ length: 256 }, (_, index) => {
|
|
57
|
+
let value = index; for (let bit = 0; bit < 8; bit++) value = value & 1 ? (value >>> 1) ^ 0xedb88320 : value >>> 1; return value >>> 0;
|
|
58
|
+
});
|
|
59
|
+
const crc32 = bytes => { let value = 0xffffffff; for (const byte of bytes) value = (value >>> 8) ^ crcTable[(value ^ byte) & 255]; return (value ^ 0xffffffff) >>> 0; };
|
|
60
|
+
|
|
61
|
+
function pngStructure(bytes) {
|
|
62
|
+
let offset = 8, header = false, data = false, endedData = false, palette = false, color, depth, dataLength = 0, zlibHeader = [];
|
|
63
|
+
while (offset + 12 <= bytes.length) {
|
|
64
|
+
const length = bytes.readUInt32BE(offset), end = offset + 12 + length;
|
|
65
|
+
if (end > bytes.length) return false;
|
|
66
|
+
const kind = bytes.toString('latin1', offset + 4, offset + 8), value = bytes.subarray(offset + 8, end - 4);
|
|
67
|
+
if (!/^[A-Za-z]{4}$/u.test(kind) || crc32(bytes.subarray(offset + 4, end - 4)) !== bytes.readUInt32BE(end - 4)) return false;
|
|
68
|
+
if (!header && kind !== 'IHDR') return false;
|
|
69
|
+
if (kind === 'IHDR') {
|
|
70
|
+
if (header || length !== 13 || !dimensionsAllowed(value.readUInt32BE(0), value.readUInt32BE(4))) return false;
|
|
71
|
+
depth = value[8]; color = value[9];
|
|
72
|
+
const allowedDepths = { 0: [1,2,4,8,16], 2: [8,16], 3: [1,2,4,8], 4: [8,16], 6: [8,16] };
|
|
73
|
+
if (!allowedDepths[color]?.includes(depth) || value[10] || value[11] || value[12] > 1) return false;
|
|
74
|
+
header = true;
|
|
75
|
+
} else if (kind === 'PLTE') {
|
|
76
|
+
if (data || palette || !length || length % 3 || length > 768 || color === 3 && length / 3 > 2 ** depth) return false;
|
|
77
|
+
palette = true;
|
|
78
|
+
} else if (kind === 'IDAT') {
|
|
79
|
+
if (endedData || color === 3 && !palette) return false;
|
|
80
|
+
data = true; dataLength += length;
|
|
81
|
+
for (let index = 0; index < value.length && zlibHeader.length < 2; index++) zlibHeader.push(value[index]);
|
|
82
|
+
} else if (kind === 'IEND') {
|
|
83
|
+
return length === 0 && end === bytes.length && dataLength >= 6 && (zlibHeader[0] & 15) === 8 && (zlibHeader[0] >>> 4) <= 7 && !(zlibHeader[1] & 32) && ((zlibHeader[0] << 8) + zlibHeader[1]) % 31 === 0;
|
|
84
|
+
} else {
|
|
85
|
+
if (data) endedData = true;
|
|
86
|
+
if (kind[0] === kind[0].toUpperCase()) return false; // Unknown critical chunk.
|
|
87
|
+
}
|
|
88
|
+
offset = end;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function jpegStructure(bytes) {
|
|
94
|
+
let offset = 2, frame = false, quantization = false, entropy = false, scans = 0;
|
|
95
|
+
while (offset < bytes.length) {
|
|
96
|
+
if (bytes[offset++] !== 255) return false;
|
|
97
|
+
while (bytes[offset] === 255) offset++;
|
|
98
|
+
const marker = bytes[offset++];
|
|
99
|
+
if (marker === 0xd9) return frame && quantization && entropy && scans > 0 && offset === bytes.length;
|
|
100
|
+
if (marker === undefined || marker === 0 || marker === 0xd8 || marker >= 0xd0 && marker <= 0xd7) return false;
|
|
101
|
+
if (marker === 0x01) continue;
|
|
102
|
+
if (offset + 2 > bytes.length) return false;
|
|
103
|
+
const length = bytes.readUInt16BE(offset), end = offset + length;
|
|
104
|
+
if (length < 2 || end > bytes.length) return false;
|
|
105
|
+
if ([0xc0,0xc1,0xc2].includes(marker)) {
|
|
106
|
+
if (frame || length < 8) return false;
|
|
107
|
+
const components = bytes[offset + 7];
|
|
108
|
+
if (!components || components > 4 || length !== 8 + components * 3 || ![8,12].includes(bytes[offset + 2]) || !dimensionsAllowed(bytes.readUInt16BE(offset + 5), bytes.readUInt16BE(offset + 3))) return false;
|
|
109
|
+
frame = true;
|
|
110
|
+
}
|
|
111
|
+
if (marker === 0xdb) { if (length < 67) return false; quantization = true; }
|
|
112
|
+
offset = end;
|
|
113
|
+
if (marker === 0xda) {
|
|
114
|
+
const components = bytes[end - length + 2];
|
|
115
|
+
if (!frame || !quantization || !components || components > 4 || length !== 6 + 2 * components) return false;
|
|
116
|
+
scans++;
|
|
117
|
+
// Scan bytes may contain FF00 escapes and restart markers, not segments.
|
|
118
|
+
while (offset < bytes.length) {
|
|
119
|
+
if (bytes[offset] !== 255) { entropy = true; offset++; continue; }
|
|
120
|
+
const next = bytes[offset + 1];
|
|
121
|
+
if (next === 0 || next >= 0xd0 && next <= 0xd7) { entropy = true; offset += 2; continue; }
|
|
122
|
+
if (next === 255) { offset++; continue; }
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function webpFrame(kind, value) {
|
|
131
|
+
if (kind === 'VP8 ') {
|
|
132
|
+
if (value.length < 11 || value[0] & 1 || !(value[0] & 16) || !value.subarray(3,6).equals(Buffer.from([0x9d,1,0x2a]))) return null;
|
|
133
|
+
const partition = value.readUIntLE(0,3) >>> 5;
|
|
134
|
+
const width = value.readUInt16LE(6) & 0x3fff, height = value.readUInt16LE(8) & 0x3fff;
|
|
135
|
+
return partition > 0 && partition <= value.length - 10 && dimensionsAllowed(width,height) ? { width,height } : null;
|
|
136
|
+
}
|
|
137
|
+
if (kind === 'VP8L') {
|
|
138
|
+
if (value.length < 6 || value[0] !== 0x2f || value[4] >>> 5) return null;
|
|
139
|
+
const bits = value.readUInt32LE(1), width = (bits & 0x3fff) + 1, height = ((bits >>> 14) & 0x3fff) + 1;
|
|
140
|
+
return dimensionsAllowed(width,height) ? { width,height } : null;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
function webpStructure(bytes) {
|
|
145
|
+
if (bytes.length < 26 || bytes.readUInt32LE(4) + 8 !== bytes.length) return false;
|
|
146
|
+
let offset = 12, canvas = null, frame = null;
|
|
147
|
+
while (offset + 8 <= bytes.length) {
|
|
148
|
+
const kind = bytes.toString('latin1',offset,offset + 4), length = bytes.readUInt32LE(offset + 4), end = offset + 8 + length, next = end + (length & 1);
|
|
149
|
+
if (next > bytes.length || length & 1 && bytes[end] !== 0) return false;
|
|
150
|
+
const value = bytes.subarray(offset + 8,end);
|
|
151
|
+
if (kind === 'VP8X') {
|
|
152
|
+
if (offset !== 12 || length !== 10 || value[0] & 0xc1 || value[1] || value[2] || value[3] || value[0] & 2) return false; // Static images only.
|
|
153
|
+
canvas = {width:value.readUIntLE(4,3) + 1,height:value.readUIntLE(7,3) + 1};
|
|
154
|
+
if (!dimensionsAllowed(canvas.width,canvas.height)) return false;
|
|
155
|
+
} else if (kind === 'VP8 ' || kind === 'VP8L') {
|
|
156
|
+
if (frame || !(frame = webpFrame(kind,value))) return false;
|
|
157
|
+
} else if (!canvas || kind === 'ANIM' || kind === 'ANMF') return false;
|
|
158
|
+
offset = next;
|
|
159
|
+
}
|
|
160
|
+
return offset === bytes.length && frame !== null && (!canvas || canvas.width === frame.width && canvas.height === frame.height);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Bounded container/header validation, not full pixel decoding. Format contracts:
|
|
164
|
+
// https://www.w3.org/TR/png-3/ ; https://www.w3.org/Graphics/JPEG/itu-t81.pdf
|
|
165
|
+
// https://developers.google.com/speed/webp/docs/riff_container
|
|
166
|
+
export function imageSignature(bytes) {
|
|
167
|
+
if (!Buffer.isBuffer(bytes) || bytes.length > HUMAN_IMAGE_LIMIT) return null;
|
|
168
|
+
if (bytes.length >= 8 && bytes.subarray(0,8).equals(Buffer.from('89504e470d0a1a0a','hex'))) return pngStructure(bytes) ? 'image/png' : null;
|
|
169
|
+
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return jpegStructure(bytes) ? 'image/jpeg' : null;
|
|
170
|
+
if (bytes.length >= 12 && bytes.subarray(0,4).toString() === 'RIFF' && bytes.subarray(8,12).toString() === 'WEBP') return webpStructure(bytes) ? 'image/webp' : null;
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function stageImage(candidate, options = {}) {
|
|
175
|
+
let filename;
|
|
176
|
+
if (typeof candidate === 'object' && candidate && typeof candidate.path === 'string') filename = path.resolve(options.cwd || process.cwd(), candidate.path);
|
|
177
|
+
else {
|
|
178
|
+
const matches = findImageCandidates(String(candidate), options);
|
|
179
|
+
if (matches.length !== 1 || matches[0].start !== 0 || matches[0].end !== String(candidate).length) throw fail('Select one literal PNG, JPEG or WebP image path.');
|
|
180
|
+
filename = matches[0].path;
|
|
181
|
+
}
|
|
182
|
+
let file;
|
|
183
|
+
try {
|
|
184
|
+
// O_NONBLOCK avoids waiting on FIFOs; O_NOFOLLOW refuses a final symlink.
|
|
185
|
+
// fstat validates the opened descriptor, closing the lstat/open race.
|
|
186
|
+
file = await open(filename, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
|
|
187
|
+
const before = await file.stat();
|
|
188
|
+
if (!before.isFile()) throw fail('The selected image must be a regular file.');
|
|
189
|
+
if (!before.size || before.size > HUMAN_IMAGE_LIMIT) throw fail('Images must be nonempty and at most 2 MiB.', 'IMAGE_ATTACHMENT_SIZE');
|
|
190
|
+
const buffer = Buffer.alloc(HUMAN_IMAGE_LIMIT + 1); let offset = 0;
|
|
191
|
+
while (offset < buffer.length) {
|
|
192
|
+
const { bytesRead } = await file.read(buffer, offset, buffer.length - offset, offset);
|
|
193
|
+
if (!bytesRead) break;
|
|
194
|
+
offset += bytesRead;
|
|
195
|
+
}
|
|
196
|
+
const after = await file.stat();
|
|
197
|
+
if (offset > HUMAN_IMAGE_LIMIT) throw fail('Images must be at most 2 MiB.', 'IMAGE_ATTACHMENT_SIZE');
|
|
198
|
+
if (before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || offset !== before.size) throw fail('The image changed during capture. Select it again.', 'IMAGE_ATTACHMENT_CHANGED');
|
|
199
|
+
const bytes = buffer.subarray(0, offset), media_type = imageSignature(bytes);
|
|
200
|
+
if (!media_type) throw fail('Invalid or unsupported image. Select a complete PNG, JPEG or static WebP image, at most 16384 pixels per side and 40 megapixels.', 'IMAGE_ATTACHMENT_FORMAT');
|
|
201
|
+
const id = randomUUID(), descriptor = Object.freeze({ id, label: '[Image]', name: path.basename(filename), path: filename, sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length, media_type });
|
|
202
|
+
const stage = Object.freeze({ descriptor });
|
|
203
|
+
staged.set(stage, { data: bytes.toString('base64'), descriptor });
|
|
204
|
+
return stage;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (error.code?.startsWith('IMAGE_ATTACHMENT')) throw error;
|
|
207
|
+
if (error.code === 'ELOOP') throw fail('Symbolic links cannot be attached. Select the image file itself.');
|
|
208
|
+
throw fail(`Cannot attach the selected image: ${error.code || error.message}.`);
|
|
209
|
+
} finally { await file?.close(); }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function descriptorForStage(stage) {
|
|
213
|
+
const snapshot = staged.get(stage);
|
|
214
|
+
if (!snapshot) throw fail('The image snapshot was not captured by the composer.');
|
|
215
|
+
return snapshot.descriptor;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The selected external file is read exactly once. Only the sealed host record
|
|
219
|
+
// holds encoded bytes; drafts, queues and model messages carry small references.
|
|
220
|
+
export async function persistHumanImage(store, sessionId, stage) {
|
|
221
|
+
const snapshot = staged.get(stage);
|
|
222
|
+
if (!snapshot) throw fail('The image snapshot was not captured by the composer.');
|
|
223
|
+
const row = await store.append(sessionId, { type: 'human_image', source: 'image_attachment_host', actor: { kind: 'human', id: 'local-user' }, session_id: sessionId,
|
|
224
|
+
attachment: snapshot.descriptor, data: snapshot.data });
|
|
225
|
+
return Object.freeze({ kind: 'human_attachment', attachment_id: snapshot.descriptor.id,
|
|
226
|
+
evidence: Object.freeze({ session_id: sessionId, row: row.audit.sequence }), name: snapshot.descriptor.name,
|
|
227
|
+
media_type: snapshot.descriptor.media_type, bytes: snapshot.descriptor.bytes, sha256: snapshot.descriptor.sha256 });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function resolveHumanImage(session, reference) {
|
|
231
|
+
if (!auditJournal(session.rows || []).verified) throw fail('Image attachment journal integrity could not be verified.', 'AUDIT_INVALID');
|
|
232
|
+
return decodeRecordedImage(session, reference);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function decodeRecordedImage(session, reference) {
|
|
236
|
+
if (reference?.kind !== 'human_attachment' || reference.evidence?.session_id !== session.id || !Number.isSafeInteger(reference.evidence.row)) throw fail('The image reference is outside this session.');
|
|
237
|
+
const row = session.rows[reference.evidence.row], attachment = row?.attachment;
|
|
238
|
+
if (row?.type !== 'human_image' || row.source !== 'image_attachment_host' || !isHuman(row.actor) || row.session_id !== session.id
|
|
239
|
+
|| reference.attachment_id !== attachment?.id || reference.sha256 !== attachment.sha256 || reference.bytes !== attachment.bytes || reference.media_type !== attachment.media_type
|
|
240
|
+
|| !/^[a-f0-9]{64}$/.test(attachment.sha256) || !Number.isSafeInteger(attachment.bytes) || attachment.bytes < 1 || attachment.bytes > HUMAN_IMAGE_LIMIT
|
|
241
|
+
|| typeof row.data !== 'string' || row.data.length > Math.ceil(HUMAN_IMAGE_LIMIT / 3) * 4) throw fail('The image reference does not match a human-selected snapshot.');
|
|
242
|
+
const bytes = Buffer.from(row.data, 'base64');
|
|
243
|
+
if (bytes.toString('base64') !== row.data || bytes.length !== attachment.bytes || createHash('sha256').update(bytes).digest('hex') !== attachment.sha256 || imageSignature(bytes) !== attachment.media_type) throw fail('The image snapshot does not match its recorded digest.');
|
|
244
|
+
return { ...attachment, data: row.data };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Only an explicit human composer message activates a captured draft. A staged
|
|
248
|
+
// image alone, tool text, assistant reference or projected row grants no pixels.
|
|
249
|
+
export function humanImageReferences(session) {
|
|
250
|
+
const rows = session.rows || [], reset = rows.findLastIndex(row => row.type === 'reset');
|
|
251
|
+
const messages = rows.slice(reset + 1).filter(row => row.type === 'message' && row.source === 'human_composer' && isHuman(row.actor) && row.message?.role === 'user' && Array.isArray(row.message.content));
|
|
252
|
+
const latest = messages.findLast(row => row.message.content.some(block => block.type === 'image_reference' && block.reference?.kind === 'human_attachment'));
|
|
253
|
+
if (!latest) return [];
|
|
254
|
+
if (!auditJournal(rows).verified) throw fail('Image attachment journal integrity could not be verified.', 'AUDIT_INVALID');
|
|
255
|
+
const references = latest.message.content.filter(block => block.type === 'image_reference').map(block => block.reference);
|
|
256
|
+
if (references.length > HUMAN_IMAGE_COUNT) throw fail('Attach at most two images per message.', 'IMAGE_ATTACHMENT_COUNT');
|
|
257
|
+
for (const reference of references) {
|
|
258
|
+
if (reference.evidence?.row <= reset) throw fail('The image attachment was cleared by the session reset.');
|
|
259
|
+
if (reference.evidence.row >= rows.indexOf(latest)) throw fail('The image snapshot must precede its human message.');
|
|
260
|
+
decodeRecordedImage(session, reference);
|
|
261
|
+
}
|
|
262
|
+
return references;
|
|
263
|
+
}
|
package/src/model-controls.mjs
CHANGED
|
@@ -7,7 +7,7 @@ const bool = value => typeof value === 'boolean' ? value : typeof value?.support
|
|
|
7
7
|
const validLevel = value => EFFORT_LEVELS.includes(value);
|
|
8
8
|
const fail = message => Object.assign(new Error(message), { code: 'MODEL_EFFORT_UNAVAILABLE' });
|
|
9
9
|
export function normalizeEffort(value = 'auto') {
|
|
10
|
-
if (typeof value !== 'string' || !validLevel(value)) throw fail(`
|
|
10
|
+
if (typeof value !== 'string' || !validLevel(value)) throw fail(`Invalid effort. Use ${EFFORT_LEVELS.join(', ')}.`);
|
|
11
11
|
return value;
|
|
12
12
|
}
|
|
13
13
|
function protocol(provider) { return provider.protocol || (provider.url ? 'anthropic' : provider.baseURL?.includes('generativelanguage') ? 'gemini' : 'openai'); }
|
|
@@ -24,7 +24,7 @@ async function json(provider, url, { signal, ...options } = {}) {
|
|
|
24
24
|
if (!reader) return null;
|
|
25
25
|
let text = '', size = 0; const decoder = new TextDecoder();
|
|
26
26
|
try {
|
|
27
|
-
for (;;) { const { value, done } = await reader.read(); if (done) break; size += value.byteLength; if (size > 1024 * 1024) throw new Error('
|
|
27
|
+
for (;;) { const { value, done } = await reader.read(); if (done) break; size += value.byteLength; if (size > 1024 * 1024) throw new Error('Model metadata exceeds 1 MiB.'); text += decoder.decode(value, { stream: true }); }
|
|
28
28
|
return JSON.parse(text + decoder.decode());
|
|
29
29
|
} finally { await reader.cancel(); }
|
|
30
30
|
}
|
|
@@ -44,7 +44,7 @@ export async function listProviderModels(provider, { signal } = {}) {
|
|
|
44
44
|
const url = new URL(base(provider));
|
|
45
45
|
if (next) url.searchParams.set(protocol(provider) === 'gemini' ? 'pageToken' : 'after_id', next);
|
|
46
46
|
const data = await json(provider, url.href, { signal });
|
|
47
|
-
if (!data) throw Object.assign(new Error('
|
|
47
|
+
if (!data) throw Object.assign(new Error('The server did not allow listing models.'), { code: 'MODEL_DISCOVERY_UNAVAILABLE' });
|
|
48
48
|
for (const item of Array.isArray(data.data) ? data.data : Array.isArray(data.models) ? data.models : []) { const safe = safeModel(item); if (safe && !models.some(model => model.id === safe.id)) models.push(safe); }
|
|
49
49
|
next = protocol(provider) === 'gemini' ? data.nextPageToken : data.has_more ? data.last_id : '';
|
|
50
50
|
} while (next && ++pages < 8 && models.length < 1024);
|
|
@@ -156,7 +156,7 @@ export async function setProviderEffort(provider, value, { signal } = {}) {
|
|
|
156
156
|
value = normalizeEffort(value);
|
|
157
157
|
if (value !== 'auto') {
|
|
158
158
|
const state = await discoverModelCapabilities(provider, { signal });
|
|
159
|
-
if (!state.effort.levels.includes(value)) throw fail(`
|
|
159
|
+
if (!state.effort.levels.includes(value)) throw fail(`Model ${provider.model || '(no model)'} does not advertise effort ${value}. Available: ${state.effort.levels.join(', ')}.`);
|
|
160
160
|
}
|
|
161
161
|
provider.effort = value;
|
|
162
162
|
if (Object.hasOwn(provider, 'thinking')) provider.thinking = 'auto';
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readFile, mkdir, writeFile, rename, unlink } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { t } from './i18n/index.mjs';
|
|
6
|
+
|
|
7
|
+
const selectors = ['provider', 'base-url', 'model', 'profile', 'providers-config', 'auth-provider'];
|
|
8
|
+
export function hasProviderSelection(options = {}, profiles = {}, env = process.env) {
|
|
9
|
+
return Boolean(options.demo || options.resume || options.continue || selectors.some(key => options[key]) || profiles.defaultProfile || Object.entries(env).some(([key, value]) => value && /^(?:(?:COMAL|HARNESS)_(?:PROVIDER|BASE_URL|MODEL|PROVIDERS_CONFIG|PROFILE)|(?:OPENAI|ANTHROPIC|GEMINI)_(?:BASE_URL|MODEL|API_KEY))$/.test(key)));
|
|
10
|
+
}
|
|
11
|
+
const preferenceFile = env => path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'comal', 'connection.json');
|
|
12
|
+
function normalizeConnection(value) {
|
|
13
|
+
if (!value || Array.isArray(value) || typeof value !== 'object' || Object.keys(value).some(key => !['provider', 'base-url', 'model', 'auth-provider'].includes(key))) throw Error('Invalid saved Comal connection.');
|
|
14
|
+
if (value['auth-provider']) {
|
|
15
|
+
if (Object.keys(value).some(key => !['auth-provider', 'model'].includes(key)) || !['codex','kimi','minimax','grok'].includes(value['auth-provider'])) throw Error('Invalid saved login provider.');
|
|
16
|
+
} else {
|
|
17
|
+
if (value.provider !== 'openai') throw Error('Invalid saved connection protocol.');
|
|
18
|
+
const url = new URL(value['base-url']);
|
|
19
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw Error('Use an HTTP(S) endpoint without embedded credentials or query parameters.');
|
|
20
|
+
}
|
|
21
|
+
if (value.model !== undefined && (typeof value.model !== 'string' || !value.model.trim() || value.model.length > 256 || /[\x00-\x1f\x7f]/.test(value.model))) throw Error('Invalid saved model.');
|
|
22
|
+
return { ...value };
|
|
23
|
+
}
|
|
24
|
+
export async function loadConnection({ env = process.env } = {}) {
|
|
25
|
+
try { const text = await readFile(preferenceFile(env), 'utf8'); if (Buffer.byteLength(text) > 4096) throw Error('Saved Comal connection exceeds 4 KiB.'); return normalizeConnection(JSON.parse(text)); }
|
|
26
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
27
|
+
}
|
|
28
|
+
export async function saveConnection(value, { env = process.env } = {}) {
|
|
29
|
+
const data = normalizeConnection(value), file = preferenceFile(env), temporary = file + '.' + randomUUID();
|
|
30
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
31
|
+
try { await writeFile(temporary, JSON.stringify(data) + '\n', { flag: 'wx', mode: 0o600 }); await rename(temporary, file); }
|
|
32
|
+
finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The terminal stays owned by the caller. Only a successfully validated connection
|
|
36
|
+
// is remembered; credentials live in AuthManager/environment, never in this file.
|
|
37
|
+
export async function onboard({ ui, profiles, authManager, connect, signal, env = process.env }) {
|
|
38
|
+
while (!signal?.aborted && !ui.closed) {
|
|
39
|
+
const choice = await ui.setupChoice({ title: t('Your first turn'), description: t('Choose how to start. Stay here while Comal connects.'), signal, items: [
|
|
40
|
+
{ id: 'demo', label: t('Try the offline demo'), description: t('No account or model. A scripted tool cycle; no inference.') },
|
|
41
|
+
...profiles.profiles.map(profile => ({ id: 'profile:' + profile.name, label: profile.name, description: `${profile.provider} · ${profile.model || t('discover model')}` })),
|
|
42
|
+
{ id: 'codex', label: t('Connect Codex'), description: t('Sign in with your existing subscription in the browser.') },
|
|
43
|
+
{ id: 'server', label: t('Connect a model server'), description: t('OpenAI-compatible endpoint. API keys stay in your environment.') },
|
|
44
|
+
] });
|
|
45
|
+
if (!choice || signal?.aborted || ui.closed) return null;
|
|
46
|
+
if (choice === 'demo') { ui.clearSetup(); return { options: { demo: true } }; }
|
|
47
|
+
let options;
|
|
48
|
+
try {
|
|
49
|
+
if (choice.startsWith('profile:')) options = { profile: choice.slice(8) };
|
|
50
|
+
else if (choice === 'codex') {
|
|
51
|
+
ui.setupStatus({ title: t('Connecting Codex'), description: t('Waiting for browser authorization. Esc cancels this connection.') });
|
|
52
|
+
const operation = new AbortController();
|
|
53
|
+
ui.callbacks.onSetupCancel = () => operation.abort();
|
|
54
|
+
try {
|
|
55
|
+
const status = await authManager.status('codex');
|
|
56
|
+
operation.signal.throwIfAborted(); signal?.throwIfAborted();
|
|
57
|
+
if (status.status !== 'authenticated') await authManager.login('codex', { signal: signal ? AbortSignal.any([signal, operation.signal]) : operation.signal, onEvent: event => {
|
|
58
|
+
if (operation.signal.aborted || signal?.aborted || ui.closed) return;
|
|
59
|
+
if (event.type === 'device_code') ui.setupStatus({ title: t('Authorize Codex'), description: `${event.verification_uri}\n\n${t('Code')}: ${event.user_code}\n\n${t('Approve only if you started this connection. Esc cancels.')}` });
|
|
60
|
+
} });
|
|
61
|
+
operation.signal.throwIfAborted(); signal?.throwIfAborted();
|
|
62
|
+
} finally { delete ui.callbacks.onSetupCancel; }
|
|
63
|
+
options = { 'auth-provider': 'codex' };
|
|
64
|
+
} else {
|
|
65
|
+
const endpoint = await ui.setupInput({ title: t('Model server'), description: t('Endpoint URL. No API key or password in the URL.'), value: 'http://127.0.0.1:8080/v1', signal, validate: text => { try { normalizeConnection({ provider: 'openai', 'base-url': text }); return true; } catch (error) { return error.message; } } });
|
|
66
|
+
if (!endpoint) continue;
|
|
67
|
+
const model = await ui.setupInput({ title: t('Model'), description: t('Leave empty to discover the model from /models. OPENAI_API_KEY is used when set.'), signal });
|
|
68
|
+
if (model === null) continue;
|
|
69
|
+
options = { provider: 'openai', 'base-url': endpoint, ...(model.trim() ? { model: model.trim() } : {}) };
|
|
70
|
+
}
|
|
71
|
+
ui.setupStatus({ title: t('Checking connection'), description: t('Discovering the model and its capabilities. No task is sent.') });
|
|
72
|
+
const connection = await connect(options);
|
|
73
|
+
signal?.throwIfAborted(); if (ui.closed) return null;
|
|
74
|
+
if (!options.profile) {
|
|
75
|
+
try { await saveConnection({ ...options, ...(connection.provider?.model ? { model: connection.provider.model } : {}) }, { env }); }
|
|
76
|
+
catch (error) { ui.notice(t('Connected, but settings could not be saved: ') + error.message, true); }
|
|
77
|
+
}
|
|
78
|
+
ui.clearSetup(); return { options, connection };
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (signal?.aborted || ui.closed) return null;
|
|
81
|
+
if (error.name === 'AbortError' || error.code === 'ABORT_ERR') continue;
|
|
82
|
+
const action = await ui.setupChoice({ title: t('Connection not ready'), description: String(error.message).slice(0, 600), signal, items: [
|
|
83
|
+
{ id: 'back', label: t('Choose another connection'), description: t('Your terminal stays open.') },
|
|
84
|
+
{ id: 'demo', label: t('Try the offline demo'), description: t('Continue without an account or model.') },
|
|
85
|
+
] });
|
|
86
|
+
if (!action) return null;
|
|
87
|
+
if (action === 'demo') { ui.clearSetup(); return { options: { demo: true } }; }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
package/src/openai-provider.mjs
CHANGED
|
@@ -13,7 +13,7 @@ export const DEFAULT_LOCAL_URL = 'http://llama.local:11438/';
|
|
|
13
13
|
// Accept both server roots and conventional OpenAI base URLs ending in /v1.
|
|
14
14
|
export function openaiBaseURL(baseURL) {
|
|
15
15
|
const url = new URL(baseURL);
|
|
16
|
-
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('
|
|
16
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('Invalid OpenAI URL; use an HTTP(S) base without credentials or a query.');
|
|
17
17
|
let pathname = url.pathname.replace(/\/+$/, '');
|
|
18
18
|
if (pathname.endsWith('/chat/completions')) pathname = pathname.slice(0, -'/chat/completions'.length);
|
|
19
19
|
if (!pathname.endsWith('/v1')) pathname += '/v1';
|
|
@@ -23,12 +23,12 @@ export function openaiBaseURL(baseURL) {
|
|
|
23
23
|
|
|
24
24
|
function textContent(content) {
|
|
25
25
|
if (typeof content === 'string') return content;
|
|
26
|
-
if (!Array.isArray(content)) throw new Error('
|
|
26
|
+
if (!Array.isArray(content)) throw new Error('Invalid message content.');
|
|
27
27
|
return content.filter(block => block.type === 'text').map(block => block.text).join('\n');
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export function toOpenAIMessages(system, messages, { reasoningField = 'reasoning_content' } = {}) {
|
|
31
|
-
if (!['reasoning_content', 'reasoning'].includes(reasoningField)) throw new Error('
|
|
31
|
+
if (!['reasoning_content', 'reasoning'].includes(reasoningField)) throw new Error('Invalid OpenAI reasoning field.');
|
|
32
32
|
const result = system ? [{ role: 'system', content: system }] : [];
|
|
33
33
|
for (const message of messages) {
|
|
34
34
|
const blocks = Array.isArray(message.content) ? message.content : [];
|
|
@@ -50,8 +50,8 @@ export function toOpenAIMessages(system, messages, { reasoningField = 'reasoning
|
|
|
50
50
|
for (const block of blocks) {
|
|
51
51
|
if (block.type === 'text') pending.push({ type: 'text', text: block.text });
|
|
52
52
|
else if (block.type === 'image' && block.source?.type === 'base64') pending.push({ type: 'image_url', image_url: { url: `data:${block.source.media_type};base64,${block.source.data}` } });
|
|
53
|
-
else if (block.type === 'image_reference') throw new Error('
|
|
54
|
-
else if (block.type === 'video' || block.type === 'media') throw Object.assign(new Error('
|
|
53
|
+
else if (block.type === 'image_reference') throw new Error('Unresolved image reference; it was not sent to the provider.');
|
|
54
|
+
else if (block.type === 'video' || block.type === 'media') throw Object.assign(new Error('The OpenAI Chat Completions adapter does not support video; use a profile with media transport.'), { code: 'PROVIDER_CAPABILITY_UNAVAILABLE' });
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
flush();
|
|
@@ -61,20 +61,20 @@ export function toOpenAIMessages(system, messages, { reasoningField = 'reasoning
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function normalizedMessage({ text = '', reasoning = '', calls = [], finish, usage = {} }) {
|
|
64
|
-
if (typeof text !== 'string' || typeof reasoning !== 'string' || !Array.isArray(calls)) throw new Error('
|
|
65
|
-
if (!finish) throw new Error('
|
|
64
|
+
if (typeof text !== 'string' || typeof reasoning !== 'string' || !Array.isArray(calls)) throw new Error('Invalid OpenAI content.');
|
|
65
|
+
if (!finish) throw new Error('OpenAI streaming was interrupted; no tools from this response were executed.');
|
|
66
66
|
if (finish === 'length') {
|
|
67
|
-
const error = new Error('
|
|
67
|
+
const error = new Error('OpenAI response truncated (length): generation budget or server capacity exhausted; no tools were executed. Use /compact and --max-tokens, or split the script into smaller calls.');
|
|
68
68
|
error.code = 'OUTPUT_LENGTH'; error.usage_accounting = normalizeUsage('openai',usage); error.usage = { input_tokens: usage.prompt_tokens || 0, output_tokens: usage.completion_tokens || 0 }; throw error;
|
|
69
69
|
}
|
|
70
|
-
if (finish === 'content_filter') throw new Error('
|
|
70
|
+
if (finish === 'content_filter') throw new Error('Incomplete OpenAI response (content_filter); no tools were executed.');
|
|
71
71
|
if (finish !== 'stop' && finish !== 'tool_calls') throw new Error(`finish_reason OpenAI no soportado: ${finish}`);
|
|
72
|
-
if (finish === 'tool_calls' && !calls.length) throw new Error('
|
|
72
|
+
if (finish === 'tool_calls' && !calls.length) throw new Error('tool_calls response contains no calls.');
|
|
73
73
|
const content = [], ids = new Set();
|
|
74
74
|
if (reasoning) content.push({ type: 'openai_reasoning', text: reasoning });
|
|
75
75
|
if (text) content.push({ type: 'text', text });
|
|
76
76
|
for (const call of calls) {
|
|
77
|
-
if (typeof call.id !== 'string' || !call.id || ids.has(call.id) || call.type !== 'function' || !call.function?.name) throw new Error('
|
|
77
|
+
if (typeof call.id !== 'string' || !call.id || ids.has(call.id) || call.type !== 'function' || !call.function?.name) throw new Error('Invalid OpenAI tool call.');
|
|
78
78
|
let input;
|
|
79
79
|
try {
|
|
80
80
|
if (call.function.arguments !== undefined && typeof call.function.arguments !== 'string') throw new Error('Non-string arguments');
|
|
@@ -82,14 +82,14 @@ function normalizedMessage({ text = '', reasoning = '', calls = [], finish, usag
|
|
|
82
82
|
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Non-object arguments');
|
|
83
83
|
} catch {
|
|
84
84
|
const raw = typeof call.function.arguments === 'string' ? call.function.arguments : JSON.stringify(call.function.arguments ?? '');
|
|
85
|
-
throw Object.assign(new Error('
|
|
85
|
+
throw Object.assign(new Error('Invalid OpenAI argument JSON; no tools were executed.'), { code: 'TOOL_ARGUMENTS_INVALID', usage_accounting: normalizeUsage('openai',usage),
|
|
86
86
|
usage: { input_tokens: usage.prompt_tokens || 0, output_tokens: usage.completion_tokens || 0 },
|
|
87
87
|
details: { call_id: call.id, tool: call.function.name, arguments_bytes: Buffer.byteLength(raw), arguments_digest: digest(raw) } });
|
|
88
88
|
}
|
|
89
89
|
ids.add(call.id);
|
|
90
90
|
content.push({ type: 'tool_use', id: call.id, name: call.function.name, input });
|
|
91
91
|
}
|
|
92
|
-
if (!text && !calls.length) throw Object.assign(new Error('
|
|
92
|
+
if (!text && !calls.length) throw Object.assign(new Error('The model returned neither text nor tools.'), { code: 'MODEL_EMPTY', usage_accounting: normalizeUsage('openai',usage), usage: { input_tokens: usage.prompt_tokens || 0, output_tokens: usage.completion_tokens || 0 } });
|
|
93
93
|
return { role: 'assistant', content, stop_reason: calls.length ? 'tool_use' : 'end_turn', usage_accounting: normalizeUsage('openai',usage), usage: { input_tokens: usage.prompt_tokens || 0, output_tokens: usage.completion_tokens || 0 } };
|
|
94
94
|
}
|
|
95
95
|
|
|
@@ -100,7 +100,7 @@ export class OpenAIProvider {
|
|
|
100
100
|
this.reasoningField = new URL(this.baseURL).hostname === 'ollama.com' ? 'reasoning' : 'reasoning_content';
|
|
101
101
|
Object.assign(this, modelTimingSettings({ timeoutMs, idleTimeoutMs }, {}));
|
|
102
102
|
this.maxTokens = maxTokens; this.fetch = fetchImpl;
|
|
103
|
-
if (!['auto', 'on', 'off', 'low', 'medium', 'high'].includes(thinking)) throw new Error('--thinking / COMAL_THINKING
|
|
103
|
+
if (!['auto', 'on', 'off', 'low', 'medium', 'high'].includes(thinking)) throw new Error('--thinking / COMAL_THINKING must be auto, on, off, low, medium or high.');
|
|
104
104
|
this.thinking = thinking;
|
|
105
105
|
Object.assign(this, visionSettings({ vision, imageTokens }, {}));
|
|
106
106
|
this.visionCache = new Map();
|
|
@@ -123,7 +123,7 @@ export class OpenAIProvider {
|
|
|
123
123
|
...(ollamaCloud ? { method: 'POST', body: JSON.stringify({ model: this.model }) } : {}) });
|
|
124
124
|
if (response.ok) {
|
|
125
125
|
let text = ''; const reader = response.body.getReader(), decoder = new TextDecoder();
|
|
126
|
-
try { for (;;) { const { value, done } = await reader.read(); if (done) { text += decoder.decode(); break; } text += decoder.decode(value, { stream: true }); if (Buffer.byteLength(text) > 65536) throw new Error('Props
|
|
126
|
+
try { for (;;) { const { value, done } = await reader.read(); if (done) { text += decoder.decode(); break; } text += decoder.decode(value, { stream: true }); if (Buffer.byteLength(text) > 65536) throw new Error('Props response is too large.'); } }
|
|
127
127
|
finally { await reader.cancel(); }
|
|
128
128
|
const props = JSON.parse(text);
|
|
129
129
|
if (ollamaCloud && Array.isArray(props.capabilities)) capability = { ...base, supported: props.capabilities.includes('vision'), source: 'ollama_show' };
|
|
@@ -150,9 +150,9 @@ export class OpenAIProvider {
|
|
|
150
150
|
details = JSON.parse(text).error;
|
|
151
151
|
} catch {}
|
|
152
152
|
if ([400, 413, 422].includes(response.status) && (details?.code === 'context_length_exceeded' || /(?:context|prompt).*(?:exceed|too (?:long|large)|overflow)|exceed.*context/i.test(String(details?.message || '')))) {
|
|
153
|
-
const error = new Error('
|
|
153
|
+
const error = new Error('The server rejected the request because it exceeds its context window. Use /compact or reduce COMAL_CONTEXT_WINDOW.'); error.code = 'CONTEXT_LENGTH'; throw error;
|
|
154
154
|
}
|
|
155
|
-
throw new Error(`OpenAI HTTP ${response.status}.
|
|
155
|
+
throw new Error(`OpenAI HTTP ${response.status}. Check the endpoint, model and OPENAI_API_KEY if the server requires authentication.`);
|
|
156
156
|
}
|
|
157
157
|
async discoverModel({ signal } = {}) {
|
|
158
158
|
if (this.model) return this.model;
|
|
@@ -165,7 +165,7 @@ export class OpenAIProvider {
|
|
|
165
165
|
const models = Array.isArray(body.data) ? body.data : Array.isArray(body.models) ? body.models : [];
|
|
166
166
|
this.discoveredModels = models;
|
|
167
167
|
const model = models.map(item => item.id || item.name || item.model).find(id => typeof id === 'string' && id.trim());
|
|
168
|
-
if (!model) throw new Error('
|
|
168
|
+
if (!model) throw new Error('The server does not advertise models; specify COMAL_MODEL, OPENAI_MODEL or --model.');
|
|
169
169
|
this.model = model; return model;
|
|
170
170
|
} catch (error) { throw deadline.error(error); } finally { deadline.close(); }
|
|
171
171
|
}
|
|
@@ -174,7 +174,7 @@ export class OpenAIProvider {
|
|
|
174
174
|
const accountingRequest={system,messages,tools,maxTokens}, identity={...accountingIdentity(this),projection_epoch:contextIdentity?.projection_epoch ?? null};
|
|
175
175
|
const schemaFrame=compileToolSchemas(tools,{provider:this.protocol,capabilities:{...(this.getCapabilities?.() || {}),...(this.toolSchemaCapabilities?{tool_schema:this.toolSchemaCapabilities}:{})},catalogue:schemaCatalog}); tools=schemaFrame.tools;
|
|
176
176
|
if (messages.some(message => Array.isArray(message.content) && message.content.some(block => block.type === 'image'))) {
|
|
177
|
-
if (!(await this.getVisionCapability({ signal })).supported) throw new Error('
|
|
177
|
+
if (!(await this.getVisionCapability({ signal })).supported) throw new Error('The provider does not advertise vision; no pixels were sent. Configure a multimodal model, or --vision on only if your endpoint supports it.');
|
|
178
178
|
}
|
|
179
179
|
const effortWire = await providerEffortWire(this, { signal });
|
|
180
180
|
const body = JSON.stringify({ model: this.model, messages: toOpenAIMessages(system, messages, { reasoningField: this.reasoningField }), max_tokens: maxTokens, stream: true, stream_options: { include_usage: true },
|
|
@@ -182,20 +182,20 @@ export class OpenAIProvider {
|
|
|
182
182
|
...effortWire,
|
|
183
183
|
...(tools.length ? { tools: tools.map(tool => ({ type: 'function', function: { name: tool.name, description: tool.description, parameters: tool.input_schema } })), tool_choice: 'auto' } : {}) });
|
|
184
184
|
const account=result=>{result.usage_accounting=bindUsageAccounting(result.usage_accounting,accountingRequest,identity,body); result.schema_diagnostics=schemaFrame.diagnostics;result.schema_manifest=schemaFrame.manifest;return result;};
|
|
185
|
-
if (Buffer.byteLength(body) > 8 * 1024 * 1024) throw new Error('
|
|
185
|
+
if (Buffer.byteLength(body) > 8 * 1024 * 1024) throw new Error('The context exceeds 8 MiB; start a new session with /clear.');
|
|
186
186
|
const deadline = modelDeadline({ signal, timeoutMs: this.timeoutMs, idleTimeoutMs: this.idleTimeoutMs, purpose });
|
|
187
187
|
const progress = { reasoning_chars: 0, text_chars: 0, tool_argument_chars: 0 }; let lastProgress = 0;
|
|
188
188
|
const report = (force = false) => { if (force || Date.now() - lastProgress >= 750) { lastProgress = Date.now(); onProgress({ ...progress, elapsed_ms: deadline.elapsedMs(), purpose }); } };
|
|
189
189
|
try {
|
|
190
190
|
let response;
|
|
191
191
|
try { response = await this.fetch(`${this.baseURL}/chat/completions`, { method: 'POST', headers: this.headers(), body, signal: deadline.signal }); }
|
|
192
|
-
catch (error) { if (error.name === 'TypeError') throw new Error(`
|
|
192
|
+
catch (error) { if (error.name === 'TypeError') throw new Error(`Could not connect to ${this.baseURL}: ${error.cause?.code || error.message}`); throw error; }
|
|
193
193
|
await this.checkResponse(response);
|
|
194
194
|
deadline.signal.throwIfAborted();
|
|
195
195
|
if (!response.headers.get('content-type')?.includes('text/event-stream')) {
|
|
196
196
|
const data = await response.json();
|
|
197
197
|
deadline.signal.throwIfAborted();
|
|
198
|
-
if (data.error) throw new Error('
|
|
198
|
+
if (data.error) throw new Error('The OpenAI server returned an error.');
|
|
199
199
|
const choice = data.choices?.find(choice => choice.index === 0) || data.choices?.[0];
|
|
200
200
|
const message = normalizedMessage({ text: choice?.message?.content || '', reasoning: this.reasoningOf(choice?.message), calls: choice?.message?.tool_calls || [], finish: choice?.finish_reason, usage: data.usage });
|
|
201
201
|
for (const block of message.content) if (block.type === 'text') onText(block.text);
|
|
@@ -204,7 +204,7 @@ export class OpenAIProvider {
|
|
|
204
204
|
let text = '', reasoning = '', finish, usage = {}; const calls = new ToolCallStream();
|
|
205
205
|
for await (const event of sseEvents(response.body)) {
|
|
206
206
|
if (event.type === 'stream_done') break;
|
|
207
|
-
if (event.error) throw new Error('
|
|
207
|
+
if (event.error) throw new Error('The OpenAI server returned a streaming error.');
|
|
208
208
|
if (event.usage) usage = event.usage;
|
|
209
209
|
const choice = event.choices?.find(choice => choice.index === 0);
|
|
210
210
|
if (!choice) continue;
|
|
@@ -236,7 +236,7 @@ export class OpenAIProvider {
|
|
|
236
236
|
export function providerSettings(options = {}, env = process.env, session) {
|
|
237
237
|
env = comalEnvironment(env);
|
|
238
238
|
const provider = options.provider || env.COMAL_PROVIDER || 'openai';
|
|
239
|
-
if (!['openai', 'anthropic'].includes(provider)) throw new Error('
|
|
239
|
+
if (!['openai', 'anthropic'].includes(provider)) throw new Error('Invalid provider; use openai or anthropic.');
|
|
240
240
|
const sameProvider = session && (session.provider || 'anthropic') === provider;
|
|
241
241
|
return { provider,
|
|
242
242
|
...visionSettings(options, env),
|
package/src/peer-tools.mjs
CHANGED
|
@@ -65,10 +65,11 @@ export class PeerTurnGuard {
|
|
|
65
65
|
|
|
66
66
|
export function providerFeatureDefinitions(provider) {
|
|
67
67
|
const caps=provider.getCapabilities?.() || {};
|
|
68
|
+
const mediaTypes=[...(caps.vision?['image/png','image/jpeg','image/webp']:[]),...(caps.video?['video/mp4','video/webm','video/quicktime']:[])];
|
|
68
69
|
return [
|
|
69
70
|
{name:'ProviderCapabilities',description:'Read the current provider capability contract. Configured support does not prove task success.',input_schema:object({})},
|
|
70
71
|
...(caps.web_search && typeof provider.nativeSearch==='function'?[{name:'ProviderSearch',description:'Search using this provider’s native web grounding. Returns its answer and source URLs as untrusted evidence; records provider usage. Requires actual native support.',input_schema:{...object({query:{type:'string',minLength:1,maxLength:4000}}),required:['query']}}]:[]),
|
|
71
|
-
...((caps.video || caps.vision) && typeof provider.analyzeMedia==='function'?[{name:'AnalyzeMedia',description:'
|
|
72
|
+
...((caps.video || caps.vision) && typeof provider.analyzeMedia==='function'?[{name:'AnalyzeMedia',description:'Analyze workspace media DIRECTLY. Supported MIME types for this provider: '+mediaTypes.join(', ')+'. '+(!caps.video?'Native video is unavailable: extract frames with an authorized local tool, then analyze the images. ':'')+'No text Read prerequisite. Bytes stay out of conversation; results are advisory provider reports, not independent verification.',input_schema:{...object({prompt:{type:'string',minLength:1,maxLength:4000},file_path:{type:'string',minLength:1,maxLength:4096},mime_type:{type:'string',enum:mediaTypes}}),required:['prompt','file_path','mime_type']}}]:[]),
|
|
72
73
|
];
|
|
73
74
|
}
|
|
74
75
|
|
|
@@ -139,7 +140,7 @@ export async function completePeerReplies(final,{source,baseline=0,requests,stor
|
|
|
139
140
|
}
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
export async function executeProviderFeature(name,input,{provider,workspace,record,signal,invoke=async(request,run)=>run(request)}) {
|
|
143
|
+
export async function executeProviderFeature(name,input,{provider,workspace,record,signal,observe=()=>{},invoke=async(request,run)=>run(request)}) {
|
|
143
144
|
const caps=provider.getCapabilities?.() || {text:true,tools:true,vision:false,video:false,web_search:false};
|
|
144
145
|
if(name==='ProviderCapabilities')return JSON.stringify({model:provider.model,capabilities:caps,authority:'capability_contract_not_execution'});
|
|
145
146
|
let result, media;
|
|
@@ -148,7 +149,7 @@ export async function executeProviderFeature(name,input,{provider,workspace,reco
|
|
|
148
149
|
if(name==='ProviderSearch' && caps.web_search && provider.nativeSearch)result=await invoke({messages:[{role:'user',content:input.query}],tools:[],signal,maxTokens:provider.maxTokens},options=>provider.nativeSearch({query:input.query,signal,maxTokens:options.maxTokens}));
|
|
149
150
|
else if(name==='AnalyzeMedia' && provider.analyzeMedia) {
|
|
150
151
|
const video=input.mime_type.startsWith('video/');
|
|
151
|
-
if(!(video?caps.video:caps.vision))throw new Error('
|
|
152
|
+
if(!(video?caps.video:caps.vision)){observe({kind:'provider_media_preflight_rejected',reason:'unsupported_modality',mime_type:input.mime_type,target_dispatched:false});throw Object.assign(new Error('The current provider does not support this media type. Use a supported MIME from the live schema; extract video frames for an image-only provider.'),{code:'PROVIDER_MEDIA_UNSUPPORTED'});}
|
|
152
153
|
const file=await workspace.resolve(input.file_path),info=await stat(file);
|
|
153
154
|
if(!info.isFile() || info.size>16*1024*1024)throw new Error('El medio debe ser un archivo del workspace de hasta 16 MiB.');
|
|
154
155
|
const bytes=await readFile(file);if(bytes.length>16*1024*1024)throw new Error('El medio excede 16 MiB.');
|