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/imagegen.mjs
ADDED
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐ IMAGERY IN THE LOOP, WITH NO SETUP AND NO ACCOUNT ─────────────────────
|
|
3
|
+
*
|
|
4
|
+
* Roman, 2026-08-09: *"make sure Perchance is real and working and is native to
|
|
5
|
+
* Acuvo for CLI and browser builder… native abilities, as if we were a lab that
|
|
6
|
+
* built out Acuvo AI."*
|
|
7
|
+
*
|
|
8
|
+
* This is `generate_image` for the terminal. Ask Acuvo Code for a landing page
|
|
9
|
+
* and it writes the markup AND produces the hero shot, in the same session, into
|
|
10
|
+
* your own repo — zero configuration, no account, no key.
|
|
11
|
+
*
|
|
12
|
+
* ── ⚠️ THIS BANNER USED TO READ "THE THING NO OTHER CODING AGENT CAN DO" ─────
|
|
13
|
+
* It was struck 2026-08-11, for the same reason the README's "nobody else can
|
|
14
|
+
* see" was struck the day before: it is FALSE and it dies on first contact.
|
|
15
|
+
* Any agent with an MCP image server generates images, and the old text went
|
|
16
|
+
* further — "Claude Code and Codex cannot do this at any price" — which a
|
|
17
|
+
* customer disproves in one install. A claim that cannot survive a demo makes
|
|
18
|
+
* every other claim in the file suspect, and this file has several that are
|
|
19
|
+
* true.
|
|
20
|
+
*
|
|
21
|
+
* ⭐ WHAT IS ACTUALLY DEFENSIBLE, and it is not the GPU:
|
|
22
|
+
* 1. ZERO SETUP. It works on a fresh clone with no key and no config, and a
|
|
23
|
+
* capability you have to discover and configure is one most people never
|
|
24
|
+
* see. That is a product decision, not a moat, and it is worth more here
|
|
25
|
+
* than either.
|
|
26
|
+
* 2. ⭐⭐ THE CRITIC. The generator is wrapped in generate → LOOK → re-direct
|
|
27
|
+
* (`image-director.mjs`), so the image is scored before it is accepted and
|
|
28
|
+
* an unreviewed one is reported as unreviewed. A generator without a critic
|
|
29
|
+
* is a slot machine, and an agent that cannot see will reference a smeared
|
|
30
|
+
* illegible hero across four pages with total confidence — which is a thing
|
|
31
|
+
* that happened here, on our own coffee site.
|
|
32
|
+
* Price and pitch on those two. Neither needs anyone else to be incapable.
|
|
33
|
+
*
|
|
34
|
+
* ── ⚠️ IT WRITES A FILE, IT DOES NOT RETURN AN IMAGE ─────────────────────────
|
|
35
|
+
* The tool result handed back to the model is a PATH and a byte count, never the
|
|
36
|
+
* image itself. Two reasons, and the second is the one that would actually hurt:
|
|
37
|
+
* 1. A 200KB PNG is ~270KB of base64, which would blow the context window on
|
|
38
|
+
* one call.
|
|
39
|
+
* 2. The model cannot see it anyway — `deepseek-v4-flash` is text-only. Handing
|
|
40
|
+
* it pixels would cost a fortune to be ignored. (The model that CAN see —
|
|
41
|
+
* qwen3.7-flash — is the critic in the browser builder's render loop, a
|
|
42
|
+
* different job on a different surface.)
|
|
43
|
+
*
|
|
44
|
+
* ── ⚠️⚠️ PERCHANCE CLOSED THE DOOR ON 2026-08-11. READ THIS FIRST. ───────────
|
|
45
|
+
* This header used to say the default engine was Perchance, direct over HTTP/2,
|
|
46
|
+
* 3 seconds. The transport claim is still true. The capability is not:
|
|
47
|
+
*
|
|
48
|
+
* GET /api/verifyUser -> {"status":"failed_verification",
|
|
49
|
+
* "reason":"token_required"}
|
|
50
|
+
*
|
|
51
|
+
* Four consecutive calls, seconds apart, identical every time. Anonymous
|
|
52
|
+
* generation now needs an account token. ⭐ THIS IS A POLICY CHANGE, NOT A
|
|
53
|
+
* TECHNICAL DEFEAT — our HTTP/2 + Safari-cipher fingerprint still gets us a real
|
|
54
|
+
* JSON answer from their private API rather than a Cloudflare wall, which is the
|
|
55
|
+
* hard part and it still works. They simply stopped serving strangers.
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ AND NOTHING FAILED LOUDLY, WHICH IS THE PART TO LEARN FROM. Every test here
|
|
58
|
+
* was mocked, so the suite stayed green for as long as it took someone to run
|
|
59
|
+
* the real chain. A provider is the one dependency a mock cannot vouch for.
|
|
60
|
+
*
|
|
61
|
+
* ⭐ WHAT ACTUALLY DRAWS YOUR IMAGE TODAY: Pollinations, the keyless fallback.
|
|
62
|
+
* It is free, lower quality, and notably weaker at lettering than the engine it
|
|
63
|
+
* replaced — so a hero with a legible sign on it is no longer something this
|
|
64
|
+
* chain can promise. Perchance stays in the chain because it costs one ~0.4s
|
|
65
|
+
* probe per process to find out whether the door reopened, and the breaker makes
|
|
66
|
+
* sure it is exactly one; it is not tried again after the first refusal.
|
|
67
|
+
*
|
|
68
|
+
* ⚠️ There is a better answer we own and cannot reach from here: the
|
|
69
|
+
* `acuvo-flux-studio` Modal app (shuttle-3.1-aesthetic, Apache-2.0, ungated,
|
|
70
|
+
* ~$0.0016 an image WARM — ⚠️ a per-render figure that ignores cold start and
|
|
71
|
+
* scaledown exactly as the $0.003 one did, so treat it as a floor, not a
|
|
72
|
+
* price). It needs a URL and `ACUVO_SHARED_SECRET`, neither of which
|
|
73
|
+
* exists on a fresh clone, and it is async (spawn a callId, poll `/result`). It
|
|
74
|
+
* is deliberately NOT wired here yet, because plumbing a provider that cannot be
|
|
75
|
+
* run even once is how this package ends up with another capability that is
|
|
76
|
+
* built and unproven. It is also weak at text in images, so it would not restore
|
|
77
|
+
* what Perchance was good at — do not let anyone swap it in and call the
|
|
78
|
+
* capability unchanged.
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
import { writeFileSync, readFileSync } from 'node:fs';
|
|
82
|
+
import { join } from 'node:path';
|
|
83
|
+
import { resolveInWorkspace } from './workspace.mjs';
|
|
84
|
+
import { throughBreaker, deadReason, skipMessage, markUnreachable } from './breaker.mjs';
|
|
85
|
+
/**
|
|
86
|
+
* ⚠️ ONLY THE ENGINES WE PAY FOR ARE CHARGED. Pollinations and perchance.org
|
|
87
|
+
* are somebody else's machines and cost us exactly $0.00 — recording a
|
|
88
|
+
* zero-dollar entry for them would grow a "GPU spend" section on runs that
|
|
89
|
+
* spent none of our money. `chargeGpu` returns null for a free provider, and
|
|
90
|
+
* the free paths below simply do not call it.
|
|
91
|
+
*/
|
|
92
|
+
import { chargeGpu } from './budget.mjs';
|
|
93
|
+
import { directImage } from './image-director.mjs';
|
|
94
|
+
import { generateNative } from './perchance.mjs';
|
|
95
|
+
/**
|
|
96
|
+
* ⭐ ENGINE CHOICE, AND IT DELIBERATELY BRINGS NO PRICES WITH IT. That module
|
|
97
|
+
* holds ids and names only; the credit cost of an image is an account fact that
|
|
98
|
+
* lives on the server. See its header for why a published npm package must not
|
|
99
|
+
* carry a price list.
|
|
100
|
+
*/
|
|
101
|
+
import { checkEngine, runEngineFor } from './creative-engines.mjs';
|
|
102
|
+
|
|
103
|
+
/** Where the service lives. Absent ⇒ the tool is not offered at all. */
|
|
104
|
+
export const IMAGE_URL_ENV = 'PERCHANCE_IMAGE_URL';
|
|
105
|
+
export const IMAGE_TOKEN_ENV = 'PERCHANCE_IMAGE_TOKEN';
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* ── ⚠️⭐ SET BY MEASURED SUCCESS, NOT BY HOPE ───────────────────────────────
|
|
109
|
+
*
|
|
110
|
+
* This was 180s, justified as "generous". Measured 2026-08-10, both ends:
|
|
111
|
+
* · the LOCAL perchance-server returns a real image in **49s**
|
|
112
|
+
* · the HOSTED default returns **502 after 303s** — "no finished image
|
|
113
|
+
* within 300s"
|
|
114
|
+
*
|
|
115
|
+
* So 180s bought nothing in either direction. It is 3.6x longer than a success
|
|
116
|
+
* needs, and it still gives up two minutes before the broken one admits defeat.
|
|
117
|
+
* All that extra patience does is spend a coding agent's round budget waiting
|
|
118
|
+
* for an answer that was never coming.
|
|
119
|
+
*
|
|
120
|
+
* ⭐ 90s is comfortably twice the measured success and short enough that a dead
|
|
121
|
+
* provider costs one round instead of the session. The breaker then makes sure
|
|
122
|
+
* we only pay it once. If a provider genuinely needs longer than 90 seconds to
|
|
123
|
+
* draw a picture, it does not belong in an interactive loop.
|
|
124
|
+
*/
|
|
125
|
+
const FETCH_TIMEOUT_MS = 90_000;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* ── ⭐⭐ EVERYONE GETS PERCHANCE. IT IS BAKED IN, NOT CONFIGURED. ────────────
|
|
129
|
+
*
|
|
130
|
+
* Roman, 2026-08-10: *"perchance is our native image gen, it will be baked
|
|
131
|
+
* inside of Acuvo CLI, it's a no brainer — like Gemini having their image model
|
|
132
|
+
* activated when asked. Inside Gemini Code ours is perchance, and FAL for
|
|
133
|
+
* premium users alongside perchance. Everyone gets perchance."*
|
|
134
|
+
*
|
|
135
|
+
* ⚠️ AND UNTIL NOW THAT WAS FALSE FOR EVERY INSTALLED COPY. `configured` was
|
|
136
|
+
* `base.length > 0` against an env var, so `generate_image` was offered only on
|
|
137
|
+
* a machine where somebody had already set `PERCHANCE_IMAGE_URL` — i.e. ours,
|
|
138
|
+
* with a dev server on localhost:8080. Anyone who cloned the CLI got no image
|
|
139
|
+
* capability at all and no indication that one existed. A native capability
|
|
140
|
+
* that requires the user to know a URL is not native; it is a hidden feature.
|
|
141
|
+
*
|
|
142
|
+
* ⭐ THE DEFAULT IS NOW NATIVE — no URL at all. `generateThroughProviders` goes
|
|
143
|
+
* straight to perchance.org over HTTP/2 when this variable is unset, and this
|
|
144
|
+
* hosted endpoint is only used when someone points at it explicitly.
|
|
145
|
+
*
|
|
146
|
+
* ⚠️ RETIRED AS A DEFAULT BECAUSE IT WAS THREE PROBLEMS AT ONCE, all measured:
|
|
147
|
+
* it returned 502 after 303 seconds (so it never worked for anybody who
|
|
148
|
+
* installed this), it was an unauthenticated compute bill payable by us from
|
|
149
|
+
* every installed copy, and it routed a stranger's prompt through OUR server.
|
|
150
|
+
* Going direct removes all three, and it is 100x faster.
|
|
151
|
+
*
|
|
152
|
+
* Kept exported because `PERCHANCE_IMAGE_URL` may still point here, and because
|
|
153
|
+
* deleting the constant would silently change what an existing config means.
|
|
154
|
+
*/
|
|
155
|
+
export const DEFAULT_IMAGE_URL = 'https://xxiautomate-star--acuvo-perchance-images-serve.modal.run';
|
|
156
|
+
|
|
157
|
+
export function imageConfig(env = process.env) {
|
|
158
|
+
const override = (env[IMAGE_URL_ENV] || '').trim();
|
|
159
|
+
/**
|
|
160
|
+
* ⚠️ AN EXPLICIT EMPTY STRING IS NOT "USE THE DEFAULT" — but an UNSET variable
|
|
161
|
+
* is. Someone who sets `PERCHANCE_IMAGE_URL=` is deliberately turning the
|
|
162
|
+
* capability off (an air-gapped machine, a policy that forbids the call), and
|
|
163
|
+
* silently reinstating our endpoint would override an intentional decision.
|
|
164
|
+
*/
|
|
165
|
+
const disabled = IMAGE_URL_ENV in env && override === '';
|
|
166
|
+
const base = disabled ? '' : (override || DEFAULT_IMAGE_URL);
|
|
167
|
+
return {
|
|
168
|
+
base,
|
|
169
|
+
token: (env[IMAGE_TOKEN_ENV] || '').trim(),
|
|
170
|
+
configured: base.length > 0,
|
|
171
|
+
// ⭐ Reported so the CLI can say WHICH engine answered — a user debugging a
|
|
172
|
+
// bad image needs to know whether it came from us or from their override.
|
|
173
|
+
usingDefault: !disabled && !override,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The service's route is `POST /generate`. Accept the env var with or WITHOUT
|
|
179
|
+
* the path — a bare host 404'd silently the first time this was wired in the
|
|
180
|
+
* console, and repeating that here would produce the same ten-minute mystery.
|
|
181
|
+
*/
|
|
182
|
+
export function generateEndpoint(base) {
|
|
183
|
+
const trimmed = base.replace(/\/$/, '');
|
|
184
|
+
return trimmed.endsWith('/generate') ? trimmed : `${trimmed}/generate`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* ⚠️ THE FILENAME IS DERIVED FROM THE PROMPT, NOT SUPPLIED BY THE MODEL, and
|
|
189
|
+
* then passed through the executor's own path safety. A model choosing where to
|
|
190
|
+
* write a binary is the same traversal risk as any other write, and giving it a
|
|
191
|
+
* separate un-checked path parameter would quietly reopen the hole
|
|
192
|
+
* `workspace.mjs` exists to close.
|
|
193
|
+
*/
|
|
194
|
+
export function suggestFilename(prompt) {
|
|
195
|
+
const slug = String(prompt || 'image')
|
|
196
|
+
.toLowerCase()
|
|
197
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
198
|
+
.replace(/^-+|-+$/g, '')
|
|
199
|
+
.slice(0, 40) || 'image';
|
|
200
|
+
return `${slug}.png`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Ask the service for one image and write it into the workspace.
|
|
205
|
+
*
|
|
206
|
+
* `executor` supplies the path safety — the bytes are written through
|
|
207
|
+
* `resolveWritablePath` exactly like any other file, so an image cannot land
|
|
208
|
+
* anywhere a `write_file` could not.
|
|
209
|
+
*/
|
|
210
|
+
export async function generateViaService(
|
|
211
|
+
{ prompt, width = 1200, height = 800, executor, env = process.env, fetchImpl = fetch },
|
|
212
|
+
) {
|
|
213
|
+
const cfg = imageConfig(env);
|
|
214
|
+
if (!cfg.configured) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
error:
|
|
218
|
+
`No ${IMAGE_URL_ENV} in the environment, so there is no image service to ask.\n` +
|
|
219
|
+
`Deploy it with: modal deploy gpu/modal/perchance_images.py\n` +
|
|
220
|
+
`then set ${IMAGE_URL_ENV} to the URL Modal returns.`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (!prompt || !String(prompt).trim()) return { ok: false, error: 'an image needs a prompt' };
|
|
224
|
+
|
|
225
|
+
const rel = suggestFilename(prompt);
|
|
226
|
+
/**
|
|
227
|
+
* ⚠️ RESOLVED THROUGH THE EXECUTOR'S OWN PATH SAFETY, and resolved BEFORE the
|
|
228
|
+
* 54-second render. Discovering the destination is refused AFTER the wait is a
|
|
229
|
+
* minute of someone's life spent on nothing, and reusing `resolveInWorkspace`
|
|
230
|
+
* means an image can never land somewhere a `write_file` could not — one
|
|
231
|
+
* safety rule, not a second one written for binaries.
|
|
232
|
+
*/
|
|
233
|
+
const dest = resolveInWorkspace(executor.root, rel, 'write');
|
|
234
|
+
if (!dest.ok) return { ok: false, error: dest.reason };
|
|
235
|
+
|
|
236
|
+
const headers = { 'content-type': 'application/json' };
|
|
237
|
+
if (cfg.token) headers.authorization = `Bearer ${cfg.token}`;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* ⚠️ THE BREAKER GOES HERE, AND THIS EXACT CALL IS WHY IT EXISTS. Measured on
|
|
241
|
+
* a real four-page website build: the service was down, every attempt waited
|
|
242
|
+
* the full 180s, and the model tried three times across two rounds — six
|
|
243
|
+
* minutes of a five-round budget — because the error below used to end with
|
|
244
|
+
* "try once more". It read that as an instruction, which it is.
|
|
245
|
+
*/
|
|
246
|
+
const endpoint = generateEndpoint(cfg.base);
|
|
247
|
+
const alreadyDead = deadReason(endpoint);
|
|
248
|
+
if (alreadyDead) {
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
error: skipMessage('The image service', endpoint),
|
|
252
|
+
// ⚠️ See `reasonOf` below: the sentence above ENDS in an order, and this
|
|
253
|
+
// failure may yet be followed by a successful fallback.
|
|
254
|
+
reason: `the self-hosted image service did not answer earlier this run (${alreadyDead})`,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let res;
|
|
259
|
+
// ⚠️ CHARGED TOO. `PERCHANCE_IMAGE_URL` points at a container someone is
|
|
260
|
+
// paying for — ours by default (`DEFAULT_IMAGE_URL` is a Modal app). "Free"
|
|
261
|
+
// describes perchance.org itself, never a service we host in front of it.
|
|
262
|
+
const startedAt = Date.now();
|
|
263
|
+
try {
|
|
264
|
+
res = await throughBreaker(endpoint, 'The image service', () => fetchImpl(endpoint, {
|
|
265
|
+
method: 'POST',
|
|
266
|
+
headers,
|
|
267
|
+
body: JSON.stringify({ prompt: String(prompt).trim(), width, height }),
|
|
268
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
269
|
+
}));
|
|
270
|
+
} catch (err) {
|
|
271
|
+
/**
|
|
272
|
+
* ⚠️ NO LONGER "try once more". A cold container is still the likeliest
|
|
273
|
+
* cause, but the advice has to be aimed at the reader — and the reader is a
|
|
274
|
+
* model with a round budget, not a human who can wait and come back. The
|
|
275
|
+
* breaker has already recorded this endpoint, so a second call in this run
|
|
276
|
+
* returns instantly.
|
|
277
|
+
*/
|
|
278
|
+
const why = err?.name === 'TimeoutError'
|
|
279
|
+
? `no response in ${FETCH_TIMEOUT_MS / 1000}s (it may be cold starting, but waiting again would cost another ${FETCH_TIMEOUT_MS / 1000}s)`
|
|
280
|
+
: String(err?.message || err);
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
error: `image service unreachable: ${why}. Continue without the image — do not call this tool again in this run — and say in your summary that it was unavailable.`,
|
|
284
|
+
reason: `the self-hosted image service was unreachable (${why})`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
chargeGpu({ verb: 'generate_image', seconds: (Date.now() - startedAt) / 1000, endpoint });
|
|
289
|
+
|
|
290
|
+
if (!res.ok) {
|
|
291
|
+
const snippet = (await res.text().catch(() => '')).slice(0, 200);
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
error: res.status === 401
|
|
295
|
+
? `image service returned 401 — ${IMAGE_TOKEN_ENV} must match the service's SHARED_TOKEN (same value, two different names)`
|
|
296
|
+
: `image service returned HTTP ${res.status}${snippet ? `: ${snippet}` : ''}`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let b64;
|
|
301
|
+
try {
|
|
302
|
+
const json = await res.json();
|
|
303
|
+
b64 = json?.image_b64;
|
|
304
|
+
} catch {
|
|
305
|
+
return { ok: false, error: 'image service returned a body that was not JSON' };
|
|
306
|
+
}
|
|
307
|
+
// ⚠️ A 200 WITH NO IMAGE IS A FAILURE, NOT AN EMPTY IMAGE. Writing a zero-byte
|
|
308
|
+
// PNG would leave a broken file on disk that looks like a successful result —
|
|
309
|
+
// the same rule the render audit states about empty measurements.
|
|
310
|
+
if (typeof b64 !== 'string' || b64.length < 100) {
|
|
311
|
+
return { ok: false, error: 'image service returned no image' };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const bytes = Buffer.from(b64, 'base64');
|
|
315
|
+
try {
|
|
316
|
+
writeFileSync(dest.absolute, bytes);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
return { ok: false, error: `could not write ${rel}: ${String(err?.message || err)}` };
|
|
319
|
+
}
|
|
320
|
+
return { ok: true, path: rel, bytes: bytes.length, width, height, provider: 'perchance' };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* ── ⭐⭐ OUR OWN GPU — THE ENGINE THAT CANNOT BE TAKEN AWAY ──────────────────
|
|
325
|
+
*
|
|
326
|
+
* Measured live 2026-08-12, three renders through this exact endpoint:
|
|
327
|
+
*
|
|
328
|
+
* cold start 13.1s · then 10.5s · 8.9s · 8.8s A10G, segmind/SSD-1B
|
|
329
|
+
* 1024×1024 PNG, 1.0–1.8MB, and the pictures are GOOD — correct bicycle spoke
|
|
330
|
+
* and chain geometry, real depth of field, a usable corporate headshot.
|
|
331
|
+
*
|
|
332
|
+
* ⭐ WHY THIS IS THE PRIMARY AND NOT A THIRD OPTION. Every free image source we
|
|
333
|
+
* have leaned on has closed: Perchance now answers `{"status":"not_verified"}`
|
|
334
|
+
* behind a human verification wall — measured through our own headless browser,
|
|
335
|
+
* which hung and had to be aborted at 150s — and Pollinations throttles from
|
|
336
|
+
* 2.6s to 45s once you actually use it. This one is ours. Nobody can put a wall
|
|
337
|
+
* in front of it.
|
|
338
|
+
*
|
|
339
|
+
* ⚠️⚠️ THIS PARAGRAPH SAID "roughly $0.003 a render" AND THAT NUMBER WAS WRONG
|
|
340
|
+
* BY 13x. It counted the ~10 warm render seconds and nothing else. A cold,
|
|
341
|
+
* isolated render bills ~130 seconds once the container boot, the 4.5GB weight
|
|
342
|
+
* pull and Modal's 60s scaledown window are included: **$0.0398**. The $0.003
|
|
343
|
+
* figure is only true for the SECOND and later images of the same run. The
|
|
344
|
+
* arithmetic is in `priceGpuCall` (budget.mjs) and it is what the ledger now
|
|
345
|
+
* charges, so the pricing claim and the meter cannot drift apart again.
|
|
346
|
+
*
|
|
347
|
+
* ⚠️ IT FAILS SHUT WITHOUT A SECRET, and that is deliberate on a PAID GPU. A
|
|
348
|
+
* missing credential must never mean "open to everyone" — the service itself
|
|
349
|
+
* enforces this (it returns `{"ok":false,"error":"unauthorised"}`, verified),
|
|
350
|
+
* and the client agrees rather than firing a request that cannot succeed. With
|
|
351
|
+
* no secret this leg is DARK, not broken, and the chain moves on quietly.
|
|
352
|
+
*
|
|
353
|
+
* ⚠️ THE TIMEOUT IS 120s, NOT 60s, and that is the whole reason this capability
|
|
354
|
+
* looked dead for a week. A cold container spends its first seconds pulling
|
|
355
|
+
* 4.5GB of weights; the console's 60s budget aborted before the GPU had
|
|
356
|
+
* finished waking, so a WORKING engine reported as unreachable. The failure was
|
|
357
|
+
* never in the model — it was in the caller's patience.
|
|
358
|
+
*/
|
|
359
|
+
export const ENGINE_URL_ENV = 'ACUVO_IMAGE_ENGINE_URL';
|
|
360
|
+
export const ENGINE_SECRET_ENV = 'ACUVO_IMAGE_SECRET';
|
|
361
|
+
export const DEFAULT_ENGINE_URL = 'https://xxiautomate-star--acuvo-image-engine-engine-web.modal.run';
|
|
362
|
+
export const ENGINE_TIMEOUT_MS = 120_000;
|
|
363
|
+
|
|
364
|
+
export function engineConfig(env = process.env) {
|
|
365
|
+
const override = (env[ENGINE_URL_ENV] || '').trim();
|
|
366
|
+
const disabled = ENGINE_URL_ENV in env && override === '';
|
|
367
|
+
const base = disabled ? '' : (override || DEFAULT_ENGINE_URL);
|
|
368
|
+
const secret = (env[ENGINE_SECRET_ENV] || env.MODAL_VIDEO_SECRET || '').trim();
|
|
369
|
+
return { base, secret, configured: base.length > 0 && secret.length > 0 };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function generateViaOwnEngine(
|
|
373
|
+
{ prompt, width = 1024, height = 1024, seed = null, executor, env = process.env, fetchImpl = fetch },
|
|
374
|
+
) {
|
|
375
|
+
const cfg = engineConfig(env);
|
|
376
|
+
if (!cfg.configured) {
|
|
377
|
+
return {
|
|
378
|
+
ok: false,
|
|
379
|
+
error: `our own image engine is not configured — set ${ENGINE_SECRET_ENV} (the shared secret the Modal service expects).`,
|
|
380
|
+
reason: `our own GPU engine is dark (no ${ENGINE_SECRET_ENV})`,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
if (!prompt || !String(prompt).trim()) return { ok: false, error: 'an image needs a prompt' };
|
|
384
|
+
|
|
385
|
+
const rel = suggestFilename(prompt);
|
|
386
|
+
const dest = resolveInWorkspace(executor.root, rel, 'write');
|
|
387
|
+
if (!dest.ok) return { ok: false, error: dest.reason };
|
|
388
|
+
|
|
389
|
+
const endpoint = `${cfg.base.replace(/\/$/, '')}/generate-image`;
|
|
390
|
+
const alreadyDead = deadReason(endpoint);
|
|
391
|
+
if (alreadyDead) {
|
|
392
|
+
return {
|
|
393
|
+
ok: false,
|
|
394
|
+
error: skipMessage('Our image engine', endpoint),
|
|
395
|
+
reason: `our own GPU engine did not answer earlier this run (${alreadyDead})`,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
let res;
|
|
400
|
+
/**
|
|
401
|
+
* ── ⭐⭐ THE MOST EXPENSIVE VERB IN THE PACKAGE, AND IT COST $0 ON THE BOOKS ─
|
|
402
|
+
*
|
|
403
|
+
* ⚠️ AND THE PRICE EVERYWHERE IN THIS FILE WAS WRONG BY 13x. See
|
|
404
|
+
* `priceGpuCall` in budget.mjs: a cold, isolated render bills ~130 seconds —
|
|
405
|
+
* 60s of container boot and weight pull, ~10s of render, then Modal's 60s
|
|
406
|
+
* scaledown window — which is **$0.0398**, not the $0.003 this file used to
|
|
407
|
+
* quote. $0.003 was the render seconds alone, as if the container were
|
|
408
|
+
* already up and would never go away. The second image of the same run really
|
|
409
|
+
* is ~$0.003, which is why the cold start is charged once per endpoint.
|
|
410
|
+
*/
|
|
411
|
+
const startedAt = Date.now();
|
|
412
|
+
try {
|
|
413
|
+
res = await throughBreaker(endpoint, 'Our image engine', () => fetchImpl(endpoint, {
|
|
414
|
+
method: 'POST',
|
|
415
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.secret}` },
|
|
416
|
+
/**
|
|
417
|
+
* ⚠️ THE SECRET GOES IN THE BODY AS WELL AS THE HEADER, because the
|
|
418
|
+
* service accepts either and a proxy that strips `Authorization` would
|
|
419
|
+
* otherwise turn a working call into an unexplainable 401.
|
|
420
|
+
*/
|
|
421
|
+
body: JSON.stringify({ prompt: String(prompt).trim(), width, height, ...(seed === null ? {} : { seed }), secret: cfg.secret }),
|
|
422
|
+
signal: AbortSignal.timeout(ENGINE_TIMEOUT_MS),
|
|
423
|
+
}));
|
|
424
|
+
} catch (err) {
|
|
425
|
+
const why = err?.name === 'TimeoutError'
|
|
426
|
+
? `no response in ${ENGINE_TIMEOUT_MS / 1000}s`
|
|
427
|
+
: String(err?.message || err);
|
|
428
|
+
return {
|
|
429
|
+
ok: false,
|
|
430
|
+
error: `our image engine was unreachable: ${why}.`,
|
|
431
|
+
reason: `our own GPU engine was unreachable (${why})`,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* ⚠️ CHARGED ON ANY RESPONSE, INCLUDING A REFUSAL. This service answers
|
|
437
|
+
* `200 {"ok":false,"error":"unauthorised"}` — measured — and a container that
|
|
438
|
+
* answers is a container that booted, ran and billed. Only the no-response
|
|
439
|
+
* path above escapes the charge, because nothing ran there.
|
|
440
|
+
*/
|
|
441
|
+
chargeGpu({ verb: 'generate_image', seconds: (Date.now() - startedAt) / 1000, endpoint });
|
|
442
|
+
|
|
443
|
+
if (!res.ok) {
|
|
444
|
+
const snippet = (await res.text().catch(() => '')).slice(0, 200);
|
|
445
|
+
return {
|
|
446
|
+
ok: false,
|
|
447
|
+
error: `our image engine returned HTTP ${res.status}${snippet ? `: ${snippet}` : ''}`,
|
|
448
|
+
reason: `our own GPU engine returned HTTP ${res.status}`,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
let json;
|
|
453
|
+
try {
|
|
454
|
+
json = await res.json();
|
|
455
|
+
} catch {
|
|
456
|
+
return { ok: false, error: 'our image engine returned a body that was not JSON', reason: 'our own GPU engine returned non-JSON' };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* ⚠️⚠️ A 200 CARRYING `ok:false` IS A FAILURE. This service answers 200 with
|
|
461
|
+
* `{"ok":false,"error":"unauthorised"}` — verified by sending the wrong
|
|
462
|
+
* secret. Reading `res.ok` alone would have written a zero-byte PNG and
|
|
463
|
+
* called it a render, which is the exact class of bug that has cost this
|
|
464
|
+
* codebase more time than any other: **`res.ok` answers a question about the
|
|
465
|
+
* HTTP conversation, never about whether the work happened.**
|
|
466
|
+
*/
|
|
467
|
+
if (json?.ok === false) {
|
|
468
|
+
const why = String(json.error || 'no reason given');
|
|
469
|
+
return { ok: false, error: `our image engine refused: ${why}`, reason: `our own GPU engine refused (${why})` };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const b64 = json?.image_b64;
|
|
473
|
+
if (typeof b64 !== 'string' || b64.length < 100) {
|
|
474
|
+
return { ok: false, error: 'our image engine returned no image', reason: 'our own GPU engine returned no image' };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const bytes = Buffer.from(b64, 'base64');
|
|
478
|
+
try {
|
|
479
|
+
writeFileSync(dest.absolute, bytes);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
return { ok: false, error: `could not write ${rel}: ${String(err?.message || err)}` };
|
|
482
|
+
}
|
|
483
|
+
return {
|
|
484
|
+
ok: true,
|
|
485
|
+
path: rel,
|
|
486
|
+
bytes: bytes.length,
|
|
487
|
+
width: json.width ?? width,
|
|
488
|
+
height: json.height ?? height,
|
|
489
|
+
provider: 'acuvo-gpu',
|
|
490
|
+
model: json.model ?? null,
|
|
491
|
+
renderMs: json.render_ms ?? null,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* ── ⭐⭐ THE FLOOR: A KEYLESS IMAGE PROVIDER THAT NEEDS NO ACCOUNT ───────────
|
|
497
|
+
*
|
|
498
|
+
* Pollinations serves an image from a plain GET with no key and no signup.
|
|
499
|
+
* Measured 2026-08-10: HTTP 200, a 40KB JPEG of a genuinely usable product
|
|
500
|
+
* photograph, in **2.2 seconds**.
|
|
501
|
+
*
|
|
502
|
+
* ⭐ WHY THIS IS STRATEGIC AND NOT A STOPGAP. It makes image generation work on
|
|
503
|
+
* EVERY install with zero configuration: clone, set one model key, and the agent
|
|
504
|
+
* can put real imagery in a page. A capability that needs a second account is a
|
|
505
|
+
* capability most people never see — the same trap `DEFAULT_IMAGE_URL` was
|
|
506
|
+
* written to escape, one level deeper.
|
|
507
|
+
*
|
|
508
|
+
* ── ⚠️ IT IS FREE. IT IS NOT UNLIMITED, AND THIS COMMENT USED TO SAY IT WAS ──
|
|
509
|
+
* The previous line read "no quota to configure", which is true and reads as
|
|
510
|
+
* "no quota". Measured 2026-08-11 on three consecutive images through this exact
|
|
511
|
+
* function: **2.6s, then 44.9s, then 45.9s.** Nothing changed but the call
|
|
512
|
+
* count. It is a shared free service that throttles whoever leans on it, so a
|
|
513
|
+
* build that wants six heroes should expect to wait, and nothing here may be
|
|
514
|
+
* sold as unlimited free imagery. Infinite is a courtesy, not a contract.
|
|
515
|
+
*
|
|
516
|
+
* ⚠️ AND IT IS A FALLBACK, NOT A REPLACEMENT — a point that got sharper the day
|
|
517
|
+
* the preferred engine closed its door, because "the fallback" has quietly
|
|
518
|
+
* become the engine drawing nearly every image. The caller is TOLD which one
|
|
519
|
+
* produced the file, because a user debugging a disappointing image has to know
|
|
520
|
+
* whose model made it, and because a substitution nobody announces is how "our
|
|
521
|
+
* image model" becomes a claim no one can check.
|
|
522
|
+
*/
|
|
523
|
+
export function pollinationsUrl(prompt, width, height, { seed = null } = {}) {
|
|
524
|
+
const q = new URLSearchParams({ width: String(width), height: String(height), nologo: 'true' });
|
|
525
|
+
if (seed !== null) q.set('seed', String(seed));
|
|
526
|
+
return `https://image.pollinations.ai/prompt/${encodeURIComponent(String(prompt).trim())}?${q}`;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Long enough for a real render, short enough that a failure is still a demo. */
|
|
530
|
+
const POLLINATIONS_TIMEOUT_MS = 90_000;
|
|
531
|
+
|
|
532
|
+
export async function generateViaPollinations(
|
|
533
|
+
{ prompt, width = 1200, height = 800, executor, fetchImpl = fetch },
|
|
534
|
+
) {
|
|
535
|
+
const url = pollinationsUrl(prompt, width, height);
|
|
536
|
+
const dead = deadReason(url);
|
|
537
|
+
if (dead) {
|
|
538
|
+
return {
|
|
539
|
+
ok: false,
|
|
540
|
+
error: skipMessage('The fallback image service', url),
|
|
541
|
+
reason: `the fallback image service did not answer earlier this run (${dead})`,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
let res;
|
|
546
|
+
try {
|
|
547
|
+
res = await throughBreaker(url, 'The fallback image service', () => fetchImpl(url, {
|
|
548
|
+
signal: AbortSignal.timeout(POLLINATIONS_TIMEOUT_MS),
|
|
549
|
+
}));
|
|
550
|
+
} catch (err) {
|
|
551
|
+
return { ok: false, error: `fallback image service unreachable: ${err?.name ?? err}` };
|
|
552
|
+
}
|
|
553
|
+
if (!res.ok) return { ok: false, error: `fallback image service returned HTTP ${res.status}` };
|
|
554
|
+
|
|
555
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
556
|
+
/**
|
|
557
|
+
* ⚠️ A 200 WITH NO IMAGE IS A FAILURE, NOT AN EMPTY IMAGE — the same rule the
|
|
558
|
+
* primary follows. An error page served with status 200 is a real thing this
|
|
559
|
+
* provider does, and writing it to disk as `hero.jpg` would put a broken file
|
|
560
|
+
* in someone's page and call it success.
|
|
561
|
+
*/
|
|
562
|
+
if (bytes.length < 1000) return { ok: false, error: 'fallback image service returned no image' };
|
|
563
|
+
const isJpeg = bytes[0] === 0xff && bytes[1] === 0xd8;
|
|
564
|
+
const isPng = bytes.subarray(0, 4).toString('hex') === '89504e47';
|
|
565
|
+
if (!isJpeg && !isPng) return { ok: false, error: 'fallback image service returned something that is not an image' };
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* ⚠️ NAMED FOR WHAT IT ACTUALLY IS. This provider returns JPEG; writing those
|
|
569
|
+
* bytes to a `.png` produces a file every browser still displays and every
|
|
570
|
+
* image tool refuses — a mismatch nobody finds until it matters.
|
|
571
|
+
*/
|
|
572
|
+
const rel = suggestFilename(prompt).replace(/\.png$/, isJpeg ? '.jpg' : '.png');
|
|
573
|
+
const dest = resolveInWorkspace(executor.root, rel, 'write');
|
|
574
|
+
if (!dest.ok) return { ok: false, error: dest.reason };
|
|
575
|
+
try {
|
|
576
|
+
writeFileSync(dest.absolute, bytes);
|
|
577
|
+
} catch (err) {
|
|
578
|
+
return { ok: false, error: `could not write ${rel}: ${String(err?.message || err)}` };
|
|
579
|
+
}
|
|
580
|
+
return { ok: true, path: rel, bytes: bytes.length, width, height, provider: 'pollinations' };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* ── ⭐ NEVER SINGLE. THIS FILE WAS THE LAST PLACE THAT WAS. ─────────────────
|
|
585
|
+
*
|
|
586
|
+
* "Never single" has been doctrine for the model chain since the beginning, and
|
|
587
|
+
* image generation quietly ignored it — one provider, and when it went down the
|
|
588
|
+
* capability went down with it, taking nine minutes of a session with it.
|
|
589
|
+
*
|
|
590
|
+
* ⚠️ ORDER MATTERS AND SO DOES THE BREAKER. When the primary is already known
|
|
591
|
+
* dead this run, the fallback is reached in milliseconds rather than after
|
|
592
|
+
* another 180-second wait — that combination is what turns "images are broken"
|
|
593
|
+
* into "images took two seconds".
|
|
594
|
+
*/
|
|
595
|
+
/**
|
|
596
|
+
* ── ⭐⭐ PERCHANCE, DIRECT. THE DEFAULT IS NOW NATIVE. ───────────────────────
|
|
597
|
+
*
|
|
598
|
+
* `lib/perchance.mjs` talks to perchance.org itself over HTTP/2 — no browser, no
|
|
599
|
+
* Modal, no dependency, 5.2s for a real image. This is the wrapper that puts
|
|
600
|
+
* those bytes into the workspace through the same path safety as any other write.
|
|
601
|
+
*
|
|
602
|
+
* ⭐ IT ALSO RETIRES THE HOSTED DEFAULT, WHICH WAS THREE PROBLEMS AT ONCE:
|
|
603
|
+
* it returned 502 after 303 seconds (so it never worked for anyone who installed
|
|
604
|
+
* this), it was an unauthenticated compute bill from every copy, and it meant a
|
|
605
|
+
* stranger's prompt travelled to OUR server. Going direct removes all three.
|
|
606
|
+
* `PERCHANCE_IMAGE_URL` still overrides, for anyone running their own instance.
|
|
607
|
+
*/
|
|
608
|
+
/**
|
|
609
|
+
* The breaker keys on a URL. The native path never builds one — it is four calls
|
|
610
|
+
* to four routes — so it gets a stable identity of its own. Naming the real host
|
|
611
|
+
* matters: this string is what a later `skipMessage` puts in front of a user.
|
|
612
|
+
*/
|
|
613
|
+
export const NATIVE_PERCHANCE_ID = 'https://image-generation.perchance.org (native)';
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* ── ⚠️⭐ A CLOSED DOOR IS NOT A BAD REQUEST, AND NOT AN OUTAGE EITHER ────────
|
|
617
|
+
*
|
|
618
|
+
* `breaker.mjs` says in its own header: *"IT TRIPS ON UNREACHABLE, NEVER ON A
|
|
619
|
+
* REFUSAL"* — because an HTTP 400 means THIS request was wrong and the next may
|
|
620
|
+
* be right, and disabling a working service over one bad prompt is worse than
|
|
621
|
+
* the outage it imitates. That rule stands, and this function does not bend it.
|
|
622
|
+
*
|
|
623
|
+
* ⭐ THE DISTINCTION THE RULE WAS MISSING IS *WHOSE* REFUSAL IT IS. A refusal
|
|
624
|
+
* can be about the REQUEST (retry may help) or about the CALLER (retry cannot).
|
|
625
|
+
* Measured live 2026-08-11, four consecutive calls, seconds apart:
|
|
626
|
+
*
|
|
627
|
+
* verifyUser -> {"status":"failed_verification","reason":"token_required"}
|
|
628
|
+
* verifyUser -> {"status":"failed_verification","reason":"token_required"}
|
|
629
|
+
* verifyUser -> {"status":"failed_verification","reason":"token_required"}
|
|
630
|
+
* verifyUser -> {"status":"failed_verification","reason":"token_required"}
|
|
631
|
+
*
|
|
632
|
+
* Identical every time, with four different prompts behind them. Perchance has
|
|
633
|
+
* closed anonymous access — a POLICY change, not a fault. Nothing a later call
|
|
634
|
+
* in this process can carry will change the answer, so paying 0.4s for it on
|
|
635
|
+
* every image of a build buys precisely nothing.
|
|
636
|
+
*
|
|
637
|
+
* ⚠️ THE LIST IS DELIBERATELY SHORT, and everything on it is prompt-independent:
|
|
638
|
+
* · `token_required` / `failed_verification` — the gate, measured above.
|
|
639
|
+
* · a Cloudflare challenge — we are not being let in at all.
|
|
640
|
+
* · `invalid_parameter` — `perchance.mjs` already states this one is our own
|
|
641
|
+
* shape drift and *"retrying will not help"*; it is a fact about the client,
|
|
642
|
+
* which is the same request every time.
|
|
643
|
+
* A short read, a bad byte count, a timeout — none of those are here. They are
|
|
644
|
+
* ordinary failures, they may well succeed next time, and treating them as a
|
|
645
|
+
* closed door would be the guard failing correct work.
|
|
646
|
+
*/
|
|
647
|
+
export function isProviderClosed({ error, challenged } = {}) {
|
|
648
|
+
if (challenged === true) return true;
|
|
649
|
+
return /token_required|failed_verification|invalid_parameter/i.test(String(error ?? ''));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function generateViaNativePerchance({
|
|
653
|
+
prompt, width = 1200, height = 800, executor, nativeImpl = generateNative,
|
|
654
|
+
}) {
|
|
655
|
+
/**
|
|
656
|
+
* ⚠️ THE BREAKER, ON THE PATH THAT DID NOT HAVE IT. When the hosted default
|
|
657
|
+
* was retired for the direct route, the direct route was wired straight to
|
|
658
|
+
* `generateNative` — no breaker, no skip, no memory. That was invisible while
|
|
659
|
+
* Perchance worked and became the whole cost model the day it stopped.
|
|
660
|
+
*/
|
|
661
|
+
const closed = deadReason(NATIVE_PERCHANCE_ID);
|
|
662
|
+
if (closed) {
|
|
663
|
+
return {
|
|
664
|
+
ok: false,
|
|
665
|
+
skipped: true,
|
|
666
|
+
error: skipMessage('Perchance', NATIVE_PERCHANCE_ID),
|
|
667
|
+
reason: `Perchance is not serving this run (${closed})`,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* ⚠️ THE SERVICE TAKES A SQUARE-ISH `resolution` STRING, not our width/height.
|
|
673
|
+
* Snapped to what it actually offers rather than passed through, because an
|
|
674
|
+
* unsupported value comes back as `invalid_parameter` and reads like an
|
|
675
|
+
* outage.
|
|
676
|
+
*/
|
|
677
|
+
const longest = Math.max(Number(width) || 0, Number(height) || 0);
|
|
678
|
+
const resolution = longest >= 1024 ? '1024x1024' : longest >= 768 ? '768x768' : '512x512';
|
|
679
|
+
|
|
680
|
+
const r = await nativeImpl({ prompt, resolution });
|
|
681
|
+
if (!r.ok) {
|
|
682
|
+
if (isProviderClosed(r)) {
|
|
683
|
+
markUnreachable(NATIVE_PERCHANCE_ID, 'it now requires a token we do not have');
|
|
684
|
+
return {
|
|
685
|
+
ok: false,
|
|
686
|
+
error: `perchance: ${r.error}`,
|
|
687
|
+
/**
|
|
688
|
+
* ⭐ NAMED AS A POLICY CHANGE RATHER THAN A FAILURE, because the two want
|
|
689
|
+
* opposite responses and the reader is a model. "Unreachable" invites a
|
|
690
|
+
* retry and a bug report about our networking; "they closed the door"
|
|
691
|
+
* invites neither. Our HTTP/2 fingerprint still works — we get a real
|
|
692
|
+
* JSON answer back, not a Cloudflare wall — so this is not a defeat of
|
|
693
|
+
* the transport and must not be reported as one.
|
|
694
|
+
*/
|
|
695
|
+
reason: 'Perchance now requires an account token for image generation, so anonymous access is closed to us',
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
return { ok: false, error: `perchance: ${r.error}`, reason: `Perchance could not draw it (${r.error})` };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const rel = suggestFilename(prompt).replace(/\.png$/, r.mimeType === 'image/png' ? '.png' : '.jpg');
|
|
702
|
+
const dest = resolveInWorkspace(executor.root, rel, 'write');
|
|
703
|
+
if (!dest.ok) return { ok: false, error: dest.reason };
|
|
704
|
+
try {
|
|
705
|
+
writeFileSync(dest.absolute, r.bytes);
|
|
706
|
+
} catch (err) {
|
|
707
|
+
return { ok: false, error: `could not write ${rel}: ${String(err?.message || err)}` };
|
|
708
|
+
}
|
|
709
|
+
return {
|
|
710
|
+
ok: true, path: rel, bytes: r.bytes.length, width, height,
|
|
711
|
+
provider: 'perchance', seed: r.seed,
|
|
712
|
+
// ⚠️ Passed up, not dropped. The caller is about to put this in a page.
|
|
713
|
+
...(r.maybeNsfw ? { maybeNsfw: true } : {}),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* ── ⚠️⭐ AN ERROR STRING IS AN INSTRUCTION, AND ONE OF THESE RIDES A SUCCESS ──
|
|
719
|
+
*
|
|
720
|
+
* A provider's `error` is written for the case where the whole tool failed, so
|
|
721
|
+
* it ends in an order: *"do not call this tool again in this run"*. That is
|
|
722
|
+
* exactly right when there is no image, and it is a bug the moment the FALLBACK
|
|
723
|
+
* succeeds — because the same sentence gets bolted to `fellBackFrom` on a result
|
|
724
|
+
* that contains a perfectly good picture, and the model reads it literally and
|
|
725
|
+
* stops asking for imagery it could have had.
|
|
726
|
+
*
|
|
727
|
+
* ⭐ So every provider now carries a second string: `reason` states the FACT and
|
|
728
|
+
* commands nothing. `error` is for the dead end, `reason` is for the footnote.
|
|
729
|
+
* Splitting them beat trying to strip the imperative out of one string, which is
|
|
730
|
+
* the kind of regex that works until someone rewords the sentence.
|
|
731
|
+
*/
|
|
732
|
+
function reasonOf(result, fallbackText) {
|
|
733
|
+
return result?.reason || fallbackText || result?.error || 'it did not answer';
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export async function generateThroughProviders(args) {
|
|
737
|
+
const env = args.env ?? process.env;
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* ⚠️ AN EXPLICIT `PERCHANCE_IMAGE_URL=` STILL MEANS OFF, for the whole
|
|
741
|
+
* Perchance path — someone who disabled it did so deliberately and must not be
|
|
742
|
+
* handed the native route as a surprise substitute.
|
|
743
|
+
*/
|
|
744
|
+
const perchanceDisabled = IMAGE_URL_ENV in env && (env[IMAGE_URL_ENV] || '').trim() === '';
|
|
745
|
+
let primaryError = 'the primary image service is switched off in this environment';
|
|
746
|
+
let primaryReason = primaryError;
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* ── ⭐⭐ OUR OWN GPU GOES FIRST ──────────────────────────────────────────
|
|
750
|
+
*
|
|
751
|
+
* It was third — behind a service now sitting behind a human verification
|
|
752
|
+
* wall, and a free endpoint that throttles to 45s under real use. Measured
|
|
753
|
+
* 2026-08-12: ours renders a 1024×1024 PNG in 8.8–10.5s warm, and the
|
|
754
|
+
* pictures are good enough to ship.
|
|
755
|
+
*
|
|
756
|
+
* ⚠️ IT IS SKIPPED SILENTLY WHEN DARK, not reported as a failure. Without a
|
|
757
|
+
* secret there is nothing broken to tell anyone about, and a chain that
|
|
758
|
+
* announces every unconfigured leg trains the reader to ignore it.
|
|
759
|
+
*/
|
|
760
|
+
const own = await generateViaOwnEngine(args);
|
|
761
|
+
if (own.ok) return own;
|
|
762
|
+
if (engineConfig(env).configured) {
|
|
763
|
+
primaryError = own.error;
|
|
764
|
+
primaryReason = reasonOf(own);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (!perchanceDisabled) {
|
|
768
|
+
// A configured URL means "use my server"; unset means "go direct".
|
|
769
|
+
const useOwnServer = Boolean((env[IMAGE_URL_ENV] || '').trim());
|
|
770
|
+
const primary = useOwnServer ? await generateViaService(args) : await generateViaNativePerchance(args);
|
|
771
|
+
if (primary.ok) return primary;
|
|
772
|
+
primaryError = primary.error;
|
|
773
|
+
primaryReason = reasonOf(primary);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const fallback = await generateViaPollinations(args);
|
|
777
|
+
if (fallback.ok) {
|
|
778
|
+
// ⭐ Said out loud. The model should know it did not get the preferred
|
|
779
|
+
// engine, because it may want to mention that to the user — and because a
|
|
780
|
+
// silent substitution is how "our image model" becomes an unverifiable claim.
|
|
781
|
+
return { ...fallback, fellBackFrom: primaryReason };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return {
|
|
785
|
+
ok: false,
|
|
786
|
+
error: `both image providers failed. Primary: ${primaryReason}. Fallback: ${reasonOf(fallback)}. `
|
|
787
|
+
+ 'Continue without the image — do not call this tool again in this run — and say in your summary that imagery was unavailable.',
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* ── ⭐⭐ THE TOOL NOW HAS TASTE, AND THE SCHEMA DID NOT CHANGE ───────────────
|
|
793
|
+
*
|
|
794
|
+
* `generate_image` used to be: one prompt, one shot, whatever came back, written
|
|
795
|
+
* to disk and referenced in the page. The agent cannot see, so a smeared
|
|
796
|
+
* illegible hero got shipped across four pages with total confidence — that
|
|
797
|
+
* happened today, on our own coffee site.
|
|
798
|
+
*
|
|
799
|
+
* ⭐ Direction and criticism now wrap the provider chain: the prompt is composed
|
|
800
|
+
* like a photograph rather than described like a noun, and the result is LOOKED
|
|
801
|
+
* AT before it is accepted. The tool's name, parameters and return shape are
|
|
802
|
+
* unchanged, so nothing downstream needed rewiring — the capability simply got
|
|
803
|
+
* better underneath.
|
|
804
|
+
*
|
|
805
|
+
* ⚠️ TWO ATTEMPTS, NOT THREE. Measured: three attempts on a structurally
|
|
806
|
+
* impossible subject scored 2, 2, 2 and cost 100 seconds. This is a coding
|
|
807
|
+
* agent's round budget being spent, not an art department's afternoon.
|
|
808
|
+
*
|
|
809
|
+
* ⚠️ AND THE CRITIC IS SKIPPED WITHOUT A KEY rather than assumed to approve.
|
|
810
|
+
* `accepted:false` on an unlooked-at image is the honest report, and the same
|
|
811
|
+
* rule the render audit had to learn the hard way.
|
|
812
|
+
*/
|
|
813
|
+
/**
|
|
814
|
+
* ── ⚠️⚠️ A PER-RUN CEILING, BECAUSE MEASURED BEHAVIOUR NEEDED ONE ──────────
|
|
815
|
+
*
|
|
816
|
+
* MEASURED on a real landing-page task: the model called `generate_image` FOUR
|
|
817
|
+
* TIMES in the first five rounds. It was not retrying — each call asked for a
|
|
818
|
+
* different subject (a shop interior, then a cup of coffee) — which is
|
|
819
|
+
* defensible taste and indefensible economics. Every call is GPU seconds plus a
|
|
820
|
+
* whole round, and each one internally makes up to TWO renders, so four calls is
|
|
821
|
+
* up to eight renders before a single line of HTML was written.
|
|
822
|
+
*
|
|
823
|
+
* ⭐ EVERY OTHER EXPENSIVE VERB HERE ALREADY HAS THIS. `web_search` stops at 12,
|
|
824
|
+
* `read_image` at 12, `fetch_url` at 10. Image generation was the one unbounded
|
|
825
|
+
* one, and it is the most expensive of them — an omission, not a decision.
|
|
826
|
+
*/
|
|
827
|
+
export const MAX_IMAGES_PER_PROCESS = 4;
|
|
828
|
+
|
|
829
|
+
let imagesThisProcess = 0;
|
|
830
|
+
|
|
831
|
+
/** Test seam — the per-run cap must not leak between test files. */
|
|
832
|
+
export function resetImageState() {
|
|
833
|
+
imagesThisProcess = 0;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export async function generateImage(args) {
|
|
837
|
+
const env = args.env ?? process.env;
|
|
838
|
+
const apiKey = env.OPENROUTER_API_KEY;
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* ── ⭐⭐ WHICH ENGINE, AND WHO CHOSE IT — BEFORE ANY GPU SECOND IS SPENT ───
|
|
842
|
+
*
|
|
843
|
+
* Roman, 2026-08-16: *"in the CLI and in the builder the AI will ask which
|
|
844
|
+
* Acuvo image model or video model they want, basic or premium."*
|
|
845
|
+
*
|
|
846
|
+
* ⚠️ THE DEFAULT IS THE CORE ENGINE AND IT ALWAYS WILL BE. `checkEngine` has
|
|
847
|
+
* no path that returns an Ultra engine unless the caller named one, because a
|
|
848
|
+
* premium engine firing on its own spends credits on a decision the user did
|
|
849
|
+
* not make — 585 credits a clip against Starter's 2,000 is a quarter of the
|
|
850
|
+
* month for one silent escalation.
|
|
851
|
+
*
|
|
852
|
+
* ⭐ IT IS FIRST, ABOVE THE PER-RUN CAP AND ABOVE THE COUNTER. A refusal must
|
|
853
|
+
* not consume one of the four images this process is allowed: being told
|
|
854
|
+
* "that engine is not on your plan" is not a render, and charging a slot for
|
|
855
|
+
* it would make the second attempt fail for a different and confusing reason.
|
|
856
|
+
*
|
|
857
|
+
* ⚠️ AND WITH NO ACCOUNT NOTHING CHANGES. There is no catalogue to consult,
|
|
858
|
+
* so no entitlement check runs, so this is a no-op for every existing caller
|
|
859
|
+
* — which is the fail-safe direction: the gateway is what actually charges,
|
|
860
|
+
* and it is the authority on what a tenant may reach.
|
|
861
|
+
*/
|
|
862
|
+
const engineChoice = checkEngine('image', args.engine ?? runEngineFor('image'), { env });
|
|
863
|
+
if (!engineChoice.ok) return { ok: false, error: engineChoice.error, code: engineChoice.code };
|
|
864
|
+
|
|
865
|
+
if (imagesThisProcess >= MAX_IMAGES_PER_PROCESS) {
|
|
866
|
+
return {
|
|
867
|
+
ok: false,
|
|
868
|
+
error: `this run has already generated ${MAX_IMAGES_PER_PROCESS} images, which is the cap. `
|
|
869
|
+
+ 'Use one you already made — they are on disk and listed in the changes — or finish the task without more imagery. '
|
|
870
|
+
+ 'Generating another will not make the page better than writing the rest of it.',
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
imagesThisProcess += 1;
|
|
874
|
+
|
|
875
|
+
const directed = await directImage({
|
|
876
|
+
prompt: args.prompt,
|
|
877
|
+
// Each attempt goes through the full provider chain, so a re-shoot still
|
|
878
|
+
// gets primary-then-fallback rather than being pinned to whoever answered.
|
|
879
|
+
generate: ({ prompt }) => generateThroughProviders({ ...args, prompt }),
|
|
880
|
+
readBytes: (shot) => {
|
|
881
|
+
try { return readFileSync(join(args.executor.root, shot.path)); } catch { return null; }
|
|
882
|
+
},
|
|
883
|
+
maxAttempts: 2,
|
|
884
|
+
apiKey,
|
|
885
|
+
fetchImpl: args.criticFetch ?? fetch,
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
if (!directed.ok) return directed;
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* ── ⭐ WHOSE MODEL DREW THIS. IT USED TO BE UNSAYABLE. ──────────────────────
|
|
892
|
+
*
|
|
893
|
+
* `provider` and `fellBackFrom` have always been on the returned object, and
|
|
894
|
+
* `note` — the one string written to be read out loud — never mentioned
|
|
895
|
+
* either. So a Perchance image and a Pollinations image produced word-for-word
|
|
896
|
+
* identical reports, and on the day Perchance closed its door EVERY image
|
|
897
|
+
* silently came from the free last resort with nothing in the text to say so.
|
|
898
|
+
*
|
|
899
|
+
* ⚠️ These are not interchangeable engines. The one we preferred was the one
|
|
900
|
+
* that could put legible lettering in a picture; the one answering now is a
|
|
901
|
+
* free shared service that throttles. Substituting is fine. Substituting
|
|
902
|
+
* quietly is how a user spends an afternoon blaming their prompt.
|
|
903
|
+
*/
|
|
904
|
+
const engine = PROVIDER_LABELS[directed.provider] ?? directed.provider ?? 'an unnamed engine';
|
|
905
|
+
const drawnBy = directed.fellBackFrom
|
|
906
|
+
? `Drawn by ${engine}, after the preferred engine was unavailable: ${directed.fellBackFrom}.`
|
|
907
|
+
: `Drawn by ${engine}.`;
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* ⚠️ THE SCORE TRAVELS BACK TO THE MODEL. Without it the agent writes
|
|
911
|
+
* `<img src="hero.jpg">` and moves on regardless — which is precisely the
|
|
912
|
+
* behaviour that put a garbled label on four pages. With it, a low score is a
|
|
913
|
+
* fact it can act on or mention.
|
|
914
|
+
*/
|
|
915
|
+
const review = directed.score === null
|
|
916
|
+
? 'This image was NOT reviewed (no critic available), so nothing confirms it looks right.'
|
|
917
|
+
: directed.accepted
|
|
918
|
+
? `Reviewed and accepted (${directed.score}/10).`
|
|
919
|
+
: `⚠️ Reviewed and NOT accepted (${directed.score}/10): ${(directed.problems ?? []).slice(0, 2).join('; ')}. `
|
|
920
|
+
+ 'It is the best of the attempts and it is on disk. Use it if imagery matters less than shipping, '
|
|
921
|
+
+ 'or choose a different subject — re-running the same request will not help.';
|
|
922
|
+
|
|
923
|
+
return { ...directed, note: `${drawnBy} ${review}` };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* ⚠️ NAMED, NOT SLUGGED. `provider` is an identifier; this is the thing a human
|
|
928
|
+
* reads in a summary, so it says what the engine IS — including, for the free
|
|
929
|
+
* one, that it is the free one.
|
|
930
|
+
*/
|
|
931
|
+
const PROVIDER_LABELS = {
|
|
932
|
+
perchance: 'Perchance',
|
|
933
|
+
pollinations: 'Pollinations (the free fallback engine — shared, and it throttles under repeated use)',
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
/** The tool schema, offered only when the service is configured. */
|
|
937
|
+
export function imageToolSchema() {
|
|
938
|
+
return {
|
|
939
|
+
type: 'function',
|
|
940
|
+
function: {
|
|
941
|
+
name: 'generate_image',
|
|
942
|
+
/**
|
|
943
|
+
* ── ⚠️ THIS TEXT IS RE-READ BY THE MODEL ON EVERY CALL ─────────────────
|
|
944
|
+
* so a stale claim in it is repeated for as long as it stands. Two were
|
|
945
|
+
* stale, both measured 2026-08-11:
|
|
946
|
+
* · "about a minute per image" — the chain answered in **2.6s**. A
|
|
947
|
+
* wildly pessimistic cost teaches the model to avoid a capability that
|
|
948
|
+
* is cheap, which is the same damage as an over-promise, pointed the
|
|
949
|
+
* other way.
|
|
950
|
+
* · "save it into the workspace as a .png" — the engine actually serving
|
|
951
|
+
* us returns JPEG, and the extension follows the bytes precisely so a
|
|
952
|
+
* `.png` full of JPEG never reaches someone's disk. The schema was
|
|
953
|
+
* promising the one thing the code deliberately refuses to do.
|
|
954
|
+
* ⭐ The honest number is a RANGE, because the free engine throttles: 2.6s
|
|
955
|
+
* for the first image of a run, 45s for the third. Both measured, minutes
|
|
956
|
+
* apart, same function.
|
|
957
|
+
*/
|
|
958
|
+
description: [
|
|
959
|
+
'Generate a real image from a text prompt and save it into the workspace.',
|
|
960
|
+
'The file is a .png or a .jpg — the extension follows whatever the engine returns,',
|
|
961
|
+
'so use the exact path from the result rather than assuming one.',
|
|
962
|
+
'Use it when the user asks for imagery, or when a page you are building needs a hero',
|
|
963
|
+
'or illustration — reference the returned path from your HTML.',
|
|
964
|
+
'⚠️ Usually a few seconds, but it runs on a shared free engine that throttles:',
|
|
965
|
+
'the third image of a run can take ~45s. Ask for one only when it is genuinely wanted,',
|
|
966
|
+
'and never generate images speculatively while iterating on code.',
|
|
967
|
+
'Write GOOD prompts: subject, style, lighting, composition. The quality is on the prompt.',
|
|
968
|
+
/**
|
|
969
|
+
* ⚠️ THE ENGINE SENTENCE IS AN INSTRUCTION, NOT A DESCRIPTION, and it is
|
|
970
|
+
* the one line in this schema that protects somebody's money. A model
|
|
971
|
+
* that reads "acuvo-image-ultra: better" WILL reach for it — the same
|
|
972
|
+
* way it called generate_image four times in five rounds when nothing
|
|
973
|
+
* told it not to. So the schema states the rule, not the ranking.
|
|
974
|
+
*/
|
|
975
|
+
'⚠️ ENGINE: leave `engine` unset unless the USER asked for premium quality by name.',
|
|
976
|
+
'Ultra engines cost many times more per image and spend the user\'s credits, so choosing one for them is not yours to do.',
|
|
977
|
+
'Call list_engines first if you need to know what an engine costs.',
|
|
978
|
+
].join(' '),
|
|
979
|
+
parameters: {
|
|
980
|
+
type: 'object',
|
|
981
|
+
properties: {
|
|
982
|
+
prompt: {
|
|
983
|
+
type: 'string',
|
|
984
|
+
description: 'A rich, specific description — subject, style, lighting, lens, mood.',
|
|
985
|
+
},
|
|
986
|
+
width: { type: 'number', description: 'Pixels wide. Default 1200.' },
|
|
987
|
+
height: { type: 'number', description: 'Pixels tall. Default 800.' },
|
|
988
|
+
engine: {
|
|
989
|
+
type: 'string',
|
|
990
|
+
description: 'Only when the user asked for a specific engine: "acuvo-image" (default, ours, cheap) or '
|
|
991
|
+
+ '"acuvo-image-ultra" (premium, far dearer). Omit it and the core engine is used.',
|
|
992
|
+
},
|
|
993
|
+
},
|
|
994
|
+
required: ['prompt'],
|
|
995
|
+
},
|
|
996
|
+
},
|
|
997
|
+
};
|
|
998
|
+
}
|