pantheon-opencode 1.5.2 → 1.6.0-beta.10

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 (146) hide show
  1. package/.pantheon/code-mode/README.md +4 -2
  2. package/.pantheon/code-mode/checkpoint_session.py +138 -102
  3. package/.pantheon/code-mode/compress-inline.py +102 -57
  4. package/.pantheon/code-mode/manifest.json +2 -8
  5. package/AGENTS.global.md +182 -0
  6. package/AGENTS.md +10 -891
  7. package/CHANGELOG.md +343 -0
  8. package/README.md +577 -79
  9. package/README.pt-BR.md +345 -60
  10. package/bin/pantheon-init.mjs +26 -3
  11. package/commands/pantheon-audit.md +7 -1
  12. package/commands/pantheon-consolidate.md +1 -1
  13. package/commands/pantheon-deepwork.md +14 -3
  14. package/commands/pantheon-focus.md +17 -0
  15. package/commands/pantheon-optimize.md +2 -2
  16. package/commands/pantheon-vision.md +61 -12
  17. package/package.json +30 -11
  18. package/plugin.json +2 -2
  19. package/pyproject.toml +11 -16
  20. package/scripts/build-agents-md.mjs +376 -58
  21. package/scripts/changelog-merge.mjs +161 -0
  22. package/scripts/check-plugin-v2-coverage.mjs +213 -0
  23. package/scripts/ci-validate-yaml.py +7 -5
  24. package/scripts/{code_mode_server.py → code_mode.py} +119 -27
  25. package/scripts/cost.mjs +244 -41
  26. package/scripts/doctor.mjs +236 -29
  27. package/scripts/install/agents-md.mjs +4 -4
  28. package/scripts/install/cli-ui.mjs +1 -1
  29. package/scripts/install/config-migration.mjs +319 -6
  30. package/scripts/install/health-check.mjs +26 -37
  31. package/scripts/install/interactive.mjs +7 -7
  32. package/scripts/install/migrate.mjs +1 -1
  33. package/scripts/install/opencode-version.mjs +246 -4
  34. package/scripts/install/opencode.mjs +627 -179
  35. package/scripts/install/plugin.mjs +43 -9
  36. package/scripts/install/shared.mjs +120 -12
  37. package/scripts/install/vision-prompt.mjs +161 -0
  38. package/scripts/install-mcp.mjs +28 -9
  39. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
  40. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  41. package/scripts/{memory_mcp_server.py → memory_mcp.py} +372 -198
  42. package/scripts/postinstall.mjs +0 -7
  43. package/scripts/probe-context-rehydrate.mjs +36 -13
  44. package/scripts/probe-pantheon-cost.mjs +71 -15
  45. package/scripts/prune.mjs +2 -2
  46. package/scripts/run-node-tests.mjs +189 -0
  47. package/scripts/sync-artifacts.mjs +26 -13
  48. package/scripts/sync-tui.mjs +24 -3
  49. package/scripts/test-opencode-v2-sandbox.sh +1913 -0
  50. package/scripts/validate-routing.mjs +43 -3
  51. package/scripts/validate-sandbox-install.mjs +138 -0
  52. package/scripts/versioning.mjs +13 -1
  53. package/scripts/vision-status.mjs +682 -0
  54. package/skills-lock.json +15 -16
  55. package/src/agents/aphrodite.md +20 -37
  56. package/src/agents/apollo.md +7 -3
  57. package/src/agents/athena.md +63 -35
  58. package/src/agents/demeter.md +8 -28
  59. package/src/agents/gaia.md +1 -1
  60. package/src/agents/hephaestus.md +10 -21
  61. package/src/agents/hermes.md +15 -38
  62. package/src/agents/iris.md +1 -1
  63. package/src/agents/mnemosyne.md +23 -60
  64. package/src/agents/nyx.md +1 -1
  65. package/src/agents/prometheus.md +16 -20
  66. package/src/agents/talos.md +10 -2
  67. package/src/agents/themis.md +5 -5
  68. package/src/agents/zeus.md +99 -130
  69. package/src/instructions/agent-return-format.instructions.md +18 -3
  70. package/src/instructions/backend-standards.instructions.md +2 -0
  71. package/src/instructions/frontend-standards.instructions.md +3 -1
  72. package/src/instructions/memory-protocol.instructions.md +11 -110
  73. package/src/instructions/zeus-anti-stall.instructions.md +14 -61
  74. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  75. package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
  76. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  77. package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
  78. package/src/mcp/{code_mode_server.py → code_mode.py} +119 -27
  79. package/src/mcp/eval_store.py +2 -2
  80. package/src/mcp/mcp_codemap_module.py +559 -129
  81. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
  82. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  83. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +373 -250
  84. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +396 -40
  85. package/src/mcp/requirements-mcp.txt +10 -6
  86. package/src/mcp/requirements-vision.txt +4 -2
  87. package/src/pantheon/cost-command.ts +399 -101
  88. package/src/pantheon/delegation-enforce.ts +10 -1
  89. package/src/pantheon/model-command.ts +18 -7
  90. package/src/pantheon/presets.d.mts +0 -8
  91. package/src/pantheon/presets.mjs +5 -32
  92. package/src/pantheon/todo-enforcer.ts +1 -1
  93. package/src/pantheon/v2-bounded-read.ts +640 -0
  94. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  95. package/src/pantheon/v2-execute-after.ts +186 -0
  96. package/src/pantheon/v2-tools.ts +429 -0
  97. package/src/pantheon/v2-unsupported.d.mts +11 -0
  98. package/src/pantheon/v2-unsupported.mjs +90 -0
  99. package/src/pantheon/v2-vision.ts +622 -0
  100. package/src/pantheon/vision-local.ts +531 -0
  101. package/src/pantheon/vision.ts +1161 -116
  102. package/src/plugin-v2.ts +1244 -288
  103. package/src/plugin.ts +12 -9
  104. package/src/plugins/tui/dist/server.d.ts +1 -2
  105. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  106. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  107. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  108. package/src/plugins/tui/dist/tui-v2.js +659 -0
  109. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  110. package/src/plugins/tui/dist/tui.d.ts +85 -82
  111. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  112. package/src/plugins/tui/dist/tui.js +72 -59
  113. package/src/plugins/tui/index-v2.ts +6 -0
  114. package/src/plugins/tui/package-lock.json +5504 -1341
  115. package/src/plugins/tui/package.json +25 -15
  116. package/src/plugins/tui/src/index-v2.tsx +734 -0
  117. package/src/plugins/tui/src/index.tsx +106 -85
  118. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  119. package/src/plugins/tui/tsconfig.build.json +5 -0
  120. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  121. package/src/plugins/tui/tsdown.config.ts +22 -2
  122. package/src/plugins/tui/tui-v2.ts +2 -0
  123. package/src/plugins/tui/tui.ts +3 -0
  124. package/src/routing.yml +11 -22
  125. package/src/skills/README.md +34 -39
  126. package/src/skills/agent-coordination/SKILL.md +7 -2
  127. package/src/skills/artifact-management/SKILL.md +12 -5
  128. package/src/skills/auto-continue/SKILL.md +54 -95
  129. package/src/skills/code-review-checklist/SKILL.md +5 -5
  130. package/src/skills/context-compression/SKILL.md +26 -879
  131. package/src/skills/session-goal/SKILL.md +14 -5
  132. package/src/skills/spec-driven-development/SKILL.md +3 -1
  133. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  134. package/.pantheon/code-mode/eval-llm-judge.py +0 -255
  135. package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
  136. package/.pantheon/code-mode/eval-run.py +0 -197
  137. package/.pantheon/code-mode/eval-static.py +0 -321
  138. package/.pantheon/code-mode/session-end-save.py +0 -186
  139. package/.pantheon/code-mode/session-end-save.sh +0 -18
  140. package/opencode.json +0 -58
  141. package/scripts/recover-zenodo-deposition.mjs +0 -146
  142. package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
  143. package/scripts/validate-zenodo-release.mjs +0 -154
  144. package/scripts/zenodo-artifact.mjs +0 -94
  145. package/scripts/zenodo-release-state.mjs +0 -53
  146. package/src/skills/orchestration-workflow/SKILL.md +0 -311
@@ -0,0 +1,531 @@
1
+ /**
2
+ * Deterministic, dependency-free local image extraction (Fase 4).
3
+ *
4
+ * This module is the LAST rung of the vision fallback chain:
5
+ * `native (provider-direct) → MCP tool → local (> none)`.
6
+ *
7
+ * It parses image bytes with the Node standard library only (`node:zlib` for
8
+ * PNG scanline inflate, `node:child_process` for the opt-in tesseract probe) —
9
+ * no network, no API key, no new dependency. The contract is deliberately
10
+ * conservative:
11
+ *
12
+ * - It reports only what the bytes actually contain: dimensions, format, file
13
+ * size, and (for non-interlaced 8-bit RGB/RGBA PNG) a quantized palette of
14
+ * dominant colors. Anything it cannot decode is reported as unavailable.
15
+ * - OCR runs ONLY when a `tesseract` binary is present on PATH; otherwise the
16
+ * block states OCR is unavailable. A missing binary is never an error.
17
+ * - It NEVER hallucinates content: no synthesized description is ever
18
+ * produced. Every export is total — malformed/truncated/unsupported input
19
+ * degrades to `null` or a metadata-only block, and never throws.
20
+ *
21
+ * Degradation matrix (documented, intentional):
22
+ * - PNG non-interlaced, bitDepth 8, colorType 2 (RGB) or 6 (RGBA): dims + colors.
23
+ * - PNG interlaced / other bit depths / paletted / grayscale: dims only (no colors).
24
+ * - JPEG: dims via SOF markers only (no colors — pixel decode is out of scope).
25
+ * - GIF / WebP: dims only via header fields (best effort).
26
+ * - Unknown signature: size only, format "unknown".
27
+ * - Remote http(s) URL: declared MIME from the path only (bytes are not fetched).
28
+ */
29
+
30
+ import { spawnSync } from 'node:child_process'
31
+ import { inflateSync } from 'node:zlib'
32
+
33
+ // ─── Types ─────────────────────────────────────────────────────────────────
34
+
35
+ export type LocalImageFormat = 'png' | 'jpeg' | 'gif' | 'webp' | 'unknown'
36
+
37
+ export type LocalDominantColor = { hex: string; ratio: number }
38
+
39
+ export type LocalImageInfo = {
40
+ format: LocalImageFormat
41
+ mime: string
42
+ width: number | null
43
+ height: number | null
44
+ /** Raw byte length, or null when the bytes are not locally available. */
45
+ bytes: number | null
46
+ /** Quantized dominant colors, or null when not computed for this format. */
47
+ colors: LocalDominantColor[] | null
48
+ /** Best-effort OCR text, or null when no OCR runner produced text. */
49
+ ocrText: string | null
50
+ }
51
+
52
+ /**
53
+ * OCR runner. `undefined` uses the default tesseract runner (which no-ops when
54
+ * the binary is absent); `null` disables OCR entirely.
55
+ */
56
+ export type LocalOcrRunner = (bytes: Uint8Array, mime: string) => Promise<string | null>
57
+
58
+ export type LocalVisionOptions = {
59
+ ocr?: LocalOcrRunner | null
60
+ maxColors?: number
61
+ }
62
+
63
+ // ─── Constants ─────────────────────────────────────────────────────────────
64
+
65
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
66
+ const DEFAULT_MAX_COLORS = 5
67
+ /** Bound the number of sampled pixels so a huge image cannot stall the turn. */
68
+ const MAX_SAMPLED_PIXELS = 2_000_000
69
+ const TESSERACT_PROBE_TIMEOUT_MS = 3_000
70
+ const TESSERACT_OCR_TIMEOUT_MS = 20_000
71
+ const TESSERACT_MAX_OUTPUT_BYTES = 1_000_000
72
+
73
+ const FORMAT_LABELS: Record<LocalImageFormat, string> = {
74
+ png: 'PNG',
75
+ jpeg: 'JPEG',
76
+ gif: 'GIF',
77
+ webp: 'WebP',
78
+ unknown: 'formato desconhecido',
79
+ }
80
+
81
+ const MIME_BY_FORMAT: Record<LocalImageFormat, string> = {
82
+ png: 'image/png',
83
+ jpeg: 'image/jpeg',
84
+ gif: 'image/gif',
85
+ webp: 'image/webp',
86
+ unknown: 'application/octet-stream',
87
+ }
88
+
89
+ // ─── Byte helpers ──────────────────────────────────────────────────────────
90
+
91
+ function asBuffer(input: Uint8Array): Buffer {
92
+ return Buffer.isBuffer(input) ? input : Buffer.from(input)
93
+ }
94
+
95
+ function startsWithAscii(buf: Buffer, text: string): boolean {
96
+ if (buf.length < text.length) return false
97
+ for (let i = 0; i < text.length; i += 1) {
98
+ if (buf[i] !== text.charCodeAt(i)) return false
99
+ }
100
+ return true
101
+ }
102
+
103
+ function hex2(value: number): string {
104
+ return value.toString(16).padStart(2, '0')
105
+ }
106
+
107
+ /** Identify a supported format from its magic signature. Never throws. */
108
+ export function detectImageFormat(input: Uint8Array): LocalImageFormat {
109
+ const buf = asBuffer(input)
110
+ if (
111
+ buf.length >= PNG_SIGNATURE.length &&
112
+ startsWithAscii(buf, PNG_SIGNATURE.toString('latin1'))
113
+ ) {
114
+ return 'png'
115
+ }
116
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'jpeg'
117
+ if (startsWithAscii(buf, 'GIF87a') || startsWithAscii(buf, 'GIF89a')) return 'gif'
118
+ if (buf.length >= 12 && startsWithAscii(buf, 'RIFF') && buf.toString('ascii', 8, 12) === 'WEBP') {
119
+ return 'webp'
120
+ }
121
+ return 'unknown'
122
+ }
123
+
124
+ // ─── PNG header + dominant colors ──────────────────────────────────────────
125
+
126
+ type PngHeader = {
127
+ width: number
128
+ height: number
129
+ bitDepth: number
130
+ colorType: number
131
+ interlace: number
132
+ }
133
+
134
+ /** Parse the IHDR chunk. Returns null for a truncated or non-PNG buffer. */
135
+ function parsePngHeader(buf: Buffer): PngHeader | null {
136
+ if (buf.length < 33) return null
137
+ if (!startsWithAscii(buf, PNG_SIGNATURE.toString('latin1'))) return null
138
+ if (buf.toString('ascii', 12, 16) !== 'IHDR') return null
139
+ const width = buf.readUInt32BE(16)
140
+ const height = buf.readUInt32BE(20)
141
+ if (width <= 0 || height <= 0) return null
142
+ return {
143
+ width,
144
+ height,
145
+ bitDepth: buf[24] ?? 0,
146
+ colorType: buf[25] ?? 0,
147
+ interlace: buf[28] ?? 0,
148
+ }
149
+ }
150
+
151
+ function paeth(a: number, b: number, c: number): number {
152
+ const p = a + b - c
153
+ const pa = Math.abs(p - a)
154
+ const pb = Math.abs(p - b)
155
+ const pc = Math.abs(p - c)
156
+ if (pa <= pb && pa <= pc) return a
157
+ if (pb <= pc) return b
158
+ return c
159
+ }
160
+
161
+ /** Reverse one PNG scanline filter (types 0-4) in place. */
162
+ function unfilterRow(filter: number, row: Buffer, previous: Buffer, channels: number): void {
163
+ const length = row.length
164
+ switch (filter) {
165
+ case 1:
166
+ for (let i = channels; i < length; i += 1) {
167
+ row[i] = ((row[i] ?? 0) + (row[i - channels] ?? 0)) & 0xff
168
+ }
169
+ return
170
+ case 2:
171
+ for (let i = 0; i < length; i += 1) {
172
+ row[i] = ((row[i] ?? 0) + (previous[i] ?? 0)) & 0xff
173
+ }
174
+ return
175
+ case 3:
176
+ for (let i = 0; i < length; i += 1) {
177
+ const left = i >= channels ? (row[i - channels] ?? 0) : 0
178
+ row[i] = ((row[i] ?? 0) + ((left + (previous[i] ?? 0)) >> 1)) & 0xff
179
+ }
180
+ return
181
+ case 4:
182
+ for (let i = 0; i < length; i += 1) {
183
+ const left = i >= channels ? (row[i - channels] ?? 0) : 0
184
+ const up = previous[i] ?? 0
185
+ const upLeft = i >= channels ? (previous[i - channels] ?? 0) : 0
186
+ row[i] = ((row[i] ?? 0) + paeth(left, up, upLeft)) & 0xff
187
+ }
188
+ return
189
+ default:
190
+ // Filter 0 (None) and any unknown filter leave the row untouched.
191
+ return
192
+ }
193
+ }
194
+
195
+ function bucketToHex(key: number): string {
196
+ const r = ((key >> 8) & 0xf) * 17
197
+ const g = ((key >> 4) & 0xf) * 17
198
+ const b = (key & 0xf) * 17
199
+ return `#${hex2(r)}${hex2(g)}${hex2(b)}`
200
+ }
201
+
202
+ /**
203
+ * Quantize a non-interlaced 8-bit RGB/RGBA PNG into its top-N dominant colors.
204
+ * Returns null when the encoding is unsupported, the IDAT cannot be inflated,
205
+ * or the scanlines are malformed — callers then simply omit colors.
206
+ */
207
+ function extractPngColors(
208
+ buf: Buffer,
209
+ header: PngHeader,
210
+ maxColors: number,
211
+ ): LocalDominantColor[] | null {
212
+ if (header.interlace !== 0 || header.bitDepth !== 8) return null
213
+ const channels = header.colorType === 2 ? 3 : header.colorType === 6 ? 4 : 0
214
+ if (channels === 0) return null
215
+ const { width, height } = header
216
+
217
+ const idatChunks: Buffer[] = []
218
+ let offset = 8
219
+ while (offset + 8 <= buf.length) {
220
+ const length = buf.readUInt32BE(offset)
221
+ const type = buf.toString('ascii', offset + 4, offset + 8)
222
+ const dataStart = offset + 8
223
+ const dataEnd = dataStart + length
224
+ if (length < 0 || dataEnd + 4 > buf.length) break
225
+ if (type === 'IDAT') idatChunks.push(buf.subarray(dataStart, dataEnd))
226
+ if (type === 'IEND') break
227
+ offset = dataEnd + 4
228
+ }
229
+ if (idatChunks.length === 0) return null
230
+
231
+ let raw: Buffer
232
+ try {
233
+ raw = inflateSync(Buffer.concat(idatChunks))
234
+ } catch {
235
+ return null
236
+ }
237
+
238
+ const stride = width * channels
239
+ const previous = Buffer.alloc(stride)
240
+ const current = Buffer.alloc(stride)
241
+ const buckets = new Map<number, number>()
242
+ const totalPixels = width * height
243
+ const step = totalPixels > MAX_SAMPLED_PIXELS ? Math.ceil(totalPixels / MAX_SAMPLED_PIXELS) : 1
244
+ let pos = 0
245
+ let total = 0
246
+
247
+ for (let y = 0; y < height; y += 1) {
248
+ const filter = raw[pos] ?? 0
249
+ pos += 1
250
+ if (pos + stride > raw.length) return null
251
+ raw.copy(current, 0, pos, pos + stride)
252
+ pos += stride
253
+ unfilterRow(filter, current, previous, channels)
254
+
255
+ for (let x = 0; x < width; x += 1) {
256
+ if ((y * width + x) % step !== 0) continue
257
+ const base = x * channels
258
+ const r = current[base] ?? 0
259
+ const g = current[base + 1] ?? 0
260
+ const b = current[base + 2] ?? 0
261
+ if (channels === 4 && (current[base + 3] ?? 0) === 0) continue
262
+ const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4)
263
+ buckets.set(key, (buckets.get(key) ?? 0) + 1)
264
+ total += 1
265
+ }
266
+ current.copy(previous)
267
+ }
268
+
269
+ if (total === 0) return []
270
+ return [...buckets.entries()]
271
+ .sort((a, b) => b[1] - a[1] || a[0] - b[0])
272
+ .slice(0, maxColors)
273
+ .map(([key, count]) => ({ hex: bucketToHex(key), ratio: count / total }))
274
+ }
275
+
276
+ function safePngColors(
277
+ buf: Buffer,
278
+ header: PngHeader,
279
+ maxColors: number,
280
+ ): LocalDominantColor[] | null {
281
+ try {
282
+ return extractPngColors(buf, header, maxColors)
283
+ } catch {
284
+ return null
285
+ }
286
+ }
287
+
288
+ // ─── JPEG / GIF / WebP dimensions ──────────────────────────────────────────
289
+
290
+ /** Read JPEG dimensions from the first start-of-frame marker. Never throws. */
291
+ function parseJpegDimensions(buf: Buffer): { width: number; height: number } | null {
292
+ if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null
293
+ const sofMarkers = new Set<number>()
294
+ for (let marker = 0xc0; marker <= 0xcf; marker += 1) {
295
+ if (marker === 0xc4 || marker === 0xc8 || marker === 0xcc) continue
296
+ sofMarkers.add(marker)
297
+ }
298
+ let index = 2
299
+ while (index + 9 < buf.length) {
300
+ if (buf[index] !== 0xff) {
301
+ index += 1
302
+ continue
303
+ }
304
+ while (index < buf.length && buf[index] === 0xff) index += 1
305
+ if (index >= buf.length) break
306
+ const marker = buf[index] ?? 0
307
+ index += 1
308
+ if (marker === 0xd8 || marker === 0xd9) continue
309
+ if (index + 2 > buf.length) break
310
+ const segmentLength = buf.readUInt16BE(index)
311
+ if (segmentLength < 2 || index + segmentLength > buf.length) break
312
+ if (sofMarkers.has(marker) && segmentLength >= 7) {
313
+ return { height: buf.readUInt16BE(index + 3), width: buf.readUInt16BE(index + 5) }
314
+ }
315
+ index += segmentLength
316
+ }
317
+ return null
318
+ }
319
+
320
+ function readDimensions(
321
+ buf: Buffer,
322
+ format: LocalImageFormat,
323
+ ): { width: number; height: number } | null {
324
+ if (format === 'png') {
325
+ const header = parsePngHeader(buf)
326
+ return header ? { width: header.width, height: header.height } : null
327
+ }
328
+ if (format === 'jpeg') return parseJpegDimensions(buf)
329
+ if (format === 'gif' && buf.length >= 10) {
330
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) }
331
+ }
332
+ if (format === 'webp' && buf.length >= 30 && buf.toString('ascii', 12, 16) === 'VP8X') {
333
+ return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) }
334
+ }
335
+ return null
336
+ }
337
+
338
+ // ─── OCR (opt-in, tesseract only when present) ─────────────────────────────
339
+
340
+ let tesseractProbe: boolean | null = null
341
+
342
+ /**
343
+ * True when a `tesseract` binary answers `--version` on PATH. The probe result
344
+ * is cached for the process; a missing binary is a normal (false) outcome.
345
+ */
346
+ export function tesseractAvailable(): boolean {
347
+ if (tesseractProbe !== null) return tesseractProbe
348
+ try {
349
+ const result = spawnSync('tesseract', ['--version'], {
350
+ timeout: TESSERACT_PROBE_TIMEOUT_MS,
351
+ encoding: 'utf8',
352
+ })
353
+ tesseractProbe = result.status === 0
354
+ } catch {
355
+ tesseractProbe = false
356
+ }
357
+ return tesseractProbe
358
+ }
359
+
360
+ /**
361
+ * Best-effort OCR through the tesseract CLI (stdin → stdout). Returns null when
362
+ * the binary is absent, the run fails, times out, or yields only whitespace.
363
+ * Never throws.
364
+ */
365
+ export async function runTesseractOcr(bytes: Uint8Array, _mime: string): Promise<string | null> {
366
+ if (!tesseractAvailable()) return null
367
+ try {
368
+ const result = spawnSync('tesseract', ['stdin', 'stdout'], {
369
+ input: Buffer.from(bytes),
370
+ maxBuffer: TESSERACT_MAX_OUTPUT_BYTES,
371
+ timeout: TESSERACT_OCR_TIMEOUT_MS,
372
+ encoding: 'utf8',
373
+ })
374
+ if (result.status !== 0 || typeof result.stdout !== 'string') return null
375
+ const text = result.stdout.trim()
376
+ return text.length > 0 ? text : null
377
+ } catch {
378
+ return null
379
+ }
380
+ }
381
+
382
+ async function resolveOcr(
383
+ bytes: Buffer,
384
+ mime: string,
385
+ opts: LocalVisionOptions,
386
+ ): Promise<string | null> {
387
+ if (opts.ocr === null) return null
388
+ const runner = opts.ocr ?? runTesseractOcr
389
+ try {
390
+ const text = await runner(bytes, mime)
391
+ return text && text.trim().length > 0 ? text.trim() : null
392
+ } catch {
393
+ return null
394
+ }
395
+ }
396
+
397
+ // ─── Extraction ────────────────────────────────────────────────────────────
398
+
399
+ function parseDataUri(source: string): { mime: string; bytes: Buffer } | null {
400
+ const match = /^data:([^;,]*);base64,([\s\S]+)$/.exec(source)
401
+ if (!match) return null
402
+ try {
403
+ return { mime: (match[1] ?? '').toLowerCase(), bytes: Buffer.from(match[2] ?? '', 'base64') }
404
+ } catch {
405
+ return null
406
+ }
407
+ }
408
+
409
+ function formatFromMime(mime: string | null): LocalImageFormat {
410
+ if (!mime) return 'unknown'
411
+ if (mime === 'image/png') return 'png'
412
+ if (mime === 'image/jpeg' || mime === 'image/jpg') return 'jpeg'
413
+ if (mime === 'image/gif') return 'gif'
414
+ if (mime === 'image/webp') return 'webp'
415
+ return 'unknown'
416
+ }
417
+
418
+ function mimeFromUrl(source: string): string | null {
419
+ const match = /\.([a-z0-9]+)(?:[?#].*)?$/i.exec(source)
420
+ if (!match) return null
421
+ const extension = (match[1] ?? '').toLowerCase()
422
+ if (extension === 'png') return 'image/png'
423
+ if (extension === 'jpg' || extension === 'jpeg') return 'image/jpeg'
424
+ if (extension === 'gif') return 'image/gif'
425
+ if (extension === 'webp') return 'image/webp'
426
+ return null
427
+ }
428
+
429
+ /**
430
+ * Extract deterministic metadata (and best-effort colors/OCR) from one source.
431
+ * Accepts a `data:` URI, a remote http(s) URL, or any other string treated as
432
+ * a local path. Returns null only when the source is unusable; never throws.
433
+ */
434
+ export async function extractImageInfo(
435
+ source: string,
436
+ opts: LocalVisionOptions = {},
437
+ ): Promise<LocalImageInfo | null> {
438
+ const maxColors = opts.maxColors ?? DEFAULT_MAX_COLORS
439
+ try {
440
+ const dataUri = parseDataUri(source)
441
+ if (dataUri) {
442
+ const bytes = dataUri.bytes
443
+ const format = detectImageFormat(bytes)
444
+ const mime = dataUri.mime || MIME_BY_FORMAT[format]
445
+ const dimensions = readDimensions(bytes, format)
446
+ const header = format === 'png' ? parsePngHeader(bytes) : null
447
+ return {
448
+ format,
449
+ mime,
450
+ width: dimensions?.width ?? null,
451
+ height: dimensions?.height ?? null,
452
+ bytes: bytes.length,
453
+ colors: header ? safePngColors(bytes, header, maxColors) : null,
454
+ ocrText: await resolveOcr(bytes, mime, opts),
455
+ }
456
+ }
457
+
458
+ if (source.startsWith('http://') || source.startsWith('https://')) {
459
+ const mime = mimeFromUrl(source)
460
+ return {
461
+ format: formatFromMime(mime),
462
+ mime: mime ?? 'application/octet-stream',
463
+ width: null,
464
+ height: null,
465
+ bytes: null,
466
+ colors: null,
467
+ ocrText: null,
468
+ }
469
+ }
470
+
471
+ return null
472
+ } catch {
473
+ return null
474
+ }
475
+ }
476
+
477
+ // ─── Formatting ────────────────────────────────────────────────────────────
478
+
479
+ function formatBytes(bytes: number): string {
480
+ if (bytes < 1024) return `${bytes} B`
481
+ const kb = bytes / 1024
482
+ if (kb < 1024) return `${Math.round(kb)} KB`
483
+ return `${(kb / 1024).toFixed(1)} MB`
484
+ }
485
+
486
+ /** Render one extraction as a short, structured block for prompt injection. */
487
+ export function formatLocalImageBlock(info: LocalImageInfo, index?: number): string {
488
+ const prefix = index !== undefined && index > 0 ? `Imagem local ${index}` : 'Imagem local'
489
+ const dimensions =
490
+ info.width !== null && info.height !== null
491
+ ? `${info.width}x${info.height}`
492
+ : 'dimensões desconhecidas'
493
+ const size = info.bytes !== null ? formatBytes(info.bytes) : 'tamanho desconhecido'
494
+ const lines = [`${prefix}: ${dimensions} ${FORMAT_LABELS[info.format]}, ${size}.`]
495
+ if (info.colors && info.colors.length > 0) {
496
+ const palette = info.colors
497
+ .map((color) => `${color.hex} (${Math.round(color.ratio * 100)}%)`)
498
+ .join(', ')
499
+ lines.push(`Cores dominantes: ${palette}`)
500
+ } else {
501
+ lines.push('Cores dominantes: indisponível para este formato/codificação.')
502
+ }
503
+ if (info.ocrText) {
504
+ lines.push(`Texto OCR: ${info.ocrText.replace(/\s+/g, ' ').trim()}`)
505
+ } else {
506
+ lines.push('Texto OCR: indisponível (tesseract não instalado).')
507
+ }
508
+ return lines.join('\n')
509
+ }
510
+
511
+ /**
512
+ * Build the local extraction block for one or more image sources (data URIs or
513
+ * remote URLs produced by `resolveImageSourcePayload`). Returns null when no
514
+ * source yields any info, so callers can fall back to an explicit unavailable
515
+ * message instead of injecting an empty block.
516
+ */
517
+ export async function buildLocalVisionBlock(
518
+ sources: readonly string[],
519
+ opts: LocalVisionOptions = {},
520
+ ): Promise<string | null> {
521
+ if (sources.length === 0) return null
522
+ const blocks: string[] = []
523
+ for (let i = 0; i < sources.length; i += 1) {
524
+ const source = sources[i]
525
+ if (source === undefined) continue
526
+ const info = await extractImageInfo(source, opts)
527
+ if (!info) continue
528
+ blocks.push(formatLocalImageBlock(info, sources.length > 1 ? i + 1 : undefined))
529
+ }
530
+ return blocks.length > 0 ? blocks.join('\n\n') : null
531
+ }