Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796import { ApiError, listChildren, listJobs, readContentBatch, readHistory, readRequests, type HistoryEntry, type Job, type RequestRecord, type Session } from './api';import type { Trace as CallTrace } from './burst';import { scaleLinear } from 'd3-scale';import { busy } from './live.svelte';import { failedTool, jsonField } from './transcript.svelte';
// A task's trace: every model call and tool call of a session, and of the// agents it started, on one clock (robot-docs/web.md). A task is a turn and// the turns that follow it without the person typing again: mail from a// child, a continuation. Model calls come from the daemon's request records// (timing, first byte, tokens), tool calls from the transcript (a call's// entry to its result's), and agents from the session tree.
export type StepKind = 'model' | 'tool' | 'waiting';
export interface Step { id: string; lane: string; kind: StepKind; start: number; end: number; /** What ran: the model, or the tool and the first line of its input. */ name: string; detail: string; /** When a model call's first byte arrived. */ firstByte?: number; input?: number; output?: number; cached?: number; failed: boolean; error?: string; /** Still running: its end is now. */ open?: boolean; /** A tool call's input entry, when a page only referenced its input. */ entry?: string; /** A tool call's transcript entries: the call, and its result once there is one. */ call?: string; result?: string; /** A model call that wrote a compaction summary rather than a turn. */ compaction?: boolean; /** What a tool call did, from its trace: searches, reads, commands, MCP calls, edits. */ did?: CallTrace; /** A tool call's result entry, when a page only referenced its trace. */ traced?: string; /** A tool call's turn, and where it starts among what was read: what a review of its edits opens. */ turn?: { id: string; from: number }; /** A tool call's input, as far as search looks. */ source?: string;}
/** A background job a lane started, on the same clock as its calls. */export interface JobSpan { id: string; command: string; start: number; end: number; running: boolean; exit: number | null; timedOut: boolean; service: boolean;}
export interface Lane { id: string; jobs: JobSpan[]; name: string; model: string; parent: string | null; depth: number; steps: Step[]; start: number; end: number; busy: boolean;}
export interface Link { id: string; from: string; to: string; kind: 'spawn' | 'reply';}
/** A message the person sent while the trace follows its session. */export interface Mark { at: number; text: string;}
export interface Trace { title: string; /** It follows its session: every turn after the first is added as it comes. */ follows: boolean; marks: Mark[]; start: number; end: number; lanes: Lane[]; links: Link[]; live: boolean;}
export interface Totals { duration: number; input: number; output: number; cached: number; calls: number; tools: number; agents: number; errors: number;}
const PAGE = 200;/** How deep agents nest (robot-docs/agents.md). */const DEPTH = 3;/** A gap shorter than this between a parent's steps is not waiting. */const WAIT_MIN = 1500;/** Pages of history read per agent before its older work is left out. */const HISTORY_PAGES = 30;
const at = (iso: string | null | undefined) => (iso ? Date.parse(iso) : NaN);const ended = (row: RequestRecord) => at(row.ended_at ?? row.started_at);
function textOf(entry: HistoryEntry): string { return entry.content.map((part) => (part.kind === 'text' ? part.text : '')).join('');}
function firstLine(text: string): string { return text.split('\n').map((line) => line.trim()).find(Boolean) ?? '';}
/** How much of a tool call's input search reads. */const SOURCE = 4000;
/** A tool call's code, or its first text argument, and that as one line. */export interface Input { line: string; source: string;}
function inputOf(title: string | null | undefined, args: unknown): Input | null { if (title) { const fields = args && typeof args === 'object' ? (args as Record<string, unknown>) : {}; const code = typeof fields.code === 'string' ? fields.code : Object.values(fields).find((value) => typeof value === 'string'); return { line: title.slice(0, 160), source: typeof code === 'string' ? code.slice(0, SOURCE) : title }; } if (!args || typeof args !== 'object') return null; const fields = args as Record<string, unknown>; const code = typeof fields.code === 'string' ? fields.code : Object.values(fields).find((value) => typeof value === 'string'); if (typeof code !== 'string') return null; return { line: firstLine(code).slice(0, 160), source: code.slice(0, SOURCE) };}
/** * The task's entries: the turn at `from` and the turns after it, until the * person has started `turns` turns, or every later turn when `turns` is * null. `newest` says no later message from the person came after them. */export async function readTask(sessionId: string, from: number, turns: number | null, signal?: AbortSignal, onstart?: (time: number) => void): Promise<{ entries: HistoryEntry[]; newest: boolean }> { const entries: HistoryEntry[] = []; let after = from - 1; let turn: string | null = null; let started = 0; for (;;) { const page = await readHistory(sessionId, { after, limit: 200 }, signal); for (const entry of page.items) { if (entry.position <= after) continue; if (!entries.length) onstart?.(at(entry.created_at)); if (entry.turn_id !== turn && (turn === null || entry.turn_type === 'user')) { turn = entry.turn_id; started += 1; if (turns !== null && started > turns) return { entries, newest: false }; } entries.push(entry); } if (!page.newer || !page.items.length) return { entries, newest: true }; after = page.items.at(-1)!.position; }}
/** Where the person's message before `position` begins, or null when there is none. */export async function turnBefore(sessionId: string, position: number, signal?: AbortSignal): Promise<number | null> { let before = position; for (;;) { const page = await readHistory(sessionId, { before }, signal); const said = page.items.filter((entry) => entry.position < position && entry.kind === 'user' && entry.turn_type === 'user').at(-1); if (said) return said.position; if (!page.older || !page.items.length) return null; before = page.items[0].position; }}
/** An agent's entries inside a window, read back from its newest. */async function readWindow(sessionId: string, start: number, end: number, signal?: AbortSignal): Promise<HistoryEntry[]> { const found: HistoryEntry[] = []; let before: number | undefined; for (let pages = 0; pages < HISTORY_PAGES; pages++) { const page = await readHistory(sessionId, before === undefined ? {} : { before }, signal); for (const entry of page.items) { const time = at(entry.created_at); if (time >= start && time <= end) found.push(entry); } const oldest = page.items[0]; if (!page.older || !oldest || at(oldest.created_at) < start) break; before = oldest.position; } return found.sort((a, b) => a.position - b.position);}
/** Request rows read so far, and the sequence to read on from, or null at the last row. */type Found = { rows: RequestRecord[]; next: number | null };
/** Probes a search sends at once: each round narrows the range ninefold. */const PROBES = 8;
/** * The sequence a session's model calls overlapping a window can be read * after. Requests are paged by sequence, oldest first, so a long session * finds where the window starts by probing single rows, several at once, * rather than reading every page before it. */async function probeRequestsStart(sessionId: string, start: number, signal?: AbortSignal): Promise<Found> { const first = await readRequests(sessionId, 0, PAGE, signal); const last = first.items.at(-1); if (!last || !first.next || ended(last) >= start) return { rows: first.items, next: first.next && last ? last.sequence : null }; // The row after each probe is the session's next call: one that ended // before the window raises the floor to its own sequence. const probe = async (after: number) => (await readRequests(sessionId, after, 1, signal)).items[0] ?? null; let low = last.sequence; let high: number | null = null; const narrow = async (points: number[]) => { const found = await Promise.all(points.map(probe)); points.forEach((point, index) => { const row = found[index]; if (row && ended(row) < start) low = Math.max(low, row.sequence); else high = Math.min(high ?? point, point); }); }; while (high === null) await narrow(Array.from({ length: PROBES }, (_, index) => low * 2 ** (index + 1))); while (high - low > 1) { const step = (high - low) / (PROBES + 1); const points = [...new Set(Array.from({ length: PROBES }, (_, index) => Math.round(low + step * (index + 1))))].filter((point) => point > low && point < high!); if (!points.length) break; await narrow(points); } return { rows: [], next: low };}
// Capability detection is shared by all lanes, including simultaneous first reads.let sinceSupported: boolean | undefined;let checkingSince: Promise<void> | undefined;
async function requestsStart(sessionId: string, start: number, signal?: AbortSignal): Promise<Found> { if (checkingSince) await checkingSince; if (sinceSupported === false) return probeRequestsStart(sessionId, start, signal); const read = readRequests(sessionId, 0, PAGE, signal, Math.max(0, Math.floor(start))); if (sinceSupported === undefined) checkingSince = read.then(() => { sinceSupported = true; }, (error) => { if (error instanceof ApiError && error.status === 400) sinceSupported = false; }); try { const page = await read; return { rows: page.items, next: page.next ? page.items.at(-1)!.sequence : null }; } catch (error) { if (!(error instanceof ApiError) || error.status !== 400) throw error; sinceSupported = false; return probeRequestsStart(sessionId, start, signal); } finally { checkingSince = undefined; }}
/** A provider's failure as a sentence: the message inside a JSON error body, or the text as it came. */function reason(detail: string | undefined): string | undefined { if (!detail) return undefined; try { const body = JSON.parse(detail); return body?.error?.message ?? body?.message ?? detail; } catch { return detail; }}
function modelStep(lane: string, row: RequestRecord, now: number): Step { const start = at(row.started_at); const end = row.ended_at ? at(row.ended_at) : now; const input = row.prompt_tokens.observed ?? undefined; const output = row.completion_tokens.observed ?? undefined; return { id: `${lane}:request:${row.sequence}`, lane, kind: 'model', start, end, name: row.kind === 'summarizer' ? 'compaction summary' : row.model, compaction: row.kind === 'summarizer', detail: row.effort ? `${row.model} · ${row.effort}` : row.model, firstByte: row.first_byte_ms === null ? undefined : start + row.first_byte_ms, input, output, cached: row.cached_tokens.observed ?? undefined, failed: row.outcome === 'failed', error: reason(row.failure?.detail) || (row.http_status ? `HTTP ${row.http_status}` : undefined), open: !row.ended_at, };}
/** Tool calls from a call's entry to its result's; one still running ends now. */function toolSteps(lane: string, entries: HistoryEntry[], now: number): Step[] { const results = new Map(entries.filter((entry) => entry.kind === 'tool_result' && entry.tool).map((entry) => [entry.tool!.tool_call_id, entry])); const starts = new Map<string, number>(); for (const entry of entries) if (entry.turn_id && !starts.has(entry.turn_id)) starts.set(entry.turn_id, entry.position); return entries .filter((entry) => entry.kind === 'tool_call' && entry.tool) .map((call) => { const result = results.get(call.tool!.tool_call_id); const input = inputOf(call.tool?.title, jsonField(call, 'arguments')); const did = result?.content.find((part) => part.kind === 'trace'); const traced = result?.content.some((part) => part.kind === 'reference' && part.reference.field === 'trace'); return { id: `${lane}:tool:${call.tool!.tool_call_id}`, lane, kind: 'tool' as const, start: at(call.created_at), end: result ? at(result.created_at) : now, name: call.tool!.name, detail: input?.line ?? '', source: input?.source, failed: !!result && failedTool(result), open: !result, entry: input ? undefined : call.id, call: call.id, result: result?.id, did: did?.kind === 'trace' ? did.trace : undefined, traced: traced ? result!.id : undefined, turn: call.turn_id ? { id: call.turn_id, from: starts.get(call.turn_id)! } : undefined, }; }) .filter((step) => Number.isFinite(step.start));}
/** Gaps in a parent's work while an agent below it runs. */function waits(lane: Lane, below: Lane[]): Step[] { const found: Step[] = []; for (let index = 1; index < lane.steps.length; index++) { const start = lane.steps[index - 1].end; const end = lane.steps[index].start; if (end - start < WAIT_MIN) continue; if (!below.some((other) => other.steps.some((step) => step.start < end && step.end > start))) continue; found.push({ id: `${lane.id}:wait:${index}`, lane: lane.id, kind: 'waiting', start, end, name: 'waiting', detail: 'for an agent', failed: false }); } return found;}
function laneOf(session: Session, parent: string | null, depth: number, steps: Step[], jobs: JobSpan[] = []): Lane { const sorted = steps.sort((a, b) => a.start - b.start || a.end - b.end); return { id: session.id, jobs, name: session.name || 'untitled session', model: session.model ?? '', parent, depth, steps: sorted, start: sorted[0]?.start ?? Infinity, end: sorted.reduce((latest, step) => Math.max(latest, step.end), -Infinity), busy: busy(session), };}
/** * A session's background jobs that ran inside a window. The kernel keeps * only its recent jobs, and a restarted kernel none, so older ones are gone. */function jobsBetween(jobs: Job[], start: number, end: number, now: number): JobSpan[] { return jobs .map((job) => ({ id: job.id, command: job.command, start: job.started_at, end: job.ended_at ?? now, running: job.state === 'running', exit: job.exit_code, timedOut: job.timed_out, service: job.service, })) .filter((job) => job.start && job.start <= end && job.end >= start) .sort((a, b) => a.start - b.start);}
interface LaneCursor { entries: HistoryEntry[]; rows: RequestRecord[]; sequence: number; children?: Session[]; activity?: string | null; running?: boolean; jobs?: Job[]; tools?: Step[]; processed?: number;}
/** Cursors belong to one trace window and are dropped after a failed read. */export class TraceCursor { key = ''; lanes = new Map<string, LaneCursor>();
reset() { this.key = ''; this.lanes.clear(); }}
function mergedTools(id: string, lane: LaneCursor, entries: HistoryEntry[], now: number): Step[] { const fresh = entries.filter((entry) => entry.position > (lane.processed ?? -1)); const pending = new Set(lane.tools?.filter((step) => step.open).map((step) => step.call)); const calls = entries.filter((entry) => pending.has(entry.id)); const updated = toolSteps(id, [...calls, ...fresh], now); const known = new Map(lane.tools?.map((step) => [step.id, step])); const starts = new Map<string, number>(); for (const entry of entries) if (entry.turn_id && !starts.has(entry.turn_id)) starts.set(entry.turn_id, entry.position); for (const step of updated) { if (step.turn) step.turn.from = starts.get(step.turn.id)!; known.set(step.id, step); } lane.processed = entries.at(-1)?.position; lane.tools = [...known.values()]; return lane.tools;}
function cursorFor(cursor: TraceCursor, id: string): LaneCursor { let lane = cursor.lanes.get(id); if (!lane) { lane = { entries: [], rows: [], sequence: -1 }; cursor.lanes.set(id, lane); } return lane;}
async function appendHistory(id: string, lane: LaneCursor, signal?: AbortSignal): Promise<HistoryEntry[]> { const fresh = await readTask(id, (lane.entries.at(-1)?.position ?? 0) + 1, null, signal); lane.entries = [...lane.entries, ...fresh.entries]; return lane.entries;}
function nextRequests(id: string, lane: LaneCursor, start: number, signal?: AbortSignal): Promise<Found> { if (lane.sequence < 0) return requestsStart(id, start, signal); return readRequests(id, lane.sequence, PAGE, signal).then((page) => ({ rows: page.items, next: page.next ? page.items.at(-1)!.sequence : null }));}
async function appendRequests(id: string, lane: LaneCursor, start: number, end: number, signal?: AbortSignal, first = nextRequests(id, lane, start, signal)): Promise<RequestRecord[]> { const found = await first; let sequence = Math.max(0, lane.sequence); const track = (rows: RequestRecord[]) => { for (const row of rows) sequence = Math.max(sequence, row.sequence); return rows; }; track(found.rows); // Keep background calls out of the lane, but advance past them too. const rows = [...found.rows]; let next = found.rows.length && at(found.rows.at(-1)!.started_at) > end ? null : found.next; while (next !== null) { const page = await readRequests(id, next, PAGE, signal); rows.push(...track(page.items)); const tail = page.items.at(-1); next = page.next && tail && at(tail.started_at) <= end ? tail.sequence : null; } lane.sequence = sequence; lane.rows = [...lane.rows, ...rows.filter((row) => row.kind !== 'background' && ended(row) >= start)]; return lane.rows.filter((row) => at(row.started_at) <= end);}
/** * Reads the trace of `turns` turns from `from` in a session. One that * reaches the newest turn follows the session (`follows` undefined asks * which it is); one that ends on an older turn stays as it is. `onroot` * gets the session's own lane as soon as it is read, before its agents. */async function readTraceFrom( session: Session, from: number, turns: number, follows: boolean | undefined, signal?: AbortSignal, onroot?: (trace: Trace) => void, cursor = new TraceCursor(),): Promise<Trace> { const key = `${session.id}:${from}:${turns}`; if (cursor.key !== key || follows === false) cursor.reset(); cursor.key = key; const saved = cursorFor(cursor, session.id); const now = Date.now(); // What does not wait on the task's history starts with it: the agents, // the jobs, and the search for where its model calls begin, once the // first page says when the task opened. const children = listChildren(session.id, signal).then((children) => { saved.children = children; return children; }); const jobs = listJobs(session.id, signal).catch((): Job[] => []); let started = (_time: number) => {}; const begun = new Promise<number>((resolve) => (started = resolve)).then((time) => nextRequests(session.id, saved, time - 1000, signal)); // Read as a task, the newest turns already hold everything after `from`. const incremental = saved.entries.length > 0 && follows === true; const { entries, newest } = incremental ? { entries: await appendHistory(session.id, saved, signal), newest: true } : await readTask(session.id, from, follows ? null : turns, signal, started); saved.entries = entries; const following = follows ?? newest; const opened = at(entries[0]?.created_at); started(opened || now); // A followed session that works is still going; otherwise the task ends with its last entry. const closed = entries.reduce((latest, entry) => Math.max(latest, at(entry.created_at) || 0), opened); const going = following && busy(session); const window = { start: opened, end: going ? now : closed, follows: following }; const rows = await appendRequests(session.id, saved, window.start - 1000, window.end + 1000, signal, begun); const steps = [...rows.map((row) => modelStep(session.id, row, now)), ...mergedTools(session.id, saved, entries, now)]; const root = { ...laneOf(session, null, 0, steps, jobsBetween(await jobs, window.start, window.end, now)), busy: going }; window.end = Math.max(window.end, root.end);
const said = entries.filter((entry) => entry.kind === 'user' && entry.turn_type === 'user'); const assemble = (lanes: Lane[], links: Link[]): Trace => ({ title: firstLine(said[0] ? textOf(said[0]) : '').slice(0, 200), follows: following, marks: said.slice(1).map((entry) => ({ at: at(entry.created_at), text: firstLine(textOf(entry)).slice(0, 200) })), start: Math.min(window.start, ...lanes.map((lane) => lane.start)), end: Math.max(window.end, ...lanes.map((lane) => lane.end)), lanes: lanes.filter((lane) => lane.steps.length), links, live: lanes.some((lane) => lane.busy), }); onroot?.(assemble([root], []));
const lanes: Lane[] = [root]; const links: Link[] = []; const mail = entries.filter((entry) => entry.mail?.sender_session_id); await agentsOf(root, children, window, now, lanes, links, cursor, signal); for (const lane of lanes) { if (lane === root) continue; // A reply: the agent's mail landing, and the parent's next step. const landed = mail.find((entry) => entry.mail!.sender_session_id === lane.id); const time = at(landed?.created_at); const next = root.steps.find((step) => step.start >= time); const before = [...lane.steps].reverse().find((step) => step.end <= time + 1000); if (landed && next && before) links.push({ id: `reply:${lane.id}`, from: before.id, to: next.id, kind: 'reply' }); } for (const lane of lanes) { const below = lanes.filter((other) => other.parent === lane.id); if (below.length) lane.steps = [...lane.steps, ...waits(lane, below)].sort((a, b) => a.start - b.start); } return assemble(lanes, links);}
/** Reads new rows from a window's cursors; a failed read discards them. */export async function readTrace( session: Session, from: number, turns: number, follows: boolean | undefined, signal?: AbortSignal, onroot?: (trace: Trace) => void, cursor = new TraceCursor(),): Promise<Trace> { try { return await readTraceFrom(session, from, turns, follows, signal, onroot, cursor); } catch (error) { cursor.reset(); throw error; }}
/** Adds the agents a lane started inside the window, depth first, with their spawn links. */async function agentsOf(parent: Lane, listed: Promise<Session[]>, window: { start: number; end: number; follows: boolean }, now: number, lanes: Lane[], links: Link[], cursor: TraceCursor, signal?: AbortSignal) { if (parent.depth >= DEPTH) return; const children = (await listed).filter((child) => { const born = at(child.created_at); return born <= window.end && at(child.activity_at ?? child.created_at) >= window.start; }); const read = await Promise.all( children.map(async (child) => { const end = window.follows ? Math.max(window.end, at(child.activity_at) || 0, busy(child) ? now : 0) : window.end; const span = { start: Math.max(window.start, at(child.created_at)), end }; // Its own agents are listed while it is read. const saved = cursorFor(cursor, child.id); const activity = child.activity_at ?? child.created_at; const changed = saved.activity !== activity || busy(child) || saved.running || !!saved.tools?.some((step) => step.open); const discover = changed || !saved.children || saved.children.some(busy); const below = parent.depth + 1 < DEPTH && discover ? listChildren(child.id, signal) : Promise.resolve(saved.children ?? []); let history = Promise.resolve(saved.entries); if (!saved.entries.length) history = readWindow(child.id, span.start, span.end, signal); else if (changed) history = appendHistory(child.id, saved, signal); const [entries, rows, jobs] = await Promise.all([ history, changed ? appendRequests(child.id, saved, span.start, span.end, signal) : Promise.resolve(saved.rows), changed || saved.jobs?.some((job) => job.state === 'running') ? listJobs(child.id, signal) : Promise.resolve(saved.jobs ?? []), ]); saved.entries = entries; saved.children = await below; saved.activity = activity; saved.running = busy(child); saved.jobs = jobs; return { child, below, jobs: jobsBetween(jobs, span.start, span.end, now), steps: [...rows.map((row) => modelStep(child.id, row, now)), ...mergedTools(child.id, saved, entries.filter((entry) => at(entry.created_at) <= span.end), now)] }; }), ); for (const { child, below, steps, jobs } of read.sort((a, b) => at(a.child.created_at) - at(b.child.created_at))) { const lane = laneOf(child, parent.id, parent.depth + 1, steps, jobs); if (!lane.steps.length) continue; lanes.push(lane); // The call that started it: the parent's tool call running when it was born. const born = at(child.created_at); const spawner = parent.steps.find((step) => step.kind === 'tool' && step.start <= born && step.end >= born - 500); if (spawner) links.push({ id: `spawn:${lane.id}`, from: spawner.id, to: lane.steps[0].id, kind: 'spawn' }); await agentsOf(lane, below, window, now, lanes, links, cursor, signal); }}
/** What a page only referenced of a tool call: its input, and what it did. */export interface Details { input?: Input; did?: CallTrace;}
/** * The parts of tool calls the history pages only referenced, by step id: * read after the trace shows, in one walk per agent. */export async function details(trace: Trace, signal?: AbortSignal, known?: ReadonlyMap<string, Details>): Promise<Map<string, Details>> { const found = new Map<string, Details>(); await Promise.all( trace.lanes.map(async (lane) => { const wanted = lane.steps.filter((step) => (step.entry && !known?.get(step.id)?.input) || (step.traced && !known?.get(step.id)?.did)); for (let index = 0; index < wanted.length; index += 100) { const batch = wanted.slice(index, index + 100); const ids = batch.flatMap((step) => [step.entry, step.traced].filter((id): id is string => !!id)); const fields = await readContentBatch(lane.id, ids, signal); for (const step of batch) { const input = parsed(step.entry && fields.get(step.entry)?.arguments); const did = parsed(step.traced && fields.get(step.traced)?.trace); found.set(step.id, { input: inputOf(step.detail, input) ?? known?.get(step.id)?.input, did: did && typeof did === 'object' ? (did as CallTrace) : known?.get(step.id)?.did }); } } }), ); return found;}
function parsed(raw: string | undefined | null | false): unknown { if (!raw) return undefined; try { return JSON.parse(raw); } catch { return undefined; }}
/** A stretch with nothing running, longer than this, is folded into a break. */const IDLE = 20_000;/** How much time a folded break takes on the axis. */const BREAK = 2_500;
/** An idle stretch folded out of the axis: where it sits, and how long it really was. */export interface Break { at: number; width: number; idle: number;}
/** * The trace's axis, with idle stretches folded so hours away from the chat * do not crowd out the work. `at` maps a time to a fraction of the axis and * `time` maps back; `total` is the axis length in milliseconds of work. */export interface Clock { at: (time: number) => number; time: (fraction: number) => number; total: number; breaks: Break[]; /** Round elapsed times to label, about one per `every` of the axis. */ ticks: (from: number, to: number, every: number) => { value: number; at: number }[];}
/** * `horizon`, when later than the trace's end, is where a live trace's axis * ends: room ahead of now, so the calls already drawn hold still as it grows. */export function clockOf(trace: Trace, horizon = trace.end): Clock { const spans = [ ...trace.lanes.flatMap((lane) => lane.steps.map((step) => [step.start, step.end] as const)), ...trace.marks.map((mark) => [mark.at, mark.at] as const), [trace.start, trace.start] as const, [trace.end, trace.end] as const, ].sort((a, b) => a[0] - b[0]); // Busy stretches: spans that overlap or lie closer than IDLE together. const busy: [number, number][] = []; for (const [start, end] of spans) { const last = busy.at(-1); if (last && start - last[1] < IDLE) last[1] = Math.max(last[1], end); else busy.push([start, end]); } // The room ahead belongs to the last stretch, so it never folds away. const last = busy.at(-1); if (last) last[1] = Math.max(last[1], horizon); const domain: number[] = []; const range: number[] = []; const breaks: Break[] = []; let length = 0; for (const [index, [start, end]] of busy.entries()) { if (index) { breaks.push({ at: length, width: BREAK, idle: start - domain.at(-1)! }); length += BREAK; } domain.push(start, Math.max(end, start + 1)); range.push(length, (length += Math.max(end - start, 1))); } const scale = scaleLinear().domain(domain).range(range).clamp(true); const total = Math.max(1, length); return { at: (time) => scale(time) / total, time: (fraction) => scale.invert(fraction * total), total, breaks: breaks.map((gap) => ({ ...gap, at: gap.at / total, width: gap.width / total })), ticks(from, to, every) { const found: { value: number; at: number }[] = []; for (const [start, end] of busy) { const first = scale(start) / total; const last = scale(end) / total; if (last < from || first > to) continue; const count = Math.max(1, Math.round((last - first) / every)); for (const value of scaleLinear().domain([start - trace.start, end - trace.start]).ticks(count)) { const at = scale(trace.start + value) / total; if (at >= from && at <= to && (!found.length || at - found.at(-1)!.at >= every * 0.6)) found.push({ value, at }); } } return found; }, };}
export type Spent = Record<'thinking' | 'running' | 'agents' | 'you' | 'between', number>;
/** * Where the task's own time went, up to `until`: the model thinking, its * code running, waiting for its agents, waiting for the person (an idle * stretch that ends with their message), and the gaps between steps. */export function spent(trace: Trace, until = trace.end): Spent { const totals: Spent = { thinking: 0, running: 0, agents: 0, you: 0, between: 0 }; const root = trace.lanes.find((lane) => !lane.parent); const end = Math.min(until, trace.end); if (!root || end <= trace.start) return totals; const kinds: Record<StepKind, keyof Spent> = { model: 'thinking', tool: 'running', waiting: 'agents' }; const order: (keyof Spent)[] = ['thinking', 'running', 'agents']; const edges = [...new Set([trace.start, end, ...root.steps.flatMap((step) => [step.start, step.end])])].filter((time) => time >= trace.start && time <= end).sort((a, b) => a - b); for (let index = 1; index < edges.length; index++) { const [from, to] = [edges[index - 1], edges[index]]; const middle = (from + to) / 2; const active = root.steps.filter((step) => step.start <= middle && step.end >= middle).map((step) => kinds[step.kind]); const kind = order.find((candidate) => active.includes(candidate)); if (kind) totals[kind] += to - from; // An idle stretch the person ended by writing was spent waiting for them. else if (to - from >= IDLE && trace.marks.some((mark) => mark.at >= to - 5000 && mark.at <= to + 1000)) totals.you += to - from; else totals.between += to - from; } return totals;}
export function totals(trace: Trace): Totals { const steps = trace.lanes.flatMap((lane) => lane.steps); const models = steps.filter((step) => step.kind === 'model'); const sum = (pick: (step: Step) => number | undefined) => models.reduce((total, step) => total + (pick(step) ?? 0), 0); return { duration: trace.end - trace.start, input: sum((step) => step.input), output: sum((step) => step.output), cached: sum((step) => step.cached), calls: models.length, tools: steps.filter((step) => step.kind === 'tool').length, agents: trace.lanes.length - 1, errors: steps.filter((step) => step.failed).length, };}
/** A span of time, short: 820ms, 4.2s, 3m 10s, 1h 4m. */export function span(ms: number): string { if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`; if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`; if (ms < 60_000) return `${Math.round(ms / 1000)}s`; const minutes = Math.floor(ms / 60_000); if (minutes < 60) return `${minutes}m ${Math.round((ms % 60_000) / 1000)}s`; return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;}
/** A token count, short: 940, 11.3k, 1.2M. */export function count(tokens: number): string { if (tokens < 1000) return String(tokens); if (tokens < 1_000_000) return `${(tokens / 1000).toFixed(tokens < 10_000 ? 1 : 0)}k`; return `${(tokens / 1_000_000).toFixed(1)}M`;}