cc-flight 0.4.1

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.
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect width="64" height="64" rx="12" fill="#111827"/>
3
+ <path d="M15 19h34v6H15zM15 30h24v6H15zM15 41h34v6H15z" fill="#f8fafc"/>
4
+ <path d="M45 29l7 5-7 5z" fill="#38bdf8"/>
5
+ </svg>
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
7
+ <title>SuperView</title>
8
+ <script type="module" crossorigin src="/assets/index-D572SQ_N.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-Cu10rtbj.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "cc-flight",
3
+ "version": "0.4.1",
4
+ "description": "Flight recorder for Claude Code and coding agents — timeline dashboard with replayable agent-run drill-downs.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/TheAceTeam/SuperView.git"
10
+ },
11
+ "keywords": [
12
+ "cc",
13
+ "claude",
14
+ "codex",
15
+ "claude-code",
16
+ "ai-agent",
17
+ "observability",
18
+ "llm-tracing",
19
+ "agent-dashboard",
20
+ "developer-tools"
21
+ ],
22
+ "bin": {
23
+ "cc-flight": "runtime-node/cli-start.js",
24
+ "ccflight": "runtime-node/cli-start.js"
25
+ },
26
+ "files": [
27
+ "runtime-node/",
28
+ "core/",
29
+ "storage/",
30
+ "dist/ui/"
31
+ ],
32
+ "scripts": {
33
+ "dev": "concurrently -k -n server,client -c orange,blue \"pnpm dev:server\" \"pnpm dev:client\"",
34
+ "dev:client": "vite --host 127.0.0.1 --port 5173",
35
+ "dev:server": "tsx watch runtime-node/dev-server.ts",
36
+ "start": "node runtime-node/cli-start.js",
37
+ "build": "tsc --noEmit && vite build",
38
+ "preview": "vite preview --host 127.0.0.1 --port 4173",
39
+ "test": "vitest run",
40
+ "test:e2e": "playwright test",
41
+ "typecheck": "tsc --noEmit",
42
+ "ingest": "tsx runtime-node/cli-ingest.ts",
43
+ "publish:dual": "node scripts/publish-dual-packages.mjs",
44
+ "publish:dual:live": "node scripts/publish-dual-packages.mjs --publish"
45
+ },
46
+ "dependencies": {
47
+ "@vitejs/plugin-react": "^5.1.1",
48
+ "better-sqlite3": "^12.11.1",
49
+ "chokidar": "^4.0.3",
50
+ "concurrently": "^9.2.1",
51
+ "date-fns": "^4.1.0",
52
+ "express": "^5.2.1",
53
+ "fast-glob": "^3.3.3",
54
+ "lucide-react": "^0.561.0",
55
+ "react": "^19.2.3",
56
+ "react-dom": "^19.2.3",
57
+ "tsx": "^4.21.0",
58
+ "zod": "^4.2.1"
59
+ },
60
+ "devDependencies": {
61
+ "@playwright/test": "^1.57.0",
62
+ "@testing-library/jest-dom": "^6.9.1",
63
+ "@testing-library/react": "^16.3.1",
64
+ "@types/better-sqlite3": "^7.6.13",
65
+ "@types/express": "^5.0.6",
66
+ "@types/node": "^25.0.3",
67
+ "@types/react": "^19.2.7",
68
+ "@types/react-dom": "^19.2.3",
69
+ "@types/supertest": "^7.2.0",
70
+ "jsdom": "^27.3.0",
71
+ "supertest": "^7.2.2",
72
+ "typescript": "^5.9.3",
73
+ "vite": "^7.3.0",
74
+ "vitest": "^4.0.16"
75
+ }
76
+ }
@@ -0,0 +1,215 @@
1
+ import fg from "fast-glob";
2
+ import path from "node:path";
3
+ import { readFile } from "node:fs/promises";
4
+ import { AgentLogAdapter, NormalizedBundle, ParsedAgentEvent, TokenUsage } from "../../core/types";
5
+ import { normalizeCodexLines } from "../../core/normalizer";
6
+ import { resolveClaudeHome } from "../../storage/paths";
7
+ import { asRecord, fileSource, makeTokenUsage, parsedEvent, stringValue } from "./shared";
8
+
9
+ export const claudeCodeAdapter: AgentLogAdapter = {
10
+ provider: "claude-code",
11
+ async scan(config) {
12
+ const root = config?.root ?? resolveClaudeHome();
13
+ const projectsDir = path.join(root, "projects");
14
+ const files = await fg("**/*.jsonl", {
15
+ cwd: projectsDir,
16
+ absolute: true,
17
+ onlyFiles: true,
18
+ suppressErrors: true
19
+ });
20
+ return Promise.all(files.map((file) => fileSource("claude-code", file)));
21
+ },
22
+ async parseSource(source, options = {}) {
23
+ const content = await readFile(source.path, "utf8");
24
+ const externalSessionId = externalSessionIdFromClaudeJsonl(content, source.path);
25
+ const historyProject = externalSessionId ? await claudeHistoryProjectForSession(source.path, externalSessionId) : null;
26
+ return normalizeClaudeCodeJsonl(content, source.path, options.repoRoot, historyProject);
27
+ }
28
+ };
29
+
30
+ export function normalizeClaudeCodeJsonl(content: string, sourcePath: string, repoRoot?: string | null, projectCwdOverride?: string | null): NormalizedBundle | null {
31
+ const rawLines = content.split(/\r?\n/).filter((line) => line.trim().length > 0);
32
+ const records = rawLines.map((line) => JSON.parse(line) as unknown);
33
+ if (records.length === 0) return null;
34
+
35
+ // Metadata fields aren't guaranteed on the first record — headless `claude -p`
36
+ // sessions open with a queue-operation that carries no cwd/version. Scan for
37
+ // the first record that actually has each field instead of assuming line 0.
38
+ const firstWith = (key: string): string | undefined => {
39
+ for (const recordValue of records) {
40
+ const value = stringValue(asRecord(recordValue)[key]);
41
+ if (value) return value;
42
+ }
43
+ return undefined;
44
+ };
45
+
46
+ const externalSessionId = firstWith("sessionId") ?? path.basename(sourcePath, ".jsonl");
47
+ const startedAt = firstWith("timestamp") ?? new Date(0).toISOString();
48
+ const cwd = projectCwdOverride ?? firstWith("cwd") ?? process.cwd();
49
+ const version = firstWith("version");
50
+
51
+ const lines: ParsedAgentEvent[] = [
52
+ parsedEvent({
53
+ provider: "claude-code",
54
+ sourcePath,
55
+ lineNo: 1,
56
+ timestamp: startedAt,
57
+ type: "session_meta",
58
+ payload: {
59
+ id: externalSessionId,
60
+ timestamp: startedAt,
61
+ cwd,
62
+ cli_version: version,
63
+ model_provider: "Anthropic",
64
+ source: "claude-code"
65
+ },
66
+ raw: rawLines[0]
67
+ })
68
+ ];
69
+
70
+ let syntheticLine = 2;
71
+ for (const [index, recordValue] of records.entries()) {
72
+ const record = asRecord(recordValue);
73
+ const timestamp = stringValue(record.timestamp) ?? startedAt;
74
+ const type = stringValue(record.type);
75
+ const message = asRecord(record.message);
76
+ const role = stringValue(message.role) ?? type;
77
+ const content = message.content;
78
+ const usage = makeTokenUsage(message.usage ?? record.usage);
79
+
80
+ for (const eventPayload of claudePayloadsForRecord(content, role, usage)) {
81
+ lines.push(
82
+ parsedEvent({
83
+ provider: "claude-code",
84
+ sourcePath,
85
+ lineNo: syntheticLine,
86
+ timestamp,
87
+ type: "response_item",
88
+ payload: eventPayload,
89
+ raw: rawLines[index]
90
+ })
91
+ );
92
+ syntheticLine += 1;
93
+ }
94
+ }
95
+
96
+ return normalizeCodexLines(lines, {
97
+ repoRoot,
98
+ provider: "claude-code",
99
+ prefixSessionId: true,
100
+ modelProvider: "Anthropic",
101
+ source: "claude-code",
102
+ agentName: "Claude Code"
103
+ });
104
+ }
105
+
106
+ function externalSessionIdFromClaudeJsonl(content: string, sourcePath: string): string | null {
107
+ const firstLine = content.split(/\r?\n/).find((line) => line.trim().length > 0);
108
+ if (!firstLine) return path.basename(sourcePath, ".jsonl");
109
+ try {
110
+ return stringValue(asRecord(JSON.parse(firstLine)).sessionId) ?? path.basename(sourcePath, ".jsonl");
111
+ } catch {
112
+ return path.basename(sourcePath, ".jsonl");
113
+ }
114
+ }
115
+
116
+ async function claudeHistoryProjectForSession(sourcePath: string, externalSessionId: string): Promise<string | null> {
117
+ const claudeRoot = claudeRootFromSourcePath(sourcePath);
118
+ if (!claudeRoot) return null;
119
+ try {
120
+ const history = await readFile(path.join(claudeRoot, "history.jsonl"), "utf8");
121
+ let project: string | null = null;
122
+ for (const line of history.split(/\r?\n/)) {
123
+ if (!line.trim()) continue;
124
+ try {
125
+ const record = asRecord(JSON.parse(line));
126
+ if (stringValue(record.sessionId) === externalSessionId) {
127
+ project = stringValue(record.project) ?? project;
128
+ }
129
+ } catch {
130
+ continue;
131
+ }
132
+ }
133
+ return project;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ function claudeRootFromSourcePath(sourcePath: string): string | null {
140
+ const marker = `${path.sep}projects${path.sep}`;
141
+ const index = sourcePath.lastIndexOf(marker);
142
+ return index >= 0 ? sourcePath.slice(0, index) : null;
143
+ }
144
+
145
+ function claudePayloadsForRecord(content: unknown, role: string | null, usage: TokenUsage | null): Array<Record<string, unknown>> {
146
+ if (role === "user" && containsToolResult(content)) {
147
+ return toolResultsFromContent(content);
148
+ }
149
+ const payloads: Array<Record<string, unknown>> = [];
150
+ const text = textFromContent(content);
151
+ if (text) {
152
+ payloads.push({
153
+ type: "message",
154
+ role: role === "assistant" ? "assistant" : "user",
155
+ content: [{ type: role === "assistant" ? "output_text" : "input_text", text }],
156
+ ...(usage ? { usage } : {})
157
+ });
158
+ }
159
+ if (role === "assistant") {
160
+ payloads.push(...toolCallsFromContent(content));
161
+ }
162
+ return payloads;
163
+ }
164
+
165
+ function containsToolResult(content: unknown): boolean {
166
+ return contentItems(content).some((item) => stringValue(asRecord(item).type) === "tool_result");
167
+ }
168
+
169
+ function toolResultsFromContent(content: unknown): Array<Record<string, unknown>> {
170
+ return contentItems(content)
171
+ .filter((item) => stringValue(asRecord(item).type) === "tool_result")
172
+ .map((item) => {
173
+ const record = asRecord(item);
174
+ return {
175
+ type: "function_call_output",
176
+ call_id: stringValue(record.tool_use_id) ?? stringValue(record.id),
177
+ output: textFromContent(record.content)
178
+ };
179
+ });
180
+ }
181
+
182
+ function toolCallsFromContent(content: unknown): Array<Record<string, unknown>> {
183
+ return contentItems(content)
184
+ .filter((item) => stringValue(asRecord(item).type) === "tool_use")
185
+ .map((item) => {
186
+ const record = asRecord(item);
187
+ return {
188
+ type: "function_call",
189
+ call_id: stringValue(record.id),
190
+ name: stringValue(record.name) ?? "tool",
191
+ arguments: JSON.stringify(record.input ?? {})
192
+ };
193
+ });
194
+ }
195
+
196
+ function textFromContent(content: unknown): string {
197
+ if (typeof content === "string") return content;
198
+ return contentItems(content)
199
+ .map((item) => {
200
+ if (typeof item === "string") return item;
201
+ const record = asRecord(item);
202
+ const type = stringValue(record.type);
203
+ if (type === "text") return stringValue(record.text) ?? "";
204
+ if (type === "tool_result") return textFromContent(record.content);
205
+ return "";
206
+ })
207
+ .filter(Boolean)
208
+ .join("\n");
209
+ }
210
+
211
+ function contentItems(content: unknown): unknown[] {
212
+ if (Array.isArray(content)) return content;
213
+ if (content === null || content === undefined) return [];
214
+ return [content];
215
+ }
@@ -0,0 +1,24 @@
1
+ import { AgentLogAdapter } from "../../core/types";
2
+ import { parseCodexJsonlFile } from "../../core/parser";
3
+ import { normalizeCodexLines } from "../../core/normalizer";
4
+ import { resolveCodexHome } from "../../storage/paths";
5
+ import { scanRolloutFiles } from "../scanner";
6
+ import { fileSource } from "./shared";
7
+
8
+ export const codexAdapter: AgentLogAdapter = {
9
+ provider: "codex",
10
+ async scan(config) {
11
+ const files = await scanRolloutFiles(config?.root ?? resolveCodexHome());
12
+ return Promise.all(files.map((file) => fileSource("codex", file)));
13
+ },
14
+ async parseSource(source, options = {}) {
15
+ const lines = await parseCodexJsonlFile(source.path);
16
+ return normalizeCodexLines(lines, {
17
+ repoRoot: options.repoRoot,
18
+ provider: "codex",
19
+ prefixSessionId: true,
20
+ modelProvider: "OpenAI",
21
+ source: "codex"
22
+ });
23
+ }
24
+ };
@@ -0,0 +1,18 @@
1
+ import { AgentLogAdapter, AgentProvider } from "../../core/types";
2
+ import { claudeCodeAdapter } from "./claude-code";
3
+ import { codexAdapter } from "./codex";
4
+ import { opencodeAdapter } from "./opencode";
5
+
6
+ const adapters: Record<AgentProvider, AgentLogAdapter> = {
7
+ codex: codexAdapter,
8
+ "claude-code": claudeCodeAdapter,
9
+ opencode: opencodeAdapter
10
+ };
11
+
12
+ export function adapterForProvider(provider: AgentProvider): AgentLogAdapter {
13
+ return adapters[provider];
14
+ }
15
+
16
+ export function defaultAdapters(): AgentLogAdapter[] {
17
+ return [codexAdapter];
18
+ }
@@ -0,0 +1,275 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import Database from "better-sqlite3";
5
+ import { AgentLogAdapter, AgentLogSource, NormalizedBundle, ParsedAgentEvent } from "../../core/types";
6
+ import { normalizeCodexLines } from "../../core/normalizer";
7
+ import { opencodeDbCandidates } from "../../storage/paths";
8
+ import { asRecord, makeTokenUsage, numberTimestamp, parsedEvent, readJsonFile, stringValue } from "./shared";
9
+
10
+ const SOURCE_PREFIX = "opencode:ses:";
11
+
12
+ export const opencodeAdapter: AgentLogAdapter = {
13
+ provider: "opencode",
14
+ async scan(config) {
15
+ if (config?.path) {
16
+ return [await fileLikeSource(config.path)];
17
+ }
18
+ const dbPath = resolveOpencodeDb();
19
+ if (!dbPath) return [];
20
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
21
+ try {
22
+ const rows = db
23
+ .prepare(
24
+ "SELECT s.id AS id, s.time_updated AS timeUpdated, COUNT(p.id) AS parts " +
25
+ "FROM session s LEFT JOIN part p ON p.session_id = s.id GROUP BY s.id"
26
+ )
27
+ .all() as Array<{ id: string; timeUpdated: number; parts: number }>;
28
+ return rows.map((row) => ({
29
+ provider: "opencode" as const,
30
+ id: `${SOURCE_PREFIX}${row.id}`,
31
+ path: dbPath,
32
+ sizeBytes: row.parts,
33
+ mtimeMs: row.timeUpdated
34
+ }));
35
+ } finally {
36
+ db.close();
37
+ }
38
+ },
39
+ async parseSource(source, options = {}) {
40
+ if (!source.id.startsWith(SOURCE_PREFIX)) {
41
+ const json = await readJsonFile(source.path);
42
+ return normalizeOpenCodeExport(json, source.path, options.repoRoot);
43
+ }
44
+ const sessionId = source.id.slice(SOURCE_PREFIX.length);
45
+ const db = new Database(source.path, { readonly: true, fileMustExist: true });
46
+ try {
47
+ const session = db.prepare("SELECT * FROM session WHERE id = ?").get(sessionId) as
48
+ | Record<string, unknown>
49
+ | undefined;
50
+ if (!session) return null;
51
+ const messageRows = db
52
+ .prepare("SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created")
53
+ .all(sessionId) as Array<{ id: string; data: string }>;
54
+ const partRows = db
55
+ .prepare("SELECT message_id AS messageId, data FROM part WHERE session_id = ? ORDER BY time_created")
56
+ .all(sessionId) as Array<{ messageId: string; data: string }>;
57
+
58
+ const partsByMessage = new Map<string, unknown[]>();
59
+ for (const part of partRows) {
60
+ const list = partsByMessage.get(part.messageId) ?? [];
61
+ list.push(safeParse(part.data));
62
+ partsByMessage.set(part.messageId, list);
63
+ }
64
+
65
+ const exportShape = {
66
+ info: {
67
+ id: session.id,
68
+ directory: session.directory,
69
+ version: session.version,
70
+ title: session.title,
71
+ time: { created: session.time_created, updated: session.time_updated }
72
+ },
73
+ messages: messageRows.map((message) => ({
74
+ info: safeParse(message.data),
75
+ parts: partsByMessage.get(message.id) ?? []
76
+ }))
77
+ };
78
+ return normalizeOpenCodeExport(exportShape, source.path, options.repoRoot);
79
+ } finally {
80
+ db.close();
81
+ }
82
+ }
83
+ };
84
+
85
+ export function normalizeOpenCodeExport(json: unknown, sourcePath: string, repoRoot?: string | null): NormalizedBundle | null {
86
+ const root = asRecord(json);
87
+ const info = asRecord(root.info ?? root);
88
+ const messages = asArray(root.messages ?? root.message ?? root.parts);
89
+ const externalSessionId = stringValue(info.id) ?? stringValue(root.id) ?? path.basename(sourcePath, ".json");
90
+ const cwd = stringValue(info.directory) ?? stringValue(info.cwd) ?? stringValue(root.cwd) ?? process.cwd();
91
+ const startedAt = timestampFromValue(asRecord(info.time).created ?? info.created ?? messages[0]);
92
+ const version = stringValue(info.version) ?? stringValue(root.version);
93
+ const modelProvider = providerFromMessages(messages) ?? stringValue(info.provider);
94
+
95
+ const lines: ParsedAgentEvent[] = [
96
+ parsedEvent({
97
+ provider: "opencode",
98
+ sourcePath,
99
+ lineNo: 1,
100
+ timestamp: startedAt,
101
+ type: "session_meta",
102
+ payload: {
103
+ id: externalSessionId,
104
+ timestamp: startedAt,
105
+ cwd,
106
+ cli_version: version,
107
+ model_provider: modelProvider,
108
+ source: "opencode"
109
+ }
110
+ })
111
+ ];
112
+
113
+ let lineNo = 2;
114
+ for (const messageValue of messages) {
115
+ const message = asRecord(messageValue);
116
+ const mi = asRecord(message.info ?? message);
117
+ const timestamp = timestampFromValue(asRecord(mi.time).created ?? mi.created ?? mi.timestamp);
118
+ const role = stringValue(mi.role) ?? stringValue(mi.type);
119
+ const parts = asArray(message.parts ?? mi.parts ?? message.content);
120
+ const usage = makeTokenUsage(mi.tokens ?? mi.usage);
121
+
122
+ const messageText = textFromParts(parts);
123
+ if ((role === "user" || role === "assistant") && messageText) {
124
+ lines.push(
125
+ parsedEvent({
126
+ provider: "opencode",
127
+ sourcePath,
128
+ lineNo,
129
+ timestamp,
130
+ type: "response_item",
131
+ payload: {
132
+ type: "message",
133
+ role,
134
+ content: [{ type: role === "assistant" ? "output_text" : "input_text", text: messageText }],
135
+ ...(usage ? { usage } : {})
136
+ }
137
+ })
138
+ );
139
+ lineNo += 1;
140
+ }
141
+
142
+ for (const part of parts) {
143
+ const partRecord = asRecord(part);
144
+ if (!isToolPart(partRecord)) continue;
145
+ const state = asRecord(partRecord.state);
146
+ const callId = stringValue(partRecord.callID) ?? stringValue(partRecord.callId) ?? stringValue(partRecord.id);
147
+ lines.push(
148
+ parsedEvent({
149
+ provider: "opencode",
150
+ sourcePath,
151
+ lineNo,
152
+ timestamp,
153
+ type: "response_item",
154
+ payload: {
155
+ type: "function_call",
156
+ call_id: callId,
157
+ name: stringValue(partRecord.tool) ?? stringValue(partRecord.name) ?? "tool",
158
+ arguments: JSON.stringify(state.input ?? partRecord.input ?? partRecord.arguments ?? {})
159
+ }
160
+ })
161
+ );
162
+ lineNo += 1;
163
+
164
+ const output = toolOutput(state);
165
+ lines.push(
166
+ parsedEvent({
167
+ provider: "opencode",
168
+ sourcePath,
169
+ lineNo,
170
+ timestamp,
171
+ type: "response_item",
172
+ payload: {
173
+ type: "function_call_output",
174
+ call_id: callId,
175
+ output
176
+ }
177
+ })
178
+ );
179
+ lineNo += 1;
180
+ }
181
+ }
182
+
183
+ return normalizeCodexLines(lines, {
184
+ repoRoot,
185
+ provider: "opencode",
186
+ prefixSessionId: true,
187
+ modelProvider,
188
+ source: "opencode",
189
+ agentName: "OpenCode"
190
+ });
191
+ }
192
+
193
+ function resolveOpencodeDb(): string | null {
194
+ for (const candidate of opencodeDbCandidates()) {
195
+ try {
196
+ if (existsSync(candidate)) return candidate;
197
+ } catch {
198
+ // ignore — try next candidate
199
+ }
200
+ }
201
+ return null;
202
+ }
203
+
204
+ async function fileLikeSource(filePath: string): Promise<AgentLogSource> {
205
+ const content = await readFile(filePath, "utf8");
206
+ const stats = statSync(filePath);
207
+ return {
208
+ provider: "opencode",
209
+ id: `opencode:${filePath}`,
210
+ path: filePath,
211
+ sizeBytes: Buffer.byteLength(content),
212
+ mtimeMs: stats.mtimeMs
213
+ };
214
+ }
215
+
216
+ function safeParse(value: string): unknown {
217
+ try {
218
+ return JSON.parse(value);
219
+ } catch {
220
+ return {};
221
+ }
222
+ }
223
+
224
+ function asArray(value: unknown): unknown[] {
225
+ if (Array.isArray(value)) return value;
226
+ if (!value || typeof value !== "object") return [];
227
+ const record = value as Record<string, unknown>;
228
+ for (const key of ["items", "data", "sessions", "messages"]) {
229
+ if (Array.isArray(record[key])) return record[key];
230
+ }
231
+ return [];
232
+ }
233
+
234
+ function timestampFromValue(value: unknown): string {
235
+ if (typeof value === "string") return value;
236
+ const numeric = numberTimestamp(value);
237
+ return numeric ?? new Date(0).toISOString();
238
+ }
239
+
240
+ function providerFromMessages(messages: unknown[]): string | null {
241
+ for (const messageValue of messages) {
242
+ const mi = asRecord(asRecord(messageValue).info ?? messageValue);
243
+ const provider = stringValue(mi.providerID) ?? stringValue(mi.provider);
244
+ if (provider) return provider;
245
+ }
246
+ return null;
247
+ }
248
+
249
+ function textFromParts(value: unknown): string {
250
+ if (typeof value === "string") return value;
251
+ return asArray(value)
252
+ .map((part) => {
253
+ if (typeof part === "string") return part;
254
+ const record = asRecord(part);
255
+ if (record.type && record.type !== "text") return "";
256
+ return stringValue(record.text) ?? stringValue(record.content) ?? stringValue(record.output) ?? "";
257
+ })
258
+ .filter(Boolean)
259
+ .join("\n");
260
+ }
261
+
262
+ function isToolPart(part: Record<string, unknown>) {
263
+ const type = stringValue(part.type);
264
+ if (type === "tool" || type === "tool_call") return true;
265
+ if (type) return false;
266
+ return Boolean(part.tool ?? part.name);
267
+ }
268
+
269
+ function toolOutput(state: Record<string, unknown>): string {
270
+ const direct = stringValue(state.output);
271
+ if (direct) return direct;
272
+ const metaOutput = stringValue(asRecord(state.metadata).output);
273
+ if (metaOutput) return metaOutput;
274
+ return "";
275
+ }