Something went wrong. Try again.
I'm going to build my own OpenClaw, with blackjack... and bun!
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431#!/usr/bin/env bun/** * token-chart.ts – Generate an SVG bar chart of token usage over the last * N days, sourced from the SQLite database or session log files. */
import { readdirSync, statSync, readFileSync, mkdirSync, writeFileSync, existsSync } from "fs";import { basename, dirname, join } from "path";import Database from "bun:sqlite";
const args = process.argv.slice(2);const daysArgIndex = args.indexOf("--days");const days = daysArgIndex >= 0 ? parseInt(args[daysArgIndex + 1], 10) : 7;const targetDays = Number.isFinite(days) && days > 0 ? days : 7;
const sessionsArgIndex = args.indexOf("--sessions-dir");const sessionsCandidate = sessionsArgIndex >= 0 ? args[sessionsArgIndex + 1] : undefined;const sessionsDir = sessionsCandidate && !sessionsCandidate.startsWith("--") ? sessionsCandidate : "/workspace/.piclaw/data/sessions";
const sourceArgIndex = args.indexOf("--source");const sourceCandidate = sourceArgIndex >= 0 ? args[sourceArgIndex + 1] : undefined;const source = sourceCandidate && !sourceCandidate.startsWith("--") ? sourceCandidate : "db";const useSessions = source === "sessions" || sessionsArgIndex >= 0;
const ipcEnabled = args.includes("--ipc");const nudgeEnabled = args.includes("--nudge");const chatJidIndex = args.indexOf("--chat-jid");const chatJidCandidate = chatJidIndex >= 0 ? args[chatJidIndex + 1] : undefined;const chatJid = chatJidCandidate && !chatJidCandidate.startsWith("--") ? chatJidCandidate : process.env.PICLAW_CHAT_JID || "web:default";const dataDir = process.env.PICLAW_DATA || "/workspace/.piclaw/data";const messagesDir = join(dataDir, "ipc", "messages");const mediaDir = join(dataDir, "ipc", "media");const outputSvgArgIndex = args.indexOf("--output-svg");const outputSvgCandidate = outputSvgArgIndex >= 0 ? args[outputSvgArgIndex + 1] : undefined;const outputSvg = outputSvgCandidate && !outputSvgCandidate.startsWith("--") ? outputSvgCandidate : undefined;const storeDir = process.env.PICLAW_STORE || "/workspace/.piclaw/store";const dbPath = join(storeDir, "messages.db");
const now = new Date();const start = new Date(now);start.setHours(0, 0, 0, 0);start.setDate(start.getDate() - (targetDays - 1));
const dayKeys: string[] = [];const dayDates: Date[] = [];const totals = new Map<string, number>();const inputs = new Map<string, number>();const outputs = new Map<string, number>();const cacheReads = new Map<string, number>();const cacheWrites = new Map<string, number>();const costs = new Map<string, number>();
const pad = (n: number) => n.toString().padStart(2, "0");const formatKey = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;const formatCompact = (value: number): string => { if (!Number.isFinite(value)) return "0"; const abs = Math.abs(value); if (abs >= 1_000_000) { const scaled = value / 1_000_000; const fixed = Math.abs(scaled) >= 100 ? scaled.toFixed(0) : scaled.toFixed(1); return `${fixed.replace(/\.0$/, "")}M`; } if (abs >= 1_000) { const scaled = value / 1_000; const fixed = Math.abs(scaled) >= 100 ? scaled.toFixed(0) : scaled.toFixed(1); return `${fixed.replace(/\.0$/, "")}K`; } return value.toString();};
for (let i = 0; i < targetDays; i += 1) { const d = new Date(start); d.setDate(start.getDate() + i); const key = formatKey(d); dayKeys.push(key); dayDates.push(d); totals.set(key, 0); inputs.set(key, 0); outputs.set(key, 0); cacheReads.set(key, 0); cacheWrites.set(key, 0); costs.set(key, 0);}
const inRange = (d: Date) => d >= start && d <= now;
function addTokens( ts: Date, input: number, output: number, cacheRead: number, cacheWrite: number, totalTokens: number, costTotal: number) { if (!ts || Number.isNaN(ts.getTime()) || !inRange(ts)) return; const key = formatKey(ts); if (!totals.has(key)) return;
totals.set(key, (totals.get(key) || 0) + totalTokens); inputs.set(key, (inputs.get(key) || 0) + input); outputs.set(key, (outputs.get(key) || 0) + output); cacheReads.set(key, (cacheReads.get(key) || 0) + cacheRead); cacheWrites.set(key, (cacheWrites.get(key) || 0) + cacheWrite); costs.set(key, (costs.get(key) || 0) + costTotal);}
function addUsage(record: any) { if (!record || record.type !== "message") return; const msg = record.message; if (!msg || msg.role !== "assistant" || !msg.usage) return;
let ts: Date | null = null; if (record.timestamp) { ts = typeof record.timestamp === "string" || typeof record.timestamp === "number" ? new Date(record.timestamp) : null; } if (!ts || Number.isNaN(ts.getTime())) { if (msg.timestamp) { ts = new Date(msg.timestamp); } } if (!ts || Number.isNaN(ts.getTime())) return;
const usage = msg.usage || {}; const input = typeof usage.input === "number" ? usage.input : 0; const output = typeof usage.output === "number" ? usage.output : 0; const cacheRead = typeof usage.cacheRead === "number" ? usage.cacheRead : 0; const cacheWrite = typeof usage.cacheWrite === "number" ? usage.cacheWrite : 0; const totalTokens = (typeof usage.totalTokens === "number" && usage.totalTokens) || (typeof usage.total === "number" && usage.total) || input + output + cacheRead + cacheWrite;
const costObj = usage.cost || {}; const costTotal = (typeof costObj.total === "number" && costObj.total) || (typeof costObj.input === "number" ? costObj.input : 0) + (typeof costObj.output === "number" ? costObj.output : 0) + (typeof costObj.cacheRead === "number" ? costObj.cacheRead : 0) + (typeof costObj.cacheWrite === "number" ? costObj.cacheWrite : 0);
addTokens(ts, input, output, cacheRead, cacheWrite, totalTokens, costTotal);}
function scanFile(filePath: string) { const content = readFileSync(filePath, "utf8"); const lines = content.split(/\n/); for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; try { const record = JSON.parse(trimmed); addUsage(record); } catch { // ignore malformed line } }}
function walk(dir: string) { let entries: string[] = []; try { entries = readdirSync(dir); } catch { return; } for (const entry of entries) { const full = join(dir, entry); let stat; try { stat = statSync(full); } catch { continue; } if (stat.isDirectory()) { walk(full); } else if (stat.isFile() && entry.endsWith(".jsonl")) { scanFile(full); } }}
function loadFromDb(): boolean { if (!existsSync(dbPath)) return false; let db: Database | null = null; try { db = new Database(dbPath); db.exec("PRAGMA busy_timeout = 5000;"); const startIso = start.toISOString(); const endIso = now.toISOString(); const rows = db .query( `SELECT run_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total FROM token_usage WHERE run_at >= ? AND run_at <= ?` ) .all(startIso, endIso) as Array<Record<string, any>>;
for (const row of rows) { const ts = row.run_at ? new Date(row.run_at) : null; if (!ts || Number.isNaN(ts.getTime())) continue;
const input = typeof row.input_tokens === "number" ? row.input_tokens : 0; const output = typeof row.output_tokens === "number" ? row.output_tokens : 0; const cacheRead = typeof row.cache_read_tokens === "number" ? row.cache_read_tokens : 0; const cacheWrite = typeof row.cache_write_tokens === "number" ? row.cache_write_tokens : 0; const totalTokens = typeof row.total_tokens === "number" ? row.total_tokens : input + output + cacheRead + cacheWrite; const costTotal = (typeof row.cost_total === "number" && row.cost_total) || (typeof row.cost_input === "number" ? row.cost_input : 0) + (typeof row.cost_output === "number" ? row.cost_output : 0) + (typeof row.cost_cache_read === "number" ? row.cost_cache_read : 0) + (typeof row.cost_cache_write === "number" ? row.cost_cache_write : 0);
addTokens(ts, input, output, cacheRead, cacheWrite, totalTokens, costTotal); } return true; } catch (err) { console.warn("[token-chart] Failed to read token_usage; falling back to sessions.", err); return false; } finally { try { db?.close(); } catch { // ignore } }}
const loadedFromDb = !useSessions ? loadFromDb() : false;if (useSessions || !loadedFromDb) { walk(sessionsDir);}
const values = dayKeys.map((key) => totals.get(key) || 0);const cachedValues = dayKeys.map((key) => (cacheReads.get(key) || 0) + (cacheWrites.get(key) || 0));const uncachedValues = values.map((total, idx) => Math.max(total - cachedValues[idx], 0));const rawMaxValue = Math.max(0, ...values);const sumValue = values.reduce((a, b) => a + b, 0);const sumInput = dayKeys.reduce((acc, key) => acc + (inputs.get(key) || 0), 0);const sumOutput = dayKeys.reduce((acc, key) => acc + (outputs.get(key) || 0), 0);const sumCacheRead = dayKeys.reduce((acc, key) => acc + (cacheReads.get(key) || 0), 0);const sumCacheWrite = dayKeys.reduce((acc, key) => acc + (cacheWrites.get(key) || 0), 0);const sumCost = dayKeys.reduce((acc, key) => acc + (costs.get(key) || 0), 0);const cachedTotal = sumCacheRead + sumCacheWrite;const cachedPct = sumValue > 0 ? Math.round((cachedTotal / sumValue) * 1000) / 10 : 0;
const niceNumber = (value: number, round: boolean): number => { if (value === 0) return 0; const exponent = Math.floor(Math.log10(Math.abs(value))); const fraction = Math.abs(value) / Math.pow(10, exponent); let niceFraction = 1; if (round) { if (fraction < 1.5) niceFraction = 1; else if (fraction < 3) niceFraction = 2; else if (fraction < 7) niceFraction = 5; else niceFraction = 10; } else { if (fraction <= 1) niceFraction = 1; else if (fraction <= 2) niceFraction = 2; else if (fraction <= 5) niceFraction = 5; else niceFraction = 10; } return niceFraction * Math.pow(10, exponent);};
const buildTicks = (min: number, max: number, tickCount: number) => { const range = niceNumber(max - min || 1, false); const step = niceNumber(range / Math.max(1, tickCount - 1), true) || 1; const graphMin = Math.floor(min / step) * step; const graphMax = Math.ceil(max / step) * step; const ticks: number[] = []; for (let v = graphMin; v <= graphMax + step / 2; v += step) { ticks.push(Number(v.toFixed(6))); } return { min: graphMin, max: graphMax, ticks, step };};
const width = 680;const height = 240;const padding = { left: 48, right: 16, top: 28, bottom: 42 };const chartWidth = width - padding.left - padding.right;const chartHeight = height - padding.top - padding.bottom;const step = chartWidth / targetDays;const gap = Math.min(12, step * 0.2);const barWidth = step - gap;const { max: maxAxis, ticks: yTicks } = buildTicks(0, Math.max(1, rawMaxValue), 5);const scaleY = (value: number) => padding.top + (chartHeight - (value / maxAxis) * chartHeight);
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];const label = (key: string) => key.slice(5);const labelLong = (d: Date) => `${dayNames[d.getDay()]} ${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const bars = values.map((value, i) => { const x = padding.left + i * step + gap / 2; const cached = cachedValues[i] || 0; const uncached = uncachedValues[i] || 0; const cachedHeight = Math.round((cached / maxAxis) * chartHeight); const uncachedHeight = Math.round((uncached / maxAxis) * chartHeight); const cachedY = padding.top + (chartHeight - cachedHeight); const uncachedY = padding.top + (chartHeight - cachedHeight - uncachedHeight); const rects = [] as string[]; if (cachedHeight > 0) { rects.push(`<rect class="bar bar-cached" x="${x.toFixed(1)}" y="${cachedY.toFixed(1)}" width="${barWidth.toFixed(1)}" height="${cachedHeight.toFixed(1)}" rx="4" />`); } if (uncachedHeight > 0) { rects.push(`<rect class="bar bar-uncached" x="${x.toFixed(1)}" y="${uncachedY.toFixed(1)}" width="${barWidth.toFixed(1)}" height="${uncachedHeight.toFixed(1)}" rx="4" />`); } return rects.join("\n ");});
const labels = dayKeys.map((key, i) => { const x = padding.left + i * step + step / 2; const y = height - 16; return `<text class="label" x="${x.toFixed(1)}" y="${y}" text-anchor="middle">${label(key)}</text>`;});
const yBottom = padding.top + chartHeight;const axisX = `<line class="axis" x1="${padding.left}" y1="${yBottom}" x2="${width - padding.right}" y2="${yBottom}" />`;const axisY = `<line class="axis" x1="${padding.left}" y1="${padding.top}" x2="${padding.left}" y2="${yBottom}" />`;const yGrid = yTicks .filter((value) => value > 0) .map((value) => { const y = scaleY(value); return `<line class="grid" x1="${padding.left}" y1="${y.toFixed(1)}" x2="${width - padding.right}" y2="${y.toFixed(1)}" />`; }) .join("\n ");const yTickMarks = yTicks .map((value) => { const y = scaleY(value); return `<line class="tick" x1="${(padding.left - 4).toFixed(1)}" y1="${y.toFixed(1)}" x2="${padding.left.toFixed(1)}" y2="${y.toFixed(1)}" />`; }) .join("\n ");const xTickMarks = dayKeys.map((_, i) => { const x = padding.left + i * step + step / 2; return `<line class="tick" x1="${x.toFixed(1)}" y1="${yBottom.toFixed(1)}" x2="${x.toFixed(1)}" y2="${(yBottom + 4).toFixed(1)}" />`;});const yLabels = yTicks .map((value) => { const y = scaleY(value); return `<text class="label" x="${padding.left - 6}" y="${(y + 4).toFixed(1)}" text-anchor="end">${formatCompact(value)}</text>`; }) .join("\n ");
const title = `Tokens last ${targetDays} days • total ${formatCompact(sumValue)}`;
const svg = `<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="${title}"> <style> svg { --text: #0f1419; --grid: #e8ebed; --tick: #cbd5e1; --bar-uncached: #1d9bf0; --bar-cached: #2ecc71; --muted: #536471; } @media (prefers-color-scheme: dark) { svg { --text: #e7e9ea; --grid: #2f3336; --tick: #4b5563; --bar-uncached: #1d9bf0; --bar-cached: #27ae60; --muted: #71767b; } } .title { font: 600 14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; fill: var(--text); } .label { font: 11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; fill: var(--muted); } .axis { stroke: var(--grid); stroke-width: 1.2; } .grid { stroke: var(--grid); stroke-width: 1; stroke-dasharray: 4 4; opacity: 0.8; vector-effect: non-scaling-stroke; } .tick { stroke: var(--tick); stroke-width: 1.2; vector-effect: non-scaling-stroke; } .bar-uncached { fill: var(--bar-uncached); } .bar-cached { fill: var(--bar-cached); } </style> <text class="title" x="${padding.left}" y="18">${title}</text> <rect x="${width - padding.right - 140}" y="6" width="10" height="10" fill="var(--bar-uncached)" rx="2" /> <text class="label" x="${width - padding.right - 124}" y="15">uncached</text> <rect x="${width - padding.right - 70}" y="6" width="10" height="10" fill="var(--bar-cached)" rx="2" /> <text class="label" x="${width - padding.right - 54}" y="15">cached</text> ${bars.join("\n ")} ${yGrid} ${axisX} ${axisY} ${yTickMarks} ${xTickMarks.join("\n ")} ${yLabels} ${labels.join("\n ")}</svg>`;
if (outputSvg) { mkdirSync(dirname(outputSvg), { recursive: true }); writeFileSync(outputSvg, svg, "utf8");}
const summaryLines = [ `Token usage (all chats) — last ${targetDays} days, total ${formatCompact(sumValue)}`, `Input ${formatCompact(sumInput)} • Output ${formatCompact(sumOutput)} • Cache read ${formatCompact(sumCacheRead)} • Cache write ${formatCompact(sumCacheWrite)} (${cachedPct}% cached)`, ...dayDates.map((d, i) => { const key = dayKeys[i]; const total = values[i]; const cached = (cacheReads.get(key) || 0) + (cacheWrites.get(key) || 0); const uncached = Math.max(total - cached, 0); return `• ${labelLong(d)}: ${formatCompact(total)} tokens (cached ${formatCompact(cached)}, uncached ${formatCompact(uncached)})`; }),];
const message = summaryLines.join("\n");
if (ipcEnabled) { mkdirSync(messagesDir, { recursive: true }); mkdirSync(mediaDir, { recursive: true });
const svgPath = outputSvg || join(mediaDir, `token-chart-${Date.now()}.svg`); if (!outputSvg) { writeFileSync(svgPath, svg, "utf8"); }
const outPath = join(messagesDir, `msg_${Date.now()}_tokenchart.json`); const payload = { type: "message", chatJid, text: message, noNudge: !nudgeEnabled, media: [ { path: svgPath, content_type: "image/svg+xml", filename: basename(svgPath), inline: true, }, ], }; writeFileSync(outPath, JSON.stringify(payload, null, 2)); process.stdout.write(outPath);} else { const dataUrl = `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`; process.stdout.write([``, "", ...summaryLines].join("\n"));}