takomi 2.1.19 → 2.1.21

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,12 @@ type NotifySoundConfig = {
8
8
  enabled: boolean;
9
9
  };
10
10
 
11
+ type NotifyMethod = "auto" | "wav";
12
+
11
13
  const CONFIG_PATH = join(process.cwd(), ".pi", "notify-sound.json");
14
+ const WAV_PATH = join(process.cwd(), ".pi", "notify-sound.wav");
15
+ const PS1_PATH = join(process.cwd(), ".pi", "notify-sound.ps1");
16
+ const VBS_PATH = join(process.cwd(), ".pi", "notify-sound.vbs");
12
17
  const STALE_AGENT_START_MS = 24 * 60 * 60 * 1000;
13
18
 
14
19
  let config: NotifySoundConfig = { enabled: true };
@@ -16,6 +21,7 @@ let lastAgentStartedAt = 0;
16
21
 
17
22
  export default async function notifySoundExtension(pi: ExtensionAPI) {
18
23
  await loadConfig();
24
+ await ensureTuneWav();
19
25
 
20
26
  pi.on("session_start", async (_event, ctx) => {
21
27
  updateStatus(ctx);
@@ -37,12 +43,34 @@ export default async function notifySoundExtension(pi: ExtensionAPI) {
37
43
 
38
44
  const command = {
39
45
  description: "Toggle or test the agent completion tune notification",
46
+ getArgumentCompletions: (argumentPrefix: string) => {
47
+ const token = argumentPrefix.trim().toLowerCase();
48
+ return [
49
+ { value: "status", label: "status", description: "Show whether the completion tune is on" },
50
+ { value: "test", label: "test", description: "Play the completion tune" },
51
+ { value: "test wav", label: "test wav", description: "Play the generated WAV completion tune" },
52
+ { value: "on", label: "on", description: "Enable the completion tune" },
53
+ { value: "off", label: "off", description: "Disable the completion tune" },
54
+ { value: "toggle", label: "toggle", description: "Toggle the completion tune" },
55
+ ].filter((completion) => !token || completion.value.startsWith(token));
56
+ },
40
57
  handler: async (args: string, ctx: ExtensionCommandContext) => {
41
58
  const action = args.trim().toLowerCase() || "toggle";
42
59
 
43
60
  if (action === "test") {
44
- playCompletionTune();
45
- ctx.ui.notify("Played completion tune.", "info");
61
+ playCompletionTune("wav");
62
+ ctx.ui.notify("Played completion tune using WAV mode. This is the default reliable Windows path.", "info");
63
+ return;
64
+ }
65
+
66
+ if (action === "test wav") {
67
+ playCompletionTune("wav");
68
+ ctx.ui.notify("Played completion tune using WAV mode.", "info");
69
+ return;
70
+ }
71
+
72
+ if (action.startsWith("test ")) {
73
+ ctx.ui.notify("Usage: /notify test", "warning");
46
74
  return;
47
75
  }
48
76
 
@@ -101,16 +129,89 @@ async function setEnabled(enabled: boolean, ctx: ExtensionContext): Promise<void
101
129
  ctx.ui.notify(`Completion tune ${enabled ? "enabled" : "disabled"}.`, "info");
102
130
  }
103
131
 
132
+ async function ensureTuneWav(): Promise<void> {
133
+ await mkdir(dirname(WAV_PATH), { recursive: true });
134
+ await writeFile(WAV_PATH, createTuneWav(), "binary");
135
+ const wav = WAV_PATH.replace(/'/g, "''");
136
+ await writeFile(PS1_PATH, [
137
+ "$ErrorActionPreference = 'SilentlyContinue'",
138
+ `if (Test-Path '${wav}') {`,
139
+ ` $p = New-Object System.Media.SoundPlayer '${wav}'`,
140
+ " $p.Load()",
141
+ " $p.PlaySync()",
142
+ "}",
143
+ "[System.Media.SystemSounds]::Asterisk.Play()",
144
+ "Start-Sleep -Milliseconds 250",
145
+ "",
146
+ ].join("\n"), "utf8");
147
+ const ps1 = PS1_PATH.replace(/"/g, "\"\"");
148
+ await writeFile(VBS_PATH, [
149
+ "Set shell = CreateObject(\"WScript.Shell\")",
150
+ `shell.Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -File ""${ps1}""", 0, False`,
151
+ "",
152
+ ].join("\r\n"), "utf8");
153
+ }
154
+
155
+ function createTuneWav(): Buffer {
156
+ const sampleRate = 44100;
157
+ const notes = [
158
+ [659, 0.11],
159
+ [784, 0.11],
160
+ [988, 0.15],
161
+ [1319, 0.21],
162
+ [1175, 0.12],
163
+ [1319, 0.26],
164
+ ];
165
+ const gapSeconds = 0.035;
166
+ const samples: number[] = [];
167
+
168
+ for (const [freq, seconds] of notes) {
169
+ const count = Math.floor(sampleRate * seconds);
170
+ for (let i = 0; i < count; i++) {
171
+ const t = i / sampleRate;
172
+ const fadeIn = Math.min(1, i / 120);
173
+ const fadeOut = Math.min(1, (count - i) / 300);
174
+ const env = Math.min(fadeIn, fadeOut) * 0.28;
175
+ samples.push(Math.sin(2 * Math.PI * freq * t) * env);
176
+ }
177
+ for (let i = 0; i < Math.floor(sampleRate * gapSeconds); i++) samples.push(0);
178
+ }
179
+
180
+ const dataSize = samples.length * 2;
181
+ const buffer = Buffer.alloc(44 + dataSize);
182
+ buffer.write("RIFF", 0);
183
+ buffer.writeUInt32LE(36 + dataSize, 4);
184
+ buffer.write("WAVE", 8);
185
+ buffer.write("fmt ", 12);
186
+ buffer.writeUInt32LE(16, 16);
187
+ buffer.writeUInt16LE(1, 20);
188
+ buffer.writeUInt16LE(1, 22);
189
+ buffer.writeUInt32LE(sampleRate, 24);
190
+ buffer.writeUInt32LE(sampleRate * 2, 28);
191
+ buffer.writeUInt16LE(2, 32);
192
+ buffer.writeUInt16LE(16, 34);
193
+ buffer.write("data", 36);
194
+ buffer.writeUInt32LE(dataSize, 40);
195
+
196
+ let offset = 44;
197
+ for (const sample of samples) {
198
+ const value = Math.max(-1, Math.min(1, sample));
199
+ buffer.writeInt16LE(Math.round(value * 32767), offset);
200
+ offset += 2;
201
+ }
202
+ return buffer;
203
+ }
204
+
104
205
  function updateStatus(ctx: ExtensionContext): void {
105
206
  if (!ctx.hasUI) return;
106
207
  ctx.ui.setStatus("notify-sound", ctx.ui.theme.fg("dim", `tune:${config.enabled ? "on" : "off"}`));
107
208
  }
108
209
 
109
- function playCompletionTune(): void {
210
+ function playCompletionTune(method: NotifyMethod = "auto"): void {
110
211
  const os = platform();
111
212
 
112
213
  if (os === "win32") {
113
- playWindowsTune();
214
+ playWindowsTune(method);
114
215
  return;
115
216
  }
116
217
 
@@ -122,28 +223,16 @@ function playCompletionTune(): void {
122
223
  playLinuxTune();
123
224
  }
124
225
 
125
- function playWindowsTune(): void {
126
- // A short ascending arpeggio + resolution. This is a tune, not a single alert beep.
127
- const melody = [
128
- [659, 110], // E5
129
- [784, 110], // G5
130
- [988, 150], // B5
131
- [1319, 210], // E6
132
- [1175, 120], // D6
133
- [1319, 260], // E6
134
- ];
135
-
136
- const commands = melody
137
- .map(([frequency, duration]) => `[Console]::Beep(${frequency}, ${duration})`)
138
- .join("; Start-Sleep -Milliseconds 35; ");
226
+ function playWindowsTune(method: NotifyMethod): void {
227
+ // Windows uses the generated WAV path because Console.Beep, system sounds,
228
+ // and terminal bells are silent on many modern Windows machines.
229
+ playWindowsWav();
230
+ }
139
231
 
140
- runDetached("powershell.exe", [
141
- "-NoProfile",
142
- "-ExecutionPolicy",
143
- "Bypass",
144
- "-Command",
145
- commands,
146
- ]);
232
+ function playWindowsWav(): void {
233
+ // WScript launches the PowerShell player hidden without stealing focus. This
234
+ // is more reliable than spawning hidden PowerShell directly from Pi's TUI.
235
+ runDetached("wscript.exe", [VBS_PATH]);
147
236
  }
148
237
 
149
238
  function playLinuxTune(): void {
@@ -163,6 +252,7 @@ function runDetached(command: string, args: string[]): void {
163
252
  detached: true,
164
253
  stdio: "ignore",
165
254
  windowsHide: true,
255
+ shell: false,
166
256
  });
167
257
  child.unref();
168
258
  } catch {
@@ -17,7 +17,7 @@ const ROOT_COMPLETIONS: TakomiCompletion[] = [
17
17
  { value: "mode", label: "mode", description: "Set direct, orchestrate, or review mode" },
18
18
  { value: "gate", label: "gate", description: "Set auto or review-gated execution" },
19
19
  { value: "subagents", label: "subagents", description: "Control subagent usage and view" },
20
- { value: "stats", label: "stats", description: "Show token, model, project, and subagent usage stats" },
20
+ { value: "stats", label: "stats", description: "Show token, model, project, session, tool, and subagent usage stats" },
21
21
  { value: "routing", label: "routing", description: "Show or update Takomi model routing policy" },
22
22
  ];
23
23
 
@@ -44,7 +44,10 @@ const SUBCOMMAND_COMPLETIONS: Record<string, TakomiCompletion[]> = {
44
44
  { value: "daily", label: "daily", description: "Show daily usage rows" },
45
45
  { value: "models", label: "models", description: "Show model usage leaderboard" },
46
46
  { value: "projects", label: "projects", description: "Show project usage leaderboard" },
47
- { value: "agents", label: "agents", description: "Show subagent run leaderboard" },
47
+ { value: "sessions", label: "sessions", description: "Show longest/busiest main sessions" },
48
+ { value: "tools", label: "tools", description: "Show most used tools" },
49
+ { value: "agents", label: "agents", description: "Show main agent role leaderboard" },
50
+ { value: "subagents", label: "subagents", description: "Show subagent run leaderboard" },
48
51
  { value: "sources", label: "sources", description: "Show global/project source split" },
49
52
  { value: "since 7d", label: "since 7d", description: "Filter stats to the last 7 days" },
50
53
  { value: "since 14d", label: "since 14d", description: "Filter stats to the last 14 days" },
@@ -92,7 +95,7 @@ export function commandHelp(): string {
92
95
  "/takomi mode <direct|orchestrate|review>",
93
96
  "/takomi gate <auto|review>",
94
97
  "/takomi subagents <on|off|status|expand|collapse|toggle>",
95
- "/takomi stats [overview|daily|models|projects|agents|sources] [since 7d]",
98
+ "/takomi stats [overview|daily|models|projects|sessions|tools|subagents|sources] [since 7d]",
96
99
  "/takomi routing [show|where]",
97
100
  "/takomi routing <policy text> # updates global policy",
98
101
  "/takomi routing local <policy text> # project override",
@@ -257,7 +257,7 @@ export function registerTakomiCommands(pi: ExtensionAPI, options: RegisterTakomi
257
257
  });
258
258
 
259
259
  pi.registerCommand("takomi-stats", {
260
- description: "Show bundled Takomi/Pi token, model, project, and subagent usage stats",
260
+ description: "Show Takomi token, model, project, session, tool, and subagent usage stats",
261
261
  getArgumentCompletions: (argumentPrefix: string) => completions(`stats ${argumentPrefix}`).map((completion) => ({
262
262
  ...completion,
263
263
  value: completion.value.replace(/^stats\s+/, ""),
@@ -1,7 +1,18 @@
1
- import fs from 'fs-extra';
1
+ import { promises as fs } from 'node:fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
- import pc from 'picocolors';
4
+
5
+ const colorEnabled = process.env.NO_COLOR !== '1' && process.env.NO_COLOR !== 'true';
6
+ const ansi = (open, close) => (value) => colorEnabled ? `\u001b[${open}m${value}\u001b[${close}m` : String(value);
7
+ const pc = {
8
+ bold: ansi(1, 22),
9
+ dim: ansi(2, 22),
10
+ white: ansi(37, 39),
11
+ gray: ansi(90, 39),
12
+ cyan: ansi(36, 39),
13
+ blue: ansi(34, 39),
14
+ magenta: ansi(35, 39),
15
+ };
5
16
 
6
17
  const PRICES = {
7
18
  'gpt-5.5': [5.00, 0.50, 30.00],
@@ -21,6 +32,7 @@ const PRICES = {
21
32
  'claude-sonnet-4-6': [3.00, 0.30, 15.00],
22
33
  };
23
34
 
35
+ async function exists(target) { try { await fs.access(target); return true; } catch { return false; } }
24
36
  function safeJson(line) { try { return JSON.parse(line); } catch { return null; } }
25
37
  function dayOf(ts) { return typeof ts === 'string' && ts.length >= 10 ? ts.slice(0, 10) : 'unknown'; }
26
38
  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); }
@@ -66,7 +78,7 @@ function ansiPadStart(s, w) { return ' '.repeat(Math.max(0, w - visLen(s))) + s;
66
78
 
67
79
  async function files(root, suffix = '.jsonl') {
68
80
  const out = [];
69
- if (!root || !(await fs.pathExists(root))) return out;
81
+ if (!root || !(await exists(root))) return out;
70
82
  async function walk(dir) {
71
83
  for (const ent of await fs.readdir(dir, { withFileTypes: true })) {
72
84
  const p = path.join(dir, ent.name);
@@ -76,23 +88,62 @@ async function files(root, suffix = '.jsonl') {
76
88
  await walk(root); return out;
77
89
  }
78
90
 
79
- async function scanPiSessions(root, source, events) {
91
+ async function scanPiSessions(root, source, events, sessionRows = [], taskRows = []) {
80
92
  for (const file of await files(root)) {
81
- let provider = 'unknown', model = 'unknown', session = path.basename(file, '.jsonl');
93
+ let provider = 'unknown', model = 'unknown', session = path.basename(file, '.jsonl'), cwd = '', currentTask = null;
94
+ const row = { key: session, session, source, file, project: projectKey(file), cwd, start: '', end: '', turns: 0, messages: 0, toolCalls: 0, subagentCalls: 0, roles: new Map(), stages: new Map(), workflows: new Map(), activeMs: 0 };
82
95
  const text = await fs.readFile(file, 'utf8').catch(() => '');
83
96
  for (const line of text.split(/\r?\n/)) {
84
97
  const obj = safeJson(line); if (!obj) continue;
85
- if (obj.type === 'session') session = obj.id || session;
98
+ if (obj.timestamp) { row.start ||= obj.timestamp; row.end = obj.timestamp; }
99
+ if (obj.type === 'session') { session = obj.id || session; cwd = obj.cwd || cwd; row.key = session; row.session = session; row.cwd = cwd; }
86
100
  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) });
101
+ if (obj.type === 'custom' && obj.customType === 'takomi-runtime-state' && obj.data) {
102
+ const role = obj.data.role || 'unknown';
103
+ const stage = obj.data.stage || 'unknown';
104
+ const workflow = obj.data.workflow || 'unknown';
105
+ row.roles.set(role, (row.roles.get(role) || 0) + 1);
106
+ row.stages.set(stage, (row.stages.get(stage) || 0) + 1);
107
+ row.workflows.set(workflow, (row.workflows.get(workflow) || 0) + 1);
108
+ events.push({ source, file, timestamp: obj.timestamp, day: dayOf(obj.timestamp), session, provider, model, project: projectKey(file), kind: 'role', role, stage, workflow, input: 0, cache: 0, output: 0, total: 0, cost: 0 });
109
+ }
110
+ const msg = obj.type === 'message' && obj.message ? obj.message : null;
111
+ if (msg) {
112
+ row.messages += 1;
113
+ const ts = obj.timestamp || msg.timestamp || '';
114
+ if (msg.role === 'user') {
115
+ if (currentTask?.end && currentTask.end !== currentTask.start) taskRows.push(currentTask);
116
+ row.turns += 1;
117
+ const textPart = (msg.content || []).find(p => p?.type === 'text')?.text || '';
118
+ currentTask = { source, file, session, project: projectKey(file), cwd, start: ts, end: ts, provider, model, turns: 1, toolCalls: 0, title: String(textPart).replace(/\s+/g, ' ').trim() };
119
+ } else if (currentTask && ts) {
120
+ currentTask.end = ts;
121
+ }
122
+ for (const part of msg.content || []) {
123
+ if (!part || part.type !== 'toolCall') continue;
124
+ const name = part.name || 'unknown';
125
+ row.toolCalls += 1;
126
+ if (currentTask) currentTask.toolCalls += 1;
127
+ if (name === 'takomi_subagent') {
128
+ const args = part.arguments || {};
129
+ const count = Array.isArray(args.tasks) ? args.tasks.length : Array.isArray(args.chain) ? args.chain.length : 1;
130
+ row.subagentCalls += count;
131
+ }
132
+ events.push({ source, file, timestamp: obj.timestamp, day: dayOf(obj.timestamp), session, provider, model, project: projectKey(file), kind: 'tool', tool: name, input: 0, cache: 0, output: 0, total: 0, cost: 0 });
133
+ }
134
+ }
135
+ const u = msg && msg.usage;
136
+ if (u) events.push({ source, file, timestamp: obj.timestamp, day: dayOf(obj.timestamp), session, provider, model, project: projectKey(file), kind: 'usage', 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
137
  }
138
+ if (currentTask?.end && currentTask.end !== currentTask.start) taskRows.push(currentTask);
139
+ row.activeMs = taskRows.filter(t => t.file === file).reduce((sum, t) => sum + taskDuration(t), 0);
140
+ if (row.messages || row.toolCalls || row.turns) sessionRows.push(row);
90
141
  }
91
142
  }
92
143
 
93
144
  async function scanRunHistory(file) {
94
145
  const runs = [];
95
- if (!(await fs.pathExists(file))) return runs;
146
+ if (!(await exists(file))) return runs;
96
147
  const text = await fs.readFile(file, 'utf8').catch(() => '');
97
148
  for (const line of text.split(/\r?\n/)) { const o = safeJson(line); if (o) runs.push(o); }
98
149
  return runs;
@@ -101,24 +152,37 @@ async function scanRunHistory(file) {
101
152
  export async function collectTakomiStats(opts = {}) {
102
153
  const home = opts.home || os.homedir();
103
154
  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);
155
+ const rawEvents = [], rawSessions = [], rawTasks = [];
156
+ const globalSessions = path.resolve(path.join(home, '.pi', 'agent', 'sessions'));
157
+ const projectSessions = path.resolve(path.join(cwd, '.pi', 'agent', 'sessions'));
158
+ await scanPiSessions(globalSessions, 'pi-global', rawEvents, rawSessions, rawTasks);
159
+ if (projectSessions !== globalSessions) await scanPiSessions(projectSessions, 'pi-project', rawEvents, rawSessions, rawTasks);
160
+ await scanPiSessions(path.join(cwd, '.pi', 'takomi'), 'takomi-project', rawEvents, rawSessions, rawTasks);
108
161
  const sinceDay = parseSince(opts.since);
109
- const events = rawEvents
110
- .filter(e => !sinceDay || e.day >= sinceDay)
111
- .map(e => ({ ...e, project: projectKey(e.file) }));
162
+ const events = rawEvents.filter(e => !sinceDay || e.day >= sinceDay);
163
+ const sessionRows = rawSessions.filter(s => !sinceDay || dayOf(s.end || s.start) >= sinceDay);
164
+ const taskRows = rawTasks.filter(t => !sinceDay || dayOf(t.end || t.start) >= sinceDay);
112
165
  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 };
166
+ const byDay = new Map(), byModel = new Map(), bySource = new Map(), byProject = new Map(), byTool = new Map(), byRole = new Map(), byStage = new Map(), byWorkflow = new Map();
167
+ let totals = { input: 0, cache: 0, output: 0, total: 0, cost: 0, events: events.filter(e => e.kind === 'usage').length, toolCalls: 0, turns: 0 };
168
+ for (const s of sessionRows) { totals.toolCalls += s.toolCalls; totals.turns += s.turns; }
115
169
  for (const e of events) {
170
+ if (e.kind === 'tool') { add(byTool, e.tool || 'unknown', { total: 0, events: 1 }); continue; }
171
+ if (e.kind === 'role') {
172
+ add(byRole, e.role || 'unknown', { total: 0, events: 1 });
173
+ add(byStage, e.stage || 'unknown', { total: 0, events: 1 });
174
+ add(byWorkflow, e.workflow || 'unknown', { total: 0, events: 1 });
175
+ continue;
176
+ }
116
177
  totals.input += e.input; totals.cache += e.cache; totals.output += e.output; totals.total += e.total; totals.cost += e.cost;
117
178
  add(byDay, e.day, e); add(byModel, e.model, e); add(bySource, e.source, e); add(byProject, e.project, e);
118
179
  }
119
180
  const byAgent = new Map(); let longestRun = null;
120
181
  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) };
182
+ const topSessions = [...sessionRows].sort((a,b)=>(b.activeMs||0)-(a.activeMs||0) || b.turns-a.turns || b.toolCalls-a.toolCalls).slice(0, 20);
183
+ const topTasks = [...taskRows].sort((a,b)=>taskDuration(b)-taskDuration(a) || b.toolCalls-a.toolCalls).slice(0, 20);
184
+ const mostSubagentsSession = [...sessionRows].sort((a,b)=>b.subagentCalls-a.subagentCalls)[0] || null;
185
+ return { generatedAt: new Date().toISOString(), cwd, since: sinceDay, totals, sessions: new Set([...events.map(e => e.session), ...sessionRows.map(s => s.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), byTool: [...byTool.values()].sort((a,b)=>b.events-a.events), byRole: [...byRole.values()].sort((a,b)=>b.events-a.events), byStage: [...byStage.values()].sort((a,b)=>b.events-a.events), byWorkflow: [...byWorkflow.values()].sort((a,b)=>b.events-a.events), byAgent: [...byAgent.values()].sort((a,b)=>b.events-a.events), sessionRows, taskRows, topSessions, topTasks, mostSubagentsSession, runs, longestRun, recent: events.sort((a,b)=>(b.timestamp||'').localeCompare(a.timestamp||'')).slice(0, 10) };
122
186
  }
123
187
 
124
188
  // ── Streak Calculation ──────────────────────────────────────────────────────
@@ -263,6 +327,28 @@ function renderTable(title, rows, columns) {
263
327
  return lines.join('\n');
264
328
  }
265
329
 
330
+ function sessionLabel(row, width = 36) {
331
+ const project = row.project || row.cwd || row.key || 'unknown';
332
+ return project.length > width ? '…' + project.slice(-(width - 1)) : project;
333
+ }
334
+ function sessionDay(row) { return dayOf(row.start || row.end).slice(5) || '??-??'; }
335
+ function sessionDuration(row) {
336
+ return row?.activeMs || 0;
337
+ }
338
+ function taskDuration(row) {
339
+ const start = Date.parse(row?.start || '');
340
+ const end = Date.parse(row?.end || '');
341
+ return Number.isFinite(start) && Number.isFinite(end) && end >= start ? end - start : 0;
342
+ }
343
+ function taskLabel(row, width = 34) {
344
+ const label = row?.title || row?.project || row?.session || 'unknown';
345
+ return label.length > width ? label.slice(0, width - 1) + '…' : label;
346
+ }
347
+ function runLabel(run, width = 28) {
348
+ const label = run ? `${run.agent || 'unknown'}: ${run.task || ''}`.trim() : '-';
349
+ return label.length > width ? label.slice(0, width - 1) + '…' : label;
350
+ }
351
+
266
352
  function renderFocusedView(stats, opts = {}) {
267
353
  const view = opts.view;
268
354
  const limit = opts.limit || 20;
@@ -285,11 +371,36 @@ function renderFocusedView(stats, opts = {}) {
285
371
  { width: 10, align: 'right', get: r => pc.dim(fmtMoney(r.cost)) },
286
372
  { width: 12, align: 'right', get: r => pc.dim(r.events + ' calls') },
287
373
  ]],
288
- agents: ['Top Agents', stats.byAgent, [
374
+ agents: ['Main Agent Roles', stats.byRole, [
375
+ { width: 24, align: 'left', get: r => pc.white(r.key) },
376
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.events)) },
377
+ { width: 12, align: 'left', get: () => pc.dim('state hits') },
378
+ ]],
379
+ subagents: ['Top Subagents', stats.byAgent, [
289
380
  { width: 24, align: 'left', get: r => pc.white(r.key) },
290
381
  { width: 8, align: 'right', get: r => pc.cyan(String(r.events)) },
291
382
  { width: 8, align: 'left', get: () => pc.dim('runs') },
292
383
  ]],
384
+ tools: ['Top Tools', stats.byTool, [
385
+ { width: 28, align: 'left', get: r => pc.white(r.key) },
386
+ { width: 10, align: 'right', get: r => pc.cyan(String(r.events)) },
387
+ { width: 8, align: 'left', get: () => pc.dim('calls') },
388
+ ]],
389
+ sessions: ['Longest Active Sessions', stats.topSessions, [
390
+ { width: 6, align: 'left', get: r => pc.dim(sessionDay(r)) },
391
+ { width: 30, align: 'left', get: r => pc.white(sessionLabel(r, 30)) },
392
+ { width: 9, align: 'right', get: r => pc.cyan(ms(sessionDuration(r))) },
393
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.turns)) },
394
+ { width: 8, align: 'left', get: () => pc.dim('turns') },
395
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.toolCalls)) },
396
+ { width: 8, align: 'left', get: () => pc.dim('tools') },
397
+ ]],
398
+ tasks: ['Longest Tasks', stats.topTasks || [], [
399
+ { width: 6, align: 'left', get: r => pc.dim(dayOf(r.start).slice(5)) },
400
+ { width: 9, align: 'right', get: r => pc.cyan(ms(taskDuration(r))) },
401
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.toolCalls)) },
402
+ { width: 36, align: 'left', get: r => pc.white(taskLabel(r, 36)) },
403
+ ]],
293
404
  daily: ['Daily Usage', [...stats.byDay].reverse(), [
294
405
  { width: 12, align: 'left', get: r => pc.white(r.key) },
295
406
  { width: 10, align: 'right', get: r => pc.cyan(fmtTokens(r.total)) },
@@ -312,6 +423,8 @@ export function renderTakomiStats(stats, opts = {}) {
312
423
  const topModel = stats.byModel[0]?.key || 'unknown';
313
424
  const peak = stats.byDay.reduce((a,b) => b.total > (a?.total||0) ? b : a, null);
314
425
  const streaks = calcStreaks(stats.byDay);
426
+ const longestSession = stats.topSessions[0] || null;
427
+ const longestTask = stats.topTasks?.[0] || null;
315
428
  const lines = [];
316
429
 
317
430
  // ── Header ────────────────────────────────────────────────────────────
@@ -330,7 +443,7 @@ export function renderTakomiStats(stats, opts = {}) {
330
443
  statCard(fmtTokens(stats.totals.cache), 'Cache Tokens'),
331
444
  statCard(fmtMoney(stats.totals.cost), 'Est. Cost'),
332
445
  statCard(String(stats.sessions), 'Sessions'),
333
- statCard(String(stats.runs.length), 'Agent Runs'),
446
+ statCard(String(stats.totals.turns), 'Main Turns'),
334
447
  ];
335
448
 
336
449
  const cardW = Math.floor((W - 4) / cards1.length);
@@ -361,7 +474,7 @@ export function renderTakomiStats(stats, opts = {}) {
361
474
  const cards2 = [
362
475
  statCard(peak ? fmtTokens(peak.total) : '-', 'Peak Day'),
363
476
  statCard(topModel, 'Top Model'),
364
- statCard(ms(stats.longestRun?.duration), 'Longest Run'),
477
+ statCard(String(stats.totals.toolCalls), 'Tool Calls'),
365
478
  statCard(`${streaks.current} days`, 'Current Streak'),
366
479
  statCard(`${streaks.longest} days`, 'Longest Streak'),
367
480
  ];
@@ -370,6 +483,19 @@ export function renderTakomiStats(stats, opts = {}) {
370
483
  lines.push(v2);
371
484
  lines.push(l2);
372
485
 
486
+ // ── Duration Cards ────────────────────────────────────────────────────
487
+ lines.push('');
488
+ const cards3 = [
489
+ statCard(longestSession ? ms(sessionDuration(longestSession)) : '-', 'Longest Session'),
490
+ statCard(longestSession ? String(longestSession.turns) : '-', 'Session Turns'),
491
+ statCard(longestTask ? ms(taskDuration(longestTask)) : '-', 'Longest Task'),
492
+ statCard(longestTask ? String(longestTask.toolCalls) : '-', 'Task Tools'),
493
+ statCard(stats.mostSubagentsSession ? String(stats.mostSubagentsSession.subagentCalls) : '0', 'Most Subagents'),
494
+ ];
495
+ const [v3, l3] = buildCardLines(cards3);
496
+ lines.push(v3);
497
+ lines.push(l3);
498
+
373
499
  // ── Info line ─────────────────────────────────────────────────────────
374
500
  lines.push('');
375
501
  const infoText = `Peak: ${peak?.key || '-'} · ${streaks.quietDays} quiet days · ${stats.totals.events.toLocaleString()} events${stats.since ? ` · since ${stats.since}` : ''}`;
@@ -404,6 +530,61 @@ export function renderTakomiStats(stats, opts = {}) {
404
530
  ]));
405
531
  }
406
532
 
533
+ // ── Main Agent Roles Table ──────────────────────────────────────────────
534
+ if (stats.byRole.length) {
535
+ lines.push('');
536
+ lines.push(renderTable('Main Agent Roles', stats.byRole.slice(0, modelLimit), [
537
+ { width: 20, align: 'left', get: r => pc.white(r.key) },
538
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.events)) },
539
+ { width: 10, align: 'left', get: r => pc.dim('state hits') },
540
+ ]));
541
+ }
542
+
543
+ // ── Main Session Table ──────────────────────────────────────────────────
544
+ if (stats.topSessions.length) {
545
+ lines.push('');
546
+ lines.push(renderTable('Longest Active Sessions', stats.topSessions.slice(0, 5), [
547
+ { width: 6, align: 'left', get: r => pc.dim(sessionDay(r)) },
548
+ { width: 28, align: 'left', get: r => pc.white(sessionLabel(r, 28)) },
549
+ { width: 9, align: 'right', get: r => pc.cyan(ms(sessionDuration(r))) },
550
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.turns)) },
551
+ { width: 8, align: 'left', get: r => pc.dim('turns') },
552
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.toolCalls)) },
553
+ { width: 6, align: 'left', get: r => pc.dim('tools') },
554
+ ]));
555
+ }
556
+
557
+ // ── Longest Tasks ──────────────────────────────────────────────────────
558
+ if (stats.topTasks?.length) {
559
+ lines.push('');
560
+ lines.push(renderTable('Longest Tasks', stats.topTasks.slice(0, 5), [
561
+ { width: 6, align: 'left', get: r => pc.dim(dayOf(r.start).slice(5)) },
562
+ { width: 9, align: 'right', get: r => pc.cyan(ms(taskDuration(r))) },
563
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.toolCalls)) },
564
+ { width: 34, align: 'left', get: r => pc.white(taskLabel(r, 34)) },
565
+ ]));
566
+ }
567
+
568
+ // ── Longest Subagent Run ───────────────────────────────────────────────
569
+ if (stats.longestRun) {
570
+ lines.push('');
571
+ lines.push(renderTable('Longest Subagent Run', [stats.longestRun], [
572
+ { width: 22, align: 'left', get: r => pc.white(r.agent || 'unknown') },
573
+ { width: 10, align: 'right', get: r => pc.cyan(ms(+r.duration || 0)) },
574
+ { width: 30, align: 'left', get: r => pc.dim(runLabel(r, 30)) },
575
+ ]));
576
+ }
577
+
578
+ // ── Tools Table ─────────────────────────────────────────────────────────
579
+ if (stats.byTool.length) {
580
+ lines.push('');
581
+ lines.push(renderTable('Top Tools', stats.byTool.slice(0, modelLimit), [
582
+ { width: 24, align: 'left', get: r => pc.white(r.key) },
583
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.events)) },
584
+ { width: 6, align: 'left', get: r => pc.dim('calls') },
585
+ ]));
586
+ }
587
+
407
588
  // ── Sources Table ───────────────────────────────────────────────────────
408
589
  lines.push('');
409
590
  lines.push(renderTable('Sources', stats.bySource, [
@@ -412,10 +593,10 @@ export function renderTakomiStats(stats, opts = {}) {
412
593
  { width: 14, align: 'right', get: r => pc.dim(r.events + ' events') },
413
594
  ]));
414
595
 
415
- // ── Agents Table ────────────────────────────────────────────────────────
596
+ // ── Subagents Table ─────────────────────────────────────────────────────
416
597
  if (stats.byAgent.length) {
417
598
  lines.push('');
418
- lines.push(renderTable('Top Agents', stats.byAgent.slice(0, modelLimit), [
599
+ lines.push(renderTable('Top Subagents', stats.byAgent.slice(0, modelLimit), [
419
600
  { width: 20, align: 'left', get: r => pc.white(r.key) },
420
601
  { width: 8, align: 'right', get: r => pc.cyan(String(r.events)) },
421
602
  { width: 6, align: 'left', get: r => pc.dim('runs') },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "takomi",
3
- "version": "2.1.19",
3
+ "version": "2.1.21",
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": {
package/src/cli.js CHANGED
@@ -846,7 +846,7 @@ program
846
846
 
847
847
  program
848
848
  .command('stats [view]')
849
- .description('Show bundled Takomi/Pi token, model, project, and subagent usage stats')
849
+ .description('Show Takomi token, model, project, session, tool, and subagent usage stats')
850
850
  .option('--json', 'Print machine-readable JSON')
851
851
  .option('--home <path>', 'Override home directory for Pi history scanning')
852
852
  .option('--cwd <path>', 'Override project directory for project-local stats')
@@ -173,6 +173,7 @@ export async function validatePiHarnessInstall() {
173
173
  subagents: await fs.pathExists(path.join(targets.extensions, 'takomi-subagents')),
174
174
  contextManager: await fs.pathExists(path.join(targets.extensions, 'takomi-context-manager')),
175
175
  oauthRouter: await fs.pathExists(path.join(targets.extensions, 'oauth-router')),
176
+ notifySound: await fs.pathExists(path.join(targets.extensions, 'notify-sound')),
176
177
  prompts: await fs.pathExists(targets.prompts),
177
178
  agents: await fs.pathExists(targets.agents),
178
179
  themes: await fs.pathExists(targets.themes),
@@ -187,7 +188,7 @@ export function printPiInstallSummary(result, validation) {
187
188
  console.log(pc.green('\n✔ Installed Takomi Pi harness assets'));
188
189
  console.log(pc.white(` Root: ${result.targets.root}`));
189
190
  console.log(pc.white(` Manifest: ${PI_MANIFEST_PATH}`));
190
- console.log(pc.white(` Extensions: ${validation.runtime && validation.subagents && validation.contextManager && validation.oauthRouter ? 'ok' : 'check needed'}`));
191
+ console.log(pc.white(` Extensions: ${validation.runtime && validation.subagents && validation.contextManager && validation.oauthRouter && validation.notifySound ? 'ok' : 'check needed'}`));
191
192
  console.log(pc.white(` Prompts: ${validation.prompts ? 'ok' : 'missing'}`));
192
193
  console.log(pc.white(` Agents: ${validation.agents ? 'ok' : 'missing'}`));
193
194
  console.log(pc.white(` Themes: ${validation.themes ? 'ok' : 'missing'}`));
@@ -1,7 +1,18 @@
1
- import fs from 'fs-extra';
1
+ import { promises as fs } from 'node:fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
- import pc from 'picocolors';
4
+
5
+ const colorEnabled = process.env.NO_COLOR !== '1' && process.env.NO_COLOR !== 'true';
6
+ const ansi = (open, close) => (value) => colorEnabled ? `\u001b[${open}m${value}\u001b[${close}m` : String(value);
7
+ const pc = {
8
+ bold: ansi(1, 22),
9
+ dim: ansi(2, 22),
10
+ white: ansi(37, 39),
11
+ gray: ansi(90, 39),
12
+ cyan: ansi(36, 39),
13
+ blue: ansi(34, 39),
14
+ magenta: ansi(35, 39),
15
+ };
5
16
 
6
17
  const PRICES = {
7
18
  'gpt-5.5': [5.00, 0.50, 30.00],
@@ -21,6 +32,7 @@ const PRICES = {
21
32
  'claude-sonnet-4-6': [3.00, 0.30, 15.00],
22
33
  };
23
34
 
35
+ async function exists(target) { try { await fs.access(target); return true; } catch { return false; } }
24
36
  function safeJson(line) { try { return JSON.parse(line); } catch { return null; } }
25
37
  function dayOf(ts) { return typeof ts === 'string' && ts.length >= 10 ? ts.slice(0, 10) : 'unknown'; }
26
38
  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); }
@@ -66,7 +78,7 @@ function ansiPadStart(s, w) { return ' '.repeat(Math.max(0, w - visLen(s))) + s;
66
78
 
67
79
  async function files(root, suffix = '.jsonl') {
68
80
  const out = [];
69
- if (!root || !(await fs.pathExists(root))) return out;
81
+ if (!root || !(await exists(root))) return out;
70
82
  async function walk(dir) {
71
83
  for (const ent of await fs.readdir(dir, { withFileTypes: true })) {
72
84
  const p = path.join(dir, ent.name);
@@ -76,23 +88,51 @@ async function files(root, suffix = '.jsonl') {
76
88
  await walk(root); return out;
77
89
  }
78
90
 
79
- async function scanPiSessions(root, source, events) {
91
+ async function scanPiSessions(root, source, events, sessionRows = []) {
80
92
  for (const file of await files(root)) {
81
- let provider = 'unknown', model = 'unknown', session = path.basename(file, '.jsonl');
93
+ let provider = 'unknown', model = 'unknown', session = path.basename(file, '.jsonl'), cwd = '';
94
+ const row = { key: session, session, source, file, project: projectKey(file), cwd, start: '', end: '', turns: 0, messages: 0, toolCalls: 0, subagentCalls: 0, roles: new Map(), stages: new Map(), workflows: new Map() };
82
95
  const text = await fs.readFile(file, 'utf8').catch(() => '');
83
96
  for (const line of text.split(/\r?\n/)) {
84
97
  const obj = safeJson(line); if (!obj) continue;
85
- if (obj.type === 'session') session = obj.id || session;
98
+ if (obj.timestamp) { row.start ||= obj.timestamp; row.end = obj.timestamp; }
99
+ if (obj.type === 'session') { session = obj.id || session; cwd = obj.cwd || cwd; row.key = session; row.session = session; row.cwd = cwd; }
86
100
  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) });
101
+ if (obj.type === 'custom' && obj.customType === 'takomi-runtime-state' && obj.data) {
102
+ const role = obj.data.role || 'unknown';
103
+ const stage = obj.data.stage || 'unknown';
104
+ const workflow = obj.data.workflow || 'unknown';
105
+ row.roles.set(role, (row.roles.get(role) || 0) + 1);
106
+ row.stages.set(stage, (row.stages.get(stage) || 0) + 1);
107
+ row.workflows.set(workflow, (row.workflows.get(workflow) || 0) + 1);
108
+ events.push({ source, file, timestamp: obj.timestamp, day: dayOf(obj.timestamp), session, provider, model, project: projectKey(file), kind: 'role', role, stage, workflow, input: 0, cache: 0, output: 0, total: 0, cost: 0 });
109
+ }
110
+ const msg = obj.type === 'message' && obj.message ? obj.message : null;
111
+ if (msg) {
112
+ row.messages += 1;
113
+ if (msg.role === 'user') row.turns += 1;
114
+ for (const part of msg.content || []) {
115
+ if (!part || part.type !== 'toolCall') continue;
116
+ const name = part.name || 'unknown';
117
+ row.toolCalls += 1;
118
+ if (name === 'takomi_subagent') {
119
+ const args = part.arguments || {};
120
+ const count = Array.isArray(args.tasks) ? args.tasks.length : Array.isArray(args.chain) ? args.chain.length : 1;
121
+ row.subagentCalls += count;
122
+ }
123
+ events.push({ source, file, timestamp: obj.timestamp, day: dayOf(obj.timestamp), session, provider, model, project: projectKey(file), kind: 'tool', tool: name, input: 0, cache: 0, output: 0, total: 0, cost: 0 });
124
+ }
125
+ }
126
+ const u = msg && msg.usage;
127
+ if (u) events.push({ source, file, timestamp: obj.timestamp, day: dayOf(obj.timestamp), session, provider, model, project: projectKey(file), kind: 'usage', 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
128
  }
129
+ if (row.messages || row.toolCalls || row.turns) sessionRows.push(row);
90
130
  }
91
131
  }
92
132
 
93
133
  async function scanRunHistory(file) {
94
134
  const runs = [];
95
- if (!(await fs.pathExists(file))) return runs;
135
+ if (!(await exists(file))) return runs;
96
136
  const text = await fs.readFile(file, 'utf8').catch(() => '');
97
137
  for (const line of text.split(/\r?\n/)) { const o = safeJson(line); if (o) runs.push(o); }
98
138
  return runs;
@@ -101,24 +141,33 @@ async function scanRunHistory(file) {
101
141
  export async function collectTakomiStats(opts = {}) {
102
142
  const home = opts.home || os.homedir();
103
143
  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);
144
+ const rawEvents = [], rawSessions = [];
145
+ await scanPiSessions(path.join(home, '.pi', 'agent', 'sessions'), 'pi-global', rawEvents, rawSessions);
146
+ await scanPiSessions(path.join(cwd, '.pi', 'agent', 'sessions'), 'pi-project', rawEvents, rawSessions);
147
+ await scanPiSessions(path.join(cwd, '.pi', 'takomi'), 'takomi-project', rawEvents, rawSessions);
108
148
  const sinceDay = parseSince(opts.since);
109
- const events = rawEvents
110
- .filter(e => !sinceDay || e.day >= sinceDay)
111
- .map(e => ({ ...e, project: projectKey(e.file) }));
149
+ const events = rawEvents.filter(e => !sinceDay || e.day >= sinceDay);
150
+ const sessionRows = rawSessions.filter(s => !sinceDay || dayOf(s.end || s.start) >= sinceDay);
112
151
  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 };
152
+ const byDay = new Map(), byModel = new Map(), bySource = new Map(), byProject = new Map(), byTool = new Map(), byRole = new Map(), byStage = new Map(), byWorkflow = new Map();
153
+ let totals = { input: 0, cache: 0, output: 0, total: 0, cost: 0, events: events.filter(e => e.kind === 'usage').length, toolCalls: 0, turns: 0 };
154
+ for (const s of sessionRows) { totals.toolCalls += s.toolCalls; totals.turns += s.turns; }
115
155
  for (const e of events) {
156
+ if (e.kind === 'tool') { add(byTool, e.tool || 'unknown', { total: 0, events: 1 }); continue; }
157
+ if (e.kind === 'role') {
158
+ add(byRole, e.role || 'unknown', { total: 0, events: 1 });
159
+ add(byStage, e.stage || 'unknown', { total: 0, events: 1 });
160
+ add(byWorkflow, e.workflow || 'unknown', { total: 0, events: 1 });
161
+ continue;
162
+ }
116
163
  totals.input += e.input; totals.cache += e.cache; totals.output += e.output; totals.total += e.total; totals.cost += e.cost;
117
164
  add(byDay, e.day, e); add(byModel, e.model, e); add(bySource, e.source, e); add(byProject, e.project, e);
118
165
  }
119
166
  const byAgent = new Map(); let longestRun = null;
120
167
  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) };
168
+ const topSessions = [...sessionRows].sort((a,b)=>b.turns-a.turns || b.toolCalls-a.toolCalls).slice(0, 20);
169
+ const mostSubagentsSession = [...sessionRows].sort((a,b)=>b.subagentCalls-a.subagentCalls)[0] || null;
170
+ return { generatedAt: new Date().toISOString(), cwd, since: sinceDay, totals, sessions: new Set([...events.map(e => e.session), ...sessionRows.map(s => s.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), byTool: [...byTool.values()].sort((a,b)=>b.events-a.events), byRole: [...byRole.values()].sort((a,b)=>b.events-a.events), byStage: [...byStage.values()].sort((a,b)=>b.events-a.events), byWorkflow: [...byWorkflow.values()].sort((a,b)=>b.events-a.events), byAgent: [...byAgent.values()].sort((a,b)=>b.events-a.events), sessionRows, topSessions, mostSubagentsSession, runs, longestRun, recent: events.sort((a,b)=>(b.timestamp||'').localeCompare(a.timestamp||'')).slice(0, 10) };
122
171
  }
123
172
 
124
173
  // ── Streak Calculation ──────────────────────────────────────────────────────
@@ -263,6 +312,12 @@ function renderTable(title, rows, columns) {
263
312
  return lines.join('\n');
264
313
  }
265
314
 
315
+ function sessionLabel(row, width = 36) {
316
+ const project = row.project || row.cwd || row.key || 'unknown';
317
+ return project.length > width ? '…' + project.slice(-(width - 1)) : project;
318
+ }
319
+ function sessionDay(row) { return dayOf(row.start || row.end).slice(5) || '??-??'; }
320
+
266
321
  function renderFocusedView(stats, opts = {}) {
267
322
  const view = opts.view;
268
323
  const limit = opts.limit || 20;
@@ -285,11 +340,29 @@ function renderFocusedView(stats, opts = {}) {
285
340
  { width: 10, align: 'right', get: r => pc.dim(fmtMoney(r.cost)) },
286
341
  { width: 12, align: 'right', get: r => pc.dim(r.events + ' calls') },
287
342
  ]],
288
- agents: ['Top Agents', stats.byAgent, [
343
+ agents: ['Main Agent Roles', stats.byRole, [
344
+ { width: 24, align: 'left', get: r => pc.white(r.key) },
345
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.events)) },
346
+ { width: 12, align: 'left', get: () => pc.dim('state hits') },
347
+ ]],
348
+ subagents: ['Top Subagents', stats.byAgent, [
289
349
  { width: 24, align: 'left', get: r => pc.white(r.key) },
290
350
  { width: 8, align: 'right', get: r => pc.cyan(String(r.events)) },
291
351
  { width: 8, align: 'left', get: () => pc.dim('runs') },
292
352
  ]],
353
+ tools: ['Top Tools', stats.byTool, [
354
+ { width: 28, align: 'left', get: r => pc.white(r.key) },
355
+ { width: 10, align: 'right', get: r => pc.cyan(String(r.events)) },
356
+ { width: 8, align: 'left', get: () => pc.dim('calls') },
357
+ ]],
358
+ sessions: ['Longest Main Sessions', stats.topSessions, [
359
+ { width: 6, align: 'left', get: r => pc.dim(sessionDay(r)) },
360
+ { width: 34, align: 'left', get: r => pc.white(sessionLabel(r, 34)) },
361
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.turns)) },
362
+ { width: 8, align: 'left', get: () => pc.dim('turns') },
363
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.toolCalls)) },
364
+ { width: 8, align: 'left', get: () => pc.dim('tools') },
365
+ ]],
293
366
  daily: ['Daily Usage', [...stats.byDay].reverse(), [
294
367
  { width: 12, align: 'left', get: r => pc.white(r.key) },
295
368
  { width: 10, align: 'right', get: r => pc.cyan(fmtTokens(r.total)) },
@@ -330,7 +403,7 @@ export function renderTakomiStats(stats, opts = {}) {
330
403
  statCard(fmtTokens(stats.totals.cache), 'Cache Tokens'),
331
404
  statCard(fmtMoney(stats.totals.cost), 'Est. Cost'),
332
405
  statCard(String(stats.sessions), 'Sessions'),
333
- statCard(String(stats.runs.length), 'Agent Runs'),
406
+ statCard(String(stats.totals.turns), 'Main Turns'),
334
407
  ];
335
408
 
336
409
  const cardW = Math.floor((W - 4) / cards1.length);
@@ -361,7 +434,7 @@ export function renderTakomiStats(stats, opts = {}) {
361
434
  const cards2 = [
362
435
  statCard(peak ? fmtTokens(peak.total) : '-', 'Peak Day'),
363
436
  statCard(topModel, 'Top Model'),
364
- statCard(ms(stats.longestRun?.duration), 'Longest Run'),
437
+ statCard(String(stats.totals.toolCalls), 'Tool Calls'),
365
438
  statCard(`${streaks.current} days`, 'Current Streak'),
366
439
  statCard(`${streaks.longest} days`, 'Longest Streak'),
367
440
  ];
@@ -404,6 +477,39 @@ export function renderTakomiStats(stats, opts = {}) {
404
477
  ]));
405
478
  }
406
479
 
480
+ // ── Main Agent Roles Table ──────────────────────────────────────────────
481
+ if (stats.byRole.length) {
482
+ lines.push('');
483
+ lines.push(renderTable('Main Agent Roles', stats.byRole.slice(0, modelLimit), [
484
+ { width: 20, align: 'left', get: r => pc.white(r.key) },
485
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.events)) },
486
+ { width: 10, align: 'left', get: r => pc.dim('state hits') },
487
+ ]));
488
+ }
489
+
490
+ // ── Main Session Table ──────────────────────────────────────────────────
491
+ if (stats.topSessions.length) {
492
+ lines.push('');
493
+ lines.push(renderTable('Longest Main Sessions', stats.topSessions.slice(0, 5), [
494
+ { width: 6, align: 'left', get: r => pc.dim(sessionDay(r)) },
495
+ { width: 32, align: 'left', get: r => pc.white(sessionLabel(r, 32)) },
496
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.turns)) },
497
+ { width: 8, align: 'left', get: r => pc.dim('turns') },
498
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.toolCalls)) },
499
+ { width: 6, align: 'left', get: r => pc.dim('tools') },
500
+ ]));
501
+ }
502
+
503
+ // ── Tools Table ─────────────────────────────────────────────────────────
504
+ if (stats.byTool.length) {
505
+ lines.push('');
506
+ lines.push(renderTable('Top Tools', stats.byTool.slice(0, modelLimit), [
507
+ { width: 24, align: 'left', get: r => pc.white(r.key) },
508
+ { width: 8, align: 'right', get: r => pc.cyan(String(r.events)) },
509
+ { width: 6, align: 'left', get: r => pc.dim('calls') },
510
+ ]));
511
+ }
512
+
407
513
  // ── Sources Table ───────────────────────────────────────────────────────
408
514
  lines.push('');
409
515
  lines.push(renderTable('Sources', stats.bySource, [
@@ -412,10 +518,10 @@ export function renderTakomiStats(stats, opts = {}) {
412
518
  { width: 14, align: 'right', get: r => pc.dim(r.events + ' events') },
413
519
  ]));
414
520
 
415
- // ── Agents Table ────────────────────────────────────────────────────────
521
+ // ── Subagents Table ─────────────────────────────────────────────────────
416
522
  if (stats.byAgent.length) {
417
523
  lines.push('');
418
- lines.push(renderTable('Top Agents', stats.byAgent.slice(0, modelLimit), [
524
+ lines.push(renderTable('Top Subagents', stats.byAgent.slice(0, modelLimit), [
419
525
  { width: 20, align: 'left', get: r => pc.white(r.key) },
420
526
  { width: 8, align: 'right', get: r => pc.cyan(String(r.events)) },
421
527
  { width: 6, align: 'left', get: r => pc.dim('runs') },