From 71a60ab0545cd56b27796f0ade3e182f4612f518 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Wed, 8 Jul 2026 19:33:55 +0100 Subject: [PATCH] =?UTF-8?q?toknmtr=20=E2=80=94=20self-hostable=20Claude=20?= =?UTF-8?q?Code=20usage=20&=20analytics=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .dockerignore | 15 + .env.example | 13 + .gitignore | 32 + .npmrc | 1 + .prettierignore | 9 + .prettierrc | 15 + DEPLOY.md | 184 + Dockerfile | 23 + README.md | 101 + agent/cursor.ts | 66 + agent/hooks/toknmtr-capture.sh | 74 + agent/parse.ts | 384 ++ agent/push.ts | 111 + agent/run.ts | 220 + agent/usage.ts | 235 + docker-compose.yml | 33 + eslint.config.js | 41 + ops/README.md | 137 + ops/install-cron.sh | 95 + ops/install-hook.sh | 113 + package-lock.json | 4047 +++++++++++++++++ package.json | 38 + src/app.d.ts | 13 + src/app.html | 28 + src/lib/assets/favicon.svg | 1 + .../components/CacheEfficiencyPanel.svelte | 212 + src/lib/components/CumulativeCostChart.svelte | 260 ++ src/lib/components/HourOfDayChart.svelte | 143 + src/lib/components/InfoTip.svelte | 117 + src/lib/components/ModelDonut.svelte | 240 + src/lib/components/ModelSeriesChart.svelte | 305 ++ src/lib/components/RangePicker.svelte | 174 + src/lib/components/TimeSeriesChart.svelte | 334 ++ .../dashboard/ActivityCalendar.svelte | 344 ++ src/lib/components/dashboard/ByProject.svelte | 213 + .../components/dashboard/GaugeHistory.svelte | 274 ++ .../components/dashboard/LatencyTrends.svelte | 246 + src/lib/components/dashboard/Punchcard.svelte | 158 + .../components/dashboard/ToolErrors.svelte | 240 + .../components/dashboard/TopActivity.svelte | 192 + src/lib/components/dashboard/WebUsage.svelte | 168 + src/lib/format.ts | 141 + src/lib/index.ts | 1 + src/lib/ranges.ts | 33 + src/lib/server/config.ts | 18 + src/lib/server/db.ts | 101 + src/lib/server/pricing.ts | 48 + src/lib/server/queries.ts | 704 +++ src/lib/server/range.ts | 78 + src/lib/server/stats/byProject.ts | Bin 0 -> 6354 bytes src/lib/server/stats/calendar.ts | 80 + src/lib/server/stats/gaugeHistory.ts | 92 + src/lib/server/stats/latency.ts | 126 + src/lib/server/stats/punchcard.ts | 147 + src/lib/server/stats/toolErrors.ts | 128 + src/lib/server/stats/topActivity.ts | 159 + src/lib/server/stats/webUsage.ts | 88 + src/routes/+layout.server.ts | 8 + src/routes/+layout.svelte | 407 ++ src/routes/+page.server.ts | 57 + src/routes/+page.svelte | 1191 +++++ src/routes/api/ingest/+server.ts | 414 ++ src/routes/api/search/+server.ts | 110 + src/routes/api/stats/+server.ts | 40 + src/routes/api/usage/+server.ts | 94 + src/routes/search/+page.server.ts | 86 + src/routes/search/+page.svelte | 373 ++ src/routes/sessions/+page.server.ts | 32 + src/routes/sessions/+page.svelte | 399 ++ .../[host]/[sessionId]/+page.server.ts | 273 ++ .../sessions/[host]/[sessionId]/+page.svelte | 497 ++ static/robots.txt | 3 + tsconfig.json | 20 + vite.config.ts | 16 + 74 files changed, 15613 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 DEPLOY.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 agent/cursor.ts create mode 100755 agent/hooks/toknmtr-capture.sh create mode 100644 agent/parse.ts create mode 100644 agent/push.ts create mode 100644 agent/run.ts create mode 100644 agent/usage.ts create mode 100644 docker-compose.yml create mode 100644 eslint.config.js create mode 100644 ops/README.md create mode 100755 ops/install-cron.sh create mode 100755 ops/install-hook.sh create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/app.d.ts create mode 100644 src/app.html create mode 100644 src/lib/assets/favicon.svg create mode 100644 src/lib/components/CacheEfficiencyPanel.svelte create mode 100644 src/lib/components/CumulativeCostChart.svelte create mode 100644 src/lib/components/HourOfDayChart.svelte create mode 100644 src/lib/components/InfoTip.svelte create mode 100644 src/lib/components/ModelDonut.svelte create mode 100644 src/lib/components/ModelSeriesChart.svelte create mode 100644 src/lib/components/RangePicker.svelte create mode 100644 src/lib/components/TimeSeriesChart.svelte create mode 100644 src/lib/components/dashboard/ActivityCalendar.svelte create mode 100644 src/lib/components/dashboard/ByProject.svelte create mode 100644 src/lib/components/dashboard/GaugeHistory.svelte create mode 100644 src/lib/components/dashboard/LatencyTrends.svelte create mode 100644 src/lib/components/dashboard/Punchcard.svelte create mode 100644 src/lib/components/dashboard/ToolErrors.svelte create mode 100644 src/lib/components/dashboard/TopActivity.svelte create mode 100644 src/lib/components/dashboard/WebUsage.svelte create mode 100644 src/lib/format.ts create mode 100644 src/lib/index.ts create mode 100644 src/lib/ranges.ts create mode 100644 src/lib/server/config.ts create mode 100644 src/lib/server/db.ts create mode 100644 src/lib/server/pricing.ts create mode 100644 src/lib/server/queries.ts create mode 100644 src/lib/server/range.ts create mode 100644 src/lib/server/stats/byProject.ts create mode 100644 src/lib/server/stats/calendar.ts create mode 100644 src/lib/server/stats/gaugeHistory.ts create mode 100644 src/lib/server/stats/latency.ts create mode 100644 src/lib/server/stats/punchcard.ts create mode 100644 src/lib/server/stats/toolErrors.ts create mode 100644 src/lib/server/stats/topActivity.ts create mode 100644 src/lib/server/stats/webUsage.ts create mode 100644 src/routes/+layout.server.ts create mode 100644 src/routes/+layout.svelte create mode 100644 src/routes/+page.server.ts create mode 100644 src/routes/+page.svelte create mode 100644 src/routes/api/ingest/+server.ts create mode 100644 src/routes/api/search/+server.ts create mode 100644 src/routes/api/stats/+server.ts create mode 100644 src/routes/api/usage/+server.ts create mode 100644 src/routes/search/+page.server.ts create mode 100644 src/routes/search/+page.svelte create mode 100644 src/routes/sessions/+page.server.ts create mode 100644 src/routes/sessions/+page.svelte create mode 100644 src/routes/sessions/[host]/[sessionId]/+page.server.ts create mode 100644 src/routes/sessions/[host]/[sessionId]/+page.svelte create mode 100644 static/robots.txt create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a2009f0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +.git +build +.svelte-kit +data +.env +.env.* +!.env.example +*.db +*.db-* +*.sqlite +*.sqlite-* +.vscode +.DS_Store +Thumbs.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0009ab7 --- /dev/null +++ b/.env.example @@ -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://:3001 # base URL of your deployed server +# TOKNMTR_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb832a1 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7d74fe2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..3f7802c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,15 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..893f4c1 --- /dev/null +++ b/DEPLOY.md @@ -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 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://: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://:3001 +TOKNMTR_TOKEN= +EOF +chmod 600 ~/.toknmtr/env +``` + +Replace `` 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 +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7ca9dcf --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4122745 --- /dev/null +++ b/README.md @@ -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://:3001 +TOKNMTR_TOKEN= +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). diff --git a/agent/cursor.ts b/agent/cursor.ts new file mode 100644 index 0000000..e5ab61f --- /dev/null +++ b/agent/cursor.ts @@ -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; + +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]; +} diff --git a/agent/hooks/toknmtr-capture.sh b/agent/hooks/toknmtr-capture.sh new file mode 100755 index 0000000..0738cd8 --- /dev/null +++ b/agent/hooks/toknmtr-capture.sh @@ -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 </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 diff --git a/agent/parse.ts b/agent/parse.ts new file mode 100644 index 0000000..42f8e23 --- /dev/null +++ b/agent/parse.ts @@ -0,0 +1,384 @@ +/** + * toknmtr agent — JSONL parser. + * + * Walks ~/.claude/projects//.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; + try { + d = JSON.parse(trimmed) as Record; + } catch { + return null; + } + + const type = d.type as string | undefined; + if (!type || typeof d.uuid !== 'string') return null; + + const message = (d.message ?? {}) as Record; + const usage = (message.usage ?? {}) as Record; + const serverTool = (usage.server_tool_use ?? {}) as Record; + 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 `.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(); + const pendingToolCalls = new Map(); + const usageGroups = new Map(); + + 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; + try { + d = JSON.parse(trimmed) as Record; + } 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 }; +} diff --git a/agent/push.ts b/agent/push.ts new file mode 100644 index 0000000..ae40a16 --- /dev/null +++ b/agent/push.ts @@ -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): Promise { + 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 { + 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 = { 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 { + 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; +} diff --git a/agent/run.ts b/agent/run.ts new file mode 100644 index 0000000..c6b5843 --- /dev/null +++ b/agent/run.ts @@ -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=] + * + * --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-.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 { + 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 { + 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 { + 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; +}); diff --git a/agent/usage.ts b/agent/usage.ts new file mode 100644 index 0000000..cf5b45f --- /dev/null +++ b/agent/usage.ts @@ -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 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 { + 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 { + 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 [ ... ; 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 { + 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 { + 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(); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b4a3cc5 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..ed35999 --- /dev/null +++ b/eslint.config.js @@ -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: {} + } +); diff --git a/ops/README.md b/ops/README.md new file mode 100644 index 0000000..2e00ef6 --- /dev/null +++ b/ops/README.md @@ -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://:3001 +TOKNMTR_TOKEN= +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 ` 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`. diff --git a/ops/install-cron.sh b/ops/install-cron.sh new file mode 100755 index 0000000..357ebc6 --- /dev/null +++ b/ops/install-cron.sh @@ -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')" diff --git a/ops/install-hook.sh b/ops/install-hook.sh new file mode 100755 index 0000000..20d4d46 --- /dev/null +++ b/ops/install-hook.sh @@ -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. file written next to it)" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7fe8fb0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4047 @@ +{ + "name": "toknmtr", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "toknmtr", + "version": "0.0.1", + "dependencies": { + "better-sqlite3": "^12.11.1" + }, + "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" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.3.tgz", + "integrity": "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.7.tgz", + "integrity": "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "@rollup/plugin-replace": "^6.0.3", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.68.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.68.0.tgz", + "integrity": "sha512-PdKiWsqinAoubVsSiRgVFkg3MHzGhQPnwQ8VxnGQKpZYijpapZ3UHHBje0GeByt2TvfjHPw+kxV+dNK2RIZg9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", + "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", + "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/type-utils": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.62.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", + "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", + "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.62.1", + "@typescript-eslint/types": "^8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", + "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", + "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", + "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", + "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", + "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.62.1", + "@typescript-eslint/tsconfig-utils": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", + "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-svelte": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.20.0.tgz", + "integrity": "sha512-AElKLVt7Hjy4d7ljwhrhw9hux60DCxCNkmK8cY/aAXvjs8tpR7PvU4DlyI/SA1PaJww1gh0wPGo2pbyURuEwxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.6.1", + "@jridgewell/sourcemap-codec": "^1.5.0", + "esutils": "^2.0.3", + "globals": "^16.0.0", + "known-css-properties": "^0.37.0", + "postcss": "^8.4.49", + "postcss-load-config": "^3.1.4", + "postcss-safe-parser": "^7.0.0", + "semver": "^7.6.3", + "svelte-eslint-parser": "^1.7.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-svelte/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/known-css-properties": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.93.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.93.0.tgz", + "integrity": "sha512-Cu6yUpX5Iavugm8BeX7c0wgU9CvOqfd1yM6A1d2q2ZMjym7GjpASv2GdRcTq3Fx+Sb5OgBkEEpw4VnAbY6Y5RA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-4.1.1.tgz", + "integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^5.0.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.1.tgz", + "integrity": "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.56.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.1.tgz", + "integrity": "sha512-FGUOmAqxXdN/H9Zm8slrqO7SLtFisXRB7rfOsHNJ3MLTD2po/+Stg8XyErkpumPHbuUiYTcqrEIzxpVWKTLqtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-eslint-parser": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.8.0.tgz", + "integrity": "sha512-mikR1qwIVy3t5WthUoAXkMwxkXvabZP9FJgdx35Ei7EbGWmctva1Pih16Koeor/bdNNq8NXHlwKGS6NkYTawLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.0.0", + "postcss": "^8.4.49", + "postcss-scss": "^4.0.9", + "postcss-selector-parser": "^7.0.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0", + "pnpm": "10.34.1" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte-eslint-parser/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/svelte/node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", + "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.62.1", + "@typescript-eslint/parser": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz", + "integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..978c383 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/src/app.d.ts @@ -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 {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..947ffd7 --- /dev/null +++ b/src/app.html @@ -0,0 +1,28 @@ + + + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/lib/assets/favicon.svg b/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/src/lib/components/CacheEfficiencyPanel.svelte b/src/lib/components/CacheEfficiencyPanel.svelte new file mode 100644 index 0000000..c450895 --- /dev/null +++ b/src/lib/components/CacheEfficiencyPanel.svelte @@ -0,0 +1,212 @@ + + +{#if !hasData} +

No cached usage in this window.

+{:else} +
+ {fmtPct(cache.cacheReadShare)} + of input tokens served from cache +
+ + + +
+
+ {fmtCompact(cache.cacheReadTokens)} cached +
+
+ {fmtCompact(cache.freshInputTokens)} fresh +
+
+ +
+
+ $ saved + {fmtUsd(cache.dollarsSaved)} +
+
+ Effective discount + {fmtPct(cache.effectiveDiscountPct)} +
+
+ Cache writes + {fmtCompact(cache.cacheWriteTokens)} tok +
+
+ Paid on reads + {fmtUsd(cache.dollarsSpentOnReads)} +
+
+ +

+ Caching saved {fmtUsd(cache.dollarsSaved)} vs. paying the full input rate for those + reused tokens. +

+{/if} + + diff --git a/src/lib/components/CumulativeCostChart.svelte b/src/lib/components/CumulativeCostChart.svelte new file mode 100644 index 0000000..b094d9a --- /dev/null +++ b/src/lib/components/CumulativeCostChart.svelte @@ -0,0 +1,260 @@ + + +{#if chart.empty} +

No data in this window.

+{:else} + + + + + + + + + + + + + + {#each GRIDLINE_FRACS as frac (frac)} + + {/each} + + + + {#each chart.hoverSlices as slice, i (i)} + + {slice.title} + + {/each} + {#if chart.dot} + + + {/if} + {chart.finalTotal} + + {#if chart.axisLabels} +
+ {chart.axisLabels.first} + {chart.axisLabels.mid} + {chart.axisLabels.last} +
+ {/if} +{/if} + + diff --git a/src/lib/components/HourOfDayChart.svelte b/src/lib/components/HourOfDayChart.svelte new file mode 100644 index 0000000..d264ff4 --- /dev/null +++ b/src/lib/components/HourOfDayChart.svelte @@ -0,0 +1,143 @@ + + +{#if !hasData} +

No data in this window.

+{:else} + + + + + + + + + {#each [0.25, 0.5, 0.75, 1] as frac (frac)} + + {/each} + + + + {#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} + + {hourLabel(b.hour)} UTC · {fmtInt(b.totalTokens)} tok / {fmtUsd(b.costUsd)} · {fmtInt( + b.eventCount + )} events + + {/if} + {/each} + + + {#each tickHours as hour (hour)} + {hourLabel(hour)} + {/each} + +
+ Hour of day + UTC +
+{/if} + + diff --git a/src/lib/components/InfoTip.svelte b/src/lib/components/InfoTip.svelte new file mode 100644 index 0000000..8ec12b4 --- /dev/null +++ b/src/lib/components/InfoTip.svelte @@ -0,0 +1,117 @@ + + + + + {@render children()} + + + diff --git a/src/lib/components/ModelDonut.svelte b/src/lib/components/ModelDonut.svelte new file mode 100644 index 0000000..d090db0 --- /dev/null +++ b/src/lib/components/ModelDonut.svelte @@ -0,0 +1,240 @@ + + +{#if hasData} +
+ + + {#each MODEL_GRADIENT_IDS as gid, i (gid)} + + + + + {/each} + + + + {#each slices as s (s.model)} + + {s.label} · {fmtPct(s.share)} + + {/each} + + {grandTotalLabel} + + {metric === 'cost' ? 'total cost' : 'total tokens'} + + +
+ {#each slices as s (s.model)} +
+ + {s.label} + {fmtPct(s.share)} + {fmtValue(s.value)} +
+ {/each} +
+
+{:else} +

No model usage in this window.

+{/if} + + diff --git a/src/lib/components/ModelSeriesChart.svelte b/src/lib/components/ModelSeriesChart.svelte new file mode 100644 index 0000000..7c6f454 --- /dev/null +++ b/src/lib/components/ModelSeriesChart.svelte @@ -0,0 +1,305 @@ + + +{#if isEmpty} +

No data in this window.

+{:else} + + + {#each MODEL_GRADIENT_IDS as gid, i (gid)} + + + + + {/each} + + {#each gridFracs as f (f)} + + {/each} + + + {#if type === 'bars'} + {#each barSegments as seg (seg.key)} + + {seg.title} + + {/each} + {:else} + {#each areaBands as band (band.key)} + + {/each} + {#each areaPoints as pt (pt.key)} + + {pt.title} + + {/each} + {/if} + + + {#if modelSet.length > 1} +
+ {#each modelSet as model, i (model)} +
+ + {modelLabel(model)} +
+ {/each} +
+ {/if} + +
+ {#each axisLabels as label, i (i)} + {label} + {/each} +
+{/if} + + diff --git a/src/lib/components/RangePicker.svelte b/src/lib/components/RangePicker.svelte new file mode 100644 index 0000000..39405b7 --- /dev/null +++ b/src/lib/components/RangePicker.svelte @@ -0,0 +1,174 @@ + + +
+
+ {#each RANGE_PRESETS as p (p.key)} + + {/each} + +
+ + {#if showCustom} +
+ (userFrom = e.currentTarget.value)} + aria-label="From date" + max={to} + /> + + (userTo = e.currentTarget.value)} + aria-label="To date" + min={from} + /> + +
+ {/if} +
+ + diff --git a/src/lib/components/TimeSeriesChart.svelte b/src/lib/components/TimeSeriesChart.svelte new file mode 100644 index 0000000..1cc12d4 --- /dev/null +++ b/src/lib/components/TimeSeriesChart.svelte @@ -0,0 +1,334 @@ + + +
+ {#if !hasData} +

No data in this window.

+ {:else} + + + + + + + + + + + + + + + + + + + + {#each [0.25, 0.5, 0.75, 1] as f (f)} + + {/each} + + + {#if type === 'bars'} + {#each barColumns as col, i (i)} + {#each col.segments as seg, j (j)} + + {seg.title} + + {/each} + {/each} + {:else} + {#each areaBands as band, i (i)} + + + {#each band.markers as m, j (j)} + + {m.title} + + {/each} + {/each} + {/if} + + +
+ {#each seriesDefs as s (s.key)} + {s.label} + {/each} +
+ +
+ {firstLabel} + {#if showMidLabel} + {midLabel} + {/if} + {lastLabel} +
+ {/if} +
+ + diff --git a/src/lib/components/dashboard/ActivityCalendar.svelte b/src/lib/components/dashboard/ActivityCalendar.svelte new file mode 100644 index 0000000..53d5f92 --- /dev/null +++ b/src/lib/components/dashboard/ActivityCalendar.svelte @@ -0,0 +1,344 @@ + + +
+
+

Activity calendar

+
+
+ + +
+
+
+ + {#if !hasData} +

No activity in the last year.

+ {:else} + + + {#each [1, 2, 3, 4] as lvl (lvl)} + + + + + {/each} + + + + {#each grid.monthLabels as m (m.col + m.label)} + {m.label} + {/each} + + + {#each weekdayTicks as wd (wd.row)} + {wd.label} + {/each} + + + {#each grid.cells as c (c.date)} + + {tooltip(c)} + + {/each} + + +
+ UTC · last 53 weeks + + Less + + + + + + More + +
+ {/if} +
+ + diff --git a/src/lib/components/dashboard/ByProject.svelte b/src/lib/components/dashboard/ByProject.svelte new file mode 100644 index 0000000..ff20fd1 --- /dev/null +++ b/src/lib/components/dashboard/ByProject.svelte @@ -0,0 +1,213 @@ + + +
+
+

By project

+
+
+ + +
+
+
+ + {#if hasData} +
+ {#each ranked as p (p.isOther ? '__other__' : (p.project ?? '__null__'))} + {@const share = metric === 'cost' ? p.costShare : p.tokenShare} +
+
+ {label(p)} + + {metric === 'cost' ? fmtUsd(p.costUsd) : `${fmtCompact(p.totalTokens)} tok`} + +
+
+
+
+
+
+ {fmtPct(share)} + {fmtInt(p.sessionCount)} sessions + {fmtInt(p.eventCount)} events + {metric === 'cost' ? `${fmtCompact(p.totalTokens)} tok` : fmtUsd(p.costUsd)} +
+
+ {/each} +
+ {:else} +

No project activity in this window.

+ {/if} +
+ + diff --git a/src/lib/components/dashboard/GaugeHistory.svelte b/src/lib/components/dashboard/GaugeHistory.svelte new file mode 100644 index 0000000..e418a9f --- /dev/null +++ b/src/lib/components/dashboard/GaugeHistory.svelte @@ -0,0 +1,274 @@ + + +
+
+

How close to limits over time

+ {#if data.host} + {data.host} + {/if} +
+ + {#if !hasData} +

No usage-gauge data in this window.

+ {:else} +
+ {#each lines as l (l.key)} + + + {l.label} + {fmtLimitPct(l.latest)} + + {/each} +
+ + + {#each gridFracs as frac (frac)} + + {frac * 100}% + {/each} + + + {#each lines as l (l.key)} + {#if l.dots.length > 1} + + {/if} + {#each l.dots as dot, i (i)} + + {fmtDateTimeShort(dot.ts)} · {l.label}: {dot.pct.toFixed(0)}% + + {/each} + {/each} + + +
+ {firstLabel} + UTC + {lastLabel} +
+ {/if} +
+ + diff --git a/src/lib/components/dashboard/LatencyTrends.svelte b/src/lib/components/dashboard/LatencyTrends.svelte new file mode 100644 index 0000000..a04225d --- /dev/null +++ b/src/lib/components/dashboard/LatencyTrends.svelte @@ -0,0 +1,246 @@ + + +
+
+

Speed trends

+ {bucketGran === 'hour' ? 'per hour' : 'per day'} · UTC +
+ +
+ {#if !hasData} +

No latency data in this window.

+ {:else} + + + + + + + + + {#each yTicks as t (t.f)} + + {fmtMs(t.value)} + {/each} + + + {#each lines as line (line.key)} + + {#each line.markers as m, j (j)} + + {m.title} + + {/each} + {/each} + + +
+ {#each LINES as l (l.key)} + {l.label} + {/each} +
+ +
+ {firstLabel} + {#if showMidLabel}{midLabel}{/if} + {lastLabel} +
+ {/if} +
+
+ + diff --git a/src/lib/components/dashboard/Punchcard.svelte b/src/lib/components/dashboard/Punchcard.svelte new file mode 100644 index 0000000..3876fa6 --- /dev/null +++ b/src/lib/components/dashboard/Punchcard.svelte @@ -0,0 +1,158 @@ + + +{#if !hasData} +

No data in this window.

+{:else} +
+ +
+ Less + + + + + More + UTC +
+
+{/if} + + diff --git a/src/lib/components/dashboard/ToolErrors.svelte b/src/lib/components/dashboard/ToolErrors.svelte new file mode 100644 index 0000000..7635ba6 --- /dev/null +++ b/src/lib/components/dashboard/ToolErrors.svelte @@ -0,0 +1,240 @@ + + +
+
+
+ {fmtInt(toolErrors.totalErrors)} + tool errors +
+ {fmtPct(toolErrors.overallErrorRate)} of {fmtInt(toolErrors.totalCalls)} calls +
+ + + {#if !hasErrors} +

No tool errors in this window.

+ {:else} + + + + + + + + {#each [0.25, 0.5, 0.75, 1] as frac (frac)} + + {/each} + + + {#each daily as d, i (d.day)} + {@const h = maxError > 0 ? (d.errorCount / maxError) * chartH : 0} + {#if h > 0.4} + + {fmtDateShort(d.day)} · {fmtInt(d.errorCount)} errors / {fmtInt(d.callCount)} calls + + {/if} + {/each} + + {#each tickIdx as i (i)} + {fmtDateShort(daily[i].day)} + {/each} + +
+ Errors per day + UTC +
+ {/if} + + + {#if byTool.length > 0} +
    + {#each byTool as t (t.toolName)} +
  • + {t.toolName} + + {fmtInt(t.errorCount)}/{fmtInt(t.callCount)} + {fmtPct(t.errorRate)} +
  • + {/each} +
+ {/if} +
+ + diff --git a/src/lib/components/dashboard/TopActivity.svelte b/src/lib/components/dashboard/TopActivity.svelte new file mode 100644 index 0000000..f7a7dc2 --- /dev/null +++ b/src/lib/components/dashboard/TopActivity.svelte @@ -0,0 +1,192 @@ + + +
+
+

What I do the most

+ Top Bash commands & most-touched files in this window +
+ +
+
+

Top commands

+ {#if !hasCommands} +

No commands in this window.

+ {:else} +
    + {#each commands as c, i (c.command)} +
  1. + {i + 1} + + {c.command} + {c.program} + + {fmtInt(c.count)} +
  2. + {/each} +
+ {/if} +
+ +
+

Top files

+ {#if !hasFiles} +

No file edits in this window.

+ {:else} +
    + {#each files as f, i (f.path)} +
  1. + {i + 1} + + {f.name} + {f.path} + + {fmtInt(f.count)} +
  2. + {/each} +
+ {/if} +
+
+
+ + diff --git a/src/lib/components/dashboard/WebUsage.svelte b/src/lib/components/dashboard/WebUsage.svelte new file mode 100644 index 0000000..8356f54 --- /dev/null +++ b/src/lib/components/dashboard/WebUsage.svelte @@ -0,0 +1,168 @@ + + +{#snippet stat( + label: string, + value: number, + spark: { line: string; area: string } | null, + gradId: string, + stroke: string +)} +
+
+ {fmtInt(value)} + {label} +
+ {#if spark} + + + + + + + + + + + {:else} + + {/if} +
+{/snippet} + +
+
+

Web tools

+ in window +
+
+ {@render stat('Web searches', web.totalSearches, searchSpark, 'wu-grad-search', 'var(--accent)')} + {@render stat('Web fetches', web.totalFetches, fetchSpark, 'wu-grad-fetch', 'var(--purple)')} +
+
+ + diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..6646cbc --- /dev/null +++ b/src/lib/format.ts @@ -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]; +} diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/src/lib/ranges.ts b/src/lib/ranges.ts new file mode 100644 index 0000000..cc44473 --- /dev/null +++ b/src/lib/ranges.ts @@ -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) +} diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts new file mode 100644 index 0000000..a593ad2 --- /dev/null +++ b/src/lib/server/config.ts @@ -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'; diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts new file mode 100644 index 0000000..2b45832 --- /dev/null +++ b/src/lib/server/db.ts @@ -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; +} diff --git a/src/lib/server/pricing.ts b/src/lib/server/pricing.ts new file mode 100644 index 0000000..d9b35a6 --- /dev/null +++ b/src/lib/server/pricing.ts @@ -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 = { + // 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/*, ) 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 + ); +} diff --git a/src/lib/server/queries.ts b/src/lib/server/queries.ts new file mode 100644 index 0000000..6710d03 --- /dev/null +++ b/src/lib/server/queries.ts @@ -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(); + 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(); + 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(); + 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 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 + })); +} diff --git a/src/lib/server/range.ts b/src/lib/server/range.ts new file mode 100644 index 0000000..c4b1365 --- /dev/null +++ b/src/lib/server/range.ts @@ -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> = { '24h': 1, '7d': 7, '30d': 30, '90d': 90 }; +const RANGE_LABEL: Record = { + '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 + }; +} diff --git a/src/lib/server/stats/byProject.ts b/src/lib/server/stats/byProject.ts new file mode 100644 index 0000000000000000000000000000000000000000..f90efa209c3b88cc87f312b963bb580ed3918f7f GIT binary patch literal 6354 zcmcIoZExGi5$;#%S4AcVt{fVNuNeEWuenb zIc1b45XV-mAsElG|CpXND@SQE?o*T}#uCkjG+IMz#&|%wAnaSN_}{$hY1UTjXr?ID z6jfE3PU;HhfKhCQvp=nLV@Mh0l;)vv0aoQjg~LV}@6PJjbkVU?<-wvzR7M}th{iEY zeqEG7e@wy6-{0Y+*@|?%(Aex=13pnOqzx|W3~Gv5#CTIo82K@mh? z%)N0ttEWWictCNqQ#=&OSF z|B5Dce6K1Ow%0|L6%Vi$e$mlFL1Ug2_-6*w@~Q+DC_-HSQL8dlIz()bsEY0tET!>n zx=`QCf-zgbk(#DirAk%^@XW+G(sP_Dp>Yybs=|^0r|#2>6KV!d;Xih9RHnMf*|=dh z1z5_89x0j76HUuvLA@{>CX?QAJ7@hrplY>Lbjvh0jBPGrBc^1`L1rJA_ z%|KhhhwN2)R9A7Ye}pi^BIwLLwp+RHAAf?!aa5oNkcAnQ_NGqg$DeTEW)e1T&!$rf z9zyX|pH+24OSr7{JZQ1A_Xyjbw&2r;dR_p{W`)y)@uzOb=oW{6f$neCp}9l1(PVU& zs;bM}^N_JSQ~3ocDZ zt2x-c#Nh&=>KAlK4|5Q;wV}@-)zzI&8l~M%Ra8+X+yd0vt&r5FAg)``?MMyU9Auq0 z%!18&KBDjPNV2Xq*Q&)E#H+h?_i6cl_?4*=Hklnwb=9M_Bg z5bxi`D+cLYIId7s?P*Qj9PSORdxzpr;~{ORj6r0$Yp}CavPN4iqe>vh)$-TpUmMC4 z4F0#Zk|Zfm-DWk?i~{53;U3x(&O&4!YK-kM;wUfjG)D6Poay7XpR%jjzF_@$vDBJtjcX8tr*$_?6qEU2GmpN zC`^d~ml~@?9Q32bwmE1Wt)2pTssO(`Pgu4VuO0I{qw+~P&;Q#pPu$!z3DjJl0z+NTTvR~t+?gVcOnRGXp?}i~p1dEm7p4 zC8`jT4*kxseGWteqsDWeLFgth?V>M+rRODw2vC^o3XJ=_i54UGYQ+~>$6f(nC=PTq zReiq5G8U=_+}mceQq6E-(zH+|uCg(AM%l`-C8H5;IF>|t zlM}G=M&9)4SGA9kneW5tHphm zFFhra5dTxmLa;JvWH14A%xkzuN?z;aw@=W)X^^KE#_$9jhR~ za}}C6(SV<5`Ic@iZXO%U1)$uSeMW%?2BXBCYmWBDF4tL#?U9E5<+RO z5>SAwKop#N`3?i`a@@j2w8X^#?v{CGV-8YiUON0-5yxSbOk146tWJPTaaiL(Y^7>* zD$W@*>)VRVTJvu!<$INLYu(SSTF|LnNCk;^U1eq;^BdZ~0drs;X#fBK literal 0 HcmV?d00001 diff --git a/src/lib/server/stats/calendar.ts b/src/lib/server/stats/calendar.ts new file mode 100644 index 0000000..cbbc957 --- /dev/null +++ b/src/lib/server/stats/calendar.ts @@ -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(); + 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)); +} diff --git a/src/lib/server/stats/gaugeHistory.ts b/src/lib/server/stats/gaugeHistory.ts new file mode 100644 index 0000000..2f8ac58 --- /dev/null +++ b/src/lib/server/stats/gaugeHistory.ts @@ -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 (0–100 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 + })) + }; +} diff --git a/src/lib/server/stats/latency.ts b/src/lib/server/stats/latency.ts new file mode 100644 index 0000000..fdbbf1d --- /dev/null +++ b/src/lib/server/stats/latency.ts @@ -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(); + 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 }; +} diff --git a/src/lib/server/stats/punchcard.ts b/src/lib/server/stats/punchcard.ts new file mode 100644 index 0000000..53540d1 --- /dev/null +++ b/src/lib/server/stats/punchcard.ts @@ -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' }; +} diff --git a/src/lib/server/stats/toolErrors.ts b/src/lib/server/stats/toolErrors.ts new file mode 100644 index 0000000..778aa82 --- /dev/null +++ b/src/lib/server/stats/toolErrors.ts @@ -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 + }; +} diff --git a/src/lib/server/stats/topActivity.ts b/src/lib/server/stats/topActivity.ts new file mode 100644 index 0000000..0a225f9 --- /dev/null +++ b/src/lib/server/stats/topActivity.ts @@ -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(); + 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(); + 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 }; +} diff --git a/src/lib/server/stats/webUsage.ts b/src/lib/server/stats/webUsage.ts new file mode 100644 index 0000000..f17a80a --- /dev/null +++ b/src/lib/server/stats/webUsage.ts @@ -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) + }; +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..28f93d6 --- /dev/null +++ b/src/routes/+layout.server.ts @@ -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 }); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..41fa098 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,407 @@ + + + + + + +
+
+
+ + + + toknmtr + + + + + +
+
+ {#each THEMES as t (t.key)} + + {/each} +
+
+
+
+ +
+ {@render children()} +
+
+ + diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..58e5a54 --- /dev/null +++ b/src/routes/+page.server.ts @@ -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) + }; +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..f1a9b1d --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,1191 @@ + + +{#snippet seg(items: { value: string; label: string }[], active: string, pick: (v: string) => void)} +
+ {#each items as it (it.value)} + + {/each} +
+{/snippet} + +{#snippet arcGauge(label: string, pct: number | null)} +
+ + + + + {pct === null ? '—' : `${Math.round(pct)}%`} + {label} +
+{/snippet} + +toknmtr — dashboard + +
+
+
+
+

Dashboard

+

+ {#if ov.dateRange.earliest && ov.dateRange.latest} + {range.label} · activity {fmtDateShort(ov.dateRange.earliest)} – {fmtDateShort( + ov.dateRange.latest + )} + {:else} + {range.label} · no activity in this window yet. + {/if} +

+
+ +
+
+ + +
+
+ + Notional cost · {range.label} + {fmtUsd(ov.totalCostUsd)} + API-equivalent · subscription is flat-rate + + {#if spark} + + {/if} + +
+
+ {fmtCompact(ov.totalTokens)} + tokens +
+
+ {fmtInt(ov.sessionCount)} + sessions +
+
+ {fmtCompact(ov.eventCount)} + events +
+
+
+ +
+
+

Usage over time

+
+ {@render seg( + [ + { value: 'tokens', label: 'Tokens' }, + { value: 'cost', label: 'Cost' } + ], + seriesMetric, + (v) => (seriesMetric = v as 'tokens' | 'cost') + )} + {@render seg( + [ + { value: 'bars', label: 'Bars' }, + { value: 'area', label: 'Area' } + ], + seriesType, + (v) => (seriesType = v as 'bars' | 'area') + )} +
+
+ +
+
+ + +
+
+ +
+ {fmtCompact(ov.totalTokens)} + Tokens +
+ {fmtCompact(ov.totalInputTokens)}↓ {fmtCompact(ov.totalOutputTokens)}↑ +
+
+ +
+ {fmtInt(ov.sessionCount)} + Sessions +
+ in window +
+
+ +
+ {fmtCompact(ov.eventCount)} + Events +
+ {fmtInt(ov.eventCount)} +
+
+ +
+ {fmtCompact(ov.toolCallCount)} + Tool calls +
+ {fmtInt(ov.toolCallCount)} +
+
+ + +
+
+
+

By model

+
+ {#if modelView === 'donut'} + {@render seg( + [ + { value: 'cost', label: '$' }, + { value: 'tokens', label: 'Tokens' } + ], + donutMetric, + (v) => (donutMetric = v as 'cost' | 'tokens') + )} + {/if} + {@render seg( + [ + { value: 'donut', label: 'Donut' }, + { value: 'share', label: 'Bars' } + ], + modelView, + (v) => (modelView = v as 'share' | 'donut') + )} +
+
+ {#if modelView === 'donut'} + + {:else if byModel.length} +
+ {#each byModel as m (m.model)} +
+
+ {modelLabel(m.model)} + {fmtUsd(m.costUsd)} +
+
+
+
+
+ {fmtPct(m.costShare)} of cost + {fmtCompact(m.totalTokens)} tok + {fmtInt(m.eventCount)} events +
+
+ {/each} +
+ {:else} +

No model usage in this window.

+ {/if} +
+ +
+
+

+ Cache efficiency + +

+ Prompt caching stores the stable prefix of your prompt (system prompt, tools, earlier + turns) so repeat requests skip reprocessing it. +

+

+ Cache write — the first time a chunk of context is cached (≈1.25× the input + rate). +

+

+ Cache read — that context reused on later turns (≈0.1× the input rate, + ~10× cheaper). +

+

+ Claude Code resends the whole conversation each turn, so most of the input side + becomes cheap cache reads. $ saved is those reads priced at the + read rate vs. what they'd cost at the full input rate. +

+
+

+
+ +
+ +
+
+

Subscription

+ /usage +
+ {#if gauges.length} +
+ {#each gauges as g (g.host)} +
+ {g.host} +
+ {@render arcGauge('Session', g.sessionPct)} + {@render arcGauge('Week', g.weekAllPct)} + {@render arcGauge('Sonnet', g.weekSonnetPct)} +
+
+ {/each} +
+ {:else} +

No usage gauges yet.

+ {/if} +
+
+ + +
+
+

By model over time

+
+ {@render seg( + [ + { value: 'cost', label: 'Cost' }, + { value: 'tokens', label: 'Tokens' } + ], + modelSeriesMetric, + (v) => (modelSeriesMetric = v as 'tokens' | 'cost') + )} + {@render seg( + [ + { value: 'area', label: 'Area' }, + { value: 'bars', label: 'Bars' } + ], + modelSeriesType, + (v) => (modelSeriesType = v as 'bars' | 'area') + )} +
+
+ +
+ + +
+
+
+

Activity by hour

+
+ {@render seg( + [ + { value: 'tokens', label: 'Tokens' }, + { value: 'cost', label: 'Cost' } + ], + hourMetric, + (v) => (hourMetric = v as 'tokens' | 'cost') + )} +
+
+ +
+ +
+
+

Cumulative cost

+ running total · {range.label} +
+ +
+
+ + +
+
+
+

Top tools

+ by call count +
+ {#if tools.length} + + + + + + + + + + + + {#each tools as t (t.toolName)} + + + + + + + + {/each} + +
ToolCallsErrorsAvg latencyOutput
{t.toolName}{fmtInt(t.callCount)} 0}> + {t.errorCount > 0 + ? `${fmtInt(t.errorCount)} (${fmtPct(t.errorCount / t.callCount)})` + : '—'} + {fmtMs(t.avgDurationMs)}{fmtBytes(t.totalResultBytes)}
+ {:else} +

No tool calls in this window.

+ {/if} +
+ +
+
+

Recent sessions

+ View all → +
+ {#if sessions.length} +
+ {#each sessions as s (s.host + '/' + s.sessionId)} +
+
+ {#if data.showTranscripts} + + {basename(s.project)} + + {:else} + {basename(s.project)} + {/if} + {#if s.gitBranch}{s.gitBranch}{/if} +
+
+ {s.host} + {relativeTime(s.lastEventAt)} +
+
+ {fmtInt(s.eventCount)} ev + {fmtInt(s.toolCallCount)} tools + {fmtCompact(s.totalTokens)} tok + {fmtUsd(s.costUsd)} +
+
+ {/each} +
+ {:else} +

No sessions in this window.

+ {/if} +
+
+ + +
+ +
+ + +
+
+
+

Web usage

+ searches & fetches +
+ +
+
+
+

Which tools fail, and when

+ errors over time +
+ +
+
+ + + + + + + + +
+
+

When do I work

+ events by day & hour · UTC +
+ +
+ + + + + + +
+ + diff --git a/src/routes/api/ingest/+server.ts b/src/routes/api/ingest/+server.ts new file mode 100644 index 0000000..89d204d --- /dev/null +++ b/src/routes/api/ingest/+server.ts @@ -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; + +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(); + // groups touched by this batch that have usage but no explicit flag — candidates for recompute + const needsRecompute = new Map(); + // derived session bounds (host|session_id -> min/max ts_utc), used when `sessions` wasn't supplied + const bounds = new Map(); + + 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' }); diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts new file mode 100644 index 0000000..42d0a83 --- /dev/null +++ b/src/routes/api/search/+server.ts @@ -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=&limit= + * + * 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 }); +}; diff --git a/src/routes/api/stats/+server.ts b/src/routes/api/stats/+server.ts new file mode 100644 index 0000000..5b3b899 --- /dev/null +++ b/src/routes/api/stats/+server.ts @@ -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() + }); +}; diff --git a/src/routes/api/usage/+server.ts b/src/routes/api/usage/+server.ts new file mode 100644 index 0000000..043f122 --- /dev/null +++ b/src/routes/api/usage/+server.ts @@ -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; + +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' }); diff --git a/src/routes/search/+page.server.ts b/src/routes/search/+page.server.ts new file mode 100644 index 0000000..e3b61bc --- /dev/null +++ b/src/routes/search/+page.server.ts @@ -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 }; +}; diff --git a/src/routes/search/+page.svelte b/src/routes/search/+page.svelte new file mode 100644 index 0000000..eca4ace --- /dev/null +++ b/src/routes/search/+page.svelte @@ -0,0 +1,373 @@ + + +toknmtr — search + +
+

Search

+

Full-text search across every captured prompt, response, and tool result.

+
+ +
+
+
+ + + +
+ +
+ + {#if data.query.trim() === ''} +

Enter a search term above to get started.

+ {:else if data.results.length === 0} +

No results for {data.query}.

+ {:else} +

+ {data.count} result{data.count === 1 ? '' : 's'} for {data.query} +

+
    + {#each data.results as r, i (r.host + r.sessionId + r.uuid)} +
  • +
    + {roleLabel(r.role, r.type)} + {basename(r.project)} + + {r.host} + {fmtTs(r.tsUtc)} +
    +

    + {#each snippetParts(r.snippet) as part, i (i)} + {#if part.hit}{part.text}{:else}{part.text}{/if} + {/each} +

    + {r.sessionId} +
  • + {/each} +
+ {/if} +
+ + diff --git a/src/routes/sessions/+page.server.ts b/src/routes/sessions/+page.server.ts new file mode 100644 index 0000000..3fbeda9 --- /dev/null +++ b/src/routes/sessions/+page.server.ts @@ -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 }; +}; diff --git a/src/routes/sessions/+page.svelte b/src/routes/sessions/+page.svelte new file mode 100644 index 0000000..71558ad --- /dev/null +++ b/src/routes/sessions/+page.svelte @@ -0,0 +1,399 @@ + + +toknmtr — sessions + +
+
+
+
+

Sessions

+

+ Every session with activity in the window — sort by cost, tokens, or recency. +

+
+
+ + +
+
+
+ +
+
+
+ {fmtInt(data.total)} + sessions +
+
+ {fmtUsd(totalCost)} + notional cost +
+
+ {fmtCompact(totalTokens)} + tokens +
+ {range.label} · click a column to sort +
+ + {#if sessions.length} +
+ + + + {#each columns as c (c.key)} + + {/each} + + + + {#each sessions as s (s.host + '/' + s.sessionId)} + + + + + + + + + {/each} + +
+ +
+ {#if showTranscripts} + + {basename(s.project)} + + {:else} + {basename(s.project)} + {/if} + + {#if s.gitBranch}{s.gitBranch}{/if} + {s.host} + + {relativeTime(s.lastEventAt)}{fmtInt(s.eventCount)}{fmtInt(s.toolCallCount)}{fmtCompact(s.totalTokens)}{fmtUsd(s.costUsd)}
+
+ {:else} +

No sessions with activity in this window.

+ {/if} +
+
+ + diff --git a/src/routes/sessions/[host]/[sessionId]/+page.server.ts b/src/routes/sessions/[host]/[sessionId]/+page.server.ts new file mode 100644 index 0000000..6fe7a00 --- /dev/null +++ b/src/routes/sessions/[host]/[sessionId]/+page.server.ts @@ -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(contentRows.map((c) => [c.uuid, c.text])); + + // tool calls hang off the assistant event that invoked them (event_uuid). + const toolsByEvent = new Map(); + const orphanTools: TranscriptToolCall[] = []; + const eventUuids = new Set(eventRows.map((e) => e.uuid)); + for (const t of toolRows) { + const tc: TranscriptToolCall = { + toolUseId: t.tool_use_id, + toolName: t.tool_name, + inputJson: t.input_json, + isError: !!t.is_error, + resultBytes: t.result_bytes, + durationMs: t.duration_ms, + tsUtc: t.ts_utc + }; + if (t.event_uuid && eventUuids.has(t.event_uuid)) { + const list = toolsByEvent.get(t.event_uuid) ?? []; + list.push(tc); + toolsByEvent.set(t.event_uuid, list); + } else { + orphanTools.push(tc); + } + } + + const totals: SessionTotals = { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + costUsd: 0, + eventCount: eventRows.length, + toolCallCount: toolRows.length, + firstTs: eventRows.length ? eventRows[0].ts_utc : null, + lastTs: eventRows.length ? eventRows[eventRows.length - 1].ts_utc : null + }; + + const turns: TranscriptTurn[] = eventRows.map((e) => { + const canonical = e.is_usage_canonical === 1; + const counts: TokenCounts = { + input_tokens: e.input_tokens, + output_tokens: e.output_tokens, + cache_creation_tokens: e.cache_creation_tokens, + cache_read_tokens: e.cache_read_tokens + }; + // Only canonical rows count for tokens/$ (dedup of streamed assistant lines). + const inputTokens = canonical ? (e.input_tokens ?? 0) : 0; + const outputTokens = canonical ? (e.output_tokens ?? 0) : 0; + const cacheCreationTokens = canonical ? (e.cache_creation_tokens ?? 0) : 0; + const cacheReadTokens = canonical ? (e.cache_read_tokens ?? 0) : 0; + const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; + const costUsd = canonical ? costFor(e.model, counts) : 0; + + totals.inputTokens += inputTokens; + totals.outputTokens += outputTokens; + totals.cacheCreationTokens += cacheCreationTokens; + totals.cacheReadTokens += cacheReadTokens; + totals.costUsd += costUsd; + + return { + uuid: e.uuid, + tsUtc: e.ts_utc, + type: e.type, + role: e.role, + model: e.model, + text: textByUuid.get(e.uuid) ?? null, + countsTowardUsage: canonical, + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + totalTokens, + costUsd, + toolCalls: toolsByEvent.get(e.uuid) ?? [] + }; + }); + totals.totalTokens = + totals.inputTokens + + totals.outputTokens + + totals.cacheCreationTokens + + totals.cacheReadTokens; + + // Preserve any tool calls whose owning event wasn't ingested as synthetic turns. + for (const t of orphanTools) { + turns.push({ + uuid: `tool:${t.toolUseId}`, + tsUtc: t.tsUtc, + type: 'tool', + role: null, + model: null, + text: null, + countsTowardUsage: false, + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + costUsd: 0, + toolCalls: [t] + }); + } + if (orphanTools.length) { + turns.sort((a, b) => (a.tsUtc ?? '').localeCompare(b.tsUtc ?? '')); + } + + const meta: SessionMeta = { + host, + sessionId, + project: session?.project ?? null, + gitBranch: session?.git_branch ?? null, + ccVersion: session?.cc_version ?? null, + entrypoint: session?.entrypoint ?? null, + startedAt: session?.started_at ?? null, + endedAt: session?.ended_at ?? null + }; + + return { meta, totals, turns }; +}; diff --git a/src/routes/sessions/[host]/[sessionId]/+page.svelte b/src/routes/sessions/[host]/[sessionId]/+page.svelte new file mode 100644 index 0000000..da9eb64 --- /dev/null +++ b/src/routes/sessions/[host]/[sessionId]/+page.svelte @@ -0,0 +1,497 @@ + + +toknmtr — session {basename(meta.project)} + +
+
+ + + All sessions + +

{basename(meta.project)}

+
+ {#if meta.gitBranch}{meta.gitBranch}{/if} + {meta.host} + {#if meta.ccVersion}cc {meta.ccVersion}{/if} + {#if meta.entrypoint}{meta.entrypoint}{/if} +
+

{meta.project ?? 'unknown project'}

+
+ +
+
+
+ {fmtInt(totals.eventCount)} + events +
+
+ {fmtInt(totals.toolCallCount)} + tool calls +
+
+ {fmtCompact(totals.totalTokens)} + tokens +
+
+ {fmtUsd(totals.costUsd)} + notional cost +
+ + {fmtTs(meta.startedAt ?? totals.firstTs)} + → + {relativeTime(meta.endedAt ?? totals.lastTs)} + +
+
+ in {fmtCompact(totals.inputTokens)} + out {fmtCompact(totals.outputTokens)} + cache-write {fmtCompact(totals.cacheCreationTokens)} + cache-read {fmtCompact(totals.cacheReadTokens)} +
+
+ +
+
+

Transcript

+ {turns.length} turns · chronological (UTC timestamps shown local) +
+ + {#if turns.length === 0} +

No events captured for this session.

+ {:else} +
    + {#each turns as t (t.uuid)} +
  1. +
    + {turnLabel(t)} + {#if t.model}{modelLabel(t.model)}{/if} + + {#if t.countsTowardUsage && t.totalTokens > 0} + {fmtCompact(t.totalTokens)} tok + {fmtUsd(t.costUsd)} + {/if} + {fmtTs(t.tsUtc)} +
    + + {#if t.text && t.text.trim()} + {@const long = t.text.length > CLAMP_CHARS} + {@const open = expanded.has(t.uuid)} +
    {t.text}
    + {#if long} + + {/if} + {/if} + + {#if t.toolCalls.length} +
      + {#each t.toolCalls as tc (tc.toolUseId)} +
    • + {tc.toolName} + {#if tc.isError}error{/if} + {toolSummary(tc)} + + {#if tc.durationMs !== null}{fmtMs(tc.durationMs)}{/if} + {#if tc.resultBytes !== null}{fmtBytes(tc.resultBytes)}{/if} +
    • + {/each} +
    + {/if} +
  2. + {/each} +
+ {/if} +
+
+ + diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/tsconfig.json @@ -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 +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..abed2e9 --- /dev/null +++ b/vite.config.ts @@ -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() + }) + ] +});