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
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ AN IMAGE GENERATOR WITH TASTE ───────────────────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* A generator without a critic is a slot machine. You pull it, you get what you
|
|
5
|
+
* get, and the agent — which cannot see — writes `<img src="hero.jpg">` and
|
|
6
|
+
* calls the page finished. Today's own coffee site did exactly that: a hero with
|
|
7
|
+
* an illegible smeared label, referenced confidently in four pages.
|
|
8
|
+
*
|
|
9
|
+
* ⭐ SO: GENERATE → LOOK → RE-DIRECT. The same render→look→fix loop that makes
|
|
10
|
+
* the code half work, applied to the creative half. Measured on the real thing:
|
|
11
|
+
* qwen3.7-flash judged that hero in **6.5 seconds for $0.00004** and its verdict
|
|
12
|
+
* was *"garbled and illegible text on the product label is the primary
|
|
13
|
+
* disqualifier"* — score 2/10, not usable — which is precisely what a human
|
|
14
|
+
* noticed and the agent could not.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ THE CRITIC IS THE CHEAP PART. Four thousandths of a cent against seconds of
|
|
17
|
+
* GPU. There is no economic argument for shipping an unlooked-at image, and
|
|
18
|
+
* there never was — we simply had no eyes.
|
|
19
|
+
*
|
|
20
|
+
* ── ⚠️ WHY EVERY PROMPT IS REWRITTEN, NOT PASSED THROUGH ────────────────────
|
|
21
|
+
* A coding model asked for "an image of a coffee bag" writes exactly that, and
|
|
22
|
+
* gets the flat, grey, stock-photo mush that makes generated pages look
|
|
23
|
+
* generated. Image models respond to DIRECTION — lens, light, composition,
|
|
24
|
+
* grade. Supplying it is not decoration, it is the difference between an asset
|
|
25
|
+
* and a placeholder.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* ⚠️ THE SINGLE MOST VALUABLE RULE HERE, AND IT CAME FROM THE CRITIC.
|
|
30
|
+
* Diffusion models cannot spell. Asking for a label, a sign, a logo or a UI
|
|
31
|
+
* guarantees garbled glyphs, and garbled text is the one defect a viewer
|
|
32
|
+
* notices instantly and reads as "made by a machine". So requests for text are
|
|
33
|
+
* actively REMOVED and replaced with a composition that does not need any.
|
|
34
|
+
*
|
|
35
|
+
* A human art director does this without being asked. It is the whole job.
|
|
36
|
+
*/
|
|
37
|
+
const TEXT_REQUESTS = /\b(text|label(?:led|led)?|logo|sign|signage|words?|writing|typography|caption|title|lettering|banner|poster|menu|headline)\b/gi;
|
|
38
|
+
|
|
39
|
+
/** Direction that reads as cinema rather than stock photography. */
|
|
40
|
+
const CINEMATIC = [
|
|
41
|
+
'cinematic still',
|
|
42
|
+
'shallow depth of field',
|
|
43
|
+
'volumetric directional light',
|
|
44
|
+
'rich colour grade with lifted shadows',
|
|
45
|
+
'shot on 35mm, subtle film grain',
|
|
46
|
+
'composed off-centre with negative space',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Turn a plain request into a directed one.
|
|
51
|
+
*
|
|
52
|
+
* ⚠️ THE USER'S SUBJECT IS NEVER DISCARDED — direction is appended, not
|
|
53
|
+
* substituted. A "cinematic" rewrite that loses what was asked for is a worse
|
|
54
|
+
* failure than a flat image, because the page ends up showing the wrong thing
|
|
55
|
+
* beautifully.
|
|
56
|
+
*/
|
|
57
|
+
export function cinematicPrompt(prompt, { mode = 'cinematic', avoidText = true } = {}) {
|
|
58
|
+
const subject = String(prompt ?? '').trim();
|
|
59
|
+
if (!subject) return { prompt: '', strippedText: false };
|
|
60
|
+
|
|
61
|
+
let base = subject;
|
|
62
|
+
let strippedText = false;
|
|
63
|
+
if (avoidText && TEXT_REQUESTS.test(subject)) {
|
|
64
|
+
TEXT_REQUESTS.lastIndex = 0;
|
|
65
|
+
// Rather than delete the noun and leave a hole, neutralise it: the object
|
|
66
|
+
// stays, its unrenderable surface detail goes.
|
|
67
|
+
base = subject.replace(TEXT_REQUESTS, '').replace(/\s{2,}/g, ' ').replace(/\s+([,.])/g, '$1').trim();
|
|
68
|
+
strippedText = true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* ── ⚠️⭐ MEASURED: A NEGATIVE PROMPT DOES NOT WORK, SO RE-COMPOSE INSTEAD ──
|
|
73
|
+
*
|
|
74
|
+
* Run live against the fallback provider: "no text, no lettering, no logos"
|
|
75
|
+
* was in the prompt all three times and every shot came back with a garbled
|
|
76
|
+
* label. The critic's verdict, three times identically: *"garbled and
|
|
77
|
+
* completely illegible text on the label"*, score 2/10.
|
|
78
|
+
*
|
|
79
|
+
* ⚠️ Re-prompting cannot rescue a subject that CONTAINS text. A coffee bag has
|
|
80
|
+
* a label; asking politely for it to be blank does not remove it from the
|
|
81
|
+
* model's idea of a coffee bag, and most endpoints ignore negative phrasing
|
|
82
|
+
* entirely.
|
|
83
|
+
*
|
|
84
|
+
* ⭐ WHAT A REAL ART DIRECTOR DOES: if the label cannot be rendered, do not
|
|
85
|
+
* shoot the label. Turn it away, crop past it, or shoot the product out of its
|
|
86
|
+
* packaging. That is a COMPOSITION instruction, which models obey, instead of
|
|
87
|
+
* a prohibition, which they do not.
|
|
88
|
+
*/
|
|
89
|
+
const packaged = /\b(bag|packet|pouch|box|bottle|can|jar|tin|carton|book|magazine|poster|shopfront|storefront|screen|monitor|laptop|phone)\b/i.test(base);
|
|
90
|
+
const composition = avoidText && (strippedText || packaged)
|
|
91
|
+
? 'framed so no printed surface faces the camera, label turned away or cropped out of frame, focus on material and texture'
|
|
92
|
+
: '';
|
|
93
|
+
|
|
94
|
+
const direction = mode === 'flat'
|
|
95
|
+
? ['clean product photography', 'even soft light', 'plain background']
|
|
96
|
+
: CINEMATIC;
|
|
97
|
+
|
|
98
|
+
const negative = avoidText
|
|
99
|
+
// ⚠️ Stated positively as well as negatively: many endpoints ignore a
|
|
100
|
+
// negative-prompt convention entirely, so the instruction has to survive in
|
|
101
|
+
// the positive prompt too.
|
|
102
|
+
? 'no text, no lettering, no logos, no watermarks, clean unmarked surfaces'
|
|
103
|
+
: '';
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
// Composition goes BEFORE the grade: it is an instruction about what is in
|
|
107
|
+
// the frame, and the grade only describes how the frame looks.
|
|
108
|
+
prompt: [base, composition, ...direction, negative].filter(Boolean).join(', '),
|
|
109
|
+
strippedText,
|
|
110
|
+
recomposed: Boolean(composition),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* ⭐ CHOSEN ON MEASURED COST, NOT REPUTATION. qwen3.7-flash judged a real image
|
|
116
|
+
* in 6.5s for $0.00004; qwen2.5-vl-72b gave a near-identical verdict for 6x the
|
|
117
|
+
* price. When two models agree, the cheap one is the right one.
|
|
118
|
+
*/
|
|
119
|
+
export const DEFAULT_CRITIC_MODEL = 'qwen/qwen3.7-flash';
|
|
120
|
+
|
|
121
|
+
const CRITIC_TIMEOUT_MS = 60_000;
|
|
122
|
+
|
|
123
|
+
const CRITIC_BRIEF = [
|
|
124
|
+
'You are an art director reviewing a generated image before it goes on a client website.',
|
|
125
|
+
'Reply with ONLY a JSON object, no prose, no code fence:',
|
|
126
|
+
'{"usable":true|false,"score":0-10,"problems":["..."],"betterPrompt":"..."}',
|
|
127
|
+
'Be harsh. Any of these makes it NOT usable: garbled or misspelled text, malformed hands or',
|
|
128
|
+
'faces, duplicated limbs, mushy or melted detail, a watermark, or a subject that is not what',
|
|
129
|
+
'was asked for. "betterPrompt" must be a complete replacement prompt that would fix the',
|
|
130
|
+
'problems — and it must not ask for any text, because the generator cannot spell.',
|
|
131
|
+
].join(' ');
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Look at an image and say whether it is good enough.
|
|
135
|
+
*
|
|
136
|
+
* ⚠️ RETURNS ok:false RATHER THAN A GUESS WHEN IT CANNOT TELL. An art director
|
|
137
|
+
* who bluffs is worse than none: a fabricated "looks great" would launder a bad
|
|
138
|
+
* asset through a process that exists to catch it. Every failure path here
|
|
139
|
+
* abstains loudly.
|
|
140
|
+
*/
|
|
141
|
+
export async function critiqueImage(imageBytes, intent, {
|
|
142
|
+
apiKey = process.env.OPENROUTER_API_KEY,
|
|
143
|
+
model = DEFAULT_CRITIC_MODEL,
|
|
144
|
+
mimeType = 'image/jpeg',
|
|
145
|
+
fetchImpl = fetch,
|
|
146
|
+
} = {}) {
|
|
147
|
+
if (!apiKey) return { ok: false, error: 'no OPENROUTER_API_KEY, so the image cannot be looked at' };
|
|
148
|
+
if (!imageBytes || imageBytes.length < 1000) return { ok: false, error: 'there is no image here to look at' };
|
|
149
|
+
|
|
150
|
+
const dataUrl = `data:${mimeType};base64,${Buffer.from(imageBytes).toString('base64')}`;
|
|
151
|
+
let res;
|
|
152
|
+
try {
|
|
153
|
+
res = await fetchImpl('https://openrouter.ai/api/v1/chat/completions', {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
|
|
156
|
+
body: JSON.stringify({
|
|
157
|
+
model,
|
|
158
|
+
max_tokens: 500,
|
|
159
|
+
// ⚠️ Reasoning OFF. On a reasoning model the thinking budget is charged
|
|
160
|
+
// against max_tokens and can consume all of it, returning zero content —
|
|
161
|
+
// measured on this exact account today, 15,999 reasoning tokens and an
|
|
162
|
+
// empty reply.
|
|
163
|
+
reasoning: { enabled: false },
|
|
164
|
+
messages: [{
|
|
165
|
+
role: 'user',
|
|
166
|
+
content: [
|
|
167
|
+
{ type: 'text', text: `${CRITIC_BRIEF}\n\nThe image was meant to be: ${String(intent).slice(0, 400)}` },
|
|
168
|
+
{ type: 'image_url', image_url: { url: dataUrl } },
|
|
169
|
+
],
|
|
170
|
+
}],
|
|
171
|
+
}),
|
|
172
|
+
signal: AbortSignal.timeout(CRITIC_TIMEOUT_MS),
|
|
173
|
+
});
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return { ok: false, error: `could not reach the critic: ${err?.name ?? err}` };
|
|
176
|
+
}
|
|
177
|
+
if (!res.ok) return { ok: false, error: `the critic returned HTTP ${res.status}` };
|
|
178
|
+
|
|
179
|
+
let text;
|
|
180
|
+
try {
|
|
181
|
+
const j = await res.json();
|
|
182
|
+
text = j?.choices?.[0]?.message?.content ?? '';
|
|
183
|
+
} catch {
|
|
184
|
+
return { ok: false, error: 'the critic returned a body that was not JSON' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const parsed = parseVerdict(text);
|
|
188
|
+
if (!parsed) return { ok: false, error: 'the critic did not answer in the required shape' };
|
|
189
|
+
return { ok: true, ...parsed };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* ⚠️ MODELS FENCE THEIR JSON EVEN WHEN TOLD NOT TO, and they add a sentence
|
|
194
|
+
* before it. Refusing to parse that would abstain on a perfectly good verdict,
|
|
195
|
+
* so the object is extracted rather than the whole string trusted.
|
|
196
|
+
*/
|
|
197
|
+
export function parseVerdict(text) {
|
|
198
|
+
if (!text) return null;
|
|
199
|
+
const body = String(text).replace(/```(?:json)?/gi, '');
|
|
200
|
+
const start = body.indexOf('{');
|
|
201
|
+
const end = body.lastIndexOf('}');
|
|
202
|
+
if (start < 0 || end <= start) return null;
|
|
203
|
+
let obj;
|
|
204
|
+
try { obj = JSON.parse(body.slice(start, end + 1)); } catch { return null; }
|
|
205
|
+
if (typeof obj !== 'object' || obj === null) return null;
|
|
206
|
+
|
|
207
|
+
const score = Number(obj.score);
|
|
208
|
+
return {
|
|
209
|
+
// ⚠️ A missing verdict is treated as NOT usable. The whole point is to catch
|
|
210
|
+
// bad assets, so an unparseable opinion must not read as approval.
|
|
211
|
+
usable: obj.usable === true,
|
|
212
|
+
score: Number.isFinite(score) ? Math.max(0, Math.min(10, score)) : 0,
|
|
213
|
+
problems: Array.isArray(obj.problems) ? obj.problems.map(String).slice(0, 6) : [],
|
|
214
|
+
betterPrompt: typeof obj.betterPrompt === 'string' && obj.betterPrompt.trim() ? obj.betterPrompt.trim() : null,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Below this, re-shoot. 7 is "a client would accept it". */
|
|
219
|
+
export const ACCEPT_SCORE = 7;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* ── ⭐ THE DIRECTOR: SHOOT, LOOK, RE-SHOOT ──────────────────────────────────
|
|
223
|
+
*
|
|
224
|
+
* @param generate async ({ prompt }) => { ok, path, bytes, absolutePath, mimeType }
|
|
225
|
+
*
|
|
226
|
+
* ⚠️ BOUNDED AT TWO RE-SHOOTS AND THAT IS A COST DECISION, not a quality one. A
|
|
227
|
+
* loop that keeps going until it is happy is exactly the unmetered loop that
|
|
228
|
+
* makes this product lose money per call, and image models plateau fast: if the
|
|
229
|
+
* third attempt is still wrong, the fourth will be too.
|
|
230
|
+
*
|
|
231
|
+
* ⚠️ AND IT ALWAYS RETURNS THE BEST ATTEMPT, never nothing. A page with a
|
|
232
|
+
* mediocre hero is a page; a page with no hero is a bug. What changes is that
|
|
233
|
+
* the caller is TOLD the score, so it can mention it rather than pretend.
|
|
234
|
+
*/
|
|
235
|
+
export async function directImage({
|
|
236
|
+
prompt,
|
|
237
|
+
generate,
|
|
238
|
+
readBytes,
|
|
239
|
+
mode = 'cinematic',
|
|
240
|
+
maxAttempts = 3,
|
|
241
|
+
acceptScore = ACCEPT_SCORE,
|
|
242
|
+
apiKey = process.env.OPENROUTER_API_KEY,
|
|
243
|
+
criticModel = DEFAULT_CRITIC_MODEL,
|
|
244
|
+
fetchImpl = fetch,
|
|
245
|
+
}) {
|
|
246
|
+
const directed = cinematicPrompt(prompt, { mode });
|
|
247
|
+
let current = directed.prompt;
|
|
248
|
+
const attempts = [];
|
|
249
|
+
let best = null;
|
|
250
|
+
|
|
251
|
+
for (let i = 0; i < Math.max(1, maxAttempts); i += 1) {
|
|
252
|
+
const shot = await generate({ prompt: current });
|
|
253
|
+
if (!shot?.ok) {
|
|
254
|
+
attempts.push({ attempt: i + 1, error: shot?.error ?? 'generation failed' });
|
|
255
|
+
// ⚠️ A generation failure is not a critique failure — stop, do not burn
|
|
256
|
+
// the remaining attempts on a provider that is down. The breaker upstream
|
|
257
|
+
// already decided this endpoint is not answering.
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let verdict = null;
|
|
262
|
+
if (apiKey) {
|
|
263
|
+
const bytes = readBytes ? readBytes(shot) : null;
|
|
264
|
+
if (bytes) {
|
|
265
|
+
verdict = await critiqueImage(bytes, prompt, {
|
|
266
|
+
apiKey, model: criticModel, fetchImpl,
|
|
267
|
+
mimeType: shot.mimeType ?? (String(shot.path).endsWith('.png') ? 'image/png' : 'image/jpeg'),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const score = verdict?.ok ? verdict.score : null;
|
|
273
|
+
attempts.push({ attempt: i + 1, path: shot.path, score, problems: verdict?.ok ? verdict.problems : [], criticError: verdict?.ok ? null : verdict?.error ?? 'not looked at' });
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* ⚠️ AN UNSCORED SHOT WINS ONLY IF NOTHING IS SCORED. Treating "could not
|
|
277
|
+
* look" as a passing grade would silently disable the whole mechanism the
|
|
278
|
+
* moment the critic has a bad day — the exact shape of failure that let a
|
|
279
|
+
* blind audit issue all-clears for eighteen commits.
|
|
280
|
+
*/
|
|
281
|
+
if (!best || (score ?? -1) > (best.score ?? -1)) best = { ...shot, score, problems: verdict?.ok ? verdict.problems : [] };
|
|
282
|
+
|
|
283
|
+
if (score === null) break; // no eyes available; one shot is all we can justify
|
|
284
|
+
if (score >= acceptScore) break; // good enough — stop spending
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* ── ⚠️⭐ PLATEAU: A RE-SHOOT THAT DID NOT IMPROVE WILL NOT IMPROVE ───────
|
|
288
|
+
*
|
|
289
|
+
* Measured live: three attempts at the same subject scored 2, 2, 2 — the
|
|
290
|
+
* critic's rewritten prompt each time, and 100 seconds of wall clock to
|
|
291
|
+
* arrive back where it started. The defect was structural (a coffee bag has
|
|
292
|
+
* a label and the model cannot spell), and no amount of re-prompting fixes a
|
|
293
|
+
* structural defect.
|
|
294
|
+
*
|
|
295
|
+
* ⭐ So the loop stops when a shot fails to BEAT the previous one, rather
|
|
296
|
+
* than running out its budget. One wasted re-shoot to discover the plateau
|
|
297
|
+
* is a fair price; two is just paying to be told again.
|
|
298
|
+
*/
|
|
299
|
+
const previous = attempts[attempts.length - 2];
|
|
300
|
+
if (previous && typeof previous.score === 'number' && score <= previous.score) {
|
|
301
|
+
attempts[attempts.length - 1].stoppedBecause = 'plateau — re-shooting did not improve the score';
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
if (i === maxAttempts - 1) break;
|
|
305
|
+
if (verdict?.betterPrompt) {
|
|
306
|
+
// Re-direct using the critic's own rewrite, then re-apply house direction
|
|
307
|
+
// so a plain rewrite does not undo the cinematic grade.
|
|
308
|
+
current = cinematicPrompt(verdict.betterPrompt, { mode }).prompt;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!best) return { ok: false, error: attempts[attempts.length - 1]?.error ?? 'no image was produced', attempts };
|
|
313
|
+
return {
|
|
314
|
+
ok: true,
|
|
315
|
+
...best,
|
|
316
|
+
directedPrompt: directed.prompt,
|
|
317
|
+
strippedText: directed.strippedText,
|
|
318
|
+
attempts,
|
|
319
|
+
// Honest, and it belongs in the summary the user reads.
|
|
320
|
+
accepted: (best.score ?? 0) >= acceptScore,
|
|
321
|
+
};
|
|
322
|
+
}
|