toknmtr — self-hostable Claude Code usage & analytics dashboard

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>
This commit is contained in:
megaproxy 2026-07-08 19:33:55 +01:00
commit 71a60ab054
74 changed files with 15613 additions and 0 deletions

15
.dockerignore Normal file
View file

@ -0,0 +1,15 @@
node_modules
.git
build
.svelte-kit
data
.env
.env.*
!.env.example
*.db
*.db-*
*.sqlite
*.sqlite-*
.vscode
.DS_Store
Thumbs.db

13
.env.example Normal file
View file

@ -0,0 +1,13 @@
# toknmtr server config. Copy to `.env` (gitignored) before `docker compose up`.
# Only API_TOKEN is required — docker-compose.yml sets DB_PATH, the port, and body limit.
# --- server ---
API_TOKEN=change-me # Bearer token the agent must present to /api/ingest.
# Generate a strong one: openssl rand -hex 32
# SHOW_TRANSCRIPTS=true # Unset/false = HIDE the transcript view + full-text search
# (raw prompt/response text). Only enable behind auth.
# --- agent (informational) ---
# The per-machine agent reads these from ~/.toknmtr/env, NOT from this file (see ops/README.md):
# TOKNMTR_URL=http://<server-host>:3001 # base URL of your deployed server
# TOKNMTR_TOKEN=<same value as API_TOKEN above>

32
.gitignore vendored Normal file
View file

@ -0,0 +1,32 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# toknmtr data — never commit the SQLite DB or secrets
/data/
*.sqlite
*.sqlite-*
*.db
*.db-*
*.pem
*.key

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

9
.prettierignore Normal file
View file

@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

15
.prettierrc Normal file
View file

@ -0,0 +1,15 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

184
DEPLOY.md Normal file
View file

@ -0,0 +1,184 @@
# Deploying toknmtr — step by step
This walks you from a clone to a live dashboard with your own Claude Code usage flowing in.
toknmtr has **two halves**, and you set them up in order:
1. **Server** — one Docker container (dashboard + ingest API + SQLite). Run it once, anywhere
reachable from your machines (a home server, a NAS, or even your laptop).
2. **Agent** — a small script you run on **every machine where you use Claude Code**. It reads
that machine's transcripts and pushes them to the server. Nothing shows up until at least
one agent has run.
> **The server image holds no data.** The database is created empty in a Docker volume the
> first time the container starts. Everything you see in the dashboard came from *your* agent
> pushing *your* transcripts to *your* server.
---
## Prerequisites
**For the server:**
- A host with **Docker** + the **Compose plugin** (`docker compose version` works).
**For the agent (on each machine you use Claude Code):**
- **Node.js 24+** (`node --version`) — needed for `--experimental-strip-types`.
- A clone of this repo.
- `jq` (only if you use the auto-capture hook installer).
---
## Part 1 — Run the server
### 1.1 Clone and configure
```sh
git clone <this-repo-url> toknmtr
cd toknmtr
cp .env.example .env
```
Open `.env` and set a strong `API_TOKEN` — this is the shared secret between the server and
every agent. Generate one:
```sh
openssl rand -hex 32
```
Paste it as `API_TOKEN=...`. Leave `SHOW_TRANSCRIPTS` commented out for now (see
[Security](#security--exposure) below).
### 1.2 Start it
```sh
docker compose up -d --build
```
The first build takes a few minutes (it compiles `better-sqlite3`). When it finishes, the
dashboard is at **http://localhost:3001** (or `http://<server-host>:3001` from another
machine on your network).
### 1.3 Verify
```sh
# health check — should print {"ok":true,...}
curl -s http://localhost:3001/api/ingest
# dashboard should return 200
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/
```
The dashboard will be empty until you set up an agent (Part 2). The container restarts
automatically unless you stop it; the DB persists in the `toknmtr-data` Docker volume across
restarts and rebuilds.
**Common first-run issues**
- `API_TOKEN` error from compose → you didn't set it in `.env`.
- Port 3001 already in use → change the host side of the mapping in `docker-compose.yml`
(`"3001:3000"` → e.g. `"8099:3000"`), then `docker compose up -d`.
---
## Part 2 — Feed it data (the agent)
Do this on **each machine** where you run Claude Code. The agent reads
`~/.claude/projects/**/*.jsonl` and POSTs to your server.
### 2.1 Point the agent at your server
```sh
mkdir -p ~/.toknmtr
cat > ~/.toknmtr/env <<'EOF'
TOKNMTR_URL=http://<server-host>:3001
TOKNMTR_TOKEN=<the same API_TOKEN you put on the server>
EOF
chmod 600 ~/.toknmtr/env
```
Replace `<server-host>` with the server's hostname or LAN IP (use `localhost` if the agent
runs on the same box as the server). `TOKNMTR_TOKEN` **must exactly match** the server's
`API_TOKEN`.
### 2.2 Backfill existing history (one time)
From your clone of this repo on that machine:
```sh
node --experimental-strip-types agent/run.ts --backfill
```
This ingests every transcript already on disk. Refresh the dashboard — data should appear.
> Claude Code prunes local transcripts after `cleanupPeriodDays` (default 30), so backfill
> only reaches as far back as what's still on disk. From here on, the live hook (next step)
> captures everything going forward into the server's permanent DB.
### 2.3 Turn on live capture
```sh
ops/install-hook.sh
```
This registers a Claude Code `Stop` hook that pushes new activity at the end of every turn —
near-live, and near-zero cost to your session. It's additive and reversible
(`ops/install-hook.sh --remove`).
Optionally add a periodic reconcile sweep as a safety net (catches anything a missed hook or
a server-down window skipped):
```sh
ops/install-cron.sh
```
Full agent details — the `~/.toknmtr/env` format, the hook-vs-cron tradeoff, and
troubleshooting — are in **[ops/README.md](ops/README.md)**.
Re-ingesting is always safe: the server upserts idempotently on `host + session_id + uuid`,
so backfills and overlapping hook/cron sweeps never create duplicates.
---
## Security & exposure
**The dashboard has no login.** Treat it as trusted-network-only unless you add auth.
- **Raw conversation text is hidden by default.** The transcript view and full-text search —
the only surfaces that show verbatim prompts/responses — return `403` unless you set
`SHOW_TRANSCRIPTS=true`. Charts, KPIs, and session metadata are always visible. Only enable
`SHOW_TRANSCRIPTS` once the whole dashboard is behind authentication.
- **Don't put it on the public internet as-is.** Keep it on your LAN/VPN, or front it with a
reverse proxy that enforces auth (e.g. Caddy/nginx basic-auth, Authelia, Tailscale, etc.).
- The `API_TOKEN` gates ingest only, not the dashboard. Keep it secret; it lives in `.env`
(server) and `~/.toknmtr/env` (agents), both of which stay off git.
To reveal transcripts/search after you've added auth: set `SHOW_TRANSCRIPTS=true` in `.env`
and `docker compose up -d` again.
---
## Updating
```sh
cd toknmtr
git pull
docker compose up -d --build
```
Your data is untouched — it lives in the `toknmtr-data` volume, not the image.
On agent machines, `git pull` in the clone; the hook/cron run the updated code automatically.
---
## Uninstalling
```sh
# server
docker compose down # stop + remove the container (keeps the data volume)
docker compose down -v # ...and DELETE the database volume too
# agent (per machine)
ops/install-hook.sh --remove
ops/install-cron.sh --remove
rm -rf ~/.toknmtr
```

23
Dockerfile Normal file
View file

@ -0,0 +1,23 @@
# --- build stage ---
FROM node:24-slim AS build
WORKDIR /app
# build tools for better-sqlite3 if a prebuilt binary isn't available
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build && npm prune --omit=dev
# --- runtime stage ---
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV DB_PATH=/data/toknmtr.db
COPY --from=build /app/build ./build
COPY --from=build /app/node_modules ./node_modules
COPY package.json ./
VOLUME /data
EXPOSE 3000
CMD ["node", "build"]

101
README.md Normal file
View file

@ -0,0 +1,101 @@
# toknmtr
Self-hosted **Claude Code usage & analytics dashboard**. An **agent** on each of your
machines parses Claude Code's JSONL transcripts into a full event log and pushes it to a
**server** (one SvelteKit + SQLite container). The server stores everything — usage, tool
calls, commands, raw prompts/responses — and serves a dashboard plus a full-text-searchable
session archive.
It's two halves:
- **Server** (`src/`, shipped as a Docker image) — the dashboard, the ingest API, the DB.
- **Agent** (`agent/` + `ops/`, runs on each machine) — parses `~/.claude/projects/**/*.jsonl`
and POSTs to the server. Not part of the image; you run it wherever you use Claude Code.
The server image contains **no data** — the SQLite DB is created empty in a mounted volume on
first run. All conversation content only ever comes from *your* agent pushing *your*
transcripts to *your* server.
> **New here? Follow [DEPLOY.md](DEPLOY.md)** — a full step-by-step walkthrough (prerequisites,
> server, agent, security, updating). The sections below are the quick reference.
---
## 1. Run the server (Docker)
Requires Docker with the Compose plugin. From the repo root:
```sh
cp .env.example .env
# edit .env: set API_TOKEN to a long random secret
# openssl rand -hex 32
docker compose up -d --build
```
The dashboard is now at **http://localhost:3001**. The DB lives in the `toknmtr-data`
Docker volume; the container restarts unless stopped.
**Privacy note — the dashboard has no auth.** By default the two surfaces that expose
verbatim prompt/response text (the per-session **transcript view** and full-text **search**)
are **hidden**; charts, KPIs, and session metadata stay visible. Only set
`SHOW_TRANSCRIPTS=true` in `.env` once you've put the dashboard behind auth (reverse proxy,
VPN, etc.). Don't expose it to the public internet as-is.
### Config (`.env`)
| Var | Purpose |
| ------------------ | --------------------------------------------------------------- |
| `API_TOKEN` | **Required.** Bearer token the agent must send to `/api/ingest`. |
| `SHOW_TRANSCRIPTS` | `true` reveals transcript view + search. Unset = hidden (safe). |
| `PORT` | Host port is set in `docker-compose.yml` (`3001:3000`). |
---
## 2. Feeding it data (the agent)
The server starts empty. To populate it, run the agent on each machine where you use Claude
Code (needs Node 24+ for `--experimental-strip-types`). One-time setup per machine:
```sh
mkdir -p ~/.toknmtr
cat > ~/.toknmtr/env <<'EOF'
TOKNMTR_URL=http://<server-host>:3001
TOKNMTR_TOKEN=<the same API_TOKEN you set on the server>
EOF
chmod 600 ~/.toknmtr/env
```
Then, from a clone of this repo on that machine:
```sh
# one-time: ingest all transcripts already on disk
node --experimental-strip-types agent/run.ts --backfill
# ongoing capture — register the Stop hook so every turn pushes incrementally
ops/install-hook.sh
```
`ops/install-hook.sh` adds a near-zero-cost Claude Code `Stop` hook (additive + reversible
with `--remove`). An optional `ops/install-cron.sh` adds a reconcile sweep as a safety net.
Full agent/capture docs — the `~/.toknmtr/env` format, the hook-vs-cron tradeoff, backfill,
and troubleshooting — are in **[`ops/README.md`](ops/README.md)**.
Ingest is an idempotent upsert (keyed on `host + session_id + uuid`), so re-running the
backfill or overlapping hook/cron sweeps never duplicates data.
---
## 3. Development
Node 24, SvelteKit 2 (Svelte 5), TypeScript, `better-sqlite3`.
```sh
npm install
npm run dev # dev server
npm run check # typecheck
npm run build # production build → build/ (run with `node build`)
npm run lint # prettier + eslint
```
Pricing lives server-side in `src/lib/server/pricing.ts` — adding a model is a one-line
update. Because the subscription is flat-rate, all `$` figures are *notional* (API-equivalent).

66
agent/cursor.ts Normal file
View file

@ -0,0 +1,66 @@
/**
* toknmtr agent per-file byte-offset cursor.
*
* Persists, per transcript file, the byte offset up to which we've already parsed and
* pushed events, so re-runs only emit NEW bytes appended since last time. Stored as a
* single JSON file at ~/.toknmtr/cursors.json (one process at a time is assumed there's
* no file locking).
*
* Handles truncation/rotation: if a file's current size is smaller than the recorded
* offset, the file was truncated or replaced (e.g. a session id got reused, or the file
* was edited out from under us) getOffset() resets to 0 so the whole file is reparsed.
*/
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
export const CURSOR_FILE = join(homedir(), '.toknmtr', 'cursors.json');
interface CursorEntry {
offset: number;
size: number;
}
type CursorMap = Record<string, CursorEntry>;
let cache: CursorMap | null = null;
function load(): CursorMap {
if (cache) return cache;
try {
const parsed = JSON.parse(readFileSync(CURSOR_FILE, 'utf8')) as unknown;
cache = parsed && typeof parsed === 'object' ? (parsed as CursorMap) : {};
} catch {
cache = {};
}
return cache;
}
/** Persist the in-memory cursor map to disk. Call once after a batch of setOffset() calls. */
export function save(): void {
const map = load();
mkdirSync(dirname(CURSOR_FILE), { recursive: true });
writeFileSync(CURSOR_FILE, JSON.stringify(map, null, 2));
}
/**
* Byte offset to resume reading `filePath` from, given its current size on disk.
* Returns 0 (full reparse) if there's no recorded cursor, or if the file shrank since
* last run (truncated/rotated).
*/
export function getOffset(filePath: string, currentSize: number): number {
const entry = load()[filePath];
if (!entry) return 0;
if (entry.offset > currentSize) return 0;
return entry.offset;
}
/** Record the new offset/size for `filePath` in memory. Call save() to persist. */
export function setOffset(filePath: string, offset: number, size: number): void {
load()[filePath] = { offset, size };
}
/** Forget a file's cursor entirely (forces a full reparse on next run). */
export function resetOffset(filePath: string): void {
delete load()[filePath];
}

74
agent/hooks/toknmtr-capture.sh Executable file
View file

@ -0,0 +1,74 @@
#!/usr/bin/env bash
#
# toknmtr-capture.sh — Claude Code hook that triggers an incremental toknmtr agent sweep.
#
# Registered (by ops/install-hook.sh) under the Claude Code 'Stop' hook event, so it fires
# at the end of every assistant turn — see ops/install-hook.sh for the Stop-vs-SessionEnd
# tradeoff writeup.
#
# FAIL-OPEN CONTRACT — this script must NEVER block, slow down, or fail the Claude Code
# session it's attached to:
# - The actual agent run (`node agent/run.ts --once`) is launched with `nohup ... &` and
# disowned, with stdio redirected to a log file (never inherited from the hook's own
# stdio), so this script returns to Claude Code in a handful of milliseconds — it does
# NOT wait for the parse+push to finish.
# - `timeout` wraps the backgrounded agent run so a hung/unreachable server can never
# leave an orphaned process running forever.
# - Every prerequisite (project dir, config file, node, timeout) is individually checked;
# any miss just skips capture for this turn silently.
# - This script prints NOTHING to its own stdout/stderr (Claude Code parses Stop-hook
# stdout as potential JSON — e.g. {"decision":"block"} would force the session to keep
# going — so silence here is required, not just polite). All diagnostic output goes to
# $LOG_FILE instead.
# - Always exits 0, intentionally not using `set -e`: every step below is already
# individually guarded, so a failure anywhere means "skip capture this turn", never
# "fail the hook" / block the Stop event.
#
# Config: ~/.toknmtr/env (TOKNMTR_URL, TOKNMTR_TOKEN — see ops/README.md). Kept out of
# ~/.claude/settings.json so secrets aren't sitting in a file that's more likely to be
# shared, synced, or dumped for support/debugging.
#
# Override knobs (env, all optional):
# TOKNMTR_PROJECT_DIR path to the toknmtr repo checkout (default: ~/claude/projects/toknmtr)
# TOKNMTR_ENV_FILE path to the config file (default: ~/.toknmtr/env)
# TOKNMTR_LOG_FILE where backgrounded output is logged (default: ~/.toknmtr/capture.log)
# TOKNMTR_HOOK_TIMEOUT_S max seconds the backgrounded sweep may run (default: 25)
PROJECT_DIR="${TOKNMTR_PROJECT_DIR:-$HOME/claude/projects/toknmtr}"
CONFIG_FILE="${TOKNMTR_ENV_FILE:-$HOME/.toknmtr/env}"
LOG_FILE="${TOKNMTR_LOG_FILE:-$HOME/.toknmtr/capture.log}"
TIMEOUT_S="${TOKNMTR_HOOK_TIMEOUT_S:-25}"
# Claude Code hooks receive a JSON payload on stdin describing the event. We don't need its
# contents (the agent re-walks transcripts itself from disk), but drain it anyway so we
# never leave the pipe half-read.
cat >/dev/null 2>&1 || true
# Bail out quietly (still exit 0) if any prerequisite is missing — never surface a hook
# failure to the session over a merely-unconfigured machine.
[ -d "$PROJECT_DIR" ] || exit 0
[ -f "$PROJECT_DIR/agent/run.ts" ] || exit 0
[ -f "$CONFIG_FILE" ] || exit 0
command -v node >/dev/null 2>&1 || exit 0
mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || exit 0
# Build the backgrounded command as a single string for `bash -c` so it can `cd`, source
# the config file (exporting TOKNMTR_URL/TOKNMTR_TOKEN into its own environment), and then
# exec node — all inside the detached child, never the foreground hook process.
read -r -d '' INNER_CMD <<EOF || true
cd "$PROJECT_DIR" || exit 0
set -a
. "$CONFIG_FILE"
set +a
exec node --experimental-strip-types agent/run.ts --once
EOF
if command -v timeout >/dev/null 2>&1; then
nohup timeout "${TIMEOUT_S}s" bash -c "$INNER_CMD" >>"$LOG_FILE" 2>&1 &
else
nohup bash -c "$INNER_CMD" >>"$LOG_FILE" 2>&1 &
fi
disown 2>/dev/null || true
exit 0

384
agent/parse.ts Normal file
View file

@ -0,0 +1,384 @@
/**
* toknmtr agent JSONL parser.
*
* Walks ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl and turns every line
* into a typed event the server can ingest. NOT bundled by Vite this runs
* standalone via the capture hook + cron (e.g. `node --experimental-strip-types
* agent/run.ts`).
*
* Real JSONL line shape (see CLAUDE.md / the shared agent contract):
* - Not every physical line is a trackable event: `mode`, `file-history-snapshot`,
* `attachment`, `ai-title`, `last-prompt`, etc. carry no top-level `uuid` and are
* skipped (parseLine returns null), but they (and every other line) may still carry
* `sessionId`/`cwd`/`gitBranch`/`version`/`entrypoint`/`timestamp`, which we scan for
* session metadata regardless of whether the line itself becomes an event.
* - Assistant turns STREAM: the same `message.id` repeats across several consecutive
* physical lines (each with its own unique top-level `uuid`, chained via `parentUuid`),
* each carrying a different slice of `message.content` (one block per line in
* practice: a `thinking` line, then a `text` line, then one `tool_use` line per tool
* call). Every physical line is still stored as its own `events` row.
* - `message.content` is either a plain string (typed user prompt) or an array of
* blocks: `{type:'text', text}`, `{type:'thinking', thinking}`,
* `{type:'tool_use', id, name, input}`, and on USER lines
* `{type:'tool_result', tool_use_id, content, is_error}` (content is a string or a
* content-block array).
*/
import { homedir } from 'node:os';
import { join } from 'node:path';
export const TRANSCRIPT_ROOT = join(homedir(), '.claude', 'projects');
export interface ParsedEvent {
host: string;
session_id: string;
uuid: string;
parent_uuid: string | null;
ts_utc: string;
type: string;
role: string | null;
model: string | null;
request_id: string | null;
message_id: string | null;
is_sidechain: boolean;
/** True for the one row per (session_id, message_id, request_id) group that should
* count toward token totals the final/max-output_tokens streamed line. */
is_usage_canonical: boolean;
stop_reason: string | null;
/** ms between this line's timestamp and its parent line's timestamp (assistant lines
* only, when the parent is known within the parsed chunk). */
latency_ms: number | null;
input_tokens: number | null;
output_tokens: number | null;
cache_creation_tokens: number | null;
cache_read_tokens: number | null;
web_search_requests: number | null;
web_fetch_requests: number | null;
/** Flattened visible text (joined `text` content blocks, or the raw string content of
* a plain-string user message). Does NOT include `thinking` or `tool_result` content. */
text: string | null;
}
/** Mirrors the `tool_calls` table. */
export interface ToolCall {
host: string;
session_id: string;
tool_use_id: string;
event_uuid: string | null;
tool_name: string;
input_json: string | null;
is_error: boolean | null;
result_bytes: number | null;
duration_ms: number | null;
ts_utc: string | null;
}
/** Mirrors the `sessions` table. */
export interface SessionMeta {
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;
}
interface ContentBlock {
type?: string;
text?: string;
thinking?: string;
id?: string;
name?: string;
input?: unknown;
tool_use_id?: string;
content?: unknown;
is_error?: boolean;
}
function asContentBlocks(content: unknown): ContentBlock[] {
return Array.isArray(content) ? (content as ContentBlock[]) : [];
}
/** Join all `text`-type content blocks, or return a plain-string message as-is. */
function flattenText(content: unknown): string | null {
if (typeof content === 'string') return content.length > 0 ? content : null;
const texts = asContentBlocks(content)
.filter(
(b): b is ContentBlock & { text: string } => b.type === 'text' && typeof b.text === 'string'
)
.map((b) => b.text);
return texts.length > 0 ? texts.join('\n\n') : null;
}
/** Byte length of tool_result content, which may be a string or a content-block array. */
function byteLengthOf(content: unknown): number {
if (typeof content === 'string') return Buffer.byteLength(content, 'utf8');
if (content === undefined || content === null) return 0;
try {
return Buffer.byteLength(JSON.stringify(content), 'utf8');
} catch {
return 0;
}
}
interface ParsedToolUse {
tool_use_id: string;
tool_name: string;
input_json: string;
}
interface ParsedToolResult {
tool_use_id: string;
is_error: boolean;
result_bytes: number;
}
export interface LineParseResult {
event: ParsedEvent;
toolUses: ParsedToolUse[];
toolResults: ParsedToolResult[];
}
/**
* Parse a single JSONL line into a ParsedEvent (plus any tool_use/tool_result blocks it
* carries), or null if the line isn't a trackable transcript event (no `type`/`uuid`) or
* isn't valid JSON at all (workflow journal files, a truncated in-flight line, etc.).
*/
export function parseLine(host: string, line: string): LineParseResult | null {
const trimmed = line.trim();
if (!trimmed) return null;
let d: Record<string, unknown>;
try {
d = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
return null;
}
const type = d.type as string | undefined;
if (!type || typeof d.uuid !== 'string') return null;
const message = (d.message ?? {}) as Record<string, unknown>;
const usage = (message.usage ?? {}) as Record<string, unknown>;
const serverTool = (usage.server_tool_use ?? {}) as Record<string, unknown>;
const content = message.content;
const toolUses: ParsedToolUse[] = [];
const toolResults: ParsedToolResult[] = [];
for (const block of asContentBlocks(content)) {
if (
block.type === 'tool_use' &&
typeof block.id === 'string' &&
typeof block.name === 'string'
) {
toolUses.push({
tool_use_id: block.id,
tool_name: block.name,
input_json: JSON.stringify(block.input ?? {})
});
} else if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
toolResults.push({
tool_use_id: block.tool_use_id,
is_error: Boolean(block.is_error),
result_bytes: byteLengthOf(block.content)
});
}
}
const numOrNull = (v: unknown): number | null =>
typeof v === 'number' && Number.isFinite(v) ? v : null;
const event: ParsedEvent = {
host,
session_id: (d.sessionId as string) ?? '',
uuid: d.uuid,
parent_uuid: typeof d.parentUuid === 'string' ? d.parentUuid : null,
ts_utc: typeof d.timestamp === 'string' ? d.timestamp : '',
type,
role: typeof message.role === 'string' ? message.role : null,
model: typeof message.model === 'string' ? message.model : null,
request_id: typeof d.requestId === 'string' ? d.requestId : null,
message_id: typeof message.id === 'string' ? message.id : null,
is_sidechain: Boolean(d.isSidechain),
is_usage_canonical: false, // set by parseTranscript, which sees the whole batch
stop_reason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
latency_ms: null, // set by parseTranscript, which has sibling-line context
input_tokens: numOrNull(usage.input_tokens),
output_tokens: numOrNull(usage.output_tokens),
cache_creation_tokens: numOrNull(usage.cache_creation_input_tokens),
cache_read_tokens: numOrNull(usage.cache_read_input_tokens),
web_search_requests: numOrNull(serverTool.web_search_requests),
web_fetch_requests: numOrNull(serverTool.web_fetch_requests),
text: flattenText(content)
};
return { event, toolUses, toolResults };
}
export interface ParseResult {
events: ParsedEvent[];
toolCalls: ToolCall[];
session: SessionMeta;
}
/**
* Parse a chunk of JSONL text (a whole file, or just the bytes appended since the last
* cursor position see cursor.ts/run.ts) into events, tool calls, and session metadata.
*
* `sessionIdFallback` should be the session id derived from the filename (transcripts are
* named `<session-id>.jsonl`); it's used for lines that for some reason omit `sessionId`,
* and as the session's id before any line with `sessionId` has been seen.
*
* Session metadata (project/branch/version/entrypoint/started_at/ended_at) is scanned
* across ALL lines in the chunk, including ones that aren't trackable events themselves
* (e.g. the leading `mode`/`file-history-snapshot` lines carry `cwd`/`gitBranch`). On an
* incremental (non-backfill) chunk that doesn't include the start of the file, some of
* these fields may come back null the ingest server COALESCEs session fields so that's
* safe (an earlier sweep's non-null values are preserved).
*/
export function parseTranscript(
host: string,
sessionIdFallback: string,
text: string
): ParseResult {
const events: ParsedEvent[] = [];
const toolCalls: ToolCall[] = [];
const tsByUuid = new Map<string, string>();
const pendingToolCalls = new Map<string, ToolCall>();
const usageGroups = new Map<string, ParsedEvent[]>();
let sessionId = sessionIdFallback;
let project: string | null = null;
let gitBranch: string | null = null;
let ccVersion: string | null = null;
let entrypoint: string | null = null;
let minTs: string | null = null;
let maxTs: string | null = null;
for (const rawLine of text.split('\n')) {
const trimmed = rawLine.trim();
if (!trimmed) continue;
let d: Record<string, unknown>;
try {
d = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
continue; // malformed/truncated line — skip gracefully
}
// Pull session metadata from every line that has it, not just trackable events.
if (typeof d.sessionId === 'string' && d.sessionId.length > 0) sessionId = d.sessionId;
if (typeof d.cwd === 'string') project = d.cwd;
if (typeof d.gitBranch === 'string') gitBranch = d.gitBranch;
if (typeof d.version === 'string') ccVersion = d.version;
if (typeof d.entrypoint === 'string') entrypoint = d.entrypoint;
if (typeof d.timestamp === 'string' && d.timestamp.length > 0) {
if (!minTs || d.timestamp < minTs) minTs = d.timestamp;
if (!maxTs || d.timestamp > maxTs) maxTs = d.timestamp;
}
const parsed = parseLine(host, trimmed);
if (!parsed) continue;
const { event, toolUses, toolResults } = parsed;
if (!event.session_id) event.session_id = sessionId;
tsByUuid.set(event.uuid, event.ts_utc);
if (event.type === 'assistant' && event.parent_uuid && event.ts_utc) {
const parentTs = tsByUuid.get(event.parent_uuid);
if (parentTs) {
const dt = Date.parse(event.ts_utc) - Date.parse(parentTs);
if (Number.isFinite(dt) && dt >= 0) event.latency_ms = dt;
}
}
events.push(event);
if (event.message_id) {
const key = `${event.session_id}${event.message_id}${event.request_id ?? ''}`;
const group = usageGroups.get(key);
if (group) group.push(event);
else usageGroups.set(key, [event]);
}
for (const tu of toolUses) {
pendingToolCalls.set(tu.tool_use_id, {
host,
session_id: event.session_id,
tool_use_id: tu.tool_use_id,
event_uuid: event.uuid,
tool_name: tu.tool_name,
input_json: tu.input_json,
is_error: null,
result_bytes: null,
duration_ms: null,
ts_utc: event.ts_utc || null
});
}
for (const tr of toolResults) {
const call = pendingToolCalls.get(tr.tool_use_id);
if (call) {
call.is_error = tr.is_error;
call.result_bytes = tr.result_bytes;
if (call.ts_utc && event.ts_utc) {
const dt = Date.parse(event.ts_utc) - Date.parse(call.ts_utc);
if (Number.isFinite(dt) && dt >= 0) call.duration_ms = dt;
}
pendingToolCalls.delete(tr.tool_use_id);
toolCalls.push(call);
} else {
// The matching tool_use was outside this chunk (e.g. parsed in a previous
// incremental sweep). Still record what we know — the server merges
// tool_calls rows by (host, session_id, tool_use_id) via COALESCE, so this
// won't blank out the tool_name/input_json captured earlier.
toolCalls.push({
host,
session_id: event.session_id,
tool_use_id: tr.tool_use_id,
event_uuid: null,
tool_name: 'unknown',
input_json: null,
is_error: tr.is_error,
result_bytes: tr.result_bytes,
duration_ms: null,
ts_utc: event.ts_utc || null
});
}
}
}
// tool_use blocks still awaiting their result at the end of this chunk — record them
// now (result_bytes/is_error/duration_ms stay null; a later sweep fills them in).
for (const call of pendingToolCalls.values()) toolCalls.push(call);
// Usage dedup: exactly one row per (session_id, message_id, request_id) group is
// canonical — the max-output_tokens row, tie-broken toward the later (final) line.
for (const group of usageGroups.values()) {
let best: ParsedEvent | null = null;
for (const ev of group) {
if (
!best ||
(ev.output_tokens ?? -1) > (best.output_tokens ?? -1) ||
((ev.output_tokens ?? -1) === (best.output_tokens ?? -1) && ev.ts_utc >= best.ts_utc)
) {
best = ev;
}
}
if (best) best.is_usage_canonical = true;
}
const session: SessionMeta = {
host,
session_id: sessionId,
project,
git_branch: gitBranch,
cc_version: ccVersion,
entrypoint,
started_at: minTs,
ended_at: maxTs
};
return { events, toolCalls, session };
}

111
agent/push.ts Normal file
View file

@ -0,0 +1,111 @@
/**
* toknmtr agent pusher.
*
* POSTs parsed batches to the server's /api/ingest. The ingest endpoint
* (src/routes/api/ingest/+server.ts) accepts `{ host?, events, toolCalls?, sessions? }`
* events are the documented minimal contract; toolCalls/sessions are additional top-level
* keys the ingest agent reads to populate the tool_calls/sessions tables. Idempotent
* upsert on the server means re-pushing (e.g. retrying after a failed request) is always
* safe. Config via env: TOKNMTR_URL, TOKNMTR_TOKEN.
*/
import type { ParsedEvent, SessionMeta, ToolCall } from './parse.ts';
import type { UsageGauges } from './usage.ts';
const URL_BASE = process.env.TOKNMTR_URL ?? 'http://localhost:3001';
const TOKEN = process.env.TOKNMTR_TOKEN ?? '';
/** Max events per ingest request keeps request bodies (and the server's single
* transaction per request) a reasonable size for large backfills. Events carry raw
* prompt/response text, so a batch of 500 can run into many MB; 200 keeps each POST
* comfortably under the server's BODY_SIZE_LIMIT (see ops/README.md set it generously,
* e.g. BODY_SIZE_LIMIT=64M, on the deployed container). */
const CHUNK_SIZE = 200;
export interface PushBatch {
host?: string;
events: ParsedEvent[];
toolCalls?: ToolCall[];
sessions?: SessionMeta[];
}
export interface PushResult {
ok: boolean;
received: number;
requests: number;
}
interface IngestResponse {
ok: boolean;
events?: number;
tool_calls?: number;
sessions?: number;
received?: number; // older/stub server shape
}
async function postChunk(body: Record<string, unknown>): Promise<number> {
const res = await fetch(`${URL_BASE}/api/ingest`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${TOKEN}`
},
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`ingest failed: ${res.status} ${await res.text()}`);
const parsed = (await res.json()) as IngestResponse;
return parsed.events ?? parsed.received ?? 0;
}
/**
* Push a full batch (events + tool calls + session metadata), chunking `events` into
* groups of CHUNK_SIZE per request. toolCalls/sessions (typically much smaller) are sent
* once, attached to the first request.
*/
export async function pushBatch(batch: PushBatch): Promise<PushResult> {
const { host, events, toolCalls = [], sessions = [] } = batch;
if (events.length === 0 && toolCalls.length === 0 && sessions.length === 0) {
return { ok: true, received: 0, requests: 0 };
}
let received = 0;
let requests = 0;
const chunkCount = events.length > 0 ? Math.ceil(events.length / CHUNK_SIZE) : 1;
for (let i = 0; i < chunkCount; i++) {
const chunk = events.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
const body: Record<string, unknown> = { events: chunk };
if (host) body.host = host;
if (i === 0) {
if (toolCalls.length > 0) body.toolCalls = toolCalls;
if (sessions.length > 0) body.sessions = sessions;
}
received += await postChunk(body);
requests++;
}
return { ok: true, received, requests };
}
/** Convenience wrapper for pushing just events (no tool calls / session metadata). */
export async function pushEvents(
events: ParsedEvent[]
): Promise<{ ok: boolean; received: number }> {
const result = await pushBatch({ events });
return { ok: result.ok, received: result.received };
}
/**
* POST a single subscription-usage gauge reading to the server's /api/usage endpoint.
* Returns true on a 2xx. Caller decides whether/when to scrape (see agent/usage.ts) this
* just ships whatever it's handed. Idempotent on the server (upsert by host + ts_utc).
*/
export async function pushUsageGauges(gauges: UsageGauges): Promise<boolean> {
const res = await fetch(`${URL_BASE}/api/usage`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${TOKEN}`
},
body: JSON.stringify(gauges)
});
if (!res.ok) throw new Error(`usage push failed: ${res.status} ${await res.text()}`);
return true;
}

220
agent/run.ts Normal file
View file

@ -0,0 +1,220 @@
/**
* toknmtr agent CLI entry point.
*
* Walks TRANSCRIPT_ROOT recursively for *.jsonl transcripts (including subagent
* transcripts under `/subagents/`, which carry real Task/workflow sub-agent usage
* often Sonnet/Haiku attributed to their parent session; only workflow `journal.jsonl`
* orchestration files are skipped), parses only the bytes appended since the last
* run (per-file cursor, see cursor.ts), and pushes the resulting events/tool calls/session
* metadata to the server in one sweep.
*
* Usage:
* node --experimental-strip-types agent/run.ts [--once] [--backfill] [--interval=<ms>]
*
* --once Run a single sweep and exit (default behavior is a continuous loop that
* sweeps every --interval ms until killed).
* --backfill Ignore stored cursors and reparse every transcript from byte 0 (cursors
* are still updated afterward, so later normal runs resume incrementally).
* Implies a single sweep (the point of a backfill is one full catch-up
* pass, not a repeating one). Safe to re-run ingest is idempotent.
* --interval Sweep interval in ms for continuous mode (default 60000).
*
* Env: TOKNMTR_URL, TOKNMTR_TOKEN (see push.ts). host = os.hostname().
*/
import { closeSync, openSync, readSync, readdirSync, statSync, type Dirent } from 'node:fs';
import { hostname } from 'node:os';
import { basename, join } from 'node:path';
import { getOffset, save as saveCursors, setOffset } from './cursor.ts';
import {
parseTranscript,
TRANSCRIPT_ROOT,
type ParsedEvent,
type SessionMeta,
type ToolCall
} from './parse.ts';
import { pushBatch, pushUsageGauges } from './push.ts';
import { fetchUsageGauges } from './usage.ts';
const HOST = hostname();
const JOURNAL_NAME = 'journal.jsonl';
const DEFAULT_INTERVAL_MS = 60_000;
/** How often the subscription-% gauge scrape runs in continuous mode (it's slow + spawns a
* real `claude` session, so it must NOT run every sweep mirrors the widget's 5-min cadence). */
const USAGE_INTERVAL_MS = 5 * 60_000;
interface Args {
backfill: boolean;
once: boolean;
intervalMs: number;
}
function parseArgs(argv: string[]): Args {
const backfill = argv.includes('--backfill');
const once = argv.includes('--once');
const intervalArg = argv.find((a) => a.startsWith('--interval='));
const parsedInterval = intervalArg ? Number(intervalArg.slice('--interval='.length)) : NaN;
const intervalMs =
Number.isFinite(parsedInterval) && parsedInterval > 0 ? parsedInterval : DEFAULT_INTERVAL_MS;
return { backfill, once, intervalMs };
}
function walk(dir: string, out: string[]): void {
let entries: Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return; // root doesn't exist yet (fresh machine, no transcripts) — nothing to do
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(full);
}
}
/**
* All transcript files under TRANSCRIPT_ROOT. Includes subagent transcripts
* (`.../subagents/agent-<id>.jsonl`) real Task/workflow sub-agent usage that attributes
* to the parent session via each line's own `sessionId`. Only workflow `journal.jsonl`
* files are excluded: they carry orchestration bookkeeping (`type:"started"` lines with no
* `uuid`), not message events, so they'd yield zero events anyway.
*/
function listTranscripts(): string[] {
const out: string[] = [];
walk(TRANSCRIPT_ROOT, out);
return out.filter((f) => basename(f) !== JOURNAL_NAME);
}
interface NewBytes {
text: string;
newOffset: number;
}
/**
* Read the byte range [offset, size) of `path` and trim it to the last complete line
* (i.e. up to and including the last `\n`), so a file mid-write never has a partial JSON
* line handed to the parser. The trailing partial line (if any) is left for next time
* newOffset stays before it.
*/
function readNewLines(path: string, offset: number, size: number): NewBytes {
if (offset >= size) return { text: '', newOffset: offset };
const length = size - offset;
const buf = Buffer.alloc(length);
const fd = openSync(path, 'r');
try {
readSync(fd, buf, 0, length, offset);
} finally {
closeSync(fd);
}
const lastNewline = buf.lastIndexOf(0x0a); // '\n'
if (lastNewline === -1) return { text: '', newOffset: offset };
return {
text: buf.subarray(0, lastNewline + 1).toString('utf8'),
newOffset: offset + lastNewline + 1
};
}
interface CursorUpdate {
path: string;
offset: number;
size: number;
}
async function sweep(backfill: boolean): Promise<void> {
const files = listTranscripts();
const events: ParsedEvent[] = [];
const toolCalls: ToolCall[] = [];
const sessions: SessionMeta[] = [];
const cursorUpdates: CursorUpdate[] = [];
let filesWithNewData = 0;
for (const path of files) {
let size: number;
try {
size = statSync(path).size;
} catch {
continue; // file disappeared mid-walk — skip
}
const offset = backfill ? 0 : getOffset(path, size);
const { text, newOffset } = readNewLines(path, offset, size);
if (!text) continue;
const sessionIdFallback = basename(path, '.jsonl');
const result = parseTranscript(HOST, sessionIdFallback, text);
events.push(...result.events);
toolCalls.push(...result.toolCalls);
if (result.events.length > 0 || result.toolCalls.length > 0) sessions.push(result.session);
cursorUpdates.push({ path, offset: newOffset, size });
filesWithNewData++;
}
const pushResult = await pushBatch({ host: HOST, events, toolCalls, sessions });
// Only commit cursors after a successful push, so a failed/unreachable-server push
// gets retried (nothing is silently lost) on the next sweep. This runs even after a
// --backfill sweep (which ignored the *old* cursor values when deciding where to start
// reading) so subsequent normal runs pick up incrementally from here rather than
// re-walking full history every time.
for (const u of cursorUpdates) setOffset(u.path, u.offset, u.size);
if (cursorUpdates.length > 0) saveCursors();
console.log(
`toknmtr agent: scanned ${files.length} transcript(s), ${filesWithNewData} with new data, ` +
`pushed ${pushResult.received} event(s) in ${pushResult.requests} request(s) ` +
`(${toolCalls.length} tool call(s), ${sessions.length} session(s)).`
);
}
/**
* Best-effort subscription-% gauge scrape + push. Never throws a missing PTY driver, no
* `claude` on PATH, or an unreachable server must not break the transcript sweep. Disabled
* entirely by setting TOKNMTR_NO_USAGE=1 (e.g. on a headless container with no `claude`).
*/
async function sweepUsage(): Promise<void> {
if (process.env.TOKNMTR_NO_USAGE === '1') return;
try {
const gauges = await fetchUsageGauges();
if (!gauges) return;
await pushUsageGauges(gauges);
console.log(
`toknmtr agent: pushed usage gauges (session=${gauges.session_pct ?? ''}% ` +
`week=${gauges.week_all_pct ?? ''}% sonnet=${gauges.week_sonnet_pct ?? ''}%).`
);
} catch (err) {
console.error('toknmtr agent: usage gauge scrape/push failed (non-fatal):', err);
}
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
// A backfill is a pure historical-transcript catch-up: skip the (slow, live-only) gauge scrape.
if (args.backfill) {
await sweep(true);
return;
}
if (args.once) {
await sweep(false);
await sweepUsage();
return;
}
// Continuous mode: sweep transcripts every --interval, scrape usage gauges on a slower cadence.
let lastUsageAt = 0;
for (;;) {
await sweep(false);
if (Date.now() - lastUsageAt >= USAGE_INTERVAL_MS) {
await sweepUsage();
lastUsageAt = Date.now();
}
await new Promise((resolve) => setTimeout(resolve, args.intervalMs));
}
}
main().catch((err: unknown) => {
console.error('toknmtr agent failed:', err);
process.exitCode = 1;
});

235
agent/usage.ts Normal file
View file

@ -0,0 +1,235 @@
/**
* toknmtr agent `claude /usage` PTY-driven scraper.
*
* Ports `claude-usage-widget`'s `src-tauri/src/cli_usage.rs` (PTY-drive sequence + bar
* parsing) to pure Node, without the `node-pty` native dependency. We don't have a true
* PTY API in plain Node, so we shell out to a PTY-shim helper `script` (util-linux,
* preferred) or `unbuffer` (expect) and drive `claude` through it. Both make `claude`
* believe it's attached to a real terminal, which it needs in order to render the
* interactive `/usage` TUI instead of falling back to a non-interactive mode.
*
* Sequence (mirrors the Rust reference): spawn wait ~3.5s for the TUI to finish its
* startup render send "/usage\r" drain output until it goes quiet for >1.2s (or a
* 20s deadline trips) send "/exit\r" as a best-effort clean shutdown kill the child.
* The captured bytes are then ANSI-stripped and the three rendered bars ("Current
* session", "Current week (all models)", "Current week (Sonnet only)") are parsed out
* with the same `NN% used` regex the Rust version uses.
*
* ASSUMPTIONS / things to watch if this breaks:
* - Verified against a live `script -qfc "claude" /dev/null` capture (Claude Code
* v2.1.197) on 2026-07-01: the rendered frame uses bare `\r` + a cursor-down escape
* (`\x1b[1B`) in place of `\n` for most line breaks, *not* `\r\n`. Once the CSI
* sequences are stripped, only the bare `\r` survives so `stripAnsiCollapse` here
* splits on `\r\n`, lone `\r`, *and* `\n` (the Rust version relies on Rust's
* `str::lines()`, which only recognizes `\n`/`\r\n` that's fine over there because
* `portable-pty` apparently surfaces real `\n`s; it would NOT be fine against the
* `script`-captured stream this file actually sees, so don't port that exact
* behavior back unmodified).
* - The "Current week (Sonnet only)" section is genuinely optional (absent entirely in
* the live capture above) `week_sonnet_pct` legitimately being `null` is expected,
* not a parse failure.
* - `unbuffer` (expect) is supported as a fallback driver per the task spec, but was
* NOT available in the dev sandbox to test against; only `script` was exercised live.
* - This is brittle to Anthropic changing the rendered `/usage` output, same caveat as
* the widget. See that project's memory.md for prior gotchas.
*
* Standalone test: `node --experimental-strip-types agent/usage.ts`
*/
import { spawn, spawnSync } from 'node:child_process';
import { hostname } from 'node:os';
export interface UsageGauges {
host: string;
ts_utc: string;
session_pct: number | null;
week_all_pct: number | null;
week_sonnet_pct: number | null;
}
const STARTUP_DELAY_MS = 3500; // let the TUI finish its startup render before typing
const POLL_INTERVAL_MS = 700; // how often we check for new output while draining
const QUIET_PERIOD_MS = 1200; // no new bytes for this long => render is done
const TOTAL_TIMEOUT_MS = 20000; // hard cap on the whole drain phase
const EXIT_DRAIN_MS = 500; // grace period after sending /exit before we kill
function commandExists(name: string): boolean {
try {
return spawnSync('which', [name], { stdio: 'ignore' }).status === 0;
} catch {
return false;
}
}
/** Pick a PTY-shim driver for `claude`. Returns argv, or null if neither is installed. */
function pickDriver(): string[] | null {
if (commandExists('script')) {
// util-linux script: -q quiet (no "Script started/done" banner), -f flush output as
// written, -c <command> run this under a pty. Typescript log target is /dev/null —
// we only care about the copy `script` also streams to its own stdout.
return ['script', '-qfc', 'claude', '/dev/null'];
}
if (commandExists('unbuffer')) {
return ['unbuffer', 'claude'];
}
return null;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Spawn the driver, type "/usage", capture the raw (ANSI-laden) bytes, then kill it. */
async function driveClaudeUsage(argv: string[]): Promise<Buffer> {
const [cmd, ...args] = argv;
const child = spawn(cmd, args, {
env: { ...process.env, TERM: 'xterm-256color' }
});
let out = Buffer.alloc(0);
let lastGrowth = Date.now();
const onData = (chunk: Buffer) => {
out = Buffer.concat([out, chunk]);
lastGrowth = Date.now();
};
child.stdout.on('data', onData);
child.stderr.on('data', onData);
// Swallow spawn-time errors here (e.g. ENOENT racing past commandExists) — the
// caller's try/catch around fetchUsageGauges is the real backstop, but an
// unhandled 'error' event on the child would otherwise crash the process.
child.on('error', () => {});
try {
// 1. Let the TUI finish its startup render.
await sleep(STARTUP_DELAY_MS);
// 2. Send /usage.
child.stdin.write('/usage\r');
// 3. Drain until output goes quiet or we hit the deadline.
const deadline = Date.now() + TOTAL_TIMEOUT_MS;
for (;;) {
await sleep(POLL_INTERVAL_MS);
if (Date.now() - lastGrowth > QUIET_PERIOD_MS) break;
if (Date.now() > deadline) break;
}
// 4. Best-effort clean exit, then a short grace drain.
child.stdin.write('/exit\r');
await sleep(EXIT_DRAIN_MS);
} finally {
child.stdout.off('data', onData);
child.stderr.off('data', onData);
child.kill('SIGKILL');
}
return out;
}
// CSI: ESC [ ... <terminator>; OSC: ESC ] ... BEL; DCS/SOS/PM/APC: ESC P|X|^|_ ... ESC \
// eslint-disable-next-line no-control-regex -- matching raw ANSI control bytes is the point
const CSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07|\x1b[PX^_][\s\S]*?\x1b\\/g;
/**
* Strip ANSI escapes and collapse repeated/blank lines (TUIs redraw the same content
* many times). See the file-header note on why this splits on bare `\r` too, not just
* `\r\n`/`\n`.
*/
export function stripAnsiCollapse(raw: Buffer): string {
let text = raw.toString('utf8').replace(CSI_RE, '');
// Drop stray BEL / lone ESC bytes left over from any sequence the regex didn't match.
// eslint-disable-next-line no-control-regex -- matching raw ANSI control bytes is the point
text = text.replace(/[\x07\x1b]/g, '');
const deduped: string[] = [];
for (const rawLine of text.split(/\r\n|\r|\n/)) {
const trimmed = rawLine.replace(/\s+$/, '');
if (deduped.length > 0 && deduped[deduped.length - 1] === trimmed) continue;
deduped.push(trimmed);
}
const compressed: string[] = [];
let prevBlank = false;
for (const line of deduped) {
const blank = line.length === 0;
if (blank && prevBlank) continue;
compressed.push(line);
prevBlank = blank;
}
return compressed.join('\n');
}
const PCT_RE = /(\d{1,3})\s*%\s*used/;
/** Find `label`'s heading line, then look at the next few lines for "NN% used". */
function findSectionPct(lines: string[], label: string): number | null {
const normLabel = label.replace(/\s+/g, '');
const idx = lines.findIndex((l) => {
const t = l.trim();
return t === label || t.replace(/\s+/g, '') === normLabel;
});
if (idx === -1) return null;
for (const line of lines.slice(idx + 1, idx + 7)) {
const m = PCT_RE.exec(line);
if (m) {
const n = Number.parseInt(m[1], 10);
return Number.isFinite(n) ? n : null;
}
}
return null;
}
function parseUsageText(stripped: string): {
session_pct: number | null;
week_all_pct: number | null;
week_sonnet_pct: number | null;
} {
const lines = stripped.split('\n');
return {
session_pct: findSectionPct(lines, 'Current session'),
week_all_pct: findSectionPct(lines, 'Current week (all models)'),
week_sonnet_pct: findSectionPct(lines, 'Current week (Sonnet only)')
};
}
/**
* Fetch the three subscription-usage gauges by PTY-driving `claude /usage`.
*
* Best-effort: returns null (never throws) if no PTY-shim driver is installed, if
* `claude` isn't reachable, or if the output couldn't be parsed at all. This must never
* break the main agent run.
*/
export async function fetchUsageGauges(): Promise<UsageGauges | null> {
try {
const argv = pickDriver();
if (!argv) return null;
const raw = await driveClaudeUsage(argv);
if (raw.length === 0) return null;
const stripped = stripAnsiCollapse(raw);
const { session_pct, week_all_pct, week_sonnet_pct } = parseUsageText(stripped);
if (session_pct === null && week_all_pct === null && week_sonnet_pct === null) {
return null;
}
return {
host: hostname(),
ts_utc: new Date().toISOString(),
session_pct,
week_all_pct,
week_sonnet_pct
};
} catch {
return null;
}
}
async function main(): Promise<void> {
const result = await fetchUsageGauges();
console.log(JSON.stringify(result, null, 2));
}
const entryPoint = process.argv[1];
if (entryPoint && import.meta.url === `file://${entryPoint}`) {
void main();
}

33
docker-compose.yml Normal file
View file

@ -0,0 +1,33 @@
# toknmtr — self-hosted Claude Code usage & analytics dashboard (server half).
#
# Quick start:
# 1. cp .env.example .env
# 2. Edit .env — set API_TOKEN to a long random string: openssl rand -hex 32
# 3. docker compose up -d --build
# 4. Open http://localhost:3001
#
# The image contains ZERO data: the SQLite DB is created empty in the named volume
# `toknmtr-data` on first run. To get data in, run the agent on each of your machines
# (see README.md → "Feeding it data").
services:
toknmtr:
build: .
image: toknmtr:latest
container_name: toknmtr
restart: unless-stopped
ports:
# host:container — the app listens on 3000 inside the container (Dockerfile PORT).
- '3001:3000'
environment:
# Bearer token the agent must present to /api/ingest. REQUIRED — compose errors if unset.
API_TOKEN: ${API_TOKEN:?set API_TOKEN in .env (openssl rand -hex 32)}
DB_PATH: /data/toknmtr.db
BODY_SIZE_LIMIT: 64M
# Leave unset to HIDE the transcript view + full-text search (raw prompt/response text).
# Only set to "true" once the dashboard is behind auth. Default (unset) = hidden.
SHOW_TRANSCRIPTS: ${SHOW_TRANSCRIPTS:-}
volumes:
- toknmtr-data:/data
volumes:
toknmtr-data:

41
eslint.config.js Normal file
View file

@ -0,0 +1,41 @@
import prettier from 'eslint-config-prettier';
import path from 'node:path';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig, includeIgnoreFile } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
ts.configs.recommended,
svelte.configs.recommended,
prettier,
svelte.configs.prettier,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser
}
}
},
{
// Override or add rule settings here, such as:
// 'svelte/button-has-type': 'error'
rules: {}
}
);

137
ops/README.md Normal file
View file

@ -0,0 +1,137 @@
# ops/ — capture wiring (hooks + cron)
This directory holds everything needed to make the `agent/` JSONL parser run automatically
on a machine, instead of being invoked by hand. Two complementary triggers feed the same
idempotent ingest pipeline:
| Trigger | Script | Fires | Purpose |
| ----------------------- | --------------------------------------------------------------------- | --------------------------- | ---------------------------- |
| Claude Code `Stop` hook | `agent/hooks/toknmtr-capture.sh` (installed by `ops/install-hook.sh`) | end of every assistant turn | near-live capture |
| cron | `ops/install-cron.sh` | every ~10 min | reconcile sweep / safety net |
Both ultimately just run:
```sh
node --experimental-strip-types agent/run.ts --once
```
which is itself idempotent (see "Why both triggers are safe to overlap" below), so having
both wired up is never a correctness problem — only a (very cheap) redundancy.
## 1. One-time setup: `~/.toknmtr/env`
Both the hook and the cron sweep read agent config from a file **outside**
`~/.claude/settings.json`, specifically so `TOKNMTR_TOKEN` never ends up in a file that's
more likely to be synced, shared, or pasted into a support thread.
Create `~/.toknmtr/env` (plain `KEY=value` lines, shell-sourceable):
```sh
mkdir -p ~/.toknmtr
cat > ~/.toknmtr/env <<'EOF'
TOKNMTR_URL=http://<server-host>:3001
TOKNMTR_TOKEN=<the API_TOKEN configured on the server>
EOF
chmod 600 ~/.toknmtr/env
```
- `TOKNMTR_URL` — base URL of the deployed server (see `.env.example` at the repo root for
the server-side `API_TOKEN` / `PORT` config that this must match).
- `TOKNMTR_TOKEN` — must equal the server's `API_TOKEN` env var; sent as
`Authorization: Bearer <token>` on every `/api/ingest` request (`agent/push.ts`).
Neither the hook script nor the installers will create this file for you — they treat its
absence as "not configured yet" and (for the hook) silently no-op, or (for the installers)
proceed anyway since the file only needs to exist by the time a sweep actually runs.
If you'd rather keep config somewhere else, both scripts honor `TOKNMTR_ENV_FILE` to
override the path (see each script's header for the full list of override env vars).
## 2. Install the live (`Stop`-hook) capture path
```sh
ops/install-hook.sh
```
This merges a `Stop` hook entry into `~/.claude/settings.json` (via `jq`, additive — it
will not touch any other hooks or settings already there) pointing at
`agent/hooks/toknmtr-capture.sh`. It:
- backs up `settings.json` (timestamped, alongside the original) before writing,
- is idempotent — matches on the exact command string, so re-running is a no-op,
- is reversible — `ops/install-hook.sh --remove` deletes just this entry.
**Why `Stop` and not `SessionEnd`** (this is a real tradeoff, documented in full in the
script's header comment — short version): `Stop` fires at the end of every assistant turn,
so capture is close to live; `SessionEnd` only fires once when the CLI process exits, and
Claude Code does not guarantee it fires on every exit path (e.g. a killed terminal can skip
it). Since `agent/hooks/toknmtr-capture.sh` is designed to cost the foreground session
basically nothing (it backgrounds the real work and returns in milliseconds — verified:
~3ms wall time in testing), `Stop`'s higher firing frequency is nearly free, so it wins.
The exact JSON this installs (for review before running it live) is reproduced in this
repo's PR/report — see also `~/.claude/settings.json` directly after running the script.
## 3. Install the cron reconcile sweep
```sh
ops/install-cron.sh
```
Adds a crontab line (default schedule `*/10 * * * *`) that sources `~/.toknmtr/env` and
runs `agent/run.ts --once`, logging to `~/.toknmtr/cron.log`. Idempotent (matched by a
`# toknmtr-cron-reconcile` marker comment) and reversible (`ops/install-cron.sh --remove`,
or just delete the marked line via `crontab -e`). Only touches the one marked line — every
other line in the user's crontab is preserved verbatim.
## 4. Backfill (one-time, per machine)
The hook/cron only push _new_ bytes appended to transcripts since the last run (see the
per-file cursor logic in `agent/cursor.ts` / `agent/run.ts`). To ingest every transcript
that already exists on a machine before capture was wired up:
```sh
cd ~/claude/projects/toknmtr
node --experimental-strip-types agent/run.ts --backfill
```
`--backfill` ignores stored cursors and reparses every transcript from byte 0 (but still
updates cursors afterward, so the _next_ run — hook or cron — resumes incrementally from
there rather than re-walking everything again). Safe to re-run any time: ingest is an
idempotent upsert keyed on `host + session_id + uuid` (events) and deduped on
`session_id + message_id + request_id` (usage), so a repeated backfill just re-writes the
same rows.
## Why both triggers are safe to overlap
Every push is an idempotent upsert. The event primary key is `host + session_id + uuid`
(every physical JSONL line has a unique top-level `uuid`); usage rows are deduped to
exactly one canonical row per `session_id + message_id + request_id` group server-side.
So if a hook-triggered sweep and a cron-triggered sweep ever race (or a hook fires twice
because of a fast back-to-back turn, or the cron sweep re-reads a tail the hook already
pushed), the result is just redundant writes of already-correct rows — never duplicate or
conflicting data. Cursors (`agent/cursor.ts`) are only advanced _after_ a push succeeds, so
a failed/unreachable-server push is naturally retried by the next sweep (hook or cron)
rather than silently dropping data.
## Files in this directory
- `install-hook.sh` — registers/unregisters the `Stop` hook in `~/.claude/settings.json`.
- `install-cron.sh` — registers/unregisters the cron reconcile line.
- `README.md` — this file.
The hook script itself lives at `agent/hooks/toknmtr-capture.sh` (next to the rest of the
agent code, since it's part of the agent's runtime surface, not an ops/deploy concern).
## Troubleshooting
- **Nothing showing up in the dashboard:** check `~/.toknmtr/capture.log` (hook) and
`~/.toknmtr/cron.log` (cron) for errors — most commonly a wrong `TOKNMTR_URL`/
`TOKNMTR_TOKEN` in `~/.toknmtr/env`, or the server being unreachable.
- **Hook seems to never run:** confirm it's actually registered —
`jq '.hooks.Stop' ~/.claude/settings.json` — and that `agent/hooks/toknmtr-capture.sh` is
executable (`chmod +x`).
- **Suspect a missed turn / gap:** the cron sweep will pick it up within its schedule
window regardless of what the hook did or didn't capture; you can also just run
`node --experimental-strip-types agent/run.ts --once` by hand at any time.
- **Want to undo everything:** `ops/install-hook.sh --remove && ops/install-cron.sh --remove`.

95
ops/install-cron.sh Executable file
View file

@ -0,0 +1,95 @@
#!/usr/bin/env bash
set -euo pipefail
#
# ops/install-cron.sh — idempotently install a crontab entry that runs the toknmtr agent in
# reconcile mode (`--once`) every ~10 minutes.
#
# Why this exists alongside the Stop hook (ops/install-hook.sh):
# The hook is near-live but best-effort — it's deliberately fail-open (see
# agent/hooks/toknmtr-capture.sh), so it silently no-ops if the project dir/config/node
# binary is momentarily missing, and it never retries a failed push itself (a failed push
# just leaves the file's cursor unmoved, see agent/run.ts). It also only fires on Claude
# Code turns, so a transcript that's touched outside a tracked Stop event (or a session
# whose final hook fire was the one that raced/failed) won't get its tail captured until
# *something* sweeps again. This cron line is that backstop: a periodic, host-crontab-
# driven sweep that's independent of any particular Claude Code session being open, so the
# data self-heals even if every hook fire that session ever missed/failed.
# Push is idempotent (PK = host+session_id+uuid, see CLAUDE.md), so overlapping
# hook-triggered and cron-triggered sweeps are always safe to interleave.
#
# What this script does:
# - Adds (or updates, if already present) a single crontab line running
# `node --experimental-strip-types agent/run.ts --once` every 10 minutes, sourcing
# ~/.toknmtr/env first for TOKNMTR_URL/TOKNMTR_TOKEN, with stdout/stderr appended to a
# log file.
# - Idempotent: matches by a unique marker comment, so re-running replaces (rather than
# duplicating) the line — safe to re-run after changing TOKNMTR_PROJECT_DIR etc.
# - Reversible: `ops/install-cron.sh --remove` deletes just the marked line; everything
# else in the user's crontab is left untouched.
#
# Usage:
# ops/install-cron.sh # install/update the cron line
# ops/install-cron.sh --remove # remove just this cron line
#
# Override knobs (env, all optional):
# TOKNMTR_PROJECT_DIR path to the toknmtr repo checkout (default: ~/claude/projects/toknmtr)
# TOKNMTR_ENV_FILE path to the config file (default: ~/.toknmtr/env)
# TOKNMTR_LOG_FILE cron sweep log path (default: ~/.toknmtr/cron.log)
# TOKNMTR_CRON_SCHEDULE cron schedule expression (default: "*/10 * * * *")
PROJECT_DIR="${TOKNMTR_PROJECT_DIR:-$HOME/claude/projects/toknmtr}"
CONFIG_FILE="${TOKNMTR_ENV_FILE:-$HOME/.toknmtr/env}"
LOG_FILE="${TOKNMTR_LOG_FILE:-$HOME/.toknmtr/cron.log}"
SCHEDULE="${TOKNMTR_CRON_SCHEDULE:-*/10 * * * *}"
MARKER="# toknmtr-cron-reconcile"
CRON_CMD="cd \"$PROJECT_DIR\" && . \"$CONFIG_FILE\" && node --experimental-strip-types agent/run.ts --once >> \"$LOG_FILE\" 2>&1"
CRON_LINE="$SCHEDULE bash -lc '$CRON_CMD' $MARKER"
current_crontab() {
crontab -l 2>/dev/null || true
}
if [ "${1:-}" = "--remove" ]; then
existing="$(current_crontab)"
if ! printf '%s\n' "$existing" | grep -qF "$MARKER"; then
echo "no toknmtr cron line found, nothing to remove"
exit 0
fi
printf '%s\n' "$existing" | grep -vF "$MARKER" | crontab -
echo "removed toknmtr reconcile cron line"
exit 0
fi
command -v crontab >/dev/null 2>&1 || {
echo "error: crontab is required (e.g. 'sudo apt install cron') and the cron daemon must be running" >&2
exit 1
}
[ -d "$PROJECT_DIR" ] || {
echo "error: $PROJECT_DIR does not exist (set TOKNMTR_PROJECT_DIR?)" >&2
exit 1
}
[ -f "$PROJECT_DIR/agent/run.ts" ] || {
echo "error: $PROJECT_DIR/agent/run.ts not found — is TOKNMTR_PROJECT_DIR correct?" >&2
exit 1
}
mkdir -p "$(dirname "$LOG_FILE")"
# Replace any prior toknmtr line (matched by marker) with the new one; append if absent.
existing="$(current_crontab)"
filtered="$(printf '%s\n' "$existing" | grep -vF "$MARKER" || true)"
{
[ -n "$filtered" ] && printf '%s\n' "$filtered"
printf '%s\n' "$CRON_LINE"
} | crontab -
echo "installed toknmtr reconcile cron line (schedule: $SCHEDULE):"
echo " $CRON_LINE"
echo ""
echo "config read from: $CONFIG_FILE (must exist with TOKNMTR_URL / TOKNMTR_TOKEN, see ops/README.md)"
echo "sweep log: $LOG_FILE"
echo ""
echo "to remove later:"
echo " ops/install-cron.sh --remove"
echo " (or run 'crontab -e' and delete the line ending in '$MARKER')"

113
ops/install-hook.sh Executable file
View file

@ -0,0 +1,113 @@
#!/usr/bin/env bash
set -euo pipefail
#
# ops/install-hook.sh — idempotently register the toknmtr capture hook
# (agent/hooks/toknmtr-capture.sh) in ~/.claude/settings.json under the Claude Code 'Stop'
# hook event.
#
# WHY 'Stop' AND NOT 'SessionEnd':
# Stop fires at the end of EVERY assistant turn (each time Claude finishes responding and
# yields back to the user) — not just once when the whole CLI process exits. That makes
# capture close to live: a dashboard refresh a few seconds after a turn already reflects
# it. The cost is the hook running more often, but agent/hooks/toknmtr-capture.sh is
# designed to be near-zero-cost on the foreground session (it backgrounds the real work
# and returns in milliseconds — see that script's header), so the extra frequency is
# nearly free.
# SessionEnd only fires once, when the CLI process actually exits — on a long interactive
# session that can be hours after the data was generated, and Claude Code does not
# guarantee SessionEnd fires on every exit path (e.g. a killed/crashed terminal can skip
# it entirely), so relying on it alone risks losing the tail of a session permanently
# until the next cron sweep (ops/install-cron.sh) catches it.
# Net: Stop is the better default given the hook is cheap. The cron sweep is an
# independent safety net either way (server-down resilience, missed hook fires, etc.) —
# it doesn't change this tradeoff, it just makes the choice lower-stakes. Revisit by
# swapping HOOK_EVENT below to "SessionEnd" if Stop's per-turn frequency ever proves to be
# a real problem in practice (e.g. very chatty multi-turn sessions on a slow machine).
#
# What this script does:
# - Merges (via jq) a Stop-hook entry pointing at agent/hooks/toknmtr-capture.sh into
# ~/.claude/settings.json, WITHOUT touching any other keys/hooks already in that file.
# - Idempotent: matches on the exact command string, so re-running is a safe no-op.
# - Writes a timestamped backup of settings.json before every modification.
# - Reversible: `ops/install-hook.sh --remove` undoes just this hook's entry (or restore
# a backup manually — see the "to remove later" line this script prints on install).
#
# Usage:
# ops/install-hook.sh # install/update the hook
# ops/install-hook.sh --remove # remove this hook's entry from settings.json
#
# Override knobs (env, all optional):
# TOKNMTR_PROJECT_DIR path to the toknmtr repo checkout (default: ~/claude/projects/toknmtr)
# CLAUDE_SETTINGS_FILE path to settings.json (default: ~/.claude/settings.json)
PROJECT_DIR="${TOKNMTR_PROJECT_DIR:-$HOME/claude/projects/toknmtr}"
SETTINGS_FILE="${CLAUDE_SETTINGS_FILE:-$HOME/.claude/settings.json}"
HOOK_SCRIPT="$PROJECT_DIR/agent/hooks/toknmtr-capture.sh"
HOOK_EVENT="Stop"
HOOK_CMD="bash \"$HOOK_SCRIPT\""
HOOK_TIMEOUT_S=10
command -v jq >/dev/null 2>&1 || {
echo "error: jq is required (sudo apt install jq / brew install jq)" >&2
exit 1
}
backup_settings() {
[ -f "$SETTINGS_FILE" ] || return 0
local stamp
stamp="$(date +%Y%m%dT%H%M%S)"
cp "$SETTINGS_FILE" "$SETTINGS_FILE.bak.$stamp"
echo "backed up $SETTINGS_FILE -> $SETTINGS_FILE.bak.$stamp"
}
if [ "${1:-}" = "--remove" ]; then
if [ ! -f "$SETTINGS_FILE" ]; then
echo "no settings file at $SETTINGS_FILE, nothing to remove"
exit 0
fi
backup_settings
tmp="$(mktemp)"
jq --arg cmd "$HOOK_CMD" --arg event "$HOOK_EVENT" '
if (.hooks // {} | has($event)) then
.hooks[$event] = [
.hooks[$event][] |
.hooks = [(.hooks // [])[] | select(.command != $cmd)] |
select((.hooks | length) > 0)
]
else . end
' "$SETTINGS_FILE" >"$tmp" && mv "$tmp" "$SETTINGS_FILE"
echo "removed toknmtr capture hook from $SETTINGS_FILE.$HOOK_EVENT (backup written above; other $HOOK_EVENT hooks, if any, were left untouched)"
exit 0
fi
if [ ! -f "$HOOK_SCRIPT" ]; then
echo "error: $HOOK_SCRIPT not found (is TOKNMTR_PROJECT_DIR=$PROJECT_DIR correct?)" >&2
exit 1
fi
chmod +x "$HOOK_SCRIPT" 2>/dev/null || true
mkdir -p "$(dirname "$SETTINGS_FILE")"
[ -f "$SETTINGS_FILE" ] || echo '{}' >"$SETTINGS_FILE"
jq empty "$SETTINGS_FILE" || {
echo "error: $SETTINGS_FILE is not valid JSON — fix it manually before running this script" >&2
exit 1
}
backup_settings
tmp="$(mktemp)"
jq --arg cmd "$HOOK_CMD" --arg event "$HOOK_EVENT" --argjson timeout "$HOOK_TIMEOUT_S" '
.hooks //= {} |
.hooks[$event] //= [] |
([.hooks[$event][]? | (.hooks // [])[]? | .command] | index($cmd)) as $already |
if $already == null then
.hooks[$event] += [{"hooks": [{"type": "command", "command": $cmd, "timeout": $timeout}]}]
else . end
' "$SETTINGS_FILE" >"$tmp" && mv "$tmp" "$SETTINGS_FILE"
echo "installed toknmtr capture hook into $SETTINGS_FILE under hooks.$HOOK_EVENT"
echo " command: $HOOK_CMD"
echo ""
echo "to remove later:"
echo " ops/install-hook.sh --remove"
echo " (or restore a $SETTINGS_FILE.bak.<timestamp> file written next to it)"

4047
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

38
package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "toknmtr",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^24",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.19.0",
"globals": "^17.6.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.1.0",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vite": "^8.0.16"
},
"dependencies": {
"better-sqlite3": "^12.11.1"
}
}

13
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

28
src/app.html Normal file
View file

@ -0,0 +1,28 @@
<!doctype html>
<html lang="en" data-theme="eclipse">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<meta name="color-scheme" content="dark light" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700;800&family=Hanken+Grotesk:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<script>
// Set the theme before first paint to avoid a flash of the default theme.
(function () {
try {
var t = localStorage.getItem('toknmtr-theme');
if (t) document.documentElement.setAttribute('data-theme', t);
} catch (e) {}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,212 @@
<script lang="ts">
import { fmtCompact, fmtPct, fmtUsd } from '$lib/format';
import type { CacheEfficiency } from '$lib/server/queries';
interface Props {
cache: CacheEfficiency;
}
let { cache }: Props = $props();
const totalTokens = $derived(cache.cacheReadTokens + cache.freshInputTokens);
const hasData = $derived(totalTokens > 0);
const cachedPct = $derived(hasData ? (cache.cacheReadTokens / totalTokens) * 100 : 0);
const freshPct = $derived(hasData ? 100 - cachedPct : 0);
</script>
{#if !hasData}
<p class="empty">No cached usage in this window.</p>
{:else}
<div class="headline">
<span class="headline-value">{fmtPct(cache.cacheReadShare)}</span>
<span class="headline-label">of input tokens served from cache</span>
</div>
<div class="split-bar" role="img" aria-label="Cache read vs fresh input token split">
<div class="split-segment cached" style:width="{Math.max(cachedPct, cachedPct > 0 ? 0.5 : 0)}%">
<title>Cached: {fmtCompact(cache.cacheReadTokens)} tok</title>
</div>
<div class="split-segment fresh" style:width="{Math.max(freshPct, freshPct > 0 ? 0.5 : 0)}%">
<title>Fresh: {fmtCompact(cache.freshInputTokens)} tok</title>
</div>
</div>
<div class="chart-legend">
<div class="legend-item">
<i style="background: var(--accent-2)"></i>{fmtCompact(cache.cacheReadTokens)} cached
</div>
<div class="legend-item">
<i style="background: var(--accent)"></i>{fmtCompact(cache.freshInputTokens)} fresh
</div>
</div>
<div class="tiles">
<div class="tile">
<span class="tile-label">$ saved</span>
<span class="tile-value amber">{fmtUsd(cache.dollarsSaved)}</span>
</div>
<div class="tile">
<span class="tile-label">Effective discount</span>
<span class="tile-value">{fmtPct(cache.effectiveDiscountPct)}</span>
</div>
<div class="tile">
<span class="tile-label">Cache writes</span>
<span class="tile-value">{fmtCompact(cache.cacheWriteTokens)} tok</span>
</div>
<div class="tile">
<span class="tile-label">Paid on reads</span>
<span class="tile-value">{fmtUsd(cache.dollarsSpentOnReads)}</span>
</div>
</div>
<p class="explainer">
Caching saved <strong>{fmtUsd(cache.dollarsSaved)}</strong> vs. paying the full input rate for those
reused tokens.
</p>
{/if}
<style>
.empty {
color: var(--text-faint);
font-size: 0.9rem;
margin: 0;
padding: 1rem 0;
}
.headline {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.headline-value {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 2.4rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
line-height: 1;
}
.headline-label {
color: var(--text-dim);
font-size: 0.85rem;
}
.split-bar {
display: flex;
width: 100%;
height: 0.85rem;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg-raised);
border: 1px solid var(--border-soft);
box-shadow: inset 0 1px 2px color-mix(in srgb, var(--text) 8%, transparent);
}
.split-segment {
height: 100%;
min-width: 0;
transition: width 0.2s ease;
}
.split-segment.cached {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--accent-2) 88%, white 12%),
var(--accent-2)
);
}
.split-segment.fresh {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--accent) 88%, white 12%),
var(--accent)
);
}
.chart-legend {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-top: 0.5rem;
font-size: 0.8rem;
color: var(--text-dim);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.4rem;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.legend-item i {
display: inline-block;
width: 0.65rem;
height: 0.65rem;
border-radius: 2px;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text) 10%, transparent);
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr));
gap: 0.6rem;
margin-top: 1rem;
}
.tile {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.65rem 0.8rem;
background: var(--bg-raised);
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.tile:hover {
border-color: var(--border);
box-shadow: var(--shadow-pop);
}
.tile-label {
font-size: 0.72rem;
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.tile-value {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
}
.tile-value.amber {
color: var(--amber);
}
.explainer {
margin: 0.9rem 0 0;
font-size: 0.82rem;
color: var(--text-dim);
line-height: 1.4;
}
.explainer strong {
color: var(--amber);
font-weight: 600;
}
</style>

View file

@ -0,0 +1,260 @@
<script lang="ts">
import { fmtUsd, fmtBucketLabel } from '$lib/format';
import type { SeriesBucket, Bucket } from '$lib/server/queries';
interface Props {
series: SeriesBucket[];
bucket: Bucket;
}
let { series, bucket }: Props = $props();
const W = 720;
const H = 180;
const PAD_TOP = 26;
const PAD_BOTTOM = 10;
const PAD_X = 4;
const PLOT_LEFT = PAD_X;
const PLOT_RIGHT = W - PAD_X;
const PLOT_TOP = PAD_TOP;
const PLOT_BOTTOM = H - PAD_BOTTOM;
const PLOT_WIDTH = PLOT_RIGHT - PLOT_LEFT;
const PLOT_HEIGHT = PLOT_BOTTOM - PLOT_TOP;
const GRIDLINE_FRACS = [0.25, 0.5, 0.75, 1.0];
const EPS = 0.005;
interface Point {
x: number;
y: number;
}
interface HoverSlice {
x: number;
width: number;
title: string;
}
interface ChartData {
empty: boolean;
finalTotal: string;
areaPath: string;
linePath: string;
dot: Point | null;
hoverSlices: HoverSlice[];
axisLabels: { first: string; mid: string; last: string } | null;
}
const emptyChart: ChartData = {
empty: true,
finalTotal: '',
areaPath: '',
linePath: '',
dot: null,
hoverSlices: [],
axisLabels: null
};
let chart = $derived.by((): ChartData => {
const n = series.length;
if (n === 0) return emptyChart;
let running = 0;
const cum = series.map((b) => (running += b.costUsd));
const finalTotal = cum[cum.length - 1];
if (finalTotal <= 0) return emptyChart;
const maxY = Math.max(finalTotal, EPS);
const xAt = (i: number): number =>
n === 1 ? PLOT_LEFT : PLOT_LEFT + (i / (n - 1)) * PLOT_WIDTH;
const yAt = (v: number): number => PLOT_BOTTOM - (v / maxY) * PLOT_HEIGHT;
const points: Point[] =
n === 1
? [
{ x: PLOT_LEFT, y: yAt(cum[0]) },
{ x: PLOT_RIGHT, y: yAt(cum[0]) }
]
: cum.map((v, i) => ({ x: xAt(i), y: yAt(v) }));
const fmt = (v: number): string => v.toFixed(2);
const linePath = points
.map((p, i) => `${i === 0 ? 'M' : 'L'}${fmt(p.x)},${fmt(p.y)}`)
.join(' ');
const areaPath =
`M${fmt(points[0].x)},${PLOT_BOTTOM} ` +
points.map((p) => `L${fmt(p.x)},${fmt(p.y)}`).join(' ') +
` L${fmt(points[points.length - 1].x)},${PLOT_BOTTOM} Z`;
const lastPoint = points[points.length - 1];
const hoverWidth = n === 1 ? PLOT_WIDTH : PLOT_WIDTH / n;
const hoverSlices: HoverSlice[] = series.map((b, i) => {
const center = xAt(i);
const rawX = center - hoverWidth / 2;
const x = Math.max(PLOT_LEFT, Math.min(rawX, PLOT_RIGHT - hoverWidth));
return {
x,
width: hoverWidth,
title: `${fmtBucketLabel(b.start, bucket)}: ${fmtUsd(cum[i])}`
};
});
const mid = Math.floor((n - 1) / 2);
return {
empty: false,
finalTotal: fmtUsd(finalTotal),
areaPath,
linePath,
dot: { x: lastPoint.x, y: lastPoint.y },
hoverSlices,
axisLabels: {
first: fmtBucketLabel(series[0].start, bucket),
mid: fmtBucketLabel(series[mid].start, bucket),
last: fmtBucketLabel(series[n - 1].start, bucket)
}
};
});
</script>
{#if chart.empty}
<p class="empty">No data in this window.</p>
{:else}
<svg
viewBox="0 0 {W} {H}"
role="img"
aria-label="Cumulative cost over time, total {chart.finalTotal}"
class="chart"
>
<defs>
<linearGradient id="ccc-grad-area" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--amber)" stop-opacity="0.32" />
<stop offset="1" stop-color="var(--amber)" stop-opacity="0" />
</linearGradient>
<filter id="ccc-glow-line" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow
dx="0"
dy="0"
stdDeviation="2.5"
flood-color="var(--amber)"
flood-opacity="0.55"
/>
</filter>
<filter id="ccc-glow-dot" x="-150%" y="-150%" width="400%" height="400%">
<feDropShadow
dx="0"
dy="0"
stdDeviation="2"
flood-color="var(--amber)"
flood-opacity="0.8"
/>
</filter>
</defs>
{#each GRIDLINE_FRACS as frac (frac)}
<line
class="gridline"
x1={PLOT_LEFT}
x2={PLOT_RIGHT}
y1={PLOT_BOTTOM - frac * PLOT_HEIGHT}
y2={PLOT_BOTTOM - frac * PLOT_HEIGHT}
/>
{/each}
<line class="baseline" x1={PLOT_LEFT} x2={PLOT_RIGHT} y1={PLOT_BOTTOM} y2={PLOT_BOTTOM} />
<path class="area" d={chart.areaPath} />
<path class="line" d={chart.linePath} />
{#each chart.hoverSlices as slice, i (i)}
<rect class="hover" x={slice.x} y={PLOT_TOP} width={slice.width} height={PLOT_HEIGHT}>
<title>{slice.title}</title>
</rect>
{/each}
{#if chart.dot}
<circle class="dot-halo" cx={chart.dot.x} cy={chart.dot.y} r="7" />
<circle class="dot" cx={chart.dot.x} cy={chart.dot.y} r="3.25" />
{/if}
<text class="total-label" x={PLOT_RIGHT} y="16" text-anchor="end">{chart.finalTotal}</text>
</svg>
{#if chart.axisLabels}
<div class="chart-axis">
<span>{chart.axisLabels.first}</span>
<span>{chart.axisLabels.mid}</span>
<span>{chart.axisLabels.last}</span>
</div>
{/if}
{/if}
<style>
.chart {
display: block;
width: 100%;
height: 180px;
overflow: visible;
}
.gridline {
stroke: var(--grid-line);
stroke-width: 1;
}
.baseline {
stroke: var(--border);
stroke-width: 1;
}
.area {
fill: url(#ccc-grad-area);
stroke: none;
}
.line {
fill: none;
stroke: var(--amber);
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
filter: url(#ccc-glow-line);
}
.dot-halo {
fill: var(--amber);
opacity: 0.18;
}
.dot {
fill: var(--amber);
stroke: var(--bg-panel);
stroke-width: 1.5;
filter: url(#ccc-glow-dot);
}
.hover {
fill: transparent;
}
.hover:hover {
fill: color-mix(in srgb, var(--amber) 10%, transparent);
}
.total-label {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 13px;
font-weight: 600;
fill: var(--text);
}
.chart-axis {
display: flex;
justify-content: space-between;
margin-top: 4px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-faint);
}
.empty {
padding: 32px 0;
text-align: center;
color: var(--text-faint);
font-size: 13px;
}
</style>

View file

@ -0,0 +1,143 @@
<script lang="ts">
import { fmtInt, fmtUsd } from '$lib/format';
import type { HourBucket } from '$lib/server/queries';
interface Props {
hourly: HourBucket[];
metric: 'tokens' | 'cost';
}
let { hourly, metric }: Props = $props();
const W = 720;
const H = 160;
const PAD_TOP = 8;
const PAD_BOTTOM = 26;
const chartH = $derived(H - PAD_TOP - PAD_BOTTOM);
function hourLabel(hour: number): string {
return `${String(hour).padStart(2, '0')}:00`;
}
const values = $derived(hourly.map((b) => (metric === 'tokens' ? b.totalTokens : b.costUsd)));
const hasData = $derived(values.some((v) => v > 0));
const maxValue = $derived(Math.max(1, ...values));
const barW = $derived(W / Math.max(1, hourly.length));
const tickHours = [0, 6, 12, 18, 23];
</script>
{#if !hasData}
<p class="empty">No data in this window.</p>
{:else}
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label="Usage by hour of day (UTC)" class="chart">
<defs>
<linearGradient id="hoc-grad-accent" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--accent)" stop-opacity="1" />
<stop offset="1" stop-color="var(--accent)" stop-opacity="0.55" />
</linearGradient>
</defs>
<!-- gridlines -->
{#each [0.25, 0.5, 0.75, 1] as frac (frac)}
<line
class="gridline"
x1="0"
x2={W}
y1={PAD_TOP + chartH * (1 - frac)}
y2={PAD_TOP + chartH * (1 - frac)}
/>
{/each}
<line class="baseline" x1="0" x2={W} y1={PAD_TOP + chartH} y2={PAD_TOP + chartH} />
<!-- bars -->
{#each hourly as b (b.hour)}
{@const v = metric === 'tokens' ? b.totalTokens : b.costUsd}
{@const h = maxValue > 0 ? (v / maxValue) * chartH : 0}
{#if h > 0.4}
<rect
class="bar"
x={b.hour * barW + barW * 0.15}
y={PAD_TOP + chartH - h}
width={barW * 0.7}
height={h}
rx="2.5"
>
<title
>{hourLabel(b.hour)} UTC · {fmtInt(b.totalTokens)} tok / {fmtUsd(b.costUsd)} · {fmtInt(
b.eventCount
)} events</title
>
</rect>
{/if}
{/each}
<!-- x-axis ticks -->
{#each tickHours as hour (hour)}
<text class="tick" x={hour * barW + barW / 2} y={H - 6} text-anchor="middle"
>{hourLabel(hour)}</text
>
{/each}
</svg>
<div class="chart-axis">
<span>Hour of day</span>
<span class="utc-note">UTC</span>
</div>
{/if}
<style>
.chart {
width: 100%;
height: 160px;
overflow: visible;
}
.gridline {
stroke: var(--grid-line);
stroke-width: 1;
}
.baseline {
stroke: var(--border);
stroke-width: 1;
}
.bar {
fill: url(#hoc-grad-accent);
transition: opacity 0.15s ease;
}
.bar:hover {
opacity: 0.85;
}
.tick {
fill: var(--text-faint);
font-family: var(--font-mono);
font-size: 9px;
}
.chart-axis {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-top: 4px;
font-size: 11px;
color: var(--text-faint);
}
.utc-note {
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.empty {
color: var(--text-dim);
font-size: 13px;
padding: 24px 0;
text-align: center;
}
</style>

View file

@ -0,0 +1,117 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// A small "i" affordance with a hover/focus tooltip bubble. `children` is the
// tooltip body (rich markup allowed). Keyboard-accessible via :focus-within.
let { label = 'More information', children }: { label?: string; children: Snippet } = $props();
</script>
<span class="infotip">
<button type="button" class="trigger" aria-label={label}>i</button>
<span class="bubble" role="tooltip">{@render children()}</span>
</span>
<style>
.infotip {
position: relative;
display: inline-flex;
vertical-align: middle;
margin-left: 0.4rem;
}
.trigger {
appearance: none;
width: 1.05rem;
height: 1.05rem;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-dim);
border-radius: 50%;
font-family: var(--font-mono);
font-size: 0.68rem;
font-style: italic;
line-height: 1;
cursor: help;
transition:
color 0.12s ease,
border-color 0.12s ease,
box-shadow 0.12s ease;
}
.trigger:hover,
.trigger:focus-visible {
color: var(--on-accent);
background: var(--grad-accent);
border-color: transparent;
box-shadow: 0 4px 14px -4px var(--glow);
outline: none;
}
.bubble {
position: absolute;
top: calc(100% + 0.5rem);
left: 50%;
transform: translateX(-50%) translateY(-4px);
width: min(20rem, 78vw);
z-index: 20;
padding: 0.75rem 0.85rem;
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-pop);
color: var(--text-dim);
font-size: 0.78rem;
font-weight: 400;
line-height: 1.5;
text-align: left;
white-space: normal;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity 0.14s ease,
transform 0.14s ease;
}
/* little pointer arrow */
.bubble::before {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-bottom-color: var(--border);
}
.infotip:hover .bubble,
.infotip:focus-within .bubble {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
pointer-events: auto;
}
.bubble :global(strong) {
color: var(--text);
font-weight: 600;
}
.bubble :global(code) {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--accent);
}
.bubble :global(p) {
margin: 0 0 0.5rem;
}
.bubble :global(p:last-child) {
margin-bottom: 0;
}
</style>

View file

@ -0,0 +1,240 @@
<script lang="ts">
import type { ModelUsageRow } from '$lib/server/queries';
import { fmtUsd, fmtCompact, fmtPct, modelLabel, modelColor } from '$lib/format';
interface Props {
byModel: ModelUsageRow[];
metric: 'cost' | 'tokens';
}
let { byModel, metric }: Props = $props();
// ---------------------------------------------------------------------
// donut geometry — thick ring drawn as a mid-radius stroked circle,
// segmented per model via stroke-dasharray/dashoffset
// ---------------------------------------------------------------------
const SIZE = 220;
const CENTER = 110;
const R_OUTER = 85;
const R_INNER = 48;
const R_MID = (R_OUTER + R_INNER) / 2;
const STROKE_W = R_OUTER - R_INNER;
const CIRCUMFERENCE = 2 * Math.PI * R_MID;
interface Slice {
model: string;
label: string;
share: number;
value: number;
color: string;
gradientId: string;
dasharray: string;
dashoffset: number;
}
const MODEL_GRADIENT_IDS = Array.from({ length: 8 }, (_, i) => `donut-grad-${i}`);
function shareOf(m: ModelUsageRow): number {
return metric === 'cost' ? m.costShare : m.tokenShare;
}
function valueOf(m: ModelUsageRow): number {
return metric === 'cost' ? m.costUsd : m.totalTokens;
}
function fmtValue(v: number): string {
return metric === 'cost' ? fmtUsd(v) : `${fmtCompact(v)} tok`;
}
// slice order/index tracks the original (cost-desc) byModel order so
// colors stay stable even when zero-share models are skipped
const slices = $derived.by(() => {
let offset = 0;
const out: Slice[] = [];
byModel.forEach((m, i) => {
const share = shareOf(m);
if (share <= 0) return;
const len = share * CIRCUMFERENCE;
out.push({
model: m.model,
label: modelLabel(m.model),
share,
value: valueOf(m),
color: modelColor(i),
gradientId: MODEL_GRADIENT_IDS[i % MODEL_GRADIENT_IDS.length],
dasharray: `${len} ${Math.max(0, CIRCUMFERENCE - len)}`,
dashoffset: -offset
});
offset += len;
});
return out;
});
const hasData = $derived(slices.length > 0);
const grandTotal = $derived(byModel.reduce((sum, m) => sum + valueOf(m), 0));
const grandTotalLabel = $derived(metric === 'cost' ? fmtUsd(grandTotal) : fmtCompact(grandTotal));
</script>
{#if hasData}
<div class="donut-wrap">
<svg
viewBox="0 0 {SIZE} {SIZE}"
class="donut-svg"
role="img"
aria-label="Share of {metric === 'cost' ? 'notional cost' : 'tokens'} by model"
>
<defs>
{#each MODEL_GRADIENT_IDS as gid, i (gid)}
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--model-{i})" stop-opacity="1" />
<stop offset="1" stop-color="var(--model-{i})" stop-opacity="0.72" />
</linearGradient>
{/each}
</defs>
<g transform="rotate(-90 {CENTER} {CENTER})">
<circle
cx={CENTER}
cy={CENTER}
r={R_MID}
fill="none"
class="donut-track"
stroke-width={STROKE_W}
/>
{#each slices as s (s.model)}
<circle
cx={CENTER}
cy={CENTER}
r={R_MID}
fill="none"
stroke="url(#{s.gradientId})"
stroke-width={STROKE_W}
stroke-dasharray={s.dasharray}
stroke-dashoffset={s.dashoffset}
stroke-linecap="butt"
>
<title>{s.label} · {fmtPct(s.share)}</title>
</circle>
{/each}
</g>
<text x={CENTER} y={CENTER - 4} text-anchor="middle" class="donut-total"
>{grandTotalLabel}</text
>
<text x={CENTER} y={CENTER + 16} text-anchor="middle" class="donut-total-label">
{metric === 'cost' ? 'total cost' : 'total tokens'}
</text>
</svg>
<div class="donut-legend">
{#each slices as s (s.model)}
<div class="donut-legend-item">
<i style="background:{s.color}"></i>
<span class="donut-legend-name">{s.label}</span>
<span class="donut-legend-share">{fmtPct(s.share)}</span>
<span class="donut-legend-value">{fmtValue(s.value)}</span>
</div>
{/each}
</div>
</div>
{:else}
<p class="empty">No model usage in this window.</p>
{/if}
<style>
.donut-wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.75rem;
}
.donut-svg {
width: 200px;
height: 200px;
flex-shrink: 0;
overflow: visible;
display: block;
}
.donut-track {
stroke: var(--grid-line);
}
.donut-total {
fill: var(--text);
font-family: var(--font-mono);
font-size: 17px;
font-weight: 700;
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
}
.donut-total-label {
fill: var(--text-faint);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.donut-legend {
display: flex;
flex-direction: column;
gap: 0.55rem;
flex: 1;
min-width: 200px;
}
.donut-legend-item {
display: flex;
align-items: center;
gap: 0.55rem;
font-size: 0.82rem;
color: var(--text-dim);
padding: 0.15rem 0.3rem;
margin: -0.15rem -0.3rem;
border-radius: var(--radius-sm);
transition: background 0.15s ease;
}
.donut-legend-item:hover {
background: var(--bg-raised);
}
.donut-legend-item i {
width: 0.6rem;
height: 0.6rem;
border-radius: 2px;
flex-shrink: 0;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text) 10%, transparent);
}
.donut-legend-name {
flex: 1;
min-width: 0;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.donut-legend-share {
width: 3.4rem;
text-align: right;
color: var(--text-faint);
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.donut-legend-value {
width: 5.2rem;
text-align: right;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.empty {
color: var(--text-faint);
font-size: 0.88rem;
padding: 1.5rem 0;
text-align: center;
}
</style>

View file

@ -0,0 +1,305 @@
<script lang="ts">
import { fmtCompact, fmtUsd, fmtBucketLabel, modelLabel, modelColor } from '$lib/format';
import type { SeriesBucket, Bucket } from '$lib/server/queries';
interface Props {
series: SeriesBucket[];
modelSet: string[];
bucket: Bucket;
metric: 'tokens' | 'cost';
type: 'bars' | 'area';
}
let { series, modelSet, bucket, metric, type }: Props = $props();
const W = 720;
const H = 180;
const PAD_TOP = 8;
const PAD_BOTTOM = 4;
const CHART_H = H - PAD_TOP - PAD_BOTTOM;
/** Per-bucket, per-model value in stacking order (matches `modelSet`). */
interface StackedBucket {
start: string;
values: number[]; // one entry per model in modelSet
total: number;
}
function valueFor(model: string, bucket: SeriesBucket): number {
const slice = bucket.models.find((m) => m.model === model);
if (!slice) return 0;
return metric === 'tokens' ? slice.totalTokens : slice.costUsd;
}
const stacked = $derived<StackedBucket[]>(
series.map((b) => {
const values = modelSet.map((model) => valueFor(model, b));
const total = values.reduce((a, v) => a + v, 0);
return { start: b.start, values, total };
})
);
const maxTotal = $derived(Math.max(1, ...stacked.map((b) => b.total)));
const isEmpty = $derived(modelSet.length === 0 || stacked.every((b) => b.total <= 0));
const gridFracs = [0.25, 0.5, 0.75, 1];
const MODEL_GRADIENT_IDS = Array.from({ length: 8 }, (_, i) => `msc-grad-model-${i}`);
function gradientIdFor(mi: number): string {
return MODEL_GRADIENT_IDS[mi % MODEL_GRADIENT_IDS.length];
}
function fmtValue(v: number): string {
return metric === 'tokens' ? `${fmtCompact(v)} tok` : fmtUsd(v);
}
// --- bars layout ---
const barGap = 2;
const barSlot = $derived(stacked.length > 0 ? W / stacked.length : W);
const barWidth = $derived(Math.max(1, barSlot - barGap));
function barX(i: number): number {
return i * barSlot + barGap / 2;
}
interface BarSegment {
key: string;
x: number;
y: number;
width: number;
height: number;
color: string;
gradientId: string;
title: string;
}
const barSegments = $derived<BarSegment[]>(
isEmpty
? []
: stacked.flatMap((b, i) => {
let cumTop = CHART_H;
const segs: BarSegment[] = [];
b.values.forEach((v, mi) => {
const h = (v / maxTotal) * CHART_H;
if (h > 0.4) {
const y = cumTop - h;
segs.push({
key: `${i}-${mi}`,
x: barX(i),
y: PAD_TOP + y,
width: barWidth,
height: h,
color: modelColor(mi),
gradientId: gradientIdFor(mi),
title: `${fmtBucketLabel(b.start, bucket)} · ${modelLabel(modelSet[mi])}: ${fmtValue(v)}`
});
}
cumTop -= h;
});
return segs;
})
);
// --- area layout ---
interface AreaBand {
key: string;
path: string;
color: string;
gradientId: string;
model: string;
}
function xForIndex(i: number): number {
if (stacked.length <= 1) return W / 2;
return (i / (stacked.length - 1)) * W;
}
const areaBands = $derived<AreaBand[]>(
isEmpty
? []
: modelSet.map((model, mi) => {
// cumulative top (from bottom) up to and including this model
const topsBefore = stacked.map((b) => b.values.slice(0, mi).reduce((a, v) => a + v, 0));
const topsIncl = stacked.map((b) => b.values.slice(0, mi + 1).reduce((a, v) => a + v, 0));
const yFor = (v: number) => PAD_TOP + CHART_H - (v / maxTotal) * CHART_H;
const topPoints = topsIncl.map((v, i) => `${xForIndex(i)},${yFor(v)}`);
const bottomPoints = topsBefore.map((v, i) => `${xForIndex(i)},${yFor(v)}`).reverse();
const path = `M${topPoints.join(' L')} L${bottomPoints.join(' L')} Z`;
return { key: model, path, color: modelColor(mi), gradientId: gradientIdFor(mi), model };
})
);
// per-point tooltips (invisible markers) for the area chart
interface AreaPoint {
key: string;
cx: number;
cy: number;
color: string;
title: string;
}
const areaPoints = $derived<AreaPoint[]>(
isEmpty
? []
: modelSet.flatMap((model, mi) =>
stacked.map((b, i) => {
const v = b.values[mi];
const topIncl = b.values.slice(0, mi + 1).reduce((a, x) => a + x, 0);
const y = PAD_TOP + CHART_H - (topIncl / maxTotal) * CHART_H;
return {
key: `${model}-${i}`,
cx: xForIndex(i),
cy: y,
color: modelColor(mi),
title: `${fmtBucketLabel(b.start, bucket)} · ${modelLabel(model)}: ${fmtValue(v)}`
};
})
)
);
const axisLabels = $derived.by(() => {
const n = stacked.length;
if (n === 0) return [];
if (n === 1) return [fmtBucketLabel(stacked[0].start, bucket)];
if (n === 2) {
return [fmtBucketLabel(stacked[0].start, bucket), fmtBucketLabel(stacked[1].start, bucket)];
}
return [
fmtBucketLabel(stacked[0].start, bucket),
fmtBucketLabel(stacked[Math.floor((n - 1) / 2)].start, bucket),
fmtBucketLabel(stacked[n - 1].start, bucket)
];
});
</script>
{#if isEmpty}
<p class="empty">No data in this window.</p>
{:else}
<svg
viewBox="0 0 {W} {H}"
role="img"
aria-label="{metric === 'tokens' ? 'Token' : 'Cost'} usage by model over time"
class="chart"
>
<defs>
{#each MODEL_GRADIENT_IDS as gid, i (gid)}
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--model-{i})" stop-opacity="1" />
<stop offset="1" stop-color="var(--model-{i})" stop-opacity="0.6" />
</linearGradient>
{/each}
</defs>
{#each gridFracs as f (f)}
<line
class="gridline"
x1="0"
x2={W}
y1={PAD_TOP + CHART_H * (1 - f)}
y2={PAD_TOP + CHART_H * (1 - f)}
/>
{/each}
<line class="baseline" x1="0" x2={W} y1={PAD_TOP + CHART_H} y2={PAD_TOP + CHART_H} />
{#if type === 'bars'}
{#each barSegments as seg (seg.key)}
<rect
x={seg.x}
y={seg.y}
width={seg.width}
height={seg.height}
rx="2.5"
fill="url(#{seg.gradientId})"
>
<title>{seg.title}</title>
</rect>
{/each}
{:else}
{#each areaBands as band (band.key)}
<path d={band.path} fill="url(#{band.gradientId})" fill-opacity="0.85" stroke="none" />
{/each}
{#each areaPoints as pt (pt.key)}
<circle cx={pt.cx} cy={pt.cy} r="6" fill={pt.color} fill-opacity="0">
<title>{pt.title}</title>
</circle>
{/each}
{/if}
</svg>
{#if modelSet.length > 1}
<div class="chart-legend">
{#each modelSet as model, i (model)}
<div class="legend-item">
<i style="background:{modelColor(i)}"></i>
<span>{modelLabel(model)}</span>
</div>
{/each}
</div>
{/if}
<div class="chart-axis">
{#each axisLabels as label, i (i)}
<span>{label}</span>
{/each}
</div>
{/if}
<style>
.chart {
width: 100%;
height: 180px;
overflow: visible;
}
.gridline {
stroke: var(--grid-line);
stroke-width: 1;
}
.baseline {
stroke: var(--border);
stroke-width: 1;
}
.empty {
color: var(--text-faint);
font-size: 0.875rem;
text-align: center;
padding: 2.5rem 0;
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.5rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--text-dim);
}
.legend-item i {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text) 10%, transparent);
}
.chart-axis {
display: flex;
justify-content: space-between;
margin-top: 0.375rem;
font-family: var(--font-mono);
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
color: var(--text-faint);
}
</style>

View file

@ -0,0 +1,174 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { RANGE_PRESETS, type ResolvedRange } from '$lib/ranges';
let { range }: { range: ResolvedRange } = $props();
// local overrides only kick in once the user edits/opens; otherwise everything
// tracks the resolved range from the server load (so navigation re-seeds it).
let manualCustom = $state(false);
let userFrom = $state<string | null>(null);
let userTo = $state<string | null>(null);
const showCustom = $derived(manualCustom || range.custom);
const from = $derived(
userFrom ?? range.from ?? (range.since ?? new Date().toISOString()).slice(0, 10)
);
const to = $derived(userTo ?? range.to ?? range.until.slice(0, 10));
// Navigate on the CURRENT route (dashboard, /sessions, …), preserving any other
// query params (e.g. sort/dir) and only rewriting range/from/to. resolve() only
// rewrites pathnames, not query strings, so the disable is required.
function navWith(updates: Record<string, string | null>) {
const params = new SvelteURLSearchParams(page.url.search);
for (const [k, v] of Object.entries(updates)) {
if (v === null) params.delete(k);
else params.set(k, v);
}
const qs = params.toString();
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(`${page.url.pathname}${qs ? `?${qs}` : ''}`, { keepFocus: true, noScroll: true });
}
function selectPreset(key: string) {
manualCustom = false;
navWith({ range: key, from: null, to: null });
}
function applyCustom(e: Event) {
e.preventDefault();
if (!from || !to) return;
navWith({ range: 'custom', from, to });
}
</script>
<div class="range">
<div class="seg" role="group" aria-label="Time range">
{#each RANGE_PRESETS as p (p.key)}
<button
type="button"
class:active={!showCustom && range.key === p.key}
onclick={() => selectPreset(p.key)}>{p.label}</button
>
{/each}
<button type="button" class:active={showCustom} onclick={() => (manualCustom = !manualCustom)}
>Custom</button
>
</div>
{#if showCustom}
<form class="custom" onsubmit={applyCustom}>
<input
type="date"
value={from}
oninput={(e) => (userFrom = e.currentTarget.value)}
aria-label="From date"
max={to}
/>
<span class="dash"></span>
<input
type="date"
value={to}
oninput={(e) => (userTo = e.currentTarget.value)}
aria-label="To date"
min={from}
/>
<button type="submit" class="apply">Apply</button>
</form>
{/if}
</div>
<style>
.range {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.seg {
display: inline-flex;
background: var(--bg-panel-2);
border: 1px solid var(--border-soft);
border-radius: 999px;
padding: 2px;
}
.seg button {
appearance: none;
border: none;
background: transparent;
color: var(--text-dim);
font: inherit;
font-size: 0.8rem;
padding: 0.32rem 0.7rem;
border-radius: 999px;
cursor: pointer;
transition:
background 0.12s ease,
color 0.12s ease;
}
.seg button:hover {
color: var(--text);
}
.seg button.active {
background: var(--grad-accent);
color: var(--on-accent);
font-weight: 600;
box-shadow: 0 4px 14px -4px var(--glow);
}
.custom {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.custom input[type='date'] {
background: var(--bg-raised);
border: 1px solid var(--border-soft);
color: var(--text);
border-radius: var(--radius-sm);
padding: 0.3rem 0.5rem;
font: inherit;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 0.8rem;
transition: border-color 0.12s ease;
}
.custom input[type='date']:hover,
.custom input[type='date']:focus-visible {
border-color: var(--accent);
outline: none;
}
.dash {
color: var(--text-faint);
font-size: 0.8rem;
}
.apply {
appearance: none;
border: 1px solid var(--border-soft);
background: var(--bg-raised);
color: var(--text);
font: inherit;
font-size: 0.8rem;
padding: 0.32rem 0.75rem;
border-radius: var(--radius-sm);
cursor: pointer;
transition:
border-color 0.12s ease,
background 0.12s ease;
}
.apply:hover {
border-color: var(--accent);
background: var(--bg-panel-2);
}
</style>

View file

@ -0,0 +1,334 @@
<script lang="ts">
import type { SeriesBucket, Bucket } from '$lib/server/queries';
import { fmtInt, fmtUsd, fmtBucketLabel } from '$lib/format';
interface Props {
series: SeriesBucket[];
bucket: Bucket;
metric: 'tokens' | 'cost';
type: 'bars' | 'area';
}
let { series, bucket, metric, type }: Props = $props();
const W = 720;
const H = 180;
const TOP_PAD = 10;
interface SeriesDef {
key: string;
label: string;
color: string;
gradientId: string;
get: (b: SeriesBucket) => number;
}
const TOKEN_SERIES: SeriesDef[] = [
{
key: 'input',
label: 'Input',
color: 'var(--accent)',
gradientId: 'tsc-grad-accent',
get: (b) => b.inputTokens
},
{
key: 'output',
label: 'Output',
color: 'var(--accent-2)',
gradientId: 'tsc-grad-accent-2',
get: (b) => b.outputTokens
},
{
key: 'cacheWrite',
label: 'Cache write',
color: 'var(--amber)',
gradientId: 'tsc-grad-amber',
get: (b) => b.cacheCreationTokens
},
{
key: 'cacheRead',
label: 'Cache read',
color: 'var(--purple)',
gradientId: 'tsc-grad-purple',
get: (b) => b.cacheReadTokens
}
];
const COST_SERIES: SeriesDef[] = [
{
key: 'cost',
label: 'Cost ($)',
color: 'var(--amber)',
gradientId: 'tsc-grad-amber',
get: (b) => b.costUsd
}
];
let seriesDefs = $derived(metric === 'tokens' ? TOKEN_SERIES : COST_SERIES);
function valueLabel(value: number): string {
return metric === 'tokens' ? `${fmtInt(value)} tok` : fmtUsd(value);
}
let stackTotals = $derived(
series.map((b) => seriesDefs.reduce((sum, s) => sum + Math.max(0, s.get(b)), 0))
);
let hasData = $derived(series.length > 0 && stackTotals.some((v) => v > 0));
let maxValue = $derived(Math.max(1, ...stackTotals, 0));
function yFor(value: number): number {
return H - (Math.max(0, value) / maxValue) * (H - TOP_PAD);
}
interface BarSegment {
y: number;
height: number;
color: string;
gradientId: string;
title: string;
}
interface BarColumn {
x: number;
width: number;
segments: BarSegment[];
}
let barColumns = $derived.by((): BarColumn[] => {
if (type !== 'bars' || series.length === 0) return [];
const slot = W / series.length;
const width = Math.max(2, Math.min(slot * 0.6, 48));
return series.map((b, i) => {
const x = i * slot + (slot - width) / 2;
let cursor = H;
const segments: BarSegment[] = [];
for (const s of seriesDefs) {
const value = Math.max(0, s.get(b));
const h = (value / maxValue) * (H - TOP_PAD);
if (h > 0.4) {
segments.push({
y: cursor - h,
height: h,
color: s.color,
gradientId: s.gradientId,
title: `${fmtBucketLabel(b.start, bucket)} · ${s.label}: ${valueLabel(value)}`
});
}
cursor -= h;
}
return { x, width, segments };
});
});
interface AreaMarker {
x: number;
y: number;
title: string;
}
interface AreaBand {
color: string;
gradientId: string;
bandPolygon: string;
topLine: string;
markers: AreaMarker[];
}
let areaBands = $derived.by((): AreaBand[] => {
if (type !== 'area' || series.length === 0) return [];
// Duplicate a lone bucket so the band spans the full plot width.
const buckets = series.length === 1 ? [series[0], series[0]] : series;
const pts = buckets.length;
const xs = buckets.map((_, i) => (i / (pts - 1)) * W);
let cumBefore = new Array<number>(pts).fill(0);
const bands: AreaBand[] = [];
for (const s of seriesDefs) {
const cumAfter = buckets.map((b, i) => cumBefore[i] + Math.max(0, s.get(b)));
const topPoints = xs.map((x, i) => ({ x, y: yFor(cumAfter[i]) }));
const bottomPoints = xs.map((x, i) => ({ x, y: yFor(cumBefore[i]) }));
const bandPolygon =
topPoints.map((p) => `${p.x},${p.y}`).join(' ') +
' ' +
[...bottomPoints]
.reverse()
.map((p) => `${p.x},${p.y}`)
.join(' ');
const topLine = topPoints.map((p) => `${p.x},${p.y}`).join(' ');
const markers = buckets.map((b, i) => ({
x: xs[i],
y: topPoints[i].y,
title: `${fmtBucketLabel(b.start, bucket)} · ${s.label}: ${valueLabel(Math.max(0, s.get(b)))}`
}));
bands.push({ color: s.color, gradientId: s.gradientId, bandPolygon, topLine, markers });
cumBefore = cumAfter;
}
return bands;
});
let firstLabel = $derived(series.length > 0 ? fmtBucketLabel(series[0].start, bucket) : '');
let lastLabel = $derived(
series.length > 0 ? fmtBucketLabel(series[series.length - 1].start, bucket) : ''
);
let midIndex = $derived(Math.floor((series.length - 1) / 2));
let showMidLabel = $derived(series.length > 2);
let midLabel = $derived(showMidLabel ? fmtBucketLabel(series[midIndex].start, bucket) : '');
</script>
<div class="chart">
{#if !hasData}
<p class="empty">No data in this window.</p>
{:else}
<svg
viewBox="0 0 {W} {H}"
role="img"
aria-label="{metric === 'tokens' ? 'Token usage' : 'Cost'} over time"
class="chart-svg"
>
<defs>
<linearGradient id="tsc-grad-accent" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--accent)" stop-opacity="1" />
<stop offset="1" stop-color="var(--accent)" stop-opacity="0.6" />
</linearGradient>
<linearGradient id="tsc-grad-accent-2" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--accent-2)" stop-opacity="1" />
<stop offset="1" stop-color="var(--accent-2)" stop-opacity="0.6" />
</linearGradient>
<linearGradient id="tsc-grad-amber" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--amber)" stop-opacity="1" />
<stop offset="1" stop-color="var(--amber)" stop-opacity="0.6" />
</linearGradient>
<linearGradient id="tsc-grad-purple" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--purple)" stop-opacity="1" />
<stop offset="1" stop-color="var(--purple)" stop-opacity="0.6" />
</linearGradient>
</defs>
{#each [0.25, 0.5, 0.75, 1] as f (f)}
<line class="gridline" x1="0" y1={yFor(f * maxValue)} x2={W} y2={yFor(f * maxValue)} />
{/each}
<line class="baseline" x1="0" y1={H} x2={W} y2={H} />
{#if type === 'bars'}
{#each barColumns as col, i (i)}
{#each col.segments as seg, j (j)}
<rect
x={col.x}
y={seg.y}
width={col.width}
height={seg.height}
rx="2.5"
fill="url(#{seg.gradientId})"
>
<title>{seg.title}</title>
</rect>
{/each}
{/each}
{:else}
{#each areaBands as band, i (i)}
<polygon points={band.bandPolygon} fill="url(#{band.gradientId})" opacity="0.85" />
<polyline points={band.topLine} fill="none" stroke={band.color} stroke-width="1.5" />
{#each band.markers as m, j (j)}
<circle class="marker" cx={m.x} cy={m.y} r="5" fill={band.color}>
<title>{m.title}</title>
</circle>
{/each}
{/each}
{/if}
</svg>
<div class="chart-legend">
{#each seriesDefs as s (s.key)}
<span class="legend-item"><i style="background:{s.color}"></i>{s.label}</span>
{/each}
</div>
<div class="chart-axis">
<span>{firstLabel}</span>
{#if showMidLabel}
<span>{midLabel}</span>
{/if}
<span>{lastLabel}</span>
</div>
{/if}
</div>
<style>
.chart {
width: 100%;
}
.chart-svg {
width: 100%;
height: 180px;
overflow: visible;
}
.gridline {
stroke: var(--grid-line);
stroke-width: 1;
}
.baseline {
stroke: var(--border);
stroke-width: 1;
}
.marker {
opacity: 0;
pointer-events: all;
transition: opacity 0.1s ease;
}
.marker:hover {
opacity: 0.6;
}
.empty {
margin: 0;
padding: 48px 0;
text-align: center;
color: var(--text-faint);
font-size: 0.85rem;
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 10px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
color: var(--text-dim);
}
.legend-item i {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 2px;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text) 10%, transparent);
}
.chart-axis {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: 0.72rem;
color: var(--text-faint);
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -0,0 +1,344 @@
<script lang="ts">
import { fmtInt, fmtUsd, fmtDateShort } from '$lib/format';
import type { CalendarDay } from '$lib/server/stats/calendar';
interface Props {
days: CalendarDay[];
}
let { days }: Props = $props();
let metric = $state<'tokens' | 'cost'>('tokens');
// --- geometry (fixed unitless viewBox, scales to 100% width via CSS) ---
const CELL = 12; // cell edge
const GAP = 3; // gap between cells
const STEP = CELL + GAP; // 15
const WEEKS = 53;
const PAD_LEFT = 26; // room for weekday labels
const PAD_TOP = 16; // room for month labels
const W = PAD_LEFT + WEEKS * STEP;
const H = PAD_TOP + 7 * STEP;
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
interface Cell {
date: string;
col: number;
row: number;
tokens: number;
cost: number;
events: number;
value: number;
level: number; // 0..4
}
// Build the trailing 53-week grid ending on the current week (UTC). Column 52
// is the current week; each column is a Sun→Sat week, rows 0..6 = Sun..Sat.
const grid = $derived.by(() => {
const lookup = new Map(days.map((d) => [d.date, d]));
// Start of the current week (Sunday) in UTC.
const now = new Date();
const todayUTC = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
);
const weekStart = new Date(todayUTC);
weekStart.setUTCDate(weekStart.getUTCDate() - todayUTC.getUTCDay());
// Grid start = 52 weeks before the current week's Sunday → 53 columns.
const gridStart = new Date(weekStart);
gridStart.setUTCDate(gridStart.getUTCDate() - (WEEKS - 1) * 7);
const cells: Cell[] = [];
const monthLabels: { col: number; label: string }[] = [];
let lastMonth = -1;
let maxValue = 0;
const cur = new Date(gridStart);
for (let col = 0; col < WEEKS; col++) {
for (let row = 0; row < 7; row++) {
const iso = cur.toISOString().slice(0, 10);
const isFuture = cur.getTime() > todayUTC.getTime();
const hit = lookup.get(iso);
const tokens = hit?.tokens ?? 0;
const cost = hit?.cost ?? 0;
const events = hit?.events ?? 0;
if (!isFuture) {
const value = metric === 'tokens' ? tokens : cost;
if (value > maxValue) maxValue = value;
cells.push({ date: iso, col, row, tokens, cost, events, value, level: 0 });
}
// month label at the top of the first column that starts a new month
if (row === 0) {
const m = cur.getUTCMonth();
if (m !== lastMonth) {
monthLabels.push({ col, label: MONTHS[m] });
lastMonth = m;
}
}
cur.setUTCDate(cur.getUTCDate() + 1);
}
}
// Log-scaled intensity so a few huge days don't wash out the rest.
const denom = Math.log(maxValue + 1);
for (const c of cells) {
if (c.value <= 0 || denom <= 0) {
c.level = 0;
} else {
c.level = Math.min(4, Math.max(1, Math.ceil((Math.log(c.value + 1) / denom) * 4)));
}
}
return { cells, monthLabels, maxValue };
});
const hasData = $derived(grid.maxValue > 0);
const weekdayTicks = [
{ row: 1, label: 'Mon' },
{ row: 3, label: 'Wed' },
{ row: 5, label: 'Fri' }
];
function cellX(col: number): number {
return PAD_LEFT + col * STEP;
}
function cellY(row: number): number {
return PAD_TOP + row * STEP;
}
function levelFill(level: number): string {
return level === 0 ? 'var(--grid-line)' : `url(#cal-grad-${level})`;
}
function tooltip(c: Cell): string {
return `${fmtDateShort(c.date)} · ${fmtInt(c.tokens)} tok / ${fmtUsd(c.cost)} · ${fmtInt(c.events)} events`;
}
</script>
<section class="panel reveal" style="--d:7">
<div class="panel-head">
<h2>Activity calendar</h2>
<div class="panel-controls">
<div class="seg" role="group" aria-label="Calendar metric">
<button
type="button"
class:active={metric === 'tokens'}
onclick={() => (metric = 'tokens')}>Tokens</button
>
<button
type="button"
class:active={metric === 'cost'}
onclick={() => (metric = 'cost')}>Cost</button
>
</div>
</div>
</div>
{#if !hasData}
<p class="empty">No activity in the last year.</p>
{:else}
<svg
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label="Activity by day over the last 53 weeks (UTC)"
class="chart"
>
<defs>
{#each [1, 2, 3, 4] as lvl (lvl)}
<linearGradient id={`cal-grad-${lvl}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--accent)" stop-opacity={0.28 + lvl * 0.18} />
<stop offset="1" stop-color="var(--accent)" stop-opacity={0.18 + lvl * 0.15} />
</linearGradient>
{/each}
</defs>
<!-- month labels -->
{#each grid.monthLabels as m (m.col + m.label)}
<text class="tick" x={cellX(m.col)} y={PAD_TOP - 5}>{m.label}</text>
{/each}
<!-- weekday labels -->
{#each weekdayTicks as wd (wd.row)}
<text
class="tick weekday"
x={PAD_LEFT - 6}
y={cellY(wd.row) + CELL - 2}
text-anchor="end">{wd.label}</text
>
{/each}
<!-- day cells -->
{#each grid.cells as c (c.date)}
<rect
class="cell"
x={cellX(c.col)}
y={cellY(c.row)}
width={CELL}
height={CELL}
rx="2.5"
fill={levelFill(c.level)}
>
<title>{tooltip(c)}</title>
</rect>
{/each}
</svg>
<div class="chart-axis">
<span class="utc-note">UTC · last 53 weeks</span>
<span class="legend">
<span class="legend-lbl">Less</span>
<span class="swatch lvl0"></span>
<span class="swatch lvl1"></span>
<span class="swatch lvl2"></span>
<span class="swatch lvl3"></span>
<span class="swatch lvl4"></span>
<span class="legend-lbl">More</span>
</span>
</div>
{/if}
</section>
<style>
.panel {
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 1.3rem 1.4rem 1.5rem;
margin-bottom: 1.35rem;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.panel-head h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text);
}
.panel-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.seg {
display: inline-flex;
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg-raised);
}
.seg button {
appearance: none;
border: none;
background: transparent;
color: var(--text-dim);
font: inherit;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.3rem 0.7rem;
cursor: pointer;
transition: background 140ms ease, color 140ms ease;
}
.seg button:hover {
color: var(--text);
}
.seg button.active {
background: var(--accent);
color: var(--bg-panel);
}
.chart {
width: 100%;
height: auto;
overflow: visible;
}
.tick {
fill: var(--text-faint);
font-family: var(--font-mono);
font-size: 9px;
}
.weekday {
font-size: 8.5px;
}
.cell {
stroke: var(--border-soft);
stroke-width: 0.5;
transition: opacity 0.15s ease;
}
.cell:hover {
opacity: 0.8;
}
.chart-axis {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
font-size: 11px;
color: var(--text-faint);
}
.utc-note {
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.legend {
display: inline-flex;
align-items: center;
gap: 3px;
}
.legend-lbl {
margin: 0 4px;
}
.swatch {
display: inline-block;
width: 11px;
height: 11px;
border-radius: 2.5px;
border: 0.5px solid var(--border-soft);
}
.lvl0 {
background: var(--grid-line);
}
.lvl1 {
background: color-mix(in srgb, var(--accent) 34%, transparent);
}
.lvl2 {
background: color-mix(in srgb, var(--accent) 52%, transparent);
}
.lvl3 {
background: color-mix(in srgb, var(--accent) 70%, transparent);
}
.lvl4 {
background: color-mix(in srgb, var(--accent) 88%, transparent);
}
.empty {
color: var(--text-dim);
font-size: 13px;
padding: 24px 0;
text-align: center;
}
</style>

View file

@ -0,0 +1,213 @@
<script lang="ts">
import { fmtInt, fmtCompact, fmtUsd, fmtPct, basename } from '$lib/format';
import type { ProjectUsageRow } from '$lib/server/stats/byProject';
interface Props {
projects: ProjectUsageRow[];
}
let { projects }: Props = $props();
// client-side view toggle — mirrors the "By model" card's $ / Tokens control
let metric = $state<'cost' | 'tokens'>('cost');
const value = (p: ProjectUsageRow) => (metric === 'cost' ? p.costUsd : p.totalTokens);
const label = (p: ProjectUsageRow) =>
p.isOther ? `Other (${fmtInt(p.otherCount)})` : basename(p.project);
// leaderboard: rank by the selected metric, scale bars to the largest value
const ranked = $derived([...projects].sort((a, b) => value(b) - value(a)));
const maxValue = $derived(Math.max(1, ...ranked.map((p) => value(p))));
const hasData = $derived(ranked.some((p) => value(p) > 0));
</script>
<div class="panel byproject">
<div class="panel-head">
<h2>By project</h2>
<div class="panel-controls">
<div class="tg" role="group">
<button type="button" class:active={metric === 'cost'} onclick={() => (metric = 'cost')}
>$</button
>
<button
type="button"
class:active={metric === 'tokens'}
onclick={() => (metric = 'tokens')}>Tokens</button
>
</div>
</div>
</div>
{#if hasData}
<div class="proj-list">
{#each ranked as p (p.isOther ? '__other__' : (p.project ?? '__null__'))}
{@const share = metric === 'cost' ? p.costShare : p.tokenShare}
<div class="proj-row" class:other={p.isOther}>
<div class="proj-row-top">
<span class="proj-name" title={p.isOther ? undefined : (p.project ?? undefined)}
>{label(p)}</span
>
<span class="proj-val mono" class:accent-amber={metric === 'cost'}>
{metric === 'cost' ? fmtUsd(p.costUsd) : `${fmtCompact(p.totalTokens)} tok`}
</span>
</div>
<div class="proj-bar-track">
<div class="proj-bar-fill" style="width:{((value(p) / maxValue) * 100).toFixed(2)}%">
</div>
</div>
<div class="proj-row-bottom mono">
<span>{fmtPct(share)}</span>
<span>{fmtInt(p.sessionCount)} sessions</span>
<span>{fmtInt(p.eventCount)} events</span>
<span
>{metric === 'cost' ? `${fmtCompact(p.totalTokens)} tok` : fmtUsd(p.costUsd)}</span
>
</div>
</div>
{/each}
</div>
{:else}
<p class="empty">No project activity in this window.</p>
{/if}
</div>
<style>
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.accent-amber {
color: var(--amber);
}
/* ---- panel shell (matches the dashboard's .panel) ---- */
.panel {
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 1.3rem 1.4rem 1.5rem;
margin-bottom: 1.35rem;
min-width: 0;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1.1rem;
flex-wrap: wrap;
}
.panel-head h2 {
margin: 0;
font-family: var(--font-sans);
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.005em;
}
.panel-controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* ---- segmented toggle (matches .tg in the dashboard) ---- */
.tg {
display: inline-flex;
background: var(--bg-panel-2);
border: 1px solid var(--border-soft);
border-radius: 999px;
padding: 3px;
gap: 1px;
}
.tg button {
appearance: none;
border: none;
background: transparent;
color: var(--text-dim);
font: inherit;
font-family: var(--font-sans);
font-size: 0.73rem;
font-weight: 500;
padding: 0.28rem 0.66rem;
border-radius: 999px;
cursor: pointer;
transition:
color 150ms ease,
background 150ms ease,
box-shadow 150ms ease;
}
.tg button:hover {
color: var(--text);
}
.tg button.active {
background: var(--bg-raised);
color: var(--text);
font-weight: 600;
box-shadow:
inset 0 0 0 1px var(--border),
var(--shadow-card);
}
/* ---- leaderboard rows (modeled on .model-*) ---- */
.proj-list {
display: flex;
flex-direction: column;
gap: 1.05rem;
}
.proj-row-top {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.4rem;
}
.proj-name {
font-family: var(--font-sans);
font-size: 0.92rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.proj-row.other .proj-name {
color: var(--text-dim);
font-style: italic;
}
.proj-val {
font-size: 0.92rem;
font-weight: 600;
white-space: nowrap;
}
.proj-bar-track {
height: 8px;
border-radius: 4px;
background: var(--bg-raised);
overflow: hidden;
}
.proj-bar-fill {
height: 100%;
border-radius: 4px;
background: var(--grad-accent);
transition: width 300ms ease;
}
.proj-row.other .proj-bar-fill {
background: var(--border);
}
.proj-row-bottom {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 0.4rem;
font-size: 0.76rem;
color: var(--text-dim);
}
.empty {
color: var(--text-faint);
font-size: 0.88rem;
padding: 1.75rem 0;
text-align: center;
}
</style>

View file

@ -0,0 +1,274 @@
<script lang="ts">
import { fmtDateTimeShort } from '$lib/format';
import type { GaugeHistory, GaugeHistoryPoint } from '$lib/server/stats/gaugeHistory';
interface Props {
data: GaugeHistory;
/** Reveal-stagger index, matching the dashboard's `style="--d:N"` sections. */
d?: number;
}
let { data, d = 7 }: Props = $props();
const W = 720;
const H = 180;
const PAD_TOP = 10;
const PAD_BOTTOM = 24;
const chartH = $derived(H - PAD_TOP - PAD_BOTTOM);
interface LineDef {
key: string;
label: string;
color: string;
get: (p: GaugeHistoryPoint) => number | null;
}
const LINES: LineDef[] = [
{ key: 'session', label: 'Session', color: 'var(--accent)', get: (p) => p.sessionPct },
{ key: 'weekAll', label: 'Week (all)', color: 'var(--amber)', get: (p) => p.weekAllPct },
{ key: 'weekSonnet', label: 'Week (Sonnet)', color: 'var(--purple)', get: (p) => p.weekSonnetPct }
];
const points = $derived(data.points);
const hasData = $derived(
points.length > 0 &&
LINES.some((l) => points.some((p) => l.get(p) !== null && (l.get(p) as number) > 0))
);
// Time span → x. Percentages are on a fixed 0100 axis.
const t0 = $derived(points.length ? Date.parse(points[0].tsUtc) : 0);
const tSpan = $derived(
points.length ? Math.max(0, Date.parse(points[points.length - 1].tsUtc) - t0) : 0
);
function xFor(ts: string, i: number): number {
if (points.length <= 1) return W / 2;
if (tSpan <= 0) return (i / (points.length - 1)) * W;
return ((Date.parse(ts) - t0) / tSpan) * W;
}
function yFor(pct: number): number {
const clamped = Math.min(100, Math.max(0, pct));
return PAD_TOP + chartH * (1 - clamped / 100);
}
interface RenderPoint {
x: number;
y: number;
pct: number;
ts: string;
}
interface RenderLine {
key: string;
label: string;
color: string;
polyline: string;
dots: RenderPoint[];
latest: number | null;
}
const lines = $derived.by((): RenderLine[] =>
LINES.map((l) => {
const rp: RenderPoint[] = [];
for (let i = 0; i < points.length; i++) {
const v = l.get(points[i]);
if (v === null || v === undefined) continue;
rp.push({ x: xFor(points[i].tsUtc, i), y: yFor(v), pct: v, ts: points[i].tsUtc });
}
const latest = rp.length ? rp[rp.length - 1].pct : null;
return {
key: l.key,
label: l.label,
color: l.color,
polyline: rp.map((p) => `${p.x},${p.y}`).join(' '),
dots: rp,
latest
};
})
);
const gridFracs = [0, 0.25, 0.5, 0.75, 1];
const firstLabel = $derived(points.length ? fmtDateTimeShort(points[0].tsUtc) : '');
const lastLabel = $derived(points.length ? fmtDateTimeShort(points[points.length - 1].tsUtc) : '');
function fmtLimitPct(v: number | null): string {
return v === null ? '—' : `${v.toFixed(0)}%`;
}
</script>
<section class="panel-card reveal" style={`--d:${d}`}>
<div class="panel-head">
<h2>How close to limits over time</h2>
{#if data.host}
<span class="panel-sub mono">{data.host}</span>
{/if}
</div>
{#if !hasData}
<p class="empty">No usage-gauge data in this window.</p>
{:else}
<div class="legend">
{#each lines as l (l.key)}
<span class="legend-item">
<i style={`background:${l.color}`}></i>
<span class="legend-label">{l.label}</span>
<span class="legend-val mono">{fmtLimitPct(l.latest)}</span>
</span>
{/each}
</div>
<svg
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label="Usage limit percentages over time"
class="chart"
>
{#each gridFracs as frac (frac)}
<line
class="gridline"
x1="0"
x2={W}
y1={PAD_TOP + chartH * (1 - frac)}
y2={PAD_TOP + chartH * (1 - frac)}
/>
<text class="ytick" x="0" y={PAD_TOP + chartH * (1 - frac) - 2}>{frac * 100}%</text>
{/each}
<line class="baseline" x1="0" x2={W} y1={PAD_TOP + chartH} y2={PAD_TOP + chartH} />
{#each lines as l (l.key)}
{#if l.dots.length > 1}
<polyline points={l.polyline} fill="none" stroke={l.color} stroke-width="1.75" />
{/if}
{#each l.dots as dot, i (i)}
<circle cx={dot.x} cy={dot.y} r={l.dots.length === 1 ? 3.5 : 2.5} fill={l.color}>
<title>{fmtDateTimeShort(dot.ts)} · {l.label}: {dot.pct.toFixed(0)}%</title>
</circle>
{/each}
{/each}
</svg>
<div class="chart-axis">
<span>{firstLabel}</span>
<span class="utc-note">UTC</span>
<span>{lastLabel}</span>
</div>
{/if}
</section>
<style>
.panel-card {
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 1.3rem 1.4rem 1.5rem;
margin-bottom: 1.35rem;
}
.reveal {
animation: gh-reveal-up 520ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
animation-delay: calc(var(--d, 0) * 70ms);
}
@keyframes gh-reveal-up {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.reveal {
animation: none;
}
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1.1rem;
flex-wrap: wrap;
}
.panel-head h2 {
margin: 0;
font-family: var(--font-sans);
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.005em;
}
.panel-sub {
font-size: 0.78rem;
color: var(--text-faint);
}
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 0.9rem;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
color: var(--text-dim);
}
.legend-item i {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 2px;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text) 10%, transparent);
}
.legend-val {
font-weight: 700;
color: var(--text);
}
.chart {
width: 100%;
height: 180px;
overflow: visible;
}
.gridline {
stroke: var(--grid-line);
stroke-width: 1;
}
.baseline {
stroke: var(--border);
stroke-width: 1;
}
.ytick {
fill: var(--text-faint);
font-family: var(--font-mono);
font-size: 9px;
}
.chart-axis {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-top: 6px;
font-size: 0.72rem;
color: var(--text-faint);
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.utc-note {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.empty {
margin: 0;
padding: 48px 0;
text-align: center;
color: var(--text-faint);
font-size: 0.85rem;
}
</style>

View file

@ -0,0 +1,246 @@
<script lang="ts">
import { fmtMs, fmtInt, fmtBucketLabel } from '$lib/format';
import type { LatencyTrends } from '$lib/server/stats/latency';
interface Props {
trends: LatencyTrends;
/** stagger index for the section's reveal animation */
d?: number;
}
let { trends, d = 0 }: Props = $props();
const W = 720;
const H = 180;
const TOP_PAD = 12;
const bucketList = $derived(trends.buckets);
const bucketGran = $derived(trends.bucket);
const hasData = $derived(bucketList.length > 0);
// Duplicate a lone bucket so a single-point series still spans the plot width.
const points = $derived(bucketList.length === 1 ? [bucketList[0], bucketList[0]] : bucketList);
const maxValue = $derived(Math.max(1, ...points.map((b) => Math.max(b.p50, b.p95))));
function xFor(i: number): number {
return points.length <= 1 ? 0 : (i / (points.length - 1)) * W;
}
function yFor(value: number): number {
return H - (Math.max(0, value) / maxValue) * (H - TOP_PAD);
}
interface LineDef {
key: 'p50' | 'p95';
label: string;
color: string;
get: (b: LatencyTrends['buckets'][number]) => number;
}
const LINES: LineDef[] = [
{ key: 'p50', label: 'p50 (median)', color: 'var(--accent)', get: (b) => b.p50 },
{ key: 'p95', label: 'p95', color: 'var(--amber)', get: (b) => b.p95 }
];
interface Marker {
x: number;
y: number;
title: string;
}
interface Line {
key: string;
color: string;
polyline: string;
markers: Marker[];
}
const lines = $derived.by((): Line[] =>
LINES.map((def) => {
const markers: Marker[] = points.map((b, i) => ({
x: xFor(i),
y: yFor(def.get(b)),
title: `${fmtBucketLabel(b.start, bucketGran)} · ${def.label}: ${fmtMs(def.get(b))} · ${fmtInt(b.count)} turns`
}));
return {
key: def.key,
color: def.color,
polyline: markers.map((m) => `${m.x},${m.y}`).join(' '),
markers
};
})
);
// Y-axis reference values (top of plot + gridline fractions).
const yTicks = $derived([0.25, 0.5, 0.75, 1].map((f) => ({ f, value: f * maxValue })));
const firstLabel = $derived(hasData ? fmtBucketLabel(bucketList[0].start, bucketGran) : '');
const lastLabel = $derived(
hasData ? fmtBucketLabel(bucketList[bucketList.length - 1].start, bucketGran) : ''
);
const midIndex = $derived(Math.floor((bucketList.length - 1) / 2));
const showMidLabel = $derived(bucketList.length > 2);
const midLabel = $derived(showMidLabel ? fmtBucketLabel(bucketList[midIndex].start, bucketGran) : '');
</script>
<section class="panel reveal" style="--d:{d}">
<div class="panel-head">
<h2>Speed trends</h2>
<span class="panel-sub mono">{bucketGran === 'hour' ? 'per hour' : 'per day'} · UTC</span>
</div>
<div class="chart">
{#if !hasData}
<p class="empty">No latency data in this window.</p>
{:else}
<svg viewBox="0 0 {W} {H}" role="img" aria-label="Response latency p50 and p95 over time" class="chart-svg">
<defs>
<linearGradient id="lat-grad-accent" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--accent)" stop-opacity="1" />
<stop offset="1" stop-color="var(--accent)" stop-opacity="0.6" />
</linearGradient>
</defs>
{#each yTicks as t (t.f)}
<line class="gridline" x1="0" y1={yFor(t.value)} x2={W} y2={yFor(t.value)} />
<text class="ytick" x="0" y={yFor(t.value) - 3}>{fmtMs(t.value)}</text>
{/each}
<line class="baseline" x1="0" y1={H} x2={W} y2={H} />
{#each lines as line (line.key)}
<polyline points={line.polyline} fill="none" stroke={line.color} stroke-width="1.75" stroke-linejoin="round" />
{#each line.markers as m, j (j)}
<circle class="marker" cx={m.x} cy={m.y} r="4.5" fill={line.color}>
<title>{m.title}</title>
</circle>
{/each}
{/each}
</svg>
<div class="chart-legend">
{#each LINES as l (l.key)}
<span class="legend-item"><i style="background:{l.color}"></i>{l.label}</span>
{/each}
</div>
<div class="chart-axis">
<span>{firstLabel}</span>
{#if showMidLabel}<span>{midLabel}</span>{/if}
<span>{lastLabel}</span>
</div>
{/if}
</div>
</section>
<style>
.panel {
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 1.3rem 1.4rem 1.5rem;
margin-bottom: 1.35rem;
}
.panel-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.panel-head h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text);
}
.panel-sub {
font-size: 0.72rem;
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.chart {
width: 100%;
}
.chart-svg {
width: 100%;
height: 180px;
overflow: visible;
}
.gridline {
stroke: var(--grid-line);
stroke-width: 1;
}
.baseline {
stroke: var(--border);
stroke-width: 1;
}
.ytick {
fill: var(--text-faint);
font-family: var(--font-mono);
font-size: 9px;
}
.marker {
opacity: 0.9;
transition: opacity 0.1s ease;
}
.marker:hover {
opacity: 1;
}
.empty {
margin: 0;
padding: 48px 0;
text-align: center;
color: var(--text-faint);
font-size: 0.85rem;
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 10px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
color: var(--text-dim);
}
.legend-item i {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 2px;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--text) 10%, transparent);
}
.chart-axis {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: 0.72rem;
color: var(--text-faint);
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -0,0 +1,158 @@
<script lang="ts">
import { fmtInt, fmtCompact, fmtUsd } from '$lib/format';
import type { Punchcard } from '$lib/server/stats/punchcard';
interface Props {
punchcard: Punchcard;
metric?: 'events' | 'tokens';
}
let { punchcard, metric = 'events' }: Props = $props();
// Row labels, Monday-first to match the server's dow index (0=Mon..6=Sun).
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const hours = Array.from({ length: 24 }, (_, h) => h);
const tickHours = [0, 6, 12, 18, 23];
const cells = $derived(punchcard.cells);
const maxValue = $derived(
metric === 'events'
? Math.max(1, punchcard.maxEventCount)
: Math.max(1, punchcard.maxTotalTokens)
);
const hasData = $derived(punchcard.totalEvents > 0);
function cellValue(dow: number, hour: number): number {
const c = cells[dow][hour];
return metric === 'events' ? c.eventCount : c.totalTokens;
}
// Intensity 0..1 → opacity of the accent fill. sqrt gives lighter cells more
// visible contrast at the low end.
function intensity(dow: number, hour: number): number {
const v = cellValue(dow, hour);
if (v <= 0) return 0;
return Math.sqrt(v / maxValue);
}
function hourLabel(hour: number): string {
return `${String(hour).padStart(2, '0')}:00`;
}
</script>
{#if !hasData}
<p class="empty">No data in this window.</p>
{:else}
<div class="punch">
<div class="grid" role="img" aria-label="Activity by day of week and hour of day (UTC)">
{#each DAY_LABELS as day, dow (day)}
<span class="day-label mono">{day}</span>
{#each hours as hour (hour)}
{@const cell = cells[dow][hour]}
{@const op = intensity(dow, hour)}
<span
class="cell"
class:zero={op === 0}
style={`--op:${op}`}
title={`${day} ${hourLabel(hour)} UTC · ${fmtInt(cell.eventCount)} events · ${fmtCompact(cell.totalTokens)} tok · ${fmtUsd(cell.costUsd)}`}
></span>
{/each}
{/each}
<span class="corner"></span>
{#each hours as hour (hour)}
<span class="hour-tick mono">{tickHours.includes(hour) ? hour : ''}</span>
{/each}
</div>
<div class="legend">
<span>Less</span>
<span class="swatch" style="--op:0.1"></span>
<span class="swatch" style="--op:0.4"></span>
<span class="swatch" style="--op:0.7"></span>
<span class="swatch" style="--op:1"></span>
<span>More</span>
<span class="utc-note mono">UTC</span>
</div>
</div>
{/if}
<style>
.punch {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.grid {
display: grid;
/* day label column + 24 hour columns */
grid-template-columns: auto repeat(24, 1fr);
gap: 3px;
align-items: center;
}
.day-label {
font-size: 0.7rem;
color: var(--text-faint);
text-align: right;
padding-right: 0.5rem;
white-space: nowrap;
}
.cell {
aspect-ratio: 1 / 1;
border-radius: 3px;
background: var(--accent);
opacity: var(--op);
min-height: 12px;
transition: transform 0.12s ease;
}
.cell:hover {
transform: scale(1.18);
}
.cell.zero {
background: var(--bg-raised);
opacity: 1;
}
.corner {
/* aligns with the day-label column under the grid */
display: block;
}
.hour-tick {
font-size: 0.62rem;
color: var(--text-faint);
text-align: center;
}
.legend {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.7rem;
color: var(--text-faint);
}
.swatch {
width: 12px;
height: 12px;
border-radius: 3px;
background: var(--accent);
opacity: var(--op);
}
.utc-note {
margin-left: auto;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.empty {
color: var(--text-dim);
font-size: 13px;
padding: 24px 0;
text-align: center;
}
</style>

View file

@ -0,0 +1,240 @@
<script lang="ts">
import { fmtInt, fmtPct, fmtDateShort } from '$lib/format';
import type { ToolErrors } from '$lib/server/stats/toolErrors';
interface Props {
toolErrors: ToolErrors;
}
let { toolErrors }: Props = $props();
const daily = $derived(toolErrors.daily);
const byTool = $derived(toolErrors.byTool);
// --- errors-over-time chart geometry (hand-rolled inline SVG) -----------
const W = 720;
const H = 150;
const PAD_TOP = 8;
const PAD_BOTTOM = 26;
const chartH = $derived(H - PAD_TOP - PAD_BOTTOM);
const errorValues = $derived(daily.map((d) => d.errorCount));
const hasErrors = $derived(errorValues.some((v) => v > 0));
const maxError = $derived(Math.max(1, ...errorValues));
const barW = $derived(W / Math.max(1, daily.length));
// sparse x-axis ticks: first, middle, last day
const tickIdx = $derived.by(() => {
const n = daily.length;
if (n === 0) return [];
if (n === 1) return [0];
if (n === 2) return [0, 1];
return [0, Math.floor((n - 1) / 2), n - 1];
});
// error-rate list is scaled against the worst rate so the widest bar is full.
const maxRate = $derived(Math.max(0.0001, ...byTool.map((t) => t.errorRate)));
</script>
<div class="te-wrap">
<div class="te-head">
<div>
<span class="te-total mono">{fmtInt(toolErrors.totalErrors)}</span>
<span class="te-total-lbl">tool errors</span>
</div>
<span class="te-rate mono">{fmtPct(toolErrors.overallErrorRate)} of {fmtInt(toolErrors.totalCalls)} calls</span>
</div>
<!-- (a) errors over time -->
{#if !hasErrors}
<p class="empty">No tool errors in this window.</p>
{:else}
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label="Tool errors per day (UTC)" class="chart">
<defs>
<linearGradient id="te-grad-danger" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--danger)" stop-opacity="1" />
<stop offset="1" stop-color="var(--danger)" stop-opacity="0.5" />
</linearGradient>
</defs>
{#each [0.25, 0.5, 0.75, 1] as frac (frac)}
<line
class="gridline"
x1="0"
x2={W}
y1={PAD_TOP + chartH * (1 - frac)}
y2={PAD_TOP + chartH * (1 - frac)}
/>
{/each}
<line class="baseline" x1="0" x2={W} y1={PAD_TOP + chartH} y2={PAD_TOP + chartH} />
{#each daily as d, i (d.day)}
{@const h = maxError > 0 ? (d.errorCount / maxError) * chartH : 0}
{#if h > 0.4}
<rect
class="bar"
x={i * barW + barW * 0.15}
y={PAD_TOP + chartH - h}
width={barW * 0.7}
height={h}
rx="2.5"
>
<title
>{fmtDateShort(d.day)} · {fmtInt(d.errorCount)} errors / {fmtInt(d.callCount)} calls</title
>
</rect>
{/if}
{/each}
{#each tickIdx as i (i)}
<text class="tick" x={i * barW + barW / 2} y={H - 6} text-anchor="middle"
>{fmtDateShort(daily[i].day)}</text
>
{/each}
</svg>
<div class="chart-axis">
<span>Errors per day</span>
<span class="utc-note">UTC</span>
</div>
{/if}
<!-- (b) per-tool error rate for the top offenders -->
{#if byTool.length > 0}
<ul class="te-list">
{#each byTool as t (t.toolName)}
<li class="te-item">
<span class="te-name mono" title={t.toolName}>{t.toolName}</span>
<span class="te-track" aria-hidden="true">
<span class="te-fill" style={`width:${Math.max(2, (t.errorRate / maxRate) * 100)}%`}></span>
</span>
<span class="te-count mono">{fmtInt(t.errorCount)}/{fmtInt(t.callCount)}</span>
<span class="te-pct mono">{fmtPct(t.errorRate)}</span>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.te-wrap {
display: flex;
flex-direction: column;
}
.te-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.6rem;
}
.te-total {
font-size: 1.5rem;
font-weight: 700;
color: var(--danger);
line-height: 1;
}
.te-total-lbl {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
margin-left: 0.35rem;
}
.te-rate {
font-size: 0.78rem;
color: var(--text-dim);
white-space: nowrap;
}
.chart {
width: 100%;
height: 150px;
overflow: visible;
}
.gridline {
stroke: var(--grid-line);
stroke-width: 1;
}
.baseline {
stroke: var(--border);
stroke-width: 1;
}
.bar {
fill: url(#te-grad-danger);
transition: opacity 0.15s ease;
}
.bar:hover {
opacity: 0.85;
}
.tick {
fill: var(--text-faint);
font-family: var(--font-mono);
font-size: 9px;
}
.chart-axis {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-top: 4px;
font-size: 11px;
color: var(--text-faint);
}
.utc-note {
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.te-list {
list-style: none;
margin: 1rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.te-item {
display: grid;
grid-template-columns: minmax(80px, 1.4fr) 3fr auto auto;
align-items: center;
gap: 0.6rem;
font-size: 0.8rem;
}
.te-name {
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.te-track {
position: relative;
height: 7px;
border-radius: 4px;
background: var(--bg-raised);
overflow: hidden;
}
.te-fill {
position: absolute;
inset: 0 auto 0 0;
border-radius: 4px;
background: linear-gradient(90deg, var(--amber), var(--danger));
}
.te-count {
color: var(--text-dim);
font-size: 0.75rem;
text-align: right;
white-space: nowrap;
}
.te-pct {
color: var(--danger);
font-weight: 600;
text-align: right;
min-width: 3.2rem;
white-space: nowrap;
}
.empty {
color: var(--text-dim);
font-size: 13px;
padding: 24px 0;
text-align: center;
}
</style>

View file

@ -0,0 +1,192 @@
<script lang="ts">
import { fmtInt } from '$lib/format';
import type { TopActivity } from '$lib/server/stats/topActivity';
interface Props {
activity: TopActivity;
}
let { activity }: Props = $props();
const commands = $derived(activity.commands);
const files = $derived(activity.files);
const hasCommands = $derived(commands.length > 0);
const hasFiles = $derived(files.length > 0);
</script>
<section class="panel reveal top-activity">
<div class="panel-head">
<h2>What I do the most</h2>
<span class="panel-sub">Top Bash commands &amp; most-touched files in this window</span>
</div>
<div class="cols">
<div class="list-col">
<h3 class="col-title">Top commands</h3>
{#if !hasCommands}
<p class="empty">No commands in this window.</p>
{:else}
<ol class="ranked">
{#each commands as c, i (c.command)}
<li class="row">
<span class="rank mono">{i + 1}</span>
<span class="label">
<span class="primary mono" title={c.command}>{c.command}</span>
<span class="secondary mono">{c.program}</span>
</span>
<span class="count mono">{fmtInt(c.count)}</span>
</li>
{/each}
</ol>
{/if}
</div>
<div class="list-col">
<h3 class="col-title">Top files</h3>
{#if !hasFiles}
<p class="empty">No file edits in this window.</p>
{:else}
<ol class="ranked">
{#each files as f, i (f.path)}
<li class="row">
<span class="rank mono">{i + 1}</span>
<span class="label">
<span class="primary mono" title={f.path}>{f.name}</span>
<span class="secondary mono">{f.path}</span>
</span>
<span class="count mono">{fmtInt(f.count)}</span>
</li>
{/each}
</ol>
{/if}
</div>
</div>
</section>
<style>
.panel {
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 1.3rem 1.4rem 1.5rem;
margin-bottom: 1.35rem;
}
.panel-head {
display: flex;
align-items: baseline;
gap: 0.7rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.panel-head h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text);
}
.panel-sub {
font-size: 0.8rem;
color: var(--text-faint);
}
.cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 760px) {
.cols {
grid-template-columns: 1fr;
}
}
.col-title {
margin: 0 0 0.6rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
}
.ranked {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.row {
display: grid;
grid-template-columns: 1.4rem minmax(0, 1fr) auto;
align-items: center;
gap: 0.65rem;
padding: 0.45rem 0.35rem;
border-bottom: 1px solid var(--border-soft);
transition: background 0.15s ease;
}
.row:last-child {
border-bottom: none;
}
.row:hover {
background: var(--bg-raised);
}
.rank {
font-size: 0.78rem;
color: var(--text-faint);
text-align: right;
font-variant-numeric: tabular-nums;
}
.label {
display: flex;
flex-direction: column;
min-width: 0;
gap: 0.1rem;
}
.primary {
font-size: 0.83rem;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.secondary {
font-size: 0.7rem;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.count {
font-size: 0.9rem;
font-weight: 600;
color: var(--accent);
font-variant-numeric: tabular-nums;
text-align: right;
}
.empty {
color: var(--text-faint);
font-size: 0.85rem;
margin: 0;
padding: 0.75rem 0;
}
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
</style>

View file

@ -0,0 +1,168 @@
<script lang="ts">
import { fmtInt } from '$lib/format';
import type { WebUsageStats } from '$lib/server/stats/webUsage';
interface Props {
web: WebUsageStats;
}
let { web }: Props = $props();
// ---- tiny sparkline geometry (per the hero-sparkline approach in +page.svelte) ----
const SPARK_W = 120;
const SPARK_H = 34;
function sparkPaths(vals: number[]): { line: string; area: string } | null {
const n = vals.length;
if (n === 0) return null;
const max = Math.max(...vals, 0.000001);
const pts = vals.map((v, i) => {
const x = n === 1 ? SPARK_W : (i / (n - 1)) * SPARK_W;
const y = SPARK_H - 3 - (v / max) * (SPARK_H - 6);
return { x, y };
});
const line = pts
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`)
.join(' ');
const area = `M0,${SPARK_H} ${pts
.map((p) => `L${p.x.toFixed(1)},${p.y.toFixed(1)}`)
.join(' ')} L${SPARK_W},${SPARK_H} Z`;
return { line, area };
}
const searchSpark = $derived(sparkPaths(web.searchSeries));
const fetchSpark = $derived(sparkPaths(web.fetchSeries));
</script>
{#snippet stat(
label: string,
value: number,
spark: { line: string; area: string } | null,
gradId: string,
stroke: string
)}
<div class="wu-stat">
<div class="wu-body">
<span class="wu-val mono">{fmtInt(value)}</span>
<span class="wu-lbl">{label}</span>
</div>
{#if spark}
<svg
class="wu-spark"
viewBox={`0 0 ${SPARK_W} ${SPARK_H}`}
preserveAspectRatio="none"
role="img"
aria-label={`${label} per day`}
>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color={stroke} stop-opacity="0.35" />
<stop offset="1" stop-color={stroke} stop-opacity="0" />
</linearGradient>
</defs>
<path d={spark.area} fill={`url(#${gradId})`} />
<path
d={spark.line}
fill="none"
stroke={stroke}
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{:else}
<span class="wu-empty"></span>
{/if}
</div>
{/snippet}
<div class="wu">
<div class="wu-head">
<h2>Web tools</h2>
<span class="wu-sub">in window</span>
</div>
<div class="wu-grid">
{@render stat('Web searches', web.totalSearches, searchSpark, 'wu-grad-search', 'var(--accent)')}
{@render stat('Web fetches', web.totalFetches, fetchSpark, 'wu-grad-fetch', 'var(--purple)')}
</div>
</div>
<style>
.wu {
display: flex;
flex-direction: column;
gap: 0.9rem;
height: 100%;
}
.wu-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.6rem;
}
.wu-head h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text);
}
.wu-sub {
font-size: 0.72rem;
color: var(--text-dim);
white-space: nowrap;
}
.wu-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.wu-stat {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.8rem 0.9rem;
background: var(--bg-raised);
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-card);
transition:
transform 180ms ease,
box-shadow 180ms ease,
border-color 180ms ease;
}
.wu-stat:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-pop);
border-color: var(--border);
}
.wu-body {
display: flex;
flex-direction: column;
min-width: 0;
}
.wu-val {
font-size: 1.5rem;
font-weight: 700;
line-height: 1.05;
color: var(--text);
}
.wu-lbl {
margin-top: 0.15rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
}
.wu-spark {
width: 100%;
height: 34px;
overflow: visible;
}
.wu-empty {
height: 34px;
display: flex;
align-items: center;
color: var(--text-faint);
font-family: var(--font-mono);
}
</style>

141
src/lib/format.ts Normal file
View file

@ -0,0 +1,141 @@
/** Shared display formatting for the dashboard + chart components. */
const intFmt = new Intl.NumberFormat('en-US');
const compactFmt = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1
});
const usdFmt = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
const dateFmt = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
timeZone: 'UTC'
});
const dateTimeFmt = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
timeZone: 'UTC'
});
export function fmtInt(n: number | null | undefined): string {
return intFmt.format(n ?? 0);
}
export function fmtCompact(n: number | null | undefined): string {
return compactFmt.format(n ?? 0);
}
export function fmtUsd(n: number | null | undefined): string {
const v = n ?? 0;
if (v !== 0 && Math.abs(v) < 0.01) return `$${v.toFixed(4)}`;
return usdFmt.format(v);
}
export function fmtPct(x: number | null | undefined): string {
return `${((x ?? 0) * 100).toFixed(1)}%`;
}
export function fmtMs(n: number | null | undefined): string {
if (n === null || n === undefined) return '—';
if (n < 1000) return `${Math.round(n)}ms`;
return `${(n / 1000).toFixed(1)}s`;
}
export function fmtBytes(n: number | null | undefined): string {
const v = n ?? 0;
if (v < 1024) return `${v} B`;
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB`;
return `${(v / 1024 / 1024).toFixed(1)} MB`;
}
/** `YYYY-MM-DD` or full ISO → short UTC date label, e.g. "Jun 30". */
export function fmtDateShort(value: string | null): string {
if (!value) return '—';
const iso = value.length <= 10 ? `${value}T00:00:00Z` : value;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return value;
return dateFmt.format(d);
}
/** Full ISO → "Jun 30, 11 PM" UTC label (used for hourly buckets). */
export function fmtDateTimeShort(value: string | null): string {
if (!value) return '—';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return dateTimeFmt.format(d);
}
/** Bucket start ISO → axis label appropriate to the granularity. */
export function fmtBucketLabel(value: string, bucket: 'hour' | 'day'): string {
return bucket === 'hour' ? fmtDateTimeShort(value) : fmtDateShort(value);
}
/** Coarse "Nh ago" / "Nd ago" relative-time label. */
export function relativeTime(value: string | null): string {
if (!value) return '—';
const iso = value.length <= 10 ? `${value}T00:00:00Z` : value;
const ts = Date.parse(iso);
if (Number.isNaN(ts)) return '—';
const diffSec = Math.max(0, Math.round((Date.now() - ts) / 1000));
if (diffSec < 5) return 'just now';
const units: [string, number][] = [
['y', 31536000],
['mo', 2592000],
['d', 86400],
['h', 3600],
['m', 60]
];
for (const [label, secs] of units) {
if (diffSec >= secs) return `${Math.floor(diffSec / secs)}${label} ago`;
}
return `${diffSec}s ago`;
}
/** "claude-sonnet-4-6" → "Sonnet 4.6"; "claude-opus-4-8[1m]" → "Opus 4.8 [1M]". */
export function modelLabel(model: string): string {
let s = model.replace(/^claude-/, '').replace(/-\d{8}$/, '');
let suffix = '';
const bracket = s.match(/\[(.+)\]$/);
if (bracket) {
suffix = ` [${bracket[1].toUpperCase()}]`;
s = s.slice(0, bracket.index);
}
s = s.replace(/(\d)-(\d)/g, '$1.$2');
const words = s.split('-').filter(Boolean);
const label = words
.map((w) => (/^[\d.]/.test(w) ? w : w.charAt(0).toUpperCase() + w.slice(1)))
.join(' ');
return label + suffix;
}
export function basename(path: string | null): string {
if (!path) return 'unknown project';
const parts = path.split('/').filter(Boolean);
return parts.length ? parts[parts.length - 1] : path;
}
/**
* Stable palette for per-model series (indexed by stacking order).
* Values are CSS custom properties so the palette re-themes with the active
* theme the concrete colors live in `+layout.svelte` per `[data-theme]`.
*/
export const MODEL_COLORS = [
'var(--model-0)',
'var(--model-1)',
'var(--model-2)',
'var(--model-3)',
'var(--model-4)',
'var(--model-5)',
'var(--model-6)',
'var(--model-7)'
];
export function modelColor(index: number): string {
return MODEL_COLORS[index % MODEL_COLORS.length];
}

1
src/lib/index.ts Normal file
View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

33
src/lib/ranges.ts Normal file
View file

@ -0,0 +1,33 @@
/**
* Time-range presets shared by the server load (range window resolution) and
* the client-side RangePicker (buttons/labels). Pure no DB or server imports.
*/
export type RangeKey = '24h' | '7d' | '30d' | '90d' | 'all' | 'custom';
export interface RangePreset {
key: RangeKey;
label: string;
}
/** Quick-select presets shown as a segmented control (custom is a separate affordance). */
export const RANGE_PRESETS: RangePreset[] = [
{ key: '24h', label: '24h' },
{ key: '7d', label: '7 days' },
{ key: '30d', label: '30 days' },
{ key: '90d', label: '90 days' },
{ key: 'all', label: 'All time' }
];
export const DEFAULT_RANGE: RangeKey = '30d';
/** Resolved range the page load returns to the client (for header text + control state). */
export interface ResolvedRange {
key: RangeKey;
label: string; // human label for the header, e.g. "Last 30 days"
since: string | null; // ISO-8601 UTC (null only if the DB is empty and range=all)
until: string; // ISO-8601 UTC
bucket: 'hour' | 'day';
custom: boolean;
from: string | null; // YYYY-MM-DD (custom only)
to: string | null; // YYYY-MM-DD (custom only)
}

18
src/lib/server/config.ts Normal file
View file

@ -0,0 +1,18 @@
import { env } from '$env/dynamic/private';
/**
* Runtime feature flags. Read from the environment lazily (via `$env/dynamic/private`)
* so the container is reconfigurable without a rebuild.
*/
/**
* Gate for the raw-conversation surfaces the full per-session **transcript view**
* (`/sessions/[host]/[sessionId]`) and full-text **search** (`/search` + `/api/search`),
* both of which expose verbatim prompt/response text.
*
* Defaults to **false (hidden)**: the dashboard is currently reachable publicly, so no
* conversation content should be readable. Aggregate analytics (charts, KPIs, the sessions
* list metadata) stay public regardless. Flip on with `SHOW_TRANSCRIPTS=true` once access
* is gated behind auth.
*/
export const showTranscripts = env.SHOW_TRANSCRIPTS === 'true';

101
src/lib/server/db.ts Normal file
View file

@ -0,0 +1,101 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { env } from '$env/dynamic/private';
const DB_PATH = env.DB_PATH ?? 'data/toknmtr.db';
// Schema is inlined (not read from a .sql file) so the SvelteKit/Vite build
// doesn't need to copy a runtime asset. Migrations are additive + idempotent.
const SCHEMA = `
CREATE TABLE IF NOT EXISTS sessions (
host TEXT NOT NULL,
session_id TEXT NOT NULL,
project TEXT, -- cwd
git_branch TEXT,
cc_version TEXT,
entrypoint TEXT,
started_at TEXT,
ended_at TEXT,
PRIMARY KEY (host, session_id)
);
CREATE TABLE IF NOT EXISTS events (
host TEXT NOT NULL,
session_id TEXT NOT NULL,
uuid TEXT NOT NULL, -- unique per physical JSONL line
parent_uuid TEXT,
ts_utc TEXT NOT NULL,
type TEXT NOT NULL, -- user | assistant | system | summary
role TEXT,
model TEXT,
request_id TEXT,
message_id TEXT, -- usage dedup key: (session_id, message_id, request_id)
is_sidechain INTEGER DEFAULT 0,
is_usage_canonical INTEGER DEFAULT 0,-- 1 = the row that counts for token totals
stop_reason TEXT,
latency_ms INTEGER,
input_tokens INTEGER,
output_tokens INTEGER,
cache_creation_tokens INTEGER,
cache_read_tokens INTEGER,
web_search_requests INTEGER,
web_fetch_requests INTEGER,
PRIMARY KEY (host, session_id, uuid)
);
CREATE INDEX IF NOT EXISTS idx_events_session ON events(host, session_id);
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts_utc);
CREATE INDEX IF NOT EXISTS idx_events_model ON events(model);
CREATE TABLE IF NOT EXISTS tool_calls (
host TEXT NOT NULL,
session_id TEXT NOT NULL,
tool_use_id TEXT NOT NULL,
event_uuid TEXT,
tool_name TEXT NOT NULL,
input_json TEXT,
is_error INTEGER,
result_bytes INTEGER,
duration_ms INTEGER,
ts_utc TEXT,
PRIMARY KEY (host, session_id, tool_use_id)
);
CREATE INDEX IF NOT EXISTS idx_tool_name ON tool_calls(tool_name);
CREATE TABLE IF NOT EXISTS content (
host TEXT NOT NULL,
session_id TEXT NOT NULL,
uuid TEXT NOT NULL,
role TEXT,
text TEXT,
PRIMARY KEY (host, session_id, uuid)
);
-- Standalone FTS5 index for the searchable archive (kept in sync by the ingest
-- layer). Switch to external-content + triggers later if duplication matters.
CREATE VIRTUAL TABLE IF NOT EXISTS content_fts USING fts5(
text, host UNINDEXED, session_id UNINDEXED, uuid UNINDEXED
);
CREATE TABLE IF NOT EXISTS usage_gauges (
host TEXT NOT NULL,
ts_utc TEXT NOT NULL,
session_pct REAL,
week_all_pct REAL,
week_sonnet_pct REAL,
PRIMARY KEY (host, ts_utc)
);
`;
let _db: Database.Database | null = null;
export function db(): Database.Database {
if (_db) return _db;
mkdirSync(dirname(DB_PATH), { recursive: true });
const handle = new Database(DB_PATH);
handle.pragma('journal_mode = WAL');
handle.pragma('foreign_keys = ON');
handle.exec(SCHEMA);
_db = handle;
return _db;
}

48
src/lib/server/pricing.ts Normal file
View file

@ -0,0 +1,48 @@
// Per-model token pricing (USD per 1M tokens). Subscription is flat-rate, so
// these produce a *notional* API-equivalent cost. Adding a new model = one line.
// cacheWrite ≈ 1.25× input, cacheRead ≈ 0.1× input (Anthropic prompt-caching).
// TODO: confirm exact rates against the Claude pricing docs before trusting $.
export interface ModelPrice {
input: number;
output: number;
cacheWrite: number;
cacheRead: number;
}
const M = 1_000_000;
export const PRICING: Record<string, ModelPrice> = {
// USD per 1M tokens, verified against Anthropic pricing 2026-07-01.
// cacheWrite = 1.25× input (5-min TTL), cacheRead = 0.1× input.
'claude-opus-4-8': { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
'claude-opus-4-8[1m]': { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 }, // 1M ctx, no premium
'claude-opus-4-7': { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
'claude-sonnet-5': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 }, // intro $2/$10 to 2026-08-31
'claude-sonnet-4-6': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
'claude-haiku-4-5': { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 },
'claude-haiku-4-5-20251001': { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 },
'claude-fable-5': { input: 10, output: 50, cacheWrite: 12.5, cacheRead: 1.0 }
// local models (qwen/*, <synthetic>) are intentionally absent → cost 0
};
export interface TokenCounts {
input_tokens?: number | null;
output_tokens?: number | null;
cache_creation_tokens?: number | null;
cache_read_tokens?: number | null;
}
/** Notional USD cost for one turn's token counts. Unknown/local models → 0. */
export function costFor(model: string | null | undefined, t: TokenCounts): number {
if (!model) return 0;
const p = PRICING[model];
if (!p) return 0;
return (
((t.input_tokens ?? 0) * p.input +
(t.output_tokens ?? 0) * p.output +
(t.cache_creation_tokens ?? 0) * p.cacheWrite +
(t.cache_read_tokens ?? 0) * p.cacheRead) /
M
);
}

704
src/lib/server/queries.ts Normal file
View file

@ -0,0 +1,704 @@
/**
* Read/query layer over the toknmtr SQLite DB. Everything here is read-only
* the schema in `db.ts` is the source of truth and is never modified from
* this file. `$` figures are always computed via `costFor()` at query time
* (subscription is flat-rate, so cost is *notional*, API-equivalent).
*
* All "usage" aggregates (tokens, $) are restricted to `is_usage_canonical = 1`
* to avoid double-counting streamed assistant lines. Activity aggregates
* (event counts, session counts, tool calls) are not restricted that way
* they reflect every ingested row.
*
* Every aggregate takes a `TimeWindow` (`{ since, until }`, ISO-8601 or null =
* unbounded) so the whole dashboard can be filtered to 24h / 7d / 30d / custom.
*/
import { db } from './db';
import { costFor, type TokenCounts } from './pricing';
// ---------------------------------------------------------------------------
// windowing
// ---------------------------------------------------------------------------
export interface TimeWindow {
since: string | null; // inclusive lower bound (ISO-8601 UTC), null = unbounded
until: string | null; // inclusive upper bound (ISO-8601 UTC), null = unbounded
}
/** Build a WHERE fragment (prefixed with ` AND `) + bind params for a window over `col`. */
function windowClause(w: TimeWindow, col = 'ts_utc'): { clause: string; params: string[] } {
const parts: string[] = [];
const params: string[] = [];
if (w.since) {
parts.push(`datetime(${col}) >= datetime(?)`);
params.push(w.since);
}
if (w.until) {
parts.push(`datetime(${col}) <= datetime(?)`);
params.push(w.until);
}
return { clause: parts.length ? ` AND ${parts.join(' AND ')}` : '', params };
}
/** Earliest event timestamp in the DB (for resolving the "all time" range), or null if empty. */
export function earliestEventTs(): string | null {
const row = db().prepare(`SELECT MIN(ts_utc) AS min_ts FROM events`).get() as {
min_ts: string | null;
};
return row.min_ts;
}
// ---------------------------------------------------------------------------
// token folding
// ---------------------------------------------------------------------------
interface ModelTokenRow {
model: string | null;
input_tokens: number | null;
output_tokens: number | null;
cache_creation_tokens: number | null;
cache_read_tokens: number | null;
}
interface TokenTotals {
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
totalTokens: number;
costUsd: number;
}
const emptyTotals = (): TokenTotals => ({
inputTokens: 0,
outputTokens: 0,
cacheCreationTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
costUsd: 0
});
/** Fold a set of (model, summed-token-counts) rows into totals, costing each model separately. */
function foldModelRows(rows: ModelTokenRow[]): TokenTotals {
const totals = emptyTotals();
for (const r of rows) {
const counts: TokenCounts = {
input_tokens: r.input_tokens,
output_tokens: r.output_tokens,
cache_creation_tokens: r.cache_creation_tokens,
cache_read_tokens: r.cache_read_tokens
};
totals.inputTokens += r.input_tokens ?? 0;
totals.outputTokens += r.output_tokens ?? 0;
totals.cacheCreationTokens += r.cache_creation_tokens ?? 0;
totals.cacheReadTokens += r.cache_read_tokens ?? 0;
totals.costUsd += costFor(r.model, counts);
}
totals.totalTokens =
totals.inputTokens + totals.outputTokens + totals.cacheCreationTokens + totals.cacheReadTokens;
return totals;
}
// ---------------------------------------------------------------------------
// overview
// ---------------------------------------------------------------------------
export interface OverviewStats {
totalInputTokens: number;
totalOutputTokens: number;
totalCacheCreationTokens: number;
totalCacheReadTokens: number;
totalTokens: number;
totalCostUsd: number;
eventCount: number;
sessionCount: number;
toolCallCount: number;
dateRange: { earliest: string | null; latest: string | null };
}
export function overviewStats(w: TimeWindow): OverviewStats {
const dbh = db();
const win = windowClause(w);
const modelRows = dbh
.prepare(
`SELECT model, SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens,
SUM(cache_creation_tokens) AS cache_creation_tokens, SUM(cache_read_tokens) AS cache_read_tokens
FROM events
WHERE is_usage_canonical = 1${win.clause}
GROUP BY model`
)
.all(...win.params) as ModelTokenRow[];
const totals = foldModelRows(modelRows);
const activity = dbh
.prepare(
`SELECT COUNT(*) AS event_count, COUNT(DISTINCT host || ' ' || session_id) AS session_count,
MIN(ts_utc) AS earliest, MAX(ts_utc) AS latest
FROM events
WHERE 1 = 1${win.clause}`
)
.get(...win.params) as {
event_count: number;
session_count: number;
earliest: string | null;
latest: string | null;
};
const toolRow = dbh
.prepare(`SELECT COUNT(*) AS tool_call_count FROM tool_calls WHERE 1 = 1${win.clause}`)
.get(...win.params) as { tool_call_count: number };
return {
totalInputTokens: totals.inputTokens,
totalOutputTokens: totals.outputTokens,
totalCacheCreationTokens: totals.cacheCreationTokens,
totalCacheReadTokens: totals.cacheReadTokens,
totalTokens: totals.totalTokens,
totalCostUsd: totals.costUsd,
eventCount: activity.event_count,
sessionCount: activity.session_count,
toolCallCount: toolRow.tool_call_count,
dateRange: { earliest: activity.earliest, latest: activity.latest }
};
}
// ---------------------------------------------------------------------------
// time series (adaptive hour/day buckets) — also carries a per-model split
// ---------------------------------------------------------------------------
export type Bucket = 'hour' | 'day';
export interface SeriesModelSlice {
model: string;
totalTokens: number;
costUsd: number;
}
export interface SeriesBucket {
start: string; // ISO-8601 UTC of the bucket start
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
totalTokens: number;
costUsd: number;
eventCount: number;
models: SeriesModelSlice[]; // per-model split within this bucket (non-empty models only)
}
/** SQLite strftime pattern that produces the bucket key for the given granularity. */
function bucketExpr(bucket: Bucket): string {
return bucket === 'hour'
? `strftime('%Y-%m-%dT%H', ts_utc)` // e.g. 2026-06-30T23
: `date(ts_utc)`; // e.g. 2026-06-30
}
/** Zero-filled, ordered list of bucket keys spanning [since, until]. Keys match `bucketExpr`. */
function bucketKeys(
since: string,
until: string,
bucket: Bucket
): { key: string; start: string }[] {
const out: { key: string; start: string }[] = [];
const s = new Date(since);
const u = new Date(until);
if (Number.isNaN(s.getTime()) || Number.isNaN(u.getTime())) return out;
if (bucket === 'hour') {
const cur = new Date(
Date.UTC(s.getUTCFullYear(), s.getUTCMonth(), s.getUTCDate(), s.getUTCHours())
);
while (cur.getTime() <= u.getTime()) {
const iso = cur.toISOString(); // 2026-06-30T23:00:00.000Z
out.push({ key: iso.slice(0, 13), start: `${iso.slice(0, 13)}:00:00Z` });
cur.setUTCHours(cur.getUTCHours() + 1);
if (out.length > 24 * 31) break; // safety cap
}
} else {
const cur = new Date(Date.UTC(s.getUTCFullYear(), s.getUTCMonth(), s.getUTCDate()));
const end = new Date(Date.UTC(u.getUTCFullYear(), u.getUTCMonth(), u.getUTCDate()));
while (cur.getTime() <= end.getTime()) {
const day = cur.toISOString().slice(0, 10);
out.push({ key: day, start: `${day}T00:00:00Z` });
cur.setUTCDate(cur.getUTCDate() + 1);
if (out.length > 800) break; // safety cap
}
}
return out;
}
/**
* Per-bucket token/$ series across [since, until], zero-filled, oldest first.
* `since`/`until` must be concrete ISO strings (the caller resolves "all time"
* to the earliest event / now). Each bucket also carries a per-model split.
*/
export function usageSeries(since: string, until: string, bucket: Bucket): SeriesBucket[] {
const dbh = db();
const expr = bucketExpr(bucket);
const win = windowClause({ since, until });
const rows = dbh
.prepare(
`SELECT ${expr} AS bkey, model,
SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens,
SUM(cache_creation_tokens) AS cache_creation_tokens, SUM(cache_read_tokens) AS cache_read_tokens,
COUNT(*) AS event_count
FROM events
WHERE is_usage_canonical = 1${win.clause}
GROUP BY bkey, model`
)
.all(...win.params) as (ModelTokenRow & { bkey: string; event_count: number })[];
const byBucket = new Map<string, { rows: ModelTokenRow[]; eventCount: number }>();
for (const r of rows) {
const b = byBucket.get(r.bkey) ?? { rows: [], eventCount: 0 };
b.rows.push(r);
b.eventCount += r.event_count;
byBucket.set(r.bkey, b);
}
return bucketKeys(since, until, bucket).map(({ key, start }) => {
const b = byBucket.get(key);
const totals = b ? foldModelRows(b.rows) : emptyTotals();
const models: SeriesModelSlice[] = (b?.rows ?? [])
.filter((r) => r.model)
.map((r) => {
const t = foldModelRows([r]);
return { model: r.model as string, totalTokens: t.totalTokens, costUsd: t.costUsd };
})
.filter((m) => m.totalTokens > 0 || m.costUsd > 0);
return {
start,
inputTokens: totals.inputTokens,
outputTokens: totals.outputTokens,
cacheCreationTokens: totals.cacheCreationTokens,
cacheReadTokens: totals.cacheReadTokens,
totalTokens: totals.totalTokens,
costUsd: totals.costUsd,
eventCount: b?.eventCount ?? 0,
models
};
});
}
// ---------------------------------------------------------------------------
// by model
// ---------------------------------------------------------------------------
export interface ModelUsageRow {
model: string;
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
totalTokens: number;
costUsd: number;
eventCount: number;
tokenShare: number; // 0..1
costShare: number; // 0..1
}
/** Per-model token/$ totals + share of the whole, ordered by cost desc. */
export function usageByModel(w: TimeWindow): ModelUsageRow[] {
const dbh = db();
const win = windowClause(w);
const rows = dbh
.prepare(
`SELECT model, SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens,
SUM(cache_creation_tokens) AS cache_creation_tokens, SUM(cache_read_tokens) AS cache_read_tokens,
COUNT(*) AS event_count
FROM events
WHERE is_usage_canonical = 1 AND model IS NOT NULL${win.clause}
GROUP BY model`
)
.all(...win.params) as (ModelTokenRow & { event_count: number })[];
const perModel = rows.map((r) => ({
model: r.model as string,
eventCount: r.event_count,
totals: foldModelRows([r])
}));
const grandTokens = perModel.reduce((sum, m) => sum + m.totals.totalTokens, 0);
const grandCost = perModel.reduce((sum, m) => sum + m.totals.costUsd, 0);
return perModel
.map((m) => ({
model: m.model,
inputTokens: m.totals.inputTokens,
outputTokens: m.totals.outputTokens,
cacheCreationTokens: m.totals.cacheCreationTokens,
cacheReadTokens: m.totals.cacheReadTokens,
totalTokens: m.totals.totalTokens,
costUsd: m.totals.costUsd,
eventCount: m.eventCount,
tokenShare: grandTokens > 0 ? m.totals.totalTokens / grandTokens : 0,
costShare: grandCost > 0 ? m.totals.costUsd / grandCost : 0
}))
.sort((a, b) => b.costUsd - a.costUsd);
}
// ---------------------------------------------------------------------------
// activity by hour-of-day (UTC)
// ---------------------------------------------------------------------------
export interface HourBucket {
hour: number; // 0..23 (UTC)
totalTokens: number;
costUsd: number;
eventCount: number;
}
/** 24 buckets (0..23, UTC) of tokens/$/events within the window. Always length 24. */
export function hourOfDayActivity(w: TimeWindow): HourBucket[] {
const dbh = db();
const win = windowClause(w);
const rows = dbh
.prepare(
`SELECT CAST(strftime('%H', ts_utc) AS INTEGER) AS hour, model,
SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens,
SUM(cache_creation_tokens) AS cache_creation_tokens, SUM(cache_read_tokens) AS cache_read_tokens,
COUNT(*) AS event_count
FROM events
WHERE is_usage_canonical = 1${win.clause}
GROUP BY hour, model`
)
.all(...win.params) as (ModelTokenRow & { hour: number; event_count: number })[];
const byHour = new Map<number, { rows: ModelTokenRow[]; eventCount: number }>();
for (const r of rows) {
const b = byHour.get(r.hour) ?? { rows: [], eventCount: 0 };
b.rows.push(r);
b.eventCount += r.event_count;
byHour.set(r.hour, b);
}
return Array.from({ length: 24 }, (_, hour) => {
const b = byHour.get(hour);
const totals = b ? foldModelRows(b.rows) : emptyTotals();
return {
hour,
totalTokens: totals.totalTokens,
costUsd: totals.costUsd,
eventCount: b?.eventCount ?? 0
};
});
}
// ---------------------------------------------------------------------------
// cache efficiency
// ---------------------------------------------------------------------------
export interface CacheEfficiency {
freshInputTokens: number; // input_tokens (uncached)
cacheReadTokens: number; // cache_read_input_tokens
cacheWriteTokens: number; // cache_creation_input_tokens
cacheReadShare: number; // cacheRead / (cacheRead + freshInput), 0..1
dollarsSpentOnReads: number; // notional $ actually paid for cache reads
dollarsSaved: number; // notional $ saved vs paying full input rate for those reads
effectiveDiscountPct: number; // dollarsSaved / (dollarsSaved + dollarsSpentOnReads), 0..1
}
/** Cache reuse + notional $ saved by prompt caching, over the window (per-model priced). */
export function cacheEfficiency(w: TimeWindow): CacheEfficiency {
const dbh = db();
const win = windowClause(w);
const rows = dbh
.prepare(
`SELECT model, SUM(input_tokens) AS input_tokens, SUM(cache_creation_tokens) AS cache_creation_tokens,
SUM(cache_read_tokens) AS cache_read_tokens
FROM events
WHERE is_usage_canonical = 1${win.clause}
GROUP BY model`
)
.all(...win.params) as ModelTokenRow[];
let freshInputTokens = 0;
let cacheReadTokens = 0;
let cacheWriteTokens = 0;
let dollarsSpentOnReads = 0;
let dollarsSaved = 0;
for (const r of rows) {
const reads = r.cache_read_tokens ?? 0;
freshInputTokens += r.input_tokens ?? 0;
cacheReadTokens += reads;
cacheWriteTokens += r.cache_creation_tokens ?? 0;
// what we paid for the reads vs. what those tokens would cost at the full input rate
const paid = costFor(r.model, { cache_read_tokens: reads });
const atInputRate = costFor(r.model, { input_tokens: reads });
dollarsSpentOnReads += paid;
dollarsSaved += atInputRate - paid;
}
const denom = cacheReadTokens + freshInputTokens;
const savedDenom = dollarsSaved + dollarsSpentOnReads;
return {
freshInputTokens,
cacheReadTokens,
cacheWriteTokens,
cacheReadShare: denom > 0 ? cacheReadTokens / denom : 0,
dollarsSpentOnReads,
dollarsSaved,
effectiveDiscountPct: savedDenom > 0 ? dollarsSaved / savedDenom : 0
};
}
// ---------------------------------------------------------------------------
// top tools
// ---------------------------------------------------------------------------
export interface ToolUsageRow {
toolName: string;
callCount: number;
errorCount: number;
avgDurationMs: number | null;
totalResultBytes: number;
}
export function topTools(limit: number, w: TimeWindow): ToolUsageRow[] {
const dbh = db();
const win = windowClause(w);
const rows = dbh
.prepare(
`SELECT tool_name, COUNT(*) AS call_count, SUM(CASE WHEN is_error THEN 1 ELSE 0 END) AS error_count,
AVG(duration_ms) AS avg_duration_ms, COALESCE(SUM(result_bytes), 0) AS total_result_bytes
FROM tool_calls
WHERE 1 = 1${win.clause}
GROUP BY tool_name
ORDER BY call_count DESC
LIMIT ?`
)
.all(...win.params, Math.max(0, Math.floor(limit))) as {
tool_name: string;
call_count: number;
error_count: number;
avg_duration_ms: number | null;
total_result_bytes: number;
}[];
return rows.map((r) => ({
toolName: r.tool_name,
callCount: r.call_count,
errorCount: r.error_count,
avgDurationMs: r.avg_duration_ms,
totalResultBytes: r.total_result_bytes
}));
}
// ---------------------------------------------------------------------------
// recent sessions
// ---------------------------------------------------------------------------
export interface RecentSessionRow {
host: string;
sessionId: string;
project: string | null;
gitBranch: string | null;
ccVersion: string | null;
entrypoint: string | null;
startedAt: string | null;
endedAt: string | null;
lastEventAt: string | null;
eventCount: number;
toolCallCount: number;
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
totalTokens: number;
costUsd: number;
}
/**
* All sessions with activity in the window, joined with their in-window token/$/tool
* aggregates, UNSORTED. Sessions with no events in the window are dropped. Shared by
* `recentSessions` (dashboard preview) and `allSessions` (the /sessions page).
*/
function mergeSessions(w: TimeWindow): RecentSessionRow[] {
const dbh = db();
const win = windowClause(w);
const sessionRows = dbh
.prepare(
`SELECT host, session_id, project, git_branch, cc_version, entrypoint, started_at, ended_at
FROM sessions`
)
.all() 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;
}[];
const activityRows = dbh
.prepare(
`SELECT host, session_id, COUNT(*) AS event_count, MAX(ts_utc) AS last_ts
FROM events
WHERE 1 = 1${win.clause}
GROUP BY host, session_id`
)
.all(...win.params) as {
host: string;
session_id: string;
event_count: number;
last_ts: string | null;
}[];
const tokenRows = dbh
.prepare(
`SELECT host, session_id, model, SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens,
SUM(cache_creation_tokens) AS cache_creation_tokens, SUM(cache_read_tokens) AS cache_read_tokens
FROM events
WHERE is_usage_canonical = 1${win.clause}
GROUP BY host, session_id, model`
)
.all(...win.params) as (ModelTokenRow & { host: string; session_id: string })[];
const toolRows = dbh
.prepare(
`SELECT host, session_id, COUNT(*) AS tool_call_count FROM tool_calls
WHERE 1 = 1${win.clause}
GROUP BY host, session_id`
)
.all(...win.params) as { host: string; session_id: string; tool_call_count: number }[];
const key = (host: string, sessionId: string) => `${host} ${sessionId}`;
const toolByKey = new Map(toolRows.map((r) => [key(r.host, r.session_id), r.tool_call_count]));
const tokensByKey = new Map<string, ModelTokenRow[]>();
for (const r of tokenRows) {
const k = key(r.host, r.session_id);
const bucket = tokensByKey.get(k) ?? [];
bucket.push(r);
tokensByKey.set(k, bucket);
}
const sessionMeta = new Map(sessionRows.map((s) => [key(s.host, s.session_id), s]));
// Drive off in-window activity so sessions with no events in the window drop out.
return activityRows.map((a) => {
const k = key(a.host, a.session_id);
const s = sessionMeta.get(k);
const totals = foldModelRows(tokensByKey.get(k) ?? []);
return {
host: a.host,
sessionId: a.session_id,
project: s?.project ?? null,
gitBranch: s?.git_branch ?? null,
ccVersion: s?.cc_version ?? null,
entrypoint: s?.entrypoint ?? null,
startedAt: s?.started_at ?? null,
endedAt: s?.ended_at ?? null,
lastEventAt: a.last_ts,
eventCount: a.event_count,
toolCallCount: toolByKey.get(k) ?? 0,
inputTokens: totals.inputTokens,
outputTokens: totals.outputTokens,
cacheCreationTokens: totals.cacheCreationTokens,
cacheReadTokens: totals.cacheReadTokens,
totalTokens: totals.totalTokens,
costUsd: totals.costUsd
};
});
}
/**
* Sessions with activity in the window, newest-first (by latest in-window event).
* Dashboard preview capped at `limit`.
*/
export function recentSessions(limit: number, w: TimeWindow): RecentSessionRow[] {
const merged = mergeSessions(w);
merged.sort((a, b) => (b.lastEventAt ?? '').localeCompare(a.lastEventAt ?? ''));
return merged.slice(0, Math.max(0, Math.floor(limit)));
}
// ---------------------------------------------------------------------------
// all sessions (the /sessions page) — sortable
// ---------------------------------------------------------------------------
export type SessionSort = 'recent' | 'cost' | 'tokens' | 'events' | 'tools' | 'project';
export type SortDir = 'asc' | 'desc';
/** Ascending comparators per sort key; `allSessions` flips them for `desc`. */
const SESSION_SORTERS: Record<SessionSort, (a: RecentSessionRow, b: RecentSessionRow) => number> = {
recent: (a, b) => (a.lastEventAt ?? '').localeCompare(b.lastEventAt ?? ''),
cost: (a, b) => a.costUsd - b.costUsd,
tokens: (a, b) => a.totalTokens - b.totalTokens,
events: (a, b) => a.eventCount - b.eventCount,
tools: (a, b) => a.toolCallCount - b.toolCallCount,
project: (a, b) => (a.project ?? '').localeCompare(b.project ?? '')
};
/** Distinct project paths across all sessions (for the /sessions project filter), basename-sorted. */
export function sessionProjects(): string[] {
const rows = db()
.prepare(
`SELECT DISTINCT project FROM sessions WHERE project IS NOT NULL AND project <> '' ORDER BY project`
)
.all() as { project: string }[];
const base = (p: string) => p.split('/').filter(Boolean).pop() ?? p;
return rows.map((r) => r.project).sort((a, b) => base(a).localeCompare(base(b)));
}
/**
* Every session with activity in the window, sorted by `sort`/`dir`, optionally filtered to a
* single `project` (full cwd path). Returns the full list plus its length (there's no server-side
* paging the session table is small for a single-user tool). Ties break by most-recent activity.
*/
export function allSessions(
w: TimeWindow,
sort: SessionSort = 'recent',
dir: SortDir = 'desc',
project: string | null = null
): { rows: RecentSessionRow[]; total: number } {
const merged = project ? mergeSessions(w).filter((r) => r.project === project) : mergeSessions(w);
const cmp = SESSION_SORTERS[sort] ?? SESSION_SORTERS.recent;
const recency = SESSION_SORTERS.recent;
merged.sort((a, b) => {
const primary = dir === 'asc' ? cmp(a, b) : cmp(b, a);
return primary !== 0 ? primary : recency(b, a); // tie-break: newest first
});
return { rows: merged, total: merged.length };
}
// ---------------------------------------------------------------------------
// subscription gauges (not windowed — always the latest scrape per host)
// ---------------------------------------------------------------------------
export interface UsageGaugeRow {
host: string;
tsUtc: string;
sessionPct: number | null;
weekAllPct: number | null;
weekSonnetPct: number | null;
}
export function usageGauges(): UsageGaugeRow[] {
const dbh = db();
const rows = dbh
.prepare(
`SELECT g.host, g.ts_utc, g.session_pct, g.week_all_pct, g.week_sonnet_pct
FROM usage_gauges g
INNER JOIN (
SELECT host, MAX(ts_utc) AS max_ts FROM usage_gauges GROUP BY host
) latest ON latest.host = g.host AND latest.max_ts = g.ts_utc`
)
.all() as {
host: string;
ts_utc: string;
session_pct: number | null;
week_all_pct: number | null;
week_sonnet_pct: number | null;
}[];
return rows.map((r) => ({
host: r.host,
tsUtc: r.ts_utc,
sessionPct: r.session_pct,
weekAllPct: r.week_all_pct,
weekSonnetPct: r.week_sonnet_pct
}));
}

78
src/lib/server/range.ts Normal file
View file

@ -0,0 +1,78 @@
/**
* Resolve the request's `?range`/`?from`/`?to` params into a concrete
* `{ since, until, bucket, label, … }` window. Server-side (reads the DB's earliest
* event to resolve "all time"), shared by every page that offers the RangePicker
* (dashboard, sessions, ) so range behavior stays identical across the app.
*/
import { earliestEventTs } from './queries';
import { DEFAULT_RANGE, type RangeKey, type ResolvedRange } from '$lib/ranges';
const DAY_MS = 86_400_000;
const RANGE_DAYS: Partial<Record<RangeKey, number>> = { '24h': 1, '7d': 7, '30d': 30, '90d': 90 };
const RANGE_LABEL: Record<RangeKey, string> = {
'24h': 'Last 24 hours',
'7d': 'Last 7 days',
'30d': 'Last 30 days',
'90d': 'Last 90 days',
all: 'All time',
custom: 'Custom range'
};
function isYmd(v: string | null): v is string {
return !!v && /^\d{4}-\d{2}-\d{2}$/.test(v);
}
export function resolveRange(url: URL): ResolvedRange {
const nowIso = new Date().toISOString();
const raw = (url.searchParams.get('range') ?? DEFAULT_RANGE) as RangeKey;
const from = url.searchParams.get('from');
const to = url.searchParams.get('to');
if (raw === 'custom' && isYmd(from) && isYmd(to)) {
const sinceIso = `${from}T00:00:00Z`;
const untilIso = `${to}T23:59:59Z`;
// hourly buckets only for short custom spans, else daily
const spanDays = (Date.parse(untilIso) - Date.parse(sinceIso)) / DAY_MS;
const ordered = Date.parse(untilIso) >= Date.parse(sinceIso);
const s = ordered ? sinceIso : `${to}T00:00:00Z`;
const u = ordered ? untilIso : `${from}T23:59:59Z`;
return {
key: 'custom',
label: `${ordered ? from : to}${ordered ? to : from}`,
since: s,
until: u,
bucket: Math.abs(spanDays) <= 2 ? 'hour' : 'day',
custom: true,
from: ordered ? from : to,
to: ordered ? to : from
};
}
if (raw === 'all') {
const earliest = earliestEventTs();
return {
key: 'all',
label: RANGE_LABEL.all,
since: earliest,
until: nowIso,
bucket: 'day',
custom: false,
from: null,
to: null
};
}
const days = RANGE_DAYS[raw] ?? 30;
const key: RangeKey = RANGE_DAYS[raw] ? raw : '30d';
return {
key,
label: RANGE_LABEL[key],
since: new Date(Date.now() - days * DAY_MS).toISOString(),
until: nowIso,
bucket: key === '24h' ? 'hour' : 'day',
custom: false,
from: null,
to: null
};
}

Binary file not shown.

View file

@ -0,0 +1,80 @@
/**
* Activity-calendar (GitHub-style contribution heatmap) data source.
*
* Unlike the rest of the dashboard, this query intentionally IGNORES the active
* dashboard time window a contribution graph is always "the last year". It
* returns one entry per day that had activity within the trailing ~53 weeks
* (missing days are simply absent the component treats them as zero).
*
* Tokens/$ are restricted to `is_usage_canonical = 1` (dedup) and cost is
* computed per-model via `costFor()`, exactly like the windowed aggregates.
*/
import { db } from '../db';
import { costFor, type TokenCounts } from '../pricing';
/** One calendar cell: an ISO `YYYY-MM-DD` day (UTC) with its activity totals. */
export interface CalendarDay {
date: string; // "YYYY-MM-DD" (UTC)
tokens: number;
cost: number;
events: number;
}
interface DayModelRow {
day: string;
model: string | null;
input_tokens: number | null;
output_tokens: number | null;
cache_creation_tokens: number | null;
cache_read_tokens: number | null;
event_count: number;
}
const DAY_MS = 86_400_000;
/**
* Activity for every day with usage in the trailing ~53 weeks (371 days) up to
* and including today (UTC), oldest first. Days without activity are omitted.
*/
export function getActivityCalendar(): CalendarDay[] {
// Trailing 53 weeks. Normal app code — `new Date()` is fine at request time.
const now = new Date();
const until = now.toISOString();
const since = new Date(now.getTime() - 371 * DAY_MS).toISOString();
const rows = db()
.prepare(
`SELECT date(ts_utc) AS day, model,
SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens,
SUM(cache_creation_tokens) AS cache_creation_tokens, SUM(cache_read_tokens) AS cache_read_tokens,
COUNT(*) AS event_count
FROM events
WHERE is_usage_canonical = 1
AND datetime(ts_utc) >= datetime(?)
AND datetime(ts_utc) <= datetime(?)
GROUP BY day, model`
)
.all(since, until) as DayModelRow[];
// Fold per-model rows into one entry per day, costing each model separately.
const byDay = new Map<string, CalendarDay>();
for (const r of rows) {
const entry = byDay.get(r.day) ?? { date: r.day, tokens: 0, cost: 0, events: 0 };
const counts: TokenCounts = {
input_tokens: r.input_tokens,
output_tokens: r.output_tokens,
cache_creation_tokens: r.cache_creation_tokens,
cache_read_tokens: r.cache_read_tokens
};
entry.tokens +=
(r.input_tokens ?? 0) +
(r.output_tokens ?? 0) +
(r.cache_creation_tokens ?? 0) +
(r.cache_read_tokens ?? 0);
entry.cost += costFor(r.model, counts);
entry.events += r.event_count;
byDay.set(r.day, entry);
}
return [...byDay.values()].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
}

View file

@ -0,0 +1,92 @@
/**
* "How close to limits over time" the usage_gauges time series for a single
* host. Reads the three rolling-limit percentages (session, weekly-all,
* weekly-Sonnet) that the agent snapshots alongside events, so the dashboard
* can plot how close usage crept to Claude Code's rate limits over the window.
*
* Read-only over the DB schema in `db.ts` (never modified here). Accepts the
* same `TimeWindow` object every other query takes and filters identically to
* the canonical `windowClause` helper in `queries.ts`.
*/
import { db } from '../db';
import type { TimeWindow } from '../queries';
/** Build a WHERE fragment (prefixed with ` AND `) + bind params for a window over `col`. */
function windowClause(w: TimeWindow, col = 'ts_utc'): { clause: string; params: string[] } {
const parts: string[] = [];
const params: string[] = [];
if (w.since) {
parts.push(`datetime(${col}) >= datetime(?)`);
params.push(w.since);
}
if (w.until) {
parts.push(`datetime(${col}) <= datetime(?)`);
params.push(w.until);
}
return { clause: parts.length ? ` AND ${parts.join(' AND ')}` : '', params };
}
/** One snapshot of the three limit percentages at a point in time (0100 scale). */
export interface GaugeHistoryPoint {
tsUtc: string;
sessionPct: number | null;
weekAllPct: number | null;
weekSonnetPct: number | null;
}
/** The full gauge history for the primary host within the window. */
export interface GaugeHistory {
host: string | null; // the chosen primary host, or null when there is no data
points: GaugeHistoryPoint[]; // chronological (ts_utc asc)
}
/**
* The usage_gauges series (session_pct, week_all_pct, week_sonnet_pct over
* ts_utc) within `w`, for the primary host. When more than one host has
* gauge rows in the window, the host with the most rows is chosen so the
* chart reflects a single machine's limit trajectory.
*/
export function getGaugeHistory(w: TimeWindow): GaugeHistory {
const dbh = db();
const win = windowClause(w);
// Pick the host with the most gauge snapshots inside the window.
const hostRow = dbh
.prepare(
`SELECT host, COUNT(*) AS n
FROM usage_gauges
WHERE 1 = 1${win.clause}
GROUP BY host
ORDER BY n DESC, host ASC
LIMIT 1`
)
.get(...win.params) as { host: string; n: number } | undefined;
if (!hostRow) {
return { host: null, points: [] };
}
const rows = dbh
.prepare(
`SELECT ts_utc, session_pct, week_all_pct, week_sonnet_pct
FROM usage_gauges
WHERE host = ?${win.clause}
ORDER BY datetime(ts_utc) ASC`
)
.all(hostRow.host, ...win.params) as {
ts_utc: string;
session_pct: number | null;
week_all_pct: number | null;
week_sonnet_pct: number | null;
}[];
return {
host: hostRow.host,
points: rows.map((r) => ({
tsUtc: r.ts_utc,
sessionPct: r.session_pct,
weekAllPct: r.week_all_pct,
weekSonnetPct: r.week_sonnet_pct
}))
};
}

View file

@ -0,0 +1,126 @@
/**
* Latency ("speed") trends over a time window.
*
* Reads `events.latency_ms` for assistant turns where the value is present and
* buckets it into a p50/p95 series. Granularity is per-day by default, or
* per-hour when the window spans <= 2 days (matching the dashboard's hour/day
* bucketing convention). Percentiles are computed in JS since SQLite has no
* native percentile aggregate the query only pulls (bucket-key, latency_ms)
* rows and the folding happens here.
*
* Follows the read-layer conventions in `../queries.ts`: `db()` handle, the
* `TimeWindow` shape, and the same `datetime(ts_utc) >= datetime(?)` window
* filtering. This module never mutates the schema.
*/
import { db } from '../db';
/** Same window object every dashboard query accepts (see queries.ts). */
export interface TimeWindow {
since: string | null; // inclusive lower bound (ISO-8601 UTC), null = unbounded
until: string | null; // inclusive upper bound (ISO-8601 UTC), null = unbounded
}
/** One point in the latency series. */
export interface LatencyBucket {
start: string; // ISO-8601 UTC of the bucket start
p50: number; // median latency, ms
p95: number; // 95th-percentile latency, ms
count: number; // number of assistant turns with a latency in this bucket
}
/** Full result: the chosen granularity plus the ordered (oldest-first) series. */
export interface LatencyTrends {
bucket: 'hour' | 'day';
buckets: LatencyBucket[];
}
const DAY_MS = 86_400_000;
/** Build a WHERE fragment (prefixed with ` AND `) + bind params for a window over `col`. */
function windowClause(w: TimeWindow, col = 'ts_utc'): { clause: string; params: string[] } {
const parts: string[] = [];
const params: string[] = [];
if (w.since) {
parts.push(`datetime(${col}) >= datetime(?)`);
params.push(w.since);
}
if (w.until) {
parts.push(`datetime(${col}) <= datetime(?)`);
params.push(w.until);
}
return { clause: parts.length ? ` AND ${parts.join(' AND ')}` : '', params };
}
/** Nearest-rank percentile over an already-sorted ascending numeric array. */
function percentile(sorted: number[], p: number): number {
const n = sorted.length;
if (n === 0) return 0;
if (n === 1) return sorted[0];
const rank = Math.round((p / 100) * (n - 1));
const idx = Math.min(n - 1, Math.max(0, rank));
return sorted[idx];
}
/** ISO bucket-start from a strftime bucket key (`2026-06-30T23` or `2026-06-30`). */
function startFromKey(key: string, bucket: 'hour' | 'day'): string {
return bucket === 'hour' ? `${key}:00:00Z` : `${key}T00:00:00Z`;
}
/**
* p50/p95 assistant-latency trend over `w`. Only buckets that actually contain
* timed turns are returned (sparse), ordered oldest-first. Granularity is hour
* when the effective span is <= 2 days, day otherwise.
*/
export function getLatencyTrends(w: TimeWindow): LatencyTrends {
const dbh = db();
const win = windowClause(w);
// Resolve the effective span from the window bounds, falling back to the
// actual data extent when a bound is unbounded (range=all on an empty-ish DB).
const extent = dbh
.prepare(
`SELECT MIN(ts_utc) AS min_ts, MAX(ts_utc) AS max_ts
FROM events
WHERE type = 'assistant' AND latency_ms IS NOT NULL${win.clause}`
)
.get(...win.params) as { min_ts: string | null; max_ts: string | null };
const sinceMs = w.since ? Date.parse(w.since) : extent.min_ts ? Date.parse(extent.min_ts) : NaN;
const untilMs = w.until ? Date.parse(w.until) : extent.max_ts ? Date.parse(extent.max_ts) : NaN;
const spanMs =
Number.isNaN(sinceMs) || Number.isNaN(untilMs) ? 0 : Math.max(0, untilMs - sinceMs);
const bucket: 'hour' | 'day' = spanMs <= 2 * DAY_MS ? 'hour' : 'day';
const expr = bucket === 'hour' ? `strftime('%Y-%m-%dT%H', ts_utc)` : `date(ts_utc)`;
const rows = dbh
.prepare(
`SELECT ${expr} AS bkey, latency_ms
FROM events
WHERE type = 'assistant' AND latency_ms IS NOT NULL${win.clause}
ORDER BY bkey`
)
.all(...win.params) as { bkey: string; latency_ms: number }[];
const byBucket = new Map<string, number[]>();
for (const r of rows) {
const arr = byBucket.get(r.bkey);
if (arr) arr.push(r.latency_ms);
else byBucket.set(r.bkey, [r.latency_ms]);
}
const buckets: LatencyBucket[] = [...byBucket.keys()]
.sort()
.map((key) => {
const vals = byBucket.get(key)!;
vals.sort((a, b) => a - b);
return {
start: startFromKey(key, bucket),
p50: percentile(vals, 50),
p95: percentile(vals, 95),
count: vals.length
};
});
return { bucket, buckets };
}

View file

@ -0,0 +1,147 @@
/**
* "When do I work" punchcard aggregate a 7 (day-of-week) x 24 (hour-of-day)
* grid of activity over a time window.
*
* TIMEZONE BASIS: **UTC**. This matches the existing "Activity by Hour" widget
* (`hourOfDayActivity` in `queries.ts`), which buckets on `strftime('%H', ts_utc)`.
* We derive both the hour and the day-of-week from `ts_utc` via SQLite `strftime`,
* so every bucket here is a UTC hour/day. Keep this consistent with that widget.
*
* SQLite `strftime('%w', ...)` returns 0=Sunday .. 6=Saturday. We remap to a
* Monday-first index (0=Mon .. 6=Sun) so the grid reads Mon..Sun top-to-bottom.
*
* Follows the query-layer conventions in `src/lib/server/queries.ts`:
* `db()` handle, the same `TimeWindow` shape + window filtering, SUM token
* columns grouped by model then cost in JS via `costFor()`.
*/
import { db } from '../db';
import { costFor, type TokenCounts } from '../pricing';
import type { TimeWindow } from '../queries';
// ---------------------------------------------------------------------------
// windowing — mirrors `windowClause` in queries.ts (not exported there)
// ---------------------------------------------------------------------------
function windowClause(w: TimeWindow, col = 'ts_utc'): { clause: string; params: string[] } {
const parts: string[] = [];
const params: string[] = [];
if (w.since) {
parts.push(`datetime(${col}) >= datetime(?)`);
params.push(w.since);
}
if (w.until) {
parts.push(`datetime(${col}) <= datetime(?)`);
params.push(w.until);
}
return { clause: parts.length ? ` AND ${parts.join(' AND ')}` : '', params };
}
// ---------------------------------------------------------------------------
// types
// ---------------------------------------------------------------------------
/** One cell of the 7x24 grid. `dow` is 0=Mon..6=Sun (UTC); `hour` is 0..23 (UTC). */
export interface PunchcardCell {
dow: number; // 0=Mon .. 6=Sun
hour: number; // 0..23 (UTC)
eventCount: number;
totalTokens: number;
costUsd: number;
}
export interface Punchcard {
/** Row-major 7x24 grid: `cells[dow][hour]`. dow 0=Mon..6=Sun, hour 0..23 (UTC). */
cells: PunchcardCell[][];
maxEventCount: number; // for heatmap intensity scaling (>= 1)
maxTotalTokens: number; // for heatmap intensity scaling (>= 1)
totalEvents: number;
tz: 'UTC'; // timezone basis of the buckets
}
// ---------------------------------------------------------------------------
// query
// ---------------------------------------------------------------------------
interface PunchRow {
sqlite_dow: number; // 0=Sun..6=Sat (SQLite %w)
hour: number; // 0..23
model: string | null;
input_tokens: number | null;
output_tokens: number | null;
cache_creation_tokens: number | null;
cache_read_tokens: number | null;
event_count: number;
}
/** Remap SQLite `%w` (0=Sun..6=Sat) to a Monday-first index (0=Mon..6=Sun). */
function toMondayFirst(sqliteDow: number): number {
return (sqliteDow + 6) % 7;
}
/**
* Aggregate all events in the window into a 7x24 (day-of-week x hour) grid.
* Tokens/$ use only `is_usage_canonical = 1` rows (consistent with the other
* usage aggregates); event counts likewise reflect canonical rows so tokens and
* counts line up cell-for-cell with the "Activity by Hour" widget.
*/
export function getPunchcard(w: TimeWindow): Punchcard {
const dbh = db();
const win = windowClause(w);
const rows = dbh
.prepare(
`SELECT CAST(strftime('%w', ts_utc) AS INTEGER) AS sqlite_dow,
CAST(strftime('%H', ts_utc) AS INTEGER) AS hour,
model,
SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens,
SUM(cache_creation_tokens) AS cache_creation_tokens,
SUM(cache_read_tokens) AS cache_read_tokens,
COUNT(*) AS event_count
FROM events
WHERE is_usage_canonical = 1${win.clause}
GROUP BY sqlite_dow, hour, model`
)
.all(...win.params) as PunchRow[];
// Empty 7x24 grid.
const cells: PunchcardCell[][] = Array.from({ length: 7 }, (_, dow) =>
Array.from({ length: 24 }, (_, hour) => ({
dow,
hour,
eventCount: 0,
totalTokens: 0,
costUsd: 0
}))
);
for (const r of rows) {
const dow = toMondayFirst(r.sqlite_dow);
const cell = cells[dow][r.hour];
const counts: TokenCounts = {
input_tokens: r.input_tokens,
output_tokens: r.output_tokens,
cache_creation_tokens: r.cache_creation_tokens,
cache_read_tokens: r.cache_read_tokens
};
cell.eventCount += r.event_count;
cell.totalTokens +=
(r.input_tokens ?? 0) +
(r.output_tokens ?? 0) +
(r.cache_creation_tokens ?? 0) +
(r.cache_read_tokens ?? 0);
cell.costUsd += costFor(r.model, counts);
}
let maxEventCount = 1;
let maxTotalTokens = 1;
let totalEvents = 0;
for (const row of cells) {
for (const cell of row) {
if (cell.eventCount > maxEventCount) maxEventCount = cell.eventCount;
if (cell.totalTokens > maxTotalTokens) maxTotalTokens = cell.totalTokens;
totalEvents += cell.eventCount;
}
}
return { cells, maxEventCount, maxTotalTokens, totalEvents, tz: 'UTC' };
}

View file

@ -0,0 +1,128 @@
/**
* Tool-failure analytics: *when* tools fail (a daily error-count trend) and
* *which* tools fail (per-tool error count + error rate for the top offenders).
*
* Read-only over the `tool_calls` table. This is an activity aggregate (it
* counts calls, not tokens/$), so it filters with `WHERE ... 1 = 1`-style base
* terms and the shared window clause never restricting to canonical usage
* rows. Deliberately does NOT reproduce the Top Tools table: the focus here is
* failures and their trend, not overall call volume.
*/
import { db } from '../db';
import type { TimeWindow } from '../queries';
/** Build a WHERE fragment (prefixed with ` AND `) + bind params for a window over `col`. */
function windowClause(w: TimeWindow, col = 'ts_utc'): { clause: string; params: string[] } {
const parts: string[] = [];
const params: string[] = [];
if (w.since) {
parts.push(`datetime(${col}) >= datetime(?)`);
params.push(w.since);
}
if (w.until) {
parts.push(`datetime(${col}) <= datetime(?)`);
params.push(w.until);
}
return { clause: parts.length ? ` AND ${parts.join(' AND ')}` : '', params };
}
/** One calendar day (UTC) of tool-call activity, with how many of those calls errored. */
export interface ToolErrorDay {
day: string; // YYYY-MM-DD (UTC)
errorCount: number;
callCount: number;
}
/** A single offending tool: raw error count + error rate (errors / total calls). */
export interface ToolErrorRate {
toolName: string;
errorCount: number;
callCount: number;
errorRate: number; // 0..1
}
export interface ToolErrors {
/** Ascending daily buckets over the window (only days that had tool activity). */
daily: ToolErrorDay[];
/** Top offenders by error count, then rate — each with its own error rate. */
byTool: ToolErrorRate[];
totalErrors: number;
totalCalls: number;
overallErrorRate: number; // 0..1
}
/**
* Error trend + top offending tools within the window.
* @param limit max number of offending tools to return (default 8).
*/
export function getToolErrors(w: TimeWindow, limit = 8): ToolErrors {
const dbh = db();
const win = windowClause(w);
// (a) daily error count over the window — one row per day that saw any tool
// call, so zero-error days still appear as a baseline in the trend chart.
const dayRows = dbh
.prepare(
`SELECT strftime('%Y-%m-%d', ts_utc) AS day,
SUM(CASE WHEN is_error THEN 1 ELSE 0 END) AS error_count,
COUNT(*) AS call_count
FROM tool_calls
WHERE ts_utc IS NOT NULL${win.clause}
GROUP BY day
ORDER BY day ASC`
)
.all(...win.params) as { day: string; error_count: number; call_count: number }[];
const daily: ToolErrorDay[] = dayRows.map((r) => ({
day: r.day,
errorCount: r.error_count ?? 0,
callCount: r.call_count ?? 0
}));
// (b) per-tool error count + rate for the top offenders. Only tools that
// have actually errored are offenders; ordered by absolute errors, then rate.
const toolRows = dbh
.prepare(
`SELECT tool_name,
SUM(CASE WHEN is_error THEN 1 ELSE 0 END) AS error_count,
COUNT(*) AS call_count
FROM tool_calls
WHERE 1 = 1${win.clause}
GROUP BY tool_name
HAVING error_count > 0
ORDER BY error_count DESC, (CAST(error_count AS REAL) / COUNT(*)) DESC
LIMIT ?`
)
.all(...win.params, Math.max(0, Math.floor(limit))) as {
tool_name: string;
error_count: number;
call_count: number;
}[];
const byTool: ToolErrorRate[] = toolRows.map((r) => ({
toolName: r.tool_name,
errorCount: r.error_count ?? 0,
callCount: r.call_count ?? 0,
errorRate: r.call_count > 0 ? (r.error_count ?? 0) / r.call_count : 0
}));
// overall totals across every tool call in the window (not just offenders).
const totals = dbh
.prepare(
`SELECT SUM(CASE WHEN is_error THEN 1 ELSE 0 END) AS error_count, COUNT(*) AS call_count
FROM tool_calls
WHERE 1 = 1${win.clause}`
)
.get(...win.params) as { error_count: number | null; call_count: number | null };
const totalErrors = totals.error_count ?? 0;
const totalCalls = totals.call_count ?? 0;
return {
daily,
byTool,
totalErrors,
totalCalls,
overallErrorRate: totalCalls > 0 ? totalErrors / totalCalls : 0
};
}

View file

@ -0,0 +1,159 @@
/**
* "What I do the most" activity mining over `tool_calls.input_json`.
*
* Two ranked lists per window:
* 1. Top Bash commands the `command` field of every Bash tool call,
* normalized (whitespace-collapsed) and grouped by that cleaned key,
* with the leading program token exposed for a sub-label.
* 2. Most-touched files the `file_path` field of Edit / Write / Read
* tool calls, grouped by absolute path.
*
* `input_json` is an opaque JSON string; every row is parsed inside a
* try/catch so a single malformed payload never breaks the aggregate.
*
* Filters through the same `TimeWindow` shape + windowing semantics as the
* rest of the query layer (`datetime(ts_utc) >= datetime(?)`).
*/
import { db } from '../db';
import type { TimeWindow } from '../queries';
/** How many entries each list returns. */
const DEFAULT_LIMIT = 10;
/** One row in the "Top commands" list. */
export interface TopCommandRow {
/** Cleaned, whitespace-collapsed command string (the grouping key / display value). */
command: string;
/** Leading program token, e.g. `git`, `npm`, `rg` (for a sub-label). */
program: string;
/** Number of times this exact cleaned command was run. */
count: number;
}
/** One row in the "Top files" list. */
export interface TopFileRow {
/** Absolute file path (the grouping key / display value). */
path: string;
/** Basename of the path, for a compact primary label. */
name: string;
/** Number of Edit/Write/Read tool calls that touched this path. */
count: number;
}
/** Result of {@link getTopActivity}. */
export interface TopActivity {
commands: TopCommandRow[];
files: TopFileRow[];
}
/** Local mirror of the query layer's window builder (private there). */
function windowClause(w: TimeWindow, col = 'ts_utc'): { clause: string; params: string[] } {
const parts: string[] = [];
const params: string[] = [];
if (w.since) {
parts.push(`datetime(${col}) >= datetime(?)`);
params.push(w.since);
}
if (w.until) {
parts.push(`datetime(${col}) <= datetime(?)`);
params.push(w.until);
}
return { clause: parts.length ? ` AND ${parts.join(' AND ')}` : '', params };
}
/** Collapse runs of whitespace/newlines to single spaces and trim. */
function cleanCommand(cmd: string): string {
return cmd.replace(/\s+/g, ' ').trim();
}
/** First bare token of a command — its leading program (skips leading env assignments). */
function programOf(cmd: string): string {
const tokens = cmd.split(' ').filter(Boolean);
for (const t of tokens) {
// Skip `FOO=bar` env prefixes.
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) continue;
return t;
}
return tokens[0] ?? cmd;
}
/** Basename of a `/`-separated path (falls back to the whole string). */
function baseName(path: string): string {
const parts = path.split('/').filter(Boolean);
return parts.length ? parts[parts.length - 1] : path;
}
/**
* Mine the two ranked activity lists for a window.
*
* @param w inclusive time window (same shape every query accepts)
* @param limit entries per list (default 10)
*/
export function getTopActivity(w: TimeWindow, limit = DEFAULT_LIMIT): TopActivity {
const dbh = db();
const win = windowClause(w);
const cap = Math.max(0, Math.floor(limit));
// Bash commands ----------------------------------------------------------
const bashRows = dbh
.prepare(
`SELECT input_json
FROM tool_calls
WHERE tool_name = 'Bash'${win.clause}`
)
.all(...win.params) as { input_json: string | null }[];
const cmdCounts = new Map<string, { program: string; count: number }>();
for (const r of bashRows) {
if (!r.input_json) continue;
try {
const input = JSON.parse(r.input_json) as { command?: unknown };
if (typeof input.command !== 'string') continue;
const cleaned = cleanCommand(input.command);
if (!cleaned) continue;
const existing = cmdCounts.get(cleaned);
if (existing) {
existing.count += 1;
} else {
cmdCounts.set(cleaned, { program: programOf(cleaned), count: 1 });
}
} catch {
// malformed input_json — skip this row
}
}
const commands: TopCommandRow[] = [...cmdCounts.entries()]
.map(([command, v]) => ({ command, program: v.program, count: v.count }))
.sort((a, b) => b.count - a.count || a.command.localeCompare(b.command))
.slice(0, cap);
// Touched files ----------------------------------------------------------
const fileRows = dbh
.prepare(
`SELECT input_json
FROM tool_calls
WHERE tool_name IN ('Edit', 'Write', 'Read')${win.clause}`
)
.all(...win.params) as { input_json: string | null }[];
const fileCounts = new Map<string, number>();
for (const r of fileRows) {
if (!r.input_json) continue;
try {
const input = JSON.parse(r.input_json) as { file_path?: unknown };
if (typeof input.file_path !== 'string') continue;
const path = input.file_path.trim();
if (!path) continue;
fileCounts.set(path, (fileCounts.get(path) ?? 0) + 1);
} catch {
// malformed input_json — skip this row
}
}
const files: TopFileRow[] = [...fileCounts.entries()]
.map(([path, count]) => ({ path, name: baseName(path), count }))
.sort((a, b) => b.count - a.count || a.path.localeCompare(b.path))
.slice(0, cap);
return { commands, files };
}

View file

@ -0,0 +1,88 @@
/**
* Web-tool usage aggregate: how many web *searches* and web *fetches* Claude
* issued in the window. Counted from the `tool_calls` table by `tool_name`
* ('WebSearch' / 'WebFetch') the `events.web_*_requests` usage counters are
* not populated in practice, so the tool-call rows are the reliable source.
* These are *activity* aggregates, so the window filter uses the
* `WHERE 1 = 1${clause}` shape (no `is_usage_canonical` restriction), matching
* `hourOfDayActivity` / `topTools` in `queries.ts`.
*/
import { db } from '../db';
import type { TimeWindow } from '../queries';
/** Build a WHERE fragment (prefixed with ` AND `) + bind params for a window over `col`. */
function windowClause(w: TimeWindow, col = 'ts_utc'): { clause: string; params: string[] } {
const parts: string[] = [];
const params: string[] = [];
if (w.since) {
parts.push(`datetime(${col}) >= datetime(?)`);
params.push(w.since);
}
if (w.until) {
parts.push(`datetime(${col}) <= datetime(?)`);
params.push(w.until);
}
return { clause: parts.length ? ` AND ${parts.join(' AND ')}` : '', params };
}
/** One day's web-tool counts (UTC calendar day). */
export interface WebUsageDay {
day: string; // YYYY-MM-DD (UTC)
searches: number;
fetches: number;
}
export interface WebUsageStats {
totalSearches: number;
totalFetches: number;
/** Per-day trend, oldest first (UTC days that had at least one event). */
trend: WebUsageDay[];
/** Convenience sparkline series (oldest first), aligned with `trend`. */
searchSeries: number[];
fetchSeries: number[];
}
/**
* Sum web-search / web-fetch request counts over the window, plus a simple
* per-UTC-day trend for each (oldest first). Days with no events are omitted
* a plain, compact trend for a sparkline.
*/
export function getWebUsage(w: TimeWindow): WebUsageStats {
const dbh = db();
const win = windowClause(w);
const totals = dbh
.prepare(
`SELECT COALESCE(SUM(tool_name = 'WebSearch'), 0) AS searches,
COALESCE(SUM(tool_name = 'WebFetch'), 0) AS fetches
FROM tool_calls
WHERE tool_name IN ('WebSearch', 'WebFetch')${win.clause}`
)
.get(...win.params) as { searches: number; fetches: number };
const dayRows = dbh
.prepare(
`SELECT date(ts_utc) AS day,
COALESCE(SUM(tool_name = 'WebSearch'), 0) AS searches,
COALESCE(SUM(tool_name = 'WebFetch'), 0) AS fetches
FROM tool_calls
WHERE tool_name IN ('WebSearch', 'WebFetch')${win.clause}
GROUP BY day
ORDER BY day ASC`
)
.all(...win.params) as { day: string; searches: number; fetches: number }[];
const trend: WebUsageDay[] = dayRows.map((r) => ({
day: r.day,
searches: r.searches ?? 0,
fetches: r.fetches ?? 0
}));
return {
totalSearches: totals.searches ?? 0,
totalFetches: totals.fetches ?? 0,
trend,
searchSeries: trend.map((d) => d.searches),
fetchSeries: trend.map((d) => d.fetches)
};
}

View file

@ -0,0 +1,8 @@
import { showTranscripts } from '$lib/server/config';
import type { LayoutServerLoad } from './$types';
/**
* Expose the transcript/search gate to every page (layout load data merges into each
* page's `data`). Drives nav visibility and whether session rows link into transcripts.
*/
export const load: LayoutServerLoad = () => ({ showTranscripts });

407
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,407 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import { onMount } from 'svelte';
import { page } from '$app/state';
import { resolve } from '$app/paths';
let { children, data } = $props();
// Search exposes raw transcript text, so it's only in the nav when transcripts are enabled.
const navLinks = $derived([
{ href: resolve('/'), label: 'Dashboard' },
{ href: resolve('/sessions'), label: 'Sessions' },
...(data.showTranscripts ? [{ href: resolve('/search'), label: 'Search' }] : [])
]);
const THEMES = [
{ key: 'eclipse', label: 'Eclipse' },
{ key: 'daybreak', label: 'Daybreak' }
];
let theme = $state('eclipse');
onMount(() => {
theme = document.documentElement.getAttribute('data-theme') ?? 'eclipse';
});
function setTheme(t: string) {
theme = t;
document.documentElement.setAttribute('data-theme', t);
try {
localStorage.setItem('toknmtr-theme', t);
} catch {
/* storage may be unavailable — ignore */
}
}
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<div class="shell">
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href={resolve('/')} aria-label="toknmtr home">
<span class="brand-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20">
<defs>
<linearGradient id="brandGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="var(--accent)" />
<stop offset="1" stop-color="var(--purple)" />
</linearGradient>
</defs>
<rect x="3" y="3" width="18" height="18" rx="6" fill="url(#brandGrad)" />
<path
d="M8 15.5V9.2M12 15.5V7.5M16 15.5v-4.2"
stroke="var(--on-accent)"
stroke-width="2"
stroke-linecap="round"
fill="none"
/>
</svg>
</span>
<span class="brand-word">
<span class="brand-mark">tok</span><span class="brand-mark brand-mark-dim">nmtr</span>
</span>
</a>
<nav aria-label="Primary">
{#each navLinks as link (link.href)}
<a href={link.href} class:active={page.url.pathname === link.href}>{link.label}</a>
{/each}
</nav>
<div class="topbar-actions">
<div class="theme-switch" role="group" aria-label="Theme">
{#each THEMES as t (t.key)}
<button
type="button"
class:active={theme === t.key}
onclick={() => setTheme(t.key)}
aria-pressed={theme === t.key}
aria-label={`${t.label} theme`}
title={`${t.label} theme`}
>
{#if t.key === 'eclipse'}
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
<path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z" fill="currentColor" />
</svg>
{:else}
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
<circle cx="12" cy="12" r="4.2" fill="currentColor" />
<g stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path
d="M12 2.5v2.4M12 19.1v2.4M4.6 4.6l1.7 1.7M17.7 17.7l1.7 1.7M2.5 12h2.4M19.1 12h2.4M4.6 19.4l1.7-1.7M17.7 6.3l1.7-1.7"
/>
</g>
</svg>
{/if}
<span class="theme-label">{t.label}</span>
</button>
{/each}
</div>
</div>
</div>
</header>
<main class="content">
{@render children()}
</main>
</div>
<style>
/* ============================================================
Design tokens — structural (shared across all themes)
============================================================ */
:global(:root) {
--radius: 16px;
--radius-sm: 10px;
--radius-lg: 22px;
--maxw: 84rem;
}
/* ============================================================
Theme · Eclipse (dark — inspired by Corelytics)
============================================================ */
:global(:root),
:global(:root[data-theme='eclipse']) {
color-scheme: dark;
--bg: #0a0b11;
--bg-panel: #12141e;
--bg-panel-2: #171a26;
--bg-raised: #1c2030;
--border: #282e40;
--border-soft: #1c2233;
--text: #eceef5;
--text-dim: #9aa3bd;
--text-faint: #616b85;
--accent: #7c8cff;
--accent-2: #3ddca0;
--amber: #f5b544;
--purple: #b794fa;
--danger: #fb7185;
--on-accent: #0a0b11;
--grad-accent: linear-gradient(135deg, #7c8cff 0%, #b794fa 100%);
--grad-accent-2: linear-gradient(135deg, #3ddca0 0%, #38d6ea 100%);
--glow: rgba(124, 140, 255, 0.35);
--shadow-card: 0 1px 0 rgba(255, 255, 255, 0.03) inset, 0 10px 34px -12px rgba(0, 0, 0, 0.55);
--shadow-pop: 0 16px 44px -8px rgba(0, 0, 0, 0.6);
--page-glow-1: rgba(124, 140, 255, 0.11);
--page-glow-2: rgba(61, 220, 160, 0.07);
--grid-line: rgba(255, 255, 255, 0.05);
--model-0: #7c8cff;
--model-1: #3ddca0;
--model-2: #f5b544;
--model-3: #b794fa;
--model-4: #fb7185;
--model-5: #38d6ea;
--model-6: #fb923c;
--model-7: #f472d0;
--font-sans: 'Sora', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace;
}
/* ============================================================
Theme · Daybreak (light — inspired by images 2 & 3)
============================================================ */
:global(:root[data-theme='daybreak']) {
color-scheme: light;
--bg: #eef1f8;
--bg-panel: #ffffff;
--bg-panel-2: #eef1f8;
--bg-raised: #f4f6fb;
--border: #e2e7f1;
--border-soft: #eaeef6;
--text: #182338;
--text-dim: #57617a;
--text-faint: #97a1b8;
--accent: #3b6ef6;
--accent-2: #10b981;
--amber: #f59e0b;
--purple: #8b5cf6;
--danger: #ef4444;
--on-accent: #ffffff;
--grad-accent: linear-gradient(135deg, #3b6ef6 0%, #8b5cf6 100%);
--grad-accent-2: linear-gradient(135deg, #10b981 0%, #06b6d4 100%);
--glow: rgba(59, 110, 246, 0.18);
--shadow-card: 0 1px 2px rgba(16, 24, 40, 0.04), 0 8px 24px -8px rgba(16, 24, 40, 0.1);
--shadow-pop: 0 16px 40px -8px rgba(16, 24, 40, 0.18);
--page-glow-1: rgba(59, 110, 246, 0.09);
--page-glow-2: rgba(16, 185, 129, 0.06);
--grid-line: rgba(16, 24, 40, 0.07);
--model-0: #3b6ef6;
--model-1: #10b981;
--model-2: #f59e0b;
--model-3: #8b5cf6;
--model-4: #ef4444;
--model-5: #06b6d4;
--model-6: #f97316;
--model-7: #ec4899;
--font-sans:
'Hanken Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace;
}
:global(body) {
margin: 0;
background: var(--bg);
background-image:
radial-gradient(1100px 620px at 8% -8%, var(--page-glow-1), transparent 60%),
radial-gradient(900px 520px at 100% 0%, var(--page-glow-2), transparent 55%);
background-attachment: fixed;
background-repeat: no-repeat;
color: var(--text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
:global(*) {
box-sizing: border-box;
}
:global(a) {
color: inherit;
text-decoration: none;
}
:global(::selection) {
background: var(--glow);
color: var(--text);
}
.shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ---- top bar ---- */
.topbar {
position: sticky;
top: 0;
z-index: 20;
border-bottom: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--bg) 78%, transparent);
backdrop-filter: saturate(1.6) blur(14px);
-webkit-backdrop-filter: saturate(1.6) blur(14px);
}
.topbar-inner {
width: 100%;
max-width: var(--maxw);
margin: 0 auto;
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.85rem 2rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.6rem;
}
.brand-glyph {
display: inline-flex;
filter: drop-shadow(0 4px 10px var(--glow));
}
.brand-word {
font-family: var(--font-mono);
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.brand-mark {
color: var(--text);
}
.brand-mark-dim {
color: var(--text-faint);
}
nav {
display: flex;
gap: 0.35rem;
margin-left: 0.5rem;
}
nav a {
font-size: 0.88rem;
font-weight: 500;
color: var(--text-dim);
padding: 0.4rem 0.85rem;
border-radius: 999px;
transition:
color 0.15s ease,
background 0.15s ease;
}
nav a:hover {
color: var(--text);
background: var(--bg-raised);
}
nav a.active {
color: var(--text);
background: var(--bg-raised);
box-shadow: inset 0 0 0 1px var(--border);
}
.topbar-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.75rem;
}
.theme-switch {
display: inline-flex;
gap: 2px;
padding: 3px;
border-radius: 999px;
background: var(--bg-panel-2);
border: 1px solid var(--border-soft);
}
.theme-switch button {
display: inline-flex;
align-items: center;
gap: 0.35rem;
appearance: none;
border: none;
background: transparent;
color: var(--text-dim);
font: inherit;
font-size: 0.78rem;
font-weight: 500;
padding: 0.3rem 0.7rem;
border-radius: 999px;
cursor: pointer;
transition:
color 0.15s ease,
background 0.15s ease;
}
.theme-switch button svg {
display: block;
}
.theme-switch button:hover {
color: var(--text);
}
.theme-switch button.active {
color: var(--on-accent);
background: var(--grad-accent);
box-shadow: 0 4px 14px -4px var(--glow);
}
.content {
flex: 1;
width: 100%;
max-width: var(--maxw);
margin: 0 auto;
padding: 2.25rem 2rem 5rem;
}
@media (max-width: 640px) {
.topbar-inner {
padding: 0.75rem 1rem;
gap: 0.75rem;
}
.content {
padding: 1.5rem 1rem 4rem;
}
.theme-label {
display: none;
}
.brand-word {
display: none;
}
}
</style>

View file

@ -0,0 +1,57 @@
import {
overviewStats,
usageSeries,
usageByModel,
topTools,
recentSessions,
hourOfDayActivity,
cacheEfficiency,
usageGauges,
type Bucket,
type TimeWindow
} from '$lib/server/queries';
import { resolveRange } from '$lib/server/range';
import { getCostByProject } from '$lib/server/stats/byProject';
import { getGaugeHistory } from '$lib/server/stats/gaugeHistory';
import { getWebUsage } from '$lib/server/stats/webUsage';
import { getToolErrors } from '$lib/server/stats/toolErrors';
import { getTopActivity } from '$lib/server/stats/topActivity';
import { getLatencyTrends } from '$lib/server/stats/latency';
import { getActivityCalendar } from '$lib/server/stats/calendar';
import { getPunchcard } from '$lib/server/stats/punchcard';
import type { PageServerLoad } from './$types';
const TOP_TOOLS_LIMIT = 10;
const RECENT_SESSIONS_LIMIT = 7; // dashboard shows a short preview; full list lives at /sessions
const DAY_MS = 86_400_000;
export const load: PageServerLoad = async ({ url }) => {
const range = resolveRange(url);
const window: TimeWindow = { since: range.since, until: range.until };
// usageSeries needs concrete bounds; fall back to a 30-day span if the DB is empty.
const seriesSince = range.since ?? new Date(Date.now() - 30 * DAY_MS).toISOString();
const bucket: Bucket = range.bucket;
const byModel = usageByModel(window);
return {
range,
overview: overviewStats(window),
series: usageSeries(seriesSince, range.until, bucket),
modelSet: byModel.map((m) => m.model), // stacking order (cost desc)
byModel,
hourly: hourOfDayActivity(window),
cache: cacheEfficiency(window),
topTools: topTools(TOP_TOOLS_LIMIT, window),
recentSessions: recentSessions(RECENT_SESSIONS_LIMIT, window),
gauges: usageGauges(),
projects: getCostByProject(window),
gaugeHistory: getGaugeHistory(window),
webUsage: getWebUsage(window),
toolErrors: getToolErrors(window),
topActivity: getTopActivity(window),
speed: getLatencyTrends(window),
activityCalendar: getActivityCalendar(),
punchcard: getPunchcard(window)
};
};

1191
src/routes/+page.svelte Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,414 @@
import { json, error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { db } from '$lib/server/db';
import type { RequestHandler } from './$types';
/**
* Ingest endpoint. The agent POSTs batches of parsed events here.
* Auth: `Authorization: Bearer $API_TOKEN`.
*
* Accepted body shape (all top-level keys optional except `events`):
*
* {
* host?: string, // fallback host for entries that omit their own `host`
* events: IncomingEvent[], // see agent/parse.ts ParsedEvent — read defensively, coerced
* toolCalls?: IncomingToolCall[],
* sessions?: IncomingSession[] // if omitted, session rows are derived from event timestamps
* }
*
* Everything is read off `unknown` and coerced (str/num/bool01 below) because agent/parse.ts is still
* being finalized in parallel this endpoint must not break if a field is missing, null, or mistyped.
*
* Idempotency: events upsert by (host, session_id, uuid) re-posting the same physical JSONL line just
* overwrites the row. tool_calls upsert by (host, session_id, tool_use_id) and *merge* (COALESCE) so a
* tool_use line and its later tool_result line can arrive in separate batches without blanking fields.
* content_fts is a standalone fts5 table, so re-ingesting a uuid does delete+insert to avoid duplicate rows.
*
* is_usage_canonical: trusted from the agent when the field is present (even if false) on at least one row
* of a (session_id, message_id, request_id) group; otherwise recomputed server-side as a safety net the
* row with the max output_tokens in that group (across the whole DB, not just this batch) wins.
*/
interface IncomingEvent {
host?: unknown;
session_id?: unknown;
uuid?: unknown;
parent_uuid?: unknown;
ts_utc?: unknown;
type?: unknown;
role?: unknown;
model?: unknown;
request_id?: unknown;
message_id?: unknown;
is_sidechain?: unknown;
is_usage_canonical?: unknown;
stop_reason?: unknown;
latency_ms?: unknown;
input_tokens?: unknown;
output_tokens?: unknown;
cache_creation_tokens?: unknown;
cache_read_tokens?: unknown;
web_search_requests?: unknown;
web_fetch_requests?: unknown;
text?: unknown;
}
interface IncomingToolCall {
host?: unknown;
session_id?: unknown;
tool_use_id?: unknown;
event_uuid?: unknown;
tool_name?: unknown;
input_json?: unknown;
input?: unknown; // accepted as an alternative to input_json — JSON.stringify'd if input_json is absent
is_error?: unknown;
result_bytes?: unknown;
duration_ms?: unknown;
ts_utc?: unknown;
}
interface IncomingSession {
host?: unknown;
session_id?: unknown;
project?: unknown;
git_branch?: unknown;
cc_version?: unknown;
entrypoint?: unknown;
started_at?: unknown;
ended_at?: unknown;
}
interface IngestBody {
host?: unknown;
events?: unknown;
toolCalls?: unknown;
sessions?: unknown;
}
function str(v: unknown): string | null {
return typeof v === 'string' && v.length > 0 ? v : null;
}
function num(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null;
}
function bool01(v: unknown): number {
return v ? 1 : 0;
}
// Prepared statements are built lazily on first ingest so that *importing* this
// module (e.g. SvelteKit's build-time analyse pass, which has no writable DB dir)
// never opens the database. They are created once, then reused for the process life.
type Stmt = import('better-sqlite3').Statement<unknown[]>;
let prepared = false;
let upsertSessionStmt!: Stmt;
let upsertEventStmt!: Stmt;
let upsertToolCallStmt!: Stmt;
let upsertContentStmt!: Stmt;
let deleteFtsStmt!: Stmt;
let insertFtsStmt!: Stmt;
let selectUsageGroupStmt!: Stmt;
let setCanonicalStmt!: Stmt;
let clearCanonicalStmt!: Stmt;
let runIngest!: (body: IngestBody) => { events: number; tool_calls: number; sessions: number };
function ensurePrepared() {
if (prepared) return;
const conn = db();
upsertSessionStmt = conn.prepare(`
INSERT INTO sessions (host, session_id, project, git_branch, cc_version, entrypoint, started_at, ended_at)
VALUES (@host, @session_id, @project, @git_branch, @cc_version, @entrypoint, @started_at, @ended_at)
ON CONFLICT(host, session_id) DO UPDATE SET
project = COALESCE(excluded.project, project),
git_branch = COALESCE(excluded.git_branch, git_branch),
cc_version = COALESCE(excluded.cc_version, cc_version),
entrypoint = COALESCE(excluded.entrypoint, entrypoint),
started_at = COALESCE(MIN(started_at, excluded.started_at), started_at, excluded.started_at),
ended_at = COALESCE(MAX(ended_at, excluded.ended_at), ended_at, excluded.ended_at)
`);
upsertEventStmt = conn.prepare(`
INSERT INTO events (
host, session_id, uuid, parent_uuid, ts_utc, type, role, model, request_id, message_id,
is_sidechain, is_usage_canonical, stop_reason, latency_ms,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
web_search_requests, web_fetch_requests
) VALUES (
@host, @session_id, @uuid, @parent_uuid, @ts_utc, @type, @role, @model, @request_id, @message_id,
@is_sidechain, @is_usage_canonical, @stop_reason, @latency_ms,
@input_tokens, @output_tokens, @cache_creation_tokens, @cache_read_tokens,
@web_search_requests, @web_fetch_requests
)
ON CONFLICT(host, session_id, uuid) DO UPDATE SET
parent_uuid = excluded.parent_uuid,
ts_utc = excluded.ts_utc,
type = excluded.type,
role = excluded.role,
model = excluded.model,
request_id = excluded.request_id,
message_id = excluded.message_id,
is_sidechain = excluded.is_sidechain,
is_usage_canonical = excluded.is_usage_canonical,
stop_reason = excluded.stop_reason,
latency_ms = excluded.latency_ms,
input_tokens = excluded.input_tokens,
output_tokens = excluded.output_tokens,
cache_creation_tokens = excluded.cache_creation_tokens,
cache_read_tokens = excluded.cache_read_tokens,
web_search_requests = excluded.web_search_requests,
web_fetch_requests = excluded.web_fetch_requests
`);
upsertToolCallStmt = conn.prepare(`
INSERT INTO tool_calls (
host, session_id, tool_use_id, event_uuid, tool_name, input_json, is_error, result_bytes, duration_ms, ts_utc
) VALUES (
@host, @session_id, @tool_use_id, @event_uuid, @tool_name, @input_json, @is_error, @result_bytes, @duration_ms, @ts_utc
)
ON CONFLICT(host, session_id, tool_use_id) DO UPDATE SET
event_uuid = COALESCE(excluded.event_uuid, event_uuid),
tool_name = COALESCE(excluded.tool_name, tool_name),
input_json = COALESCE(excluded.input_json, input_json),
is_error = COALESCE(excluded.is_error, is_error),
result_bytes = COALESCE(excluded.result_bytes, result_bytes),
duration_ms = COALESCE(excluded.duration_ms, duration_ms),
ts_utc = COALESCE(excluded.ts_utc, ts_utc)
`);
upsertContentStmt = conn.prepare(`
INSERT INTO content (host, session_id, uuid, role, text)
VALUES (@host, @session_id, @uuid, @role, @text)
ON CONFLICT(host, session_id, uuid) DO UPDATE SET
role = excluded.role,
text = excluded.text
`);
deleteFtsStmt = conn.prepare(
`DELETE FROM content_fts WHERE host = ? AND session_id = ? AND uuid = ?`
);
insertFtsStmt = conn.prepare(
`INSERT INTO content_fts (text, host, session_id, uuid) VALUES (?, ?, ?, ?)`
);
selectUsageGroupStmt = conn.prepare(`
SELECT uuid, output_tokens FROM events
WHERE host = ? AND session_id = ? AND message_id = ? AND request_id = ? AND output_tokens IS NOT NULL
`);
setCanonicalStmt = conn.prepare(
`UPDATE events SET is_usage_canonical = 1 WHERE host = ? AND session_id = ? AND uuid = ?`
);
clearCanonicalStmt = conn.prepare(`
UPDATE events SET is_usage_canonical = 0
WHERE host = ? AND session_id = ? AND message_id = ? AND request_id = ? AND uuid != ?
`);
runIngest = conn.transaction(ingestBatch);
prepared = true;
}
interface SessionBounds {
host: string;
session_id: string;
started_at: string | null;
ended_at: string | null;
}
interface UsageGroupKey {
host: string;
session_id: string;
message_id: string;
request_id: string;
}
function groupKey(g: UsageGroupKey): string {
return `${g.host}${g.session_id}${g.message_id}${g.request_id}`;
}
function ingestBatch(body: IngestBody) {
const fallbackHost = str(body.host) ?? '';
const rawEvents = Array.isArray(body.events) ? (body.events as IncomingEvent[]) : [];
const rawToolCalls = Array.isArray(body.toolCalls) ? (body.toolCalls as IncomingToolCall[]) : [];
const rawSessions = Array.isArray(body.sessions) ? (body.sessions as IncomingSession[]) : [];
let eventCount = 0;
let toolCallCount = 0;
let sessionCount = 0;
// groups the agent explicitly flagged (is_usage_canonical present on >=1 row) — skip recompute for these
const agentSetGroups = new Set<string>();
// groups touched by this batch that have usage but no explicit flag — candidates for recompute
const needsRecompute = new Map<string, UsageGroupKey>();
// derived session bounds (host|session_id -> min/max ts_utc), used when `sessions` wasn't supplied
const bounds = new Map<string, SessionBounds>();
for (const e of rawEvents) {
const host = str(e.host) ?? fallbackHost;
const session_id = str(e.session_id);
const uuid = str(e.uuid);
if (!host || !session_id || !uuid) continue; // incomplete PK, can't store
const ts_utc = str(e.ts_utc) ?? '';
const role = str(e.role);
const message_id = str(e.message_id);
const request_id = str(e.request_id);
const hasExplicitCanonical =
e.is_usage_canonical !== undefined && e.is_usage_canonical !== null;
const is_usage_canonical = hasExplicitCanonical ? bool01(e.is_usage_canonical) : 0;
const output_tokens = num(e.output_tokens);
upsertEventStmt.run({
host,
session_id,
uuid,
parent_uuid: str(e.parent_uuid),
ts_utc,
type: str(e.type) ?? 'unknown',
role,
model: str(e.model),
request_id,
message_id,
is_sidechain: bool01(e.is_sidechain),
is_usage_canonical,
stop_reason: str(e.stop_reason),
latency_ms: num(e.latency_ms),
input_tokens: num(e.input_tokens),
output_tokens,
cache_creation_tokens: num(e.cache_creation_tokens),
cache_read_tokens: num(e.cache_read_tokens),
web_search_requests: num(e.web_search_requests),
web_fetch_requests: num(e.web_fetch_requests)
});
eventCount++;
if (message_id && request_id) {
const key = groupKey({ host, session_id, message_id, request_id });
if (hasExplicitCanonical) {
agentSetGroups.add(key);
} else if (output_tokens !== null) {
needsRecompute.set(key, { host, session_id, message_id, request_id });
}
}
const text = str(e.text);
if (text && (role === 'user' || role === 'assistant')) {
upsertContentStmt.run({ host, session_id, uuid, role, text });
// standalone fts5 table — manual delete+insert keeps re-ingest of the same uuid from duplicating rows
deleteFtsStmt.run(host, session_id, uuid);
insertFtsStmt.run(text, host, session_id, uuid);
}
if (ts_utc) {
const bkey = `${host}${session_id}`;
const existing = bounds.get(bkey);
if (!existing) {
bounds.set(bkey, { host, session_id, started_at: ts_utc, ended_at: ts_utc });
} else {
if (existing.started_at === null || ts_utc < existing.started_at)
existing.started_at = ts_utc;
if (existing.ended_at === null || ts_utc > existing.ended_at) existing.ended_at = ts_utc;
}
}
}
for (const t of rawToolCalls) {
const host = str(t.host) ?? fallbackHost;
const session_id = str(t.session_id);
const tool_use_id = str(t.tool_use_id);
if (!host || !session_id || !tool_use_id) continue;
let input_json = str(t.input_json);
if (input_json === null && t.input !== undefined && t.input !== null) {
try {
input_json = JSON.stringify(t.input);
} catch {
input_json = null;
}
}
upsertToolCallStmt.run({
host,
session_id,
tool_use_id,
event_uuid: str(t.event_uuid),
tool_name: str(t.tool_name) ?? 'unknown',
input_json,
is_error: t.is_error === undefined || t.is_error === null ? null : bool01(t.is_error),
result_bytes: num(t.result_bytes),
duration_ms: num(t.duration_ms),
ts_utc: str(t.ts_utc)
});
toolCallCount++;
}
for (const s of rawSessions) {
const host = str(s.host) ?? fallbackHost;
const session_id = str(s.session_id);
if (!host || !session_id) continue;
upsertSessionStmt.run({
host,
session_id,
project: str(s.project),
git_branch: str(s.git_branch),
cc_version: str(s.cc_version),
entrypoint: str(s.entrypoint),
started_at: str(s.started_at),
ended_at: str(s.ended_at)
});
sessionCount++;
bounds.delete(`${host}${session_id}`); // an explicit session row wins over derived bounds
}
// derive session rows (start/end bounds only) for any session not explicitly supplied above
for (const b of bounds.values()) {
upsertSessionStmt.run({
host: b.host,
session_id: b.session_id,
project: null,
git_branch: null,
cc_version: null,
entrypoint: null,
started_at: b.started_at,
ended_at: b.ended_at
});
sessionCount++;
}
// safety net: recompute is_usage_canonical for groups this batch touched but the agent didn't flag
for (const [key, g] of needsRecompute) {
if (agentSetGroups.has(key)) continue; // agent already flagged this group — trust it
const rows = selectUsageGroupStmt.all(
g.host,
g.session_id,
g.message_id,
g.request_id
) as Array<{
uuid: string;
output_tokens: number;
}>;
if (rows.length === 0) continue;
let best = rows[0];
for (const r of rows) if (r.output_tokens > best.output_tokens) best = r;
setCanonicalStmt.run(g.host, g.session_id, best.uuid);
clearCanonicalStmt.run(g.host, g.session_id, g.message_id, g.request_id, best.uuid);
}
return { events: eventCount, tool_calls: toolCallCount, sessions: sessionCount };
}
export const POST: RequestHandler = async ({ request }) => {
const token = env.API_TOKEN;
if (!token) throw error(500, 'server missing API_TOKEN');
if (request.headers.get('authorization') !== `Bearer ${token}`) throw error(401, 'unauthorized');
const body = (await request.json().catch(() => null)) as IngestBody | null;
if (!body || typeof body !== 'object') throw error(400, 'invalid JSON body');
ensurePrepared();
const result = runIngest(body);
return json({ ok: true, ...result });
};
export const GET: RequestHandler = async () => json({ ok: true, service: 'toknmtr ingest' });

View file

@ -0,0 +1,110 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { showTranscripts } from '$lib/server/config';
import type { RequestHandler } from './$types';
/**
* Full-text search over the session archive (`content_fts`, fts5).
* GET ?q=<query>&limit=<n>
*
* Read-only, LAN-only, no auth (matches /api/stats). Empty/whitespace-only `q` short-circuits
* to an empty result set instead of hitting SQLite (an unqualified `MATCH ''` is a syntax error).
*
* The `q` string is never spliced into the MATCH expression as-is: each whitespace-separated term
* is individually double-quoted (with embedded `"` doubled, fts5's own escape) so user input can't
* inject fts5 query syntax (`OR`, `NOT`, `NEAR`, column filters, dangling `*`, etc). Multiple quoted
* terms are matched with fts5's default implicit AND.
*
* Snippets come from `snippet()`, wrapped in control-character markers (not HTML) so the client can
* split + highlight without ever needing `{@html}` on raw transcript content.
*/
// Control characters (never appear in normal transcript text) used to delimit highlighted spans in
// the returned snippet, so the client can split on them without ever rendering raw HTML.
const SNIPPET_MARK_START = '';
const SNIPPET_MARK_END = '';
const SNIPPET_TOKENS = 12;
/** Turn free-text user input into a safe, quoted fts5 MATCH expression, or null if there's nothing to search. */
function buildFtsQuery(raw: string): string | null {
const terms = raw
.trim()
.split(/\s+/)
.filter(Boolean)
.map((term) => `"${term.replace(/"/g, '""')}"`);
return terms.length > 0 ? terms.join(' ') : null;
}
interface SearchRow {
host: string;
session_id: string;
uuid: string;
snippet: string;
role: string | null;
type: string | null;
ts_utc: string | null;
project: string | null;
}
export interface SearchResult {
host: string;
sessionId: string;
uuid: string;
snippet: string;
role: string | null;
type: string | null;
tsUtc: string | null;
project: string | null;
}
export const GET: RequestHandler = async ({ url }) => {
// Returns verbatim transcript snippets — refuse while conversation content is hidden.
if (!showTranscripts) {
throw error(403, 'Search is disabled');
}
const q = url.searchParams.get('q') ?? '';
const limitParam = Number(url.searchParams.get('limit'));
const limit =
Number.isFinite(limitParam) && limitParam > 0 ? Math.min(200, Math.floor(limitParam)) : 30;
const ftsQuery = buildFtsQuery(q);
if (!ftsQuery) {
return json({ query: q, count: 0, results: [] satisfies SearchResult[] });
}
const dbh = db();
let rows: SearchRow[];
try {
rows = dbh
.prepare(
`SELECT f.host AS host, f.session_id AS session_id, f.uuid AS uuid,
snippet(content_fts, 0, ?, ?, ' … ', ${SNIPPET_TOKENS}) AS snippet,
e.role AS role, e.type AS type, e.ts_utc AS ts_utc, s.project AS project
FROM content_fts f
JOIN events e ON e.host = f.host AND e.session_id = f.session_id AND e.uuid = f.uuid
LEFT JOIN sessions s ON s.host = f.host AND s.session_id = f.session_id
WHERE content_fts MATCH ?
ORDER BY rank
LIMIT ?`
)
.all(SNIPPET_MARK_START, SNIPPET_MARK_END, ftsQuery, limit) as SearchRow[];
} catch {
// Malformed MATCH expression (shouldn't happen given the quoting above, but fts5 can still
// reject pathological input like a lone `""`) — treat as "no results" rather than a 500.
return json({ query: q, count: 0, results: [] satisfies SearchResult[] });
}
const results: SearchResult[] = rows.map((r) => ({
host: r.host,
sessionId: r.session_id,
uuid: r.uuid,
snippet: r.snippet,
role: r.role,
type: r.type,
tsUtc: r.ts_utc,
project: r.project
}));
return json({ query: q, count: results.length, results });
};

View file

@ -0,0 +1,40 @@
import { json } from '@sveltejs/kit';
import {
overviewStats,
usageSeries,
usageByModel,
topTools,
recentSessions,
hourOfDayActivity,
cacheEfficiency,
usageGauges,
type TimeWindow
} from '$lib/server/queries';
import type { RequestHandler } from './$types';
/**
* Read-only stats bundle. LAN-only, no auth.
* Query params: `?days=30` window size in days (default 30). Series buckets by
* hour when days <= 2, else by day.
*/
export const GET: RequestHandler = async ({ url }) => {
const daysParam = Number(url.searchParams.get('days'));
const days = Number.isFinite(daysParam) && daysParam > 0 ? Math.floor(daysParam) : 30;
const untilIso = new Date().toISOString();
const sinceIso = new Date(Date.now() - days * 86_400_000).toISOString();
const window: TimeWindow = { since: sinceIso, until: untilIso };
const bucket = days <= 2 ? 'hour' : 'day';
return json({
days,
overview: overviewStats(window),
series: usageSeries(sinceIso, untilIso, bucket),
byModel: usageByModel(window),
hourly: hourOfDayActivity(window),
cache: cacheEfficiency(window),
topTools: topTools(10, window),
recentSessions: recentSessions(20, window),
gauges: usageGauges()
});
};

View file

@ -0,0 +1,94 @@
import { json, error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import { db } from '$lib/server/db';
import type { RequestHandler } from './$types';
/**
* Usage-gauge ingest. The agent PTY-drives `claude /usage` (see agent/usage.ts) and POSTs the
* three subscription bars here. Separate from /api/ingest because the shape and cadence differ
* (a slow, ~5-9s scrape done once per run, not per JSONL sweep).
*
* Auth: `Authorization: Bearer $API_TOKEN` (same token as /api/ingest).
*
* Accepted body either a single gauge object or a batch:
* { host, ts_utc, session_pct?, week_all_pct?, week_sonnet_pct? }
* { host?, gauges: GaugeRow[] } // host is a fallback for rows that omit their own
*
* Rows upsert by (host, ts_utc) re-posting the same scrape is idempotent.
*/
interface IncomingGauge {
host?: unknown;
ts_utc?: unknown;
session_pct?: unknown;
week_all_pct?: unknown;
week_sonnet_pct?: unknown;
}
interface UsageBody extends IncomingGauge {
gauges?: unknown;
}
function str(v: unknown): string | null {
return typeof v === 'string' && v.length > 0 ? v : null;
}
function num(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null;
}
// Lazily prepared on first request so importing this module (e.g. the build's
// analyse pass, which has no writable DB dir) never opens the database.
type Stmt = import('better-sqlite3').Statement<unknown[]>;
let prepared = false;
let upsertGaugeStmt!: Stmt;
let insertMany!: (rows: IncomingGauge[], fallbackHost: string | null) => number;
function ensurePrepared() {
if (prepared) return;
const conn = db();
upsertGaugeStmt = conn.prepare(`
INSERT INTO usage_gauges (host, ts_utc, session_pct, week_all_pct, week_sonnet_pct)
VALUES (@host, @ts_utc, @session_pct, @week_all_pct, @week_sonnet_pct)
ON CONFLICT(host, ts_utc) DO UPDATE SET
session_pct = COALESCE(excluded.session_pct, session_pct),
week_all_pct = COALESCE(excluded.week_all_pct, week_all_pct),
week_sonnet_pct = COALESCE(excluded.week_sonnet_pct, week_sonnet_pct)
`);
insertMany = conn.transaction((rows: IncomingGauge[], fallbackHost: string | null) => {
let count = 0;
for (const g of rows) {
const host = str(g.host) ?? fallbackHost;
const ts_utc = str(g.ts_utc);
if (!host || !ts_utc) continue; // incomplete PK — skip
upsertGaugeStmt.run({
host,
ts_utc,
session_pct: num(g.session_pct),
week_all_pct: num(g.week_all_pct),
week_sonnet_pct: num(g.week_sonnet_pct)
});
count++;
}
return count;
});
prepared = true;
}
export const POST: RequestHandler = async ({ request }) => {
const token = env.API_TOKEN;
if (!token) throw error(500, 'server missing API_TOKEN');
if (request.headers.get('authorization') !== `Bearer ${token}`) throw error(401, 'unauthorized');
const body = (await request.json().catch(() => null)) as UsageBody | null;
if (!body || typeof body !== 'object') throw error(400, 'invalid JSON body');
ensurePrepared();
const rows = Array.isArray(body.gauges) ? (body.gauges as IncomingGauge[]) : [body];
const count = insertMany(rows, str(body.host));
return json({ ok: true, gauges: count });
};
export const GET: RequestHandler = async () => json({ ok: true, service: 'toknmtr usage' });

View file

@ -0,0 +1,86 @@
import { error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { showTranscripts } from '$lib/server/config';
import type { PageServerLoad } from './$types';
import type { SearchResult } from '../api/search/+server';
/**
* Server-rendered counterpart to GET /api/search reads the same `?q=` (and optional `?limit=`)
* params so the page works on first load (no client-side fetch needed) and is link/bookmark-able.
* Query logic itself lives in the API route's module; this just re-runs the same SQL via `db()`
* directly per the ownership split (search-specific SQL stays out of `queries.ts`).
*/
const SNIPPET_MARK_START = '';
const SNIPPET_MARK_END = '';
const SNIPPET_TOKENS = 12;
function buildFtsQuery(raw: string): string | null {
const terms = raw
.trim()
.split(/\s+/)
.filter(Boolean)
.map((term) => `"${term.replace(/"/g, '""')}"`);
return terms.length > 0 ? terms.join(' ') : null;
}
interface SearchRow {
host: string;
session_id: string;
uuid: string;
snippet: string;
role: string | null;
type: string | null;
ts_utc: string | null;
project: string | null;
}
export const load: PageServerLoad = async ({ url }) => {
// Search returns verbatim snippets of prompt/response text — hidden while public.
if (!showTranscripts) {
throw error(403, 'Search is disabled');
}
const q = url.searchParams.get('q') ?? '';
const limitParam = Number(url.searchParams.get('limit'));
const limit =
Number.isFinite(limitParam) && limitParam > 0 ? Math.min(200, Math.floor(limitParam)) : 30;
const ftsQuery = buildFtsQuery(q);
if (!ftsQuery) {
return { query: q, count: 0, results: [] as SearchResult[] };
}
const dbh = db();
let rows: SearchRow[];
try {
rows = dbh
.prepare(
`SELECT f.host AS host, f.session_id AS session_id, f.uuid AS uuid,
snippet(content_fts, 0, ?, ?, ' … ', ${SNIPPET_TOKENS}) AS snippet,
e.role AS role, e.type AS type, e.ts_utc AS ts_utc, s.project AS project
FROM content_fts f
JOIN events e ON e.host = f.host AND e.session_id = f.session_id AND e.uuid = f.uuid
LEFT JOIN sessions s ON s.host = f.host AND s.session_id = f.session_id
WHERE content_fts MATCH ?
ORDER BY rank
LIMIT ?`
)
.all(SNIPPET_MARK_START, SNIPPET_MARK_END, ftsQuery, limit) as SearchRow[];
} catch {
return { query: q, count: 0, results: [] as SearchResult[] };
}
const results: SearchResult[] = rows.map((r) => ({
host: r.host,
sessionId: r.session_id,
uuid: r.uuid,
snippet: r.snippet,
role: r.role,
type: r.type,
tsUtc: r.ts_utc,
project: r.project
}));
return { query: q, count: results.length, results };
};

View file

@ -0,0 +1,373 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// Local input state, seeded from (and reset whenever) the URL's `q` changes — e.g. on
// load or back/forward nav. Assigning to a $derived value installs a local override that's
// dropped the next time its dependency (`data.query`) actually changes.
let queryInput = $derived(data.query);
function submitSearch(e: SubmitEvent) {
e.preventDefault();
const q = queryInput.trim();
const params = new SvelteURLSearchParams(page.url.search);
if (q) {
params.set('q', q);
} else {
params.delete('q');
}
params.delete('limit');
goto(resolve(`/search?${params.toString()}`), { keepFocus: true });
}
/** Split a snippet on the \x01...\x02 highlight markers (set server-side) into plain/hit runs. */
function snippetParts(snippet: string): { text: string; hit: boolean }[] {
const parts: { text: string; hit: boolean }[] = [];
let rest = snippet;
while (rest.length > 0) {
const start = rest.indexOf('\x01');
if (start === -1) {
parts.push({ text: rest, hit: false });
break;
}
if (start > 0) parts.push({ text: rest.slice(0, start), hit: false });
const end = rest.indexOf('\x02', start + 1);
if (end === -1) {
parts.push({ text: rest.slice(start + 1), hit: false });
break;
}
parts.push({ text: rest.slice(start + 1, end), hit: true });
rest = rest.slice(end + 1);
}
return parts;
}
function fmtTs(ts: string | null): string {
if (!ts) return '—';
const d = new Date(ts);
return Number.isNaN(d.getTime()) ? ts : d.toLocaleString();
}
function roleLabel(role: string | null, type: string | null): string {
return role ?? type ?? 'event';
}
function basename(path: string | null): string {
if (!path) return 'unknown project';
const parts = path.split('/').filter(Boolean);
return parts.length ? parts[parts.length - 1] : path;
}
</script>
<svelte:head><title>toknmtr — search</title></svelte:head>
<header class="page-head">
<h1>Search</h1>
<p class="subtitle">Full-text search across every captured prompt, response, and tool result.</p>
</header>
<section class="panel">
<form onsubmit={submitSearch} class="search-form">
<div class="search-field">
<svg class="search-icon" viewBox="0 0 24 24" width="17" height="17" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M20 20l-4.3-4.3" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<!-- svelte-ignore a11y_autofocus -->
<input
type="search"
name="q"
placeholder="Search prompts, responses, tool output…"
bind:value={queryInput}
autofocus
/>
</div>
<button type="submit">Search</button>
</form>
{#if data.query.trim() === ''}
<p class="empty">Enter a search term above to get started.</p>
{:else if data.results.length === 0}
<p class="empty">No results for <strong>{data.query}</strong>.</p>
{:else}
<p class="result-count mono">
{data.count} result{data.count === 1 ? '' : 's'} for <span class="accent">{data.query}</span>
</p>
<ul class="results">
{#each data.results as r, i (r.host + r.sessionId + r.uuid)}
<li class="result-row" style="animation-delay: {Math.min(i, 12) * 30}ms">
<div class="result-meta">
<span class="role role-{roleLabel(r.role, r.type)}">{roleLabel(r.role, r.type)}</span>
<span class="result-project">{basename(r.project)}</span>
<span class="result-spacer"></span>
<span class="result-host mono">{r.host}</span>
<span class="result-ts mono">{fmtTs(r.tsUtc)}</span>
</div>
<p class="snippet">
{#each snippetParts(r.snippet) as part, i (i)}
{#if part.hit}<mark>{part.text}</mark>{:else}{part.text}{/if}
{/each}
</p>
<span class="result-session mono" title={r.sessionId}>{r.sessionId}</span>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.accent {
color: var(--accent);
}
.page-head {
margin-bottom: 1.75rem;
}
.page-head h1 {
margin: 0 0 0.35rem;
font-size: 1.5rem;
letter-spacing: -0.01em;
}
.subtitle {
margin: 0;
color: var(--text-dim);
font-size: 0.92rem;
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 1.3rem 1.4rem 1.5rem;
}
.empty {
color: var(--text-faint);
font-size: 0.88rem;
padding: 2rem 0;
text-align: center;
}
/* ---- search form ---- */
.search-form {
display: flex;
gap: 0.6rem;
margin-bottom: 1.2rem;
}
.search-field {
position: relative;
flex: 1;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 0.85rem;
color: var(--text-faint);
pointer-events: none;
}
input[type='search'] {
width: 100%;
background: var(--bg-panel-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
padding: 0.65rem 0.9rem 0.65rem 2.5rem;
font-size: 0.92rem;
font-family: var(--font-sans);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
background 0.15s ease;
}
input[type='search']::placeholder {
color: var(--text-faint);
}
input[type='search']:hover {
border-color: var(--border);
background: var(--bg-raised);
}
input[type='search']:focus {
outline: none;
border-color: var(--accent);
background: var(--bg-raised);
box-shadow: 0 0 0 3px var(--glow);
}
/* Native WebKit "clear" and search-cancel affordances inherit currentColor fine, but
keep the search icon color from bleeding into focus rings. */
.search-field:focus-within .search-icon {
color: var(--accent);
}
button[type='submit'] {
background: var(--grad-accent);
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--on-accent);
font-weight: 600;
font-size: 0.88rem;
padding: 0.65rem 1.3rem;
cursor: pointer;
box-shadow: 0 4px 14px -4px var(--glow);
transition:
transform 0.15s ease,
box-shadow 0.15s ease,
filter 0.15s ease;
}
button[type='submit']:hover {
filter: brightness(1.06);
box-shadow: 0 6px 18px -4px var(--glow);
}
button[type='submit']:active {
transform: translateY(1px);
}
button[type='submit']:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.result-count {
font-size: 0.8rem;
color: var(--text-faint);
margin: 0 0 1rem;
}
/* ---- results ---- */
.results {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.result-row {
background: var(--bg-raised);
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
padding: 0.85rem 1.1rem;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
transform 0.15s ease;
animation: rowIn 0.35s ease backwards;
}
.result-row:hover {
border-color: var(--border);
box-shadow: var(--shadow-pop);
transform: translateY(-1px);
}
@keyframes rowIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.result-row {
animation: none;
}
.result-row:hover {
transform: none;
}
}
.result-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
font-size: 0.76rem;
color: var(--text-faint);
margin-bottom: 0.5rem;
}
.role {
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
font-size: 0.68rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: var(--bg-panel-2);
color: var(--text-dim);
border: 1px solid var(--border-soft);
}
.role-user {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
background: color-mix(in srgb, var(--accent) 12%, var(--bg-panel-2));
}
.role-assistant {
color: var(--amber);
border-color: color-mix(in srgb, var(--amber) 45%, transparent);
background: color-mix(in srgb, var(--amber) 12%, var(--bg-panel-2));
}
.result-project {
font-weight: 500;
color: var(--text-dim);
font-family: var(--font-sans);
}
.result-spacer {
flex: 1;
}
.snippet {
margin: 0 0 0.5rem;
font-size: 0.88rem;
color: var(--text);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.snippet mark {
background: color-mix(in srgb, var(--amber) 26%, transparent);
color: var(--text);
border-radius: 3px;
padding: 0 0.15rem;
}
.result-session {
display: block;
font-size: 0.7rem;
color: var(--text-faint);
}
</style>

View file

@ -0,0 +1,32 @@
import {
allSessions,
sessionProjects,
type SessionSort,
type SortDir,
type TimeWindow
} from '$lib/server/queries';
import { resolveRange } from '$lib/server/range';
import type { PageServerLoad } from './$types';
const SORTS: SessionSort[] = ['recent', 'cost', 'tokens', 'events', 'tools', 'project'];
const DIRS: SortDir[] = ['asc', 'desc'];
export const load: PageServerLoad = async ({ url }) => {
const range = resolveRange(url);
const window: TimeWindow = { since: range.since, until: range.until };
const sortParam = url.searchParams.get('sort');
const dirParam = url.searchParams.get('dir');
const sort: SessionSort = SORTS.includes(sortParam as SessionSort)
? (sortParam as SessionSort)
: 'recent';
const dir: SortDir = DIRS.includes(dirParam as SortDir) ? (dirParam as SortDir) : 'desc';
const projects = sessionProjects();
const projectParam = url.searchParams.get('project');
const project = projectParam && projects.includes(projectParam) ? projectParam : null;
const { rows, total } = allSessions(window, sort, dir, project);
return { range, sort, dir, project, projects, sessions: rows, total };
};

View file

@ -0,0 +1,399 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import type { PageData } from './$types';
import type { SessionSort } from '$lib/server/queries';
import RangePicker from '$lib/components/RangePicker.svelte';
import { fmtInt, fmtCompact, fmtUsd, relativeTime, basename } from '$lib/format';
let { data }: { data: PageData } = $props();
const range = $derived(data.range);
const sessions = $derived(data.sessions);
const sort = $derived(data.sort);
const dir = $derived(data.dir);
const projects = $derived(data.projects);
const project = $derived(data.project);
const showTranscripts = $derived(data.showTranscripts);
// summary across the (filtered) set currently shown
const totalCost = $derived(sessions.reduce((s, r) => s + r.costUsd, 0));
const totalTokens = $derived(sessions.reduce((s, r) => s + r.totalTokens, 0));
function pickProject(e: Event) {
const value = (e.currentTarget as HTMLSelectElement).value;
const params = new SvelteURLSearchParams(page.url.search);
if (value) params.set('project', value);
else params.delete('project');
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(`${page.url.pathname}?${params.toString()}`, { keepFocus: true, noScroll: true });
}
function sortBy(key: SessionSort) {
const nextDir =
sort === key ? (dir === 'desc' ? 'asc' : 'desc') : key === 'project' ? 'asc' : 'desc';
const params = new SvelteURLSearchParams(page.url.search);
params.set('sort', key);
params.set('dir', nextDir);
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(`${page.url.pathname}?${params.toString()}`, { keepFocus: true, noScroll: true });
}
const columns: { key: SessionSort; label: string; num: boolean }[] = [
{ key: 'project', label: 'Project', num: false },
{ key: 'recent', label: 'Last activity', num: false },
{ key: 'events', label: 'Events', num: true },
{ key: 'tools', label: 'Tools', num: true },
{ key: 'tokens', label: 'Tokens', num: true },
{ key: 'cost', label: 'Cost', num: true }
];
</script>
<svelte:head><title>toknmtr — sessions</title></svelte:head>
<div class="wrap">
<header class="page-head">
<div class="head-row">
<div>
<h1>Sessions</h1>
<p class="subtitle">
Every session with activity in the window — sort by cost, tokens, or recency.
</p>
</div>
<div class="filters">
<label class="proj-filter">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" aria-hidden="true">
<path
d="M4 5.5h16l-6 7v5l-4 2v-7l-6-7Z"
stroke="currentColor"
stroke-width="1.5"
stroke-linejoin="round"
/>
</svg>
<select value={project ?? ''} onchange={pickProject} aria-label="Filter by project">
<option value="">All projects</option>
{#each projects as p (p)}
<option value={p}>{basename(p)}</option>
{/each}
</select>
</label>
<RangePicker {range} />
</div>
</div>
</header>
<section class="panel">
<div class="summary">
<div class="summary-item">
<span class="summary-val mono">{fmtInt(data.total)}</span>
<span class="summary-lbl">sessions</span>
</div>
<div class="summary-item">
<span class="summary-val mono accent-amber">{fmtUsd(totalCost)}</span>
<span class="summary-lbl">notional cost</span>
</div>
<div class="summary-item">
<span class="summary-val mono">{fmtCompact(totalTokens)}</span>
<span class="summary-lbl">tokens</span>
</div>
<span class="summary-hint">{range.label} · click a column to sort</span>
</div>
{#if sessions.length}
<div class="table-scroll">
<table class="sessions-table">
<thead>
<tr>
{#each columns as c (c.key)}
<th class:num={c.num} class:active={sort === c.key}>
<button type="button" onclick={() => sortBy(c.key)}>
{c.label}
<span class="arrow" aria-hidden="true"
>{sort === c.key ? (dir === 'desc' ? '▾' : '▴') : ''}</span
>
</button>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sessions as s (s.host + '/' + s.sessionId)}
<tr>
<td class="cell-project">
{#if showTranscripts}
<a
class="proj-link"
href={`/sessions/${encodeURIComponent(s.host)}/${encodeURIComponent(s.sessionId)}`}
>
<span class="proj">{basename(s.project)}</span>
</a>
{:else}
<span class="proj">{basename(s.project)}</span>
{/if}
<span class="meta">
{#if s.gitBranch}<span class="branch mono">{s.gitBranch}</span>{/if}
<span class="host mono">{s.host}</span>
</span>
</td>
<td class="cell-when mono">{relativeTime(s.lastEventAt)}</td>
<td class="num mono">{fmtInt(s.eventCount)}</td>
<td class="num mono">{fmtInt(s.toolCallCount)}</td>
<td class="num mono">{fmtCompact(s.totalTokens)}</td>
<td class="num mono accent-amber">{fmtUsd(s.costUsd)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="empty">No sessions with activity in this window.</p>
{/if}
</section>
</div>
<style>
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.accent-amber {
color: var(--amber);
}
.page-head {
margin-bottom: 1.6rem;
}
.head-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.page-head h1 {
margin: 0;
font-family: var(--font-sans);
font-size: 1.7rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.subtitle {
margin: 0.35rem 0 0;
color: var(--text-dim);
font-size: 0.9rem;
}
/* ---- filters (project select + range picker) ---- */
.filters {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.proj-filter {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0 0.55rem 0 0.7rem;
background: var(--bg-panel-2);
border: 1px solid var(--border-soft);
border-radius: 999px;
color: var(--text-faint);
transition:
border-color 150ms ease,
color 150ms ease;
}
.proj-filter:focus-within {
border-color: var(--accent);
color: var(--accent);
}
.proj-filter select {
appearance: none;
border: none;
background: transparent;
color: var(--text);
font: inherit;
font-family: var(--font-sans);
font-size: 0.8rem;
font-weight: 500;
padding: 0.4rem 1.2rem 0.4rem 0.15rem;
cursor: pointer;
/* custom chevron */
background-image:
linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position:
right 0.5rem center,
right 0.28rem center;
background-size:
5px 5px,
5px 5px;
background-repeat: no-repeat;
color: var(--text-dim);
}
.proj-filter select:focus-visible {
outline: none;
}
.proj-filter option {
color: var(--text);
background: var(--bg-panel);
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 1.3rem 1.4rem 1.4rem;
}
/* ---- summary strip ---- */
.summary {
display: flex;
align-items: baseline;
gap: 1.6rem;
flex-wrap: wrap;
padding-bottom: 1rem;
margin-bottom: 0.4rem;
border-bottom: 1px solid var(--border-soft);
}
.summary-item {
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.summary-val {
font-size: 1.25rem;
font-weight: 700;
color: var(--text);
}
.summary-lbl {
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
}
.summary-hint {
margin-left: auto;
font-size: 0.76rem;
color: var(--text-faint);
}
/* ---- table ---- */
.table-scroll {
overflow-x: auto;
}
.sessions-table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-sans);
font-size: 0.86rem;
}
.sessions-table th {
text-align: left;
padding: 0;
border-bottom: 1px solid var(--border);
}
.sessions-table th.num button {
justify-content: flex-end;
}
.sessions-table th button {
display: flex;
align-items: center;
gap: 0.3rem;
width: 100%;
appearance: none;
border: none;
background: transparent;
color: var(--text-faint);
font: inherit;
font-family: var(--font-sans);
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.4rem 0.65rem 0.7rem;
cursor: pointer;
transition: color 120ms ease;
}
.sessions-table th button:hover {
color: var(--text-dim);
}
.sessions-table th.active button {
color: var(--accent);
}
.arrow {
font-size: 0.7rem;
line-height: 1;
}
.sessions-table td {
padding: 0.6rem 0.65rem;
border-bottom: 1px solid var(--border-soft);
vertical-align: middle;
}
.sessions-table tbody tr {
transition: background 120ms ease;
}
.sessions-table tbody tr:hover {
background: var(--bg-raised);
}
.sessions-table tbody tr:last-child td {
border-bottom: none;
}
.num {
text-align: right;
white-space: nowrap;
}
.cell-project {
min-width: 12rem;
}
.proj-link {
text-decoration: none;
display: inline-block;
max-width: 22rem;
}
.proj-link:hover .proj {
color: var(--accent);
text-decoration: underline;
}
.proj {
display: block;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 22rem;
}
.cell-project .meta {
display: inline-flex;
align-items: center;
gap: 0.45rem;
margin-top: 0.2rem;
}
.branch {
font-size: 0.72rem;
color: var(--text-dim);
background: var(--bg-panel-2);
border: 1px solid var(--border-soft);
border-radius: 999px;
padding: 0.05rem 0.5rem;
}
.host {
font-size: 0.72rem;
color: var(--text-faint);
}
.cell-when {
color: var(--text-dim);
white-space: nowrap;
}
.empty {
color: var(--text-faint);
font-size: 0.9rem;
padding: 2rem 0;
text-align: center;
}
</style>

View file

@ -0,0 +1,273 @@
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 };
};

View file

@ -0,0 +1,497 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { SvelteSet } from 'svelte/reactivity';
import type { PageData } from './$types';
import type { TranscriptTurn, TranscriptToolCall } from './+page.server';
import {
fmtInt,
fmtCompact,
fmtUsd,
fmtMs,
fmtBytes,
relativeTime,
basename,
modelLabel
} from '$lib/format';
let { data }: { data: PageData } = $props();
const meta = $derived(data.meta);
const totals = $derived(data.totals);
const turns = $derived(data.turns);
// Turns whose long text the user has expanded (keyed by turn uuid).
const expanded = new SvelteSet<string>();
function toggle(uuid: string) {
if (expanded.has(uuid)) expanded.delete(uuid);
else expanded.add(uuid);
}
const CLAMP_CHARS = 600;
function fmtTs(ts: string | null): string {
if (!ts) return '—';
const d = new Date(ts);
return Number.isNaN(d.getTime()) ? ts : d.toLocaleString();
}
/** user | assistant | system | summary | tool → the badge label. */
function turnLabel(t: TranscriptTurn): string {
return t.role ?? t.type ?? 'event';
}
/** Condense a tool call's input_json into a one-line summary for the header row. */
function toolSummary(tc: TranscriptToolCall): string {
if (!tc.inputJson) return '';
let parsed: unknown;
try {
parsed = JSON.parse(tc.inputJson);
} catch {
return tc.inputJson.length > 120 ? tc.inputJson.slice(0, 120) + '…' : tc.inputJson;
}
if (parsed && typeof parsed === 'object') {
const o = parsed as Record<string, unknown>;
for (const key of [
'command',
'file_path',
'path',
'pattern',
'query',
'url',
'prompt',
'description',
'old_string'
]) {
const v = o[key];
if (typeof v === 'string' && v.trim()) {
const one = v.replace(/\s+/g, ' ').trim();
return one.length > 120 ? one.slice(0, 120) + '…' : one;
}
}
const keys = Object.keys(o);
if (keys.length) return keys.join(', ');
}
const s = String(parsed);
return s.length > 120 ? s.slice(0, 120) + '…' : s;
}
const backHref = resolve('/sessions');
</script>
<svelte:head><title>toknmtr — session {basename(meta.project)}</title></svelte:head>
<div class="wrap">
<header class="page-head">
<a class="back" href={backHref}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" aria-hidden="true">
<path
d="M14 6l-6 6 6 6"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
All sessions
</a>
<h1>{basename(meta.project)}</h1>
<div class="meta-line">
{#if meta.gitBranch}<span class="branch mono">{meta.gitBranch}</span>{/if}
<span class="host mono">{meta.host}</span>
{#if meta.ccVersion}<span class="chip mono">cc {meta.ccVersion}</span>{/if}
{#if meta.entrypoint}<span class="chip mono">{meta.entrypoint}</span>{/if}
</div>
<p class="project-path mono" title={meta.project ?? ''}>{meta.project ?? 'unknown project'}</p>
</header>
<section class="panel summary-panel">
<div class="summary">
<div class="summary-item">
<span class="summary-val mono">{fmtInt(totals.eventCount)}</span>
<span class="summary-lbl">events</span>
</div>
<div class="summary-item">
<span class="summary-val mono">{fmtInt(totals.toolCallCount)}</span>
<span class="summary-lbl">tool calls</span>
</div>
<div class="summary-item">
<span class="summary-val mono">{fmtCompact(totals.totalTokens)}</span>
<span class="summary-lbl">tokens</span>
</div>
<div class="summary-item">
<span class="summary-val mono accent-amber">{fmtUsd(totals.costUsd)}</span>
<span class="summary-lbl">notional cost</span>
</div>
<span class="summary-hint">
{fmtTs(meta.startedAt ?? totals.firstTs)}
{relativeTime(meta.endedAt ?? totals.lastTs)}
</span>
</div>
<div class="token-breakdown mono">
<span>in {fmtCompact(totals.inputTokens)}</span>
<span>out {fmtCompact(totals.outputTokens)}</span>
<span>cache-write {fmtCompact(totals.cacheCreationTokens)}</span>
<span>cache-read {fmtCompact(totals.cacheReadTokens)}</span>
</div>
</section>
<section class="panel transcript-panel">
<div class="panel-head">
<h2>Transcript</h2>
<span class="panel-sub">{turns.length} turns · chronological (UTC timestamps shown local)</span>
</div>
{#if turns.length === 0}
<p class="empty">No events captured for this session.</p>
{:else}
<ol class="turns">
{#each turns as t (t.uuid)}
<li class="turn turn-{t.type}">
<div class="turn-meta">
<span class="role role-{turnLabel(t)}">{turnLabel(t)}</span>
{#if t.model}<span class="model mono">{modelLabel(t.model)}</span>{/if}
<span class="turn-spacer"></span>
{#if t.countsTowardUsage && t.totalTokens > 0}
<span class="turn-tokens mono">{fmtCompact(t.totalTokens)} tok</span>
<span class="turn-cost mono accent-amber">{fmtUsd(t.costUsd)}</span>
{/if}
<span class="turn-ts mono">{fmtTs(t.tsUtc)}</span>
</div>
{#if t.text && t.text.trim()}
{@const long = t.text.length > CLAMP_CHARS}
{@const open = expanded.has(t.uuid)}
<pre class="turn-text" class:clamped={long && !open}>{t.text}</pre>
{#if long}
<button type="button" class="more" onclick={() => toggle(t.uuid)}>
{open ? 'Show less' : `Show more (${fmtInt(t.text.length)} chars)`}
</button>
{/if}
{/if}
{#if t.toolCalls.length}
<ul class="tools">
{#each t.toolCalls as tc (tc.toolUseId)}
<li class="tool" class:tool-error={tc.isError}>
<span class="tool-name mono">{tc.toolName}</span>
{#if tc.isError}<span class="tool-flag">error</span>{/if}
<span class="tool-arg">{toolSummary(tc)}</span>
<span class="tool-spacer"></span>
{#if tc.durationMs !== null}<span class="tool-stat mono"
>{fmtMs(tc.durationMs)}</span
>{/if}
{#if tc.resultBytes !== null}<span class="tool-stat mono"
>{fmtBytes(tc.resultBytes)}</span
>{/if}
</li>
{/each}
</ul>
{/if}
</li>
{/each}
</ol>
{/if}
</section>
</div>
<style>
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.accent-amber {
color: var(--amber);
}
.wrap {
max-width: 960px;
}
/* ---- header ---- */
.page-head {
margin-bottom: 1.4rem;
}
.back {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--text-faint);
text-decoration: none;
margin-bottom: 0.6rem;
transition: color 120ms ease;
}
.back:hover {
color: var(--accent);
}
.page-head h1 {
margin: 0;
font-family: var(--font-sans);
font-size: 1.6rem;
font-weight: 700;
letter-spacing: -0.02em;
word-break: break-word;
}
.meta-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
margin-top: 0.5rem;
}
.branch,
.chip {
font-size: 0.72rem;
color: var(--text-dim);
background: var(--bg-panel-2);
border: 1px solid var(--border-soft);
border-radius: 999px;
padding: 0.05rem 0.5rem;
}
.host {
font-size: 0.72rem;
color: var(--text-faint);
}
.project-path {
margin: 0.55rem 0 0;
font-size: 0.74rem;
color: var(--text-faint);
word-break: break-all;
}
/* ---- panels ---- */
.panel {
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 1.3rem 1.4rem 1.5rem;
margin-bottom: 1.35rem;
}
.panel-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.panel-head h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text);
}
.panel-sub {
font-size: 0.76rem;
color: var(--text-faint);
}
/* ---- summary strip ---- */
.summary-panel {
padding-bottom: 1.1rem;
}
.summary {
display: flex;
align-items: baseline;
gap: 1.6rem;
flex-wrap: wrap;
}
.summary-item {
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.summary-val {
font-size: 1.25rem;
font-weight: 700;
color: var(--text);
}
.summary-lbl {
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
}
.summary-hint {
margin-left: auto;
font-size: 0.76rem;
color: var(--text-faint);
}
.token-breakdown {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 0.9rem;
padding-top: 0.9rem;
border-top: 1px solid var(--border-soft);
font-size: 0.76rem;
color: var(--text-dim);
}
/* ---- transcript ---- */
.turns {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.turn {
background: var(--bg-raised);
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
padding: 0.85rem 1.1rem;
border-left: 3px solid var(--border);
}
.turn-user {
border-left-color: var(--accent);
}
.turn-assistant {
border-left-color: var(--amber);
}
.turn-tool {
border-left-color: var(--purple);
}
.turn-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
font-size: 0.76rem;
color: var(--text-faint);
}
.turn-spacer {
flex: 1;
}
.role {
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
font-size: 0.68rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: var(--bg-panel-2);
color: var(--text-dim);
border: 1px solid var(--border-soft);
}
.role-user {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
background: color-mix(in srgb, var(--accent) 12%, var(--bg-panel-2));
}
.role-assistant {
color: var(--amber);
border-color: color-mix(in srgb, var(--amber) 45%, transparent);
background: color-mix(in srgb, var(--amber) 12%, var(--bg-panel-2));
}
.model {
font-size: 0.72rem;
color: var(--text-dim);
}
.turn-tokens {
font-size: 0.72rem;
color: var(--text-dim);
}
.turn-cost {
font-size: 0.72rem;
}
.turn-ts {
font-size: 0.72rem;
color: var(--text-faint);
}
.turn-text {
margin: 0.6rem 0 0;
font-family: var(--font-mono);
font-size: 0.82rem;
line-height: 1.55;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
overflow: hidden;
}
.turn-text.clamped {
display: -webkit-box;
-webkit-line-clamp: 10;
line-clamp: 10;
-webkit-box-orient: vertical;
max-height: 16rem;
}
.more {
margin-top: 0.45rem;
appearance: none;
border: none;
background: transparent;
color: var(--accent);
font: inherit;
font-size: 0.76rem;
font-weight: 600;
cursor: pointer;
padding: 0;
}
.more:hover {
text-decoration: underline;
}
/* ---- tool calls ---- */
.tools {
list-style: none;
margin: 0.7rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.tool {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.78rem;
padding: 0.35rem 0.55rem;
background: var(--bg-panel-2);
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
}
.tool-error {
border-color: color-mix(in srgb, var(--danger) 50%, transparent);
background: color-mix(in srgb, var(--danger) 10%, var(--bg-panel-2));
}
.tool-name {
font-weight: 600;
color: var(--purple);
white-space: nowrap;
}
.tool-flag {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
color: var(--danger);
}
.tool-arg {
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.tool-spacer {
flex: 1;
}
.tool-stat {
font-size: 0.72rem;
color: var(--text-faint);
white-space: nowrap;
}
.empty {
color: var(--text-faint);
font-size: 0.9rem;
padding: 2rem 0;
text-align: center;
}
</style>

3
static/robots.txt Normal file
View file

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

20
tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

16
vite.config.ts Normal file
View file

@ -0,0 +1,16 @@
import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
sveltekit({
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
adapter: adapter()
})
]
});