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.
@@ -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 install pi
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 install skills
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 sync pi # refreshes Takomi Pi assets and runs pi update for Pi-managed packages
39
- takomi sync skills
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 install` | One-time setup detects your IDEs, creates your toolkit, syncs everything |
177
- | `takomi sync` | Push updates from `~/.takomi/` to all linked harnesses |
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 harnesses` | See what's connected and your toolkit status |
180
- | `takomi init` | Project-specific setup (works alongside global) |
181
- | `takomi update` | Refresh resources from GitHub (global store supported) |
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 harnesses
196
+ pnpm dlx takomi status
191
197
 
192
- # Push updates everywhere
193
- pnpm dlx takomi sync
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.16",
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('init')
689
- .description('Drop Takomi into this project')
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 (NEW)
863
+ // Global installer (legacy alias)
693
864
  program
694
- .command('install [target]')
695
- .description('Build your global command center or run a target-specific installer')
696
- .action(install);
865
+ .command('install [target]', { hidden: true })
866
+ .description('Legacy alias: use "takomi setup [target]"')
867
+ .action(setup);
697
868
 
698
- // Re-sync (NEW)
869
+ // Re-sync (legacy alias)
699
870
  program
700
- .command('sync [target]')
701
- .description('Push updates to all connected IDEs or sync a target-specific install')
702
- .action(sync);
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('See your toolkit status and connected IDEs')
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('Manually update the global Takomi CLI package from npm')
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 (EXISTING — enhanced)
902
+ // Update from GitHub (legacy alias)
734
903
  program
735
- .command('update')
736
- .description('Pull fresh resources from GitHub')
737
- .action(async () => {
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
+ }