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,22 @@
1
+ /**
2
+ * content-redact.ts — the ONE redaction gate for everything Stage A sends out:
3
+ * model prompts (OpenRouter), research queries (HN / GitHub), and the
4
+ * content_ingest payload (Supabase).
5
+ *
6
+ * scrub(s) = redactSecrets(clientRedactor.redact(s), { assistant: true }),
7
+ * i.e. the project denylist + default client patterns (lens-redact.ts), then
8
+ * every secret shape incl. the high-entropy fallback (transcript-sanitizer.ts).
9
+ * Idempotent: already-redacted text comes back unchanged.
10
+ * Never throws. A broken denylist file falls back to the default patterns.
11
+ */
12
+ export interface ContentScrubber {
13
+ /** Redact one string. */
14
+ scrub: (s: string) => string;
15
+ /** Deep-redact every string inside a JSON-like value (objects, arrays). Returns a copy. */
16
+ scrubDeep: <T>(v: T) => T;
17
+ /** True when the text would change under scrub(), i.e. a secret or client term is still in it. */
18
+ isDirty: (s: string) => boolean;
19
+ /** Denylist terms loaded (for logs; never the terms themselves). */
20
+ terms: number;
21
+ }
22
+ export declare function makeScrubber(projectDir: string | null): ContentScrubber;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * content-redact.ts — the ONE redaction gate for everything Stage A sends out:
3
+ * model prompts (OpenRouter), research queries (HN / GitHub), and the
4
+ * content_ingest payload (Supabase).
5
+ *
6
+ * scrub(s) = redactSecrets(clientRedactor.redact(s), { assistant: true }),
7
+ * i.e. the project denylist + default client patterns (lens-redact.ts), then
8
+ * every secret shape incl. the high-entropy fallback (transcript-sanitizer.ts).
9
+ * Idempotent: already-redacted text comes back unchanged.
10
+ * Never throws. A broken denylist file falls back to the default patterns.
11
+ */
12
+ import { loadClientRedactor } from './lens-redact.js';
13
+ import { redactSecrets } from './transcript-sanitizer.js';
14
+ const passthrough = { rules: [], terms: 0, source: null, redact: (s) => s };
15
+ export function makeScrubber(projectDir) {
16
+ let red = passthrough;
17
+ try {
18
+ red = loadClientRedactor(projectDir);
19
+ }
20
+ catch {
21
+ /* defaults only */
22
+ }
23
+ const scrub = (s) => {
24
+ if (typeof s !== 'string' || !s)
25
+ return s;
26
+ try {
27
+ return redactSecrets(red.redact(s), { assistant: true });
28
+ }
29
+ catch {
30
+ // Fail CLOSED for outbound text: if redaction itself breaks, send nothing of it.
31
+ return '[redacted]';
32
+ }
33
+ };
34
+ const scrubDeep = (v) => deep(v, scrub);
35
+ return { scrub, scrubDeep, isDirty: s => scrub(s) !== s, terms: red.terms };
36
+ }
37
+ function deep(v, f) {
38
+ if (typeof v === 'string')
39
+ return f(v);
40
+ if (Array.isArray(v))
41
+ return v.map(x => deep(x, f));
42
+ if (v && typeof v === 'object') {
43
+ const o = {};
44
+ for (const [k, x] of Object.entries(v))
45
+ o[k] = deep(x, f);
46
+ return o;
47
+ }
48
+ return v;
49
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * content-research.ts — per-topic research for Stage A (rules §4, §8, §8a).
3
+ *
4
+ * Two layers, both grounded in REAL, cited threads (no invented people):
5
+ * 1. Demand — what performs: the keyless HN Algolia gap check (lens-hn.ts).
6
+ * 2. Problem research (§8a) — conventions, the common problems / failure
7
+ * points people hit, concrete scenarios, tradeoffs. Raw material: HN
8
+ * comments (keyless Algolia) + GitHub issue search (keyless; GITHUB_TOKEN
9
+ * is used when present, never logged). One OpenRouter call synthesises
10
+ * them; every finding must cite source ids that were actually fetched —
11
+ * uncited / unknown-cite findings are dropped deterministically.
12
+ *
13
+ * Cache: 7 days per topic under <project>/.content-research-cache/<hash>.json.
14
+ * --research-refresh bypasses it. Search queries are scrubbed, then must pass
15
+ * the deterministic outbound gate (content-query-gate.ts) before leaving the
16
+ * machine; if none survive, no search runs (status no-threads). A no-threads
17
+ * result is cached only when at least one source answered. Fail-open: network errors give fewer sources, never a throw —
18
+ * EXCEPT a spend-cap hit (CapError), which the run turns into status "capped".
19
+ */
20
+ import { type HnStory } from './lens-hn.js';
21
+ import { type ContentLlmOptions } from './content-llm.js';
22
+ export declare const RESEARCH_CACHE_DIR = ".content-research-cache";
23
+ export declare const RESEARCH_TTL_MS: number;
24
+ export interface ResearchTopic {
25
+ /** Short subtopic, e.g. "context loss at Claude Code compaction". */
26
+ subtopic: string;
27
+ /** 1-3 short search queries (2-5 words). */
28
+ queries: string[];
29
+ }
30
+ export interface Citation {
31
+ id: string;
32
+ source: 'hn-story' | 'hn-comment' | 'github-issue';
33
+ url: string;
34
+ title: string;
35
+ snippet: string;
36
+ points?: number;
37
+ comments?: number;
38
+ }
39
+ export interface Finding {
40
+ text: string;
41
+ /** Citation URLs (only ones actually fetched). */
42
+ cites: string[];
43
+ }
44
+ export interface TopicResearch {
45
+ key: string;
46
+ topic: ResearchTopic;
47
+ at: string;
48
+ status: 'ok' | 'no-threads' | 'model-error';
49
+ demand: {
50
+ note: string;
51
+ reachable: boolean;
52
+ stories: HnStory[];
53
+ };
54
+ /** Who is living through this (feeds the brief's viewer, rules §4). */
55
+ viewers: Finding[];
56
+ conventions: Finding[];
57
+ problems: Finding[];
58
+ scenarios: Finding[];
59
+ tradeoffs: Finding[];
60
+ citations: Citation[];
61
+ costUsd: number;
62
+ cached?: boolean;
63
+ }
64
+ export interface ResearchOptions {
65
+ projectDir: string | null;
66
+ llm: ContentLlmOptions;
67
+ refresh?: boolean;
68
+ now?: number;
69
+ /** Project basename / cwd paths: queries naming any of their segments never leave the machine. */
70
+ privateTerms?: string[];
71
+ }
72
+ export declare function researchKey(t: ResearchTopic): string;
73
+ /** HN comments matching the query: where people describe what broke for them. */
74
+ export declare function hnComments(q: string): Promise<Citation[]>;
75
+ /** GitHub issues matching the query, best match first (sort=comments surfaced bot status boards). Keyless unless GITHUB_TOKEN is set. */
76
+ export declare function githubIssues(q: string): Promise<Citation[]>;
77
+ export declare const RESEARCH_SYSTEM: string;
78
+ export declare function researchPrompt(t: ResearchTopic, demandNote: string, cites: Citation[]): string;
79
+ /** Keep only findings whose cites are real fetched ids; map ids → URLs. */
80
+ export declare function groundFindings(raw: unknown, cites: Citation[]): Finding[];
81
+ /** Research one topic (cached 7 days). Throws only CapError. */
82
+ export declare function researchTopic(topic: ResearchTopic, o: ResearchOptions): Promise<TopicResearch>;
83
+ /** Compact, citation-tagged text block of one topic's research (for briefs, scripts and truth-check sources). */
84
+ export declare function researchDigest(r: TopicResearch): string;
85
+ /** Every URL a piece's research actually cites (for source_anchors). */
86
+ export declare function researchCitations(r: TopicResearch): {
87
+ url: string;
88
+ title: string;
89
+ source: Citation['source'];
90
+ }[];
@@ -0,0 +1,256 @@
1
+ /**
2
+ * content-research.ts — per-topic research for Stage A (rules §4, §8, §8a).
3
+ *
4
+ * Two layers, both grounded in REAL, cited threads (no invented people):
5
+ * 1. Demand — what performs: the keyless HN Algolia gap check (lens-hn.ts).
6
+ * 2. Problem research (§8a) — conventions, the common problems / failure
7
+ * points people hit, concrete scenarios, tradeoffs. Raw material: HN
8
+ * comments (keyless Algolia) + GitHub issue search (keyless; GITHUB_TOKEN
9
+ * is used when present, never logged). One OpenRouter call synthesises
10
+ * them; every finding must cite source ids that were actually fetched —
11
+ * uncited / unknown-cite findings are dropped deterministically.
12
+ *
13
+ * Cache: 7 days per topic under <project>/.content-research-cache/<hash>.json.
14
+ * --research-refresh bypasses it. Search queries are scrubbed, then must pass
15
+ * the deterministic outbound gate (content-query-gate.ts) before leaving the
16
+ * machine; if none survive, no search runs (status no-threads). A no-threads
17
+ * result is cached only when at least one source answered. Fail-open: network errors give fewer sources, never a throw —
18
+ * EXCEPT a spend-cap hit (CapError), which the run turns into status "capped".
19
+ */
20
+ import { createHash } from 'node:crypto';
21
+ import { mkdirSync, readFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { gapCheck } from './lens-hn.js';
24
+ import { CapError, llmJson } from './content-llm.js';
25
+ import { writeAtomic } from './content-manifest.js';
26
+ import { gateQueries } from './content-query-gate.js';
27
+ export const RESEARCH_CACHE_DIR = '.content-research-cache';
28
+ export const RESEARCH_TTL_MS = 7 * 86_400_000;
29
+ const FETCH_TIMEOUT_MS = 10_000;
30
+ const SNIPPET = 420;
31
+ export function researchKey(t) {
32
+ const norm = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim();
33
+ return createHash('sha256').update(JSON.stringify([norm(t.subtopic), t.queries.map(norm).sort()])).digest('hex').slice(0, 24);
34
+ }
35
+ const stripHtml = (s) => String(s ?? '')
36
+ .replace(/<[^>]+>/g, ' ')
37
+ .replace(/&#x27;|&#39;/g, "'")
38
+ .replace(/&quot;/g, '"')
39
+ .replace(/&gt;/g, '>')
40
+ .replace(/&lt;/g, '<')
41
+ .replace(/&#x2F;/g, '/')
42
+ .replace(/&amp;/g, '&')
43
+ .replace(/\s+/g, ' ')
44
+ .trim();
45
+ const clip = (s, n = SNIPPET) => (s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s);
46
+ /** `ok` = the source answered (HTTP 2xx + JSON), even with zero hits. */
47
+ async function getJson(url, headers = {}) {
48
+ const ctrl = new AbortController();
49
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
50
+ try {
51
+ const r = await fetch(url, { signal: ctrl.signal, headers });
52
+ if (!r.ok)
53
+ return { ok: false, data: null };
54
+ return { ok: true, data: await r.json() };
55
+ }
56
+ catch {
57
+ return { ok: false, data: null };
58
+ }
59
+ finally {
60
+ clearTimeout(timer);
61
+ }
62
+ }
63
+ /** HN comments matching the query: where people describe what broke for them. */
64
+ export async function hnComments(q) {
65
+ return (await hnCommentsR(q)).cites;
66
+ }
67
+ async function hnCommentsR(q) {
68
+ const { ok, data: d } = await getJson(`https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(q)}&tags=comment&hitsPerPage=8`);
69
+ const hits = Array.isArray(d?.hits) ? d.hits : [];
70
+ const cites = hits
71
+ .filter(h => h?.objectID && h?.comment_text)
72
+ .map(h => ({
73
+ id: '',
74
+ source: 'hn-comment',
75
+ url: `https://news.ycombinator.com/item?id=${encodeURIComponent(String(h.objectID))}`,
76
+ title: clip(stripHtml(h.story_title || 'HN comment'), 140),
77
+ snippet: clip(stripHtml(h.comment_text)),
78
+ points: Number(h.points) || undefined,
79
+ }))
80
+ .filter(c => c.snippet.length >= 60);
81
+ return { reachable: ok, cites };
82
+ }
83
+ /** GitHub issues matching the query, best match first (sort=comments surfaced bot status boards). Keyless unless GITHUB_TOKEN is set. */
84
+ export async function githubIssues(q) {
85
+ return (await githubIssuesR(q)).cites;
86
+ }
87
+ async function githubIssuesR(q) {
88
+ const headers = { Accept: 'application/vnd.github+json', 'User-Agent': 'osborn-content-research' };
89
+ const tok = (process.env.GITHUB_TOKEN || '').trim();
90
+ if (tok)
91
+ headers.Authorization = `Bearer ${tok}`;
92
+ const { ok, data: d } = await getJson(`https://api.github.com/search/issues?q=${encodeURIComponent(`${q} is:issue`)}&per_page=6`, headers);
93
+ const items = Array.isArray(d?.items) ? d.items : [];
94
+ const cites = items
95
+ .filter(i => typeof i?.html_url === 'string' && /^https:\/\/github\.com\//.test(i.html_url) && i?.title)
96
+ .map(i => ({
97
+ id: '',
98
+ source: 'github-issue',
99
+ url: String(i.html_url),
100
+ title: clip(stripHtml(i.title), 160),
101
+ snippet: clip(stripHtml(i.body || '')),
102
+ comments: Number(i.comments) || 0,
103
+ }));
104
+ return { reachable: ok, cites };
105
+ }
106
+ function readCache(projectDir, key, now) {
107
+ if (!projectDir)
108
+ return null;
109
+ try {
110
+ const c = JSON.parse(readFileSync(join(projectDir, RESEARCH_CACHE_DIR, `${key}.json`), 'utf-8'));
111
+ if (c?.key === key && now - Date.parse(c.at) < RESEARCH_TTL_MS && c.status !== 'model-error')
112
+ return { ...c, cached: true, costUsd: 0 };
113
+ }
114
+ catch {
115
+ /* miss */
116
+ }
117
+ return null;
118
+ }
119
+ function writeCache(projectDir, r) {
120
+ if (!projectDir)
121
+ return;
122
+ try {
123
+ const dir = join(projectDir, RESEARCH_CACHE_DIR);
124
+ mkdirSync(dir, { recursive: true });
125
+ writeAtomic(join(dir, `${r.key}.json`), JSON.stringify(r, null, 2) + '\n');
126
+ }
127
+ catch {
128
+ /* cache is best-effort */
129
+ }
130
+ }
131
+ export const RESEARCH_SYSTEM = 'You are a developer-audience researcher. You are given REAL threads (Hacker News stories/comments, GitHub issues), each with an id like S3. ' +
132
+ 'Summarise the PROBLEM SPACE of the subtopic for someone about to make a short video about it. Use ONLY what the threads say. ' +
133
+ 'Never invent people, quotes, numbers, products or threads. Never name individual commenters — say "an HN commenter" or "a GitHub issue". ' +
134
+ 'Every finding must cite 1-3 thread ids it is drawn from. Reply with ONE JSON object only.';
135
+ export function researchPrompt(t, demandNote, cites) {
136
+ const src = cites.map(c => `[${c.id}] (${c.source}${c.points ? `, ${c.points} pts` : ''}${c.comments ? `, ${c.comments} comments` : ''}) ${c.title}\n${c.snippet}`).join('\n\n');
137
+ return [
138
+ `SUBTOPIC: ${t.subtopic}`,
139
+ `DEMAND (HN stories): ${demandNote}`,
140
+ '<threads>',
141
+ src,
142
+ '</threads>',
143
+ 'TASK (problem research): from the threads above, extract',
144
+ '- "viewers": who is living through this (role + situation), e.g. "solo devs running long Claude Code sessions"',
145
+ '- "conventions": the approaches people conventionally use',
146
+ '- "problems": the most common problems and failure points they actually hit',
147
+ '- "scenarios": concrete situations people are stuck in',
148
+ '- "tradeoffs": what they weigh against what',
149
+ 'Each list 0-5 items, each item {"text": one plain sentence, "cites": ["S1", ...]}. Skip anything the threads do not support. ' +
150
+ 'If the threads are off-topic, return empty lists.',
151
+ 'Output ONLY: {"viewers": [...], "conventions": [...], "problems": [...], "scenarios": [...], "tradeoffs": [...]}',
152
+ ].join('\n\n');
153
+ }
154
+ /** Keep only findings whose cites are real fetched ids; map ids → URLs. */
155
+ export function groundFindings(raw, cites) {
156
+ const byId = new Map(cites.map(c => [c.id.toUpperCase(), c.url]));
157
+ const out = [];
158
+ for (const it of Array.isArray(raw) ? raw.slice(0, 5) : []) {
159
+ const text = String(it?.text ?? '').replace(/\s+/g, ' ').trim();
160
+ const ids = Array.isArray(it?.cites) ? it.cites : [];
161
+ const urls = [...new Set(ids.map(i => byId.get(String(i).replace(/[\[\]\s]/g, '').toUpperCase())).filter((u) => !!u))];
162
+ if (text.length >= 8 && urls.length)
163
+ out.push({ text: clip(text, 300), cites: urls });
164
+ }
165
+ return out;
166
+ }
167
+ /** Research one topic (cached 7 days). Throws only CapError. */
168
+ export async function researchTopic(topic, o) {
169
+ const now = o.now ?? Date.now();
170
+ const key = researchKey(topic);
171
+ if (!o.refresh) {
172
+ const hit = readCache(o.projectDir, key, now);
173
+ if (hit)
174
+ return hit;
175
+ }
176
+ const log = o.llm.log ?? (() => { });
177
+ const scrubbed = topic.queries.map(q => o.llm.scrub(q).replace(/\[[^\]]*\]/g, ' ').replace(/\s+/g, ' ').trim()).filter(q => q.length >= 3);
178
+ // Deterministic outbound gate: only plain, non-project-specific queries ever reach HN / GitHub.
179
+ const queries = gateQueries(scrubbed, o.privateTerms ?? []).slice(0, 2);
180
+ if (scrubbed.length > queries.length)
181
+ log(`research "${clip(topic.subtopic, 60)}": ${scrubbed.length - queries.length} query(ies) held back by the outbound gate`);
182
+ const base = {
183
+ key, topic: { subtopic: topic.subtopic, queries }, at: new Date(now).toISOString(), status: 'no-threads',
184
+ demand: { note: 'No search queries.', reachable: false, stories: [] },
185
+ viewers: [], conventions: [], problems: [], scenarios: [], tradeoffs: [], citations: [], costUsd: 0,
186
+ };
187
+ if (!queries.length)
188
+ return base;
189
+ const gap = await gapCheck(queries).catch(() => null);
190
+ if (gap)
191
+ base.demand = { note: gap.note, reachable: gap.reachable, stories: gap.stories };
192
+ let reachable = !!gap?.reachable;
193
+ const seen = new Set();
194
+ const cites = [];
195
+ const add = (c) => {
196
+ if (seen.has(c.url) || cites.length >= 16)
197
+ return;
198
+ seen.add(c.url);
199
+ cites.push({ ...c, id: `S${cites.length + 1}`, title: o.llm.scrub(c.title), snippet: o.llm.scrub(c.snippet) });
200
+ };
201
+ for (const s of base.demand.stories)
202
+ add({ id: '', source: 'hn-story', url: s.url, title: s.title, snippet: s.title, points: s.points, comments: s.num_comments });
203
+ const none = { reachable: false, cites: [] };
204
+ for (const q of queries) {
205
+ for (const f of [await hnCommentsR(q).catch(() => none), await githubIssuesR(q).catch(() => none)]) {
206
+ reachable ||= f.reachable;
207
+ for (const c of f.cites)
208
+ add(c);
209
+ }
210
+ }
211
+ base.citations = cites;
212
+ log(`research "${clip(topic.subtopic, 60)}": ${cites.length} thread(s) (${cites.filter(c => c.source === 'github-issue').length} GitHub)`);
213
+ if (cites.length < 2) {
214
+ // Cache "no threads" only when a source actually answered; a network failure must not stick for 7 days.
215
+ if (reachable)
216
+ writeCache(o.projectDir, base);
217
+ return base;
218
+ }
219
+ try {
220
+ const r = await llmJson(o.llm, { what: 'research', system: RESEARCH_SYSTEM, user: researchPrompt(topic, base.demand.note, cites), maxOut: 1800, temperature: 0.1 });
221
+ base.costUsd = r.costUsd;
222
+ const j = r.json ?? {};
223
+ for (const k of ['viewers', 'conventions', 'problems', 'scenarios', 'tradeoffs'])
224
+ base[k] = groundFindings(j[k], cites);
225
+ base.status = 'ok';
226
+ }
227
+ catch (e) {
228
+ if (e instanceof CapError)
229
+ throw e;
230
+ base.status = 'model-error';
231
+ log(`research "${clip(topic.subtopic, 60)}": synthesis failed (${e?.message ?? e}); demand data kept`);
232
+ }
233
+ writeCache(o.projectDir, base);
234
+ return base;
235
+ }
236
+ /** Compact, citation-tagged text block of one topic's research (for briefs, scripts and truth-check sources). */
237
+ export function researchDigest(r) {
238
+ const sec = (label, fs) => (fs.length ? `${label}:\n${fs.map(f => `- ${f.text} (${f.cites.join(', ')})`).join('\n')}` : '');
239
+ return [
240
+ `RESEARCH — ${r.topic.subtopic}`,
241
+ `Demand: ${r.demand.note}`,
242
+ ...r.demand.stories.map(s => `- HN story: "${s.title}" ${s.points} pts / ${s.num_comments} comments (${s.url})`),
243
+ sec('Who is living through it', r.viewers),
244
+ sec('Conventional approaches', r.conventions),
245
+ sec('Common problems / failure points', r.problems),
246
+ sec('Concrete scenarios', r.scenarios),
247
+ sec('Tradeoffs', r.tradeoffs),
248
+ ].filter(Boolean).join('\n');
249
+ }
250
+ /** Every URL a piece's research actually cites (for source_anchors). */
251
+ export function researchCitations(r) {
252
+ const used = new Set([r.viewers, r.conventions, r.problems, r.scenarios, r.tradeoffs].flat().flatMap(f => f.cites));
253
+ for (const s of r.demand.stories)
254
+ used.add(s.url);
255
+ return r.citations.filter(c => used.has(c.url)).map(c => ({ url: c.url, title: c.title, source: c.source }));
256
+ }
@@ -0,0 +1,85 @@
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 { type DbRecord } from './lens-db.js';
18
+ import { type LibraryPeriod } from './lens-library-select.js';
19
+ import { type CapKind } from './content-llm.js';
20
+ import { type ContentManifest, type PieceEntry } from './content-manifest.js';
21
+ import { type Brief } from './content-brief.js';
22
+ import { type Script } from './content-script.js';
23
+ export { isContentPipelineEnabled } from './content-llm.js';
24
+ export type ContentRunStatus = 'disabled' | 'no-key' | 'no-db' | 'no-page' | 'already-done' | 'done' | 'partial' | 'capped' | 'dry' | 'no-pieces' | 'error' | 'failed';
25
+ export interface ContentRunOptions {
26
+ sessionId: string;
27
+ projectDir?: string | null;
28
+ cwd?: string;
29
+ /** The period the library step just handled (hand-off). Otherwise the newest period with a page is picked. */
30
+ period?: LibraryPeriod | null;
31
+ dry?: boolean;
32
+ /** Bypass the 7-day research cache for this run. */
33
+ researchRefresh?: boolean;
34
+ keepAlive?: () => void;
35
+ log?: (m: string) => void;
36
+ /** Wall-clock budget: no new model call starts after this many ms (default 25 min). */
37
+ budgetMs?: number;
38
+ }
39
+ /** Stop starting new calls after ~25 minutes; what is left resumes on the next run. */
40
+ export declare const CONTENT_BUDGET_MS: number;
41
+ export interface ContentPieceReport {
42
+ id: string;
43
+ tier: string;
44
+ status: PieceEntry['status'];
45
+ title: string;
46
+ flags: string[];
47
+ seconds: number | null;
48
+ citations: number;
49
+ file: string | null;
50
+ }
51
+ export interface ContentRunResult {
52
+ status: ContentRunStatus;
53
+ libraryDir: string | null;
54
+ period: {
55
+ index: number;
56
+ startRowId: number;
57
+ endRowId: number;
58
+ page: string;
59
+ } | null;
60
+ pieces: ContentPieceReport[];
61
+ costUsd: number;
62
+ /** 'time' = the wall-clock budget ran out (no new call started); the rest resumes next run. */
63
+ capHit: CapKind | null;
64
+ errors: string[];
65
+ }
66
+ /**
67
+ * Pick the period: the hand-off when given, else the newest of the last three
68
+ * periods that has a library page on disk and is not done here.
69
+ */
70
+ export declare function pickContentPeriod(db: string, sessionId: string, dir: string, m: ContentManifest, handoff?: LibraryPeriod | null): {
71
+ period: LibraryPeriod;
72
+ page: string;
73
+ } | {
74
+ period: null;
75
+ reason: 'no-page' | 'already-done';
76
+ };
77
+ /** The row range the page itself states (manual backfill pages can run past the segment end). */
78
+ export declare function pageRange(page: string, p: LibraryPeriod): {
79
+ from: number;
80
+ to: number;
81
+ };
82
+ /** Truth-check ground truth: the page, the rows the script draws on, and the research it speaks to. */
83
+ export declare function truthSources(page: string, records: DbRecord[], brief: Brief, script: Script, researchText: string): string;
84
+ /** Run Stage A once for one period. Never throws. */
85
+ export declare function runContentStep(o: ContentRunOptions): Promise<ContentRunResult>;