storyforge 0.4.21 → 0.6.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.
@@ -0,0 +1,451 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ log
4
+ } from "./chunk-GJQ45C5W.js";
5
+
6
+ // src/bridge-poller.ts
7
+ import { spawn, spawnSync } from "child_process";
8
+ import * as crypto from "crypto";
9
+ import * as fs from "fs";
10
+ var HEARTBEAT_INTERVAL_MS = 6e4;
11
+ var POLL_INTERVAL_MS = 8e3;
12
+ var CANCEL_POLL_INTERVAL_MS = 5e3;
13
+ var CLI_TIMEOUT_MS = Number(process.env.SCRIPT_GEN_TIMEOUT_MS) || 20 * 60 * 1e3;
14
+ var IMAGE_GEN_TIMEOUT_MS = Number(process.env.CODEX_IMAGE_GEN_TIMEOUT_MS) || Number(process.env.IMAGE_GEN_TIMEOUT_MS) || 8 * 60 * 1e3;
15
+ var MAX_IN_FLIGHT_JOBS = Math.max(1, Math.min(6, Number(process.env.FORGE_BRIDGE_MAX_IN_FLIGHT) || 4));
16
+ var CODEX_IMAGEGEN_AGENT_MODELS = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"];
17
+ var CODEX_AGENT_MODEL_RE = /^gpt-5(?:$|[.\-])/i;
18
+ var BridgePoller = class {
19
+ baseUrl;
20
+ token;
21
+ clientVersion;
22
+ instanceId;
23
+ heartbeatTimer = null;
24
+ pollTimer = null;
25
+ cancelPollTimer = null;
26
+ inFlight = /* @__PURE__ */ new Set();
27
+ /**
28
+ * P-Concern-J: track each in-flight job's child process so we can
29
+ * SIGTERM it when the cancel_requested flag fires. Cleared on
30
+ * runJob exit.
31
+ */
32
+ inFlightChildren = /* @__PURE__ */ new Map();
33
+ stopped = false;
34
+ constructor(opts) {
35
+ this.baseUrl = opts.baseUrl.replace(/\/$/, "");
36
+ this.token = opts.token;
37
+ this.clientVersion = opts.clientVersion;
38
+ this.instanceId = `${process.pid}-${crypto.randomBytes(2).toString("hex")}-${Date.now()}`;
39
+ }
40
+ start() {
41
+ if (this.heartbeatTimer || this.pollTimer) return;
42
+ log.info(`[bridge] poller starting \xB7 ${this.baseUrl} \xB7 instance=${this.instanceId}`);
43
+ void this.heartbeat();
44
+ this.heartbeatTimer = setInterval(() => {
45
+ void this.heartbeat();
46
+ }, HEARTBEAT_INTERVAL_MS);
47
+ this.pollTimer = setInterval(() => {
48
+ void this.poll();
49
+ }, POLL_INTERVAL_MS);
50
+ this.cancelPollTimer = setInterval(() => {
51
+ void this.checkCancels();
52
+ }, CANCEL_POLL_INTERVAL_MS);
53
+ }
54
+ async stop() {
55
+ this.stopped = true;
56
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
57
+ if (this.pollTimer) clearInterval(this.pollTimer);
58
+ if (this.cancelPollTimer) clearInterval(this.cancelPollTimer);
59
+ this.heartbeatTimer = null;
60
+ this.pollTimer = null;
61
+ this.cancelPollTimer = null;
62
+ }
63
+ probeBinary(name) {
64
+ if (!/^[a-z][a-z0-9-]*$/i.test(name)) return { available: false, path: null };
65
+ const r = spawnSync("which", [name], { encoding: "utf-8", timeout: 2e3 });
66
+ if (r.status !== 0) return { available: false, path: null };
67
+ const path = (r.stdout ?? "").trim();
68
+ return { available: !!path, path: path || null };
69
+ }
70
+ async heartbeat() {
71
+ if (this.stopped) return;
72
+ try {
73
+ const resp = await fetch(`${this.baseUrl}/api/cli-bridge/heartbeat`, {
74
+ method: "POST",
75
+ headers: {
76
+ Authorization: `Bearer ${this.token}`,
77
+ "Content-Type": "application/json"
78
+ },
79
+ body: JSON.stringify({
80
+ availableClis: {
81
+ claude: this.probeBinary("claude"),
82
+ codex: this.probeBinary("codex")
83
+ },
84
+ clientVersion: this.clientVersion,
85
+ instanceId: this.instanceId
86
+ }),
87
+ signal: AbortSignal.timeout(45e3)
88
+ });
89
+ if (!resp.ok) {
90
+ const text = await resp.text().catch(() => "");
91
+ log.warn(`[bridge] heartbeat HTTP ${resp.status}: ${text.slice(0, 120)}`);
92
+ return;
93
+ }
94
+ const data = await resp.json().catch(() => null);
95
+ this.handleCancelRequestedIds(data?.cancelRequestedJobs ?? []);
96
+ } catch (err) {
97
+ log.warn(`[bridge] heartbeat failed: ${err.message}`);
98
+ }
99
+ }
100
+ /**
101
+ * P-Concern-J: tighter-cadence cancel detection. Polls the
102
+ * heartbeat-style endpoint every 5s asking only for the cancel list
103
+ * (cheap query — partial index on cancel_requested). When a flagged
104
+ * job ID matches an in-flight one, SIGTERM the child process.
105
+ *
106
+ * We reuse the heartbeat endpoint itself rather than adding a new
107
+ * route; the endpoint already returns cancelRequestedJobs and the
108
+ * cost of the inner UPDATE is constant (idempotent).
109
+ */
110
+ async checkCancels() {
111
+ if (this.stopped || this.inFlight.size === 0) return;
112
+ try {
113
+ const resp = await fetch(`${this.baseUrl}/api/cli-bridge/heartbeat`, {
114
+ method: "POST",
115
+ headers: {
116
+ Authorization: `Bearer ${this.token}`,
117
+ "Content-Type": "application/json"
118
+ },
119
+ body: JSON.stringify({
120
+ availableClis: {
121
+ claude: this.probeBinary("claude"),
122
+ codex: this.probeBinary("codex")
123
+ },
124
+ clientVersion: this.clientVersion,
125
+ instanceId: this.instanceId
126
+ }),
127
+ signal: AbortSignal.timeout(15e3)
128
+ });
129
+ if (!resp.ok) return;
130
+ const data = await resp.json().catch(() => null);
131
+ this.handleCancelRequestedIds(data?.cancelRequestedJobs ?? []);
132
+ } catch {
133
+ }
134
+ }
135
+ handleCancelRequestedIds(ids) {
136
+ if (!ids.length) return;
137
+ for (const id of ids) {
138
+ const child = this.inFlightChildren.get(id);
139
+ if (!child) continue;
140
+ log.warn(`[bridge] cancel_requested for job ${id.slice(0, 8)} \u2014 SIGTERM-ing child pid=${child.pid}`);
141
+ try {
142
+ child.kill("SIGTERM");
143
+ setTimeout(() => {
144
+ try {
145
+ if (!child.killed) child.kill("SIGKILL");
146
+ } catch {
147
+ }
148
+ }, 3e3);
149
+ } catch (err) {
150
+ log.warn(`[bridge] SIGTERM failed for ${id.slice(0, 8)}: ${err.message}`);
151
+ }
152
+ }
153
+ }
154
+ async poll() {
155
+ if (this.stopped) return;
156
+ const availableSlots = MAX_IN_FLIGHT_JOBS - this.inFlight.size;
157
+ if (availableSlots <= 0) return;
158
+ let jobs = [];
159
+ try {
160
+ const resp = await fetch(`${this.baseUrl}/api/cli-bridge/claim`, {
161
+ method: "POST",
162
+ headers: {
163
+ Authorization: `Bearer ${this.token}`,
164
+ "Content-Type": "application/json"
165
+ },
166
+ body: JSON.stringify({ instanceId: this.instanceId, limit: Math.min(3, availableSlots) }),
167
+ signal: AbortSignal.timeout(45e3)
168
+ });
169
+ if (!resp.ok) {
170
+ if (resp.status === 401) {
171
+ log.warn("[bridge] 401 \u2014 bridge token rejected. Stopping poller. Set BRIDGE_TOKEN to the value Vercel has and restart.");
172
+ await this.stop();
173
+ return;
174
+ }
175
+ const text = await resp.text().catch(() => "");
176
+ log.warn(`[bridge] claim HTTP ${resp.status}: ${text.slice(0, 120)}`);
177
+ return;
178
+ }
179
+ const data = await resp.json();
180
+ jobs = data.jobs ?? [];
181
+ } catch (err) {
182
+ log.warn(`[bridge] claim failed: ${err.message}`);
183
+ return;
184
+ }
185
+ if (jobs.length === 0) return;
186
+ await Promise.all(jobs.map((job) => this.runJob(job)));
187
+ }
188
+ async runJob(job) {
189
+ if (this.inFlight.has(job.id)) return;
190
+ this.inFlight.add(job.id);
191
+ const imagePayload = parseImageGenPayload(job.prompt);
192
+ const modelLabel = imagePayload && job.cli === "codex" ? `codex:imagegen via ${codexImagegenModelCandidates(job.model)[0]}${job.model ? ` (requested ${job.model})` : ""}` : `${job.cli}${job.model ? `:${job.model}` : ""}`;
193
+ log.info(`[bridge] running job ${job.id.slice(0, 8)} \xB7 ${modelLabel}`);
194
+ const startedAt = Date.now();
195
+ try {
196
+ const { text, modelUsed, error } = await this.invokeJob(job);
197
+ const latencyMs = Date.now() - startedAt;
198
+ await this.respond(job.id, { text, modelUsed, latencyMs, error });
199
+ if (error) {
200
+ log.warn(`[bridge] job ${job.id.slice(0, 8)} failed (${latencyMs}ms): ${error.slice(0, 120)}`);
201
+ } else {
202
+ log.success(`[bridge] job ${job.id.slice(0, 8)} done (${latencyMs}ms \xB7 ${modelUsed})`);
203
+ }
204
+ } catch (err) {
205
+ const latencyMs = Date.now() - startedAt;
206
+ await this.respond(job.id, { error: err.message, latencyMs });
207
+ } finally {
208
+ this.inFlight.delete(job.id);
209
+ this.inFlightChildren.delete(job.id);
210
+ }
211
+ }
212
+ async invokeCli(cli, model, prompt, onSpawn) {
213
+ const candidates = cli === "codex" ? model && /^[a-z0-9.\-]+$/i.test(model) ? [model] : ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"] : model && /^[a-z0-9-]+$/i.test(model) ? [model] : ["opus", "sonnet"];
214
+ let lastErr = "";
215
+ for (const m of candidates) {
216
+ try {
217
+ const args = cli === "codex" ? ["exec", "-", "--model", m, "--search"] : [
218
+ "-p",
219
+ "--model",
220
+ m,
221
+ "--no-session-persistence",
222
+ "--allowedTools",
223
+ "WebSearch,WebFetch"
224
+ ];
225
+ const text = await runSpawn(cli, args, prompt, CLI_TIMEOUT_MS, onSpawn);
226
+ if (text.trim()) return { text, modelUsed: `${cli}:${m}` };
227
+ lastErr = `${cli}/${m} returned empty stdout`;
228
+ } catch (err) {
229
+ lastErr = `${cli}/${m}: ${err.message}`;
230
+ }
231
+ }
232
+ return { error: lastErr || "no model produced output" };
233
+ }
234
+ async invokeJob(job) {
235
+ const onSpawn = (child) => {
236
+ this.inFlightChildren.set(job.id, child);
237
+ };
238
+ const claudePromptEnvelope = parseClaudePromptEnvelope(job.prompt);
239
+ if (claudePromptEnvelope) {
240
+ if (job.cli !== "claude") return { error: `${claudePromptEnvelope.__forgeJobKind} envelope requires claude CLI` };
241
+ return this.invokeClaudeWithEnvelope(job.model, claudePromptEnvelope, onSpawn);
242
+ }
243
+ const imagePayload = parseImageGenPayload(job.prompt);
244
+ if (imagePayload) {
245
+ if (job.cli !== "codex") return { error: "image-gen bridge jobs require codex CLI" };
246
+ return this.invokeCodexImageGen(job.model, imagePayload, onSpawn);
247
+ }
248
+ return this.invokeCli(job.cli, job.model, job.prompt, onSpawn);
249
+ }
250
+ /**
251
+ * Run a Claude prompt envelope. Web tools are gated by envelope.webTools:
252
+ * ['WebSearch','WebFetch'] for fact-pack-build (research stage)
253
+ * [] for script-from-pack (pack-only writing)
254
+ */
255
+ async invokeClaudeWithEnvelope(jobModel, envelope, onSpawn) {
256
+ const requestedModel = envelope.model || jobModel;
257
+ const fallbackChain = envelope.__forgeJobKind === "fact-pack-build" ? ["opus", "sonnet"] : ["sonnet", "haiku"];
258
+ const candidates = requestedModel && /^[a-z0-9-]+$/i.test(requestedModel) ? [requestedModel] : fallbackChain;
259
+ const tools = (envelope.webTools ?? []).filter((t) => t === "WebSearch" || t === "WebFetch");
260
+ const baseArgs = ["-p", "--model", "<m>", "--no-session-persistence"];
261
+ if (tools.length > 0) {
262
+ baseArgs.push("--allowedTools", tools.join(","));
263
+ }
264
+ let lastErr = "";
265
+ for (const m of candidates) {
266
+ try {
267
+ const args = baseArgs.map((a) => a === "<m>" ? m : a);
268
+ const text = await runSpawn("claude", args, envelope.prompt, CLI_TIMEOUT_MS, onSpawn);
269
+ if (text.trim()) return { text, modelUsed: `claude:${m}${tools.length === 0 ? " (no-web)" : ""}` };
270
+ lastErr = `claude/${m} returned empty stdout`;
271
+ } catch (err) {
272
+ lastErr = `claude/${m}: ${err.message}`;
273
+ }
274
+ }
275
+ return { error: lastErr || "no model produced output" };
276
+ }
277
+ async invokeCodexImageGen(model, payload, onSpawn) {
278
+ const candidates = codexImagegenModelCandidates(model);
279
+ const count = Math.max(1, Math.min(4, Number(payload.count) || 1));
280
+ const prompt = [
281
+ "Use Codex imagegen to generate raster image assets for Forge.",
282
+ "Use the installed imagegen skill in its default built-in tool mode.",
283
+ `Generate exactly ${count} image${count === 1 ? "" : "s"}.`,
284
+ `Aspect: ${payload.aspect ?? "16:9"}. Quality: ${payload.quality ?? "medium"}.`,
285
+ payload.size ? `Preferred size: ${payload.size}.` : "",
286
+ "",
287
+ "Image prompt:",
288
+ payload.prompt,
289
+ "",
290
+ "After the file(s) are generated, return ONLY strict JSON in this shape:",
291
+ '{"images":[{"path":"/absolute/path/to/generated.png","mimeType":"image/png"}]}',
292
+ "No markdown, no prose, no thumbnails, no relative paths."
293
+ ].filter(Boolean).join("\n");
294
+ let lastErr = "";
295
+ for (const m of candidates) {
296
+ try {
297
+ const stdout = await runSpawn("codex", ["exec", "-", "--model", m], prompt, IMAGE_GEN_TIMEOUT_MS, onSpawn);
298
+ const images = collectImagesFromCodexStdout(stdout, count);
299
+ if (images.length === 0) {
300
+ lastErr = `codex/${m} imagegen returned no readable image paths`;
301
+ continue;
302
+ }
303
+ return {
304
+ text: JSON.stringify({ images, model: `codex-cli:${m}` }),
305
+ modelUsed: `codex-cli:${m}`
306
+ };
307
+ } catch (err) {
308
+ lastErr = `codex/${m}: ${err.message}`;
309
+ }
310
+ }
311
+ return { error: lastErr || "codex imagegen produced no output" };
312
+ }
313
+ async respond(jobId, body) {
314
+ try {
315
+ const resp = await fetch(`${this.baseUrl}/api/cli-bridge/respond`, {
316
+ method: "POST",
317
+ headers: {
318
+ Authorization: `Bearer ${this.token}`,
319
+ "Content-Type": "application/json"
320
+ },
321
+ body: JSON.stringify({ jobId, ...body }),
322
+ signal: AbortSignal.timeout(45e3)
323
+ });
324
+ if (!resp.ok) {
325
+ const text = await resp.text().catch(() => "");
326
+ log.warn(`[bridge] respond HTTP ${resp.status}: ${text.slice(0, 120)}`);
327
+ }
328
+ } catch (err) {
329
+ log.warn(`[bridge] respond failed for ${jobId.slice(0, 8)}: ${err.message}`);
330
+ }
331
+ }
332
+ };
333
+ function codexImagegenModelCandidates(model) {
334
+ const requested = model?.trim();
335
+ if (requested && /^[a-z0-9.\-]+$/i.test(requested) && CODEX_AGENT_MODEL_RE.test(requested)) {
336
+ return [requested];
337
+ }
338
+ return CODEX_IMAGEGEN_AGENT_MODELS;
339
+ }
340
+ function parseImageGenPayload(prompt) {
341
+ try {
342
+ const parsed = JSON.parse(prompt);
343
+ if (parsed.__forgeJobKind === "image-gen" && typeof parsed.prompt === "string" && parsed.prompt.trim()) {
344
+ return parsed;
345
+ }
346
+ } catch {
347
+ }
348
+ return null;
349
+ }
350
+ var CLAUDE_JOB_KINDS = [
351
+ "fact-pack-build",
352
+ "script-from-pack",
353
+ "script-evaluate",
354
+ "script-improve"
355
+ ];
356
+ function parseClaudePromptEnvelope(prompt) {
357
+ try {
358
+ const parsed = JSON.parse(prompt);
359
+ const kind = parsed.__forgeJobKind;
360
+ if (kind && CLAUDE_JOB_KINDS.includes(kind) && typeof parsed.prompt === "string" && parsed.prompt.length > 0) {
361
+ return parsed;
362
+ }
363
+ } catch {
364
+ }
365
+ return null;
366
+ }
367
+ function collectImagesFromCodexStdout(stdout, maxCount) {
368
+ const parsedPaths = parseImagePathsFromJson(stdout);
369
+ const regexPaths = Array.from(stdout.matchAll(/(?:^|["`\s])(\/[^"`\n\r]+\.(?:png|jpe?g|webp))(?:["`\s]|$)/gi)).map((match) => match[1]);
370
+ const paths = Array.from(/* @__PURE__ */ new Set([...parsedPaths, ...regexPaths]));
371
+ const images = [];
372
+ for (const p of paths) {
373
+ if (images.length >= maxCount) break;
374
+ try {
375
+ const stat = fs.statSync(p);
376
+ if (!stat.isFile() || stat.size <= 0) continue;
377
+ images.push({
378
+ base64: fs.readFileSync(p).toString("base64"),
379
+ mimeType: mimeTypeForPath(p),
380
+ model: "codex-cli:imagegen"
381
+ });
382
+ } catch {
383
+ }
384
+ }
385
+ return images;
386
+ }
387
+ function parseImagePathsFromJson(stdout) {
388
+ const trimmed = stdout.replace(/^```(?:json)?\s*/, "").replace(/```\s*$/, "").trim();
389
+ const candidates = [trimmed];
390
+ const objectMatch = trimmed.match(/\{[\s\S]*\}/);
391
+ if (objectMatch) candidates.push(objectMatch[0]);
392
+ for (const candidate of candidates) {
393
+ try {
394
+ const parsed = JSON.parse(candidate);
395
+ if (Array.isArray(parsed.images)) {
396
+ return parsed.images.map((img) => img.path).filter((p) => typeof p === "string");
397
+ }
398
+ } catch {
399
+ }
400
+ }
401
+ return [];
402
+ }
403
+ function mimeTypeForPath(p) {
404
+ const lower = p.toLowerCase();
405
+ if (lower.endsWith(".webp")) return "image/webp";
406
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
407
+ return "image/png";
408
+ }
409
+ function runSpawn(cmd, args, stdin, timeoutMs, onSpawn) {
410
+ return new Promise((resolve, reject) => {
411
+ const proc = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
412
+ if (onSpawn) {
413
+ try {
414
+ onSpawn(proc);
415
+ } catch {
416
+ }
417
+ }
418
+ let stdout = "";
419
+ let stderr = "";
420
+ const timer = setTimeout(() => {
421
+ try {
422
+ proc.kill("SIGKILL");
423
+ } catch {
424
+ }
425
+ reject(new Error(`${cmd} timed out after ${timeoutMs}ms`));
426
+ }, timeoutMs);
427
+ proc.stdout.on("data", (d) => {
428
+ stdout += d.toString("utf-8");
429
+ });
430
+ proc.stderr.on("data", (d) => {
431
+ stderr += d.toString("utf-8");
432
+ });
433
+ proc.on("error", (err) => {
434
+ clearTimeout(timer);
435
+ reject(err);
436
+ });
437
+ proc.on("close", (code, signal) => {
438
+ clearTimeout(timer);
439
+ if (signal === "SIGTERM" || signal === "SIGKILL") {
440
+ reject(new Error(`${cmd} terminated by ${signal} (likely user cancel)`));
441
+ return;
442
+ }
443
+ if (code !== 0) reject(new Error(`${cmd} exit ${code}: ${stderr.slice(-300)}`));
444
+ else resolve(stdout);
445
+ });
446
+ proc.stdin.end(stdin);
447
+ });
448
+ }
449
+ export {
450
+ BridgePoller
451
+ };
@@ -5,6 +5,23 @@ import * as fs from "fs";
5
5
  import * as path from "path";
6
6
  import * as os from "os";
7
7
  import { loadSessionBlockData } from "ccusage/data-loader";
8
+ var SILENCED_LITELLM_RE = /(LiteLLM|Loaded pricing for \d+ models|Fetching latest model pricing)/;
9
+ function patchWrite(stream) {
10
+ const orig = stream.write.bind(stream);
11
+ stream.write = (chunk, ...rest) => {
12
+ if (typeof chunk === "string" && SILENCED_LITELLM_RE.test(chunk)) return true;
13
+ if (Buffer.isBuffer(chunk) && SILENCED_LITELLM_RE.test(chunk.toString("utf-8"))) return true;
14
+ return orig(chunk, ...rest);
15
+ };
16
+ }
17
+ var writePatchApplied = false;
18
+ function applyWritePatch() {
19
+ if (writePatchApplied) return;
20
+ patchWrite(process.stdout);
21
+ patchWrite(process.stderr);
22
+ writePatchApplied = true;
23
+ }
24
+ applyWritePatch();
8
25
  var PRICES = {
9
26
  // Anthropic — claude.com/pricing
10
27
  "claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
@@ -39,6 +56,7 @@ function computeCost(model, u) {
39
56
  return cost;
40
57
  }
41
58
  var CLI_USAGE_PARSER_VERSION = 4;
59
+ var warnedCcusageFallback = false;
42
60
  function emptyModelUsage(model) {
43
61
  return { model, inputTokens: 0, cachedReadTokens: 0, cacheCreateTokens: 0, outputTokens: 0, costUsd: 0 };
44
62
  }
@@ -190,7 +208,10 @@ async function gatherCliUsage() {
190
208
  claudeFileCount = listJsonlFiles(path.join(os.homedir(), ".claude", "projects")).length;
191
209
  } catch (err) {
192
210
  claudeError = `ccusage unavailable, using fallback parser (${err.message?.slice(0, 80) ?? "unknown"})`;
193
- console.error("[cli-usage] ccusage loader failed, falling back:", claudeError);
211
+ if (!warnedCcusageFallback) {
212
+ console.error("[cli-usage] ccusage loader failed, falling back:", claudeError, "(further occurrences suppressed)");
213
+ warnedCcusageFallback = true;
214
+ }
194
215
  const claudeDir = path.join(os.homedir(), ".claude", "projects");
195
216
  const claudeFiles = listJsonlFiles(claudeDir);
196
217
  claudeFileCount = claudeFiles.length;
package/dist/index.js CHANGED
@@ -113,7 +113,7 @@ ${args.referenceScript.slice(0, 8e3)}
113
113
 
114
114
  === END SAMPLE ===
115
115
 
116
- Use this ONLY to calibrate the channel's voice \u2014 sentence rhythm, tolerance for technical detail, willingness to name real things. Do NOT copy its structure. If the sample opens with historical examples and this topic works better with a counter-intuitive question or a single unfolding metaphor, choose the format that serves the topic. The best script for THIS topic may look structurally different from the sample, and that's correct.` : "";
116
+ Use this ONLY to calibrate the channel's voice: sentence rhythm, specificity, confidence, and tolerance for technical detail. Do NOT copy its hook type, historical sequence, metaphor, beat order, payoff shape, named examples, or chunk structure. Before writing, identify what THIS topic naturally wants: a failure story, mystery, product timeline, human profile, mechanism teardown, market shift, investigation, or another shape. The best script for THIS topic may look structurally nothing like the sample, and that is correct.` : "";
117
117
  const guidanceBlock = (args.additionalGuidance ?? "").trim() ? `
118
118
 
119
119
  \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
@@ -144,32 +144,34 @@ TARGET LENGTH: ${minMin}-${maxMin} minutes (write to the topic's natural length
144
144
  ENGAGEMENT BAR (this overrides everything below \u2014 RULE -1)
145
145
  \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
146
146
 
147
- A previous KV Cache script we got back was technically accurate but
148
- DEAD on arrival. It read like a textbook chapter. It was a 1/10 on
149
- retention. Why? It made these mistakes \u2014 DO NOT REPEAT THEM:
147
+ A previous script we got back was technically accurate but DEAD on
148
+ arrival. It read like a textbook chapter. It was a 1/10 on retention.
149
+ Why? It made these mistakes \u2014 DO NOT REPEAT THEM:
150
150
 
151
151
  1. NO NAMED CHARACTERS. Just "researchers" and "engineers". Real
152
152
  scripts have humans with names doing things in specific places.
153
153
 
154
- 2. NO SCENES. Just told us "imagine the librarian". Real scripts
155
- SHOW: time of day, location, what's on the screen, what someone
156
- said in a meeting, the GPU temperature when something broke.
154
+ 2. NO SCENES. Just named abstractions. Real scripts SHOW the
155
+ topic-specific scene: time of day, location, what's on the screen,
156
+ the physical artifact, the number on the dashboard, what someone
157
+ said in a meeting, or the failure that made the mechanism matter.
157
158
 
158
159
  3. NO SECOND PERSON. Wrote in detached third person throughout.
159
160
  Engaging scripts say "your AI assistant", "you're typing into
160
161
  ChatGPT and..." \u2014 pull the viewer into the scene.
161
162
 
162
163
  4. ACADEMIC SENTENCE STRUCTURE. Long compound sentences with
163
- multiple subordinate clauses. "Production inference has two
164
- very different phases, and KV Cache explains why they feel
165
- different to users." That reads like a paper. The same
166
- information as: "Two phases. They feel completely different.
167
- KV Cache explains why."
168
-
169
- 5. NO ANTAGONIST / NO TENSION. Tesla had Waymo. KV Cache had no
170
- foil. Every great explainer has a "vs" \u2014 brute force vs
171
- elegance, the old way vs the new way, the hopeful believer vs
172
- the cynical realist.
164
+ multiple subordinate clauses. "This system has two very different
165
+ phases, and the architecture explains why they feel different to
166
+ users." That reads like a paper. Prefer shorter,
167
+ scene-driven sentences that name the specific constraint and why
168
+ it matters to the viewer.
169
+
170
+ 5. NO TOPIC-NATIVE TENSION. Every great explainer has pressure, but
171
+ the pressure must come from THIS topic. It might be brute force vs
172
+ elegance, promise vs bill, safety vs speed, incumbent vs upstart,
173
+ customer need vs engineering reality, or something else. Do not
174
+ import a foil from a reference script.
173
175
 
174
176
  6. STALE CITATIONS. Cited Gemini 1.5 (2024), Llama 2 (2023), GPT-3
175
177
  (2020). The video is being made in April 2026. If you cite
@@ -186,33 +188,32 @@ BORING vs ENGAGING \u2014 concrete A/B examples
186
188
 
187
189
  HOOK
188
190
  Bad (academic):
189
- "In 2024, Google DeepMind showed Gemini 1.5 Pro reading up to
190
- 1 million tokens. That sounds like a bigger brain. It is not."
191
- Good (specific scene + character + stakes):
192
- "It's 3 AM in March 2026. A junior engineer at OpenAI watches
193
- her dashboard turn red as a single user pastes the entire
194
- Twilight saga into GPT-5.5. Latency hits 41 seconds. The cost
195
- counter ticks past 12 dollars for ONE response. She stares at
196
- the number that explains why AI is getting more expensive even
197
- though the models are getting cheaper. It's called KV Cache.
198
- And right now, it's eating the company alive."
191
+ Open with a generic date, a vague capability, then a thesis.
192
+ Good (topic-native):
193
+ Open on the most cinematic pressure point THIS topic actually has:
194
+ a named launch day, a dashboard turning red, a failed demo, a court
195
+ filing, a supply-chain bottleneck, a customer behavior shift, a lab
196
+ result that contradicted intuition, or a single number that changes
197
+ the viewer's mental model. The hook type should be different when
198
+ the topic is a product race, a science mystery, a business collapse,
199
+ an algorithmic mechanism, or a social consequence.
199
200
 
200
201
  MECHANISM EXPLANATION
201
- Bad: "Each new token has to compare against a long history."
202
- Good: "Imagine typing the next word of an email, but before each
203
- keystroke your laptop has to re-read every email you've sent
204
- this year. That's what a transformer would do without KV Cache.
205
- Now imagine your laptop wrote a single Post-it note for every
206
- important word and stuck them on a desk. To type the next word,
207
- it just glances at the desk. That's KV Cache. The Post-its are
208
- the desk's secret. They're also the reason your AI bill has
209
- exploded."
202
+ Bad: State the mechanism as a definition.
203
+ Good: Build the explanation from objects and constraints that belong
204
+ to THIS topic. If the topic is chips, show memory lanes, wafers,
205
+ racks, latency, heat, and cost. If the topic is biology, show cells,
206
+ assays, instruments, failure modes, and time. If the topic is a
207
+ company, show product decisions, customers, competitors, margins,
208
+ and distribution. The analogy is allowed only when it predicts the
209
+ mechanism better than the literal scene.
210
210
 
211
211
  SECTION TRANSITION (re-hook)
212
- Bad: "Remember the open loop. We asked..."
213
- Good: "Stop for a second. The thing we just described \u2014 that
214
- desk full of Post-its \u2014 is now eating 80% of an H200's memory
215
- on every long conversation. THAT'S the bill nobody talks about."
212
+ Bad: Repeat the same open-loop phrase.
213
+ Good: Re-hook with the specific unresolved pressure from the current
214
+ topic: the bill, the deadline, the missing proof, the user behavior,
215
+ the regulatory risk, the physical constraint, or the surprising
216
+ second-order effect.
216
217
 
217
218
  \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
218
219
  NON-NEGOTIABLE ENGAGEMENT RULES
@@ -272,7 +273,7 @@ H. CURRENT FACTS via web_search: BEFORE writing each chunk that
272
273
  OpenAI flagship \u2192 GPT-5.5 (released April 23 2026)
273
274
  Anthropic flagship \u2192 Claude Opus 4.7
274
275
  Meta open-weights \u2192 Llama 4
275
- Google \u2192 Gemini 2.x family
276
+ Google \u2192 Gemini 3.1
276
277
  USE WEB_SEARCH to verify. Don't trust your memory.
277
278
 
278
279
  \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
@@ -331,26 +332,24 @@ RETENTION PRINCIPLES (these govern every decision you make)
331
332
  question. You should have 3-5 of these naturally distributed.
332
333
 
333
334
  3. ONE DOMINANT METAPHOR \u2014 TEACHING, NOT DECORATION:
334
- Pick ONE concrete analogy before you start writing. Thread it
335
- through every section. Every time the mechanism gets technical,
336
- bend back to the metaphor to scaffold understanding.
335
+ Pick ONE concrete analogy before you start writing. It must be
336
+ contextual to THIS topic, not a reusable prep-scene frame.
337
+ Thread it through every section only if it keeps teaching;
338
+ otherwise use concrete product scenes and named artifacts.
337
339
 
338
340
  The metaphor MUST DO WORK \u2014 it must PREDICT the mechanism, not
339
341
  merely label it.
340
342
 
341
- BAD (decoration): "Like our metaphorical chef in the kitchen,
342
- KV Cache speeds things up."
343
- GOOD (teaching): "If our chef can grab pre-prepped ingredients
344
- without looking, what happens when fifty orders
345
- come in at once? Each new order needs the same
346
- lookups \u2014 but the prep is already done. That is
347
- exactly what KV Cache does: it computes each
348
- key-value pair once, then reuses them across
349
- every subsequent token."
343
+ BAD (decoration): "This mechanism is just a speed-up shortcut."
344
+ GOOD (teaching): The viewer can predict what happens next because the
345
+ metaphor maps cleanly to the topic's actual parts:
346
+ who/what moves, what gets cached or constrained,
347
+ what fails under scale, and why the final tradeoff
348
+ matters. If the literal domain already has strong
349
+ objects and scenes, use those instead of an analogy.
350
350
 
351
- Banned: the word "metaphorical". The word "akin to" used more
352
- than once. Phrases like "back to our chef" used as filler instead
353
- of as a vehicle for predicting the next mechanism.
351
+ Banned: food-prep/cooking language, the word "metaphorical", the word "akin to"
352
+ used more than once, and "back to our X" filler.
354
353
 
355
354
  Pick your metaphor in a pre-planning step before you write chunk 1.
356
355
  Good metaphor tests: (a) can a non-expert picture it in 2 seconds?
@@ -398,10 +397,9 @@ facts. Examples of stale defaults to avoid:
398
397
 
399
398
  If web search is unavailable AND you cannot verify a specific current
400
399
  name, prefer a generic descriptor over a stale specific:
401
- Bad: "GPT-4 uses KV Cache to speed inference."
402
- Good: "The current generation of frontier LLMs \u2014 including OpenAI's
403
- flagship and Anthropic's Opus tier \u2014 relies on KV Cache to
404
- speed inference."
400
+ Bad: "GPT-4 is the current example."
401
+ Good: "Use a generic current-generation descriptor unless you can
402
+ verify the exact April 2026 model, product, or paper name."
405
403
 
406
404
  A wrong stale name is worse than a true descriptor.
407
405
 
@@ -418,7 +416,7 @@ ${banList}
418
416
 
419
417
  ALSO BANNED (regardless of channel DNA):
420
418
  - "Imagine teaching a..." / "Imagine taking..." / "Imagine a..."
421
- - "Step into the kitchen of..." / "Step into the world of..."
419
+ - "Step into the world of..." / generic doorstep openers
422
420
  - "back to our metaphorical..." / "our metaphorical X"
423
421
  - "akin to" used more than once total
424
422
  - "Are we prepared for..." / "What if..." as section openers
@@ -434,7 +432,8 @@ STRUCTURE (choose the format that serves THIS topic \u2014 do NOT default)
434
432
  \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
435
433
 
436
434
  You have multiple formats available. Pick the one this topic is best
437
- suited to. Do NOT default to Tesla-style "history \u2192 bet \u2192 stakes".
435
+ suited to. Do NOT default to any fixed sample sequence unless THIS topic
436
+ truly demands it.
438
437
 
439
438
  A. COUNTER-INTUITIVE (Veritasium mode)
440
439
  Hook with a wrong-sounding claim. Challenge viewer's intuition.
@@ -497,7 +496,7 @@ PER-CHUNK FIELDS (output JSON \u2014 no extra commentary, no markdown)
497
496
  "metaphor": the single video-wide metaphor you chose (string, same value on every chunk),
498
497
  "openLoopId": present on the chunk that plants a loop + on the chunk that pays it off. Same id links them.
499
498
  "imagePromptHints": [
500
- "3-5 teaching-oriented image prompts for THIS chunk. Each should illustrate the concept, not decorate it. Example (KV Cache): 'A chef mid-service at a high-end restaurant, every ingredient prepped and labeled in steel containers, hands reaching without looking \u2014 represents instant key-value lookup.' NOT 'glowing neural network nodes over dark background.' Write prompts that a cinematographer could use to teach a 12-year-old the concept."
499
+ "3-5 teaching-oriented image prompts for THIS chunk. Each should illustrate the concept, not decorate it. Make every prompt contextual to the chunk's actual domain: the real product, place, person, machine, document, material, interface, or physical constraint that the narration is about. Use a metaphor only if it teaches the mechanism better than literal domain visuals. Do not use food-prep scenes or glowing neural-network-node wallpaper. Write prompts that a cinematographer could use to teach a 12-year-old the concept."
501
500
  ]
502
501
  }
503
502
  ]
@@ -841,7 +840,7 @@ async function devCommand(options) {
841
840
  }
842
841
  void (async () => {
843
842
  try {
844
- const { gatherCliUsage, CLI_USAGE_PARSER_VERSION } = await import("./cli-usage-P3LK7WEE.js");
843
+ const { gatherCliUsage, CLI_USAGE_PARSER_VERSION } = await import("./cli-usage-FG3YUG3W.js");
845
844
  const report = await gatherCliUsage();
846
845
  const g = global;
847
846
  g.__forgeCliUsageCache = { at: Date.now(), ver: CLI_USAGE_PARSER_VERSION, data: report };
@@ -877,7 +876,7 @@ async function devCommand(options) {
877
876
  const pathname = url.pathname;
878
877
  if (pathname === "/api/cli-usage") {
879
878
  try {
880
- const { gatherCliUsage, CLI_USAGE_PARSER_VERSION } = await import("./cli-usage-P3LK7WEE.js");
879
+ const { gatherCliUsage, CLI_USAGE_PARSER_VERSION } = await import("./cli-usage-FG3YUG3W.js");
881
880
  const g = global;
882
881
  const now = Date.now();
883
882
  if (g.__forgeCliUsageCache && g.__forgeCliUsageCache.ver === CLI_USAGE_PARSER_VERSION && now - g.__forgeCliUsageCache.at < 3e4) {
@@ -1615,7 +1614,7 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
1615
1614
  return "0.0.0";
1616
1615
  })();
1617
1616
  void (async () => {
1618
- const { BridgePoller } = await import("./bridge-poller-MCXVVFXL.js");
1617
+ const { BridgePoller } = await import("./bridge-poller-MM6K2GKM.js");
1619
1618
  const poller = new BridgePoller({ baseUrl: bridgeUrl, token: bridgeToken, clientVersion: `storyforge ${pkgVersion}` });
1620
1619
  poller.start();
1621
1620
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyforge",
3
- "version": "0.4.21",
3
+ "version": "0.6.0",
4
4
  "description": "StoryForge — local bridge for the Forge video production web app. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,206 +0,0 @@
1
- #!/usr/bin/env node
2
- import {
3
- log
4
- } from "./chunk-GJQ45C5W.js";
5
-
6
- // src/bridge-poller.ts
7
- import { spawn, spawnSync } from "child_process";
8
- import * as crypto from "crypto";
9
- var HEARTBEAT_INTERVAL_MS = 6e4;
10
- var POLL_INTERVAL_MS = 8e3;
11
- var CLI_TIMEOUT_MS = Number(process.env.SCRIPT_GEN_TIMEOUT_MS) || 20 * 60 * 1e3;
12
- var BridgePoller = class {
13
- baseUrl;
14
- token;
15
- clientVersion;
16
- instanceId;
17
- heartbeatTimer = null;
18
- pollTimer = null;
19
- inFlight = /* @__PURE__ */ new Set();
20
- stopped = false;
21
- constructor(opts) {
22
- this.baseUrl = opts.baseUrl.replace(/\/$/, "");
23
- this.token = opts.token;
24
- this.clientVersion = opts.clientVersion;
25
- this.instanceId = `${process.pid}-${crypto.randomBytes(2).toString("hex")}-${Date.now()}`;
26
- }
27
- start() {
28
- if (this.heartbeatTimer || this.pollTimer) return;
29
- log.info(`[bridge] poller starting \xB7 ${this.baseUrl} \xB7 instance=${this.instanceId}`);
30
- void this.heartbeat();
31
- this.heartbeatTimer = setInterval(() => {
32
- void this.heartbeat();
33
- }, HEARTBEAT_INTERVAL_MS);
34
- this.pollTimer = setInterval(() => {
35
- void this.poll();
36
- }, POLL_INTERVAL_MS);
37
- }
38
- async stop() {
39
- this.stopped = true;
40
- if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
41
- if (this.pollTimer) clearInterval(this.pollTimer);
42
- this.heartbeatTimer = null;
43
- this.pollTimer = null;
44
- }
45
- probeBinary(name) {
46
- if (!/^[a-z][a-z0-9-]*$/i.test(name)) return { available: false, path: null };
47
- const r = spawnSync("which", [name], { encoding: "utf-8", timeout: 2e3 });
48
- if (r.status !== 0) return { available: false, path: null };
49
- const path = (r.stdout ?? "").trim();
50
- return { available: !!path, path: path || null };
51
- }
52
- async heartbeat() {
53
- if (this.stopped) return;
54
- try {
55
- const resp = await fetch(`${this.baseUrl}/api/cli-bridge/heartbeat`, {
56
- method: "POST",
57
- headers: {
58
- Authorization: `Bearer ${this.token}`,
59
- "Content-Type": "application/json"
60
- },
61
- body: JSON.stringify({
62
- availableClis: {
63
- claude: this.probeBinary("claude"),
64
- codex: this.probeBinary("codex")
65
- },
66
- clientVersion: this.clientVersion,
67
- instanceId: this.instanceId
68
- }),
69
- signal: AbortSignal.timeout(45e3)
70
- });
71
- if (!resp.ok) {
72
- const text = await resp.text().catch(() => "");
73
- log.warn(`[bridge] heartbeat HTTP ${resp.status}: ${text.slice(0, 120)}`);
74
- }
75
- } catch (err) {
76
- log.warn(`[bridge] heartbeat failed: ${err.message}`);
77
- }
78
- }
79
- async poll() {
80
- if (this.stopped) return;
81
- let jobs = [];
82
- try {
83
- const resp = await fetch(`${this.baseUrl}/api/cli-bridge/claim`, {
84
- method: "POST",
85
- headers: {
86
- Authorization: `Bearer ${this.token}`,
87
- "Content-Type": "application/json"
88
- },
89
- body: JSON.stringify({ instanceId: this.instanceId, limit: 3 }),
90
- signal: AbortSignal.timeout(45e3)
91
- });
92
- if (!resp.ok) {
93
- if (resp.status === 401) {
94
- log.warn("[bridge] 401 \u2014 bridge token rejected. Stopping poller. Set BRIDGE_TOKEN to the value Vercel has and restart.");
95
- await this.stop();
96
- return;
97
- }
98
- const text = await resp.text().catch(() => "");
99
- log.warn(`[bridge] claim HTTP ${resp.status}: ${text.slice(0, 120)}`);
100
- return;
101
- }
102
- const data = await resp.json();
103
- jobs = data.jobs ?? [];
104
- } catch (err) {
105
- log.warn(`[bridge] claim failed: ${err.message}`);
106
- return;
107
- }
108
- if (jobs.length === 0) return;
109
- await Promise.all(jobs.map((job) => this.runJob(job)));
110
- }
111
- async runJob(job) {
112
- if (this.inFlight.has(job.id)) return;
113
- this.inFlight.add(job.id);
114
- log.info(`[bridge] running job ${job.id.slice(0, 8)} \xB7 ${job.cli}${job.model ? `:${job.model}` : ""}`);
115
- const startedAt = Date.now();
116
- try {
117
- const { text, modelUsed, error } = await this.invokeCli(job.cli, job.model, job.prompt);
118
- const latencyMs = Date.now() - startedAt;
119
- await this.respond(job.id, { text, modelUsed, latencyMs, error });
120
- if (error) {
121
- log.warn(`[bridge] job ${job.id.slice(0, 8)} failed (${latencyMs}ms): ${error.slice(0, 120)}`);
122
- } else {
123
- log.success(`[bridge] job ${job.id.slice(0, 8)} done (${latencyMs}ms \xB7 ${modelUsed})`);
124
- }
125
- } catch (err) {
126
- const latencyMs = Date.now() - startedAt;
127
- await this.respond(job.id, { error: err.message, latencyMs });
128
- } finally {
129
- this.inFlight.delete(job.id);
130
- }
131
- }
132
- async invokeCli(cli, model, prompt) {
133
- const candidates = cli === "codex" ? model && /^[a-z0-9.\-]+$/i.test(model) ? [model] : ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"] : model && /^[a-z0-9-]+$/i.test(model) ? [model] : ["opus", "sonnet"];
134
- let lastErr = "";
135
- for (const m of candidates) {
136
- try {
137
- const args = cli === "codex" ? ["exec", "-", "--model", m, "--search"] : [
138
- "-p",
139
- "--model",
140
- m,
141
- "--no-session-persistence",
142
- "--allowedTools",
143
- "WebSearch,WebFetch"
144
- ];
145
- const text = await runSpawn(cli, args, prompt, CLI_TIMEOUT_MS);
146
- if (text.trim()) return { text, modelUsed: `${cli}:${m}` };
147
- lastErr = `${cli}/${m} returned empty stdout`;
148
- } catch (err) {
149
- lastErr = `${cli}/${m}: ${err.message}`;
150
- }
151
- }
152
- return { error: lastErr || "no model produced output" };
153
- }
154
- async respond(jobId, body) {
155
- try {
156
- const resp = await fetch(`${this.baseUrl}/api/cli-bridge/respond`, {
157
- method: "POST",
158
- headers: {
159
- Authorization: `Bearer ${this.token}`,
160
- "Content-Type": "application/json"
161
- },
162
- body: JSON.stringify({ jobId, ...body }),
163
- signal: AbortSignal.timeout(45e3)
164
- });
165
- if (!resp.ok) {
166
- const text = await resp.text().catch(() => "");
167
- log.warn(`[bridge] respond HTTP ${resp.status}: ${text.slice(0, 120)}`);
168
- }
169
- } catch (err) {
170
- log.warn(`[bridge] respond failed for ${jobId.slice(0, 8)}: ${err.message}`);
171
- }
172
- }
173
- };
174
- function runSpawn(cmd, args, stdin, timeoutMs) {
175
- return new Promise((resolve, reject) => {
176
- const proc = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
177
- let stdout = "";
178
- let stderr = "";
179
- const timer = setTimeout(() => {
180
- try {
181
- proc.kill("SIGKILL");
182
- } catch {
183
- }
184
- reject(new Error(`${cmd} timed out after ${timeoutMs}ms`));
185
- }, timeoutMs);
186
- proc.stdout.on("data", (d) => {
187
- stdout += d.toString("utf-8");
188
- });
189
- proc.stderr.on("data", (d) => {
190
- stderr += d.toString("utf-8");
191
- });
192
- proc.on("error", (err) => {
193
- clearTimeout(timer);
194
- reject(err);
195
- });
196
- proc.on("close", (code) => {
197
- clearTimeout(timer);
198
- if (code !== 0) reject(new Error(`${cmd} exit ${code}: ${stderr.slice(-300)}`));
199
- else resolve(stdout);
200
- });
201
- proc.stdin.end(stdin);
202
- });
203
- }
204
- export {
205
- BridgePoller
206
- };