takomi 2.1.17 → 2.1.18

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.
@@ -0,0 +1,171 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { platform } from "node:os";
5
+ import { spawn } from "node:child_process";
6
+
7
+ type NotifySoundConfig = {
8
+ enabled: boolean;
9
+ };
10
+
11
+ const CONFIG_PATH = join(process.cwd(), ".pi", "notify-sound.json");
12
+ const STALE_AGENT_START_MS = 24 * 60 * 60 * 1000;
13
+
14
+ let config: NotifySoundConfig = { enabled: true };
15
+ let lastAgentStartedAt = 0;
16
+
17
+ export default async function notifySoundExtension(pi: ExtensionAPI) {
18
+ await loadConfig();
19
+
20
+ pi.on("session_start", async (_event, ctx) => {
21
+ updateStatus(ctx);
22
+ });
23
+
24
+ pi.on("agent_start", async () => {
25
+ lastAgentStartedAt = Date.now();
26
+ });
27
+
28
+ pi.on("agent_end", async (_event, ctx) => {
29
+ updateStatus(ctx);
30
+
31
+ if (!config.enabled) return;
32
+ if (!lastAgentStartedAt) return;
33
+ if (Date.now() - lastAgentStartedAt > STALE_AGENT_START_MS) return;
34
+
35
+ playCompletionTune();
36
+ });
37
+
38
+ const command = {
39
+ description: "Toggle or test the agent completion tune notification",
40
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
41
+ const action = args.trim().toLowerCase() || "toggle";
42
+
43
+ if (action === "test") {
44
+ playCompletionTune();
45
+ ctx.ui.notify("Played completion tune.", "info");
46
+ return;
47
+ }
48
+
49
+ if (action === "status") {
50
+ updateStatus(ctx);
51
+ ctx.ui.notify(`Completion tune is ${config.enabled ? "ON" : "OFF"}.`, "info");
52
+ return;
53
+ }
54
+
55
+ if (action === "on") {
56
+ await setEnabled(true, ctx);
57
+ return;
58
+ }
59
+
60
+ if (action === "off") {
61
+ await setEnabled(false, ctx);
62
+ return;
63
+ }
64
+
65
+ if (action === "toggle") {
66
+ await setEnabled(!config.enabled, ctx);
67
+ return;
68
+ }
69
+
70
+ ctx.ui.notify("Usage: /notify [test|status|on|off|toggle]", "warning");
71
+ },
72
+ };
73
+
74
+ pi.registerCommand("notify-sound", command);
75
+ pi.registerCommand("notify", {
76
+ ...command,
77
+ description: "Alias for /notify-sound",
78
+ });
79
+ }
80
+
81
+ async function loadConfig(): Promise<void> {
82
+ try {
83
+ const raw = await readFile(CONFIG_PATH, "utf8");
84
+ const parsed = JSON.parse(raw) as Partial<NotifySoundConfig>;
85
+ config = { enabled: parsed.enabled !== false };
86
+ } catch {
87
+ config = { enabled: true };
88
+ await saveConfig();
89
+ }
90
+ }
91
+
92
+ async function saveConfig(): Promise<void> {
93
+ await mkdir(dirname(CONFIG_PATH), { recursive: true });
94
+ await writeFile(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
95
+ }
96
+
97
+ async function setEnabled(enabled: boolean, ctx: ExtensionContext): Promise<void> {
98
+ config.enabled = enabled;
99
+ await saveConfig();
100
+ updateStatus(ctx);
101
+ ctx.ui.notify(`Completion tune ${enabled ? "enabled" : "disabled"}.`, "info");
102
+ }
103
+
104
+ function updateStatus(ctx: ExtensionContext): void {
105
+ if (!ctx.hasUI) return;
106
+ ctx.ui.setStatus("notify-sound", ctx.ui.theme.fg("dim", `tune:${config.enabled ? "on" : "off"}`));
107
+ }
108
+
109
+ function playCompletionTune(): void {
110
+ const os = platform();
111
+
112
+ if (os === "win32") {
113
+ playWindowsTune();
114
+ return;
115
+ }
116
+
117
+ if (os === "darwin") {
118
+ runDetached("osascript", ["-e", "beep 1", "-e", "delay 0.12", "-e", "beep 1", "-e", "delay 0.12", "-e", "beep 2"]);
119
+ return;
120
+ }
121
+
122
+ playLinuxTune();
123
+ }
124
+
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; ");
139
+
140
+ runDetached("powershell.exe", [
141
+ "-NoProfile",
142
+ "-ExecutionPolicy",
143
+ "Bypass",
144
+ "-Command",
145
+ commands,
146
+ ]);
147
+ }
148
+
149
+ function playLinuxTune(): void {
150
+ // Prefer shell printf bells for broad compatibility; terminal may silence them.
151
+ // If paplay/aplay exists, play the freedesktop complete sound as an additional fallback.
152
+ runDetached("sh", [
153
+ "-c",
154
+ "(command -v paplay >/dev/null 2>&1 && paplay /usr/share/sounds/freedesktop/stereo/complete.oga >/dev/null 2>&1) || " +
155
+ "(command -v aplay >/dev/null 2>&1 && aplay /usr/share/sounds/alsa/Front_Center.wav >/dev/null 2>&1) || " +
156
+ "printf '\\a'; sleep 0.12; printf '\\a'; sleep 0.12; printf '\\a'",
157
+ ]);
158
+ }
159
+
160
+ function runDetached(command: string, args: string[]): void {
161
+ try {
162
+ const child = spawn(command, args, {
163
+ detached: true,
164
+ stdio: "ignore",
165
+ windowsHide: true,
166
+ });
167
+ child.unref();
168
+ } catch {
169
+ // Notification failures should never interrupt pi.
170
+ }
171
+ }
@@ -9,6 +9,7 @@ export type TakomiCompletion = {
9
9
  };
10
10
 
11
11
  const ROOT_COMPLETIONS: TakomiCompletion[] = [
12
+ { value: "help", label: "help", description: "Show the Takomi command guide" },
12
13
  { value: "genesis", label: "genesis", description: "Run the Genesis planning stage" },
13
14
  { value: "design", label: "design", description: "Run UI/UX design from approved scope" },
14
15
  { value: "build", label: "build", description: "Implement against the agreed UI" },
@@ -16,6 +17,7 @@ const ROOT_COMPLETIONS: TakomiCompletion[] = [
16
17
  { value: "mode", label: "mode", description: "Set direct, orchestrate, or review mode" },
17
18
  { value: "gate", label: "gate", description: "Set auto or review-gated execution" },
18
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" },
19
21
  { value: "routing", label: "routing", description: "Show or update Takomi model routing policy" },
20
22
  ];
21
23
 
@@ -37,6 +39,18 @@ const SUBCOMMAND_COMPLETIONS: Record<string, TakomiCompletion[]> = {
37
39
  { value: "collapse", label: "collapse", description: "Collapse native tool results" },
38
40
  { value: "toggle", label: "toggle", description: "Toggle native tool result expansion" },
39
41
  ],
42
+ stats: [
43
+ { value: "overview", label: "overview", description: "Show the full profile-card dashboard" },
44
+ { value: "daily", label: "daily", description: "Show daily usage rows" },
45
+ { value: "models", label: "models", description: "Show model usage leaderboard" },
46
+ { value: "projects", label: "projects", description: "Show project usage leaderboard" },
47
+ { value: "agents", label: "agents", description: "Show subagent run leaderboard" },
48
+ { value: "sources", label: "sources", description: "Show global/project source split" },
49
+ { value: "since 7d", label: "since 7d", description: "Filter stats to the last 7 days" },
50
+ { value: "since 14d", label: "since 14d", description: "Filter stats to the last 14 days" },
51
+ { value: "since 4w", label: "since 4w", description: "Filter stats to the last 4 weeks" },
52
+ { value: "since 3m", label: "since 3m", description: "Filter stats to the last 3 months" },
53
+ ],
40
54
  routing: [
41
55
  { value: "show", label: "show", description: "Show active routing policy source, path, and contents" },
42
56
  { value: "global", label: "global", description: "Save following policy text globally" },
@@ -70,6 +84,7 @@ function withArgumentPrefix(parent: string, completions: TakomiCompletion[], tok
70
84
  export function commandHelp(): string {
71
85
  return [
72
86
  "Takomi commands:",
87
+ "/takomi help # show this guide",
73
88
  "/takomi genesis [prompt]",
74
89
  "/takomi design [prompt]",
75
90
  "/takomi build [prompt]",
@@ -77,10 +92,12 @@ export function commandHelp(): string {
77
92
  "/takomi mode <direct|orchestrate|review>",
78
93
  "/takomi gate <auto|review>",
79
94
  "/takomi subagents <on|off|status|expand|collapse|toggle>",
95
+ "/takomi stats [overview|daily|models|projects|agents|sources] [since 7d]",
80
96
  "/takomi routing [show|where]",
81
97
  "/takomi routing <policy text> # updates global policy",
82
98
  "/takomi routing local <policy text> # project override",
83
99
  "/takomi-status",
100
+ "/takomi-stats [view] [since 7d]",
84
101
  "/takomi-reset",
85
102
  ].join("\n");
86
103
  }
@@ -8,6 +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
12
 
12
13
  export type TakomiRuntimeCommandState = {
13
14
  enabled: boolean;
@@ -209,7 +210,7 @@ export function registerTakomiCommands(pi: ExtensionAPI, options: RegisterTakomi
209
210
  handler: async (args, ctx) => {
210
211
  const [subcommand = "", ...rest] = args.trim().split(/\s+/).filter(Boolean);
211
212
  const tail = rest.join(" ");
212
- if (!subcommand) {
213
+ if (!subcommand || subcommand === "help" || subcommand === "?" || subcommand === "commands") {
213
214
  await options.updateState(ctx, () => {
214
215
  options.getState().enabled = true;
215
216
  }, commandHelp());
@@ -227,6 +228,18 @@ export function registerTakomiCommands(pi: ExtensionAPI, options: RegisterTakomi
227
228
  if (subcommand === "gate") return handleGate(ctx, rest[0]);
228
229
  if (subcommand === "routing" || subcommand === "route" || subcommand === "models") return handleRouting(ctx, tail);
229
230
  if (subcommand === "subagents" || subcommand === "subagent") return handleSubagents(ctx, rest[0]);
231
+ if (subcommand === "stats") {
232
+ try {
233
+ const view = rest[0];
234
+ const sinceIndex = rest.findIndex((token) => token === "--since" || token === "since");
235
+ const since = sinceIndex >= 0 ? rest[sinceIndex + 1] : undefined;
236
+ const stats = await collectTakomiStats({ cwd: ctx.cwd, since });
237
+ ctx.ui.notify(renderTakomiStats(stats, { limit: 8, view }), "info");
238
+ } catch (error) {
239
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
240
+ }
241
+ return;
242
+ }
230
243
  if (subcommand === "status") {
231
244
  ctx.ui.notify(statusText(options.getState(), options.subagentController), "info");
232
245
  return;
@@ -243,6 +256,26 @@ export function registerTakomiCommands(pi: ExtensionAPI, options: RegisterTakomi
243
256
  },
244
257
  });
245
258
 
259
+ pi.registerCommand("takomi-stats", {
260
+ description: "Show bundled Takomi/Pi token, model, project, and subagent usage stats",
261
+ getArgumentCompletions: (argumentPrefix: string) => completions(`stats ${argumentPrefix}`).map((completion) => ({
262
+ ...completion,
263
+ value: completion.value.replace(/^stats\s+/, ""),
264
+ })),
265
+ handler: async (args, ctx) => {
266
+ try {
267
+ const parts = args.trim().split(/\s+/).filter(Boolean);
268
+ const view = parts[0];
269
+ const sinceIndex = parts.findIndex((token) => token === "--since" || token === "since");
270
+ const since = sinceIndex >= 0 ? parts[sinceIndex + 1] : undefined;
271
+ const stats = await collectTakomiStats({ cwd: ctx.cwd, since });
272
+ ctx.ui.notify(renderTakomiStats(stats, { limit: 8, view }), "info");
273
+ } catch (error) {
274
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
275
+ }
276
+ },
277
+ });
278
+
246
279
  pi.registerCommand("takomi-reset", {
247
280
  description: "Reset Takomi runtime state to defaults",
248
281
  handler: async (_args, ctx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "takomi",
3
- "version": "2.1.17",
3
+ "version": "2.1.18",
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
@@ -45,6 +45,7 @@ import { ensurePiInstalled, ensurePiSubagentsInstalled, launchTakomiHarness, pri
45
45
  import { installPiHarnessAssets, printPiInstallSummary, syncPiHarnessAssets, validatePiHarnessInstall } from './pi-installer.js';
46
46
  import { installBundledSkills, printSkillsInstallSummary, validateSkillsInstall } from './skills-installer.js';
47
47
  import { notifyIfTakomiUpdateAvailable, printTakomiUpdateStatus, upgradeTakomiPackage } from './update-check.js';
48
+ import { printTakomiStats } from './takomi-stats.js';
48
49
 
49
50
  const packageJson = await fs.readJson(PATHS.packageJson);
50
51
  const program = new Command();
@@ -802,7 +803,25 @@ async function updateProjectResources() {
802
803
  program
803
804
  .name('takomi')
804
805
  .description('Your AI team. Activated. 🎯')
805
- .version(packageJson.version);
806
+ .version(packageJson.version)
807
+ .addHelpText('after', `
808
+ Primary flow:
809
+ takomi setup Set Takomi up once
810
+ takomi refresh Update/upgrade/sync everything
811
+ takomi status Check what is connected
812
+ takomi Launch Takomi in this project
813
+
814
+ Examples:
815
+ takomi setup pi Set up the Pi harness
816
+ takomi setup skills Install bundled skills
817
+ takomi setup project
818
+ takomi refresh One-command maintenance
819
+ takomi refresh pi Refresh only Pi-related pieces
820
+
821
+ Legacy aliases still work:
822
+ install -> setup, sync/upgrade -> refresh, init -> setup project,
823
+ harnesses -> status, update -> refresh project
824
+ `);
806
825
 
807
826
  program
808
827
  .command('setup [target]')
@@ -825,21 +844,31 @@ program
825
844
  .description('Show connected IDEs and Takomi toolkit status')
826
845
  .action(status);
827
846
 
847
+ program
848
+ .command('stats [view]')
849
+ .description('Show bundled Takomi/Pi token, model, project, and subagent usage stats')
850
+ .option('--json', 'Print machine-readable JSON')
851
+ .option('--home <path>', 'Override home directory for Pi history scanning')
852
+ .option('--cwd <path>', 'Override project directory for project-local stats')
853
+ .option('--since <date|range>', 'Filter from YYYY-MM-DD or relative range like 7d, 4w, 3m')
854
+ .option('--limit <n>', 'Rows per section', '8')
855
+ .action((view, options) => printTakomiStats({ ...options, view, limit: Number(options.limit) || 8 }));
856
+
828
857
  // Per-project setup (legacy alias)
829
858
  program
830
- .command('init')
859
+ .command('init', { hidden: true })
831
860
  .description('Legacy alias: use "takomi setup project"')
832
861
  .action(init);
833
862
 
834
863
  // Global installer (legacy alias)
835
864
  program
836
- .command('install [target]')
865
+ .command('install [target]', { hidden: true })
837
866
  .description('Legacy alias: use "takomi setup [target]"')
838
867
  .action(setup);
839
868
 
840
869
  // Re-sync (legacy alias)
841
870
  program
842
- .command('sync [target]')
871
+ .command('sync [target]', { hidden: true })
843
872
  .description('Legacy alias: use "takomi refresh [target]"')
844
873
  .action(refresh);
845
874
 
@@ -851,7 +880,7 @@ program
851
880
 
852
881
  // Show harness status (NEW)
853
882
  program
854
- .command('harnesses')
883
+ .command('harnesses', { hidden: true })
855
884
  .description('Legacy alias: use "takomi status"')
856
885
  .action(harnesses);
857
886
 
@@ -861,18 +890,18 @@ program
861
890
  .action(() => runDoctor({ version: program.version() }));
862
891
 
863
892
  program
864
- .command('check-update')
893
+ .command('check-update', { hidden: true })
865
894
  .description('Check whether a newer Takomi package is available')
866
895
  .action(() => printTakomiUpdateStatus(program.version()));
867
896
 
868
897
  program
869
- .command('upgrade [target]')
898
+ .command('upgrade [target]', { hidden: true })
870
899
  .description('Legacy alias: use "takomi refresh [target]"')
871
900
  .action(refresh);
872
901
 
873
902
  // Update from GitHub (legacy alias)
874
903
  program
875
- .command('update')
904
+ .command('update', { hidden: true })
876
905
  .description('Legacy alias: use "takomi refresh project"')
877
906
  .action(updateProjectResources);
878
907
 
@@ -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
+ }