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,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ CHANGING PART OF A PICTURE, INSTEAD OF THROWING IT AWAY ─────────────
|
|
3
|
+
*
|
|
4
|
+
* Until now this agent had exactly one image verb: make a new one. So "the hero
|
|
5
|
+
* is great, just lose the sign on the van" meant regenerating the whole
|
|
6
|
+
* photograph and losing everything that was already right about it. That is not
|
|
7
|
+
* an edit, it is a re-roll, and it is why generated pages drift.
|
|
8
|
+
*
|
|
9
|
+
* Two engines that were deployed, healthy, paid for, and called by nothing:
|
|
10
|
+
*
|
|
11
|
+
* acuvo-select name a thing in words -> a pixel-accurate mask of it
|
|
12
|
+
* acuvo-flux-studio inpaint (fill a masked region) · outpaint (extend a frame)
|
|
13
|
+
*
|
|
14
|
+
* ⭐ NEITHER IS USEFUL ALONE, WHICH IS PROBABLY WHY BOTH STAYED DARK. Inpainting
|
|
15
|
+
* needs a mask, and in a terminal there is no brush and no canvas — so the
|
|
16
|
+
* inpaint verb had no door on it. `select_mask.py` says this outright in its own
|
|
17
|
+
* header: it exists to BE that door. This file is the sentence that joins them.
|
|
18
|
+
*
|
|
19
|
+
* ── ⚠️⚠️ THE ONE THING THAT MUST NOT BE FORGOTTEN ABOUT THE SELECTOR ────────
|
|
20
|
+
*
|
|
21
|
+
* **It cannot tell you whether the thing is there.** Grounding DINO returns a
|
|
22
|
+
* confident box for an object that is absent — the score localises, it does not
|
|
23
|
+
* confirm. The worker ships `presence_verified: false` and a caveat in every
|
|
24
|
+
* payload precisely so a caller cannot look at 0.95 and conclude the sofa exists.
|
|
25
|
+
*
|
|
26
|
+
* ⭐ SO THE SAFETY HERE IS STRUCTURAL, NOT STATISTICAL: **the source image is
|
|
27
|
+
* never overwritten.** The edit lands in a new file, always, and asking to write
|
|
28
|
+
* over the original is refused. A wrong selection then costs a render — about a
|
|
29
|
+
* cent — instead of costing the picture. No threshold could have given that
|
|
30
|
+
* guarantee, because the number being thresholded is the wrong number.
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ AND THE CAVEAT IS PASSED THROUGH TO THE MODEL VERBATIM rather than being
|
|
33
|
+
* absorbed here. The agent can LOOK at what it made (`read_image`, `see_page`) —
|
|
34
|
+
* it is the only participant that can actually check, so it is the one that has
|
|
35
|
+
* to be told.
|
|
36
|
+
*
|
|
37
|
+
* ── ⚠️ WHY THIS IS ITS OWN FILE AND NOT MORE OF `media.mjs` ─────────────────
|
|
38
|
+
* media.mjs is the request/response half of five services already. This is a
|
|
39
|
+
* CHAIN — select, then spawn, then poll — with its own failure modes, and a
|
|
40
|
+
* second terminal is editing media.mjs in the same worktree today. A new file
|
|
41
|
+
* costs one import and cannot collide.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { writeFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
45
|
+
import { dirname } from 'node:path';
|
|
46
|
+
import { resolveInWorkspace } from './workspace.mjs';
|
|
47
|
+
import { throughBreaker, deadReason, skipMessage } from './breaker.mjs';
|
|
48
|
+
|
|
49
|
+
export const DEFAULT_SELECT_URL = 'https://xxiautomate-star--acuvo-select-select.modal.run';
|
|
50
|
+
export const DEFAULT_FLUX_URL = 'https://xxiautomate-star--acuvo-flux-studio-studio.modal.run';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* ⚠️ THE POLL URL IS DERIVED, AND THE DERIVATION IS THE RISKY PART. Both
|
|
54
|
+
* endpoints belong to one Modal app and differ only in the function name, so a
|
|
55
|
+
* custom `MODAL_FLUX_URL` with no matching poll URL would otherwise poll the
|
|
56
|
+
* spawn endpoint forever. `RENDER_AUDIT_URL` -> drive uses the same trick and
|
|
57
|
+
* carries the same escape hatch: set `MODAL_FLUX_RESULT_URL` when the guess is
|
|
58
|
+
* wrong, rather than discovering it as a hang.
|
|
59
|
+
*/
|
|
60
|
+
export function fluxResultUrl(studioUrl) {
|
|
61
|
+
if (!studioUrl) return null;
|
|
62
|
+
return studioUrl.includes('-studio.modal.run')
|
|
63
|
+
? studioUrl.replace('-studio.modal.run', '-result.modal.run')
|
|
64
|
+
: null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 12 MB. A generated hero is ~1.5 MB; anything past this is not a web image. */
|
|
68
|
+
const MAX_IMAGE_MB = 12;
|
|
69
|
+
const MAX_IMAGE_BYTES = MAX_IMAGE_MB * 1024 * 1024;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* ⚠️ SIX MINUTES, AND IT IS NOT PESSIMISM. The first call after a scale-to-zero
|
|
73
|
+
* loads 34 GB of weights; the worker is async for exactly this reason and says
|
|
74
|
+
* so ("HTTP 500 after 306.7s, which was the door closing, not the render
|
|
75
|
+
* failing"). A warm render is ~3s, so this ceiling is only ever reached cold.
|
|
76
|
+
*/
|
|
77
|
+
const RENDER_TIMEOUT_MS = 360_000;
|
|
78
|
+
const POLL_EVERY_MS = 3_000;
|
|
79
|
+
/** The selector is ~900M parameters doing fixed work: ~2s warm, under a minute cold. */
|
|
80
|
+
const SELECT_TIMEOUT_MS = 120_000;
|
|
81
|
+
|
|
82
|
+
/** The aspects `flux_studio.py` accepts. Kept here so a wrong one is refused
|
|
83
|
+
* before a container is paid for, and named in the refusal. */
|
|
84
|
+
export const ASPECTS = ['16:9', '9:16', '4:5', '5:4', '1:1', '3:2', '2:3', '2:1', '21:9'];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* ── ⚠️⚠️ MEASURED BY LOOKING, 2026-08-12 — OUTPAINT DEGRADES WITH DISTANCE ───
|
|
88
|
+
*
|
|
89
|
+
* One 1200x800 photograph of a living room, widened three ways, then opened and
|
|
90
|
+
* looked at rather than trusted because the call returned `ok: true`:
|
|
91
|
+
*
|
|
92
|
+
* 3:2 -> 16:9 +16% new width clean. Sofa extended plausibly, faint seam.
|
|
93
|
+
* 3:2 -> 2:1 +25% the outer ~190px is a smeared red/white blur
|
|
94
|
+
* 3:2 -> 21:9 +36% both edges are vertical streaks. Unusable.
|
|
95
|
+
*
|
|
96
|
+
* ⭐ AND IT FAILS AS SMEAR, NOT AS AN ERROR — every one of those returned
|
|
97
|
+
* `ok: true` in about the same time. The worker has a flatness guard for a
|
|
98
|
+
* *blank* extension; a streak has plenty of variance and sails through it. So
|
|
99
|
+
* nothing in the stack below this line can tell the difference, and a caller who
|
|
100
|
+
* trusts the status code ships the streaks.
|
|
101
|
+
*
|
|
102
|
+
* ⚠️ THE ANSWER IS NOT TO REFUSE. 21:9 from a square is a legitimate thing to
|
|
103
|
+
* want, and a capability that refuses what it can attempt is worse than one that
|
|
104
|
+
* says how it will go. The number is reported, the warning names the fix, and
|
|
105
|
+
* the caller is told to look.
|
|
106
|
+
*/
|
|
107
|
+
export const OUTPAINT_SAFE_ADDED = 0.20;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Width and height from the file's own header — PNG (IHDR) and JPEG (SOFn).
|
|
111
|
+
* ⚠️ NO DEPENDENCY, and no decode: this reads about twelve bytes. Bringing in an
|
|
112
|
+
* image library to learn a picture's shape would break the zero-dependency rule
|
|
113
|
+
* this package's whole install story rests on.
|
|
114
|
+
*/
|
|
115
|
+
export function imageDimensions(buf) {
|
|
116
|
+
if (buf.length > 24 && buf.subarray(0, 8).toString('latin1') === '\x89PNG\r\n\x1a\n') {
|
|
117
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
118
|
+
}
|
|
119
|
+
if (buf.length > 4 && buf[0] === 0xff && buf[1] === 0xd8) {
|
|
120
|
+
let i = 2;
|
|
121
|
+
while (i + 9 < buf.length) {
|
|
122
|
+
if (buf[i] !== 0xff) { i += 1; continue; }
|
|
123
|
+
const marker = buf[i + 1];
|
|
124
|
+
// SOFn carries the size; the three exceptions are not frame headers.
|
|
125
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
|
|
126
|
+
return { width: buf.readUInt16BE(i + 7), height: buf.readUInt16BE(i + 5) };
|
|
127
|
+
}
|
|
128
|
+
const len = buf.readUInt16BE(i + 2);
|
|
129
|
+
if (len < 2) return null;
|
|
130
|
+
i += 2 + len;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** How much of the new frame is invented, 0..1. Null when the source is unreadable. */
|
|
137
|
+
export function addedFraction(source, target) {
|
|
138
|
+
if (!source?.width || !source?.height || !target?.width || !target?.height) return null;
|
|
139
|
+
const a = source.width / source.height;
|
|
140
|
+
const b = target.width / target.height;
|
|
141
|
+
if (!(a > 0) || !(b > 0)) return null;
|
|
142
|
+
return Math.max(0, 1 - Math.min(a, b) / Math.max(a, b));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function editConfig(env = process.env) {
|
|
146
|
+
const secret = env.ACUVO_MEDIA_SECRET?.trim() || env.MODAL_VIDEO_SECRET?.trim() || null;
|
|
147
|
+
// Same three-state rule as media.mjs: unset -> ours · set -> theirs ·
|
|
148
|
+
// explicitly empty -> off. A paid GPU service must fail shut with no secret.
|
|
149
|
+
const withDefault = (k, fallback) => {
|
|
150
|
+
if (k in env && (env[k] ?? '').trim() === '') return null;
|
|
151
|
+
return env[k]?.trim() || (secret ? fallback : null);
|
|
152
|
+
};
|
|
153
|
+
const flux = withDefault('MODAL_FLUX_URL', DEFAULT_FLUX_URL);
|
|
154
|
+
return {
|
|
155
|
+
select: withDefault('MODAL_SELECT_URL', DEFAULT_SELECT_URL),
|
|
156
|
+
flux,
|
|
157
|
+
fluxResult: env.MODAL_FLUX_RESULT_URL?.trim() || fluxResultUrl(flux),
|
|
158
|
+
secret,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** POST JSON through the breaker. Never throws — a failure is data. */
|
|
163
|
+
async function post(url, body, { fetchImpl = fetch, timeoutMs, label }) {
|
|
164
|
+
const already = deadReason(url);
|
|
165
|
+
if (already) return { ok: false, error: skipMessage(label, url) };
|
|
166
|
+
try {
|
|
167
|
+
const res = await throughBreaker(url, label, () => fetchImpl(url, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'content-type': 'application/json' },
|
|
170
|
+
body: JSON.stringify(body),
|
|
171
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
172
|
+
}));
|
|
173
|
+
const text = await res.text();
|
|
174
|
+
let json = null;
|
|
175
|
+
try { json = JSON.parse(text); } catch { /* keep the text */ }
|
|
176
|
+
if (!res.ok) return { ok: false, error: `HTTP ${res.status}: ${(json?.error ?? text ?? '').toString().slice(0, 400)}` };
|
|
177
|
+
// A 200 whose body says ok:false is a failure — the lesson media.mjs paid
|
|
178
|
+
// four retries to learn. The service's own words go through verbatim.
|
|
179
|
+
if (json && json.ok === false) {
|
|
180
|
+
const detail = String(json.error ?? 'the service reported a failure with no reason').slice(0, 400);
|
|
181
|
+
const auth = /unauthoris|unauthoriz|forbidden|invalid secret|401|403/i.test(detail)
|
|
182
|
+
? ' — set MODAL_VIDEO_SECRET to the value this endpoint expects (retrying will not help)'
|
|
183
|
+
: '';
|
|
184
|
+
return { ok: false, error: `${label} refused the request: ${detail}${auth}` };
|
|
185
|
+
}
|
|
186
|
+
return { ok: true, json: json ?? {} };
|
|
187
|
+
} catch (err) {
|
|
188
|
+
const code = err?.cause?.code || err?.name || 'unknown';
|
|
189
|
+
return { ok: false, error: `could not reach ${label}: ${code}` };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Read an image out of the workspace as base64, bounded. */
|
|
194
|
+
function loadImage(root, path) {
|
|
195
|
+
const target = resolveInWorkspace(root, path, 'read');
|
|
196
|
+
if (!target.ok) return { ok: false, error: target.reason };
|
|
197
|
+
let buf;
|
|
198
|
+
try { buf = readFileSync(target.absolute); }
|
|
199
|
+
catch (err) { return { ok: false, error: `could not read ${target.relative}: ${err?.message ?? err}` }; }
|
|
200
|
+
if (buf.length > MAX_IMAGE_BYTES) {
|
|
201
|
+
return { ok: false, error: `${target.relative} is ${(buf.length / 1e6).toFixed(1)} MB, over the ${MAX_IMAGE_MB} MB limit for an image edit` };
|
|
202
|
+
}
|
|
203
|
+
return { ok: true, relative: target.relative, b64: buf.toString('base64'), dimensions: imageDimensions(buf) };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* ⚠️⚠️ THE OUTPUT PATH IS NEVER THE INPUT PATH. This is the whole safety model
|
|
208
|
+
* of this file (see the header): the selector cannot verify presence, so the
|
|
209
|
+
* only honest guarantee is that a wrong edit cannot destroy the original.
|
|
210
|
+
* Refused explicitly rather than silently renamed — a caller who asked to
|
|
211
|
+
* overwrite must learn that it did not happen.
|
|
212
|
+
*/
|
|
213
|
+
function outputPath(sourceRelative, requested, suffix) {
|
|
214
|
+
if (requested && requested.trim()) {
|
|
215
|
+
const want = requested.trim();
|
|
216
|
+
if (want.replace(/\\/g, '/') === sourceRelative.replace(/\\/g, '/')) {
|
|
217
|
+
return { ok: false, error: `refusing to overwrite ${sourceRelative} — an edit built on an unverified selection must not be able to destroy the original. Choose a different output path.` };
|
|
218
|
+
}
|
|
219
|
+
return { ok: true, path: want };
|
|
220
|
+
}
|
|
221
|
+
const dot = sourceRelative.lastIndexOf('.');
|
|
222
|
+
const stem = dot > 0 ? sourceRelative.slice(0, dot) : sourceRelative;
|
|
223
|
+
return { ok: true, path: `${stem}-${suffix}.png` };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function writeImage(root, rawPath, base64, dryRun) {
|
|
227
|
+
const target = resolveInWorkspace(root, rawPath, 'write');
|
|
228
|
+
if (!target.ok) return { ok: false, error: target.reason };
|
|
229
|
+
const buf = Buffer.from(base64, 'base64');
|
|
230
|
+
if (!dryRun) {
|
|
231
|
+
mkdirSync(dirname(target.absolute), { recursive: true });
|
|
232
|
+
writeFileSync(target.absolute, buf);
|
|
233
|
+
}
|
|
234
|
+
return { ok: true, path: target.relative, bytes: buf.length, dryRun };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Spawn a flux task and wait for the picture.
|
|
239
|
+
*
|
|
240
|
+
* ⚠️ A POLL LOOP NEEDS A CLOCK IT DOES NOT OWN. `now` and `sleep` are injected
|
|
241
|
+
* so a test can drive six minutes of polling in a millisecond — otherwise the
|
|
242
|
+
* timeout branch, which is the one that matters, is the one nothing ever covers.
|
|
243
|
+
*/
|
|
244
|
+
export async function runFluxTask(task, payload, {
|
|
245
|
+
env = process.env, fetchImpl = fetch,
|
|
246
|
+
now = () => Date.now(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
247
|
+
} = {}) {
|
|
248
|
+
const cfg = editConfig(env);
|
|
249
|
+
if (!cfg.flux) return { ok: false, error: 'no image studio is configured (MODAL_FLUX_URL)' };
|
|
250
|
+
if (!cfg.fluxResult) {
|
|
251
|
+
return { ok: false, error: 'MODAL_FLUX_URL does not look like a Modal studio endpoint, so the poll URL cannot be derived — set MODAL_FLUX_RESULT_URL as well' };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const started = now();
|
|
255
|
+
const queued = await post(cfg.flux, { ...payload, task, secret: cfg.secret ?? undefined }, {
|
|
256
|
+
fetchImpl, timeoutMs: 60_000, label: 'the image studio',
|
|
257
|
+
});
|
|
258
|
+
if (!queued.ok) return queued;
|
|
259
|
+
|
|
260
|
+
const callId = queued.json?.callId;
|
|
261
|
+
if (!callId) return { ok: false, error: 'the image studio accepted the job but returned no callId, so there is nothing to poll' };
|
|
262
|
+
|
|
263
|
+
for (;;) {
|
|
264
|
+
if (now() - started > RENDER_TIMEOUT_MS) {
|
|
265
|
+
return { ok: false, error: `the ${task} did not finish within ${Math.round(RENDER_TIMEOUT_MS / 1000)}s — the job may still be running on the GPU (callId ${callId})` };
|
|
266
|
+
}
|
|
267
|
+
await sleep(POLL_EVERY_MS);
|
|
268
|
+
const poll = await post(cfg.fluxResult, { callId, secret: cfg.secret ?? undefined }, {
|
|
269
|
+
fetchImpl, timeoutMs: 60_000, label: 'the image studio',
|
|
270
|
+
});
|
|
271
|
+
// ⚠️ A FAILED POLL IS NOT A FAILED RENDER. One dropped request must not
|
|
272
|
+
// abandon a job that is still running — but a poll that says `failed` IS
|
|
273
|
+
// the answer, and `post` has already turned that into ok:false.
|
|
274
|
+
if (!poll.ok) return poll;
|
|
275
|
+
const status = poll.json?.status;
|
|
276
|
+
if (status === 'done') {
|
|
277
|
+
const data = poll.json?.data;
|
|
278
|
+
if (!data) return { ok: false, error: 'the image studio reported done and returned no image' };
|
|
279
|
+
return { ok: true, data, seconds: Math.round((now() - started) / 1000), width: queued.json?.width ?? null, height: queued.json?.height ?? null };
|
|
280
|
+
}
|
|
281
|
+
if (status && status !== 'running' && status !== 'queued') {
|
|
282
|
+
return { ok: false, error: `the ${task} ended as "${status}"` };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Name a thing in an image and get a mask of it back.
|
|
289
|
+
* Exported on its own because a caller may want the mask (or a cutout) without
|
|
290
|
+
* an edit — and because it is the half that can be wrong.
|
|
291
|
+
*/
|
|
292
|
+
export async function selectRegion(root, path, text, { env = process.env, fetchImpl = fetch } = {}) {
|
|
293
|
+
const cfg = editConfig(env);
|
|
294
|
+
if (!cfg.select) return { ok: false, error: 'no selection service is configured (MODAL_SELECT_URL)' };
|
|
295
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
296
|
+
return { ok: false, error: 'name the thing to select, e.g. "the sofa" or "the sign on the van"' };
|
|
297
|
+
}
|
|
298
|
+
const img = loadImage(root, path);
|
|
299
|
+
if (!img.ok) return img;
|
|
300
|
+
|
|
301
|
+
const res = await post(cfg.select, { image: img.b64, text: text.trim(), secret: cfg.secret ?? undefined }, {
|
|
302
|
+
fetchImpl, timeoutMs: SELECT_TIMEOUT_MS, label: 'the selection service',
|
|
303
|
+
});
|
|
304
|
+
if (!res.ok) return res;
|
|
305
|
+
|
|
306
|
+
const matched = Number(res.json?.matched ?? 0);
|
|
307
|
+
if (!matched || !res.json?.mask) {
|
|
308
|
+
/**
|
|
309
|
+
* ⭐ NOTHING MATCHED IS AN ANSWER, AND IT IS THE GOOD FAILURE. The selector's
|
|
310
|
+
* dangerous mode is the confident box for an absent object; a plain "no
|
|
311
|
+
* match" is the case where it behaved. Say what was searched for, so the
|
|
312
|
+
* next move is a different phrase rather than a repeat of the same one.
|
|
313
|
+
*/
|
|
314
|
+
return { ok: false, error: `nothing in ${img.relative} matched "${text.trim()}" — try naming it differently, or more simply ("the van", not "the white delivery van on the left")` };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
ok: true,
|
|
319
|
+
path: img.relative,
|
|
320
|
+
mask: res.json.mask,
|
|
321
|
+
matched,
|
|
322
|
+
labels: res.json?.labels ?? [],
|
|
323
|
+
scores: res.json?.scores ?? [],
|
|
324
|
+
coverage: res.json?.coverage ?? null,
|
|
325
|
+
presenceVerified: false,
|
|
326
|
+
caveat: res.json?.caveat ?? 'score is localisation confidence, not presence',
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Replace a named thing in an image with something else.
|
|
332
|
+
* select (mask) -> flux inpaint (fill) -> a NEW file in the workspace.
|
|
333
|
+
*/
|
|
334
|
+
export async function editImage(root, path, target, replacement, {
|
|
335
|
+
env = process.env, fetchImpl = fetch, dryRun = false, out = null, now, sleep,
|
|
336
|
+
} = {}) {
|
|
337
|
+
if (typeof replacement !== 'string' || !replacement.trim()) {
|
|
338
|
+
return { ok: false, error: 'say what should be there instead — the replacement description is what gets painted in' };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const selection = await selectRegion(root, path, target, { env, fetchImpl });
|
|
342
|
+
if (!selection.ok) return selection;
|
|
343
|
+
|
|
344
|
+
const dest = outputPath(selection.path, out, 'edited');
|
|
345
|
+
if (!dest.ok) return dest;
|
|
346
|
+
|
|
347
|
+
const img = loadImage(root, path);
|
|
348
|
+
if (!img.ok) return img;
|
|
349
|
+
|
|
350
|
+
const render = await runFluxTask('inpaint', {
|
|
351
|
+
image: img.b64, mask: selection.mask, prompt: replacement.trim(),
|
|
352
|
+
}, { env, fetchImpl, now, sleep });
|
|
353
|
+
if (!render.ok) return render;
|
|
354
|
+
|
|
355
|
+
const written = writeImage(root, dest.path, render.data, dryRun);
|
|
356
|
+
if (!written.ok) return written;
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* ── ⚠️⚠️ MEASURED BY LOOKING: A BIG MASK REPAINTS ITS NEIGHBOURS ──────────
|
|
360
|
+
*
|
|
361
|
+
* "the red sofa" -> "a green velvet armchair" on a real photograph, 2026-08-12.
|
|
362
|
+
* The armchair arrived and was good. The left third of the room ALSO changed —
|
|
363
|
+
* a wooden staircase and a glass balustrade appeared where a plain wall had
|
|
364
|
+
* been, because the L-shaped sofa ran to the frame edge and the inpainter,
|
|
365
|
+
* given that whole region, furnished it.
|
|
366
|
+
*
|
|
367
|
+
* ⭐ Coverage was 12.8%, so this is not an outlier at 50% — it is the ordinary
|
|
368
|
+
* behaviour of a mask that spans a lot of frame, and it returns `ok: true`.
|
|
369
|
+
* A tighter phrase ("the sofa cushions") is the fix, and it is only findable
|
|
370
|
+
* if the number reaches the caller.
|
|
371
|
+
*/
|
|
372
|
+
const coverage = Number(selection.coverage ?? 0);
|
|
373
|
+
const spread = coverage > 0.10
|
|
374
|
+
? `the selection covers ${Math.round(coverage * 100)}% of the frame; inpainting a region this large routinely repaints its surroundings too (measured: a 13% mask invented a staircase). A more specific phrase gives a safer edit.`
|
|
375
|
+
: null;
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
...written,
|
|
379
|
+
source: selection.path,
|
|
380
|
+
replaced: String(target).trim(),
|
|
381
|
+
with: replacement.trim(),
|
|
382
|
+
coverage: selection.coverage,
|
|
383
|
+
labels: selection.labels,
|
|
384
|
+
...(spread ? { warning: spread } : {}),
|
|
385
|
+
seconds: render.seconds,
|
|
386
|
+
/**
|
|
387
|
+
* ⚠️ CARRIED ALL THE WAY OUT TO THE MODEL, not absorbed here. The selector
|
|
388
|
+
* cannot confirm the thing was ever in the picture, and the agent is the
|
|
389
|
+
* only participant that can LOOK. Telling it the original is untouched is
|
|
390
|
+
* what makes "check it" a cheap instruction rather than an alarming one.
|
|
391
|
+
*/
|
|
392
|
+
presenceVerified: false,
|
|
393
|
+
note: `the selector localises but cannot confirm presence — look at ${written.path} with read_image before using it. ${selection.path} is unchanged.`,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Extend an image to a new aspect ratio by PAINTING what would have been there,
|
|
399
|
+
* instead of cropping away what is.
|
|
400
|
+
*/
|
|
401
|
+
export async function expandImage(root, path, aspect, {
|
|
402
|
+
env = process.env, fetchImpl = fetch, dryRun = false, out = null, prompt = '', now, sleep,
|
|
403
|
+
} = {}) {
|
|
404
|
+
const want = String(aspect || '16:9').trim();
|
|
405
|
+
if (!ASPECTS.includes(want)) {
|
|
406
|
+
return { ok: false, error: `unknown aspect "${want}" — have ${ASPECTS.join(', ')}` };
|
|
407
|
+
}
|
|
408
|
+
const img = loadImage(root, path);
|
|
409
|
+
if (!img.ok) return img;
|
|
410
|
+
|
|
411
|
+
const dest = outputPath(img.relative, out, want.replace(':', 'x'));
|
|
412
|
+
if (!dest.ok) return dest;
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* ⚠️ THE PROMPT IS NOT OPTIONAL TO THE SERVICE, ONLY TO THE CALLER. An
|
|
416
|
+
* outpaint with no prompt has nothing to condition the new edges on and drifts
|
|
417
|
+
* into invention — the worker's own note records an outpaint that grew a
|
|
418
|
+
* SECOND VAN. Defaulting to "continue the scene" states the intent that a
|
|
419
|
+
* caller who omitted it obviously had.
|
|
420
|
+
*/
|
|
421
|
+
const render = await runFluxTask('outpaint', {
|
|
422
|
+
image: img.b64,
|
|
423
|
+
prompt: prompt?.trim() || 'continue the existing scene naturally to the edges of the frame',
|
|
424
|
+
aspect: want,
|
|
425
|
+
}, { env, fetchImpl, now, sleep });
|
|
426
|
+
if (!render.ok) return render;
|
|
427
|
+
|
|
428
|
+
const written = writeImage(root, dest.path, render.data, dryRun);
|
|
429
|
+
if (!written.ok) return written;
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* ⭐ THE NUMBER THAT PREDICTS THE FAILURE, COMPUTED AND REPORTED. See
|
|
433
|
+
* OUTPAINT_SAFE_ADDED: this fails as smear, and smear returns ok:true. The one
|
|
434
|
+
* thing a caller can act on is how far it was asked to reach.
|
|
435
|
+
*/
|
|
436
|
+
const added = addedFraction(img.dimensions, { width: render.width, height: render.height });
|
|
437
|
+
const warning = added !== null && added > OUTPAINT_SAFE_ADDED
|
|
438
|
+
? `${Math.round(added * 100)}% of this frame is invented. Measured on a real photograph: 16% was clean, 25% smeared the outer edge, 36% was unusable — and all three returned success. Look at ${written.path}; if the edges are streaked, widen in two smaller steps or pick a nearer aspect.`
|
|
439
|
+
: null;
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
...written,
|
|
443
|
+
source: img.relative,
|
|
444
|
+
aspect: want,
|
|
445
|
+
sourceSize: img.dimensions,
|
|
446
|
+
width: render.width,
|
|
447
|
+
height: render.height,
|
|
448
|
+
invented: added === null ? null : Math.round(added * 100) / 100,
|
|
449
|
+
...(warning ? { warning } : {}),
|
|
450
|
+
seconds: render.seconds,
|
|
451
|
+
note: `${img.relative} is unchanged — the wider frame is a new file.`,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Offered only where the services are configured. A dead button costs a round. */
|
|
456
|
+
export function imageEditToolSchemas(env = process.env) {
|
|
457
|
+
const cfg = editConfig(env);
|
|
458
|
+
const out = [];
|
|
459
|
+
|
|
460
|
+
// ⚠️ edit_image needs BOTH services. Offering it with only flux configured
|
|
461
|
+
// would present a verb that cannot produce a mask, which is the "dead button"
|
|
462
|
+
// this package refuses to ship.
|
|
463
|
+
if (cfg.select && cfg.flux) {
|
|
464
|
+
out.push({
|
|
465
|
+
type: 'function',
|
|
466
|
+
function: {
|
|
467
|
+
name: 'edit_image',
|
|
468
|
+
description: [
|
|
469
|
+
'Change ONE THING in an image instead of regenerating it: name what to replace in plain words',
|
|
470
|
+
'and what should be there instead. "the sign on the van" -> "plain white panel".',
|
|
471
|
+
'Use it whenever an image is nearly right — regenerating loses everything that already worked.',
|
|
472
|
+
'Writes a NEW file; the original is never overwritten.',
|
|
473
|
+
'Name the thing as TIGHTLY as you can — a mask that spans a lot of the frame repaints its surroundings too.',
|
|
474
|
+
'The selector localises but CANNOT confirm the thing is present, so look at the result with read_image.',
|
|
475
|
+
].join(' '),
|
|
476
|
+
parameters: {
|
|
477
|
+
type: 'object',
|
|
478
|
+
properties: {
|
|
479
|
+
path: { type: 'string', description: 'Workspace-relative path to the image to edit.' },
|
|
480
|
+
target: { type: 'string', description: 'The thing to replace, named simply: "the sofa", "the sky", "the sign".' },
|
|
481
|
+
replacement: { type: 'string', description: 'What should be there instead, described as you would describe a photograph.' },
|
|
482
|
+
out: { type: 'string', description: 'Optional output path. Defaults to <name>-edited.png.' },
|
|
483
|
+
},
|
|
484
|
+
required: ['path', 'target', 'replacement'],
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (cfg.flux) {
|
|
491
|
+
out.push({
|
|
492
|
+
type: 'function',
|
|
493
|
+
function: {
|
|
494
|
+
name: 'expand_image',
|
|
495
|
+
description: [
|
|
496
|
+
'Widen or lengthen an image to a new aspect ratio by PAINTING the new edges, rather than cropping.',
|
|
497
|
+
'Use it to turn a picture you already have into a 16:9 hero, a 9:16 story or a 4:5 post',
|
|
498
|
+
'without throwing away a third of the composition. Writes a NEW file.',
|
|
499
|
+
'QUALITY FALLS OFF WITH DISTANCE: a small change of aspect is clean, a large one smears the new edges',
|
|
500
|
+
'and still reports success. Prefer the nearest aspect that works, and look at the result.',
|
|
501
|
+
].join(' '),
|
|
502
|
+
parameters: {
|
|
503
|
+
type: 'object',
|
|
504
|
+
properties: {
|
|
505
|
+
path: { type: 'string', description: 'Workspace-relative path to the image.' },
|
|
506
|
+
aspect: { type: 'string', enum: ASPECTS, description: 'Target aspect ratio.' },
|
|
507
|
+
prompt: { type: 'string', description: 'Optional hint for what the new edges contain. Defaults to continuing the scene.' },
|
|
508
|
+
out: { type: 'string', description: 'Optional output path.' },
|
|
509
|
+
},
|
|
510
|
+
required: ['path', 'aspect'],
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return out;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Names only — the offer list needs these without building the schemas twice. */
|
|
520
|
+
export function imageEditToolNames(env = process.env) {
|
|
521
|
+
return imageEditToolSchemas(env).map((t) => t.function.name);
|
|
522
|
+
}
|