@tracemarketplace/shared 0.0.8 → 0.0.10

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 (71) hide show
  1. package/dist/extractor-claude-code.test.js +63 -0
  2. package/dist/extractor-claude-code.test.js.map +1 -1
  3. package/dist/extractor-codex.test.d.ts +2 -0
  4. package/dist/extractor-codex.test.d.ts.map +1 -0
  5. package/dist/extractor-codex.test.js +212 -0
  6. package/dist/extractor-codex.test.js.map +1 -0
  7. package/dist/extractor-cursor.test.d.ts +2 -0
  8. package/dist/extractor-cursor.test.d.ts.map +1 -0
  9. package/dist/extractor-cursor.test.js +120 -0
  10. package/dist/extractor-cursor.test.js.map +1 -0
  11. package/dist/extractors/claude-code.d.ts.map +1 -1
  12. package/dist/extractors/claude-code.js +11 -5
  13. package/dist/extractors/claude-code.js.map +1 -1
  14. package/dist/extractors/codex.d.ts.map +1 -1
  15. package/dist/extractors/codex.js +63 -35
  16. package/dist/extractors/codex.js.map +1 -1
  17. package/dist/extractors/common.d.ts +14 -0
  18. package/dist/extractors/common.d.ts.map +1 -0
  19. package/dist/extractors/common.js +100 -0
  20. package/dist/extractors/common.js.map +1 -0
  21. package/dist/extractors/cursor.d.ts.map +1 -1
  22. package/dist/extractors/cursor.js +205 -45
  23. package/dist/extractors/cursor.js.map +1 -1
  24. package/dist/hash.d.ts.map +1 -1
  25. package/dist/hash.js +35 -2
  26. package/dist/hash.js.map +1 -1
  27. package/dist/hash.test.js +29 -2
  28. package/dist/hash.test.js.map +1 -1
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/redact.d.ts +12 -0
  34. package/dist/redact.d.ts.map +1 -1
  35. package/dist/redact.js +120 -38
  36. package/dist/redact.js.map +1 -1
  37. package/dist/redact.test.d.ts +2 -0
  38. package/dist/redact.test.d.ts.map +1 -0
  39. package/dist/redact.test.js +96 -0
  40. package/dist/redact.test.js.map +1 -0
  41. package/dist/turn-actors.d.ts +4 -0
  42. package/dist/turn-actors.d.ts.map +1 -0
  43. package/dist/turn-actors.js +57 -0
  44. package/dist/turn-actors.js.map +1 -0
  45. package/dist/turn-actors.test.d.ts +2 -0
  46. package/dist/turn-actors.test.d.ts.map +1 -0
  47. package/dist/turn-actors.test.js +65 -0
  48. package/dist/turn-actors.test.js.map +1 -0
  49. package/dist/types.d.ts +5 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/validators.d.ts +24 -0
  52. package/dist/validators.d.ts.map +1 -1
  53. package/dist/validators.js +3 -0
  54. package/dist/validators.js.map +1 -1
  55. package/package.json +5 -2
  56. package/src/extractor-claude-code.test.ts +69 -0
  57. package/src/extractor-codex.test.ts +225 -0
  58. package/src/extractor-cursor.test.ts +141 -0
  59. package/src/extractors/claude-code.ts +12 -5
  60. package/src/extractors/codex.ts +69 -38
  61. package/src/extractors/common.ts +139 -0
  62. package/src/extractors/cursor.ts +294 -52
  63. package/src/hash.test.ts +31 -2
  64. package/src/hash.ts +38 -3
  65. package/src/index.ts +1 -0
  66. package/src/redact.test.ts +100 -0
  67. package/src/redact.ts +175 -58
  68. package/src/turn-actors.test.ts +71 -0
  69. package/src/turn-actors.ts +73 -0
  70. package/src/types.ts +6 -0
  71. package/src/validators.ts +3 -0
@@ -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,
@@ -218,10 +219,16 @@ export async function extractClaudeCode(
218
219
  });
219
220
  }
220
221
 
221
- const startedAt = turns[0]?.timestamp ?? new Date().toISOString();
222
- 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
+ }));
223
227
 
224
- const allBlocks = turns.flatMap((t) => t.content);
228
+ const startedAt = normalizedTurns[0]?.timestamp ?? new Date().toISOString();
229
+ const endedAt = normalizedTurns[normalizedTurns.length - 1]?.timestamp ?? new Date().toISOString();
230
+
231
+ const allBlocks = normalizedTurns.flatMap((t) => t.content);
225
232
  const toolCallCount = allBlocks.filter((b) => b.type === "tool_use").length;
226
233
  const hasFileChanges = allBlocks.some(
227
234
  (b) =>
@@ -250,8 +257,8 @@ export async function extractClaudeCode(
250
257
  working_language: null,
251
258
  started_at: startedAt,
252
259
  ended_at: endedAt,
253
- turns,
254
- turn_count: turns.length,
260
+ turns: normalizedTurns,
261
+ turn_count: normalizedTurns.length,
255
262
  tool_call_count: toolCallCount,
256
263
  has_tool_calls: toolCallCount > 0,
257
264
  has_thinking_blocks: hasThinking,
@@ -5,6 +5,22 @@ import type {
5
5
  Turn,
6
6
  ContentBlock,
7
7
  } from "../types.js";
8
+ import {
9
+ collectTraceMetrics,
10
+ createPassiveEnvState,
11
+ extractTextFragments,
12
+ pushUniqueTextBlock,
13
+ } from "./common.js";
14
+
15
+ function toNumber(value: unknown): number | null {
16
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
17
+ }
18
+
19
+ function extractExitCode(output: string | undefined): number | null {
20
+ if (!output) return null;
21
+ const match = output.match(/Process exited with code (\d+)/);
22
+ return match ? Number.parseInt(match[1], 10) : null;
23
+ }
8
24
 
9
25
  export async function extractCodex(
10
26
  rolloutFileBuffer: Buffer,
@@ -29,6 +45,10 @@ export async function extractCodex(
29
45
  let currentBlocks: ContentBlock[] = [];
30
46
  let taskTimestamp: string | null = null;
31
47
  let currentModel: string | null = null;
48
+ let currentTaskLastTimestamp: string | null = null;
49
+ let totalInputTokens: number | null = null;
50
+ let totalOutputTokens: number | null = null;
51
+ let totalCacheReadTokens: number | null = null;
32
52
 
33
53
  for (const event of events) {
34
54
  const type = event["type"] as string;
@@ -41,6 +61,7 @@ export async function extractCodex(
41
61
  currentUserMessage = null;
42
62
  currentBlocks = [];
43
63
  taskTimestamp = timestamp;
64
+ currentTaskLastTimestamp = timestamp;
44
65
  continue;
45
66
  }
46
67
 
@@ -61,10 +82,7 @@ export async function extractCodex(
61
82
 
62
83
  const lastMsg = payload["last_agent_message"] as string | undefined;
63
84
  if (lastMsg) {
64
- const alreadyCaptured = currentBlocks.some(
65
- b => b.type === "text" && (b as { type: "text"; text: string }).text === lastMsg
66
- );
67
- if (!alreadyCaptured) currentBlocks.push({ type: "text", text: lastMsg });
85
+ pushUniqueTextBlock(currentBlocks, "text", lastMsg);
68
86
  }
69
87
 
70
88
  if (currentBlocks.length > 0) {
@@ -72,7 +90,7 @@ export async function extractCodex(
72
90
  turn_id: currentTurnId ?? randomUUID(),
73
91
  parent_turn_id: currentUserMessage ? "user_" + currentTurnId : null,
74
92
  role: "assistant",
75
- timestamp: taskTimestamp,
93
+ timestamp: currentTaskLastTimestamp ?? timestamp ?? taskTimestamp,
76
94
  content: currentBlocks,
77
95
  model: currentModel,
78
96
  usage: null,
@@ -86,11 +104,14 @@ export async function extractCodex(
86
104
  currentBlocks = [];
87
105
  taskTimestamp = null;
88
106
  currentModel = null;
107
+ currentTaskLastTimestamp = null;
89
108
  continue;
90
109
  }
91
110
 
92
111
  if (!inTask) continue;
93
112
 
113
+ if (timestamp) currentTaskLastTimestamp = timestamp;
114
+
94
115
  if (type === "turn_context") {
95
116
  currentModel = (payload["model"] as string | undefined) ?? null;
96
117
  continue;
@@ -102,14 +123,42 @@ export async function extractCodex(
102
123
  currentUserMessage = (payload["message"] as string | undefined) ?? "";
103
124
  break;
104
125
  case "agent_reasoning":
105
- if (payload["text"]) currentBlocks.push({ type: "thinking", text: payload["text"] as string });
126
+ pushUniqueTextBlock(currentBlocks, "thinking", payload["text"] as string | undefined);
106
127
  break;
107
128
  case "agent_message":
108
- if (payload["message"]) currentBlocks.push({ type: "text", text: payload["message"] as string });
129
+ pushUniqueTextBlock(currentBlocks, "text", payload["message"] as string | undefined);
130
+ break;
131
+ case "token_count": {
132
+ const info = (payload["info"] ?? {}) as Record<string, unknown>;
133
+ const usage = (info["total_token_usage"] ?? {}) as Record<string, unknown>;
134
+ totalInputTokens = toNumber(usage["input_tokens"]) ?? totalInputTokens;
135
+ totalOutputTokens = toNumber(usage["output_tokens"]) ?? totalOutputTokens;
136
+ totalCacheReadTokens =
137
+ toNumber(usage["cached_input_tokens"]) ?? totalCacheReadTokens;
109
138
  break;
139
+ }
110
140
  }
111
141
  } else if (type === "response_item") {
112
142
  switch (payload["type"] as string) {
143
+ case "message": {
144
+ const role = payload["role"] as string | undefined;
145
+ const texts = extractTextFragments(payload["content"]);
146
+ if (role === "assistant") {
147
+ for (const text of texts) {
148
+ pushUniqueTextBlock(currentBlocks, "text", text);
149
+ }
150
+ } else if (role === "user" && !currentUserMessage) {
151
+ currentUserMessage = texts.join("\n\n") || currentUserMessage;
152
+ }
153
+ break;
154
+ }
155
+ case "reasoning": {
156
+ const reasoningText =
157
+ extractTextFragments(payload["content"]).join("\n\n") ||
158
+ extractTextFragments(payload["summary"]).join("\n\n");
159
+ pushUniqueTextBlock(currentBlocks, "thinking", reasoningText);
160
+ break;
161
+ }
113
162
  case "function_call": {
114
163
  const callId = (payload["call_id"] as string | undefined) ?? randomUUID();
115
164
  let toolInput: Record<string, unknown> = {};
@@ -126,15 +175,13 @@ export async function extractCodex(
126
175
  }
127
176
  case "function_call_output": {
128
177
  const output = payload["output"] as string | undefined;
129
- const isError = typeof output === "string"
130
- && output.includes("Process exited with code")
131
- && !output.includes("code 0");
178
+ const exitCode = extractExitCode(output);
132
179
  currentBlocks.push({
133
180
  type: "tool_result",
134
181
  tool_call_id: (payload["call_id"] as string | undefined) ?? "",
135
- is_error: isError,
182
+ is_error: exitCode !== null ? exitCode !== 0 : false,
136
183
  result_content: output ?? null,
137
- exit_code: null,
184
+ exit_code: exitCode,
138
185
  });
139
186
  break;
140
187
  }
@@ -152,15 +199,7 @@ export async function extractCodex(
152
199
  }
153
200
  }
154
201
 
155
- const allBlocks = turns.flatMap(t => t.content);
156
- const toolCallCount = allBlocks.filter(b => b.type === "tool_use").length;
157
- const hasFileChanges = allBlocks.some(
158
- b => b.type === "tool_use" && ["write_file", "file_change", "create_file"].includes((b as { type: "tool_use"; tool_name: string }).tool_name)
159
- );
160
- const hasShellCommands = allBlocks.some(
161
- b => b.type === "tool_use" && ["exec_command", "bash", "shell"].includes((b as { type: "tool_use"; tool_name: string }).tool_name)
162
- );
163
- const hasThinking = allBlocks.some(b => b.type === "thinking");
202
+ const metrics = collectTraceMetrics(turns);
164
203
  const startedAt = (sessionMetaPayload["timestamp"] as string | undefined) ?? events[0]?.["timestamp"] as string ?? new Date().toISOString();
165
204
  const endedAt = events[events.length - 1]?.["timestamp"] as string ?? new Date().toISOString();
166
205
 
@@ -179,24 +218,16 @@ export async function extractCodex(
179
218
  ended_at: endedAt,
180
219
  turns,
181
220
  turn_count: turns.length,
182
- tool_call_count: toolCallCount,
183
- has_tool_calls: toolCallCount > 0,
184
- has_thinking_blocks: hasThinking,
185
- has_file_changes: hasFileChanges,
186
- has_shell_commands: hasShellCommands,
187
- total_input_tokens: null,
188
- total_output_tokens: null,
189
- total_cache_read_tokens: null,
221
+ tool_call_count: metrics.toolCallCount,
222
+ has_tool_calls: metrics.toolCallCount > 0,
223
+ has_thinking_blocks: metrics.hasThinkingBlocks,
224
+ has_file_changes: metrics.hasFileChanges,
225
+ has_shell_commands: metrics.hasShellCommands,
226
+ total_input_tokens: totalInputTokens,
227
+ total_output_tokens: totalOutputTokens,
228
+ total_cache_read_tokens: totalCacheReadTokens,
190
229
  content_fidelity: "full",
191
- env_state: {
192
- git_branch: null,
193
- inferred_file_tree: null,
194
- inferred_changed_files: null,
195
- inferred_error_files: null,
196
- shell_exit_codes: null,
197
- open_files_in_editor: null,
198
- extraction_method: "passive",
199
- },
230
+ env_state: createPassiveEnvState(),
200
231
  score: null,
201
232
  raw_r2_key: "",
202
233
  normalized_r2_key: "",
@@ -0,0 +1,139 @@
1
+ import type { ContentBlock, EnvState, Turn } from "../types.js";
2
+
3
+ const SHELL_TOOL_NAMES = ["exec_command", "bash", "shell", "write_stdin"];
4
+ const FILE_MUTATION_TOOL_NAMES = [
5
+ "apply_patch",
6
+ "write_file",
7
+ "create_file",
8
+ "delete_file",
9
+ "rename_file",
10
+ "move_file",
11
+ "file_change",
12
+ "edit",
13
+ "edit_file",
14
+ "multiedit",
15
+ "write",
16
+ ];
17
+
18
+ function normalizeToolName(toolName: string): string {
19
+ return toolName.trim().toLowerCase();
20
+ }
21
+
22
+ function toolNameMatches(toolName: string, candidate: string): boolean {
23
+ const normalized = normalizeToolName(toolName);
24
+ return normalized === candidate || normalized.endsWith(`.${candidate}`);
25
+ }
26
+
27
+ export function isShellToolName(toolName: string): boolean {
28
+ return SHELL_TOOL_NAMES.some((candidate) => toolNameMatches(toolName, candidate));
29
+ }
30
+
31
+ export function isFileMutationTool(
32
+ toolName: string,
33
+ toolInput: Record<string, unknown>,
34
+ ): boolean {
35
+ if (
36
+ FILE_MUTATION_TOOL_NAMES.some((candidate) => toolNameMatches(toolName, candidate))
37
+ ) {
38
+ return true;
39
+ }
40
+
41
+ if (!isShellToolName(toolName)) return false;
42
+
43
+ const command =
44
+ typeof toolInput.cmd === "string"
45
+ ? toolInput.cmd
46
+ : typeof toolInput.command === "string"
47
+ ? toolInput.command
48
+ : null;
49
+
50
+ return command !== null && /\bapply_patch\b|\bsed\s+-i\b|\bperl\s+-pi\b/.test(command);
51
+ }
52
+
53
+ export function collectTraceMetrics(turns: Turn[]) {
54
+ const allBlocks = turns.flatMap((turn) => turn.content);
55
+ const toolUses = allBlocks.filter(
56
+ (block): block is Extract<ContentBlock, { type: "tool_use" }> =>
57
+ block.type === "tool_use",
58
+ );
59
+
60
+ return {
61
+ toolCallCount: toolUses.length,
62
+ hasFileChanges: toolUses.some((block) =>
63
+ isFileMutationTool(block.tool_name, block.tool_input),
64
+ ),
65
+ hasShellCommands: toolUses.some((block) => isShellToolName(block.tool_name)),
66
+ hasThinkingBlocks: allBlocks.some((block) => block.type === "thinking"),
67
+ };
68
+ }
69
+
70
+ export function createPassiveEnvState(
71
+ overrides: Partial<EnvState> = {},
72
+ ): EnvState {
73
+ return {
74
+ git_branch: null,
75
+ inferred_file_tree: null,
76
+ inferred_changed_files: null,
77
+ inferred_error_files: null,
78
+ shell_exit_codes: null,
79
+ open_files_in_editor: null,
80
+ extraction_method: "passive",
81
+ ...overrides,
82
+ };
83
+ }
84
+
85
+ export function extractTextFragments(value: unknown): string[] {
86
+ if (typeof value === "string") {
87
+ return value.trim() ? [value] : [];
88
+ }
89
+
90
+ if (Array.isArray(value)) {
91
+ return value.flatMap((item) => extractTextFragments(item));
92
+ }
93
+
94
+ if (!value || typeof value !== "object") {
95
+ return [];
96
+ }
97
+
98
+ const record = value as Record<string, unknown>;
99
+
100
+ if (typeof record.text === "string" && record.text.trim()) {
101
+ return [record.text];
102
+ }
103
+
104
+ const nested: string[] = [];
105
+ for (const key of ["content", "summary", "thinking", "message"]) {
106
+ if (key in record) {
107
+ nested.push(...extractTextFragments(record[key]));
108
+ }
109
+ }
110
+
111
+ return nested;
112
+ }
113
+
114
+ export function pushUniqueTextBlock(
115
+ blocks: ContentBlock[],
116
+ type: "text" | "thinking",
117
+ text: string | null | undefined,
118
+ ): void {
119
+ const normalized = text?.trim();
120
+ if (!normalized) return;
121
+
122
+ const exists = blocks.some(
123
+ (block) => block.type === type && block.text === normalized,
124
+ );
125
+ if (!exists) {
126
+ blocks.push({ type, text: normalized });
127
+ }
128
+ }
129
+
130
+ export function normalizeTimestamp(value: unknown): string | null {
131
+ if (typeof value === "string" && value.trim()) return value;
132
+
133
+ if (typeof value === "number" && Number.isFinite(value)) {
134
+ const millis = value > 1_000_000_000_000 ? value : value * 1000;
135
+ return new Date(millis).toISOString();
136
+ }
137
+
138
+ return null;
139
+ }