commit 71a60ab0545cd56b27796f0ade3e182f4612f518 Author: megaproxy Date: Wed Jul 8 19:33:55 2026 +0100 toknmtr — self-hostable Claude Code usage & analytics dashboard Server (SvelteKit + SQLite Docker container) + per-machine agent that parses Claude Code JSONL transcripts. docker-compose one-command deploy; raw transcript view + search gated behind SHOW_TRANSCRIPTS (hidden by default). Co-Authored-By: Claude Opus 4.8 (1M context) 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 0000000..f90efa2 Binary files /dev/null and b/src/lib/server/stats/byProject.ts differ 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() + }) + ] +});