diff --git a/skill/reference/document.md b/skill/reference/document.md index 3cd45988f..0e9e0f96e 100644 --- a/skill/reference/document.md +++ b/skill/reference/document.md @@ -403,8 +403,9 @@ Keep skill vocabulary (seed, register, anti-reference) out of question text; ask Interview answers are words; a palette is easier picked by eye. Before writing the seed, branch on capability: -- **The harness has native image generation** (Codex's `image_gen`, an equivalent MCP tool, or similar): generate the cues directly; no setup needed. This branch wins even when `.impeccable/.env` already holds an `IMAGE_GEN_API_KEY` or an earlier run in another harness left a wrapper script behind; those are fallbacks for keyless harnesses, not the preferred path. -- **No native path**: pause and {{ask_instruction}} whether the user wants generated visual cues to pick a palette by eye. *"I can generate a few small palette-and-mood images so you choose a direction visually instead of from descriptions. That needs an image-generation API key, stored as `IMAGE_GEN_API_KEY` in `.impeccable/.env`. Add one, or skip straight to the seed?"* If a key arrives, write it to `.impeccable/.env`, confirm that file is listed in the project's `.gitignore` (add it if missing; a committed key is a leak), and ask which provider it belongs to so you call the right API. Wrap the API in a small script (e.g. `.impeccable/image-gen.mjs`) that takes a prompt, an output path, and an optional reference image, and build it to survive the pipeline: send request bodies from a temp file, never as a command-line argument, because a base64 reference image overflows argv and kills the call; retry transient network errors internally, up to three attempts with a short backoff, so a blip costs the script seconds instead of costing a specialist its generation budget; print the output path on success and exit non-zero with the error text on failure. +- **The harness has native image generation** (Codex's `image_gen`, an equivalent MCP tool, or similar): generate the cues directly; no setup needed. This branch wins even when `.impeccable/.env` already holds an `IMAGE_GEN_API_KEY` or an earlier run in another harness left a wrapper script behind; those are fallbacks for keyless harnesses, not the preferred path. A native tool that **cannot generate** (zero credits, failed auth) counts as absent: fall through to the next branch without asking, and mention the swap in the final report. +- **No usable native path, key already in `.impeccable/.env`**: no pause, no questions. Load [image-api.md](image-api.md) and use its shipped wrapper; it pre-answers everything this path has ever stopped to ask, including which provider the key belongs to. +- **No usable native path, no key**: pause and {{ask_instruction}} whether the user wants generated visual cues to pick a palette by eye. *"I can generate a few small palette-and-mood images so you choose a direction visually instead of from descriptions. That needs an image-generation API key (FLUX and Google Nano Banana are supported out of the box; other providers work too), stored as `IMAGE_GEN_API_KEY` in `.impeccable/.env`. Add one, or skip straight to the seed?"* If a key arrives, write it to `.impeccable/.env` together with `IMAGE_GEN_PROVIDER` (`bfl` for FLUX, `gemini` for Nano Banana, the provider's own name for anything else; when the user does not say, let the wrapper infer it from the key). Confirm that file is listed in the project's `.gitignore` (add it if missing; a committed key is a leak), then load [image-api.md](image-api.md). Its shipped wrapper is the whole integration for the built-in providers; only a provider it does not know earns the project-local wrapper that file specifies. - **The user opts out, or no key arrives**: go to Step 5 and seed from the answers alone. When generation is available, **stop and load [visual-cues.md](visual-cues.md)** and follow its pipeline; it owns everything from the one-line user announcement and the persona palette studio through parallel or serial generation and `cues.json`. Do not restate its mechanics here or in chat. When its `cues.json` is written, tell the user the cues are ready and **end your turn**. The pick round is a separate later step; Steps 5-6 run after that pick, or immediately when the user opted out of generation. diff --git a/skill/reference/image-api.md b/skill/reference/image-api.md new file mode 100644 index 000000000..b18290208 --- /dev/null +++ b/skill/reference/image-api.md @@ -0,0 +1,52 @@ +# Image API Path (keyless harnesses) + +Loaded when a pipeline needs image generation and the harness has **no usable native tool**. It answers, upfront, every question an agent has historically stopped to ask on this path; with a funded key in place, a run through this file asks the user nothing and debugs nothing. + +**This file never overrides a working native tool.** A harness with native image generation skips this path entirely; the precedence rule lives where the path is picked ([visual-cues.md](visual-cues.md) Step 3, [document.md](document.md) seed Step 4), not here. One refinement to that rule: a native tool that **cannot generate** (zero credits, failed auth, disabled account) counts as absent. Fall through to this path silently and mention the swap in the final report; do not stop to ask which path to use. A stopped question costs hours when the user is away; the swap costs nothing. + +## The setup, already answered + +- **Key**: `IMAGE_GEN_API_KEY` in `.impeccable/.env` at the project root. The wrapper reads that file itself; never `source` it, never export the key by hand, never rename the variable. Never delete or truncate that file either, cleanup included: it is the user's stored credential, not run output, and a wiped key turns the next run's silent keyless path into a stalled question. +- **Provider**: `IMAGE_GEN_PROVIDER` in the same file: `bfl` (FLUX / Black Forest Labs) or `gemini` (Google Nano Banana), both built into the wrapper; any other value routes to a custom wrapper (below). Loose spellings from earlier runs (`flux`, `google`, `nano-banana`) normalize to the built-ins, and a missing provider line is inferred from the key's shape (Google keys start with `AIza`; anything else runs as `bfl`), so a misworded or absent line is never a reason to stop and ask. +- **Wrapper**: `{{scripts_path}}/image-gen.mjs`, shipped with the skill. Do **not** write a new wrapper for a built-in provider, edit this one, or fall back to raw `curl`/`fetch` calls; every known failure mode below is already handled inside it. Wrappers left by earlier runs under other names (`flux-gen.mjs`, project-local copies) are superseded by the shipped one. +- **No smoke test.** A funded key plus the shipped wrapper is a working path; the first real generation is the test, and the wrapper turns transient failures into internal retries rather than failed calls. + +## The command + +One command regardless of provider; the provider switch happens inside the wrapper, so calling pipelines never branch on it: + +```text +node {{scripts_path}}/image-gen.mjs --prompt "..." --out /abs/path.png \ + [--ref /abs/reference.png] [--width 1408] [--height 1408] +``` + +Run it from the project root (that is where it finds `.impeccable/.env`). It prints the absolute output path on success and exits non-zero with the error on stderr. `--ref` switches text-to-image to image-to-image where the provider supports it. + +## Provider facts, so no one re-derives them + +**bfl** (FLUX): + +- **Models**: `flux-pro-1.1` text-to-image; with `--ref`, `flux-kontext-max` image-to-image (reference sent as base64, aspect ratio pinned 1:1). +- **Size**: BFL accepts 256-1440 px in multiples of 32. The default `1408x1408` is the largest clean square; passing `--width 1500` fails validation locally, before any credit is spent. Output is always square unless you pass unequal values. +- **Concurrency**: BFL allows 24 active tasks (`flux-kontext-max`: 6). A six-spawn wave fits both caps; do not throttle it. +- **Protocol**: submit returns a `polling_url`; the wrapper polls exactly that URL (the global endpoint requires it) and downloads the signed result URL immediately, inside its 10-minute expiry. None of this is the caller's concern. + +**gemini** (Nano Banana): + +- **Model**: `gemini-3.1-flash-image` by default; a `IMAGE_GEN_MODEL` line in `.impeccable/.env` overrides it, and the wrapper retries the `-preview` sibling once when Google's model naming drifts. +- **Size**: the wrapper pins aspect ratio 1:1, so output is always square; Gemini picks the pixel size for its tier (1024 by default) and ignores `--width`/`--height`. A 1024 square passes the pipelines' square gate as a "nearest supported square"; do not upscale it. +- **Protocol**: synchronous; one call returns the image inline, no polling. Moderation arrives as an imageless response, which the wrapper turns into a clear error, not as an HTTP failure. + +**Any other provider**: the user names it, so the integration cannot be pre-shipped. Write `.impeccable/image-gen.mjs` implementing the same CLI (same flags, print the absolute output path on success, non-zero exit with the error on stderr, transient retries handled inside), set `IMAGE_GEN_PROVIDER` to the provider's name, and the shipped wrapper delegates to it automatically; calling pipelines keep using the shipped command unchanged. Build it from the provider's API docs, and give it square output; do **not** modify the shipped wrapper to add the provider inline. + +## Failures and what they mean + +The wrapper retries transient failures internally (DNS, network blips, 429 back-pressure, poll hiccups, expired-download re-fetches), so an error that reaches the caller is real and carries its own explanation: + +- **"out of credits"** (bfl, HTTP 402): a human must top up at dashboard.bfl.ai. Report it and stop this path; retrying is pointless, and so is asking the user to choose an alternative that does not exist. +- **"quota or rate limit exhausted"** (gemini, HTTP 429 after the wrapper's own retries): the key's plan is out of headroom. Report it and stop this path; the fix is billing, not retries. +- **"rejected the key"** (either provider): the key in `.impeccable/.env` is wrong or revoked. Report it; do not mint debugging sessions around a dead key. +- **Moderation** ("Content Moderated" / "Request Moderated" / "Prompt was moderated"): the prompt tripped the provider's filter; rewording the prompt is the fix, within the caller's normal generation budget. +- **"cannot resolve"**: the wrapper already tried the system resolver, `dig`, Google, and Cloudflare. **Never debug DNS beyond this**: no `/etc/hosts` edits, no new resolvers, no rewriting the wrapper to use `fetch()` (sandboxed harnesses block the default resolver for these hosts; the wrapper pins IPs via `curl --resolve` for exactly that reason). Report the failure and let the parent decide. + +Subagents on this path inherit the generation-failure budget from their own pipeline ([visual-cues.md](visual-cues.md)'s three-call budget, or the calling pipeline's equivalent); the wrapper's internal retries do not count against it, only whole failed invocations do. diff --git a/skill/reference/visual-cues.md b/skill/reference/visual-cues.md index edcd1b778..807abb706 100644 --- a/skill/reference/visual-cues.md +++ b/skill/reference/visual-cues.md @@ -157,9 +157,9 @@ Done when: six one-line territories exist, each closing on a hue ground, no two ## Step 3: The wave (parallel) -**Pick the generation path first.** The harness's native image-generation tool is the path whenever one exists; the `IMAGE_GEN_API_KEY` wrapper exists only for harnesses that have none. A key in `.impeccable/.env` or a wrapper script left by an earlier run in another harness does not outrank a native tool: check for the native tool first, and touch the wrapper only after confirming there is none. +**Pick the generation path first.** The harness's native image-generation tool is the path whenever one exists and works; a native tool that **cannot generate** (zero credits, failed auth) counts as absent: fall through without asking the user, and mention the swap in the final report. The keyless path is [image-api.md](image-api.md): its shipped wrapper and pre-answered setup are canonical, so a key in `.impeccable/.env` or a leftover project-local wrapper never outranks a working native tool, and never needs re-deriving when it is the path. -**Do not smoke-test the path.** Presence is the whole check: a tool the harness lists works, and the wrapper spec in [document.md](document.md) already retries transient failures internally, so a preflight generation buys nothing the first persona's report would not carry, and it costs a generation call and half a minute on every clean run. Instead, fill the brief file's tool slot with the exact call the spawns will make: the tool or wrapper command, the square-size parameter to pass, and where it writes output files (some native tools ignore directory paths and save to a fixed folder of their own; say so in the slot, so no specialist rediscovers it alone). +**Do not smoke-test the path.** Presence is the whole check: a tool the harness lists works, and the image-api.md wrapper already retries transient failures internally, so a preflight generation buys nothing the first persona's report would not carry, and it costs a generation call and half a minute on every clean run. Instead, fill the brief file's tool slot with the exact call the spawns will make: the tool or wrapper command, the square-size parameter to pass, where it writes output files (some native tools ignore directory paths and save to a fixed folder of their own; say so in the slot, so no specialist rediscovers it alone), and whether the output is already guaranteed square (the shipped wrapper's is), so no specialist burns a tool call measuring it. If the harness exposes any subagent/spawn tool (Task, spawn_agent, agents, or similar), parallel is **required**, not preferred: emit all six spawns as **one tool-call batch, a single message carrying six spawn calls**, one persona per subagent, each doing the full job (palette, concept, hero), and only then wait for the reports. Spawning one, waiting for its report, then spawning the next is a serial loop and a failure even though every spawn "used a subagent"; so is generating any image yourself while a subagent tool exists. The whole run must take only as long as the slowest single persona. Attach the harness's image-generation skill to each spawn when the harness expects that (Codex: the `imagegen` skill). (No subagent tool at all: Step 4.) @@ -314,7 +314,7 @@ node {{scripts_path}}/visual-cues.mjs compile [hero.png] \ --out .impeccable/visual-cues ``` -The script copies the hero untouched to `[slug].png`; for each palette role it searches the hero for the closest rendered pixel (`snapped`, with its hero position), then updates `cues.json`: +The script copies the hero untouched to `[slug].png` (removing a `[slug]-hero.png` intermediate inside the out dir, so the folder holds one file per cue, not a byte-identical pair); for each palette role it searches the hero for the closest rendered pixel (`snapped`, with its hero position), then updates `cues.json`: ```json { diff --git a/skill/scripts/image-gen.mjs b/skill/scripts/image-gen.mjs new file mode 100644 index 000000000..1150e8ecc --- /dev/null +++ b/skill/scripts/image-gen.mjs @@ -0,0 +1,328 @@ +#!/usr/bin/env node +// image-gen.mjs — image generation for keyless harnesses. +// Playbook: skill/reference/image-api.md (canonical; this help text is not). +// +// node image-gen.mjs --prompt "..." --out /abs/path.png +// [--ref /abs/ref.png] [--width 1408] [--height 1408] +// +// One CLI, several providers. IMAGE_GEN_PROVIDER in .impeccable/.env picks +// the backend: +// bfl FLUX (Black Forest Labs). No ref: flux-pro-1.1 text-to-image; +// with ref: flux-kontext-max image-to-image, aspect ratio 1:1. +// gemini Google Nano Banana (Gemini image models), always square 1:1. +// delegates to a project-local .impeccable/image-gen.mjs that +// implements this same CLI (see image-api.md for the contract). +// When the provider line is missing it is inferred from the key's shape +// (Google keys start with "AIza"; anything else is treated as bfl). +// +// Prints the absolute output path on success; exits non-zero with the +// error on stderr on failure. Dependency-free; needs curl and (as a DNS +// fallback) dig on PATH. +// +// Reads IMAGE_GEN_API_KEY from the environment, falling back to +// ./.impeccable/.env relative to the working directory, so callers never +// need to `source` anything: run it from the project root and it finds +// the key itself. + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import dns from "node:dns"; +import { execFileSync, spawnSync } from "node:child_process"; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// ------------------------------------------------------------------ env + +// The key lives in .impeccable/.env per the document seed flow. Loading it +// here (instead of requiring the caller to export it) removes the one setup +// step subagents historically forgot, which cost a failed call each time. +// IMAGE_API_KEY is accepted as a legacy alias: early seed runs wrote that +// name, and those .env files are still in the wild. +function loadEnv(...names) { + for (const name of names) if (process.env[name]) return process.env[name]; + const envPath = path.join(process.cwd(), ".impeccable", ".env"); + if (!fs.existsSync(envPath)) return undefined; + const vars = {}; + for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { + const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/); + if (m) vars[m[1]] = m[2].replace(/^["']|["']$/g, ""); + } + for (const name of names) if (vars[name]) return vars[name]; + return undefined; +} + +// ------------------------------------------------------------------ DNS + +// Sandboxed harnesses (Claude Code among them) often block the default +// resolver for the providers' hosts while the hosts stay reachable by IP. +// So every request resolves the host here — system resolver first, then +// dig against the default, Google, and Cloudflare resolvers — and pins +// curl to the IP with --resolve. fetch() is never used; it dies at the +// DNS stage. +async function resolveIp(hostname) { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const { address } = await dns.promises.lookup(hostname, { family: 4 }); + if (address) return address; + } catch { + // fall through to dig + } + for (const server of [null, "8.8.8.8", "1.1.1.1"]) { + try { + const args = ["+short", "+time=3", "A", hostname]; + if (server) args.push(`@${server}`); + const ips = execFileSync("dig", args, { encoding: "utf8" }) + .trim() + .split("\n") + .map((l) => l.trim()) + .filter((l) => /^\d+\.\d+\.\d+\.\d+$/.test(l)); + if (ips.length > 0) return ips[ips.length - 1]; + } catch { + // next resolver + } + } + await sleep(1000); + } + throw new Error(`cannot resolve ${hostname} via system resolver, dig, 8.8.8.8, or 1.1.1.1`); +} + +// ----------------------------------------------------------------- curl + +// Returns { status, json, text } instead of throwing on HTTP errors, so +// callers can branch on 402 (credits) and 429 (rate/quota) rather than +// seeing one opaque curl failure. Request bodies always travel via a temp +// file: a base64 reference image passed as a literal -d argument overflows +// argv (E2BIG) and kills the call before it reaches the network. +async function curlJson(url, { method = "GET", headers = {}, body } = {}) { + const { hostname } = new URL(url); + const ip = await resolveIp(hostname); + const args = ["-sS", "--max-time", "180", "--resolve", `${hostname}:443:${ip}`, "-X", method, "-w", "\n%{http_code}"]; + for (const [k, v] of Object.entries(headers)) args.push("-H", `${k}: ${v}`); + let bodyFile; + if (body !== undefined) { + bodyFile = path.join(os.tmpdir(), `image-gen-body-${process.pid}-${Date.now()}.json`); + fs.writeFileSync(bodyFile, body); + args.push("-d", `@${bodyFile}`); + } + args.push(url); + try { + const out = execFileSync("curl", args, { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }); + const nl = out.lastIndexOf("\n"); + const status = parseInt(out.slice(nl + 1), 10); + const text = out.slice(0, nl); + let json = null; + try { + json = JSON.parse(text); + } catch { + // non-JSON body (edge HTML error page); callers see json === null + } + return { status, json, text }; + } finally { + if (bodyFile) fs.rmSync(bodyFile, { force: true }); + } +} + +async function download(url, outPath) { + const { hostname } = new URL(url); + let lastErr; + // Re-resolve on every attempt: CDN delivery hosts are the flakiest to + // resolve, and a fresh IP is usually what fixes a failure. + for (let attempt = 0; attempt < 3; attempt++) { + try { + const ip = await resolveIp(hostname); + execFileSync("curl", ["-sS", "-f", "--max-time", "60", "--resolve", `${hostname}:443:${ip}`, "-o", outPath, url]); + if (fs.existsSync(outPath) && fs.statSync(outPath).size > 0) return; + lastErr = new Error("download produced an empty file"); + } catch (e) { + lastErr = e; + } + await sleep(2000 * (attempt + 1)); + } + throw lastErr; +} + +// ----------------------------------------------------------------- args + +function getArg(name, def) { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? process.argv[i + 1] : def; +} + +function fail(msg) { + console.error(msg); + process.exit(1); +} + +// ------------------------------------------------------------------ bfl + +// FLUX is asynchronous: submit returns a polling_url, poll until Ready, +// download the signed result URL inside its 10-minute expiry. Transient +// failures are absorbed internally so a network blip costs this script +// seconds instead of costing a caller one of its generation attempts. +// Only two failures are final on the spot: 402 means the account is out +// of credits (a human must top up; retrying is pointless), and a +// moderation status means the prompt itself must change. +async function generateBfl({ apiKey, prompt, ref, width, height, out }) { + for (const [label, v] of [["width", width], ["height", height]]) { + if (Number.isNaN(v) || v < 256 || v > 1440 || v % 32 !== 0) { + fail(`${label} ${v} out of range: BFL takes 256-1440 in multiples of 32`); + } + } + + const base = "https://api.bfl.ai"; + let endpoint, body; + if (ref) { + endpoint = "/v1/flux-kontext-max"; + body = { prompt, input_image: fs.readFileSync(ref).toString("base64"), aspect_ratio: "1:1", output_format: "png" }; + } else { + endpoint = "/v1/flux-pro-1.1"; + body = { prompt, width, height, output_format: "png" }; + } + + const authHeaders = { "x-key": apiKey, "Content-Type": "application/json", accept: "application/json" }; + let submit; + for (let attempt = 0; ; attempt++) { + try { + submit = await curlJson(base + endpoint, { method: "POST", headers: authHeaders, body: JSON.stringify(body) }); + } catch (e) { + submit = { status: 0, json: null, text: e.message }; + } + if (submit.status === 200 && submit.json?.polling_url) break; + if (submit.status === 402) fail("BFL account is out of credits; add credits at dashboard.bfl.ai and re-run"); + if (submit.status === 401 || submit.status === 403) fail(`BFL rejected the key (HTTP ${submit.status}): check IMAGE_GEN_API_KEY`); + if (attempt >= 2) fail(`Submit failed after 3 attempts (last HTTP ${submit.status}): ${submit.text?.slice(0, 300)}`); + // 429 is the active-task cap (24 tasks; 6 for kontext-max): wait longer. + await sleep(submit.status === 429 ? 10000 : 2000 * (attempt + 1)); + } + + // Poll the returned polling_url (never a reconstructed one; the global + // endpoint requires it). Tolerate a few consecutive transient poll + // failures — the task keeps running server-side regardless. + let result; + let pollFailures = 0; + for (let i = 0; i < 150; i++) { + await sleep(2000); + let poll; + try { + poll = await curlJson(submit.json.polling_url, { headers: { "x-key": apiKey, accept: "application/json" } }); + } catch { + poll = null; + } + if (!poll || poll.status >= 500 || !poll.json) { + if (++pollFailures >= 5) fail("Polling failed 5 times in a row; giving up"); + continue; + } + pollFailures = 0; + if (poll.json.status === "Ready") { + result = poll.json.result; + break; + } + if (["Error", "Failed", "Content Moderated", "Request Moderated", "Task not found"].includes(poll.json.status)) { + fail(`Generation failed with status "${poll.json.status}": ${JSON.stringify(poll.json).slice(0, 300)}`); + } + } + if (!result) fail("Timed out waiting for the generation (5 minutes)"); + + // The sample URL is signed and expires after 10 minutes; download now. + await download(result.sample, out); +} + +// --------------------------------------------------------------- gemini + +// Nano Banana is synchronous: one generateContent call returns the image +// as base64 in the response, no polling, no delivery CDN. The aspect ratio +// is pinned 1:1 in imageConfig, so output is always square regardless of +// --width/--height (Gemini picks its own pixel size per tier; the pipeline +// only requires square). Moderation shows up as a response with no image +// part plus a block reason, not as an HTTP error. +async function generateGemini({ apiKey, prompt, ref, out }) { + // IMAGE_GEN_MODEL overrides for users on a different tier; the default + // is the high-volume Nano Banana model. + let model = loadEnv("IMAGE_GEN_MODEL") || "gemini-3.1-flash-image"; + const parts = [{ text: prompt }]; + if (ref) parts.push({ inlineData: { mimeType: "image/png", data: fs.readFileSync(ref).toString("base64") } }); + const body = JSON.stringify({ + contents: [{ parts }], + generationConfig: { responseModalities: ["IMAGE"], imageConfig: { aspectRatio: "1:1" } }, + }); + const headers = { "x-goog-api-key": apiKey, "Content-Type": "application/json" }; + const urlFor = (m) => `https://generativelanguage.googleapis.com/v1beta/models/${m}:generateContent`; + + let res; + for (let attempt = 0; ; attempt++) { + try { + res = await curlJson(urlFor(model), { method: "POST", headers, body }); + } catch (e) { + res = { status: 0, json: null, text: e.message }; + } + if (res.status === 200) break; + const msg = res.json?.error?.message || res.text?.slice(0, 300) || ""; + if (res.status === 400 && /API key not valid/i.test(msg)) fail(`Gemini rejected the key: check IMAGE_GEN_API_KEY (${msg.slice(0, 200)})`); + if (res.status === 401 || res.status === 403) fail(`Gemini rejected the key (HTTP ${res.status}): ${msg.slice(0, 200)}`); + // Model ids drift between stable and -preview suffixes as Google + // promotes them; try the sibling name once before giving up. + if (res.status === 404 && !model.endsWith("-preview")) { + model = `${model}-preview`; + continue; + } + if (res.status === 429 && attempt >= 4) fail(`Gemini quota or rate limit exhausted after 5 attempts: ${msg.slice(0, 200)}; check the plan and billing for this key`); + if (attempt >= 4) fail(`Gemini call failed after 5 attempts (last HTTP ${res.status}): ${msg.slice(0, 300)}`); + await sleep(res.status === 429 ? 15000 : 2000 * (attempt + 1)); + } + + const blocked = res.json?.promptFeedback?.blockReason; + if (blocked) fail(`Prompt was moderated (${blocked}); reword the prompt and re-run`); + const cand = res.json?.candidates?.[0]; + const imgPart = cand?.content?.parts?.find((p) => p.inlineData?.data || p.inline_data?.data); + if (!imgPart) { + const reason = cand?.finishReason || "no image part in the response"; + fail(`Generation returned no image (${reason}); reword the prompt and re-run`); + } + fs.writeFileSync(out, Buffer.from(imgPart.inlineData?.data || imgPart.inline_data.data, "base64")); +} + +// ----------------------------------------------------------------- main + +const prompt = getArg("prompt"); +const out = getArg("out"); +const ref = getArg("ref"); +// 1408 is the default square: comfortably under BFL's 1440 cap and +// divisible by 32. Gemini ignores it (aspect ratio 1:1 pins its square). +const width = parseInt(getArg("width", "1408"), 10); +const height = parseInt(getArg("height", "1408"), 10); +const apiKey = loadEnv("IMAGE_GEN_API_KEY", "IMAGE_API_KEY"); +// Users and earlier runs write provider names loosely ("flux" for bfl, +// "nano-banana" for gemini); normalize the known spellings instead of +// failing on them. Google API keys start with "AIza", so a missing +// provider line is recoverable from the key itself. +const PROVIDER_ALIASES = { + bfl: "bfl", flux: "bfl", "black-forest-labs": "bfl", + gemini: "gemini", google: "gemini", "nano-banana": "gemini", nanobanana: "gemini", +}; +const rawProvider = (loadEnv("IMAGE_GEN_PROVIDER") || (apiKey?.startsWith("AIza") ? "gemini" : "bfl")).toLowerCase(); +const provider = PROVIDER_ALIASES[rawProvider] || rawProvider; + +if (!prompt || !out) fail("Usage: --prompt

--out [--ref ] [--width n] [--height n]"); + +if (provider !== "bfl" && provider !== "gemini") { + // Unknown provider: hand the same argv to a project-local wrapper that + // implements this CLI. The env guard stops a copied shipped script from + // delegating to itself forever. + const custom = path.join(process.cwd(), ".impeccable", "image-gen.mjs"); + if (process.env.IMPECCABLE_IMAGE_GEN_DELEGATED || !fs.existsSync(custom)) { + fail(`Unknown IMAGE_GEN_PROVIDER "${provider}" and no ${custom}; supported providers are bfl and gemini, or write that file implementing the same CLI (see reference/image-api.md)`); + } + const child = spawnSync(process.execPath, [custom, ...process.argv.slice(2)], { + stdio: "inherit", + env: { ...process.env, IMPECCABLE_IMAGE_GEN_DELEGATED: "1" }, + }); + process.exit(child.status ?? 1); +} + +if (!apiKey) fail("Missing IMAGE_GEN_API_KEY (environment or ./.impeccable/.env)"); + +fs.mkdirSync(path.dirname(out), { recursive: true }); +if (provider === "gemini") await generateGemini({ apiKey, prompt, ref, out }); +else await generateBfl({ apiKey, prompt, ref, width, height, out }); +console.log(path.resolve(out)); diff --git a/skill/scripts/visual-cues.mjs b/skill/scripts/visual-cues.mjs index f3ad04669..db00fbe9a 100644 --- a/skill/scripts/visual-cues.mjs +++ b/skill/scripts/visual-cues.mjs @@ -7,8 +7,10 @@ // node visual-cues.mjs compile --slug // [--palette "primary=#RRGGBB;secondary=...;tertiary=...;neutral=..."] // [--out

] (default: .impeccable/visual-cues) -// Copies the hero untouched to .png, finds each planned palette -// hex's closest pixel in the hero, and updates /cues.json. +// Copies the hero untouched to .png (removing the source when it +// is an intermediate inside , so the folder holds one file per +// cue, not a byte-identical pair), finds each planned palette hex's +// closest pixel in the hero, and updates /cues.json. // The hero must be square: generation happens on a square canvas // (a size/aspect parameter, not just a prompt line), and a non-square // input is a generation to redo, not an image to fix up here. @@ -16,8 +18,8 @@ // Dependency-free: PNG decode on node:zlib. Rejects interlaced and // indexed-color PNGs; convert those with sips/ImageMagick/PIL first. -import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, realpathSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, realpathSync, unlinkSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import zlib from 'node:zlib'; @@ -268,7 +270,13 @@ function cmdCompile(args) { mkdirSync(outDir, { recursive: true }); const heroPath = join(outDir, `${slug}.png`); - copyFileSync(resolve(heroFile), heroPath); // the hero ships untouched, no crop + const srcPath = resolve(heroFile); + copyFileSync(srcPath, heroPath); // the hero ships untouched, no crop + // Subagents drop `-hero.png` intermediates into the out dir; once + // the canonical `.png` exists, that intermediate is a byte-identical + // duplicate that doubles the folder, so remove it. A source outside the + // out dir (a native tool's own output folder) is not ours to delete. + if (srcPath !== heroPath && dirname(srcPath) === outDir) unlinkSync(srcPath); // --palette is optional: the agent may compile before it has finished // designing the palette, and can re-run compile later once it has hexes.