@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,154 @@
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
+ /** Slug to mark URL. Only slugs named here resolve to a mark; the rest get a monogram. */
10
+ export const REMOTE_MARKS = {
11
+ // Simple Icons, CC0-1.0.
12
+ anthropic: `${SIMPLE_ICONS}/anthropic.svg`,
13
+ google: `${SIMPLE_ICONS}/google.svg`,
14
+ meta: `${SIMPLE_ICONS}/meta.svg`,
15
+ deepseek: `${SIMPLE_ICONS}/deepseek.svg`,
16
+ qwen: `${SIMPLE_ICONS}/qwen.svg`,
17
+ x: `${SIMPLE_ICONS}/x.svg`,
18
+ mistralai: `${SIMPLE_ICONS}/mistralai.svg`,
19
+ minimax: `${SIMPLE_ICONS}/minimax.svg`,
20
+ zdotai: `${SIMPLE_ICONS}/zdotai.svg`,
21
+ xiaomi: `${SIMPLE_ICONS}/xiaomi.svg`,
22
+ kimi: `${SIMPLE_ICONS}/kimi.svg`,
23
+ moonshotai: `${SIMPLE_ICONS}/moonshotai.svg`,
24
+ nvidia: `${SIMPLE_ICONS}/nvidia.svg`,
25
+ amd: `${SIMPLE_ICONS}/amd.svg`,
26
+ ollama: `${SIMPLE_ICONS}/ollama.svg`,
27
+ cline: `${SIMPLE_ICONS}/cline.svg`,
28
+ githubcopilot: `${SIMPLE_ICONS}/githubcopilot.svg`,
29
+ openrouter: `${SIMPLE_ICONS}/openrouter.svg`,
30
+ github: `${SIMPLE_ICONS}/github.svg`,
31
+ instagram: `${SIMPLE_ICONS}/instagram.svg`,
32
+ reddit: `${SIMPLE_ICONS}/reddit.svg`,
33
+ // Owner-hosted, nominative use.
34
+ groq: "https://groq.com/favicon.svg",
35
+ poolside: "https://poolside.ai/favicon/favicon.svg",
36
+ cerebras: "https://cdn.sanity.io/images/e4qjo92p/production/e7a55ae5ab7e2c4fdfd4e66a51f628d1f2f44207-967x967.png?w=256&h=256&fit=max",
37
+ inclusionai: "https://cdn-avatars.huggingface.co/v1/production/uploads/662e1f9da266499277937d33/fyKuazRifqiaIO34xrhhm.jpeg",
38
+ // Owner avatars: the vendor or gateway posts no standalone icon, so its account picture stands in.
39
+ stepfun: "https://cdn-avatars.huggingface.co/v1/production/uploads/644f7e6233ac8f46fa0b9e26/CmF2ocXhkr2UtHXgmwq7-.png",
40
+ // typesafe.ai serves one full-bleed tile: a dark square with pink art inside it.
41
+ typesafe: "https://framerusercontent.com/images/aNFzSFxM4fjICmnibw7npfZjcQ.png",
42
+ // A gateway's own brand, which names the service that served the request. Never another vendor's logo.
43
+ opencode: "https://opencode.ai/favicon.svg",
44
+ commandcode: "https://commandcode.ai/favicon.ico",
45
+ kilo: "https://kilo.ai/favicon.ico",
46
+ magpie: "https://usemagpie.ai/favicon.png",
47
+ gmicloud: "https://gmicloud.ai/favicon.ico",
48
+ charmhyper: "https://github.com/charmbracelet.png?size=80",
49
+ };
50
+ /**
51
+ * Tile fill, not text. A mark whose tile carries its brand color reads as the brand, and a mark
52
+ * whose logo already fills its own box never shows the fill. Values come from the brand (the
53
+ * service's own page or its own asset), never from a guess; a slug absent here keeps the plain
54
+ * panel and the theme ink.
55
+ */
56
+ export const MARK_BG = {
57
+ // Simple Icons marks: the vendor's brand color, taken from its own brand page.
58
+ anthropic: "#d97757",
59
+ meta: "#0082fb",
60
+ google: "#4285f4",
61
+ deepseek: "#4d6bfe",
62
+ qwen: "#6950ef",
63
+ mistralai: "#ff7000",
64
+ minimax: "#e11d48",
65
+ // Kimi's own favicon carries this blue, not the Moonshot violet.
66
+ kimi: "#1782fe",
67
+ xiaomi: "#ff6900",
68
+ nvidia: "#76b900",
69
+ amd: "#ed1c24",
70
+ x: "#000000",
71
+ ollama: "#000000",
72
+ zdotai: "#2d2d2d",
73
+ github: "#181717",
74
+ githubcopilot: "#181717",
75
+ cline: "#9f58fa",
76
+ openrouter: "#03080a",
77
+ instagram: "#e4405f",
78
+ reddit: "#ff4500",
79
+ linkedin: "#0a66c2",
80
+ // Owner-hosted marks: the tile matches the asset's own square, and a slug whose artwork already
81
+ // carries its own color takes a white tile so the art reads.
82
+ groq: "#f43e01",
83
+ poolside: "#4137ff",
84
+ cerebras: "#ffffff",
85
+ typesafe: "#171717",
86
+ charmhyper: "#ffffff",
87
+ stepfun: "#ffffff",
88
+ kilo: "#f7f576",
89
+ opencode: "#131010",
90
+ // The mark's own artwork is white, so the tile is white and the glyph is black.
91
+ openai: "#ffffff",
92
+ commandcode: "#ffffff",
93
+ gmicloud: "#ffffff",
94
+ magpie: "#0b0b0c",
95
+ };
96
+ /**
97
+ * Glyph scale per slug, applied to the glyph box the tile clips. Simple Icons art fills its 24 box
98
+ * by very different amounts, so the measured ink box decides the scale: a logo that runs to its
99
+ * box edge shrinks and one with an invisible margin grows. One is the default.
100
+ */
101
+ export const MARK_SCALE = {
102
+ kimi: 0.72,
103
+ zdotai: 0.72,
104
+ openrouter: 0.9,
105
+ magpie: 1.15,
106
+ };
107
+ /** WCAG relative luminance of a `#rrggbb` string. */
108
+ export function luminance(hex) {
109
+ const n = parseInt(hex.slice(1), 16);
110
+ const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
111
+ const s = v / 255;
112
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
113
+ });
114
+ return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
115
+ }
116
+ /**
117
+ * The glyph color that stays legible on a tile fill. The cut sits at 0.35, which keeps white ink
118
+ * on a brand color: Meta blue, DeepSeek blue, Xiaomi orange, Mistral orange, and Kimi blue all
119
+ * carry a white logo, which is how their own brand sheets print them. Only a light neutral takes
120
+ * black: Kilo yellow, StepFun teal, and Lightbend orange.
121
+ */
122
+ export function contrastInk(hex) {
123
+ return luminance(hex) > 0.35 ? "#000000" : "#ffffff";
124
+ }
125
+ /**
126
+ * Marks that carry their own color or their own background. Everything else is opaque black, so
127
+ * it draws as an alpha mask filled with the chosen ink instead of an image. A slug whose asset is
128
+ * a filled tile must draw as an image, or the mask paints one flat block and the logo vanishes.
129
+ */
130
+ export const COLORED_MARKS = new Set([
131
+ "cerebras",
132
+ "inclusionai",
133
+ "typesafe",
134
+ "opencode",
135
+ "commandcode",
136
+ "kilo",
137
+ "magpie",
138
+ "charmhyper",
139
+ "stepfun",
140
+ // The ICO and the two inline PNGs are dark tiles with light art drawn inside them. The Groq
141
+ // favicon is one flat orange, so it draws as a mask instead: a masked glyph on a Groq-orange
142
+ // tile is a logo, and the same art as an image on the same orange tile is a blank tile.
143
+ "gmicloud",
144
+ "stealth",
145
+ "cognition",
146
+ ]);
147
+ /** The URL a mark comes from, or null when the name has no hosted mark. */
148
+ export function markUrl(slug) {
149
+ return REMOTE_MARKS[slug] ?? null;
150
+ }
151
+ /** True when the mark carries its own color and must draw as an image. */
152
+ export function isColoredMark(slug) {
153
+ return COLORED_MARKS.has(slug);
154
+ }
@@ -1,7 +1,7 @@
1
1
  // One settings file for every host; parse failure yields {} with per-caller defaults.
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { homeDir, tersioHome } from '../lib/utils.js';
4
+ import { homeDir, tersioHome } from './utils.js';
5
5
  export const PLUGIN_NAME = '@krtclcdy/tersio';
6
6
  // The one store both hosts read; this module owns its path.
7
7
  export function tersioSettingsFile() {
@@ -1,9 +1,11 @@
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.js';
4
+ import { tersioDataPath } from './utils.js';
5
5
  // Sonnet-class default for unpriced models; always paired with known:false.
6
6
  export const DEFAULT_PRICE = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
7
+ /** Tiers firm enough to call a price. `tail` and `default` are estimates, so they are not. */
8
+ export const PRICE_TIERS_FIRM = ['measured', 'override', 'exact', 'alias'];
7
9
  const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
8
10
  // Readers use the file even when stale, and only one refresh runs per process; `tersio update` still forces a fresh fetch.
9
11
  const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
@@ -14,6 +16,53 @@ export function pricesCachePath() {
14
16
  return override;
15
17
  return tersioDataPath('prices.json', 'tersio-prices.json');
16
18
  }
19
+ /**
20
+ * The operator's own prices, merged over the feed and keyed exactly as the transcript spells a
21
+ * model. A `*` prefix matches any gateway suffix. This is where a model the feed keys under a
22
+ * different provider gets a real price.
23
+ */
24
+ export function pricesOverridePath() {
25
+ const override = process.env.TERSIO_PRICES_OVERRIDE_FILE;
26
+ if (override)
27
+ return override;
28
+ return tersioDataPath('prices.local.json', 'tersio-prices.local.json');
29
+ }
30
+ /** The override file's mtime, or 0 when absent. Callers key their memo on it. */
31
+ function overrideStamp() {
32
+ try {
33
+ return fs.statSync(pricesOverridePath()).mtimeMs;
34
+ }
35
+ catch {
36
+ return 0;
37
+ }
38
+ }
39
+ let overrideCache = null;
40
+ /** Reads the override file, keeping a `*`-prefixed entry as a suffix matcher. */
41
+ function loadOverrides(stamp = overrideStamp()) {
42
+ if (stamp === 0)
43
+ return null;
44
+ const file = pricesOverridePath();
45
+ if (overrideCache && overrideCache.stamp === stamp)
46
+ return overrideCache;
47
+ const exact = new Map();
48
+ const tails = [];
49
+ try {
50
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
51
+ for (const [id, row] of Object.entries(parsed ?? {})) {
52
+ const price = asPrice(row);
53
+ if (!price)
54
+ continue;
55
+ // A `*` prefix matches any gateway prefix, so one entry covers every spelling of a model.
56
+ if (id.startsWith('*'))
57
+ tails.push([id.slice(1).toLowerCase(), price]);
58
+ else
59
+ exact.set(id, price);
60
+ }
61
+ }
62
+ catch { /* an unreadable override is empty, not fatal */ }
63
+ overrideCache = { stamp, exact, tails };
64
+ return overrideCache;
65
+ }
17
66
  export function pricesUrl() {
18
67
  return process.env.TERSIO_PRICES_URL || LITELLM_URL;
19
68
  }
@@ -94,26 +143,26 @@ export function canonicalPriceId(model) {
94
143
  export function priceFor(model, live) {
95
144
  const table = live === undefined ? loadLivePrices() : live;
96
145
  if (live === undefined) {
97
- const memo = priceMemo(table);
146
+ // An operator who edits the override file expects the new price at once, not after a refresh.
147
+ const stamp = overrideStamp();
148
+ if (memoTable !== table || memoStamp !== stamp) {
149
+ memoTable = table;
150
+ memoStamp = stamp;
151
+ memo = new Map();
152
+ }
98
153
  const hit = memo.get(model);
99
154
  if (hit)
100
155
  return hit;
101
- const resolved = resolvePrice(model, table);
156
+ const resolved = resolvePrice(model, table, stamp);
102
157
  memo.set(model, resolved);
103
158
  return resolved;
104
159
  }
105
- return resolvePrice(model, table);
160
+ return resolvePrice(model, table, overrideStamp());
106
161
  }
107
162
  // Recent rows repeat a handful of models; the memo turns thousands of lookups into one per model. Cleared whenever the table identity changes.
108
163
  let memoTable;
164
+ let memoStamp = 0;
109
165
  let memo = new Map();
110
- function priceMemo(table) {
111
- if (memoTable !== table) {
112
- memoTable = table;
113
- memo = new Map();
114
- }
115
- return memo;
116
- }
117
166
  // Validated once per table instead of once per lookup.
118
167
  let compiledTable = null;
119
168
  let compiledEntries = [];
@@ -130,28 +179,43 @@ function priceEntries(table) {
130
179
  compiledEntries = entries;
131
180
  return entries;
132
181
  }
133
- function resolvePrice(model, table) {
134
- // 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.
135
- const id = MODEL_ALIASES.find((a) => a.id === model.toLowerCase())?.feed ?? model;
182
+ function resolvePrice(model, table, stamp = overrideStamp()) {
183
+ // The operator's file wins outright: it is the only source that can name what a gateway charges.
184
+ const overrides = loadOverrides(stamp);
185
+ if (overrides) {
186
+ const own = overrides.exact.get(model) ?? overrides.exact.get(model.toLowerCase());
187
+ if (own)
188
+ return { price: own, known: true, live: false, tier: 'override' };
189
+ const low = model.toLowerCase();
190
+ for (const [suffix, price] of overrides.tails) {
191
+ if (low === suffix || low.endsWith(`/${suffix}`)) {
192
+ return { price, known: true, live: false, tier: 'override' };
193
+ }
194
+ }
195
+ }
196
+ // An alias goes through the same chain as any other id, so it still resolves when the feed keys
197
+ // it under a provider prefix.
198
+ const alias = MODEL_ALIASES.find((a) => a.id === model.toLowerCase());
199
+ const id = alias?.feed ?? model;
136
200
  if (table) {
137
201
  const entries = priceEntries(table);
138
- const lower = id.toLowerCase();
139
- const hit = entries.find(([k]) => k === id) ?? entries.find(([k]) => k === lower);
202
+ // 1. The id verbatim, then case-folded. Nothing is being guessed here.
203
+ const hit = entries.find(([k]) => k === id) ?? entries.find(([k]) => k === id.toLowerCase());
140
204
  if (hit)
141
- return { price: hit[1], known: true, live: true };
142
- // Provider-prefixed ids ("azure/gpt-4o", "dashscope/qwen-max"): match the
143
- // first cached key that ends with the full id, then with the bare tail
144
- // segment, so "codex/openai/gpt-6-luna" still finds "gpt-6-luna".
145
- const name = lower;
146
- const tail = name.split('/').pop() ?? name;
147
- const key = entries.find(([k]) => {
205
+ return { price: hit[1], known: true, live: true, tier: alias ? 'alias' : 'exact' };
206
+ const lower = id.toLowerCase();
207
+ const tail = lower.split('/').pop() ?? lower;
208
+ // The bare model name under any provider, which need not be the provider that billed the row.
209
+ // Reported as tier `tail`, so a caller keeps it out of a firm total; `prices.local.json` fixes
210
+ // a specific case.
211
+ const bare = entries.find(([k]) => {
148
212
  const lk = k.toLowerCase();
149
- return lk === name || lk.endsWith(`/${name}`) || lk === tail || lk.endsWith(`/${tail}`);
213
+ return lk === tail || lk.endsWith(`/${tail}`);
150
214
  });
151
- if (key)
152
- return { price: key[1], known: true, live: true };
215
+ if (bare)
216
+ return { price: bare[1], known: true, live: true, tier: 'tail' };
153
217
  }
154
- return { price: DEFAULT_PRICE, known: false, live: false };
218
+ return { price: DEFAULT_PRICE, known: false, live: false, tier: 'default' };
155
219
  }
156
220
  // Fetch LiteLLM pricing and write the cache. False on any failure — callers fall back silently.
157
221
  export async function refreshPrices() {
@@ -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.js';
4
+ import { homeDir, isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from './utils.js';
5
5
  import { execFileSync } from 'node:child_process';
6
6
  export function ledgerPath() {
7
7
  const override = process.env.TERSIO_USAGE_FILE;
@@ -223,9 +223,9 @@ const RTK_ELIGIBLE_HEADS = new Set([
223
223
  'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
224
224
  ]);
225
225
  export function newSessionAccum() {
226
- return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, byHost: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
226
+ return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, byHost: {}, byAgent: {}, byAgentMessages: {}, byModelMeasured: {}, byModelCostMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [], recentFloor: Number.POSITIVE_INFINITY };
227
227
  }
228
- export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host, id) {
228
+ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host, id, agent) {
229
229
  const watermark = readResetWatermark();
230
230
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
231
231
  if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
@@ -240,10 +240,33 @@ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames,
240
240
  hs[host] ??= zeroBreakdown();
241
241
  addInto(hs[host], usage);
242
242
  }
243
+ if (agent) {
244
+ const as = (accum.byAgent[key] ??= {});
245
+ as[agent] ??= zeroBreakdown();
246
+ addInto(as[agent], usage);
247
+ const am = (accum.byAgentMessages[key] ??= {});
248
+ am[agent] = (am[agent] ?? 0) + 1;
249
+ }
243
250
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
244
251
  if (Number.isFinite(ms)) {
245
252
  const outcome = run ?? { st: 'completed' };
246
- 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 });
253
+ // The store holds every message ever recorded, but only the newest few are ever read. Skip
254
+ // anything older than the floor, and re-read the floor when the list is full, so a 50k-row
255
+ // store costs 2000 rows of memory instead of 50k.
256
+ if (accum.recent.length < RECENT_LIMIT || ms >= accum.recentFloor) {
257
+ 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 });
258
+ if (accum.recent.length > RECENT_LIMIT) {
259
+ let drop = 0;
260
+ for (let k = 1; k < accum.recent.length; k += 1)
261
+ if (accum.recent[k].t < accum.recent[drop].t)
262
+ drop = k;
263
+ accum.recent.splice(drop, 1);
264
+ accum.recentFloor = accum.recent.reduce((lo, r) => Math.min(lo, r.t), Number.POSITIVE_INFINITY);
265
+ }
266
+ else if (ms < accum.recentFloor) {
267
+ accum.recentFloor = ms;
268
+ }
269
+ }
247
270
  }
248
271
  const day = ts !== undefined ? dayKey(ts) : null;
249
272
  if (day) {
@@ -257,8 +280,11 @@ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames,
257
280
  }
258
281
  accum.messages += 1;
259
282
  const measured = costOf(usage);
260
- if (measured !== undefined)
283
+ if (measured !== undefined) {
261
284
  accum.costMeasured += measured;
285
+ accum.byModelMeasured[key] = (accum.byModelMeasured[key] ?? 0) + measured;
286
+ accum.byModelCostMessages[key] = (accum.byModelCostMessages[key] ?? 0) + 1;
287
+ }
262
288
  for (const name of toolNames ?? [])
263
289
  accum.byTool[name] = (accum.byTool[name] ?? 0) + 1;
264
290
  return true;
@@ -352,14 +378,14 @@ export function processOpencodeFile(accum, text, file) {
352
378
  if (!parsed)
353
379
  return;
354
380
  const id = file ? path.basename(file).replace(/\.[^.]+$/, '') || undefined : undefined;
355
- ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, undefined, 'opencode', id);
381
+ ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, undefined, 'opencode', id, 'main');
356
382
  }
357
383
  // One v2 message row into the shared accum. The msg id is the row primary key.
358
384
  export function ingestOpencodeDbRow(accum, row) {
359
385
  const parsed = classifyOpencodeMessage(row.row);
360
386
  if (!parsed)
361
387
  return;
362
- ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, undefined, 'opencode', row.id);
388
+ ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, undefined, 'opencode', row.id, 'main');
363
389
  }
364
390
  // Fold `:free`/`-free` suffixes and case variants into one chart key.
365
391
  export function canonicalModelId(model) {
@@ -491,10 +517,10 @@ export function importSessionTokens() {
491
517
  set codexProvider(v) { codexProvider = v; },
492
518
  get codexModel() { return codexModel; },
493
519
  set codexModel(v) { codexModel = v; },
494
- }, text, hostOfSessionFile(file));
520
+ }, text, hostOfSessionFile(file), agentOfSessionText(text));
495
521
  }
496
522
  accum.recent.sort((a, b) => b.t - a.t);
497
- 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) };
523
+ 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) };
498
524
  }
499
525
  // Which agent wrote a session file, from the directory it sits in.
500
526
  export function hostOfSessionFile(file) {
@@ -507,6 +533,24 @@ export function hostOfSessionFile(file) {
507
533
  return 'opencode';
508
534
  return 'pi';
509
535
  }
536
+ // Which agent ran a session file, from its own `session` row: a transcript with a parent
537
+ // session is a subagent's, and everything else is the main agent. The row is the host's own
538
+ // record, so a mirrored transcript carries the same answer as the stats database.
539
+ export function agentOfSessionText(text) {
540
+ if (!text.includes('parentSession'))
541
+ return 'main';
542
+ for (const line of text.split('\n')) {
543
+ if (!line.includes('parentSession'))
544
+ continue;
545
+ try {
546
+ const row = JSON.parse(line);
547
+ if (row.type === 'session' && typeof row.parentSession === 'string' && row.parentSession !== '')
548
+ return 'subagent';
549
+ }
550
+ catch { /* skip a corrupt line */ }
551
+ }
552
+ return 'main';
553
+ }
510
554
  const adoptionFileCache = new Map();
511
555
  function parseAdoptionFile(file) {
512
556
  const counts = { bashCalls: 0, eligibleCalls: 0, rtkCalls: 0 };
@@ -581,7 +625,7 @@ export function readRtkAdoption() {
581
625
  const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
582
626
  return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
583
627
  }
584
- export function processSessionText(accum, state, text, host) {
628
+ export function processSessionText(accum, state, text, host, agent) {
585
629
  for (const line of text.split('\n')) {
586
630
  if (!line.trim())
587
631
  continue;
@@ -603,12 +647,12 @@ export function processSessionText(accum, state, text, host) {
603
647
  const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
604
648
  // The provider alone told you nothing about which model ran.
605
649
  const model = state.codexModel ? `${base}/${state.codexModel}` : base;
606
- ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' }, undefined, host ?? 'codex', rid);
650
+ ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' }, undefined, host ?? 'codex', rid, agent);
607
651
  continue;
608
652
  }
609
653
  if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage)
610
654
  continue;
611
- ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid);
655
+ ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid, agent);
612
656
  }
613
657
  catch { /* skip corrupt lines */ }
614
658
  }
@@ -653,13 +697,28 @@ export function leadBinary(command) {
653
697
  return 'bash';
654
698
  }
655
699
  // --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
656
- import { DEFAULT_PRICE, canonicalPriceId, priceFor, refreshPricesIfStale } from './pricing.js';
700
+ import { DEFAULT_PRICE, PRICE_TIERS_FIRM, canonicalPriceId, priceFor, pricesOverridePath, refreshPricesIfStale } from './pricing.js';
657
701
  import { co2GramsFor, energyWhFor } from './carbon.js';
658
- export { DEFAULT_PRICE, priceFor, refreshPricesIfStale };
702
+ export { DEFAULT_PRICE, PRICE_TIERS_FIRM, priceFor, pricesOverridePath, refreshPricesIfStale };
659
703
  export { co2GramsFor, energyWhFor };
660
- export function usdCost(t, model) {
661
- const { price, known } = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false };
704
+ /**
705
+ * Price one model's tokens. `measured` is the cost the host reported for those tokens; it outranks
706
+ * every rate card, because it is the only figure from the party that billed. Pass undefined rather
707
+ * than 0 for a cost nobody reported: `costOf` keeps the two apart.
708
+ */
709
+ export function usdCost(t, model, measured) {
662
710
  const m = 1 / 1_000_000;
711
+ if (measured !== undefined) {
712
+ // Only a bare total arrives, so it attributes to input, the largest bucket for every model here.
713
+ return {
714
+ usd: measured,
715
+ priced: true,
716
+ tier: 'measured',
717
+ buckets: { input: measured, output: 0, cacheRead: 0, cacheWrite: 0 },
718
+ };
719
+ }
720
+ const hit = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false, live: false, tier: 'default' };
721
+ const price = hit.price;
663
722
  const buckets = {
664
723
  input: t.input * price.input * m,
665
724
  output: t.output * price.output * m,
@@ -668,7 +727,10 @@ export function usdCost(t, model) {
668
727
  };
669
728
  return {
670
729
  usd: buckets.input + buckets.output + buckets.cacheRead + buckets.cacheWrite,
671
- priced: model ? known : false,
730
+ // A tier the caller cannot stand behind is not a price. `tail` and `default` are estimates,
731
+ // and a report that sums them beside measured cost reads as a bill it is not.
732
+ priced: PRICE_TIERS_FIRM.includes(hit.tier),
733
+ tier: hit.tier,
672
734
  buckets,
673
735
  };
674
736
  }