2026-08-06 09f01778d5a034e2af50ae05da8908f7b6a871c8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
#!/usr/bin/env node
/**
 * API image generation fallback: renders a mock or world board with the
 * user's own OpenAI key when the harness has no native image generation.
 *
 * context.mjs reports availability (it checks OPENAI_API_KEY); harness-native
 * generation always wins when present. This uses gpt-image-2 and spends the
 * user's API credit (roughly $0.05-0.25 per image at default quality), so the
 * skill states that before the first call in a session.
 *
 *   node generate-image.mjs --prompt "..." --out mock.png [--size 1536x1024] [--quality medium]
 *   node generate-image.mjs --prompt-file prompt.txt --out mock.png
 */
import fs from 'node:fs';
import zlib from 'node:zlib';
 
function arg(name, fallback = null) {
  const i = process.argv.indexOf(`--${name}`);
  if (i === -1) return fallback;
  const v = process.argv[i + 1];
  return v && !v.startsWith('--') ? v : fallback;
}
 
// ---------------------------------------------------------------------------
// Fake mode (IMPECCABLE_IMAGE_GEN_FAKE=1)
//
// Deterministic offline stand-in for the OpenAI call: same prompt -> identical
// bytes, no network, no key, cost line reads $0.00. Used by the new-work smoke
// suite so the concept/serve-question/image chain can run without spend. The
// output renders the prompt over a 2-3 color palette hashed from the prompt,
// plus a "SYNTHETIC COMP" corner label. SVG carries the readable text; the
// raster (.png/.webp/.jpg) fallback carries palette stripes and stows the
// prompt + marker in a PNG tEXt chunk so downstream stays a valid image.
// ---------------------------------------------------------------------------
 
// FNV-1a 32-bit: tiny, dependency-free, stable across runs and platforms.
function hash32(str) {
  let h = 0x811c9dc5;
  for (let i = 0; i < str.length; i++) {
    h ^= str.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return h >>> 0;
}
 
function hslToRgb(hDeg, s, l) {
  const h = ((hDeg % 360) + 360) % 360 / 360;
  const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
  const p = 2 * l - q;
  const hue = (t) => {
    let tt = t;
    if (tt < 0) tt += 1;
    if (tt > 1) tt -= 1;
    if (tt < 1 / 6) return p + (q - p) * 6 * tt;
    if (tt < 1 / 2) return q;
    if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
    return p;
  };
  return [hue(h + 1 / 3), hue(h), hue(h - 1 / 3)].map((c) => Math.round(c * 255));
}
 
const toHex = ([r, g, b]) =>
  '#' + [r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('');
 
// Two or three deterministic swatches derived from the prompt hash. The band
// count itself is prompt-derived, so different prompts differ in palette.
function palette(prompt) {
  const h = hash32(prompt);
  const base = h % 360;
  const bands = 2 + (h >>> 9) % 2; // 2 or 3
  const spread = 40 + (h >>> 3) % 120;
  const out = [];
  for (let i = 0; i < bands; i++) {
    const hue = base + i * spread;
    const light = 0.32 + ((h >>> (i * 5)) % 40) / 100; // 0.32 - 0.71
    out.push(hslToRgb(hue, 0.55, light));
  }
  return out;
}
 
function svgFake(prompt, [w, h]) {
  const colors = palette(prompt).map(toHex);
  const stops = colors
    .map((c, i) => `<stop offset="${Math.round((i / (colors.length - 1)) * 100)}%" stop-color="${c}"/>`)
    .join('');
  // Greedy word wrap tuned to the canvas width so the prompt stays legible.
  const perLine = Math.max(12, Math.floor(w / 26));
  const words = String(prompt).replace(/\s+/g, ' ').trim().split(' ');
  const lines = [];
  let cur = '';
  for (const word of words) {
    if ((cur + ' ' + word).trim().length > perLine) {
      if (cur) lines.push(cur);
      cur = word;
    } else {
      cur = (cur + ' ' + word).trim();
    }
    if (lines.length >= 10) break;
  }
  if (cur && lines.length < 11) lines.push(cur);
  const escape = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
  const fontSize = Math.round(w / 24);
  const startY = h / 2 - ((lines.length - 1) * fontSize * 1.3) / 2;
  const text = lines
    .map((line, i) => `<text x="${w / 2}" y="${Math.round(startY + i * fontSize * 1.3)}" font-family="Helvetica, Arial, sans-serif" font-size="${fontSize}" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">${escape(line)}</text>`)
    .join('');
  return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
  <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">${stops}</linearGradient></defs>
  <rect width="${w}" height="${h}" fill="url(#g)"/>
  <rect x="0" y="0" width="${w}" height="${h}" fill="#000000" fill-opacity="0.22"/>
  ${text}
  <rect x="${w - Math.round(w / 4.2)}" y="${h - Math.round(h / 16)}" width="${Math.round(w / 4.2)}" height="${Math.round(h / 16)}" fill="#000000" fill-opacity="0.55"/>
  <text x="${w - Math.round(w / 8.4)}" y="${h - Math.round(h / 32)}" font-family="Helvetica, Arial, sans-serif" font-size="${Math.round(w / 60)}" letter-spacing="2" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">SYNTHETIC COMP</text>
</svg>
`;
}
 
// Minimal valid PNG: palette stripes plus a tEXt chunk carrying the marker and
// prompt, so a .png/.webp fake stays a decodable image and still contains the
// "SYNTHETIC" bytes downstream tools look for.
function crc32(buf) {
  let c = 0xffffffff;
  for (let i = 0; i < buf.length; i++) {
    c ^= buf[i];
    for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
  }
  return (c ^ 0xffffffff) >>> 0;
}
 
function pngChunk(type, data) {
  const typeBuf = Buffer.from(type, 'latin1');
  const body = Buffer.concat([typeBuf, data]);
  const len = Buffer.alloc(4);
  len.writeUInt32BE(data.length, 0);
  const crc = Buffer.alloc(4);
  crc.writeUInt32BE(crc32(body), 0);
  return Buffer.concat([len, body, crc]);
}
 
function pngFake(prompt, [w, h]) {
  const colors = palette(prompt); // [[r,g,b], ...]
  const bandH = Math.ceil(h / colors.length);
  // Raw image: each scanline prefixed with a 0 filter byte, RGB pixels.
  const stride = w * 3;
  const raw = Buffer.alloc(h * (stride + 1));
  for (let y = 0; y < h; y++) {
    const rowStart = y * (stride + 1);
    raw[rowStart] = 0;
    const [r, g, b] = colors[Math.min(colors.length - 1, Math.floor(y / bandH))];
    for (let x = 0; x < w; x++) {
      const p = rowStart + 1 + x * 3;
      raw[p] = r;
      raw[p + 1] = g;
      raw[p + 2] = b;
    }
  }
  const ihdr = Buffer.alloc(13);
  ihdr.writeUInt32BE(w, 0);
  ihdr.writeUInt32BE(h, 4);
  ihdr[8] = 8;  // bit depth
  ihdr[9] = 2;  // color type: truecolor RGB
  const idat = zlib.deflateSync(raw, { level: 9 });
  const textData = Buffer.concat([
    Buffer.from('Comment', 'latin1'),
    Buffer.from([0]),
    Buffer.from(`SYNTHETIC COMP: ${String(prompt).replace(/\s+/g, ' ').trim()}`, 'latin1'),
  ]);
  return Buffer.concat([
    Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
    pngChunk('IHDR', ihdr),
    pngChunk('tEXt', textData),
    pngChunk('IDAT', idat),
    pngChunk('IEND', Buffer.alloc(0)),
  ]);
}
 
function parseSize(sizeStr) {
  const m = String(sizeStr).match(/^(\d+)x(\d+)$/);
  if (!m) return [1536, 1024];
  return [Number(m[1]), Number(m[2])];
}
 
if (process.env.IMPECCABLE_IMAGE_GEN_FAKE) {
  const fakePromptFile = arg('prompt-file');
  const fakePrompt = fakePromptFile ? fs.readFileSync(fakePromptFile, 'utf8') : arg('prompt');
  const fakeOut = arg('out');
  if (!fakePrompt || !fakeOut) {
    console.error('generate-image: --prompt (or --prompt-file) and --out are required.');
    process.exit(1);
  }
  const dims = parseSize(arg('size', '1536x1024'));
  const bytes = fakeOut.endsWith('.svg')
    ? Buffer.from(svgFake(fakePrompt, dims), 'utf8')
    : pngFake(fakePrompt, dims);
  fs.writeFileSync(fakeOut, bytes);
  console.log(`IMAGE: ${fakeOut} (${dims[0]}x${dims[1]}, fake synthetic comp, $0.00, no API call)`);
  process.exit(0);
}
 
const key = process.env.OPENAI_API_KEY;
if (!key) {
  console.error('generate-image: OPENAI_API_KEY is not set; use the harness-native image tool instead.');
  process.exit(1);
}
const promptFile = arg('prompt-file');
const prompt = promptFile ? fs.readFileSync(promptFile, 'utf8') : arg('prompt');
const out = arg('out');
if (!prompt || !out) {
  console.error('generate-image: --prompt (or --prompt-file) and --out are required.');
  process.exit(1);
}
const size = arg('size', '1536x1024');
const quality = arg('quality', 'medium');
 
const response = await fetch('https://api.openai.com/v1/images/generations', {
  method: 'POST',
  headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
  body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }),
});
if (!response.ok) {
  console.error(`generate-image: API error ${response.status}: ${(await response.text()).slice(0, 300)}`);
  process.exit(1);
}
const json = await response.json();
const b64 = json?.data?.[0]?.b64_json;
if (!b64) {
  console.error('generate-image: no image in response');
  process.exit(1);
}
fs.writeFileSync(out, Buffer.from(b64, 'base64'));
// The prompt travels with the asset: embedded in the file itself (EXIF-class
// metadata via embed-prompt.mjs) so intent survives copies across harnesses,
// plus a sidecar for anything that indexes rather than opens the image.
try {
  const { spawnSync } = await import('node:child_process');
  spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
  fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2' }, null, 2));
} catch { /* embedding is best-effort */ }
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);