acuvo-code 0.2.0
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/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/vision.mjs
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ EYES — THE SENSE THE AGENT NEVER HAD ────────────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* This CLI could MAKE a picture and could not LOOK at one. Everything good that
|
|
5
|
+
* came out of this codebase this week came from someone looking: a watermark
|
|
6
|
+
* that rendered as "ΛACUVO", a banner printing a 100-character absolute path, a
|
|
7
|
+
* change list printed twice, six generated images judged good enough to ship.
|
|
8
|
+
* A model that cannot see can verify that code RAN. It can never verify that
|
|
9
|
+
* the result was GOOD — and for anything with a screen, those are different
|
|
10
|
+
* questions with different answers.
|
|
11
|
+
*
|
|
12
|
+
* ── ⚠️⚠️ THE CODER MODEL CANNOT SEE, AND THIS IS NOT A PASSTHROUGH ─────────
|
|
13
|
+
*
|
|
14
|
+
* The default model here is `deepseek-v4-flash`, which is text-only. Attaching
|
|
15
|
+
* an image to it does not fail loudly — it either drops the attachment or,
|
|
16
|
+
* far worse, answers the question anyway from the filename and the surrounding
|
|
17
|
+
* conversation, in fluent confident prose. A hallucinated "the layout looks
|
|
18
|
+
* clean and balanced" about a page nobody rendered is strictly worse than "I
|
|
19
|
+
* cannot see it": it ENDS the investigation with a false all-clear, which is
|
|
20
|
+
* exactly the bug `see_page` had to be fixed for once already.
|
|
21
|
+
*
|
|
22
|
+
* ⭐ So this makes its own call, to a model chosen because it can actually see.
|
|
23
|
+
* `qwen3.7-flash` is measured at **$0.042/M** — a 1024×1024 image is ~1,400
|
|
24
|
+
* tokens, so a look costs about **$0.00006**. Cheap enough that "check before
|
|
25
|
+
* you claim" stops being a budget decision.
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ AND IT ABSTAINS RATHER THAN GUESSES. Every failure path returns ok:false
|
|
28
|
+
* with a reason. The one behaviour this module must never have is producing a
|
|
29
|
+
* plausible sentence about an image it did not receive.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
33
|
+
|
|
34
|
+
import { resolveInWorkspace } from './workspace.mjs';
|
|
35
|
+
import { chargeEstimate } from './budget.mjs';
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_VISION_MODEL = 'qwen/qwen3.7-flash';
|
|
38
|
+
export const VISION_TIMEOUT_MS = 60_000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* ⭐ $0.042 per million — the measured price of `qwen3.7-flash`, and the same
|
|
42
|
+
* number this file's header quotes. It exists as a constant now because it has
|
|
43
|
+
* to price the calls the provider does not price for us; see `chargeEstimate`
|
|
44
|
+
* at the bottom of `readImage`.
|
|
45
|
+
*/
|
|
46
|
+
export const VISION_USD_PER_MILLION_TOKENS = 0.042;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* ⚠️ A CEILING IN BYTES, BECAUSE WE CANNOT RESIZE. There is no image library
|
|
50
|
+
* here (zero dependencies is the product), so an oversized screenshot cannot be
|
|
51
|
+
* scaled down on the way out — it can only be sent whole or refused. Refusing
|
|
52
|
+
* with the number and a way forward beats silently sending 40MB.
|
|
53
|
+
*/
|
|
54
|
+
export const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
55
|
+
export const PIXELS_PER_TOKEN = 750;
|
|
56
|
+
export const MAX_LOOKS_PER_PROCESS = 12;
|
|
57
|
+
|
|
58
|
+
let looksThisProcess = 0;
|
|
59
|
+
|
|
60
|
+
/** Test seam — the per-run cap must not leak between test files. */
|
|
61
|
+
export function resetVisionState() {
|
|
62
|
+
looksThisProcess = 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* ⚠️ SNIFFED FROM THE BYTES, NOT THE EXTENSION. A `.png` that is really a JPEG
|
|
67
|
+
* is common (every "save as" dialog produces some), and declaring the wrong
|
|
68
|
+
* mime type to the API is a 400 that reads like the image being rejected on its
|
|
69
|
+
* merits rather than on its label.
|
|
70
|
+
*/
|
|
71
|
+
export function sniffImage(buf) {
|
|
72
|
+
if (!buf || buf.length < 12) return null;
|
|
73
|
+
const b = buf;
|
|
74
|
+
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png';
|
|
75
|
+
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
|
|
76
|
+
if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) return 'image/gif';
|
|
77
|
+
if (b.slice(0, 4).toString('latin1') === 'RIFF' && b.slice(8, 12).toString('latin1') === 'WEBP') return 'image/webp';
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Width and height without decoding the image.
|
|
83
|
+
*
|
|
84
|
+
* ⭐ THIS EXISTS SO THE COST CAN BE STATED RATHER THAN GUESSED. Vision billing
|
|
85
|
+
* is per pixel, so "this look will cost about $0.00006" is only honest if the
|
|
86
|
+
* dimensions are read from the file. Returns null when the format hides them,
|
|
87
|
+
* and the caller then says "unknown" instead of inventing a number.
|
|
88
|
+
*/
|
|
89
|
+
export function imageSize(buf) {
|
|
90
|
+
const mime = sniffImage(buf);
|
|
91
|
+
try {
|
|
92
|
+
if (mime === 'image/png') {
|
|
93
|
+
// IHDR is always the first chunk: width at byte 16, height at 20.
|
|
94
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
95
|
+
}
|
|
96
|
+
if (mime === 'image/gif') {
|
|
97
|
+
return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
|
|
98
|
+
}
|
|
99
|
+
if (mime === 'image/jpeg') {
|
|
100
|
+
/**
|
|
101
|
+
* ⚠️ JPEG HIDES ITS SIZE BEHIND A SEGMENT WALK. There is no fixed offset:
|
|
102
|
+
* the dimensions live in whichever SOF marker appears, after any number of
|
|
103
|
+
* EXIF/comment segments of arbitrary length. Guessing an offset is how you
|
|
104
|
+
* report a 6000×4000 photo as 2×19029.
|
|
105
|
+
*/
|
|
106
|
+
let i = 2;
|
|
107
|
+
while (i + 9 < buf.length) {
|
|
108
|
+
if (buf[i] !== 0xff) { i += 1; continue; }
|
|
109
|
+
const marker = buf[i + 1];
|
|
110
|
+
// SOF0..SOF15, excluding the non-frame markers C4 (DHT), C8, CC.
|
|
111
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
112
|
+
return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
|
|
113
|
+
}
|
|
114
|
+
i += 2 + buf.readUInt16BE(i + 2);
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (mime === 'image/webp') {
|
|
119
|
+
// Only the simple VP8X form carries dimensions at a fixed offset.
|
|
120
|
+
if (buf.slice(12, 16).toString('latin1') === 'VP8X') {
|
|
121
|
+
return {
|
|
122
|
+
width: 1 + (buf[24] | (buf[25] << 8) | (buf[26] << 16)),
|
|
123
|
+
height: 1 + (buf[27] | (buf[28] << 8) | (buf[29] << 16)),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function estimateLookTokens(size) {
|
|
135
|
+
if (!size?.width || !size?.height) return null;
|
|
136
|
+
return Math.ceil((size.width * size.height) / PIXELS_PER_TOKEN);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const DEFAULT_QUESTION = [
|
|
140
|
+
'Describe this image precisely and factually.',
|
|
141
|
+
'State what is actually visible — layout, text (quote it exactly), colours, objects, and anything that looks wrong,',
|
|
142
|
+
'broken, misaligned, garbled or misspelled.',
|
|
143
|
+
'Do not speculate about anything you cannot see, and do not be polite about defects.',
|
|
144
|
+
].join(' ');
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Look at an image file in the workspace and answer a question about it.
|
|
148
|
+
*
|
|
149
|
+
* ⚠️ EVERY FAILURE ABSTAINS. `ok:false` with a reason, never a sentence that
|
|
150
|
+
* could be mistaken for an observation.
|
|
151
|
+
*/
|
|
152
|
+
export async function readImage(params = {}) {
|
|
153
|
+
const root = params.root ?? params.executor?.root;
|
|
154
|
+
const path = String(params.path ?? '').trim();
|
|
155
|
+
const question = String(params.question ?? '').trim() || DEFAULT_QUESTION;
|
|
156
|
+
const apiKey = params.apiKey ?? process.env.OPENROUTER_API_KEY;
|
|
157
|
+
const model = params.model ?? process.env.ACUVO_VISION_MODEL ?? DEFAULT_VISION_MODEL;
|
|
158
|
+
const fetchImpl = params.fetchImpl ?? fetch;
|
|
159
|
+
|
|
160
|
+
if (!path) return { ok: false, error: 'read_image needs a `path` to an image file in the workspace.' };
|
|
161
|
+
if (!root) return { ok: false, error: 'read_image has no workspace root to resolve the path against.' };
|
|
162
|
+
if (!apiKey) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
error: 'no OPENROUTER_API_KEY, so there is nothing that can look at this image. Do not describe it from its filename — say you could not see it.',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (looksThisProcess >= MAX_LOOKS_PER_PROCESS) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
error: `this run has already looked at ${MAX_LOOKS_PER_PROCESS} images, which is the cap. Looking again at the same thing will not tell you something new — act on what you saw.`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const resolved = resolveInWorkspace(root, path, 'read');
|
|
176
|
+
if (!resolved.ok) return { ok: false, error: resolved.reason };
|
|
177
|
+
|
|
178
|
+
let stat;
|
|
179
|
+
try {
|
|
180
|
+
stat = statSync(resolved.absolute);
|
|
181
|
+
} catch {
|
|
182
|
+
return { ok: false, error: `no such file: ${path}. Check the path with list_dir — an image you just generated is written under the name the tool reported, not the one you asked for.` };
|
|
183
|
+
}
|
|
184
|
+
if (stat.size > MAX_IMAGE_BYTES) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
error: `${path} is ${(stat.size / 1024 / 1024).toFixed(1)}MB, over the ${MAX_IMAGE_BYTES / 1024 / 1024}MB limit. This agent has no image library and cannot resize it — render or export a smaller version and look at that.`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const bytes = readFileSync(resolved.absolute);
|
|
192
|
+
const mime = sniffImage(bytes);
|
|
193
|
+
if (!mime) {
|
|
194
|
+
/**
|
|
195
|
+
* ⚠️ NAMED BY WHAT IT IS, NOT BY WHAT IT IS NOT. "unsupported format" sends
|
|
196
|
+
* the model round the same loop; saying the first bytes do not match any
|
|
197
|
+
* image format tells it the file is probably HTML, an error page, or empty.
|
|
198
|
+
*/
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
error: `${path} does not begin with PNG, JPEG, GIF or WEBP magic bytes, so it is not an image this can look at — it may be an error page, a text file, or a truncated download. Read the first bytes with read_file if you need to know which.`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const size = imageSize(bytes);
|
|
206
|
+
const tokens = estimateLookTokens(size);
|
|
207
|
+
looksThisProcess += 1;
|
|
208
|
+
|
|
209
|
+
let res;
|
|
210
|
+
try {
|
|
211
|
+
res = await fetchImpl('https://openrouter.ai/api/v1/chat/completions', {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
|
|
214
|
+
body: JSON.stringify({
|
|
215
|
+
model,
|
|
216
|
+
max_tokens: 900,
|
|
217
|
+
/**
|
|
218
|
+
* ⚠️ REASONING OFF. Measured on this account: a reasoning model charges
|
|
219
|
+
* its thinking budget against max_tokens and can spend ALL of it —
|
|
220
|
+
* 15,999 reasoning tokens and an empty reply. An empty reply from a
|
|
221
|
+
* vision model is indistinguishable from "I saw nothing".
|
|
222
|
+
*/
|
|
223
|
+
reasoning: { enabled: false },
|
|
224
|
+
messages: [{
|
|
225
|
+
role: 'user',
|
|
226
|
+
content: [
|
|
227
|
+
{ type: 'text', text: question },
|
|
228
|
+
{ type: 'image_url', image_url: { url: `data:${mime};base64,${bytes.toString('base64')}` } },
|
|
229
|
+
],
|
|
230
|
+
}],
|
|
231
|
+
}),
|
|
232
|
+
signal: AbortSignal.timeout(VISION_TIMEOUT_MS),
|
|
233
|
+
});
|
|
234
|
+
} catch (err) {
|
|
235
|
+
const why = err?.name === 'TimeoutError' ? `no answer in ${VISION_TIMEOUT_MS / 1000}s` : String(err?.message || err);
|
|
236
|
+
return { ok: false, error: `could not reach the vision model (${why}). Say you were unable to look at the image rather than describing it.` };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!res.ok) {
|
|
240
|
+
const snippet = await res.text().catch(() => '');
|
|
241
|
+
return { ok: false, error: `the vision model returned HTTP ${res.status}${snippet ? `: ${String(snippet).slice(0, 200)}` : ''}` };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let json;
|
|
245
|
+
try {
|
|
246
|
+
json = await res.json();
|
|
247
|
+
} catch {
|
|
248
|
+
return { ok: false, error: 'the vision model returned a body that was not JSON' };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const text = json?.choices?.[0]?.message?.content;
|
|
252
|
+
/**
|
|
253
|
+
* ⚠️⚠️ AN EMPTY REPLY IS A FAILURE, NOT AN EMPTY IMAGE. This is the same 200
|
|
254
|
+
* that meant failure in model.mjs, media.mjs and the image engine — four
|
|
255
|
+
* places now. `res.ok` answers a question about the HTTP conversation, never
|
|
256
|
+
* about whether the work happened.
|
|
257
|
+
*/
|
|
258
|
+
if (typeof text !== 'string' || text.trim() === '') {
|
|
259
|
+
return { ok: false, error: 'the vision model answered with nothing at all, so the image was not read. Do not describe it.' };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* ── ⚠️⚠️ WHEN THE PROVIDER DOES NOT PRICE IT, IT WAS FREE. IT IS NOT. ──────
|
|
264
|
+
*
|
|
265
|
+
* `tools.mjs:1481` charges this call back only when `costUsd` is a finite
|
|
266
|
+
* number greater than zero, and `costUsd` is `json.usage.cost ?? null` —
|
|
267
|
+
* OpenRouter does not always return one. So a paid vision call on a quiet
|
|
268
|
+
* reporting day cost the governor nothing: the same "unknown priced as free"
|
|
269
|
+
* failure `budget.mjs` calls the worst one available, in the one tool whose
|
|
270
|
+
* only other bound is a COUNT.
|
|
271
|
+
*
|
|
272
|
+
* ⭐ IT IS CHARGED AS AN ESTIMATE, IN THE ESTIMATE LEDGER, NOT SMUGGLED INTO
|
|
273
|
+
* `costUsd`. Filling `costUsd` with a guess would make the run report a
|
|
274
|
+
* reported figure it never received, and `report()` would stop saying the
|
|
275
|
+
* total is approximate — trading a number that is too low for one that lies
|
|
276
|
+
* about its own provenance. The ledger keeps the two kinds apart.
|
|
277
|
+
*/
|
|
278
|
+
const reported = json?.usage?.cost;
|
|
279
|
+
const reportedIsReal = typeof reported === 'number' && Number.isFinite(reported);
|
|
280
|
+
if (!reportedIsReal) {
|
|
281
|
+
const outTokens = Number(json?.usage?.completion_tokens) || 900;
|
|
282
|
+
const inTokens = Number(json?.usage?.prompt_tokens) || (tokens ?? 1400);
|
|
283
|
+
chargeEstimate({
|
|
284
|
+
kind: 'vision',
|
|
285
|
+
verb: 'read_image',
|
|
286
|
+
usd: ((inTokens + outTokens) / 1e6) * VISION_USD_PER_MILLION_TOKENS,
|
|
287
|
+
basis: `${inTokens + outTokens} tokens at the published ${VISION_USD_PER_MILLION_TOKENS}/M for ${model} — the provider reported no cost`,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
ok: true,
|
|
293
|
+
path,
|
|
294
|
+
text: text.trim(),
|
|
295
|
+
model,
|
|
296
|
+
mime,
|
|
297
|
+
...(size ? { width: size.width, height: size.height } : {}),
|
|
298
|
+
...(tokens ? { approxImageTokens: tokens } : {}),
|
|
299
|
+
costUsd: reportedIsReal ? reported : null,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function visionToolSchemas() {
|
|
304
|
+
return [
|
|
305
|
+
{
|
|
306
|
+
type: 'function',
|
|
307
|
+
function: {
|
|
308
|
+
name: 'read_image',
|
|
309
|
+
description: [
|
|
310
|
+
'LOOK at an image file in the workspace and get back a factual description.',
|
|
311
|
+
'Use it on anything visual you produced or were given: a screenshot, a generated image, a chart, a photo, a design mock.',
|
|
312
|
+
'You cannot see images yourself — this makes a separate call to a model that can, so it is the ONLY way',
|
|
313
|
+
'to know what is actually in a picture.',
|
|
314
|
+
'Ask a specific question when you have one ("is the heading text spelled correctly?", "does the button overlap the image?");',
|
|
315
|
+
'with no question it describes the image and names anything that looks broken.',
|
|
316
|
+
'If it cannot see the image it says so — in that case tell the user you could not look, and never describe the image from its filename.',
|
|
317
|
+
`At most ${MAX_LOOKS_PER_PROCESS} looks per run.`,
|
|
318
|
+
].join(' '),
|
|
319
|
+
parameters: {
|
|
320
|
+
type: 'object',
|
|
321
|
+
properties: {
|
|
322
|
+
path: { type: 'string', description: 'Path to the image inside the workspace, e.g. "shot.png" or "public/hero.jpg".' },
|
|
323
|
+
question: { type: 'string', description: 'What you want to know about it. Leave out for a general description with defects called out.' },
|
|
324
|
+
},
|
|
325
|
+
required: ['path'],
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
];
|
|
330
|
+
}
|