storyforge 0.4.21 → 0.5.3
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,367 @@
|
|
|
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 CLI_TIMEOUT_MS = Number(process.env.SCRIPT_GEN_TIMEOUT_MS) || 20 * 60 * 1e3;
|
|
13
|
+
var IMAGE_GEN_TIMEOUT_MS = Number(process.env.CODEX_IMAGE_GEN_TIMEOUT_MS) || Number(process.env.IMAGE_GEN_TIMEOUT_MS) || 8 * 60 * 1e3;
|
|
14
|
+
var MAX_IN_FLIGHT_JOBS = Math.max(1, Math.min(6, Number(process.env.FORGE_BRIDGE_MAX_IN_FLIGHT) || 4));
|
|
15
|
+
var CODEX_IMAGEGEN_AGENT_MODELS = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"];
|
|
16
|
+
var CODEX_AGENT_MODEL_RE = /^gpt-5(?:$|[.\-])/i;
|
|
17
|
+
var BridgePoller = class {
|
|
18
|
+
baseUrl;
|
|
19
|
+
token;
|
|
20
|
+
clientVersion;
|
|
21
|
+
instanceId;
|
|
22
|
+
heartbeatTimer = null;
|
|
23
|
+
pollTimer = null;
|
|
24
|
+
inFlight = /* @__PURE__ */ new Set();
|
|
25
|
+
stopped = false;
|
|
26
|
+
constructor(opts) {
|
|
27
|
+
this.baseUrl = opts.baseUrl.replace(/\/$/, "");
|
|
28
|
+
this.token = opts.token;
|
|
29
|
+
this.clientVersion = opts.clientVersion;
|
|
30
|
+
this.instanceId = `${process.pid}-${crypto.randomBytes(2).toString("hex")}-${Date.now()}`;
|
|
31
|
+
}
|
|
32
|
+
start() {
|
|
33
|
+
if (this.heartbeatTimer || this.pollTimer) return;
|
|
34
|
+
log.info(`[bridge] poller starting \xB7 ${this.baseUrl} \xB7 instance=${this.instanceId}`);
|
|
35
|
+
void this.heartbeat();
|
|
36
|
+
this.heartbeatTimer = setInterval(() => {
|
|
37
|
+
void this.heartbeat();
|
|
38
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
39
|
+
this.pollTimer = setInterval(() => {
|
|
40
|
+
void this.poll();
|
|
41
|
+
}, POLL_INTERVAL_MS);
|
|
42
|
+
}
|
|
43
|
+
async stop() {
|
|
44
|
+
this.stopped = true;
|
|
45
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
46
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
47
|
+
this.heartbeatTimer = null;
|
|
48
|
+
this.pollTimer = null;
|
|
49
|
+
}
|
|
50
|
+
probeBinary(name) {
|
|
51
|
+
if (!/^[a-z][a-z0-9-]*$/i.test(name)) return { available: false, path: null };
|
|
52
|
+
const r = spawnSync("which", [name], { encoding: "utf-8", timeout: 2e3 });
|
|
53
|
+
if (r.status !== 0) return { available: false, path: null };
|
|
54
|
+
const path = (r.stdout ?? "").trim();
|
|
55
|
+
return { available: !!path, path: path || null };
|
|
56
|
+
}
|
|
57
|
+
async heartbeat() {
|
|
58
|
+
if (this.stopped) return;
|
|
59
|
+
try {
|
|
60
|
+
const resp = await fetch(`${this.baseUrl}/api/cli-bridge/heartbeat`, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: {
|
|
63
|
+
Authorization: `Bearer ${this.token}`,
|
|
64
|
+
"Content-Type": "application/json"
|
|
65
|
+
},
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
availableClis: {
|
|
68
|
+
claude: this.probeBinary("claude"),
|
|
69
|
+
codex: this.probeBinary("codex")
|
|
70
|
+
},
|
|
71
|
+
clientVersion: this.clientVersion,
|
|
72
|
+
instanceId: this.instanceId
|
|
73
|
+
}),
|
|
74
|
+
signal: AbortSignal.timeout(45e3)
|
|
75
|
+
});
|
|
76
|
+
if (!resp.ok) {
|
|
77
|
+
const text = await resp.text().catch(() => "");
|
|
78
|
+
log.warn(`[bridge] heartbeat HTTP ${resp.status}: ${text.slice(0, 120)}`);
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
log.warn(`[bridge] heartbeat failed: ${err.message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async poll() {
|
|
85
|
+
if (this.stopped) return;
|
|
86
|
+
const availableSlots = MAX_IN_FLIGHT_JOBS - this.inFlight.size;
|
|
87
|
+
if (availableSlots <= 0) return;
|
|
88
|
+
let jobs = [];
|
|
89
|
+
try {
|
|
90
|
+
const resp = await fetch(`${this.baseUrl}/api/cli-bridge/claim`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: {
|
|
93
|
+
Authorization: `Bearer ${this.token}`,
|
|
94
|
+
"Content-Type": "application/json"
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify({ instanceId: this.instanceId, limit: Math.min(3, availableSlots) }),
|
|
97
|
+
signal: AbortSignal.timeout(45e3)
|
|
98
|
+
});
|
|
99
|
+
if (!resp.ok) {
|
|
100
|
+
if (resp.status === 401) {
|
|
101
|
+
log.warn("[bridge] 401 \u2014 bridge token rejected. Stopping poller. Set BRIDGE_TOKEN to the value Vercel has and restart.");
|
|
102
|
+
await this.stop();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const text = await resp.text().catch(() => "");
|
|
106
|
+
log.warn(`[bridge] claim HTTP ${resp.status}: ${text.slice(0, 120)}`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const data = await resp.json();
|
|
110
|
+
jobs = data.jobs ?? [];
|
|
111
|
+
} catch (err) {
|
|
112
|
+
log.warn(`[bridge] claim failed: ${err.message}`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (jobs.length === 0) return;
|
|
116
|
+
await Promise.all(jobs.map((job) => this.runJob(job)));
|
|
117
|
+
}
|
|
118
|
+
async runJob(job) {
|
|
119
|
+
if (this.inFlight.has(job.id)) return;
|
|
120
|
+
this.inFlight.add(job.id);
|
|
121
|
+
const imagePayload = parseImageGenPayload(job.prompt);
|
|
122
|
+
const modelLabel = imagePayload && job.cli === "codex" ? `codex:imagegen via ${codexImagegenModelCandidates(job.model)[0]}${job.model ? ` (requested ${job.model})` : ""}` : `${job.cli}${job.model ? `:${job.model}` : ""}`;
|
|
123
|
+
log.info(`[bridge] running job ${job.id.slice(0, 8)} \xB7 ${modelLabel}`);
|
|
124
|
+
const startedAt = Date.now();
|
|
125
|
+
try {
|
|
126
|
+
const { text, modelUsed, error } = await this.invokeJob(job);
|
|
127
|
+
const latencyMs = Date.now() - startedAt;
|
|
128
|
+
await this.respond(job.id, { text, modelUsed, latencyMs, error });
|
|
129
|
+
if (error) {
|
|
130
|
+
log.warn(`[bridge] job ${job.id.slice(0, 8)} failed (${latencyMs}ms): ${error.slice(0, 120)}`);
|
|
131
|
+
} else {
|
|
132
|
+
log.success(`[bridge] job ${job.id.slice(0, 8)} done (${latencyMs}ms \xB7 ${modelUsed})`);
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const latencyMs = Date.now() - startedAt;
|
|
136
|
+
await this.respond(job.id, { error: err.message, latencyMs });
|
|
137
|
+
} finally {
|
|
138
|
+
this.inFlight.delete(job.id);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async invokeCli(cli, model, prompt) {
|
|
142
|
+
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"];
|
|
143
|
+
let lastErr = "";
|
|
144
|
+
for (const m of candidates) {
|
|
145
|
+
try {
|
|
146
|
+
const args = cli === "codex" ? ["exec", "-", "--model", m, "--search"] : [
|
|
147
|
+
"-p",
|
|
148
|
+
"--model",
|
|
149
|
+
m,
|
|
150
|
+
"--no-session-persistence",
|
|
151
|
+
"--allowedTools",
|
|
152
|
+
"WebSearch,WebFetch"
|
|
153
|
+
];
|
|
154
|
+
const text = await runSpawn(cli, args, prompt, CLI_TIMEOUT_MS);
|
|
155
|
+
if (text.trim()) return { text, modelUsed: `${cli}:${m}` };
|
|
156
|
+
lastErr = `${cli}/${m} returned empty stdout`;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
lastErr = `${cli}/${m}: ${err.message}`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { error: lastErr || "no model produced output" };
|
|
162
|
+
}
|
|
163
|
+
async invokeJob(job) {
|
|
164
|
+
const claudePromptEnvelope = parseClaudePromptEnvelope(job.prompt);
|
|
165
|
+
if (claudePromptEnvelope) {
|
|
166
|
+
if (job.cli !== "claude") return { error: `${claudePromptEnvelope.__forgeJobKind} envelope requires claude CLI` };
|
|
167
|
+
return this.invokeClaudeWithEnvelope(job.model, claudePromptEnvelope);
|
|
168
|
+
}
|
|
169
|
+
const imagePayload = parseImageGenPayload(job.prompt);
|
|
170
|
+
if (imagePayload) {
|
|
171
|
+
if (job.cli !== "codex") return { error: "image-gen bridge jobs require codex CLI" };
|
|
172
|
+
return this.invokeCodexImageGen(job.model, imagePayload);
|
|
173
|
+
}
|
|
174
|
+
return this.invokeCli(job.cli, job.model, job.prompt);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Run a Claude prompt envelope. Web tools are gated by envelope.webTools:
|
|
178
|
+
* ['WebSearch','WebFetch'] for fact-pack-build (research stage)
|
|
179
|
+
* [] for script-from-pack (pack-only writing)
|
|
180
|
+
*/
|
|
181
|
+
async invokeClaudeWithEnvelope(jobModel, envelope) {
|
|
182
|
+
const requestedModel = envelope.model || jobModel;
|
|
183
|
+
const fallbackChain = envelope.__forgeJobKind === "fact-pack-build" ? ["opus", "sonnet"] : ["sonnet", "haiku"];
|
|
184
|
+
const candidates = requestedModel && /^[a-z0-9-]+$/i.test(requestedModel) ? [requestedModel] : fallbackChain;
|
|
185
|
+
const tools = (envelope.webTools ?? []).filter((t) => t === "WebSearch" || t === "WebFetch");
|
|
186
|
+
const baseArgs = ["-p", "--model", "<m>", "--no-session-persistence"];
|
|
187
|
+
if (tools.length > 0) {
|
|
188
|
+
baseArgs.push("--allowedTools", tools.join(","));
|
|
189
|
+
}
|
|
190
|
+
let lastErr = "";
|
|
191
|
+
for (const m of candidates) {
|
|
192
|
+
try {
|
|
193
|
+
const args = baseArgs.map((a) => a === "<m>" ? m : a);
|
|
194
|
+
const text = await runSpawn("claude", args, envelope.prompt, CLI_TIMEOUT_MS);
|
|
195
|
+
if (text.trim()) return { text, modelUsed: `claude:${m}${tools.length === 0 ? " (no-web)" : ""}` };
|
|
196
|
+
lastErr = `claude/${m} returned empty stdout`;
|
|
197
|
+
} catch (err) {
|
|
198
|
+
lastErr = `claude/${m}: ${err.message}`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { error: lastErr || "no model produced output" };
|
|
202
|
+
}
|
|
203
|
+
async invokeCodexImageGen(model, payload) {
|
|
204
|
+
const candidates = codexImagegenModelCandidates(model);
|
|
205
|
+
const count = Math.max(1, Math.min(4, Number(payload.count) || 1));
|
|
206
|
+
const prompt = [
|
|
207
|
+
"Use Codex imagegen to generate raster image assets for Forge.",
|
|
208
|
+
"Use the installed imagegen skill in its default built-in tool mode.",
|
|
209
|
+
`Generate exactly ${count} image${count === 1 ? "" : "s"}.`,
|
|
210
|
+
`Aspect: ${payload.aspect ?? "16:9"}. Quality: ${payload.quality ?? "medium"}.`,
|
|
211
|
+
payload.size ? `Preferred size: ${payload.size}.` : "",
|
|
212
|
+
"",
|
|
213
|
+
"Image prompt:",
|
|
214
|
+
payload.prompt,
|
|
215
|
+
"",
|
|
216
|
+
"After the file(s) are generated, return ONLY strict JSON in this shape:",
|
|
217
|
+
'{"images":[{"path":"/absolute/path/to/generated.png","mimeType":"image/png"}]}',
|
|
218
|
+
"No markdown, no prose, no thumbnails, no relative paths."
|
|
219
|
+
].filter(Boolean).join("\n");
|
|
220
|
+
let lastErr = "";
|
|
221
|
+
for (const m of candidates) {
|
|
222
|
+
try {
|
|
223
|
+
const stdout = await runSpawn("codex", ["exec", "-", "--model", m], prompt, IMAGE_GEN_TIMEOUT_MS);
|
|
224
|
+
const images = collectImagesFromCodexStdout(stdout, count);
|
|
225
|
+
if (images.length === 0) {
|
|
226
|
+
lastErr = `codex/${m} imagegen returned no readable image paths`;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
text: JSON.stringify({ images, model: `codex-cli:${m}` }),
|
|
231
|
+
modelUsed: `codex-cli:${m}`
|
|
232
|
+
};
|
|
233
|
+
} catch (err) {
|
|
234
|
+
lastErr = `codex/${m}: ${err.message}`;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { error: lastErr || "codex imagegen produced no output" };
|
|
238
|
+
}
|
|
239
|
+
async respond(jobId, body) {
|
|
240
|
+
try {
|
|
241
|
+
const resp = await fetch(`${this.baseUrl}/api/cli-bridge/respond`, {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: {
|
|
244
|
+
Authorization: `Bearer ${this.token}`,
|
|
245
|
+
"Content-Type": "application/json"
|
|
246
|
+
},
|
|
247
|
+
body: JSON.stringify({ jobId, ...body }),
|
|
248
|
+
signal: AbortSignal.timeout(45e3)
|
|
249
|
+
});
|
|
250
|
+
if (!resp.ok) {
|
|
251
|
+
const text = await resp.text().catch(() => "");
|
|
252
|
+
log.warn(`[bridge] respond HTTP ${resp.status}: ${text.slice(0, 120)}`);
|
|
253
|
+
}
|
|
254
|
+
} catch (err) {
|
|
255
|
+
log.warn(`[bridge] respond failed for ${jobId.slice(0, 8)}: ${err.message}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
function codexImagegenModelCandidates(model) {
|
|
260
|
+
const requested = model?.trim();
|
|
261
|
+
if (requested && /^[a-z0-9.\-]+$/i.test(requested) && CODEX_AGENT_MODEL_RE.test(requested)) {
|
|
262
|
+
return [requested];
|
|
263
|
+
}
|
|
264
|
+
return CODEX_IMAGEGEN_AGENT_MODELS;
|
|
265
|
+
}
|
|
266
|
+
function parseImageGenPayload(prompt) {
|
|
267
|
+
try {
|
|
268
|
+
const parsed = JSON.parse(prompt);
|
|
269
|
+
if (parsed.__forgeJobKind === "image-gen" && typeof parsed.prompt === "string" && parsed.prompt.trim()) {
|
|
270
|
+
return parsed;
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
var CLAUDE_JOB_KINDS = [
|
|
277
|
+
"fact-pack-build",
|
|
278
|
+
"script-from-pack",
|
|
279
|
+
"script-evaluate",
|
|
280
|
+
"script-improve"
|
|
281
|
+
];
|
|
282
|
+
function parseClaudePromptEnvelope(prompt) {
|
|
283
|
+
try {
|
|
284
|
+
const parsed = JSON.parse(prompt);
|
|
285
|
+
const kind = parsed.__forgeJobKind;
|
|
286
|
+
if (kind && CLAUDE_JOB_KINDS.includes(kind) && typeof parsed.prompt === "string" && parsed.prompt.length > 0) {
|
|
287
|
+
return parsed;
|
|
288
|
+
}
|
|
289
|
+
} catch {
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
function collectImagesFromCodexStdout(stdout, maxCount) {
|
|
294
|
+
const parsedPaths = parseImagePathsFromJson(stdout);
|
|
295
|
+
const regexPaths = Array.from(stdout.matchAll(/(?:^|["`\s])(\/[^"`\n\r]+\.(?:png|jpe?g|webp))(?:["`\s]|$)/gi)).map((match) => match[1]);
|
|
296
|
+
const paths = Array.from(/* @__PURE__ */ new Set([...parsedPaths, ...regexPaths]));
|
|
297
|
+
const images = [];
|
|
298
|
+
for (const p of paths) {
|
|
299
|
+
if (images.length >= maxCount) break;
|
|
300
|
+
try {
|
|
301
|
+
const stat = fs.statSync(p);
|
|
302
|
+
if (!stat.isFile() || stat.size <= 0) continue;
|
|
303
|
+
images.push({
|
|
304
|
+
base64: fs.readFileSync(p).toString("base64"),
|
|
305
|
+
mimeType: mimeTypeForPath(p),
|
|
306
|
+
model: "codex-cli:imagegen"
|
|
307
|
+
});
|
|
308
|
+
} catch {
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return images;
|
|
312
|
+
}
|
|
313
|
+
function parseImagePathsFromJson(stdout) {
|
|
314
|
+
const trimmed = stdout.replace(/^```(?:json)?\s*/, "").replace(/```\s*$/, "").trim();
|
|
315
|
+
const candidates = [trimmed];
|
|
316
|
+
const objectMatch = trimmed.match(/\{[\s\S]*\}/);
|
|
317
|
+
if (objectMatch) candidates.push(objectMatch[0]);
|
|
318
|
+
for (const candidate of candidates) {
|
|
319
|
+
try {
|
|
320
|
+
const parsed = JSON.parse(candidate);
|
|
321
|
+
if (Array.isArray(parsed.images)) {
|
|
322
|
+
return parsed.images.map((img) => img.path).filter((p) => typeof p === "string");
|
|
323
|
+
}
|
|
324
|
+
} catch {
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
function mimeTypeForPath(p) {
|
|
330
|
+
const lower = p.toLowerCase();
|
|
331
|
+
if (lower.endsWith(".webp")) return "image/webp";
|
|
332
|
+
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
|
333
|
+
return "image/png";
|
|
334
|
+
}
|
|
335
|
+
function runSpawn(cmd, args, stdin, timeoutMs) {
|
|
336
|
+
return new Promise((resolve, reject) => {
|
|
337
|
+
const proc = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
338
|
+
let stdout = "";
|
|
339
|
+
let stderr = "";
|
|
340
|
+
const timer = setTimeout(() => {
|
|
341
|
+
try {
|
|
342
|
+
proc.kill("SIGKILL");
|
|
343
|
+
} catch {
|
|
344
|
+
}
|
|
345
|
+
reject(new Error(`${cmd} timed out after ${timeoutMs}ms`));
|
|
346
|
+
}, timeoutMs);
|
|
347
|
+
proc.stdout.on("data", (d) => {
|
|
348
|
+
stdout += d.toString("utf-8");
|
|
349
|
+
});
|
|
350
|
+
proc.stderr.on("data", (d) => {
|
|
351
|
+
stderr += d.toString("utf-8");
|
|
352
|
+
});
|
|
353
|
+
proc.on("error", (err) => {
|
|
354
|
+
clearTimeout(timer);
|
|
355
|
+
reject(err);
|
|
356
|
+
});
|
|
357
|
+
proc.on("close", (code) => {
|
|
358
|
+
clearTimeout(timer);
|
|
359
|
+
if (code !== 0) reject(new Error(`${cmd} exit ${code}: ${stderr.slice(-300)}`));
|
|
360
|
+
else resolve(stdout);
|
|
361
|
+
});
|
|
362
|
+
proc.stdin.end(stdin);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
export {
|
|
366
|
+
BridgePoller
|
|
367
|
+
};
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
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. "
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
5. NO
|
|
170
|
-
|
|
171
|
-
elegance,
|
|
172
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
the
|
|
197
|
-
|
|
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:
|
|
202
|
-
Good:
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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:
|
|
213
|
-
Good:
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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.
|
|
335
|
-
|
|
336
|
-
|
|
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): "
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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"
|
|
352
|
-
than once
|
|
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
|
|
402
|
-
Good: "
|
|
403
|
-
|
|
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
|
|
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
|
|
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.
|
|
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-
|
|
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-
|
|
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-
|
|
1617
|
+
const { BridgePoller } = await import("./bridge-poller-WEKSUZMT.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,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
|
-
};
|