osborn 0.9.249 → 0.9.251
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.
- package/dist/claude-llm.js +13 -0
- package/dist/content/compaction-lens-worker.d.ts +23 -0
- package/dist/content/compaction-lens-worker.js +188 -0
- package/dist/content/compaction-lens.d.ts +106 -0
- package/dist/content/compaction-lens.js +222 -0
- package/dist/content/lens-audio.d.ts +38 -0
- package/dist/content/lens-audio.js +105 -0
- package/dist/content/lens-clip-arc.d.ts +80 -0
- package/dist/content/lens-clip-arc.js +148 -0
- package/dist/content/lens-clip-formats.d.ts +58 -0
- package/dist/content/lens-clip-formats.js +175 -0
- package/dist/content/lens-clip-search.d.ts +51 -0
- package/dist/content/lens-clip-search.js +138 -0
- package/dist/content/lens-clip-window.d.ts +72 -0
- package/dist/content/lens-clip-window.js +257 -0
- package/dist/content/lens-db.d.ts +70 -0
- package/dist/content/lens-db.js +170 -0
- package/dist/content/lens-hn.d.ts +19 -0
- package/dist/content/lens-hn.js +69 -0
- package/dist/content/lens-ingest.d.ts +67 -0
- package/dist/content/lens-ingest.js +155 -0
- package/dist/content/lens-launch.d.ts +46 -0
- package/dist/content/lens-launch.js +111 -0
- package/dist/content/lens-library-index.d.ts +30 -0
- package/dist/content/lens-library-index.js +79 -0
- package/dist/content/lens-library-select.d.ts +59 -0
- package/dist/content/lens-library-select.js +77 -0
- package/dist/content/lens-library.d.ts +92 -0
- package/dist/content/lens-library.js +179 -0
- package/dist/content/lens-model.d.ts +64 -0
- package/dist/content/lens-model.js +193 -0
- package/dist/content/lens-paths.d.ts +43 -0
- package/dist/content/lens-paths.js +166 -0
- package/dist/content/lens-period-block.d.ts +63 -0
- package/dist/content/lens-period-block.js +99 -0
- package/dist/content/lens-period-boundaries.d.ts +13 -0
- package/dist/content/lens-period-boundaries.js +40 -0
- package/dist/content/lens-period-render.d.ts +28 -0
- package/dist/content/lens-period-render.js +70 -0
- package/dist/content/lens-period.d.ts +106 -0
- package/dist/content/lens-period.js +205 -0
- package/dist/content/lens-profile.d.ts +47 -0
- package/dist/content/lens-profile.js +95 -0
- package/dist/content/lens-quotes.d.ts +103 -0
- package/dist/content/lens-quotes.js +186 -0
- package/dist/content/lens-redact.d.ts +61 -0
- package/dist/content/lens-redact.js +187 -0
- package/dist/content/lens-strip.d.ts +33 -0
- package/dist/content/lens-strip.js +132 -0
- package/dist/content/period-run.d.ts +30 -0
- package/dist/content/period-run.js +135 -0
- package/dist/content/render-ws-data.d.ts +8 -0
- package/dist/content/render-ws-data.js +108 -0
- package/dist/content/render-ws-screens.d.ts +25 -0
- package/dist/content/render-ws-screens.js +189 -0
- package/dist/content/render-ws-story.d.ts +1 -0
- package/dist/content/render-ws-story.js +233 -0
- package/dist/content/transcript-sanitizer.d.ts +195 -0
- package/dist/content/transcript-sanitizer.js +515 -0
- package/dist/index.js +34 -0
- package/package.json +1 -1
- package/test-results/.last-run.json +0 -4
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render-ws-story.ts — "one missing header" short: code-server WebSocket 1006 behind a reverse proxy.
|
|
3
|
+
*
|
|
4
|
+
* Stages (STAGE env): screens | check | all (default all)
|
|
5
|
+
* 1. screens: real-artifact screens (code, logs, diagrams) -> PNG via sharp/librsvg
|
|
6
|
+
* 2. check: truth-check (same guard prompt as pipeline-e2e.ts): draft + source records -> ungrounded claims
|
|
7
|
+
* 3. narration per line via OpenRouter TTS (mai-voice-2.1-flash, en-US-Ethan) -> cue timings
|
|
8
|
+
* 4. per-screen segments with subtle zoom/drift, chunked burned captions, mux -> final mp4
|
|
9
|
+
* 5. verify (probe + frame strip), upload with unique prefix, re-download + probe
|
|
10
|
+
* Run: npx tsx agent/src/content/render-ws-story.ts (reruns reuse cached TTS so they never re-spend)
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { redactSecrets } from './transcript-sanitizer.js';
|
|
17
|
+
import { SCRIPT, CAPTIONS, SCREENS, LINE_SCREENS, PROVENANCE } from './render-ws-data.js';
|
|
18
|
+
import { pngPath } from './render-ws-screens.js';
|
|
19
|
+
const SESSION_DIR = '/workspace/.claude/projects/-workspace/osb/c97588f4-5760-4b5b-b789-ab5f65aaed29';
|
|
20
|
+
const OUT = path.join(SESSION_DIR, 'render-ws-1006');
|
|
21
|
+
const FONTS = path.join(OUT, 'fonts');
|
|
22
|
+
process.env.FONTCONFIG_FILE = path.join(FONTS, 'fonts.conf');
|
|
23
|
+
const FFMPEG = '/workspace/.local/bin/ffmpeg', FFPROBE = '/workspace/.local/bin/ffprobe';
|
|
24
|
+
const OR = 'https://openrouter.ai/api/v1';
|
|
25
|
+
const KEY = process.env.OPENROUTER_API_KEY || '';
|
|
26
|
+
const TTS_MODEL = 'microsoft/mai-voice-2.1-flash', TTS_VOICE = 'en-US-Ethan:MAI-Voice-2.1-Flash';
|
|
27
|
+
const TTS_PRICE_PER_CHAR = 0.000015;
|
|
28
|
+
const CHECK_MODEL = 'deepseek/deepseek-chat';
|
|
29
|
+
const COST_CAP = 1;
|
|
30
|
+
const GAP = 0.3;
|
|
31
|
+
const TEMPO = Number(process.env.TEMPO || 1.12);
|
|
32
|
+
const STAGE = process.env.STAGE || 'all';
|
|
33
|
+
const log = (...a) => console.log('[ws-story]', ...a);
|
|
34
|
+
const ff = (args) => execFileSync(FFMPEG, ['-y', '-hide_banner', '-loglevel', 'error', ...args], { stdio: 'inherit' });
|
|
35
|
+
const dur = (f) => Number(execFileSync(FFPROBE, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', f]).toString().trim());
|
|
36
|
+
// Secrets + the session cookie shape + the machine hostname never leave this script.
|
|
37
|
+
const scrub = (s) => redactSecrets(s).replace(/osborn_ide=[0-9a-f]+/g, 'osborn_ide=[REDACTED]').replace(/osborn-[0-9a-f]{8}-v2/g, '<app>');
|
|
38
|
+
const writeText = (f, s) => fs.writeFileSync(f, scrub(s));
|
|
39
|
+
let spent = 0;
|
|
40
|
+
async function renderScreens() {
|
|
41
|
+
const sharp = (await import('sharp')).default;
|
|
42
|
+
fs.mkdirSync(path.join(OUT, 'screens'), { recursive: true });
|
|
43
|
+
for (const [name, svg] of Object.entries(SCREENS)) {
|
|
44
|
+
await sharp(Buffer.from(svg())).png().toFile(pngPath(OUT, name));
|
|
45
|
+
}
|
|
46
|
+
log('screens rendered:', Object.keys(SCREENS).length);
|
|
47
|
+
}
|
|
48
|
+
// ---- truth-check (prompt identical to pipeline-e2e.ts LEG 5) ----
|
|
49
|
+
const RECALL_QUERIES = [
|
|
50
|
+
'x-forwarded-host websocket 1006 code-server', 'Unexpected server response: 403', 'WS host/origin matching tests',
|
|
51
|
+
'FLY-EDGE HTTP status', 'raw node:net tunnel replaced http-proxy ws 1006', 'cloudflare tunnel worked hand-rolled tunnel bug is ours',
|
|
52
|
+
'x-forwarded-host 0.9.153 committed dist', 'terminal opens ls runs file tree loads', 'no Origin header falsely passes',
|
|
53
|
+
'WebSocket close with status code 1006 screenshot',
|
|
54
|
+
];
|
|
55
|
+
function buildSources() {
|
|
56
|
+
const lens = fs.readFileSync(path.join(SESSION_DIR, 'lens-backfill-full.md'), 'utf8');
|
|
57
|
+
const angle = lens.slice(lens.indexOf('#### Why your browser WebSocket'), lens.indexOf('#### Why your Claude Code tool-loop'));
|
|
58
|
+
const capStart = lens.indexOf('- **Root-caused WebSocket 1006');
|
|
59
|
+
const cap = lens.slice(capStart, lens.indexOf('- **Root-caused and fixed code-server zombie'));
|
|
60
|
+
const note = fs.readFileSync(path.join(SESSION_DIR, 'editor-followups-and-origin-fix.md'), 'utf8').split('## FOLLOW-UP LIST')[0];
|
|
61
|
+
const recall = RECALL_QUERIES.map((q) => execFileSync('osborn-recall', [q, '--top-k', '4', '--max-chars', '1800'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString()).join('\n');
|
|
62
|
+
const code = execFileSync('git', ['-C', '/workspace/osborn', 'log', '--oneline', '-G', 'x-forwarded-host|createProxyServer', '--', 'agent/src/index.ts']).toString();
|
|
63
|
+
return scrub(`## Lens angle 4\n${angle}\n## Lens capability\n${cap}\n## Session note\n${note}\n## git log (index.ts)\n${code}\n## Recall rows\n${recall}`);
|
|
64
|
+
}
|
|
65
|
+
function buildDraft() {
|
|
66
|
+
const onScreen = Object.keys(PROVENANCE).filter((k) => !/REENACTMENT/.test(PROVENANCE[k]));
|
|
67
|
+
return 'NARRATION (one line per caption):\n' + CAPTIONS.map((c, i) => `${i + 1}. ${c}`).join('\n') +
|
|
68
|
+
'\n\nON-SCREEN LABELS:\n- before · 0.9.152 (http-proxy swap, still 1006)\n- after · shipped in 0.9.153\n' +
|
|
69
|
+
'- Cloudflare quick tunnel -> terminal works; Own reverse proxy (Fly edge) -> WebSocket close 1006\n' +
|
|
70
|
+
'- Proxy forwards Host: 127.0.0.1:8300, X-Forwarded-Host never sent; code-server authenticateOrigin() -> 403 -> 1006\n' +
|
|
71
|
+
`- screens with real artifacts: ${onScreen.join(', ')}`;
|
|
72
|
+
}
|
|
73
|
+
async function truthCheck() {
|
|
74
|
+
const sources = buildSources(), draft = buildDraft();
|
|
75
|
+
writeText(path.join(OUT, 'truth-check-sources.txt'), sources);
|
|
76
|
+
const system = 'You are a strict fact-checking editor. You are given a DRAFT post and the SOURCE records (the ONLY ' +
|
|
77
|
+
'ground truth). Identify every factual claim, event, metric, or specific detail in the draft that is ' +
|
|
78
|
+
'NOT directly supported by the source records. Treat invented stories, numbers, or named mechanisms as ' +
|
|
79
|
+
'unsupported. Do NOT rewrite or soften the post. Return ONLY a JSON array of strings — each string one ' +
|
|
80
|
+
'specific ungrounded claim (quote or tight paraphrase from the draft). If every claim is grounded, return [].';
|
|
81
|
+
const user = `SOURCE RECORDS (ground truth, speaker-tagged):\n${sources}\n\nDRAFT POST:\n${draft}\n\nList the ungrounded claims as a JSON array of strings.`;
|
|
82
|
+
const r = await fetch(`${OR}/chat/completions`, {
|
|
83
|
+
method: 'POST', headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
|
|
84
|
+
body: JSON.stringify({ model: CHECK_MODEL, messages: [{ role: 'system', content: system }, { role: 'user', content: user }], temperature: 0, max_tokens: 900, usage: { include: true } }),
|
|
85
|
+
});
|
|
86
|
+
if (!r.ok)
|
|
87
|
+
throw new Error(`truth-check HTTP ${r.status}: ${(await r.text()).slice(0, 200)}`);
|
|
88
|
+
const j = await r.json();
|
|
89
|
+
spent += Number(j?.usage?.cost ?? 0);
|
|
90
|
+
let s = String(j?.choices?.[0]?.message?.content || '').replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '');
|
|
91
|
+
s = s.slice(s.indexOf('['), s.lastIndexOf(']') + 1);
|
|
92
|
+
const flags = JSON.parse(s).map((x) => String(x).trim()).filter(Boolean);
|
|
93
|
+
writeText(path.join(OUT, 'truth-check.md'), `# Truth-check (${CHECK_MODEL}, prompt = pipeline-e2e LEG 5)\n\ncost: $${Number(j?.usage?.cost ?? 0).toFixed(4)} · source chars: ${sources.length}\n\n` +
|
|
94
|
+
`## Draft checked\n\n${draft}\n\n## Ungrounded claims flagged (${flags.length})\n\n` + (flags.map((f, i) => `${i + 1}. ${f}`).join('\n') || '(none)') + '\n');
|
|
95
|
+
log(`truth-check: ${flags.length} flag(s)`, flags);
|
|
96
|
+
return flags;
|
|
97
|
+
}
|
|
98
|
+
async function tts(text, file) {
|
|
99
|
+
if (fs.existsSync(file) && fs.statSync(file).size > 1000)
|
|
100
|
+
return;
|
|
101
|
+
if (spent + text.length * TTS_PRICE_PER_CHAR > COST_CAP)
|
|
102
|
+
throw new Error('cost cap reached');
|
|
103
|
+
const r = await fetch(`${OR}/audio/speech`, {
|
|
104
|
+
method: 'POST', headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
|
|
105
|
+
body: JSON.stringify({ model: TTS_MODEL, input: text, voice: TTS_VOICE, response_format: 'mp3' }),
|
|
106
|
+
});
|
|
107
|
+
if (!r.ok)
|
|
108
|
+
throw new Error(`TTS ${r.status}: ${(await r.text()).slice(0, 200)}`);
|
|
109
|
+
fs.writeFileSync(file, Buffer.from(await r.arrayBuffer()));
|
|
110
|
+
spent += text.length * TTS_PRICE_PER_CHAR;
|
|
111
|
+
}
|
|
112
|
+
const assTime = (t) => {
|
|
113
|
+
const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60;
|
|
114
|
+
return `${h}:${String(m).padStart(2, '0')}:${s.toFixed(2).padStart(5, '0')}`;
|
|
115
|
+
};
|
|
116
|
+
/** Chunked captions (<=4 words, breaking at punctuation), timed proportionally to characters inside each spoken line. */
|
|
117
|
+
function buildAss(cues, file) {
|
|
118
|
+
const head = '[Script Info]\nScriptType: v4.00+\nPlayResX: 1080\nPlayResY: 1920\nWrapStyle: 0\n\n[V4+ Styles]\n' +
|
|
119
|
+
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n' +
|
|
120
|
+
'Style: Cap,Roboto Medium,70,&H00FFFFFF,&H00FFFFFF,&H00000000,&H50000000,0,0,0,0,100,100,0,0,3,18,0,2,80,80,300,1\n\n' +
|
|
121
|
+
'[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n';
|
|
122
|
+
const ev = [];
|
|
123
|
+
CAPTIONS.forEach((line, i) => {
|
|
124
|
+
const chunks = [];
|
|
125
|
+
let cur = [];
|
|
126
|
+
for (const w of line.split(/\s+/)) {
|
|
127
|
+
cur.push(w);
|
|
128
|
+
if (cur.length >= 4 || /[.,:;!?]$/.test(w)) {
|
|
129
|
+
chunks.push(cur.join(' '));
|
|
130
|
+
cur = [];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (cur.length)
|
|
134
|
+
chunks.push(cur.join(' '));
|
|
135
|
+
const total = chunks.reduce((a, c) => a + c.length + 2, 0);
|
|
136
|
+
let t = cues[i].start;
|
|
137
|
+
for (const c of chunks) {
|
|
138
|
+
const d = (cues[i].speech * (c.length + 2)) / total;
|
|
139
|
+
ev.push(`Dialogue: 0,${assTime(t)},${assTime(t + d)},Cap,,0,0,0,,${c}`);
|
|
140
|
+
t += d;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
fs.writeFileSync(file, head + ev.join('\n') + '\n');
|
|
144
|
+
}
|
|
145
|
+
async function upload(file, prefix) {
|
|
146
|
+
const name = `${prefix}-${path.basename(file)}`;
|
|
147
|
+
const out = execFileSync('curl', ['-s', '-X', 'POST', 'https://www.voice-native.com/api/upload', '-F', `file=@${file};filename=${name}`]).toString();
|
|
148
|
+
const url = JSON.parse(out).url;
|
|
149
|
+
if (!url)
|
|
150
|
+
throw new Error(`upload failed: ${out.slice(0, 200)}`);
|
|
151
|
+
return url;
|
|
152
|
+
}
|
|
153
|
+
async function main() {
|
|
154
|
+
if (!KEY)
|
|
155
|
+
throw new Error('OPENROUTER_API_KEY missing — stopping (no fabricated output).');
|
|
156
|
+
fs.mkdirSync(path.join(OUT, 'lines'), { recursive: true });
|
|
157
|
+
fs.mkdirSync(path.join(OUT, 'segs'), { recursive: true });
|
|
158
|
+
await renderScreens();
|
|
159
|
+
if (STAGE === 'screens')
|
|
160
|
+
return;
|
|
161
|
+
writeText(path.join(OUT, 'script.txt'), SCRIPT.map((s, i) => `${i + 1}. ${s}\n [caption] ${CAPTIONS[i]}\n [screens] ${LINE_SCREENS[i].join(', ')}`).join('\n') + '\n');
|
|
162
|
+
const flags = await truthCheck();
|
|
163
|
+
if (STAGE === 'check') {
|
|
164
|
+
log(`spent $${spent.toFixed(4)}`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (flags.length && !process.env.FLAGS_RESOLVED)
|
|
168
|
+
throw new Error('truth-check flagged claims — resolve or set FLAGS_RESOLVED=1 after documenting');
|
|
169
|
+
// Narration
|
|
170
|
+
const cues = [];
|
|
171
|
+
const lineFiles = [];
|
|
172
|
+
let t = 0;
|
|
173
|
+
for (let i = 0; i < SCRIPT.length; i++) {
|
|
174
|
+
const h = crypto.createHash('sha1').update(SCRIPT[i]).digest('hex').slice(0, 8);
|
|
175
|
+
const raw = path.join(OUT, 'lines', `line-${i + 1}-${h}.mp3`);
|
|
176
|
+
await tts(SCRIPT[i], raw);
|
|
177
|
+
// Compress pauses > 0.3s (the voice leaves up to ~2.6s) and apply a mild tempo lift.
|
|
178
|
+
const f = raw.replace(/\.mp3$/, '.tight.wav');
|
|
179
|
+
ff(['-i', raw, '-af', `silenceremove=start_periods=1:start_threshold=-45dB:stop_periods=-1:stop_duration=0.3:stop_threshold=-45dB:stop_silence=0.22,atempo=${TEMPO}`, '-ar', '44100', f]);
|
|
180
|
+
const d = dur(f);
|
|
181
|
+
cues.push({ start: t, speech: d, end: t + d + GAP });
|
|
182
|
+
t += d + GAP;
|
|
183
|
+
lineFiles.push(f);
|
|
184
|
+
}
|
|
185
|
+
const tail = 1.2;
|
|
186
|
+
const pads = lineFiles.map((_, i) => `[${i}:a]aresample=44100,apad=pad_dur=${i === lineFiles.length - 1 ? GAP + tail : GAP}[a${i}]`).join(';');
|
|
187
|
+
const mp3 = path.join(OUT, 'narration.mp3');
|
|
188
|
+
ff([...lineFiles.flatMap((f) => ['-i', f]), '-filter_complex', `${pads};${lineFiles.map((_, i) => `[a${i}]`).join('')}concat=n=${lineFiles.length}:v=0:a=1[out]`,
|
|
189
|
+
'-map', '[out]', '-ac', '1', '-c:a', 'libmp3lame', '-b:a', '128k', mp3]);
|
|
190
|
+
cues[cues.length - 1].end += tail;
|
|
191
|
+
const ass = path.join(OUT, 'captions.ass');
|
|
192
|
+
buildAss(cues, ass);
|
|
193
|
+
// Segments: each line's screens split evenly; subtle zoom (alternating in/out) + vertical drift.
|
|
194
|
+
const segs = [];
|
|
195
|
+
const shotlist = [];
|
|
196
|
+
cues.forEach((c, i) => {
|
|
197
|
+
const names = LINE_SCREENS[i], d = (c.end - c.start) / names.length;
|
|
198
|
+
names.forEach((n, k) => {
|
|
199
|
+
const idx = segs.length, zin = idx % 2 === 0;
|
|
200
|
+
const z = zin ? `1+0.04*t/${d.toFixed(3)}` : `1.04-0.04*t/${d.toFixed(3)}`;
|
|
201
|
+
const seg = path.join(OUT, 'segs', `seg-${String(idx).padStart(2, '0')}.mp4`);
|
|
202
|
+
ff(['-loop', '1', '-framerate', '30', '-t', d.toFixed(3), '-i', pngPath(OUT, n), '-vf',
|
|
203
|
+
`scale=w='trunc(1080*(${z})/2)*2':h=-2:eval=frame,crop=1080:1920:x='(iw-1080)/2':y='(ih-1920)*${zin ? '0.35' : '0.65'}',setsar=1,fps=30,format=yuv420p`,
|
|
204
|
+
'-c:v', 'libx264', '-preset', 'medium', '-crf', '19', '-an', seg]);
|
|
205
|
+
segs.push(seg);
|
|
206
|
+
shotlist.push(`${(c.start + k * d).toFixed(2)}s-${(c.start + (k + 1) * d).toFixed(2)}s line ${i + 1} ${n} — ${PROVENANCE[n]}`);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
const list = path.join(OUT, 'segs', 'list.txt');
|
|
210
|
+
fs.writeFileSync(list, segs.map((s) => `file '${s}'`).join('\n') + '\n');
|
|
211
|
+
const silent = path.join(OUT, 'segs', 'video.mp4');
|
|
212
|
+
ff(['-f', 'concat', '-safe', '0', '-i', list, '-c', 'copy', silent]);
|
|
213
|
+
const final = path.join(OUT, 'ws-1006-one-missing-header.mp4');
|
|
214
|
+
ff(['-i', silent, '-i', mp3, '-vf', `subtitles=${ass}:fontsdir=${FONTS}`, '-c:v', 'libx264', '-preset', 'medium', '-crf', '20',
|
|
215
|
+
'-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '160k', '-shortest', '-movflags', '+faststart', final]);
|
|
216
|
+
writeText(path.join(OUT, 'shotlist.txt'), shotlist.join('\n') + '\n');
|
|
217
|
+
// Verify: contact strip of 12 evenly spaced frames.
|
|
218
|
+
const D = dur(final);
|
|
219
|
+
const strip = path.join(OUT, 'frames-strip.jpg');
|
|
220
|
+
ff(['-i', final, '-vf', `fps=12/${D.toFixed(2)},scale=270:480,tile=6x2:padding=6:color=black`, '-frames:v', '1', '-q:v', '3', strip]);
|
|
221
|
+
const prefix = `wsfix1006-${crypto.randomBytes(4).toString('hex')}`;
|
|
222
|
+
const res = { duration: D, spent_usd: Number(spent.toFixed(4)), truthCheckFlags: flags, prefix };
|
|
223
|
+
if (!process.env.SKIP_UPLOAD) {
|
|
224
|
+
res.mp4 = await upload(final, prefix);
|
|
225
|
+
res.strip = await upload(strip, prefix);
|
|
226
|
+
const dl = path.join(OUT, 'downloaded-check.mp4');
|
|
227
|
+
execFileSync('curl', ['-sfL', '-o', dl, res.mp4]);
|
|
228
|
+
res.downloaded = execFileSync(FFPROBE, ['-v', 'error', '-show_entries', 'format=duration,size:stream=codec_type,codec_name,width,height', '-of', 'compact', dl]).toString().trim();
|
|
229
|
+
}
|
|
230
|
+
writeText(path.join(OUT, 'results.json'), JSON.stringify(res, null, 2));
|
|
231
|
+
log('done', JSON.stringify(res, null, 2));
|
|
232
|
+
}
|
|
233
|
+
main().catch((e) => { console.error('[ws-story] FAILED:', e.message); process.exit(1); });
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcript-sanitizer.ts
|
|
3
|
+
* ------------------------------------------------------------------------
|
|
4
|
+
* STANDALONE, OFFLINE transcript sanitizer for the Osborn content pipeline.
|
|
5
|
+
*
|
|
6
|
+
* This module is NOT wired into the live voice runtime. It is a pure,
|
|
7
|
+
* importable function + a CLI. It reads a saved transcript (session JSONL
|
|
8
|
+
* today; arbitrary text / media later) and emits clean, per-utterance
|
|
9
|
+
* USER-speech records with live credentials masked out.
|
|
10
|
+
*
|
|
11
|
+
* It only *references* the live agent's marker formats (read-only) to stay
|
|
12
|
+
* faithful to what the runtime actually writes:
|
|
13
|
+
* - [INTERRUPTED] / [CONTEXT] templates ....... pipeline-direct-llm.ts
|
|
14
|
+
* - <session_tail> post-compaction block ...... pipeline-direct-llm.ts (buildSessionTail)
|
|
15
|
+
* Do NOT import from the live agent here — the anchors are inlined as
|
|
16
|
+
* verified string constants so this module stays self-contained.
|
|
17
|
+
*
|
|
18
|
+
* ========================= ARCHITECTURE =========================
|
|
19
|
+
* Two cleanly separated layers:
|
|
20
|
+
*
|
|
21
|
+
* (1) INPUT ADAPTERS — format-specific. Their ONLY job is to parse a
|
|
22
|
+
* source and YIELD normalized raw records: { text, timestamp, speaker? }.
|
|
23
|
+
* The core never sees JSONL / text / media specifics.
|
|
24
|
+
* - sessionJsonlAdapter(path) -> reads a Claude session .jsonl
|
|
25
|
+
* - plainTextAdapter(text, ...) -> reads a raw pasted transcript
|
|
26
|
+
* - mediaAdapter (STUB) -> would TRANSCRIBE audio/video first
|
|
27
|
+
*
|
|
28
|
+
* (2) CLEANING CORE — fully source-independent and PURE. Runs identically
|
|
29
|
+
* whether the text came from JSONL, a pasted string, or a future STT
|
|
30
|
+
* transcript of an MP4. Applies the verified drop/unwrap/strip ruleset
|
|
31
|
+
* and the secret-redaction mask list, and returns SanitizedRecord[].
|
|
32
|
+
* ================================================================
|
|
33
|
+
*/
|
|
34
|
+
/** Normalized raw record produced by ANY adapter and consumed by the core. */
|
|
35
|
+
export interface RawRecord {
|
|
36
|
+
/** Raw utterance text, exactly as stored/transcribed (may still be wrapped in a template). */
|
|
37
|
+
text: string;
|
|
38
|
+
/** ISO-8601 timestamp for the utterance. */
|
|
39
|
+
timestamp: string;
|
|
40
|
+
/** Optional speaker label (e.g. "user"). Informational only; the core keeps all real utterances. */
|
|
41
|
+
speaker?: string;
|
|
42
|
+
}
|
|
43
|
+
/** A cleaned, per-utterance speech record emitted by the core. */
|
|
44
|
+
export interface SanitizedRecord {
|
|
45
|
+
/** Cleaned utterance text (unwrapped, stripped, secret-masked). */
|
|
46
|
+
text: string;
|
|
47
|
+
/** ISO-8601 timestamp carried through from the source entry. */
|
|
48
|
+
timestamp: string;
|
|
49
|
+
/**
|
|
50
|
+
* Who produced the utterance. Carried through from the adapter so downstream
|
|
51
|
+
* knows who said what. Defaults to 'user' when the adapter did not label it.
|
|
52
|
+
*/
|
|
53
|
+
speaker: 'user' | 'assistant';
|
|
54
|
+
/** True if the text was unwrapped from an [INTERRUPTED] / [CONTEXT] template. */
|
|
55
|
+
wrapped: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Placeholder flag for downstream rendering. Original audio is treated as
|
|
58
|
+
* OPTIONAL — renderers fall back to text when it's absent. We only CARRY the
|
|
59
|
+
* flag here; no audio logic is implemented in this module. Defaults to false.
|
|
60
|
+
*/
|
|
61
|
+
audioAvailable?: boolean;
|
|
62
|
+
}
|
|
63
|
+
export type SanitizeAction = 'clean' | 'unwrap' | 'drop';
|
|
64
|
+
/** Per-record result, exposing WHY a record was kept/dropped (for stats/tests). */
|
|
65
|
+
export interface SanitizeOutcome {
|
|
66
|
+
record: SanitizedRecord | null;
|
|
67
|
+
action: SanitizeAction;
|
|
68
|
+
}
|
|
69
|
+
export interface SanitizeStats {
|
|
70
|
+
/** Raw records handed to the core (text-bearing utterances). */
|
|
71
|
+
input: number;
|
|
72
|
+
dropped: number;
|
|
73
|
+
unwrapped: number;
|
|
74
|
+
/** Kept with no template unwrap (plain passthrough after strip/redact). */
|
|
75
|
+
clean: number;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Adapter contract. Every input format — JSONL, plain text, and (future)
|
|
79
|
+
* transcribed audio/video — resolves to the SAME normalized record stream.
|
|
80
|
+
*/
|
|
81
|
+
export type TranscriptAdapter = () => RawRecord[];
|
|
82
|
+
interface SecretRule {
|
|
83
|
+
kind: string;
|
|
84
|
+
/** Global regex. Capture groups allowed; `replace` controls the substitution. */
|
|
85
|
+
re: RegExp;
|
|
86
|
+
replace: string;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Order matters. Specific, high-confidence token shapes run FIRST so their KIND
|
|
90
|
+
* label wins; the broad `*_API_KEY=/_SECRET=/_TOKEN=` assignment rule runs LAST
|
|
91
|
+
* to catch any leftover generic value assignment (and skips already-redacted
|
|
92
|
+
* values via a negative lookahead).
|
|
93
|
+
*/
|
|
94
|
+
export declare const SECRET_RULES: SecretRule[];
|
|
95
|
+
/**
|
|
96
|
+
* Conservative high-entropy fallback, applied to ASSISTANT records only (the
|
|
97
|
+
* assistant echoes tool output, so unknown-vendor keys can leak there). Matches
|
|
98
|
+
* a standalone 32+ char token of [A-Za-z0-9_+=-] that has at least one
|
|
99
|
+
* uppercase, one lowercase, and four digits. Mixed-case + digit floor skips
|
|
100
|
+
* git SHAs / sha256 hex / UUIDs (lowercase) and CONSTANT_NAMES (no lowercase);
|
|
101
|
+
* the "/" and "." boundaries skip paths and dotted identifiers. Measured 0 hits
|
|
102
|
+
* across 2508 real assistant records in session c97588f4 (no false positives).
|
|
103
|
+
*/
|
|
104
|
+
export declare const HIGH_ENTROPY_RULE: SecretRule;
|
|
105
|
+
export interface SecretScanOptions {
|
|
106
|
+
/** Treat as an assistant record: also apply the high-entropy fallback. */
|
|
107
|
+
assistant?: boolean;
|
|
108
|
+
}
|
|
109
|
+
/** Mask every secret-shaped substring in `text`. Pure. */
|
|
110
|
+
export declare function redactSecrets(text: string, opts?: SecretScanOptions): string;
|
|
111
|
+
/**
|
|
112
|
+
* Detector used by the verification harness: returns the list of secret KINDs
|
|
113
|
+
* that STILL match `text`. The authoritative "no secret survives" invariant is
|
|
114
|
+
* `detectSecrets(output).length === 0`. This is precise (uses the same shapes
|
|
115
|
+
* as redaction) rather than a naive substring scan, which would false-positive
|
|
116
|
+
* on benign prose like "task-budgets" or "fo1_/sbp_/xox/Bearer patterns".
|
|
117
|
+
*/
|
|
118
|
+
export declare function detectSecrets(text: string, opts?: SecretScanOptions): {
|
|
119
|
+
kind: string;
|
|
120
|
+
match: string;
|
|
121
|
+
}[];
|
|
122
|
+
/**
|
|
123
|
+
* CORE per-record sanitizer. Pure and source-independent: identical behavior
|
|
124
|
+
* whether `raw.text` came from JSONL, pasted text, or a future STT transcript.
|
|
125
|
+
*
|
|
126
|
+
* Pipeline order:
|
|
127
|
+
* A. strip leading <session_tail> block (5b) -> reprocess remainder
|
|
128
|
+
* B. strip leading [time: …] prefix (4)
|
|
129
|
+
* C. DROP whole entry if it leads with a drop marker (1)
|
|
130
|
+
* D. UNWRAP [INTERRUPTED] (2) / [CONTEXT] (3) -> wrapped=true
|
|
131
|
+
* E. strip trailing attachment blocks (5)
|
|
132
|
+
* F. redact secrets (6)
|
|
133
|
+
* G. trim; drop if empty
|
|
134
|
+
* Rule 7: filler words ("um", "uh", stutters) are NEVER normalized.
|
|
135
|
+
* Rule 7b: nothing in the source cleanly marks voice-vs-typed, so we do NOT
|
|
136
|
+
* filter on that — every real utterance is included.
|
|
137
|
+
*/
|
|
138
|
+
export declare function sanitizeRecord(raw: RawRecord): SanitizeOutcome;
|
|
139
|
+
/** Run the core over a record stream and return kept SanitizedRecords. Pure. */
|
|
140
|
+
export declare function sanitizeTranscript(records: Iterable<RawRecord>): SanitizedRecord[];
|
|
141
|
+
/** Like sanitizeTranscript but also returns drop/unwrap/clean counts. Pure. */
|
|
142
|
+
export declare function sanitizeTranscriptDetailed(records: Iterable<RawRecord>): {
|
|
143
|
+
records: SanitizedRecord[];
|
|
144
|
+
stats: SanitizeStats;
|
|
145
|
+
};
|
|
146
|
+
/** Options controlling which speaker turns the JSONL adapter emits. */
|
|
147
|
+
export interface SessionJsonlAdapterOptions {
|
|
148
|
+
/**
|
|
149
|
+
* Also emit the assistant's TEXT turns (the sharpest findings often live in
|
|
150
|
+
* assistant replies). Only `type:"text"` parts are extracted — tool_use,
|
|
151
|
+
* tool_result, and thinking blocks are stripped as noise by `extractJsonlText`.
|
|
152
|
+
* Assistant text is still run through the SAME secret-redaction core (it can
|
|
153
|
+
* contain commands it ran / tokens in pasted output). Defaults to true.
|
|
154
|
+
* Set false for the user-only path (keeps the original behavior, backward compatible).
|
|
155
|
+
*/
|
|
156
|
+
includeAssistant?: boolean;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* sessionJsonlAdapter — reads a Claude session .jsonl and yields normalized
|
|
160
|
+
* raw records. By default it emits text-bearing `type:"user"` AND `type:"assistant"`
|
|
161
|
+
* entries, each tagged with `speaker`. tool_result / image-only / tool_use-only /
|
|
162
|
+
* thinking-only entries are skipped (they carry no spoken utterance).
|
|
163
|
+
*
|
|
164
|
+
* Pass `{ includeAssistant: false }` for the original user-only behavior.
|
|
165
|
+
*/
|
|
166
|
+
export declare function sessionJsonlAdapter(filePath: string, opts?: SessionJsonlAdapterOptions): RawRecord[];
|
|
167
|
+
/**
|
|
168
|
+
* plainTextAdapter — reads an arbitrary pasted transcript / .txt. Utterances
|
|
169
|
+
* are split on blank lines (one normalized record per paragraph). There are no
|
|
170
|
+
* per-utterance timestamps in raw text, so a single `baseTimestamp` (default:
|
|
171
|
+
* now) is carried on every record. The cleaning core treats these identically
|
|
172
|
+
* to JSONL-sourced records.
|
|
173
|
+
*/
|
|
174
|
+
export declare function plainTextAdapter(text: string, opts?: {
|
|
175
|
+
baseTimestamp?: string;
|
|
176
|
+
speaker?: string;
|
|
177
|
+
}): RawRecord[];
|
|
178
|
+
/**
|
|
179
|
+
* mediaAdapter — STUB. The processor's input is format-agnostic: raw text, an
|
|
180
|
+
* audio file, or a video (MP4) must all resolve to the SAME normalized record
|
|
181
|
+
* stream the core consumes. A media adapter's job is to TRANSCRIBE the media to
|
|
182
|
+
* text FIRST, then emit `{ text, timestamp, speaker? }` records.
|
|
183
|
+
*
|
|
184
|
+
* Transcription is intentionally NOT implemented in this first cut — it is a
|
|
185
|
+
* separate unit that needs an STT call. The platform already uses
|
|
186
|
+
* Soniox/Deepgram (see agent/src/voice-io.ts); wire that in here later.
|
|
187
|
+
*/
|
|
188
|
+
export interface MediaAdapterOptions {
|
|
189
|
+
/** Path to an audio or video (e.g. .mp4/.wav/.m4a) file to transcribe. */
|
|
190
|
+
filePath: string;
|
|
191
|
+
/** Future: STT provider selection, diarization toggle, language, etc. */
|
|
192
|
+
provider?: 'soniox' | 'deepgram';
|
|
193
|
+
}
|
|
194
|
+
export declare function mediaAdapter(_opts: MediaAdapterOptions): RawRecord[];
|
|
195
|
+
export {};
|