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.
Files changed (62) hide show
  1. package/dist/claude-llm.js +13 -0
  2. package/dist/content/compaction-lens-worker.d.ts +23 -0
  3. package/dist/content/compaction-lens-worker.js +188 -0
  4. package/dist/content/compaction-lens.d.ts +106 -0
  5. package/dist/content/compaction-lens.js +222 -0
  6. package/dist/content/lens-audio.d.ts +38 -0
  7. package/dist/content/lens-audio.js +105 -0
  8. package/dist/content/lens-clip-arc.d.ts +80 -0
  9. package/dist/content/lens-clip-arc.js +148 -0
  10. package/dist/content/lens-clip-formats.d.ts +58 -0
  11. package/dist/content/lens-clip-formats.js +175 -0
  12. package/dist/content/lens-clip-search.d.ts +51 -0
  13. package/dist/content/lens-clip-search.js +138 -0
  14. package/dist/content/lens-clip-window.d.ts +72 -0
  15. package/dist/content/lens-clip-window.js +257 -0
  16. package/dist/content/lens-db.d.ts +70 -0
  17. package/dist/content/lens-db.js +170 -0
  18. package/dist/content/lens-hn.d.ts +19 -0
  19. package/dist/content/lens-hn.js +69 -0
  20. package/dist/content/lens-ingest.d.ts +67 -0
  21. package/dist/content/lens-ingest.js +155 -0
  22. package/dist/content/lens-launch.d.ts +46 -0
  23. package/dist/content/lens-launch.js +111 -0
  24. package/dist/content/lens-library-index.d.ts +30 -0
  25. package/dist/content/lens-library-index.js +79 -0
  26. package/dist/content/lens-library-select.d.ts +59 -0
  27. package/dist/content/lens-library-select.js +77 -0
  28. package/dist/content/lens-library.d.ts +92 -0
  29. package/dist/content/lens-library.js +179 -0
  30. package/dist/content/lens-model.d.ts +64 -0
  31. package/dist/content/lens-model.js +193 -0
  32. package/dist/content/lens-paths.d.ts +43 -0
  33. package/dist/content/lens-paths.js +166 -0
  34. package/dist/content/lens-period-block.d.ts +63 -0
  35. package/dist/content/lens-period-block.js +99 -0
  36. package/dist/content/lens-period-boundaries.d.ts +13 -0
  37. package/dist/content/lens-period-boundaries.js +40 -0
  38. package/dist/content/lens-period-render.d.ts +28 -0
  39. package/dist/content/lens-period-render.js +70 -0
  40. package/dist/content/lens-period.d.ts +106 -0
  41. package/dist/content/lens-period.js +205 -0
  42. package/dist/content/lens-profile.d.ts +47 -0
  43. package/dist/content/lens-profile.js +95 -0
  44. package/dist/content/lens-quotes.d.ts +103 -0
  45. package/dist/content/lens-quotes.js +186 -0
  46. package/dist/content/lens-redact.d.ts +61 -0
  47. package/dist/content/lens-redact.js +187 -0
  48. package/dist/content/lens-strip.d.ts +33 -0
  49. package/dist/content/lens-strip.js +132 -0
  50. package/dist/content/period-run.d.ts +30 -0
  51. package/dist/content/period-run.js +135 -0
  52. package/dist/content/render-ws-data.d.ts +8 -0
  53. package/dist/content/render-ws-data.js +108 -0
  54. package/dist/content/render-ws-screens.d.ts +25 -0
  55. package/dist/content/render-ws-screens.js +189 -0
  56. package/dist/content/render-ws-story.d.ts +1 -0
  57. package/dist/content/render-ws-story.js +233 -0
  58. package/dist/content/transcript-sanitizer.d.ts +195 -0
  59. package/dist/content/transcript-sanitizer.js +515 -0
  60. package/dist/index.js +34 -0
  61. package/package.json +1 -1
  62. package/test-results/.last-run.json +0 -4
@@ -0,0 +1,515 @@
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
+ import { readFileSync } from 'node:fs';
35
+ /**
36
+ * Order matters. Specific, high-confidence token shapes run FIRST so their KIND
37
+ * label wins; the broad `*_API_KEY=/_SECRET=/_TOKEN=` assignment rule runs LAST
38
+ * to catch any leftover generic value assignment (and skips already-redacted
39
+ * values via a negative lookahead).
40
+ */
41
+ export const SECRET_RULES = [
42
+ // npm registry auth line (contains an npm_ token; redact the whole value).
43
+ {
44
+ kind: 'NPM-AUTH',
45
+ re: /\/\/registry\.npmjs\.org\/:_authToken=[^\s"'`]+/g,
46
+ replace: '//registry.npmjs.org/:_authToken=[REDACTED-NPM-AUTH]',
47
+ },
48
+ // npm granular/automation token: npm_ + 36 base62 chars.
49
+ { kind: 'NPM-TOKEN', re: /npm_[A-Za-z0-9]{36}/g, replace: '[REDACTED-NPM-TOKEN]' },
50
+ // Fly.io macaroon tokens. A real token is a COMMA-SEPARATED list of
51
+ // segments ("FlyV1 fm2_<a>,fm2_<b>,fm2_<c>"); redact the WHOLE list, not just
52
+ // the first segment (the segment char class has no comma, so the old rule
53
+ // stopped at the first "," and leaked the rest).
54
+ {
55
+ kind: 'FLY-TOKEN',
56
+ re: /FlyV1\s+fm[12]_[A-Za-z0-9/+_=-]+(?:\s*,\s*fm[12]_[A-Za-z0-9/+_=-]+)*/g,
57
+ replace: '[REDACTED-FLY-TOKEN]',
58
+ },
59
+ // Standalone Fly macaroon segment without the FlyV1 prefix (floor skips bare
60
+ // "fm2_" word mentions).
61
+ {
62
+ kind: 'FLY-TOKEN',
63
+ re: /(?<![A-Za-z0-9])fm[12]_[A-Za-z0-9/+_=-]{20,}/g,
64
+ replace: '[REDACTED-FLY-TOKEN]',
65
+ },
66
+ // Fly GraphQL fo1_ token (floor skips bare "fo1_" word mentions).
67
+ { kind: 'FLY-TOKEN', re: /fo1_[A-Za-z0-9/+_=-]{20,}/g, replace: '[REDACTED-FLY-TOKEN]' },
68
+ // Supabase personal access token (real = 40 hex; floor skips "sbp_/" prose).
69
+ { kind: 'SUPABASE-PAT', re: /sbp_[A-Za-z0-9]{20,}/g, replace: '[REDACTED-SUPABASE-PAT]' },
70
+ // PEM private key blocks (multiline). Terminated block first, then a
71
+ // truncated/unterminated block (header + base64 body, literal "\n" allowed).
72
+ {
73
+ kind: 'PEM-PRIVATE-KEY',
74
+ re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
75
+ replace: '[REDACTED-PEM-PRIVATE-KEY]',
76
+ },
77
+ {
78
+ kind: 'PEM-PRIVATE-KEY',
79
+ re: /-----BEGIN [A-Z ]*PRIVATE KEY-----(?:[\sA-Za-z0-9+/=]|\\n)*/g,
80
+ replace: '[REDACTED-PEM-PRIVATE-KEY]',
81
+ },
82
+ // Anthropic keys: sk-ant-api03-…, sk-ant-oat01-… (MUST run before OPENAI-KEY).
83
+ { kind: 'ANTHROPIC-KEY', re: /sk-ant-[A-Za-z0-9_-]{20,}/g, replace: '[REDACTED-ANTHROPIC-KEY]' },
84
+ // OpenRouter / OpenAI prefixed keys whose body contains _ or - (sk-or-v1-, sk-proj-, sk-svcacct-).
85
+ {
86
+ kind: 'OPENAI-KEY',
87
+ re: /\bsk-(?:or-v1-|or-|proj-|svcacct-)[A-Za-z0-9_-]{20,}/g,
88
+ replace: '[REDACTED-OPENAI-KEY]',
89
+ },
90
+ // OpenAI / OpenRouter keys (sk-, sk-or-, sk-proj-). Floor skips "task-budgets".
91
+ { kind: 'OPENAI-KEY', re: /sk-(?:or-|proj-)?[A-Za-z0-9]{20,}/g, replace: '[REDACTED-OPENAI-KEY]' },
92
+ // GitHub tokens: ghp_/gho_/ghu_/ghs_/ghr_ (+ fine-grained github_pat_).
93
+ { kind: 'GITHUB-TOKEN', re: /\bgh[pousr]_[A-Za-z0-9]{30,}/g, replace: '[REDACTED-GITHUB-TOKEN]' },
94
+ { kind: 'GITHUB-TOKEN', re: /\bgithub_pat_[A-Za-z0-9_]{30,}/g, replace: '[REDACTED-GITHUB-TOKEN]' },
95
+ // Google API key.
96
+ { kind: 'GOOGLE-API-KEY', re: /AIza[0-9A-Za-z_-]{35}/g, replace: '[REDACTED-GOOGLE-API-KEY]' },
97
+ // Fireworks AI key: fw_ + ~24 base62 chars. Floor of 20 pure alphanumerics
98
+ // skips snake_case identifiers like "fw_key" / "fw_test_script".
99
+ {
100
+ kind: 'FIREWORKS-KEY',
101
+ re: /(?<![A-Za-z0-9_])fw_[A-Za-z0-9]{20,}/g,
102
+ replace: '[REDACTED-FIREWORKS-KEY]',
103
+ },
104
+ // Soniox project key: snx_proj_ + key body (floor skips bare prefix mentions).
105
+ {
106
+ kind: 'SONIOX-KEY',
107
+ re: /(?<![A-Za-z0-9_])snx_proj_[A-Za-z0-9_-]{20,}/g,
108
+ replace: '[REDACTED-SONIOX-KEY]',
109
+ },
110
+ // JWTs (header.payload.signature, base64url).
111
+ {
112
+ kind: 'JWT',
113
+ re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
114
+ replace: '[REDACTED-JWT]',
115
+ },
116
+ // API-key headers: x-api-key, x-goog-api-key (Gemini REST), and generic api-key
117
+ // (Azure etc.) — curl -H, JSON headers, raw HTTP. Keeps the header name, masks
118
+ // the value. Floor of 8 skips prose like "x-api-key: header".
119
+ {
120
+ kind: 'X-API-KEY',
121
+ // (?!\$) skips $VAR / ${var} references — not secrets, just noise.
122
+ re: /(\b(?:x-goog-|x-)?api-key["'`]?\s*[:=]\s*["'`]?)(?!\[REDACTED|\$)[^\s"'`,;}]{8,}/gi,
123
+ replace: '$1[REDACTED-API-KEY]',
124
+ },
125
+ // Authorization header (optional Bearer/Basic/Token scheme). Keeps name +
126
+ // scheme, masks the credential.
127
+ {
128
+ kind: 'AUTH-HEADER',
129
+ re: /(Authorization["'`]?\s*:\s*["'`]?(?:(?:Bearer|Basic|Token)\s+)?)(?!\[REDACTED|\$)[^\s"'`,;}]{8,}/gi,
130
+ replace: '$1[REDACTED-AUTH]',
131
+ },
132
+ // Slack tokens xoxb-/xoxa-/xoxp-/xoxr-/xoxs- (floor skips "xox/" prose).
133
+ { kind: 'SLACK-TOKEN', re: /xox[baprs]-[A-Za-z0-9-]{10,}/g, replace: '[REDACTED-SLACK-TOKEN]' },
134
+ // AWS access key id.
135
+ { kind: 'AWS-KEY', re: /\bAKIA[0-9A-Z]{16}\b/g, replace: '[REDACTED-AWS-KEY]' },
136
+ // Generic Bearer token (keep the word "Bearer"; floor skips "Bearer patterns").
137
+ { kind: 'BEARER', re: /Bearer\s+[A-Za-z0-9._\-+/=]{20,}/g, replace: 'Bearer [REDACTED-TOKEN]' },
138
+ // Generic *_API_KEY= / *_SECRET= / *_TOKEN= / *_ACCESS_KEY= / *_PASSWORD= assignments.
139
+ // Keeps NAME= and any surrounding quote; masks only the value. Skips values
140
+ // already replaced by a specific rule above (negative lookahead on [REDACTED).
141
+ {
142
+ kind: 'ENV-SECRET',
143
+ re: /\b([A-Za-z0-9_]*(?:API_KEY|SECRET|ACCESS_KEY|PASSWORD|TOKEN))(\s*=\s*)(["'`“”]?)(?!\[REDACTED)([^\s"'`“”]{6,})\3/g,
144
+ replace: '$1$2$3[REDACTED-ENV-SECRET]$3',
145
+ },
146
+ ];
147
+ /**
148
+ * Conservative high-entropy fallback, applied to ASSISTANT records only (the
149
+ * assistant echoes tool output, so unknown-vendor keys can leak there). Matches
150
+ * a standalone 32+ char token of [A-Za-z0-9_+=-] that has at least one
151
+ * uppercase, one lowercase, and four digits. Mixed-case + digit floor skips
152
+ * git SHAs / sha256 hex / UUIDs (lowercase) and CONSTANT_NAMES (no lowercase);
153
+ * the "/" and "." boundaries skip paths and dotted identifiers. Measured 0 hits
154
+ * across 2508 real assistant records in session c97588f4 (no false positives).
155
+ */
156
+ export const HIGH_ENTROPY_RULE = {
157
+ kind: 'HIGH-ENTROPY',
158
+ re: /(?<![A-Za-z0-9_+=\/.\[-])(?=[A-Za-z0-9_+=-]*[A-Z])(?=[A-Za-z0-9_+=-]*[a-z])(?=(?:[A-Za-z0-9_+=-]*\d){4})[A-Za-z0-9_+=-]{32,}(?![A-Za-z0-9_+=\/.\]-])/g,
159
+ replace: '[REDACTED-HIGH-ENTROPY]',
160
+ };
161
+ function rulesFor(opts = {}) {
162
+ return opts.assistant ? [...SECRET_RULES, HIGH_ENTROPY_RULE] : SECRET_RULES;
163
+ }
164
+ /** Mask every secret-shaped substring in `text`. Pure. */
165
+ export function redactSecrets(text, opts = {}) {
166
+ let out = text;
167
+ for (const rule of rulesFor(opts)) {
168
+ out = out.replace(rule.re, rule.replace);
169
+ }
170
+ return out;
171
+ }
172
+ /**
173
+ * Detector used by the verification harness: returns the list of secret KINDs
174
+ * that STILL match `text`. The authoritative "no secret survives" invariant is
175
+ * `detectSecrets(output).length === 0`. This is precise (uses the same shapes
176
+ * as redaction) rather than a naive substring scan, which would false-positive
177
+ * on benign prose like "task-budgets" or "fo1_/sbp_/xox/Bearer patterns".
178
+ */
179
+ export function detectSecrets(text, opts = {}) {
180
+ const hits = [];
181
+ for (const rule of rulesFor(opts)) {
182
+ // Fresh regex so lastIndex state never leaks between calls.
183
+ const re = new RegExp(rule.re.source, rule.re.flags);
184
+ let m;
185
+ while ((m = re.exec(text)) !== null) {
186
+ // The ENV-SECRET rule legitimately leaves a "NAME=[REDACTED-...]" shell;
187
+ // that is NOT a surviving secret, so ignore matches whose value is already redacted.
188
+ if (m[0].includes('[REDACTED')) {
189
+ if (m.index === re.lastIndex)
190
+ re.lastIndex++;
191
+ continue;
192
+ }
193
+ hits.push({ kind: rule.kind, match: m[0] });
194
+ if (m.index === re.lastIndex)
195
+ re.lastIndex++;
196
+ }
197
+ }
198
+ return hits;
199
+ }
200
+ // ============================================================================
201
+ // VERIFIED STRIP / UNWRAP RULESET (source-independent, pure)
202
+ // Grounded in real analysis of session c97588f4. Anchors confirmed against
203
+ // pipeline-direct-llm.ts (read-only) and against the live JSONL.
204
+ // ============================================================================
205
+ /** Rule 1 — leading markers whose entries are DROPPED whole. */
206
+ const DROP_PREFIXES = [
207
+ /^<task-notification>/,
208
+ /^\[Request interrupted by user/, // includes the "... for tool use]" variant
209
+ /^<local-command-/,
210
+ /^<command-name>/,
211
+ /^\[EMERGENCY STOP\]/,
212
+ /^\[BACKGROUND_INIT\]/,
213
+ /^\[liked\]/,
214
+ /^\[SYSTEM\]/,
215
+ /^\[MEETING —/,
216
+ /^This session is being continued/,
217
+ /^<system-reminder>/,
218
+ // Pasted "[hh:mm] System:/You:/Osborn:" transcript blocks.
219
+ /^\[\d{1,2}:\d{2}\]\s+(System|You|Osborn):/,
220
+ ];
221
+ // Rule 2 anchors — [INTERRUPTED] template (pipeline-direct-llm.ts ~L257-275).
222
+ const INTERRUPTED_PREFIX = '[INTERRUPTED]';
223
+ // Rule 3 anchors — [CONTEXT] template (pipeline-direct-llm.ts ~L293-298).
224
+ const CONTEXT_PREFIX = '[CONTEXT]';
225
+ /** Rule 4 — leading "[time: YYYY-MM-DD HH:MM local] " prefix. */
226
+ const TIME_PREFIX_RE = /^\[time:[^\]]*\]\s*/;
227
+ /**
228
+ * Rule 5b (CONFIRMED). The source analysis flagged the post-compaction session
229
+ * tail as UNVERIFIED. We confirmed the exact anchor in pipeline-direct-llm.ts:
230
+ * buildSessionTail() wraps the block as `<session_tail> ... </session_tail>` and
231
+ * PREPENDS it (+ "\n\n") to the next real user turn. So we strip a LEADING
232
+ * <session_tail>...</session_tail> block and re-process the remainder (which may
233
+ * itself be an [INTERRUPTED]/[CONTEXT] template or a plain utterance).
234
+ */
235
+ const SESSION_TAIL_RE = /^<session_tail>[\s\S]*?<\/session_tail>\s*/;
236
+ /** Rule 2 — unwrap [INTERRUPTED]: keep only the quoted `User's message: "…"`. */
237
+ function unwrapInterrupted(text) {
238
+ // User's message line sits just before the CONTEXT PRESERVATION line.
239
+ const m = text.match(/User's message:\s*"([\s\S]*?)"\s*(?:\n\s*)*CONTEXT PRESERVATION \(READ THIS\):/);
240
+ if (m)
241
+ return m[1];
242
+ // Defensive fallback: quote with no trailing CONTEXT PRESERVATION anchor.
243
+ const m2 = text.match(/User's message:\s*"([\s\S]*?)"\s*$/);
244
+ return m2 ? m2[1] : null;
245
+ }
246
+ /**
247
+ * Rule 3 — unwrap [CONTEXT] (suppressed-text template): keep only the text after
248
+ * `What the user is saying now:\n"` up to the closing quote that precedes
249
+ * `Text you produced that the user did NOT hear:`. Everything else in the
250
+ * template is AGENT speech and is discarded.
251
+ */
252
+ function unwrapContext(text) {
253
+ const m = text.match(/What the user is saying now:\s*\n\s*"([\s\S]*?)"\s*(?:\n\s*)*Text you produced that the user did NOT hear:/);
254
+ if (m)
255
+ return m[1];
256
+ const m2 = text.match(/What the user is saying now:\s*\n\s*"([\s\S]*?)"\s*$/);
257
+ return m2 ? m2[1] : null;
258
+ }
259
+ /** Rule 5 — strip trailing attachment blocks. Loops until stable. Pure. */
260
+ function stripTrailingAttachments(text) {
261
+ let prev;
262
+ let out = text;
263
+ const patterns = [
264
+ // [File: ...], [Image: ...], [Image attached: ...] possibly with a (url) tail.
265
+ /\s*\n*\[(?:File|Image)(?: attached)?:[^\]\n]*\](?:\([^)\n]*\))?\s*$/,
266
+ /\s*\[(?:File|Image)(?: attached)?:[^\]\n]*\](?:\([^)\n]*\))?\s*$/,
267
+ // [Image #N]
268
+ /\s*\n*\[Image #\d+\]\s*$/,
269
+ // "… [full content in attached file]" tail
270
+ /\s*(?:…|\.\.\.)?\s*\[full content in attached file\]\s*$/,
271
+ ];
272
+ do {
273
+ prev = out;
274
+ for (const re of patterns)
275
+ out = out.replace(re, '');
276
+ } while (out !== prev);
277
+ return out;
278
+ }
279
+ /**
280
+ * CORE per-record sanitizer. Pure and source-independent: identical behavior
281
+ * whether `raw.text` came from JSONL, pasted text, or a future STT transcript.
282
+ *
283
+ * Pipeline order:
284
+ * A. strip leading <session_tail> block (5b) -> reprocess remainder
285
+ * B. strip leading [time: …] prefix (4)
286
+ * C. DROP whole entry if it leads with a drop marker (1)
287
+ * D. UNWRAP [INTERRUPTED] (2) / [CONTEXT] (3) -> wrapped=true
288
+ * E. strip trailing attachment blocks (5)
289
+ * F. redact secrets (6)
290
+ * G. trim; drop if empty
291
+ * Rule 7: filler words ("um", "uh", stutters) are NEVER normalized.
292
+ * Rule 7b: nothing in the source cleanly marks voice-vs-typed, so we do NOT
293
+ * filter on that — every real utterance is included.
294
+ */
295
+ export function sanitizeRecord(raw) {
296
+ let text = raw.text ?? '';
297
+ let wrapped = false;
298
+ // A. Leading post-compaction session tail (confirmed anchor).
299
+ text = text.replace(SESSION_TAIL_RE, '');
300
+ // B. Leading [time: …] prefix.
301
+ text = text.replace(TIME_PREFIX_RE, '');
302
+ const lead = text.trimStart();
303
+ // C. Drop markers.
304
+ for (const re of DROP_PREFIXES) {
305
+ if (re.test(lead))
306
+ return { record: null, action: 'drop' };
307
+ }
308
+ // D. Unwrap templates.
309
+ if (lead.startsWith(INTERRUPTED_PREFIX)) {
310
+ const inner = unwrapInterrupted(lead);
311
+ if (inner === null)
312
+ return { record: null, action: 'drop' }; // malformed template, no user text
313
+ text = inner;
314
+ wrapped = true;
315
+ }
316
+ else if (lead.startsWith(CONTEXT_PREFIX)) {
317
+ const inner = unwrapContext(lead);
318
+ if (inner === null)
319
+ return { record: null, action: 'drop' };
320
+ text = inner;
321
+ wrapped = true;
322
+ }
323
+ // E. Trailing attachments.
324
+ text = stripTrailingAttachments(text);
325
+ // F. Secret redaction (on the final kept text). Assistant records also get
326
+ // the high-entropy fallback (they echo tool output).
327
+ text = redactSecrets(text, { assistant: raw.speaker === 'assistant' });
328
+ // G. Trim; drop if nothing real remains.
329
+ text = text.trim();
330
+ if (!text)
331
+ return { record: null, action: 'drop' };
332
+ const speaker = raw.speaker === 'assistant' ? 'assistant' : 'user';
333
+ return {
334
+ record: { text, timestamp: raw.timestamp, speaker, wrapped, audioAvailable: false },
335
+ action: wrapped ? 'unwrap' : 'clean',
336
+ };
337
+ }
338
+ /** Run the core over a record stream and return kept SanitizedRecords. Pure. */
339
+ export function sanitizeTranscript(records) {
340
+ const out = [];
341
+ for (const r of records) {
342
+ const { record } = sanitizeRecord(r);
343
+ if (record)
344
+ out.push(record);
345
+ }
346
+ return out;
347
+ }
348
+ /** Like sanitizeTranscript but also returns drop/unwrap/clean counts. Pure. */
349
+ export function sanitizeTranscriptDetailed(records) {
350
+ const out = [];
351
+ const stats = { input: 0, dropped: 0, unwrapped: 0, clean: 0 };
352
+ for (const r of records) {
353
+ stats.input++;
354
+ const { record, action } = sanitizeRecord(r);
355
+ if (action === 'drop')
356
+ stats.dropped++;
357
+ else if (action === 'unwrap')
358
+ stats.unwrapped++;
359
+ else
360
+ stats.clean++;
361
+ if (record)
362
+ out.push(record);
363
+ }
364
+ return { records: out, stats };
365
+ }
366
+ // ============================================================================
367
+ // INPUT ADAPTERS — format-specific parsing, quarantined from the core.
368
+ // ============================================================================
369
+ /**
370
+ * Extract an utterance's text from a Claude session JSONL `message.content`,
371
+ * which is either a string OR an array of content parts.
372
+ * - string -> the utterance itself
373
+ * - array w/ text -> concatenation of its "text" parts (a real utterance)
374
+ * - array w/o text -> tool_result / image / document only -> SKIP (null)
375
+ */
376
+ function extractJsonlText(content) {
377
+ if (typeof content === 'string')
378
+ return content;
379
+ if (Array.isArray(content)) {
380
+ const texts = content
381
+ .filter((p) => p && p.type === 'text' && typeof p.text === 'string')
382
+ .map((p) => p.text);
383
+ return texts.length ? texts.join('\n') : null;
384
+ }
385
+ return null;
386
+ }
387
+ /**
388
+ * sessionJsonlAdapter — reads a Claude session .jsonl and yields normalized
389
+ * raw records. By default it emits text-bearing `type:"user"` AND `type:"assistant"`
390
+ * entries, each tagged with `speaker`. tool_result / image-only / tool_use-only /
391
+ * thinking-only entries are skipped (they carry no spoken utterance).
392
+ *
393
+ * Pass `{ includeAssistant: false }` for the original user-only behavior.
394
+ */
395
+ export function sessionJsonlAdapter(filePath, opts = {}) {
396
+ const includeAssistant = opts.includeAssistant !== false;
397
+ const raw = readFileSync(filePath, 'utf8');
398
+ const lines = raw.split('\n');
399
+ const out = [];
400
+ for (const line of lines) {
401
+ const trimmed = line.trim();
402
+ if (!trimmed)
403
+ continue;
404
+ let obj;
405
+ try {
406
+ obj = JSON.parse(trimmed);
407
+ }
408
+ catch {
409
+ continue;
410
+ }
411
+ const type = obj?.type;
412
+ const isUser = type === 'user';
413
+ const isAssistant = type === 'assistant';
414
+ if (!isUser && !(isAssistant && includeAssistant))
415
+ continue;
416
+ // Same text extractor for both: keeps only `type:"text"` parts, which
417
+ // discards tool_use / tool_result / thinking blocks as noise.
418
+ const text = extractJsonlText(obj?.message?.content);
419
+ if (text === null)
420
+ continue;
421
+ out.push({
422
+ text,
423
+ timestamp: typeof obj.timestamp === 'string' ? obj.timestamp : '',
424
+ speaker: isAssistant ? 'assistant' : 'user',
425
+ });
426
+ }
427
+ return out;
428
+ }
429
+ /**
430
+ * plainTextAdapter — reads an arbitrary pasted transcript / .txt. Utterances
431
+ * are split on blank lines (one normalized record per paragraph). There are no
432
+ * per-utterance timestamps in raw text, so a single `baseTimestamp` (default:
433
+ * now) is carried on every record. The cleaning core treats these identically
434
+ * to JSONL-sourced records.
435
+ */
436
+ export function plainTextAdapter(text, opts = {}) {
437
+ const ts = opts.baseTimestamp ?? new Date().toISOString();
438
+ return text
439
+ .split(/\n\s*\n/)
440
+ .map((chunk) => chunk.trim())
441
+ .filter(Boolean)
442
+ .map((chunk) => ({ text: chunk, timestamp: ts, speaker: opts.speaker }));
443
+ }
444
+ export function mediaAdapter(_opts) {
445
+ // TODO(media): transcribe audio/video -> text via STT (reuse Soniox/Deepgram
446
+ // from agent/src/voice-io.ts), diarize into speaker turns, then return
447
+ // normalized RawRecord[] with per-segment timestamps. The cleaning core and
448
+ // secret-redaction below consume the result UNCHANGED.
449
+ throw new Error('mediaAdapter is not implemented yet: audio/video transcription (STT) is a separate unit. ' +
450
+ 'Use sessionJsonlAdapter or plainTextAdapter for now.');
451
+ }
452
+ // ============================================================================
453
+ // CLI
454
+ // tsx transcript-sanitizer.ts <session.jsonl> [--count] [--json] [--text FILE]
455
+ // (no flag) -> prints cleaned records (timestamp · [wrapped] · text)
456
+ // --count -> prints a drop/unwrap/clean summary only
457
+ // --json -> prints the SanitizedRecord[] as JSON
458
+ // --text -> treat the path as a plain-text transcript, not JSONL
459
+ // ============================================================================
460
+ function runCli(argv) {
461
+ const args = argv.slice(2);
462
+ const flags = new Set(args.filter((a) => a.startsWith('--')));
463
+ const positional = args.filter((a) => !a.startsWith('--'));
464
+ const path = positional[0];
465
+ if (!path) {
466
+ console.error('Usage: tsx transcript-sanitizer.ts <session.jsonl> [--count] [--json] [--text]');
467
+ return 2;
468
+ }
469
+ let raws;
470
+ try {
471
+ if (flags.has('--text')) {
472
+ const fs = require('node:fs');
473
+ raws = plainTextAdapter(fs.readFileSync(path, 'utf8'));
474
+ }
475
+ else {
476
+ raws = sessionJsonlAdapter(path);
477
+ }
478
+ }
479
+ catch (err) {
480
+ console.error(`Failed to read "${path}": ${err.message}`);
481
+ return 1;
482
+ }
483
+ const { records, stats } = sanitizeTranscriptDetailed(raws);
484
+ if (flags.has('--count')) {
485
+ console.log(`input (text-bearing utterances): ${stats.input}`);
486
+ console.log(`dropped: ${stats.dropped}`);
487
+ console.log(`unwrapped (from template): ${stats.unwrapped}`);
488
+ console.log(`clean passthrough: ${stats.clean}`);
489
+ console.log(`kept total: ${records.length}`);
490
+ return 0;
491
+ }
492
+ if (flags.has('--json')) {
493
+ console.log(JSON.stringify(records, null, 2));
494
+ return 0;
495
+ }
496
+ for (const r of records) {
497
+ const tag = r.wrapped ? '[wrapped] ' : '';
498
+ console.log(`${r.timestamp} · ${tag}${r.text.replace(/\n/g, ' ')}`);
499
+ }
500
+ return 0;
501
+ }
502
+ // ESM "run as script" guard — only fires when executed directly (tsx/node),
503
+ // never when imported.
504
+ const isMain = (() => {
505
+ try {
506
+ const { fileURLToPath } = require('node:url');
507
+ return process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
508
+ }
509
+ catch {
510
+ return false;
511
+ }
512
+ })();
513
+ if (isMain) {
514
+ process.exit(runCli(process.argv));
515
+ }
package/dist/index.js CHANGED
@@ -5920,6 +5920,40 @@ async function main() {
5920
5920
  }
5921
5921
  }
5922
5922
  }
5923
+ else if (data.type === 'stop_background_session') {
5924
+ // Close/kill a background slot: abort its subprocess + drop it from the registry.
5925
+ // Never touches the focused voice session (currentLLM). Keyed by full sessionId or
5926
+ // an 8-char prefix (the frontend row id may be either).
5927
+ const sid = data.sessionId;
5928
+ let key;
5929
+ if (sid) {
5930
+ if (slots.has(sid))
5931
+ key = sid;
5932
+ else
5933
+ key = [...slots.keys()].find(k => k === sid || k.startsWith(sid) || sid.startsWith(k));
5934
+ }
5935
+ if (!key) {
5936
+ await sendToFrontend({ type: 'background_session_error', slotId: sid ?? null, error: 'No matching background instance to stop' });
5937
+ }
5938
+ else {
5939
+ const slot = slots.get(key);
5940
+ try {
5941
+ slot.llm.abortQuery?.();
5942
+ slot.llm.abortAgent?.();
5943
+ }
5944
+ catch { }
5945
+ slots.delete(key);
5946
+ console.log(`🗑️ Stopped background slot ${key.substring(0, 8)}`);
5947
+ await sendToFrontend({ type: 'background_session_stopped', slotId: key });
5948
+ // Authoritative refresh so the panel drops the row.
5949
+ const focusedId2 = currentLLM?.sessionId || currentResumeSessionId || null;
5950
+ const focusedEntry2 = currentLLM ? [{ id: focusedId2 || '(pending)', isFocused: true, workingDir, hasSession: currentLLM.hasSession?.() ?? false }] : [];
5951
+ const bgEntries2 = [...slots.values()].map(s => ({ id: s.id, isFocused: s.isFocused, workingDir: s.workingDir, hasSession: s.llm.hasSession?.() ?? false }));
5952
+ await sendToFrontend({ type: 'slots_list', slots: [...focusedEntry2, ...bgEntries2] });
5953
+ // If that was the last background agent and no host anchor, let the room idle-exit.
5954
+ armAloneTimer();
5955
+ }
5956
+ }
5923
5957
  else if (data.type === 'list_slots') {
5924
5958
  // Return all live slots: the focused session + every background slot.
5925
5959
  const focusedId = currentLLM?.sessionId || currentResumeSessionId || null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.249",
3
+ "version": "0.9.251",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +0,0 @@
1
- {
2
- "status": "failed",
3
- "failedTests": []
4
- }