@agentsky/sky 2.20.2 → 2.22.0
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/{chunk-WTFBVTMI.js → chunk-EBH3KXSU.js} +2 -2
- package/dist/{chunk-HB7SPKQ5.js → chunk-XT3W75V3.js} +1 -1
- package/dist/{chunk-GHTAIKQ6.js → chunk-YMYQT6KR.js} +70 -34
- package/dist/index.js +917 -717
- package/dist/{src-QBXMNV2V.js → src-TGRDQ5XR.js} +1 -1
- package/dist/{trace-HTWXINXI.js → trace-PDIOL2TC.js} +2 -2
- package/dist/{trace-library-QR54Q4Z3.js → trace-library-TA3QUBHJ.js} +97 -11
- package/package.json +6 -4
- package/vendor/trace-collect/SOURCE.json +6 -0
- package/vendor/trace-collect/collect.mjs +75 -0
- package/vendor/trace-collect/identity.d.ts +8 -0
- package/vendor/trace-collect/identity.mjs +23 -0
- package/vendor/trace-collect/muse.mjs +87 -0
- package/vendor/trace-collect/normalize.d.ts +6 -0
- package/vendor/trace-collect/normalize.mjs +312 -0
- package/vendor/trace-collect/scrub.d.ts +2 -0
- package/vendor/trace-collect/scrub.mjs +186 -0
- package/vendor/trace-collect/sinks/agentsky.mjs +156 -0
- package/vendor/trace-collect/sources/_cline-family.mjs +182 -0
- package/vendor/trace-collect/sources/_project-root.mjs +40 -0
- package/vendor/trace-collect/sources/amp.mjs +18 -0
- package/vendor/trace-collect/sources/chatgpt.mjs +203 -0
- package/vendor/trace-collect/sources/claude-ai.mjs +94 -0
- package/vendor/trace-collect/sources/claude-code.mjs +4 -0
- package/vendor/trace-collect/sources/claude-desktop.mjs +87 -0
- package/vendor/trace-collect/sources/cline.mjs +14 -0
- package/vendor/trace-collect/sources/codex.mjs +4 -0
- package/vendor/trace-collect/sources/continue.mjs +117 -0
- package/vendor/trace-collect/sources/copilot-chat.mjs +145 -0
- package/vendor/trace-collect/sources/cursor-chat.mjs +274 -0
- package/vendor/trace-collect/sources/cursor.mjs +4 -0
- package/vendor/trace-collect/sources/deepseek.mjs +172 -0
- package/vendor/trace-collect/sources/dsh.mjs +145 -0
- package/vendor/trace-collect/sources/gemini-cli.mjs +113 -0
- package/vendor/trace-collect/sources/gemini.mjs +150 -0
- package/vendor/trace-collect/sources/goose.mjs +93 -0
- package/vendor/trace-collect/sources/grok-cli.mjs +110 -0
- package/vendor/trace-collect/sources/grok.mjs +83 -0
- package/vendor/trace-collect/sources/hermes.mjs +4 -0
- package/vendor/trace-collect/sources/index.mjs +119 -0
- package/vendor/trace-collect/sources/jan.mjs +134 -0
- package/vendor/trace-collect/sources/kilo-code.mjs +14 -0
- package/vendor/trace-collect/sources/kiro.mjs +77 -0
- package/vendor/trace-collect/sources/lm-studio.mjs +84 -0
- package/vendor/trace-collect/sources/muse.mjs +11 -0
- package/vendor/trace-collect/sources/opencode.mjs +109 -0
- package/vendor/trace-collect/sources/qwen-code.mjs +95 -0
- package/vendor/trace-collect/sources/roo-code.mjs +14 -0
- package/vendor/trace-collect/sources/zed.mjs +70 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// Parse harness-native session logs into the canonical "trace/v1" format.
|
|
2
|
+
// Supported: Claude Code (~/.claude/projects/**/*.jsonl), Codex (~/.codex/sessions/**/*.jsonl), trace/v1 JSON.
|
|
3
|
+
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { sourceIdentity, nativeIdentity, fileIdentity } from "./identity.mjs";
|
|
6
|
+
|
|
7
|
+
export const FORMAT = "trace/v1";
|
|
8
|
+
|
|
9
|
+
const MENTION_MAX = 512 * 1024;
|
|
10
|
+
|
|
11
|
+
/** A file the user attached: its bytes stay on this computer, outside the serialized trace. */
|
|
12
|
+
function attachment(kind, mediaType, base64, name = "") {
|
|
13
|
+
const a = { kind, media_type: mediaType || "", bytes: Math.floor((base64?.length ?? 0) * 3 / 4), ...(name ? { name } : {}) };
|
|
14
|
+
Object.defineProperty(a, "data", { configurable: true, enumerable: false, value: base64 || "" });
|
|
15
|
+
return a;
|
|
16
|
+
}
|
|
17
|
+
function dataUrl(u) {
|
|
18
|
+
const m = /^data:([^;,]+)?;base64,(.*)$/s.exec(typeof u === "string" ? u : "");
|
|
19
|
+
return m ? { media_type: m[1] || "", data: m[2] } : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function text(parts) {
|
|
23
|
+
if (typeof parts === "string") return parts;
|
|
24
|
+
if (!Array.isArray(parts)) return "";
|
|
25
|
+
return parts.map((p) => (typeof p === "string" ? p : p?.type === "text" || p?.type === "input_text" || p?.type === "output_text" ? p.text : "")).filter(Boolean).join("\n");
|
|
26
|
+
}
|
|
27
|
+
function stripReminders(s) {
|
|
28
|
+
return s.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "").replace(/<command-message>[\s\S]*?<\/command-message>/g, "").trim();
|
|
29
|
+
}
|
|
30
|
+
const SKILL_TAG = /<command-name>\/?([\w:-]+)<\/command-name>/g;
|
|
31
|
+
|
|
32
|
+
/** A reply written by the harness instead of the model: spend or usage limits, signed-out CLI, API failures. */
|
|
33
|
+
export const HARNESS_ERROR = /^\s*(You've hit your (monthly spend |usage |session |weekly )?limit|You've reached your (usage )?limit|Claude AI usage limit reached|API Error[:\s]|Invalid API key|Credit balance is too low|Prompt is too long|Request timed out|Not logged in|OAuth token (has )?expired|Please run \/login|ERROR: (unexpected status|stream disconnected|exceeded retry))/i;
|
|
34
|
+
const modelReply = (m) => m.role === "assistant" && m.text && !HARNESS_ERROR.test(m.text);
|
|
35
|
+
|
|
36
|
+
export function parseJsonl(raw) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const line of raw.split("\n")) { const t = line.trim(); if (!t) continue; try { out.push(JSON.parse(t)); } catch { /* skip partial line */ } }
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function detectHarness(rows, filePath = "") {
|
|
43
|
+
if (/(?:^|\/)\.cursor\/.*\/agent-transcripts\//.test(filePath) && rows.some((r) => ["user", "assistant"].includes(r?.role || r?.type) && r?.message?.content)) return "cursor";
|
|
44
|
+
if (rows.some((r) => r?.type === "session_meta" || r?.type === "response_item")) return "codex";
|
|
45
|
+
if (rows.some((r) => (r?.type === "user" || r?.type === "assistant") && r?.message?.role)) return "claude-code";
|
|
46
|
+
if (rows.length === 1 && rows[0]?.format === FORMAT) return rows[0].harness || "generic";
|
|
47
|
+
if (/\.codex\//.test(filePath)) return "codex";
|
|
48
|
+
if (/\.claude\//.test(filePath)) return "claude-code";
|
|
49
|
+
return "unknown";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function normalizeClaudeCode(rows, meta = {}) {
|
|
53
|
+
const messages = [];
|
|
54
|
+
const skills = new Set();
|
|
55
|
+
let model = "", cwd = meta.cwd || "", sessionId = "", started = null, ended = null, gitBranch = "";
|
|
56
|
+
const run = runInfo();
|
|
57
|
+
const mentions = new Map();
|
|
58
|
+
for (const r of rows) {
|
|
59
|
+
if (r?.type === "attachment" && !r.isSidechain) {
|
|
60
|
+
const a = r.attachment;
|
|
61
|
+
if (a?.type === "file" && typeof a.filename === "string") { const f = a.content?.file; const body = typeof f?.content === "string" ? f.content : ""; mentions.set(a.filename, { kind: "file", path: a.filename, ts: r.timestamp, ...(body && body.length <= MENTION_MAX ? { text: body } : { omitted: body ? "too large" : "no content recorded" }) }); }
|
|
62
|
+
else if (a?.type === "pdf_reference" && typeof a.filename === "string") mentions.set(a.filename, { kind: "pdf", path: a.filename, ts: r.timestamp });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (r?.type !== "user" && r?.type !== "assistant") continue;
|
|
66
|
+
if (r.version) run.version = r.version;
|
|
67
|
+
if (r.entrypoint) run.client ||= r.entrypoint;
|
|
68
|
+
if (r.effort) run.efforts.add(r.effort);
|
|
69
|
+
if (r.gitBranch && r.gitBranch !== "HEAD") gitBranch ||= r.gitBranch;
|
|
70
|
+
if (r.isSidechain) continue;
|
|
71
|
+
const m = r.message; if (!m) continue;
|
|
72
|
+
sessionId ||= r.sessionId || "";
|
|
73
|
+
cwd ||= r.cwd || "";
|
|
74
|
+
if (r.timestamp) { started ||= r.timestamp; ended = r.timestamp; }
|
|
75
|
+
const content = Array.isArray(m.content) ? m.content : [{ type: "text", text: String(m.content ?? "") }];
|
|
76
|
+
if (r.type === "assistant") {
|
|
77
|
+
model ||= m.model || "";
|
|
78
|
+
run.model(m.model);
|
|
79
|
+
if (m.usage && m.id && !run.seen.has(m.id)) { run.seen.add(m.id); run.add({ input: m.usage.input_tokens, output: m.usage.output_tokens, cache_read: m.usage.cache_read_input_tokens, cache_write: m.usage.cache_creation_input_tokens }); }
|
|
80
|
+
const tool_calls = [], thinking = [];
|
|
81
|
+
let t = "";
|
|
82
|
+
for (const p of content) {
|
|
83
|
+
if (p.type === "text") t += (t ? "\n" : "") + p.text;
|
|
84
|
+
else if (p.type === "tool_use") { tool_calls.push({ id: p.id, name: p.name, input: p.input }); if (p.name === "Skill" && p.input?.skill) skills.add(String(p.input.skill)); }
|
|
85
|
+
else if (p.type === "thinking" && p.thinking) thinking.push(p.thinking);
|
|
86
|
+
}
|
|
87
|
+
if (t || tool_calls.length || thinking.length) messages.push({ role: "assistant", ts: r.timestamp, text: t, ...(thinking.length ? { thinking: thinking.join("\n") } : {}), ...(tool_calls.length ? { tool_calls } : {}) });
|
|
88
|
+
} else {
|
|
89
|
+
const results = content.filter((p) => p.type === "tool_result");
|
|
90
|
+
if (results.length) {
|
|
91
|
+
for (const p of results) {
|
|
92
|
+
const out = typeof p.content === "string" ? p.content : text(p.content) || JSON.stringify(p.content ?? "").slice(0, 1_000_000);
|
|
93
|
+
messages.push({ role: "tool", ts: r.timestamp, tool_use_id: p.tool_use_id, is_error: !!p.is_error, text: out });
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (r.isMeta) continue;
|
|
98
|
+
let t = text(content);
|
|
99
|
+
for (const mm of t.matchAll(SKILL_TAG)) skills.add(mm[1]);
|
|
100
|
+
t = stripReminders(t.replace(SKILL_TAG, (_m, s) => `/${s}`));
|
|
101
|
+
const attachments = content.filter((p) => (p.type === "image" || p.type === "document") && p.source).map((p) => attachment(p.type === "image" ? "image" : p.source.media_type === "application/pdf" ? "pdf" : "document", p.source.media_type, p.source.type === "base64" ? p.source.data : "", p.title || ""));
|
|
102
|
+
if (t || attachments.length) messages.push({ role: "user", ts: r.timestamp, text: t || "", ...(attachments.length ? { attachments } : {}) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const t = finish({ harness: "claude-code", session_id: sessionId, model, ...run.out(), cwd, started_at: started, ended_at: ended, messages, skills: [...skills], source: meta.source || "", ...(gitBranch ? { git: { branch: gitBranch } } : {}) });
|
|
106
|
+
Object.defineProperty(t, "mentions", { configurable: true, enumerable: false, value: [...mentions.values()] });
|
|
107
|
+
return t;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function normalizeCodex(rows, meta = {}) {
|
|
111
|
+
const messages = [];
|
|
112
|
+
const skills = new Set();
|
|
113
|
+
let model = "", cwd = meta.cwd || "", sessionId = "", started = null, ended = null;
|
|
114
|
+
const callNames = new Map();
|
|
115
|
+
let gitMeta = null, delegation = null;
|
|
116
|
+
const run = runInfo();
|
|
117
|
+
for (const r of rows) {
|
|
118
|
+
if (r?.type === "session_meta") { run.version ||= r.payload?.cli_version || ""; run.client ||= r.payload?.originator || ""; run.provider ||= r.payload?.model_provider || ""; sessionId = r.payload?.session_id || r.payload?.id || ""; cwd ||= r.payload?.cwd || ""; started ||= r.payload?.timestamp || r.timestamp; const spawn = r.payload?.source?.subagent?.thread_spawn; if (spawn?.parent_thread_id) delegation = { parent_session_id: spawn.parent_thread_id, ...(Number.isInteger(spawn.depth) ? { depth: spawn.depth } : {}) }; const g = r.payload?.git; if (g && !gitMeta) gitMeta = { branch: g.branch || "", commit: g.commit_hash || "", remote: g.repository_url || "" }; continue; }
|
|
119
|
+
if (r?.type === "event_msg" && r.payload?.type === "token_count") { model ||= r.payload?.info?.model || r.payload?.model || ""; const u = r.payload?.info?.total_token_usage; if (u) run.total = { input: u.input_tokens, output: u.output_tokens, cache_read: u.cached_input_tokens, reasoning: u.reasoning_output_tokens }; continue; }
|
|
120
|
+
if (r?.type === "turn_context") { model ||= r.payload?.model || ""; run.model(r.payload?.model); if (r.payload?.effort) run.efforts.add(r.payload.effort); continue; }
|
|
121
|
+
if (r?.type !== "response_item") continue;
|
|
122
|
+
const p = r.payload; if (!p) continue;
|
|
123
|
+
if (r.timestamp) { started ||= r.timestamp; ended = r.timestamp; }
|
|
124
|
+
if (p.type === "message") {
|
|
125
|
+
if (p.role === "developer" || p.role === "system") continue;
|
|
126
|
+
let t = text(p.content);
|
|
127
|
+
if (p.role === "user") {
|
|
128
|
+
const attachments = (Array.isArray(p.content) ? p.content : []).filter((c) => c?.type === "input_image").map((c) => { const d = dataUrl(c.image_url); return attachment("image", d?.media_type, d?.data); });
|
|
129
|
+
if (attachments.length) { messages.push({ role: "user", ts: r.timestamp, text: t || "", attachments }); continue; }
|
|
130
|
+
if (/^<(environment_context|user_instructions|permissions instructions|recommended_plugins|app-context|skills_instructions)[\s>]/.test(t.trim())) continue;
|
|
131
|
+
if (/^# AGENTS\.md instructions/.test(t.trim())) continue;
|
|
132
|
+
for (const mm of t.matchAll(/\$([a-z][\w-]{2,})|\/([a-z][\w-]{2,})\b/g)) if (mm[1]) skills.add(mm[1]);
|
|
133
|
+
const sk = t.match(/<skill[^>]*name="([^"]+)"/g); if (sk) for (const x of sk) skills.add(x.replace(/.*name="([^"]+)".*/, "$1"));
|
|
134
|
+
if (t) messages.push({ role: "user", ts: r.timestamp, text: t });
|
|
135
|
+
} else if (p.role === "assistant" && t) messages.push({ role: "assistant", ts: r.timestamp, text: t });
|
|
136
|
+
} else if (p.type === "function_call" || p.type === "custom_tool_call") {
|
|
137
|
+
let input = p.arguments ?? p.input ?? "";
|
|
138
|
+
if (typeof input === "string") { try { input = JSON.parse(input); } catch { /* keep string */ } }
|
|
139
|
+
callNames.set(p.call_id, p.name);
|
|
140
|
+
const last = messages[messages.length - 1];
|
|
141
|
+
const call = { id: p.call_id, name: p.name, input };
|
|
142
|
+
if (last?.role === "assistant" && !last.text && last.tool_calls) last.tool_calls.push(call);
|
|
143
|
+
else messages.push({ role: "assistant", ts: r.timestamp, text: "", tool_calls: [call] });
|
|
144
|
+
} else if (p.type === "function_call_output" || p.type === "custom_tool_call_output") {
|
|
145
|
+
const out = typeof p.output === "string" ? p.output : JSON.stringify(p.output ?? "");
|
|
146
|
+
messages.push({ role: "tool", ts: r.timestamp, tool_use_id: p.call_id, tool_name: callNames.get(p.call_id), text: out.slice(0, 1_000_000) });
|
|
147
|
+
} else if (p.type === "reasoning") {
|
|
148
|
+
const summ = (p.summary ?? []).map((s) => s?.text).filter(Boolean).join("\n");
|
|
149
|
+
if (summ) messages.push({ role: "assistant", ts: r.timestamp, text: "", thinking: summ });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return finish({ harness: "codex", session_id: sessionId, model, ...run.out(), cwd, started_at: started, ended_at: ended, messages, skills: [...skills], source: meta.source || "", ...(gitMeta ? { git: gitMeta } : {}), ...(delegation ? { parent_session_id: delegation.parent_session_id, delegation } : {}) });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** OpenAI-style {messages:[{role,content,tool_calls?}]} — Hermes sessions and most custom harnesses. */
|
|
156
|
+
export function normalizeChatMessages(obj, meta = {}) {
|
|
157
|
+
const src = obj.messages || obj.history || [];
|
|
158
|
+
const messages = [];
|
|
159
|
+
const skills = new Set();
|
|
160
|
+
const harness = obj.harness || (obj.platform || /^session_\d{8}_/.test(meta.source.split("/").pop() || "") ? "hermes" : "generic");
|
|
161
|
+
const callNames = new Map();
|
|
162
|
+
for (const m of src) {
|
|
163
|
+
if (!m || typeof m !== "object") continue;
|
|
164
|
+
const role = m.role;
|
|
165
|
+
const t = typeof m.content === "string" ? m.content : text(m.content);
|
|
166
|
+
if (role === "system" || role === "developer") continue;
|
|
167
|
+
if (role === "user") { if (t) messages.push({ role: "user", ts: m.timestamp || m.ts, text: stripReminders(t) }); continue; }
|
|
168
|
+
if (role === "assistant") {
|
|
169
|
+
const tool_calls = (m.tool_calls || []).map((c) => { const name = c.function?.name || c.name || "tool"; let input = c.function?.arguments ?? c.arguments ?? c.input ?? ""; if (typeof input === "string") { try { input = JSON.parse(input); } catch { /* keep */ } } callNames.set(c.id || c.call_id, name); if (/^skill/.test(name) && input?.name) skills.add(String(input.name)); return { id: c.id || c.call_id, name, input }; });
|
|
170
|
+
const thinking = m.reasoning_content || m.reasoning || "";
|
|
171
|
+
if (t || tool_calls.length || (thinking && thinking.trim())) messages.push({ role: "assistant", ts: m.timestamp || m.ts, text: t, ...(thinking && thinking.trim() ? { thinking } : {}), ...(tool_calls.length ? { tool_calls } : {}) });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (role === "tool" || role === "function") messages.push({ role: "tool", ts: m.timestamp || m.ts, tool_use_id: m.tool_call_id, tool_name: m.name || callNames.get(m.tool_call_id), text: (t || "").slice(0, 1_000_000) });
|
|
175
|
+
}
|
|
176
|
+
return finish({ harness, session_id: obj.session_id || obj.id || "", model: obj.model || "", cwd: obj.cwd || "", started_at: obj.session_start || obj.started_at || null, ended_at: obj.last_updated || obj.ended_at || null, messages, skills: [...skills], source: meta.source || "" });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function normalizeCursor(rows, meta = {}) {
|
|
180
|
+
const mapped = rows.map((r) => r?.message && ["user", "assistant"].includes(r.role || r.type) ? { ...r, type: r.role || r.type, message: { ...r.message, role: r.role || r.type } } : r);
|
|
181
|
+
const t = normalizeClaudeCode(mapped, meta);
|
|
182
|
+
const oldId = t.native_session_id || "";
|
|
183
|
+
t.harness = "cursor";
|
|
184
|
+
t.session_id = oldId || path.basename(meta.source || "", path.extname(meta.source || ""));
|
|
185
|
+
return finish(t, [{ harness: "claude-code", seed: oldId || meta.source || "", native: !!oldId }]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function runInfo() {
|
|
189
|
+
const counts = new Map();
|
|
190
|
+
const sum = { input: 0, output: 0, cache_read: 0, cache_write: 0 };
|
|
191
|
+
const info = {
|
|
192
|
+
version: "", client: "", provider: "", efforts: new Set(), seen: new Set(), total: null,
|
|
193
|
+
model(m) { if (m && !/^<.*>$/.test(m)) counts.set(m, (counts.get(m) ?? 0) + 1); },
|
|
194
|
+
add(u) { for (const k of Object.keys(sum)) sum[k] += Number(u[k]) || 0; },
|
|
195
|
+
out() {
|
|
196
|
+
const models = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([m]) => m);
|
|
197
|
+
const usage = Object.fromEntries(Object.entries(info.total ?? sum).filter(([, v]) => Number(v) > 0).map(([k, v]) => [`${k}_tokens`, Number(v)]));
|
|
198
|
+
return {
|
|
199
|
+
...(models.length ? { model: models[0], models } : {}),
|
|
200
|
+
...(info.version ? { harness_version: String(info.version) } : {}),
|
|
201
|
+
...(info.client ? { client: String(info.client) } : {}),
|
|
202
|
+
...(info.provider ? { model_provider: String(info.provider) } : {}),
|
|
203
|
+
...(info.efforts.size ? { effort: [...info.efforts].join(", ") } : {}),
|
|
204
|
+
...(Object.keys(usage).length ? { usage } : {}),
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
return info;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function finish(t, extraSeeds = []) {
|
|
212
|
+
const user = t.messages.filter((m) => m.role === "user");
|
|
213
|
+
const assistant = t.messages.filter((m) => m.role === "assistant");
|
|
214
|
+
const tools = new Map();
|
|
215
|
+
for (const m of assistant) for (const c of m.tool_calls ?? []) tools.set(c.name, (tools.get(c.name) ?? 0) + 1);
|
|
216
|
+
const chars = t.messages.reduce((n, m) => n + (m.text?.length ?? 0) + (m.thinking?.length ?? 0), 0);
|
|
217
|
+
const dur = t.started_at && t.ended_at ? Math.max(0, (Date.parse(t.ended_at) - Date.parse(t.started_at)) / 60000) : 0;
|
|
218
|
+
t.stats = {
|
|
219
|
+
turns: user.length + assistant.filter((m) => m.text).length,
|
|
220
|
+
user_turns: user.length,
|
|
221
|
+
assistant_turns: assistant.length,
|
|
222
|
+
tool_calls: [...tools.values()].reduce((a, b) => a + b, 0),
|
|
223
|
+
tools: [...tools.keys()],
|
|
224
|
+
tool_counts: Object.fromEntries(tools),
|
|
225
|
+
skills: t.skills,
|
|
226
|
+
duration_min: Math.round(dur),
|
|
227
|
+
chars,
|
|
228
|
+
corrections: countCorrections(user),
|
|
229
|
+
error_only: assistant.some((m) => m.text) && !assistant.some(modelReply),
|
|
230
|
+
...(t.harness_version ? { harness_version: t.harness_version } : {}),
|
|
231
|
+
...(t.models?.length ? { models: t.models } : {}),
|
|
232
|
+
...(t.ended_at ? { ended_at: t.ended_at } : {}),
|
|
233
|
+
...(t.usage ? { usage: t.usage } : {}),
|
|
234
|
+
};
|
|
235
|
+
t.format = FORMAT;
|
|
236
|
+
const human = (m) => m.text && !/^\s*<[a-z_-]+[\s>]/i.test(m.text) && !/^\s*# AGENTS\.md/.test(m.text) && m.text.trim().length >= 8;
|
|
237
|
+
t.title = (user.find(human)?.text || user.find((m) => m.text)?.text || "untitled session").replace(/<[^>]{0,80}>/g, " ").replace(/\s+/g, " ").trim().slice(0, 120) || "untitled session";
|
|
238
|
+
const previous = t.source_identity;
|
|
239
|
+
const opaque = previous?.version === 3 && previous.kind === "session" && /^[a-f0-9]{32}$/.test(previous.token || "") && t.session_id === previous.token && previous.key === nativeIdentity(t.harness, previous.token);
|
|
240
|
+
const file = [2, 3].includes(previous?.version) && previous.kind === "file" && /^[a-f0-9]{32}$/.test(previous.token || "") && previous.key === fileIdentity(t.harness, previous.token);
|
|
241
|
+
const nativeId = opaque ? "" : t.session_id || "";
|
|
242
|
+
t.source_identity = opaque || (!nativeId && file) ? { ...previous, version: 3 } : sourceIdentity(t.harness, nativeId, t.source || "");
|
|
243
|
+
t.source_hash = t.source_identity.key;
|
|
244
|
+
t.session_id = t.source_identity.kind === "session" ? t.source_identity.token : "";
|
|
245
|
+
Object.defineProperty(t, "native_session_id", { configurable: true, enumerable: false, value: nativeId || undefined });
|
|
246
|
+
if (t.parent_session_id && !opaque) {
|
|
247
|
+
Object.defineProperty(t, "native_parent_session_id", { configurable: true, enumerable: false, value: t.parent_session_id });
|
|
248
|
+
t.parent_session_id = sourceIdentity(t.harness, t.parent_session_id).key;
|
|
249
|
+
if (t.delegation) t.delegation = { ...t.delegation, parent_session_id: t.parent_session_id };
|
|
250
|
+
}
|
|
251
|
+
// Native identifiers and compatibility seeds stay on this computer, outside serialized traces.
|
|
252
|
+
const seeds = opaque || (!nativeId && file) ? [] : [{ harness: t.harness, seed: nativeId || t.source, native: !!nativeId }, ...extraSeeds];
|
|
253
|
+
Object.defineProperty(t, "legacy_identity", { configurable: true, enumerable: false, value: { count: t.messages.length, seeds } });
|
|
254
|
+
return t;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const CORRECTION = /\b(no[,.]|not that|wrong|incorrect|actually|instead|don't|do not|undo|revert|that's not|should be|stop)\b|不对|不是|错了|不要|改成|重新|撤销|别/i;
|
|
258
|
+
export function countCorrections(userMsgs) { return userMsgs.slice(1).filter((m) => CORRECTION.test(m.text || "")).length; }
|
|
259
|
+
|
|
260
|
+
/** Converted sources store an attachment's text inline; give it the same shape as a native attachment so context collection reads it. */
|
|
261
|
+
function fileAttachments(m) {
|
|
262
|
+
if (!m?.attachments?.some((a) => typeof a.text === "string" && !a.data)) return m;
|
|
263
|
+
return { ...m, attachments: m.attachments.map((a) => (typeof a.text === "string" && !a.data ? attachment(a.kind || "document", a.media_type || "text/plain", Buffer.from(a.text, "utf8").toString("base64"), a.name || "") : a)) };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function normalizeAny(raw, filePath = "") {
|
|
267
|
+
const trimmed = raw.trimStart();
|
|
268
|
+
if (trimmed.startsWith("{") && (filePath.endsWith(".json") || !trimmed.includes("\n{"))) {
|
|
269
|
+
let obj = null; try { obj = JSON.parse(trimmed); } catch { /* fallthrough to jsonl */ }
|
|
270
|
+
if (obj?.format === FORMAT && Array.isArray(obj.messages)) return finish({ ...obj, messages: obj.messages.map(fileAttachments), harness: obj.harness || "generic", session_id: obj.session_id || "", source: obj.source || filePath, skills: obj.stats?.skills ?? obj.skills ?? [] }, !obj.session_id && !obj.source ? [{ harness: obj.harness, seed: "undefined" }] : []);
|
|
271
|
+
if (obj && Array.isArray(obj.messages || obj.history)) return normalizeChatMessages(obj, { source: filePath });
|
|
272
|
+
}
|
|
273
|
+
const rows = parseJsonl(raw);
|
|
274
|
+
const h = detectHarness(rows, filePath);
|
|
275
|
+
if (h === "cursor") return normalizeCursor(rows, { source: filePath });
|
|
276
|
+
if (h === "codex") return normalizeCodex(rows, { source: filePath });
|
|
277
|
+
if (h === "claude-code") return normalizeClaudeCode(rows, { source: filePath });
|
|
278
|
+
throw new Error(`unrecognized trace format: ${filePath || "(input)"}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Short redacted preview for the listing page: first user ask + first assistant reply. */
|
|
282
|
+
export function makePreview(t, max = 1200) {
|
|
283
|
+
const u = t.messages.find((m) => m.role === "user" && m.text)?.text ?? "";
|
|
284
|
+
const a = t.messages.find(modelReply)?.text ?? "";
|
|
285
|
+
return `USER: ${u.slice(0, max / 2)}\n\nASSISTANT: ${a.slice(0, max / 2)}`.trim();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Up to 3 public sample excerpts for a listing (already-scrubbed trace in, short text out):
|
|
290
|
+
* 1) the opening ask + first reply, 2) a user correction exchange (or the middle of the session),
|
|
291
|
+
* 3) a tool-use step. Labs see only these before buying; the seller can replace them.
|
|
292
|
+
*/
|
|
293
|
+
export function makeSamples(t, max = 1200) {
|
|
294
|
+
const msgs = t.messages;
|
|
295
|
+
const cut = (s, n = max / 2) => (s || "").replace(/\s+/g, " ").trim().slice(0, n);
|
|
296
|
+
const pairAt = (i) => { const u = msgs[i]; const a = msgs.slice(i + 1).find(modelReply); return u && a ? `USER: ${cut(u.text)}\n\nASSISTANT: ${cut(a.text)}` : ""; };
|
|
297
|
+
const samples = [];
|
|
298
|
+
const firstUser = msgs.findIndex((m) => m.role === "user" && m.text);
|
|
299
|
+
if (firstUser >= 0) samples.push({ label: "Opening request", text: pairAt(firstUser) });
|
|
300
|
+
const users = msgs.map((m, i) => [m, i]).filter(([m, i]) => m.role === "user" && m.text && i !== firstUser);
|
|
301
|
+
const corr = users.find(([m]) => CORRECTION.test(m.text));
|
|
302
|
+
const mid = users[Math.floor(users.length / 2)];
|
|
303
|
+
const second = corr ?? mid;
|
|
304
|
+
if (second) { const txt = pairAt(second[1]); if (txt) samples.push({ label: corr ? "User correction" : "Mid-session exchange", text: txt }); }
|
|
305
|
+
const toolIdx = msgs.findIndex((m) => m.role === "assistant" && m.tool_calls?.length);
|
|
306
|
+
if (toolIdx >= 0) {
|
|
307
|
+
const a = msgs[toolIdx]; const res = msgs.slice(toolIdx + 1).find((m) => m.role === "tool");
|
|
308
|
+
const calls = a.tool_calls.slice(0, 2).map((c) => `${c.name}(${cut(JSON.stringify(c.input ?? {}), 200)})`).join("\n");
|
|
309
|
+
samples.push({ label: "Tool use", text: `${a.text ? `ASSISTANT: ${cut(a.text, 300)}\n` : ""}CALLS:\n${calls}${res ? `\n\nRESULT: ${cut(res.text, 400)}` : ""}` });
|
|
310
|
+
}
|
|
311
|
+
return samples.filter((s) => s.text).slice(0, 3);
|
|
312
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export function createScrubber(opts?: { names?: string[]; terms?: string[]; keepEmails?: string[] }): { scrub<T>(v: T): T; scrubText(s: string): string; report: Record<string, number> };
|
|
2
|
+
export function residualScan(value: unknown, names?: string[]): Array<{ kind: string; count: number; sample: string }>;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// PII / secret scrubbing for agent traces. Pure, deterministic, no deps.
|
|
2
|
+
// Same code runs in the local skill (before upload) and on the server (web uploads).
|
|
3
|
+
|
|
4
|
+
const SECRET_PATTERNS = [
|
|
5
|
+
/sk-ant-[A-Za-z0-9_\-]{20,}/g,
|
|
6
|
+
/sk-(?:proj-|live-|test-)?[A-Za-z0-9_\-]{20,}/g,
|
|
7
|
+
/(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}(?:\.[A-Za-z0-9_\-]+){0,2}/g,
|
|
8
|
+
/github_pat_[A-Za-z0-9_]{30,}/g,
|
|
9
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
10
|
+
/xox[abprs]-[A-Za-z0-9\-]{10,}/g,
|
|
11
|
+
/AIza[0-9A-Za-z_\-]{35}/g,
|
|
12
|
+
/tm_[A-Za-z0-9_\-]{24,}/g,
|
|
13
|
+
/re_[A-Za-z0-9]{20,}/g,
|
|
14
|
+
/(?:pk|sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
|
|
15
|
+
/whsec_[A-Za-z0-9]{20,}/g,
|
|
16
|
+
/ya29\.[A-Za-z0-9_\-]{30,}/g,
|
|
17
|
+
/eyJ[A-Za-z0-9_\-]{10,}\.eyJ[A-Za-z0-9_\-]*(?:\.[A-Za-z0-9_\-]*)?/g, // JWT, also when cut off
|
|
18
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
19
|
+
/\b(?:Bearer|Basic)\s+[A-Za-z0-9_\-\.=+/]{16,}/g,
|
|
20
|
+
];
|
|
21
|
+
const KV_SECRET = /\b((?:api[_-]?key|secret[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key|password|passwd|pwd|token|secret)\s*[:=]\s*["']?)([^\s"',;]{8,})/gi;
|
|
22
|
+
const URL_SECRET = /([?&](?:token|key|sig|signature|access_token|api_key|apikey|auth|secret|password)=)[^&\s"']+/gi;
|
|
23
|
+
const EMAIL = /[A-Za-z0-9._%+\-*]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/g;
|
|
24
|
+
const SOCIAL = /((?:linkedin\.com\/in|x\.com|twitter\.com|instagram\.com|facebook\.com|tiktok\.com\/@|xiaohongshu\.com\/user\/profile|t\.me)\/)@?[A-Za-z0-9._\-]{2,}/gi;
|
|
25
|
+
const PHONE = /(?<![\d\w./-])(?:\+?\d{1,3}[\s.-]?)?(?:\(\d{2,4}\)[\s.-]?)?\d{3,4}[\s.-]\d{3,4}(?:[\s.-]\d{2,4})?(?![\d\w])/g;
|
|
26
|
+
const IPV4 = /\b(?!127\.0\.0\.1\b|0\.0\.0\.0\b)(?:\d{1,3}\.){3}\d{1,3}\b/g;
|
|
27
|
+
const SSN = /\b\d{3}-\d{2}-\d{4}\b/g;
|
|
28
|
+
const CARD = /\b(?:\d[ -]?){13,19}\b/g;
|
|
29
|
+
const HOME = /(\/(?:Users|home)\/)([A-Za-z0-9._\-]+)/g;
|
|
30
|
+
const WINHOME = /([A-Za-z]:\\Users\\)([A-Za-z0-9._\-]+)/g;
|
|
31
|
+
const BASE64 = /(?:[A-Za-z0-9+/]{4}){60,}={0,2}/g;
|
|
32
|
+
const LONGHEX = /\b[a-f0-9]{48,}\b/gi;
|
|
33
|
+
const ADDRESS = /\b\d{1,6}\s+(?:[A-Za-z][A-Za-z.'-]*\s+){1,5}(?:Street|St|Road|Rd|Avenue|Ave|Boulevard|Blvd|Drive|Dr|Lane|Ln|Court|Ct|Way|Place|Pl|Terrace|Ter)\b(?:[.,]?\s*(?:Apt|Unit|Suite|#)\s*[\w-]+)?/gi;
|
|
34
|
+
const VERIFICATION_CODE = /(?<!<)(?:verification|security|authentication|one[- ]time|otp|2fa|验证码|动态码)(?:\s+code)?[^\d\r\n]{0,12}\d{4,8}(?!\d)/gi;
|
|
35
|
+
const KEEP_EMAILS = ["noreply@anthropic.com", "noreply@github.com"];
|
|
36
|
+
const BARE_CODE = /^\s*\d{4,8}\s*$/g;
|
|
37
|
+
const IMAGE_REF = /\[image:[^\]\r\n]*(?:\]|$)/gi;
|
|
38
|
+
|
|
39
|
+
function luhn(s) {
|
|
40
|
+
const d = s.replace(/\D/g, "");
|
|
41
|
+
if (d.length < 13 || d.length > 19) return false;
|
|
42
|
+
let sum = 0, alt = false;
|
|
43
|
+
for (let i = d.length - 1; i >= 0; i--) { let n = +d[i]; if (alt) { n *= 2; if (n > 9) n -= 9; } sum += n; alt = !alt; }
|
|
44
|
+
return sum % 10 === 0;
|
|
45
|
+
}
|
|
46
|
+
const WORD = /[A-Za-z0-9_]/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Case-insensitive literal search for every name pattern in one pass over the text: patterns are indexed by
|
|
50
|
+
* their first three characters, so each position costs one table lookup. Patterns come longest first;
|
|
51
|
+
* overlapping hits keep the leftmost, then the longest, as a regex alternation would.
|
|
52
|
+
*/
|
|
53
|
+
function nameMatcher(names) {
|
|
54
|
+
if (!names.length) return null;
|
|
55
|
+
const key = (s, i) => s.charCodeAt(i) * 1e10 + s.charCodeAt(i + 1) * 1e5 + s.charCodeAt(i + 2);
|
|
56
|
+
const index = new Map();
|
|
57
|
+
names.forEach(([n, { boundary }], i) => {
|
|
58
|
+
const p = n.toLowerCase();
|
|
59
|
+
if (p.length < 3) return;
|
|
60
|
+
const k = key(p, 0);
|
|
61
|
+
(index.get(k) ?? index.set(k, []).get(k)).push({ p, boundary, i });
|
|
62
|
+
});
|
|
63
|
+
const slow = new RegExp(names.map(([n, { boundary }]) => (boundary ? `(?<![A-Za-z0-9_])${escapeRe(n)}(?![A-Za-z0-9_])` : escapeRe(n))).join("|"), "gi");
|
|
64
|
+
return function find(s) {
|
|
65
|
+
if (s.length < 3) return [];
|
|
66
|
+
const lower = s.toLowerCase();
|
|
67
|
+
if (lower.length !== s.length) return [...s.matchAll(slow)].map((m) => ({ start: m.index, end: m.index + m[0].length, text: m[0] }));
|
|
68
|
+
const out = [];
|
|
69
|
+
for (let at = 0; at + 3 <= lower.length; at++) {
|
|
70
|
+
const cands = index.get(key(lower, at));
|
|
71
|
+
if (!cands) continue;
|
|
72
|
+
for (const { p, boundary } of cands) {
|
|
73
|
+
if (!lower.startsWith(p, at)) continue;
|
|
74
|
+
if (boundary && ((at > 0 && WORD.test(s[at - 1])) || (at + p.length < s.length && WORD.test(s[at + p.length])))) continue;
|
|
75
|
+
out.push({ start: at, end: at + p.length, text: s.slice(at, at + p.length) });
|
|
76
|
+
at += p.length - 1;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
|
85
|
+
|
|
86
|
+
const COMMON_WORDS = new Set("song clay brow sola tong hong neil khan parker taylor jordan summer roman boston bologna santos silva franco carlo will mark may june april august grant bill art rich joy hope chase page rose dawn faith grace sky max ray jack frank lane long young king hill wood stone brown green white black gray grey bell ford hall love moon star sun lee li wang zhang chen liu yang zhao huang zhou wu xu sun ma zhu hu guo he lin luo gao zheng liang xie tang han feng deng cao peng zeng xiao tian dong pan yuan cai jiang yu du ye cheng wei su lu ding ren shen yao jin qu".split(" "));
|
|
87
|
+
|
|
88
|
+
/** "Xiaoyin Qu" -> xiaoyin qu, qu xiaoyin, xiaoyinqu, quxiaoyin, xiaoyin (long tokens also match inside handles). */
|
|
89
|
+
export function expandNames(names) {
|
|
90
|
+
const out = new Map(); // pattern -> { boundary }
|
|
91
|
+
for (const raw of names) {
|
|
92
|
+
const n = String(raw).trim(); if (n.length < 3) continue;
|
|
93
|
+
const toks = n.split(/[\s._-]+/).filter(Boolean);
|
|
94
|
+
const add = (p, boundary) => { const k = p.toLowerCase(); if (p.length < 3) return; const prev = out.get(k); if (!prev || (prev.boundary && !boundary)) out.set(k, { boundary }); };
|
|
95
|
+
add(n, true);
|
|
96
|
+
if (toks.length > 1) { add(toks.join(" "), true); add([...toks].reverse().join(" "), true); add(toks.join(""), false); add([...toks].reverse().join(""), false); add(toks.join("_"), false); add(toks.join("."), true); }
|
|
97
|
+
// A lone common word ("will", "mark", "root") is not removed everywhere; the full name still is.
|
|
98
|
+
// Parts under 4 letters ("lie", "van", "kim") are too often ordinary words to remove on their own.
|
|
99
|
+
for (const t of toks) if (toks.length === 1 || (t.length >= 4 && !COMMON_WORDS.has(t.toLowerCase()))) add(t, t.length < 5);
|
|
100
|
+
}
|
|
101
|
+
return [...out.entries()].sort((a, b) => b[0].length - a[0].length);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {object} opts
|
|
106
|
+
* @param {string[]} [opts.names] personal names / handles / orgs to replace with <NAME_n>
|
|
107
|
+
* @param {string[]} [opts.terms] any other literal strings to replace with <REDACTED>
|
|
108
|
+
* @param {string[]} [opts.keepEmails] emails allowed to stay (e.g. noreply@anthropic.com)
|
|
109
|
+
*/
|
|
110
|
+
export function createScrubber(opts = {}) {
|
|
111
|
+
const names = expandNames(opts.names ?? []);
|
|
112
|
+
const terms = [...new Set((opts.terms ?? []).map((s) => s.trim()).filter((s) => s.length >= 3))].sort((a, b) => b.length - a.length);
|
|
113
|
+
const keepEmails = new Set((opts.keepEmails ?? KEEP_EMAILS).map((e) => e.toLowerCase()));
|
|
114
|
+
const emailMap = new Map();
|
|
115
|
+
const nameTags = new Map(names.map(([n], i) => [n, `<NAME_${i + 1}>`]));
|
|
116
|
+
const findNames = nameMatcher(names);
|
|
117
|
+
const termRes = terms.map((t) => new RegExp(escapeRe(t), "gi"));
|
|
118
|
+
const report = { emails: 0, phones: 0, ips: 0, secrets: 0, cards: 0, ssn: 0, home_paths: 0, names: 0, terms: 0, blobs: 0, addresses: 0, verification_codes: 0 };
|
|
119
|
+
|
|
120
|
+
function scrubText(s) {
|
|
121
|
+
if (typeof s !== "string" || !s) return s;
|
|
122
|
+
let out = s;
|
|
123
|
+
out = out.replace(IMAGE_REF, () => { report.blobs++; return "<IMAGE_OMITTED>"; });
|
|
124
|
+
out = out.replace(ADDRESS, () => { report.addresses++; return "<ADDRESS>"; });
|
|
125
|
+
out = out.replace(VERIFICATION_CODE, () => { report.verification_codes++; return "<VERIFICATION_CODE>"; });
|
|
126
|
+
out = out.replace(BARE_CODE, () => { report.verification_codes++; return "<NUMBER_REDACTED>"; });
|
|
127
|
+
out = out.replace(BASE64, () => { report.blobs++; return "<BLOB_OMITTED>"; });
|
|
128
|
+
out = out.replace(LONGHEX, () => { report.blobs++; return "<HEX_OMITTED>"; });
|
|
129
|
+
for (const re of SECRET_PATTERNS) out = out.replace(re, () => { report.secrets++; return "<SECRET>"; });
|
|
130
|
+
out = out.replace(KV_SECRET, (_m, k) => { report.secrets++; return `${k}<SECRET>`; });
|
|
131
|
+
out = out.replace(URL_SECRET, (_m, k) => { report.secrets++; return `${k}<SECRET>`; });
|
|
132
|
+
out = out.replace(EMAIL, (m) => {
|
|
133
|
+
if (keepEmails.has(m.toLowerCase())) return m;
|
|
134
|
+
if (!emailMap.has(m.toLowerCase())) emailMap.set(m.toLowerCase(), `<EMAIL_${emailMap.size + 1}>`);
|
|
135
|
+
report.emails++; return emailMap.get(m.toLowerCase());
|
|
136
|
+
});
|
|
137
|
+
out = out.replace(SOCIAL, (_m, p) => { report.names++; return `${p}<PROFILE>`; });
|
|
138
|
+
out = out.replace(CARD, (m) => (luhn(m) ? (report.cards++, "<CARD>") : m));
|
|
139
|
+
out = out.replace(SSN, () => { report.ssn++; return "<SSN>"; });
|
|
140
|
+
out = out.replace(PHONE, (m) => (m.replace(/\D/g, "").length >= 9 ? (report.phones++, "<PHONE>") : m));
|
|
141
|
+
out = out.replace(IPV4, () => { report.ips++; return "<IP>"; });
|
|
142
|
+
out = out.replace(HOME, (_m, p) => { report.home_paths++; return `${p}<USER>`; });
|
|
143
|
+
out = out.replace(WINHOME, (_m, p) => { report.home_paths++; return `${p}<USER>`; });
|
|
144
|
+
if (findNames) {
|
|
145
|
+
const hits = findNames(out);
|
|
146
|
+
if (hits.length) {
|
|
147
|
+
let next = "", last = 0;
|
|
148
|
+
for (const h of hits) { next += out.slice(last, h.start) + (nameTags.get(h.text.toLowerCase()) ?? "<NAME>"); last = h.end; report.names++; }
|
|
149
|
+
out = next + out.slice(last);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const re of termRes) out = out.replace(re, () => { report.terms++; return "<REDACTED>"; });
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Deep-scrub any JSON value (strings inside objects/arrays). */
|
|
157
|
+
function scrub(v) {
|
|
158
|
+
if (typeof v === "string") return scrubText(v);
|
|
159
|
+
if (Array.isArray(v)) return v.map(scrub);
|
|
160
|
+
if (v && typeof v === "object") { const o = {}; for (const [k, x] of Object.entries(v)) o[scrubText(k)] = scrub(x); return o; }
|
|
161
|
+
return v;
|
|
162
|
+
}
|
|
163
|
+
return { scrub, scrubText, report };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Residual scan after scrubbing: anything that still looks like PII. Returns sample hits. */
|
|
167
|
+
export function residualScan(text, names = []) {
|
|
168
|
+
const hits = [];
|
|
169
|
+
const checks = [...SECRET_PATTERNS.map((p) => ["secret", p]), ["email", EMAIL], ["jwt", /eyJ[A-Za-z0-9_\-]{10,}\.eyJ/g], ["aws", /AKIA[0-9A-Z]{16}/g], ["ssn", SSN], ["social", SOCIAL], ["address", ADDRESS], ["verification_code", VERIFICATION_CODE], ["verification_code", BARE_CODE], ["image", IMAGE_REF]];
|
|
170
|
+
const findNames = nameMatcher(expandNames(names).filter(([n]) => n.length >= 5));
|
|
171
|
+
const visit = (value) => {
|
|
172
|
+
if (typeof value === "string") {
|
|
173
|
+
// A trace body may itself be a JSON string inside an upload envelope; scan its values, not its escaped text.
|
|
174
|
+
if (/^\s*\{/.test(value)) { try { const parsed = JSON.parse(value); if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && ("messages" in parsed || "body" in parsed || "format" in parsed)) return visit(parsed); } catch {} }
|
|
175
|
+
for (const [kind, re] of checks) {
|
|
176
|
+
const m = (value.match(re) ?? []).filter((x) => kind !== "email" || !KEEP_EMAILS.includes(x.toLowerCase()));
|
|
177
|
+
if (m.length) hits.push({ kind, count: m.length, sample: m[0].slice(0, 40) });
|
|
178
|
+
}
|
|
179
|
+
const n = findNames?.(value) ?? [];
|
|
180
|
+
if (n.length) hits.push({ kind: "name", count: n.length, sample: n[0].text.slice(0, 40) });
|
|
181
|
+
} else if (Array.isArray(value)) value.forEach(visit);
|
|
182
|
+
else if (value && typeof value === "object") for (const [key, child] of Object.entries(value)) { visit(key); visit(child); }
|
|
183
|
+
};
|
|
184
|
+
visit(text);
|
|
185
|
+
return hits;
|
|
186
|
+
}
|