storyforge 0.4.5 → 0.4.7

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,199 @@
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 = 3e4;
10
+ var POLL_INTERVAL_MS = 3e3;
11
+ var CLI_TIMEOUT_MS = 5 * 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(1e4)
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(1e4)
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] : ["-p", "--model", m, "--no-session-persistence"];
138
+ const text = await runSpawn(cli, args, prompt, CLI_TIMEOUT_MS);
139
+ if (text.trim()) return { text, modelUsed: `${cli}:${m}` };
140
+ lastErr = `${cli}/${m} returned empty stdout`;
141
+ } catch (err) {
142
+ lastErr = `${cli}/${m}: ${err.message}`;
143
+ }
144
+ }
145
+ return { error: lastErr || "no model produced output" };
146
+ }
147
+ async respond(jobId, body) {
148
+ try {
149
+ const resp = await fetch(`${this.baseUrl}/api/cli-bridge/respond`, {
150
+ method: "POST",
151
+ headers: {
152
+ Authorization: `Bearer ${this.token}`,
153
+ "Content-Type": "application/json"
154
+ },
155
+ body: JSON.stringify({ jobId, ...body }),
156
+ signal: AbortSignal.timeout(15e3)
157
+ });
158
+ if (!resp.ok) {
159
+ const text = await resp.text().catch(() => "");
160
+ log.warn(`[bridge] respond HTTP ${resp.status}: ${text.slice(0, 120)}`);
161
+ }
162
+ } catch (err) {
163
+ log.warn(`[bridge] respond failed for ${jobId.slice(0, 8)}: ${err.message}`);
164
+ }
165
+ }
166
+ };
167
+ function runSpawn(cmd, args, stdin, timeoutMs) {
168
+ return new Promise((resolve, reject) => {
169
+ const proc = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
170
+ let stdout = "";
171
+ let stderr = "";
172
+ const timer = setTimeout(() => {
173
+ try {
174
+ proc.kill("SIGKILL");
175
+ } catch {
176
+ }
177
+ reject(new Error(`${cmd} timed out after ${timeoutMs}ms`));
178
+ }, timeoutMs);
179
+ proc.stdout.on("data", (d) => {
180
+ stdout += d.toString("utf-8");
181
+ });
182
+ proc.stderr.on("data", (d) => {
183
+ stderr += d.toString("utf-8");
184
+ });
185
+ proc.on("error", (err) => {
186
+ clearTimeout(timer);
187
+ reject(err);
188
+ });
189
+ proc.on("close", (code) => {
190
+ clearTimeout(timer);
191
+ if (code !== 0) reject(new Error(`${cmd} exit ${code}: ${stderr.slice(-300)}`));
192
+ else resolve(stdout);
193
+ });
194
+ proc.stdin.end(stdin);
195
+ });
196
+ }
197
+ export {
198
+ BridgePoller
199
+ };
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/utils/log.ts
4
+ var supportsColor = process.stdout.isTTY && process.env.TERM !== "dumb" && !process.env.NO_COLOR;
5
+ var c = supportsColor ? { blue: "\x1B[34m", green: "\x1B[32m", yellow: "\x1B[33m", red: "\x1B[31m", gray: "\x1B[90m", bold: "\x1B[1m", reset: "\x1B[0m" } : { blue: "", green: "", yellow: "", red: "", gray: "", bold: "", reset: "" };
6
+ var log = {
7
+ info: (msg) => console.log(c.blue + "i" + c.reset, msg),
8
+ success: (msg) => console.log(c.green + "\u2713" + c.reset, msg),
9
+ warn: (msg) => console.log(c.yellow + "!" + c.reset, msg),
10
+ error: (msg) => console.error(c.red + "\u2717" + c.reset, msg),
11
+ step: (n, total, msg) => console.log(c.gray + `[${n}/${total}]` + c.reset, msg),
12
+ banner: (msg) => console.log(c.bold + msg + c.reset)
13
+ };
14
+
15
+ export {
16
+ log
17
+ };
package/dist/index.js CHANGED
@@ -1,25 +1,16 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ log
4
+ } from "./chunk-GJQ45C5W.js";
2
5
 
3
6
  // src/commands/dev.ts
4
7
  import * as fs2 from "fs";
5
8
  import * as os2 from "os";
6
9
  import * as path2 from "path";
7
10
  import * as http from "http";
8
- import { execFile, exec as execCb, execSync } from "child_process";
11
+ import { execFile, exec as execCb, execSync, spawn } from "child_process";
9
12
  import { promisify } from "util";
10
13
 
11
- // src/utils/log.ts
12
- var supportsColor = process.stdout.isTTY && process.env.TERM !== "dumb" && !process.env.NO_COLOR;
13
- var c = supportsColor ? { blue: "\x1B[34m", green: "\x1B[32m", yellow: "\x1B[33m", red: "\x1B[31m", gray: "\x1B[90m", bold: "\x1B[1m", reset: "\x1B[0m" } : { blue: "", green: "", yellow: "", red: "", gray: "", bold: "", reset: "" };
14
- var log = {
15
- info: (msg) => console.log(c.blue + "i" + c.reset, msg),
16
- success: (msg) => console.log(c.green + "\u2713" + c.reset, msg),
17
- warn: (msg) => console.log(c.yellow + "!" + c.reset, msg),
18
- error: (msg) => console.error(c.red + "\u2717" + c.reset, msg),
19
- step: (n, total, msg) => console.log(c.gray + `[${n}/${total}]` + c.reset, msg),
20
- banner: (msg) => console.log(c.bold + msg + c.reset)
21
- };
22
-
23
14
  // src/config.ts
24
15
  import * as fs from "fs";
25
16
  import * as path from "path";
@@ -88,13 +79,31 @@ ${args.referenceScript.slice(0, 8e3)}
88
79
  === END SAMPLE ===
89
80
 
90
81
  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.` : "";
82
+ const guidanceBlock = (args.additionalGuidance ?? "").trim() ? `
83
+
84
+ \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
85
+ VIEWER'S SPECIFIC ANGLE (RULE -2 \u2014 overrides everything below)
86
+ \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
87
+
88
+ The person commissioning this script has spelled out the lens they
89
+ want. This is NOT a hint. It is the deciding constraint when you pick
90
+ structural format, dominant metaphor, named characters, antagonist,
91
+ and the open-loop. If the LLM's default instinct conflicts with this
92
+ guidance, the guidance wins.
93
+
94
+ >>> ${args.additionalGuidance.trim().replace(/\n/g, "\n>>> ")}
95
+
96
+ Before you commit to a format / metaphor / payoff, re-read the lens
97
+ above. Every chunk should serve it. Reject any structural choice that
98
+ doesn't.
99
+ ` : "";
91
100
  return `You are a senior documentary scriptwriter for ${channel} (${handle}). The channel's promise: "${tagline}".
92
101
 
93
102
  Your job is to write a script that is UNPUTDOWNABLE \u2014 emotionally engaging, viscerally specific, and dense with surprise. Every chunk must earn the next click. "Technically accurate but academic" = FAILURE. "Comprehensive but boring" = FAILURE. The bar is: would a smart 22-year-old skip this video at second 30, or would they keep watching at minute 18?
94
103
 
95
104
  TOPIC: "${args.topic}"
96
105
  STYLE: ${args.style} \u2014 ${args.styleGuide}
97
- TARGET LENGTH: ${minMin}-${maxMin} minutes (write to the topic's natural length \u2014 pad nothing, rush nothing).
106
+ TARGET LENGTH: ${minMin}-${maxMin} minutes (write to the topic's natural length \u2014 pad nothing, rush nothing).${guidanceBlock}
98
107
 
99
108
  \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
100
109
  ENGAGEMENT BAR (this overrides everything below \u2014 RULE -1)
@@ -501,6 +510,46 @@ Now write the script.`;
501
510
  // src/commands/dev.ts
502
511
  var exec = promisify(execCb);
503
512
  var PORT = 4444;
513
+ function runCliPipingStdin(cmd, args, stdinData, opts = {}) {
514
+ const maxBytes = (opts.maxBufferMB ?? 16) * 1024 * 1024;
515
+ return new Promise((resolve2, reject) => {
516
+ const proc = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
517
+ let stdout = "";
518
+ let stderr = "";
519
+ let total = 0;
520
+ const timer = opts.timeoutMs ? setTimeout(() => {
521
+ try {
522
+ proc.kill("SIGKILL");
523
+ } catch {
524
+ }
525
+ reject(new Error(`${cmd} timed out after ${opts.timeoutMs}ms`));
526
+ }, opts.timeoutMs) : null;
527
+ proc.stdout.on("data", (d) => {
528
+ total += d.length;
529
+ if (total > maxBytes) {
530
+ try {
531
+ proc.kill("SIGKILL");
532
+ } catch {
533
+ }
534
+ reject(new Error(`${cmd} stdout exceeded ${opts.maxBufferMB ?? 16}MB`));
535
+ return;
536
+ }
537
+ stdout += d.toString("utf-8");
538
+ });
539
+ proc.stderr.on("data", (d) => {
540
+ stderr += d.toString("utf-8");
541
+ });
542
+ proc.on("error", (err) => {
543
+ if (timer) clearTimeout(timer);
544
+ reject(err);
545
+ });
546
+ proc.on("close", (code) => {
547
+ if (timer) clearTimeout(timer);
548
+ resolve2({ stdout, stderr, code });
549
+ });
550
+ proc.stdin.end(stdinData);
551
+ });
552
+ }
504
553
  var WEB_URL = "https://forge.algo-thinker.com";
505
554
  function getApiConfig() {
506
555
  const creds = loadCredentials();
@@ -531,7 +580,7 @@ function saveMeta(dir, meta) {
531
580
  fs2.writeFileSync(metaPath(dir), JSON.stringify(meta, null, 2));
532
581
  }
533
582
  function detectFolderName(dir) {
534
- return path2.basename(path2.resolve(dir)).replace(/[-_]/g, " ").replace(/\b\w/g, (c2) => c2.toUpperCase());
583
+ return path2.basename(path2.resolve(dir)).replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
535
584
  }
536
585
  function slugify(text) {
537
586
  return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
@@ -765,6 +814,14 @@ async function devCommand(options) {
765
814
  const url = new URL(req.url || "/", `http://localhost:${port}`);
766
815
  const pathname = url.pathname;
767
816
  if (pathname === "/api/health") {
817
+ const probeCli = (binary) => {
818
+ try {
819
+ const out = execSync(`which ${binary}`, { stdio: ["ignore", "pipe", "ignore"], timeout: 2e3 }).toString().trim();
820
+ return { available: !!out, path: out || null };
821
+ } catch {
822
+ return { available: false, path: null };
823
+ }
824
+ };
768
825
  res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
769
826
  res.end(JSON.stringify({
770
827
  status: "ok",
@@ -772,7 +829,14 @@ async function devCommand(options) {
772
829
  channelId: meta.channelId,
773
830
  title: meta.title,
774
831
  channelSlug: meta.channelSlug,
775
- dir
832
+ dir,
833
+ // CLI bridge surface — mirrors the production VPS bridge's /health
834
+ // shape so the web UI can use the same code path for both.
835
+ bridge: {
836
+ source: "local-forge-dev",
837
+ claude: probeCli("claude"),
838
+ codex: probeCli("codex")
839
+ }
776
840
  }));
777
841
  return;
778
842
  }
@@ -839,7 +903,7 @@ async function devCommand(options) {
839
903
  return;
840
904
  }
841
905
  const projectComps = findProjectCompositions(dir);
842
- const found = projectComps.find((c2) => c2.name === name);
906
+ const found = projectComps.find((c) => c.name === name);
843
907
  if (found) {
844
908
  res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "text/plain; charset=utf-8" });
845
909
  res.end(fs2.readFileSync(found.absolutePath, "utf-8"));
@@ -909,7 +973,7 @@ async function devCommand(options) {
909
973
  }
910
974
  if (pathname === "/api/script-gen" && req.method === "POST") {
911
975
  const bodyChunks = [];
912
- req.on("data", (c2) => bodyChunks.push(c2));
976
+ req.on("data", (c) => bodyChunks.push(c));
913
977
  req.on("end", async () => {
914
978
  let body;
915
979
  try {
@@ -935,35 +999,41 @@ async function devCommand(options) {
935
999
  forbiddenVisuals: body.forbiddenVisuals,
936
1000
  referenceScript: body.referenceScript,
937
1001
  targetMinutesMin: body.targetMinutesMin,
938
- targetMinutesMax: body.targetMinutesMax
1002
+ targetMinutesMax: body.targetMinutesMax,
1003
+ additionalGuidance: body.additionalGuidance
939
1004
  });
940
- const tmpFile = path2.join(os2.tmpdir(), `forge-script-${Date.now()}.txt`);
941
- fs2.writeFileSync(tmpFile, prompt2, "utf-8");
942
- const cleanup = () => {
943
- if (fs2.existsSync(tmpFile)) fs2.unlinkSync(tmpFile);
944
- };
945
- const candidates = ["claude-opus-4-7", "claude-sonnet-4-6"];
1005
+ const cli = body.cli === "codex" ? "codex" : "claude";
1006
+ const candidates = cli === "codex" ? ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"] : ["claude-opus-4-7", "claude-sonnet-4-6"];
946
1007
  let raw = "";
947
1008
  let modelUsed = "";
948
1009
  let lastErr;
949
1010
  for (const model of candidates) {
1011
+ if (!/^[a-z0-9.\-]+$/i.test(model)) {
1012
+ lastErr = `model alias "${model}" rejected by validator`;
1013
+ continue;
1014
+ }
950
1015
  try {
951
- log.info(`[script-gen] Generating via Claude CLI \xB7 model=${model}`);
952
- const { stdout } = await exec(
953
- `cat "${tmpFile}" | claude -p --model ${model} --no-session-persistence`,
954
- { maxBuffer: 16 * 1024 * 1024, timeout: 3e5 }
955
- );
1016
+ log.info(`[script-gen] Generating via ${cli} CLI \xB7 model=${model}`);
1017
+ const args = cli === "codex" ? ["exec", "-", "--model", model] : ["-p", "--model", model, "--no-session-persistence"];
1018
+ const { stdout, code, stderr } = await runCliPipingStdin(cli, args, prompt2, {
1019
+ timeoutMs: 3e5,
1020
+ maxBufferMB: 16
1021
+ });
1022
+ if (code !== 0) {
1023
+ lastErr = `${cli}/${model} exit ${code}: ${stderr.slice(-300)}`;
1024
+ log.warn(`[script-gen] ${lastErr.slice(0, 120)}`);
1025
+ continue;
1026
+ }
956
1027
  raw = (stdout ?? "").trim();
957
1028
  if (raw) {
958
- modelUsed = model;
1029
+ modelUsed = `${cli}:${model}`;
959
1030
  break;
960
1031
  }
961
1032
  } catch (err) {
962
1033
  lastErr = err instanceof Error ? err.message : String(err);
963
- log.warn(`[script-gen] ${model} failed: ${lastErr.slice(0, 120)}`);
1034
+ log.warn(`[script-gen] ${cli}/${model} failed: ${lastErr.slice(0, 120)}`);
964
1035
  }
965
1036
  }
966
- cleanup();
967
1037
  if (!raw) {
968
1038
  res.writeHead(502, { ...CORS_HEADERS, "Content-Type": "application/json" });
969
1039
  res.end(JSON.stringify({
@@ -997,14 +1067,16 @@ async function devCommand(options) {
997
1067
  }));
998
1068
  return;
999
1069
  }
1000
- const totalSec = chunks.reduce((s, c2) => s + (c2.durationEstimate ?? 0), 0);
1070
+ const totalSec = chunks.reduce((s, c) => s + (c.durationEstimate ?? 0), 0);
1001
1071
  log.success(`[script-gen] ${chunks.length} chunks \xB7 ~${(totalSec / 60).toFixed(1)} min via ${modelUsed}`);
1002
1072
  res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
1003
1073
  res.end(JSON.stringify({
1004
1074
  chunks,
1005
1075
  model: modelUsed,
1006
- provider: "claude-cli",
1007
- total_sec: totalSec
1076
+ provider: cli === "codex" ? "codex-cli" : "claude-cli",
1077
+ cli,
1078
+ total_sec: totalSec,
1079
+ renderedPrompt: prompt2
1008
1080
  }));
1009
1081
  });
1010
1082
  req.on("error", () => {
@@ -1015,7 +1087,7 @@ async function devCommand(options) {
1015
1087
  }
1016
1088
  if (pathname === "/api/ai-edit" && req.method === "POST") {
1017
1089
  const bodyChunks = [];
1018
- req.on("data", (c2) => bodyChunks.push(c2));
1090
+ req.on("data", (c) => bodyChunks.push(c));
1019
1091
  req.on("end", async () => {
1020
1092
  let parsed;
1021
1093
  try {
@@ -1175,7 +1247,7 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
1175
1247
  }
1176
1248
  if (pathname === "/api/chunk-compositions/append-images" && req.method === "POST") {
1177
1249
  const bodyChunks = [];
1178
- req.on("data", (c2) => bodyChunks.push(c2));
1250
+ req.on("data", (c) => bodyChunks.push(c));
1179
1251
  req.on("end", () => {
1180
1252
  try {
1181
1253
  const { assemblyId, paths } = JSON.parse(Buffer.concat(bodyChunks).toString("utf-8"));
@@ -1186,7 +1258,7 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
1186
1258
  return;
1187
1259
  }
1188
1260
  const doc = JSON.parse(fs2.readFileSync(compPath, "utf-8"));
1189
- const scene = doc.scenes?.find((c2) => c2.assemblyId === assemblyId);
1261
+ const scene = doc.scenes?.find((c) => c.assemblyId === assemblyId);
1190
1262
  if (!scene) {
1191
1263
  res.writeHead(404, CORS_HEADERS);
1192
1264
  res.end(JSON.stringify({ error: `scene ${assemblyId} not found` }));
@@ -1340,7 +1412,7 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
1340
1412
  const boundary = boundaryM[1];
1341
1413
  const chunks = [];
1342
1414
  await new Promise((resolve2, reject) => {
1343
- req.on("data", (c2) => chunks.push(Buffer.from(c2)));
1415
+ req.on("data", (c) => chunks.push(Buffer.from(c)));
1344
1416
  req.on("end", resolve2);
1345
1417
  req.on("error", reject);
1346
1418
  });
@@ -1439,6 +1511,29 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
1439
1511
  console.log(` Script generation \u2192 ${scriptGenModel}`);
1440
1512
  console.log(` POST http://localhost:${port}/api/script-gen`);
1441
1513
  }
1514
+ const bridgeUrl = process.env.FORGE_BRIDGE_URL ?? WEB_URL;
1515
+ const bridgeToken = process.env.FORGE_BRIDGE_TOKEN ?? process.env.BRIDGE_TOKEN ?? "";
1516
+ if (bridgeToken) {
1517
+ const pkgVersion = (() => {
1518
+ try {
1519
+ const here = path2.dirname(new URL(import.meta.url).pathname);
1520
+ const pkgPath = path2.resolve(here, "..", "package.json");
1521
+ if (fs2.existsSync(pkgPath)) return JSON.parse(fs2.readFileSync(pkgPath, "utf-8")).version;
1522
+ } catch {
1523
+ }
1524
+ return "0.0.0";
1525
+ })();
1526
+ void (async () => {
1527
+ const { BridgePoller } = await import("./bridge-poller-5FFV2LML.js");
1528
+ const poller = new BridgePoller({ baseUrl: bridgeUrl, token: bridgeToken, clientVersion: `storyforge ${pkgVersion}` });
1529
+ poller.start();
1530
+ })();
1531
+ console.log("");
1532
+ console.log(` Bridge tunnel \u2192 ${bridgeUrl}/api/cli-bridge/* (outbound polling)`);
1533
+ } else {
1534
+ console.log("");
1535
+ console.log(" Bridge tunnel disabled \u2014 set FORGE_BRIDGE_TOKEN to expose your CLI seats to forge.algo-thinker.com");
1536
+ }
1442
1537
  console.log("");
1443
1538
  console.log(" Ctrl+C to stop");
1444
1539
  console.log("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyforge",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "StoryForge — local bridge for the Forge video production web app. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "bin": {