@krtclcdy/tersio 2.23.0 → 2.24.0

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 (55) hide show
  1. package/README.md +58 -45
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/common.js +16 -20
  5. package/cli/currency.js +8 -12
  6. package/cli/dashboard.js +49 -37
  7. package/cli/doctor-fix.js +28 -17
  8. package/cli/doctor.js +52 -41
  9. package/cli/hosts.js +118 -0
  10. package/cli/install.js +180 -117
  11. package/cli/interactive.js +27 -1
  12. package/cli/layer.js +24 -0
  13. package/cli/manifest.js +36 -0
  14. package/cli/omp-layer.js +51 -0
  15. package/cli/pi-layer.js +37 -0
  16. package/cli/pi-wiring.js +92 -0
  17. package/cli/profile.js +44 -46
  18. package/cli/reset.js +3 -26
  19. package/cli/rtk-wiring.js +10 -15
  20. package/cli/uninstall.js +135 -44
  21. package/cli/update.js +12 -19
  22. package/dashboard/dist/index.html +16 -16
  23. package/extensions/ai-addons-updater/index.js +31 -24
  24. package/extensions/ai-addons-updater/index.ts +32 -32
  25. package/extensions/caveman-session/index.js +6 -6
  26. package/extensions/caveman-session/index.ts +6 -6
  27. package/extensions/combo-toggle/index.js +42 -40
  28. package/extensions/combo-toggle/index.ts +36 -47
  29. package/extensions/lib/utils.js +36 -9
  30. package/extensions/lib/utils.ts +37 -9
  31. package/extensions/rtk-session/index.js +9 -9
  32. package/extensions/rtk-session/index.ts +9 -14
  33. package/extensions/shared/carbon.js +5 -8
  34. package/extensions/shared/carbon.ts +5 -8
  35. package/extensions/shared/host.js +68 -0
  36. package/extensions/shared/host.ts +104 -0
  37. package/extensions/shared/omp-prompt.js +12 -0
  38. package/extensions/shared/plugin-settings.js +43 -30
  39. package/extensions/shared/plugin-settings.ts +45 -26
  40. package/extensions/shared/pricing.js +26 -15
  41. package/extensions/shared/pricing.ts +28 -16
  42. package/extensions/shared/rtk-gain.js +11 -22
  43. package/extensions/shared/rtk-gain.ts +11 -22
  44. package/extensions/shared/session-state.js +21 -25
  45. package/extensions/shared/session-state.ts +22 -26
  46. package/extensions/shared/types.ts +10 -3
  47. package/extensions/shared/usage-ledger.js +24 -36
  48. package/extensions/shared/usage-ledger.ts +29 -50
  49. package/extensions/shared/usage-store.js +14 -12
  50. package/extensions/shared/usage-store.ts +16 -14
  51. package/extensions/tersio-commands/index.js +2 -1
  52. package/extensions/tersio-commands/index.ts +2 -1
  53. package/package.json +4 -2
  54. package/tersio.js +4 -4
  55. package/extensions/shared/mode-reinforcement.js +0 -26
@@ -76,23 +76,22 @@ export function normalizeComboLevel(value) {
76
76
  const level = String(value || '').trim().toLowerCase();
77
77
  return isPresetLevel(level) ? level : null;
78
78
  }
79
- export function paintStatusBar(ui, key, emoji, label, isActive) {
79
+ // OMP themes status text through ctx.ui.theme.fg; pi has no ctx.ui.theme, so
80
+ // the same string renders plain there.
81
+ export function themeStatus(ui, indicator, label, highlight = false) {
80
82
  const theme = ui?.theme;
81
- const indicator = isActive && theme?.fg ? theme.fg('accent', emoji) : emoji;
82
- ui?.setStatus?.(key, theme?.fg ? `${indicator} ${theme.fg('muted', label)}` : `${indicator} ${label}`);
83
- }
84
- // Which context a status bar should remember to paint through.
85
- //
86
- // A UI-less event — a headless child session_start, or a top-level
87
- // session_start that fires before the TUI attaches — must never become the
88
- // remembered context. Two things went wrong when it did: the old
89
- // `ctx || lastCtx` fallback only triggered for a *falsy* ctx, so a truthy
90
- // ctx without `ui` returned early anyway, and remembering it destroyed the one
91
- // paintable context we had. Every later bare syncStatus() then went mute —
92
- // and the bare call is the bridge-listener path, which is how a mode change
93
- // made by a sibling extension reaches this bar. The symptom is a bar frozen on
94
- // a preset the session no longer has: it shows "combo BALANCED" while caveman
95
- // and rtk are already off.
83
+ if (!theme?.fg)
84
+ return `${indicator} ${label}`;
85
+ return `${highlight ? theme.fg('accent', indicator) : indicator} ${theme.fg('muted', label)}`;
86
+ }
87
+ export function paintStatusBar(ui, key, emoji, label, isActive) {
88
+ ui?.setStatus?.(key, themeStatus(ui, emoji, label, isActive));
89
+ }
90
+ // Which context a status bar paints through. A UI-less event (a headless child
91
+ // session_start, or a top-level one that fires before the TUI attaches) must
92
+ // never be remembered: doing so mutes every later bare syncStatus(), which is
93
+ // the bridge-listener path a sibling's mode change arrives on. The symptom is a
94
+ // bar frozen on a preset the session no longer has.
96
95
  export function paintableCtx(remembered, next) {
97
96
  return next?.ui?.setStatus ? next : remembered;
98
97
  }
@@ -131,8 +130,7 @@ export function setSharedComboMode(name, value) {
131
130
  return getSharedComboState();
132
131
  const modes = { ...getSharedComboState(), [name]: mode };
133
132
  // Derive the level from the final triplet so a redundant same-value write
134
- // (entry replay on resume, upstream ponytail re-affirming its mode) cannot
135
- // strand a matching preset at 'custom' and drop the combo bar.
133
+ // cannot strand a matching preset at 'custom' and drop the combo bar.
136
134
  return publish(normalizedState(modes));
137
135
  }
138
136
  export function reconcileSharedComboEntries(entries) {
@@ -156,19 +154,17 @@ export function reconcileSharedComboEntries(entries) {
156
154
  modes[name] = mode;
157
155
  }
158
156
  }
159
- // The level always reflects the final mode triplet, never the write order:
160
- // a preset entry followed by the same values reconciles back to the preset.
157
+ // The level reflects the final triplet, never the write order, so a preset
158
+ // entry followed by the same values reconciles back to the preset.
161
159
  return publish(normalizedState(modes));
162
160
  }
163
- // One-line session-wide mode summary for change confirmations, e.g.
164
- // `caveman=ULTRA, rtk=ON, ponytail=ULTRA`.
161
+ // One-line session-wide mode summary for change confirmations.
165
162
  export function activeModesSummary(state) {
166
163
  return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}`;
167
164
  }
168
165
  export function setSharedComboListener(listener) {
169
- // Additive: every sibling extension syncs its local mirror on publish, so
170
- // a /tersio or /combo switch takes effect next turn with no session reload.
171
- // A null listener clears all (no current callers; reserved for teardown).
166
+ // Additive: every sibling syncs its mirror on publish, so a /tersio or
167
+ // /combo switch lands next turn with no reload. A null listener clears all.
172
168
  if (typeof listener === 'function')
173
169
  bridge().listeners.add(listener);
174
170
  else
@@ -98,24 +98,23 @@ export function normalizeComboLevel(value: unknown): ComboLevel | null {
98
98
  return isPresetLevel(level) ? level : null;
99
99
  }
100
100
 
101
- export function paintStatusBar(ui: UiApi | undefined, key: string, emoji: string, label: string, isActive: boolean): void {
101
+ // OMP themes status text through ctx.ui.theme.fg; pi has no ctx.ui.theme, so
102
+ // the same string renders plain there.
103
+ export function themeStatus(ui: UiApi | undefined, indicator: string, label: string, highlight = false): string {
102
104
  const theme = ui?.theme;
103
- const indicator = isActive && theme?.fg ? theme.fg('accent', emoji) : emoji;
104
- ui?.setStatus?.(key, theme?.fg ? `${indicator} ${theme.fg('muted', label)}` : `${indicator} ${label}`);
105
- }
106
-
107
- // Which context a status bar should remember to paint through.
108
- //
109
- // A UI-less event — a headless child session_start, or a top-level
110
- // session_start that fires before the TUI attaches — must never become the
111
- // remembered context. Two things went wrong when it did: the old
112
- // `ctx || lastCtx` fallback only triggered for a *falsy* ctx, so a truthy
113
- // ctx without `ui` returned early anyway, and remembering it destroyed the one
114
- // paintable context we had. Every later bare syncStatus() then went mute —
115
- // and the bare call is the bridge-listener path, which is how a mode change
116
- // made by a sibling extension reaches this bar. The symptom is a bar frozen on
117
- // a preset the session no longer has: it shows "combo BALANCED" while caveman
118
- // and rtk are already off.
105
+ if (!theme?.fg) return `${indicator} ${label}`;
106
+ return `${highlight ? theme.fg('accent', indicator) : indicator} ${theme.fg('muted', label)}`;
107
+ }
108
+
109
+ export function paintStatusBar(ui: UiApi | undefined, key: string, emoji: string, label: string, isActive: boolean): void {
110
+ ui?.setStatus?.(key, themeStatus(ui, emoji, label, isActive));
111
+ }
112
+
113
+ // Which context a status bar paints through. A UI-less event (a headless child
114
+ // session_start, or a top-level one that fires before the TUI attaches) must
115
+ // never be remembered: doing so mutes every later bare syncStatus(), which is
116
+ // the bridge-listener path a sibling's mode change arrives on. The symptom is a
117
+ // bar frozen on a preset the session no longer has.
119
118
  export function paintableCtx(remembered: ExtensionCtx | undefined, next: ExtensionCtx | undefined): ExtensionCtx | undefined {
120
119
  return next?.ui?.setStatus ? next : remembered;
121
120
  }
@@ -156,8 +155,7 @@ export function setSharedComboMode(name: ModeName, value: unknown): Readonly<Com
156
155
  if (!mode) return getSharedComboState();
157
156
  const modes = { ...getSharedComboState(), [name]: mode } as Modes;
158
157
  // Derive the level from the final triplet so a redundant same-value write
159
- // (entry replay on resume, upstream ponytail re-affirming its mode) cannot
160
- // strand a matching preset at 'custom' and drop the combo bar.
158
+ // cannot strand a matching preset at 'custom' and drop the combo bar.
161
159
  return publish(normalizedState(modes));
162
160
  }
163
161
 
@@ -178,21 +176,19 @@ export function reconcileSharedComboEntries(entries: SessionEntry[] | null | und
178
176
  if (mode) modes[name] = mode;
179
177
  }
180
178
  }
181
- // The level always reflects the final mode triplet, never the write order:
182
- // a preset entry followed by the same values reconciles back to the preset.
179
+ // The level reflects the final triplet, never the write order, so a preset
180
+ // entry followed by the same values reconciles back to the preset.
183
181
  return publish(normalizedState(modes));
184
182
  }
185
183
 
186
- // One-line session-wide mode summary for change confirmations, e.g.
187
- // `caveman=ULTRA, rtk=ON, ponytail=ULTRA`.
184
+ // One-line session-wide mode summary for change confirmations.
188
185
  export function activeModesSummary(state: { caveman: string; rtk: string; ponytail: string }): string {
189
186
  return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}`;
190
187
  }
191
188
 
192
189
  export function setSharedComboListener(listener: ((state: Readonly<ComboState>) => void) | null): void {
193
- // Additive: every sibling extension syncs its local mirror on publish, so
194
- // a /tersio or /combo switch takes effect next turn with no session reload.
195
- // A null listener clears all (no current callers; reserved for teardown).
190
+ // Additive: every sibling syncs its mirror on publish, so a /tersio or
191
+ // /combo switch lands next turn with no reload. A null listener clears all.
196
192
  if (typeof listener === 'function') bridge().listeners.add(listener);
197
193
  else bridge().listeners.clear();
198
194
  }
@@ -47,8 +47,16 @@ export interface ExtensionCtx {
47
47
 
48
48
  export interface SystemPromptEvent {
49
49
  systemPrompt: string | string[];
50
+ // pi only: the mutable prompt sections. OMP does not provide it.
51
+ systemPromptOptions?: { appendSystemPrompt?: string };
50
52
  }
51
53
 
54
+ /** What a `before_agent_start` handler hands back. OMP takes an array, pi a string. */
55
+ export type PromptInjection = { systemPrompt: string[] } | { systemPrompt: string };
56
+
57
+ /** A tool `parameters` object. OMP wants zod, pi wants JSON Schema. */
58
+ export type ToolParams = Record<string, unknown>;
59
+
52
60
  export interface InputEvent {
53
61
  text?: string;
54
62
  source?: string;
@@ -68,7 +76,7 @@ export interface ExtensionApi {
68
76
  execute: (
69
77
  toolCallId: string,
70
78
  params: { args: string[] },
71
- signal: AbortSignal,
79
+ signal: AbortSignal | undefined,
72
80
  onUpdate: ((data: unknown) => void) | undefined,
73
81
  ctx: ExtensionCtx | undefined,
74
82
  ) => Promise<{
@@ -86,5 +94,4 @@ export interface ExtensionApi {
86
94
  }>;
87
95
  cwd?: string;
88
96
  zod?: { z: unknown };
89
- on<E>(event: string, handler: (event: E, ctx: ExtensionCtx) => unknown): void;
90
- }
97
+ on<E>(event: string, handler: (event: E, ctx: ExtensionCtx) => unknown): void;}
@@ -1,11 +1,9 @@
1
- // Shared usage ledger: append-only JSON-lines file. One source of truth read
2
- // by `tersio usage`, `/tersio usage`, and the Dashboard.
3
- // Best-effort by design: a ledger failure never breaks the caller, and
4
- // corrupt lines are skipped on read (same pattern as the config normalizer).
1
+ // Shared usage ledger: append-only JSON lines, read by the CLI, `/tersio`, and
2
+ // the Dashboard. Best-effort; corrupt lines are skipped on read.
5
3
  import fs from 'node:fs';
6
4
  import os from 'node:os';
7
5
  import path from 'node:path';
8
- import { resolveRtkBinary, tersioDataPath } from '../lib/utils.js';
6
+ import { isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from '../lib/utils.js';
9
7
  import { execFileSync } from 'node:child_process';
10
8
  export function ledgerPath() {
11
9
  const override = process.env.TERSIO_USAGE_FILE;
@@ -13,11 +11,7 @@ export function ledgerPath() {
13
11
  return override;
14
12
  return tersioDataPath('usage.jsonl', 'tersio-usage.jsonl');
15
13
  }
16
- // Reset watermark: tersio-owned timestamp marking the last statistics reset.
17
- // Session transcripts and the RTK database are host/tool-owned and never
18
- // touched — instead, every derived view (token stats, command tools) filters
19
- // rows from before the watermark, so "reset" empties what tersio shows
20
- // without deleting anything it does not own.
14
+ // Reset watermark: views filter rows older than it, so nothing else is deleted.
21
15
  export function resetMarkerPath() {
22
16
  const override = process.env.TERSIO_RESET_FILE;
23
17
  if (override)
@@ -55,10 +49,8 @@ function zeroBreakdown() {
55
49
  export function durOf(v) {
56
50
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
57
51
  }
58
- // Measured cost for one message. OMP writes usage.cost as
59
- // { input, output, cacheRead, cacheWrite, total }; a bare number shows up in
60
- // older fixtures. Anything else means "not recorded" — deliberately not 0, so
61
- // the dashboard can tell a genuinely free run from an unrecorded one.
52
+ // Measured cost: usage.cost.total, or a bare number in old fixtures. Anything
53
+ // else means unrecorded, which is deliberately not 0.
62
54
  export function costOf(usage) {
63
55
  const c = usage.cost;
64
56
  if (typeof c === 'number')
@@ -96,7 +88,11 @@ export function sessionsDir() {
96
88
  const override = process.env.TERSIO_SESSIONS_DIR;
97
89
  if (override)
98
90
  return override;
99
- return path.join(os.homedir(), '.omp', 'agent', 'sessions');
91
+ // The running host wins; the other host's directory is the fallback.
92
+ const dirs = isPiProcess()
93
+ ? [path.join(piAgentDir(), 'sessions'), path.join(os.homedir(), '.omp', 'agent', 'sessions')]
94
+ : [path.join(os.homedir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
95
+ return dirs.find((dir) => fs.existsSync(dir)) ?? dirs[0];
100
96
  }
101
97
  export function codexSessionsDir() {
102
98
  const override = process.env.TERSIO_CODEX_DIR;
@@ -133,9 +129,8 @@ export function walkJsonl(dir, out, cap) {
133
129
  out.push(full);
134
130
  }
135
131
  }
136
- // Recent requests are their own full-width table in the dashboard, so this is
137
- // a payload bound rather than a "few highlights" bound. Rows are small
138
- // (model, tokens, timing, status), so a few hundred cost little to ship.
132
+ // Recent requests get their own full-width table, so this bounds payload
133
+ // rather than highlights; rows are small, so a few hundred cost little.
139
134
  export const RECENT_LIMIT = 200;
140
135
  const FREE_SUFFIX = /(?::free|-free)$/i;
141
136
  const RTK_ELIGIBLE_HEADS = new Set([
@@ -211,13 +206,12 @@ export function classifySessionLine(row) {
211
206
  }
212
207
  return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
213
208
  }
214
- // Fold `:free`/`-free` suffixes and case variants into one chart key, so the
215
- // same model from two providers stops splitting into separate rows.
209
+ // Fold `:free`/`-free` suffixes and case variants into one chart key.
216
210
  export function canonicalModelId(model) {
217
- return model.replace(FREE_SUFFIX, '').toLowerCase();
211
+ return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
218
212
  }
219
- // LiteLLM-style display: namespace stays lowercase, model segments title-case
220
- // with version dots kept (`deepseek-v4.1-flash` → `Deepseek-V4.1-Flash`).
213
+ // LiteLLM-style display: lowercase namespace, title-cased model segments with
214
+ // version dots kept.
221
215
  export function displayModelId(model) {
222
216
  const bare = model.replace(FREE_SUFFIX, '');
223
217
  const cap = (s) => {
@@ -243,8 +237,8 @@ export function importSessionTokens() {
243
237
  const accum = newSessionAccum();
244
238
  const files = [];
245
239
  walkJsonl(sessionsDir(), files, 2000);
246
- // A sessions override signals an isolated environment (tests, fixtures):
247
- // only walk the real codex dir when it is explicitly set.
240
+ // An override means an isolated environment (tests, fixtures): only walk the
241
+ // real codex dir when one is explicitly set.
248
242
  if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
249
243
  walkJsonl(codexSessionsDir(), files, 2000);
250
244
  }
@@ -385,8 +379,8 @@ export function readRtkRecallDiagnostics(binary = resolveRtkBinary()) {
385
379
  return { mode: 'unknown', entries: 0, available: false };
386
380
  }
387
381
  }
388
- // Lead binary of a shell string: first segment head past `cd` chains and
389
- // VAR=x assignments (`cd /x && git status` → `git`). Falls back to `bash`.
382
+ // Lead binary of a shell string: first segment past `cd` chains and VAR=x
383
+ // assignments. Falls back to `bash`.
390
384
  const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
391
385
  export function leadBinary(command) {
392
386
  if (typeof command !== 'string' || !command.trim())
@@ -403,7 +397,7 @@ export function leadBinary(command) {
403
397
  return 'bash';
404
398
  }
405
399
  // --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
406
- import { DEFAULT_PRICE, priceFor, refreshPricesIfStale } from './pricing.js';
400
+ import { DEFAULT_PRICE, canonicalPriceId, priceFor, refreshPricesIfStale } from './pricing.js';
407
401
  import { co2GramsFor, energyWhFor } from './carbon.js';
408
402
  export { DEFAULT_PRICE, priceFor, refreshPricesIfStale };
409
403
  export { co2GramsFor, energyWhFor };
@@ -422,11 +416,6 @@ export function usdCost(t, model) {
422
416
  buckets,
423
417
  };
424
418
  }
425
- // CO2 now model-differentiated via ./carbon.ts (EcoLogits 0.8.2 port).
426
- // Always render with ~est. and never merge with measured figures.
427
- // The constant below is the served gCO2eq per 1K output tokens for the
428
- // default (gpt-4o-class) model, kept so single-figure callers stay honest.
429
- export const CO2_G_PER_1K_OUTPUT = 0.2;
430
419
  export function co2Grams(outputTokens, model) {
431
420
  return co2GramsFor(model ?? 'gpt-4o', outputTokens);
432
421
  }
@@ -452,9 +441,8 @@ export function readUsage() {
452
441
  }
453
442
  return rows;
454
443
  }
455
- // Delete the ledger file. Returns the rows cleared (0 when already absent).
456
- // Only tersio-owned data lives here; session transcripts and the RTK
457
- // database are never touched.
444
+ // Delete the ledger file and return the rows cleared. Tersio-owned data only:
445
+ // transcripts and the RTK database are never touched.
458
446
  export function clearUsageLedger() {
459
447
  const rows = readUsage().length;
460
448
  try {
@@ -1,11 +1,9 @@
1
- // Shared usage ledger: append-only JSON-lines file. One source of truth read
2
- // by `tersio usage`, `/tersio usage`, and the Dashboard.
3
- // Best-effort by design: a ledger failure never breaks the caller, and
4
- // corrupt lines are skipped on read (same pattern as the config normalizer).
1
+ // Shared usage ledger: append-only JSON lines, read by the CLI, `/tersio`, and
2
+ // the Dashboard. Best-effort; corrupt lines are skipped on read.
5
3
  import fs from 'node:fs';
6
4
  import os from 'node:os';
7
5
  import path from 'node:path';
8
- import { resolveRtkBinary, tersioDataPath } from '../lib/utils.ts';
6
+ import { isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from '../lib/utils.ts';
9
7
  import { execFileSync } from 'node:child_process';
10
8
 
11
9
  export type UsageKind = 'command' | 'toggle' | 'install' | 'update' | 'rtk-audit';
@@ -22,11 +20,7 @@ export function ledgerPath(): string {
22
20
  return tersioDataPath('usage.jsonl', 'tersio-usage.jsonl');
23
21
  }
24
22
 
25
- // Reset watermark: tersio-owned timestamp marking the last statistics reset.
26
- // Session transcripts and the RTK database are host/tool-owned and never
27
- // touched — instead, every derived view (token stats, command tools) filters
28
- // rows from before the watermark, so "reset" empties what tersio shows
29
- // without deleting anything it does not own.
23
+ // Reset watermark: views filter rows older than it, so nothing else is deleted.
30
24
  export function resetMarkerPath(): string {
31
25
  const override = process.env.TERSIO_RESET_FILE;
32
26
  if (override) return override;
@@ -59,10 +53,7 @@ export function appendUsage(kind: UsageKind, detail: string): void {
59
53
  }
60
54
 
61
55
  // --- Session tokens, tokscale-style ------------------------------------------
62
- // OMP writes per-assistant-message usage into ~/.omp/agent/sessions/**/*.jsonl:
63
- // message.role === 'assistant', message.usage = { input, output, cacheRead,
64
- // cacheWrite }, message.model, plus a per-line timestamp. Same source tokscale
65
- // parses for its oh-my-pi client. Best-effort: unreadable files are skipped.
56
+ // Assistant messages in the host transcripts carry usage, model, and a timestamp.
66
57
  export interface TokenBreakdown {
67
58
  input: number;
68
59
  output: number;
@@ -70,10 +61,7 @@ export interface TokenBreakdown {
70
61
  cacheWrite: number;
71
62
  }
72
63
 
73
- // Run outcome for one assistant message. OMP writes stopReason on every turn
74
- // (toolUse | stop | aborted | error) and, when the request failed, an HTTP
75
- // errorStatus plus errorMessage. `toolUse` is an ordinary turn that handed off
76
- // to tools, so it reads as completed rather than as a distinct state.
64
+ // `toolUse` is an ordinary turn that handed off to tools, so it reads completed.
77
65
  export type RunStatus = 'completed' | 'aborted' | 'error';
78
66
 
79
67
  export interface RecentRequest {
@@ -84,9 +72,7 @@ export interface RecentRequest {
84
72
  d?: number;
85
73
  cr?: number;
86
74
  cw?: number;
87
- // Measured spend for this message, from the transcript's usage.cost.total.
88
- // Left undefined when the host recorded no cost — never backfilled with an
89
- // estimate, so a measured figure is never mistaken for a modeled one.
75
+ // Undefined when the host recorded no cost; never backfilled.
90
76
  usd?: number;
91
77
  st: RunStatus;
92
78
  code?: number;
@@ -127,10 +113,8 @@ export function durOf(v: unknown): number | undefined {
127
113
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
128
114
  }
129
115
 
130
- // Measured cost for one message. OMP writes usage.cost as
131
- // { input, output, cacheRead, cacheWrite, total }; a bare number shows up in
132
- // older fixtures. Anything else means "not recorded" — deliberately not 0, so
133
- // the dashboard can tell a genuinely free run from an unrecorded one.
116
+ // Measured cost: usage.cost.total, or a bare number in old fixtures. Anything
117
+ // else means unrecorded, which is deliberately not 0.
134
118
  export function costOf(usage: Record<string, unknown>): number | undefined {
135
119
  const c = usage.cost;
136
120
  if (typeof c === 'number') return Number.isFinite(c) ? c : undefined;
@@ -166,7 +150,11 @@ function addInto(into: TokenBreakdown, u: { input?: unknown; output?: unknown; c
166
150
  export function sessionsDir(): string {
167
151
  const override = process.env.TERSIO_SESSIONS_DIR;
168
152
  if (override) return override;
169
- return path.join(os.homedir(), '.omp', 'agent', 'sessions');
153
+ // The running host wins; the other host's directory is the fallback.
154
+ const dirs = isPiProcess()
155
+ ? [path.join(piAgentDir(), 'sessions'), path.join(os.homedir(), '.omp', 'agent', 'sessions')]
156
+ : [path.join(os.homedir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
157
+ return dirs.find((dir) => fs.existsSync(dir)) ?? dirs[0];
170
158
  }
171
159
 
172
160
  export function codexSessionsDir(): string {
@@ -198,9 +186,8 @@ export function walkJsonl(dir: string, out: string[], cap: number): void {
198
186
  else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(full);
199
187
  }
200
188
  }
201
- // Recent requests are their own full-width table in the dashboard, so this is
202
- // a payload bound rather than a "few highlights" bound. Rows are small
203
- // (model, tokens, timing, status), so a few hundred cost little to ship.
189
+ // Recent requests get their own full-width table, so this bounds payload
190
+ // rather than highlights; rows are small, so a few hundred cost little.
204
191
  export const RECENT_LIMIT = 200;
205
192
  const FREE_SUFFIX = /(?::free|-free)$/i;
206
193
  const RTK_ELIGIBLE_HEADS = new Set([
@@ -208,8 +195,8 @@ const RTK_ELIGIBLE_HEADS = new Set([
208
195
  'npm', 'npx', 'pnpm', 'bun', 'bunx', 'cargo', 'go', 'python', 'pytest',
209
196
  'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
210
197
  ]);
211
- // Session-parse buffer shared by live reads and usage.db syncs: identical
212
- // inputs produce identical aggregates, so the store is a cache, never a fork.
198
+ // Parse buffer shared by live reads and usage.db syncs, so the store is a
199
+ // cache and never a fork.
213
200
  export interface SessionAccum {
214
201
  byModel: Record<string, TokenBreakdown>;
215
202
  byDay: Record<string, TokenBreakdown>;
@@ -284,13 +271,12 @@ export function classifySessionLine(row: { timestamp?: string | number; type?: u
284
271
  }
285
272
  return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
286
273
  }
287
- // Fold `:free`/`-free` suffixes and case variants into one chart key, so the
288
- // same model from two providers stops splitting into separate rows.
274
+ // Fold `:free`/`-free` suffixes and case variants into one chart key.
289
275
  export function canonicalModelId(model: string): string {
290
- return model.replace(FREE_SUFFIX, '').toLowerCase();
276
+ return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
291
277
  }
292
- // LiteLLM-style display: namespace stays lowercase, model segments title-case
293
- // with version dots kept (`deepseek-v4.1-flash` → `Deepseek-V4.1-Flash`).
278
+ // LiteLLM-style display: lowercase namespace, title-cased model segments with
279
+ // version dots kept.
294
280
  export function displayModelId(model: string): string {
295
281
  const bare = model.replace(FREE_SUFFIX, '');
296
282
  const cap = (s: string): string => {
@@ -312,8 +298,8 @@ export function importSessionTokens(): SessionTokens {
312
298
  const accum = newSessionAccum();
313
299
  const files: string[] = [];
314
300
  walkJsonl(sessionsDir(), files, 2000);
315
- // A sessions override signals an isolated environment (tests, fixtures):
316
- // only walk the real codex dir when it is explicitly set.
301
+ // An override means an isolated environment (tests, fixtures): only walk the
302
+ // real codex dir when one is explicitly set.
317
303
  if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
318
304
  walkJsonl(codexSessionsDir(), files, 2000);
319
305
  }
@@ -443,8 +429,8 @@ export function readRtkRecallDiagnostics(binary: string | null = resolveRtkBinar
443
429
  return { mode: 'unknown', entries: 0, available: false };
444
430
  }
445
431
  }
446
- // Lead binary of a shell string: first segment head past `cd` chains and
447
- // VAR=x assignments (`cd /x && git status` → `git`). Falls back to `bash`.
432
+ // Lead binary of a shell string: first segment past `cd` chains and VAR=x
433
+ // assignments. Falls back to `bash`.
448
434
  const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
449
435
  export function leadBinary(command: unknown): string {
450
436
  if (typeof command !== 'string' || !command.trim()) return 'bash';
@@ -459,7 +445,7 @@ export function leadBinary(command: unknown): string {
459
445
  }
460
446
 
461
447
  // --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
462
- import { DEFAULT_PRICE, priceFor, refreshPricesIfStale } from './pricing.ts';
448
+ import { DEFAULT_PRICE, canonicalPriceId, priceFor, refreshPricesIfStale } from './pricing.ts';
463
449
  import { co2GramsFor, energyWhFor } from './carbon.ts';
464
450
  import type { ModelPrice } from './pricing.ts';
465
451
 
@@ -483,12 +469,6 @@ export function usdCost(t: TokenBreakdown, model?: string): { usd: number; price
483
469
  };
484
470
  }
485
471
 
486
- // CO2 now model-differentiated via ./carbon.ts (EcoLogits 0.8.2 port).
487
- // Always render with ~est. and never merge with measured figures.
488
- // The constant below is the served gCO2eq per 1K output tokens for the
489
- // default (gpt-4o-class) model, kept so single-figure callers stay honest.
490
- export const CO2_G_PER_1K_OUTPUT = 0.2;
491
-
492
472
  export function co2Grams(outputTokens: number, model?: string): number {
493
473
  return co2GramsFor(model ?? 'gpt-4o', outputTokens);
494
474
  }
@@ -513,9 +493,8 @@ export function readUsage(): UsageRow[] {
513
493
  return rows;
514
494
  }
515
495
 
516
- // Delete the ledger file. Returns the rows cleared (0 when already absent).
517
- // Only tersio-owned data lives here; session transcripts and the RTK
518
- // database are never touched.
496
+ // Delete the ledger file and return the rows cleared. Tersio-owned data only:
497
+ // transcripts and the RTK database are never touched.
519
498
  export function clearUsageLedger(): number {
520
499
  const rows = readUsage().length;
521
500
  try {
@@ -1,16 +1,15 @@
1
1
  // extensions/shared/usage-store.ts — tersio-owned usage.db.
2
- // Persists the same per-message rows importSessionTokens derives live (model,
3
- // tokens, cache, cost input, timing, status, tools), so top models, costing,
4
- // cache, CO2 inputs, and activity survive session-file rotation. Sync is
5
- // incremental: a files ledger (mtime+size) skips unchanged transcripts, and
6
- // every read applies the current reset watermark — the store is a cache,
7
- // never a fork. Missing sqlite3 CLI → sync false / read null, callers fall
8
- // back to the live path. Never touches RTK's history.db or host transcripts.
2
+ // Persists the rows importSessionTokens derives live, so reports survive
3
+ // session-file rotation. Sync is incremental (a files ledger skips unchanged
4
+ // transcripts) and every read applies the reset watermark, so the store is a
5
+ // cache, never a fork. Missing sqlite3 → sync false / read null. Never touches
6
+ // RTK's history.db or host transcripts.
9
7
  import { execFileSync } from 'node:child_process';
10
8
  import fs from 'node:fs';
11
9
  import path from 'node:path';
12
10
  import { RECENT_LIMIT, canonicalModelId, classifySessionLine, codexSessionsDir, costOf, durOf, ingestSessionRow, newSessionAccum, sessionsDir, walkJsonl, } from './usage-ledger.js';
13
11
  import { tersioDataPath } from '../lib/utils.js';
12
+ import { MODEL_ALIASES } from './pricing.js';
14
13
  export function usageDbPath() {
15
14
  const override = process.env.TERSIO_USAGE_DB;
16
15
  if (override)
@@ -45,12 +44,16 @@ function query(db, sql) {
45
44
  }
46
45
  function ensureSchema(db) {
47
46
  fs.mkdirSync(path.dirname(db), { recursive: true });
47
+ // Rows imported before an alias existed keep the spelling they were stored
48
+ // with, and the mtime ledger will not re-read those transcripts. Fold them in
49
+ // once, so the merge shows up without a reset.
50
+ const rekeys = MODEL_ALIASES.map((a) => `UPDATE messages SET model='${a.id}' WHERE model='${a.feed}';`).join('');
48
51
  run(db, `CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);` +
49
52
  `CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, mtime REAL NOT NULL, size INTEGER NOT NULL);` +
50
53
  `CREATE TABLE IF NOT EXISTS messages (file TEXT NOT NULL, t REAL, model TEXT NOT NULL,` +
51
54
  ` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
52
55
  ` usd REAL, st TEXT NOT NULL, code REAL, note TEXT, tools TEXT NOT NULL DEFAULT '[]');` +
53
- `CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file);`);
56
+ `CREATE INDEX IF NOT EXISTS idx_messages_file ON messages(file);` + rekeys);
54
57
  }
55
58
  function readFiles(db) {
56
59
  const known = {};
@@ -129,10 +132,9 @@ function insertSql(file, r) {
129
132
  `${nullNum(r.d)},${r.cr},${r.cw},${nullNum(r.usd)},${esc(r.st)},` +
130
133
  `${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))});`;
131
134
  }
132
- // Incremental sync: unchanged transcripts are skipped via mtime+size,
133
- // changed ones are deleted and re-inserted. Rows for deleted transcripts
134
- // are kept, so OMP session rotation never erases history. True on success
135
- // (including nothing-to-do), false when sqlite3 or disk is unavailable.
135
+ // Incremental sync: unchanged transcripts are skipped via mtime+size, changed
136
+ // ones re-inserted. Rows for deleted transcripts are kept, so rotation never
137
+ // erases history. False when sqlite3 or disk is unavailable.
136
138
  export function syncUsageDb() {
137
139
  let db;
138
140
  try {