@krtclcdy/tersio 2.23.0 → 2.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -45
- package/cli/agent-hosts.js +42 -0
- package/cli/agents.js +266 -0
- package/cli/banner.js +2 -3
- package/cli/common.js +16 -20
- package/cli/currency.js +11 -19
- package/cli/dashboard.js +216 -40
- package/cli/doctor-fix.js +40 -26
- package/cli/doctor.js +41 -41
- package/cli/hosts.js +114 -0
- package/cli/install.js +284 -179
- package/cli/interactive.js +62 -2
- package/cli/layer.js +22 -0
- package/cli/manifest.js +33 -0
- package/cli/omp-layer.js +51 -0
- package/cli/pi-layer.js +37 -0
- package/cli/pi-wiring.js +92 -0
- package/cli/profile.js +48 -48
- package/cli/reset.js +5 -32
- package/cli/rtk-wiring.js +7 -17
- package/cli/settings.js +3 -8
- package/cli/uninstall.js +162 -52
- package/cli/update.js +19 -27
- package/cli/usage.js +15 -5
- package/dashboard/dist/index.html +18 -18
- package/extensions/ai-addons-updater/index.js +37 -29
- package/extensions/ai-addons-updater/index.ts +39 -38
- package/extensions/caveman-session/index.js +19 -34
- package/extensions/caveman-session/index.ts +20 -35
- package/extensions/combo-toggle/index.js +47 -49
- package/extensions/combo-toggle/index.ts +42 -58
- package/extensions/lib/utils.js +40 -9
- package/extensions/lib/utils.ts +40 -9
- package/extensions/rtk-session/index.js +34 -38
- package/extensions/rtk-session/index.ts +32 -41
- package/extensions/shared/carbon.js +3 -11
- package/extensions/shared/carbon.ts +4 -11
- package/extensions/shared/host.js +58 -0
- package/extensions/shared/host.ts +94 -0
- package/extensions/shared/omp-prompt.js +12 -0
- package/extensions/shared/plugin-settings.js +33 -30
- package/extensions/shared/plugin-settings.ts +35 -26
- package/extensions/shared/pricing.js +25 -16
- package/extensions/shared/pricing.ts +27 -17
- package/extensions/shared/rtk-gain.js +6 -22
- package/extensions/shared/rtk-gain.ts +6 -22
- package/extensions/shared/session-state.js +44 -36
- package/extensions/shared/session-state.ts +48 -35
- package/extensions/shared/types.ts +10 -3
- package/extensions/shared/usage-ledger.js +160 -51
- package/extensions/shared/usage-ledger.ts +170 -67
- package/extensions/shared/usage-store.js +178 -24
- package/extensions/shared/usage-store.ts +176 -25
- package/extensions/tersio-commands/index.js +3 -6
- package/extensions/tersio-commands/index.ts +3 -6
- package/package.json +6 -5
- package/tersio.js +4 -6
- package/extensions/shared/mode-reinforcement.js +0 -26
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { readPluginSettings } from './plugin-settings.ts';
|
|
1
2
|
import type { ComboLevel, ComboState, ExtensionCtx, SessionEntry, UiApi } from './types.ts';
|
|
2
3
|
|
|
3
4
|
const BRIDGE_KEY = Symbol.for('tersio/combo-session-state');
|
|
4
5
|
|
|
6
|
+
// A verbatim sentence from OMP's subagent prompt, so it can drift; settings.json overrides it.
|
|
5
7
|
export const OMP_SUBAGENT_MARKER = 'You are operating on a piece of work assigned to you by the main agent.';
|
|
6
8
|
|
|
7
9
|
export const COMBO_LEVELS: Record<string, Readonly<ComboState>> = Object.freeze({
|
|
@@ -28,7 +30,7 @@ const MODE_ENTRY_TYPES: Record<string, ModeName> = {
|
|
|
28
30
|
|
|
29
31
|
interface Bridge {
|
|
30
32
|
state: Readonly<ComboState>;
|
|
31
|
-
listeners:
|
|
33
|
+
listeners: Map<string, (state: Readonly<ComboState>) => void>;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export function normalizeMode(name: ModeName, value: unknown): string | null {
|
|
@@ -67,13 +69,13 @@ function bridge(): Bridge {
|
|
|
67
69
|
const existing = (globalThis as Record<symbol, unknown>)[BRIDGE_KEY] as Bridge | undefined;
|
|
68
70
|
if (existing?.state) return existing;
|
|
69
71
|
const initial = normalizedState((existing || COMBO_LEVELS.off) as Partial<Modes>);
|
|
70
|
-
return ((globalThis as Record<symbol, unknown>)[BRIDGE_KEY] = { state: initial, listeners: new
|
|
72
|
+
return ((globalThis as Record<symbol, unknown>)[BRIDGE_KEY] = { state: initial, listeners: new Map() });
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
function publish(state: Readonly<ComboState>): Readonly<ComboState> {
|
|
74
76
|
const shared = bridge();
|
|
75
77
|
shared.state = state;
|
|
76
|
-
for (const listener of shared.listeners) listener(state);
|
|
78
|
+
for (const listener of shared.listeners.values()) listener(state);
|
|
77
79
|
return state;
|
|
78
80
|
}
|
|
79
81
|
|
|
@@ -90,34 +92,50 @@ export function systemPromptIncludes(systemPrompt: string | string[], marker: st
|
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
export function isOmpSubagentPrompt(systemPrompt: string | string[]): boolean {
|
|
93
|
-
|
|
95
|
+
const raw = readPluginSettings().subagentMarkers;
|
|
96
|
+
const configured = Array.isArray(raw) ? raw.filter((m): m is string => typeof m === 'string' && m.trim() !== '') : [];
|
|
97
|
+
// A malformed setting falls back rather than matching every turn.
|
|
98
|
+
const markers = configured.length ? configured : [OMP_SUBAGENT_MARKER];
|
|
99
|
+
return markers.some((marker) => systemPromptIncludes(systemPrompt, marker));
|
|
94
100
|
}
|
|
95
101
|
|
|
102
|
+
// ultra maps to max, lite/full to nearest, rather than erroring.
|
|
103
|
+
const COMBO_ALIASES: Record<string, ComboLevel> = { ultra: 'max', lite: 'medium', full: 'balanced' };
|
|
104
|
+
|
|
96
105
|
export function normalizeComboLevel(value: unknown): ComboLevel | null {
|
|
97
106
|
const level = String(value || '').trim().toLowerCase();
|
|
98
|
-
|
|
107
|
+
if (isPresetLevel(level)) return level;
|
|
108
|
+
return COMBO_ALIASES[level] ?? null;
|
|
99
109
|
}
|
|
100
110
|
|
|
101
|
-
|
|
111
|
+
// OMP themes status text through ctx.ui.theme.fg; pi has no ctx.ui.theme, so
|
|
112
|
+
// the same string renders plain there.
|
|
113
|
+
export function themeStatus(ui: UiApi | undefined, indicator: string, label: string, highlight = false): string {
|
|
102
114
|
const theme = ui?.theme;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
if (!theme?.fg) return `${indicator} ${label}`;
|
|
116
|
+
return `${highlight ? theme.fg('accent', indicator) : indicator} ${theme.fg('muted', label)}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function paintStatusBar(ui: UiApi | undefined, key: string, emoji: string, label: string, isActive: boolean): void {
|
|
120
|
+
ui?.setStatus?.(key, themeStatus(ui, emoji, label, isActive));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Stale ctx access throws, so never let it abort a mode restore.
|
|
124
|
+
export type StatusUi = UiApi & { setStatus: NonNullable<UiApi['setStatus']> };
|
|
125
|
+
|
|
126
|
+
export function statusUi(ctx: ExtensionCtx | undefined): StatusUi | undefined {
|
|
127
|
+
try {
|
|
128
|
+
const ui = ctx?.ui;
|
|
129
|
+
return ui?.setStatus ? (ui as StatusUi) : undefined;
|
|
130
|
+
} catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Which context a status bar paints through. Never remember a UI-less event:
|
|
136
|
+
// that mutes every later bare syncStatus(), freezing the bar on a stale preset.
|
|
119
137
|
export function paintableCtx(remembered: ExtensionCtx | undefined, next: ExtensionCtx | undefined): ExtensionCtx | undefined {
|
|
120
|
-
return next
|
|
138
|
+
return statusUi(next) ? next : remembered;
|
|
121
139
|
}
|
|
122
140
|
|
|
123
141
|
// Last-wins scan for a custom session entry; skips entries whose value fails
|
|
@@ -156,8 +174,7 @@ export function setSharedComboMode(name: ModeName, value: unknown): Readonly<Com
|
|
|
156
174
|
if (!mode) return getSharedComboState();
|
|
157
175
|
const modes = { ...getSharedComboState(), [name]: mode } as Modes;
|
|
158
176
|
// Derive the level from the final triplet so a redundant same-value write
|
|
159
|
-
//
|
|
160
|
-
// strand a matching preset at 'custom' and drop the combo bar.
|
|
177
|
+
// cannot strand a matching preset at 'custom' and drop the combo bar.
|
|
161
178
|
return publish(normalizedState(modes));
|
|
162
179
|
}
|
|
163
180
|
|
|
@@ -178,23 +195,19 @@ export function reconcileSharedComboEntries(entries: SessionEntry[] | null | und
|
|
|
178
195
|
if (mode) modes[name] = mode;
|
|
179
196
|
}
|
|
180
197
|
}
|
|
181
|
-
// The level
|
|
182
|
-
//
|
|
198
|
+
// The level reflects the final triplet, never the write order, so a preset
|
|
199
|
+
// entry followed by the same values reconciles back to the preset.
|
|
183
200
|
return publish(normalizedState(modes));
|
|
184
201
|
}
|
|
185
202
|
|
|
186
|
-
// One-line session-wide mode summary for change confirmations
|
|
187
|
-
// `caveman=ULTRA, rtk=ON, ponytail=ULTRA`.
|
|
203
|
+
// One-line session-wide mode summary for change confirmations.
|
|
188
204
|
export function activeModesSummary(state: { caveman: string; rtk: string; ponytail: string }): string {
|
|
189
205
|
return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}`;
|
|
190
206
|
}
|
|
191
207
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
// A null listener clears all (no current callers; reserved for teardown).
|
|
196
|
-
if (typeof listener === 'function') bridge().listeners.add(listener);
|
|
197
|
-
else bridge().listeners.clear();
|
|
208
|
+
// Keyed registration drops stale closures; hosts offer no teardown hook.
|
|
209
|
+
export function setSharedComboListener(key: string, listener: (state: Readonly<ComboState>) => void): void {
|
|
210
|
+
bridge().listeners.set(key, listener);
|
|
198
211
|
}
|
|
199
212
|
|
|
200
213
|
export function resetSharedComboState(): Readonly<ComboState> {
|
|
@@ -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
|
|
2
|
-
//
|
|
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:
|
|
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)
|
|
@@ -52,13 +46,14 @@ export function appendUsage(kind, detail) {
|
|
|
52
46
|
function zeroBreakdown() {
|
|
53
47
|
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
54
48
|
}
|
|
49
|
+
function stampMs(ts) {
|
|
50
|
+
return ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
51
|
+
}
|
|
55
52
|
export function durOf(v) {
|
|
56
53
|
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
57
54
|
}
|
|
58
|
-
// Measured cost
|
|
59
|
-
//
|
|
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.
|
|
55
|
+
// Measured cost: usage.cost.total, or a bare number in old fixtures. Anything
|
|
56
|
+
// else means unrecorded, which is deliberately not 0.
|
|
62
57
|
export function costOf(usage) {
|
|
63
58
|
const c = usage.cost;
|
|
64
59
|
if (typeof c === 'number')
|
|
@@ -92,11 +87,20 @@ function addInto(into, u) {
|
|
|
92
87
|
into[k] += Math.floor(v);
|
|
93
88
|
}
|
|
94
89
|
}
|
|
95
|
-
export function
|
|
90
|
+
export function sessionsDirs() {
|
|
96
91
|
const override = process.env.TERSIO_SESSIONS_DIR;
|
|
97
92
|
if (override)
|
|
98
|
-
return override;
|
|
99
|
-
|
|
93
|
+
return [override];
|
|
94
|
+
// Both hosts, not just the running one: a session on pi is missing from the
|
|
95
|
+
// dashboard entirely if we stop at the first directory that exists.
|
|
96
|
+
const dirs = isPiProcess()
|
|
97
|
+
? [path.join(piAgentDir(), 'sessions'), path.join(os.homedir(), '.omp', 'agent', 'sessions')]
|
|
98
|
+
: [path.join(os.homedir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
|
|
99
|
+
const found = dirs.filter((dir) => fs.existsSync(dir));
|
|
100
|
+
return found.length > 0 ? found : [dirs[0]];
|
|
101
|
+
}
|
|
102
|
+
export function sessionsDir() {
|
|
103
|
+
return sessionsDirs()[0];
|
|
100
104
|
}
|
|
101
105
|
export function codexSessionsDir() {
|
|
102
106
|
const override = process.env.TERSIO_CODEX_DIR;
|
|
@@ -107,6 +111,22 @@ export function codexSessionsDir() {
|
|
|
107
111
|
return path.join(codexHome, 'sessions');
|
|
108
112
|
return path.join(os.homedir(), '.codex', 'sessions');
|
|
109
113
|
}
|
|
114
|
+
// One JSON file per message; probe XDG, then local, then macOS default.
|
|
115
|
+
export function opencodeSessionsDir() {
|
|
116
|
+
const override = process.env.TERSIO_OPENCODE_DIR;
|
|
117
|
+
if (override)
|
|
118
|
+
return override;
|
|
119
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
120
|
+
if (xdg && xdg.trim() !== '')
|
|
121
|
+
return path.join(xdg, 'opencode', 'storage', 'message');
|
|
122
|
+
const local = path.join(os.homedir(), '.local', 'share', 'opencode', 'storage', 'message');
|
|
123
|
+
try {
|
|
124
|
+
if (fs.existsSync(local))
|
|
125
|
+
return local;
|
|
126
|
+
}
|
|
127
|
+
catch { /* fall through to the platform default */ }
|
|
128
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'opencode', 'storage', 'message');
|
|
129
|
+
}
|
|
110
130
|
export function dayKey(ts) {
|
|
111
131
|
const ms = typeof ts === 'number' ? ts : Date.parse(ts);
|
|
112
132
|
if (!Number.isFinite(ms))
|
|
@@ -115,7 +135,7 @@ export function dayKey(ts) {
|
|
|
115
135
|
const p = (n) => String(n).padStart(2, '0');
|
|
116
136
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
117
137
|
}
|
|
118
|
-
export function walkJsonl(dir, out, cap) {
|
|
138
|
+
export function walkJsonl(dir, out, cap, ext = '.jsonl') {
|
|
119
139
|
let entries;
|
|
120
140
|
try {
|
|
121
141
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -128,14 +148,13 @@ export function walkJsonl(dir, out, cap) {
|
|
|
128
148
|
return;
|
|
129
149
|
const full = path.join(dir, e.name);
|
|
130
150
|
if (e.isDirectory())
|
|
131
|
-
walkJsonl(full, out, cap);
|
|
132
|
-
else if (e.isFile() && e.name.endsWith(
|
|
151
|
+
walkJsonl(full, out, cap, ext);
|
|
152
|
+
else if (e.isFile() && e.name.endsWith(ext))
|
|
133
153
|
out.push(full);
|
|
134
154
|
}
|
|
135
155
|
}
|
|
136
|
-
// Recent requests
|
|
137
|
-
//
|
|
138
|
-
// (model, tokens, timing, status), so a few hundred cost little to ship.
|
|
156
|
+
// Recent requests get their own full-width table, so this bounds payload
|
|
157
|
+
// rather than highlights; rows are small, so a few hundred cost little.
|
|
139
158
|
export const RECENT_LIMIT = 200;
|
|
140
159
|
const FREE_SUFFIX = /(?::free|-free)$/i;
|
|
141
160
|
const RTK_ELIGIBLE_HEADS = new Set([
|
|
@@ -146,7 +165,7 @@ const RTK_ELIGIBLE_HEADS = new Set([
|
|
|
146
165
|
export function newSessionAccum() {
|
|
147
166
|
return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
|
|
148
167
|
}
|
|
149
|
-
export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames) {
|
|
168
|
+
export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host) {
|
|
150
169
|
const watermark = readResetWatermark();
|
|
151
170
|
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
152
171
|
if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
|
|
@@ -159,7 +178,7 @@ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames)
|
|
|
159
178
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
160
179
|
if (Number.isFinite(ms)) {
|
|
161
180
|
const outcome = run ?? { st: 'completed' };
|
|
162
|
-
accum.recent.push({ m: key, i: num(usage.input), o: num(usage.output), t: ms, d: durOf(durMs), cr: num(usage.cacheRead), cw: num(usage.cacheWrite), usd: costOf(usage), st: outcome.st, code: outcome.code, note: outcome.note });
|
|
181
|
+
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 });
|
|
163
182
|
}
|
|
164
183
|
const day = ts !== undefined ? dayKey(ts) : null;
|
|
165
184
|
if (day) {
|
|
@@ -184,6 +203,10 @@ export function classifySessionLine(row) {
|
|
|
184
203
|
if (payload && typeof payload === 'object') {
|
|
185
204
|
if (row.type === 'session_meta' && typeof payload.model_provider === 'string')
|
|
186
205
|
return { kind: 'codex_provider', provider: payload.model_provider };
|
|
206
|
+
// A Codex transcript states the model once per turn, in turn_context.
|
|
207
|
+
if (row.type === 'turn_context' && typeof payload.model === 'string' && payload.model) {
|
|
208
|
+
return { kind: 'codex_model', model: payload.model };
|
|
209
|
+
}
|
|
187
210
|
if (payload.type === 'token_count') {
|
|
188
211
|
const last = payload.info?.last_token_usage;
|
|
189
212
|
if (last) {
|
|
@@ -197,7 +220,10 @@ export function classifySessionLine(row) {
|
|
|
197
220
|
if (!msg || msg.role !== 'assistant' || !msg.usage)
|
|
198
221
|
return { kind: 'skip' };
|
|
199
222
|
const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
|
|
200
|
-
|
|
223
|
+
// pi omits duration, so derive Elapsed/Speed from the timestamp pair.
|
|
224
|
+
const startMs = typeof msg.timestamp === 'number' ? msg.timestamp : undefined;
|
|
225
|
+
const endMs = typeof msg.completedAt === 'number' ? msg.completedAt : stampMs(row.timestamp);
|
|
226
|
+
const computedDur = startMs !== undefined && Number.isFinite(endMs) && endMs > startMs ? endMs - startMs : undefined;
|
|
201
227
|
const tools = [];
|
|
202
228
|
for (const part of msg.content ?? []) {
|
|
203
229
|
if (part?.type !== 'toolCall' || typeof part.name !== 'string' || !part.name)
|
|
@@ -211,13 +237,63 @@ export function classifySessionLine(row) {
|
|
|
211
237
|
}
|
|
212
238
|
return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
|
|
213
239
|
}
|
|
214
|
-
//
|
|
215
|
-
|
|
240
|
+
// True when at least one token bucket holds a positive finite count.
|
|
241
|
+
export function hasPositiveUsage(usage) {
|
|
242
|
+
for (const k of ['input', 'output', 'cacheRead', 'cacheWrite']) {
|
|
243
|
+
const v = usage[k];
|
|
244
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0)
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
// One OpenCode message file; assistant rows carry the token buckets.
|
|
250
|
+
export function classifyOpencodeMessage(obj) {
|
|
251
|
+
if (!obj || typeof obj !== 'object' || obj.role !== 'assistant')
|
|
252
|
+
return null;
|
|
253
|
+
const t = obj.tokens;
|
|
254
|
+
if (!t || typeof t !== 'object')
|
|
255
|
+
return null;
|
|
256
|
+
const usage = {
|
|
257
|
+
input: t.input,
|
|
258
|
+
output: t.output,
|
|
259
|
+
cacheRead: t.cache?.read,
|
|
260
|
+
cacheWrite: t.cache?.write,
|
|
261
|
+
cost: obj.cost,
|
|
262
|
+
};
|
|
263
|
+
if (!hasPositiveUsage(usage))
|
|
264
|
+
return null;
|
|
265
|
+
const provider = typeof obj.providerID === 'string' && obj.providerID ? obj.providerID : 'unknown';
|
|
266
|
+
const model = typeof obj.modelID === 'string' && obj.modelID ? obj.modelID : 'unknown';
|
|
267
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
|
|
268
|
+
const created = num(obj.time?.created);
|
|
269
|
+
const completed = num(obj.time?.completed);
|
|
270
|
+
return {
|
|
271
|
+
model: `opencode/${provider}/${model}`,
|
|
272
|
+
usage,
|
|
273
|
+
ms: created,
|
|
274
|
+
durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
// One OpenCode message file into the shared accum.
|
|
278
|
+
export function processOpencodeFile(accum, text) {
|
|
279
|
+
let obj;
|
|
280
|
+
try {
|
|
281
|
+
obj = JSON.parse(text);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const parsed = classifyOpencodeMessage(obj);
|
|
287
|
+
if (!parsed)
|
|
288
|
+
return;
|
|
289
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, undefined, 'opencode');
|
|
290
|
+
}
|
|
291
|
+
// Fold `:free`/`-free` suffixes and case variants into one chart key.
|
|
216
292
|
export function canonicalModelId(model) {
|
|
217
|
-
return model.replace(FREE_SUFFIX, '')
|
|
293
|
+
return canonicalPriceId(model.replace(FREE_SUFFIX, ''));
|
|
218
294
|
}
|
|
219
|
-
// LiteLLM-style display: namespace
|
|
220
|
-
//
|
|
295
|
+
// LiteLLM-style display: lowercase namespace, title-cased model segments with
|
|
296
|
+
// version dots kept.
|
|
221
297
|
export function displayModelId(model) {
|
|
222
298
|
const bare = model.replace(FREE_SUFFIX, '');
|
|
223
299
|
const cap = (s) => {
|
|
@@ -242,13 +318,30 @@ export function displayModelId(model) {
|
|
|
242
318
|
export function importSessionTokens() {
|
|
243
319
|
const accum = newSessionAccum();
|
|
244
320
|
const files = [];
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
//
|
|
321
|
+
for (const dir of sessionsDirs())
|
|
322
|
+
walkJsonl(dir, files, 2000);
|
|
323
|
+
// An override means an isolated environment (tests, fixtures): only walk the
|
|
324
|
+
// real codex dir when one is explicitly set.
|
|
248
325
|
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
249
326
|
walkJsonl(codexSessionsDir(), files, 2000);
|
|
250
327
|
}
|
|
328
|
+
// OpenCode message bodies are single JSON documents, not JSONL.
|
|
329
|
+
const ocFiles = [];
|
|
330
|
+
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
|
|
331
|
+
walkJsonl(opencodeSessionsDir(), ocFiles, 5000, '.json');
|
|
332
|
+
}
|
|
333
|
+
for (const file of ocFiles) {
|
|
334
|
+
let text;
|
|
335
|
+
try {
|
|
336
|
+
text = fs.readFileSync(file, 'utf8');
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
processOpencodeFile(accum, text);
|
|
342
|
+
}
|
|
251
343
|
let codexProvider = null;
|
|
344
|
+
let codexModel = null;
|
|
252
345
|
for (const file of files) {
|
|
253
346
|
let text;
|
|
254
347
|
try {
|
|
@@ -257,11 +350,25 @@ export function importSessionTokens() {
|
|
|
257
350
|
catch {
|
|
258
351
|
continue;
|
|
259
352
|
}
|
|
260
|
-
processSessionText(accum, {
|
|
353
|
+
processSessionText(accum, {
|
|
354
|
+
get codexProvider() { return codexProvider; },
|
|
355
|
+
set codexProvider(v) { codexProvider = v; },
|
|
356
|
+
get codexModel() { return codexModel; },
|
|
357
|
+
set codexModel(v) { codexModel = v; },
|
|
358
|
+
}, text, hostOfSessionFile(file));
|
|
261
359
|
}
|
|
262
360
|
accum.recent.sort((a, b) => b.t - a.t);
|
|
263
361
|
return { messages: accum.messages, totals: accum.totals, byModel: accum.byModel, byDay: accum.byDay, byDayModel: accum.byDayModel, byTool: accum.byTool, byModelMessages: accum.byModelMessages, costMeasured: accum.costMeasured, recent: accum.recent.slice(0, RECENT_LIMIT) };
|
|
264
362
|
}
|
|
363
|
+
// Which agent wrote a session file, from the directory it sits in.
|
|
364
|
+
export function hostOfSessionFile(file) {
|
|
365
|
+
const norm = file.replace(/\\/g, '/');
|
|
366
|
+
if (norm.includes('/.omp/'))
|
|
367
|
+
return 'omp';
|
|
368
|
+
if (norm.includes('/.codex/'))
|
|
369
|
+
return 'codex';
|
|
370
|
+
return 'pi';
|
|
371
|
+
}
|
|
265
372
|
const adoptionFileCache = new Map();
|
|
266
373
|
function parseAdoptionFile(file) {
|
|
267
374
|
const counts = { bashCalls: 0, eligibleCalls: 0, rtkCalls: 0 };
|
|
@@ -300,7 +407,8 @@ export function clearRtkAdoptionCache() {
|
|
|
300
407
|
}
|
|
301
408
|
export function readRtkAdoption() {
|
|
302
409
|
const files = [];
|
|
303
|
-
|
|
410
|
+
for (const dir of sessionsDirs())
|
|
411
|
+
walkJsonl(dir, files, 2000);
|
|
304
412
|
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
305
413
|
walkJsonl(codexSessionsDir(), files, 2000);
|
|
306
414
|
}
|
|
@@ -338,7 +446,7 @@ export function readRtkAdoption() {
|
|
|
338
446
|
const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
|
|
339
447
|
return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
|
|
340
448
|
}
|
|
341
|
-
export function processSessionText(accum, state, text) {
|
|
449
|
+
export function processSessionText(accum, state, text, host) {
|
|
342
450
|
for (const line of text.split('\n')) {
|
|
343
451
|
if (!line.trim())
|
|
344
452
|
continue;
|
|
@@ -350,14 +458,21 @@ export function processSessionText(accum, state, text) {
|
|
|
350
458
|
state.codexProvider = parsed.provider;
|
|
351
459
|
continue;
|
|
352
460
|
}
|
|
461
|
+
if (parsed.kind === 'codex_model') {
|
|
462
|
+
if (parsed.model)
|
|
463
|
+
state.codexModel = parsed.model;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
353
466
|
if (parsed.kind === 'token_row') {
|
|
354
|
-
const
|
|
355
|
-
|
|
467
|
+
const base = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
|
|
468
|
+
// The provider alone told you nothing about which model ran.
|
|
469
|
+
const model = state.codexModel ? `${base}/${state.codexModel}` : base;
|
|
470
|
+
ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, parsed.durMs, { st: 'completed' }, undefined, host ?? 'codex');
|
|
356
471
|
continue;
|
|
357
472
|
}
|
|
358
473
|
if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage)
|
|
359
474
|
continue;
|
|
360
|
-
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools);
|
|
475
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host);
|
|
361
476
|
}
|
|
362
477
|
catch { /* skip corrupt lines */ }
|
|
363
478
|
}
|
|
@@ -385,8 +500,8 @@ export function readRtkRecallDiagnostics(binary = resolveRtkBinary()) {
|
|
|
385
500
|
return { mode: 'unknown', entries: 0, available: false };
|
|
386
501
|
}
|
|
387
502
|
}
|
|
388
|
-
// Lead binary of a shell string: first segment
|
|
389
|
-
//
|
|
503
|
+
// Lead binary of a shell string: first segment past `cd` chains and VAR=x
|
|
504
|
+
// assignments. Falls back to `bash`.
|
|
390
505
|
const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
|
|
391
506
|
export function leadBinary(command) {
|
|
392
507
|
if (typeof command !== 'string' || !command.trim())
|
|
@@ -403,7 +518,7 @@ export function leadBinary(command) {
|
|
|
403
518
|
return 'bash';
|
|
404
519
|
}
|
|
405
520
|
// --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
|
|
406
|
-
import { DEFAULT_PRICE, priceFor, refreshPricesIfStale } from './pricing.js';
|
|
521
|
+
import { DEFAULT_PRICE, canonicalPriceId, priceFor, refreshPricesIfStale } from './pricing.js';
|
|
407
522
|
import { co2GramsFor, energyWhFor } from './carbon.js';
|
|
408
523
|
export { DEFAULT_PRICE, priceFor, refreshPricesIfStale };
|
|
409
524
|
export { co2GramsFor, energyWhFor };
|
|
@@ -422,11 +537,6 @@ export function usdCost(t, model) {
|
|
|
422
537
|
buckets,
|
|
423
538
|
};
|
|
424
539
|
}
|
|
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
540
|
export function co2Grams(outputTokens, model) {
|
|
431
541
|
return co2GramsFor(model ?? 'gpt-4o', outputTokens);
|
|
432
542
|
}
|
|
@@ -452,9 +562,8 @@ export function readUsage() {
|
|
|
452
562
|
}
|
|
453
563
|
return rows;
|
|
454
564
|
}
|
|
455
|
-
// Delete the ledger file
|
|
456
|
-
//
|
|
457
|
-
// database are never touched.
|
|
565
|
+
// Delete the ledger file and return the rows cleared. Tersio-owned data only:
|
|
566
|
+
// transcripts and the RTK database are never touched.
|
|
458
567
|
export function clearUsageLedger() {
|
|
459
568
|
const rows = readUsage().length;
|
|
460
569
|
try {
|