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.
- package/dist/bridge-poller-5FFV2LML.js +199 -0
- package/dist/chunk-GJQ45C5W.js +17 -0
- package/dist/index.js +135 -40
- package/package.json +1 -1
|
@@ -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, (
|
|
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((
|
|
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", (
|
|
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
|
|
941
|
-
|
|
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
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
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,
|
|
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
|
-
|
|
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", (
|
|
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", (
|
|
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((
|
|
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", (
|
|
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("");
|