storyforge 0.4.5 → 0.4.6

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.
Files changed (2) hide show
  1. package/dist/index.js +101 -20
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import * as fs2 from "fs";
5
5
  import * as os2 from "os";
6
6
  import * as path2 from "path";
7
7
  import * as http from "http";
8
- import { execFile, exec as execCb, execSync } from "child_process";
8
+ import { execFile, exec as execCb, execSync, spawn } from "child_process";
9
9
  import { promisify } from "util";
10
10
 
11
11
  // src/utils/log.ts
@@ -88,13 +88,31 @@ ${args.referenceScript.slice(0, 8e3)}
88
88
  === END SAMPLE ===
89
89
 
90
90
  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.` : "";
91
+ const guidanceBlock = (args.additionalGuidance ?? "").trim() ? `
92
+
93
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
94
+ VIEWER'S SPECIFIC ANGLE (RULE -2 \u2014 overrides everything below)
95
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
96
+
97
+ The person commissioning this script has spelled out the lens they
98
+ want. This is NOT a hint. It is the deciding constraint when you pick
99
+ structural format, dominant metaphor, named characters, antagonist,
100
+ and the open-loop. If the LLM's default instinct conflicts with this
101
+ guidance, the guidance wins.
102
+
103
+ >>> ${args.additionalGuidance.trim().replace(/\n/g, "\n>>> ")}
104
+
105
+ Before you commit to a format / metaphor / payoff, re-read the lens
106
+ above. Every chunk should serve it. Reject any structural choice that
107
+ doesn't.
108
+ ` : "";
91
109
  return `You are a senior documentary scriptwriter for ${channel} (${handle}). The channel's promise: "${tagline}".
92
110
 
93
111
  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
112
 
95
113
  TOPIC: "${args.topic}"
96
114
  STYLE: ${args.style} \u2014 ${args.styleGuide}
97
- TARGET LENGTH: ${minMin}-${maxMin} minutes (write to the topic's natural length \u2014 pad nothing, rush nothing).
115
+ TARGET LENGTH: ${minMin}-${maxMin} minutes (write to the topic's natural length \u2014 pad nothing, rush nothing).${guidanceBlock}
98
116
 
99
117
  \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\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
118
  ENGAGEMENT BAR (this overrides everything below \u2014 RULE -1)
@@ -501,6 +519,46 @@ Now write the script.`;
501
519
  // src/commands/dev.ts
502
520
  var exec = promisify(execCb);
503
521
  var PORT = 4444;
522
+ function runCliPipingStdin(cmd, args, stdinData, opts = {}) {
523
+ const maxBytes = (opts.maxBufferMB ?? 16) * 1024 * 1024;
524
+ return new Promise((resolve2, reject) => {
525
+ const proc = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
526
+ let stdout = "";
527
+ let stderr = "";
528
+ let total = 0;
529
+ const timer = opts.timeoutMs ? setTimeout(() => {
530
+ try {
531
+ proc.kill("SIGKILL");
532
+ } catch {
533
+ }
534
+ reject(new Error(`${cmd} timed out after ${opts.timeoutMs}ms`));
535
+ }, opts.timeoutMs) : null;
536
+ proc.stdout.on("data", (d) => {
537
+ total += d.length;
538
+ if (total > maxBytes) {
539
+ try {
540
+ proc.kill("SIGKILL");
541
+ } catch {
542
+ }
543
+ reject(new Error(`${cmd} stdout exceeded ${opts.maxBufferMB ?? 16}MB`));
544
+ return;
545
+ }
546
+ stdout += d.toString("utf-8");
547
+ });
548
+ proc.stderr.on("data", (d) => {
549
+ stderr += d.toString("utf-8");
550
+ });
551
+ proc.on("error", (err) => {
552
+ if (timer) clearTimeout(timer);
553
+ reject(err);
554
+ });
555
+ proc.on("close", (code) => {
556
+ if (timer) clearTimeout(timer);
557
+ resolve2({ stdout, stderr, code });
558
+ });
559
+ proc.stdin.end(stdinData);
560
+ });
561
+ }
504
562
  var WEB_URL = "https://forge.algo-thinker.com";
505
563
  function getApiConfig() {
506
564
  const creds = loadCredentials();
@@ -765,6 +823,14 @@ async function devCommand(options) {
765
823
  const url = new URL(req.url || "/", `http://localhost:${port}`);
766
824
  const pathname = url.pathname;
767
825
  if (pathname === "/api/health") {
826
+ const probeCli = (binary) => {
827
+ try {
828
+ const out = execSync(`which ${binary}`, { stdio: ["ignore", "pipe", "ignore"], timeout: 2e3 }).toString().trim();
829
+ return { available: !!out, path: out || null };
830
+ } catch {
831
+ return { available: false, path: null };
832
+ }
833
+ };
768
834
  res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
769
835
  res.end(JSON.stringify({
770
836
  status: "ok",
@@ -772,7 +838,14 @@ async function devCommand(options) {
772
838
  channelId: meta.channelId,
773
839
  title: meta.title,
774
840
  channelSlug: meta.channelSlug,
775
- dir
841
+ dir,
842
+ // CLI bridge surface — mirrors the production VPS bridge's /health
843
+ // shape so the web UI can use the same code path for both.
844
+ bridge: {
845
+ source: "local-forge-dev",
846
+ claude: probeCli("claude"),
847
+ codex: probeCli("codex")
848
+ }
776
849
  }));
777
850
  return;
778
851
  }
@@ -935,35 +1008,41 @@ async function devCommand(options) {
935
1008
  forbiddenVisuals: body.forbiddenVisuals,
936
1009
  referenceScript: body.referenceScript,
937
1010
  targetMinutesMin: body.targetMinutesMin,
938
- targetMinutesMax: body.targetMinutesMax
1011
+ targetMinutesMax: body.targetMinutesMax,
1012
+ additionalGuidance: body.additionalGuidance
939
1013
  });
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"];
1014
+ const cli = body.cli === "codex" ? "codex" : "claude";
1015
+ const candidates = cli === "codex" ? ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"] : ["claude-opus-4-7", "claude-sonnet-4-6"];
946
1016
  let raw = "";
947
1017
  let modelUsed = "";
948
1018
  let lastErr;
949
1019
  for (const model of candidates) {
1020
+ if (!/^[a-z0-9.\-]+$/i.test(model)) {
1021
+ lastErr = `model alias "${model}" rejected by validator`;
1022
+ continue;
1023
+ }
950
1024
  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
- );
1025
+ log.info(`[script-gen] Generating via ${cli} CLI \xB7 model=${model}`);
1026
+ const args = cli === "codex" ? ["exec", "-", "--model", model] : ["-p", "--model", model, "--no-session-persistence"];
1027
+ const { stdout, code, stderr } = await runCliPipingStdin(cli, args, prompt2, {
1028
+ timeoutMs: 3e5,
1029
+ maxBufferMB: 16
1030
+ });
1031
+ if (code !== 0) {
1032
+ lastErr = `${cli}/${model} exit ${code}: ${stderr.slice(-300)}`;
1033
+ log.warn(`[script-gen] ${lastErr.slice(0, 120)}`);
1034
+ continue;
1035
+ }
956
1036
  raw = (stdout ?? "").trim();
957
1037
  if (raw) {
958
- modelUsed = model;
1038
+ modelUsed = `${cli}:${model}`;
959
1039
  break;
960
1040
  }
961
1041
  } catch (err) {
962
1042
  lastErr = err instanceof Error ? err.message : String(err);
963
- log.warn(`[script-gen] ${model} failed: ${lastErr.slice(0, 120)}`);
1043
+ log.warn(`[script-gen] ${cli}/${model} failed: ${lastErr.slice(0, 120)}`);
964
1044
  }
965
1045
  }
966
- cleanup();
967
1046
  if (!raw) {
968
1047
  res.writeHead(502, { ...CORS_HEADERS, "Content-Type": "application/json" });
969
1048
  res.end(JSON.stringify({
@@ -1003,8 +1082,10 @@ async function devCommand(options) {
1003
1082
  res.end(JSON.stringify({
1004
1083
  chunks,
1005
1084
  model: modelUsed,
1006
- provider: "claude-cli",
1007
- total_sec: totalSec
1085
+ provider: cli === "codex" ? "codex-cli" : "claude-cli",
1086
+ cli,
1087
+ total_sec: totalSec,
1088
+ renderedPrompt: prompt2
1008
1089
  }));
1009
1090
  });
1010
1091
  req.on("error", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyforge",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "StoryForge — local bridge for the Forge video production web app. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "bin": {