niceeval 0.1.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.
Files changed (94) hide show
  1. package/README.md +164 -0
  2. package/README.zh.md +160 -0
  3. package/bin/niceeval.js +11 -0
  4. package/package.json +96 -0
  5. package/src/agents/bub.ts +223 -0
  6. package/src/agents/builtin.ts +6 -0
  7. package/src/agents/claude-code.ts +96 -0
  8. package/src/agents/codex.ts +122 -0
  9. package/src/agents/index.ts +25 -0
  10. package/src/agents/shared.ts +145 -0
  11. package/src/cli.ts +421 -0
  12. package/src/context/context.test.ts +96 -0
  13. package/src/context/context.ts +427 -0
  14. package/src/context/control-flow.ts +27 -0
  15. package/src/context/session.ts +124 -0
  16. package/src/define.ts +112 -0
  17. package/src/expect/index.ts +243 -0
  18. package/src/i18n/en.ts +131 -0
  19. package/src/i18n/index.ts +39 -0
  20. package/src/i18n/zh-CN.ts +132 -0
  21. package/src/index.ts +39 -0
  22. package/src/loaders/index.ts +56 -0
  23. package/src/o11y/cost.ts +57 -0
  24. package/src/o11y/derive.ts +304 -0
  25. package/src/o11y/otlp/canonical.ts +112 -0
  26. package/src/o11y/otlp/mappers/bub.ts +30 -0
  27. package/src/o11y/otlp/mappers/codex.ts +38 -0
  28. package/src/o11y/otlp/mappers/index.ts +25 -0
  29. package/src/o11y/otlp/parse.ts +330 -0
  30. package/src/o11y/otlp/receiver.ts +100 -0
  31. package/src/o11y/otlp/sandbox-receiver.ts +113 -0
  32. package/src/o11y/otlp/select.ts +82 -0
  33. package/src/o11y/parsers/bub.ts +240 -0
  34. package/src/o11y/parsers/claude-code.ts +270 -0
  35. package/src/o11y/parsers/codex.ts +480 -0
  36. package/src/o11y/parsers/index.ts +37 -0
  37. package/src/o11y/prices.json +9873 -0
  38. package/src/runner/discover.ts +77 -0
  39. package/src/runner/reporters/artifacts.ts +81 -0
  40. package/src/runner/reporters/console.ts +76 -0
  41. package/src/runner/reporters/index.ts +5 -0
  42. package/src/runner/reporters/json.ts +52 -0
  43. package/src/runner/reporters/live.ts +178 -0
  44. package/src/runner/reporters/table.ts +328 -0
  45. package/src/runner/run.ts +860 -0
  46. package/src/runner/sandbox-prep.ts +91 -0
  47. package/src/sandbox/checkpoint.ts +39 -0
  48. package/src/sandbox/docker.ts +539 -0
  49. package/src/sandbox/e2b.ts +203 -0
  50. package/src/sandbox/index.ts +25 -0
  51. package/src/sandbox/registry.ts +60 -0
  52. package/src/sandbox/resolve.ts +131 -0
  53. package/src/sandbox/source-files.ts +30 -0
  54. package/src/sandbox/vercel.ts +236 -0
  55. package/src/scoring/collector.ts +113 -0
  56. package/src/scoring/judge.ts +236 -0
  57. package/src/scoring/scoped.ts +289 -0
  58. package/src/scoring/verdict.ts +20 -0
  59. package/src/source-loc.ts +53 -0
  60. package/src/types.ts +913 -0
  61. package/src/util.test.ts +31 -0
  62. package/src/util.ts +50 -0
  63. package/src/view/app/App.tsx +189 -0
  64. package/src/view/app/components/AttemptModal.tsx +66 -0
  65. package/src/view/app/components/CodeView.tsx +272 -0
  66. package/src/view/app/components/CopyControls.tsx +89 -0
  67. package/src/view/app/components/ExperimentTable.tsx +266 -0
  68. package/src/view/app/components/GroupSelector.tsx +61 -0
  69. package/src/view/app/components/LazyArtifact.tsx +50 -0
  70. package/src/view/app/components/Trace.tsx +100 -0
  71. package/src/view/app/components/Transcript.tsx +130 -0
  72. package/src/view/app/components/primitives.tsx +43 -0
  73. package/src/view/app/components/ui/badge.tsx +21 -0
  74. package/src/view/app/components/ui/dialog.tsx +34 -0
  75. package/src/view/app/components/ui/tabs.tsx +30 -0
  76. package/src/view/app/i18n.ts +341 -0
  77. package/src/view/app/index.html +13 -0
  78. package/src/view/app/lib/cn.ts +7 -0
  79. package/src/view/app/lib/format.ts +73 -0
  80. package/src/view/app/lib/guards.ts +61 -0
  81. package/src/view/app/lib/outcome.ts +96 -0
  82. package/src/view/app/lib/rows.ts +63 -0
  83. package/src/view/app/lib/transcript-data.tsx +121 -0
  84. package/src/view/app/main.tsx +17 -0
  85. package/src/view/app/pages/RunsPage.tsx +83 -0
  86. package/src/view/app/pages/TracesPage.tsx +40 -0
  87. package/src/view/app/shared.ts +10 -0
  88. package/src/view/app/types.ts +114 -0
  89. package/src/view/app/vite.config.ts +26 -0
  90. package/src/view/client-dist/app.css +2 -0
  91. package/src/view/client-dist/app.js +56 -0
  92. package/src/view/index.ts +406 -0
  93. package/src/view/styles.css +1074 -0
  94. package/src/view/template.html +15 -0
@@ -0,0 +1,240 @@
1
+ // Bub(republic tape)transcript 解析器,同时充当「通用 JSONL」兜底解析器。
2
+ //
3
+ // 真实 tape 格式(~/.bub/tapes/<md5(ws)__md5(sess)>.jsonl,按 id 升序逐行):
4
+ // - kind=message payload={role, content} → 对话
5
+ // - kind=tool_call payload={calls:[{id,function:{name,arguments}}]}
6
+ // - kind=tool_result payload={results:[...]} ← 与上一条 tool_call.calls 按位对齐
7
+ // - kind=event payload={name, data} name=="run" → data.usage 是该次模型调用用量
8
+ // - kind=anchor payload={name, state:{summary?}} name!="session/start" = tape.handoff(压缩检查点)
9
+ // - kind=system/error
10
+ // 用量路径(已对真实 tape 校验):payload.data.usage.{prompt_tokens,completion_tokens,total_tokens,
11
+ // prompt_tokens_details.cached_tokens, cost}。compaction = 非 bootstrap 的 anchor(handoff)条数。
12
+ // 也保留 legacy {type:...} 与无 type/kind 的通用兜底。
13
+
14
+ import type { StreamEvent, Usage, ToolName, JsonValue } from "../../types.ts";
15
+ import type { ParsedTranscript } from "./index.ts";
16
+
17
+ function normalizeToolName(name: string): ToolName {
18
+ const toolMap: Record<string, ToolName> = {
19
+ "fs.read": "file_read", fs_read: "file_read", read_file: "file_read", read: "file_read",
20
+ "fs.write": "file_write", fs_write: "file_write", write_file: "file_write", write: "file_write", create_file: "file_write",
21
+ "fs.edit": "file_edit", fs_edit: "file_edit", edit_file: "file_edit", edit: "file_edit", apply_patch: "file_edit",
22
+ bash: "shell", shell: "shell", exec: "shell", command_execution: "shell",
23
+ "web.fetch": "web_fetch", web_fetch: "web_fetch", fetch: "web_fetch", curl: "web_fetch",
24
+ "web.search": "web_search", web_search: "web_search",
25
+ glob: "glob", grep: "grep", ls: "list_dir", list_dir: "list_dir",
26
+ task: "agent_task", update_todos: "agent_task",
27
+ };
28
+ return toolMap[name] || toolMap[name.toLowerCase()] || "unknown";
29
+ }
30
+
31
+ function get(obj: unknown, key: string): unknown {
32
+ return obj && typeof obj === "object" ? (obj as Record<string, unknown>)[key] : undefined;
33
+ }
34
+
35
+ function num(obj: unknown, ...keys: string[]): number {
36
+ for (const k of keys) {
37
+ const v = get(obj, k);
38
+ if (typeof v === "number" && Number.isFinite(v)) return v;
39
+ }
40
+ return 0;
41
+ }
42
+
43
+ function coerceArgs(value: unknown): JsonValue {
44
+ if (typeof value === "string") {
45
+ try {
46
+ return JSON.parse(value) as JsonValue;
47
+ } catch {
48
+ return value;
49
+ }
50
+ }
51
+ return (value ?? {}) as JsonValue;
52
+ }
53
+
54
+ function extractText(content: unknown): string {
55
+ if (typeof content === "string") return content;
56
+ if (Array.isArray(content)) {
57
+ const parts: string[] = [];
58
+ for (const b of content) {
59
+ if (typeof b === "string") parts.push(b);
60
+ else {
61
+ const t = get(b, "text") ?? get(b, "content");
62
+ if (typeof t === "string") parts.push(t);
63
+ }
64
+ }
65
+ return parts.join("\n");
66
+ }
67
+ return "";
68
+ }
69
+
70
+ export function parseBubTranscript(raw: string | undefined): ParsedTranscript {
71
+ const events: StreamEvent[] = [];
72
+ let inputTokens = 0;
73
+ let outputTokens = 0;
74
+ let cacheReadTokens = 0;
75
+ let costUSD = 0;
76
+ let requests = 0;
77
+ let compactions = 0;
78
+ let parseSuccess = true;
79
+
80
+ if (!raw || !raw.trim()) {
81
+ return { events, usage: { inputTokens: 0, outputTokens: 0 }, compactions: 0, parseSuccess: true };
82
+ }
83
+
84
+ // tape 的 tool_result 与上一条 tool_call 按位对齐;记下上一批 callId。
85
+ let lastCallIds: string[] = [];
86
+ // 无显式 id 的兜底配对。
87
+ const pendingCallIds: string[] = [];
88
+ let synth = 0;
89
+ const nextSynthId = (): string => `bub_${++synth}`;
90
+
91
+ // 从 event/run 的 data.usage(或 legacy 顶层 usage)累加用量。
92
+ const addUsage = (usage: unknown): void => {
93
+ if (!usage || typeof usage !== "object") return;
94
+ const input = num(usage, "input_tokens", "prompt_tokens", "inputTokens");
95
+ const output = num(usage, "output_tokens", "completion_tokens", "outputTokens");
96
+ let cache = num(usage, "cached_input_tokens", "cache_read_input_tokens", "cacheReadTokens");
97
+ if (cache === 0) cache = num(get(usage, "prompt_tokens_details"), "cached_tokens");
98
+ const cost = num(usage, "cost");
99
+ if (input === 0 && output === 0 && cache === 0 && cost === 0) return;
100
+ inputTokens += input;
101
+ outputTokens += output;
102
+ cacheReadTokens += cache;
103
+ costUSD += cost;
104
+ requests += 1;
105
+ };
106
+
107
+ const emitCall = (originalName: string, input: JsonValue, explicitId: unknown): string => {
108
+ const callId = explicitId != null ? String(explicitId) : nextSynthId();
109
+ if (explicitId == null) pendingCallIds.push(callId);
110
+ events.push({ type: "action.called", callId, name: originalName, input, tool: normalizeToolName(originalName) });
111
+ return callId;
112
+ };
113
+
114
+ const emitResult = (output: unknown, success: boolean, explicitId: unknown): void => {
115
+ const callId = explicitId != null ? String(explicitId) : pendingCallIds.shift() ?? nextSynthId();
116
+ events.push({ type: "action.result", callId, output: (output ?? null) as JsonValue, status: success ? "completed" : "failed" });
117
+ };
118
+
119
+ for (const line of raw.split("\n")) {
120
+ const trimmed = line.trim();
121
+ if (!trimmed) continue;
122
+
123
+ let entry: unknown;
124
+ try {
125
+ entry = JSON.parse(trimmed);
126
+ } catch {
127
+ parseSuccess = false;
128
+ continue;
129
+ }
130
+
131
+ try {
132
+ const type = get(entry, "type");
133
+ const kind = get(entry, "kind");
134
+
135
+ // ── legacy: 顶层 type ──
136
+ if (typeof type === "string") {
137
+ addUsage(get(entry, "usage"));
138
+ if (type === "message") {
139
+ const role = get(entry, "role") === "user" ? "user" : "assistant";
140
+ const text = extractText(get(entry, "content") ?? get(entry, "text"));
141
+ if (text) events.push({ type: "message", role, text });
142
+ } else if (type === "tool_call") {
143
+ const name = String(get(entry, "tool_name") ?? get(entry, "name") ?? get(entry, "tool") ?? "unknown");
144
+ emitCall(name, coerceArgs(get(entry, "args") ?? get(entry, "input")), get(entry, "call_id") ?? get(entry, "id"));
145
+ } else if (type === "tool_result") {
146
+ emitResult(get(entry, "result") ?? get(entry, "output") ?? get(entry, "content"), get(entry, "success") !== false && !get(entry, "error"), get(entry, "call_id") ?? get(entry, "tool_call_id") ?? get(entry, "id"));
147
+ } else if (type === "thinking" || type === "reasoning") {
148
+ const text = extractText(get(entry, "content") ?? get(entry, "text"));
149
+ if (text) events.push({ type: "thinking", text });
150
+ } else if (type === "error") {
151
+ events.push({ type: "error", message: String(get(entry, "message") ?? get(entry, "error") ?? "error") });
152
+ }
153
+ continue;
154
+ }
155
+
156
+ // ── republic tape: kind + payload ──
157
+ if (typeof kind === "string") {
158
+ const payload = get(entry, "payload");
159
+ if (kind === "message") {
160
+ const role = get(payload, "role") === "user" ? "user" : "assistant";
161
+ const text = extractText(get(payload, "content") ?? get(payload, "text"));
162
+ if (text) events.push({ type: "message", role, text });
163
+ } else if (kind === "tool_call") {
164
+ const calls = get(payload, "calls");
165
+ const list = Array.isArray(calls) ? calls : [payload];
166
+ lastCallIds = [];
167
+ for (const call of list) {
168
+ const fn = get(call, "function") ?? call;
169
+ const name = String(get(fn, "name") ?? get(call, "name") ?? "unknown");
170
+ const id = emitCall(name, coerceArgs(get(fn, "arguments") ?? get(call, "args") ?? get(call, "input")), get(call, "id") ?? get(call, "call_id"));
171
+ lastCallIds.push(id);
172
+ }
173
+ } else if (kind === "tool_result") {
174
+ const results = get(payload, "results");
175
+ const list = Array.isArray(results) ? results : [get(payload, "result") ?? payload];
176
+ // republic:results 与上一条 tool_call.calls 按位对齐。
177
+ list.forEach((r, i) => {
178
+ const isError = typeof r === "object" && r !== null && "error" in (r as Record<string, unknown>);
179
+ const explicitId = get(r, "tool_call_id") ?? get(r, "id") ?? lastCallIds[i];
180
+ emitResult(get(r, "output") ?? get(r, "result") ?? get(r, "content") ?? r, !isError, explicitId);
181
+ });
182
+ } else if (kind === "event") {
183
+ const data = get(payload, "data");
184
+ // 用量:run 事件首选 data.usage;但新版 bub.tape 不一定每轮都发 run/usage,
185
+ // 所以任何 event 上挂的 usage 也兜底累加(addUsage 自带去重式全零跳过)。
186
+ addUsage(get(data, "usage"));
187
+ // 仅在「真失败」时记 error:有 error 字段,或 status 命中失败词。
188
+ // loop.step 这类控制事件 status=="continue"/"running" 不是错误 —— 别再当 error 刷屏。
189
+ const status = get(data, "status");
190
+ const errVal = get(data, "error");
191
+ const failed =
192
+ (errVal != null && errVal !== false && errVal !== "") ||
193
+ (typeof status === "string" && /^(error|failed|aborted|cancell?ed|timeout)$/i.test(status));
194
+ if (failed) {
195
+ events.push({ type: "error", message: typeof errVal === "string" && errVal ? errVal : `${String(get(payload, "name"))}: ${String(status)}` });
196
+ }
197
+ } else if (kind === "anchor") {
198
+ // 非 bootstrap 的 anchor = tape.handoff = 一次压缩检查点(供 t.transcript.compactions() 守卫)。
199
+ const name = String(get(payload, "name") ?? "");
200
+ if (name && name !== "session/start") {
201
+ compactions += 1;
202
+ events.push({ type: "compaction", reason: name });
203
+ }
204
+ } else if (kind === "error") {
205
+ events.push({ type: "error", message: String(get(payload, "message") ?? get(payload, "kind") ?? "error") });
206
+ }
207
+ continue;
208
+ }
209
+
210
+ // ── 通用兜底:既无 type 也无 kind ──
211
+ addUsage(get(entry, "usage"));
212
+ const role = get(entry, "role");
213
+ const name = get(entry, "tool") ?? get(entry, "name");
214
+ if (role === "assistant" || role === "user") {
215
+ const text = extractText(get(entry, "content") ?? get(entry, "text"));
216
+ if (text) events.push({ type: "message", role, text });
217
+ } else if (typeof name === "string") {
218
+ if (get(entry, "result") !== undefined || get(entry, "output") !== undefined) {
219
+ emitResult(get(entry, "result") ?? get(entry, "output"), !get(entry, "error"), get(entry, "call_id") ?? get(entry, "id"));
220
+ } else {
221
+ emitCall(name, coerceArgs(get(entry, "args") ?? get(entry, "arguments") ?? get(entry, "input")), get(entry, "call_id") ?? get(entry, "id"));
222
+ }
223
+ }
224
+ } catch {
225
+ parseSuccess = false;
226
+ }
227
+ }
228
+
229
+ const usage: Usage = { inputTokens, outputTokens };
230
+ if (cacheReadTokens > 0) usage.cacheReadTokens = cacheReadTokens;
231
+ if (requests > 0) usage.requests = requests;
232
+ if (costUSD > 0) usage.costUSD = costUSD;
233
+
234
+ return { events, usage, compactions, parseSuccess };
235
+ }
236
+
237
+ /** 便捷形态:只要 StreamEvent[]。 */
238
+ export function parseBub(raw: string | undefined): StreamEvent[] {
239
+ return parseBubTranscript(raw).events;
240
+ }
@@ -0,0 +1,270 @@
1
+ // Claude Code transcript 解析器。
2
+ // Claude Code 把会话存成 JSONL:~/.claude/projects/{path}/{session}.jsonl。
3
+ // - 每行 { type:"user"|"assistant", message:{ content:[...], usage:{...} } };
4
+ // - assistant 行的 content 里混着 text / tool_use / thinking 块;
5
+ // - 工具结果以 user 行里的 tool_result 块回来(按 tool_use_id 配对)。
6
+ // 目标:归一成 niceeval StreamEvent[]。
7
+
8
+ import type { StreamEvent, Usage, ToolName, JsonValue } from "../../types.ts";
9
+ import type { ParsedTranscript } from "./index.ts";
10
+
11
+ // ───────────────────────── 工具名归一 ─────────────────────────
12
+
13
+ function normalizeToolName(name: string): ToolName {
14
+ const toolMap: Record<string, ToolName> = {
15
+ // 文件
16
+ Read: "file_read",
17
+ read_file: "file_read",
18
+ ReadFile: "file_read",
19
+ Write: "file_write",
20
+ write_file: "file_write",
21
+ WriteFile: "file_write",
22
+ write_to_file: "file_write",
23
+ create_file: "file_write",
24
+ Edit: "file_edit",
25
+ MultiEdit: "file_edit",
26
+ edit_file: "file_edit",
27
+ EditFile: "file_edit",
28
+ str_replace_editor: "file_edit",
29
+ StrReplace: "file_edit",
30
+ NotebookEdit: "file_edit",
31
+
32
+ // shell
33
+ Bash: "shell",
34
+ bash: "shell",
35
+ BashOutput: "shell",
36
+ Shell: "shell",
37
+ shell: "shell",
38
+ execute_command: "shell",
39
+ run_command: "shell",
40
+
41
+ // web
42
+ WebFetch: "web_fetch",
43
+ web_fetch: "web_fetch",
44
+ fetch_url: "web_fetch",
45
+ mcp__fetch__fetch: "web_fetch",
46
+ WebSearch: "web_search",
47
+ web_search: "web_search",
48
+
49
+ // 检索 / 导航
50
+ Glob: "glob",
51
+ glob: "glob",
52
+ list_files: "glob",
53
+ Grep: "grep",
54
+ grep: "grep",
55
+ search_files: "grep",
56
+ LS: "list_dir",
57
+ list_dir: "list_dir",
58
+ ListDir: "list_dir",
59
+
60
+ // 子 agent
61
+ Task: "agent_task",
62
+ task: "agent_task",
63
+ };
64
+ return toolMap[name] || "unknown";
65
+ }
66
+
67
+ // ───────────────────────── 小工具 ─────────────────────────
68
+
69
+ function get(obj: unknown, key: string): unknown {
70
+ return obj && typeof obj === "object" ? (obj as Record<string, unknown>)[key] : undefined;
71
+ }
72
+
73
+ /** content 既可能是 string,也可能挂在 data.message.content;统一取数组。 */
74
+ function getContentArray(data: unknown): unknown[] | undefined {
75
+ const direct = get(data, "content");
76
+ if (Array.isArray(direct)) return direct;
77
+ const msgContent = get(get(data, "message"), "content");
78
+ if (Array.isArray(msgContent)) return msgContent;
79
+ return undefined;
80
+ }
81
+
82
+ function getStringContent(data: unknown): string | undefined {
83
+ const direct = get(data, "content");
84
+ if (typeof direct === "string") return direct;
85
+ const msgContent = get(get(data, "message"), "content");
86
+ if (typeof msgContent === "string") return msgContent;
87
+ return undefined;
88
+ }
89
+
90
+ /** 抠 text 块(含 message.content[].type==="text")。 */
91
+ function extractText(data: unknown): string {
92
+ const s = getStringContent(data);
93
+ if (s) return s;
94
+ const arr = getContentArray(data);
95
+ if (arr) {
96
+ const texts = arr
97
+ .filter((b) => get(b, "type") === "text")
98
+ .map((b) => get(b, "text"))
99
+ .filter((t): t is string => typeof t === "string");
100
+ if (texts.length > 0) return texts.join("\n");
101
+ }
102
+ const t = get(data, "text");
103
+ return typeof t === "string" ? t : "";
104
+ }
105
+
106
+ /** 抠 thinking 块。 */
107
+ function extractThinking(data: unknown): string {
108
+ const arr = getContentArray(data);
109
+ if (!arr) return "";
110
+ const blocks = arr
111
+ .filter((b) => get(b, "type") === "thinking")
112
+ .map((b) => get(b, "thinking") ?? get(b, "text"))
113
+ .filter((t): t is string => typeof t === "string");
114
+ return blocks.join("\n");
115
+ }
116
+
117
+ // ───────────────────────── usage 聚合 ─────────────────────────
118
+
119
+ function readUsage(u: unknown): { input: number; output: number; cacheRead: number } | null {
120
+ if (!u || typeof u !== "object") return null;
121
+ const o = u as Record<string, unknown>;
122
+ const num = (...keys: string[]): number => {
123
+ for (const k of keys) {
124
+ const v = o[k];
125
+ if (typeof v === "number" && Number.isFinite(v)) return v;
126
+ }
127
+ return 0;
128
+ };
129
+ const input = num("input_tokens", "prompt_tokens", "inputTokens");
130
+ const output = num("output_tokens", "completion_tokens", "outputTokens");
131
+ const cacheRead = num("cache_read_input_tokens", "cached_input_tokens", "cache_read_tokens");
132
+ if (input === 0 && output === 0 && cacheRead === 0) return null;
133
+ return { input, output, cacheRead };
134
+ }
135
+
136
+ // ───────────────────────── compaction 标记 ─────────────────────────
137
+
138
+ /** Claude Code 压缩边界:type:"summary" / isCompactSummary / subtype:"compact_boundary"。 */
139
+ function isCompactSummary(data: unknown): boolean {
140
+ if (get(data, "type") === "summary") return true;
141
+ if (get(data, "isCompactSummary") === true) return true;
142
+ if (get(get(data, "message"), "isCompactSummary") === true) return true;
143
+ if (get(data, "subtype") === "compact_boundary") return true;
144
+ return false;
145
+ }
146
+
147
+ // ───────────────────────── 主解析 ─────────────────────────
148
+
149
+ export function parseClaudeCodeTranscript(raw: string | undefined): ParsedTranscript {
150
+ const events: StreamEvent[] = [];
151
+ let inputTokens = 0;
152
+ let outputTokens = 0;
153
+ let cacheReadTokens = 0;
154
+ let requests = 0;
155
+ let compactions = 0;
156
+ let parseSuccess = true;
157
+
158
+ if (!raw || !raw.trim()) {
159
+ return { events, usage: { inputTokens: 0, outputTokens: 0 }, compactions: 0, parseSuccess: true };
160
+ }
161
+
162
+ const addUsageFrom = (data: unknown): void => {
163
+ const u = readUsage(get(get(data, "message"), "usage") ?? get(data, "usage"));
164
+ if (!u) return;
165
+ inputTokens += u.input;
166
+ outputTokens += u.output;
167
+ cacheReadTokens += u.cacheRead;
168
+ requests += 1;
169
+ };
170
+
171
+ const lines = raw.split("\n");
172
+ for (const line of lines) {
173
+ const trimmed = line.trim();
174
+ if (!trimmed) continue;
175
+
176
+ let data: unknown;
177
+ try {
178
+ data = JSON.parse(trimmed);
179
+ } catch {
180
+ parseSuccess = false;
181
+ continue;
182
+ }
183
+
184
+ try {
185
+ // 压缩边界(在常规分类之前判,优先吃掉)。
186
+ if (isCompactSummary(data)) {
187
+ events.push({ type: "compaction" });
188
+ compactions += 1;
189
+ continue;
190
+ }
191
+
192
+ const type = get(data, "type");
193
+ const role = get(data, "role");
194
+
195
+ if (type === "user" || role === "user") {
196
+ addUsageFrom(data);
197
+ const arr = getContentArray(data);
198
+ const toolResults = arr?.filter((b) => get(b, "type") === "tool_result") ?? [];
199
+ if (toolResults.length > 0) {
200
+ for (const r of toolResults) {
201
+ const callId = String(get(r, "tool_use_id") ?? get(r, "id") ?? "unknown");
202
+ const isError = get(r, "is_error") === true || !!get(r, "error");
203
+ events.push({
204
+ type: "action.result",
205
+ callId,
206
+ output: (get(r, "content") ?? null) as JsonValue,
207
+ status: isError ? "failed" : "completed",
208
+ });
209
+ }
210
+ } else {
211
+ const text = extractText(data);
212
+ if (text) events.push({ type: "message", role: "user", text });
213
+ }
214
+ } else if (type === "assistant" || role === "assistant") {
215
+ addUsageFrom(data);
216
+
217
+ const text = extractText(data);
218
+ if (text) events.push({ type: "message", role: "assistant", text });
219
+
220
+ const thinking = extractThinking(data);
221
+ if (thinking) events.push({ type: "thinking", text: thinking });
222
+
223
+ const arr = getContentArray(data);
224
+ if (arr) {
225
+ for (const b of arr) {
226
+ if (get(b, "type") !== "tool_use") continue;
227
+ const name = String(get(b, "name") ?? "unknown");
228
+ const callId = String(get(b, "id") ?? "unknown");
229
+ const input = (get(b, "input") ?? {}) as JsonValue;
230
+ events.push({
231
+ type: "action.called",
232
+ callId,
233
+ name,
234
+ input,
235
+ tool: normalizeToolName(name),
236
+ });
237
+ }
238
+ }
239
+ } else if (type === "tool_result" || type === "tool_response") {
240
+ const callId = String(get(data, "tool_use_id") ?? get(data, "id") ?? "unknown");
241
+ const isError = get(data, "is_error") === true || !!get(data, "error");
242
+ events.push({
243
+ type: "action.result",
244
+ callId,
245
+ output: (get(data, "content") ?? get(data, "output") ?? get(data, "result") ?? null) as JsonValue,
246
+ status: isError ? "failed" : "completed",
247
+ });
248
+ } else if (type === "system" || role === "system") {
249
+ // 系统行多为元数据,不进事件流(compact_boundary 已在上面吃掉)。
250
+ } else if (type === "error" || get(data, "error")) {
251
+ const err = get(data, "error");
252
+ const msg = get(err, "message") ?? get(data, "message") ?? "error";
253
+ events.push({ type: "error", message: String(msg) });
254
+ }
255
+ } catch {
256
+ parseSuccess = false;
257
+ }
258
+ }
259
+
260
+ const usage: Usage = { inputTokens, outputTokens };
261
+ if (cacheReadTokens > 0) usage.cacheReadTokens = cacheReadTokens;
262
+ if (requests > 0) usage.requests = requests;
263
+
264
+ return { events, usage, compactions, parseSuccess };
265
+ }
266
+
267
+ /** 便捷形态:只要 StreamEvent[]。 */
268
+ export function parseClaudeCode(raw: string | undefined): StreamEvent[] {
269
+ return parseClaudeCodeTranscript(raw).events;
270
+ }