storyforge 0.2.3 → 0.4.0

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 +293 -6
  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;
@@ -355,14 +491,26 @@ async function devCommand(options) {
355
491
  res.end(JSON.stringify([...saved, ...project]));
356
492
  return;
357
493
  }
358
- const compGetMatch = pathname.match(/^\/api\/compositions\/([^/]+)$/);
494
+ const compGetMatch = pathname.match(/^\/api\/compositions\/([^/]+?)(\.json)?$/);
359
495
  if (req.method === "GET" && compGetMatch) {
360
496
  const name = decodeURIComponent(compGetMatch[1]).replace(/[^a-zA-Z0-9_-]/g, "");
497
+ const wantJson = !!compGetMatch[2] || url.searchParams.get("format") === "json";
361
498
  if (!name) {
362
499
  res.writeHead(400, CORS_HEADERS);
363
500
  res.end("Invalid name");
364
501
  return;
365
502
  }
503
+ if (wantJson) {
504
+ const jsonPath = path2.join(dir, "compositions", `${name}.json`);
505
+ if (fs2.existsSync(jsonPath)) {
506
+ res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json; charset=utf-8" });
507
+ res.end(fs2.readFileSync(jsonPath, "utf-8"));
508
+ return;
509
+ }
510
+ res.writeHead(404, CORS_HEADERS);
511
+ res.end("Not found");
512
+ return;
513
+ }
366
514
  const compositionsDir = path2.join(os2.homedir(), ".forge", "compositions", meta.projectId);
367
515
  const savedPath = path2.join(compositionsDir, `${name}.tsx`);
368
516
  if (fs2.existsSync(savedPath)) {
@@ -381,24 +529,57 @@ async function devCommand(options) {
381
529
  res.end("Not found");
382
530
  return;
383
531
  }
384
- const compPostMatch = pathname.match(/^\/api\/compositions\/([^/]+)$/);
532
+ const compPostMatch = pathname.match(/^\/api\/compositions\/([^/]+?)(\.json)?$/);
385
533
  if (req.method === "POST" && compPostMatch) {
386
534
  const name = compPostMatch[1].replace(/[^a-zA-Z0-9_-]/g, "");
535
+ const pathForcesJson = !!compPostMatch[2];
387
536
  if (!name) {
388
537
  res.writeHead(400, CORS_HEADERS);
389
538
  res.end("Invalid name");
390
539
  return;
391
540
  }
392
- const compositionsDir = path2.join(os2.homedir(), ".forge", "compositions", meta.projectId);
393
- fs2.mkdirSync(compositionsDir, { recursive: true });
394
- const filePath = path2.join(compositionsDir, `${name}.tsx`);
541
+ const reqContentType = (req.headers["content-type"] ?? "").toLowerCase();
542
+ const isJsonBody = pathForcesJson || reqContentType.includes("application/json");
395
543
  const chunks = [];
396
544
  req.on("data", (chunk) => chunks.push(chunk));
397
545
  req.on("end", () => {
398
546
  const body = Buffer.concat(chunks).toString("utf-8");
547
+ if (isJsonBody) {
548
+ let parsed;
549
+ try {
550
+ parsed = JSON.parse(body);
551
+ } catch (e) {
552
+ res.writeHead(400, { ...CORS_HEADERS, "Content-Type": "application/json" });
553
+ res.end(JSON.stringify({ error: `Invalid JSON: ${e.message}` }));
554
+ return;
555
+ }
556
+ const valid = (() => {
557
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
558
+ const isCompositionShape = typeof parsed.kind === "string" && typeof parsed.aspect === "string" && Array.isArray(parsed.layers);
559
+ const isChunkShape = Array.isArray(parsed.compositions);
560
+ return isCompositionShape || isChunkShape;
561
+ })();
562
+ if (!valid) {
563
+ res.writeHead(400, { ...CORS_HEADERS, "Content-Type": "application/json" });
564
+ res.end(JSON.stringify({
565
+ error: "Unrecognized composition shape \u2014 expected { kind, aspect, layers[] } or { compositions[] }"
566
+ }));
567
+ return;
568
+ }
569
+ const jsonDir = path2.join(dir, "compositions");
570
+ fs2.mkdirSync(jsonDir, { recursive: true });
571
+ const filePath2 = path2.join(jsonDir, `${name}.json`);
572
+ fs2.writeFileSync(filePath2, JSON.stringify(parsed, null, 2), "utf-8");
573
+ res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
574
+ res.end(JSON.stringify({ ok: true, path: filePath2, kind: "json" }));
575
+ return;
576
+ }
577
+ const compositionsDir = path2.join(os2.homedir(), ".forge", "compositions", meta.projectId);
578
+ fs2.mkdirSync(compositionsDir, { recursive: true });
579
+ const filePath = path2.join(compositionsDir, `${name}.tsx`);
399
580
  fs2.writeFileSync(filePath, body, "utf-8");
400
581
  res.writeHead(200, { ...CORS_HEADERS, "Content-Type": "application/json" });
401
- res.end(JSON.stringify({ ok: true, path: filePath }));
582
+ res.end(JSON.stringify({ ok: true, path: filePath, kind: "tsx" }));
402
583
  });
403
584
  req.on("error", () => {
404
585
  res.writeHead(500, CORS_HEADERS);
@@ -406,6 +587,112 @@ async function devCommand(options) {
406
587
  });
407
588
  return;
408
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
+ }
409
696
  if (pathname === "/api/ai-edit" && req.method === "POST") {
410
697
  const bodyChunks = [];
411
698
  req.on("data", (c2) => bodyChunks.push(c2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyforge",
3
- "version": "0.2.3",
3
+ "version": "0.4.0",
4
4
  "description": "StoryForge — local bridge for the Forge video production web app. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "bin": {