@krtclcdy/tersio 2.25.4 → 2.25.5

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 (52) hide show
  1. package/README.md +13 -30
  2. package/dashboard/dist/index.html +18 -16
  3. package/dist/cli/backups.js +194 -0
  4. package/dist/cli/common.js +2 -2
  5. package/dist/cli/dashboard.js +125 -98
  6. package/dist/cli/doctor-fix.js +41 -44
  7. package/dist/cli/doctor.js +69 -7
  8. package/dist/cli/hosts.js +4 -4
  9. package/dist/cli/install.js +71 -32
  10. package/dist/cli/manifest.js +45 -17
  11. package/dist/cli/profile.js +1 -1
  12. package/dist/cli/uninstall.js +21 -5
  13. package/dist/cli/update.js +3 -3
  14. package/dist/cli/usage.js +75 -4
  15. package/dist/extensions/{combo-toggle → combo}/index.js +1 -1
  16. package/dist/extensions/{tersio-commands → commands}/index.js +1 -1
  17. package/dist/extensions/{rtk-filter/index.js → rtk/filter.js} +1 -1
  18. package/dist/extensions/{rtk-session/index.js → rtk/session.js} +1 -1
  19. package/dist/extensions/{opencode/server.js → server.js} +8 -8
  20. package/dist/extensions/shared/agent-stats.js +1262 -0
  21. package/dist/extensions/shared/brand-marks.js +154 -0
  22. package/dist/extensions/shared/plugin-settings.js +1 -1
  23. package/dist/extensions/shared/pricing.js +91 -27
  24. package/dist/extensions/shared/usage-ledger.js +79 -17
  25. package/dist/extensions/shared/usage-store.js +106 -22
  26. package/dist/extensions/{lib → shared}/utils.js +1 -1
  27. package/dist/extensions/{ai-addons-updater → updater}/index.js +13 -3
  28. package/dist/probe-catalog.js +48 -0
  29. package/dist/probe-catalog188.js +44 -0
  30. package/dist/tersio.js +2 -2
  31. package/extensions/{combo-toggle → combo}/index.ts +1 -1
  32. package/extensions/{tersio-commands → commands}/index.ts +1 -1
  33. package/extensions/{rtk-filter/index.ts → rtk/filter.ts} +1 -1
  34. package/extensions/{rtk-session/index.ts → rtk/session.ts} +1 -1
  35. package/extensions/{opencode/server.ts → server.ts} +9 -9
  36. package/extensions/shared/agent-stats.ts +1657 -0
  37. package/extensions/shared/brand-marks.ts +163 -0
  38. package/extensions/shared/plugin-settings.ts +1 -1
  39. package/extensions/shared/pricing.ts +107 -28
  40. package/extensions/shared/usage-ledger.ts +94 -19
  41. package/extensions/shared/usage-store.ts +106 -22
  42. package/extensions/{lib → shared}/utils.ts +1 -1
  43. package/extensions/{ai-addons-updater → updater}/index.ts +15 -4
  44. package/package.json +13 -13
  45. /package/dist/extensions/{caveman-session → caveman}/index.js +0 -0
  46. /package/dist/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
  47. /package/dist/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
  48. /package/dist/extensions/{caveman-session → caveman}/rule.md +0 -0
  49. /package/extensions/{caveman-session → caveman}/index.ts +0 -0
  50. /package/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
  51. /package/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
  52. /package/extensions/{caveman-session → caveman}/rule.md +0 -0
@@ -0,0 +1,163 @@
1
+ // Brand mark catalog: names, not bytes. One list serves both consumers — the dashboard draws from
2
+ // it at runtime, and the CLI export inlines the same URLs so a single-file dashboard keeps its
3
+ // logos with the network off. Simple Icons files are CC0-1.0; the marks stay their owners'
4
+ // property and the dashboard uses them nominatively, to name the service that served a request.
5
+ // A vendor with no Simple Icons entry keeps its owner-hosted mark, one with no hosted mark is
6
+ // drawn inline (`dashboard/app/src/lib/marks/inline.ts`), and bumping `SIMPLE_ICONS` once moves
7
+ // every Simple Icons entry with it.
8
+ const SIMPLE_ICONS = "https://cdn.jsdelivr.net/npm/simple-icons@16/icons";
9
+
10
+ /** Slug to mark URL. Only slugs named here resolve to a mark; the rest get a monogram. */
11
+ export const REMOTE_MARKS: Readonly<Record<string, string>> = {
12
+ // Simple Icons, CC0-1.0.
13
+ anthropic: `${SIMPLE_ICONS}/anthropic.svg`,
14
+ google: `${SIMPLE_ICONS}/google.svg`,
15
+ meta: `${SIMPLE_ICONS}/meta.svg`,
16
+ deepseek: `${SIMPLE_ICONS}/deepseek.svg`,
17
+ qwen: `${SIMPLE_ICONS}/qwen.svg`,
18
+ x: `${SIMPLE_ICONS}/x.svg`,
19
+ mistralai: `${SIMPLE_ICONS}/mistralai.svg`,
20
+ minimax: `${SIMPLE_ICONS}/minimax.svg`,
21
+ zdotai: `${SIMPLE_ICONS}/zdotai.svg`,
22
+ xiaomi: `${SIMPLE_ICONS}/xiaomi.svg`,
23
+ kimi: `${SIMPLE_ICONS}/kimi.svg`,
24
+ moonshotai: `${SIMPLE_ICONS}/moonshotai.svg`,
25
+ nvidia: `${SIMPLE_ICONS}/nvidia.svg`,
26
+ amd: `${SIMPLE_ICONS}/amd.svg`,
27
+ ollama: `${SIMPLE_ICONS}/ollama.svg`,
28
+ cline: `${SIMPLE_ICONS}/cline.svg`,
29
+ githubcopilot: `${SIMPLE_ICONS}/githubcopilot.svg`,
30
+ openrouter: `${SIMPLE_ICONS}/openrouter.svg`,
31
+ github: `${SIMPLE_ICONS}/github.svg`,
32
+ instagram: `${SIMPLE_ICONS}/instagram.svg`,
33
+ reddit: `${SIMPLE_ICONS}/reddit.svg`,
34
+ // Owner-hosted, nominative use.
35
+ groq: "https://groq.com/favicon.svg",
36
+ poolside: "https://poolside.ai/favicon/favicon.svg",
37
+ cerebras:
38
+ "https://cdn.sanity.io/images/e4qjo92p/production/e7a55ae5ab7e2c4fdfd4e66a51f628d1f2f44207-967x967.png?w=256&h=256&fit=max",
39
+ inclusionai: "https://cdn-avatars.huggingface.co/v1/production/uploads/662e1f9da266499277937d33/fyKuazRifqiaIO34xrhhm.jpeg",
40
+ // Owner avatars: the vendor or gateway posts no standalone icon, so its account picture stands in.
41
+ stepfun: "https://cdn-avatars.huggingface.co/v1/production/uploads/644f7e6233ac8f46fa0b9e26/CmF2ocXhkr2UtHXgmwq7-.png",
42
+ // typesafe.ai serves one full-bleed tile: a dark square with pink art inside it.
43
+ typesafe: "https://framerusercontent.com/images/aNFzSFxM4fjICmnibw7npfZjcQ.png",
44
+ // A gateway's own brand, which names the service that served the request. Never another vendor's logo.
45
+ opencode: "https://opencode.ai/favicon.svg",
46
+ commandcode: "https://commandcode.ai/favicon.ico",
47
+ kilo: "https://kilo.ai/favicon.ico",
48
+ magpie: "https://usemagpie.ai/favicon.png",
49
+ gmicloud: "https://gmicloud.ai/favicon.ico",
50
+ charmhyper: "https://github.com/charmbracelet.png?size=80",
51
+ };
52
+
53
+ /**
54
+ * Tile fill, not text. A mark whose tile carries its brand color reads as the brand, and a mark
55
+ * whose logo already fills its own box never shows the fill. Values come from the brand (the
56
+ * service's own page or its own asset), never from a guess; a slug absent here keeps the plain
57
+ * panel and the theme ink.
58
+ */
59
+ export const MARK_BG: Readonly<Record<string, string>> = {
60
+ // Simple Icons marks: the vendor's brand color, taken from its own brand page.
61
+ anthropic: "#d97757",
62
+ meta: "#0082fb",
63
+ google: "#4285f4",
64
+ deepseek: "#4d6bfe",
65
+ qwen: "#6950ef",
66
+ mistralai: "#ff7000",
67
+ minimax: "#e11d48",
68
+ // Kimi's own favicon carries this blue, not the Moonshot violet.
69
+ kimi: "#1782fe",
70
+ xiaomi: "#ff6900",
71
+ nvidia: "#76b900",
72
+ amd: "#ed1c24",
73
+ x: "#000000",
74
+ ollama: "#000000",
75
+ zdotai: "#2d2d2d",
76
+ github: "#181717",
77
+ githubcopilot: "#181717",
78
+ cline: "#9f58fa",
79
+ openrouter: "#03080a",
80
+ instagram: "#e4405f",
81
+ reddit: "#ff4500",
82
+ linkedin: "#0a66c2",
83
+ // Owner-hosted marks: the tile matches the asset's own square, and a slug whose artwork already
84
+ // carries its own color takes a white tile so the art reads.
85
+ groq: "#f43e01",
86
+ poolside: "#4137ff",
87
+ cerebras: "#ffffff",
88
+ typesafe: "#171717",
89
+ charmhyper: "#ffffff",
90
+ stepfun: "#ffffff",
91
+ kilo: "#f7f576",
92
+ opencode: "#131010",
93
+ // The mark's own artwork is white, so the tile is white and the glyph is black.
94
+ openai: "#ffffff",
95
+ commandcode: "#ffffff",
96
+ gmicloud: "#ffffff",
97
+ magpie: "#0b0b0c",
98
+ };
99
+
100
+ /**
101
+ * Glyph scale per slug, applied to the glyph box the tile clips. Simple Icons art fills its 24 box
102
+ * by very different amounts, so the measured ink box decides the scale: a logo that runs to its
103
+ * box edge shrinks and one with an invisible margin grows. One is the default.
104
+ */
105
+ export const MARK_SCALE: Readonly<Record<string, number>> = {
106
+ kimi: 0.72,
107
+ zdotai: 0.72,
108
+ openrouter: 0.9,
109
+ magpie: 1.15,
110
+ };
111
+
112
+ /** WCAG relative luminance of a `#rrggbb` string. */
113
+ export function luminance(hex: string): number {
114
+ const n = parseInt(hex.slice(1), 16);
115
+ const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
116
+ const s = v / 255;
117
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
118
+ });
119
+ return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
120
+ }
121
+
122
+ /**
123
+ * The glyph color that stays legible on a tile fill. The cut sits at 0.35, which keeps white ink
124
+ * on a brand color: Meta blue, DeepSeek blue, Xiaomi orange, Mistral orange, and Kimi blue all
125
+ * carry a white logo, which is how their own brand sheets print them. Only a light neutral takes
126
+ * black: Kilo yellow, StepFun teal, and Lightbend orange.
127
+ */
128
+ export function contrastInk(hex: string): string {
129
+ return luminance(hex) > 0.35 ? "#000000" : "#ffffff";
130
+ }
131
+
132
+ /**
133
+ * Marks that carry their own color or their own background. Everything else is opaque black, so
134
+ * it draws as an alpha mask filled with the chosen ink instead of an image. A slug whose asset is
135
+ * a filled tile must draw as an image, or the mask paints one flat block and the logo vanishes.
136
+ */
137
+ export const COLORED_MARKS: ReadonlySet<string> = new Set([
138
+ "cerebras",
139
+ "inclusionai",
140
+ "typesafe",
141
+ "opencode",
142
+ "commandcode",
143
+ "kilo",
144
+ "magpie",
145
+ "charmhyper",
146
+ "stepfun",
147
+ // The ICO and the two inline PNGs are dark tiles with light art drawn inside them. The Groq
148
+ // favicon is one flat orange, so it draws as a mask instead: a masked glyph on a Groq-orange
149
+ // tile is a logo, and the same art as an image on the same orange tile is a blank tile.
150
+ "gmicloud",
151
+ "stealth",
152
+ "cognition",
153
+ ]);
154
+
155
+ /** The URL a mark comes from, or null when the name has no hosted mark. */
156
+ export function markUrl(slug: string): string | null {
157
+ return REMOTE_MARKS[slug] ?? null;
158
+ }
159
+
160
+ /** True when the mark carries its own color and must draw as an image. */
161
+ export function isColoredMark(slug: string): boolean {
162
+ return COLORED_MARKS.has(slug);
163
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import path from 'node:path';
5
- import { homeDir, tersioHome } from '../lib/utils.ts';
5
+ import { homeDir, tersioHome } from './utils.ts';
6
6
 
7
7
  export const PLUGIN_NAME = '@krtclcdy/tersio';
8
8
 
@@ -1,7 +1,7 @@
1
1
  // extensions/shared/pricing.ts — dynamic model pricing from LiteLLM. Exact id → provider-prefix strip → default, reported with known:false when unpriced.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { tersioDataPath } from '../lib/utils.ts';
4
+ import { tersioDataPath } from './utils.ts';
5
5
 
6
6
  export interface ModelPrice {
7
7
  input: number;
@@ -13,6 +13,27 @@ export interface ModelPrice {
13
13
  // Sonnet-class default for unpriced models; always paired with known:false.
14
14
  export const DEFAULT_PRICE: ModelPrice = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
15
15
 
16
+ /**
17
+ * How a price was found, strongest first. `tail` and `default` are estimates.
18
+ *
19
+ * - `measured`: the host reported it. The only tier that can be zero without being a guess.
20
+ * - `override`: the operator's `prices.local.json`, exact for the gateway it names.
21
+ * - `exact`: the feed holds the id verbatim, or case-folded. `alias`: via `MODEL_ALIASES`.
22
+ * - `tail`: the feed holds the bare name under some provider, which need not be the one billed.
23
+ * - `default`: no match. `DEFAULT_PRICE`, an estimate by definition.
24
+ */
25
+ export type PriceTier = 'measured' | 'override' | 'exact' | 'alias' | 'tail' | 'default';
26
+
27
+ /** Tiers firm enough to call a price. `tail` and `default` are estimates, so they are not. */
28
+ export const PRICE_TIERS_FIRM: readonly PriceTier[] = ['measured', 'override', 'exact', 'alias'];
29
+
30
+ export interface PriceHit {
31
+ price: ModelPrice;
32
+ known: boolean;
33
+ live: boolean;
34
+ tier: PriceTier;
35
+ }
36
+
16
37
  const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
17
38
  // Readers use the file even when stale, and only one refresh runs per process; `tersio update` still forces a fresh fetch.
18
39
  const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
@@ -24,6 +45,49 @@ export function pricesCachePath(): string {
24
45
  return tersioDataPath('prices.json', 'tersio-prices.json');
25
46
  }
26
47
 
48
+ /**
49
+ * The operator's own prices, merged over the feed and keyed exactly as the transcript spells a
50
+ * model. A `*` prefix matches any gateway suffix. This is where a model the feed keys under a
51
+ * different provider gets a real price.
52
+ */
53
+ export function pricesOverridePath(): string {
54
+ const override = process.env.TERSIO_PRICES_OVERRIDE_FILE;
55
+ if (override) return override;
56
+ return tersioDataPath('prices.local.json', 'tersio-prices.local.json');
57
+ }
58
+
59
+ /** The override file's mtime, or 0 when absent. Callers key their memo on it. */
60
+ function overrideStamp(): number {
61
+ try {
62
+ return fs.statSync(pricesOverridePath()).mtimeMs;
63
+ } catch {
64
+ return 0;
65
+ }
66
+ }
67
+
68
+ let overrideCache: { stamp: number; exact: Map<string, ModelPrice>; tails: Array<[string, ModelPrice]> } | null = null;
69
+
70
+ /** Reads the override file, keeping a `*`-prefixed entry as a suffix matcher. */
71
+ function loadOverrides(stamp = overrideStamp()): { exact: Map<string, ModelPrice>; tails: Array<[string, ModelPrice]> } | null {
72
+ if (stamp === 0) return null;
73
+ const file = pricesOverridePath();
74
+ if (overrideCache && overrideCache.stamp === stamp) return overrideCache;
75
+ const exact = new Map<string, ModelPrice>();
76
+ const tails: Array<[string, ModelPrice]> = [];
77
+ try {
78
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Record<string, unknown>;
79
+ for (const [id, row] of Object.entries(parsed ?? {})) {
80
+ const price = asPrice(row);
81
+ if (!price) continue;
82
+ // A `*` prefix matches any gateway prefix, so one entry covers every spelling of a model.
83
+ if (id.startsWith('*')) tails.push([id.slice(1).toLowerCase(), price]);
84
+ else exact.set(id, price);
85
+ }
86
+ } catch { /* an unreadable override is empty, not fatal */ }
87
+ overrideCache = { stamp, exact, tails };
88
+ return overrideCache;
89
+ }
90
+
27
91
  export function pricesUrl(): string {
28
92
  return process.env.TERSIO_PRICES_URL || LITELLM_URL;
29
93
  }
@@ -109,30 +173,29 @@ export function canonicalPriceId(model: string): string {
109
173
  return MODEL_ALIASES.find((a) => a.id === key || a.feed === key || key.endsWith(`/${a.id}`) || key.endsWith(`/${a.feed}`))?.id ?? key;
110
174
  }
111
175
 
112
- export function priceFor(model: string, live?: LivePrices | null): { price: ModelPrice; known: boolean; live: boolean } {
176
+ export function priceFor(model: string, live?: LivePrices | null): PriceHit {
113
177
  const table = live === undefined ? loadLivePrices() : live;
114
178
  if (live === undefined) {
115
- const memo = priceMemo(table);
179
+ // An operator who edits the override file expects the new price at once, not after a refresh.
180
+ const stamp = overrideStamp();
181
+ if (memoTable !== table || memoStamp !== stamp) {
182
+ memoTable = table;
183
+ memoStamp = stamp;
184
+ memo = new Map();
185
+ }
116
186
  const hit = memo.get(model);
117
187
  if (hit) return hit;
118
- const resolved = resolvePrice(model, table);
188
+ const resolved = resolvePrice(model, table, stamp);
119
189
  memo.set(model, resolved);
120
190
  return resolved;
121
191
  }
122
- return resolvePrice(model, table);
192
+ return resolvePrice(model, table, overrideStamp());
123
193
  }
124
194
 
125
195
  // Recent rows repeat a handful of models; the memo turns thousands of lookups into one per model. Cleared whenever the table identity changes.
126
196
  let memoTable: LivePrices | null | undefined;
127
- let memo = new Map<string, { price: ModelPrice; known: boolean; live: boolean }>();
128
-
129
- function priceMemo(table: LivePrices | null): Map<string, { price: ModelPrice; known: boolean; live: boolean }> {
130
- if (memoTable !== table) {
131
- memoTable = table;
132
- memo = new Map();
133
- }
134
- return memo;
135
- }
197
+ let memoStamp = 0;
198
+ let memo = new Map<string, PriceHit>();
136
199
 
137
200
  // Validated once per table instead of once per lookup.
138
201
  let compiledTable: LivePrices | null = null;
@@ -150,26 +213,42 @@ function priceEntries(table: LivePrices): Array<[string, ModelPrice]> {
150
213
  return entries;
151
214
  }
152
215
 
153
- function resolvePrice(model: string, table: LivePrices | null): { price: ModelPrice; known: boolean; live: boolean } {
154
- // An alias goes through the same exact-then-suffix chain as any other id, so it still resolves when the feed keys it under a provider prefix.
155
- const id = MODEL_ALIASES.find((a) => a.id === model.toLowerCase())?.feed ?? model;
216
+ function resolvePrice(model: string, table: LivePrices | null, stamp = overrideStamp()): PriceHit {
217
+ // The operator's file wins outright: it is the only source that can name what a gateway charges.
218
+ const overrides = loadOverrides(stamp);
219
+ if (overrides) {
220
+ const own = overrides.exact.get(model) ?? overrides.exact.get(model.toLowerCase());
221
+ if (own) return { price: own, known: true, live: false, tier: 'override' };
222
+ const low = model.toLowerCase();
223
+ for (const [suffix, price] of overrides.tails) {
224
+ if (low === suffix || low.endsWith(`/${suffix}`)) {
225
+ return { price, known: true, live: false, tier: 'override' };
226
+ }
227
+ }
228
+ }
229
+
230
+ // An alias goes through the same chain as any other id, so it still resolves when the feed keys
231
+ // it under a provider prefix.
232
+ const alias = MODEL_ALIASES.find((a) => a.id === model.toLowerCase());
233
+ const id = alias?.feed ?? model;
156
234
  if (table) {
157
235
  const entries = priceEntries(table);
236
+ // 1. The id verbatim, then case-folded. Nothing is being guessed here.
237
+ const hit = entries.find(([k]) => k === id) ?? entries.find(([k]) => k === id.toLowerCase());
238
+ if (hit) return { price: hit[1], known: true, live: true, tier: alias ? 'alias' : 'exact' };
239
+
158
240
  const lower = id.toLowerCase();
159
- const hit = entries.find(([k]) => k === id) ?? entries.find(([k]) => k === lower);
160
- if (hit) return { price: hit[1], known: true, live: true };
161
- // Provider-prefixed ids ("azure/gpt-4o", "dashscope/qwen-max"): match the
162
- // first cached key that ends with the full id, then with the bare tail
163
- // segment, so "codex/openai/gpt-6-luna" still finds "gpt-6-luna".
164
- const name = lower;
165
- const tail = name.split('/').pop() ?? name;
166
- const key = entries.find(([k]) => {
241
+ const tail = lower.split('/').pop() ?? lower;
242
+ // The bare model name under any provider, which need not be the provider that billed the row.
243
+ // Reported as tier `tail`, so a caller keeps it out of a firm total; `prices.local.json` fixes
244
+ // a specific case.
245
+ const bare = entries.find(([k]) => {
167
246
  const lk = k.toLowerCase();
168
- return lk === name || lk.endsWith(`/${name}`) || lk === tail || lk.endsWith(`/${tail}`);
247
+ return lk === tail || lk.endsWith(`/${tail}`);
169
248
  });
170
- if (key) return { price: key[1], known: true, live: true };
249
+ if (bare) return { price: bare[1], known: true, live: true, tier: 'tail' };
171
250
  }
172
- return { price: DEFAULT_PRICE, known: false, live: false };
251
+ return { price: DEFAULT_PRICE, known: false, live: false, tier: 'default' };
173
252
  }
174
253
  // Fetch LiteLLM pricing and write the cache. False on any failure — callers fall back silently.
175
254
  export async function refreshPrices(): Promise<boolean> {
@@ -1,7 +1,7 @@
1
1
  // Shared usage ledger: append-only JSON lines, read by the CLI, `/tersio`, and the Dashboard. Best-effort; corrupt lines are skipped on read.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { homeDir, isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from '../lib/utils.ts';
4
+ import { homeDir, isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from './utils.ts';
5
5
  import { execFileSync } from 'node:child_process';
6
6
 
7
7
  export type UsageKind = 'command' | 'toggle' | 'install' | 'update' | 'rtk-audit';
@@ -89,6 +89,14 @@ export interface SessionTokens {
89
89
  byTool: Record<string, number>;
90
90
  byModelMessages: Record<string, number>;
91
91
  byHost: Record<string, Record<string, TokenBreakdown>>;
92
+ /** Model -> agent kind (`main`, `subagent`), so a subagent's work reads apart from its root's. */
93
+ byAgent: Record<string, Record<string, TokenBreakdown>>;
94
+ /** Model -> agent kind -> message count, so a share can read requests beside the tokens. */
95
+ byAgentMessages: Record<string, Record<string, number>>;
96
+ /** Model -> cost the host reported, over the messages that carried one. */
97
+ byModelMeasured: Record<string, number>;
98
+ /** Model -> messages that carried a cost. Compare to `byModelMessages` before trusting the sum. */
99
+ byModelCostMessages: Record<string, number>;
92
100
  costMeasured: number;
93
101
  recent: RecentRequest[];
94
102
  }
@@ -292,15 +300,25 @@ export interface SessionAccum {
292
300
  byTool: Record<string, number>;
293
301
  byModelMessages: Record<string, number>;
294
302
  byHost: Record<string, Record<string, TokenBreakdown>>;
303
+ /** Model -> agent kind (`main`, `subagent`), so a subagent's work reads apart from its root's. */
304
+ byAgent: Record<string, Record<string, TokenBreakdown>>;
305
+ /** Model -> agent kind -> message count. */
306
+ byAgentMessages: Record<string, Record<string, number>>;
307
+ /** Model -> cost the host itself reported, summed over the messages that carried one. */
308
+ byModelMeasured: Record<string, number>;
309
+ /** Model -> messages that carried a cost. Compare to `byModelMessages` before trusting the sum. */
310
+ byModelCostMessages: Record<string, number>;
295
311
  totals: TokenBreakdown;
296
312
  messages: number;
297
313
  costMeasured: number;
298
314
  recent: RecentRequest[];
315
+ /** Oldest timestamp held in `recent`, so the list stays bounded instead of growing with the store. */
316
+ recentFloor: number;
299
317
  }
300
318
  export function newSessionAccum(): SessionAccum {
301
- return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, byHost: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
319
+ return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, byHost: {}, byAgent: {}, byAgentMessages: {}, byModelMeasured: {}, byModelCostMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [], recentFloor: Number.POSITIVE_INFINITY };
302
320
  }
303
- export function ingestSessionRow(accum: SessionAccum, model: string, usage: Record<string, unknown>, ts: string | number | undefined, durMs?: unknown, run?: { st: RunStatus; code?: number; note?: string }, toolNames?: string[], host?: string, id?: string): boolean {
321
+ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Record<string, unknown>, ts: string | number | undefined, durMs?: unknown, run?: { st: RunStatus; code?: number; note?: string }, toolNames?: string[], host?: string, id?: string, agent?: string): boolean {
304
322
  const watermark = readResetWatermark();
305
323
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
306
324
  if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark)) return false;
@@ -314,10 +332,30 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
314
332
  hs[host] ??= zeroBreakdown();
315
333
  addInto(hs[host], usage);
316
334
  }
335
+ if (agent) {
336
+ const as = (accum.byAgent[key] ??= {});
337
+ as[agent] ??= zeroBreakdown();
338
+ addInto(as[agent], usage);
339
+ const am = (accum.byAgentMessages[key] ??= {});
340
+ am[agent] = (am[agent] ?? 0) + 1;
341
+ }
317
342
  const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
318
343
  if (Number.isFinite(ms)) {
319
344
  const outcome = run ?? { st: 'completed' as RunStatus };
320
- accum.recent.push({ m: key, i: num(usage.input), o: num(usage.output), t: ms, d: durOf(durMs), h: host, cr: num(usage.cacheRead), cw: num(usage.cacheWrite), usd: costOf(usage), st: outcome.st, code: outcome.code, note: outcome.note, id });
345
+ // The store holds every message ever recorded, but only the newest few are ever read. Skip
346
+ // anything older than the floor, and re-read the floor when the list is full, so a 50k-row
347
+ // store costs 2000 rows of memory instead of 50k.
348
+ if (accum.recent.length < RECENT_LIMIT || ms >= accum.recentFloor) {
349
+ accum.recent.push({ m: key, i: num(usage.input), o: num(usage.output), t: ms, d: durOf(durMs), h: host, cr: num(usage.cacheRead), cw: num(usage.cacheWrite), usd: costOf(usage), st: outcome.st, code: outcome.code, note: outcome.note, id });
350
+ if (accum.recent.length > RECENT_LIMIT) {
351
+ let drop = 0;
352
+ for (let k = 1; k < accum.recent.length; k += 1) if (accum.recent[k].t < accum.recent[drop].t) drop = k;
353
+ accum.recent.splice(drop, 1);
354
+ accum.recentFloor = accum.recent.reduce((lo, r) => Math.min(lo, r.t), Number.POSITIVE_INFINITY);
355
+ } else if (ms < accum.recentFloor) {
356
+ accum.recentFloor = ms;
357
+ }
358
+ }
321
359
  }
322
360
  const day = ts !== undefined ? dayKey(ts) : null;
323
361
  if (day) {
@@ -331,7 +369,11 @@ export function ingestSessionRow(accum: SessionAccum, model: string, usage: Reco
331
369
  }
332
370
  accum.messages += 1;
333
371
  const measured = costOf(usage);
334
- if (measured !== undefined) accum.costMeasured += measured;
372
+ if (measured !== undefined) {
373
+ accum.costMeasured += measured;
374
+ accum.byModelMeasured[key] = (accum.byModelMeasured[key] ?? 0) + measured;
375
+ accum.byModelCostMessages[key] = (accum.byModelCostMessages[key] ?? 0) + 1;
376
+ }
335
377
  for (const name of toolNames ?? []) accum.byTool[name] = (accum.byTool[name] ?? 0) + 1;
336
378
  return true;
337
379
  }
@@ -428,14 +470,14 @@ export function processOpencodeFile(accum: SessionAccum, text: string, file?: st
428
470
  const parsed = classifyOpencodeMessage(obj as OpencodeMessage);
429
471
  if (!parsed) return;
430
472
  const id = file ? path.basename(file).replace(/\.[^.]+$/, '') || undefined : undefined;
431
- ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus }, undefined, 'opencode', id);
473
+ ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus }, undefined, 'opencode', id, 'main');
432
474
  }
433
475
 
434
476
  // One v2 message row into the shared accum. The msg id is the row primary key.
435
477
  export function ingestOpencodeDbRow(accum: SessionAccum, row: OpencodeDbRow): void {
436
478
  const parsed = classifyOpencodeMessage(row.row);
437
479
  if (!parsed) return;
438
- ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus }, undefined, 'opencode', row.id);
480
+ ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' as RunStatus }, undefined, 'opencode', row.id, 'main');
439
481
  }
440
482
 
441
483
  // Fold `:free`/`-free` suffixes and case variants into one chart key.
@@ -560,10 +602,10 @@ export function importSessionTokens(): SessionTokens {
560
602
  set codexProvider(v: string | null) { codexProvider = v; },
561
603
  get codexModel() { return codexModel; },
562
604
  set codexModel(v: string | null) { codexModel = v; },
563
- }, text, hostOfSessionFile(file));
605
+ }, text, hostOfSessionFile(file), agentOfSessionText(text));
564
606
  }
565
607
  accum.recent.sort((a, b) => b.t - a.t);
566
- return { messages: accum.messages, totals: accum.totals, byModel: accum.byModel, byDay: accum.byDay, byDayModel: accum.byDayModel, byTool: accum.byTool, byModelMessages: accum.byModelMessages, byHost: accum.byHost, costMeasured: accum.costMeasured, recent: accum.recent.slice(0, RECENT_LIMIT) };
608
+ return { messages: accum.messages, totals: accum.totals, byModel: accum.byModel, byDay: accum.byDay, byDayModel: accum.byDayModel, byTool: accum.byTool, byModelMessages: accum.byModelMessages, byHost: accum.byHost, byAgent: accum.byAgent, byAgentMessages: accum.byAgentMessages, byModelMeasured: accum.byModelMeasured, byModelCostMessages: accum.byModelCostMessages, costMeasured: accum.costMeasured, recent: accum.recent.slice(0, RECENT_LIMIT) };
567
609
  }
568
610
 
569
611
  // Which agent wrote a session file, from the directory it sits in.
@@ -575,6 +617,21 @@ export function hostOfSessionFile(file: string): string {
575
617
  return 'pi';
576
618
  }
577
619
 
620
+ // Which agent ran a session file, from its own `session` row: a transcript with a parent
621
+ // session is a subagent's, and everything else is the main agent. The row is the host's own
622
+ // record, so a mirrored transcript carries the same answer as the stats database.
623
+ export function agentOfSessionText(text: string): string {
624
+ if (!text.includes('parentSession')) return 'main';
625
+ for (const line of text.split('\n')) {
626
+ if (!line.includes('parentSession')) continue;
627
+ try {
628
+ const row = JSON.parse(line) as { type?: unknown; parentSession?: unknown };
629
+ if (row.type === 'session' && typeof row.parentSession === 'string' && row.parentSession !== '') return 'subagent';
630
+ } catch { /* skip a corrupt line */ }
631
+ }
632
+ return 'main';
633
+ }
634
+
578
635
  const adoptionFileCache = new Map<string, { size: number; mtimeMs: number; counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number } }>();
579
636
 
580
637
  function parseAdoptionFile(file: string): { counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number }; sessions: number } {
@@ -645,7 +702,7 @@ export function readRtkAdoption(): RtkAdoption {
645
702
  const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
646
703
  return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
647
704
  }
648
- export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null; codexModel?: string | null }, text: string, host?: string): void {
705
+ export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null; codexModel?: string | null }, text: string, host?: string, agent?: string): void {
649
706
  for (const line of text.split('\n')) {
650
707
  if (!line.trim()) continue;
651
708
  try {
@@ -664,11 +721,11 @@ export function processSessionText(accum: SessionAccum, state: { codexProvider:
664
721
  const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
665
722
  // The provider alone told you nothing about which model ran.
666
723
  const model = state.codexModel ? `${base}/${state.codexModel}` : base;
667
- ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' as RunStatus }, undefined, host ?? 'codex', rid);
724
+ ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' as RunStatus }, undefined, host ?? 'codex', rid, agent);
668
725
  continue;
669
726
  }
670
727
  if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage) continue;
671
- ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid);
728
+ ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid, agent);
672
729
  } catch { /* skip corrupt lines */ }
673
730
  }
674
731
  }
@@ -706,17 +763,32 @@ export function leadBinary(command: unknown): string {
706
763
  }
707
764
 
708
765
  // --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
709
- import { DEFAULT_PRICE, canonicalPriceId, priceFor, refreshPricesIfStale } from './pricing.ts';
766
+ import { DEFAULT_PRICE, PRICE_TIERS_FIRM, canonicalPriceId, priceFor, pricesOverridePath, refreshPricesIfStale, type PriceHit, type PriceTier } from './pricing.ts';
710
767
  import { co2GramsFor, energyWhFor } from './carbon.ts';
711
768
  import type { ModelPrice } from './pricing.ts';
712
769
 
713
- export { DEFAULT_PRICE, priceFor, refreshPricesIfStale };
770
+ export { DEFAULT_PRICE, PRICE_TIERS_FIRM, priceFor, pricesOverridePath, refreshPricesIfStale };
714
771
  export { co2GramsFor, energyWhFor };
715
- export type { ModelPrice };
716
-
717
- export function usdCost(t: TokenBreakdown, model?: string): { usd: number; priced: boolean; buckets: { input: number; output: number; cacheRead: number; cacheWrite: number } } {
718
- const { price, known } = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false };
772
+ export type { PriceHit, PriceTier, ModelPrice };
773
+
774
+ /**
775
+ * Price one model's tokens. `measured` is the cost the host reported for those tokens; it outranks
776
+ * every rate card, because it is the only figure from the party that billed. Pass undefined rather
777
+ * than 0 for a cost nobody reported: `costOf` keeps the two apart.
778
+ */
779
+ export function usdCost(t: TokenBreakdown, model?: string, measured?: number): { usd: number; priced: boolean; tier: PriceTier; buckets: { input: number; output: number; cacheRead: number; cacheWrite: number } } {
719
780
  const m = 1 / 1_000_000;
781
+ if (measured !== undefined) {
782
+ // Only a bare total arrives, so it attributes to input, the largest bucket for every model here.
783
+ return {
784
+ usd: measured,
785
+ priced: true,
786
+ tier: 'measured',
787
+ buckets: { input: measured, output: 0, cacheRead: 0, cacheWrite: 0 },
788
+ };
789
+ }
790
+ const hit: PriceHit = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false, live: false, tier: 'default' };
791
+ const price = hit.price;
720
792
  const buckets = {
721
793
  input: t.input * price.input * m,
722
794
  output: t.output * price.output * m,
@@ -725,7 +797,10 @@ export function usdCost(t: TokenBreakdown, model?: string): { usd: number; price
725
797
  };
726
798
  return {
727
799
  usd: buckets.input + buckets.output + buckets.cacheRead + buckets.cacheWrite,
728
- priced: model ? known : false,
800
+ // A tier the caller cannot stand behind is not a price. `tail` and `default` are estimates,
801
+ // and a report that sums them beside measured cost reads as a bill it is not.
802
+ priced: PRICE_TIERS_FIRM.includes(hit.tier),
803
+ tier: hit.tier,
729
804
  buckets,
730
805
  };
731
806
  }