import { error } from '@sveltejs/kit'; import { db } from '$lib/server/db'; import { showTranscripts } from '$lib/server/config'; import { costFor, type TokenCounts } from '$lib/server/pricing'; import type { PageServerLoad } from './$types'; /** * Per-session transcript view. This SQL is session-specific (it drives one page, * not a dashboard aggregate) so it lives here rather than in `queries.ts` — same * ownership split the /search route follows. Everything filters on the composite * `(host, session_id)` identity (indexed by `idx_events_session`), and any `$` * figure is computed with `costFor()` — only `is_usage_canonical` rows count for * tokens/$, matching the rest of the app. */ export interface TranscriptToolCall { toolUseId: string; toolName: string; inputJson: string | null; isError: boolean; resultBytes: number | null; durationMs: number | null; tsUtc: string | null; } export interface TranscriptTurn { uuid: string; tsUtc: string | null; type: string; // user | assistant | system | summary | tool (synthetic) role: string | null; model: string | null; text: string | null; countsTowardUsage: boolean; inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; totalTokens: number; costUsd: number; toolCalls: TranscriptToolCall[]; } export interface SessionMeta { host: string; sessionId: string; project: string | null; gitBranch: string | null; ccVersion: string | null; entrypoint: string | null; startedAt: string | null; endedAt: string | null; } export interface SessionTotals { inputTokens: number; outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; totalTokens: number; costUsd: number; eventCount: number; toolCallCount: number; firstTs: string | null; lastTs: string | null; } interface EventRow { uuid: string; ts_utc: string | null; type: string; role: string | null; model: string | null; is_usage_canonical: number; input_tokens: number | null; output_tokens: number | null; cache_creation_tokens: number | null; cache_read_tokens: number | null; } interface ContentRow { uuid: string; role: string | null; text: string | null; } interface ToolRow { tool_use_id: string; event_uuid: string | null; tool_name: string; input_json: string | null; is_error: number | null; result_bytes: number | null; duration_ms: number | null; ts_utc: string | null; } export const load: PageServerLoad = async ({ params }) => { // Raw transcript text is hidden while the dashboard is publicly reachable. Never touch // the DB in this case — refuse before reading any conversation content. if (!showTranscripts) { throw error(403, 'Transcript view is disabled'); } // Route params arrive URL-encoded (session ids are UUIDs, hosts are hostnames). const host = decodeURIComponent(params.host); const sessionId = decodeURIComponent(params.sessionId); const dbh = db(); const session = dbh .prepare( `SELECT host, session_id, project, git_branch, cc_version, entrypoint, started_at, ended_at FROM sessions WHERE host = ? AND session_id = ?` ) .get(host, sessionId) as | { host: string; session_id: string; project: string | null; git_branch: string | null; cc_version: string | null; entrypoint: string | null; started_at: string | null; ended_at: string | null; } | undefined; const eventRows = dbh .prepare( `SELECT uuid, ts_utc, type, role, model, is_usage_canonical, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens FROM events WHERE host = ? AND session_id = ? ORDER BY datetime(ts_utc), uuid` ) .all(host, sessionId) as EventRow[]; if (!session && eventRows.length === 0) { throw error(404, 'Session not found'); } const contentRows = dbh .prepare(`SELECT uuid, role, text FROM content WHERE host = ? AND session_id = ?`) .all(host, sessionId) as ContentRow[]; const toolRows = dbh .prepare( `SELECT tool_use_id, event_uuid, tool_name, input_json, is_error, result_bytes, duration_ms, ts_utc FROM tool_calls WHERE host = ? AND session_id = ? ORDER BY datetime(ts_utc), tool_use_id` ) .all(host, sessionId) as ToolRow[]; // content is 1:1 with an event uuid; join on it. const textByUuid = new Map(contentRows.map((c) => [c.uuid, c.text])); // tool calls hang off the assistant event that invoked them (event_uuid). const toolsByEvent = new Map(); const orphanTools: TranscriptToolCall[] = []; const eventUuids = new Set(eventRows.map((e) => e.uuid)); for (const t of toolRows) { const tc: TranscriptToolCall = { toolUseId: t.tool_use_id, toolName: t.tool_name, inputJson: t.input_json, isError: !!t.is_error, resultBytes: t.result_bytes, durationMs: t.duration_ms, tsUtc: t.ts_utc }; if (t.event_uuid && eventUuids.has(t.event_uuid)) { const list = toolsByEvent.get(t.event_uuid) ?? []; list.push(tc); toolsByEvent.set(t.event_uuid, list); } else { orphanTools.push(tc); } } const totals: SessionTotals = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, costUsd: 0, eventCount: eventRows.length, toolCallCount: toolRows.length, firstTs: eventRows.length ? eventRows[0].ts_utc : null, lastTs: eventRows.length ? eventRows[eventRows.length - 1].ts_utc : null }; const turns: TranscriptTurn[] = eventRows.map((e) => { const canonical = e.is_usage_canonical === 1; const counts: TokenCounts = { input_tokens: e.input_tokens, output_tokens: e.output_tokens, cache_creation_tokens: e.cache_creation_tokens, cache_read_tokens: e.cache_read_tokens }; // Only canonical rows count for tokens/$ (dedup of streamed assistant lines). const inputTokens = canonical ? (e.input_tokens ?? 0) : 0; const outputTokens = canonical ? (e.output_tokens ?? 0) : 0; const cacheCreationTokens = canonical ? (e.cache_creation_tokens ?? 0) : 0; const cacheReadTokens = canonical ? (e.cache_read_tokens ?? 0) : 0; const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; const costUsd = canonical ? costFor(e.model, counts) : 0; totals.inputTokens += inputTokens; totals.outputTokens += outputTokens; totals.cacheCreationTokens += cacheCreationTokens; totals.cacheReadTokens += cacheReadTokens; totals.costUsd += costUsd; return { uuid: e.uuid, tsUtc: e.ts_utc, type: e.type, role: e.role, model: e.model, text: textByUuid.get(e.uuid) ?? null, countsTowardUsage: canonical, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, totalTokens, costUsd, toolCalls: toolsByEvent.get(e.uuid) ?? [] }; }); totals.totalTokens = totals.inputTokens + totals.outputTokens + totals.cacheCreationTokens + totals.cacheReadTokens; // Preserve any tool calls whose owning event wasn't ingested as synthetic turns. for (const t of orphanTools) { turns.push({ uuid: `tool:${t.toolUseId}`, tsUtc: t.tsUtc, type: 'tool', role: null, model: null, text: null, countsTowardUsage: false, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, costUsd: 0, toolCalls: [t] }); } if (orphanTools.length) { turns.sort((a, b) => (a.tsUtc ?? '').localeCompare(b.tsUtc ?? '')); } const meta: SessionMeta = { host, sessionId, project: session?.project ?? null, gitBranch: session?.git_branch ?? null, ccVersion: session?.cc_version ?? null, entrypoint: session?.entrypoint ?? null, startedAt: session?.started_at ?? null, endedAt: session?.ended_at ?? null }; return { meta, totals, turns }; };