Server (SvelteKit + SQLite Docker container) + per-machine agent that parses Claude Code JSONL transcripts. docker-compose one-command deploy; raw transcript view + search gated behind SHOW_TRANSCRIPTS (hidden by default). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
273 lines
7.6 KiB
TypeScript
273 lines
7.6 KiB
TypeScript
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<string, string | null>(contentRows.map((c) => [c.uuid, c.text]));
|
|
|
|
// tool calls hang off the assistant event that invoked them (event_uuid).
|
|
const toolsByEvent = new Map<string, TranscriptToolCall[]>();
|
|
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 };
|
|
};
|