Skip to content

Commit 29c7831

Browse files
committed
feat(token-chart): split autoresearch into separate default stack
1 parent 2c3a614 commit 29c7831

File tree

3 files changed

+191
-35
lines changed

3 files changed

+191
-35
lines changed

runtime/skills/token-chart/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,6 @@ Generate a 7-day token usage chart (all chats) and post it to the web UI timelin
3737
- Styling is handled by the web UI CSS (token-chart image selector).
3838
- Numbers are formatted using K/M in labels and summaries.
3939
- Uses the `token_usage` table by default; pass `--source sessions` (or `--sessions-dir`) to read session JSONL files.
40+
- The default chart combines normal usage and `source = "autoresearch"` usage into a single per-day stacked bar, with cached segments below uncached ones.
4041
- `--mode provider-model` draws an alternative chart grouping tokens by provider/model.
4142
- Use this on demand (not scheduled yet).

runtime/skills/token-chart/token-chart.ts

Lines changed: 113 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ const outputs = new Map<string, number>();
7676
const cacheReads = new Map<string, number>();
7777
const cacheWrites = new Map<string, number>();
7878
const costs = new Map<string, number>();
79+
const normalTotals = new Map<string, number>();
80+
const normalCacheReads = new Map<string, number>();
81+
const normalCacheWrites = new Map<string, number>();
82+
const autoresearchTotals = new Map<string, number>();
83+
const autoresearchCacheReads = new Map<string, number>();
84+
const autoresearchCacheWrites = new Map<string, number>();
85+
86+
type UsageBucket = "normal" | "autoresearch";
7987

8088
const pad = (n: number) => n.toString().padStart(2, "0");
8189
const formatKey = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
@@ -166,18 +174,31 @@ for (let i = 0; i < targetDays; i += 1) {
166174
cacheReads.set(key, 0);
167175
cacheWrites.set(key, 0);
168176
costs.set(key, 0);
177+
normalTotals.set(key, 0);
178+
normalCacheReads.set(key, 0);
179+
normalCacheWrites.set(key, 0);
180+
autoresearchTotals.set(key, 0);
181+
autoresearchCacheReads.set(key, 0);
182+
autoresearchCacheWrites.set(key, 0);
169183
}
170184

171185
const inRange = (d: Date) => d >= start && d <= now;
172186

187+
function classifyUsageBucket(sourceValue: unknown): UsageBucket {
188+
return typeof sourceValue === "string" && sourceValue.trim() === "autoresearch"
189+
? "autoresearch"
190+
: "normal";
191+
}
192+
173193
function addTokens(
174194
ts: Date,
175195
input: number,
176196
output: number,
177197
cacheRead: number,
178198
cacheWrite: number,
179199
totalTokens: number,
180-
costTotal: number
200+
costTotal: number,
201+
bucket: UsageBucket = "normal"
181202
) {
182203
if (!ts || Number.isNaN(ts.getTime()) || !inRange(ts)) return;
183204
const key = formatKey(ts);
@@ -189,6 +210,13 @@ function addTokens(
189210
cacheReads.set(key, (cacheReads.get(key) || 0) + cacheRead);
190211
cacheWrites.set(key, (cacheWrites.get(key) || 0) + cacheWrite);
191212
costs.set(key, (costs.get(key) || 0) + costTotal);
213+
214+
const bucketTotals = bucket === "autoresearch" ? autoresearchTotals : normalTotals;
215+
const bucketCacheReads = bucket === "autoresearch" ? autoresearchCacheReads : normalCacheReads;
216+
const bucketCacheWrites = bucket === "autoresearch" ? autoresearchCacheWrites : normalCacheWrites;
217+
bucketTotals.set(key, (bucketTotals.get(key) || 0) + totalTokens);
218+
bucketCacheReads.set(key, (bucketCacheReads.get(key) || 0) + cacheRead);
219+
bucketCacheWrites.set(key, (bucketCacheWrites.get(key) || 0) + cacheWrite);
192220
}
193221

194222
function addUsage(record: any) {
@@ -225,7 +253,12 @@ function addUsage(record: any) {
225253
(typeof costObj.cacheRead === "number" ? costObj.cacheRead : 0) +
226254
(typeof costObj.cacheWrite === "number" ? costObj.cacheWrite : 0);
227255

228-
addTokens(ts, input, output, cacheRead, cacheWrite, totalTokens, costTotal);
256+
const sourceValue =
257+
typeof record?.source === "string"
258+
? record.source
259+
: (typeof msg?.metadata?.source === "string" ? msg.metadata.source : null);
260+
261+
addTokens(ts, input, output, cacheRead, cacheWrite, totalTokens, costTotal, classifyUsageBucket(sourceValue));
229262
}
230263

231264
function scanFile(filePath: string) {
@@ -274,10 +307,13 @@ function loadFromDb(): boolean {
274307
db.exec("PRAGMA busy_timeout = 5000;");
275308
const startIso = start.toISOString();
276309
const endIso = now.toISOString();
310+
const tokenUsageColumns = db.prepare("PRAGMA table_info(token_usage)").all() as Array<{ name?: string }>;
311+
const hasSourceColumn = tokenUsageColumns.some((column) => column.name === "source");
277312
const rows = db
278313
.query(
279314
`SELECT run_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens,
280-
cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total
315+
cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total,
316+
${hasSourceColumn ? "source" : "NULL AS source"}
281317
FROM token_usage
282318
WHERE run_at >= ? AND run_at <= ?`
283319
)
@@ -299,7 +335,7 @@ function loadFromDb(): boolean {
299335
(typeof row.cost_cache_read === "number" ? row.cost_cache_read : 0) +
300336
(typeof row.cost_cache_write === "number" ? row.cost_cache_write : 0);
301337

302-
addTokens(ts, input, output, cacheRead, cacheWrite, totalTokens, costTotal);
338+
addTokens(ts, input, output, cacheRead, cacheWrite, totalTokens, costTotal, classifyUsageBucket(row.source));
303339
}
304340
return true;
305341
} catch (err) {
@@ -322,6 +358,12 @@ if (useSessions || !loadedFromDb) {
322358
const values = dayKeys.map((key) => totals.get(key) || 0);
323359
const cachedValues = dayKeys.map((key) => (cacheReads.get(key) || 0) + (cacheWrites.get(key) || 0));
324360
const uncachedValues = values.map((total, idx) => Math.max(total - cachedValues[idx], 0));
361+
const normalValues = dayKeys.map((key) => normalTotals.get(key) || 0);
362+
const normalCachedValues = dayKeys.map((key) => (normalCacheReads.get(key) || 0) + (normalCacheWrites.get(key) || 0));
363+
const normalUncachedValues = normalValues.map((total, idx) => Math.max(total - normalCachedValues[idx], 0));
364+
const autoresearchValues = dayKeys.map((key) => autoresearchTotals.get(key) || 0);
365+
const autoresearchCachedValues = dayKeys.map((key) => (autoresearchCacheReads.get(key) || 0) + (autoresearchCacheWrites.get(key) || 0));
366+
const autoresearchUncachedValues = autoresearchValues.map((total, idx) => Math.max(total - autoresearchCachedValues[idx], 0));
325367
const rawMaxValue = Math.max(0, ...values);
326368
const sumValue = values.reduce((a, b) => a + b, 0);
327369
const sumInput = dayKeys.reduce((acc, key) => acc + (inputs.get(key) || 0), 0);
@@ -331,6 +373,14 @@ const sumCacheWrite = dayKeys.reduce((acc, key) => acc + (cacheWrites.get(key) |
331373
const sumCost = dayKeys.reduce((acc, key) => acc + (costs.get(key) || 0), 0);
332374
const cachedTotal = sumCacheRead + sumCacheWrite;
333375
const cachedPct = sumValue > 0 ? Math.round((cachedTotal / sumValue) * 1000) / 10 : 0;
376+
const normalTotal = normalValues.reduce((acc, value) => acc + value, 0);
377+
const normalCachedTotal = normalCachedValues.reduce((acc, value) => acc + value, 0);
378+
const normalUncachedTotal = normalUncachedValues.reduce((acc, value) => acc + value, 0);
379+
const autoresearchTotal = autoresearchValues.reduce((acc, value) => acc + value, 0);
380+
const autoresearchCachedTotal = autoresearchCachedValues.reduce((acc, value) => acc + value, 0);
381+
const autoresearchUncachedTotal = autoresearchUncachedValues.reduce((acc, value) => acc + value, 0);
382+
void uncachedValues;
383+
void sumCost;
334384

335385
const niceNumber = (value: number, round: boolean): number => {
336386
if (value === 0) return 0;
@@ -364,36 +414,61 @@ const buildTicks = (min: number, max: number, tickCount: number) => {
364414
};
365415

366416
const width = 680;
367-
const height = 240;
368-
const padding = { left: 48, right: 16, top: 28, bottom: 42 };
417+
const height = 252;
418+
const padding = { left: 48, right: 16, top: 40, bottom: 42 };
369419
const chartWidth = width - padding.left - padding.right;
370420
const chartHeight = height - padding.top - padding.bottom;
371421
const step = chartWidth / targetDays;
372422
const gap = Math.min(12, step * 0.2);
373-
const barWidth = step - gap;
423+
const barWidth = Math.max(12, step - gap);
374424
const { max: maxAxis, ticks: yTicks } = buildTicks(0, Math.max(1, rawMaxValue), 5);
375425
const scaleY = (value: number) => padding.top + (chartHeight - (value / maxAxis) * chartHeight);
376426

377427
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
378428
const label = (key: string) => key.slice(5);
379429
const labelLong = (d: Date) => `${dayNames[d.getDay()]} ${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
380430

381-
const bars = values.map((value, i) => {
382-
const x = padding.left + i * step + gap / 2;
383-
const cached = cachedValues[i] || 0;
384-
const uncached = uncachedValues[i] || 0;
385-
const cachedHeight = Math.round((cached / maxAxis) * chartHeight);
386-
const uncachedHeight = Math.round((uncached / maxAxis) * chartHeight);
387-
const cachedY = padding.top + (chartHeight - cachedHeight);
388-
const uncachedY = padding.top + (chartHeight - cachedHeight - uncachedHeight);
389-
const rects = [] as string[];
390-
if (cachedHeight > 0) {
391-
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" />`);
392-
}
393-
if (uncachedHeight > 0) {
394-
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" />`);
431+
function buildCombinedStackRects(x: number, dayIndex: number, dayKey: string): string {
432+
const segments = [
433+
{
434+
value: normalCachedValues[dayIndex] || 0,
435+
className: "bar-normal-cached",
436+
title: `${dayKey} • normal cached ${formatCompact(normalCachedValues[dayIndex] || 0)}`,
437+
},
438+
{
439+
value: autoresearchCachedValues[dayIndex] || 0,
440+
className: "bar-autoresearch-cached",
441+
title: `${dayKey} • autoresearch cached ${formatCompact(autoresearchCachedValues[dayIndex] || 0)}`,
442+
},
443+
{
444+
value: normalUncachedValues[dayIndex] || 0,
445+
className: "bar-normal-uncached",
446+
title: `${dayKey} • normal uncached ${formatCompact(normalUncachedValues[dayIndex] || 0)}`,
447+
},
448+
{
449+
value: autoresearchUncachedValues[dayIndex] || 0,
450+
className: "bar-autoresearch-uncached",
451+
title: `${dayKey} • autoresearch uncached ${formatCompact(autoresearchUncachedValues[dayIndex] || 0)}`,
452+
},
453+
];
454+
455+
let cumulative = 0;
456+
const rects: string[] = [];
457+
for (const segment of segments) {
458+
if (!segment.value) continue;
459+
const y0 = padding.top + (chartHeight - (cumulative / maxAxis) * chartHeight);
460+
const next = cumulative + segment.value;
461+
const y1 = padding.top + (chartHeight - (next / maxAxis) * chartHeight);
462+
const heightPx = Math.max(1, y0 - y1);
463+
rects.push(`<rect class="bar ${segment.className}" x="${x.toFixed(1)}" y="${y1.toFixed(1)}" width="${barWidth.toFixed(1)}" height="${heightPx.toFixed(1)}" rx="4"><title>${segment.title}</title></rect>`);
464+
cumulative = next;
395465
}
396466
return rects.join("\n ");
467+
}
468+
469+
const bars = dayKeys.map((key, i) => {
470+
const x = padding.left + i * step + gap / 2;
471+
return buildCombinedStackRects(x, i, key);
397472
});
398473

399474
const labels = dayKeys.map((key, i) => {
@@ -434,23 +509,29 @@ const title = `Tokens last ${targetDays} days • total ${formatCompact(sumValue
434509
const svg = `<?xml version="1.0" encoding="UTF-8"?>
435510
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="${title}">
436511
<style>
437-
svg { --text: #0f1419; --grid: #e8ebed; --tick: #cbd5e1; --bar-uncached: #1d9bf0; --bar-cached: #2ecc71; --muted: #536471; }
512+
svg { --text: #0f1419; --grid: #e8ebed; --tick: #cbd5e1; --normal-uncached: #1d9bf0; --normal-cached: #2ecc71; --autoresearch-uncached: #8b5cf6; --autoresearch-cached: #f59e0b; --muted: #536471; }
438513
@media (prefers-color-scheme: dark) {
439-
svg { --text: #e7e9ea; --grid: #2f3336; --tick: #4b5563; --bar-uncached: #1d9bf0; --bar-cached: #27ae60; --muted: #71767b; }
514+
svg { --text: #e7e9ea; --grid: #2f3336; --tick: #4b5563; --normal-uncached: #4aa8ff; --normal-cached: #34d399; --autoresearch-uncached: #a78bfa; --autoresearch-cached: #fbbf24; --muted: #71767b; }
440515
}
441516
.title { font: 600 14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; fill: var(--text); }
442517
.label { font: 11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; fill: var(--muted); }
443518
.axis { stroke: var(--grid); stroke-width: 1.2; }
444519
.grid { stroke: var(--grid); stroke-width: 1; stroke-dasharray: 4 4; opacity: 0.8; vector-effect: non-scaling-stroke; }
445520
.tick { stroke: var(--tick); stroke-width: 1.2; vector-effect: non-scaling-stroke; }
446-
.bar-uncached { fill: var(--bar-uncached); }
447-
.bar-cached { fill: var(--bar-cached); }
521+
.bar-normal-uncached { fill: var(--normal-uncached); }
522+
.bar-normal-cached { fill: var(--normal-cached); }
523+
.bar-autoresearch-uncached { fill: var(--autoresearch-uncached); }
524+
.bar-autoresearch-cached { fill: var(--autoresearch-cached); }
448525
</style>
449526
<text class="title" x="${padding.left}" y="18">${title}</text>
450-
<rect x="${width - padding.right - 140}" y="6" width="10" height="10" fill="var(--bar-uncached)" rx="2" />
451-
<text class="label" x="${width - padding.right - 124}" y="15">uncached</text>
452-
<rect x="${width - padding.right - 70}" y="6" width="10" height="10" fill="var(--bar-cached)" rx="2" />
453-
<text class="label" x="${width - padding.right - 54}" y="15">cached</text>
527+
<rect x="${width - padding.right - 310}" y="6" width="10" height="10" fill="var(--normal-uncached)" rx="2" />
528+
<text class="label" x="${width - padding.right - 294}" y="15">normal uncached</text>
529+
<rect x="${width - padding.right - 155}" y="6" width="10" height="10" fill="var(--normal-cached)" rx="2" />
530+
<text class="label" x="${width - padding.right - 139}" y="15">normal cached</text>
531+
<rect x="${width - padding.right - 310}" y="20" width="10" height="10" fill="var(--autoresearch-uncached)" rx="2" />
532+
<text class="label" x="${width - padding.right - 294}" y="29">autoresearch uncached</text>
533+
<rect x="${width - padding.right - 155}" y="20" width="10" height="10" fill="var(--autoresearch-cached)" rx="2" />
534+
<text class="label" x="${width - padding.right - 139}" y="29">autoresearch cached</text>
454535
${bars.join("\n ")}
455536
${yGrid}
456537
${axisX}
@@ -469,12 +550,10 @@ if (outputSvg) {
469550
const summaryLines = [
470551
`Token usage (all chats) — last ${targetDays} days, total ${formatCompact(sumValue)}`,
471552
`Input ${formatCompact(sumInput)} • Output ${formatCompact(sumOutput)} • Cache read ${formatCompact(sumCacheRead)} • Cache write ${formatCompact(sumCacheWrite)} (${cachedPct}% cached)`,
553+
`Normal ${formatCompact(normalTotal)} tokens • cached ${formatCompact(normalCachedTotal)} • uncached ${formatCompact(normalUncachedTotal)}`,
554+
`Autoresearch ${formatCompact(autoresearchTotal)} tokens • cached ${formatCompact(autoresearchCachedTotal)} • uncached ${formatCompact(autoresearchUncachedTotal)}`,
472555
...dayDates.map((d, i) => {
473-
const key = dayKeys[i];
474-
const total = values[i];
475-
const cached = (cacheReads.get(key) || 0) + (cacheWrites.get(key) || 0);
476-
const uncached = Math.max(total - cached, 0);
477-
return `• ${labelLong(d)}: ${formatCompact(total)} tokens (cached ${formatCompact(cached)}, uncached ${formatCompact(uncached)})`;
556+
return `• ${labelLong(d)}: ${formatCompact(values[i])} tokens (normal ${formatCompact(normalValues[i] || 0)}: cached ${formatCompact(normalCachedValues[i] || 0)}, uncached ${formatCompact(normalUncachedValues[i] || 0)}; autoresearch ${formatCompact(autoresearchValues[i] || 0)}: cached ${formatCompact(autoresearchCachedValues[i] || 0)}, uncached ${formatCompact(autoresearchUncachedValues[i] || 0)})`;
478557
}),
479558
];
480559

runtime/test/scripts/token-chart.test.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77

88
import { expect, test } from "bun:test";
99
import "../helpers.js";
10-
import { mkdirSync, writeFileSync } from "fs";
10+
import { mkdirSync, writeFileSync, readFileSync, rmSync } from "fs";
1111
import { join } from "path";
1212
import { tmpdir } from "os";
13+
import Database from "bun:sqlite";
1314

1415
function formatDay(d: Date) {
1516
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -89,6 +90,81 @@ test("token chart handles empty sessions directory", () => {
8990
expect(output).toContain("0 tokens");
9091
});
9192

93+
test("token chart combines normal and autoresearch into one daily stack when reading token_usage", () => {
94+
const base = join(tmpdir(), `piclaw-tokenchart-db-${Date.now()}`);
95+
const storeDir = join(base, "store");
96+
const svgPath = join(base, "token-chart.svg");
97+
mkdirSync(storeDir, { recursive: true });
98+
99+
const db = new Database(join(storeDir, "messages.db"));
100+
db.exec(`
101+
CREATE TABLE token_usage (
102+
id INTEGER PRIMARY KEY AUTOINCREMENT,
103+
chat_jid TEXT NOT NULL,
104+
run_at TEXT NOT NULL,
105+
input_tokens INTEGER DEFAULT 0,
106+
output_tokens INTEGER DEFAULT 0,
107+
cache_read_tokens INTEGER DEFAULT 0,
108+
cache_write_tokens INTEGER DEFAULT 0,
109+
total_tokens INTEGER DEFAULT 0,
110+
cost_input REAL DEFAULT 0,
111+
cost_output REAL DEFAULT 0,
112+
cost_cache_read REAL DEFAULT 0,
113+
cost_cache_write REAL DEFAULT 0,
114+
cost_total REAL DEFAULT 0,
115+
model TEXT,
116+
provider TEXT,
117+
api TEXT,
118+
turns INTEGER DEFAULT 0,
119+
source TEXT,
120+
source_ref TEXT
121+
)
122+
`);
123+
124+
const now = new Date();
125+
const insert = db.prepare(`
126+
INSERT INTO token_usage (
127+
chat_jid, run_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
128+
total_tokens, cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total,
129+
model, provider, api, turns, source, source_ref
130+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
131+
`);
132+
133+
insert.run("web:default", now.toISOString(), 1000, 500, 200, 100, 1800, 0, 0, 0, 0, 0, "model-a", "openai-codex", null, 1, "agent_pool", "agent-1");
134+
insert.run("web:default", now.toISOString(), 300, 100, 50, 50, 500, 0, 0, 0, 0, 0, "model-b", "openai-codex", null, 1, "autoresearch", "autoresearch-1");
135+
db.close();
136+
137+
const proc = Bun.spawnSync([
138+
"bun",
139+
"/workspace/piclaw/runtime/skills/token-chart/token-chart.ts",
140+
"--days",
141+
"1",
142+
"--source",
143+
"db",
144+
"--output-svg",
145+
svgPath,
146+
], {
147+
env: {
148+
...process.env,
149+
PICLAW_STORE: storeDir,
150+
},
151+
});
152+
153+
const output = proc.stdout.toString();
154+
const svg = readFileSync(svgPath, "utf8");
155+
156+
expect(output).toContain("total 2.3K");
157+
expect(output).toContain("Normal 1.8K tokens • cached 300 • uncached 1.5K");
158+
expect(output).toContain("Autoresearch 500 tokens • cached 100 • uncached 400");
159+
expect(output).toContain("normal 1.8K: cached 300, uncached 1.5K; autoresearch 500: cached 100, uncached 400");
160+
expect(svg).toContain("normal uncached");
161+
expect(svg).toContain("autoresearch uncached");
162+
expect(svg).toContain("normal cached 300");
163+
expect(svg).toContain("autoresearch uncached 400");
164+
165+
rmSync(base, { recursive: true, force: true });
166+
});
167+
92168
test("token chart ignores malformed JSONL lines", () => {
93169
const sessionsDir = join(tmpdir(), `piclaw-sessions-malformed-${Date.now()}`);
94170
mkdirSync(sessionsDir, { recursive: true });

0 commit comments

Comments
 (0)