@mingozhou/dsh-replay 0.3.3 → 0.4.2
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/README.md +225 -164
- package/README.zh.md +225 -164
- package/assets/icon.png +0 -0
- package/lib/client.js +955 -2419
- package/lib/core.js +557 -86
- package/lib/index.js +1043 -51
- package/lib/types/src/client/api.d.ts +44 -0
- package/lib/types/src/client/components/AuditView.d.ts +2 -1
- package/lib/types/src/client/components/CompareView.d.ts +2 -1
- package/lib/types/src/client/components/DashboardView.d.ts +8 -0
- package/lib/types/src/client/components/ExportDialog.d.ts +9 -0
- package/lib/types/src/client/components/ReplayApp.d.ts +4 -2
- package/lib/types/src/client/components/ReplayModal.d.ts +1 -1
- package/lib/types/src/client/components/SessionPicker.d.ts +4 -3
- package/lib/types/src/client/components/TimelineView.d.ts +2 -1
- package/lib/types/src/client/export.d.ts +6 -1
- package/lib/types/src/client/i18n.d.ts +8 -1
- package/lib/types/src/client/pricing.d.ts +6 -0
- package/lib/types/src/core/audit.d.ts +18 -0
- package/lib/types/src/core/compare.d.ts +8 -3
- package/lib/types/src/core/cost.d.ts +21 -3
- package/lib/types/src/core/index.d.ts +1 -0
- package/lib/types/src/core/redact.d.ts +40 -0
- package/lib/types/src/core/timeline.d.ts +9 -0
- package/lib/types/src/index.d.ts +52 -1
- package/lib/viewer.js +282 -29
- package/package.json +118 -107
- package/scripts/build.mjs +8 -2
- package/src/client/api.ts +114 -10
- package/src/client/components/AuditView.tsx +89 -10
- package/src/client/components/CompareView.tsx +89 -13
- package/src/client/components/DashboardView.tsx +232 -0
- package/src/client/components/ExportDialog.tsx +136 -0
- package/src/client/components/ItemDetail.tsx +36 -4
- package/src/client/components/OverviewView.tsx +150 -34
- package/src/client/components/ReplayApp.tsx +207 -60
- package/src/client/components/ReplayModal.tsx +41 -6
- package/src/client/components/SessionPicker.tsx +55 -27
- package/src/client/components/TimelineView.tsx +106 -41
- package/src/client/export.ts +20 -4
- package/src/client/i18n.ts +266 -45
- package/src/client/index.ts +7 -5
- package/src/client/mascot-image.ts +1 -1
- package/src/client/pricing.ts +90 -0
- package/src/client/styles.css +250 -1
- package/src/client/viewer-entry.tsx +25 -4
- package/src/core/audit.ts +274 -67
- package/src/core/compare.ts +0 -0
- package/src/core/cost.ts +94 -16
- package/src/core/index.ts +1 -0
- package/src/core/redact.ts +182 -0
- package/src/core/timeline.ts +107 -12
- package/src/index.ts +290 -54
package/lib/index.js
CHANGED
|
@@ -1,102 +1,1094 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
// src/core/decode.ts
|
|
5
|
+
var CHUNK_ROW_TYPES = /* @__PURE__ */ new Set(["text-chunks", "reasoning-chunks", "tool-call-chunks"]);
|
|
6
|
+
function isChunkRow(record) {
|
|
7
|
+
return CHUNK_ROW_TYPES.has(record.type);
|
|
8
|
+
}
|
|
9
|
+
function expandChunkRow(row) {
|
|
10
|
+
const { seq0, time0, data } = row;
|
|
11
|
+
const { turn, step, index, dt } = data;
|
|
12
|
+
const events = [];
|
|
13
|
+
const count = (row.type === "tool-call-chunks" ? data.args?.length : data.texts?.length) ?? 0;
|
|
14
|
+
let time = time0;
|
|
15
|
+
for (let k = 0; k < count; k++) {
|
|
16
|
+
if (k > 0) time += dt[k - 1] ?? 0;
|
|
17
|
+
let chunk;
|
|
18
|
+
if (row.type === "text-chunks") {
|
|
19
|
+
chunk = { type: "text-delta", index, text: data.texts[k] };
|
|
20
|
+
} else if (row.type === "reasoning-chunks") {
|
|
21
|
+
chunk = { type: "reasoning-delta", index, text: data.texts[k] };
|
|
22
|
+
} else {
|
|
23
|
+
chunk = {
|
|
24
|
+
type: "tool-call-delta",
|
|
25
|
+
index,
|
|
26
|
+
id: data.id,
|
|
27
|
+
...k === 0 && data.name !== void 0 ? { name: data.name } : {},
|
|
28
|
+
args: data.args[k]
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
events.push({
|
|
32
|
+
type: "assistant/chunk",
|
|
33
|
+
seq: seq0 + k,
|
|
34
|
+
time,
|
|
35
|
+
data: { turn, step, chunk }
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return events;
|
|
39
|
+
}
|
|
40
|
+
function decodeStorageRecord(record) {
|
|
41
|
+
if (isChunkRow(record)) return expandChunkRow(record);
|
|
42
|
+
return [record];
|
|
43
|
+
}
|
|
44
|
+
function fromInspection(header, records) {
|
|
45
|
+
const events = records.flatMap(decodeStorageRecord).sort((a, b) => a.seq - b.seq);
|
|
46
|
+
return { header, events, malformed: [] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/core/timeline.ts
|
|
50
|
+
function contentText(message) {
|
|
51
|
+
if (!message?.content) return "";
|
|
52
|
+
return message.content.filter((b) => typeof b.text === "string" && b.type !== "reasoning").map((b) => b.text).join("");
|
|
53
|
+
}
|
|
54
|
+
function contentReasoning(message) {
|
|
55
|
+
if (!message?.content) return "";
|
|
56
|
+
return message.content.filter((b) => b.type === "reasoning" && typeof b.text === "string").map((b) => b.text).join("");
|
|
57
|
+
}
|
|
58
|
+
function oneLine(text, max) {
|
|
59
|
+
return truncate(text.replaceAll(/\s+/gu, " ").trim(), max);
|
|
60
|
+
}
|
|
61
|
+
function summarizeToolArguments(name2, raw) {
|
|
62
|
+
let args;
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(raw);
|
|
65
|
+
if (typeof parsed === "object" && parsed !== null) args = parsed;
|
|
66
|
+
} catch {
|
|
67
|
+
args = void 0;
|
|
68
|
+
}
|
|
69
|
+
if (args === void 0) return oneLine(raw, 64);
|
|
70
|
+
const keys = ["command", "cmd", "script", "path", "file_path", "filePath", "url", "query", "pattern", "description"];
|
|
71
|
+
for (const key of keys) {
|
|
72
|
+
const value = args[key];
|
|
73
|
+
if (typeof value === "string" && value.trim() !== "") return oneLine(value, 64);
|
|
74
|
+
}
|
|
75
|
+
return name2;
|
|
76
|
+
}
|
|
77
|
+
function truncate(text, max = 96) {
|
|
78
|
+
const oneLine2 = text.replaceAll(/\s+/gu, " ").trim();
|
|
79
|
+
return oneLine2.length > max ? `${oneLine2.slice(0, max - 1)}\u2026` : oneLine2;
|
|
80
|
+
}
|
|
81
|
+
function tryPretty(raw) {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.stringify(JSON.parse(raw), null, 2);
|
|
84
|
+
} catch {
|
|
85
|
+
return void 0;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function get(obj, key) {
|
|
89
|
+
return typeof obj === "object" && obj !== null ? obj[key] : void 0;
|
|
90
|
+
}
|
|
91
|
+
var HIDDEN_TYPES = /* @__PURE__ */ new Set(["assistant/chunk", "step/start", "step/end", "turn/start"]);
|
|
92
|
+
function metaLabel(event) {
|
|
93
|
+
const d = event.data;
|
|
94
|
+
switch (event.type) {
|
|
95
|
+
case "turn/end": {
|
|
96
|
+
const reason = get(get(d, "reason"), "kind");
|
|
97
|
+
return reason === "completed" ? void 0 : `turn ended: ${String(reason)}`;
|
|
98
|
+
}
|
|
99
|
+
case "compaction/summary":
|
|
100
|
+
return "context compacted (summary)";
|
|
101
|
+
case "compaction/prune":
|
|
102
|
+
return "context pruned";
|
|
103
|
+
case "approval/asked":
|
|
104
|
+
return `approval requested: ${String(get(d, "toolName") ?? "")}`;
|
|
105
|
+
case "approval/decided": {
|
|
106
|
+
const outcome = get(d, "outcome") ?? get(d, "decision");
|
|
107
|
+
return `approval decided: ${typeof outcome === "string" ? outcome : JSON.stringify(outcome)}`;
|
|
108
|
+
}
|
|
109
|
+
case "sandbox/mode":
|
|
110
|
+
return `sandbox mode \u2192 ${JSON.stringify(d)}`;
|
|
111
|
+
case "permission/preset":
|
|
112
|
+
return `permission preset \u2192 ${JSON.stringify(d)}`;
|
|
113
|
+
case "plan/mode":
|
|
114
|
+
return `plan mode change`;
|
|
115
|
+
case "subagent/descriptor":
|
|
116
|
+
return `subagent spawned (${String(get(d, "mode") ?? "")})`;
|
|
117
|
+
case "session/end-seed":
|
|
118
|
+
return "seed boundary (fork/resume point)";
|
|
119
|
+
case "llm/retry":
|
|
120
|
+
case "llm/retry-started":
|
|
121
|
+
return "model request retried";
|
|
122
|
+
case "command/run":
|
|
123
|
+
return `command: ${truncate(JSON.stringify(d), 60)}`;
|
|
124
|
+
case "goal/change":
|
|
125
|
+
return "goal changed";
|
|
126
|
+
case "session/title":
|
|
127
|
+
return void 0;
|
|
128
|
+
default:
|
|
129
|
+
return void 0;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function shadowedSeqs(events) {
|
|
133
|
+
const surfaceOrder = [];
|
|
134
|
+
const shadowed = /* @__PURE__ */ new Set();
|
|
135
|
+
for (const event of events) {
|
|
136
|
+
const op = event.surfaceOp;
|
|
137
|
+
if (op === void 0) continue;
|
|
138
|
+
if (op === "append") {
|
|
139
|
+
surfaceOrder.push(event.seq);
|
|
140
|
+
} else if (typeof op === "object" && op.op === "replace") {
|
|
141
|
+
const removed = surfaceOrder.splice(op.start, op.end - op.start + 1, event.seq);
|
|
142
|
+
for (const seq of removed) shadowed.add(seq);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return shadowed;
|
|
146
|
+
}
|
|
147
|
+
function buildTimeline(session) {
|
|
148
|
+
const { events } = session;
|
|
149
|
+
const turns = /* @__PURE__ */ new Map();
|
|
150
|
+
const items = [];
|
|
151
|
+
const usageSamples = [];
|
|
152
|
+
const toolCallsBySeq = /* @__PURE__ */ new Map();
|
|
153
|
+
const toolCalls = [];
|
|
154
|
+
const usageByStep = /* @__PURE__ */ new Map();
|
|
155
|
+
const usageModelByStep = /* @__PURE__ */ new Map();
|
|
156
|
+
const modelTotals = {};
|
|
157
|
+
const models = /* @__PURE__ */ new Set();
|
|
158
|
+
const shadowed = shadowedSeqs(events);
|
|
159
|
+
let seedBoundarySeq;
|
|
160
|
+
let activeTurn;
|
|
161
|
+
let activeModel;
|
|
162
|
+
let sessionTitle;
|
|
163
|
+
const ensureTurn = (turn, event) => {
|
|
164
|
+
let model = turns.get(turn);
|
|
165
|
+
if (model === void 0) {
|
|
166
|
+
model = { turn, startSeq: event.seq, startTime: event.time, steps: [], items: [] };
|
|
167
|
+
turns.set(turn, model);
|
|
168
|
+
}
|
|
169
|
+
return model;
|
|
170
|
+
};
|
|
171
|
+
const ensureStep = (turnModel, step, event) => {
|
|
172
|
+
let model = turnModel.steps.find((s) => s.step === step);
|
|
173
|
+
if (model === void 0) {
|
|
174
|
+
model = { turn: turnModel.turn, step, startSeq: event.seq, startTime: event.time, items: [] };
|
|
175
|
+
turnModel.steps.push(model);
|
|
176
|
+
}
|
|
177
|
+
return model;
|
|
178
|
+
};
|
|
179
|
+
const push = (item) => {
|
|
180
|
+
if (shadowed.has(item.seq)) item.shadowed = true;
|
|
181
|
+
items.push(item);
|
|
182
|
+
if (item.turn !== void 0) {
|
|
183
|
+
const turnModel = ensureTurn(item.turn, item.event);
|
|
184
|
+
turnModel.items.push(item);
|
|
185
|
+
if (item.step !== void 0) ensureStep(turnModel, item.step, item.event).items.push(item);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
for (const event of events) {
|
|
189
|
+
const d = event.data;
|
|
190
|
+
const turn = get(d, "turn");
|
|
191
|
+
const step = get(d, "step");
|
|
192
|
+
switch (event.type) {
|
|
193
|
+
case "turn/start":
|
|
194
|
+
activeTurn = turn;
|
|
195
|
+
ensureTurn(activeTurn, event);
|
|
196
|
+
break;
|
|
197
|
+
case "turn/end": {
|
|
198
|
+
const model = ensureTurn(turn, event);
|
|
199
|
+
model.endSeq = event.seq;
|
|
200
|
+
model.endTime = event.time;
|
|
201
|
+
model.endReason = String(get(get(d, "reason"), "kind") ?? "completed");
|
|
202
|
+
activeTurn = void 0;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case "step/start":
|
|
206
|
+
ensureStep(ensureTurn(turn, event), step, event);
|
|
207
|
+
break;
|
|
208
|
+
case "step/end": {
|
|
209
|
+
const stepModel = ensureStep(ensureTurn(turn, event), step, event);
|
|
210
|
+
stepModel.endSeq = event.seq;
|
|
211
|
+
stepModel.endTime = event.time;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
case "user/message": {
|
|
215
|
+
const message = d;
|
|
216
|
+
const text = contentText(message);
|
|
217
|
+
push({
|
|
218
|
+
kind: "user",
|
|
219
|
+
seq: event.seq,
|
|
220
|
+
time: event.time,
|
|
221
|
+
type: event.type,
|
|
222
|
+
turn: activeTurn,
|
|
223
|
+
label: truncate(text) || "(user message)",
|
|
224
|
+
text,
|
|
225
|
+
event
|
|
226
|
+
});
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case "assistant/message": {
|
|
230
|
+
const message = get(d, "message");
|
|
231
|
+
const usage = get(d, "usage");
|
|
232
|
+
const text = contentText(message);
|
|
233
|
+
const reasoningText = contentReasoning(message);
|
|
234
|
+
const source = message?.source;
|
|
235
|
+
if (typeof source?.["model"] === "string") {
|
|
236
|
+
activeModel = source["model"];
|
|
237
|
+
models.add(activeModel);
|
|
238
|
+
}
|
|
239
|
+
if (usage) {
|
|
240
|
+
const key = `${String(turn)}/${String(step)}`;
|
|
241
|
+
usageByStep.set(key, usage);
|
|
242
|
+
if (activeModel !== void 0) usageModelByStep.set(key, activeModel);
|
|
243
|
+
}
|
|
244
|
+
push({
|
|
245
|
+
kind: "assistant",
|
|
246
|
+
seq: event.seq,
|
|
247
|
+
time: event.time,
|
|
248
|
+
turn,
|
|
249
|
+
step,
|
|
250
|
+
type: event.type,
|
|
251
|
+
label: truncate(text) || "(assistant message)",
|
|
252
|
+
text,
|
|
253
|
+
reasoningText,
|
|
254
|
+
usage,
|
|
255
|
+
event
|
|
256
|
+
});
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
case "assistant/chunk": {
|
|
260
|
+
const chunk = get(d, "chunk");
|
|
261
|
+
if (get(chunk, "type") === "usage") {
|
|
262
|
+
const usage = get(chunk, "usage");
|
|
263
|
+
const key = `${String(turn)}/${String(step)}`;
|
|
264
|
+
usageByStep.set(key, usage);
|
|
265
|
+
if (activeModel !== void 0 && !usageModelByStep.has(key)) {
|
|
266
|
+
usageModelByStep.set(key, activeModel);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
case "tool/call": {
|
|
272
|
+
const raw = String(get(d, "arguments") ?? "");
|
|
273
|
+
const name2 = String(get(d, "name") ?? "");
|
|
274
|
+
const info = {
|
|
275
|
+
callId: String(get(d, "callId") ?? ""),
|
|
276
|
+
name: name2,
|
|
277
|
+
argumentsRaw: raw,
|
|
278
|
+
argumentsPretty: tryPretty(raw),
|
|
279
|
+
summary: summarizeToolArguments(name2, raw),
|
|
280
|
+
callSeq: event.seq,
|
|
281
|
+
callTime: event.time
|
|
282
|
+
};
|
|
283
|
+
toolCallsBySeq.set(info.callId, info);
|
|
284
|
+
toolCalls.push(info);
|
|
285
|
+
push({
|
|
286
|
+
kind: "tool",
|
|
287
|
+
seq: event.seq,
|
|
288
|
+
time: event.time,
|
|
289
|
+
turn,
|
|
290
|
+
step,
|
|
291
|
+
type: event.type,
|
|
292
|
+
label: info.summary === name2 ? name2 : `${name2} \xB7 ${info.summary}`,
|
|
293
|
+
toolCall: info,
|
|
294
|
+
event
|
|
295
|
+
});
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
case "tool/result": {
|
|
299
|
+
const message = get(d, "message");
|
|
300
|
+
const source = message?.source;
|
|
301
|
+
const callId = String(source?.["callId"] ?? "");
|
|
302
|
+
const info = toolCallsBySeq.get(callId);
|
|
303
|
+
const block = message?.content?.[0];
|
|
304
|
+
const resultText = contentText(message) || (typeof get(block, "text") === "string" ? get(block, "text") : contentText({ content: get(block, "content") }));
|
|
305
|
+
const error = get(d, "error");
|
|
306
|
+
if (info) {
|
|
307
|
+
info.resultSeq = event.seq;
|
|
308
|
+
info.resultTime = event.time;
|
|
309
|
+
info.durationMs = event.time - info.callTime;
|
|
310
|
+
info.resultText = resultText;
|
|
311
|
+
info.error = error;
|
|
312
|
+
}
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
case "session/end-seed":
|
|
316
|
+
seedBoundarySeq = event.seq;
|
|
317
|
+
push({
|
|
318
|
+
kind: "meta",
|
|
319
|
+
seq: event.seq,
|
|
320
|
+
time: event.time,
|
|
321
|
+
type: event.type,
|
|
322
|
+
label: "seed boundary (fork/resume point)",
|
|
323
|
+
event
|
|
324
|
+
});
|
|
325
|
+
break;
|
|
326
|
+
case "system/message": {
|
|
327
|
+
const message = get(d, "message");
|
|
328
|
+
const text = contentText(message);
|
|
329
|
+
push({
|
|
330
|
+
kind: "meta",
|
|
331
|
+
seq: event.seq,
|
|
332
|
+
time: event.time,
|
|
333
|
+
turn: typeof turn === "number" ? turn : void 0,
|
|
334
|
+
step: typeof step === "number" ? step : void 0,
|
|
335
|
+
type: event.type,
|
|
336
|
+
label: "system prompt",
|
|
337
|
+
text,
|
|
338
|
+
event
|
|
339
|
+
});
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
case "model/selection": {
|
|
343
|
+
const model = get(d, "model");
|
|
344
|
+
if (typeof model === "string" && model !== "") {
|
|
345
|
+
activeModel = model;
|
|
346
|
+
models.add(model);
|
|
347
|
+
push({
|
|
348
|
+
kind: "meta",
|
|
349
|
+
seq: event.seq,
|
|
350
|
+
time: event.time,
|
|
351
|
+
type: event.type,
|
|
352
|
+
label: `model selected: ${model}`,
|
|
353
|
+
event
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
case "request/context": {
|
|
359
|
+
const model = get(d, "model");
|
|
360
|
+
if (typeof model === "string") {
|
|
361
|
+
activeModel = model;
|
|
362
|
+
models.add(model);
|
|
363
|
+
}
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
case "session/title": {
|
|
367
|
+
const title = get(d, "title");
|
|
368
|
+
if (typeof title === "string" && title !== "") sessionTitle = title;
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
default: {
|
|
372
|
+
if (HIDDEN_TYPES.has(event.type)) break;
|
|
373
|
+
const label = metaLabel(event);
|
|
374
|
+
if (label !== void 0) {
|
|
375
|
+
push({
|
|
376
|
+
kind: "meta",
|
|
377
|
+
seq: event.seq,
|
|
378
|
+
time: event.time,
|
|
379
|
+
turn: typeof turn === "number" ? turn : void 0,
|
|
380
|
+
step: typeof step === "number" ? step : void 0,
|
|
381
|
+
type: event.type,
|
|
382
|
+
label,
|
|
383
|
+
event
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, billedInput: 0 };
|
|
390
|
+
const emptyTotals = () => ({
|
|
391
|
+
input: 0,
|
|
392
|
+
output: 0,
|
|
393
|
+
cacheRead: 0,
|
|
394
|
+
cacheWrite: 0,
|
|
395
|
+
reasoning: 0,
|
|
396
|
+
billedInput: 0
|
|
397
|
+
});
|
|
398
|
+
for (const [key, usage] of usageByStep) {
|
|
399
|
+
const [turnStr, stepStr] = key.split("/");
|
|
400
|
+
const billed = usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
|
|
401
|
+
totals.input += usage.inputTokens;
|
|
402
|
+
totals.output += usage.outputTokens;
|
|
403
|
+
totals.cacheRead += usage.cacheReadTokens ?? 0;
|
|
404
|
+
totals.cacheWrite += usage.cacheWriteTokens ?? 0;
|
|
405
|
+
totals.reasoning += usage.reasoningTokens ?? 0;
|
|
406
|
+
const modelName = usageModelByStep.get(key) ?? activeModel ?? "unknown";
|
|
407
|
+
const modelTotal = modelTotals[modelName] ?? emptyTotals();
|
|
408
|
+
modelTotal.input += usage.inputTokens;
|
|
409
|
+
modelTotal.output += usage.outputTokens;
|
|
410
|
+
modelTotal.cacheRead += usage.cacheReadTokens ?? 0;
|
|
411
|
+
modelTotal.cacheWrite += usage.cacheWriteTokens ?? 0;
|
|
412
|
+
modelTotal.reasoning += usage.reasoningTokens ?? 0;
|
|
413
|
+
modelTotal.billedInput += billed;
|
|
414
|
+
modelTotals[modelName] = modelTotal;
|
|
415
|
+
const turnModel = turns.get(Number(turnStr));
|
|
416
|
+
const stepModel = turnModel?.steps.find((s) => s.step === Number(stepStr));
|
|
417
|
+
if (stepModel) stepModel.usage = usage;
|
|
418
|
+
const anchor = stepModel?.items.find((i) => i.kind === "assistant");
|
|
419
|
+
usageSamples.push({
|
|
420
|
+
seq: anchor?.seq ?? stepModel?.startSeq ?? 0,
|
|
421
|
+
turn: Number(turnStr),
|
|
422
|
+
step: Number(stepStr),
|
|
423
|
+
usage
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
usageSamples.sort((a, b) => a.seq - b.seq);
|
|
427
|
+
totals.billedInput = totals.input + totals.cacheRead + totals.cacheWrite;
|
|
428
|
+
const orderedTurns = [...turns.values()].sort((a, b) => a.turn - b.turn);
|
|
429
|
+
for (const t of orderedTurns) t.steps.sort((a, b) => a.step - b.step);
|
|
430
|
+
const startTime = events[0]?.time ?? session.header.createdAt;
|
|
431
|
+
const endTime = events.at(-1)?.time ?? startTime;
|
|
432
|
+
return {
|
|
433
|
+
turns: orderedTurns,
|
|
434
|
+
items,
|
|
435
|
+
totals,
|
|
436
|
+
modelTotals,
|
|
437
|
+
usageSamples,
|
|
438
|
+
toolCalls,
|
|
439
|
+
durationMs: endTime - startTime,
|
|
440
|
+
startTime,
|
|
441
|
+
endTime,
|
|
442
|
+
eventCount: events.length,
|
|
443
|
+
seedBoundarySeq,
|
|
444
|
+
models: [...models],
|
|
445
|
+
title: sessionTitle
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// src/core/redact.ts
|
|
450
|
+
var RULES = [
|
|
451
|
+
{ kind: "openai-key", re: /\bsk-[A-Za-z0-9_-]{16,}\b/g, replacement: "[REDACTED:openai-key]" },
|
|
452
|
+
{
|
|
453
|
+
kind: "github-token",
|
|
454
|
+
re: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g,
|
|
455
|
+
replacement: "[REDACTED:github-token]"
|
|
456
|
+
},
|
|
457
|
+
{ kind: "aws-key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED:aws-key]" },
|
|
458
|
+
{ kind: "google-key", re: /\bAIza[0-9A-Za-z_-]{20,}\b/g, replacement: "[REDACTED:google-key]" },
|
|
459
|
+
{ kind: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, replacement: "[REDACTED:slack-token]" },
|
|
460
|
+
{
|
|
461
|
+
kind: "private-key",
|
|
462
|
+
re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
463
|
+
replacement: "[REDACTED:private-key]"
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
kind: "jwt",
|
|
467
|
+
re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
|
468
|
+
replacement: "[REDACTED:jwt]"
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
kind: "env-assignment",
|
|
472
|
+
re: /(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE|CREDENTIAL)[A-Z0-9_]*\s*=\s*)(["']?)([^\s"'\\]{4,})\2/g,
|
|
473
|
+
replacement: (_m, prefix, quote) => `${prefix}${quote}[REDACTED]${quote}`
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
kind: "key-value",
|
|
477
|
+
re: /(["']?(?:api[_-]?key|secret|token|password|passwd|authorization|jwt[_-]?secret)["']?\s*[:=]\s*["']?)([^"'\s,}]{4,})/gi,
|
|
478
|
+
replacement: (_m, prefix) => `${prefix}[REDACTED]`
|
|
479
|
+
},
|
|
480
|
+
{ kind: "bearer", re: /(\bBearer\s+)[A-Za-z0-9._~+/-]{8,}/gi, replacement: "$1[REDACTED]" },
|
|
481
|
+
{
|
|
482
|
+
kind: "basic-auth-url",
|
|
483
|
+
re: /(https?:\/\/[^/\s:@]+:)([^@\s/]+)(@)/g,
|
|
484
|
+
replacement: "$1[REDACTED]$3"
|
|
485
|
+
},
|
|
486
|
+
{ kind: "windows-user", re: /([A-Za-z]:\\Users\\)([^\\\s"']+)/g, replacement: "$1[user]" },
|
|
487
|
+
{ kind: "unix-home", re: /(\/(?:home|Users)\/)([^/\s"']+)/g, replacement: "$1[user]" },
|
|
488
|
+
{ kind: "email", re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, replacement: "[email]" },
|
|
489
|
+
{ kind: "session-id", re: /\bsession-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\b/gi, replacement: "session-[id]" }
|
|
490
|
+
];
|
|
491
|
+
var CREDENTIAL_KINDS = /* @__PURE__ */ new Set([
|
|
492
|
+
"openai-key",
|
|
493
|
+
"github-token",
|
|
494
|
+
"aws-key",
|
|
495
|
+
"google-key",
|
|
496
|
+
"slack-token",
|
|
497
|
+
"private-key",
|
|
498
|
+
"jwt",
|
|
499
|
+
"env-assignment",
|
|
500
|
+
"key-value",
|
|
501
|
+
"bearer",
|
|
502
|
+
"basic-auth-url"
|
|
503
|
+
]);
|
|
504
|
+
function redactText(input) {
|
|
505
|
+
let text = input;
|
|
506
|
+
let count = 0;
|
|
507
|
+
const kinds = /* @__PURE__ */ new Set();
|
|
508
|
+
for (const rule of RULES) {
|
|
509
|
+
rule.re.lastIndex = 0;
|
|
510
|
+
const matches = text.match(rule.re);
|
|
511
|
+
if (matches === null) continue;
|
|
512
|
+
count += matches.length;
|
|
513
|
+
kinds.add(rule.kind);
|
|
514
|
+
rule.re.lastIndex = 0;
|
|
515
|
+
text = text.replace(rule.re, rule.replacement);
|
|
516
|
+
}
|
|
517
|
+
return { text, count, kinds: [...kinds] };
|
|
518
|
+
}
|
|
519
|
+
function containsSecret(input) {
|
|
520
|
+
for (const rule of RULES) {
|
|
521
|
+
if (!CREDENTIAL_KINDS.has(rule.kind)) continue;
|
|
522
|
+
rule.re.lastIndex = 0;
|
|
523
|
+
const hit = rule.re.test(input);
|
|
524
|
+
rule.re.lastIndex = 0;
|
|
525
|
+
if (hit) return true;
|
|
526
|
+
}
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// src/core/audit.ts
|
|
531
|
+
function get2(obj, key) {
|
|
532
|
+
return typeof obj === "object" && obj !== null ? obj[key] : void 0;
|
|
533
|
+
}
|
|
534
|
+
function str(value) {
|
|
535
|
+
if (typeof value === "string") return value;
|
|
536
|
+
try {
|
|
537
|
+
return JSON.stringify(value) ?? "";
|
|
538
|
+
} catch {
|
|
539
|
+
return "";
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
function clipped(value, max = 500) {
|
|
543
|
+
return redactText(value).text.slice(0, max);
|
|
544
|
+
}
|
|
545
|
+
function toolName(event) {
|
|
546
|
+
return str(get2(event.data, "name"));
|
|
547
|
+
}
|
|
548
|
+
function argsOf(event) {
|
|
549
|
+
return str(get2(event.data, "arguments"));
|
|
550
|
+
}
|
|
551
|
+
function parseArgs(raw) {
|
|
552
|
+
try {
|
|
553
|
+
const value = JSON.parse(raw);
|
|
554
|
+
return typeof value === "object" && value !== null ? value : void 0;
|
|
555
|
+
} catch {
|
|
556
|
+
return void 0;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
var PATH_KEYS = [
|
|
560
|
+
"path",
|
|
561
|
+
"file_path",
|
|
562
|
+
"filePath",
|
|
563
|
+
"target",
|
|
564
|
+
"destination",
|
|
565
|
+
"dest",
|
|
566
|
+
"filename",
|
|
567
|
+
"file",
|
|
568
|
+
"name"
|
|
569
|
+
];
|
|
570
|
+
function pathOf(raw) {
|
|
571
|
+
const args = parseArgs(raw);
|
|
572
|
+
if (args === void 0) return void 0;
|
|
573
|
+
for (const key of PATH_KEYS) {
|
|
574
|
+
const value = args[key];
|
|
575
|
+
if (typeof value === "string" && value !== "" && /[\\/.]/u.test(value)) return value;
|
|
576
|
+
}
|
|
577
|
+
return void 0;
|
|
578
|
+
}
|
|
579
|
+
var SHELL_TOOLS = /^(bash|shell|exec|run_command|terminal|subprocess|pwsh|powershell|cmd|run_code|code_execution)/iu;
|
|
580
|
+
var WRITE_TOOLS = /^(write|write_file|edit|edit_file|create_file|str_replace|multi_edit|apply_patch|notebook_edit|fs_write|mv|move|rename|copy|rm|remove|delete|del|remove_item|rmdir|rd)/iu;
|
|
581
|
+
var DESTRUCTIVE_TOOLS = /^(rm|remove|delete|del|remove_item|rmdir|rd|mv|move|rename)/iu;
|
|
582
|
+
var READ_TOOLS = /^(read|read_file|view|open|cat|type|get-content|grep|search|glob|list|find|head|tail|less|more|strings|xxd)/iu;
|
|
583
|
+
var NETWORK_TOOLS = /^(fetch|web_fetch|http|curl|download|web_search|browser|invoke-webrequest|iwr|invoke-restmethod|start-bitstransfer)/iu;
|
|
584
|
+
var DANGEROUS_SHELL = [
|
|
585
|
+
// --- destructive deletes -------------------------------------------------
|
|
586
|
+
{ re: /\brm\s+(-[a-z]*[rf][a-z]*\s+)+/iu, why: "recursive/forced delete", key: "rm-rf", severity: "critical" },
|
|
587
|
+
{ re: /\bremove-item\b[^\n]*(?:-recurse|-force)/iu, why: "recursive/forced delete", key: "rm-rf", severity: "critical" },
|
|
588
|
+
{ re: /\b(?:del|erase)\b[^\n]*\/[sq]/iu, why: "forced delete", key: "rm-rf", severity: "critical" },
|
|
589
|
+
{ re: /\b(?:rd|rmdir)\b[^\n]*\/s/iu, why: "recursive directory delete", key: "rm-rf", severity: "critical" },
|
|
590
|
+
// --- privilege / system --------------------------------------------------
|
|
591
|
+
{ re: /\bsudo\b/iu, why: "privilege escalation", key: "sudo", severity: "critical" },
|
|
592
|
+
{ re: /\b(?:takeown|icacls)\b[^\n]*(?:grant|\/grant|\/f|\/reset)/iu, why: "ownership/permission change", key: "chmod", severity: "serious" },
|
|
593
|
+
{ re: /\bchmod\s+([0-7]{3,4}|\+x)/u, why: "permission change", key: "chmod", severity: "serious" },
|
|
594
|
+
{ re: /\bset-executionpolicy\b/iu, why: "script execution policy change", key: "exec-policy", severity: "serious" },
|
|
595
|
+
{ re: /\b(?:bcdedit|vssadmin\s+delete|wevtutil\s+cl)\b/iu, why: "boot/shadow/log tampering", key: "system-tamper", severity: "critical" },
|
|
596
|
+
{ re: /\b(?:reg\s+delete|schtasks\b[^\n]*\/create|sc\s+create|net\s+user|net\s+localgroup)\b/iu, why: "persistence or account change", key: "persistence", severity: "serious" },
|
|
597
|
+
// --- disk / remote code --------------------------------------------------
|
|
598
|
+
{ re: /\b(?:dd|mkfs|fdisk|diskpart|format)\b/iu, why: "raw disk operation", key: "disk", severity: "critical" },
|
|
599
|
+
{ re: /\b(?:iwr|invoke-webrequest|curl|wget|start-bitstransfer)\b[^\n|;]*(?:\||;)\s*(?:iex|invoke-expression)\b/iu, why: "pipe remote script to shell", key: "curl-pipe", severity: "critical" },
|
|
600
|
+
{ re: /\b(?:downloadstring|downloadfile)\b/iu, why: "download and execute", key: "curl-pipe", severity: "critical" },
|
|
601
|
+
{ re: /\b(?:powershell|pwsh)\b[^\n]*-e(?:nc|ncodedcommand)?\s+[A-Za-z0-9+/=]{40,}/iu, why: "encoded PowerShell command", key: "encoded-shell", severity: "critical" },
|
|
602
|
+
// --- credentials / exfiltration -----------------------------------------
|
|
603
|
+
{ re: /\b(?:export|\$env:|\bset\b|\becho\b)[^\n]*(?:api[_-]?key|token|secret|password|passwd)/iu, why: "credential material in command", key: "creds", severity: "serious" },
|
|
604
|
+
{ re: /\b(?:get-content|type|cat)\b[^\n]*(?:\.env\b|id_rsa|\.ssh[\\/]|credentials)/iu, why: "credential file read", key: "creds", severity: "serious" },
|
|
605
|
+
{ re: /\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod)\b[^\n]*(?:-d\b|--data|--upload-file|-f\b|\.env\b|secret|token|password)/iu, why: "possible credential exfiltration", key: "exfil", severity: "critical" },
|
|
606
|
+
{ re: /\b(?:ssh|scp|rsync)\b[^\n]*(?:@|\bremote\b)/iu, why: "remote host access", key: "remote", severity: "warning" },
|
|
607
|
+
{ re: /\b(?:invoke-command|enter-pssession|winrs)\b/iu, why: "remote execution", key: "remote", severity: "serious" },
|
|
608
|
+
// --- supply chain / repo ------------------------------------------------
|
|
609
|
+
{ re: /\bgit\s+push\s+[^\n]*--force/iu, why: "force push", key: "force-push", severity: "serious" },
|
|
610
|
+
{ re: /\b(?:npm|pnpm|yarn)\s+publish\b/iu, why: "package publish", key: "publish", severity: "warning" },
|
|
611
|
+
{ re: /\bgh\s+release\s+create\b/iu, why: "release publish", key: "publish", severity: "warning" }
|
|
612
|
+
];
|
|
613
|
+
var SENSITIVE_PATH = /(?:^|[\\/])(?:\.env(?:\.[\w-]+)?(?:[\\/]|$)|\.ssh(?:[\\/]|$)|id_rsa|id_ed25519|\.aws(?:[\\/]|$)|\.kube(?:[\\/]|$)|\.docker(?:[\\/]config\.json|$)|credentials|\.npmrc|\.netrc|secrets?\.|\.pem|\.pfx|\.p12|ntuser\.dat|\.git[\\/]config|\.config[\\/]gh[\\/]hosts\.yml)/iu;
|
|
614
|
+
var NETWORK_RISK = /(?:--data|-d\s|--upload-file|-f\s|body\s*[:=]|\.env\b|secret|token|password|passwd|private[_-]?key|authorization|api[_-]?key)/iu;
|
|
615
|
+
function messageText(message) {
|
|
616
|
+
const content = get2(message, "content");
|
|
617
|
+
if (!Array.isArray(content)) return "";
|
|
618
|
+
return content.map((block) => typeof get2(block, "text") === "string" ? get2(block, "text") : "").join("");
|
|
619
|
+
}
|
|
620
|
+
function findDanger(event) {
|
|
621
|
+
if (event.type !== "tool/call") return void 0;
|
|
622
|
+
const name2 = toolName(event);
|
|
623
|
+
if (!SHELL_TOOLS.test(name2)) return void 0;
|
|
624
|
+
const args = argsOf(event);
|
|
625
|
+
return DANGEROUS_SHELL.find((pattern) => pattern.re.test(args));
|
|
626
|
+
}
|
|
627
|
+
var DEFAULT_RULES = [
|
|
628
|
+
{
|
|
629
|
+
id: "shell-exec",
|
|
630
|
+
category: "shell",
|
|
631
|
+
severity: (event) => findDanger(event)?.severity ?? "warning",
|
|
632
|
+
match: (event) => {
|
|
633
|
+
if (event.type !== "tool/call") return void 0;
|
|
634
|
+
const name2 = toolName(event);
|
|
635
|
+
if (!SHELL_TOOLS.test(name2)) return void 0;
|
|
636
|
+
const args = argsOf(event);
|
|
637
|
+
const hit = DANGEROUS_SHELL.find((pattern) => pattern.re.test(args));
|
|
638
|
+
if (hit === void 0) return void 0;
|
|
639
|
+
return {
|
|
640
|
+
title: `shell: ${hit.why}`,
|
|
641
|
+
detail: clipped(args),
|
|
642
|
+
messageKey: "f.shell.dangerous",
|
|
643
|
+
params: { whyKey: hit.key, why: hit.why, name: name2 }
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
id: "file-write",
|
|
649
|
+
category: "file",
|
|
650
|
+
severity: (event) => {
|
|
651
|
+
const args = argsOf(event);
|
|
652
|
+
const path = pathOf(args) ?? args;
|
|
653
|
+
return SENSITIVE_PATH.test(path) || containsSecret(args) ? "critical" : "warning";
|
|
654
|
+
},
|
|
655
|
+
match: (event) => {
|
|
656
|
+
if (event.type !== "tool/call") return void 0;
|
|
657
|
+
const name2 = toolName(event);
|
|
658
|
+
if (!WRITE_TOOLS.test(name2)) return void 0;
|
|
659
|
+
const args = argsOf(event);
|
|
660
|
+
const path = pathOf(args) ?? args;
|
|
661
|
+
const sensitive = SENSITIVE_PATH.test(path);
|
|
662
|
+
const secret = containsSecret(args);
|
|
663
|
+
if (!sensitive && !secret && !DESTRUCTIVE_TOOLS.test(name2)) return void 0;
|
|
664
|
+
const reason = sensitive ? "file write touches a sensitive path" : secret ? "credential material written to a file" : `destructive file operation via ${name2}`;
|
|
665
|
+
return {
|
|
666
|
+
title: reason,
|
|
667
|
+
detail: clipped(args),
|
|
668
|
+
messageKey: sensitive ? "f.file.sensitive" : secret ? "f.file.secret" : "f.file.destructive",
|
|
669
|
+
params: { name: name2, ...path !== args ? { path } : {} }
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
id: "sensitive-read",
|
|
675
|
+
category: "file",
|
|
676
|
+
severity: "serious",
|
|
677
|
+
match: (event) => {
|
|
678
|
+
if (event.type !== "tool/call") return void 0;
|
|
679
|
+
const name2 = toolName(event);
|
|
680
|
+
if (WRITE_TOOLS.test(name2) || SHELL_TOOLS.test(name2) || NETWORK_TOOLS.test(name2)) return void 0;
|
|
681
|
+
if (!READ_TOOLS.test(name2)) return void 0;
|
|
682
|
+
const args = argsOf(event);
|
|
683
|
+
const path = pathOf(args) ?? args;
|
|
684
|
+
if (!SENSITIVE_PATH.test(path)) return void 0;
|
|
685
|
+
return {
|
|
686
|
+
title: `access to sensitive path via ${name2}`,
|
|
687
|
+
detail: clipped(args),
|
|
688
|
+
messageKey: "f.read.sensitive",
|
|
689
|
+
params: { name: name2, ...path !== args ? { path } : {} }
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
id: "network",
|
|
695
|
+
category: "network",
|
|
696
|
+
severity: (event) => {
|
|
697
|
+
const args = argsOf(event);
|
|
698
|
+
return containsSecret(args) || SENSITIVE_PATH.test(args) ? "critical" : "warning";
|
|
699
|
+
},
|
|
700
|
+
match: (event) => {
|
|
701
|
+
if (event.type !== "tool/call") return void 0;
|
|
702
|
+
const name2 = toolName(event);
|
|
703
|
+
if (!NETWORK_TOOLS.test(name2)) return void 0;
|
|
704
|
+
const args = argsOf(event);
|
|
705
|
+
if (!NETWORK_RISK.test(args)) return void 0;
|
|
706
|
+
return {
|
|
707
|
+
title: `network request may carry sensitive data via ${name2}`,
|
|
708
|
+
detail: clipped(args),
|
|
709
|
+
messageKey: "f.network",
|
|
710
|
+
params: { name: name2 }
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
},
|
|
714
|
+
{
|
|
715
|
+
id: "secret-output",
|
|
716
|
+
category: "secret",
|
|
717
|
+
severity: "critical",
|
|
718
|
+
match: (event) => {
|
|
719
|
+
if (event.type !== "tool/result") return void 0;
|
|
720
|
+
const message = get2(event.data, "message");
|
|
721
|
+
const text = messageText(message) || str(message) || str(get2(event.data, "error"));
|
|
722
|
+
if (text === "" || !containsSecret(text)) return void 0;
|
|
723
|
+
return {
|
|
724
|
+
title: "tool output contains credential material",
|
|
725
|
+
detail: clipped(text, 300),
|
|
726
|
+
messageKey: "f.output.secret"
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
id: "approval-denied",
|
|
732
|
+
category: "approval",
|
|
733
|
+
severity: "warning",
|
|
734
|
+
match: (event) => {
|
|
735
|
+
if (event.type !== "approval/decided") return void 0;
|
|
736
|
+
const outcome = str(get2(event.data, "outcome") ?? get2(event.data, "decision"));
|
|
737
|
+
if (!/deny|denied|reject|refus/iu.test(outcome)) return void 0;
|
|
738
|
+
return { title: "user denied an approval", detail: clipped(outcome), messageKey: "f.approval.denied" };
|
|
739
|
+
}
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
id: "sandbox-change",
|
|
743
|
+
category: "config",
|
|
744
|
+
severity: (event) => {
|
|
745
|
+
const mode = str(get2(event.data, "mode"));
|
|
746
|
+
return /danger|full/iu.test(mode) ? "critical" : "serious";
|
|
747
|
+
},
|
|
748
|
+
match: (event) => event.type === "sandbox/mode" ? {
|
|
749
|
+
title: `sandbox mode changed to ${str(get2(event.data, "mode")) || "unknown"}`,
|
|
750
|
+
detail: clipped(JSON.stringify(event.data)),
|
|
751
|
+
messageKey: "f.sandbox.change",
|
|
752
|
+
params: { mode: str(get2(event.data, "mode")) }
|
|
753
|
+
} : void 0
|
|
754
|
+
},
|
|
755
|
+
{
|
|
756
|
+
id: "permission-change",
|
|
757
|
+
category: "config",
|
|
758
|
+
severity: (event) => {
|
|
759
|
+
const preset = str(get2(event.data, "preset"));
|
|
760
|
+
return /danger|full/iu.test(preset) ? "critical" : "serious";
|
|
761
|
+
},
|
|
762
|
+
match: (event) => event.type === "permission/preset" ? {
|
|
763
|
+
title: `permission preset changed to ${str(get2(event.data, "preset")) || "unknown"}`,
|
|
764
|
+
detail: clipped(JSON.stringify(event.data)),
|
|
765
|
+
messageKey: "f.permission.change",
|
|
766
|
+
params: { preset: str(get2(event.data, "preset")) }
|
|
767
|
+
} : void 0
|
|
768
|
+
},
|
|
769
|
+
{
|
|
770
|
+
id: "tool-error",
|
|
771
|
+
category: "error",
|
|
772
|
+
severity: "warning",
|
|
773
|
+
match: (event) => {
|
|
774
|
+
if (event.type !== "tool/result") return void 0;
|
|
775
|
+
const error = get2(event.data, "error");
|
|
776
|
+
if (error === void 0) return void 0;
|
|
777
|
+
return {
|
|
778
|
+
title: `tool failed: ${str(get2(error, "name"))}`,
|
|
779
|
+
detail: clipped(str(get2(error, "code"))),
|
|
780
|
+
messageKey: "f.tool.error",
|
|
781
|
+
params: { name: str(get2(error, "name")), code: str(get2(error, "code")) }
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
id: "turn-error",
|
|
787
|
+
category: "error",
|
|
788
|
+
severity: "warning",
|
|
789
|
+
match: (event) => {
|
|
790
|
+
if (event.type !== "turn/end") return void 0;
|
|
791
|
+
const kind = str(get2(get2(event.data, "reason"), "kind"));
|
|
792
|
+
if (kind === "" || kind === "completed" || kind === "aborted") return void 0;
|
|
793
|
+
return { title: `turn ended abnormally: ${kind}`, messageKey: "f.turn.error", params: { kind } };
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
];
|
|
797
|
+
var SEVERITY_ORDER = ["critical", "serious", "warning", "info"];
|
|
798
|
+
var CONFIG_RULES = /* @__PURE__ */ new Set(["sandbox-change", "permission-change"]);
|
|
799
|
+
function auditSession(session, rules = DEFAULT_RULES) {
|
|
800
|
+
const lastConfig = /* @__PURE__ */ new Map();
|
|
801
|
+
const changedConfig = /* @__PURE__ */ new Set();
|
|
802
|
+
let suppressed = 0;
|
|
803
|
+
for (const event of session.events) {
|
|
804
|
+
if (event.type !== "permission/preset" && event.type !== "sandbox/mode" && event.type !== "approval/policy") {
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
const value = JSON.stringify(event.data);
|
|
808
|
+
if (!lastConfig.has(event.type)) {
|
|
809
|
+
lastConfig.set(event.type, value);
|
|
810
|
+
suppressed++;
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
if (lastConfig.get(event.type) === value) {
|
|
814
|
+
suppressed++;
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
lastConfig.set(event.type, value);
|
|
818
|
+
changedConfig.add(event.seq);
|
|
819
|
+
}
|
|
820
|
+
const findings = [];
|
|
821
|
+
for (const event of session.events) {
|
|
822
|
+
for (const rule of rules) {
|
|
823
|
+
if (CONFIG_RULES.has(rule.id) && !changedConfig.has(event.seq)) continue;
|
|
824
|
+
const hit = rule.match(event);
|
|
825
|
+
if (hit === void 0) continue;
|
|
826
|
+
const severity = typeof rule.severity === "function" ? rule.severity(event) : rule.severity;
|
|
827
|
+
findings.push({
|
|
828
|
+
ruleId: rule.id,
|
|
829
|
+
severity,
|
|
830
|
+
category: hit.category ?? rule.category,
|
|
831
|
+
seq: event.seq,
|
|
832
|
+
time: event.time,
|
|
833
|
+
...hit
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
findings.sort(
|
|
838
|
+
(a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity) || a.seq - b.seq
|
|
839
|
+
);
|
|
840
|
+
const counts = { critical: 0, serious: 0, warning: 0, info: 0 };
|
|
841
|
+
for (const finding of findings) counts[finding.severity]++;
|
|
842
|
+
return { findings, counts, scanned: session.events.length, rules: rules.length, suppressed };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// src/index.ts
|
|
3
846
|
var name = "dsh-replay";
|
|
4
847
|
var inject = ["webServer", "sessionQuery"];
|
|
5
|
-
function json(res, status, body) {
|
|
848
|
+
function json(res, status, body, head = false) {
|
|
6
849
|
res.writeHead(status, {
|
|
7
850
|
"content-type": "application/json; charset=utf-8",
|
|
8
|
-
"cache-control": "no-store"
|
|
851
|
+
"cache-control": "no-store",
|
|
852
|
+
"x-content-type-options": "nosniff",
|
|
853
|
+
"referrer-policy": "no-referrer"
|
|
9
854
|
});
|
|
10
|
-
res.end(JSON.stringify(body));
|
|
855
|
+
res.end(head ? void 0 : JSON.stringify(body));
|
|
11
856
|
}
|
|
12
|
-
function
|
|
857
|
+
function get3(obj, key) {
|
|
13
858
|
return typeof obj === "object" && obj !== null ? obj[key] : void 0;
|
|
14
859
|
}
|
|
15
860
|
function headerOf(record) {
|
|
16
|
-
for (const candidate of [
|
|
17
|
-
if (typeof
|
|
861
|
+
for (const candidate of [get3(record, "header"), get3(record, "meta"), record]) {
|
|
862
|
+
if (typeof get3(candidate, "id") === "string" && typeof get3(candidate, "createdAt") === "number") {
|
|
18
863
|
return candidate;
|
|
19
864
|
}
|
|
20
865
|
}
|
|
21
866
|
return void 0;
|
|
22
867
|
}
|
|
23
|
-
function titleOf(record, depth =
|
|
868
|
+
function titleOf(record, depth = 4) {
|
|
869
|
+
if (typeof record === "string") return record === "" ? void 0 : record;
|
|
24
870
|
if (typeof record !== "object" || record === null || depth < 0) return void 0;
|
|
25
871
|
const direct = record["title"];
|
|
26
872
|
if (typeof direct === "string" && direct.length > 0) return direct;
|
|
27
|
-
for (const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (found !== void 0) return found;
|
|
31
|
-
}
|
|
873
|
+
for (const key of ["value", "session", "meta"]) {
|
|
874
|
+
const found = titleOf(record[key], depth - 1);
|
|
875
|
+
if (found !== void 0) return found;
|
|
32
876
|
}
|
|
33
877
|
return void 0;
|
|
34
878
|
}
|
|
879
|
+
function eventsOf(record) {
|
|
880
|
+
const events = get3(record, "events") ?? get3(get3(record, "session"), "events");
|
|
881
|
+
return Array.isArray(events) ? events : [];
|
|
882
|
+
}
|
|
883
|
+
function isLoopback(address) {
|
|
884
|
+
if (address === void 0) return false;
|
|
885
|
+
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1" || address.startsWith("127.");
|
|
886
|
+
}
|
|
887
|
+
function hostIsLoopback(host) {
|
|
888
|
+
if (host === void 0 || host === "") return true;
|
|
889
|
+
const name2 = host.startsWith("[") ? host.slice(1, host.indexOf("]")) : host.split(":")[0];
|
|
890
|
+
return name2 === "localhost" || name2 === "127.0.0.1" || name2 === "::1" || name2.startsWith("127.");
|
|
891
|
+
}
|
|
892
|
+
function allowed(req, allowRemote) {
|
|
893
|
+
if (!allowRemote && !isLoopback(req.socket.remoteAddress)) return false;
|
|
894
|
+
if (!allowRemote && !hostIsLoopback(req.headers.host)) return false;
|
|
895
|
+
const origin = req.headers.origin;
|
|
896
|
+
if (origin !== void 0) {
|
|
897
|
+
if (origin === "null" || origin.startsWith("file://")) return true;
|
|
898
|
+
try {
|
|
899
|
+
const originUrl = new URL(origin);
|
|
900
|
+
if (req.headers.host !== void 0 && originUrl.host !== req.headers.host) return false;
|
|
901
|
+
} catch {
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if (req.headers["sec-fetch-site"] === "cross-site") return false;
|
|
906
|
+
return true;
|
|
907
|
+
}
|
|
908
|
+
async function mapLimit(items, limit, task) {
|
|
909
|
+
const results = new Array(items.length);
|
|
910
|
+
let index = 0;
|
|
911
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
912
|
+
while (index < items.length) {
|
|
913
|
+
const current = index++;
|
|
914
|
+
results[current] = await task(items[current]);
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
await Promise.all(workers);
|
|
918
|
+
return results;
|
|
919
|
+
}
|
|
35
920
|
function apply(ctx, config = {}) {
|
|
36
921
|
const prefix = config.routePrefix ?? "/replay/api";
|
|
922
|
+
const allowRemote = config.allowRemote === true;
|
|
923
|
+
const summaryLimit = Math.min(Math.max(config.summaryLimit ?? 30, 1), 100);
|
|
37
924
|
const webServer = ctx.webServer;
|
|
38
925
|
const sessionQuery = ctx.sessionQuery;
|
|
926
|
+
const viewerCache = /* @__PURE__ */ new Map();
|
|
927
|
+
const summaryCache = /* @__PURE__ */ new Map();
|
|
928
|
+
const SUMMARY_TTL_MS = 3e4;
|
|
929
|
+
const loadEntries = async (options = {}) => {
|
|
930
|
+
const withTitles = options.withTitles !== false;
|
|
931
|
+
const records = await sessionQuery.listSessions();
|
|
932
|
+
const entries = [];
|
|
933
|
+
for (const record of records) {
|
|
934
|
+
const header = headerOf(record);
|
|
935
|
+
if (header === void 0) continue;
|
|
936
|
+
const id = String(header["id"]);
|
|
937
|
+
const title = titleOf(record);
|
|
938
|
+
const live = get3(record, "live");
|
|
939
|
+
entries.push({ header, ...title !== void 0 ? { title } : {}, ...typeof live === "boolean" ? { live } : {} });
|
|
940
|
+
}
|
|
941
|
+
const titles = /* @__PURE__ */ new Map();
|
|
942
|
+
for (const entry of entries) {
|
|
943
|
+
if (entry.title !== void 0) titles.set(String(entry.header["id"]), entry.title);
|
|
944
|
+
}
|
|
945
|
+
if (withTitles && sessionQuery.readTitleSnapshots !== void 0) {
|
|
946
|
+
try {
|
|
947
|
+
const snapshots = await sessionQuery.readTitleSnapshots(
|
|
948
|
+
entries.map((entry) => String(entry.header["id"]))
|
|
949
|
+
);
|
|
950
|
+
for (const snapshot of snapshots) {
|
|
951
|
+
const value = get3(snapshot, "value") ?? snapshot;
|
|
952
|
+
const header = get3(value, "session") ?? get3(value, "header");
|
|
953
|
+
const id = get3(header, "id");
|
|
954
|
+
const title = titleOf(value);
|
|
955
|
+
if (typeof id === "string" && title !== void 0) titles.set(id, title);
|
|
956
|
+
}
|
|
957
|
+
} catch {
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
const readTitle = sessionQuery.readTitle;
|
|
961
|
+
if (withTitles && readTitle !== void 0) {
|
|
962
|
+
await Promise.all(
|
|
963
|
+
entries.filter((entry) => !titles.has(String(entry.header["id"]))).map(async (entry) => {
|
|
964
|
+
try {
|
|
965
|
+
const id = String(entry.header["id"]);
|
|
966
|
+
const result = await readTitle.call(sessionQuery, id);
|
|
967
|
+
const title = titleOf(result);
|
|
968
|
+
if (title !== void 0) titles.set(id, title);
|
|
969
|
+
} catch {
|
|
970
|
+
}
|
|
971
|
+
})
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
for (const entry of entries) {
|
|
975
|
+
const id = String(entry.header["id"]);
|
|
976
|
+
const title = titles.get(id);
|
|
977
|
+
if (title !== void 0) entry.title = title;
|
|
978
|
+
}
|
|
979
|
+
return { entries, titles };
|
|
980
|
+
};
|
|
981
|
+
const summarize = async (entry) => {
|
|
982
|
+
const id = String(entry.header["id"]);
|
|
983
|
+
try {
|
|
984
|
+
const snapshot = await sessionQuery.readSession(id);
|
|
985
|
+
const header = headerOf(snapshot) ?? headerOf(get3(snapshot, "session")) ?? entry.header;
|
|
986
|
+
const records = eventsOf(snapshot);
|
|
987
|
+
const lastSeq = records.length > 0 ? Number(get3(records.at(-1), "seq") ?? -1) : -1;
|
|
988
|
+
const key = `${id}:${lastSeq}`;
|
|
989
|
+
const cached = summaryCache.get(id);
|
|
990
|
+
if (cached !== void 0 && cached.key === key && Date.now() - cached.at < SUMMARY_TTL_MS) {
|
|
991
|
+
return cached.value;
|
|
992
|
+
}
|
|
993
|
+
const parsed = fromInspection(
|
|
994
|
+
header,
|
|
995
|
+
records
|
|
996
|
+
);
|
|
997
|
+
const model = buildTimeline(parsed);
|
|
998
|
+
const report = auditSession(parsed);
|
|
999
|
+
const value = {
|
|
1000
|
+
header,
|
|
1001
|
+
...entry.title !== void 0 ? { title: entry.title } : model.title !== void 0 ? { title: model.title } : {},
|
|
1002
|
+
...entry.live !== void 0 ? { live: entry.live } : {},
|
|
1003
|
+
summary: {
|
|
1004
|
+
events: model.eventCount,
|
|
1005
|
+
turns: model.turns.length,
|
|
1006
|
+
steps: model.turns.reduce((sum, turn) => sum + turn.steps.length, 0),
|
|
1007
|
+
toolCalls: model.toolCalls.length,
|
|
1008
|
+
toolErrors: model.toolCalls.filter((call) => call.error !== void 0).length,
|
|
1009
|
+
durationMs: model.durationMs,
|
|
1010
|
+
models: model.models,
|
|
1011
|
+
tokens: model.totals,
|
|
1012
|
+
modelTotals: model.modelTotals,
|
|
1013
|
+
findings: report.findings.length,
|
|
1014
|
+
critical: report.counts.critical,
|
|
1015
|
+
serious: report.counts.serious,
|
|
1016
|
+
warning: report.counts.warning,
|
|
1017
|
+
info: report.counts.info
|
|
1018
|
+
}
|
|
1019
|
+
};
|
|
1020
|
+
summaryCache.set(id, { key, at: Date.now(), value });
|
|
1021
|
+
while (summaryCache.size > 100) {
|
|
1022
|
+
const oldest = summaryCache.keys().next().value;
|
|
1023
|
+
if (oldest === void 0) break;
|
|
1024
|
+
summaryCache.delete(oldest);
|
|
1025
|
+
}
|
|
1026
|
+
return value;
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
return {
|
|
1029
|
+
header: entry.header,
|
|
1030
|
+
...entry.title !== void 0 ? { title: entry.title } : {},
|
|
1031
|
+
...entry.live !== void 0 ? { live: entry.live } : {},
|
|
1032
|
+
error: String(error)
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
39
1036
|
const handler = async (req, res) => {
|
|
1037
|
+
if (!allowed(req, allowRemote)) {
|
|
1038
|
+
json(res, 403, { error: "forbidden" });
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
40
1041
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
41
1042
|
const rest = url.pathname.slice(prefix.length).replace(/^\//u, "");
|
|
1043
|
+
const head = req.method === "HEAD";
|
|
42
1044
|
try {
|
|
43
1045
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
44
|
-
json(res, 405, { error: "method not allowed" });
|
|
1046
|
+
json(res, 405, { error: "method not allowed" }, head);
|
|
45
1047
|
return;
|
|
46
1048
|
}
|
|
47
1049
|
if (rest === "sessions") {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
} catch {
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
const readTitle = sessionQuery.readTitle;
|
|
63
|
-
if (readTitle !== void 0) {
|
|
64
|
-
await Promise.all(
|
|
65
|
-
headers.filter((h) => !titles.has(String(h["id"]))).map(async (h) => {
|
|
66
|
-
try {
|
|
67
|
-
const title = titleOf(await readTitle.call(sessionQuery, String(h["id"])));
|
|
68
|
-
if (title !== void 0) titles.set(String(h["id"]), title);
|
|
69
|
-
} catch {
|
|
70
|
-
}
|
|
71
|
-
})
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
json(res, 200, {
|
|
75
|
-
sessions: headers.map((h) => ({ header: h, title: titles.get(String(h["id"])) }))
|
|
76
|
-
});
|
|
1050
|
+
const { entries } = await loadEntries();
|
|
1051
|
+
json(res, 200, { sessions: entries }, head);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
if (rest === "summary") {
|
|
1055
|
+
const limitParam = Number(url.searchParams.get("limit"));
|
|
1056
|
+
const limit = Number.isFinite(limitParam) && limitParam > 0 ? Math.min(Math.floor(limitParam), summaryLimit) : summaryLimit;
|
|
1057
|
+
const { entries } = await loadEntries({ withTitles: false });
|
|
1058
|
+
const selected = entries.slice(0, limit);
|
|
1059
|
+
const summaries = await mapLimit(selected, 4, summarize);
|
|
1060
|
+
json(res, 200, { sessions: summaries, total: entries.length }, head);
|
|
77
1061
|
return;
|
|
78
1062
|
}
|
|
79
1063
|
if (rest === "viewer.js") {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
1064
|
+
let source = viewerCache.get("viewer");
|
|
1065
|
+
if (source === void 0) {
|
|
1066
|
+
source = await readFile(new URL("./viewer.js", import.meta.url), "utf8");
|
|
1067
|
+
viewerCache.set("viewer", source);
|
|
1068
|
+
}
|
|
1069
|
+
res.writeHead(200, {
|
|
1070
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
1071
|
+
"cache-control": "no-cache",
|
|
1072
|
+
"x-content-type-options": "nosniff"
|
|
1073
|
+
});
|
|
1074
|
+
res.end(head ? void 0 : source);
|
|
83
1075
|
return;
|
|
84
1076
|
}
|
|
85
1077
|
if (rest.startsWith("session/")) {
|
|
86
1078
|
const id = decodeURIComponent(rest.slice("session/".length));
|
|
87
1079
|
const snapshot = await sessionQuery.readSession(id);
|
|
88
|
-
const header = headerOf(snapshot) ?? headerOf(
|
|
89
|
-
const events = get(snapshot, "events") ?? get(get(snapshot, "session"), "events") ?? [];
|
|
1080
|
+
const header = headerOf(snapshot) ?? headerOf(get3(snapshot, "session"));
|
|
90
1081
|
if (header === void 0) {
|
|
91
|
-
json(res, 404, { error: "session not found" });
|
|
1082
|
+
json(res, 404, { error: "session not found" }, head);
|
|
92
1083
|
return;
|
|
93
1084
|
}
|
|
94
|
-
json(res, 200, { header, events });
|
|
1085
|
+
json(res, 200, { header, events: eventsOf(snapshot) }, head);
|
|
95
1086
|
return;
|
|
96
1087
|
}
|
|
97
|
-
json(res, 404, { error: "unknown replay endpoint" });
|
|
1088
|
+
json(res, 404, { error: "unknown replay endpoint" }, head);
|
|
98
1089
|
} catch (error) {
|
|
99
|
-
|
|
1090
|
+
console.error("[dsh-replay] request failed", error);
|
|
1091
|
+
json(res, 500, { error: "internal error" }, head);
|
|
100
1092
|
}
|
|
101
1093
|
};
|
|
102
1094
|
ctx.effect(
|