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.
@@ -13,10 +13,15 @@
13
13
  * copy to <path> instead of content-profile.md and leaves the HWM alone.
14
14
  * node compaction-lens-worker.js --library <sessionId> [--cwd <dir>] [--dry] [--out-dir <dir>]
15
15
  * LIBRARY step only (manual / dry run). Takes the per-project lock itself.
16
- * Per-compaction runs do TWO independent, fail-open steps under one lock:
16
+ * node compaction-lens-worker.js --content <sessionId> [--cwd <dir>] [--dry] [--research-refresh]
17
+ * CONTENT step (Stage A) only: manual / the per-project "Refresh research"
18
+ * backend. --dry = no ingest (still spends, capped). Takes the per-project lock.
19
+ * Per-compaction runs do THREE independent, fail-open steps under one lock:
17
20
  * 1. the content lens (content-profile.md + HWM), unchanged;
18
21
  * 2. the library step (lens-library.ts): one period-map page for the period
19
- * that just ended + INDEX.md. A failure in either never stops the other.
22
+ * that just ended + INDEX.md;
23
+ * 3. the content step (content-run.ts, Stage A): research → briefs → scripts →
24
+ * checks → draft ingest for that period. A failure in any never stops the others.
20
25
  * Fully self-contained: top-level try/catch, redacted log in the project dir,
21
26
  * refreshes + releases the per-project lock, always exits.
22
27
  */
@@ -13,10 +13,15 @@
13
13
  * copy to <path> instead of content-profile.md and leaves the HWM alone.
14
14
  * node compaction-lens-worker.js --library <sessionId> [--cwd <dir>] [--dry] [--out-dir <dir>]
15
15
  * LIBRARY step only (manual / dry run). Takes the per-project lock itself.
16
- * Per-compaction runs do TWO independent, fail-open steps under one lock:
16
+ * node compaction-lens-worker.js --content <sessionId> [--cwd <dir>] [--dry] [--research-refresh]
17
+ * CONTENT step (Stage A) only: manual / the per-project "Refresh research"
18
+ * backend. --dry = no ingest (still spends, capped). Takes the per-project lock.
19
+ * Per-compaction runs do THREE independent, fail-open steps under one lock:
17
20
  * 1. the content lens (content-profile.md + HWM), unchanged;
18
21
  * 2. the library step (lens-library.ts): one period-map page for the period
19
- * that just ended + INDEX.md. A failure in either never stops the other.
22
+ * that just ended + INDEX.md;
23
+ * 3. the content step (content-run.ts, Stage A): research → briefs → scripts →
24
+ * checks → draft ingest for that period. A failure in any never stops the others.
20
25
  * Fully self-contained: top-level try/catch, redacted log in the project dir,
21
26
  * refreshes + releases the per-project lock, always exits.
22
27
  */
@@ -27,15 +32,36 @@ import { resolveSessionDb } from './lens-db.js';
27
32
  import { acquireLock, refuseOutPath, releaseLock, resolveProjectDir, touchLock } from './lens-paths.js';
28
33
  import { logLine } from './lens-profile.js';
29
34
  import { runLibraryStep } from './lens-library.js';
30
- // ≤2 big windows + reduce at ≤6 min/call, then the library step (1 call ≤6 min + model meta).
31
- const INCREMENTAL_TIMEOUT_MS = 35 * 60_000;
35
+ import { runContentStep } from './content-run.js';
36
+ // ≤2 big windows + reduce at ≤6 min/call, then the library step (1 call ≤6 min + model meta),
37
+ // then the content step (Stage A: ~10-18 small calls, ≤4 min each, capped in $).
38
+ const INCREMENTAL_TIMEOUT_MS = 45 * 60_000;
32
39
  const BACKFILL_TIMEOUT_MS = 3 * 60 * 60_000;
33
40
  const LIBRARY_TIMEOUT_MS = 15 * 60_000;
41
+ const CONTENT_TIMEOUT_MS = 20 * 60_000;
34
42
  function argVal(argv, flag) {
35
43
  const i = argv.indexOf(flag);
36
44
  return i >= 0 ? argv[i + 1] : undefined;
37
45
  }
38
46
  function parseJob(argv) {
47
+ if (argv[0] === '--content') {
48
+ const sessionId = argv[1];
49
+ if (!sessionId || sessionId.startsWith('--'))
50
+ throw new Error('usage: --content <sessionId> [--cwd <dir>] [--dry] [--research-refresh]');
51
+ const cwd = argVal(argv, '--cwd');
52
+ const db = resolveSessionDb(sessionId, null, cwd);
53
+ const projectDir = db ? dirname(dirname(dirname(db))) : null;
54
+ if (!projectDir || !acquireLock(projectDir))
55
+ throw new Error(`content: project dir ${projectDir ?? '(none)'} missing or locked`);
56
+ return {
57
+ opts: { sessionId, cwd, mode: 'incremental' },
58
+ kind: 'content',
59
+ content: { dry: argv.includes('--dry'), researchRefresh: argv.includes('--research-refresh') },
60
+ lockDir: projectDir,
61
+ logDir: projectDir,
62
+ timeoutMs: CONTENT_TIMEOUT_MS,
63
+ };
64
+ }
39
65
  if (argv[0] === '--library') {
40
66
  const sessionId = argv[1];
41
67
  if (!sessionId || sessionId.startsWith('--'))
@@ -135,7 +161,7 @@ async function main() {
135
161
  const t0 = Date.now();
136
162
  let lensErr = null;
137
163
  // Step 1 — content lens (skipped by the --library entry).
138
- if (job.kind !== 'library') {
164
+ if (job.kind !== 'library' && job.kind !== 'content') {
139
165
  try {
140
166
  const r = await runCompactionLens({
141
167
  ...job.opts,
@@ -155,7 +181,8 @@ async function main() {
155
181
  }
156
182
  // Step 2 — library page for the period that just ended (per-compaction + --library only; never backfill).
157
183
  // runLibraryStep never throws; any failure is logged and the worker still exits cleanly.
158
- if (job.opts.mode === 'incremental') {
184
+ let libPeriod = null;
185
+ if (job.opts.mode === 'incremental' && job.kind !== 'content') {
159
186
  if (lockDir)
160
187
  touchLock(lockDir);
161
188
  const lib = await runLibraryStep({
@@ -169,12 +196,38 @@ async function main() {
169
196
  logLine(logDir, `session ${sid}: library status=${lib.status} cost=$${lib.costUsd.toFixed(4)}${lib.page ? ` page=${lib.page}` : ''}`);
170
197
  if (job.kind === 'library')
171
198
  process.stdout.write(`${JSON.stringify({ status: lib.status, page: lib.page, index: lib.index, period: lib.period, cost: lib.costUsd, errors: lib.errors })}\n`);
199
+ if (lib.status === 'written')
200
+ libPeriod = lib.period;
201
+ }
202
+ // Step 3 — content plan (Stage A) for the period (per-compaction + --content; never backfill / --library).
203
+ // runContentStep never throws; the try is belt and braces so nothing here can fail the worker.
204
+ if (job.opts.mode === 'incremental' && job.kind !== 'library') {
205
+ try {
206
+ if (lockDir)
207
+ touchLock(lockDir);
208
+ const c = await runContentStep({
209
+ sessionId: job.opts.sessionId,
210
+ projectDir: lockDir,
211
+ cwd: job.opts.cwd,
212
+ period: libPeriod,
213
+ dry: job.content?.dry,
214
+ researchRefresh: job.content?.researchRefresh,
215
+ keepAlive: () => lockDir && touchLock(lockDir),
216
+ log: m => logLine(logDir, `session ${sid} [content]: ${m}`),
217
+ });
218
+ logLine(logDir, `session ${sid}: content status=${c.status} pieces=${c.pieces.length} cost=$${c.costUsd.toFixed(4)}${c.capHit ? ` cap=${c.capHit}` : ''}`);
219
+ if (job.kind === 'content')
220
+ process.stdout.write(`${JSON.stringify(c)}\n`);
221
+ }
222
+ catch (err) {
223
+ logLine(logDir, `content step error (ignored): ${err instanceof Error ? err.message : String(err)}`);
224
+ }
172
225
  }
173
226
  return lensErr ? 1 : 0;
174
227
  }
175
228
  catch (err) {
176
229
  logLine(logDir, `worker error: ${err instanceof Error ? err.stack || err.message : String(err)}`);
177
- if (err instanceof Error && /^(backfill|library):/.test(err.message))
230
+ if (err instanceof Error && /^(backfill|library|content):/.test(err.message))
178
231
  process.stderr.write(`${err.message}\n`);
179
232
  return 1;
180
233
  }
@@ -14,12 +14,14 @@
14
14
  * 7. HN gap check (fail-open), optional recording, client-redact the entry,
15
15
  * APPEND to <project>/content-profile.md (or a scratch file); advance HWM
16
16
  */
17
- import { dirname } from 'node:path';
17
+ import { basename, dirname } from 'node:path';
18
18
  import { loadConversationRows, packWindows, resolveSessionDb } from './lens-db.js';
19
19
  import { buildRecordIndex, explainQuote, parseJsonObject, readAngles, readCapabilities, splitByEvidence, verifyItems, } from './lens-quotes.js';
20
20
  import { Budget, chat, getModelInfo, mapPrompt, reducePrompt, windowTokensFor } from './lens-model.js';
21
21
  import { DENYLIST_FILE, hasBillingQuote, isBillingAngle, isBillingCapability, loadClientRedactor } from './lens-redact.js';
22
22
  import { gapCheck } from './lens-hn.js';
23
+ import { makeScrubber } from './content-redact.js';
24
+ import { gateQueries, privateSegments } from './content-query-gate.js';
23
25
  import { alignRecording, findSessionRecording } from './lens-audio.js';
24
26
  import { appendProfile, formatEntry, writeScratch } from './lens-profile.js';
25
27
  import { findTranscriptBySessionId, isProjectDir, readHwm, refuseOutPath, resolveProjectDir, writeHwm } from './lens-paths.js';
@@ -197,8 +199,17 @@ export async function runCompactionLens(opts) {
197
199
  bumpHwm(covered);
198
200
  return { ...r, status: 'nothing-survived' };
199
201
  }
202
+ // Outbound HN queries are model output built from session text: scrub, then the same
203
+ // deterministic gate research uses (project basename / cwd segments never leave the machine).
204
+ // gapCheck([]) makes no fetch and returns its neutral "no queries" result.
205
+ const sc = makeScrubber(projectDir);
206
+ const privateTerms = privateSegments([opts.cwd, process.env.OSBORN_CWD, basename(projectDir)]);
200
207
  for (const a of fin.c.angles) {
201
- r.angles.push({ title: a.title, why: a.why, quotes: a.quotes, gap: await gapCheck(a.queries) });
208
+ const scrubbed = (a.queries ?? []).map(q => sc.scrub(String(q ?? '')).replace(/\[[^\]]*\]/g, ' ').replace(/\s+/g, ' ').trim()).filter(q => q.length >= 3);
209
+ const queries = gateQueries(scrubbed, privateTerms);
210
+ if ((a.queries ?? []).length > queries.length)
211
+ log(`gap check "${a.title.slice(0, 60)}": ${(a.queries ?? []).length - queries.length} query(ies) held back by the outbound gate`);
212
+ r.angles.push({ title: a.title, why: a.why, quotes: a.quotes, gap: await gapCheck(queries) });
202
213
  }
203
214
  r.capabilities = fin.c.capabilities.map(c => ({
204
215
  name: c.name, did: c.did, evidence: c.evidence, proof: c.proof, quotes: c.quotes,
@@ -0,0 +1,90 @@
1
+ /**
2
+ * content-brief.ts — Stage A planning: which pieces a period yields, and the
3
+ * brief for each (rules §2-§4, §8a).
4
+ *
5
+ * planPieces() one OpenRouter call over the period's library page →
6
+ * ≤1 highlight + ≤3 how-to candidates, each with a subtopic,
7
+ * 1-2 search queries and the row range it is built from.
8
+ * buildBriefs() one call over the candidates + their research digests →
9
+ * per piece: VIEWER (from research), situation, tier, format
10
+ * (from the catalog), the research problems it speaks to.
11
+ * Deterministic validation after each call: unknown tiers/formats, rows outside
12
+ * the period, or a missing viewer fall back to safe defaults rather than failing.
13
+ * Throws CapError (cap) or Error (model) — content-run records either.
14
+ */
15
+ import { type ContentLlmOptions } from './content-llm.js';
16
+ import { type Tier } from './content-script-rules.js';
17
+ import type { TopicResearch } from './content-research.js';
18
+ export declare const OWNER_FILE = "content-owner.json";
19
+ export interface PieceCandidate {
20
+ id: string;
21
+ kind: Tier;
22
+ subtopic: string;
23
+ story: string;
24
+ queries: string[];
25
+ fromRow: number;
26
+ toRow: number;
27
+ why: string;
28
+ }
29
+ export interface Owner {
30
+ name: string;
31
+ handle?: string;
32
+ }
33
+ export interface Stake {
34
+ /** What is measured, e.g. "facts recalled after compaction". */
35
+ quantity: string;
36
+ before: string;
37
+ after: string;
38
+ }
39
+ export interface Brief {
40
+ id: string;
41
+ tier: Tier;
42
+ subtopic: string;
43
+ story: string;
44
+ fromRow: number;
45
+ toRow: number;
46
+ /** WHO is consuming it (rules §4), picked from the research. */
47
+ viewer: string;
48
+ /** What that viewer is living through. */
49
+ situation: string;
50
+ /** Format id from FORMAT_CATALOG. */
51
+ format: string;
52
+ angle: string;
53
+ /** Research problems (§8a) the piece speaks to. */
54
+ problems: string[];
55
+ /** WHAT-WORKS rule 1: the before -> after quantity or verdict the piece shows. Null = no provable stake (not scripted). */
56
+ stake: Stake | null;
57
+ researchKey: string;
58
+ owner: Owner | null;
59
+ autoGenerated: true;
60
+ }
61
+ /** Developer who owns the pieces (rules §6): env first, then <project>/content-owner.json. Null when unknown. */
62
+ export declare function loadOwner(projectDir: string | null): Owner | null;
63
+ export declare const PLAN_SYSTEM: string;
64
+ export declare function planPrompt(page: string, maxHighlights: number, maxHowtos: number, range: {
65
+ from: number;
66
+ to: number;
67
+ }): string;
68
+ export declare function validateCandidates(raw: unknown, range: {
69
+ from: number;
70
+ to: number;
71
+ }, maxPieces: number): PieceCandidate[];
72
+ /** Plan this period's pieces. One model call. */
73
+ export declare function planPieces(page: string, range: {
74
+ from: number;
75
+ to: number;
76
+ }, maxPieces: number, llm: ContentLlmOptions): Promise<{
77
+ candidates: PieceCandidate[];
78
+ costUsd: number;
79
+ }>;
80
+ export declare const BRIEF_SYSTEM: string;
81
+ export declare function catalogText(tier: Tier): string;
82
+ export declare function briefPrompt(cands: PieceCandidate[], research: Map<string, TopicResearch>): string;
83
+ /** A stake needs all three parts, and before must differ from after. */
84
+ export declare function validStake(v: unknown): Stake | null;
85
+ export declare function validateBriefs(raw: unknown, cands: PieceCandidate[], research: Map<string, TopicResearch>, owner: Owner | null): Brief[];
86
+ /** Build all briefs for a period in one model call. */
87
+ export declare function buildBriefs(cands: PieceCandidate[], research: Map<string, TopicResearch>, owner: Owner | null, llm: ContentLlmOptions): Promise<{
88
+ briefs: Brief[];
89
+ costUsd: number;
90
+ }>;
@@ -0,0 +1,165 @@
1
+ /**
2
+ * content-brief.ts — Stage A planning: which pieces a period yields, and the
3
+ * brief for each (rules §2-§4, §8a).
4
+ *
5
+ * planPieces() one OpenRouter call over the period's library page →
6
+ * ≤1 highlight + ≤3 how-to candidates, each with a subtopic,
7
+ * 1-2 search queries and the row range it is built from.
8
+ * buildBriefs() one call over the candidates + their research digests →
9
+ * per piece: VIEWER (from research), situation, tier, format
10
+ * (from the catalog), the research problems it speaks to.
11
+ * Deterministic validation after each call: unknown tiers/formats, rows outside
12
+ * the period, or a missing viewer fall back to safe defaults rather than failing.
13
+ * Throws CapError (cap) or Error (model) — content-run records either.
14
+ */
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { llmJson } from './content-llm.js';
18
+ import { briefRules, FORMAT_CATALOG, TIER_TEMPLATES } from './content-script-rules.js';
19
+ import { researchDigest } from './content-research.js';
20
+ import { gateQueries } from './content-query-gate.js';
21
+ export const OWNER_FILE = 'content-owner.json';
22
+ const PAGE_MAX_CHARS = 60_000;
23
+ const str = (v, n = 300) => String(v ?? '').replace(/\s+/g, ' ').trim().slice(0, n);
24
+ /** Developer who owns the pieces (rules §6): env first, then <project>/content-owner.json. Null when unknown. */
25
+ export function loadOwner(projectDir) {
26
+ const name = str(process.env.OSBORN_CONTENT_OWNER_NAME, 80);
27
+ if (name)
28
+ return { name, handle: str(process.env.OSBORN_CONTENT_OWNER_HANDLE, 80) || undefined };
29
+ if (!projectDir)
30
+ return null;
31
+ try {
32
+ const p = join(projectDir, OWNER_FILE);
33
+ if (!existsSync(p))
34
+ return null;
35
+ const o = JSON.parse(readFileSync(p, 'utf-8'));
36
+ const n = str(o?.name, 80);
37
+ return n ? { name: n, handle: str(o?.handle ?? o?.github, 80) || undefined } : null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ export const PLAN_SYSTEM = 'You plan short developer videos from ONE period of a developer\'s work session with an AI coding agent. ' +
44
+ 'You only use what the period page shows. Reply with ONE JSON object only.';
45
+ export function planPrompt(page, maxHighlights, maxHowtos, range) {
46
+ return [
47
+ 'Below is the library page for one work period (goal, ranked work, arc, stories, quotes with row numbers).',
48
+ '<period_page>',
49
+ page.length > PAGE_MAX_CHARS ? page.slice(0, PAGE_MAX_CHARS) : page,
50
+ '</period_page>',
51
+ `TASK: pick at most ${maxHighlights} highlight and at most ${maxHowtos} how-to piece(s).`,
52
+ `- highlight = ${TIER_TEMPLATES.highlight.promise} Pick the period's strongest arc.`,
53
+ `- howto = ${TIER_TEMPLATES.howto.promise} Only for something concrete someone else could copy. Skip if the period has none.`,
54
+ 'Each piece: {"id": "p1"..., "kind": "highlight"|"howto", "subtopic": the problem space in 3-8 words a stranger would search, ' +
55
+ '"story": the story title from the page it is built on, "queries": 1-2 SHORT searches (2-4 words) for the underlying general problem, phrased the way outsiders search HN / GitHub issues ' +
56
+ '(e.g. "claude code compaction", "api key machine auth"); no project-specific names or phrases, ' +
57
+ `"from_row", "to_row": the row range on the page it comes from (within ${range.from}-${range.to}), "why": one line}.`,
58
+ 'Never plan pieces about invoices, payments owed, contracts or rates. Keep [client]-style tags; never guess names.',
59
+ 'Output ONLY: {"pieces": [...]}',
60
+ ].join('\n\n');
61
+ }
62
+ export function validateCandidates(raw, range, maxPieces) {
63
+ const list = Array.isArray(raw?.pieces) ? raw.pieces : Array.isArray(raw) ? raw : [];
64
+ const out = [];
65
+ let highlights = 0;
66
+ let howtos = 0;
67
+ for (const p of list) {
68
+ const k = str(p?.kind ?? p?.tier, 20).toLowerCase().replace(/[^a-z]/g, '');
69
+ const kind = k.startsWith('highlight') ? 'highlight' : k.startsWith('howto') ? 'howto' : null;
70
+ const subtopic = str(p?.subtopic ?? p?.topic ?? p?.title ?? p?.story, 120);
71
+ if (!kind || !subtopic)
72
+ continue;
73
+ if (kind === 'highlight' ? highlights >= 1 : howtos >= 3)
74
+ continue;
75
+ if (out.length >= maxPieces)
76
+ break;
77
+ const a = Math.floor(Number(p?.from_row ?? p?.fromRow));
78
+ const b = Math.floor(Number(p?.to_row ?? p?.toRow));
79
+ const fromRow = Number.isFinite(a) && a >= range.from && a <= range.to ? a : range.from;
80
+ const toRow = Number.isFinite(b) && b >= fromRow && b <= range.to ? b : range.to;
81
+ // Outbound gate (structure rules here; research re-gates with the project's private path terms before any fetch).
82
+ const queries = gateQueries((Array.isArray(p?.queries) ? p.queries : []).map((q) => str(q, 60))).slice(0, 2);
83
+ const fallback = gateQueries([subtopic.split(' ').slice(0, 5).join(' ')]);
84
+ if (kind === 'highlight')
85
+ highlights++;
86
+ else
87
+ howtos++;
88
+ out.push({ id: `${kind}-${kind === 'highlight' ? highlights : howtos}`, kind, subtopic, story: str(p?.story, 160), queries: queries.length ? queries : fallback, fromRow, toRow, why: str(p?.why, 200) });
89
+ }
90
+ // Highlight first, then how-tos (stable ids → stable content hashes across reruns).
91
+ return out.sort((x, y) => (x.kind === y.kind ? 0 : x.kind === 'highlight' ? -1 : 1));
92
+ }
93
+ /** Plan this period's pieces. One model call. */
94
+ export async function planPieces(page, range, maxPieces, llm) {
95
+ if (maxPieces <= 0)
96
+ return { candidates: [], costUsd: 0 };
97
+ const maxHow = Math.min(3, Math.max(0, maxPieces - 1));
98
+ const r = await llmJson(llm, { what: 'plan', system: PLAN_SYSTEM, user: planPrompt(page, 1, maxHow, range), maxOut: 1500, temperature: 0.2 });
99
+ return { candidates: validateCandidates(r.json, range, maxPieces), costUsd: r.costUsd };
100
+ }
101
+ export const BRIEF_SYSTEM = 'You write video briefs for a developer\'s own short videos. Every brief starts with WHO is watching and what they are living through, ' +
102
+ 'taken from the research given (never invented). Reply with ONE JSON object only.';
103
+ export function catalogText(tier) {
104
+ return FORMAT_CATALOG.filter(f => f.tiers.includes(tier))
105
+ .map(f => `- ${f.id}: ${f.name}. Needs: ${f.needs}. Beats: ${f.beats}. Use when: ${f.whenToUse}. Evidence: ${f.evidence}.`)
106
+ .join('\n');
107
+ }
108
+ export function briefPrompt(cands, research) {
109
+ const blocks = cands.map(c => {
110
+ const r = research.get(c.id);
111
+ return [
112
+ `PIECE ${c.id} (${TIER_TEMPLATES[c.kind].label})`,
113
+ `Subtopic: ${c.subtopic}. Built on: "${c.story}" (rows ${c.fromRow}-${c.toRow}). Why: ${c.why}`,
114
+ r ? researchDigest(r) : 'RESEARCH: none available',
115
+ `FORMATS that fit this tier:\n${catalogText(c.kind)}`,
116
+ ].join('\n');
117
+ });
118
+ return [
119
+ ...blocks,
120
+ `RULES:\n${briefRules().map(r => `- ${r}`).join('\n')}`,
121
+ 'For EACH piece return {"id", "viewer": who is consuming it (role + situation, from the research), "situation": what they are living through (1 line), ' +
122
+ '"format": one format id from its list, "angle": the one-line promise of the piece to that viewer, ' +
123
+ '"problems": 1-3 of the research problems/scenarios it speaks to (copy their text), ' +
124
+ '"stake": {"quantity": what is measured, "before": the value/verdict before, "after": the same quantity after} taken from the session, or null if the session shows no provable before/after}.',
125
+ 'Output ONLY: {"briefs": [...]}',
126
+ ].join('\n\n');
127
+ }
128
+ /** A stake needs all three parts, and before must differ from after. */
129
+ export function validStake(v) {
130
+ const o = v && typeof v === 'object' ? v : null;
131
+ if (!o)
132
+ return null;
133
+ const quantity = str(o.quantity ?? o.what, 160);
134
+ const before = str(o.before, 120);
135
+ const after = str(o.after, 120);
136
+ if (!quantity || !before || !after || before.toLowerCase() === after.toLowerCase())
137
+ return null;
138
+ return { quantity, before, after };
139
+ }
140
+ const DEFAULT_FORMAT = { highlight: 'narrated-timeline', howto: 'two-voice-hud' };
141
+ export function validateBriefs(raw, cands, research, owner) {
142
+ const list = Array.isArray(raw?.briefs) ? raw.briefs : [];
143
+ const byId = new Map(list.map(b => [str(b?.id, 40), b]));
144
+ return cands.map((c, i) => {
145
+ const b = byId.get(c.id) ?? list[i] ?? {};
146
+ const r = research.get(c.id);
147
+ const fmt = str(b?.format, 40).toLowerCase();
148
+ const format = FORMAT_CATALOG.some(f => f.id === fmt && f.tiers.includes(c.kind)) ? fmt : DEFAULT_FORMAT[c.kind];
149
+ const viewer = str(b?.viewer ?? b?.audience, 200) || r?.viewers[0]?.text || `developers working on ${c.subtopic}`;
150
+ const problems = (Array.isArray(b?.problems) ? b.problems : []).map((p) => str(p, 240)).filter(Boolean).slice(0, 3);
151
+ return {
152
+ id: c.id, tier: c.kind, subtopic: c.subtopic, story: c.story, fromRow: c.fromRow, toRow: c.toRow,
153
+ viewer, situation: str(b?.situation, 240) || (r?.problems[0]?.text ?? c.subtopic), format, angle: str(b?.angle, 240) || c.why,
154
+ problems: problems.length ? problems : (r?.problems ?? []).slice(0, 2).map(p => p.text),
155
+ stake: validStake(b?.stake), researchKey: r?.key ?? '', owner, autoGenerated: true,
156
+ };
157
+ });
158
+ }
159
+ /** Build all briefs for a period in one model call. */
160
+ export async function buildBriefs(cands, research, owner, llm) {
161
+ if (!cands.length)
162
+ return { briefs: [], costUsd: 0 };
163
+ const r = await llmJson(llm, { what: 'brief', system: BRIEF_SYSTEM, user: briefPrompt(cands, research), maxOut: 1500, temperature: 0.2 });
164
+ return { briefs: validateBriefs(r.json, cands, research, owner), costUsd: r.costUsd };
165
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * content-checks.ts — Stage A gates. A HARD flag ⇒ the piece is "blocked":
3
+ * never ingested, never auto-fixed (content-run records the flags).
4
+ * HARD always: dev-voice pre-pass, truth-check, length below the tier min or
5
+ * more than +15% over the tier max, an empty script.
6
+ * ADVISORY by default (warnings → the draft's quality_flags; still ingested):
7
+ * the structure gate (stake / "no visible proof", causes, terms/min, spoken
8
+ * versions/codes), up to +15% over the tier max, and the audience check.
9
+ * OSBORN_CONTENT_STRICT=1 makes every advisory warning a hard flag again.
10
+ *
11
+ * Order (cheapest first; a failing stage skips the paid ones after it):
12
+ * 1. length deterministic: estimated seconds within the tier bounds.
13
+ * 2. dev-voice deterministic pre-pass (rules §7): every dev line must be a
14
+ * substring of ONE user row of the period. Ellipsis cuts ("…" / "...")
15
+ * are allowed only if every piece is ≥12 chars, the pieces occur in
16
+ * order in that row, and they cover ≥80% of the row span they were
17
+ * cut from (so a cut can't flip the meaning). Records the row id.
18
+ * Also caps dev lines per tier (a highlight is not a quote chain).
19
+ * 3. truth-check LLM, PINNED: deepseek/deepseek-chat on deepinfra/fp4 with
20
+ * allow_fallbacks:false (as videos-test-3/vt3-render.ts); the served
21
+ * provider must be DeepInfra. Cached by sha256(draft+sources+prompt+model+provider).
22
+ * 4. audience LLM (same pin): rules §4 — every quote/term familiar to the viewer or set up nearby.
23
+ */
24
+ import type { DbRecord } from './lens-db.js';
25
+ import { type ContentLlmOptions } from './content-llm.js';
26
+ import { type CheckSummary } from './content-manifest.js';
27
+ import { type ScriptLimits } from './content-script-rules.js';
28
+ import { type Script, type ScriptLine } from './content-script.js';
29
+ import type { Brief } from './content-brief.js';
30
+ export declare const CHECK_CACHE_DIR = ".content-check-cache";
31
+ export declare const MIN_PIECE_CHARS = 12;
32
+ export declare const MIN_CUT_COVERAGE = 0.8;
33
+ /** Non-strict: a script may run up to this fraction over the tier max (flagged, not blocked). */
34
+ export declare const OVER_LENGTH_TOLERANCE = 0.15;
35
+ export declare const NO_PROOF_FLAG = "no visible proof: brief has no before -> after stake quantity (rule 1: stake and proof both visible)";
36
+ /** Whitespace + typographic quote/dash normalisation only (never wording). */
37
+ export declare const normText: (s: string) => string;
38
+ /** Pieces of a dev line split at ellipsis cuts (leading/trailing cuts allowed). */
39
+ export declare const devPieces: (text: string) => string[];
40
+ /** Match pieces in order inside `row`. Returns the covered span ratio, or null when not all found. */
41
+ export declare function matchInRow(row: string, pieces: string[]): number | null;
42
+ export interface PrepassResult {
43
+ ok: boolean;
44
+ flags: string[];
45
+ rows: {
46
+ line: number;
47
+ row: number;
48
+ match: string;
49
+ }[];
50
+ }
51
+ /** Deterministic dev-voice pre-pass. `rows` = the period's records (only user rows are eligible). */
52
+ export declare function devVoicePrepass(lines: ScriptLine[], rows: DbRecord[], tier?: Script['tier']): PrepassResult;
53
+ export declare const draftText: (s: Script) => string;
54
+ /** pipeline-e2e LEG 5 / vt3-render.ts truth-check prompt, unchanged. */
55
+ export declare const TRUTH_SYSTEM: string;
56
+ export declare const truthUser: (sources: string, draft: string) => string;
57
+ export declare const AUDIENCE_SYSTEM = "You are an editor checking a short developer video script for ONE target viewer. Reply with ONE JSON object only.";
58
+ export declare const audienceUser: (brief: Pick<Brief, "viewer" | "situation">, draft: string) => string;
59
+ /** Flags from any reply shape: ["..."], {flags:[...]}, [{claim|item|text}], … */
60
+ export declare function flagsFrom(json: unknown): string[];
61
+ /** Spoken software versions / error codes (WHAT-WORKS rule 4). Starting patterns; dev lines are exempt (verbatim). */
62
+ export declare const VERSION_CODE_PATTERNS: RegExp[];
63
+ export interface StructureResult {
64
+ ok: boolean;
65
+ flags: string[];
66
+ causes: number;
67
+ termsPerMin: number;
68
+ }
69
+ /** WHAT-WORKS deterministic gate, run on the script BEFORE any LLM judge. Thresholds: scriptLimits() (env-tunable). */
70
+ export declare function structureCheck(s: Script, brief: Pick<Brief, 'stake'>, limits?: ScriptLimits): StructureResult;
71
+ export interface CheckContext {
72
+ projectDir: string | null;
73
+ llm: ContentLlmOptions;
74
+ /** The period's records (session.db, redacted) — dev lines are matched against the user rows. */
75
+ records: DbRecord[];
76
+ /** Truth-check ground truth: library page + cited rows + research digest (already scrubbed). */
77
+ sources: string;
78
+ }
79
+ /**
80
+ * All gates for one script. `flags` (hard) non-empty ⇒ blocked. `warnings` are advisory
81
+ * quality flags (empty under OSBORN_CONTENT_STRICT=1, where they are hard flags instead).
82
+ * Throws CapError / Error (piece → capped / error).
83
+ */
84
+ export declare function runChecks(s: Script, brief: Brief, ctx: CheckContext): Promise<{
85
+ summary: CheckSummary;
86
+ flags: string[];
87
+ warnings: string[];
88
+ }>;