takomi 2.1.18 → 2.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@ import type {
8
8
  import { commandHelp, completions, statusText, workflowPrompt } from "./command-text";
9
9
  import type { TakomiSubagentController } from "./subagent-types";
10
10
  import { installTakomiRoutingPolicy, resolveTakomiRoutingPolicy, type RoutingPolicyInstallScope } from "./routing-policy";
11
- import { collectTakomiStats, renderTakomiStats } from "../../../src/takomi-stats.js";
11
+ import { collectTakomiStats, renderTakomiStats } from "./takomi-stats.js";
12
12
 
13
13
  export type TakomiRuntimeCommandState = {
14
14
  enabled: boolean;
@@ -0,0 +1,3 @@
1
+ export function collectTakomiStats(opts?: { home?: string; cwd?: string; json?: boolean; limit?: number; view?: string; since?: string }): Promise<any>;
2
+ export function renderTakomiStats(stats: any, opts?: { limit?: number; view?: string }): string;
3
+ export function printTakomiStats(options?: { home?: string; cwd?: string; json?: boolean; limit?: number; view?: string; since?: string }): Promise<void>;
@@ -0,0 +1,438 @@
1
+ import fs from 'fs-extra';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import pc from 'picocolors';
5
+
6
+ const PRICES = {
7
+ 'gpt-5.5': [5.00, 0.50, 30.00],
8
+ 'gpt-5.4': [2.50, 0.25, 15.00],
9
+ 'gpt-5.4-mini': [0.75, 0.075, 4.50],
10
+ 'gpt-5.4-nano': [0.20, 0.02, 1.25],
11
+ 'gpt-5.3-codex': [2.50, 0.25, 15.00],
12
+ 'gpt-5.2-codex': [1.75, 0.175, 14.00],
13
+ 'gpt-5-codex': [1.25, 0.125, 10.00],
14
+ 'gpt-5.2': [1.75, 0.175, 14.00],
15
+ 'gpt-5.1': [1.25, 0.125, 10.00],
16
+ 'gpt-5': [1.25, 0.125, 10.00],
17
+ 'gpt-5-mini': [0.25, 0.025, 2.00],
18
+ 'gpt-4.1': [2.00, 0.50, 8.00],
19
+ 'gpt-4o': [2.50, 1.25, 10.00],
20
+ 'o4-mini': [1.10, 0.275, 4.40],
21
+ 'claude-sonnet-4-6': [3.00, 0.30, 15.00],
22
+ };
23
+
24
+ function safeJson(line) { try { return JSON.parse(line); } catch { return null; } }
25
+ function dayOf(ts) { return typeof ts === 'string' && ts.length >= 10 ? ts.slice(0, 10) : 'unknown'; }
26
+ function add(map, key, patch) { const row = map.get(key) || { key, input: 0, cache: 0, output: 0, total: 0, cost: 0, events: 0 }; for (const [k,v] of Object.entries(patch)) row[k] = (row[k] || 0) + (Number(v) || 0); if (!Object.prototype.hasOwnProperty.call(patch, 'events')) row.events += 1; map.set(key, row); }
27
+ function cost(model, input, cache, output, additiveCache = true) { const p = PRICES[model]; if (!p) return 0; const nonCached = additiveCache ? input : Math.max(input - cache, 0); return (nonCached*p[0] + cache*p[1] + output*p[2]) / 1_000_000; }
28
+ function fmtTokens(n) { if (n >= 1e9) return `${(n/1e9).toFixed(2)}B`; if (n >= 1e6) return `${(n/1e6).toFixed(1)}M`; if (n >= 1e3) return `${(n/1e3).toFixed(1)}K`; return String(Math.round(n || 0)); }
29
+ function fmtMoney(n) { return `$${(n || 0).toFixed(n > 100 ? 0 : 2)}`; }
30
+ function ms(n) { if (!n) return '-'; const s = Math.round(n/1000); if (s < 60) return `${s}s`; const m = Math.floor(s/60); if (m < 60) return `${m}m ${s%60}s`; const h = Math.floor(m/60); return `${h}h ${m%60}m`; }
31
+ function parseSince(value) {
32
+ if (!value) return null;
33
+ const raw = String(value).trim().toLowerCase();
34
+ const rel = raw.match(/^(\d+)(d|day|days|w|week|weeks|m|month|months)$/);
35
+ const d = new Date(); d.setHours(0,0,0,0);
36
+ if (rel) {
37
+ const n = Number(rel[1]);
38
+ const unit = rel[2][0];
39
+ d.setDate(d.getDate() - (unit === 'w' ? n * 7 : unit === 'm' ? n * 30 : n));
40
+ return d.toISOString().slice(0, 10);
41
+ }
42
+ if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
43
+ return null;
44
+ }
45
+ function projectKey(file) {
46
+ const normalized = String(file || '').replace(/\\/g, '/');
47
+ const marker = '/sessions/';
48
+ const idx = normalized.indexOf(marker);
49
+ if (idx >= 0) {
50
+ const encoded = normalized.slice(idx + marker.length).split('/')[0];
51
+ return encoded.replace(/^--/, '').replace(/--$/, '').replace(/--/g, '/').replace(/-/g, ' ').trim() || 'global';
52
+ }
53
+ const cwdMarker = '/.pi/';
54
+ const pidx = normalized.indexOf(cwdMarker);
55
+ if (pidx >= 0) return normalized.slice(0, pidx).split('/').slice(-2).join('/');
56
+ return 'unknown';
57
+ }
58
+
59
+ // ── ANSI-aware string helpers ───────────────────────────────────────────────
60
+ // eslint-disable-next-line no-control-regex
61
+ const ANSI_RE = /\u001b\[[0-9;]*m/g;
62
+ function stripAnsi(s) { return String(s).replace(ANSI_RE, ''); }
63
+ function visLen(s) { return stripAnsi(s).length; }
64
+ function ansiPadEnd(s, w) { return s + ' '.repeat(Math.max(0, w - visLen(s))); }
65
+ function ansiPadStart(s, w) { return ' '.repeat(Math.max(0, w - visLen(s))) + s; }
66
+
67
+ async function files(root, suffix = '.jsonl') {
68
+ const out = [];
69
+ if (!root || !(await fs.pathExists(root))) return out;
70
+ async function walk(dir) {
71
+ for (const ent of await fs.readdir(dir, { withFileTypes: true })) {
72
+ const p = path.join(dir, ent.name);
73
+ if (ent.isDirectory()) await walk(p); else if (ent.name.endsWith(suffix)) out.push(p);
74
+ }
75
+ }
76
+ await walk(root); return out;
77
+ }
78
+
79
+ async function scanPiSessions(root, source, events) {
80
+ for (const file of await files(root)) {
81
+ let provider = 'unknown', model = 'unknown', session = path.basename(file, '.jsonl');
82
+ const text = await fs.readFile(file, 'utf8').catch(() => '');
83
+ for (const line of text.split(/\r?\n/)) {
84
+ const obj = safeJson(line); if (!obj) continue;
85
+ if (obj.type === 'session') session = obj.id || session;
86
+ if (obj.type === 'model_change') { provider = obj.provider || provider; model = obj.modelId || model; }
87
+ const u = obj.type === 'message' && obj.message && obj.message.usage;
88
+ if (u) events.push({ source, file, timestamp: obj.timestamp, day: dayOf(obj.timestamp), session, provider, model, input: +u.input||0, cache: +u.cacheRead||0, output: +u.output||0, total: +u.totalTokens||0, cost: cost(model, +u.input||0, +u.cacheRead||0, +u.output||0, true) });
89
+ }
90
+ }
91
+ }
92
+
93
+ async function scanRunHistory(file) {
94
+ const runs = [];
95
+ if (!(await fs.pathExists(file))) return runs;
96
+ const text = await fs.readFile(file, 'utf8').catch(() => '');
97
+ for (const line of text.split(/\r?\n/)) { const o = safeJson(line); if (o) runs.push(o); }
98
+ return runs;
99
+ }
100
+
101
+ export async function collectTakomiStats(opts = {}) {
102
+ const home = opts.home || os.homedir();
103
+ const cwd = opts.cwd || process.cwd();
104
+ const rawEvents = [];
105
+ await scanPiSessions(path.join(home, '.pi', 'agent', 'sessions'), 'pi-global', rawEvents);
106
+ await scanPiSessions(path.join(cwd, '.pi', 'agent', 'sessions'), 'pi-project', rawEvents);
107
+ await scanPiSessions(path.join(cwd, '.pi', 'takomi'), 'takomi-project', rawEvents);
108
+ const sinceDay = parseSince(opts.since);
109
+ const events = rawEvents
110
+ .filter(e => !sinceDay || e.day >= sinceDay)
111
+ .map(e => ({ ...e, project: projectKey(e.file) }));
112
+ const runs = await scanRunHistory(path.join(home, '.pi', 'agent', 'run-history.jsonl'));
113
+ const byDay = new Map(), byModel = new Map(), bySource = new Map(), byProject = new Map();
114
+ let totals = { input: 0, cache: 0, output: 0, total: 0, cost: 0, events: events.length };
115
+ for (const e of events) {
116
+ totals.input += e.input; totals.cache += e.cache; totals.output += e.output; totals.total += e.total; totals.cost += e.cost;
117
+ add(byDay, e.day, e); add(byModel, e.model, e); add(bySource, e.source, e); add(byProject, e.project, e);
118
+ }
119
+ const byAgent = new Map(); let longestRun = null;
120
+ for (const r of runs) { add(byAgent, r.agent || 'unknown', { total: 0, events: 1 }); if (!longestRun || (+r.duration||0) > (+longestRun.duration||0)) longestRun = r; }
121
+ return { generatedAt: new Date().toISOString(), cwd, since: sinceDay, totals, sessions: new Set(events.map(e => e.session)).size, byDay: [...byDay.values()].sort((a,b)=>a.key.localeCompare(b.key)), byModel: [...byModel.values()].sort((a,b)=>b.total-a.total), bySource: [...bySource.values()].sort((a,b)=>b.total-a.total), byProject: [...byProject.values()].sort((a,b)=>b.total-a.total), byAgent: [...byAgent.values()].sort((a,b)=>b.events-a.events), runs, longestRun, recent: events.sort((a,b)=>(b.timestamp||'').localeCompare(a.timestamp||'')).slice(0, 10) };
122
+ }
123
+
124
+ // ── Streak Calculation ──────────────────────────────────────────────────────
125
+ function calcStreaks(byDay) {
126
+ if (!byDay.length) return { current: 0, longest: 0, quietDays: 0 };
127
+ const daySet = new Set(byDay.map(d => d.key));
128
+ const today = new Date(); today.setHours(0,0,0,0);
129
+ // current streak: walk back from today
130
+ let current = 0;
131
+ for (let d = new Date(today); ; d.setDate(d.getDate() - 1)) {
132
+ const key = d.toISOString().slice(0, 10);
133
+ if (daySet.has(key)) current++; else break;
134
+ }
135
+ // longest streak: walk all sorted days
136
+ const sorted = [...daySet].sort();
137
+ let longest = 0, run = 1;
138
+ for (let i = 1; i < sorted.length; i++) {
139
+ const prev = new Date(sorted[i - 1]); prev.setDate(prev.getDate() + 1);
140
+ if (prev.toISOString().slice(0, 10) === sorted[i]) { run++; } else { longest = Math.max(longest, run); run = 1; }
141
+ }
142
+ longest = Math.max(longest, run);
143
+ // quiet days
144
+ const first = new Date(sorted[0]);
145
+ const span = Math.round((today - first) / 86400000) + 1;
146
+ return { current, longest, quietDays: Math.max(0, span - sorted.length) };
147
+ }
148
+
149
+ // ── GitHub-style Heatmap Grid ───────────────────────────────────────────────
150
+ function heatmapGrid(byDay) {
151
+ const dayMap = new Map(byDay.map(d => [d.key, d.total]));
152
+ const max = Math.max(1, ...byDay.map(d => d.total));
153
+
154
+ // Determine range: last ~26 weeks (half year) ending at current week
155
+ const today = new Date(); today.setHours(0,0,0,0);
156
+ // End at end of current week (Sunday)
157
+ const endDate = new Date(today);
158
+ const todayDow = endDate.getDay(); // 0=Sun, 1=Mon...
159
+ if (todayDow !== 0) endDate.setDate(endDate.getDate() + (7 - todayDow));
160
+ // Start 26 weeks back on Monday
161
+ const startDate = new Date(endDate);
162
+ startDate.setDate(startDate.getDate() - (26 * 7) + 1);
163
+ while (startDate.getDay() !== 1) startDate.setDate(startDate.getDate() - 1);
164
+
165
+ // Build grid: 7 rows (Mon..Sun), N columns (weeks)
166
+ const weeks = [];
167
+ const monthPositions = []; // { col, label }
168
+ const cursor = new Date(startDate);
169
+ let col = 0;
170
+ let lastMonth = -1;
171
+
172
+ while (cursor <= endDate) {
173
+ const week = [];
174
+ for (let dow = 0; dow < 7; dow++) {
175
+ const key = cursor.toISOString().slice(0, 10);
176
+ const val = cursor <= today ? (dayMap.get(key) || 0) : -1; // -1 = future
177
+ week.push(val);
178
+ // Track month transitions on the Monday of each week
179
+ if (dow === 0) {
180
+ const m = cursor.getMonth();
181
+ if (m !== lastMonth) {
182
+ const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
183
+ monthPositions.push({ col, label: monthNames[m] });
184
+ lastMonth = m;
185
+ }
186
+ }
187
+ cursor.setDate(cursor.getDate() + 1);
188
+ }
189
+ weeks.push(week);
190
+ col++;
191
+ }
192
+
193
+ // Intensity cell — use ■ for filled, · for empty
194
+ const SQ = '■';
195
+ const EMPTY = '·';
196
+ function cell(val) {
197
+ if (val < 0) return ' '; // future
198
+ if (val === 0) return pc.gray(EMPTY);
199
+ const x = val / max;
200
+ if (x < 0.12) return pc.dim(pc.cyan(SQ));
201
+ if (x < 0.30) return pc.cyan(SQ);
202
+ if (x < 0.55) return pc.blue(SQ);
203
+ if (x < 0.80) return pc.magenta(SQ);
204
+ return pc.bold(pc.magenta(SQ));
205
+ }
206
+
207
+ const dayLabels = ['Mon',' ','Wed',' ','Fri',' ','Sun'];
208
+ const rows = [];
209
+
210
+ // Each cell is 2 chars wide (char + space) in the grid
211
+ for (let dow = 0; dow < 7; dow++) {
212
+ const prefix = pc.dim(dayLabels[dow]);
213
+ const cells = weeks.map(w => cell(w[dow]));
214
+ rows.push(` ${prefix} ${cells.join(' ')}`);
215
+ }
216
+
217
+ // Month label row — positioned under the correct columns
218
+ // Each column is 2 chars wide (cell + space separator)
219
+ let labelStr = '';
220
+ let prevEnd = 0;
221
+ for (const ml of monthPositions) {
222
+ const targetPos = ml.col * 2; // 2 chars per column (char + space)
223
+ const gap = Math.max(0, targetPos - prevEnd);
224
+ labelStr += ' '.repeat(gap) + ml.label;
225
+ prevEnd = targetPos + ml.label.length;
226
+ }
227
+ rows.push(` ${pc.dim(labelStr)}`);
228
+
229
+ // Legend row
230
+ rows.push('');
231
+ rows.push(` ${pc.dim('Less')} ${pc.gray(EMPTY)} ${pc.dim(pc.cyan(SQ))} ${pc.cyan(SQ)} ${pc.blue(SQ)} ${pc.magenta(SQ)} ${pc.bold(pc.magenta(SQ))} ${pc.dim('More')}`);
232
+
233
+ return rows.join('\n');
234
+ }
235
+
236
+ // ── Box Drawing Helpers ─────────────────────────────────────────────────────
237
+ function hrule(w, ch = '─') { return ch.repeat(w); }
238
+
239
+ function center(text, width) {
240
+ const vl = visLen(text);
241
+ const pad = Math.max(0, Math.floor((width - vl) / 2));
242
+ return ' '.repeat(pad) + text;
243
+ }
244
+
245
+ function statCard(value, label) {
246
+ return { value: String(value), label };
247
+ }
248
+
249
+ // ── Table Helper ────────────────────────────────────────────────────────────
250
+ function renderTable(title, rows, columns) {
251
+ const lines = [];
252
+ lines.push(' ' + pc.bold(pc.cyan(title)));
253
+ lines.push(' ' + pc.dim(hrule(columns.reduce((s, c) => s + c.width, 0) + columns.length * 2)));
254
+ for (const row of rows) {
255
+ let line = ' ';
256
+ for (const col of columns) {
257
+ const val = String(col.get(row));
258
+ line += col.align === 'right' ? ansiPadStart(val, col.width) : ansiPadEnd(val, col.width);
259
+ line += ' ';
260
+ }
261
+ lines.push(line);
262
+ }
263
+ return lines.join('\n');
264
+ }
265
+
266
+ function renderFocusedView(stats, opts = {}) {
267
+ const view = opts.view;
268
+ const limit = opts.limit || 20;
269
+ if (!view || view === 'overview') return null;
270
+ const tables = {
271
+ models: ['Top Models', stats.byModel, [
272
+ { width: 26, align: 'left', get: r => pc.white(r.key) },
273
+ { width: 10, align: 'right', get: r => pc.cyan(fmtTokens(r.total)) },
274
+ { width: 10, align: 'right', get: r => pc.dim(fmtMoney(r.cost)) },
275
+ { width: 12, align: 'right', get: r => pc.dim(r.events + ' calls') },
276
+ ]],
277
+ sources: ['Sources', stats.bySource, [
278
+ { width: 22, align: 'left', get: r => pc.white(r.key) },
279
+ { width: 10, align: 'right', get: r => pc.cyan(fmtTokens(r.total)) },
280
+ { width: 14, align: 'right', get: r => pc.dim(r.events + ' events') },
281
+ ]],
282
+ projects: ['Top Projects', stats.byProject, [
283
+ { width: 42, align: 'left', get: r => pc.white(r.key.length > 42 ? '…' + r.key.slice(-41) : r.key) },
284
+ { width: 10, align: 'right', get: r => pc.cyan(fmtTokens(r.total)) },
285
+ { width: 10, align: 'right', get: r => pc.dim(fmtMoney(r.cost)) },
286
+ { width: 12, align: 'right', get: r => pc.dim(r.events + ' calls') },
287
+ ]],
288
+ agents: ['Top Agents', stats.byAgent, [
289
+ { width: 24, align: 'left', get: r => pc.white(r.key) },
290
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.events)) },
291
+ { width: 8, align: 'left', get: () => pc.dim('runs') },
292
+ ]],
293
+ daily: ['Daily Usage', [...stats.byDay].reverse(), [
294
+ { width: 12, align: 'left', get: r => pc.white(r.key) },
295
+ { width: 10, align: 'right', get: r => pc.cyan(fmtTokens(r.total)) },
296
+ { width: 10, align: 'right', get: r => pc.dim(fmtMoney(r.cost)) },
297
+ { width: 12, align: 'right', get: r => pc.dim(r.events + ' calls') },
298
+ ]],
299
+ };
300
+ const spec = tables[view === 'project' ? 'projects' : view];
301
+ if (!spec) return null;
302
+ const [title, rows, cols] = spec;
303
+ const suffix = stats.since ? pc.dim(`\n Since: ${stats.since}`) : '';
304
+ return ['\n' + pc.bold(pc.magenta('Takomi Stats')), suffix, renderTable(title, rows.slice(0, limit), cols), '\n' + pc.dim('Privacy: metadata only · no raw prompts or transcripts')].filter(Boolean).join('\n');
305
+ }
306
+
307
+ // ── Main Render ─────────────────────────────────────────────────────────────
308
+ export function renderTakomiStats(stats, opts = {}) {
309
+ const focused = renderFocusedView(stats, opts);
310
+ if (focused) return focused;
311
+ const W = Math.min(process.stdout.columns || 80, 86);
312
+ const topModel = stats.byModel[0]?.key || 'unknown';
313
+ const peak = stats.byDay.reduce((a,b) => b.total > (a?.total||0) ? b : a, null);
314
+ const streaks = calcStreaks(stats.byDay);
315
+ const lines = [];
316
+
317
+ // ── Header ────────────────────────────────────────────────────────────
318
+ lines.push('');
319
+ lines.push(pc.cyan(' ' + hrule(W - 4, '━')));
320
+ lines.push('');
321
+ lines.push(center(pc.bold(pc.white('T A K O M I S T A T S')), W));
322
+ const user = process.env.USERNAME || process.env.USER || 'local';
323
+ lines.push(center(pc.dim(`@${user} · Takomi`), W));
324
+ lines.push('');
325
+ lines.push(pc.cyan(' ' + hrule(W - 4)));
326
+
327
+ // ── Stat Cards Row 1 ─────────────────────────────────────────────────
328
+ const cards1 = [
329
+ statCard(fmtTokens(stats.totals.total), 'Lifetime Tokens'),
330
+ statCard(fmtTokens(stats.totals.cache), 'Cache Tokens'),
331
+ statCard(fmtMoney(stats.totals.cost), 'Est. Cost'),
332
+ statCard(String(stats.sessions), 'Sessions'),
333
+ statCard(String(stats.runs.length), 'Agent Runs'),
334
+ ];
335
+
336
+ const cardW = Math.floor((W - 4) / cards1.length);
337
+
338
+
339
+ function buildCardLines(cards) {
340
+ let vStr = ' ';
341
+ let lStr = ' ';
342
+ for (const c of cards) {
343
+ const vPad = Math.max(0, Math.floor((cardW - c.value.length) / 2));
344
+ const lPad = Math.max(0, Math.floor((cardW - c.label.length) / 2));
345
+ const vContent = ' '.repeat(vPad) + pc.bold(pc.white(c.value));
346
+ const lContent = ' '.repeat(lPad) + pc.dim(c.label);
347
+ // Pad to cardW visible chars
348
+ vStr += ansiPadEnd(vContent, cardW);
349
+ lStr += ansiPadEnd(lContent, cardW);
350
+ }
351
+ return [vStr, lStr];
352
+ }
353
+
354
+ lines.push('');
355
+ const [v1, l1] = buildCardLines(cards1);
356
+ lines.push(v1);
357
+ lines.push(l1);
358
+
359
+ // ── Stat Cards Row 2 ─────────────────────────────────────────────────
360
+ lines.push('');
361
+ const cards2 = [
362
+ statCard(peak ? fmtTokens(peak.total) : '-', 'Peak Day'),
363
+ statCard(topModel, 'Top Model'),
364
+ statCard(ms(stats.longestRun?.duration), 'Longest Run'),
365
+ statCard(`${streaks.current} days`, 'Current Streak'),
366
+ statCard(`${streaks.longest} days`, 'Longest Streak'),
367
+ ];
368
+
369
+ const [v2, l2] = buildCardLines(cards2);
370
+ lines.push(v2);
371
+ lines.push(l2);
372
+
373
+ // ── Info line ─────────────────────────────────────────────────────────
374
+ lines.push('');
375
+ const infoText = `Peak: ${peak?.key || '-'} · ${streaks.quietDays} quiet days · ${stats.totals.events.toLocaleString()} events${stats.since ? ` · since ${stats.since}` : ''}`;
376
+ lines.push(center(pc.dim(infoText), W));
377
+
378
+ lines.push('');
379
+ lines.push(pc.cyan(' ' + hrule(W - 4, '━')));
380
+
381
+ // ── Activity Heatmap ────────────────────────────────────────────────────
382
+ lines.push('');
383
+ lines.push(' ' + pc.bold(pc.cyan('Token Activity')));
384
+ lines.push(' ' + pc.dim(hrule(W - 4)));
385
+ lines.push(heatmapGrid(stats.byDay));
386
+
387
+ // ── Models Table ────────────────────────────────────────────────────────
388
+ lines.push('');
389
+ const modelLimit = opts.limit || 8;
390
+ lines.push(renderTable('Top Models', stats.byModel.slice(0, modelLimit), [
391
+ { width: 24, align: 'left', get: r => pc.white(r.key) },
392
+ { width: 10, align: 'right', get: r => pc.cyan(fmtTokens(r.total)) },
393
+ { width: 10, align: 'right', get: r => pc.dim(fmtMoney(r.cost)) },
394
+ { width: 12, align: 'right', get: r => pc.dim(r.events + ' calls') },
395
+ ]));
396
+
397
+ // ── Projects Table ──────────────────────────────────────────────────────
398
+ if (stats.byProject.length) {
399
+ lines.push('');
400
+ lines.push(renderTable('Top Projects', stats.byProject.slice(0, 5), [
401
+ { width: 34, align: 'left', get: r => pc.white(r.key.length > 34 ? '…' + r.key.slice(-33) : r.key) },
402
+ { width: 10, align: 'right', get: r => pc.cyan(fmtTokens(r.total)) },
403
+ { width: 10, align: 'right', get: r => pc.dim(fmtMoney(r.cost)) },
404
+ ]));
405
+ }
406
+
407
+ // ── Sources Table ───────────────────────────────────────────────────────
408
+ lines.push('');
409
+ lines.push(renderTable('Sources', stats.bySource, [
410
+ { width: 20, align: 'left', get: r => pc.white(r.key) },
411
+ { width: 10, align: 'right', get: r => pc.cyan(fmtTokens(r.total)) },
412
+ { width: 14, align: 'right', get: r => pc.dim(r.events + ' events') },
413
+ ]));
414
+
415
+ // ── Agents Table ────────────────────────────────────────────────────────
416
+ if (stats.byAgent.length) {
417
+ lines.push('');
418
+ lines.push(renderTable('Top Agents', stats.byAgent.slice(0, modelLimit), [
419
+ { width: 20, align: 'left', get: r => pc.white(r.key) },
420
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.events)) },
421
+ { width: 6, align: 'left', get: r => pc.dim('runs') },
422
+ ]));
423
+ }
424
+
425
+ // ── Footer ──────────────────────────────────────────────────────────────
426
+ lines.push('');
427
+ lines.push(' ' + pc.dim(hrule(W - 4)));
428
+ lines.push(' ' + pc.dim('Privacy: metadata only · no raw prompts or transcripts'));
429
+ lines.push(' ' + pc.dim('Costs are estimates when provider prices are unknown.'));
430
+ lines.push('');
431
+
432
+ return lines.join('\n');
433
+ }
434
+
435
+ export async function printTakomiStats(options = {}) {
436
+ const stats = await collectTakomiStats(options);
437
+ if (options.json) console.log(JSON.stringify(stats, null, 2)); else console.log(renderTakomiStats(stats, options));
438
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "takomi",
3
- "version": "2.1.18",
3
+ "version": "2.1.19",
4
4
  "description": "🎯 Stop wrestling with AI. Start building with purpose. The artisan's toolkit for agent workflows, Codex skills, and original Takomi capabilities like 21st.dev integration.",
5
5
  "type": "module",
6
6
  "bin": {