diff --git a/README.md b/README.md index b3d1da4..321e5d6 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ If you build and operate your own agent in production, use a tracing platform. I ## Features +- **Per-turn ledger** — In the session summary: one row per user turn with wall-clock time, tokens (input + output + cache) and cost, bars scaled to the session maximum, tool-call and error counts, click to jump. Answers "why did this take 40 minutes / cost $3" without reading the transcript. - **Multi-platform** — Unified view across OpenClaw, Codex, Claude Code, Hermes, OMP, DeepSeek Harness and Gemini CLI sessions (dsh's multi-frame zstd session logs are decompressed transparently; Gemini CLI's `/rewind` checkpoints are folded so rewound history never renders twice) - **Session browser** — Browse agents, filter/search sessions, view message history - **Tool call inspection** — Expandable tool calls with arguments and results diff --git a/frontend/src/lib/legacy-pure.ts b/frontend/src/lib/legacy-pure.ts index 483644d..6133c31 100644 --- a/frontend/src/lib/legacy-pure.ts +++ b/frontend/src/lib/legacy-pure.ts @@ -12,6 +12,7 @@ export { getTextContent, clusterPrefillContent, buildTraceTurns, + buildTurnLedger, pickAutoPlatform, } from './pure'; export { escapeHtml, renderMarkdownHtml, renderMarkdown } from './markdown'; diff --git a/frontend/src/lib/pure.ts b/frontend/src/lib/pure.ts index de8a5e4..f89b969 100644 --- a/frontend/src/lib/pure.ts +++ b/frontend/src/lib/pure.ts @@ -217,3 +217,122 @@ export function buildTraceTurns(msgs: SessionMessage[], agentSpans: AgentSpan[] for (const tn of turns) tn.spans.sort((a, b) => a.start - b.start); return turns.filter((tn) => tn.spans.length > 0); } + +// --- Per-turn ledger: where did the time, tokens and dollars go? --- +// A turn = one user message plus everything the agent did until the next user +// message. Usage is summed from assistant messages' `usage` (already normalised +// per platform: input/output/cacheRead/cacheWrite tokens, cost as number or +// {total}). Missing usage on a platform leaves the token/cost columns at zero +// rather than hiding the row: the time column still tells the story. + +export interface TurnLedgerRow { + index: number; + text: string; + messageId: string | null; + start: number; + end: number; + durationMs: number; + toolCalls: number; + toolErrors: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + cost: number; +} + +export interface TurnLedger { + rows: TurnLedgerRow[]; + totals: { durationMs: number; toolCalls: number; tokens: number; cost: number }; + hasUsage: boolean; + hasCost: boolean; +} + +function usageCost(usage: SessionMessage['usage']): number { + const c = usage?.cost; + if (typeof c === 'number') return c; + return typeof c?.total === 'number' ? c.total : 0; +} + +function usageNumber(usage: SessionMessage['usage'], ...keys: string[]): number { + if (!usage) return 0; + for (const k of keys) { + const v = usage[k]; + if (typeof v === 'number') return v; + } + return 0; +} + +export function buildTurnLedger(msgs: SessionMessage[]): TurnLedger { + const rows: TurnLedgerRow[] = []; + let cur: TurnLedgerRow | null = null; + let hasUsage = false; + let hasCost = false; + + for (const m of msgs) { + const t = parseTimestampMs(m.timestamp); + if (m.role === 'user') { + const text = getTextContent(m.content || []) + .replace(/\s+/g, ' ') + .trim(); + cur = { + index: rows.length + 1, + text: text.slice(0, 140) || '(user)', + messageId: m.id || null, + start: t ?? Number.NaN, + end: t ?? Number.NaN, + durationMs: 0, + toolCalls: 0, + toolErrors: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cost: 0, + }; + rows.push(cur); + continue; + } + if (!cur) continue; // pre-user preamble (system prompts) belongs to no turn + if (t !== null) { + if (Number.isNaN(cur.start)) cur.start = t; + cur.end = Math.max(Number.isNaN(cur.end) ? t : cur.end, t); + } + if (m.role === 'toolCall') cur.toolCalls++; + if (m.role === 'toolResult' && m.isError) cur.toolErrors++; + for (const c of m.content || []) { + if (c.type === 'toolCall' || c.type === 'tool_use') cur.toolCalls++; + if (c.type === 'tool_result' && c.is_error) cur.toolErrors++; + } + if (m.usage) { + const inp = usageNumber(m.usage, 'input', 'inputTokens', 'input_tokens', 'prompt_tokens'); + const out = usageNumber(m.usage, 'output', 'outputTokens', 'output_tokens', 'completion_tokens'); + const cr = usageNumber(m.usage, 'cacheRead', 'cache_read_input_tokens', 'cacheReadTokens'); + const cw = usageNumber(m.usage, 'cacheWrite', 'cache_creation_input_tokens', 'cacheWriteTokens'); + const total = usageNumber(m.usage, 'totalTokens', 'total_tokens'); + if (inp || out || cr || cw || total) hasUsage = true; + cur.inputTokens += inp || (out || cr || cw ? 0 : total); + cur.outputTokens += out; + cur.cacheReadTokens += cr; + cur.cacheWriteTokens += cw; + const cost = usageCost(m.usage); + if (cost) hasCost = true; + cur.cost += cost; + } + } + + for (const r of rows) { + r.durationMs = Number.isNaN(r.start) || Number.isNaN(r.end) ? 0 : Math.max(0, r.end - r.start); + } + const totals = rows.reduce( + (acc, r) => { + acc.durationMs += r.durationMs; + acc.toolCalls += r.toolCalls; + acc.tokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens; + acc.cost += r.cost; + return acc; + }, + { durationMs: 0, toolCalls: 0, tokens: 0, cost: 0 } + ); + return { rows, totals, hasUsage, hasCost }; +} diff --git a/frontend/src/views/sessions/SessionSummary.tsx b/frontend/src/views/sessions/SessionSummary.tsx index 158f87d..cd5e8b1 100644 --- a/frontend/src/views/sessions/SessionSummary.tsx +++ b/frontend/src/views/sessions/SessionSummary.tsx @@ -10,6 +10,7 @@ import { formatCost, formatDurationCompact } from '@/lib/pure'; import { cn } from '@/lib/utils'; import { dirForPlatform, loadStoredFlag, saveStoredFlag, SUMMARY_COLLAPSED_KEY, useAppStore } from '@/store'; import { ChildAgentsSection } from '@/views/trace/ChildAgentsSection'; +import { TurnLedger } from './TurnLedger'; import type { ExportFormat } from './exports'; import { runExport } from './exports'; import type { MsgFilter, TimingAnalysis } from './lib'; @@ -350,6 +351,7 @@ export function SessionSummary({ + ) : null} diff --git a/frontend/src/views/sessions/TurnLedger.tsx b/frontend/src/views/sessions/TurnLedger.tsx new file mode 100644 index 0000000..3f60f74 --- /dev/null +++ b/frontend/src/views/sessions/TurnLedger.tsx @@ -0,0 +1,122 @@ +// Per-turn ledger: the answer to "why did this session take 40 minutes / cost $3". +// One row per user turn; bars are proportional to the session maximum so the +// expensive turn is visible without reading numbers. Clicking a row jumps to +// the user message that started it. + +import { useMemo, useState } from 'react'; +import type { SessionMessage } from '@/api/types'; +import { buildTurnLedger, formatCost, formatDurationCompact } from '@/lib/pure'; +import { cn } from '@/lib/utils'; +import { formatNumber } from './lib'; + +type SortKey = 'index' | 'durationMs' | 'tokens' | 'cost'; + +function Bar({ value, max, className }: { value: number; max: number; className: string }) { + const pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0; + return ( +
+
+
+ ); +} + +export function TurnLedger({ + messages, + onScrollToMessage, +}: { + messages: SessionMessage[]; + onScrollToMessage: (id: string) => void; +}) { + const ledger = useMemo(() => buildTurnLedger(messages), [messages]); + const [sort, setSort] = useState('index'); + + const rows = useMemo(() => { + const withTokens = ledger.rows.map((r) => ({ + ...r, + tokens: r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens, + })); + if (sort === 'index') return withTokens; + return [...withTokens].sort((a, b) => b[sort] - a[sort]); + }, [ledger, sort]); + + if (ledger.rows.length < 2) return null; + + const maxMs = Math.max(...rows.map((r) => r.durationMs), 0); + const maxTok = Math.max(...rows.map((r) => r.tokens), 0); + const maxCost = Math.max(...rows.map((r) => r.cost), 0); + const { totals } = ledger; + + const header = (key: SortKey, label: string, title: string) => ( + + ); + + return ( +
+
+
+ Per-turn ledger +
+
+ {ledger.rows.length} turns · {formatDurationCompact(totals.durationMs)} + {ledger.hasUsage ? ` · ${formatNumber(totals.tokens)} tok` : ''} + {ledger.hasCost ? ` · ${formatCost(totals.cost)}` : ''} +
+
+
+
{header('index', '#', 'Session order')}
+
turn
+
{header('durationMs', 'time', 'Wall-clock from the user message to the last agent message of the turn')}
+ {ledger.hasUsage ? ( +
{header('tokens', 'tokens', 'input + output + cache read + cache write, summed over the turn')}
+ ) : null} + {ledger.hasCost ?
{header('cost', 'cost', 'Reported cost summed over the turn')}
: null} + + {rows.map((r) => ( + + ))} +
+
+ ); +} diff --git a/package.json b/package.json index b6bc57a..3e8af6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@alloevil/agent-xray", - "version": "1.16.0", + "version": "1.17.0", "description": "Web dashboard for viewing AI agent session logs — supports OpenClaw, Codex, Claude Code, Hermes, OMP, DeepSeek Harness, and Gemini CLI", "main": "server.js", "bin": { diff --git a/public/js/pure.js b/public/js/pure.js index f945361..deb84d8 100644 --- a/public/js/pure.js +++ b/public/js/pure.js @@ -26,6 +26,7 @@ var __axrPure = (() => { var legacy_pure_exports = {}; __export(legacy_pure_exports, { buildTraceTurns: () => buildTraceTurns, + buildTurnLedger: () => buildTurnLedger, clusterPrefillContent: () => clusterPrefillContent, escapeHtml: () => escapeHtml, firstInformativeLine: () => firstInformativeLine, @@ -191,6 +192,88 @@ var __axrPure = (() => { for (const tn of turns) tn.spans.sort((a, b) => a.start - b.start); return turns.filter((tn) => tn.spans.length > 0); } + function usageCost(usage) { + const c = usage?.cost; + if (typeof c === "number") return c; + return typeof c?.total === "number" ? c.total : 0; + } + function usageNumber(usage, ...keys) { + if (!usage) return 0; + for (const k of keys) { + const v = usage[k]; + if (typeof v === "number") return v; + } + return 0; + } + function buildTurnLedger(msgs) { + const rows = []; + let cur = null; + let hasUsage = false; + let hasCost = false; + for (const m of msgs) { + const t = parseTimestampMs(m.timestamp); + if (m.role === "user") { + const text = getTextContent(m.content || []).replace(/\s+/g, " ").trim(); + cur = { + index: rows.length + 1, + text: text.slice(0, 140) || "(user)", + messageId: m.id || null, + start: t ?? Number.NaN, + end: t ?? Number.NaN, + durationMs: 0, + toolCalls: 0, + toolErrors: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cost: 0 + }; + rows.push(cur); + continue; + } + if (!cur) continue; + if (t !== null) { + if (Number.isNaN(cur.start)) cur.start = t; + cur.end = Math.max(Number.isNaN(cur.end) ? t : cur.end, t); + } + if (m.role === "toolCall") cur.toolCalls++; + if (m.role === "toolResult" && m.isError) cur.toolErrors++; + for (const c of m.content || []) { + if (c.type === "toolCall" || c.type === "tool_use") cur.toolCalls++; + if (c.type === "tool_result" && c.is_error) cur.toolErrors++; + } + if (m.usage) { + const inp = usageNumber(m.usage, "input", "inputTokens", "input_tokens", "prompt_tokens"); + const out = usageNumber(m.usage, "output", "outputTokens", "output_tokens", "completion_tokens"); + const cr = usageNumber(m.usage, "cacheRead", "cache_read_input_tokens", "cacheReadTokens"); + const cw = usageNumber(m.usage, "cacheWrite", "cache_creation_input_tokens", "cacheWriteTokens"); + const total = usageNumber(m.usage, "totalTokens", "total_tokens"); + if (inp || out || cr || cw || total) hasUsage = true; + cur.inputTokens += inp || (out || cr || cw ? 0 : total); + cur.outputTokens += out; + cur.cacheReadTokens += cr; + cur.cacheWriteTokens += cw; + const cost = usageCost(m.usage); + if (cost) hasCost = true; + cur.cost += cost; + } + } + for (const r of rows) { + r.durationMs = Number.isNaN(r.start) || Number.isNaN(r.end) ? 0 : Math.max(0, r.end - r.start); + } + const totals = rows.reduce( + (acc, r) => { + acc.durationMs += r.durationMs; + acc.toolCalls += r.toolCalls; + acc.tokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens; + acc.cost += r.cost; + return acc; + }, + { durationMs: 0, toolCalls: 0, tokens: 0, cost: 0 } + ); + return { rows, totals, hasUsage, hasCost }; + } // frontend/src/lib/markdown.ts function escapeHtml(value) { diff --git a/test/unit.test.js b/test/unit.test.js index 0b91edb..e58f8bb 100644 --- a/test/unit.test.js +++ b/test/unit.test.js @@ -107,6 +107,7 @@ test('pure.js exports the moved helpers via require', () => { 'formatBytes', 'formatCost', 'buildTraceTurns', + 'buildTurnLedger', 'parseTimestampMs', 'getTextContent', 'clusterPrefillContent', @@ -352,3 +353,58 @@ test('renderMarkdownHtml renders headings, lists, links and fenced code', () => assert.ok(html.includes('x')); assert.ok(html.includes('
code<>\n
')); }); + +// --- buildTurnLedger: per-turn time / tokens / cost --- + +test('buildTurnLedger attributes time, tools, tokens and cost to the user turn that caused them', () => { + const msgs = [ + { id: 's', role: 'system', timestamp: iso(-1000), content: [] }, // preamble: no turn + { id: 'u1', role: 'user', timestamp: iso(0), content: [{ type: 'text', text: 'first question' }] }, + { + id: 'a1', + role: 'assistant', + timestamp: iso(5000), + usage: { input: 1000, output: 200, cacheRead: 5000, cost: 0.02 }, + content: [ + { type: 'text', text: 'x' }, + { type: 'toolCall', id: 'c1', name: 'bash' }, + ], + }, + { id: 'tr', role: 'toolResult', timestamp: iso(8000), toolCallId: 'c1', isError: true, content: [] }, + { + id: 'a2', + role: 'assistant', + timestamp: iso(9000), + usage: { input: 1200, output: 50, cost: { total: 0.01 } }, + content: [], + }, + { id: 'u2', role: 'user', timestamp: iso(20000), content: [{ type: 'text', text: 'second' }] }, + { id: 'a3', role: 'assistant', timestamp: iso(21000), usage: { total_tokens: 300 }, content: [] }, + ]; + const l = pure.buildTurnLedger(msgs); + assert.equal(l.rows.length, 2); + const [t1, t2] = l.rows; + assert.equal(t1.text, 'first question'); + assert.equal(t1.messageId, 'u1'); + assert.equal(t1.durationMs, 9000); // user msg -> last agent msg of the turn + assert.equal(t1.toolCalls, 1); // content-part toolCall counted once + assert.equal(t1.toolErrors, 1); + assert.deepEqual([t1.inputTokens, t1.outputTokens, t1.cacheReadTokens, t1.cacheWriteTokens], [2200, 250, 5000, 0]); + assert.ok(Math.abs(t1.cost - 0.03) < 1e-9); // number and {total} cost shapes both summed + assert.equal(t2.durationMs, 1000); + assert.equal(t2.inputTokens, 300); // total_tokens-only platforms land in the input column + assert.equal(t2.cost, 0); + assert.deepEqual(l.totals, { durationMs: 10000, toolCalls: 1, tokens: 7750, cost: 0.03 }); + assert.equal(l.hasUsage, true); + assert.equal(l.hasCost, true); +}); + +test('buildTurnLedger without usage still yields time rows and reports hasUsage=false', () => { + const l = pure.buildTurnLedger([ + { id: 'u1', role: 'user', timestamp: iso(0), content: [{ type: 'text', text: 'q' }] }, + { id: 'a1', role: 'assistant', timestamp: iso(3000), content: [] }, + ]); + assert.equal(l.rows[0].durationMs, 3000); + assert.equal(l.hasUsage, false); + assert.equal(l.hasCost, false); +});