/** * pi-aftc-toolset — usage-report feature module. * * Reads the per-turn SQLite database (populated by usage-recording.ts) * and writes a self-contained HTML report to * /.pi-aftc-toolset/data/report.html, then opens it in * the user's browser. * * The report is a single .html file: embedded CSS, embedded JSON, * embedded JS. The only external reference is the Chart.js CDN for the * graphs; when offline the page degrades gracefully (tables always * work). The report is organised into four tabs: * * Overview — headline stat cards (total cost, prompts, calls, * cache hit, active days), a daily-spend bar chart * (last 30 days), a cost-share doughnut, and three * period summary cards (24h / 7d / 28d). * Models — per-model sortable table with a period selector * and a cost-by-model bar chart. * Thinking — per-model × thinking-level sortable table with a * period selector. * Projections — overall burn rate (avg $/day, projected month and * year from calendar days) plus per-model × thinking * $/day, $/week, $/month, $/year derived from * spend ÷ ACTIVE DAYS (not active hours — the old * hourly scaling produced absurd figures). * * Projection math: * per model×thinking: costPerDay = totalCost / activeDays, where * activeDays = distinct calendar days with at least one turn. Week = * ×7, month = ×30.44, year = ×365. Rows with fewer than 7 active * days are flagged as estimates. * overall: avgDailySpend = totalCost / calendarDays since the first * recorded turn. Flagged as an estimate below 14 calendar days. * * Per .dev/dev_guide.md section 1.5, this is a self-contained feature module: it owns * no shared state with other feature modules and is wired into pi by * the orchestrator in index.ts. It does not import core.ts or * usage-recording.ts (it only reads the DB they share). * * See `usage-report.readme.md` for the full report contents. */ import * as fs from "node:fs"; import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { getDb, isDbAvailable } from "./db"; import { getDataDir, getReportFile } from "./paths"; import { showConfirm } from "./ui/aftcUi"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type TablePeriod = "daily" | "weekly" | "monthly" | "all"; type ModelRow = { modelName: string; cost: number; turns: number; userPrompts: number; /** Self-prompted turns: total turns minus user-prompt turns. */ aiPrompts: number; /** AI (self-prompted) turns per user prompt. */ aiPerUserPrompt: number; avgCostPerUserPrompt: number; avgCacheRate: number; avgThinkingMs: number; avgResponseMs: number; }; type ModelThinkingRow = ModelRow & { thinkingLevel: string }; type PeriodSummary = { label: string; cost: number; calls: number; prompts: number; aiPrompts: number; topModel: string; topModelCost: number; topModelShare: number; // 0..1 of period cost }; type DayPoint = { day: string; label: string; cost: number; calls: number; prompts: number }; type ProjectionRow = { modelName: string; thinkingLevel: string; activeDays: number; turns: number; userPrompts: number; aiPrompts: number; cost: number; costPerDay: number; costPerWeek: number; costPerMonth: number; costPerYear: number; estimated: boolean; }; type ReportTotals = { totalCost: number; turnCount: number; userPromptCount: number; basePromptCount: number; subPromptCount: number; automatedTurnCount: number; paidTurnCount: number; paidUserPromptCount: number; totalInputTokens: number; totalOutputTokens: number; totalCacheRead: number; avgCacheRate: number; avgCostPerTurn: number; avgCostPerUserPrompt: number; turnsPerUserPrompt: number; activeDays: number; calendarDays: number; avgDailySpend: number; firstTurnMs: number; }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const DAY_MS = 24 * 60 * 60 * 1000; const SERIES_DAYS = 30; const DAYS_PER_MONTH = 30.44; const ESTIMATE_MIN_ACTIVE_DAYS = 7; const ESTIMATE_MIN_CALENDAR_DAYS = 14; const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; function num(v: unknown): number { return Number(v) || 0; } function safeDiv(a: number, b: number): number { return b > 0 ? a / b : 0; } function pad2(n: number): string { return String(n).padStart(2, "0"); } // --------------------------------------------------------------------------- // SQL fragments // --------------------------------------------------------------------------- const USER_PROMPT_SQL = `COALESCE(SUM(user_prompt), 0)`; const BASE_PROMPT_SQL = `COALESCE(SUM(base_prompt), 0)`; const SUB_PROMPT_SQL = `COALESCE(SUM(sub_prompt), 0)`; const CACHE_RATE_SQL = `AVG(CAST(cache_read AS REAL) / NULLIF(cache_read + input_tokens, 0))`; // Paid-only denominators: free / $0 (subscription) turns are recorded // for their prompt counts and timing data, but must not drag cost // averages down. const PAID_TURNS_SQL = `COALESCE(SUM(CASE WHEN cost_usd > 0 THEN 1 ELSE 0 END), 0)`; const PAID_USER_PROMPT_SQL = `COALESCE(SUM(CASE WHEN cost_usd > 0 THEN user_prompt ELSE 0 END), 0)`; // --------------------------------------------------------------------------- // UsageModule // --------------------------------------------------------------------------- class UsageModule { constructor(private pi: ExtensionAPI) {} attach(): void { this.registerCommands(); } // ------------------------------------------------------------------- // Collectors // ------------------------------------------------------------------- private windowStatsForModel(db: any, modelName: string, since: number): ModelRow { const row = db.prepare( `SELECT COUNT(*) AS turns, ${USER_PROMPT_SQL} AS user_count, ${PAID_USER_PROMPT_SQL} AS paid_user_count, COALESCE(SUM(cost_usd), 0) AS cost, ${CACHE_RATE_SQL} AS avg_cache_rate, AVG(thinking_ms) AS avg_thinking, AVG(response_ms) AS avg_response FROM turns WHERE model_name = ? AND timestamp >= ?`, ).get(modelName, since) as any; const turns = num(row.turns); const userPrompts = num(row.user_count); const paidUserPrompts = num(row.paid_user_count); const cost = num(row.cost); return { modelName, cost, turns, userPrompts, aiPrompts: Math.max(0, turns - userPrompts), aiPerUserPrompt: safeDiv(turns - userPrompts, userPrompts), avgCostPerUserPrompt: safeDiv(cost, paidUserPrompts), avgCacheRate: num(row.avg_cache_rate), avgThinkingMs: num(row.avg_thinking), avgResponseMs: num(row.avg_response), }; } /** Per-model rows for a time window. Models with no turns in the window are omitted. */ private collectWindowedModels(db: any, since: number): ModelRow[] { const models = (db.prepare( `SELECT DISTINCT model_name FROM turns WHERE model_name IS NOT NULL AND model_name != '' ORDER BY model_name`, ).all() as Array<{ model_name: string }>).map(r => r.model_name); return models .map(m => this.windowStatsForModel(db, m, since)) .filter(r => r.turns > 0); } /** Per-model × thinking-level rows for a time window. */ private collectWindowedModelThinking(db: any, since: number): ModelThinkingRow[] { const rows = db.prepare( `SELECT model_name, thinking_level, COUNT(*) AS turns, ${USER_PROMPT_SQL} AS user_count, ${PAID_USER_PROMPT_SQL} AS paid_user_count, COALESCE(SUM(cost_usd), 0) AS cost, ${CACHE_RATE_SQL} AS avg_cache_rate, AVG(thinking_ms) AS avg_thinking, AVG(response_ms) AS avg_response FROM turns WHERE model_name IS NOT NULL AND model_name != '' ${since > 0 ? "AND timestamp >= ?" : ""} GROUP BY model_name, thinking_level ORDER BY cost DESC`, ).all(...(since > 0 ? [since] : [])) as any[]; return rows.map(r => { const turns = num(r.turns); const userPrompts = num(r.user_count); const paidUserPrompts = num(r.paid_user_count); const cost = num(r.cost); return { modelName: r.model_name, thinkingLevel: r.thinking_level || "(none)", cost, turns, userPrompts, aiPrompts: Math.max(0, turns - userPrompts), aiPerUserPrompt: safeDiv(turns - userPrompts, userPrompts), avgCostPerUserPrompt: safeDiv(cost, paidUserPrompts), avgCacheRate: num(r.avg_cache_rate), avgThinkingMs: num(r.avg_thinking), avgResponseMs: num(r.avg_response), }; }); } /** Zero-filled per-day cost/calls/prompts for the last SERIES_DAYS local days. */ private collectDailySeries(db: any, now: number): DayPoint[] { const start = new Date(now); start.setHours(0, 0, 0, 0); start.setDate(start.getDate() - (SERIES_DAYS - 1)); const rows = db.prepare( `SELECT date(timestamp / 1000, 'unixepoch', 'localtime') AS day, COALESCE(SUM(cost_usd), 0) AS cost, COUNT(*) AS calls, ${USER_PROMPT_SQL} AS prompts FROM turns WHERE timestamp >= ? GROUP BY day`, ).all(start.getTime()) as any[]; const byDay = new Map(rows.map(r => [String(r.day), r])); const out: DayPoint[] = []; for (let i = SERIES_DAYS - 1; i >= 0; i--) { const d = new Date(now); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - i); const key = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; const row = byDay.get(key); out.push({ day: key, label: `${d.getDate()} ${MONTH_NAMES[d.getMonth()]}`, cost: num(row?.cost), calls: num(row?.calls), prompts: num(row?.prompts), }); } return out; } private summarizePeriod(rows: ModelRow[], label: string): PeriodSummary { const cost = rows.reduce((s, r) => s + r.cost, 0); const calls = rows.reduce((s, r) => s + r.turns, 0); const prompts = rows.reduce((s, r) => s + r.userPrompts, 0); const top = rows.slice().sort((a, b) => b.cost - a.cost)[0]; return { label, cost, calls, prompts, aiPrompts: Math.max(0, calls - prompts), topModel: top?.modelName ?? "", topModelCost: top?.cost ?? 0, topModelShare: cost > 0 && top ? top.cost / cost : 0, }; } private collectTotals(db: any, now: number): ReportTotals { const row = db.prepare( `SELECT COUNT(*) AS turns, ${USER_PROMPT_SQL} AS user_prompts, ${BASE_PROMPT_SQL} AS base_prompts, ${SUB_PROMPT_SQL} AS sub_prompts, ${PAID_TURNS_SQL} AS paid_turns, ${PAID_USER_PROMPT_SQL} AS paid_user_prompts, COALESCE(SUM(cost_usd), 0) AS total_cost, COALESCE(SUM(input_tokens), 0) AS total_input, COALESCE(SUM(output_tokens), 0) AS total_output, COALESCE(SUM(cache_read), 0) AS total_cache_read, ${CACHE_RATE_SQL} AS avg_cache_rate, COALESCE(MIN(timestamp), 0) AS first_turn FROM turns`, ).get() as any; const turns = num(row.turns); const userPrompts = num(row.user_prompts); const paidTurns = num(row.paid_turns); const paidUserPrompts = num(row.paid_user_prompts); const totalCost = num(row.total_cost); const firstTurn = num(row.first_turn); const activeDays = num(db.prepare( `SELECT COUNT(DISTINCT date(timestamp / 1000, 'unixepoch', 'localtime')) AS n FROM turns`, ).get().n); const calendarDays = firstTurn > 0 ? Math.max(1, Math.ceil((now - firstTurn) / DAY_MS)) : 0; return { totalCost, turnCount: turns, userPromptCount: userPrompts, basePromptCount: num(row.base_prompts), subPromptCount: num(row.sub_prompts), automatedTurnCount: Math.max(0, turns - userPrompts), paidTurnCount: paidTurns, paidUserPromptCount: paidUserPrompts, totalInputTokens: num(row.total_input), totalOutputTokens: num(row.total_output), totalCacheRead: num(row.total_cache_read), avgCacheRate: num(row.avg_cache_rate), avgCostPerTurn: safeDiv(totalCost, paidTurns), avgCostPerUserPrompt: safeDiv(totalCost, paidUserPrompts), turnsPerUserPrompt: safeDiv(turns, userPrompts), activeDays, calendarDays, avgDailySpend: calendarDays > 0 ? totalCost / calendarDays : 0, firstTurnMs: firstTurn, }; } /** * Cost projections. * * Per model × thinking level: costPerDay = totalCost / activeDays * (distinct local calendar days with at least one turn), scaled to * week (×7), month (×30.44) and year (×365). Rows with fewer than * ESTIMATE_MIN_ACTIVE_DAYS active days are flagged as estimates. * * Overall: avgDailySpend = totalCost / calendarDays since the first * recorded turn — the true burn rate, including idle days. Flagged * as an estimate below ESTIMATE_MIN_CALENDAR_DAYS calendar days. */ private computeProjections(db: any, totals: ReportTotals): any { const rows = db.prepare( `SELECT model_name, thinking_level, COUNT(*) AS turns, ${USER_PROMPT_SQL} AS user_count, COALESCE(SUM(cost_usd), 0) AS cost, COUNT(DISTINCT date(timestamp / 1000, 'unixepoch', 'localtime')) AS active_days FROM turns WHERE model_name IS NOT NULL AND model_name != '' GROUP BY model_name, thinking_level ORDER BY cost DESC`, ).all() as any[]; const projRows: ProjectionRow[] = rows.map(r => { const cost = num(r.cost); const turns = num(r.turns); const userPrompts = num(r.user_count); const activeDays = Math.max(1, num(r.active_days)); const perDay = safeDiv(cost, activeDays); return { modelName: r.model_name, thinkingLevel: r.thinking_level || "(none)", activeDays, turns, userPrompts, aiPrompts: Math.max(0, turns - userPrompts), cost, costPerDay: perDay, costPerWeek: perDay * 7, costPerMonth: perDay * DAYS_PER_MONTH, costPerYear: perDay * 365, estimated: activeDays < ESTIMATE_MIN_ACTIVE_DAYS, }; }); const days = Math.max(1, totals.calendarDays); const avgDaily = safeDiv(totals.totalCost, days); const noData = totals.turnCount === 0; const enough = totals.calendarDays >= ESTIMATE_MIN_CALENDAR_DAYS; return { rows: projRows, avgDailySpend: avgDaily, projectedWeek: avgDaily * 7, projectedMonth: avgDaily * DAYS_PER_MONTH, projectedYear: avgDaily * 365, calendarDays: totals.calendarDays, estimated: !noData && !enough, note: noData ? "No usage recorded yet — projections will appear after some activity." : enough ? `Overall burn rate: all-time spend ÷ ${days} calendar days since recording began.` : `Only ${days} day${days === 1 ? "" : "s"} of history so far — projections become more accurate over time.`, }; } // ------------------------------------------------------------------- // Master data collector // ------------------------------------------------------------------- private collectReportData(): any | null { const db = getDb(); if (!db) return null; const now = Date.now(); const dailySince = now - DAY_MS; const weeklySince = now - 7 * DAY_MS; const monthlySince = now - 28 * DAY_MS; const dailyModels = this.collectWindowedModels(db, dailySince); const weeklyModels = this.collectWindowedModels(db, weeklySince); const monthlyModels = this.collectWindowedModels(db, monthlySince); const allModels = this.collectWindowedModels(db, 0); const totals = this.collectTotals(db, now); return { generatedAt: now, totals, periods: { daily: this.summarizePeriod(dailyModels, "Last 24 hours"), weekly: this.summarizePeriod(weeklyModels, "Last 7 days"), monthly: this.summarizePeriod(monthlyModels, "Last 28 days"), }, dailySeries: this.collectDailySeries(db, now), modelsByPeriod: { daily: dailyModels, weekly: weeklyModels, monthly: monthlyModels, all: allModels, }, modelThinkingByPeriod: { daily: this.collectWindowedModelThinking(db, dailySince), weekly: this.collectWindowedModelThinking(db, weeklySince), monthly: this.collectWindowedModelThinking(db, monthlySince), all: this.collectWindowedModelThinking(db, 0), }, projections: this.computeProjections(db, totals), }; } // ------------------------------------------------------------------- // Clearing // ------------------------------------------------------------------- private countTurns(): number { const db = getDb(); if (!db) return 0; const row = db.prepare(`SELECT COUNT(*) AS n FROM turns`).get() as { n: number }; return row.n; } private clearTurns(): number { const db = getDb(); if (!db) return 0; const tx = db.transaction(() => { const result = db.prepare(`DELETE FROM turns`).run(); db.prepare(`DELETE FROM sqlite_sequence WHERE name = 'turns'`).run(); return result.changes; }); return tx(); } private async runClear(ctx: ExtensionCommandContext): Promise { const db = getDb(); if (!db) { ctx.ui.notify?.("Cannot clear usage data: better-sqlite3 is not available. Run /aftc-install.", "error"); return; } const count = this.countTurns(); if (count === 0) { ctx.ui.notify?.("Usage database is already empty — nothing to clear.", "info"); return; } if (ctx.hasUI) { const ok = await showConfirm(ctx, { title: "Clear usage database", body: `Permanently delete all ${count} recorded turn${count === 1 ? "" : "s"} from the SQLite database?\n\nThis cannot be undone.` }); if (!ok) return; } try { const deleted = this.clearTurns(); ctx.ui.notify?.(`Cleared usage database — deleted ${deleted} turn${deleted === 1 ? "" : "s"}.`, "info"); } catch (err) { ctx.ui.notify?.(`Failed to clear usage database: ${(err as Error).message}`, "error"); } } private registerCommands(): void { this.pi.registerCommand("usage-report", { description: "Write a self-contained model usage report (tabs, charts, projections) to the pi-aftc-toolset data folder and open it in your browser", handler: async (_a: string, ctx: ExtensionCommandContext) => this.runReport(ctx), }); this.pi.registerCommand("usage-clear", { description: "Permanently clear all recorded turns from the SQLite database (asks for confirmation)", handler: async (_a: string, ctx: ExtensionCommandContext) => this.runClear(ctx), }); } private reportDir(): string { return getDataDir(); } // ------------------------------------------------------------------- // HTML generation // // NOTE for maintainers: the client-side JS below lives inside a TS // template literal, so it must NOT use backticks or ${} — string // concatenation only. The only interpolations are ${title} and ${json}. // ------------------------------------------------------------------- private generateReportHtml(data: any): string { const json = JSON.stringify(data).replace(/<\/script/gi, "<\\/script").replace(/

Cost averages are based on paid turns only — free / $0 (subscription) models still count toward prompt, cache and timing figures.

Daily spend last 30 days
Cost share by model all time

Period summary

Generated by pi-aftc-toolset · /usage-report · All For The Code
Author Darcey.Lloyd@gmail.com
`; } // ------------------------------------------------------------------- // Writing & launching // ------------------------------------------------------------------- private writeReportHtml(html: string): string { const dir = this.reportDir(); fs.mkdirSync(dir, { recursive: true }); const filePath = getReportFile(); fs.writeFileSync(filePath, html, "utf-8"); return filePath; } private async launchBrowser(filePath: string, ctx: ExtensionCommandContext): Promise { let cmd: string; let args: string[]; if (process.platform === "win32") { cmd = "cmd"; args = ["/c", "start", "", filePath]; } else if (process.platform === "darwin") { cmd = "open"; args = [filePath]; } else { cmd = "xdg-open"; args = [filePath]; } try { await this.pi.exec(cmd, args, { timeout: 10_000 }); } catch { try { await this.pi.exec("cmd", ["/c", "start", "", filePath], { timeout: 10_000 }); } catch (err2) { ctx.ui.notify?.(`Browser launch failed (${(err2 as Error).message}). File written to ${filePath} — open it manually.`, "error"); } } } private async runReport(ctx: ExtensionCommandContext): Promise { const data = this.collectReportData(); if (!data) { ctx.ui.notify?.("Cannot generate HTML report: better-sqlite3 is not available. Run /aftc-install.", "error"); return; } let reportPath: string; try { reportPath = this.writeReportHtml(this.generateReportHtml(data)); } catch (err) { ctx.ui.notify?.(`Failed to write report.html: ${(err as Error).message}`, "error"); return; } ctx.ui.notify?.(`Wrote ${reportPath}`, "info"); if (!ctx.hasUI) { console.log(`[aftc-toolset] HTML report written: ${reportPath}`); return; } void this.launchBrowser(reportPath, ctx); } } export function createUsageModule(pi: ExtensionAPI): UsageModule { const m = new UsageModule(pi); m.attach(); return m; } export { isDbAvailable };