storyforge 0.3.0 → 0.4.1

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 +263 -3
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -42,6 +42,142 @@ function loadCredentials() {
42
42
  }
43
43
  }
44
44
 
45
+ // src/utils/script-prompt.ts
46
+ var STYLE_GUIDES = {
47
+ "3blue1brown": 'Mathematical rigor, progressive intuition building, "but why?" framing. Build from first principles.',
48
+ coldfusion: `Story-driven narrative, industry context, "here's what happened" framing. Data points, company names, real-world impact.`,
49
+ educative: "System design interview structure. Functional vs non-functional requirements, architecture, deep dives.",
50
+ wendover: 'Real-world logistics framing. "The surprising reason" hooks. Ground abstract concepts in physical analogies.',
51
+ veritasium: "Counterintuitive hook or common misconception. Hypothesis, evidence, reveal. Challenge assumptions.",
52
+ custom: "Documentary-quality educational content. Clear structure, dense evidence, engaging narration."
53
+ };
54
+ function styleGuideFor(style) {
55
+ return STYLE_GUIDES[style] ?? STYLE_GUIDES.custom;
56
+ }
57
+ function buildScriptPrompt(args) {
58
+ const minMin = args.targetMinutesMin ?? 18;
59
+ const maxMin = args.targetMinutesMax ?? 25;
60
+ const tagline = (args.channelTagline ?? "Documentary-quality explainers").trim();
61
+ const handle = (args.channelHandle ?? "@channel").trim();
62
+ const channel = (args.channelName ?? "this channel").trim();
63
+ const banList = (args.forbiddenVisuals && args.forbiddenVisuals.length ? args.forbiddenVisuals : [
64
+ "imagine a world where",
65
+ "in today's fast-paced world",
66
+ "in the realm of",
67
+ "paradigm shift",
68
+ "unlock the power of",
69
+ "revolutionizing the way we",
70
+ "studies have shown",
71
+ "up to X percent",
72
+ "as we look to the future",
73
+ "the future is bright",
74
+ "sky's the limit",
75
+ "game-changing",
76
+ "cutting-edge",
77
+ "state-of-the-art",
78
+ "in conclusion",
79
+ "let's dive in",
80
+ "buckle up"
81
+ ]).map((s) => ` - "${s}"`).join("\n");
82
+ const referenceBlock = args.referenceScript ? `
83
+
84
+ === REFERENCE SCRIPT (match this density and craft) ===
85
+
86
+ ${args.referenceScript.slice(0, 12e3)}
87
+
88
+ === END REFERENCE ===
89
+
90
+ The script you write must match the reference's:
91
+ - Hook structure (pattern of historical examples \u2192 reversal \u2192 stakes \u2192 bet).
92
+ - Evidence density (named companies, specific numbers, dollar amounts, dates).
93
+ - Section pacing (varying section lengths from 2s teasers to 200s deep dives).
94
+ - Sentence rhythm (mix of long flowing sentences and 4-word punches).` : "";
95
+ return `You are a senior documentary scriptwriter for ${channel} (${handle}). The channel's promise: "${tagline}".
96
+
97
+ You write the kind of script that earns 25 minutes of someone's attention. The kind where every paragraph adds load-bearing information. The kind where one historical example replaces three paragraphs of generic exposition.
98
+
99
+ YOUR TASK
100
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
101
+ Write a narrated documentary-style script on this topic:
102
+
103
+ TOPIC: "${args.topic}"
104
+ STYLE: ${args.style} \u2014 ${args.styleGuide}
105
+
106
+ The video target length is ${minMin}-${maxMin} minutes. Don't pad. If the topic legitimately needs less, write less; if it needs more, write more. Anemic length comes from anemic thinking \u2014 go deeper, not shorter.
107
+
108
+ HOW THE HOOK WORKS (this is non-negotiable)
109
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
110
+ The first chunk is the hook. It is NOT "Imagine a world where..." or "In today's fast-paced..." or any LLM-template opener. The hook follows this structure:
111
+
112
+ 1. PATTERN (4-6 sentences): Open with 3-5 SPECIFIC historical examples that share a common pattern. Real companies. Real years. Real outcomes.
113
+ 2. REVERSAL: Name the surprising counter-pattern that connects to the topic.
114
+ 3. STAKES: One paragraph naming the specific cost / consequence / scale.
115
+ 4. BET: Frame the topic as a bet between two competing approaches with concrete numbers.
116
+ 5. PROMISE: One sentence promising what the viewer will see in the next ${minMin}+ minutes.
117
+
118
+ The hook is typically 90-150 seconds. Do NOT aim for a uniform short hook.
119
+
120
+ EVIDENCE RULES (every paragraph must satisfy at least one)
121
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
122
+ - Name a specific company / lab / paper / person.
123
+ - Include a specific number with units (dollars, percent, milliseconds, GB, parameters, miles, etc.).
124
+ - Cite a specific year or product version.
125
+ - Reference a specific event or product launch.
126
+
127
+ If you cannot satisfy any of these for a paragraph, REMOVE the paragraph. Do not write filler.
128
+
129
+ FORBIDDEN PHRASES (zero of them)
130
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
131
+ ${banList}
132
+
133
+ Plus: no rhetorical questions opening sections, no "let's explore", no "in this video we'll look at".
134
+
135
+ LENGTH + PACING
136
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
137
+ - 14 to 22 chunks. Vary durations:
138
+ * Hook: 90-150s
139
+ * Channel intro / cold-open: 2-5s
140
+ * Body sections: 100-220s each
141
+ * Conclusion: 90-180s
142
+ * CTA: 8-15s
143
+ * Next-video teaser: 20-40s
144
+ * End-screen: 8-15s
145
+ - Total duration: ${minMin}-${maxMin} minutes.
146
+
147
+ NARRATION VOICE
148
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
149
+ - Conversational but authoritative. Speak in declarative sentences.
150
+ - Mix sentence lengths: occasionally drop a 4-word sentence after a long one for rhythm.
151
+ - No exclamation marks.
152
+ - "And" / "But" / "Because" can start sentences.
153
+ - End each section with a sentence that creates forward pull.
154
+
155
+ STYLE: ${args.style.toUpperCase()}
156
+ ${args.styleGuide}
157
+ ${referenceBlock}
158
+
159
+ OUTPUT FORMAT
160
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
161
+ Return ONLY a valid JSON array, no markdown fences, no commentary. Each chunk:
162
+
163
+ {
164
+ "id": "c01-hook" or "c07-section-three-mechanism" (semantic, kebab-case),
165
+ "title": "Hook \u2014 <Title>" or "Section Three. <Phrase>." etc.
166
+ "narrationText": "Full narration body. Multiple paragraphs separated by blank lines.",
167
+ "durationEstimate": <integer seconds>
168
+ }
169
+
170
+ CRITICAL CHECKLIST:
171
+ \u25A1 Hook follows pattern \u2192 reversal \u2192 stakes \u2192 bet \u2192 promise.
172
+ \u25A1 Total duration is ${minMin * 60}-${maxMin * 60} seconds.
173
+ \u25A1 Every paragraph has a specific number, name, year, or product reference.
174
+ \u25A1 ZERO forbidden phrases.
175
+ \u25A1 Section durations vary (not uniform 45s blocks).
176
+ \u25A1 Conclusion connects back to the hook's pattern.
177
+
178
+ Now write the script.`;
179
+ }
180
+
45
181
  // src/commands/dev.ts
46
182
  var exec = promisify(execCb);
47
183
  var PORT = 4444;
@@ -451,6 +587,112 @@ async function devCommand(options) {
451
587
  });
452
588
  return;
453
589
  }
590
+ if (pathname === "/api/script-gen" && req.method === "POST") {
591
+ const bodyChunks = [];
592
+ req.on("data", (c2) => bodyChunks.push(c2));
593
+ req.on("end", async () => {
594
+ let body;
595
+ try {
596
+ body = JSON.parse(Buffer.concat(bodyChunks).toString("utf-8"));
597
+ } catch {
598
+ res.writeHead(400, { ...CORS_HEADERS, "Content-Type": "application/json" });
599
+ res.end(JSON.stringify({ error: "Invalid JSON body" }));
600
+ return;
601
+ }
602
+ if (!body.topic || typeof body.topic !== "string") {
603
+ res.writeHead(400, { ...CORS_HEADERS, "Content-Type": "application/json" });
604
+ res.end(JSON.stringify({ error: "topic is required" }));
605
+ return;
606
+ }
607
+ const style = body.style ?? "custom";
608
+ const prompt2 = buildScriptPrompt({
609
+ topic: body.topic,
610
+ style,
611
+ styleGuide: styleGuideFor(style),
612
+ channelName: body.channelName,
613
+ channelTagline: body.channelTagline,
614
+ channelHandle: body.channelHandle,
615
+ forbiddenVisuals: body.forbiddenVisuals,
616
+ referenceScript: body.referenceScript,
617
+ targetMinutesMin: body.targetMinutesMin,
618
+ targetMinutesMax: body.targetMinutesMax
619
+ });
620
+ const tmpFile = path2.join(os2.tmpdir(), `forge-script-${Date.now()}.txt`);
621
+ fs2.writeFileSync(tmpFile, prompt2, "utf-8");
622
+ const cleanup = () => {
623
+ if (fs2.existsSync(tmpFile)) fs2.unlinkSync(tmpFile);
624
+ };
625
+ const candidates = ["claude-opus-4-7", "claude-sonnet-4-6"];
626
+ let raw = "";
627
+ let modelUsed = "";
628
+ let lastErr;
629
+ for (const model of candidates) {
630
+ try {
631
+ log.info(`[script-gen] Generating via Claude CLI \xB7 model=${model}`);
632
+ const { stdout } = await exec(
633
+ `cat "${tmpFile}" | claude -p --model ${model} --no-session-persistence`,
634
+ { maxBuffer: 16 * 1024 * 1024, timeout: 3e5 }
635
+ );
636
+ raw = (stdout ?? "").trim();
637
+ if (raw) {
638
+ modelUsed = model;
639
+ break;
640
+ }
641
+ } catch (err) {
642
+ lastErr = err instanceof Error ? err.message : String(err);
643
+ log.warn(`[script-gen] ${model} failed: ${lastErr.slice(0, 120)}`);
644
+ }
645
+ }
646
+ cleanup();
647
+ if (!raw) {
648
+ res.writeHead(502, { ...CORS_HEADERS, "Content-Type": "application/json" });
649
+ res.end(JSON.stringify({
650
+ error: "No Claude CLI model returned output",
651
+ detail: lastErr,
652
+ hint: "Run `claude` once to verify your subscription, then retry."
653
+ }));
654
+ return;
655
+ }
656
+ const stripped = raw.replace(/^```(?:json)?\s*/m, "").replace(/```\s*$/m, "").trim();
657
+ const arr = stripped.match(/\[[\s\S]*\]/);
658
+ if (!arr) {
659
+ res.writeHead(502, { ...CORS_HEADERS, "Content-Type": "application/json" });
660
+ res.end(JSON.stringify({
661
+ error: "Claude returned non-JSON",
662
+ model: modelUsed,
663
+ preview: raw.slice(0, 400)
664
+ }));
665
+ return;
666
+ }
667
+ let chunks;
668
+ try {
669
+ chunks = JSON.parse(arr[0]);
670
+ } catch (e) {
671
+ res.writeHead(502, { ...CORS_HEADERS, "Content-Type": "application/json" });
672
+ res.end(JSON.stringify({
673
+ error: "Failed to parse script JSON",
674
+ model: modelUsed,
675
+ detail: e.message,
676
+ preview: arr[0].slice(0, 400)
677
+ }));
678
+ return;
679
+ }
680
+ const totalSec = chunks.reduce((s, c2) => s + (c2.durationEstimate ?? 0), 0);
681
+ log.success(`[script-gen] ${chunks.length} chunks \xB7 ~${(totalSec / 60).toFixed(1)} min via ${modelUsed}`);
682
+ res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
683
+ res.end(JSON.stringify({
684
+ chunks,
685
+ model: modelUsed,
686
+ provider: "claude-cli",
687
+ total_sec: totalSec
688
+ }));
689
+ });
690
+ req.on("error", () => {
691
+ res.writeHead(500, CORS_HEADERS);
692
+ res.end(JSON.stringify({ error: "Request error" }));
693
+ });
694
+ return;
695
+ }
454
696
  if (pathname === "/api/ai-edit" && req.method === "POST") {
455
697
  const bodyChunks = [];
456
698
  req.on("data", (c2) => bodyChunks.push(c2));
@@ -837,9 +1079,22 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
837
1079
  openBrowser(webUrl);
838
1080
  }
839
1081
  const aiMethods = [];
1082
+ let scriptGenModel = null;
840
1083
  try {
841
1084
  execSync("which claude", { stdio: "ignore" });
842
- aiMethods.push("Claude CLI \u2192 claude-sonnet-4-6");
1085
+ const candidates = ["claude-opus-4-7", "claude-sonnet-4-6"];
1086
+ for (const model of candidates) {
1087
+ try {
1088
+ execSync(
1089
+ `echo "ping" | claude -p --model ${model} --no-session-persistence`,
1090
+ { stdio: "ignore", timeout: 3e4 }
1091
+ );
1092
+ scriptGenModel = model;
1093
+ break;
1094
+ } catch {
1095
+ }
1096
+ }
1097
+ aiMethods.push(`Claude CLI \u2192 ${scriptGenModel ?? "claude-sonnet-4-6 (default)"}`);
843
1098
  } catch {
844
1099
  }
845
1100
  try {
@@ -847,8 +1102,8 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
847
1102
  aiMethods.push("Codex CLI \u2192 gpt-5.4");
848
1103
  } catch {
849
1104
  }
850
- if (process.env.ANTHROPIC_API_KEY) aiMethods.push("Anthropic API \u2192 claude-sonnet-4-6");
851
- if (process.env.OPENAI_API_KEY) aiMethods.push("OpenAI API \u2192 gpt-5.4");
1105
+ if (process.env.ANTHROPIC_API_KEY) aiMethods.push("Anthropic API \u2192 claude-sonnet-4-5");
1106
+ if (process.env.OPENAI_API_KEY) aiMethods.push("OpenAI API \u2192 gpt-4o");
852
1107
  console.log("");
853
1108
  if (aiMethods.length > 0) {
854
1109
  console.log(" AI edit chain (tried in order):");
@@ -859,6 +1114,11 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
859
1114
  console.log(" Install Codex CLI: npm install -g @openai/codex");
860
1115
  console.log(" Or set ANTHROPIC_API_KEY / OPENAI_API_KEY in your shell");
861
1116
  }
1117
+ if (scriptGenModel) {
1118
+ console.log("");
1119
+ console.log(` Script generation \u2192 ${scriptGenModel}`);
1120
+ console.log(` POST http://localhost:${port}/api/script-gen`);
1121
+ }
862
1122
  console.log("");
863
1123
  console.log(" Ctrl+C to stop");
864
1124
  console.log("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyforge",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "StoryForge — local bridge for the Forge video production web app. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "bin": {