/** * data.ts — data sources for the pi-king dashboard banner, * stats line, inventory block, and landing page. * * Kept separate from pi-dashboard.ts so the rendering code stays readable and * so each source's cost and failure mode is documented next to the code that * pays it. Every source here is local-only: no network, no daemon, no auth. * * Deliberately NOT sourced here: * - Skill/prompt invocation counts. Pi's session JSONL format records no * skill-invocation entry (verified against all 101 session files: entry * types are only message / custom / model_change / session / session_info / * thinking_level_change, and skills expand inline into user message text * leaving no structured marker). Deriving counts would mean fuzzy string * matching across multi-MB transcripts — unreliable and slow. Inventory is * therefore sorted alphabetically, not by usage. * - Cost. call_logs carries no price data; a hardcoded price table would rot * silently. A fabricated authoritative-looking number is worse than none. * Cost is therefore computed here from the omniroute cost pipeline's live * price catalog (see PRICES_FILE below) — rates come from OpenRouter / * DeepSeek public pricing, fetched on-demand, never hardcoded. */ import { spawn } from "node:child_process"; import { openSync, readSync, closeSync, fstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { join, basename, dirname } from "node:path"; import { homedir } from "node:os"; const HOME = homedir(); /** Inventory reflects the configuration actually in force, so it must follow * PI_CODING_AGENT_DIR rather than assume the default location. */ const AGENT_DIR = process.env.PI_CODING_AGENT_DIR?.trim() || join(HOME, ".pi", "agent"); /** Directory of per-day call-log JSON, if one exists on this machine. * * Deliberately has no default. The metrics band reads a log format produced by * a router sitting in front of the model providers, and there is no format any * stock Pi install writes. Hardcoding one vendor's path would name a specific * third-party tool in a general-purpose package and would show nothing for * everyone who does not run it. Unset means the band is simply absent, which * matches the rule that missing data renders as nothing. See README. */ const CALL_LOGS = process.env.PI_KING_CALL_LOGS?.trim() || undefined; /** Price catalog exported by the omniroute cost pipeline * (~/codebase/omniroute-cost-pipeline/cost_pipeline.py export). All prices are * $/1M tokens in [miss, hit, out, reason] form; hit falls back to miss when a * model has no cache discount. Absent file or model = no price = $0 cost. */ const PRICES_FILE = process.env.PI_KING_PRICES?.trim() || join(HOME, ".omniroute", "cost-prices.json"); type PriceSet = { openrouter?: Record; deepseek?: Record; defaults?: Record }; let pricesCache: PriceSet | undefined; function loadPrices(): PriceSet | undefined { if (pricesCache !== undefined) return pricesCache; try { pricesCache = JSON.parse(readFileSync(PRICES_FILE, "utf8")) as PriceSet; } catch { pricesCache = undefined; } return pricesCache; } /** Trigger the omniroute cost pipeline's on-demand refresh (ingest + price * TTL + export + best-effort actuals). Fire-and-forget: the pipeline gates on * its own 6h price TTL and ingest checkpoint, so an idle run is a few ms, and * a failure must never block stats. PI_KING_PIPELINE is the full command, * e.g. "python3 /Users/stanz/codebase/omniroute-cost-pipeline/cost_pipeline.py". */ const PIPELINE = process.env.PI_KING_PIPELINE?.trim(); const PIPELINE_LOG = join(HOME, ".omniroute", "cost-pipeline.log"); function refreshPipeline(): void { if (!PIPELINE) return; const [cmd, ...rest] = PIPELINE.split(/\s+/); if (!cmd) return; try { // Detached, output to a log file (never to this process): the pipeline // prints actual-spend / price-refresh lines that matter for diagnosis. const log = openSync(PIPELINE_LOG, "a"); const child = spawn(cmd, [...rest, "refresh"], { detached: true, stdio: ["ignore", log, log] }); child.on("close", () => closeSync(log)); child.unref(); } catch { /* best-effort: prices stay at last-good */ } } /** API-equivalent cost of one call in USD. Resolution mirrors the pipeline: * openrouter id exact, deepseek by model suffix (provider must say deepseek), * else gateway defaults. 0 when no price is known (local/free routes). */ export function costOfTokens(model: string, provider: string, tk: Record): number { const prices = loadPrices(); if (!prices) return 0; let pr = prices.openrouter?.[model]; if (!pr && /deepseek/i.test(provider)) { for (const cand of ["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-chat", "deepseek-reasoner"]) { if (model.includes(cand)) { pr = prices.deepseek?.[cand]; break; } } } if (!pr) pr = prices.defaults?.[model]; if (!pr || pr.length < 4) return 0; const [miss, hit, out, reason] = pr; const tin = Number(tk.in) || 0; const tcr = Number(tk.cacheRead) || 0; const uncached = Math.max(0, tin - tcr); return (uncached * miss + tcr * hit + (Number(tk.out) || 0) * out + (Number(tk.reasoning) || 0) * reason) / 1e6; } /** Where per-day token totals are memoised. Kept beside the session status * files rather than inside the log directory, which belongs to whatever writes * the logs and should not accumulate our bookkeeping. */ const DAILY_CACHE = join(process.env.PI_KING_STATUS_DIR?.trim() || join(HOME, ".pi", "king", "session-status"), "..", "usage-cache.json"); /* ------------------------------------------------------------------ art -- */ /** * The π wordmark, pre-rendered as Braille (U+2800) art. * * Braille packs a 2x4 subpixel grid into a single character cell, giving 8x * the effective resolution of block-drawing characters — which is why this * reads as a real glyph rather than a blocky approximation. * * Generated by ~/.pi/agent/tools/pi-braille-art.py: the serif `π` (Times, whose * curved entry and flared leg read far better than a geometric sans at this * density) with a viking helmet — dome, brow band, and swept horns — drawn as * vectors over it, then mapped 2x4 pixels to Braille codepoints. The mark is * the name: pi-king. It is * committed as a constant rather than generated at runtime because the glyph * never changes, and shelling out to Python on every dashboard open would be a * pointless cost. Re-run the generator to resize. */ export const PI_ART: readonly string[] = [ "\u28a6\u2840\u2800\u2800\u2800\u28c0\u2800\u2800\u2800\u2880\u2874", "\u2808\u28b7\u28c4\u28f4\u28ff\u28ff\u28ff\u28e6\u28e0\u287e\u2801", "\u2800\u28e8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c5", "\u2800\u2809\u28b9\u28ff\u2809\u2809\u2809\u28ff\u284f\u2809", "\u2800\u2800\u28b8\u28ff\u2800\u2800\u2800\u28ff\u2847", "\u2800\u2800\u2818\u281b\u2800\u2800\u2800\u281b\u281b\u2802", ]; /* ------------------------------------------------------------ inventory -- */ export type Inventory = { skills: string[]; prompts: string[]; extensions: string[]; clis: string[]; }; /** CLIs worth reporting presence of — variable across machines and not printed * anywhere else, unlike skills/prompts which Pi lists at startup. Kept to * widely-used developer tools; override with PI_KING_CLIS (comma-separated) to * track your own. Listing someone's private tooling by default would leak the * shape of their setup into any screenshot they share. */ const CLI_CANDIDATES = process.env.PI_KING_CLIS?.trim() ? process.env.PI_KING_CLIS.split(",").map((c) => c.trim()).filter(Boolean) : ["bat", "docker", "fd", "gh", "jq", "rg", "tmux"]; function listDir(path: string, pick: (entry: string, full: string) => string | undefined): string[] { try { return readdirSync(path) .map((e) => pick(e, join(path, e))) .filter((v): v is string => Boolean(v)) .sort() .map((n) => clean(n, 80)); } catch { return []; } } function onPath(cmd: string): boolean { const dirs = (process.env.PATH ?? "").split(":").filter(Boolean); for (const d of dirs) { try { statSync(join(d, cmd)); return true; } catch { // not here; keep looking } } return false; } /** * Snapshot of what's installed. Called once when the dashboard opens — this is * static between config edits, so there is no refresh and no TTL. * Measured at ~0.1s in Python, dominated by interpreter startup; here it is a * handful of readdir/stat calls. */ /** Strips terminal control sequences from a string that came from outside this * process: a directory name, a session name, a field in someone else's status * file. Directory names may legally contain ESC on macOS and Linux, and nothing * validates the contents of a status file, so rendering these raw lets an * unrelated string reposition the cursor, repaint the screen, or forge a row * that looks like a different session. Also applied before send-keys, where a * newline would submit a line and leave the remainder as a prompt typed into a * running agent. */ export function clean(value: string, max = 200): string { // eslint-disable-next-line no-control-regex return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").slice(0, max); } export function readInventory(): Inventory { return { skills: listDir(join(AGENT_DIR, "skills"), (e, full) => { try { statSync(join(full, "SKILL.md")); return e; } catch { return undefined; } }), prompts: listDir(join(AGENT_DIR, "prompts"), (e) => (e.endsWith(".md") ? e.replace(/\.md$/, "") : undefined)), extensions: listDir(join(AGENT_DIR, "extensions"), (e) => (e.endsWith(".ts") ? e.replace(/\.ts$/, "") : undefined)), clis: CLI_CANDIDATES.filter(onPath), }; } /* ---------------------------------------------------------------- stats -- */ export type UsageStats = { /** Token totals for the day. cacheRead is broken out because it is the number * that actually predicts spend: cached input is billed at a fraction of fresh * input, so a high cache share means a large token count is cheap. */ tokensIn: number; tokensOut: number; tokensCacheRead: number; /** Busiest part of the day by call count, in local time, with its share. * Undefined when there are too few calls for the answer to mean anything. */ peakPeriod?: { label: string; pct: number }; /** Request durations in ms, sorted, for percentile reporting. The mean hides * the tail, and the tail is what a person waiting on a session notices. */ durations: number[]; calls: number; errors: number; topModels: Array<{ model: string; pct: number }>; /** Per-model figures for the stats screen, sorted by call count. p95 is per * model because a slow tail usually belongs to one model, and the aggregate * p95 hides which. cost is today's API-equivalent USD for the model. */ perModel: Array<{ model: string; calls: number; tokensIn: number; errors: number; p95: number; cost: number }>; /** API-equivalent USD for today, from cost-prices.json. */ cost: number; /** HTTP status codes >= 400 with their counts, most frequent first. The * band's error rate says how much is failing; this says what kind. */ errorsByStatus: Array<[number, number]>; /** The single longest call. p95 hides the outlier that made someone wait. */ slowest: { duration: number; model: string } | undefined; /** Tokens written into the provider cache. Read over write is the leverage * of caching: how many times each cached token was served back. */ tokensCacheWrite: number; /** Reasoning tokens. Measured NOT to be a strict subset of `out` (7 of * 9,058 sampled records exceed it), so it is reported beside output, never * derived from it. */ tokensReasoning: number; /** Calls per hour, midnight through the current hour. Real data from each * call's own timestamp — not smoothed, not synthesised. */ hourly: number[]; peakHour: number; partial: boolean; /** Activity in the trailing 60 minutes: the band's other numbers are * day-cumulative, which reads the same at a busy noon and a dead midnight. * Window is clipped to today's log directory, so just after midnight it may * miss calls from late yesterday — clipped, never padded. */ lastHour: { calls: number; tokensIn: number }; }; function today(): string { return localDateOf(new Date()); } /** A record's LOCAL calendar date, computed from its own timestamp. * * Never trust a call-log directory's name for this. The router names * directories by UTC calendar day, and a human's "day" is a LOCAL calendar * day — the two boundaries disagree by the local UTC offset for anyone not on * UTC. Verified at UTC+7: a call made 7 minutes before this fix was written * sat in the PREVIOUS UTC day's directory while being unambiguously part of * local "today" by any human definition. For that machine, every single day * has a 7-hour window (local midnight to 7am) where "today's" UTC-named * directory does not exist yet, and the band would report no activity * despite calls having already happened. Accepts a Date or a parseable * timestamp string; getFullYear/getMonth/getDate are LOCAL accessors. */ function localDateOf(input: Date | string): string { const t = typeof input === "string" ? new Date(input) : input; const p = (n: number) => String(n).padStart(2, "0"); return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`; } /** Strips vendor/route prefixes so model names fit the one-line ticker. */ function shortModel(id: string): string { return id.replace(/^omni-/, "").replace(/^(anthropic|openai|codex|claude)\//, ""); } /** * Aggregates today's call logs from the directory named by PI_KING_CALL_LOGS. * * Cost is real: ~2,100 files for a busy day, measured at ~0.5s. That is far * too slow for the dashboard's 1s render tick, so this is async and callers * must cache it (see StatsCache) rather than awaiting it on the render path. * * Returns undefined when no log directory is configured, or when today's * directory does not exist — meaning no calls were routed today. Callers must * render nothing rather than "0 calls", which would falsely imply measured * inactivity rather than absent measurement. */ /** Per-day input-token totals, oldest first, for the daily sparkline. * * A mean is not offered deliberately. Measured usage spans a 70x range across * eight days, so a single average collapses "barely touched it" and "ran it all * week" into one number that describes neither. A sparkline shows the burst * instead of hiding it, and the eye does the comparison without arithmetic. * * Caching: a closed UTC directory's logs never change, so its contribution is * computed once and kept. The two most recent UTC directories are always * rescanned, because the router can still write into either. "Day" here is * always a LOCAL calendar date, never the UTC directory name — the router * names directories by UTC calendar day, which disagrees with a human's day * by the local UTC offset, so a single directory routinely splits across two * local dates (verified: at UTC+7, every UTC directory's last 7 hours belong * to the NEXT local date). Bucketing is per record, by that record's own * timestamp, never by which directory it happens to sit in — see localDateOf. */ /** When the caller has already scanned today's directory (readUsageStats * does, for the band), it passes the result here so today is not read twice * per refresh. Both scans count identically — same files, same summary * fields — so substituting one for the other cannot change a number. */ type DayFields = Omit; /** Cached per UTC DIRECTORY, not per local day — the unit that is actually * closed-and-immutable is the directory (the router will never write into it * again once a newer one appears), and one directory's records can split * across two local dates for anyone not on UTC. Usually a small map (one * entry, or two when the directory straddles a local-day boundary). */ type DirCacheEntry = Record; const ZERO_FIELDS = (): DayFields => ({ tokensIn: 0, tokensOut: 0, tokensCacheRead: 0, tokensCacheWrite: 0, tokensReasoning: 0, calls: 0, cost: 0 }); const FIELD_NAMES = ["tokensIn", "tokensOut", "tokensCacheRead", "tokensCacheWrite", "tokensReasoning", "calls", "cost"] as const; const isComplete = (h: Record | undefined): h is DayFields => !!h && FIELD_NAMES.every((f) => typeof h[f] === "number"); function addFields(into: Map, localDay: string, e: DayFields): void { const cur = into.get(localDay) ?? ZERO_FIELDS(); cur.tokensIn += e.tokensIn; cur.tokensOut += e.tokensOut; cur.tokensCacheRead += e.tokensCacheRead; cur.tokensCacheWrite += e.tokensCacheWrite; cur.tokensReasoning += e.tokensReasoning; cur.calls += e.calls; cur.cost += e.cost; into.set(localDay, cur); } export async function readDailyTokens(todayOverride?: DayTotal): Promise { if (!CALL_LOGS) return []; let dirs: string[]; try { dirs = (await readdir(CALL_LOGS)).filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort(); } catch { return []; } // Only the last two UTC-named directories can still receive new writes — // the router starts a fresh one at UTC midnight and never revisits an old // one — so "volatile" here means exactly "not yet closed", same test as // before. It is NOT trying to guess which directories feed local-today; // per-record bucketing below makes that unnecessary. A directory is safe to // cache once it is closed, full stop, regardless of which local date(s) its // records land in. const volatile = new Set(dirs.slice(-2)); let cache: Record = {}; try { cache = JSON.parse(readFileSync(DAILY_CACHE, "utf8")) as typeof cache; // cost was added after the cache shipped: backfill 0 (unknown) so rotated-away // directories keep their token data and stay complete instead of dropping out. for (const entry of Object.values(cache)) for (const f of Object.values(entry)) if (typeof f.cost !== "number") f.cost = 0; } catch { /* first run, or unreadable: rebuild */ } const merged = new Map(); const scanned = new Set(dirs); let dirty = false; for (const dir of dirs) { const cached = cache[dir]; if (!volatile.has(dir) && cached && Object.values(cached).every(isComplete)) { for (const [localDay, fields] of Object.entries(cached)) addFields(merged, localDay, fields); continue; } const byLocal: DirCacheEntry = {}; try { const files = (await readdir(join(CALL_LOGS, dir))).filter((f) => f.endsWith(".json")); const BATCH = 64; for (let i = 0; i < files.length; i += BATCH) { const parsed = await Promise.all(files.slice(i, i + BATCH).map(async (f) => { try { return JSON.parse(await readFile(join(CALL_LOGS, dir, f), "utf8")) as { summary?: Record }; } catch { return undefined; } })); for (const r of parsed) { if (!r?.summary) continue; // The directory name is only a fallback for the rare record whose own // timestamp cannot be parsed — never the primary source of its day. const localDay = typeof r.summary.timestamp === "string" && r.summary.timestamp.length >= 10 ? localDateOf(r.summary.timestamp) : dir; const e = byLocal[localDay] ?? ZERO_FIELDS(); e.calls++; const tk = r.summary.tokens as Record | undefined; if (tk) { e.tokensIn += Number(tk.in) || 0; e.tokensOut += Number(tk.out) || 0; e.tokensCacheRead += Number(tk.cacheRead) || 0; e.tokensCacheWrite += Number(tk.cacheWrite) || 0; e.tokensReasoning += Number(tk.reasoning) || 0; e.cost += costOfTokens(String(r.summary.model ?? ""), String(r.summary.provider ?? ""), tk); } byLocal[localDay] = e; } } } catch { continue; } for (const [localDay, fields] of Object.entries(byLocal)) addFields(merged, localDay, fields); if (!volatile.has(dir)) { cache[dir] = byLocal; dirty = true; } } // Lifetime means lifetime: the router rotates old log directories away, and // deriving the series purely from surviving directories made every figure // labelled "lifetime" quietly shrink as days rotated out. A cached directory // was measured while its logs existed, so it still contributes after they // are gone — but only if every one of its local-date entries is complete, // because a gone directory can never be rescanned to fill in a missing // field. Incomplete orphans stay in the cache file (they are measurements; // deleting them destroys data) and out of the series. for (const [dirName, entry] of Object.entries(cache)) { if (scanned.has(dirName)) continue; // already merged above if (!/^\d{4}-\d{2}-\d{2}$/.test(dirName)) continue; if (!Object.values(entry).every(isComplete)) continue; for (const [localDay, fields] of Object.entries(entry)) addFields(merged, localDay, fields); } if (dirty) { try { mkdirSync(dirname(DAILY_CACHE), { recursive: true, mode: 0o700 }); writeFileSync(DAILY_CACHE, JSON.stringify(cache), { mode: 0o600 }); } catch { /* cache is an optimisation; losing it costs a rescan */ } } // readUsageStats already scanned the exact records that make up local // today (same files, same fields), so its result replaces whatever this // pass computed for that date rather than being counted twice. if (todayOverride) { const { day, ...fields } = todayOverride; merged.set(day, fields); } return [...merged.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([day, fields]) => ({ day, ...fields })); } /** Lifetime figures for the stats screen, derived from every day of logs on * disk rather than today's slice. Uses the same per-day cache, so the cost is * one rescan of today and yesterday. * * Streaks count days with at least one call. A streak that ended yesterday is * still reported as current until today produces no calls by end of day, which * is why "current" is computed from the most recent active day rather than * requiring today specifically: penalising someone at 00:05 for not having * worked yet would be wrong. */ /** 1.6B / 42.5M / 116k. The exact digit never changes a decision. */ /** Input minus cache reads: the text actually sent fresh, as opposed to the * conversation history re-sent on every turn. Clamped because a malformed log * could otherwise produce a negative count; cacheRead is a subset of `in` * whenever the data is sane. */ export function netTokens(tokensIn: number, tokensCacheRead: number): number { return Math.max(0, tokensIn - tokensCacheRead); } export function compactNum(n: number): string { if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`; if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${Math.round(n / 1_000)}k`; return String(n); } /** A human-scale comparison for a token count. * * Feed this DISTINCT tokens, not total input. Most input on a long session is * cache reads: the same conversation history re-sent on every turn. Counting * those as text read would say a paragraph re-read a thousand times is a * thousand paragraphs. On the machine this was written for, cache reads were * 81.5% of all input, so comparing against the raw total overstated by roughly * five times. * * Word counts are the published lengths of the works. The words-to-tokens * factor is an approximation for English prose, and code tokenizes denser than * prose, so the result still leans high; that is why every one carries a tilde. * It is a toy and it reads as one, but the arithmetic is real and the * denominator is the honest one. */ export function tokenComparison(tokens: number): string | undefined { if (tokens <= 0) return undefined; const WORDS_TO_TOKENS = 1.33; const WORKS: [string, number][] = [ ["Animal Farm", 29_966], ["The Hobbit", 95_356], ["Pride and Prejudice", 122_189], ["The Lord of the Rings", 481_103], ["War and Peace", 587_287], ["the Harry Potter series", 1_084_170], ]; // Prefer the work that yields a multiple a person can hold in their head: // the largest work still exceeded at least twice over. let best: [string, number] | undefined; for (const [title, words] of WORKS) { if (tokens / (words * WORDS_TO_TOKENS) >= 2) best = [title, words]; } if (!best) return undefined; const times = Math.round(tokens / (best[1] * WORDS_TO_TOKENS)); return `~${times.toLocaleString()}x the text of ${best[0]}`; } export async function readLifetimeStats(todayOverride?: DayTotal): Promise { const days = await readDailyTokens(todayOverride); const active = days.filter((d) => d.calls > 0); if (active.length === 0) return undefined; const tokensIn = active.reduce((n, d) => n + d.tokensIn, 0); const tokensOut = active.reduce((n, d) => n + d.tokensOut, 0); const tokensCacheRead = active.reduce((n, d) => n + d.tokensCacheRead, 0); const tokensCacheWrite = active.reduce((n, d) => n + d.tokensCacheWrite, 0); const tokensReasoning = active.reduce((n, d) => n + d.tokensReasoning, 0); const calls = active.reduce((n, d) => n + d.calls, 0); const cost = active.reduce((n, d) => n + d.cost, 0); const dayMs = 86_400_000; const asDate = (d: string): number => new Date(`${d}T00:00:00`).getTime(); let longest = 0; let run = 0; let prev = 0; for (const d of active) { const t = asDate(d.day); run = prev && t - prev === dayMs ? run + 1 : 1; longest = Math.max(longest, run); prev = t; } // Current streak: walk back from the last active day while days stay adjacent. let current = 0; for (let i = active.length - 1; i >= 0; i--) { if (i === active.length - 1) { current = 1; continue; } if (asDate(active[i + 1].day) - asDate(active[i].day) === dayMs) current++; else break; } const today = new Date(); const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; const last = active[active.length - 1].day; const gapDays = Math.round((asDate(todayKey) - asDate(last)) / dayMs); if (gapDays > 1) current = 0; return { tokensIn, tokensOut, tokensCacheRead, tokensCacheWrite, tokensReasoning, calls, cost, activeDays: active.length, currentStreak: current, longestStreak: longest, days }; } export async function readUsageStats(): Promise { if (!CALL_LOGS) return undefined; const targetLocalDay = today(); // The router names its directories by UTC calendar day; "today" is a LOCAL // calendar day, and the two boundaries disagree by the local UTC offset for // anyone not on UTC. Reading only the directory literally named today's // local date misses every record written before UTC midnight (a 7-hour // window at UTC+7, every single day) and, once that directory exists, // eventually the reverse for its own tail. Local today can only ever be fed // by the most recently created UTC directories, so the last two are read // and every record is kept or dropped by comparing ITS OWN timestamp's // local date against today — never by trusting either directory's name. let dirs: string[]; try { dirs = (await readdir(CALL_LOGS)).filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort(); } catch { return undefined; } const candidates = dirs.slice(-2); const files: { dir: string; name: string }[] = []; for (const d of candidates) { try { for (const name of await readdir(join(CALL_LOGS, d))) { if (name.endsWith(".json")) files.push({ dir: d, name }); } } catch { /* directory not created yet, e.g. before UTC midnight — not an error */ } } if (files.length === 0) return undefined; const byModel = new Map(); const byStatus = new Map(); const byHour = new Map(); let calls = 0; let errors = 0; let tokensIn = 0; let tokensOut = 0; let tokensCacheRead = 0; let tokensCacheWrite = 0; let tokensReasoning = 0; const durations: number[] = []; let partial = false; let lastHourCalls = 0; let lastHourTokensIn = 0; let slowest: { duration: number; model: string } | undefined; // Bounded concurrency: unbounded Promise.all over thousands of files spikes // file descriptors for no throughput gain. const BATCH = 64; for (let i = 0; i < files.length; i += BATCH) { const batch = files.slice(i, i + BATCH); const results = await Promise.all(batch.map(async (f) => { try { return JSON.parse(await readFile(join(CALL_LOGS, f.dir, f.name), "utf8")) as { summary?: Record }; } catch { return undefined; } })); for (const r of results) { const s = r?.summary; if (!s) { partial = true; continue; } // The whole point of reading two directories: keep only the records // that actually belong to local today, wherever they happen to sit. if (typeof s.timestamp !== "string" || s.timestamp.length < 10 || localDateOf(s.timestamp) !== targetLocalDay) continue; calls++; const status = typeof s.status === "number" ? s.status : 0; if (status >= 400) { errors++; byStatus.set(status, (byStatus.get(status) ?? 0) + 1); } const tk = s.tokens as Record | undefined; const callIn = tk ? Number(tk.in) || 0 : 0; if (tk) { tokensIn += callIn; tokensOut += Number(tk.out) || 0; tokensCacheRead += Number(tk.cacheRead) || 0; tokensCacheWrite += Number(tk.cacheWrite) || 0; tokensReasoning += Number(tk.reasoning) || 0; } const dur = Number(s.duration); const model = shortModel(String(s.model ?? "unknown")); if (Number.isFinite(dur) && dur > 0) { durations.push(dur); if (!slowest || dur > slowest.duration) slowest = { duration: dur, model }; } const m = byModel.get(model) ?? { calls: 0, tokensIn: 0, errors: 0, durations: [], cost: 0 }; m.calls++; m.tokensIn += callIn; if (status >= 400) m.errors++; if (Number.isFinite(dur) && dur > 0) m.durations.push(dur); m.cost += costOfTokens(String(s.model ?? ""), String(s.provider ?? ""), tk ?? {}); byModel.set(model, m); // Timestamps are ISO-8601 UTC. Slicing the hour out of the string buckets // by UTC, which put the busiest hour seven columns from where it happened // for anyone not on UTC, in a row that sits beside a local clock. Parse // and convert so the sparkline and the clock describe the same day. const ts = typeof s.timestamp === "string" ? s.timestamp : ""; if (ts.length >= 13) { const t = new Date(ts); const h = t.getHours(); if (Number.isFinite(h)) byHour.set(h, (byHour.get(h) ?? 0) + 1); if (Date.now() - t.getTime() <= 3_600_000) { lastHourCalls++; lastHourTokensIn += callIn; } } } } if (calls === 0) return undefined; const perModel = [...byModel.entries()] .sort((a, b) => b[1].calls - a[1].calls) .map(([model, m]) => { m.durations.sort((x, y) => x - y); const p95 = m.durations.length > 0 ? m.durations[Math.min(m.durations.length - 1, Math.floor(m.durations.length * 0.95))] : 0; return { model, calls: m.calls, tokensIn: m.tokensIn, errors: m.errors, p95, cost: m.cost }; }); const cost = perModel.reduce((n, m) => n + m.cost, 0); const topModels = perModel .slice(0, 3) .map((m) => ({ model: m.model, pct: Math.round((m.calls / calls) * 100) })); const errorsByStatus = [...byStatus.entries()].sort((a, b) => b[1] - a[1]); const lastHour = byHour.size > 0 ? Math.max(...byHour.keys()) : 0; const hourly: number[] = []; for (let h = 0; h <= lastHour; h++) hourly.push(byHour.get(h) ?? 0); // Four coarse buckets. Finer slicing implies a precision that call counts at // this volume do not support. const PERIODS: [string, number[]][] = [ ["morning", [5, 6, 7, 8, 9, 10, 11]], ["afternoon", [12, 13, 14, 15, 16]], ["evening", [17, 18, 19, 20, 21]], ["night", [22, 23, 0, 1, 2, 3, 4]], ]; let peakPeriod: { label: string; pct: number } | undefined; if (calls >= 20) { const scored = PERIODS.map(([label, hrs]) => ({ label, n: hrs.reduce((sum, h) => sum + (byHour.get(h) ?? 0), 0), })).sort((a, b) => b.n - a.n); if (scored[0].n > 0) peakPeriod = { label: scored[0].label, pct: Math.round((scored[0].n / calls) * 100) }; } durations.sort((a, b) => a - b); return { calls, errors, tokensIn, tokensOut, tokensCacheRead, tokensCacheWrite, tokensReasoning, cost, peakPeriod, durations, topModels, perModel, errorsByStatus, slowest, hourly, peakHour: Math.max(0, ...hourly), partial, lastHour: { calls: lastHourCalls, tokensIn: lastHourTokensIn } }; } /** * Lazily-refreshed cache with a 60s TTL. Reads never block: callers get the * previous value (or undefined on first paint) and a refresh is kicked off in * the background, so the dashboard is navigable immediately on open. */ const STATS_TTL_MS = 60_000; /** Days in the band's sparkline. A trailing week: seven bars map to the unit * people already think in, and the row has to share space with everything else * on it. */ const DAILY_WINDOW = 7; export class StatsCache { private value: UsageStats | undefined; private daily: DayTotal[] = []; private life: LifetimeStats | undefined; private loaded = false; private fetchedAt = 0; private inFlight = false; constructor(private onUpdate: () => void) {} /** Lifetime figures for the stats screen. Same refresh cycle as the band. */ lifetime(): LifetimeStats | undefined { return this.life; } /** Non-blocking. Triggers a background refresh when stale. Both figures come * from one pass so the band never mixes a fresh today with a stale history. */ get(): { stats: UsageStats | undefined; daily: DayTotal[]; loaded: boolean } { if (!this.inFlight && Date.now() - this.fetchedAt > STATS_TTL_MS) { this.inFlight = true; // Costs are fresh whenever stats are viewed: the pipeline ingests new // calls and refreshes prices if stale, all gated by its own TTLs. refreshPipeline(); // Sequential on purpose: readUsageStats scans today's directory for the // band, and its totals are handed to readLifetimeStats so the lifetime // walk skips that directory rather than scanning it a second time in // the same refresh. Both count identically — same files, same summary // fields — so the substitution cannot change a number. readUsageStats() .then(async (s) => { const override: DayTotal | undefined = s ? { day: today(), tokensIn: s.tokensIn, tokensOut: s.tokensOut, tokensCacheRead: s.tokensCacheRead, tokensCacheWrite: s.tokensCacheWrite, tokensReasoning: s.tokensReasoning, calls: s.calls, cost: s.cost } : undefined; const l = await readLifetimeStats(override); this.value = s; this.life = l; this.daily = l ? l.days.slice(-DAILY_WINDOW) : []; }) // A refresh that throws leaves the previous reading in place rather // than blanking it. "Missing data renders as nothing" governs data // that was never measured; a transient hiccup on a read that // succeeded 60s ago is a different case; discarding a real number // because the NEXT attempt to refresh it failed is not honesty, it is // a display bug that reads as one at the worst moment — disk still // settling or the log writer still coming back up right after a // reboot, exactly when a stale-but-real number is more useful than // no number. Self-heals on the next successful refresh; no error is // swallowed silently, since normal operation never reaches here. .catch(() => { /* keep this.value / this.daily / this.life as they were */ }) .finally(() => { this.loaded = true; this.fetchedAt = Date.now(); this.inFlight = false; this.onUpdate(); }); } return { stats: this.value, daily: this.daily, loaded: this.loaded }; } } /* ------------------------------------------------------ recent projects -- */ export type LifetimeStats = { tokensIn: number; tokensOut: number; /** Cache reads, so callers can subtract them. Input alone counts the same * history re-sent each turn as though it were new text. */ tokensCacheRead: number; /** Cache writes. Read over write is the leverage of caching: how many times * each token written into the cache was served back out of it. */ tokensCacheWrite: number; /** Reasoning tokens, reported beside output and never derived from it — * measured not to be a strict subset of `out`. */ tokensReasoning: number; calls: number; activeDays: number; currentStreak: number; longestStreak: number; /** API-equivalent USD across the whole series, from cost-prices.json. */ cost: number; days: DayTotal[]; }; export type DayTotal = { day: string; tokensIn: number; tokensOut: number; tokensCacheRead: number; // Added after the cache first shipped. The cache requires every field to be // present before it trusts an entry, so entries written before these existed // are recomputed on the next scan rather than read back as zero. tokensCacheWrite: number; tokensReasoning: number; calls: number; // API-equivalent USD from cost-prices.json (see costOfTokens). Same cache // discipline as the fields above; old entries are backfilled with 0. cost: number; }; export type RecentProject = { project: string; path: string; lastActive: number }; /** The last assistant text from a session transcript, read from the tail * without slurping the file (transcripts reach multi-MB). This is what Claude * Code's agent list shows per agent, and it is the best answer to "what did * this session last say" that exists on disk — it works for sessions running * older extension code and for exited cards, because the transcript is the * source, not the tracker. Assistant entries whose content is only thinking * blocks are skipped; the reply is the thing said out loud. */ export function readLastReply(sessionFile: string, tailBytes = 262_144): string | undefined { let fd: number | undefined; try { fd = openSync(sessionFile, "r"); const size = fstatSync(fd).size; const start = Math.max(0, size - tailBytes); const buf = Buffer.alloc(Math.min(tailBytes, size)); readSync(fd, buf, 0, buf.length, start); const lines = buf.toString("utf8").split("\n"); // First line may be a partial JSON record when the window starts mid-line; // JSON.parse simply rejects it, which is the correct outcome. for (let i = lines.length - 1; i >= 0; i--) { let entry: { type?: string; message?: { role?: string; content?: unknown } }; try { entry = JSON.parse(lines[i]); } catch { continue; } if (entry.type !== "message" || entry.message?.role !== "assistant") continue; const content = Array.isArray(entry.message.content) ? entry.message.content : []; for (const c of content) { const block = c as { type?: string; text?: string }; if (block?.type === "text" && typeof block.text === "string") { // Markdown emphasis markers are noise at one line of display width; // stripping them is display formatting, not content editing. const text = clean(block.text.replace(/[*`_#]/g, "").replace(/\s+/g, " ").trim()); if (text) return text.length > 200 ? `${text.slice(0, 199)}\u2026` : text; } } } return undefined; } catch { return undefined; } finally { if (fd !== undefined) try { closeSync(fd); } catch { /* already closed */ } } } /** Reads just the first line of a file without slurping it. Session * transcripts reach multi-MB; only the opening `session` entry is needed. */ function firstLine(path: string, max = 512): string { let fd: number | undefined; try { fd = openSync(path, "r"); const buf = Buffer.alloc(max); const n = readSync(fd, buf, 0, max, 0); const text = buf.subarray(0, n).toString("utf8"); const nl = text.indexOf("\n"); return nl === -1 ? text : text.slice(0, nl); } catch { return ""; } finally { if (fd !== undefined) { try { closeSync(fd); } catch { /* already gone */ } } } } /** * Recent project directories, newest first, for the landing page when no * sessions are running — so an empty dashboard offers a way back into work * instead of just saying "nothing here". * * The real cwd is read from the opening `session` entry of the newest * transcript, NOT decoded from the session directory name. That encoding * (`--Users-you-codebase-example-project--`) is genuinely ambiguous: a literal * hyphen in a directory name is indistinguishable from a path separator, so * naive decoding turns `codebase/example-project` into `codebase/example/project`. * Confirmed against a real session before choosing this approach. * * Cost stays low: one readdir per project dir plus a 512-byte read of one * file. Transcripts are never parsed. */ export function readRecentProjects(limit: number): RecentProject[] { const root = join(AGENT_DIR, "sessions"); let entries: string[]; try { entries = readdirSync(root); } catch { return []; } const dirs: Array<{ dir: string; lastActive: number }> = []; for (const e of entries) { try { const st = statSync(join(root, e)); if (st.isDirectory()) dirs.push({ dir: e, lastActive: st.mtimeMs }); } catch { // vanished between readdir and stat; skip } } dirs.sort((a, b) => b.lastActive - a.lastActive); const out: RecentProject[] = []; for (const { dir, lastActive } of dirs) { if (out.length >= limit) break; const full = join(root, dir); let newest: { file: string; mtime: number } | undefined; try { for (const f of readdirSync(full)) { if (!f.endsWith(".jsonl")) continue; const mt = statSync(join(full, f)).mtimeMs; if (!newest || mt > newest.mtime) newest = { file: f, mtime: mt }; } } catch { continue; } if (!newest) continue; let cwd = ""; try { const parsed = JSON.parse(firstLine(join(full, newest.file))) as { cwd?: unknown }; if (typeof parsed.cwd === "string") cwd = parsed.cwd; } catch { // Unparseable opening entry — skip rather than show a guessed path. continue; } if (!cwd) continue; out.push({ project: clean(basename(cwd) || cwd, 60), path: clean(cwd, 200), lastActive }); } return out; } /** Unicode block sparkline. Returns "" for empty input rather than a fake flat * line, so "no data" never renders as "measured zero". * * Exactly zero renders as a gap, not as the shortest bar. The block set starts * at the one-eighth block, so without this a day with no activity and a day * with a trace of it drew the same mark. That is harmless for volume and wrong * beside the streak counters, where the day that renders as a small bar is * precisely the day that ended a streak. A gap says nothing happened; the * shortest bar says something did. */ export function sparkline(series: number[]): string { if (series.length === 0) return ""; const blocks = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588"; const max = Math.max(...series); if (max <= 0) return ""; return series.map((v) => (v <= 0 ? " " : blocks[Math.min(7, Math.floor((v / max) * 7))])).join(""); } /* ---------------------------------------------------------------- quote -- */ /** * Quote of the day. * * Deliberately NOT fetched from a network API: this renders on dashboard open, * and a remote call there would add latency, an offline failure mode, and a * dependency — for a decorative line. A local list rotating on the date gives * the identical experience with none of that, and works on a plane. * * Users can override with their own one-per-line file (blank lines and * `#` comments ignored) at $PI_CODING_AGENT_DIR/pi-king-quotes.txt. */ const QUOTES: readonly string[] = [ "Not all who wander are lost. Some are just detached.", "Uneasy lies the head that wears the helmet.", "A king is only as good as his subjects \u2014 and yours are all idle.", "\u03c0 is irrational. So is running nine sessions at once.", "Conquer. Pillage. Detach.", "The horns are decorative. The persistence is not.", "A watched session never compacts.", "He who controls the tmux controls the universe.", "Ragnar\u00f6k is just a very large refactor.", "In Valhalla, all tests pass.", "Long live the king \u2014 and the session you left running since Tuesday.", "Some sessions are born great. Some achieve greatness. Some just never got killed.", "Sharpen the axe, then run the tests.", "A fleet is just loneliness, in parallel.", "3.14159 reasons to background that session.", "Your subjects are working. You are watching. Both are billable.", "Idle hands are the compiler's workshop.", "Two roads diverged in a wood, and I took the one with tmux.", "Rule kindly. Or at least remember they exist.", "The raiding party returns when it returns.", "Every kingdom falls. Yours falls to an unhandled promise rejection.", "\u03c0-rate: the speed at which you plunder your token budget.", "Detachment is not indifference.", "Winter is coming. So is the context limit.", "A crown is just a helmet that stopped doing its job.", // \u2014 norse "Odin gave an eye for wisdom. You gave a context window.", "The longship sails whether or not you watch it.", "Berserkers never asked permission either.", "Sk\u00e5l to the sessions that survived the night.", "A saga is just a very long changelog.", "Raid. Loot. Detach. Repeat.", "Valhalla has no merge conflicts.", "Runes were also just poorly documented syntax.", "Longships were the original horizontal scaling.", "Thor had a hammer. You have a rollback.", "The raven brings news. Usually a stack trace.", // \u2014 crown "Heavy is the head that runs nine agents.", "The king is dead. Long live the daemon.", "Et tu, linter?", "A horse, a horse, my kingdom for a green test suite.", "All the terminal's a stage, and all the sessions merely players.", "Something is rotten in the state of node_modules.", "To compact, or not to compact.", "Crowns are heavy. Delegate.", "A kingdom divided cannot ship.", "Kings do not read logs. Kings are told about logs.", // \u2014 \u03c0 "\u03c0 goes on forever. So does this refactor.", "Irrational, transcendental, and still more predictable than an LLM.", "You can approximate \u03c0. You cannot approximate a passing test.", "Circumference of scope creep: unbounded.", "Some constants are universal. Your build time is not one of them.", // \u2014 terminal "Detached, not deserted.", "It works on my session.", "The session persists. The motivation does not.", "There is no cloud, only someone else's tmux.", "Ctrl+C is not a strategy.", "Your terminal has more tabs than your browser. Seek counsel.", "The best time to background a session was an hour ago.", "git blame, then git blame yourself.", // \u2014 agents "The agent is thinking. Allegedly.", "Six agents, one bottleneck: you.", "Autonomy is delegation with better marketing.", "The subagent finished. Nobody was watching.", "It is not hallucinating, it is improvising.", "Trust, but verify. Mostly verify.", "Parallel agents, serial regrets.", "Somewhere, a session you forgot is still working.", "Persistence is the only feature that matters at 3am.", "You will come back. They always do.", ]; /** Day-of-year rotation: stable for a whole day, no randomness, no state. */ export function quoteOfTheDay(): string { let pool: string[] = [...QUOTES]; try { const custom = readFileSync(join(AGENT_DIR, "pi-king-quotes.txt"), "utf8") .split("\n") .map((l) => l.trim()) .filter((l) => l && !l.startsWith("#")); if (custom.length > 0) pool = custom; } catch { // No override file — the built-in list is the default, not a fallback. } const now = new Date(); const start = new Date(now.getFullYear(), 0, 0); const day = Math.floor((now.getTime() - start.getTime()) / 86_400_000); return pool[day % pool.length] ?? pool[0]; }