|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +/** |
| 4 | + * Convert a Traces-page `webbrain-trace/1` JSON export to ATIF v1.7. |
| 5 | + * |
| 6 | + * This deliberately stays offline and dependency-free. Screenshots and verbose |
| 7 | + * diagnostic events are counted but not copied because ATIF represents images |
| 8 | + * as files referenced alongside the trajectory, not embedded data URLs. |
| 9 | + */ |
| 10 | + |
| 11 | +import fs from 'node:fs/promises'; |
| 12 | +import path from 'node:path'; |
| 13 | +import process from 'node:process'; |
| 14 | +import { fileURLToPath } from 'node:url'; |
| 15 | + |
| 16 | +const SOURCE_SCHEMA = 'webbrain-trace/1'; |
| 17 | +const ATIF_SCHEMA_VERSION = 'ATIF-v1.7'; |
| 18 | + |
| 19 | +function isObject(value) { |
| 20 | + return !!value && typeof value === 'object' && !Array.isArray(value); |
| 21 | +} |
| 22 | + |
| 23 | +function finiteNumber(value) { |
| 24 | + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; |
| 25 | +} |
| 26 | + |
| 27 | +function timestamp(value) { |
| 28 | + if (value == null || value === '') return undefined; |
| 29 | + const date = new Date(value); |
| 30 | + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); |
| 31 | +} |
| 32 | + |
| 33 | +function jsonSafe(value, fallback) { |
| 34 | + try { |
| 35 | + const serialized = JSON.stringify(value); |
| 36 | + return serialized === undefined ? fallback : JSON.parse(serialized); |
| 37 | + } catch { |
| 38 | + return fallback; |
| 39 | + } |
| 40 | +} |
| 41 | + |
| 42 | +function compactObject(value) { |
| 43 | + return Object.fromEntries( |
| 44 | + Object.entries(value).filter(([, item]) => item !== undefined), |
| 45 | + ); |
| 46 | +} |
| 47 | + |
| 48 | +function parseArguments(value) { |
| 49 | + if (isObject(value)) return { arguments: jsonSafe(value, {}) }; |
| 50 | + if (typeof value === 'string') { |
| 51 | + try { |
| 52 | + const parsed = JSON.parse(value); |
| 53 | + if (isObject(parsed)) return { arguments: parsed }; |
| 54 | + } catch {} |
| 55 | + return { |
| 56 | + arguments: {}, |
| 57 | + extra: { raw_arguments: value }, |
| 58 | + }; |
| 59 | + } |
| 60 | + if (value == null) return { arguments: {} }; |
| 61 | + return { |
| 62 | + arguments: {}, |
| 63 | + extra: { raw_arguments: String(value) }, |
| 64 | + }; |
| 65 | +} |
| 66 | + |
| 67 | +function resultContent(value) { |
| 68 | + if (value === undefined) return '(missing tool result)'; |
| 69 | + if (typeof value === 'string') return value; |
| 70 | + try { |
| 71 | + const serialized = JSON.stringify(value); |
| 72 | + return serialized === undefined ? String(value) : serialized; |
| 73 | + } catch { |
| 74 | + return String(value); |
| 75 | + } |
| 76 | +} |
| 77 | + |
| 78 | +function usageMetrics(usage) { |
| 79 | + if (!isObject(usage)) return undefined; |
| 80 | + const extra = {}; |
| 81 | + // WebBrain records provider-native cost. Some providers report USD, while |
| 82 | + // others do not guarantee a currency, so never mislabel it as ATIF cost_usd. |
| 83 | + const reportedCost = finiteNumber(usage.cost); |
| 84 | + if (reportedCost !== undefined) extra.webbrain_reported_cost = reportedCost; |
| 85 | + const metrics = compactObject({ |
| 86 | + prompt_tokens: finiteNumber(usage.prompt_tokens), |
| 87 | + completion_tokens: finiteNumber(usage.completion_tokens), |
| 88 | + cached_tokens: finiteNumber( |
| 89 | + usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens, |
| 90 | + ), |
| 91 | + extra: Object.keys(extra).length ? extra : undefined, |
| 92 | + }); |
| 93 | + return Object.keys(metrics).length ? metrics : undefined; |
| 94 | +} |
| 95 | + |
| 96 | +function eventOrder(left, right) { |
| 97 | + const leftSeq = finiteNumber(left?.seq) ?? Number.MAX_SAFE_INTEGER; |
| 98 | + const rightSeq = finiteNumber(right?.seq) ?? Number.MAX_SAFE_INTEGER; |
| 99 | + if (leftSeq !== rightSeq) return leftSeq - rightSeq; |
| 100 | + return (finiteNumber(left?.ts) ?? 0) - (finiteNumber(right?.ts) ?? 0); |
| 101 | +} |
| 102 | + |
| 103 | +function sameToolName(left, right) { |
| 104 | + return String(left || '') === String(right || ''); |
| 105 | +} |
| 106 | + |
| 107 | +export function webbrainTraceToAtif(input) { |
| 108 | + if (!isObject(input)) throw new TypeError('WebBrain trace export must be an object.'); |
| 109 | + if (input.schema !== SOURCE_SCHEMA) { |
| 110 | + throw new TypeError(`Expected schema "${SOURCE_SCHEMA}".`); |
| 111 | + } |
| 112 | + if (!isObject(input.run)) throw new TypeError('WebBrain trace run must be an object.'); |
| 113 | + if (!Array.isArray(input.events)) throw new TypeError('WebBrain trace events must be an array.'); |
| 114 | + if (typeof input.run.runId !== 'string' || !input.run.runId.trim()) { |
| 115 | + throw new TypeError('WebBrain trace run.runId must be a non-empty string.'); |
| 116 | + } |
| 117 | + |
| 118 | + const run = input.run; |
| 119 | + const runId = String(run.runId).trim(); |
| 120 | + const version = String( |
| 121 | + run.webbrainVersion || input.exportedByWebBrainVersion || 'unknown', |
| 122 | + ); |
| 123 | + const steps = []; |
| 124 | + const omittedEventCounts = {}; |
| 125 | + let lastAgentMessage = ''; |
| 126 | + let pendingAgent = null; |
| 127 | + const allocatedToolCallIds = new Set(); |
| 128 | + |
| 129 | + function appendStep(step) { |
| 130 | + const complete = { step_id: steps.length + 1, ...compactObject(step) }; |
| 131 | + steps.push(complete); |
| 132 | + return complete; |
| 133 | + } |
| 134 | + |
| 135 | + appendStep({ |
| 136 | + timestamp: timestamp(run.startedAt), |
| 137 | + source: 'user', |
| 138 | + message: String(run.userMessage || ''), |
| 139 | + }); |
| 140 | + |
| 141 | + for (const event of [...input.events].sort(eventOrder)) { |
| 142 | + if (!isObject(event)) continue; |
| 143 | + if (event.runId != null && String(event.runId) !== runId) { |
| 144 | + throw new TypeError(`Trace event runId "${event.runId}" does not match run.runId "${runId}".`); |
| 145 | + } |
| 146 | + const data = isObject(event.data) ? event.data : {}; |
| 147 | + const eventSeq = finiteNumber(event.seq); |
| 148 | + |
| 149 | + if (event.kind === 'llm_response') { |
| 150 | + const toolCalls = Array.isArray(data.toolCalls) |
| 151 | + ? data.toolCalls.map((call, index) => { |
| 152 | + const parsed = parseArguments(call?.args); |
| 153 | + const recordedId = String(call?.id || '').trim(); |
| 154 | + const fallbackId = `webbrain-${runId}-${eventSeq ?? 'unknown'}-${index + 1}`; |
| 155 | + const toolCallId = recordedId && !allocatedToolCallIds.has(recordedId) |
| 156 | + ? recordedId |
| 157 | + : fallbackId; |
| 158 | + allocatedToolCallIds.add(toolCallId); |
| 159 | + return compactObject({ |
| 160 | + tool_call_id: toolCallId, |
| 161 | + function_name: String(call?.name || 'unknown_tool'), |
| 162 | + arguments: parsed.arguments, |
| 163 | + extra: parsed.extra, |
| 164 | + }); |
| 165 | + }) |
| 166 | + : []; |
| 167 | + const message = String(data.content || ''); |
| 168 | + const extra = compactObject({ |
| 169 | + webbrain_seq: eventSeq, |
| 170 | + webbrain_step: finiteNumber(data.step), |
| 171 | + phase: data.phase ? String(data.phase) : undefined, |
| 172 | + latency_ms: finiteNumber(data.latencyMs), |
| 173 | + }); |
| 174 | + const step = appendStep({ |
| 175 | + timestamp: timestamp(event.ts), |
| 176 | + source: 'agent', |
| 177 | + model_name: data.model ? String(data.model) : undefined, |
| 178 | + message, |
| 179 | + tool_calls: toolCalls.length ? toolCalls : undefined, |
| 180 | + metrics: usageMetrics(data.usage), |
| 181 | + llm_call_count: 1, |
| 182 | + extra: Object.keys(extra).length ? extra : undefined, |
| 183 | + }); |
| 184 | + pendingAgent = { |
| 185 | + step, |
| 186 | + webbrainStep: finiteNumber(data.step), |
| 187 | + usedCallIds: new Set(), |
| 188 | + }; |
| 189 | + if (message.trim()) lastAgentMessage = message.trim(); |
| 190 | + continue; |
| 191 | + } |
| 192 | + |
| 193 | + if (event.kind === 'tool') { |
| 194 | + const toolName = String(data.name || 'unknown_tool'); |
| 195 | + const pendingCall = pendingAgent?.step.tool_calls?.find((call) => ( |
| 196 | + !pendingAgent.usedCallIds.has(call.tool_call_id) |
| 197 | + && sameToolName(call.function_name, toolName) |
| 198 | + && ( |
| 199 | + pendingAgent.webbrainStep === undefined |
| 200 | + || finiteNumber(data.step) === undefined |
| 201 | + || pendingAgent.webbrainStep === finiteNumber(data.step) |
| 202 | + ) |
| 203 | + )); |
| 204 | + const observationExtra = compactObject({ |
| 205 | + latency_ms: finiteNumber(data.latencyMs), |
| 206 | + webbrain_seq: eventSeq, |
| 207 | + }); |
| 208 | + if (pendingCall) { |
| 209 | + pendingAgent.usedCallIds.add(pendingCall.tool_call_id); |
| 210 | + if (!pendingAgent.step.observation) pendingAgent.step.observation = { results: [] }; |
| 211 | + pendingAgent.step.observation.results.push({ |
| 212 | + source_call_id: pendingCall.tool_call_id, |
| 213 | + content: resultContent(data.result), |
| 214 | + ...(Object.keys(observationExtra).length ? { extra: observationExtra } : {}), |
| 215 | + }); |
| 216 | + continue; |
| 217 | + } |
| 218 | + |
| 219 | + const parsed = parseArguments(data.args); |
| 220 | + let toolCallId = `webbrain-${runId}-${eventSeq ?? steps.length + 1}-1`; |
| 221 | + let suffix = 2; |
| 222 | + while (allocatedToolCallIds.has(toolCallId)) { |
| 223 | + toolCallId = `webbrain-${runId}-${eventSeq ?? steps.length + 1}-${suffix}`; |
| 224 | + suffix += 1; |
| 225 | + } |
| 226 | + allocatedToolCallIds.add(toolCallId); |
| 227 | + const stepExtra = compactObject({ |
| 228 | + webbrain_seq: eventSeq, |
| 229 | + webbrain_step: finiteNumber(data.step), |
| 230 | + }); |
| 231 | + appendStep({ |
| 232 | + timestamp: timestamp(event.ts), |
| 233 | + source: 'agent', |
| 234 | + message: '', |
| 235 | + tool_calls: [compactObject({ |
| 236 | + tool_call_id: toolCallId, |
| 237 | + function_name: toolName, |
| 238 | + arguments: parsed.arguments, |
| 239 | + extra: parsed.extra, |
| 240 | + })], |
| 241 | + observation: { |
| 242 | + results: [{ |
| 243 | + source_call_id: toolCallId, |
| 244 | + content: resultContent(data.result), |
| 245 | + ...(Object.keys(observationExtra).length ? { extra: observationExtra } : {}), |
| 246 | + }], |
| 247 | + }, |
| 248 | + llm_call_count: 0, |
| 249 | + extra: Object.keys(stepExtra).length ? stepExtra : undefined, |
| 250 | + }); |
| 251 | + pendingAgent = null; |
| 252 | + continue; |
| 253 | + } |
| 254 | + |
| 255 | + if (event.kind === 'error') { |
| 256 | + const errorExtra = compactObject({ |
| 257 | + webbrain_seq: eventSeq, |
| 258 | + webbrain_step: finiteNumber(data.step), |
| 259 | + phase: data.phase ? String(data.phase) : undefined, |
| 260 | + }); |
| 261 | + appendStep({ |
| 262 | + timestamp: timestamp(event.ts), |
| 263 | + source: 'system', |
| 264 | + message: 'WebBrain runtime error', |
| 265 | + observation: { |
| 266 | + results: [{ content: String(data.message || 'Unknown error') }], |
| 267 | + }, |
| 268 | + extra: Object.keys(errorExtra).length ? errorExtra : undefined, |
| 269 | + }); |
| 270 | + pendingAgent = null; |
| 271 | + continue; |
| 272 | + } |
| 273 | + |
| 274 | + const kind = String(event.kind || 'unknown'); |
| 275 | + omittedEventCounts[kind] = (omittedEventCounts[kind] || 0) + 1; |
| 276 | + } |
| 277 | + |
| 278 | + const finalContent = String(run.finalContent || '').trim(); |
| 279 | + if (finalContent && finalContent !== lastAgentMessage) { |
| 280 | + appendStep({ |
| 281 | + timestamp: timestamp(run.endedAt), |
| 282 | + source: 'agent', |
| 283 | + message: finalContent, |
| 284 | + extra: { webbrain_final_content: true }, |
| 285 | + }); |
| 286 | + } |
| 287 | + |
| 288 | + const agentExtra = compactObject({ |
| 289 | + provider_id: run.providerId ? String(run.providerId) : undefined, |
| 290 | + provider_class: run.providerClass ? String(run.providerClass) : undefined, |
| 291 | + mode: run.mode ? String(run.mode) : undefined, |
| 292 | + }); |
| 293 | + const rootExtra = compactObject({ |
| 294 | + source_schema: SOURCE_SCHEMA, |
| 295 | + source_exported_at: timestamp(input.exportedAt), |
| 296 | + source_exported_by_webbrain_version: input.exportedByWebBrainVersion |
| 297 | + ? String(input.exportedByWebBrainVersion) |
| 298 | + : undefined, |
| 299 | + status: run.status ? String(run.status) : undefined, |
| 300 | + tab_url: run.tabUrl ? String(run.tabUrl) : undefined, |
| 301 | + tab_title: run.tabTitle ? String(run.tabTitle) : undefined, |
| 302 | + omitted_event_counts: Object.keys(omittedEventCounts).length |
| 303 | + ? omittedEventCounts |
| 304 | + : undefined, |
| 305 | + }); |
| 306 | + const finalMetrics = compactObject({ |
| 307 | + total_prompt_tokens: finiteNumber(run.totalInputTokens), |
| 308 | + total_completion_tokens: finiteNumber(run.totalOutputTokens), |
| 309 | + total_steps: steps.length, |
| 310 | + }); |
| 311 | + |
| 312 | + return { |
| 313 | + schema_version: ATIF_SCHEMA_VERSION, |
| 314 | + session_id: runId, |
| 315 | + trajectory_id: runId, |
| 316 | + agent: compactObject({ |
| 317 | + name: 'webbrain', |
| 318 | + version, |
| 319 | + model_name: run.model ? String(run.model) : undefined, |
| 320 | + extra: Object.keys(agentExtra).length ? agentExtra : undefined, |
| 321 | + }), |
| 322 | + steps, |
| 323 | + final_metrics: finalMetrics, |
| 324 | + extra: rootExtra, |
| 325 | + }; |
| 326 | +} |
| 327 | + |
| 328 | +async function main(argv) { |
| 329 | + const [inputPath, outputPath] = argv; |
| 330 | + if (!inputPath || argv.length > 2) { |
| 331 | + throw new Error('Usage: node scripts/trace-to-atif.mjs <trace.json> [trajectory.json|-]'); |
| 332 | + } |
| 333 | + const raw = await fs.readFile(inputPath, 'utf8'); |
| 334 | + const trajectory = webbrainTraceToAtif(JSON.parse(raw)); |
| 335 | + const serialized = `${JSON.stringify(trajectory, null, 2)}\n`; |
| 336 | + if (outputPath === '-') { |
| 337 | + process.stdout.write(serialized); |
| 338 | + return; |
| 339 | + } |
| 340 | + const destination = outputPath || path.join( |
| 341 | + path.dirname(inputPath), |
| 342 | + `${path.basename(inputPath, path.extname(inputPath))}.atif.json`, |
| 343 | + ); |
| 344 | + await fs.writeFile(destination, serialized, 'utf8'); |
| 345 | + process.stderr.write(`Wrote ${destination}\n`); |
| 346 | +} |
| 347 | + |
| 348 | +const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ''; |
| 349 | +if (invokedPath === fileURLToPath(import.meta.url)) { |
| 350 | + main(process.argv.slice(2)).catch((error) => { |
| 351 | + process.stderr.write(`${error?.message || error}\n`); |
| 352 | + process.exitCode = 1; |
| 353 | + }); |
| 354 | +} |
0 commit comments