takomi 2.1.44 → 2.5.1

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.
Files changed (50) hide show
  1. package/.pi/README.md +9 -8
  2. package/.pi/agents/architect.md +2 -2
  3. package/.pi/agents/designer.md +2 -2
  4. package/.pi/agents/worker.md +32 -0
  5. package/.pi/extensions/oauth-router/commands.ts +66 -35
  6. package/.pi/extensions/oauth-router/index.ts +51 -7
  7. package/.pi/extensions/oauth-router/report-ui.ts +205 -0
  8. package/.pi/extensions/takomi-context-manager/diagnostics-tools.ts +41 -3
  9. package/.pi/extensions/takomi-context-manager/diagnostics.ts +26 -0
  10. package/.pi/extensions/takomi-context-manager/index.ts +3 -2
  11. package/.pi/extensions/takomi-context-manager/model-policy-gate.ts +50 -117
  12. package/.pi/extensions/takomi-context-manager/policy-tools.ts +93 -42
  13. package/.pi/extensions/takomi-context-manager/skill-categories.d.ts +11 -0
  14. package/.pi/extensions/takomi-context-manager/skill-categories.js +212 -0
  15. package/.pi/extensions/takomi-context-manager/skill-registry.ts +179 -8
  16. package/.pi/extensions/takomi-context-manager/skill-tools.ts +208 -103
  17. package/.pi/extensions/takomi-context-manager/tool-renderers.ts +112 -0
  18. package/.pi/extensions/takomi-context-manager/types.ts +6 -0
  19. package/.pi/extensions/takomi-runtime/command-text.ts +5 -4
  20. package/.pi/extensions/takomi-runtime/commands.ts +58 -25
  21. package/.pi/extensions/takomi-runtime/gate-provenance.ts +24 -0
  22. package/.pi/extensions/takomi-runtime/index.ts +385 -124
  23. package/.pi/extensions/takomi-runtime/model-routing-defaults.ts +262 -326
  24. package/.pi/extensions/takomi-runtime/profile.ts +9 -8
  25. package/.pi/extensions/takomi-runtime/routing-policy.ts +97 -66
  26. package/.pi/extensions/takomi-runtime/shared.ts +7 -30
  27. package/.pi/extensions/takomi-runtime/takomi-stats.js +46 -26
  28. package/.pi/extensions/takomi-runtime/tool-renderers.ts +234 -0
  29. package/.pi/extensions/takomi-runtime/workflow-catalog.ts +54 -0
  30. package/.pi/extensions/takomi-subagents/agents.ts +9 -0
  31. package/.pi/extensions/takomi-subagents/async-lifecycle.ts +401 -0
  32. package/.pi/extensions/takomi-subagents/detached-results.ts +940 -0
  33. package/.pi/extensions/takomi-subagents/index.ts +50 -1
  34. package/.pi/extensions/takomi-subagents/native-render.ts +250 -188
  35. package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +62 -47
  36. package/.pi/extensions/takomi-subagents/pi-subagents-internal.ts +11 -5
  37. package/.pi/extensions/takomi-subagents/result-heartbeat.ts +55 -0
  38. package/.pi/extensions/takomi-subagents/subagent-ux.ts +162 -0
  39. package/.pi/extensions/takomi-subagents/tool-runner.ts +198 -29
  40. package/.pi/settings.json +39 -36
  41. package/.pi/takomi/model-routing.md +288 -3
  42. package/.pi/takomi-profile.json +54 -50
  43. package/assets/.agent/skills/shared-resend-portfolio/SKILL.md +124 -0
  44. package/package.json +4 -3
  45. package/src/pi-takomi-core/orchestration.ts +39 -21
  46. package/src/pi-takomi-core/routing.ts +8 -8
  47. package/src/pi-takomi-core/types.ts +35 -5
  48. package/src/pi-takomi-core/workflows.ts +86 -45
  49. package/src/skills-catalog.js +2 -202
  50. package/src/skills-installer.js +4 -1
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import type {
5
5
  TakomiDispatchDefaults,
6
6
  TakomiProfile,
7
- TakomiRole,
7
+ TakomiPersona,
8
8
  VibeLifecycleStage,
9
9
  } from "../../../src/pi-takomi-core";
10
10
 
@@ -18,14 +18,15 @@ export const DEFAULT_TAKOMI_PROFILE: TakomiProfile = {
18
18
  roles: {
19
19
  orchestrator: { agent: "orchestrator", dispatchPolicy: "subagent" },
20
20
  architect: { agent: "architect", dispatchPolicy: "subagent" },
21
- design: { agent: "designer", dispatchPolicy: "subagent" },
22
- code: { agent: "coder", dispatchPolicy: "subagent" },
23
- review: { agent: "reviewer", dispatchPolicy: "review-first" },
21
+ designer: { agent: "designer", dispatchPolicy: "subagent" },
22
+ coder: { agent: "coder", dispatchPolicy: "subagent" },
23
+ worker: { agent: "worker", dispatchPolicy: "subagent" },
24
+ reviewer: { agent: "reviewer", dispatchPolicy: "review-first" },
24
25
  },
25
26
  stages: {
26
27
  genesis: { agent: "architect", dispatchPolicy: "subagent" },
27
28
  design: { agent: "designer", dispatchPolicy: "subagent" },
28
- build: { agent: "orchestrator", dispatchPolicy: "subagent" },
29
+ build: { agent: "coder", dispatchPolicy: "subagent" },
29
30
  },
30
31
  review: {
31
32
  enabled: true,
@@ -57,7 +58,7 @@ function mergeDefaults(
57
58
  function mergeProfile(base: TakomiProfile, next?: Partial<TakomiProfile>): TakomiProfile {
58
59
  if (!next) return base;
59
60
  const roles = { ...(base.roles ?? {}) };
60
- for (const [role, defaults] of Object.entries(next.roles ?? {}) as Array<[TakomiRole, TakomiDispatchDefaults]>) {
61
+ for (const [role, defaults] of Object.entries(next.roles ?? {}) as Array<[TakomiPersona, TakomiDispatchDefaults]>) {
61
62
  roles[role] = mergeDefaults(roles[role], defaults);
62
63
  }
63
64
 
@@ -99,12 +100,12 @@ export async function loadTakomiProfile(cwd: string): Promise<TakomiProfile> {
99
100
  const userProfilePath = path.join(os.homedir(), ".pi", "agent", "takomi", "profile.json");
100
101
  const projectProfile = await readProfileFile(projectProfilePath);
101
102
  const userProfile = await readProfileFile(userProfilePath);
102
- return mergeProfile(mergeProfile(DEFAULT_TAKOMI_PROFILE, projectProfile), userProfile);
103
+ return mergeProfile(mergeProfile(DEFAULT_TAKOMI_PROFILE, userProfile), projectProfile);
103
104
  }
104
105
 
105
106
  export function getProfileDefaults(
106
107
  profile: TakomiProfile,
107
- role: TakomiRole,
108
+ role: TakomiPersona,
108
109
  stage?: VibeLifecycleStage,
109
110
  ): TakomiDispatchDefaults {
110
111
  return {
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  export const TAKOMI_ROUTING_POLICY_RELATIVE = path.join(".pi", "takomi", "model-routing.md");
7
- export const GLOBAL_TAKOMI_ROUTING_POLICY_PATH = path.join(os.homedir(), ".pi", "takomi", "model-routing.md");
7
+ export const GLOBAL_TAKOMI_ROUTING_POLICY_PATH = path.join(os.homedir(), ".pi", "agent", "takomi", "model-routing.md");
8
8
  export const GLOBAL_PI_SETTINGS_PATH = path.join(os.homedir(), ".pi", "agent", "settings.json");
9
9
  export const PROJECT_PI_SETTINGS_RELATIVE = path.join(".pi", "settings.json");
10
10
  export const BUNDLED_TAKOMI_ROUTING_POLICY_PATH = path.resolve(
@@ -99,59 +99,6 @@ function normalizeForSettings(filePath: string): string {
99
99
  return filePath.replaceAll(path.sep, "/");
100
100
  }
101
101
 
102
- function extractPreferredProvider(policy: string): string | undefined {
103
- const match = policy.match(/(?:preferred|default)\s+(?:provider|router)(?:\s*\/\s*(?:provider|router))?\s*:\s*([a-z0-9-]+)/i)
104
- ?? policy.match(/use\s+([a-z0-9-]+)\s+as\s+(?:the\s+)?(?:provider|router)/i);
105
- return match?.[1];
106
- }
107
-
108
- function findExplicitProviderModel(policy: string, family: RegExp): string | undefined {
109
- const refs = policy.match(/[a-z0-9-]+\/[a-z0-9._-]+/gi) ?? [];
110
- return refs.find((ref) => family.test(ref));
111
- }
112
-
113
- function providerModel(preferredProvider: string | undefined, model: string): string | undefined {
114
- return preferredProvider ? `${preferredProvider}/${model}` : undefined;
115
- }
116
-
117
- function withOptionalModel(model: string | undefined, thinking: string, extra: JsonObject = {}): JsonObject {
118
- return model ? { model, thinking, ...extra } : { thinking, ...extra };
119
- }
120
-
121
- function deriveSubagentDefaults(policy: string): { overrides: JsonObject; detected: string[] } {
122
- const lower = policy.toLowerCase();
123
- const has55 = /gpt[- ]?5\.5/.test(lower);
124
- const has54 = /gpt[- ]?5\.4(?!\s*mini)/.test(lower);
125
- const hasMini = /gpt[- ]?5\.4\s*mini/.test(lower);
126
- if (!has55 && !has54 && !hasMini) return { overrides: {}, detected: [] };
127
-
128
- // Keep generated settings provider-agnostic unless the policy explicitly
129
- // declares provider-qualified models or a preferred provider/router header.
130
- const preferredProvider = extractPreferredProvider(policy);
131
- const model55 = findExplicitProviderModel(policy, /gpt[-_.]?5\.5/i) ?? providerModel(preferredProvider, "gpt-5.5");
132
- const model54 = findExplicitProviderModel(policy, /gpt[-_.]?5\.4(?![-_.]?mini)/i) ?? providerModel(preferredProvider, "gpt-5.4");
133
- const modelMini = findExplicitProviderModel(policy, /gpt[-_.]?5\.4[-_.]?mini/i) ?? providerModel(preferredProvider, "gpt-5.4-mini");
134
- const overrides: JsonObject = {};
135
- const detected: string[] = [];
136
-
137
- if (has55) {
138
- overrides.orchestrator = withOptionalModel(model55, "high");
139
- overrides.architect = withOptionalModel(model55, "high");
140
- overrides.reviewer = withOptionalModel(model55, "high");
141
- detected.push(model55 ? `orchestrator/architect/reviewer → ${model55} high` : "orchestrator/architect/reviewer → GPT-5.5 high intent");
142
- }
143
- if (has54) {
144
- overrides.general = withOptionalModel(model54, "high", model55 ? { fallbackModels: [`${model55}:low`] } : {});
145
- overrides.coder = withOptionalModel(model54, "high", model55 ? { fallbackModels: [`${model55}:low`] } : {});
146
- overrides.designer = withOptionalModel(model54, "high", model55 ? { fallbackModels: [`${model55}:low`] } : {});
147
- detected.push(model54 ? `general/coder/designer → ${model54} high` : "general/coder/designer → GPT-5.4 high intent");
148
- }
149
- if (hasMini) {
150
- detected.push(modelMini ? `GPT-5.4 Mini available for explicit small-task overrides: ${modelMini}` : "GPT-5.4 Mini available as small-task intent only");
151
- }
152
- return { overrides, detected };
153
- }
154
-
155
102
  export async function resolveTakomiRoutingPolicy(cwd: string): Promise<ResolvedRoutingPolicy> {
156
103
  const projectSettingsPath = path.join(cwd, PROJECT_PI_SETTINGS_RELATIVE);
157
104
  const projectSettings = await readJsonObject(projectSettingsPath);
@@ -205,7 +152,7 @@ export async function resolveTakomiRoutingPolicy(cwd: string): Promise<ResolvedR
205
152
  return { source: "missing" };
206
153
  }
207
154
 
208
- export function previewTakomiRoutingPolicy(cwd: string, input: string, options: { scope?: RoutingPolicyInstallScope } = {}): RoutingPolicyPreviewResult {
155
+ export function previewTakomiRoutingPolicy(cwd: string, input: string, options: { scope?: RoutingPolicyInstallScope; availableModels?: string[] } = {}): RoutingPolicyPreviewResult {
209
156
  const policy = extractQuotedPolicy(input);
210
157
  if (!policy) throw new Error("No routing policy text found. Paste the policy after /takomi routing or inside triple quotes.");
211
158
 
@@ -216,8 +163,22 @@ export function previewTakomiRoutingPolicy(cwd: string, input: string, options:
216
163
  const settingsPath = scope === "project"
217
164
  ? path.join(cwd, PROJECT_PI_SETTINGS_RELATIVE)
218
165
  : GLOBAL_PI_SETTINGS_PATH;
219
- const { overrides, detected } = deriveSubagentDefaults(policy);
220
- return { scope, policy, policyPath, settingsPath, detectedDefaults: detected, overrides };
166
+ const overrides: JsonObject = {};
167
+ const detected: string[] = [];
168
+
169
+ // Markdown is advisory model-facing guidance. It is never parsed into executable defaults.
170
+ // Resolve named model families from Pi's registry only for preview visibility.
171
+ // are conditional routing intents, not role-wide defaults, so do not invent
172
+ // agentOverrides merely to make the extraction non-empty.
173
+ const availableModels = [...new Set(options.availableModels ?? [])];
174
+ for (const alias of ["luna", "sol", "terra"]) {
175
+ if (!new RegExp(`\\b${alias}\\b`, "i").test(policy)) continue;
176
+ const matches = availableModels.filter((model) => new RegExp(`(?:^|/)gpt[-_.]?5\\.6[-_.]?${alias}$`, "i").test(model));
177
+ if (matches.length === 1) detected.push(`${alias[0].toUpperCase()}${alias.slice(1)} intent resolves to ${matches[0]} (conditional route)`);
178
+ else if (matches.length > 1) detected.push(`${alias[0].toUpperCase()}${alias.slice(1)} intent matches available models: ${matches.join(", ")}`);
179
+ else detected.push(`${alias[0].toUpperCase()}${alias.slice(1)} remains a providerless conditional routing intent`);
180
+ }
181
+ return { scope, policy, policyPath, settingsPath, detectedDefaults: [...new Set(detected)], overrides };
221
182
  }
222
183
 
223
184
  export async function installTakomiRoutingPolicy(cwd: string, input: string, options: { scope?: RoutingPolicyInstallScope } = {}): Promise<RoutingPolicyInstallResult> {
@@ -234,13 +195,6 @@ export async function installTakomiRoutingPolicy(cwd: string, input: string, opt
234
195
  : normalizeForSettings(GLOBAL_TAKOMI_ROUTING_POLICY_PATH);
235
196
  settings.takomi = takomi;
236
197
 
237
- if (Object.keys(overrides).length > 0) {
238
- const subagents = asObject(settings.subagents);
239
- const existingOverrides = asObject(subagents.agentOverrides);
240
- subagents.agentOverrides = { ...existingOverrides, ...overrides };
241
- settings.subagents = subagents;
242
- }
243
-
244
198
  await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
245
199
  return { policyPath, settingsPath, settingsUpdated: true, detectedDefaults };
246
200
  }
@@ -252,14 +206,91 @@ export function renderRoutingPolicyPreview(preview: RoutingPolicyPreviewResult):
252
206
  `Policy path: ${preview.policyPath}`,
253
207
  `Settings path: ${preview.settingsPath}`,
254
208
  "",
255
- preview.detectedDefaults.length ? "Detected routing defaults:" : "Detected routing defaults: none",
209
+ preview.detectedDefaults.length ? "Advisory model concepts recognized:" : "Advisory model concepts recognized: none",
256
210
  ...preview.detectedDefaults.map((item) => `- ${item}`),
257
211
  "",
258
- overrideLines.length ? "Settings overrides to write:" : "Settings overrides to write: none",
212
+ overrideLines.length ? "Executable settings changes:" : "Executable settings changes: none (Markdown is advisory only)",
259
213
  ...overrideLines,
260
214
  ].join("\n");
261
215
  }
262
216
 
217
+ export type TakomiRoutingConfigUpdate = {
218
+ defaultProvider?: string;
219
+ approvedModels?: string[];
220
+ roleDefaults?: Record<string, { model?: string; thinking?: string; fallbackModels?: string[] }>;
221
+ };
222
+
223
+ export type TakomiRoutingConfigPreview = {
224
+ scope: RoutingPolicyInstallScope;
225
+ settingsPath: string;
226
+ before: JsonObject;
227
+ after: JsonObject;
228
+ };
229
+
230
+ const CANONICAL_ROUTING_ROLES = new Set(["architect", "designer", "coder", "worker", "reviewer", "orchestrator"]);
231
+
232
+ function validateRoutingConfig(update: TakomiRoutingConfigUpdate, availableModels: string[] = []): void {
233
+ const allConfigured = [
234
+ ...(update.approvedModels ?? []),
235
+ ...Object.values(update.roleDefaults ?? {}).flatMap((entry) => [entry.model, ...(entry.fallbackModels ?? [])]).filter((item): item is string => Boolean(item)),
236
+ ];
237
+ for (const role of Object.keys(update.roleDefaults ?? {})) {
238
+ if (!CANONICAL_ROUTING_ROLES.has(role)) throw new Error(`Unknown Takomi persona '${role}'. Use architect, designer, coder, worker, reviewer, or orchestrator.`);
239
+ }
240
+ for (const model of allConfigured) {
241
+ const base = model.replace(/:(?:off|minimal|low|medium|high|xhigh)$/i, "");
242
+ if (!base.includes("/")) throw new Error(`Model '${model}' must be provider-qualified.`);
243
+ if (availableModels.length && !availableModels.includes(base)) throw new Error(`Model '${base}' is not enabled in Pi's available model registry.`);
244
+ }
245
+ }
246
+
247
+ export async function previewTakomiRoutingConfig(
248
+ cwd: string,
249
+ scope: RoutingPolicyInstallScope,
250
+ update: TakomiRoutingConfigUpdate,
251
+ availableModels: string[] = [],
252
+ ): Promise<TakomiRoutingConfigPreview> {
253
+ validateRoutingConfig(update, availableModels);
254
+ const settingsPath = scope === "project" ? path.join(cwd, PROJECT_PI_SETTINGS_RELATIVE) : GLOBAL_PI_SETTINGS_PATH;
255
+ const settings = await readJsonObject(settingsPath);
256
+ const takomi = asObject(settings.takomi);
257
+ const before = asObject(takomi.routing);
258
+ const existingRoles = asObject(before.roleDefaults);
259
+ const nextRoles = { ...existingRoles };
260
+ for (const [role, value] of Object.entries(update.roleDefaults ?? {})) {
261
+ nextRoles[role] = { ...asObject(existingRoles[role]), ...value };
262
+ }
263
+ const after: JsonObject = {
264
+ ...before,
265
+ ...(update.defaultProvider !== undefined ? { defaultProvider: update.defaultProvider } : {}),
266
+ ...(update.approvedModels !== undefined ? { approvedModels: [...new Set(update.approvedModels)] } : {}),
267
+ ...(update.roleDefaults !== undefined ? { roleDefaults: nextRoles } : {}),
268
+ };
269
+ return { scope, settingsPath, before, after };
270
+ }
271
+
272
+ export async function installTakomiRoutingConfig(preview: TakomiRoutingConfigPreview): Promise<void> {
273
+ const settings = await readJsonObject(preview.settingsPath);
274
+ const takomi = asObject(settings.takomi);
275
+ takomi.routing = preview.after;
276
+ settings.takomi = takomi;
277
+ await mkdir(path.dirname(preview.settingsPath), { recursive: true });
278
+ await writeFile(preview.settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
279
+ }
280
+
281
+ export function renderTakomiRoutingConfigPreview(preview: TakomiRoutingConfigPreview): string {
282
+ return [
283
+ `Scope: ${preview.scope}`,
284
+ `Settings: ${preview.settingsPath}`,
285
+ "",
286
+ "Before:",
287
+ JSON.stringify(preview.before, null, 2),
288
+ "",
289
+ "After:",
290
+ JSON.stringify(preview.after, null, 2),
291
+ ].join("\n");
292
+ }
293
+
263
294
  export async function loadTakomiRoutingPolicy(cwd: string): Promise<string | undefined> {
264
295
  return (await resolveTakomiRoutingPolicy(cwd)).text;
265
296
  }
@@ -443,38 +443,15 @@ function modelCandidates(requested?: string, fallback?: string | string[]): stri
443
443
  return [requested, ...fallbackList].filter(Boolean) as string[];
444
444
  }
445
445
 
446
- export async function resolvePreferredModel(ctx: ExtensionContext, requested?: string, fallback?: string | string[]): Promise<{ model?: string; warning?: string }> {
447
- const candidates = modelCandidates(requested, fallback);
448
- if (candidates.length === 0) return {};
446
+ export async function resolvePreferredModel(ctx: ExtensionContext, requested?: string, _fallback?: string | string[]): Promise<{ model?: string; warning?: string }> {
447
+ if (!requested) return {};
449
448
  const keys = await getAvailableModelKeys(ctx);
450
- const exact = candidates.find((candidate) => keys.includes(candidate));
449
+ const exact = keys.find((key) => key === requested) ?? keys.find((key) => key.toLowerCase() === requested.toLowerCase());
451
450
  if (exact) return { model: exact };
452
-
453
- const loweredKeys = keys.map((key) => key.toLowerCase());
454
- for (const candidate of candidates) {
455
- const idx = loweredKeys.findIndex((key) => key === candidate.toLowerCase());
456
- if (idx >= 0) return { model: keys[idx] };
457
- }
458
-
459
- for (const candidate of candidates) {
460
- const idx = loweredKeys.findIndex((key) => key.includes(candidate.toLowerCase()) || candidate.toLowerCase().includes(key));
461
- if (idx >= 0) {
462
- return {
463
- model: keys[idx],
464
- warning: `Requested model '${candidate}' was unavailable; using '${keys[idx]}' instead.`,
465
- };
466
- }
467
- }
468
-
469
- const firstAvailable = keys.find((key) => key.includes("/"));
470
- if (firstAvailable) {
471
- return {
472
- model: firstAvailable,
473
- warning: `Requested models '${candidates.join("', '")}' were unavailable; using '${firstAvailable}' instead.`,
474
- };
475
- }
476
-
477
- return { warning: `No available signed-in model matched '${candidates.join("', '")}'.` };
451
+ return {
452
+ model: requested,
453
+ warning: `Requested model '${requested}' is not currently enabled in Pi's model registry. The exact provider-qualified ID was preserved; no equivalent provider was substituted.`,
454
+ };
478
455
  }
479
456
 
480
457
  export async function listModelsViaPi(cwd: string, signal?: AbortSignal): Promise<{ ok: boolean; output: string }> {
@@ -15,29 +15,47 @@ const pc = {
15
15
  magenta: ansi(35, 39),
16
16
  };
17
17
 
18
+ // USD per million tokens: input, cache read, output, cache write.
19
+ // OAuth-router's configured catalog is merged over these fallbacks at runtime,
20
+ // keeping Stats aligned with custom/new models without another hard-coded edit.
18
21
  const PRICES = {
19
- 'gpt-5.5': [5.00, 0.50, 30.00],
20
- 'gpt-5.4': [2.50, 0.25, 15.00],
21
- 'gpt-5.4-mini': [0.75, 0.075, 4.50],
22
- 'gpt-5.4-nano': [0.20, 0.02, 1.25],
23
- 'gpt-5.3-codex': [2.50, 0.25, 15.00],
24
- 'gpt-5.2-codex': [1.75, 0.175, 14.00],
25
- 'gpt-5-codex': [1.25, 0.125, 10.00],
26
- 'gpt-5.2': [1.75, 0.175, 14.00],
27
- 'gpt-5.1': [1.25, 0.125, 10.00],
28
- 'gpt-5': [1.25, 0.125, 10.00],
29
- 'gpt-5-mini': [0.25, 0.025, 2.00],
30
- 'gpt-4.1': [2.00, 0.50, 8.00],
31
- 'gpt-4o': [2.50, 1.25, 10.00],
32
- 'o4-mini': [1.10, 0.275, 4.40],
33
- 'claude-sonnet-4-6': [3.00, 0.30, 15.00],
22
+ 'gpt-5.6-luna': [1.00, 0.10, 6.00, 1.25],
23
+ 'gpt-5.6-sol': [5.00, 0.50, 30.00, 6.25],
24
+ 'gpt-5.6-terra': [2.50, 0.25, 15.00, 3.125],
25
+ 'gpt-5.5': [5.00, 0.50, 30.00, 6.25],
26
+ 'gpt-5.4': [2.50, 0.25, 15.00, 3.125],
27
+ 'gpt-5.4-mini': [0.75, 0.075, 4.50, 0.9375],
28
+ 'gpt-5.4-nano': [0.20, 0.02, 1.25, 0.25],
29
+ 'gpt-5.3-codex': [2.50, 0.25, 15.00, 3.125],
30
+ 'gpt-5.2-codex': [1.75, 0.175, 14.00, 2.1875],
31
+ 'gpt-5-codex': [1.25, 0.125, 10.00, 1.5625],
32
+ 'gpt-5.2': [1.75, 0.175, 14.00, 2.1875],
33
+ 'gpt-5.1': [1.25, 0.125, 10.00, 1.5625],
34
+ 'gpt-5': [1.25, 0.125, 10.00, 1.5625],
35
+ 'gpt-5-mini': [0.25, 0.025, 2.00, 0.3125],
36
+ 'gpt-4.1': [2.00, 0.50, 8.00, 2.00],
37
+ 'gpt-4o': [2.50, 1.25, 10.00, 2.50],
38
+ 'o4-mini': [1.10, 0.275, 4.40, 1.10],
39
+ 'claude-sonnet-4-6': [3.00, 0.30, 15.00, 3.75],
34
40
  };
35
41
 
36
42
  async function exists(target) { try { await fs.access(target); return true; } catch { return false; } }
37
43
  function safeJson(line) { try { return JSON.parse(line); } catch { return null; } }
38
44
  function dayOf(ts) { return typeof ts === 'string' && ts.length >= 10 ? ts.slice(0, 10) : 'unknown'; }
39
- 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); }
40
- 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; }
45
+ function add(map, key, patch) { const row = map.get(key) || { key, input: 0, cache: 0, cacheWrite: 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); }
46
+ function cost(model, input, cache, output, cacheWrite = 0, prices = PRICES) { const p = prices[model]; if (!p) return 0; return (input*p[0] + cache*p[1] + output*p[2] + cacheWrite*(p[3] ?? p[0])) / 1_000_000; }
47
+ async function loadPrices(home) {
48
+ const prices = { ...PRICES };
49
+ const configPath = path.join(home, '.pi', 'agent', 'oauth-router', 'config.json');
50
+ try {
51
+ const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
52
+ for (const model of config.models || []) {
53
+ if (!model?.id || !model?.cost) continue;
54
+ prices[model.id] = [model.cost.input || 0, model.cost.cacheRead || 0, model.cost.output || 0, model.cost.cacheWrite ?? model.cost.input ?? 0];
55
+ }
56
+ } catch {}
57
+ return prices;
58
+ }
41
59
  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)); }
42
60
  function fmtMoney(n) { return `$${(n || 0).toFixed(n > 100 ? 0 : 2)}`; }
43
61
  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`; }
@@ -117,7 +135,7 @@ function pushTask(taskRows, task) {
117
135
  taskRows.push(task);
118
136
  }
119
137
 
120
- async function scanPiSessions(root, source, events, sessionRows = [], taskRows = []) {
138
+ async function scanPiSessions(root, source, events, sessionRows = [], taskRows = [], prices = PRICES) {
121
139
  for (const file of await files(root)) {
122
140
  let provider = 'unknown', model = 'unknown', session = path.basename(file, '.jsonl'), cwd = '', currentTask = null;
123
141
  const row = { key: session, session, source, file, project: projectKey(file), cwd, start: '', end: '', turns: 0, messages: 0, toolCalls: 0, subagentCalls: 0, roles: new Map(), stages: new Map(), workflows: new Map(), activeMs: 0, activityTimestamps: [] };
@@ -170,7 +188,7 @@ async function scanPiSessions(root, source, events, sessionRows = [], taskRows =
170
188
  }
171
189
  }
172
190
  const u = msg && msg.usage;
173
- if (u) events.push({ source, file, timestamp: obj.timestamp, day: dayOf(obj.timestamp), session, provider, model, project: projectKey(file), kind: 'usage', input: +u.input||0, cache: +u.cacheRead||0, output: +u.output||0, total: +u.totalTokens||0, cost: cost(model, +u.input||0, +u.cacheRead||0, +u.output||0, true) });
191
+ if (u) events.push({ source, file, timestamp: obj.timestamp, day: dayOf(obj.timestamp), session, provider, model, project: projectKey(file), kind: 'usage', input: +u.input||0, cache: +u.cacheRead||0, cacheWrite: +u.cacheWrite||0, output: +u.output||0, total: +u.totalTokens||0, cost: cost(model, +u.input||0, +u.cacheRead||0, +u.output||0, +u.cacheWrite||0, prices) });
174
192
  }
175
193
  } catch {
176
194
  continue;
@@ -193,18 +211,19 @@ export async function collectTakomiStats(opts = {}) {
193
211
  const home = opts.home || os.homedir();
194
212
  const cwd = opts.cwd || process.cwd();
195
213
  const rawEvents = [], rawSessions = [], rawTasks = [];
214
+ const prices = await loadPrices(home);
196
215
  const globalSessions = path.resolve(path.join(home, '.pi', 'agent', 'sessions'));
197
216
  const projectSessions = path.resolve(path.join(cwd, '.pi', 'agent', 'sessions'));
198
- await scanPiSessions(globalSessions, 'pi-global', rawEvents, rawSessions, rawTasks);
199
- if (projectSessions !== globalSessions) await scanPiSessions(projectSessions, 'pi-project', rawEvents, rawSessions, rawTasks);
200
- await scanPiSessions(path.join(cwd, '.pi', 'takomi'), 'takomi-project', rawEvents, rawSessions, rawTasks);
217
+ await scanPiSessions(globalSessions, 'pi-global', rawEvents, rawSessions, rawTasks, prices);
218
+ if (projectSessions !== globalSessions) await scanPiSessions(projectSessions, 'pi-project', rawEvents, rawSessions, rawTasks, prices);
219
+ await scanPiSessions(path.join(cwd, '.pi', 'takomi'), 'takomi-project', rawEvents, rawSessions, rawTasks, prices);
201
220
  const sinceDay = parseSince(opts.since);
202
221
  const events = rawEvents.filter(e => !sinceDay || e.day >= sinceDay);
203
222
  const sessionRows = rawSessions.filter(s => !sinceDay || dayOf(s.end || s.start) >= sinceDay);
204
223
  const taskRows = rawTasks.filter(t => !sinceDay || dayOf(t.end || t.start) >= sinceDay);
205
224
  const runs = await scanRunHistory(path.join(home, '.pi', 'agent', 'run-history.jsonl'));
206
225
  const byDay = new Map(), byModel = new Map(), bySource = new Map(), byProject = new Map(), byTool = new Map(), byRole = new Map(), byStage = new Map(), byWorkflow = new Map();
207
- let totals = { input: 0, cache: 0, output: 0, total: 0, cost: 0, events: events.filter(e => e.kind === 'usage').length, toolCalls: 0, turns: 0 };
226
+ let totals = { input: 0, cache: 0, cacheWrite: 0, output: 0, total: 0, cost: 0, events: events.filter(e => e.kind === 'usage').length, toolCalls: 0, turns: 0 };
208
227
  for (const s of sessionRows) { totals.toolCalls += s.toolCalls; totals.turns += s.turns; }
209
228
  for (const e of events) {
210
229
  if (e.kind === 'tool') { add(byTool, e.tool || 'unknown', { total: 0, events: 1 }); continue; }
@@ -214,7 +233,7 @@ export async function collectTakomiStats(opts = {}) {
214
233
  add(byWorkflow, e.workflow || 'unknown', { total: 0, events: 1 });
215
234
  continue;
216
235
  }
217
- totals.input += e.input; totals.cache += e.cache; totals.output += e.output; totals.total += e.total; totals.cost += e.cost;
236
+ totals.input += e.input; totals.cache += e.cache; totals.cacheWrite += e.cacheWrite || 0; totals.output += e.output; totals.total += e.total; totals.cost += e.cost;
218
237
  add(byDay, e.day, e); add(byModel, e.model, e); add(bySource, e.source, e); add(byProject, e.project, e);
219
238
  }
220
239
  const byAgent = new Map(); let longestRun = null;
@@ -509,7 +528,8 @@ export function renderTakomiStats(stats, opts = {}) {
509
528
  // ── Stat Cards Row 1 ─────────────────────────────────────────────────
510
529
  const cards1 = [
511
530
  statCard(fmtTokens(stats.totals.total), 'Lifetime Tokens'),
512
- statCard(fmtTokens(stats.totals.cache), 'Cache Tokens'),
531
+ statCard(fmtTokens(stats.totals.cache), 'Cache Reads'),
532
+ statCard(fmtTokens(stats.totals.cacheWrite), 'Cache Writes'),
513
533
  statCard(fmtMoney(stats.totals.cost), 'Est. Cost'),
514
534
  statCard(String(stats.sessions), 'Sessions'),
515
535
  statCard(String(stats.totals.turns), 'Main Turns'),
@@ -677,7 +697,7 @@ export function renderTakomiStats(stats, opts = {}) {
677
697
  lines.push('');
678
698
  lines.push(' ' + pc.dim(hrule(W - 4)));
679
699
  lines.push(' ' + pc.dim('Privacy: metadata only · no raw prompts or transcripts'));
680
- lines.push(' ' + pc.dim('Costs are estimates when provider prices are unknown.'));
700
+ lines.push(' ' + pc.dim('Costs use the oauth-router catalog when available; cache reads and writes are priced separately.'));
681
701
  lines.push('');
682
702
 
683
703
  return lines.join('\n');