explainmyrepo 0.1.1

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.
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env node
2
+ // generate-image.mjs — Station 4 (VISUALIZE): generate the EMOTIONAL raster rungs.
3
+ //
4
+ // One pure tool over the verified OpenAI image engine. Reads the brain-authored emotional
5
+ // rungs from the BuildContext (`visuals.hero` + every entry in `visuals.sections[]`), generates
6
+ // each as a real raster image, and merges ONLY its own two slots back into build.json. The
7
+ // STRUCTURAL rungs (architecture/flow/big-idea/insight SVGs) are make-diagrams' job, not this one.
8
+ //
9
+ // Image engine (ADR-0005 D7 / Station 4): PRIMARY = gpt-image-2 (verified real + available in this
10
+ // project 2026-06-28 via GET /v1/models/gpt-image-2 -> HTTP 200), quality "high". FALLBACK =
11
+ // gpt-image-1, used ONLY if a build-time availability probe of gpt-image-2 fails. If the whole
12
+ // OpenAI chain 404s we STOP LOUD with the failing IDs — never a silent substitution, never a
13
+ // placeholder. (Deeper cross-provider fallbacks imagen-3 -> gemini-2.x-image are out of scope for
14
+ // this OpenAI-keyed tool; their absence is a loud stop, not a fake image.)
15
+ //
16
+ // Sizes: hero = 1536x1024; raster sections = 1024x1024 (valid gpt-image sizes: 1024x1024,
17
+ // 1024x1536, 1536x1024, auto — the DALL·E-3 1792x1024 is rejected).
18
+ //
19
+ // CONTRACT (tools/CONTRACT.md): pure (reads only `visuals` rungs + `concept.palette` + the OpenAI
20
+ // key from env), fail-loud (non-zero exit + clear message, NEVER a placeholder asset), single JSON
21
+ // result object on stdout, diagnostics on stderr, merges ONLY visuals.hero + visuals.sections[].
22
+ //
23
+ // Usage: node tools/generate-image.mjs <build-dir>
24
+
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const TOOLS_DIR = path.dirname(fileURLToPath(import.meta.url)); // tools/
30
+ const ROOT = path.resolve(TOOLS_DIR, '..');
31
+
32
+ const API_URL = 'https://api.openai.com/v1';
33
+ const QUALITY = 'high'; // owner requirement: max quality
34
+ const PRIMARY_MODEL = 'gpt-image-2'; // verified primary (ADR-0005 D7)
35
+ const FALLBACK_MODEL = 'gpt-image-1'; // safety net only if the probe fails
36
+ const VALID_SIZES = new Set(['1024x1024', '1024x1536', '1536x1024', 'auto']);
37
+ const PROBE_TIMEOUT_MS = 30_000;
38
+ const GEN_TIMEOUT_MS = 300_000; // high-quality photoreal renders can take 60–90s+ when the endpoint is loaded; 180s was too tight and aborted mid-render
39
+ const GEN_ATTEMPTS = 3; // retry transient upstream 502s / aborts
40
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
41
+
42
+ // ---- single-JSON-on-stdout result helpers (exit code is the source of truth) ----
43
+ function emit(result, code) {
44
+ process.stdout.write(JSON.stringify(result) + '\n');
45
+ process.exit(code);
46
+ }
47
+ function fail(error, code = 1) { emit({ ok: false, outputs: {}, error }, code); }
48
+ function succeed(outputs) { emit({ ok: true, outputs, error: null }, 0); }
49
+
50
+ // ---- env: OpenAI key from process env, else parse the repo-root .env (OPENAI_API_KEY / OPEN_AI_KEY) ----
51
+ function parseEnvFile(file, keys) {
52
+ const out = {};
53
+ let text;
54
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return out; }
55
+ for (const raw of text.split(/\r?\n/)) {
56
+ const line = raw.trim();
57
+ if (!line || line.startsWith('#')) continue;
58
+ const eq = line.indexOf('=');
59
+ if (eq === -1) continue;
60
+ const k = line.slice(0, eq).trim();
61
+ if (!keys.includes(k)) continue;
62
+ let v = line.slice(eq + 1).trim();
63
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
64
+ out[k] = v;
65
+ }
66
+ return out;
67
+ }
68
+ function loadOpenAiKey() {
69
+ const fromProc = process.env.OPENAI_API_KEY || process.env.OPEN_AI_KEY;
70
+ if (fromProc && fromProc.trim()) return fromProc.trim();
71
+ const dotenv = parseEnvFile(path.join(ROOT, '.env'), ['OPENAI_API_KEY', 'OPEN_AI_KEY']);
72
+ const fromFile = dotenv.OPENAI_API_KEY || dotenv.OPEN_AI_KEY;
73
+ return fromFile && fromFile.trim() ? fromFile.trim() : null;
74
+ }
75
+
76
+ async function fetchWithTimeout(url, opts, timeoutMs) {
77
+ const ctrl = new AbortController();
78
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
79
+ try {
80
+ return await fetch(url, { ...opts, signal: ctrl.signal });
81
+ } finally {
82
+ clearTimeout(t);
83
+ }
84
+ }
85
+
86
+ // Probe a model ID against the live keys; returns true iff GET /v1/models/<id> -> HTTP 200.
87
+ async function probeModel(model, apiKey) {
88
+ let res;
89
+ try {
90
+ res = await fetchWithTimeout(`${API_URL}/models/${encodeURIComponent(model)}`,
91
+ { headers: { Authorization: `Bearer ${apiKey}` } }, PROBE_TIMEOUT_MS);
92
+ } catch (e) {
93
+ console.error(`[generate-image] probe ${model}: network error — ${e?.message || e}`);
94
+ return false;
95
+ }
96
+ if (res.status === 200) { console.error(`[generate-image] probe ${model}: HTTP 200 (available)`); return true; }
97
+ console.error(`[generate-image] probe ${model}: HTTP ${res.status} (unavailable)`);
98
+ return false;
99
+ }
100
+
101
+ // One pure image API call. Returns a validated PNG Buffer or THROWS (no placeholder, ever).
102
+ async function generateOne(model, prompt, size, apiKey) {
103
+ let res;
104
+ try {
105
+ res = await fetchWithTimeout(`${API_URL}/images/generations`, {
106
+ method: 'POST',
107
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
108
+ body: JSON.stringify({ model, prompt, size, quality: QUALITY, n: 1 }),
109
+ }, GEN_TIMEOUT_MS);
110
+ } catch (e) {
111
+ throw new Error(`image API request failed (${model}, ${size}): ${e?.message || e}`);
112
+ }
113
+ const bodyText = await res.text();
114
+ if (res.status !== 200) {
115
+ let msg = bodyText;
116
+ try { msg = JSON.parse(bodyText)?.error?.message || bodyText; } catch { /* keep raw */ }
117
+ throw new Error(`image API HTTP ${res.status} (${model}, ${size}): ${msg}`);
118
+ }
119
+ let json;
120
+ try { json = JSON.parse(bodyText); } catch { throw new Error(`image API returned non-JSON (${model}): ${bodyText.slice(0, 200)}`); }
121
+ const b64 = json?.data?.[0]?.b64_json;
122
+ if (!b64) throw new Error(`image API 200 but no b64_json image in response (${model}, ${size})`);
123
+ const buf = Buffer.from(b64, 'base64');
124
+ if (buf.length === 0) throw new Error(`image API returned an empty image (${model}, ${size})`);
125
+ if (!buf.subarray(0, 8).equals(PNG_MAGIC)) throw new Error(`image API returned non-PNG bytes (${model}, ${size})`);
126
+ return buf;
127
+ }
128
+
129
+ function safeName(s) { return String(s).replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'image'; }
130
+
131
+ // Build a deterministic colour-direction suffix from the brain's palette (pure transform).
132
+ function paletteSuffix(palette) {
133
+ if (!palette || typeof palette !== 'object') return '';
134
+ const parts = Object.entries(palette)
135
+ .filter(([, v]) => v != null && String(v).trim())
136
+ .map(([k, v]) => `${k}: ${String(v).trim()}`);
137
+ return parts.length ? `\n\nColour direction (hold to this palette): ${parts.join('; ')}.` : '';
138
+ }
139
+
140
+ // Normalise the brain-declared rungs into a uniform work list. Fail loud on any malformed rung.
141
+ function collectRungs(visuals) {
142
+ const rungs = [];
143
+ const hero = visuals.hero;
144
+ if (hero && (hero.prompt != null || hero.role != null || hero.px != null)) {
145
+ rungs.push({ kind: 'hero', id: 'hero', role: hero.role, prompt: hero.prompt, px: hero.px || '1536x1024' });
146
+ }
147
+ const sections = visuals.sections;
148
+ if (sections != null) {
149
+ if (!Array.isArray(sections)) throw new Error('visuals.sections must be an array');
150
+ sections.forEach((s, i) => {
151
+ if (!s || typeof s !== 'object') throw new Error(`visuals.sections[${i}] is not an object`);
152
+ rungs.push({ kind: 'section', index: i, id: s.id, role: s.role, prompt: s.prompt, px: s.px || '1024x1024' });
153
+ });
154
+ }
155
+ return rungs;
156
+ }
157
+
158
+ function validateRung(r) {
159
+ const where = r.kind === 'hero' ? 'visuals.hero' : `visuals.sections[${r.index}]`;
160
+ if (r.kind === 'section' && (r.id == null || String(r.id).trim() === '')) throw new Error(`${where}.id is required (drives filename + arc mapping)`);
161
+ if (r.role == null || String(r.role).trim() === '') throw new Error(`${where}.role is required`);
162
+ if (r.prompt == null || String(r.prompt).trim() === '') throw new Error(`${where}.prompt is required`);
163
+ if (!VALID_SIZES.has(r.px)) throw new Error(`${where}.px="${r.px}" is not a valid gpt-image size (allowed: ${[...VALID_SIZES].join(', ')})`);
164
+ }
165
+
166
+ async function main() {
167
+ const buildDir = process.argv[2];
168
+ if (!buildDir) fail('usage: node tools/generate-image.mjs <build-dir>', 2);
169
+ const absBuildDir = path.isAbsolute(buildDir) ? buildDir : path.resolve(process.cwd(), buildDir);
170
+ const buildJsonPath = path.join(absBuildDir, 'build.json');
171
+
172
+ // ---- read the BuildContext + take ONLY the declared slice ----
173
+ let build;
174
+ try { build = JSON.parse(fs.readFileSync(buildJsonPath, 'utf8')); }
175
+ catch (e) { return fail(`cannot read build.json at ${buildJsonPath}: ${e?.message || e}`); }
176
+
177
+ const visuals = build.visuals;
178
+ if (!visuals || typeof visuals !== 'object') return fail('build.json has no `visuals` slot — nothing declared to generate (a missing input is a loud stop)');
179
+
180
+ let rungs;
181
+ try { rungs = collectRungs(visuals); } catch (e) { return fail(`malformed visuals rungs: ${e?.message || e}`); }
182
+ if (rungs.length === 0) return fail('no emotional rungs declared (visuals.hero + visuals.sections[] are both empty) — nothing to generate');
183
+ try { for (const r of rungs) validateRung(r); } catch (e) { return fail(e?.message || String(e)); }
184
+
185
+ const palette = (build.concept && typeof build.concept === 'object') ? build.concept.palette : null;
186
+ const colourSuffix = paletteSuffix(palette);
187
+
188
+ const apiKey = loadOpenAiKey();
189
+ if (!apiKey) return fail('no OpenAI key found (set OPENAI_API_KEY / OPEN_AI_KEY in the environment or repo-root .env)');
190
+
191
+ // ---- probe the engine: gpt-image-2 (verified primary) -> gpt-image-1 (fallback) -> loud stop ----
192
+ let engine = null;
193
+ if (await probeModel(PRIMARY_MODEL, apiKey)) engine = PRIMARY_MODEL;
194
+ else if (await probeModel(FALLBACK_MODEL, apiKey)) {
195
+ engine = FALLBACK_MODEL;
196
+ console.error(`[generate-image] gpt-image-2 probe failed — falling back to ${FALLBACK_MODEL}`);
197
+ } else {
198
+ return fail(`image-engine probe failed for the whole OpenAI chain (${PRIMARY_MODEL}, ${FALLBACK_MODEL}) — both unavailable on these keys; refusing to substitute or fake an image`);
199
+ }
200
+
201
+ const assetsDir = path.join(absBuildDir, 'assets');
202
+ fs.mkdirSync(assetsDir, { recursive: true });
203
+
204
+ // ---- generate every rung SEQUENTIALLY; ANY failure is a loud stop (no partial slot merge) ----
205
+ // Sequential (not concurrent): the image endpoint 502s / stalls when several high-quality
206
+ // renders peak at once, so one request would starve and abort. One-at-a-time is slower but
207
+ // reliable. Failures are still collected across ALL rungs (not fail-fast), so the loud-stop
208
+ // below reports every missing image at once — same contract as the previous Promise.allSettled.
209
+ async function renderRung(r) {
210
+ const label = `${r.kind}${r.kind === 'section' ? `(${r.id})` : ''}`;
211
+ const fileName = `${safeName(r.kind === 'hero' ? 'hero' : r.id)}.png`;
212
+ const filePath = path.join(assetsDir, fileName);
213
+ // resume idempotency: reuse a valid PNG already on disk (from an earlier partial run) — don't re-roll finished work
214
+ try {
215
+ const cached = fs.readFileSync(filePath);
216
+ if (cached.length > 0 && cached.subarray(0, 8).equals(PNG_MAGIC)) {
217
+ console.error(`[generate-image] ${label}: reusing cached ${cached.length} bytes -> ${filePath}`);
218
+ return { rung: r, filePath, bytes: cached.length };
219
+ }
220
+ } catch { /* not cached — generate below */ }
221
+ let buf, lastErr;
222
+ for (let attempt = 1; attempt <= GEN_ATTEMPTS; attempt++) {
223
+ try { buf = await generateOne(engine, String(r.prompt) + colourSuffix, r.px, apiKey); break; }
224
+ catch (e) {
225
+ lastErr = e;
226
+ if (attempt < GEN_ATTEMPTS) {
227
+ console.error(`[generate-image] ${label}: attempt ${attempt}/${GEN_ATTEMPTS} failed (${e?.message || e}) — retrying in ${attempt * 4}s`);
228
+ await new Promise((res) => setTimeout(res, attempt * 4000));
229
+ }
230
+ }
231
+ }
232
+ if (!buf) throw lastErr || new Error(`image generation failed after ${GEN_ATTEMPTS} attempts`);
233
+ fs.writeFileSync(filePath, buf);
234
+ console.error(`[generate-image] ${label}: ${buf.length} bytes -> ${filePath}`);
235
+ return { rung: r, filePath, bytes: buf.length };
236
+ }
237
+
238
+ const results = [];
239
+ for (const r of rungs) {
240
+ try { results.push({ status: 'fulfilled', value: await renderRung(r) }); }
241
+ catch (reason) { results.push({ status: 'rejected', reason }); }
242
+ }
243
+
244
+ const failures = results.map((res, i) => (res.status === 'rejected' ? `${rungs[i].kind}${rungs[i].kind === 'section' ? `(${rungs[i].id})` : ''}: ${res.reason?.message || res.reason}` : null)).filter(Boolean);
245
+ if (failures.length) return fail(`image generation failed for ${failures.length} rung(s): ${failures.join(' | ')}`);
246
+
247
+ // ---- merge ONLY visuals.hero + visuals.sections[] back into build.json (read-modify-write) ----
248
+ // Re-read so we never clobber a slot another tool may have updated; touch ONLY our two sub-slots.
249
+ let fresh;
250
+ try { fresh = JSON.parse(fs.readFileSync(buildJsonPath, 'utf8')); }
251
+ catch (e) { return fail(`cannot re-read build.json before merge: ${e?.message || e}`); }
252
+ if (!fresh.visuals || typeof fresh.visuals !== 'object') fresh.visuals = {};
253
+
254
+ const newSections = [];
255
+ for (let i = 0; i < results.length; i++) {
256
+ const { rung, filePath, bytes } = results[i].value;
257
+ const entry = { role: rung.role, prompt: rung.prompt, file: filePath, px: rung.px, engine, http200: true, bytes };
258
+ if (rung.kind === 'hero') {
259
+ fresh.visuals.hero = entry;
260
+ } else {
261
+ newSections.push({ id: rung.id, ...entry });
262
+ }
263
+ }
264
+ // Only overwrite sections[] if the brain declared sections this run (otherwise leave untouched).
265
+ if (Array.isArray(visuals.sections)) fresh.visuals.sections = newSections;
266
+
267
+ fs.writeFileSync(buildJsonPath, JSON.stringify(fresh, null, 2) + '\n');
268
+
269
+ const files = results.map((res) => res.value.filePath);
270
+ succeed({
271
+ engine,
272
+ quality: QUALITY,
273
+ rungs: results.map((res) => ({ id: res.value.rung.id, kind: res.value.rung.kind, px: res.value.rung.px, file: res.value.filePath, http200: true })),
274
+ files,
275
+ slots: ['visuals.hero', 'visuals.sections'],
276
+ buildJson: buildJsonPath,
277
+ });
278
+ }
279
+
280
+ main().catch((e) => fail(`unexpected error: ${e?.stack || e?.message || e}`));