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.
Files changed (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
package/lib/media.mjs ADDED
@@ -0,0 +1,1283 @@
1
+ /**
2
+ * ── ⭐⭐ THE NATIVE HALF — WHAT MAKES THIS NOT ANOTHER TERMINAL CODER ─────────
3
+ *
4
+ * Roman: *"whether it's builder or Acuvo Code, we need to provide an exceptional
5
+ * product... it also natively has image/video gen and other stuff."*
6
+ *
7
+ * The strategic argument for this file, stated honestly because it has a real
8
+ * counter-argument: a critique we took seriously says media in a coding CLI is
9
+ * feature bloat, and for a backend engineer refactoring a service **it is**.
10
+ *
11
+ * ⭐ THE DISCRIMINATOR IS NOT "IS MEDIA USEFUL" BUT "DOES THE THING YOU ARE
12
+ * BUILDING NEED ASSETS." A landing page, a product site, an app with a hero and
13
+ * empty states needs images every single time — and today that is a context
14
+ * switch to another tool, another tab, another subscription, a download and a
15
+ * file move. Claude Code cannot put an image on a page. Measured, not asserted.
16
+ *
17
+ * ── ⭐ AND THE ONE THAT IS ACTUALLY UNIQUE: `see_page` ───────────────────────
18
+ * Every other terminal agent is BLIND. It writes a page and has no idea what it
19
+ * looks like — no other coding CLI can render its own output and read the
20
+ * result back. We already own that engine (`acuvo-render-audit` on Modal,
21
+ * verified live 2026-08-10), and it is the difference between "I wrote some
22
+ * HTML" and "I looked at it and the footer heading is invisible".
23
+ *
24
+ * ⚠️ IF ONLY ONE THING IN THIS FILE SURVIVES, IT SHOULD BE THAT ONE. Image
25
+ * generation is a convenience others could copy in a week. Sight is a loop.
26
+ *
27
+ * ── ⚠️⚠️ BUT DO NOT SAY "NOBODY ELSE CAN SEE". IT IS FALSE, AND IT WAS IN THE
28
+ * README (corrected 2026-08-10) ──────────────────────────────────────────────
29
+ * Screenshot tooling is not scarce. Playwright MCP and Chrome DevTools MCP are
30
+ * free, first-party and one install away; some agents ship a browser natively.
31
+ * A pitch built on "we can see and they cannot" dies the first time a customer
32
+ * types `claude mcp add playwright`, and it makes everything else we claim
33
+ * suspect.
34
+ *
35
+ * ⭐ THE DEFENSIBLE CLAIM IS THE RETURN VALUE, NOT THE BROWSER. They hand the
36
+ * model a PNG — 15k-25k tokens — and ask it to interpret its own screenshot,
37
+ * which is the thing models are worst at. This measures in code and returns
38
+ * ~200 tokens of ordered, specific defects, and `findingsFrom` ABSTAINS rather
39
+ * than guessing. That is a software edge, it is real, and it is reproducible by
40
+ * a competent developer in a weekend — so it buys a head start, never a moat.
41
+ * Price and pitch accordingly.
42
+ *
43
+ * ── ⚠️ THE DEPENDENCY RULE THIS FILE MUST NOT BREAK ─────────────────────────
44
+ * `acuvo-code/` is zero-dependency and must never import from `console/`. These
45
+ * are plain HTTPS calls to endpoints whose URLs come from the environment. The
46
+ * moment this file imports upward, the CLI stops being installable on its own —
47
+ * which is the whole point of it being a separate package.
48
+ *
49
+ * ── ⚠️ AND THE HONESTY RULE, WHICH THIS REPO KEEPS RELEARNING ───────────────
50
+ * A tool is OFFERED only when its endpoint is configured. `console/lib/
51
+ * capabilities.ts` decides "live" by checking that an env var is non-empty, and
52
+ * on 2026-08-10 that had the product advertising image generation while every
53
+ * image path was dead. Presence answers "is it configured"; only a request
54
+ * answers "does it work". So: absent config → the tool is never mentioned;
55
+ * present config → the tool exists and its FAILURES ARE REPORTED VERBATIM
56
+ * rather than smoothed into "something went wrong".
57
+ */
58
+
59
+ // ⚠️ readFileSync IMPORTED, NOT require()'d. This is an .mjs module — my first
60
+ // draft called require('node:fs') inline, which throws "require is not defined"
61
+ // in ESM. It would have failed at RUNTIME, in the one branch a unit test with a
62
+ // stubbed fetch never reaches.
63
+ import { writeFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
64
+ import { dirname, extname } from 'node:path';
65
+ import { resolveInWorkspace } from './workspace.mjs';
66
+ // ⭐ Engine choice for `speak`. Ids and names only — no prices live in this
67
+ // package; see creative-engines.mjs's header for why that is load-bearing.
68
+ import { checkEngine, runEngineFor } from './creative-engines.mjs';
69
+
70
+ /**
71
+ * ⚠️ A CAP AND AN ALLOWED SET FOR `transcribe`, because its sibling `speak` had
72
+ * both and this had neither (ENTERPRISE §3.6). 25MB is roughly a 25-minute
73
+ * recording — past that the right move is to split the file, not to pay for a
74
+ * 200MB upload the model asked for by mistake.
75
+ */
76
+ export const MAX_TRANSCRIBE_BYTES = 25 * 1024 * 1024;
77
+ export const AUDIO_EXTENSIONS = new Set(['.wav', '.mp3', '.m4a', '.aac', '.ogg', '.opus', '.flac', '.webm', '.mp4', '.mov']);
78
+ import { throughBreaker, deadReason, skipMessage } from './breaker.mjs';
79
+ /**
80
+ * ⭐ THE ONLY IMPORT THIS FILE NEEDED TO STOP BEING FREE. Every media verb goes
81
+ * through `postJson` below, so one charge at that choke point meters all six —
82
+ * see_page, speak, transcribe, make_document, read_document, read_table. Wiring
83
+ * them one by one is how five get done and the sixth is found months later.
84
+ */
85
+ import { chargeGpu } from './budget.mjs';
86
+
87
+ /** Long enough for a cold Modal container; short enough to not hang a session. */
88
+ const DEFAULT_TIMEOUT_MS = 180_000;
89
+ /** A render is a screenshot round trip — slower than the rest. */
90
+ const RENDER_TIMEOUT_MS = 240_000;
91
+
92
+ /**
93
+ * Which media endpoints this install can actually reach.
94
+ *
95
+ * ⚠️ READ FROM `env` AT CALL TIME, never captured at import. A test must be able
96
+ * to hand in a different environment, and a long-running session must see a
97
+ * variable that changed — the module-level snapshot version of this is how a
98
+ * capability silently stays dark after it was fixed.
99
+ */
100
+ /**
101
+ * ── ⭐⭐ THE MULTIMODAL HALF, REACHABLE BY DEFAULT ──────────────────────────
102
+ *
103
+ * MEASURED 2026-08-12, all eight endpoints probed concurrently: every one is
104
+ * LIVE. A `405 Method Not Allowed` on a GET is the healthy answer from a
105
+ * POST-only service, and reading it as a failure is how a working stack gets
106
+ * reported as broken.
107
+ *
108
+ * tts 405 · transcribe 405 · document-press 405 · video-render 405
109
+ * voice-clone 405 · avatar 405 · face-gateway 404 · image-engine 404
110
+ *
111
+ * ⚠️⚠️ AND YET `--doctor` SAID DARK. Not because anything was down — because a
112
+ * fresh install has no `MODAL_*_URL` set, so the capability existed, worked,
113
+ * and was unreachable by anyone who had not been told the URLs. That is the
114
+ * same failure this repo has now hit five times: BUILT IS NOT WIRED, and a
115
+ * capability only the author can reach has not shipped.
116
+ *
117
+ * ⭐ So the URLs are baked in, exactly as `DEFAULT_ENGINE_URL` is for images.
118
+ * One secret turns on speech, transcription and documents together — an agent
119
+ * that can SEE (read_image), HEAR, SPEAK and PRINT, out of the box.
120
+ *
121
+ * ⚠️ IT STILL FAILS SHUT. These are PAID GPU services: with no secret the
122
+ * config is dark, because a missing credential must never mean "open to
123
+ * everyone" on something that bills per second. An explicit empty string still
124
+ * means OFF — someone who sets `MODAL_TTS_URL=` on an air-gapped machine made a
125
+ * decision, and silently reinstating our endpoint would override it.
126
+ */
127
+ export const DEFAULT_TTS_URL = 'https://xxiautomate-star--acuvo-tts-tts.modal.run';
128
+ export const DEFAULT_TRANSCRIBE_URL = 'https://xxiautomate-star--acuvo-transcribe-transcribe.modal.run';
129
+ export const DEFAULT_PRESS_URL = 'https://xxiautomate-star--acuvo-document-press-press.modal.run';
130
+ /**
131
+ * ── ⭐⭐ THE INPUT HALF. EVERYTHING ABOVE IS AN OUTPUT ───────────────────────
132
+ *
133
+ * Measured 2026-08-12 against the live Modal account: 24 apps deployed, 13
134
+ * wired, **11 unreachable** — deployed, healthy, paid for, and named by nothing
135
+ * any client reads. `acuvo-doc-read` and `acuvo-table-read` were two of them.
136
+ *
137
+ * ⭐ AND THEY ARE THE TWO THAT CHANGE WHAT THIS AGENT *IS*. Read the list of
138
+ * what it already had: speak, transcribe, make_document, see_page, generate
139
+ * image. Every one is something it EMITS. Nothing anywhere took an artefact a
140
+ * person hands over and turned it into something the model can reason about.
141
+ * "Build me the quote form from this PDF" was not a task this CLI could accept.
142
+ *
143
+ * ⚠️ TWO SERVICES, NOT ONE, AND THE SECOND IS NOT AN UPGRADE OF THE FIRST.
144
+ * `read_document` gets text out of anything (pdf · docx · xlsx · pptx · csv ·
145
+ * html · png/jpg, OCR'd when there is no text layer). `read_table` recovers the
146
+ * GRID from a picture of a table, which OCR destroys — see the note on
147
+ * `TABLE_ADVICE` below, which is the whole reason both exist.
148
+ */
149
+ export const DEFAULT_DOC_READ_URL = 'https://xxiautomate-star--acuvo-doc-read-read.modal.run';
150
+ export const DEFAULT_TABLE_READ_URL = 'https://xxiautomate-star--acuvo-table-read-extract.modal.run';
151
+
152
+ /**
153
+ * ⭐ THE NAMES THIS MODULE ACCEPTS FOR THE SHARED SECRET, EXPORTED so nothing
154
+ * else has to guess them. `doctor.mjs` hard-coded `ACUVO_MEDIA_SECRET`, went
155
+ * looking for it in a neighbouring env file, found nothing, and reported the
156
+ * capability as unfixable — while the credential sat in the same file under the
157
+ * other accepted name. Fourth time in one day that a constant naming another
158
+ * module's strings was a guess until something compared them.
159
+ */
160
+ export const MEDIA_SECRET_ENV_NAMES = Object.freeze(['ACUVO_MEDIA_SECRET', 'MODAL_VIDEO_SECRET']);
161
+
162
+ export function mediaConfig(env = process.env) {
163
+ const url = (k) => env[k]?.trim() || null;
164
+ const secret = env.ACUVO_MEDIA_SECRET?.trim() || env.MODAL_VIDEO_SECRET?.trim() || null;
165
+
166
+ /**
167
+ * ⚠️ `k in env` DISTINGUISHES "UNSET" FROM "DELIBERATELY EMPTY". Unset means
168
+ * "use ours"; an explicit empty string means "off". Collapsing the two is how
169
+ * a deliberate opt-out becomes a surprise network call.
170
+ */
171
+ const withDefault = (k, fallback) => {
172
+ if (k in env && (env[k] ?? '').trim() === '') return null;
173
+ return url(k) || (secret ? fallback : null);
174
+ };
175
+
176
+ return {
177
+ render: url('RENDER_AUDIT_URL') || url('MODAL_RENDER_AUDIT_URL'),
178
+ speak: withDefault('MODAL_TTS_URL', DEFAULT_TTS_URL),
179
+ transcribe: withDefault('MODAL_TRANSCRIBE_URL', DEFAULT_TRANSCRIBE_URL),
180
+ document: withDefault('MODAL_PRESS_URL', DEFAULT_PRESS_URL),
181
+ docRead: withDefault('MODAL_DOC_READ_URL', DEFAULT_DOC_READ_URL),
182
+ tableRead: withDefault('MODAL_TABLE_READ_URL', DEFAULT_TABLE_READ_URL),
183
+ secret,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * POST JSON with a bound timeout. Never throws — a failure is data.
189
+ *
190
+ * ── ⭐⭐ AND IT IS WHERE THE MONEY IS COUNTED ────────────────────────────────
191
+ *
192
+ * Every one of these endpoints is a metered Modal container, and until this
193
+ * change not one of them charged anything: `grep -n costUsd lib/media.mjs`
194
+ * returned NOTHING. `--budget`, `--fleet-budget` and `acuvo spend` all priced a
195
+ * run off model tokens alone, so a task that rendered four pages and printed a
196
+ * PDF reported the cost of the sentences describing it.
197
+ *
198
+ * ⚠️ THE CHARGE LANDS AFTER A RESPONSE, NOT AFTER A REQUEST, and the difference
199
+ * is a real dollar. A container that answered — even with `HTTP 500`, even with
200
+ * `{ok:false,"unauthorised"}` — booted, ran and billed. A connection that never
201
+ * got a reply (DNS failure, the endpoint is gone) started nothing, and charging
202
+ * it $0.04 of cold-start for a request that never arrived would be a guard that
203
+ * bills correct work. So: response ⇒ charge, no response ⇒ nothing, breaker
204
+ * skip ⇒ nothing (there was no call at all).
205
+ */
206
+ async function postJson(url, body, { timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = fetch, label = 'the service', verb = null } = {}) {
207
+ /**
208
+ * ⚠️ THE BREAKER IS CHECKED BEFORE THE REQUEST, for the reason set out in
209
+ * breaker.mjs: these timeouts are 180-240s, and a session budget is measured
210
+ * in minutes. Two waits on a dead endpoint is the whole run.
211
+ */
212
+ const already = deadReason(url);
213
+ if (already) return { ok: false, error: skipMessage(label, url) };
214
+ /**
215
+ * ⚠️ WALL CLOCK, NOT A TABLE OF DURATIONS. The seconds are the one part of a
216
+ * GPU price we can actually measure from here, so they are measured; only the
217
+ * $/second and the cold-start allowance come from a table. A table of guessed
218
+ * durations on top of a table of guessed rates would be a number with nothing
219
+ * real in it at all.
220
+ *
221
+ * ⚠️ AND A MISSING `verb` FALLS BACK TO THE LABEL RATHER THAN TO FREE. A
222
+ * future call site that forgets the option is then merely priced coarsely
223
+ * (unknown verbs default to the expensive GPU class), instead of silently
224
+ * reopening the exact hole this closes.
225
+ */
226
+ const startedAt = Date.now();
227
+ try {
228
+ const res = await throughBreaker(url, label, () => fetchImpl(url, {
229
+ method: 'POST',
230
+ headers: { 'content-type': 'application/json' },
231
+ body: JSON.stringify(body),
232
+ signal: AbortSignal.timeout(timeoutMs),
233
+ }));
234
+ chargeGpu({ verb: verb || label, seconds: (Date.now() - startedAt) / 1000, endpoint: url });
235
+ const text = await res.text();
236
+ let json = null;
237
+ try { json = JSON.parse(text); } catch { /* not JSON — keep the text */ }
238
+ if (!res.ok) {
239
+ /**
240
+ * ⚠️ THE BODY IS INCLUDED, NOT JUST THE STATUS. "HTTP 502" sent a whole
241
+ * afternoon guessing; the body said `Executable doesn't exist at
242
+ * /ms-playwright/chromium-1234` and named the bug outright. A model given
243
+ * another round can act on the second and can do nothing with the first.
244
+ */
245
+ return { ok: false, error: `HTTP ${res.status}: ${(json?.error ?? text ?? '').toString().slice(0, 400)}` };
246
+ }
247
+ /**
248
+ * ── ⚠️⚠️ A 200 THAT MEANS FAILURE ──────────────────────────────────────
249
+ *
250
+ * `res.ok` answers a question about the HTTP conversation, never about
251
+ * whether the work happened. These services answer 200 and put the verdict
252
+ * in the BODY: `{ ok: false, error: "unauthorised" }`. Without this branch
253
+ * the caller got `{ ok: true }`, looked for its payload key, did not find
254
+ * one, and INVENTED a message of its own.
255
+ *
256
+ * MEASURED 2026-08-11 against the live TTS endpoint:
257
+ * POST {text} -> 200 {"ok":false,"error":"unauthorised"}
258
+ * POST {text, secret} -> 200 {"ok":true,"audio":"UklGRlRW…"} (116 KB WAV)
259
+ *
260
+ * The service named the problem exactly — one missing credential — and the
261
+ * agent was told "the speech service returned no audio". It then retried
262
+ * FOUR times and spent six rounds on a call that could never succeed.
263
+ *
264
+ * ⭐ AN ERROR STRING IS AN INSTRUCTION. "returned no audio" reads transient,
265
+ * so retrying is the rational response; "unauthorised" reads like
266
+ * configuration, and nothing retries that. The wrong string did not merely
267
+ * fail to inform — it BOUGHT the retries.
268
+ *
269
+ * ⚠️ Only an EXPLICIT `ok: false` counts. Several of these services answer
270
+ * success with no `ok` field at all, so treating "absent" as failure would
271
+ * take every media tool down at once.
272
+ */
273
+ if (json && json.ok === false) {
274
+ const detail = String(json.error ?? 'the service reported a failure with no reason').slice(0, 400);
275
+ const auth = /unauthoris|unauthoriz|forbidden|invalid secret|401|403/i.test(detail)
276
+ ? ' — set MODAL_VIDEO_SECRET to the value this endpoint expects (retrying will not help)'
277
+ : '';
278
+ return { ok: false, error: `${label} refused the request: ${detail}${auth}` };
279
+ }
280
+
281
+ return { ok: true, json: json ?? {}, text };
282
+ } catch (err) {
283
+ const code = err?.cause?.code || err?.name || 'unknown';
284
+ const hint = code === 'TimeoutError'
285
+ ? ' (the endpoint may be cold-starting — try once more)'
286
+ : '';
287
+ return { ok: false, error: `could not reach the service: ${code}${hint}` };
288
+ }
289
+ }
290
+
291
+ /** Write text into the workspace, through the workspace's own rules. */
292
+ function writeText(root, rawPath, text) {
293
+ const target = resolveInWorkspace(root, rawPath, 'write');
294
+ if (!target.ok) return { ok: false, error: target.reason };
295
+ mkdirSync(dirname(target.absolute), { recursive: true });
296
+ writeFileSync(target.absolute, String(text ?? ''), 'utf8');
297
+ return { ok: true, path: target.relative, bytes: Buffer.byteLength(String(text ?? '')) };
298
+ }
299
+
300
+ /**
301
+ * ── ⭐⭐ FETCH A RUNNING PAGE AND EVERYTHING IT NEEDS TO LOOK RIGHT ──────────
302
+ *
303
+ * The render service takes HTML, not a URL — measured, it answers
304
+ * `{"ok":false,"error":"no html supplied"}` — so the page and its assets are
305
+ * fetched here and inlined exactly as a file's would be.
306
+ *
307
+ * ⭐ TWO PASSES, SO THE TRAVERSAL IS NOT DUPLICATED. `inlineLocalAssets` already
308
+ * knows which tags matter, what the size caps are, and how to report what it
309
+ * could not resolve. Its resolver is synchronous and fetching is not, so pass
310
+ * one runs with a RECORDING resolver that returns null and notes every URL
311
+ * asked for; the assets are fetched; pass two runs with a Map. Re-implementing
312
+ * those regexes for the URL case is exactly how the two paths would drift.
313
+ *
314
+ * ⚠️ AN ASSET THAT WILL NOT LOAD IS REPORTED, NOT HIDDEN. A stylesheet that
315
+ * 404s is the single most valuable finding this tool produces — without it the
316
+ * page has no design and every visual check silently passes.
317
+ */
318
+ async function fetchServedPage(url, { fetchImpl = fetch, timeoutMs = 15_000 } = {}) {
319
+ const get = async (target) => {
320
+ const ac = new AbortController();
321
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
322
+ try {
323
+ const r = await fetchImpl(String(target), { signal: ac.signal, redirect: 'follow' });
324
+ if (!r.ok) return { ok: false, status: r.status };
325
+ return { ok: true, buf: Buffer.from(await r.arrayBuffer()) };
326
+ } catch (err) {
327
+ return { ok: false, error: err?.name === 'AbortError' ? `timed out after ${timeoutMs}ms` : (err?.message ?? String(err)) };
328
+ } finally {
329
+ clearTimeout(timer);
330
+ }
331
+ };
332
+
333
+ const page = await get(url);
334
+ if (!page.ok) {
335
+ return {
336
+ ok: false,
337
+ error: page.status
338
+ ? `the server at ${url.href} answered HTTP ${page.status} — nothing was rendered`
339
+ : `could not reach ${url.href}: ${page.error}. Is the server still running? check_process will say.`,
340
+ };
341
+ }
342
+ const html = page.buf.toString('utf8');
343
+
344
+ // Pass 1 — record what the page asks for, without fetching anything.
345
+ const wanted = [];
346
+ inlineLocalAssets('', '', html, { absolute: true, resolveImpl: (u) => { wanted.push(u); return null; } });
347
+
348
+ // Fetch each one against the page's own origin.
349
+ const got = new Map();
350
+ const unreachable = [];
351
+ for (const raw of [...new Set(wanted)].slice(0, 40)) {
352
+ let assetUrl;
353
+ try { assetUrl = new URL(raw, url); } catch { unreachable.push(`${raw} is not a resolvable url`); continue; }
354
+ const a = await get(assetUrl);
355
+ if (a.ok) got.set(raw, a.buf);
356
+ else unreachable.push(`"${raw}" could not be fetched from the server${a.status ? ` (HTTP ${a.status})` : ''}`);
357
+ }
358
+
359
+ // Pass 2 — the same traversal, now able to answer.
360
+ const bundled = inlineLocalAssets('', '', html, {
361
+ absolute: true,
362
+ resolveImpl: (u) => (got.has(u) ? { buf: got.get(u), rel: u } : null),
363
+ });
364
+
365
+ return { ok: true, html: bundled.html, missing: [...unreachable, ...bundled.missing] };
366
+ }
367
+
368
+ /** Write a base64 payload into the workspace, through the workspace's own rules. */
369
+ function writeBinary(root, rawPath, base64, dryRun) {
370
+ const target = resolveInWorkspace(root, rawPath, 'write');
371
+ if (!target.ok) return { ok: false, error: target.reason };
372
+ const buf = Buffer.from(base64, 'base64');
373
+ if (!dryRun) {
374
+ mkdirSync(dirname(target.absolute), { recursive: true });
375
+ writeFileSync(target.absolute, buf);
376
+ }
377
+ return { ok: true, path: target.relative, bytes: buf.length, dryRun };
378
+ }
379
+
380
+ /**
381
+ * ── ⚠️⚠️ INLINE THE PAGE'S OWN FILES BEFORE SENDING IT ──────────────────────
382
+ *
383
+ * The render service is given HTML **text**, not a directory. So
384
+ * `<link rel="stylesheet" href="styles.css">` resolves to nothing, and a page
385
+ * that is perfect on disk arrives at the browser completely naked.
386
+ *
387
+ * ⚠️ THIS WAS CAUGHT BY LOOKING, NOT BY TESTING, AND IT HAD ALREADY FOOLED THE
388
+ * AUDIT. A generated four-page site rendered with bulleted navigation, blue
389
+ * underlined links and broken image icons — 1994 — and `see_page` reported
390
+ * "no measured problems" on all four pages, because every check it runs
391
+ * (contrast, painted ratio, console errors) is genuinely fine on an unstyled
392
+ * page. Black text on white has excellent contrast. That is a false all-clear
393
+ * of exactly the kind this file was fixed for once already today, one layer up.
394
+ *
395
+ * ⭐ We hold the workspace root and the page's own path, so the siblings are
396
+ * ours to resolve. Every asset goes through `resolveInWorkspace`, so inlining
397
+ * can never read a file `read_file` could not.
398
+ *
399
+ * ⚠️ AND A REFERENCE WE CANNOT RESOLVE BECOMES A FINDING. Silently rendering
400
+ * without the stylesheet is how the original bug stayed invisible; saying "the
401
+ * page asked for styles.css and it is not there" is usually the actual defect.
402
+ */
403
+ export function inlineLocalAssets(root, pageRelPath, html, { readImpl = readFileSync, resolveImpl = null, absolute = false } = {}) {
404
+ const missing = [];
405
+ const dir = pageRelPath.includes('/') ? pageRelPath.slice(0, pageRelPath.lastIndexOf('/')) : '';
406
+ /**
407
+ * Only same-workspace, non-absolute, non-remote references are ours to inline.
408
+ *
409
+ * ⚠️ `absolute` WIDENS THIS FOR A SERVED PAGE, and only for one. A page on
410
+ * disk cannot resolve `/styles.css` — there is no document root, so treating
411
+ * it as local would mean reading from the filesystem root. A page fetched over
412
+ * HTTP resolves it perfectly well against its own origin, and root-relative
413
+ * hrefs are what real servers emit. Same string, two meanings, decided by
414
+ * where the page came from rather than by a guess.
415
+ */
416
+ const isLocal = (u) => u
417
+ && !/^(https?:|data:|\/\/|#|mailto:|tel:)/i.test(u)
418
+ && (absolute || !u.startsWith('/'));
419
+ /**
420
+ * ⭐ THE RESOLVER IS INJECTABLE so a page fetched from a running server can
421
+ * supply its assets over HTTP instead of from the workspace. The traversal —
422
+ * which tags count, what gets inlined, the size caps, the `missing` reporting
423
+ * — is identical either way, and duplicating those regexes for the URL case
424
+ * is exactly how the two paths would drift.
425
+ */
426
+ const resolveAsset = resolveImpl ?? ((url) => {
427
+ const clean = url.split('?')[0].split('#')[0];
428
+ const rel = dir ? `${dir}/${clean}` : clean;
429
+ const t = resolveInWorkspace(root, rel, 'read');
430
+ if (!t.ok) return null;
431
+ try { return { buf: readImpl(t.absolute), rel: t.relative }; } catch { return null; }
432
+ });
433
+
434
+ let out = html;
435
+
436
+ // 1. Stylesheets → <style>. The single most important one: without it the
437
+ // page has no design at all, and every visual check silently passes.
438
+ out = out.replace(/<link\b[^>]*>/gi, (tag) => {
439
+ if (!/rel\s*=\s*["']?stylesheet/i.test(tag)) return tag;
440
+ const m = /href\s*=\s*["']([^"']+)["']/i.exec(tag);
441
+ if (!m || !isLocal(m[1])) return tag;
442
+ const got = resolveAsset(m[1]);
443
+ if (!got) { missing.push(`stylesheet "${m[1]}" is linked but was not found — the page renders unstyled`); return tag; }
444
+ return `<style>\n${got.buf.toString('utf8')}\n</style>`;
445
+ });
446
+
447
+ /**
448
+ * 2. Scripts → inline. ⚠️ A module's own relative imports CANNOT survive this:
449
+ * inlining changes the script's base URL, so `import './x.mjs'` inside it
450
+ * still fails. Inlining the entry point is strictly better than dropping it —
451
+ * most page scripts are self-contained — and the console error from a nested
452
+ * import is reported rather than hidden, which is the honest outcome.
453
+ */
454
+ out = out.replace(/<script\b([^>]*)\bsrc\s*=\s*["']([^"']+)["']([^>]*)>\s*<\/script>/gi, (tag, pre, url, post) => {
455
+ if (!isLocal(url)) return tag;
456
+ const got = resolveAsset(url);
457
+ if (!got) { missing.push(`script "${url}" is referenced but was not found`); return tag; }
458
+ const isModule = /type\s*=\s*["']module["']/i.test(pre + post);
459
+ return `<script${isModule ? ' type="module"' : ''}>\n${got.buf.toString('utf8')}\n</script>`;
460
+ });
461
+
462
+ // 3. Images → data: URIs, so "broken image" means broken and not "not sent".
463
+ out = out.replace(/(<img\b[^>]*?\bsrc\s*=\s*["'])([^"']+)(["'])/gi, (tag, head, url, tail) => {
464
+ if (!isLocal(url)) return tag;
465
+ const got = resolveAsset(url);
466
+ if (!got) { missing.push(`image "${url}" is referenced but was not found`); return tag; }
467
+ // A megabyte of base64 per image would blow the request up for no gain.
468
+ if (got.buf.length > 400_000) { missing.push(`image "${url}" is ${Math.round(got.buf.length / 1024)}KB — too large to preview, skipped`); return tag; }
469
+ const ext = url.split('.').pop().toLowerCase().split(/[?#]/)[0];
470
+ const mime = ext === 'svg' ? 'image/svg+xml' : ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : ext === 'gif' ? 'image/gif' : 'image/png';
471
+ return `${head}data:${mime};base64,${got.buf.toString('base64')}${tail}`;
472
+ });
473
+
474
+ return { html: out, missing };
475
+ }
476
+
477
+ /**
478
+ * ── ⭐ SEE A PAGE — the capability no other coding CLI has ──────────────────
479
+ *
480
+ * Renders local HTML in a real browser and returns what was MEASURED: the
481
+ * screenshot, plus geometry and contrast findings. This is the thing that turns
482
+ * "I wrote some HTML" into "I looked at it".
483
+ */
484
+ /**
485
+ * ── ⭐⭐ IS THIS A PAGE ON DISK, OR A SERVER THAT IS RUNNING? ────────────────
486
+ *
487
+ * `start_process` can start a dev server, `check_process` can prove it answers,
488
+ * and `see_page` could only ever open a FILE — three finished halves of one
489
+ * loop that had never been joined. A developer's normal shape is "run it and
490
+ * look at it", and the agent could do everything except the looking.
491
+ *
492
+ * ⚠️⚠️ LOOPBACK ONLY, AND THIS IS THE WHOLE SECURITY ARGUMENT. `see_page` is
493
+ * reachable by a model reading a repository we do not control, so an arbitrary
494
+ * URL here is a request-forgery primitive: a hostile ACUVO.md could ask it to
495
+ * fetch an internal metadata endpoint or a private host and — because the page
496
+ * body is inlined and sent onward — read the answer back out.
497
+ *
498
+ * ⭐ `resolveApiUrl` in model.mjs already decided this exact question the same
499
+ * way and for the same reason. Loopback is where the agent's OWN server lives,
500
+ * which is the entire use case; the public web already has `fetch_url`, whose
501
+ * output is text the model reads rather than bytes we render and store.
502
+ *
503
+ * ⚠️ Hostname, not substring. `http://127.0.0.1.evil.com/` contains "127.0.0.1"
504
+ * and is not loopback; parsing and comparing the host is the only version of
505
+ * this check that cannot be talked around.
506
+ */
507
+ export function loopbackTarget(raw) {
508
+ const s = String(raw ?? '').trim();
509
+ if (!/^https?:\/\//i.test(s)) return { isUrl: false };
510
+ let u;
511
+ try { u = new URL(s); } catch { return { isUrl: true, ok: false, reason: `not a valid URL: ${s.slice(0, 120)}` }; }
512
+ const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
513
+ const loopback = host === 'localhost' || host === '::1' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
514
+ if (!loopback) {
515
+ return {
516
+ isUrl: true,
517
+ ok: false,
518
+ reason: `see_page only opens files in the workspace or a LOOPBACK url (localhost / 127.x). `
519
+ + `"${u.hostname}" is neither — it would let a page choose what this machine fetches. `
520
+ + `Use fetch_url to read a public page as text.`,
521
+ };
522
+ }
523
+ return { isUrl: true, ok: true, url: u };
524
+ }
525
+
526
+ export async function seePage(root, htmlPath, { env = process.env, fetchImpl = fetch, dryRun = false } = {}) {
527
+ const cfg = mediaConfig(env);
528
+ if (!cfg.render) return { ok: false, error: 'no render service is configured (RENDER_AUDIT_URL)' };
529
+
530
+ /**
531
+ * ⭐ A LOOPBACK URL IS RENDERED FROM THE SERVER, NOT FROM DISK. The render
532
+ * service will not do it for us — measured 2026-08-15, it answers
533
+ * `{"ok":false,"error":"no html supplied"}` to a `url` field — so the CLI
534
+ * fetches the page and its assets itself and sends the same inlined HTML it
535
+ * would send for a file.
536
+ */
537
+ const asUrl = loopbackTarget(htmlPath);
538
+ if (asUrl.isUrl) {
539
+ if (!asUrl.ok) return { ok: false, error: asUrl.reason };
540
+ const fetched = await fetchServedPage(asUrl.url, { fetchImpl });
541
+ if (!fetched.ok) return fetched;
542
+
543
+ /**
544
+ * ⭐ THE SERVED PAGE BECOMES A FILE, AND THEN TAKES THE ORDINARY PATH.
545
+ *
546
+ * Everything after this point — post to the renderer, check the measurement
547
+ * shape, write the screenshot, assemble the findings — is identical for a
548
+ * file and for a URL. Writing the fetched bundle to disk and falling through
549
+ * means there is exactly ONE renderer, by construction, rather than two that
550
+ * agree today. I tried extracting a shared helper first; this is smaller,
551
+ * has no second code path to keep in step, and leaves the exact bytes that
552
+ * were rendered on disk where the agent can read them.
553
+ *
554
+ * ⚠️ `dryRun` MUST NOT WRITE. The flag promises "touch nothing", and this is
555
+ * a real file in the user's workspace — so a dry run reports what it would
556
+ * have looked at and stops, rather than leaving a snapshot behind.
557
+ */
558
+ if (dryRun) {
559
+ return { ok: false, error: `this is a --dry-run, so ${asUrl.url.href} was not fetched or rendered` };
560
+ }
561
+ const snapshot = `.acuvo/served-${Date.now()}.html`;
562
+ const written = writeText(root, snapshot, fetched.html);
563
+ if (!written.ok) return written;
564
+
565
+ const looked = await seePage(root, written.path, { env, fetchImpl, dryRun });
566
+ if (!looked.ok) return looked;
567
+ return {
568
+ ...looked,
569
+ // ⚠️ The URL is what the user asked about; the snapshot is an artefact.
570
+ // Reporting the temp filename as "the page" would be true and useless.
571
+ path: asUrl.url.href,
572
+ snapshot: written.path,
573
+ findings: [...fetched.missing, ...(looked.findings ?? [])].slice(0, 20),
574
+ };
575
+ }
576
+
577
+ const target = resolveInWorkspace(root, htmlPath, 'read');
578
+ if (!target.ok) return { ok: false, error: target.reason };
579
+
580
+ let html;
581
+ try {
582
+ html = readFileSync(target.absolute, 'utf8');
583
+ } catch (err) {
584
+ return { ok: false, error: `could not read ${target.relative}: ${err?.message ?? err}` };
585
+ }
586
+
587
+ /**
588
+ * ⚠️ THE PAGE IS SENT WITH ITS OWN FILES IN IT. Sending the bare source
589
+ * renders a page nobody wrote — see `inlineLocalAssets` for the four-page
590
+ * site that passed this audit while looking like 1994.
591
+ */
592
+ const bundled = inlineLocalAssets(root, target.relative, html);
593
+
594
+ const res = await postJson(cfg.render, { html: bundled.html, secret: cfg.secret ?? undefined },
595
+ { timeoutMs: RENDER_TIMEOUT_MS, fetchImpl, label: 'The render service', verb: 'see_page' });
596
+ if (!res.ok) return res;
597
+
598
+ /**
599
+ * ── ⚠️⚠️ THE ENVELOPE. THIS WAS THE WORST BUG IN THE PRODUCT ───────────────
600
+ *
601
+ * The service replies `{ ok, measurement: { … } }`. This function used to read
602
+ * `res.json.findings`, `res.json.viewport` and `res.json.screenshotPngB64` —
603
+ * three keys that DO NOT EXIST at that level. So every call returned
604
+ * `{ findings: [], viewport: null, screenshot: null, looked: true }`.
605
+ *
606
+ * ⚠️ AND IT FAILED IN THE CONFIDENT DIRECTION. `looked: true` with an empty
607
+ * findings list reads as "I looked and it was fine" — so the one capability we
608
+ * sell as our differentiator was issuing all-clears for pages it never saw.
609
+ * Measured against the live endpoint on a deliberately broken page: the
610
+ * service returned `lowContrastText: [{text:'invisible', ratio:1.05}]` and
611
+ * `paintedRatio: 0.0198`, and every byte of it was discarded here.
612
+ *
613
+ * ⭐ SO: unwrap, and REFUSE rather than reassure when the shape is unfamiliar.
614
+ * A service that changed its contract must break loudly. An empty findings
615
+ * list is now only ever produced by a measurement we actually parsed.
616
+ */
617
+ const m = res.json?.measurement ?? res.json?.measurements ?? null;
618
+ if (!m || typeof m !== 'object') {
619
+ return {
620
+ ok: false,
621
+ error: `the render service returned a shape this version does not understand (keys: ${Object.keys(res.json ?? {}).slice(0, 8).join(', ') || 'none'})`,
622
+ };
623
+ }
624
+
625
+ /**
626
+ * ⭐ THE SCREENSHOT IS SAVED TO DISK, not just described. A path the user can
627
+ * open is worth more than any summary, and it is also the only way THEY can
628
+ * check whether the agent's description of its own work is true.
629
+ */
630
+ let shot = null;
631
+ // Reported so the change summary can state a real size. A screenshot listed
632
+ // as "0 bytes" reads as a failed write.
633
+ let shotBytes = 0;
634
+ if (m.screenshotPngB64 && !dryRun) {
635
+ const w = writeBinary(root, `.acuvo/render-${Date.now()}.png`, m.screenshotPngB64, dryRun);
636
+ if (w.ok) { shot = w.path; shotBytes = w.bytes; }
637
+ }
638
+
639
+ return {
640
+ ok: true,
641
+ path: target.relative,
642
+ screenshot: shot,
643
+ screenshotBytes: shotBytes,
644
+ viewport: m.viewport ?? null,
645
+ // ⚠️ Unresolvable references come FIRST: "the page asked for styles.css and
646
+ // it is not there" explains every layout complaint underneath it, exactly
647
+ // like a console error does.
648
+ findings: [...bundled.missing, ...findingsFrom(m)].slice(0, 20),
649
+ // Present even when empty, so "I looked and it was fine" is distinguishable
650
+ // from "I could not look" — the render audit made that mistake once already.
651
+ looked: true,
652
+ };
653
+ }
654
+
655
+ /**
656
+ * ── ⭐ TURN A MEASUREMENT INTO SENTENCES A MODEL CAN ACT ON ──────────────────
657
+ *
658
+ * The service measures; it does not judge. Judging here keeps the thresholds in
659
+ * one reviewable place instead of inside a prompt.
660
+ *
661
+ * ⚠️ ORDERED BY WHAT ACTUALLY BREAKS A PAGE. A console error that stopped the
662
+ * app booting has to be the first line the model reads — it explains every other
663
+ * finding underneath it, and a model that fixes the contrast of a page that never
664
+ * rendered has wasted the round.
665
+ */
666
+ export function findingsFrom(m) {
667
+ const out = [];
668
+
669
+ // 1. The page did not run. Everything below is a consequence of this.
670
+ for (const e of (m.consoleErrors ?? []).slice(0, 5)) {
671
+ out.push(`console error: ${String(e).slice(0, 200)}`);
672
+ }
673
+
674
+ /**
675
+ * 2. The page is blank. ⚠️ `paintedRatio` is the share of pixels differing
676
+ * from the backdrop, so a legitimately minimal page scores low too — which is
677
+ * why this reads as "almost nothing rendered" and names the number rather than
678
+ * asserting a bug. The model has the source; it can tell which it is.
679
+ */
680
+ if (typeof m.paintedRatio === 'number' && m.paintedRatio < 0.05) {
681
+ out.push(`almost nothing rendered — ${(m.paintedRatio * 100).toFixed(1)}% of the viewport differs from the backdrop`);
682
+ }
683
+
684
+ // 3. Text nobody can read. 4.5:1 is WCAG AA for body copy.
685
+ for (const t of (m.lowContrastText ?? []).slice(0, 5)) {
686
+ const ratio = typeof t?.ratio === 'number' ? t.ratio.toFixed(2) : '?';
687
+ out.push(`unreadable text (contrast ${ratio}:1, needs 4.5): ${String(t?.text ?? '').slice(0, 80)}`);
688
+ }
689
+
690
+ for (const t of (m.clippedText ?? []).slice(0, 5)) {
691
+ out.push(`text is cut off: ${String(t?.text ?? JSON.stringify(t)).slice(0, 80)}`);
692
+ }
693
+ for (const o of (m.overlaps ?? []).slice(0, 5)) {
694
+ out.push(`elements overlap: ${String(o?.a ?? '?')} over ${String(o?.b ?? '?')}`.slice(0, 120));
695
+ }
696
+ for (const b of (m.brokenImages ?? []).slice(0, 5)) {
697
+ out.push(`image failed to load: ${String(b?.src ?? b).slice(0, 120)}`);
698
+ }
699
+
700
+ // 4. Horizontal scroll — the classic responsive failure, and invisible in a
701
+ // screenshot cropped to the viewport.
702
+ if (typeof m.scrollWidth === 'number' && m.viewport?.width && m.scrollWidth > m.viewport.width + 1) {
703
+ out.push(`the page scrolls sideways: content is ${m.scrollWidth}px wide in a ${m.viewport.width}px viewport`);
704
+ }
705
+
706
+ return out.slice(0, 20);
707
+ }
708
+
709
+ /**
710
+ * Speak text aloud into a workspace audio file. Modal TTS (Kokoro, Apache-2.0).
711
+ *
712
+ * ── ⚠️⚠️ THIS IS NOT "ACUVO VOICE", AND THE OBVIOUS WIRING WOULD MISPRICE IT ─
713
+ *
714
+ * The catalogue's **Acuvo Voice** is `chatterbox-tts`: clone a voice from a few
715
+ * seconds of audio and then speak in it, measured at $0.00167 a line. What this
716
+ * function calls is `MODAL_TTS_URL` running **Kokoro**, a FIXED-voice reader
717
+ * that clones nothing. Mapping one onto the other — which is what "speak → the
718
+ * voice engine" looks like from a distance — would have quoted a cloning price
719
+ * for a capability that cannot clone, and put a branded name on a different
720
+ * model.
721
+ *
722
+ * ⭐ So `engine` is accepted here in order to REFUSE clearly: naming
723
+ * `acuvo-voice` says why it is not this, and no engine name silently changes
724
+ * which model reads your text. When the CLI can reach the cloning engine, that
725
+ * is the day this comment changes.
726
+ */
727
+ export async function speak(root, text, outPath, { env = process.env, fetchImpl = fetch, dryRun = false, engine = null } = {}) {
728
+ /**
729
+ * ⚠️ THE ENGINE CHECK IS FIRST, ABOVE THE CONFIG CHECK, because "you asked
730
+ * for an engine that does not run here" is true whether or not a TTS endpoint
731
+ * happens to be configured — and the config message would send the reader to
732
+ * an environment variable that was never the problem.
733
+ */
734
+ const requested = engine ?? runEngineFor('voice');
735
+ if (requested) {
736
+ const choice = checkEngine('voice', requested, { env });
737
+ if (!choice.ok) return { ok: false, error: choice.error, code: choice.code };
738
+ }
739
+ const cfg = mediaConfig(env);
740
+ if (!cfg.speak) return { ok: false, error: 'no speech service is configured (MODAL_TTS_URL)' };
741
+ if (typeof text !== 'string' || !text.trim()) return { ok: false, error: 'nothing to say — text is required' };
742
+ if (text.length > 5_000) return { ok: false, error: `text is ${text.length} characters, over the 5,000 limit` };
743
+
744
+ const res = await postJson(cfg.speak, { text, secret: cfg.secret ?? undefined }, { fetchImpl, verb: 'speak' });
745
+ if (!res.ok) return res;
746
+ const b64 = res.json?.audioB64 ?? res.json?.audio_b64 ?? res.json?.audio;
747
+ if (!b64) return { ok: false, error: 'the speech service returned no audio' };
748
+ return writeBinary(root, outPath || `.acuvo/speech-${Date.now()}.wav`, b64, dryRun);
749
+ }
750
+
751
+ /** Transcribe an audio file already in the workspace. faster-whisper, MIT. */
752
+ export async function transcribe(root, audioPath, { env = process.env, fetchImpl = fetch, dryRun = false } = {}) {
753
+ const cfg = mediaConfig(env);
754
+ if (!cfg.transcribe) return { ok: false, error: 'no transcription service is configured (MODAL_TRANSCRIBE_URL)' };
755
+
756
+ /**
757
+ * ⚠️ A DRY RUN MUST NOT POST. `--dry-run` promises "touch nothing, run
758
+ * nothing", and this is a base64 upload to a metered GPU service: it costs
759
+ * egress, it costs GPU seconds, and it hands a file to a remote host. The
760
+ * other three verbs gated only the WRITE and sent the request anyway
761
+ * (ENTERPRISE §3.6), which made the flag a false guarantee rather than a weak
762
+ * one — exactly the finding that closed §3.1's first half.
763
+ */
764
+ if (dryRun) return { ok: false, error: 'this is a --dry-run, so nothing is uploaded (transcription sends the file to a metered service)' };
765
+
766
+ const target = resolveInWorkspace(root, audioPath, 'read');
767
+ if (!target.ok) return { ok: false, error: target.reason };
768
+
769
+ /**
770
+ * ⚠️ A CAP WAS WRITTEN FOR `speak` AND OMITTED HERE. Its sibling refuses text
771
+ * over 5,000 characters; this base64'd and POSTed ANY file in the workspace at
772
+ * ANY size, so "transcribe node_modules/.cache/something.bin" was a 200MB
773
+ * upload the model could ask for by accident.
774
+ */
775
+ let bytes;
776
+ try {
777
+ bytes = statSync(target.absolute).size;
778
+ } catch (err) {
779
+ return { ok: false, error: `could not read ${target.relative}: ${err?.message ?? err}` };
780
+ }
781
+ if (bytes > MAX_TRANSCRIBE_BYTES) {
782
+ return {
783
+ ok: false,
784
+ error: `${target.relative} is ${(bytes / 1024 / 1024).toFixed(1)}MB, over the ${Math.round(MAX_TRANSCRIBE_BYTES / 1024 / 1024)}MB limit for transcription. `
785
+ + 'Trim or split the audio first.',
786
+ };
787
+ }
788
+
789
+ /**
790
+ * ⚠️ AND AN EXTENSION CHECK, because the useful half of this refusal is
791
+ * telling the model it pointed at the wrong FILE. Without it, a `.zip` is a
792
+ * paid round trip that comes back "could not decode" — which reads like the
793
+ * service is broken rather than like the argument was wrong.
794
+ */
795
+ if (!AUDIO_EXTENSIONS.has(extname(target.absolute).toLowerCase())) {
796
+ return {
797
+ ok: false,
798
+ error: `${target.relative} is not an audio or video file (${[...AUDIO_EXTENSIONS].join(' ')}). `
799
+ + 'Transcription needs a recording, not an arbitrary file.',
800
+ };
801
+ }
802
+
803
+ let b64;
804
+ try {
805
+ b64 = readFileSync(target.absolute).toString('base64');
806
+ } catch (err) {
807
+ return { ok: false, error: `could not read ${target.relative}: ${err?.message ?? err}` };
808
+ }
809
+
810
+ /**
811
+ * ── ⚠️ ONE UNDERSCORE COST THE ENTIRE VOICE LOOP ───────────────────────────
812
+ *
813
+ * This posted `audioB64`. The service reads `audio_b64` and says so outright:
814
+ * `supply audio_url or audio_b64`. So the CLI could SPEAK — a real 211 KB WAV
815
+ * — and could never hear its own output back.
816
+ *
817
+ * MEASURED 2026-08-11, same file both times:
818
+ * { audioB64 } -> 200 {"ok":false,"error":"supply audio_url or audio_b64"}
819
+ * { audio_b64 } -> 200 {"ok":true,"text":"The quick brown fox jumps over the
820
+ * lazy dog near the riverbank.", segments:[…]}
821
+ *
822
+ * ⚠️ BOTH KEYS GO, deliberately. An ignored extra key costs nothing; guessing
823
+ * wrong costs the capability, and this endpoint has been redeployed more than
824
+ * once. `test/transcribe-payload-contract.test.mjs` pins that they carry
825
+ * identical bytes, so they can never drift into disagreeing about the audio.
826
+ */
827
+ const res = await postJson(
828
+ cfg.transcribe,
829
+ { audio_b64: b64, audioB64: b64, secret: cfg.secret ?? undefined },
830
+ { fetchImpl, verb: 'transcribe' },
831
+ );
832
+ if (!res.ok) return res;
833
+ return {
834
+ ok: true,
835
+ path: target.relative,
836
+ text: res.json?.text ?? '',
837
+ // ⭐ Segments, not just the blob. "What was said at 4:12" is the question
838
+ // people actually have, and a wall of text cannot answer it.
839
+ segments: Array.isArray(res.json?.segments) ? res.json.segments.slice(0, 200) : [],
840
+ };
841
+ }
842
+
843
+ /** Turn HTML into a real document — PDF, PNG or PPTX. */
844
+ export async function makeDocument(root, htmlPath, outPath, format, { env = process.env, fetchImpl = fetch, dryRun = false } = {}) {
845
+ const cfg = mediaConfig(env);
846
+ if (!cfg.document) return { ok: false, error: 'no document service is configured (MODAL_PRESS_URL)' };
847
+ const fmt = String(format || 'pdf').toLowerCase();
848
+ if (!['pdf', 'png', 'pptx'].includes(fmt)) {
849
+ return { ok: false, error: `format must be pdf, png or pptx — got "${format}"` };
850
+ }
851
+ const target = resolveInWorkspace(root, htmlPath, 'read');
852
+ if (!target.ok) return { ok: false, error: target.reason };
853
+ let html;
854
+ try { html = readFileSync(target.absolute, 'utf8'); }
855
+ catch (err) { return { ok: false, error: `could not read ${target.relative}: ${err?.message ?? err}` }; }
856
+
857
+ const res = await postJson(cfg.document, { html, format: fmt, secret: cfg.secret ?? undefined }, { fetchImpl, verb: 'make_document' });
858
+ if (!res.ok) return res;
859
+ const b64 = res.json?.fileB64 ?? res.json?.file_b64 ?? res.json?.data;
860
+ if (!b64) return { ok: false, error: 'the document service returned no file' };
861
+ return writeBinary(root, outPath || `.acuvo/document-${Date.now()}.${fmt}`, b64, dryRun);
862
+ }
863
+
864
+ /* ────────────────────────────────────────────────────────────────────────────
865
+ * READING WHAT SOMEONE HANDS YOU
866
+ * ──────────────────────────────────────────────────────────────────────────── */
867
+
868
+ /**
869
+ * ⚠️ 20 MB, WELL UNDER THE WORKER'S OWN 100 MB. The worker's ceiling is for a
870
+ * file it FETCHES; this path sends bytes in a JSON body, where base64 inflates
871
+ * by a third and a serverless request limit arrives long before 100 MB does.
872
+ * Refusing at a stated number beats a 413 nobody can act on.
873
+ */
874
+ const MAX_DOC_UPLOAD_MB = 20;
875
+ const MAX_DOC_UPLOAD_BYTES = MAX_DOC_UPLOAD_MB * 1024 * 1024;
876
+ /**
877
+ * ⚠️ THE LIMIT IS PRINTED FROM THE MB FIGURE, NOT DERIVED FROM THE BYTES. The
878
+ * first version divided the byte ceiling by 1e6 and told the user their file was
879
+ * "over the 20.97152 MB limit" — a number no human wrote and nobody can act on
880
+ * cleanly. Caught by a test asserting the sentence, not the behaviour.
881
+ */
882
+
883
+ /**
884
+ * ── ⚠️⚠️ THE RETURN VALUE IS THE EXPENSIVE PART, NOT THE CALL ───────────────
885
+ *
886
+ * The service will happily return 400,000 characters. Handing that back becomes
887
+ * a tool result inside a transcript that is re-sent on EVERY subsequent round —
888
+ * so one careless read of a long PDF is not a one-off cost, it is a tax on the
889
+ * rest of the session. At ~4 chars/token that is ~100k tokens, multiplied by
890
+ * however many rounds follow.
891
+ *
892
+ * ⭐ So a call returns a WINDOW and says where the next one starts, exactly as
893
+ * `read_lines` does for a large file. The model is not being protected from the
894
+ * document; it is being handed it a page-range at a time, with the range stated.
895
+ */
896
+ const DOC_TEXT_BUDGET = 12_000;
897
+ /** Cells are dense; a 40-row cap keeps a real invoice whole and a dump bounded. */
898
+ const MAX_TABLE_ROWS = 40;
899
+ const MAX_TABLES_RETURNED = 8;
900
+
901
+ /**
902
+ * ── ⭐⭐ THE SENTENCE THAT MAKES THE TWO SERVICES ONE CAPABILITY ─────────────
903
+ *
904
+ * `doc_read` extracts tables from a PDF's VECTOR layer — the ruling lines. A
905
+ * scan has no vector layer, so on exactly the documents that matter most (a
906
+ * photographed invoice, a signed quote) it truthfully returns `tables: []` while
907
+ * OCR flattens the grid into a paragraph of words in reading order.
908
+ *
909
+ * ⚠️⚠️ A FLATTENED TABLE IS WORSE THAN A MISSING ONE. Every number survives, so
910
+ * the text looks complete and a model will answer from it confidently — but the
911
+ * row-column relationship is gone, so "what did we charge for labour" moves from
912
+ * *unanswerable* to *wrong*. Nothing in the response marks the difference.
913
+ *
914
+ * ⭐ WHICH IS WHY THIS IS A NOTE ON THE RESULT AND NOT A LINE IN THE README.
915
+ * This package has already proven the principle twice: an error that said
916
+ * "returned no audio" bought four useless retries, and a plan banner that said
917
+ * "0/3 done" bought eight rounds, because neither NAMED THE VERB. A caller that
918
+ * is told the grid may be lost, and told which tool recovers it, can act in the
919
+ * same round.
920
+ */
921
+ const TABLE_ADVICE = 'this page was OCR\'d, and OCR flattens tables into '
922
+ + 'sentences — the rows and columns are gone from the text above. If the page '
923
+ + 'has a table you need, call read_table on the same path to recover the grid.';
924
+
925
+ /**
926
+ * Read a document a human handed over. PDF · DOCX · XLSX · PPTX · CSV · TXT ·
927
+ * MD · HTML · PNG/JPG/WEBP/TIFF, with OCR for pages that carry no text layer.
928
+ */
929
+ export async function readDocument(root, path, {
930
+ env = process.env, fetchImpl = fetch, ocr = 'auto', fromPage = 1, maxPages,
931
+ } = {}) {
932
+ const cfg = mediaConfig(env);
933
+ if (!cfg.docRead) return { ok: false, error: 'no document reader is configured (MODAL_DOC_READ_URL)' };
934
+
935
+ const mode = String(ocr || 'auto').toLowerCase();
936
+ if (!['auto', 'always', 'never'].includes(mode)) {
937
+ return { ok: false, error: `ocr must be auto, always or never — got "${ocr}"` };
938
+ }
939
+
940
+ const target = resolveInWorkspace(root, path, 'read');
941
+ if (!target.ok) return { ok: false, error: target.reason };
942
+
943
+ let buf;
944
+ try {
945
+ buf = readFileSync(target.absolute);
946
+ } catch (err) {
947
+ return { ok: false, error: `could not read ${target.relative}: ${err?.message ?? err}` };
948
+ }
949
+ if (buf.length > MAX_DOC_UPLOAD_BYTES) {
950
+ return {
951
+ ok: false,
952
+ error: `${target.relative} is ${(buf.length / 1e6).toFixed(1)} MB, over the `
953
+ + `${MAX_DOC_UPLOAD_MB} MB limit for a document read`,
954
+ };
955
+ }
956
+
957
+ /**
958
+ * ⚠️⚠️ THE FILENAME IS NOT COSMETIC AND MUST BE SENT. The worker classifies by
959
+ * MAGIC BYTES precisely because a caller's filename lies — but DOCX, XLSX and
960
+ * PPTX are all a ZIP with identical magic, and the extension is the only thing
961
+ * that separates them. Omit it and every Office document this agent is handed
962
+ * comes back "unsupported file type (zip-unknown)".
963
+ */
964
+ const filename = target.relative.split(/[\\/]/).pop() || 'document';
965
+
966
+ const res = await postJson(
967
+ cfg.docRead,
968
+ {
969
+ file_b64: buf.toString('base64'),
970
+ filename,
971
+ ocr: mode,
972
+ ...(Number.isFinite(maxPages) && maxPages > 0 ? { max_pages: Math.floor(maxPages) } : {}),
973
+ secret: cfg.secret ?? undefined,
974
+ },
975
+ { fetchImpl, label: 'the document reader', verb: 'read_document' },
976
+ );
977
+ if (!res.ok) return res;
978
+
979
+ const body = res.json ?? {};
980
+ const allPages = Array.isArray(body.pages) ? body.pages : [];
981
+
982
+ // ── The window. 1-based and clamped, because an out-of-range page is a typo,
983
+ // not a reason to return nothing and let the model conclude the file is
984
+ // empty — the failure this whole file exists to refuse.
985
+ const start = Math.max(1, Math.floor(Number(fromPage) || 1));
986
+ const windowed = [];
987
+ let used = 0;
988
+ let nextPage = null;
989
+ for (const p of allPages) {
990
+ const num = Number(p?.page ?? 0);
991
+ if (num < start) continue;
992
+ const text = String(p?.text ?? '');
993
+ if (used && used + text.length > DOC_TEXT_BUDGET) { nextPage = num; break; }
994
+ windowed.push({ page: num, text: text.slice(0, DOC_TEXT_BUDGET), ocr: p?.ocr === true, ...(p?.sheet ? { sheet: p.sheet } : {}) });
995
+ used += text.length;
996
+ }
997
+
998
+ const tables = [];
999
+ let tablesTruncated = false;
1000
+ for (const p of allPages) {
1001
+ if (tables.length >= MAX_TABLES_RETURNED) { tablesTruncated = true; break; }
1002
+ for (const grid of Array.isArray(p?.tables) ? p.tables : []) {
1003
+ if (!Array.isArray(grid) || !grid.length) continue;
1004
+ if (tables.length >= MAX_TABLES_RETURNED) { tablesTruncated = true; break; }
1005
+ tables.push({
1006
+ page: Number(p?.page ?? 0),
1007
+ rows: grid.slice(0, MAX_TABLE_ROWS).map((r) => (Array.isArray(r) ? r.map((c) => (c == null ? '' : String(c))) : [])),
1008
+ rowsTotal: grid.length,
1009
+ });
1010
+ if (grid.length > MAX_TABLE_ROWS) tablesTruncated = true;
1011
+ }
1012
+ }
1013
+
1014
+ const ocrPages = Array.isArray(body.ocr_pages) ? body.ocr_pages : [];
1015
+ const notes = [...(Array.isArray(body.notes) ? body.notes : [])];
1016
+ // ⭐ The cross-service instruction — see TABLE_ADVICE. Only when it can matter:
1017
+ // a page was OCR'd AND no grid was recovered from anywhere in the document.
1018
+ if (ocrPages.length && !tables.length) notes.push(`page${ocrPages.length > 1 ? 's' : ''} ${ocrPages.join(', ')}: ${TABLE_ADVICE}`);
1019
+
1020
+ return {
1021
+ ok: true,
1022
+ path: target.relative,
1023
+ kind: body.kind ?? 'unknown',
1024
+ pageCount: Number(body.page_count ?? allPages.length),
1025
+ pages: windowed,
1026
+ text: windowed.map((p) => p.text).filter(Boolean).join('\n\n').trim(),
1027
+ tables,
1028
+ tablesTruncated,
1029
+ ocrPages,
1030
+ notes,
1031
+ // ⚠️ Two different truncations, named separately. `truncated` is the
1032
+ // WORKER's (the document exceeded its own character ceiling); `nextPage` is
1033
+ // OURS (more pages exist and here is where to resume). Collapsing them would
1034
+ // tell a model to retry a window it already holds.
1035
+ truncated: body.truncated === true,
1036
+ nextPage,
1037
+ };
1038
+ }
1039
+
1040
+ /**
1041
+ * Recover the ROWS AND COLUMNS from a picture of a table — a scanned invoice, a
1042
+ * photographed price list, a table inside a PDF that has no vector ruling lines.
1043
+ * Table Transformer (MIT, code and weights both checked).
1044
+ */
1045
+ export async function readTable(root, path, { env = process.env, fetchImpl = fetch, page = 1, ocr = true } = {}) {
1046
+ const cfg = mediaConfig(env);
1047
+ if (!cfg.tableRead) return { ok: false, error: 'no table reader is configured (MODAL_TABLE_READ_URL)' };
1048
+
1049
+ const target = resolveInWorkspace(root, path, 'read');
1050
+ if (!target.ok) return { ok: false, error: target.reason };
1051
+
1052
+ let buf;
1053
+ try {
1054
+ buf = readFileSync(target.absolute);
1055
+ } catch (err) {
1056
+ return { ok: false, error: `could not read ${target.relative}: ${err?.message ?? err}` };
1057
+ }
1058
+ if (buf.length > MAX_DOC_UPLOAD_BYTES) {
1059
+ return {
1060
+ ok: false,
1061
+ error: `${target.relative} is ${(buf.length / 1e6).toFixed(1)} MB, over the `
1062
+ + `${MAX_DOC_UPLOAD_MB} MB limit for a table read`,
1063
+ };
1064
+ }
1065
+
1066
+ /**
1067
+ * ⚠️ SNIFFED, NOT TRUSTED — and for once the filename is the wrong signal even
1068
+ * though we control it, because `read_table` is most often pointed at whatever
1069
+ * `read_document` just complained about. `%PDF-` is five bytes and settles it.
1070
+ */
1071
+ const isPdf = buf.subarray(0, 5).toString('latin1') === '%PDF-';
1072
+ const b64 = buf.toString('base64');
1073
+
1074
+ const res = await postJson(
1075
+ cfg.tableRead,
1076
+ {
1077
+ ...(isPdf ? { pdf_b64: b64, page: Math.max(1, Math.floor(Number(page) || 1)) } : { image_b64: b64 }),
1078
+ ocr: ocr !== false,
1079
+ secret: cfg.secret ?? undefined,
1080
+ },
1081
+ { fetchImpl, label: 'the table reader', verb: 'read_table' },
1082
+ );
1083
+ if (!res.ok) return res;
1084
+
1085
+ const found = Array.isArray(res.json?.tables) ? res.json.tables : [];
1086
+
1087
+ /**
1088
+ * ⚠️ NO TABLE FOUND IS AN ANSWER, NOT A FAILURE. It genuinely means "there is
1089
+ * no table on this page", which is a useful thing to learn — but returning
1090
+ * `{ ok: true, tables: [] }` invites the model to retry the same call. So it
1091
+ * succeeds, and says what it looked at, so the next move is obvious.
1092
+ */
1093
+ const tables = found.slice(0, MAX_TABLES_RETURNED).map((t) => ({
1094
+ rows: Number(t?.rows ?? 0),
1095
+ cols: Number(t?.cols ?? 0),
1096
+ confidence: Number(t?.score ?? 0),
1097
+ grid: (Array.isArray(t?.grid) ? t.grid : []).slice(0, MAX_TABLE_ROWS)
1098
+ .map((r) => (Array.isArray(r) ? r.map((c) => (c == null ? '' : String(c))) : [])),
1099
+ rowsReturned: Math.min(Number(t?.rows ?? 0), MAX_TABLE_ROWS),
1100
+ }));
1101
+
1102
+ return {
1103
+ ok: true,
1104
+ path: target.relative,
1105
+ ...(isPdf ? { page: Math.max(1, Math.floor(Number(page) || 1)) } : {}),
1106
+ count: found.length,
1107
+ tables,
1108
+ ...(found.length ? {} : { note: `no table was detected on ${isPdf ? `page ${page} of ` : ''}${target.relative} — the page may have no table, or the image may be too low-resolution to find one` }),
1109
+ };
1110
+ }
1111
+
1112
+ /**
1113
+ * Only the tools whose service is configured.
1114
+ *
1115
+ * ⚠️ A TOOL THAT CANNOT WORK IS NEVER OFFERED. This package already learned it
1116
+ * the expensive way: a control that presents itself and does nothing is worse
1117
+ * than one that is absent, because the model presses it, waits, and apologises —
1118
+ * spending a round to discover what the schema could have said for free.
1119
+ */
1120
+ export function mediaToolSchemas(env = process.env) {
1121
+ const cfg = mediaConfig(env);
1122
+ const out = [];
1123
+
1124
+ if (cfg.render) {
1125
+ out.push({
1126
+ type: 'function',
1127
+ function: {
1128
+ name: 'see_page',
1129
+ description: [
1130
+ 'LOOK at an HTML file you wrote — renders it in a real browser and returns a screenshot',
1131
+ 'plus measured problems (invisible text, overflow, cramped sections).',
1132
+ 'Use it after building any page: you cannot judge a layout by reading its source, and this',
1133
+ 'is how you find the heading that is white on white. Saves the screenshot into the workspace.',
1134
+ ].join(' '),
1135
+ parameters: {
1136
+ type: 'object',
1137
+ properties: { path: { type: 'string', description: 'Workspace-relative path to an .html file.' } },
1138
+ required: ['path'],
1139
+ },
1140
+ },
1141
+ });
1142
+ }
1143
+
1144
+ if (cfg.speak) {
1145
+ out.push({
1146
+ type: 'function',
1147
+ function: {
1148
+ name: 'speak',
1149
+ description: [
1150
+ 'Turn text into speech and save it as an audio file in the workspace.',
1151
+ 'It reads in a FIXED voice — it cannot clone one, so do not offer to make it sound like anybody.',
1152
+ 'Leave `engine` unset; there is only one speech engine here and naming another is refused.',
1153
+ ].join(' '),
1154
+ parameters: {
1155
+ type: 'object',
1156
+ properties: {
1157
+ text: { type: 'string', description: 'What to say. Up to 5,000 characters.' },
1158
+ path: { type: 'string', description: 'Optional output path, e.g. "audio/intro.wav".' },
1159
+ engine: {
1160
+ type: 'string',
1161
+ description: 'Only if the user named an engine. Voice cloning ("acuvo-voice") is not reachable from the CLI and will be refused with an explanation.',
1162
+ },
1163
+ },
1164
+ required: ['text'],
1165
+ },
1166
+ },
1167
+ });
1168
+ }
1169
+
1170
+ if (cfg.transcribe) {
1171
+ out.push({
1172
+ type: 'function',
1173
+ function: {
1174
+ name: 'transcribe',
1175
+ description: 'Transcribe an audio or video file in the workspace, with timestamped segments.',
1176
+ parameters: {
1177
+ type: 'object',
1178
+ properties: { path: { type: 'string', description: 'Workspace-relative path to the media file.' } },
1179
+ required: ['path'],
1180
+ },
1181
+ },
1182
+ });
1183
+ }
1184
+
1185
+ if (cfg.document) {
1186
+ out.push({
1187
+ type: 'function',
1188
+ function: {
1189
+ name: 'make_document',
1190
+ description: [
1191
+ 'Turn an HTML file into a real PDF, PNG or PPTX saved in the workspace.',
1192
+ 'Use it when the user asks for a document, a deck or an export — not for a web page.',
1193
+ ].join(' '),
1194
+ parameters: {
1195
+ type: 'object',
1196
+ properties: {
1197
+ path: { type: 'string', description: 'Workspace-relative .html file to convert.' },
1198
+ format: { type: 'string', enum: ['pdf', 'png', 'pptx'], description: 'Output format.' },
1199
+ out: { type: 'string', description: 'Optional output path.' },
1200
+ },
1201
+ required: ['path', 'format'],
1202
+ },
1203
+ },
1204
+ });
1205
+ }
1206
+
1207
+ if (cfg.docRead) {
1208
+ out.push({
1209
+ type: 'function',
1210
+ function: {
1211
+ name: 'read_document',
1212
+ description: [
1213
+ 'READ a document the user gave you — PDF, Word, Excel, PowerPoint, CSV, HTML or a photo of a page.',
1214
+ 'Returns the text, any tables, and which pages had to be OCR\'d.',
1215
+ 'Use it before building anything from a supplied file: a spec, an invoice, a price list, a brief.',
1216
+ 'A scanned PDF works — pages with no text layer are OCR\'d automatically.',
1217
+ 'Long documents come back one page-window at a time; the result says which page to resume from.',
1218
+ ].join(' '),
1219
+ parameters: {
1220
+ type: 'object',
1221
+ properties: {
1222
+ path: { type: 'string', description: 'Workspace-relative path to the document.' },
1223
+ from_page: { type: 'number', description: 'First page to return. Default 1. Use the nextPage from a previous call to continue.' },
1224
+ max_pages: { type: 'number', description: 'How many pages the reader should process at all. Default 60, maximum 200.' },
1225
+ ocr: {
1226
+ type: 'string',
1227
+ enum: ['auto', 'always', 'never'],
1228
+ description: 'auto (default) OCRs only pages with no text layer; always forces it when a PDF\'s text layer is a bad prior OCR; never disables it.',
1229
+ },
1230
+ },
1231
+ required: ['path'],
1232
+ },
1233
+ },
1234
+ });
1235
+ }
1236
+
1237
+ /**
1238
+ * ⚠️⚠️ THE "PREFER read_document" LINE IS MEASURED, NOT POLITE. Proven live
1239
+ * 2026-08-12 on the same one-page quote — a digital PDF with a real text layer:
1240
+ *
1241
+ * read_document TOTAL … 1000 Amount (from the vector layer, exact)
1242
+ * read_table TOTAL … 1001 Am (5x4 grid found at conf 0.999,
1243
+ * cells OCR'd off a 200-DPI render)
1244
+ *
1245
+ * ⭐ The GEOMETRY was perfect and the CHARACTERS were not, which is exactly
1246
+ * right for what it is: this service reads a PICTURE of a page. On a scan that
1247
+ * is the only thing that can work; on a digital PDF it is a strictly worse
1248
+ * answer that looks equally confident. A tool that is better in one direction
1249
+ * and worse in the other must say which, or it gets used as an upgrade.
1250
+ */
1251
+ if (cfg.tableRead) {
1252
+ out.push({
1253
+ type: 'function',
1254
+ function: {
1255
+ name: 'read_table',
1256
+ description: [
1257
+ 'Recover the ROWS AND COLUMNS from a picture of a table — a scanned invoice, a photographed',
1258
+ 'price list, or a PDF page whose table read_document could not see.',
1259
+ 'Use it whenever you need cell-level accuracy: OCR text keeps every number but destroys which',
1260
+ 'number belongs to which line item, so answering from flattened text is how you get it wrong.',
1261
+ 'Takes a PDF (with a page number) or an image file.',
1262
+ 'PREFER the tables read_document already returned when it returned any — this reads the page as a',
1263
+ 'PICTURE, so on a document that has a real text layer it is a downgrade, not a second opinion.',
1264
+ ].join(' '),
1265
+ parameters: {
1266
+ type: 'object',
1267
+ properties: {
1268
+ path: { type: 'string', description: 'Workspace-relative path to a PDF or an image.' },
1269
+ page: { type: 'number', description: 'Which page of a PDF, 1-based. Ignored for images. Default 1.' },
1270
+ },
1271
+ required: ['path'],
1272
+ },
1273
+ },
1274
+ });
1275
+ }
1276
+
1277
+ return out;
1278
+ }
1279
+
1280
+ /** Names only — the offer list needs these without building the schemas twice. */
1281
+ export function mediaToolNames(env = process.env) {
1282
+ return mediaToolSchemas(env).map((t) => t.function.name);
1283
+ }