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.
- package/.pantheon/code-mode/README.md +4 -2
- package/.pantheon/code-mode/checkpoint_session.py +138 -102
- package/.pantheon/code-mode/compress-inline.py +102 -57
- package/.pantheon/code-mode/manifest.json +2 -8
- package/AGENTS.global.md +182 -0
- package/AGENTS.md +10 -891
- package/CHANGELOG.md +343 -0
- package/README.md +577 -79
- package/README.pt-BR.md +345 -60
- package/bin/pantheon-init.mjs +26 -3
- package/commands/pantheon-audit.md +7 -1
- package/commands/pantheon-consolidate.md +1 -1
- package/commands/pantheon-deepwork.md +14 -3
- package/commands/pantheon-focus.md +17 -0
- package/commands/pantheon-optimize.md +2 -2
- package/commands/pantheon-vision.md +61 -12
- package/package.json +30 -11
- package/plugin.json +2 -2
- package/pyproject.toml +11 -16
- package/scripts/build-agents-md.mjs +376 -58
- package/scripts/changelog-merge.mjs +161 -0
- package/scripts/check-plugin-v2-coverage.mjs +213 -0
- package/scripts/ci-validate-yaml.py +7 -5
- package/scripts/{code_mode_server.py → code_mode.py} +119 -27
- package/scripts/cost.mjs +244 -41
- package/scripts/doctor.mjs +236 -29
- package/scripts/install/agents-md.mjs +4 -4
- package/scripts/install/cli-ui.mjs +1 -1
- package/scripts/install/config-migration.mjs +319 -6
- package/scripts/install/health-check.mjs +26 -37
- package/scripts/install/interactive.mjs +7 -7
- package/scripts/install/migrate.mjs +1 -1
- package/scripts/install/opencode-version.mjs +246 -4
- package/scripts/install/opencode.mjs +627 -179
- package/scripts/install/plugin.mjs +43 -9
- package/scripts/install/shared.mjs +120 -12
- package/scripts/install/vision-prompt.mjs +161 -0
- package/scripts/install-mcp.mjs +28 -9
- package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
- package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
- package/scripts/{memory_mcp_server.py → memory_mcp.py} +372 -198
- package/scripts/postinstall.mjs +0 -7
- package/scripts/probe-context-rehydrate.mjs +36 -13
- package/scripts/probe-pantheon-cost.mjs +71 -15
- package/scripts/prune.mjs +2 -2
- package/scripts/run-node-tests.mjs +189 -0
- package/scripts/sync-artifacts.mjs +26 -13
- package/scripts/sync-tui.mjs +24 -3
- package/scripts/test-opencode-v2-sandbox.sh +1913 -0
- package/scripts/validate-routing.mjs +43 -3
- package/scripts/validate-sandbox-install.mjs +138 -0
- package/scripts/versioning.mjs +13 -1
- package/scripts/vision-status.mjs +682 -0
- package/skills-lock.json +15 -16
- package/src/agents/aphrodite.md +20 -37
- package/src/agents/apollo.md +7 -3
- package/src/agents/athena.md +63 -35
- package/src/agents/demeter.md +8 -28
- package/src/agents/gaia.md +1 -1
- package/src/agents/hephaestus.md +10 -21
- package/src/agents/hermes.md +15 -38
- package/src/agents/iris.md +1 -1
- package/src/agents/mnemosyne.md +23 -60
- package/src/agents/nyx.md +1 -1
- package/src/agents/prometheus.md +16 -20
- package/src/agents/talos.md +10 -2
- package/src/agents/themis.md +5 -5
- package/src/agents/zeus.md +99 -130
- package/src/instructions/agent-return-format.instructions.md +18 -3
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +11 -110
- package/src/instructions/zeus-anti-stall.instructions.md +14 -61
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
- package/src/instructions/zeus-memory-operations.instructions.md +26 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
- package/src/mcp/{code_mode_server.py → code_mode.py} +119 -27
- package/src/mcp/eval_store.py +2 -2
- package/src/mcp/mcp_codemap_module.py +559 -129
- package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
- package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
- package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +373 -250
- package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +396 -40
- package/src/mcp/requirements-mcp.txt +10 -6
- package/src/mcp/requirements-vision.txt +4 -2
- package/src/pantheon/cost-command.ts +399 -101
- package/src/pantheon/delegation-enforce.ts +10 -1
- package/src/pantheon/model-command.ts +18 -7
- package/src/pantheon/presets.d.mts +0 -8
- package/src/pantheon/presets.mjs +5 -32
- package/src/pantheon/todo-enforcer.ts +1 -1
- package/src/pantheon/v2-bounded-read.ts +640 -0
- package/src/pantheon/v2-delegation-enforce.ts +214 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-tools.ts +429 -0
- package/src/pantheon/v2-unsupported.d.mts +11 -0
- package/src/pantheon/v2-unsupported.mjs +90 -0
- package/src/pantheon/v2-vision.ts +622 -0
- package/src/pantheon/vision-local.ts +531 -0
- package/src/pantheon/vision.ts +1161 -116
- package/src/plugin-v2.ts +1244 -288
- package/src/plugin.ts +12 -9
- package/src/plugins/tui/dist/server.d.ts +1 -2
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
- package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui-v2.js +659 -0
- package/src/plugins/tui/dist/tui-v2.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +85 -82
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +72 -59
- package/src/plugins/tui/index-v2.ts +6 -0
- package/src/plugins/tui/package-lock.json +5504 -1341
- package/src/plugins/tui/package.json +25 -15
- package/src/plugins/tui/src/index-v2.tsx +734 -0
- package/src/plugins/tui/src/index.tsx +106 -85
- package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
- package/src/plugins/tui/tsconfig.build.json +5 -0
- package/src/plugins/tui/tsconfig.typecheck.json +5 -0
- package/src/plugins/tui/tsdown.config.ts +22 -2
- package/src/plugins/tui/tui-v2.ts +2 -0
- package/src/plugins/tui/tui.ts +3 -0
- package/src/routing.yml +11 -22
- package/src/skills/README.md +34 -39
- package/src/skills/agent-coordination/SKILL.md +7 -2
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +54 -95
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/context-compression/SKILL.md +26 -879
- package/src/skills/session-goal/SKILL.md +14 -5
- package/src/skills/spec-driven-development/SKILL.md +3 -1
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- package/.pantheon/code-mode/eval-llm-judge.py +0 -255
- package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
- package/.pantheon/code-mode/eval-run.py +0 -197
- package/.pantheon/code-mode/eval-static.py +0 -321
- package/.pantheon/code-mode/session-end-save.py +0 -186
- package/.pantheon/code-mode/session-end-save.sh +0 -18
- package/opencode.json +0 -58
- package/scripts/recover-zenodo-deposition.mjs +0 -146
- package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
- package/scripts/validate-zenodo-release.mjs +0 -154
- package/scripts/zenodo-artifact.mjs +0 -94
- package/scripts/zenodo-release-state.mjs +0 -53
- 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
|
+
}
|