storyforge 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "storyforge",
3
- "version": "0.6.0",
4
- "description": "StoryForge — local bridge for the Forge video production web app. Zero runtime dependencies.",
3
+ "version": "0.7.0",
4
+ "description": "StoryForge — local bridge for the Forge video production web app. Parallel clip-render orchestrator (Remotion 4 + Manim + HyperFrames + ffmpeg) + final video stitcher + dependency doctor.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "storyforge": "./dist/index.js"
@@ -1,451 +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
- 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
- };