@tracemarketplace/shared 0.0.6 → 0.0.9

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 (82) hide show
  1. package/dist/chunker.d.ts.map +1 -1
  2. package/dist/chunker.js +14 -2
  3. package/dist/chunker.js.map +1 -1
  4. package/dist/extractor-claude-code.test.d.ts +2 -0
  5. package/dist/extractor-claude-code.test.d.ts.map +1 -0
  6. package/dist/extractor-claude-code.test.js +290 -0
  7. package/dist/extractor-claude-code.test.js.map +1 -0
  8. package/dist/extractor-codex.test.d.ts +2 -0
  9. package/dist/extractor-codex.test.d.ts.map +1 -0
  10. package/dist/extractor-codex.test.js +212 -0
  11. package/dist/extractor-codex.test.js.map +1 -0
  12. package/dist/extractor-cursor.test.d.ts +2 -0
  13. package/dist/extractor-cursor.test.d.ts.map +1 -0
  14. package/dist/extractor-cursor.test.js +120 -0
  15. package/dist/extractor-cursor.test.js.map +1 -0
  16. package/dist/extractors/claude-code.d.ts.map +1 -1
  17. package/dist/extractors/claude-code.js +172 -73
  18. package/dist/extractors/claude-code.js.map +1 -1
  19. package/dist/extractors/codex.d.ts.map +1 -1
  20. package/dist/extractors/codex.js +63 -35
  21. package/dist/extractors/codex.js.map +1 -1
  22. package/dist/extractors/common.d.ts +14 -0
  23. package/dist/extractors/common.d.ts.map +1 -0
  24. package/dist/extractors/common.js +100 -0
  25. package/dist/extractors/common.js.map +1 -0
  26. package/dist/extractors/cursor.d.ts.map +1 -1
  27. package/dist/extractors/cursor.js +205 -45
  28. package/dist/extractors/cursor.js.map +1 -1
  29. package/dist/hash.d.ts.map +1 -1
  30. package/dist/hash.js +35 -2
  31. package/dist/hash.js.map +1 -1
  32. package/dist/hash.test.js +29 -2
  33. package/dist/hash.test.js.map +1 -1
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/redact.d.ts +12 -0
  39. package/dist/redact.d.ts.map +1 -1
  40. package/dist/redact.js +120 -38
  41. package/dist/redact.js.map +1 -1
  42. package/dist/redact.test.d.ts +2 -0
  43. package/dist/redact.test.d.ts.map +1 -0
  44. package/dist/redact.test.js +96 -0
  45. package/dist/redact.test.js.map +1 -0
  46. package/dist/turn-actors.d.ts +3 -0
  47. package/dist/turn-actors.d.ts.map +1 -0
  48. package/dist/turn-actors.js +57 -0
  49. package/dist/turn-actors.js.map +1 -0
  50. package/dist/turn-actors.test.d.ts +2 -0
  51. package/dist/turn-actors.test.d.ts.map +1 -0
  52. package/dist/turn-actors.test.js +65 -0
  53. package/dist/turn-actors.test.js.map +1 -0
  54. package/dist/types.d.ts +5 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/utils.d.ts +1 -1
  57. package/dist/utils.d.ts.map +1 -1
  58. package/dist/utils.js +4 -0
  59. package/dist/utils.js.map +1 -1
  60. package/dist/validators.d.ts +24 -0
  61. package/dist/validators.d.ts.map +1 -1
  62. package/dist/validators.js +3 -0
  63. package/dist/validators.js.map +1 -1
  64. package/package.json +5 -1
  65. package/src/chunker.ts +17 -2
  66. package/src/extractor-claude-code.test.ts +326 -0
  67. package/src/extractor-codex.test.ts +225 -0
  68. package/src/extractor-cursor.test.ts +141 -0
  69. package/src/extractors/claude-code.ts +180 -69
  70. package/src/extractors/codex.ts +69 -38
  71. package/src/extractors/common.ts +139 -0
  72. package/src/extractors/cursor.ts +294 -52
  73. package/src/hash.test.ts +31 -2
  74. package/src/hash.ts +38 -3
  75. package/src/index.ts +1 -0
  76. package/src/redact.test.ts +100 -0
  77. package/src/redact.ts +175 -58
  78. package/src/turn-actors.test.ts +71 -0
  79. package/src/turn-actors.ts +71 -0
  80. package/src/types.ts +6 -0
  81. package/src/utils.ts +3 -1
  82. package/src/validators.ts +3 -0
@@ -0,0 +1,225 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { extractCodex } from "./extractors/codex.js";
3
+
4
+ function makeBuffer(lines: Array<Record<string, unknown>>): Buffer {
5
+ return Buffer.from(lines.map((line) => JSON.stringify(line)).join("\n") + "\n", "utf-8");
6
+ }
7
+
8
+ describe("extractCodex", () => {
9
+ it("parses modern Codex response items and token counts", async () => {
10
+ const trace = await extractCodex(
11
+ makeBuffer([
12
+ {
13
+ timestamp: "2026-03-21T00:00:00.000Z",
14
+ type: "session_meta",
15
+ payload: {
16
+ id: "codex-session-1",
17
+ timestamp: "2026-03-21T00:00:00.000Z",
18
+ cwd: "/Users/test/project",
19
+ cli_version: "0.110.0",
20
+ },
21
+ },
22
+ {
23
+ timestamp: "2026-03-21T00:00:01.000Z",
24
+ type: "event_msg",
25
+ payload: { type: "task_started", turn_id: "turn-1" },
26
+ },
27
+ {
28
+ timestamp: "2026-03-21T00:00:01.000Z",
29
+ type: "turn_context",
30
+ payload: { model: "gpt-5.4" },
31
+ },
32
+ {
33
+ timestamp: "2026-03-21T00:00:02.000Z",
34
+ type: "event_msg",
35
+ payload: { type: "user_message", message: "Audit the repo" },
36
+ },
37
+ {
38
+ timestamp: "2026-03-21T00:00:03.000Z",
39
+ type: "response_item",
40
+ payload: {
41
+ type: "message",
42
+ role: "assistant",
43
+ content: [{ type: "output_text", text: "I will inspect the repo." }],
44
+ },
45
+ },
46
+ {
47
+ timestamp: "2026-03-21T00:00:04.000Z",
48
+ type: "response_item",
49
+ payload: {
50
+ type: "function_call",
51
+ name: "exec_command",
52
+ arguments: JSON.stringify({
53
+ cmd: "sed -n '1,120p' src/main.ts",
54
+ workdir: "/Users/test/project",
55
+ }),
56
+ call_id: "call-1",
57
+ },
58
+ },
59
+ {
60
+ timestamp: "2026-03-21T00:00:05.000Z",
61
+ type: "response_item",
62
+ payload: {
63
+ type: "function_call_output",
64
+ call_id: "call-1",
65
+ output: "Process exited with code 0\nsrc/main.ts",
66
+ },
67
+ },
68
+ {
69
+ timestamp: "2026-03-21T00:00:06.000Z",
70
+ type: "event_msg",
71
+ payload: {
72
+ type: "token_count",
73
+ info: {
74
+ total_token_usage: {
75
+ input_tokens: 1200,
76
+ cached_input_tokens: 200,
77
+ output_tokens: 300,
78
+ reasoning_output_tokens: 50,
79
+ total_tokens: 1500,
80
+ },
81
+ },
82
+ },
83
+ },
84
+ {
85
+ timestamp: "2026-03-21T00:00:07.000Z",
86
+ type: "event_msg",
87
+ payload: { type: "agent_message", message: "I found the relevant file." },
88
+ },
89
+ {
90
+ timestamp: "2026-03-21T00:00:08.000Z",
91
+ type: "event_msg",
92
+ payload: {
93
+ type: "task_complete",
94
+ last_agent_message: "I found the relevant file.",
95
+ },
96
+ },
97
+ ]),
98
+ "test@example.com",
99
+ );
100
+
101
+ expect(trace.source_tool).toBe("codex_cli");
102
+ expect(trace.source_session_id).toBe("codex-session-1");
103
+ expect(trace.source_version).toBe("0.110.0");
104
+ expect(trace.turn_count).toBe(2);
105
+ expect(trace.total_input_tokens).toBe(1200);
106
+ expect(trace.total_output_tokens).toBe(300);
107
+ expect(trace.total_cache_read_tokens).toBe(200);
108
+ expect(trace.has_shell_commands).toBe(true);
109
+ expect(trace.has_tool_calls).toBe(true);
110
+ expect(trace.cwd_hash).toBeTruthy();
111
+
112
+ expect(trace.turns[0]).toMatchObject({
113
+ role: "user",
114
+ content: [{ type: "text", text: "Audit the repo" }],
115
+ });
116
+
117
+ expect(trace.turns[1].role).toBe("assistant");
118
+ expect(trace.turns[1].model).toBe("gpt-5.4");
119
+ expect(trace.turns[1].content.map((block) => block.type)).toEqual([
120
+ "text",
121
+ "tool_use",
122
+ "tool_result",
123
+ "text",
124
+ ]);
125
+ expect(trace.turns[1].content[0]).toMatchObject({
126
+ type: "text",
127
+ text: "I will inspect the repo.",
128
+ });
129
+ expect(trace.turns[1].content[1]).toMatchObject({
130
+ type: "tool_use",
131
+ tool_name: "exec_command",
132
+ });
133
+ expect(trace.turns[1].content[2]).toMatchObject({
134
+ type: "tool_result",
135
+ tool_call_id: "call-1",
136
+ is_error: false,
137
+ exit_code: 0,
138
+ });
139
+ expect(trace.turns[1].content[3]).toMatchObject({
140
+ type: "text",
141
+ text: "I found the relevant file.",
142
+ });
143
+ });
144
+
145
+ it("falls back to response-item user messages and preserves invalid tool arguments", async () => {
146
+ const trace = await extractCodex(
147
+ makeBuffer([
148
+ {
149
+ timestamp: "2026-03-21T01:00:00.000Z",
150
+ type: "session_meta",
151
+ payload: { id: "codex-session-2", timestamp: "2026-03-21T01:00:00.000Z" },
152
+ },
153
+ {
154
+ timestamp: "2026-03-21T01:00:01.000Z",
155
+ type: "event_msg",
156
+ payload: { type: "task_started", turn_id: "turn-2" },
157
+ },
158
+ {
159
+ timestamp: "2026-03-21T01:00:02.000Z",
160
+ type: "response_item",
161
+ payload: {
162
+ type: "message",
163
+ role: "user",
164
+ content: [{ type: "input_text", text: "Retry the failing command" }],
165
+ },
166
+ },
167
+ {
168
+ timestamp: "2026-03-21T01:00:03.000Z",
169
+ type: "response_item",
170
+ payload: {
171
+ type: "reasoning",
172
+ content: [{ type: "text", text: "I should re-run it with verbose output." }],
173
+ },
174
+ },
175
+ {
176
+ timestamp: "2026-03-21T01:00:04.000Z",
177
+ type: "response_item",
178
+ payload: {
179
+ type: "function_call",
180
+ name: "exec_command",
181
+ arguments: "{not-json",
182
+ call_id: "call-err",
183
+ },
184
+ },
185
+ {
186
+ timestamp: "2026-03-21T01:00:05.000Z",
187
+ type: "response_item",
188
+ payload: {
189
+ type: "function_call_output",
190
+ call_id: "call-err",
191
+ output: "Process exited with code 2\nError: command failed",
192
+ },
193
+ },
194
+ {
195
+ timestamp: "2026-03-21T01:00:06.000Z",
196
+ type: "event_msg",
197
+ payload: { type: "task_complete", last_agent_message: "The command failed again." },
198
+ },
199
+ ]),
200
+ "test@example.com",
201
+ );
202
+
203
+ expect(trace.turn_count).toBe(2);
204
+ expect(trace.has_thinking_blocks).toBe(true);
205
+ expect(trace.turns[0]).toMatchObject({
206
+ role: "user",
207
+ content: [{ type: "text", text: "Retry the failing command" }],
208
+ });
209
+
210
+ const assistantBlocks = trace.turns[1].content;
211
+ expect(assistantBlocks[0]).toMatchObject({
212
+ type: "thinking",
213
+ text: "I should re-run it with verbose output.",
214
+ });
215
+ expect(assistantBlocks[1]).toMatchObject({
216
+ type: "tool_use",
217
+ tool_input: { raw: "{not-json" },
218
+ });
219
+ expect(assistantBlocks[2]).toMatchObject({
220
+ type: "tool_result",
221
+ is_error: true,
222
+ exit_code: 2,
223
+ });
224
+ });
225
+ });
@@ -0,0 +1,141 @@
1
+ import { mkdtempSync, rmSync } from "fs";
2
+ import { join } from "path";
3
+ import { tmpdir } from "os";
4
+ import Database from "better-sqlite3";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { extractCursor } from "./extractors/cursor.js";
7
+
8
+ const tempDirs: string[] = [];
9
+
10
+ function makeCursorDb(
11
+ sessionId: string,
12
+ headers: Array<{ bubbleId: string; type: number }>,
13
+ values: Array<[string, unknown]>,
14
+ ): string {
15
+ const dir = mkdtempSync(join(tmpdir(), "tracemp-cursor-"));
16
+ tempDirs.push(dir);
17
+
18
+ const dbPath = join(dir, "state.vscdb");
19
+ const db = new Database(dbPath);
20
+ db.exec("CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
21
+
22
+ db.prepare("INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)").run(
23
+ `composerData:${sessionId}`,
24
+ JSON.stringify({
25
+ _v: 14,
26
+ createdAt: Date.parse("2026-03-21T00:00:00.000Z"),
27
+ fullConversationHeadersOnly: headers,
28
+ }),
29
+ );
30
+
31
+ const insert = db.prepare("INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)");
32
+ for (const [key, value] of values) {
33
+ insert.run(key, JSON.stringify(value));
34
+ }
35
+
36
+ db.close();
37
+ return dbPath;
38
+ }
39
+
40
+ afterEach(() => {
41
+ while (tempDirs.length > 0) {
42
+ rmSync(tempDirs.pop()!, { force: true, recursive: true });
43
+ }
44
+ });
45
+
46
+ describe("extractCursor", () => {
47
+ it("supports the modern bubbleId:<session>:<bubble> storage format", async () => {
48
+ const sessionId = "cursor-modern";
49
+ const dbPath = makeCursorDb(sessionId, [
50
+ { bubbleId: "user-1", type: 1 },
51
+ { bubbleId: "assistant-1", type: 2 },
52
+ ], [
53
+ [
54
+ `bubbleId:${sessionId}:user-1`,
55
+ {
56
+ type: 1,
57
+ text: "Help me debug the repo",
58
+ createdAt: "2026-03-21T00:00:01.000Z",
59
+ tokenCount: { inputTokens: 0, outputTokens: 0 },
60
+ },
61
+ ],
62
+ [
63
+ `bubbleId:${sessionId}:assistant-1`,
64
+ {
65
+ type: 2,
66
+ text: "I will inspect src/main.ts",
67
+ thinking: { text: "Start with the entrypoint." },
68
+ createdAt: "2026-03-21T00:00:02.000Z",
69
+ modelName: "gpt-5.4",
70
+ tokenCount: { inputTokens: 200, outputTokens: 80 },
71
+ relevantFiles: [{ path: "src/main.ts" }],
72
+ workspaceUris: ["file:///Users/test/project"],
73
+ },
74
+ ],
75
+ ]);
76
+
77
+ const trace = await extractCursor(dbPath, sessionId, "test@example.com");
78
+
79
+ expect(trace.turn_count).toBe(2);
80
+ expect(trace.source_version).toBe("14");
81
+ expect(trace.total_input_tokens).toBe(200);
82
+ expect(trace.total_output_tokens).toBe(80);
83
+ expect(trace.has_thinking_blocks).toBe(true);
84
+ expect(trace.turns[0]).toMatchObject({
85
+ role: "user",
86
+ content: [{ type: "text", text: "Help me debug the repo" }],
87
+ });
88
+ expect(trace.turns[1].role).toBe("assistant");
89
+ expect(trace.turns[1].model).toBe("gpt-5.4");
90
+ expect(trace.turns[1].content.map((block) => block.type)).toEqual([
91
+ "thinking",
92
+ "text",
93
+ ]);
94
+ expect(trace.turns[1].usage).toMatchObject({
95
+ input_tokens: 200,
96
+ output_tokens: 80,
97
+ });
98
+ expect(trace.env_state?.open_files_in_editor).toEqual(
99
+ expect.arrayContaining(["src/main.ts", "/Users/test/project"]),
100
+ );
101
+ });
102
+
103
+ it("falls back to the legacy agentKv:blob:<bubble> storage format", async () => {
104
+ const sessionId = "cursor-legacy";
105
+ const dbPath = makeCursorDb(sessionId, [
106
+ { bubbleId: "user-1", type: 1 },
107
+ { bubbleId: "assistant-1", type: 2 },
108
+ ], [
109
+ [
110
+ `agentKv:blob:user-1`,
111
+ {
112
+ role: "user",
113
+ type: "user",
114
+ text: "Open app.py",
115
+ createdAt: "2026-03-21T01:00:01.000Z",
116
+ },
117
+ ],
118
+ [
119
+ `agentKv:blob:assistant-1`,
120
+ {
121
+ role: "assistant",
122
+ message: "Opened the file.",
123
+ createdAt: "2026-03-21T01:00:02.000Z",
124
+ usage: { promptTokens: 55, completionTokens: 21 },
125
+ context: { openFiles: [{ path: "/tmp/app.py" }] },
126
+ },
127
+ ],
128
+ ]);
129
+
130
+ const trace = await extractCursor(dbPath, sessionId, "test@example.com");
131
+
132
+ expect(trace.turn_count).toBe(2);
133
+ expect(trace.total_input_tokens).toBe(55);
134
+ expect(trace.total_output_tokens).toBe(21);
135
+ expect(trace.turns[1]).toMatchObject({
136
+ role: "assistant",
137
+ content: [{ type: "text", text: "Opened the file." }],
138
+ });
139
+ expect(trace.env_state?.open_files_in_editor).toEqual(["/tmp/app.py"]);
140
+ });
141
+ });
@@ -8,6 +8,7 @@ import type {
8
8
  ContentBlock,
9
9
  TokenUsage,
10
10
  } from "../types.js";
11
+ import { deriveTurnActors } from "../turn-actors.js";
11
12
 
12
13
  export async function extractClaudeCode(
13
14
  sessionFilePath: string,
@@ -27,97 +28,207 @@ export async function extractClaudeCode(
27
28
 
28
29
  const sessionId =
29
30
  sessionFilePath.split("/").pop()?.replace(".jsonl", "") ?? randomUUID();
30
- const turns: Turn[] = [];
31
31
 
32
+ // Build uuid→entry map for parent lookups
33
+ const uuidMap = new Map<string, any>();
34
+ for (const line of lines) {
35
+ if (line.uuid) uuidMap.set(line.uuid, line);
36
+ }
37
+
38
+ // Filter to lines that carry message content (skip progress, snapshots, etc.)
39
+ const SKIP_TYPES = new Set([
40
+ "progress",
41
+ "file-history-snapshot",
42
+ "system",
43
+ "last-prompt",
44
+ ]);
45
+ const entries = lines.filter(
46
+ (l) => !SKIP_TYPES.has(l.type) && l.message
47
+ );
48
+
49
+ // Claude Code splits a single logical turn into multiple chained JSONL entries:
50
+ //
51
+ // 1. Assistant turns: thinking → tool_use → text each parent the next
52
+ // (assistant→assistant parentage). Merge into one turn.
53
+ //
54
+ // 2. User tool_results for parallel tool calls: each result is a separate entry
55
+ // whose parentUuid points to a different entry in the same assistant chain.
56
+ // Merge into one user turn.
57
+
58
+ // For assistant entries: walk up until the parent is NOT an assistant entry.
59
+ function getAssistantRoot(entry: any): any {
60
+ let cur = entry;
61
+ while (cur.message?.role === "assistant") {
62
+ const parent = uuidMap.get(cur.parentUuid);
63
+ if (!parent?.message || parent.message.role !== "assistant") break;
64
+ cur = parent;
65
+ }
66
+ return cur;
67
+ }
68
+
69
+ // For a user entry: return the uuid of the assistant group root that it responds to
70
+ // (null if its parent is not an assistant).
71
+ function parentAssistantRootUuid(entry: any): string | null {
72
+ const parent = uuidMap.get(entry.parentUuid);
73
+ if (!parent?.message || parent.message.role !== "assistant") return null;
74
+ return getAssistantRoot(parent).uuid ?? null;
75
+ }
76
+
77
+ // Group entries in file order.
78
+ // Key: root uuid for assistant groups; entry uuid for user groups.
79
+ const groupMap = new Map<string, any[]>();
80
+ const rootOrder: string[] = [];
81
+
82
+ for (const entry of entries) {
83
+ const role = entry.message?.role;
84
+
85
+ if (role === "assistant") {
86
+ const root = getAssistantRoot(entry);
87
+ const rootUuid = root.uuid ?? randomUUID();
88
+ if (!groupMap.has(rootUuid)) {
89
+ groupMap.set(rootUuid, [root]);
90
+ rootOrder.push(rootUuid);
91
+ } else if (entry !== root) {
92
+ groupMap.get(rootUuid)!.push(entry);
93
+ }
94
+ } else {
95
+ // User entry: merge with the previous user group if they share the same
96
+ // parent assistant root (i.e., parallel tool results for the same tool call batch).
97
+ const myParentRoot = parentAssistantRootUuid(entry);
98
+ const lastKey = rootOrder[rootOrder.length - 1];
99
+ const lastGroup = lastKey ? groupMap.get(lastKey) : undefined;
100
+ const lastRole = lastGroup?.[0]?.message?.role;
101
+
102
+ if (
103
+ myParentRoot &&
104
+ lastRole === "user" &&
105
+ parentAssistantRootUuid(lastGroup![0]) === myParentRoot
106
+ ) {
107
+ lastGroup!.push(entry);
108
+ } else {
109
+ const groupKey = entry.uuid ?? randomUUID();
110
+ groupMap.set(groupKey, [entry]);
111
+ rootOrder.push(groupKey);
112
+ }
113
+ }
114
+ }
115
+
116
+ const turns: Turn[] = [];
32
117
  let totalInputTokens = 0;
33
118
  let totalOutputTokens = 0;
34
119
  let totalCacheReadTokens = 0;
35
120
  let gitBranch: string | null = null;
36
121
  let cwdHash: string | null = null;
37
122
 
38
- for (const line of lines) {
39
- if (!line.message) continue;
40
- const { role, content, usage, model } = line.message;
41
-
42
- const tokenUsage: TokenUsage | null = usage
43
- ? {
44
- input_tokens: usage.input_tokens ?? 0,
45
- output_tokens: usage.output_tokens ?? 0,
46
- cache_read_input_tokens: usage.cache_read_input_tokens ?? null,
47
- cache_creation_input_tokens:
48
- usage.cache_creation_input_tokens ?? null,
49
- reasoning_tokens: null,
50
- }
51
- : null;
52
- if (tokenUsage) {
53
- totalInputTokens += tokenUsage.input_tokens;
54
- totalOutputTokens += tokenUsage.output_tokens;
55
- totalCacheReadTokens += tokenUsage.cache_read_input_tokens ?? 0;
56
- }
123
+ for (const rootUuid of rootOrder) {
124
+ const group = groupMap.get(rootUuid)!;
125
+ const root = group[0];
57
126
 
58
- if (line.gitBranch) gitBranch = line.gitBranch;
59
- if (line.cwd && !cwdHash) cwdHash = hashString(line.cwd);
127
+ if (root.gitBranch) gitBranch = root.gitBranch;
128
+ if (root.cwd && !cwdHash) cwdHash = hashString(root.cwd);
60
129
 
130
+ const role: "user" | "assistant" =
131
+ root.message.role === "assistant" ? "assistant" : "user";
132
+
133
+ // Collect content blocks from all entries in the group
61
134
  const contentBlocks: ContentBlock[] = [];
62
- const rawContent = Array.isArray(content)
63
- ? content
64
- : typeof content === "string"
65
- ? [{ type: "text", text: content }]
66
- : [];
67
-
68
- for (const block of rawContent) {
69
- if (!block || !block.type) continue;
70
- if (block.type === "file-history-snapshot") continue;
71
- if (block.type === "text") {
72
- contentBlocks.push({ type: "text", text: block.text ?? "" });
73
- } else if (block.type === "thinking") {
74
- contentBlocks.push({
75
- type: "thinking",
76
- text: block.thinking ?? block.text ?? "",
77
- });
78
- } else if (block.type === "tool_use") {
79
- contentBlocks.push({
80
- type: "tool_use",
81
- tool_call_id: block.id ?? randomUUID(),
82
- tool_name: block.name ?? "",
83
- tool_input: block.input ?? {},
84
- });
85
- } else if (block.type === "tool_result") {
86
- const resultContent = Array.isArray(block.content)
87
- ? block.content.map((c: any) => c.text ?? "").join("\n")
88
- : block.content ?? null;
89
- contentBlocks.push({
90
- type: "tool_result",
91
- tool_call_id: block.tool_use_id ?? "",
92
- is_error: block.is_error ?? false,
93
- result_content: resultContent,
94
- exit_code: null,
95
- });
135
+ for (const entry of group) {
136
+ const raw = entry.message.content;
137
+ const rawContent = Array.isArray(raw)
138
+ ? raw
139
+ : typeof raw === "string"
140
+ ? [{ type: "text", text: raw }]
141
+ : [];
142
+
143
+ for (const block of rawContent) {
144
+ if (!block || !block.type) continue;
145
+ if (block.type === "file-history-snapshot") continue;
146
+ if (block.type === "text") {
147
+ contentBlocks.push({ type: "text", text: block.text ?? "" });
148
+ } else if (block.type === "thinking") {
149
+ contentBlocks.push({
150
+ type: "thinking",
151
+ text: block.thinking ?? block.text ?? "",
152
+ });
153
+ } else if (block.type === "tool_use") {
154
+ contentBlocks.push({
155
+ type: "tool_use",
156
+ tool_call_id: block.id ?? randomUUID(),
157
+ tool_name: block.name ?? "",
158
+ tool_input: block.input ?? {},
159
+ });
160
+ } else if (block.type === "tool_result") {
161
+ const resultContent = Array.isArray(block.content)
162
+ ? block.content.map((c: any) => c.text ?? "").join("\n")
163
+ : block.content ?? null;
164
+ contentBlocks.push({
165
+ type: "tool_result",
166
+ tool_call_id: block.tool_use_id ?? "",
167
+ is_error: block.is_error ?? false,
168
+ result_content: resultContent,
169
+ exit_code: null,
170
+ });
171
+ }
96
172
  }
97
173
  }
98
174
 
99
175
  if (contentBlocks.length === 0) continue;
100
176
 
177
+ // Token accounting for assistant turns:
178
+ // - input/cache tokens come from the root entry only (same API call repeated on each chain entry)
179
+ // - output tokens must be summed across all chain entries (each has its own generated content)
180
+ let tokenUsage: TokenUsage | null = null;
181
+ if (role === "assistant") {
182
+ const rootUsage = root.message.usage;
183
+ if (rootUsage) {
184
+ const inputTokens = rootUsage.input_tokens ?? 0;
185
+ const cacheCreate = rootUsage.cache_creation_input_tokens ?? 0;
186
+ const cacheRead = rootUsage.cache_read_input_tokens ?? 0;
187
+ // Sum output tokens across all chain entries
188
+ const outputTokens = group.reduce((sum: number, entry: any) => {
189
+ return sum + (entry.message.usage?.output_tokens ?? 0);
190
+ }, 0);
191
+
192
+ tokenUsage = {
193
+ input_tokens: inputTokens + cacheCreate + cacheRead,
194
+ output_tokens: outputTokens,
195
+ cache_read_input_tokens: cacheRead,
196
+ cache_creation_input_tokens: cacheCreate,
197
+ reasoning_tokens: null,
198
+ };
199
+
200
+ totalInputTokens += inputTokens + cacheCreate + cacheRead;
201
+ totalOutputTokens += outputTokens;
202
+ totalCacheReadTokens += cacheRead;
203
+ }
204
+ }
205
+
101
206
  turns.push({
102
- turn_id: line.uuid ?? randomUUID(),
103
- parent_turn_id: line.parentUuid ?? null,
104
- role: role === "assistant" ? "assistant" : "user",
105
- timestamp: line.timestamp ?? null,
207
+ turn_id: root.uuid ?? randomUUID(),
208
+ parent_turn_id: root.parentUuid ?? null,
209
+ role,
210
+ timestamp: root.timestamp ?? null,
106
211
  content: contentBlocks,
107
- model: model ?? null,
212
+ model: root.message.model ?? null,
108
213
  usage: tokenUsage,
109
214
  source_metadata: {
110
- uuid: line.uuid,
111
- parentUuid: line.parentUuid,
112
- gitBranch: line.gitBranch,
215
+ uuid: root.uuid,
216
+ parentUuid: root.parentUuid,
217
+ gitBranch: root.gitBranch,
113
218
  },
114
219
  });
115
220
  }
116
221
 
117
- const startedAt = turns[0]?.timestamp ?? new Date().toISOString();
118
- const endedAt = turns[turns.length - 1]?.timestamp ?? new Date().toISOString();
222
+ const turnActors = deriveTurnActors(turns);
223
+ const normalizedTurns = turns.map((turn) => ({
224
+ ...turn,
225
+ actor: turnActors[turn.turn_id],
226
+ }));
227
+
228
+ const startedAt = normalizedTurns[0]?.timestamp ?? new Date().toISOString();
229
+ const endedAt = normalizedTurns[normalizedTurns.length - 1]?.timestamp ?? new Date().toISOString();
119
230
 
120
- const allBlocks = turns.flatMap((t) => t.content);
231
+ const allBlocks = normalizedTurns.flatMap((t) => t.content);
121
232
  const toolCallCount = allBlocks.filter((b) => b.type === "tool_use").length;
122
233
  const hasFileChanges = allBlocks.some(
123
234
  (b) =>
@@ -146,8 +257,8 @@ export async function extractClaudeCode(
146
257
  working_language: null,
147
258
  started_at: startedAt,
148
259
  ended_at: endedAt,
149
- turns,
150
- turn_count: turns.length,
260
+ turns: normalizedTurns,
261
+ turn_count: normalizedTurns.length,
151
262
  tool_call_count: toolCallCount,
152
263
  has_tool_calls: toolCallCount > 0,
153
264
  has_thinking_blocks: hasThinking,