@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.
- package/dist/chunker.d.ts.map +1 -1
- package/dist/chunker.js +14 -2
- package/dist/chunker.js.map +1 -1
- package/dist/extractor-claude-code.test.d.ts +2 -0
- package/dist/extractor-claude-code.test.d.ts.map +1 -0
- package/dist/extractor-claude-code.test.js +290 -0
- package/dist/extractor-claude-code.test.js.map +1 -0
- package/dist/extractor-codex.test.d.ts +2 -0
- package/dist/extractor-codex.test.d.ts.map +1 -0
- package/dist/extractor-codex.test.js +212 -0
- package/dist/extractor-codex.test.js.map +1 -0
- package/dist/extractor-cursor.test.d.ts +2 -0
- package/dist/extractor-cursor.test.d.ts.map +1 -0
- package/dist/extractor-cursor.test.js +120 -0
- package/dist/extractor-cursor.test.js.map +1 -0
- package/dist/extractors/claude-code.d.ts.map +1 -1
- package/dist/extractors/claude-code.js +172 -73
- package/dist/extractors/claude-code.js.map +1 -1
- package/dist/extractors/codex.d.ts.map +1 -1
- package/dist/extractors/codex.js +63 -35
- package/dist/extractors/codex.js.map +1 -1
- package/dist/extractors/common.d.ts +14 -0
- package/dist/extractors/common.d.ts.map +1 -0
- package/dist/extractors/common.js +100 -0
- package/dist/extractors/common.js.map +1 -0
- package/dist/extractors/cursor.d.ts.map +1 -1
- package/dist/extractors/cursor.js +205 -45
- package/dist/extractors/cursor.js.map +1 -1
- package/dist/hash.d.ts.map +1 -1
- package/dist/hash.js +35 -2
- package/dist/hash.js.map +1 -1
- package/dist/hash.test.js +29 -2
- package/dist/hash.test.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/redact.d.ts +12 -0
- package/dist/redact.d.ts.map +1 -1
- package/dist/redact.js +120 -38
- package/dist/redact.js.map +1 -1
- package/dist/redact.test.d.ts +2 -0
- package/dist/redact.test.d.ts.map +1 -0
- package/dist/redact.test.js +96 -0
- package/dist/redact.test.js.map +1 -0
- package/dist/turn-actors.d.ts +3 -0
- package/dist/turn-actors.d.ts.map +1 -0
- package/dist/turn-actors.js +57 -0
- package/dist/turn-actors.js.map +1 -0
- package/dist/turn-actors.test.d.ts +2 -0
- package/dist/turn-actors.test.d.ts.map +1 -0
- package/dist/turn-actors.test.js +65 -0
- package/dist/turn-actors.test.js.map +1 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +4 -0
- package/dist/utils.js.map +1 -1
- package/dist/validators.d.ts +24 -0
- package/dist/validators.d.ts.map +1 -1
- package/dist/validators.js +3 -0
- package/dist/validators.js.map +1 -1
- package/package.json +5 -1
- package/src/chunker.ts +17 -2
- package/src/extractor-claude-code.test.ts +326 -0
- package/src/extractor-codex.test.ts +225 -0
- package/src/extractor-cursor.test.ts +141 -0
- package/src/extractors/claude-code.ts +180 -69
- package/src/extractors/codex.ts +69 -38
- package/src/extractors/common.ts +139 -0
- package/src/extractors/cursor.ts +294 -52
- package/src/hash.test.ts +31 -2
- package/src/hash.ts +38 -3
- package/src/index.ts +1 -0
- package/src/redact.test.ts +100 -0
- package/src/redact.ts +175 -58
- package/src/turn-actors.test.ts +71 -0
- package/src/turn-actors.ts +71 -0
- package/src/types.ts +6 -0
- package/src/utils.ts +3 -1
- 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
|
|
39
|
-
|
|
40
|
-
const
|
|
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 (
|
|
59
|
-
if (
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
?
|
|
66
|
-
:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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:
|
|
103
|
-
parent_turn_id:
|
|
104
|
-
role
|
|
105
|
-
timestamp:
|
|
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:
|
|
111
|
-
parentUuid:
|
|
112
|
-
gitBranch:
|
|
215
|
+
uuid: root.uuid,
|
|
216
|
+
parentUuid: root.parentUuid,
|
|
217
|
+
gitBranch: root.gitBranch,
|
|
113
218
|
},
|
|
114
219
|
});
|
|
115
220
|
}
|
|
116
221
|
|
|
117
|
-
const
|
|
118
|
-
const
|
|
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 =
|
|
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:
|
|
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,
|