takomi 2.1.16 → 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.
- 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/README.md +20 -14
- package/package.json +1 -1
- package/src/cli.js +193 -87
- 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 "../../../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/README.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
22
|
npm install -g takomi
|
|
23
|
-
takomi
|
|
23
|
+
takomi setup pi
|
|
24
24
|
cd my-project
|
|
25
25
|
takomi
|
|
26
26
|
```
|
|
@@ -28,19 +28,21 @@ takomi
|
|
|
28
28
|
Optional global skills:
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
|
-
takomi
|
|
31
|
+
takomi setup skills
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Useful management commands:
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
+
takomi refresh # one-command update: Takomi + Pi/assets/skills
|
|
38
|
+
takomi status
|
|
37
39
|
takomi doctor
|
|
38
|
-
takomi
|
|
39
|
-
takomi
|
|
40
|
-
takomi install all
|
|
41
|
-
takomi init
|
|
40
|
+
takomi setup all
|
|
41
|
+
takomi setup project
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
Legacy commands like `takomi install pi`, `takomi sync pi`, `takomi upgrade`, and `takomi init` still work, but the simpler mental model is: **setup once, refresh when stale, run `takomi` to use it.**
|
|
45
|
+
|
|
44
46
|
### Context Manager
|
|
45
47
|
|
|
46
48
|
Takomi now ships a Pi-native `takomi-context-manager` extension. It reduces prompt bloat with progressive context loading:
|
|
@@ -173,12 +175,16 @@ Takomi v2.0 introduces the **Global Skills Router** — install skills once, and
|
|
|
173
175
|
|
|
174
176
|
| Command | What It Does |
|
|
175
177
|
|---|---|
|
|
176
|
-
| `takomi
|
|
177
|
-
| `takomi
|
|
178
|
+
| `takomi` | Launch Takomi in the current project |
|
|
179
|
+
| `takomi setup` | One-time guided setup — detects IDEs, creates your toolkit, syncs everything |
|
|
180
|
+
| `takomi setup pi\|skills\|project\|all` | Targeted setup without memorizing installer internals |
|
|
181
|
+
| `takomi refresh` | One-command update for Takomi CLI, Pi/assets, and skills |
|
|
182
|
+
| `takomi refresh pi\|skills\|project\|all` | Targeted refresh when you need it |
|
|
178
183
|
| `takomi add <url>` | Pull skills from any GitHub repo into your global store |
|
|
179
|
-
| `takomi
|
|
180
|
-
| `takomi
|
|
181
|
-
|
|
184
|
+
| `takomi status` | See what's connected and your toolkit status |
|
|
185
|
+
| `takomi doctor` | Run detailed diagnostics |
|
|
186
|
+
|
|
187
|
+
Legacy aliases remain supported: `install` → `setup`, `sync`/`upgrade` → `refresh`, `init` → `setup project`, `harnesses` → `status`, `update` → `refresh project`.
|
|
182
188
|
|
|
183
189
|
### Example: Add Remote Skills
|
|
184
190
|
|
|
@@ -187,10 +193,10 @@ Takomi v2.0 introduces the **Global Skills Router** — install skills once, and
|
|
|
187
193
|
pnpm dlx takomi add https://github.com/JStaRFilms/VibeCode-Protocol-Suite
|
|
188
194
|
|
|
189
195
|
# See what's connected
|
|
190
|
-
pnpm dlx takomi
|
|
196
|
+
pnpm dlx takomi status
|
|
191
197
|
|
|
192
|
-
#
|
|
193
|
-
pnpm dlx takomi
|
|
198
|
+
# Refresh everything
|
|
199
|
+
pnpm dlx takomi refresh
|
|
194
200
|
```
|
|
195
201
|
|
|
196
202
|
### KiloCode YAML Auto-Sync
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "takomi",
|
|
3
|
-
"version": "2.1.
|
|
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();
|
|
@@ -307,6 +308,58 @@ async function syncAllTargets() {
|
|
|
307
308
|
await syncSkillsTarget();
|
|
308
309
|
}
|
|
309
310
|
|
|
311
|
+
async function setup(target) {
|
|
312
|
+
await install(target);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function refresh(target = 'all') {
|
|
316
|
+
const normalizedTarget = target || 'all';
|
|
317
|
+
|
|
318
|
+
if (normalizedTarget === 'project') {
|
|
319
|
+
await updateProjectResources();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
await upgrade(normalizedTarget);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function upgrade(target = 'all') {
|
|
327
|
+
const normalizedTarget = target || 'all';
|
|
328
|
+
const supportedTargets = new Set(['all', 'pi', 'skills', 'cli']);
|
|
329
|
+
|
|
330
|
+
if (!supportedTargets.has(normalizedTarget)) {
|
|
331
|
+
console.log(pc.yellow(`Unsupported upgrade target: ${normalizedTarget}`));
|
|
332
|
+
console.log(pc.dim('Supported targets: all, cli, pi, skills'));
|
|
333
|
+
console.log(pc.dim('Use plain "takomi upgrade" for the one-command upgrade path.\n'));
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
console.log(pc.magenta('⬆ Takomi One-Command Upgrade\n'));
|
|
338
|
+
|
|
339
|
+
const upgradeExitCode = upgradeTakomiPackage();
|
|
340
|
+
if (upgradeExitCode !== 0) {
|
|
341
|
+
process.exitCode = upgradeExitCode;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (normalizedTarget === 'cli') {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (normalizedTarget === 'pi') {
|
|
350
|
+
await installPiTarget();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (normalizedTarget === 'skills') {
|
|
355
|
+
await installSkillsTarget();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
await installAllTargets();
|
|
360
|
+
console.log(pc.magenta('\n✨ Fully upgraded. Next: run `takomi` from your project.\n'));
|
|
361
|
+
}
|
|
362
|
+
|
|
310
363
|
function printUnsupportedInstallTarget(target) {
|
|
311
364
|
console.log(pc.yellow(`Unsupported install target: ${target}`));
|
|
312
365
|
console.log(pc.dim('Supported targets right now: pi, skills, all'));
|
|
@@ -674,6 +727,75 @@ async function harnesses() {
|
|
|
674
727
|
}
|
|
675
728
|
}
|
|
676
729
|
|
|
730
|
+
async function status() {
|
|
731
|
+
await harnesses();
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function updateProjectResources() {
|
|
735
|
+
console.log(pc.magenta('📡 Updating your toolkit from GitHub...\n'));
|
|
736
|
+
|
|
737
|
+
const storeExists = await isStoreInitialized();
|
|
738
|
+
|
|
739
|
+
const response = await prompts([
|
|
740
|
+
{
|
|
741
|
+
type: 'multiselect',
|
|
742
|
+
name: 'components',
|
|
743
|
+
message: 'What components do you want to update from GitHub?',
|
|
744
|
+
choices: [
|
|
745
|
+
{ title: '.agent (Workflows & Skills)', value: 'agent', selected: true },
|
|
746
|
+
{ title: 'Agent YAMLs', value: 'yamls' },
|
|
747
|
+
{ title: 'Legacy Protocols', value: 'legacy' },
|
|
748
|
+
...(storeExists ? [{ title: 'Global Store', value: 'global', description: 'Update ~/.takomi/' }] : []),
|
|
749
|
+
],
|
|
750
|
+
hint: '- Space to select. Return to submit'
|
|
751
|
+
}
|
|
752
|
+
]);
|
|
753
|
+
|
|
754
|
+
if (!response.components || response.components.length === 0) return;
|
|
755
|
+
|
|
756
|
+
const destRoot = process.cwd();
|
|
757
|
+
|
|
758
|
+
if (response.components.includes('agent')) {
|
|
759
|
+
const agentDest = path.join(destRoot, '.agent');
|
|
760
|
+
await updateWorkflows(path.join(agentDest, 'workflows'));
|
|
761
|
+
await updateSkills(path.join(agentDest, 'skills'));
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (response.components.includes('yamls')) {
|
|
765
|
+
await updateAgentYamls(path.join(destRoot, 'Takomi-Agents'));
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (response.components.includes('legacy')) {
|
|
769
|
+
await updateLegacyManual(path.join(destRoot, 'Legacy-Protocols'));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Update global store if selected
|
|
773
|
+
if (response.components.includes('global')) {
|
|
774
|
+
console.log(pc.cyan('\n📡 Updating global store from package assets...\n'));
|
|
775
|
+
const skills = await populateSkills('all');
|
|
776
|
+
const workflows = await populateWorkflows('all');
|
|
777
|
+
const yamls = await populateAgentYamls();
|
|
778
|
+
console.log(pc.green(` ✔ ${skills.length} skills, ${workflows.length} workflows, ${yamls.length} YAMLs updated`));
|
|
779
|
+
|
|
780
|
+
// Auto-sync to linked harnesses
|
|
781
|
+
const manifest = await getManifest();
|
|
782
|
+
if (manifest.linkedHarnesses.length > 0) {
|
|
783
|
+
const detected = detectHarnesses();
|
|
784
|
+
const linked = detected.filter(h => manifest.linkedHarnesses.includes(h.id));
|
|
785
|
+
if (linked.length > 0) {
|
|
786
|
+
console.log(pc.cyan('\n📡 Auto-syncing to linked harnesses...\n'));
|
|
787
|
+
await syncToAllHarnesses(linked, STORE_PATH);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
manifest.installed.skills = await getStoreSkills();
|
|
792
|
+
manifest.installed.workflows = await getStoreWorkflows();
|
|
793
|
+
await writeManifest(manifest);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
console.log(pc.magenta('\n✨ Your toolkit is fresh and ready to ship.'));
|
|
797
|
+
}
|
|
798
|
+
|
|
677
799
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
678
800
|
// Command Registration
|
|
679
801
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -681,25 +803,74 @@ async function harnesses() {
|
|
|
681
803
|
program
|
|
682
804
|
.name('takomi')
|
|
683
805
|
.description('Your AI team. Activated. 🎯')
|
|
684
|
-
.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
|
+
`);
|
|
825
|
+
|
|
826
|
+
program
|
|
827
|
+
.command('setup [target]')
|
|
828
|
+
.description('Set up Takomi: guided setup, or setup pi|skills|project|all')
|
|
829
|
+
.action(async (target) => {
|
|
830
|
+
if (target === 'project') {
|
|
831
|
+
await init();
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
await setup(target);
|
|
835
|
+
});
|
|
685
836
|
|
|
686
|
-
// Per-project setup (backward compatible)
|
|
687
837
|
program
|
|
688
|
-
.command('
|
|
689
|
-
.description('
|
|
838
|
+
.command('refresh [target]')
|
|
839
|
+
.description('One-command refresh: update Takomi plus Pi/assets/skills, or refresh pi|skills|project|all')
|
|
840
|
+
.action(refresh);
|
|
841
|
+
|
|
842
|
+
program
|
|
843
|
+
.command('status')
|
|
844
|
+
.description('Show connected IDEs and Takomi toolkit status')
|
|
845
|
+
.action(status);
|
|
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
|
+
|
|
857
|
+
// Per-project setup (legacy alias)
|
|
858
|
+
program
|
|
859
|
+
.command('init', { hidden: true })
|
|
860
|
+
.description('Legacy alias: use "takomi setup project"')
|
|
690
861
|
.action(init);
|
|
691
862
|
|
|
692
|
-
// Global installer (
|
|
863
|
+
// Global installer (legacy alias)
|
|
693
864
|
program
|
|
694
|
-
.command('install [target]')
|
|
695
|
-
.description('
|
|
696
|
-
.action(
|
|
865
|
+
.command('install [target]', { hidden: true })
|
|
866
|
+
.description('Legacy alias: use "takomi setup [target]"')
|
|
867
|
+
.action(setup);
|
|
697
868
|
|
|
698
|
-
// Re-sync (
|
|
869
|
+
// Re-sync (legacy alias)
|
|
699
870
|
program
|
|
700
|
-
.command('sync [target]')
|
|
701
|
-
.description('
|
|
702
|
-
.action(
|
|
871
|
+
.command('sync [target]', { hidden: true })
|
|
872
|
+
.description('Legacy alias: use "takomi refresh [target]"')
|
|
873
|
+
.action(refresh);
|
|
703
874
|
|
|
704
875
|
// Add remote skills (NEW)
|
|
705
876
|
program
|
|
@@ -709,8 +880,8 @@ program
|
|
|
709
880
|
|
|
710
881
|
// Show harness status (NEW)
|
|
711
882
|
program
|
|
712
|
-
.command('harnesses')
|
|
713
|
-
.description('
|
|
883
|
+
.command('harnesses', { hidden: true })
|
|
884
|
+
.description('Legacy alias: use "takomi status"')
|
|
714
885
|
.action(harnesses);
|
|
715
886
|
|
|
716
887
|
program
|
|
@@ -719,85 +890,20 @@ program
|
|
|
719
890
|
.action(() => runDoctor({ version: program.version() }));
|
|
720
891
|
|
|
721
892
|
program
|
|
722
|
-
.command('check-update')
|
|
893
|
+
.command('check-update', { hidden: true })
|
|
723
894
|
.description('Check whether a newer Takomi package is available')
|
|
724
895
|
.action(() => printTakomiUpdateStatus(program.version()));
|
|
725
896
|
|
|
726
897
|
program
|
|
727
|
-
.command('upgrade')
|
|
728
|
-
.description('
|
|
729
|
-
.action(
|
|
730
|
-
process.exitCode = upgradeTakomiPackage();
|
|
731
|
-
});
|
|
898
|
+
.command('upgrade [target]', { hidden: true })
|
|
899
|
+
.description('Legacy alias: use "takomi refresh [target]"')
|
|
900
|
+
.action(refresh);
|
|
732
901
|
|
|
733
|
-
// Update from GitHub (
|
|
902
|
+
// Update from GitHub (legacy alias)
|
|
734
903
|
program
|
|
735
|
-
.command('update')
|
|
736
|
-
.description('
|
|
737
|
-
.action(
|
|
738
|
-
console.log(pc.magenta('📡 Updating your toolkit from GitHub...\n'));
|
|
739
|
-
|
|
740
|
-
const storeExists = await isStoreInitialized();
|
|
741
|
-
|
|
742
|
-
const response = await prompts([
|
|
743
|
-
{
|
|
744
|
-
type: 'multiselect',
|
|
745
|
-
name: 'components',
|
|
746
|
-
message: 'What components do you want to update from GitHub?',
|
|
747
|
-
choices: [
|
|
748
|
-
{ title: '.agent (Workflows & Skills)', value: 'agent', selected: true },
|
|
749
|
-
{ title: 'Agent YAMLs', value: 'yamls' },
|
|
750
|
-
{ title: 'Legacy Protocols', value: 'legacy' },
|
|
751
|
-
...(storeExists ? [{ title: 'Global Store', value: 'global', description: 'Update ~/.takomi/' }] : []),
|
|
752
|
-
],
|
|
753
|
-
hint: '- Space to select. Return to submit'
|
|
754
|
-
}
|
|
755
|
-
]);
|
|
756
|
-
|
|
757
|
-
if (!response.components || response.components.length === 0) return;
|
|
758
|
-
|
|
759
|
-
const destRoot = process.cwd();
|
|
760
|
-
|
|
761
|
-
if (response.components.includes('agent')) {
|
|
762
|
-
const agentDest = path.join(destRoot, '.agent');
|
|
763
|
-
await updateWorkflows(path.join(agentDest, 'workflows'));
|
|
764
|
-
await updateSkills(path.join(agentDest, 'skills'));
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
if (response.components.includes('yamls')) {
|
|
768
|
-
await updateAgentYamls(path.join(destRoot, 'Takomi-Agents'));
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
if (response.components.includes('legacy')) {
|
|
772
|
-
await updateLegacyManual(path.join(destRoot, 'Legacy-Protocols'));
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// Update global store if selected
|
|
776
|
-
if (response.components.includes('global')) {
|
|
777
|
-
console.log(pc.cyan('\n📡 Updating global store from package assets...\n'));
|
|
778
|
-
const skills = await populateSkills('all');
|
|
779
|
-
const workflows = await populateWorkflows('all');
|
|
780
|
-
const yamls = await populateAgentYamls();
|
|
781
|
-
console.log(pc.green(` ✔ ${skills.length} skills, ${workflows.length} workflows, ${yamls.length} YAMLs updated`));
|
|
782
|
-
|
|
783
|
-
// Auto-sync to linked harnesses
|
|
784
|
-
const manifest = await getManifest();
|
|
785
|
-
if (manifest.linkedHarnesses.length > 0) {
|
|
786
|
-
const detected = detectHarnesses();
|
|
787
|
-
const linked = detected.filter(h => manifest.linkedHarnesses.includes(h.id));
|
|
788
|
-
if (linked.length > 0) {
|
|
789
|
-
console.log(pc.cyan('\n📡 Auto-syncing to linked harnesses...\n'));
|
|
790
|
-
await syncToAllHarnesses(linked, STORE_PATH);
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
manifest.installed.skills = await getStoreSkills();
|
|
795
|
-
manifest.installed.workflows = await getStoreWorkflows();
|
|
796
|
-
await writeManifest(manifest);
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
console.log(pc.magenta('\n✨ Your toolkit is fresh and ready to ship.'));
|
|
800
|
-
});
|
|
904
|
+
.command('update', { hidden: true })
|
|
905
|
+
.description('Legacy alias: use "takomi refresh project"')
|
|
906
|
+
.action(updateProjectResources);
|
|
801
907
|
|
|
802
908
|
if (process.argv.length <= 2) {
|
|
803
909
|
notifyIfTakomiUpdateAvailable(program.version());
|
|
@@ -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
|
+
}
|