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,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