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,412 @@
1
+ /**
2
+ * content-llm.ts — Stage A's flags, spend caps and its ONE model-call path.
3
+ * OpenRouter ONLY (OPENROUTER_API_KEY) — never the Claude subscription / Anthropic SDK.
4
+ *
5
+ * Flags:
6
+ * OSBORN_CONTENT_PIPELINE default ON; 0/off/false/no = off. OSBORN_CONTENT_LENS=0 also turns it off.
7
+ * Caps (a hit = status "capped", never an error):
8
+ * OSBORN_CONTENT_PERIOD_USD default 0.75 (cumulative per period, kept in the content manifest)
9
+ * OSBORN_CONTENT_DAILY_USD default 2.00 (UTC day, per MACHINE: $OSBORN_HOME or ~/.osborn/content-spend-ledger.json,
10
+ * locked read-modify-write so concurrent workers across projects share one cap)
11
+ * OSBORN_CONTENT_MAX_PIECES default 4 (1 highlight + up to 3 how-tos)
12
+ * Every call reserves its WORST case (prompt + max output) against both caps up
13
+ * front and writes it to the ledger before the fetch; it is reconciled to the
14
+ * actual cost after. A failed call is charged the worst case, and a hard exit
15
+ * mid-call leaves the worst case in the ledger, so the caps stay hard.
16
+ * All outbound prompt text goes through the caller's scrub() first.
17
+ */
18
+ import { closeSync, fstatSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
19
+ import { randomBytes } from 'node:crypto';
20
+ import { homedir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { isContentLensEnabled } from './lens-launch.js';
23
+ const OPENROUTER = 'https://openrouter.ai/api/v1';
24
+ const OFF = ['0', 'off', 'false', 'no'];
25
+ export const LEDGER_FILE = 'content-spend-ledger.json';
26
+ export const DEFAULT_GEN_MODEL = 'minimax/minimax-m3';
27
+ /** Pinned judge (proven in videos-test-3/vt3-render.ts): model + provider endpoint, no fallbacks. */
28
+ export const CHECK_MODEL = 'deepseek/deepseek-chat';
29
+ export const CHECK_PROVIDER = 'deepinfra/fp4';
30
+ export const CHECK_PROVIDER_NAME = 'DeepInfra';
31
+ const CALL_TIMEOUT_MS = 4 * 60_000;
32
+ /** USD per token. Unknown models get a deliberately high price so the cap still binds. */
33
+ const PRICES = {
34
+ 'minimax/minimax-m3': { p: 0.3e-6, c: 1.2e-6 },
35
+ 'deepseek/deepseek-chat': { p: 0.5e-6, c: 1.5e-6 },
36
+ };
37
+ const UNKNOWN_PRICE = { p: 3e-6, c: 15e-6 };
38
+ export const priceFor = (model) => PRICES[model] ?? UNKNOWN_PRICE;
39
+ /** OFF when OSBORN_CONTENT_PIPELINE, OSBORN_CONTENT_LENS or OSBORN_CONTENT_INGEST is 0/off/false/no: ingest off ⇒ no LLM spend at all. */
40
+ export function isContentPipelineEnabled() {
41
+ if (!isContentLensEnabled())
42
+ return false;
43
+ const off = (k) => OFF.includes((process.env[k] ?? '').trim().toLowerCase());
44
+ return !off('OSBORN_CONTENT_PIPELINE') && !off('OSBORN_CONTENT_INGEST');
45
+ }
46
+ /**
47
+ * OSBORN_CONTENT_STRICT=1 restores hard blocks for the quality gates (structure,
48
+ * over-length tolerance, missing stake, audience). Default: those are advisory —
49
+ * the piece is still scripted and ingested as a draft with quality_flags attached.
50
+ * Dev-voice, truth-check, redaction, the outbound-query gate and the caps are hard either way.
51
+ */
52
+ export function isContentStrict() {
53
+ return ['1', 'on', 'true', 'yes'].includes((process.env.OSBORN_CONTENT_STRICT ?? '').trim().toLowerCase());
54
+ }
55
+ const envNum = (k, d) => {
56
+ const raw = (process.env[k] ?? '').trim();
57
+ const n = Number(raw);
58
+ return raw !== '' && Number.isFinite(n) && n >= 0 ? n : d;
59
+ };
60
+ export function contentCaps() {
61
+ // Official names first; the plan's earlier names (…_PERIOD_CAP_USD / …_DAY_CAP_USD) are accepted as fallback aliases.
62
+ return {
63
+ periodUsd: envNum('OSBORN_CONTENT_PERIOD_USD', envNum('OSBORN_CONTENT_PERIOD_CAP_USD', 0.75)),
64
+ dailyUsd: envNum('OSBORN_CONTENT_DAILY_USD', envNum('OSBORN_CONTENT_DAY_CAP_USD', 2.0)),
65
+ maxPieces: Math.max(0, Math.floor(envNum('OSBORN_CONTENT_MAX_PIECES', 4))),
66
+ };
67
+ }
68
+ export const genModel = () => (process.env.OSBORN_CONTENT_MODEL || '').trim() || DEFAULT_GEN_MODEL;
69
+ const today = (now = Date.now()) => new Date(now).toISOString().slice(0, 10);
70
+ /** Machine-scoped ledger dir: $OSBORN_HOME, else ~/.osborn. The DAY cap is per machine, not per project. */
71
+ export function ledgerDir() {
72
+ return (process.env.OSBORN_HOME || '').trim() || join(homedir(), '.osborn');
73
+ }
74
+ export const ledgerPath = () => join(ledgerDir(), LEDGER_FILE);
75
+ function readLedgerAt(path) {
76
+ try {
77
+ const o = JSON.parse(readFileSync(path, 'utf-8'));
78
+ if (o && typeof o.days === 'object' && !Array.isArray(o.days))
79
+ return { days: o.days };
80
+ }
81
+ catch {
82
+ /* missing → empty */
83
+ }
84
+ return { days: {} };
85
+ }
86
+ /** The machine ledger (UTC day → USD). */
87
+ export function readLedger() {
88
+ return readLedgerAt(ledgerPath());
89
+ }
90
+ /** Spend recorded (incl. open reservations) on this machine today, across every project. */
91
+ export function daySpent(now = Date.now()) {
92
+ return Number(readLedger().days[today(now)]) || 0;
93
+ }
94
+ const LOCK_STALE_MS = 30_000;
95
+ const LOCK_TRIES = 40;
96
+ const LOCK_WAIT_MS = 50;
97
+ const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
98
+ const lockToken = () => `${process.pid}.${randomBytes(8).toString('hex')}`;
99
+ /**
100
+ * Remove `path` if it is stale: rename it to a unique name (only ONE caller's
101
+ * rename can succeed), check the renamed file is the same stale file we judged
102
+ * (inode, mtime, content), and put it back with link() if a fresh one was
103
+ * swapped in (link fails rather than clobbers). True only to the caller that
104
+ * removed a stale file; any error = keep waiting.
105
+ */
106
+ function removeIfStale(path) {
107
+ let seen;
108
+ try {
109
+ const fd = openSync(path, 'r');
110
+ try {
111
+ const st = fstatSync(fd);
112
+ if (Date.now() - st.mtimeMs <= LOCK_STALE_MS)
113
+ return false;
114
+ seen = { ino: st.ino, mtimeMs: st.mtimeMs, body: readFileSync(fd, 'utf-8') };
115
+ }
116
+ finally {
117
+ closeSync(fd);
118
+ }
119
+ }
120
+ catch {
121
+ return false; // vanished between calls
122
+ }
123
+ const claimed = `${path}.stale.${process.pid}.${randomBytes(6).toString('hex')}`;
124
+ try {
125
+ renameSync(path, claimed);
126
+ }
127
+ catch {
128
+ return false; // another caller claimed it first
129
+ }
130
+ let same = false;
131
+ try {
132
+ const st = statSync(claimed);
133
+ same = st.ino === seen.ino && st.mtimeMs === seen.mtimeMs && readFileSync(claimed, 'utf-8') === seen.body;
134
+ }
135
+ catch {
136
+ /* treat as not ours */
137
+ }
138
+ if (!same) {
139
+ try {
140
+ linkSync(claimed, path); // a live file was swapped in: restore it
141
+ }
142
+ catch {
143
+ /* a newer one already exists */
144
+ }
145
+ }
146
+ try {
147
+ unlinkSync(claimed);
148
+ }
149
+ catch {
150
+ /* already gone */
151
+ }
152
+ return same;
153
+ }
154
+ /**
155
+ * Claim a stale ledger lock. The judge-then-rename step runs under a short
156
+ * `<lock>.reap` guard so two waiters can never both act on one stale lock (or
157
+ * rename away the fresh lock the other just created). A guard left by a crash
158
+ * goes stale too and is removed the same way.
159
+ */
160
+ function claimStaleLock(lock) {
161
+ const guard = `${lock}.reap`;
162
+ let g;
163
+ try {
164
+ g = openSync(guard, 'wx');
165
+ }
166
+ catch {
167
+ removeIfStale(guard);
168
+ return false;
169
+ }
170
+ const token = lockToken();
171
+ try {
172
+ writeSync(g, token);
173
+ return removeIfStale(lock);
174
+ }
175
+ catch {
176
+ return false;
177
+ }
178
+ finally {
179
+ closeSync(g);
180
+ releaseLock(guard, token);
181
+ }
182
+ }
183
+ /** Release only a lock this holder owns (its token is still in the file). */
184
+ function releaseLock(lock, token) {
185
+ try {
186
+ if (readFileSync(lock, 'utf-8') === token)
187
+ unlinkSync(lock);
188
+ }
189
+ catch {
190
+ /* already gone */
191
+ }
192
+ }
193
+ /**
194
+ * Atomic read-modify-write of the machine ledger under a short `wx` lock file
195
+ * holding a unique token (stale after 30s, claimed via rename so only one waiter
196
+ * can remove it). `fn` returns the new ledger, or null to leave it unchanged.
197
+ * Returns false when the lock could not be taken (callers decide fail-closed vs best-effort).
198
+ */
199
+ function withLedger(fn, tries = LOCK_TRIES) {
200
+ const dir = ledgerDir();
201
+ mkdirSync(dir, { recursive: true });
202
+ const path = join(dir, LEDGER_FILE);
203
+ const lock = `${path}.lock`;
204
+ for (let i = 0; i < tries; i++) {
205
+ let fd;
206
+ try {
207
+ fd = openSync(lock, 'wx');
208
+ }
209
+ catch (e) {
210
+ if (e?.code !== 'EEXIST')
211
+ throw e;
212
+ if (claimStaleLock(lock))
213
+ continue; // we removed the stale lock: retry the wx create now
214
+ sleepSync(LOCK_WAIT_MS);
215
+ continue;
216
+ }
217
+ const token = lockToken();
218
+ try {
219
+ writeSync(fd, token);
220
+ const next = fn(readLedgerAt(path));
221
+ if (next) {
222
+ const cutoff = today(Date.now() - 14 * 86_400_000);
223
+ for (const k of Object.keys(next.days))
224
+ if (k < cutoff)
225
+ delete next.days[k];
226
+ const tmp = `${path}.${process.pid}.tmp`;
227
+ writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', 'utf-8');
228
+ renameSync(tmp, path);
229
+ }
230
+ return true;
231
+ }
232
+ finally {
233
+ closeSync(fd);
234
+ releaseLock(lock, token);
235
+ }
236
+ }
237
+ return false;
238
+ }
239
+ export class CapError extends Error {
240
+ cap;
241
+ needUsd;
242
+ constructor(cap, needUsd) {
243
+ super(`content cap reached (${cap})`);
244
+ this.cap = cap;
245
+ this.needUsd = needUsd;
246
+ }
247
+ }
248
+ /**
249
+ * Per-run spend guard: the per-period cap (cumulative, per project, in memory +
250
+ * content manifest), the per-day cap (per MACHINE, in the locked ledger) and an
251
+ * optional wall-clock deadline after which no new call starts.
252
+ */
253
+ export class SpendGuard {
254
+ projectDir;
255
+ caps;
256
+ periodUsd;
257
+ deadline;
258
+ runUsd = 0;
259
+ calls = 0;
260
+ capHit = null;
261
+ constructor(
262
+ /** Kept for callers/logs; the day ledger is machine-scoped, not under this dir. */
263
+ projectDir, caps,
264
+ /** Spend already recorded for this period by earlier runs. */
265
+ periodUsd = 0,
266
+ /** Epoch ms after which reserve() refuses new calls (CapError 'time'). */
267
+ deadline = null) {
268
+ this.projectDir = projectDir;
269
+ this.caps = caps;
270
+ this.periodUsd = periodUsd;
271
+ this.deadline = deadline;
272
+ }
273
+ /**
274
+ * Reserve a worst-case spend of `usd`: throws CapError if it would cross a cap
275
+ * (or the deadline passed); otherwise writes it to the machine ledger BEFORE the
276
+ * call, so a hard exit mid-call still counts it. Fails closed if the ledger lock
277
+ * cannot be taken.
278
+ */
279
+ reserve(usd) {
280
+ if (this.deadline !== null && Date.now() >= this.deadline)
281
+ throw this.hit('time', usd);
282
+ if (this.periodUsd + usd > this.caps.periodUsd)
283
+ throw this.hit('period', usd);
284
+ const day = today();
285
+ let over = false;
286
+ let locked;
287
+ try {
288
+ locked = withLedger(l => {
289
+ const cur = Number(l.days[day]) || 0;
290
+ if (cur + usd > this.caps.dailyUsd) {
291
+ over = true;
292
+ return null;
293
+ }
294
+ l.days[day] = Number((cur + usd).toFixed(6));
295
+ return l;
296
+ });
297
+ }
298
+ catch {
299
+ locked = false;
300
+ }
301
+ if (over || !locked)
302
+ throw this.hit('day', usd);
303
+ return { day, usd, inLedger: true, settled: false };
304
+ }
305
+ hit(cap, usd) {
306
+ this.capHit = cap;
307
+ return new CapError(cap, usd);
308
+ }
309
+ /** Reconcile a reservation to the actual cost (once). The in-memory totals always move; the ledger is best-effort. */
310
+ settle(r, actualUsd) {
311
+ if (r.settled)
312
+ return;
313
+ r.settled = true;
314
+ const usd = actualUsd > 0 ? actualUsd : 0;
315
+ this.runUsd += usd;
316
+ this.periodUsd += usd;
317
+ if (!r.inLedger)
318
+ return;
319
+ const delta = usd - r.usd;
320
+ if (delta === 0)
321
+ return;
322
+ try {
323
+ // Long wait for the lock; if still held, write anyway — losing a reconcile would under-count (actual > worst).
324
+ const apply = (l) => {
325
+ l.days[r.day] = Math.max(0, Number(((Number(l.days[r.day]) || 0) + delta).toFixed(6)));
326
+ return l;
327
+ };
328
+ if (!withLedger(apply, LOCK_TRIES * 4) && delta > 0) {
329
+ const path = ledgerPath();
330
+ const tmp = `${path}.${process.pid}.tmp`;
331
+ writeFileSync(tmp, JSON.stringify(apply(readLedgerAt(path)), null, 2) + '\n', 'utf-8');
332
+ renameSync(tmp, path);
333
+ }
334
+ }
335
+ catch {
336
+ /* ledger write failure never blocks; the in-memory guard still holds */
337
+ }
338
+ }
339
+ }
340
+ /** Lenient JSON extraction: strips fences, takes the outermost {...} or [...]. Null when unparseable. */
341
+ export function parseJsonLoose(s) {
342
+ const t = String(s ?? '').replace(/^\s*```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim();
343
+ for (const [o, c] of [['{', '}'], ['[', ']']]) {
344
+ const a = t.indexOf(o);
345
+ const b = t.lastIndexOf(c);
346
+ if (a >= 0 && b > a) {
347
+ try {
348
+ return JSON.parse(t.slice(a, b + 1));
349
+ }
350
+ catch {
351
+ /* try the other shape */
352
+ }
353
+ }
354
+ }
355
+ return null;
356
+ }
357
+ /** One chat call → JSON. Throws CapError on a cap, Error on HTTP/timeout/provider mismatch. */
358
+ export async function llmJson(o, call) {
359
+ const model = call.model ?? genModel();
360
+ const system = o.scrub(call.system);
361
+ const user = o.scrub(call.user);
362
+ const price = priceFor(model);
363
+ const estPrompt = Math.ceil((system.length + user.length) / 3.5);
364
+ const worst = estPrompt * price.p + call.maxOut * price.c;
365
+ const res = o.guard.reserve(worst);
366
+ o.guard.calls++;
367
+ const ctrl = new AbortController();
368
+ const timer = setTimeout(() => ctrl.abort(), CALL_TIMEOUT_MS);
369
+ try {
370
+ const body = {
371
+ model,
372
+ temperature: call.temperature ?? 0.2,
373
+ max_tokens: call.maxOut,
374
+ usage: { include: true },
375
+ messages: [
376
+ { role: 'system', content: system },
377
+ { role: 'user', content: user },
378
+ ],
379
+ provider: call.pin ? { order: [call.pin.endpoint], allow_fallbacks: false } : { sort: 'price' },
380
+ };
381
+ if (!call.pin)
382
+ body.reasoning = { enabled: false };
383
+ const resp = await (o.fetchImpl ?? fetch)(`${OPENROUTER}/chat/completions`, {
384
+ method: 'POST',
385
+ signal: ctrl.signal,
386
+ headers: { Authorization: `Bearer ${o.apiKey}`, 'Content-Type': 'application/json' },
387
+ body: JSON.stringify(body),
388
+ });
389
+ if (!resp.ok)
390
+ throw new Error(`OpenRouter HTTP ${resp.status}`);
391
+ const data = await resp.json();
392
+ const pt = Number(data?.usage?.prompt_tokens) || estPrompt;
393
+ const ct = Number(data?.usage?.completion_tokens) || 0;
394
+ const reported = Number(data?.usage?.cost);
395
+ const cost = Number.isFinite(reported) && reported > 0 ? reported : pt * price.p + ct * price.c;
396
+ o.guard.settle(res, cost);
397
+ const provider = String(data?.provider ?? '');
398
+ if (call.pin && provider !== call.pin.name)
399
+ throw new Error(`${call.what}: served by ${provider || '?'}, expected pinned ${call.pin.name}`);
400
+ const content = String(data?.choices?.[0]?.message?.content ?? '');
401
+ return { content, json: parseJsonLoose(content), costUsd: cost, provider, model, finish: String(data?.choices?.[0]?.finish_reason ?? '') };
402
+ }
403
+ catch (e) {
404
+ if (e instanceof CapError)
405
+ throw e;
406
+ o.guard.settle(res, worst);
407
+ throw e?.name === 'AbortError' ? new Error(`${call.what}: timeout`) : e;
408
+ }
409
+ finally {
410
+ clearTimeout(timer);
411
+ }
412
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * content-manifest.ts — Stage A per-period / per-piece state in
3
+ * <library dir>/.content-manifest.json
4
+ * mirroring lens-library.ts's .library-manifest.json (same dir, keyed on the
5
+ * period's opening row id).
6
+ *
7
+ * Idempotency rules (content-run.ts):
8
+ * - period "done" → skipped, no model call, no spend.
9
+ * - piece "ready" → checks passed; only the ingest is (re)tried, no model call.
10
+ * - piece "ingested" / "blocked" → terminal. Blocked is NEVER auto-fixed or retried.
11
+ * - piece "error" / "capped" → regenerated next run (research + truth-check caches still apply).
12
+ * - the period's plan (topics, research refs, briefs) is stored once and reused.
13
+ * Never throws on read (missing / unreadable → empty).
14
+ */
15
+ import type { IngestState } from './lens-ingest.js';
16
+ export declare const CONTENT_MANIFEST_FILE = ".content-manifest.json";
17
+ /** Script drafts on disk: <library dir>/content/period-NN/<piece>.{json,md} */
18
+ export declare const CONTENT_DIR = "content";
19
+ export type PieceKind = 'highlight' | 'howto';
20
+ export type PieceStatus = 'ready' | 'ingested' | 'blocked' | 'error' | 'capped';
21
+ export type PeriodStatus = 'done' | 'partial' | 'capped' | 'error' | 'dry' | 'no-pieces';
22
+ export interface CheckSummary {
23
+ prepass: {
24
+ ok: boolean;
25
+ flags: string[];
26
+ rows: {
27
+ line: number;
28
+ row: number;
29
+ match: string;
30
+ }[];
31
+ };
32
+ truth: {
33
+ ok: boolean;
34
+ flags: string[];
35
+ model: string;
36
+ provider: string;
37
+ cacheKey: string;
38
+ cached: boolean;
39
+ } | null;
40
+ audience: {
41
+ ok: boolean;
42
+ flags: string[];
43
+ model: string;
44
+ provider: string;
45
+ } | null;
46
+ length: {
47
+ ok: boolean;
48
+ seconds: number;
49
+ min: number;
50
+ max: number;
51
+ };
52
+ /** WHAT-WORKS deterministic gate: stake present, causes vs duration, terms/min, no spoken versions/codes. */
53
+ structure?: {
54
+ ok: boolean;
55
+ flags: string[];
56
+ causes: number;
57
+ termsPerMin: number;
58
+ };
59
+ }
60
+ export interface PieceEntry {
61
+ id: string;
62
+ kind: PieceKind;
63
+ status: PieceStatus;
64
+ title: string;
65
+ /** Every HARD flag from every check (empty unless blocked). */
66
+ flags: string[];
67
+ /** Advisory quality flags (piece still scripted + ingested as a draft); see OSBORN_CONTENT_STRICT. */
68
+ warnings?: string[];
69
+ checks?: CheckSummary;
70
+ /** Stable per piece (session + period + piece id), so later media attach hits the same row. */
71
+ contentHash?: string;
72
+ ingest?: IngestState;
73
+ /** Script JSON on disk, relative to the library dir. */
74
+ file?: string;
75
+ costUsd: number;
76
+ errors: string[];
77
+ updatedAt: string;
78
+ }
79
+ export interface PeriodEntry {
80
+ startRowId: number;
81
+ endRowId: number;
82
+ index: number;
83
+ /** The library page this period's content was built from (file name in the library dir). */
84
+ page: string;
85
+ status: PeriodStatus;
86
+ /** Cumulative spend for this period across runs (the per-period cap reads this). */
87
+ costUsd: number;
88
+ /** Topics + briefs, stored once so a rerun never re-plans. */
89
+ plan?: unknown;
90
+ pieces: Record<string, PieceEntry>;
91
+ errors: string[];
92
+ updatedAt: string;
93
+ }
94
+ export interface ContentManifest {
95
+ version: 1;
96
+ sessionId: string;
97
+ /** key = String(startRowId) */
98
+ periods: Record<string, PeriodEntry>;
99
+ }
100
+ export declare function readContentManifest(dir: string, sessionId: string): ContentManifest;
101
+ export declare function writeAtomic(path: string, text: string): void;
102
+ export declare function writeContentManifest(dir: string, m: ContentManifest): void;
103
+ export declare const isPieceTerminal: (p: PieceEntry | undefined) => boolean;
104
+ /** Checks done (ready / ingested / blocked): never regenerated. */
105
+ export declare const isPieceSettled: (p: PieceEntry | undefined) => boolean;
106
+ /**
107
+ * Period status from its pieces. `ingestPending` = some ready piece still needs an
108
+ * ingest that can be retried for free; the period then stays "partial".
109
+ */
110
+ export declare function periodStatusFor(e: PeriodEntry, o: {
111
+ dry: boolean;
112
+ ingestPending: boolean;
113
+ planned: boolean;
114
+ }): PeriodStatus;
115
+ export declare const periodDirName: (index: number) => string;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * content-manifest.ts — Stage A per-period / per-piece state in
3
+ * <library dir>/.content-manifest.json
4
+ * mirroring lens-library.ts's .library-manifest.json (same dir, keyed on the
5
+ * period's opening row id).
6
+ *
7
+ * Idempotency rules (content-run.ts):
8
+ * - period "done" → skipped, no model call, no spend.
9
+ * - piece "ready" → checks passed; only the ingest is (re)tried, no model call.
10
+ * - piece "ingested" / "blocked" → terminal. Blocked is NEVER auto-fixed or retried.
11
+ * - piece "error" / "capped" → regenerated next run (research + truth-check caches still apply).
12
+ * - the period's plan (topics, research refs, briefs) is stored once and reused.
13
+ * Never throws on read (missing / unreadable → empty).
14
+ */
15
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ export const CONTENT_MANIFEST_FILE = '.content-manifest.json';
18
+ /** Script drafts on disk: <library dir>/content/period-NN/<piece>.{json,md} */
19
+ export const CONTENT_DIR = 'content';
20
+ export function readContentManifest(dir, sessionId) {
21
+ try {
22
+ const o = JSON.parse(readFileSync(join(dir, CONTENT_MANIFEST_FILE), 'utf-8'));
23
+ if (o && typeof o.periods === 'object' && !Array.isArray(o.periods))
24
+ return { version: 1, sessionId, periods: o.periods };
25
+ }
26
+ catch {
27
+ /* missing / unreadable → empty */
28
+ }
29
+ return { version: 1, sessionId, periods: {} };
30
+ }
31
+ export function writeAtomic(path, text) {
32
+ const tmp = `${path}.${process.pid}.tmp`;
33
+ writeFileSync(tmp, text, 'utf-8');
34
+ renameSync(tmp, path);
35
+ }
36
+ export function writeContentManifest(dir, m) {
37
+ mkdirSync(dir, { recursive: true });
38
+ writeAtomic(join(dir, CONTENT_MANIFEST_FILE), JSON.stringify(m, null, 2) + '\n');
39
+ }
40
+ export const isPieceTerminal = (p) => !!p && (p.status === 'ingested' || p.status === 'blocked');
41
+ /** Checks done (ready / ingested / blocked): never regenerated. */
42
+ export const isPieceSettled = (p) => !!p && (isPieceTerminal(p) || p.status === 'ready');
43
+ /**
44
+ * Period status from its pieces. `ingestPending` = some ready piece still needs an
45
+ * ingest that can be retried for free; the period then stays "partial".
46
+ */
47
+ export function periodStatusFor(e, o) {
48
+ const ps = Object.values(e.pieces);
49
+ if (o.planned && ps.length === 0)
50
+ return 'no-pieces';
51
+ if (ps.some(p => p.status === 'capped'))
52
+ return 'capped';
53
+ if (!o.planned || ps.some(p => p.status === 'error'))
54
+ return ps.some(p => isPieceSettled(p)) ? 'partial' : 'error';
55
+ if (o.dry)
56
+ return 'dry';
57
+ return o.ingestPending ? 'partial' : 'done';
58
+ }
59
+ export const periodDirName = (index) => `period-${String(index).padStart(2, '0')}`;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * content-query-gate.ts — deterministic gate on every search query that leaves
3
+ * the machine (HN Algolia stories/comments, GitHub issue search). Runs AFTER
4
+ * the scrubber, as a second, rule-based line: a query either passes untouched
5
+ * or is dropped whole (never "repaired").
6
+ *
7
+ * A query is DROPPED when it:
8
+ * - has any character outside [A-Za-z0-9 .+#-] (this covers / \ @ :// _ etc.)
9
+ * - has a token containing "_" or CamelCase in the middle (refreshToken, MyApp)
10
+ * - has a token equal to the project basename or a cwd path segment, or
11
+ * contains such a segment as a phrase (acme-billing ⇔ "acme billing"), case-insensitive
12
+ * - is not 2-5 plain words
13
+ *
14
+ * The allowlist is structural only: it cannot recognise private plain words
15
+ * (client names, IPs, people), so it relies on the scrubber running first.
16
+ */
17
+ /** Private path terms: every segment of each path, plus each path's basename (and the raw value of non-path terms). */
18
+ export declare function privateSegments(paths: (string | null | undefined)[]): string[];
19
+ /** Returns the query (whitespace-collapsed) if it may leave the machine, else null. */
20
+ export declare function gateQuery(q: string, privateTerms?: string[]): string | null;
21
+ /** Gate a list: survivors only, de-duplicated, order kept. */
22
+ export declare function gateQueries(qs: string[], privateTerms?: string[]): string[];
@@ -0,0 +1,78 @@
1
+ /**
2
+ * content-query-gate.ts — deterministic gate on every search query that leaves
3
+ * the machine (HN Algolia stories/comments, GitHub issue search). Runs AFTER
4
+ * the scrubber, as a second, rule-based line: a query either passes untouched
5
+ * or is dropped whole (never "repaired").
6
+ *
7
+ * A query is DROPPED when it:
8
+ * - has any character outside [A-Za-z0-9 .+#-] (this covers / \ @ :// _ etc.)
9
+ * - has a token containing "_" or CamelCase in the middle (refreshToken, MyApp)
10
+ * - has a token equal to the project basename or a cwd path segment, or
11
+ * contains such a segment as a phrase (acme-billing ⇔ "acme billing"), case-insensitive
12
+ * - is not 2-5 plain words
13
+ *
14
+ * The allowlist is structural only: it cannot recognise private plain words
15
+ * (client names, IPs, people), so it relies on the scrubber running first.
16
+ */
17
+ import { basename } from 'node:path';
18
+ const ALLOWED = /^[A-Za-z0-9 .+#-]+$/;
19
+ const CAMEL_MID = /[a-z0-9][A-Z]/;
20
+ const MIN_WORDS = 2;
21
+ const MAX_WORDS = 5;
22
+ const norm = (s) => s.toLowerCase().replace(/[-_.\s]+/g, ' ').trim();
23
+ /** Private path terms: every segment of each path, plus each path's basename (and the raw value of non-path terms). */
24
+ export function privateSegments(paths) {
25
+ const out = new Set();
26
+ for (const p of paths) {
27
+ const s = String(p ?? '').trim();
28
+ if (!s)
29
+ continue;
30
+ for (const seg of s.split(/[\\/]+/))
31
+ if (seg.trim())
32
+ out.add(seg.trim().toLowerCase());
33
+ const b = basename(s.replace(/[\\/]+$/, ''));
34
+ if (b)
35
+ out.add(b.toLowerCase());
36
+ }
37
+ return [...out];
38
+ }
39
+ /** Returns the query (whitespace-collapsed) if it may leave the machine, else null. */
40
+ export function gateQuery(q, privateTerms = []) {
41
+ const s = String(q ?? '').replace(/\s+/g, ' ').trim();
42
+ if (!s || !ALLOWED.test(s))
43
+ return null;
44
+ if (/:\/\/|[\\/@_]/.test(s))
45
+ return null;
46
+ const tokens = s.split(' ');
47
+ if (tokens.length < MIN_WORDS || tokens.length > MAX_WORDS)
48
+ return null;
49
+ for (const t of tokens) {
50
+ if (!/[A-Za-z0-9]/.test(t))
51
+ return null;
52
+ if (t.includes('_') || CAMEL_MID.test(t))
53
+ return null;
54
+ }
55
+ const lowTokens = new Set(tokens.map(t => t.toLowerCase()));
56
+ const phrase = ` ${norm(s)} `;
57
+ for (const term of privateTerms) {
58
+ const raw = term.trim().toLowerCase();
59
+ if (!raw)
60
+ continue;
61
+ if (lowTokens.has(raw))
62
+ return null;
63
+ const n = norm(raw);
64
+ if (n && phrase.includes(` ${n} `))
65
+ return null;
66
+ }
67
+ return s;
68
+ }
69
+ /** Gate a list: survivors only, de-duplicated, order kept. */
70
+ export function gateQueries(qs, privateTerms = []) {
71
+ const out = [];
72
+ for (const q of qs) {
73
+ const g = gateQuery(q, privateTerms);
74
+ if (g && !out.some(o => o.toLowerCase() === g.toLowerCase()))
75
+ out.push(g);
76
+ }
77
+ return out;
78
+ }