osborn 0.9.251 → 0.9.252

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,321 @@
1
+ /**
2
+ * content-run.ts — Stage A orchestrator ("content plan"): step 3 of the
3
+ * compaction-lens worker, after the library step, and the --content CLI entry.
4
+ *
5
+ * period page (library) → plan pieces → research per topic (demand + §8a
6
+ * problems, cited, 7-day cache) → briefs (viewer, tier, format, stake) →
7
+ * scripts (≤1 highlight + ≤3 how-tos) → checks (length, dev-voice pre-pass,
8
+ * structure, pinned truth-check, audience) → ingest clean drafts (text_post).
9
+ *
10
+ * NEVER throws. Idempotent per period via .content-manifest.json (library dir):
11
+ * a done period costs nothing; settled pieces are never regenerated; a blocked
12
+ * piece is never ingested and never auto-fixed. Caps ⇒ status "capped".
13
+ * Flags: OSBORN_CONTENT_PIPELINE (default ON), OSBORN_CONTENT_LENS, OSBORN_CONTENT_INGEST,
14
+ * OSBORN_CONTENT_STRICT=1 (quality gates block again instead of flagging the draft).
15
+ * --dry: everything except the ingest (it still spends; the caps apply).
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
18
+ import { basename, dirname, join } from 'node:path';
19
+ import Database from 'better-sqlite3';
20
+ import { loadConversationRows, recordView, resolveSessionDb } from './lens-db.js';
21
+ import { readCompactionBoundaries } from './lens-period-boundaries.js';
22
+ import { pageFileName, segmentPeriods } from './lens-library-select.js';
23
+ import { libraryDirFor, readManifest } from './lens-library.js';
24
+ import { CapError, contentCaps, daySpent, genModel, isContentPipelineEnabled, isContentStrict, SpendGuard } from './content-llm.js';
25
+ import { makeScrubber } from './content-redact.js';
26
+ import { CONTENT_DIR, isPieceSettled, periodDirName, periodStatusFor, readContentManifest, writeAtomic, writeContentManifest, } from './content-manifest.js';
27
+ import { buildBriefs, loadOwner, planPieces } from './content-brief.js';
28
+ import { researchCitations, researchDigest, researchTopic } from './content-research.js';
29
+ import { writeScript } from './content-script.js';
30
+ import { runChecks } from './content-checks.js';
31
+ import { ingestScript, isIngestConfigured, pieceContentHash, scriptMarkdown } from './content-ingest.js';
32
+ import { needsIngest } from './lens-ingest.js';
33
+ import { privateSegments } from './content-query-gate.js';
34
+ export { isContentPipelineEnabled } from './content-llm.js';
35
+ /** Stop starting new calls after ~25 minutes; what is left resumes on the next run. */
36
+ export const CONTENT_BUDGET_MS = 25 * 60_000;
37
+ const PAGE_RANGE_RE = /Rows in block: #(\d+)[–-]#(\d+)/;
38
+ /**
39
+ * Pick the period: the hand-off when given, else the newest of the last three
40
+ * periods that has a library page on disk and is not done here.
41
+ */
42
+ export function pickContentPeriod(db, sessionId, dir, m, handoff) {
43
+ const isDone = (start) => ['done', 'no-pieces'].includes(m.periods[String(start)]?.status ?? '');
44
+ const hasPage = (p) => existsSync(join(dir, pageFileName(p)));
45
+ if (handoff) {
46
+ if (!hasPage(handoff))
47
+ return { period: null, reason: 'no-page' };
48
+ return isDone(handoff.startRowId) ? { period: null, reason: 'already-done' } : { period: handoff, page: pageFileName(handoff) };
49
+ }
50
+ const rows = loadConversationRows(db, 0).records.map(r => ({ id: r.id, timestamp: r.timestamp }));
51
+ const seg = segmentPeriods(rows, readCompactionBoundaries(db));
52
+ const lib = readManifest(dir, sessionId);
53
+ const recent = [...(seg.tail ? [seg.tail] : []), ...[...seg.closed].reverse()].slice(0, 3);
54
+ let done = false;
55
+ for (const p of recent) {
56
+ const le = lib.periods[String(p.startRowId)];
57
+ if ((le && le.status !== 'ok') || !hasPage(p))
58
+ continue;
59
+ if (!isDone(p.startRowId))
60
+ return { period: p, page: pageFileName(p) };
61
+ done = true;
62
+ }
63
+ return { period: null, reason: done ? 'already-done' : 'no-page' };
64
+ }
65
+ /** The row range the page itself states (manual backfill pages can run past the segment end). */
66
+ export function pageRange(page, p) {
67
+ const m = page.match(PAGE_RANGE_RE);
68
+ const a = m ? Number(m[1]) : NaN;
69
+ const b = m ? Number(m[2]) : NaN;
70
+ return { from: Number.isFinite(a) ? Math.min(a, p.startRowId) : p.startRowId, to: Number.isFinite(b) ? Math.max(b, p.endRowId) : p.endRowId };
71
+ }
72
+ /** The session's working directories (session.db cwd column) — for the outbound-query gate. Best-effort. */
73
+ function sessionCwds(db) {
74
+ try {
75
+ const d = new Database(db, { readonly: true, fileMustExist: true });
76
+ try {
77
+ return d.prepare('SELECT DISTINCT cwd FROM content WHERE cwd IS NOT NULL AND cwd != \'\' LIMIT 20').all().map(r => String(r.cwd));
78
+ }
79
+ finally {
80
+ d.close();
81
+ }
82
+ }
83
+ catch {
84
+ return [];
85
+ }
86
+ }
87
+ /** Truth-check ground truth: the page, the rows the script draws on, and the research it speaks to. */
88
+ export function truthSources(page, records, brief, script, researchText) {
89
+ const cited = new Set(script.lines.map(l => l.row).filter((r) => !!r));
90
+ const inRange = records.filter(r => cited.has(r.id) || (r.id >= brief.fromRow && r.id <= brief.toRow));
91
+ let used = 0;
92
+ const rows = [];
93
+ for (const r of inRange) {
94
+ const v = recordView(r);
95
+ if (used + v.length > 30_000 && !cited.has(r.id))
96
+ continue;
97
+ used += v.length;
98
+ rows.push(v);
99
+ }
100
+ return [`LIBRARY PAGE (period map of the session):\n${page.slice(0, 40_000)}`, `SESSION ROWS:\n${rows.join('\n\n')}`, researchText ? `RESEARCH (public threads):\n${researchText}` : ''].filter(Boolean).join('\n\n');
101
+ }
102
+ function scrubScript(s, sc) {
103
+ return { ...s, title: sc.scrub(s.title), hook: sc.scrub(s.hook), lines: s.lines.map(l => ({ ...l, text: sc.scrub(l.text) })), causes: s.causes.map(sc.scrub), terms: s.terms.map(sc.scrub) };
104
+ }
105
+ const report = (p, d) => ({
106
+ id: p.id, tier: p.kind, status: p.status, title: p.title, flags: p.flags,
107
+ seconds: d?.script.estSeconds ?? null, citations: d?.citations.length ?? 0, file: p.file ?? null,
108
+ });
109
+ function readDraft(dir, p) {
110
+ if (!p.file)
111
+ return null;
112
+ try {
113
+ return JSON.parse(readFileSync(join(dir, p.file), 'utf-8')).draft ?? null;
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ }
119
+ /** Persist one piece's draft (JSON for retries + markdown for review). Returns the path relative to the library dir. */
120
+ function saveDraft(dir, d, piece) {
121
+ const rel = join(CONTENT_DIR, periodDirName(d.period.index), `${piece.id}.json`);
122
+ mkdirSync(dirname(join(dir, rel)), { recursive: true });
123
+ writeAtomic(join(dir, rel), JSON.stringify({ status: piece.status, flags: piece.flags, draft: d }, null, 2) + '\n');
124
+ const flags = piece.flags.length ? `\n\n## BLOCKED — ${piece.flags.length} flag(s), not ingested\n${piece.flags.map(f => `- ${f}`).join('\n')}\n` : '\n';
125
+ writeAtomic(join(dir, rel.replace(/\.json$/, '.md')), scriptMarkdown(d) + flags);
126
+ return rel;
127
+ }
128
+ /** Free retry pass: "ready" pieces whose ingest never landed. No model call. Returns true if anything changed. */
129
+ async function retryIngests(dir, m, sc, log, skipKey) {
130
+ if (!isIngestConfigured())
131
+ return false;
132
+ let changed = false;
133
+ for (const [key, e] of Object.entries(m.periods)) {
134
+ if (key === skipKey)
135
+ continue;
136
+ for (const p of Object.values(e.pieces)) {
137
+ if (p.status !== 'ready' || !p.contentHash || !needsIngest(p.ingest, p.contentHash))
138
+ continue;
139
+ const d = readDraft(dir, p);
140
+ if (!d)
141
+ continue;
142
+ p.ingest = await ingestScript(d, sc.scrubDeep, { log });
143
+ if (p.ingest.status === 'ok')
144
+ p.status = 'ingested';
145
+ changed = true;
146
+ }
147
+ if (changed && e.status === 'partial' && !Object.values(e.pieces).some(p => p.status !== 'ingested' && p.status !== 'blocked'))
148
+ e.status = 'done';
149
+ }
150
+ return changed;
151
+ }
152
+ /** Run Stage A once for one period. Never throws. */
153
+ export async function runContentStep(o) {
154
+ const log = o.log ?? (() => { });
155
+ const deadline = Date.now() + (o.budgetMs ?? CONTENT_BUDGET_MS);
156
+ const res = { status: 'failed', libraryDir: null, period: null, pieces: [], costUsd: 0, capHit: null, errors: [] };
157
+ const timer = o.keepAlive ? setInterval(o.keepAlive, 60_000) : null;
158
+ timer?.unref();
159
+ let guard = null;
160
+ try {
161
+ if (!isContentPipelineEnabled())
162
+ return { ...res, status: 'disabled' };
163
+ const apiKey = (process.env.OPENROUTER_API_KEY || '').trim();
164
+ if (!apiKey)
165
+ return { ...res, status: 'no-key' };
166
+ const db = resolveSessionDb(o.sessionId, o.projectDir, o.cwd);
167
+ if (!db)
168
+ return { ...res, status: 'no-db' };
169
+ const projectDir = dirname(dirname(dirname(db)));
170
+ const dir = (res.libraryDir = libraryDirFor(db, o.sessionId));
171
+ const sc = makeScrubber(projectDir);
172
+ const manifest = readContentManifest(dir, o.sessionId);
173
+ const pick = pickContentPeriod(db, o.sessionId, dir, manifest, o.period);
174
+ if ('reason' in pick) {
175
+ if (!o.dry && (await retryIngests(dir, manifest, sc, log)))
176
+ writeContentManifest(dir, manifest);
177
+ log(`content: nothing to do (${pick.reason})`);
178
+ return { ...res, status: pick.reason };
179
+ }
180
+ const p = pick.period;
181
+ const key = String(p.startRowId);
182
+ const pageText = sc.scrub(readFileSync(join(dir, pick.page), 'utf-8'));
183
+ const range = pageRange(pageText, p);
184
+ const period = (res.period = { index: p.index, startRowId: p.startRowId, endRowId: range.to, page: pick.page });
185
+ const entry = manifest.periods[key] ?? {
186
+ startRowId: p.startRowId, endRowId: range.to, index: p.index, page: pick.page, status: 'error', costUsd: 0, pieces: {}, errors: [], updatedAt: '',
187
+ };
188
+ entry.errors = [];
189
+ manifest.periods[key] = entry;
190
+ const caps = contentCaps();
191
+ const g = (guard = new SpendGuard(projectDir, caps, entry.costUsd, deadline));
192
+ const llm = { apiKey, guard: g, scrub: sc.scrub, log };
193
+ const save = () => {
194
+ entry.costUsd = Number(g.periodUsd.toFixed(6));
195
+ entry.updatedAt = new Date().toISOString();
196
+ writeContentManifest(dir, manifest);
197
+ };
198
+ log(`content: period ${p.index} rows #${range.from}–#${range.to} (${pick.page}); spent so far $${entry.costUsd.toFixed(4)} period / $${daySpent().toFixed(4)} today (machine)`);
199
+ const records = loadConversationRows(db, range.from - 1, sc.scrub, range.to).records;
200
+ // 1. Plan → research → briefs, once per period.
201
+ let plan = entry.plan;
202
+ if (!plan?.briefs) {
203
+ try {
204
+ const { candidates } = await planPieces(pageText, range, caps.maxPieces, llm);
205
+ const research = new Map();
206
+ // Project basename, slug and every cwd path segment: queries naming any of them never leave the machine.
207
+ const privateTerms = privateSegments([o.cwd, process.env.OSBORN_CWD, basename(projectDir), ...sessionCwds(db)]);
208
+ for (const c of candidates) {
209
+ o.keepAlive?.();
210
+ research.set(c.id, await researchTopic({ subtopic: c.subtopic, queries: c.queries }, { projectDir, llm, refresh: o.researchRefresh, privateTerms }));
211
+ }
212
+ const { briefs } = await buildBriefs(candidates, research, loadOwner(projectDir), llm);
213
+ plan = { candidates, briefs, research: Object.fromEntries([...research].map(([id, r]) => [id, { key: r.key, digest: researchDigest(r), citations: researchCitations(r) }])) };
214
+ entry.plan = plan;
215
+ }
216
+ catch (e) {
217
+ if (e instanceof CapError)
218
+ res.capHit = e.cap;
219
+ else
220
+ res.errors.push(`plan: ${e?.message ?? e}`);
221
+ entry.status = e instanceof CapError ? 'capped' : 'error';
222
+ entry.errors = [...res.errors];
223
+ save();
224
+ log(`content: planning stopped (${entry.status}) ${res.errors.join('; ')}`);
225
+ return { ...res, status: entry.status, costUsd: g.runUsd };
226
+ }
227
+ }
228
+ // 2. Script + checks per piece (settled pieces are never regenerated).
229
+ const drafts = new Map();
230
+ let capped = false;
231
+ for (const b of plan.briefs.slice(0, caps.maxPieces)) {
232
+ const prev = entry.pieces[b.id];
233
+ if (isPieceSettled(prev))
234
+ continue;
235
+ const piece = {
236
+ id: b.id, kind: b.tier, status: 'error', title: prev?.title || b.subtopic, flags: [], warnings: [], costUsd: prev?.costUsd ?? 0, errors: [],
237
+ updatedAt: new Date().toISOString(), contentHash: pieceContentHash(o.sessionId, p.startRowId, b.id),
238
+ };
239
+ entry.pieces[b.id] = piece;
240
+ if (capped) {
241
+ piece.status = 'capped';
242
+ continue;
243
+ }
244
+ if (!b.stake && isContentStrict()) {
245
+ // Strict: WHAT-WORKS rule 1, no provable before/after ⇒ not scripted (no spend). Default: scripted, flagged "no visible proof".
246
+ piece.status = 'blocked';
247
+ piece.flags = ['brief has no before -> after stake quantity (rule 1: stake and proof both visible)'];
248
+ continue;
249
+ }
250
+ const r = plan.research[b.id];
251
+ const before = g.runUsd;
252
+ try {
253
+ o.keepAlive?.();
254
+ const { script: raw } = await writeScript(b, pageText, records, r?.digest ?? '', llm);
255
+ const script = scrubScript(raw, sc);
256
+ piece.title = script.title || piece.title;
257
+ const sources = truthSources(pageText, records, b, script, r?.digest ?? '');
258
+ const { summary, flags, warnings } = await runChecks(script, b, { projectDir, llm, records, sources });
259
+ piece.checks = summary;
260
+ piece.flags = flags;
261
+ piece.warnings = warnings;
262
+ piece.status = flags.length ? 'blocked' : 'ready';
263
+ const draft = { sessionId: o.sessionId, projectDir, period, brief: b, script, checks: summary, qualityFlags: warnings, citations: r?.citations ?? [], model: genModel() };
264
+ piece.file = saveDraft(dir, draft, piece);
265
+ drafts.set(b.id, draft);
266
+ log(`content: ${b.id} → ${piece.status}${flags.length ? ` (${flags.length} flag(s))` : ''}${warnings.length ? ` (${warnings.length} quality warning(s))` : ''} ~${script.estSeconds}s`);
267
+ }
268
+ catch (e) {
269
+ if (e instanceof CapError) {
270
+ capped = true;
271
+ res.capHit = e.cap;
272
+ piece.status = 'capped';
273
+ }
274
+ else {
275
+ piece.errors = [String(e?.message ?? e)];
276
+ res.errors.push(`${b.id}: ${e?.message ?? e}`);
277
+ }
278
+ log(`content: ${b.id} → ${piece.status}${piece.errors.length ? ` ${piece.errors[0]}` : ''}`);
279
+ }
280
+ finally {
281
+ piece.costUsd = Number((piece.costUsd + g.runUsd - before).toFixed(6));
282
+ piece.updatedAt = new Date().toISOString();
283
+ }
284
+ save();
285
+ }
286
+ // 3. Ingest clean pieces (never blocked ones). Dry: no ingest.
287
+ let ingestPending = false;
288
+ if (!o.dry) {
289
+ for (const pe of Object.values(entry.pieces)) {
290
+ if (pe.status !== 'ready' || (pe.ingest && pe.contentHash && !needsIngest(pe.ingest, pe.contentHash)))
291
+ continue;
292
+ const d = drafts.get(pe.id) ?? readDraft(dir, pe);
293
+ if (!d)
294
+ continue;
295
+ pe.ingest = await ingestScript(d, sc.scrubDeep, { log });
296
+ if (pe.ingest.status === 'ok')
297
+ pe.status = 'ingested';
298
+ else if (pe.ingest.status === 'error')
299
+ ingestPending = true;
300
+ }
301
+ await retryIngests(dir, manifest, sc, log, key);
302
+ }
303
+ entry.status = periodStatusFor(entry, { dry: !!o.dry, planned: true, ingestPending });
304
+ entry.errors = [...res.errors];
305
+ save();
306
+ res.status = entry.status;
307
+ res.pieces = Object.values(entry.pieces).map(pe => report(pe, drafts.get(pe.id) ?? readDraft(dir, pe)));
308
+ res.costUsd = g.runUsd;
309
+ log(`content: period ${p.index} → ${res.status} pieces=${res.pieces.map(x => `${x.id}:${x.status}`).join(',') || 'none'} $${g.runUsd.toFixed(4)}`);
310
+ return res;
311
+ }
312
+ catch (e) {
313
+ res.errors.push(String(e?.message ?? e));
314
+ log(`content: failed (fail-open): ${e?.message ?? e}`);
315
+ return { ...res, status: 'failed', costUsd: guard?.runUsd ?? 0 };
316
+ }
317
+ finally {
318
+ if (timer)
319
+ clearInterval(timer);
320
+ }
321
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * content-script-rules.ts — the WRITING rules, format catalog and per-tier
3
+ * templates for Stage A. Pure data, no pipeline logic.
4
+ *
5
+ * This module is the drop-in point for prompt improvements: the separate
6
+ * "what makes a video postable" analysis replaces/extends SCRIPT_RULES and the
7
+ * TIER_TEMPLATES beats here without touching content-brief / content-script /
8
+ * content-run. Source of truth for everything below:
9
+ * SYSTEM-content-rules.md (§2 catalog, §3 tiers, §4 audience, §5 story,
10
+ * §6 ownership, §7 grounding, §8a problem research) and
11
+ * SYSTEM-format-playbook-oct06.md (templates, ~2.5 words/s, audience anchor).
12
+ */
13
+ export type Tier = 'highlight' | 'howto';
14
+ /** Spoken pace used for every length estimate (playbook: about 2.5 words/s). */
15
+ export declare const WORDS_PER_SECOND = 2.5;
16
+ export interface TierSpec {
17
+ tier: Tier;
18
+ label: string;
19
+ /** Hard bounds on the estimated spoken length (seconds). */
20
+ minS: number;
21
+ maxS: number;
22
+ targetS: number;
23
+ /** Spoken-word target range from WHAT-WORKS (prompt guidance; the hard gate is minS/maxS). */
24
+ words: {
25
+ min: number;
26
+ max: number;
27
+ };
28
+ /** Dev-voice lines allowed (verbatim user rows only). */
29
+ devLines: {
30
+ min: number;
31
+ max: number;
32
+ };
33
+ /** The question the piece answers. */
34
+ promise: string;
35
+ beats: string[];
36
+ /** The budget line from WHAT-WORKS for this tier. */
37
+ budget: string;
38
+ }
39
+ /**
40
+ * Templates from WHAT-WORKS-video-writing-rules.md (round 2, gate PASS).
41
+ * Length note: the how-to's 400-440 words is ~160-176s at 2.5 words/s (renders run
42
+ * TTS at ~1.1x tempo, i.e. ~150s on screen), so its spoken bound is 140-180s.
43
+ */
44
+ export declare const TIER_TEMPLATES: Record<Tier, TierSpec>;
45
+ export interface ScriptLimits {
46
+ /** Minimum seconds of runtime per cause (WHAT-WORKS: ~40s). */
47
+ secondsPerCause: number;
48
+ maxCauses: Record<Tier, number>;
49
+ maxTermsPerMin: Record<Tier, number>;
50
+ }
51
+ /**
52
+ * Deterministic gate thresholds (STARTING VALUES from 12 videos + hand counts).
53
+ * Env overrides: OSBORN_CONTENT_SECONDS_PER_CAUSE, OSBORN_CONTENT_MAX_CAUSES_HIGHLIGHT,
54
+ * OSBORN_CONTENT_MAX_CAUSES_HOWTO, OSBORN_CONTENT_TERMS_PER_MIN_HIGHLIGHT, OSBORN_CONTENT_TERMS_PER_MIN_HOWTO.
55
+ */
56
+ export declare function scriptLimits(): ScriptLimits;
57
+ export interface FormatEntry {
58
+ id: string;
59
+ name: string;
60
+ /** What the format needs from the inputs. */
61
+ needs: string;
62
+ beats: string;
63
+ whenToUse: string;
64
+ evidence: string;
65
+ tiers: Tier[];
66
+ }
67
+ /** Rules §2: every entry is first-class; none is the default. */
68
+ export declare const FORMAT_CATALOG: FormatEntry[];
69
+ /** Writing rules shared by every script prompt (rules §4, §5, §7). */
70
+ export declare const SCRIPT_RULES: string[];
71
+ export declare const BRIEF_RULES: string[];
72
+ /** BRIEF_RULES for the current mode: under OSBORN_CONTENT_STRICT a missing stake blocks scripting, so say so. */
73
+ export declare function briefRules(): string[];
74
+ export declare const AUDIENCE_RULES: string;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * content-script-rules.ts — the WRITING rules, format catalog and per-tier
3
+ * templates for Stage A. Pure data, no pipeline logic.
4
+ *
5
+ * This module is the drop-in point for prompt improvements: the separate
6
+ * "what makes a video postable" analysis replaces/extends SCRIPT_RULES and the
7
+ * TIER_TEMPLATES beats here without touching content-brief / content-script /
8
+ * content-run. Source of truth for everything below:
9
+ * SYSTEM-content-rules.md (§2 catalog, §3 tiers, §4 audience, §5 story,
10
+ * §6 ownership, §7 grounding, §8a problem research) and
11
+ * SYSTEM-format-playbook-oct06.md (templates, ~2.5 words/s, audience anchor).
12
+ */
13
+ import { isContentStrict } from './content-llm.js';
14
+ /** Spoken pace used for every length estimate (playbook: about 2.5 words/s). */
15
+ export const WORDS_PER_SECOND = 2.5;
16
+ /**
17
+ * Templates from WHAT-WORKS-video-writing-rules.md (round 2, gate PASS).
18
+ * Length note: the how-to's 400-440 words is ~160-176s at 2.5 words/s (renders run
19
+ * TTS at ~1.1x tempo, i.e. ~150s on screen), so its spoken bound is 140-180s.
20
+ */
21
+ export const TIER_TEMPLATES = {
22
+ highlight: {
23
+ tier: 'highlight',
24
+ label: '61-90s highlight (narrated timeline)',
25
+ minS: 61,
26
+ maxS: 90,
27
+ targetS: 74,
28
+ words: { min: 155, max: 200 },
29
+ devLines: { min: 0, max: 2 },
30
+ promise: '"Is this possible? Here is how it went." A narrated TIMELINE of what happened, in order. NOT a chain of quotes.',
31
+ beats: [
32
+ 'Line 1: the claim / paradox = the lesson, in plain words (not a bare number or status)',
33
+ 'Line 2: the stake as a visible quantity or verdict (the BEFORE)',
34
+ 'Line 3: setup, at most 1 new term, defined in the same line',
35
+ 'Lines 4-5: the test, and the "before" result',
36
+ 'Line 6: the cause, in one plain sentence',
37
+ 'Lines 7-8: the fix, and the "after" with the SAME quantity as line 2',
38
+ 'Line 9 (optional): a 2nd instance of the same cause, in line 6\'s words',
39
+ 'Line 10: the rule the viewer can take away',
40
+ 'Line 11: sign-off with the developer\'s name (when known)',
41
+ ],
42
+ budget: 'About 13 lines, 155-200 words. At most 2 causes, at most 3.5 load-bearing terms per minute (about 5 at 90s), no spoken versions or error codes, no standalone hedge.',
43
+ },
44
+ howto: {
45
+ tier: 'howto',
46
+ label: '~150s how-to ("if you\'re in this situation...")',
47
+ minS: 140,
48
+ maxS: 180,
49
+ targetS: 168,
50
+ words: { min: 400, max: 440 },
51
+ devLines: { min: 1, max: 6 },
52
+ promise: '"If you\'re in this situation, here\'s what we said, what happened, and how to do it." ONE specific, useful accomplishment, step by step.',
53
+ beats: [
54
+ 'Line 1: "If you [situation], here\'s the trap: [claim]. Here\'s how we fixed ours."',
55
+ 'Lines 2-3: the situation and the stake quantity (the BEFORE), in terms of the common problems from the research',
56
+ 'Line 4: what we tried first, and its visible failure',
57
+ 'Line 5: a human reaction or quote (verbatim dev line) that sets up the next beat',
58
+ 'Line 6: the correction or surprise, restating the lesson',
59
+ 'Line 7: the mechanism in firing order (at most 8 terms, each defined)',
60
+ 'Line 8: the proof, the AFTER with the same quantity as line 2',
61
+ 'Line 9 (optional): further instances, each entered by a trigger line (a quote, "you were right", a look at the log)',
62
+ 'Line 10: "To do it yourself: One, Two, Three", each step mapping to a cause shown',
63
+ 'Line 11: sign-off with the developer\'s name (when known)',
64
+ ],
65
+ budget: 'About 18 lines, 400-440 words. At most 3 causes, at most 3 load-bearing terms per minute, at most one optional extra instance.',
66
+ },
67
+ };
68
+ /**
69
+ * Deterministic gate thresholds (STARTING VALUES from 12 videos + hand counts).
70
+ * Env overrides: OSBORN_CONTENT_SECONDS_PER_CAUSE, OSBORN_CONTENT_MAX_CAUSES_HIGHLIGHT,
71
+ * OSBORN_CONTENT_MAX_CAUSES_HOWTO, OSBORN_CONTENT_TERMS_PER_MIN_HIGHLIGHT, OSBORN_CONTENT_TERMS_PER_MIN_HOWTO.
72
+ */
73
+ export function scriptLimits() {
74
+ const n = (k, d) => {
75
+ const v = Number((process.env[k] ?? '').trim());
76
+ return (process.env[k] ?? '').trim() !== '' && Number.isFinite(v) && v > 0 ? v : d;
77
+ };
78
+ return {
79
+ secondsPerCause: n('OSBORN_CONTENT_SECONDS_PER_CAUSE', 40),
80
+ maxCauses: { highlight: n('OSBORN_CONTENT_MAX_CAUSES_HIGHLIGHT', 2), howto: n('OSBORN_CONTENT_MAX_CAUSES_HOWTO', 3) },
81
+ maxTermsPerMin: { highlight: n('OSBORN_CONTENT_TERMS_PER_MIN_HIGHLIGHT', 3.5), howto: n('OSBORN_CONTENT_TERMS_PER_MIN_HOWTO', 3) },
82
+ };
83
+ }
84
+ /** Rules §2: every entry is first-class; none is the default. */
85
+ export const FORMAT_CATALOG = [
86
+ { id: 'narrated-timeline', name: 'Narrated timeline trailer', needs: 'one narrator voice; a clean ordered arc', beats: 'situation > goal > attempt > turn > result > lesson', whenToUse: 'a session arc with a clear before/after', evidence: 'v1 cheap-models narration (videos-test-1), user-approved style', tiers: ['highlight'] },
87
+ { id: 'two-voice-hud', name: 'Two-voice replay with a state HUD', needs: 'a single numeric/state quantity that changes; dev + agent lines', beats: 'cold open (explained) > stakes > attempt > break + clue > fix > proof > takeaway', whenToUse: 'stories with 3+ causal steps around one quantity', evidence: 'F2 memory seam (videos-test-2): HUD made cause->effect followable', tiers: ['highlight', 'howto'] },
88
+ { id: 'chat-log-replay', name: 'Chat/log replay with a narrator', needs: 'pivotal messages; narrator bridges', beats: 'pivotal message > rewind > context > escalating causes (each defined) > blameless close', whenToUse: 'disaster or surprise stories', evidence: 'Fang GitLab postmortem 3.61M views; F3 fleet sweep (videos-test-2)', tiers: ['highlight', 'howto'] },
89
+ { id: 'explainer-build', name: 'Explainer screens built up progressively', needs: 'a concept the viewer lacks', beats: 'each sentence ADDS one visual, never replaces', whenToUse: 'the viewer needs a mental model first', evidence: 'Azure tokens 114k on 12.8k subs', tiers: ['highlight', 'howto'] },
90
+ { id: 'test-scoreboard', name: 'Test and scoreboard', needs: 'a binary or numeric result', beats: 'question hook > stakes > attempt > test > scoreboard + proof > takeaway', whenToUse: 'a comparison with a clear score', evidence: 'F1 cheap models; small-creator ratio 0.05-0.4x (brand via series)', tiers: ['highlight'] },
91
+ { id: 'two-character-dialogue', name: 'Two-character dialogue', needs: 'real back-and-forth between dev and agent', beats: 'situation > exchange > turn > resolution', whenToUse: 'the conversation itself is the story', evidence: 'AI-convo outliers (Gibberlink 14.1M); no dev-niche proof yet', tiers: ['howto'] },
92
+ { id: 'chat-bubble', name: 'Chat-bubble / DM replay', needs: 'short dev and agent lines', beats: 'thread scrolls; narrator sets up each turn', whenToUse: 'quick exchanges that read well as text', evidence: 'under-supplied in dev niche (no data)', tiers: ['highlight', 'howto'] },
93
+ { id: 'audiogram', name: 'Audiogram (waveform + captions)', needs: 'TTS or real audio; captions', beats: 'one continuous spoken thread', whenToUse: 'a monologue-style explanation', evidence: 'catalog (untested)', tiers: ['howto'] },
94
+ { id: 'podcast-clip', name: 'Animated or avatar podcast clip', needs: 'two voices', beats: 'host frames > guest explains > host lands it', whenToUse: 'interview-style how-to', evidence: 'catalog (untested)', tiers: ['howto'] },
95
+ { id: 'unrelated-footage', name: 'Audio over unrelated footage', needs: 'a self-contained narrated track', beats: 'narration carries everything', whenToUse: 'broad-audience retention play', evidence: 'catalog (untested in dev niche)', tiers: ['highlight'] },
96
+ { id: 'phone-call', name: 'Phone-call style', needs: 'two voices, short turns', beats: 'call opens on the problem > back-and-forth > resolution', whenToUse: 'a help-desk style fix', evidence: 'catalog (untested)', tiers: ['howto'] },
97
+ ];
98
+ /** Writing rules shared by every script prompt (rules §4, §5, §7). */
99
+ export const SCRIPT_RULES = [
100
+ 'WHO: write to the named viewer, someone living the situation. Context, not explaining every line: terms they already know need no setup; anything outside their world gets just enough setup nearby.',
101
+ 'Every quote, term and number must be understandable to that viewer: either already known to that viewer or explained in the line before or right after it. A quote with no context for THAT viewer fails.',
102
+ 'STORY: the cause -> effect chain must be clean. Every line is the next thing that happened or the reason for it. No unexplained jumps, numbers or version strings.',
103
+ 'Hook placement is free (start, middle or end), but the hook must be explained.',
104
+ // WHAT-WORKS-video-writing-rules.md (round 2, gate PASS): the rules that separate postable videos from incoherent ones.
105
+ 'STAKE AND PROOF, BOTH VISIBLE: show the problem as a quantity or verdict (the brief\'s stake BEFORE), then the SAME quantity after the fix (AFTER). E.g. 0/4 -> 4/4; bar full -> one fire; zero markers -> reject.',
106
+ 'TERMS: at most ~3.5 load-bearing terms per minute (how-to: ~3), each defined in the line where it first appears.',
107
+ 'CAUSES: at least ~40 seconds per cause. A cause is a distinct fault or catch needing its own fix. Under 60s: 1 cause; 61-90s: at most 2 (the 2nd only if it repeats the 1st\'s cause); ~150s: at most 3, each an instance of the lesson. Cut unrelated catches.',
108
+ 'NO spoken software versions or error codes as story beats (no "0.9.73", "v2.1", "HTTP 502", "1006", "ENOENT") outside a verbatim dev line.',
109
+ 'The CLAIM lands by line 2, in plain words. Do not open on a bare number or status. No standalone hedges.',
110
+ 'GROUNDING: every claim must come from the SESSION material or the RESEARCH given. Never invent numbers, names, products, results or people. If unsure, leave it out.',
111
+ 'Speak to the problems the research shows people actually hit; frame the solution against them.',
112
+ 'DEV VOICE: speaker "dev" lines are copied VERBATIM from ONE user row given (same characters; you may cut words with " … " but every kept piece must be 12+ characters and the cuts must not change the meaning). Put that row number in "row". Never put the assistant\'s findings, or anything paraphrased, in the dev voice.',
113
+ 'Speaker "agent" lines restate what the assistant did or found, plainly. Speaker "narrator" carries the timeline.',
114
+ 'Client work stays redacted: keep tags like [client] as they are, never guess or reintroduce names, companies, IDs or amounts.',
115
+ 'No secrets, keys, tokens, emails, URLs to private systems, or personal details.',
116
+ 'Plain spoken English, short sentences, no markdown, no emojis, no stage directions inside "text".',
117
+ ];
118
+ const STAKE_RULE = 'STAKE: name the before -> after quantity or verdict the piece will SHOW (e.g. "facts recalled: 0/4 -> 4/4"), taken from the session.';
119
+ export const BRIEF_RULES = [
120
+ 'Every brief starts with WHO is consuming it and what they are living through, picked from the research (who is living through it / common problems).',
121
+ 'One highlight per period at most; how-tos only where the session shows ONE specific, useful accomplishment someone could copy.',
122
+ 'Pick the format from the catalog by fit (what it needs vs. what the session has). No format is the default.',
123
+ 'Pick the stretch of the session (row range) the piece is built from; dev lines can only come from user rows in it.',
124
+ `${STAKE_RULE} If the story has no provable before/after in the session, leave stake empty — the draft will be flagged "no visible proof".`,
125
+ ];
126
+ /** BRIEF_RULES for the current mode: under OSBORN_CONTENT_STRICT a missing stake blocks scripting, so say so. */
127
+ export function briefRules() {
128
+ if (!isContentStrict())
129
+ return BRIEF_RULES;
130
+ return BRIEF_RULES.map(r => (r.startsWith(STAKE_RULE) ? `${STAKE_RULE} If the story has no provable before/after in the session, leave stake empty — it will not be scripted.` : r));
131
+ }
132
+ export const AUDIENCE_RULES = 'AUDIENCE CHECK (rules §4): for the target viewer named below, is every quote or term either already familiar to them or set up nearby ' +
133
+ '(in the line before or right after)? A quote or term that lands with no context for THAT viewer fails. Familiar terms pass. ' +
134
+ 'Also fail an unexplained number or version string.';
@@ -0,0 +1,61 @@
1
+ /**
2
+ * content-script.ts — Stage A script generation: one script per brief.
3
+ * A period yields at most 1 highlight (61-90s narrated timeline) + up to 3
4
+ * how-tos (~150s "if you're in this situation…"); content-run enforces the count.
5
+ *
6
+ * Writing rules and per-tier beats come ONLY from content-script-rules.ts
7
+ * (SCRIPT_RULES / TIER_TEMPLATES), so they can be replaced without touching
8
+ * this logic. Dev-voice lines must be verbatim user rows: the model is shown
9
+ * row-tagged rows and must cite the row; content-checks.ts verifies every dev
10
+ * line deterministically before anything else runs. Throws CapError / Error.
11
+ */
12
+ import { type DbRecord } from './lens-db.js';
13
+ import { type ContentLlmOptions } from './content-llm.js';
14
+ import { type Tier } from './content-script-rules.js';
15
+ import type { Brief } from './content-brief.js';
16
+ export type Speaker = 'narrator' | 'dev' | 'agent';
17
+ export interface ScriptLine {
18
+ speaker: Speaker;
19
+ text: string;
20
+ /** Source row (required for dev lines). */
21
+ row?: number;
22
+ }
23
+ export interface Script {
24
+ id: string;
25
+ tier: Tier;
26
+ title: string;
27
+ hook: string;
28
+ lines: ScriptLine[];
29
+ /** Model-declared distinct causes (faults/catches needing their own fix) — gated by content-checks. */
30
+ causes: string[];
31
+ /** Model-declared load-bearing terms — gated per minute by content-checks. */
32
+ terms: string[];
33
+ words: number;
34
+ estSeconds: number;
35
+ }
36
+ export declare const countWords: (s: string) => number;
37
+ export declare const estimateSeconds: (lines: Pick<ScriptLine, "text">[]) => number;
38
+ export declare function tierBounds(tier: Tier): {
39
+ min: number;
40
+ max: number;
41
+ target: number;
42
+ };
43
+ export declare function lengthCheck(s: Pick<Script, 'tier' | 'lines'>): {
44
+ ok: boolean;
45
+ seconds: number;
46
+ min: number;
47
+ max: number;
48
+ };
49
+ /** Row-tagged session rows for the brief's range (widened to the whole period when thin), capped. */
50
+ export declare function rowsForBrief(records: DbRecord[], brief: Pick<Brief, 'fromRow' | 'toRow'>): DbRecord[];
51
+ export declare const SCRIPT_SYSTEM: string;
52
+ export declare function scriptPrompt(brief: Brief, page: string, rows: DbRecord[], researchText: string): string;
53
+ /** Tolerant parse of the model's reply into a Script. Never throws. */
54
+ export declare function normalizeScript(raw: unknown, brief: Pick<Brief, 'id' | 'tier'>): Script;
55
+ /** Generate one script. One model call. */
56
+ export declare function writeScript(brief: Brief, page: string, records: DbRecord[], researchText: string, llm: ContentLlmOptions): Promise<{
57
+ script: Script;
58
+ costUsd: number;
59
+ }>;
60
+ /** Plain transcript (one line per spoken line) — the ingest `transcript` field. */
61
+ export declare const scriptTranscript: (s: Script) => string;