@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.
Files changed (187) hide show
  1. package/README.md +30 -28
  2. package/backend/contracts/remote-commands-v1.json +125 -0
  3. package/bin/comal.mjs +16 -5
  4. package/docs/investigation.md +2 -2
  5. package/governance/chrome-task-v2.json +67 -0
  6. package/node_modules/@jitl/quickjs-ffi-types/LICENSE +21 -0
  7. package/node_modules/@jitl/quickjs-ffi-types/README.md +5 -0
  8. package/node_modules/@jitl/quickjs-ffi-types/dist/index.d.mts +549 -0
  9. package/node_modules/@jitl/quickjs-ffi-types/dist/index.d.ts +549 -0
  10. package/node_modules/@jitl/quickjs-ffi-types/dist/index.js +2 -0
  11. package/node_modules/@jitl/quickjs-ffi-types/dist/index.js.map +1 -0
  12. package/node_modules/@jitl/quickjs-ffi-types/dist/index.mjs +2 -0
  13. package/node_modules/@jitl/quickjs-ffi-types/dist/index.mjs.map +1 -0
  14. package/node_modules/@jitl/quickjs-ffi-types/package.json +36 -0
  15. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/LICENSE +47 -0
  16. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/README.md +93 -0
  17. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.browser.d.ts +15 -0
  18. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.browser.mjs +2 -0
  19. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cjs +2 -0
  20. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cloudflare.cjs +2 -0
  21. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cloudflare.d.ts +15 -0
  22. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.d.ts +15 -0
  23. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.mjs +2 -0
  24. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.wasm +0 -0
  25. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.wasm.map +1 -0
  26. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.d.mts +94 -0
  27. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.d.ts +94 -0
  28. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.js +2 -0
  29. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.js.map +1 -0
  30. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.mjs +2 -0
  31. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.mjs.map +1 -0
  32. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.d.mts +20 -0
  33. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.d.ts +20 -0
  34. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.js +2 -0
  35. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.js.map +1 -0
  36. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.mjs +2 -0
  37. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.mjs.map +1 -0
  38. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/package.json +61 -0
  39. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/LICENSE +47 -0
  40. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/README.md +88 -0
  41. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.browser.d.ts +11 -0
  42. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.browser.mjs +5430 -0
  43. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cjs +5485 -0
  44. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cloudflare.cjs +5424 -0
  45. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cloudflare.d.ts +11 -0
  46. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.d.ts +11 -0
  47. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.mjs +5541 -0
  48. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.wasm +0 -0
  49. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.wasm.map +1 -0
  50. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.d.mts +85 -0
  51. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.d.ts +85 -0
  52. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.js +2 -0
  53. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.js.map +1 -0
  54. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.mjs +2 -0
  55. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.mjs.map +1 -0
  56. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.d.mts +20 -0
  57. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.d.ts +20 -0
  58. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.js +2 -0
  59. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.js.map +1 -0
  60. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.mjs +2 -0
  61. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.mjs.map +1 -0
  62. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/package.json +61 -0
  63. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/LICENSE +47 -0
  64. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/README.md +89 -0
  65. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.browser.d.ts +15 -0
  66. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.browser.mjs +26 -0
  67. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cjs +25 -0
  68. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cloudflare.cjs +25 -0
  69. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cloudflare.d.ts +15 -0
  70. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.d.ts +15 -0
  71. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.mjs +30 -0
  72. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.wasm +0 -0
  73. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.d.mts +94 -0
  74. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.d.ts +94 -0
  75. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.js +2 -0
  76. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.js.map +1 -0
  77. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.mjs +2 -0
  78. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.mjs.map +1 -0
  79. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.d.mts +20 -0
  80. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.d.ts +20 -0
  81. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.js +2 -0
  82. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.js.map +1 -0
  83. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.mjs +2 -0
  84. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.mjs.map +1 -0
  85. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/package.json +61 -0
  86. package/node_modules/@jitl/quickjs-wasmfile-release-sync/LICENSE +47 -0
  87. package/node_modules/@jitl/quickjs-wasmfile-release-sync/README.md +82 -0
  88. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.browser.d.ts +11 -0
  89. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.browser.mjs +22 -0
  90. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cjs +21 -0
  91. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cloudflare.cjs +21 -0
  92. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cloudflare.d.ts +11 -0
  93. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.d.ts +11 -0
  94. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.mjs +25 -0
  95. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm +0 -0
  96. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.d.mts +85 -0
  97. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.d.ts +85 -0
  98. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.js +2 -0
  99. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.js.map +1 -0
  100. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.mjs +2 -0
  101. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.mjs.map +1 -0
  102. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.d.mts +20 -0
  103. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.d.ts +20 -0
  104. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.js +2 -0
  105. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.js.map +1 -0
  106. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.mjs +2 -0
  107. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.mjs.map +1 -0
  108. package/node_modules/@jitl/quickjs-wasmfile-release-sync/package.json +61 -0
  109. package/node_modules/quickjs-emscripten/LICENSE +21 -0
  110. package/node_modules/quickjs-emscripten/README.md +928 -0
  111. package/node_modules/quickjs-emscripten/dist/chunk-OHAYRCBA.mjs +2 -0
  112. package/node_modules/quickjs-emscripten/dist/chunk-OHAYRCBA.mjs.map +1 -0
  113. package/node_modules/quickjs-emscripten/dist/index.d.mts +57 -0
  114. package/node_modules/quickjs-emscripten/dist/index.d.ts +57 -0
  115. package/node_modules/quickjs-emscripten/dist/index.global.js +7309 -0
  116. package/node_modules/quickjs-emscripten/dist/index.js +2 -0
  117. package/node_modules/quickjs-emscripten/dist/index.js.map +1 -0
  118. package/node_modules/quickjs-emscripten/dist/index.mjs +2 -0
  119. package/node_modules/quickjs-emscripten/dist/index.mjs.map +1 -0
  120. package/node_modules/quickjs-emscripten/dist/variants.d.mts +39 -0
  121. package/node_modules/quickjs-emscripten/dist/variants.d.ts +39 -0
  122. package/node_modules/quickjs-emscripten/dist/variants.js +2 -0
  123. package/node_modules/quickjs-emscripten/dist/variants.js.map +1 -0
  124. package/node_modules/quickjs-emscripten/dist/variants.mjs +2 -0
  125. package/node_modules/quickjs-emscripten/dist/variants.mjs.map +1 -0
  126. package/node_modules/quickjs-emscripten/package.json +69 -0
  127. package/node_modules/quickjs-emscripten-core/LICENSE +21 -0
  128. package/node_modules/quickjs-emscripten-core/README.md +341 -0
  129. package/node_modules/quickjs-emscripten-core/README.template.md +70 -0
  130. package/node_modules/quickjs-emscripten-core/dist/chunk-TAV5CUKK.mjs +2 -0
  131. package/node_modules/quickjs-emscripten-core/dist/chunk-TAV5CUKK.mjs.map +1 -0
  132. package/node_modules/quickjs-emscripten-core/dist/chunk-V2S4ZYJR.mjs +7 -0
  133. package/node_modules/quickjs-emscripten-core/dist/chunk-V2S4ZYJR.mjs.map +1 -0
  134. package/node_modules/quickjs-emscripten-core/dist/index.d.mts +2033 -0
  135. package/node_modules/quickjs-emscripten-core/dist/index.d.ts +2033 -0
  136. package/node_modules/quickjs-emscripten-core/dist/index.js +7 -0
  137. package/node_modules/quickjs-emscripten-core/dist/index.js.map +1 -0
  138. package/node_modules/quickjs-emscripten-core/dist/index.mjs +2 -0
  139. package/node_modules/quickjs-emscripten-core/dist/index.mjs.map +1 -0
  140. package/node_modules/quickjs-emscripten-core/dist/module-ES6BEMUI.mjs +2 -0
  141. package/node_modules/quickjs-emscripten-core/dist/module-ES6BEMUI.mjs.map +1 -0
  142. package/node_modules/quickjs-emscripten-core/dist/module-asyncify-2EFITU5U.mjs +2 -0
  143. package/node_modules/quickjs-emscripten-core/dist/module-asyncify-2EFITU5U.mjs.map +1 -0
  144. package/node_modules/quickjs-emscripten-core/package.json +49 -0
  145. package/package.json +6 -2
  146. package/src/agent.mjs +17 -6
  147. package/src/artifact-tools.mjs +3 -2
  148. package/src/auto-guard.mjs +5 -1
  149. package/src/cli.mjs +267 -201
  150. package/src/cloud-client.mjs +58 -8
  151. package/src/code-run-worker.mjs +70 -0
  152. package/src/code-run.mjs +97 -0
  153. package/src/codex-provider.mjs +28 -28
  154. package/src/compaction.mjs +1 -1
  155. package/src/goals.mjs +2 -2
  156. package/src/house.mjs +24 -24
  157. package/src/i18n/cli-es.mjs +97 -0
  158. package/src/i18n/es.mjs +227 -0
  159. package/src/i18n/index.mjs +48 -0
  160. package/src/image-attachments.mjs +263 -0
  161. package/src/model-controls.mjs +4 -4
  162. package/src/onboarding.mjs +91 -0
  163. package/src/openai-provider.mjs +24 -24
  164. package/src/peer-tools.mjs +4 -3
  165. package/src/permissions.mjs +53 -17
  166. package/src/provider-auth.mjs +30 -30
  167. package/src/provider-profiles.mjs +27 -27
  168. package/src/provider.mjs +30 -30
  169. package/src/remote-commands.mjs +11 -0
  170. package/src/remote-control.mjs +180 -0
  171. package/src/sessions.mjs +3 -3
  172. package/src/subagents.mjs +7 -3
  173. package/src/tool-packs.mjs +5 -2
  174. package/src/tools.mjs +92 -34
  175. package/src/tui/brand.mjs +2 -1
  176. package/src/tui/clipboard.mjs +5 -4
  177. package/src/tui/composer.mjs +77 -11
  178. package/src/tui/controller.mjs +111 -29
  179. package/src/tui/discovery.mjs +105 -100
  180. package/src/tui/markdown.mjs +10 -9
  181. package/src/tui/message-queue.mjs +27 -9
  182. package/src/tui/screen.mjs +209 -118
  183. package/src/vision.mjs +31 -3
  184. package/src/work-assertions.mjs +7 -7
  185. package/src/work-plan.mjs +189 -21
  186. package/src/work-recovery-proof.mjs +13 -1
  187. 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
+ }
@@ -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(`Effort inválido. Usa ${EFFORT_LEVELS.join(', ')}.`);
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('Metadatos del modelo exceden 1 MiB.'); text += decoder.decode(value, { stream: true }); }
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('El servidor no permitió listar modelos.'), { code: 'MODEL_DISCOVERY_UNAVAILABLE' });
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(`El modelo ${provider.model || '(sin modelo)'} no anuncia effort ${value}. Disponibles: ${state.effort.levels.join(', ')}.`);
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
+ }
@@ -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('URL OpenAI inválida; usa una base HTTP(S) sin credenciales ni query.');
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('Contenido de mensaje inválido.');
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('Campo de razonamiento OpenAI inválido.');
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('Referencia visual sin resolver; no se envió al proveedor.');
54
- else if (block.type === 'video' || block.type === 'media') throw Object.assign(new Error('El adaptador OpenAI Chat Completions no admite video; usa un perfil con transporte multimedia.'), { code: 'PROVIDER_CAPABILITY_UNAVAILABLE' });
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('Contenido OpenAI inválido.');
65
- if (!finish) throw new Error('Streaming OpenAI interrumpido; no se ejecutaron herramientas de esta respuesta.');
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('Respuesta OpenAI truncada (length): se agotó el presupuesto de generación o el espacio del servidor; no se ejecutaron herramientas. Usa /compact y --max-tokens, o divide el script en llamadas más pequeñas.');
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('Respuesta OpenAI incompleta (content_filter); no se ejecutaron herramientas.');
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('Respuesta tool_calls sin llamadas.');
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('Llamada de herramienta OpenAI inválida.');
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('JSON de argumentos OpenAI inválido; no se ejecutaron herramientas.'), { code: 'TOOL_ARGUMENTS_INVALID', usage_accounting: normalizeUsage('openai',usage),
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('El modelo no devolvió texto ni herramientas.'), { code: 'MODEL_EMPTY', usage_accounting: normalizeUsage('openai',usage), usage: { input_tokens: usage.prompt_tokens || 0, output_tokens: usage.completion_tokens || 0 } });
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 debe ser auto, on, off, low, medium o high.');
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 demasiado grande.'); } }
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('El servidor rechazó la petición porque excede su ventana de contexto. Usa /compact o reduce COMAL_CONTEXT_WINDOW.'); error.code = 'CONTEXT_LENGTH'; throw 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}. Revisa el endpoint, modelo y OPENAI_API_KEY si el servidor requiere autenticación.`);
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('El servidor no anuncia modelos; especifica COMAL_MODEL, OPENAI_MODEL o --model.');
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('El proveedor no anuncia visión; no se enviaron píxeles. Configura un modelo multimodal o --vision on sólo si tu endpoint lo soporta.');
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('El contexto excede 8 MiB; inicia una sesión nueva con /clear.');
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(`No se pudo conectar con ${this.baseURL}: ${error.cause?.code || error.message}`); throw 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('El servidor OpenAI devolvió un 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('El servidor OpenAI devolvió un error de streaming.');
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('Proveedor inválido; usa openai o anthropic.');
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),
@@ -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:'Read and analyze a workspace image/video DIRECTLY with this provider. No Bash or text Read prerequisite; Read rejecting binary is not an AnalyzeMedia permission denial. Each media call still requires house permission. Bytes stay out of conversation; records hash and advisory answer, not an objective verdict. Video requires video capability.',input_schema:{...object({prompt:{type:'string',minLength:1,maxLength:4000},file_path:{type:'string',minLength:1,maxLength:4096},mime_type:{type:'string',enum:['image/png','image/jpeg','image/webp','video/mp4','video/webm','video/quicktime']}}),required:['prompt','file_path','mime_type']}}]:[]),
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('Este perfil no ofrece la capacidad de medios solicitada.');
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.');