takomi 2.1.17 → 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.
- package/.pi/extensions/notify-sound/index.ts +171 -0
- package/.pi/extensions/takomi-runtime/command-text.ts +17 -0
- package/.pi/extensions/takomi-runtime/commands.ts +34 -1
- package/.pi/extensions/takomi-runtime/takomi-stats.d.ts +3 -0
- package/.pi/extensions/takomi-runtime/takomi-stats.js +438 -0
- package/package.json +1 -1
- package/src/cli.js +37 -8
- package/src/takomi-stats.d.ts +3 -0
- package/src/takomi-stats.js +438 -0
|
@@ -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 "./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) => {
|
|
@@ -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.
|
|
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": {
|
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
|
+
}
|