@krtclcdy/tersio 2.17.0 → 2.19.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.
- package/README.md +20 -4
- package/cli/common.js +8 -2
- package/cli/currency.js +102 -0
- package/cli/dashboard.js +54 -2
- package/cli/doctor.js +2 -0
- package/cli/install.js +7 -73
- package/cli/profile.js +92 -0
- package/cli/reset.js +4 -2
- package/cli/settings.js +122 -0
- package/cli/usage.js +42 -16
- package/dashboard/app.js +435 -67
- package/dashboard/styles.css +83 -3
- package/dashboard/template.html +97 -17
- package/extensions/caveman-session/index.js +3 -4
- package/extensions/combo-toggle/index.js +3 -4
- package/extensions/rtk-session/index.js +3 -4
- package/extensions/shared/rtk-gain.js +13 -1
- package/extensions/shared/session-state.js +15 -0
- package/extensions/shared/usage-ledger.js +155 -83
- package/extensions/shared/usage-store.js +294 -0
- package/package.json +18 -1
- package/tersio.js +8 -1
|
@@ -50,12 +50,39 @@ export function appendUsage(kind, detail) {
|
|
|
50
50
|
function zeroBreakdown() {
|
|
51
51
|
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
52
52
|
}
|
|
53
|
-
|
|
54
|
-
// codex token_count rows carry none. Non-positive/non-finite → undefined
|
|
55
|
-
// so renderers print – instead of a fake 0.0s.
|
|
56
|
-
function durOf(v) {
|
|
53
|
+
export function durOf(v) {
|
|
57
54
|
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
|
58
55
|
}
|
|
56
|
+
// Measured cost for one message. OMP writes usage.cost as
|
|
57
|
+
// { input, output, cacheRead, cacheWrite, total }; a bare number shows up in
|
|
58
|
+
// older fixtures. Anything else means "not recorded" — deliberately not 0, so
|
|
59
|
+
// the dashboard can tell a genuinely free run from an unrecorded one.
|
|
60
|
+
export function costOf(usage) {
|
|
61
|
+
const c = usage.cost;
|
|
62
|
+
if (typeof c === 'number')
|
|
63
|
+
return Number.isFinite(c) ? c : undefined;
|
|
64
|
+
if (c && typeof c === 'object') {
|
|
65
|
+
const total = c.total;
|
|
66
|
+
if (typeof total === 'number' && Number.isFinite(total))
|
|
67
|
+
return total;
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
// First line only: real messages carry multi-line provider errors, and this
|
|
72
|
+
// ends up in a tooltip.
|
|
73
|
+
export function statusOf(msg) {
|
|
74
|
+
const stop = typeof msg.stopReason === 'string' ? msg.stopReason : '';
|
|
75
|
+
const note = typeof msg.errorMessage === 'string' && msg.errorMessage.trim()
|
|
76
|
+
? msg.errorMessage.split('\n')[0].trim().slice(0, 180)
|
|
77
|
+
: undefined;
|
|
78
|
+
if (stop === 'error' || msg.isError === true) {
|
|
79
|
+
const code = typeof msg.errorStatus === 'number' && Number.isFinite(msg.errorStatus) ? msg.errorStatus : undefined;
|
|
80
|
+
return { st: 'error', code, note };
|
|
81
|
+
}
|
|
82
|
+
if (stop === 'aborted')
|
|
83
|
+
return { st: 'aborted', note };
|
|
84
|
+
return { st: 'completed', note };
|
|
85
|
+
}
|
|
59
86
|
function addInto(into, u) {
|
|
60
87
|
for (const k of ['input', 'output', 'cacheRead', 'cacheWrite']) {
|
|
61
88
|
const v = u[k];
|
|
@@ -78,7 +105,7 @@ export function codexSessionsDir() {
|
|
|
78
105
|
return path.join(codexHome, 'sessions');
|
|
79
106
|
return path.join(os.homedir(), '.codex', 'sessions');
|
|
80
107
|
}
|
|
81
|
-
function dayKey(ts) {
|
|
108
|
+
export function dayKey(ts) {
|
|
82
109
|
const ms = typeof ts === 'number' ? ts : Date.parse(ts);
|
|
83
110
|
if (!Number.isFinite(ms))
|
|
84
111
|
return null;
|
|
@@ -86,7 +113,7 @@ function dayKey(ts) {
|
|
|
86
113
|
const p = (n) => String(n).padStart(2, '0');
|
|
87
114
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
88
115
|
}
|
|
89
|
-
function walkJsonl(dir, out, cap) {
|
|
116
|
+
export function walkJsonl(dir, out, cap) {
|
|
90
117
|
let entries;
|
|
91
118
|
try {
|
|
92
119
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -104,16 +131,104 @@ function walkJsonl(dir, out, cap) {
|
|
|
104
131
|
out.push(full);
|
|
105
132
|
}
|
|
106
133
|
}
|
|
107
|
-
|
|
134
|
+
// Recent requests are their own full-width table in the dashboard, so this is
|
|
135
|
+
// a payload bound rather than a "few highlights" bound. Rows are small
|
|
136
|
+
// (model, tokens, timing, status), so a few hundred cost little to ship.
|
|
137
|
+
export const RECENT_LIMIT = 200;
|
|
138
|
+
const FREE_SUFFIX = /(?::free|-free)$/i;
|
|
139
|
+
export function newSessionAccum() {
|
|
140
|
+
return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
|
|
141
|
+
}
|
|
142
|
+
export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames) {
|
|
108
143
|
const watermark = readResetWatermark();
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
144
|
+
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
145
|
+
if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
|
|
146
|
+
return false;
|
|
147
|
+
addInto(accum.totals, usage);
|
|
148
|
+
const key = model.replace(FREE_SUFFIX, '').toLowerCase();
|
|
149
|
+
accum.byModel[key] ??= zeroBreakdown();
|
|
150
|
+
addInto(accum.byModel[key], usage);
|
|
151
|
+
accum.byModelMessages[key] = (accum.byModelMessages[key] ?? 0) + 1;
|
|
152
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
153
|
+
if (Number.isFinite(ms)) {
|
|
154
|
+
const outcome = run ?? { st: 'completed' };
|
|
155
|
+
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 });
|
|
156
|
+
}
|
|
157
|
+
const day = ts !== undefined ? dayKey(ts) : null;
|
|
158
|
+
if (day) {
|
|
159
|
+
accum.byDay[day] ??= zeroBreakdown();
|
|
160
|
+
addInto(accum.byDay[day], usage);
|
|
161
|
+
const sum = ['input', 'output', 'cacheRead', 'cacheWrite'].reduce((a, k) => a + (typeof usage[k] === 'number' && Number.isFinite(usage[k]) ? Math.floor(usage[k]) : 0), 0);
|
|
162
|
+
if (sum > 0) {
|
|
163
|
+
accum.byDayModel[day] ??= {};
|
|
164
|
+
accum.byDayModel[day][key] = (accum.byDayModel[day][key] ?? 0) + sum;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
accum.messages += 1;
|
|
168
|
+
const measured = costOf(usage);
|
|
169
|
+
if (measured !== undefined)
|
|
170
|
+
accum.costMeasured += measured;
|
|
171
|
+
for (const name of toolNames ?? [])
|
|
172
|
+
accum.byTool[name] = (accum.byTool[name] ?? 0) + 1;
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
export function classifySessionLine(row) {
|
|
176
|
+
const payload = row.payload;
|
|
177
|
+
if (payload && typeof payload === 'object') {
|
|
178
|
+
if (row.type === 'session_meta' && typeof payload.model_provider === 'string')
|
|
179
|
+
return { kind: 'codex_provider', provider: payload.model_provider };
|
|
180
|
+
if (payload.type === 'token_count') {
|
|
181
|
+
const last = payload.info?.last_token_usage;
|
|
182
|
+
if (last) {
|
|
183
|
+
const provider = typeof payload.model_provider === 'string' ? payload.model_provider : undefined;
|
|
184
|
+
const ts = row.timestamp === undefined ? NaN : typeof row.timestamp === 'number' ? row.timestamp : Date.parse(row.timestamp);
|
|
185
|
+
return { kind: 'token_row', provider, model: 'codex', usage: { input: last['input_tokens'], output: last['output_tokens'], cacheRead: last['cached_input_tokens'], cacheWrite: last['cache_write_input_tokens'] }, ms: Number.isFinite(ts) ? ts : undefined };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const msg = row.message;
|
|
190
|
+
if (!msg || msg.role !== 'assistant' || !msg.usage)
|
|
191
|
+
return { kind: 'skip' };
|
|
192
|
+
const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
|
|
193
|
+
const computedDur = (typeof msg.completedAt === 'number' && typeof msg.timestamp === 'number') ? msg.completedAt - msg.timestamp : undefined;
|
|
194
|
+
const tools = [];
|
|
195
|
+
for (const part of msg.content ?? []) {
|
|
196
|
+
if (part?.type === 'toolCall' && typeof part.name === 'string' && part.name) {
|
|
197
|
+
tools.push(part.name === 'bash' ? `bash:${leadBinary(part.arguments?.command)}` : part.name);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
|
|
201
|
+
}
|
|
202
|
+
// Fold `:free`/`-free` suffixes and case variants into one chart key, so the
|
|
203
|
+
// same model from two providers stops splitting into separate rows.
|
|
204
|
+
export function canonicalModelId(model) {
|
|
205
|
+
return model.replace(FREE_SUFFIX, '').toLowerCase();
|
|
206
|
+
}
|
|
207
|
+
// LiteLLM-style display: namespace stays lowercase, model segments title-case
|
|
208
|
+
// with version dots kept (`deepseek-v4.1-flash` → `Deepseek-V4.1-Flash`).
|
|
209
|
+
export function displayModelId(model) {
|
|
210
|
+
const bare = model.replace(FREE_SUFFIX, '');
|
|
211
|
+
const cap = (s) => {
|
|
212
|
+
const low = s.toLowerCase();
|
|
213
|
+
if (low === 'openai')
|
|
214
|
+
return 'OpenAI';
|
|
215
|
+
if (low === 'ai')
|
|
216
|
+
return 'AI';
|
|
217
|
+
if (/^\d+[a-z]+$/.test(low))
|
|
218
|
+
return low.toUpperCase();
|
|
219
|
+
if (s.length <= 2)
|
|
220
|
+
return s.toUpperCase();
|
|
221
|
+
return s[0].toUpperCase() + s.slice(1).toLowerCase();
|
|
222
|
+
};
|
|
223
|
+
const seg = (s) => s.split('.').map(cap).join('.');
|
|
224
|
+
const words = (s) => s.split(/[-_:]+/).filter(Boolean).map(seg).join('-');
|
|
225
|
+
const slash = bare.indexOf('/');
|
|
226
|
+
if (slash >= 0)
|
|
227
|
+
return `${bare.slice(0, slash).toLowerCase()}/${words(bare.slice(slash + 1))}`;
|
|
228
|
+
return words(bare);
|
|
229
|
+
}
|
|
230
|
+
export function importSessionTokens() {
|
|
231
|
+
const accum = newSessionAccum();
|
|
117
232
|
const files = [];
|
|
118
233
|
walkJsonl(sessionsDir(), files, 2000);
|
|
119
234
|
// A sessions override signals an isolated environment (tests, fixtures):
|
|
@@ -122,32 +237,6 @@ export function importSessionTokens() {
|
|
|
122
237
|
walkJsonl(codexSessionsDir(), files, 2000);
|
|
123
238
|
}
|
|
124
239
|
let codexProvider = null;
|
|
125
|
-
const recent = [];
|
|
126
|
-
// Rows from before the reset watermark (and rows with no usable timestamp,
|
|
127
|
-
function ingest(model, usage, ts, durMs) {
|
|
128
|
-
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
129
|
-
if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
|
|
130
|
-
return false;
|
|
131
|
-
addInto(totals, usage);
|
|
132
|
-
byModel[model] ??= zeroBreakdown();
|
|
133
|
-
addInto(byModel[model], usage);
|
|
134
|
-
byModelMessages[model] = (byModelMessages[model] ?? 0) + 1;
|
|
135
|
-
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
136
|
-
if (Number.isFinite(ms))
|
|
137
|
-
recent.push({ m: model, i: num(usage.input), o: num(usage.output), t: ms, d: durOf(durMs), cr: num(usage.cacheRead), cw: num(usage.cacheWrite) });
|
|
138
|
-
const day = ts !== undefined ? dayKey(ts) : null;
|
|
139
|
-
if (day) {
|
|
140
|
-
byDay[day] ??= zeroBreakdown();
|
|
141
|
-
addInto(byDay[day], usage);
|
|
142
|
-
const sum = ['input', 'output', 'cacheRead', 'cacheWrite'].reduce((a, k) => a + (typeof usage[k] === 'number' && Number.isFinite(usage[k]) ? Math.floor(usage[k]) : 0), 0);
|
|
143
|
-
if (sum > 0) {
|
|
144
|
-
byDayModel[day] ??= {};
|
|
145
|
-
byDayModel[day][model] = (byDayModel[day][model] ?? 0) + sum;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
messages += 1;
|
|
149
|
-
return true;
|
|
150
|
-
}
|
|
151
240
|
for (const file of files) {
|
|
152
241
|
let text;
|
|
153
242
|
try {
|
|
@@ -156,56 +245,39 @@ export function importSessionTokens() {
|
|
|
156
245
|
catch {
|
|
157
246
|
continue;
|
|
158
247
|
}
|
|
159
|
-
|
|
160
|
-
|
|
248
|
+
processSessionText(accum, { get codexProvider() { return codexProvider; }, set codexProvider(v) { codexProvider = v; } }, text);
|
|
249
|
+
}
|
|
250
|
+
accum.recent.sort((a, b) => b.t - a.t);
|
|
251
|
+
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) };
|
|
252
|
+
}
|
|
253
|
+
export function processSessionText(accum, state, text) {
|
|
254
|
+
for (const line of text.split('\n')) {
|
|
255
|
+
if (!line.trim())
|
|
256
|
+
continue;
|
|
257
|
+
try {
|
|
258
|
+
const row = JSON.parse(line);
|
|
259
|
+
const parsed = classifySessionLine(row);
|
|
260
|
+
if (parsed.kind === 'codex_provider') {
|
|
261
|
+
if (parsed.provider)
|
|
262
|
+
state.codexProvider = parsed.provider;
|
|
161
263
|
continue;
|
|
162
|
-
try {
|
|
163
|
-
const row = JSON.parse(line);
|
|
164
|
-
const codexRow = row;
|
|
165
|
-
const payload = codexRow.payload;
|
|
166
|
-
if (payload && typeof payload === 'object') {
|
|
167
|
-
if (codexRow.type === 'session_meta' && typeof payload.model_provider === 'string') {
|
|
168
|
-
codexProvider = payload.model_provider;
|
|
169
|
-
}
|
|
170
|
-
else if (payload.type === 'token_count') {
|
|
171
|
-
const last = payload.info?.last_token_usage;
|
|
172
|
-
if (last) {
|
|
173
|
-
ingest(codexProvider ? `codex/${codexProvider}` : 'codex', {
|
|
174
|
-
input: last['input_tokens'],
|
|
175
|
-
output: last['output_tokens'],
|
|
176
|
-
cacheRead: last['cached_input_tokens'],
|
|
177
|
-
cacheWrite: last['cache_write_input_tokens'],
|
|
178
|
-
}, row.timestamp);
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
const msg = row.message;
|
|
184
|
-
if (!msg || msg.role !== 'assistant' || !msg.usage)
|
|
185
|
-
continue;
|
|
186
|
-
const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
|
|
187
|
-
const counted = ingest(model, msg.usage, row.timestamp, msg.duration);
|
|
188
|
-
if (counted && typeof msg.usage.cost === 'number' && Number.isFinite(msg.usage.cost))
|
|
189
|
-
costMeasured += msg.usage.cost;
|
|
190
|
-
if (!counted)
|
|
191
|
-
continue;
|
|
192
|
-
for (const part of msg.content ?? []) {
|
|
193
|
-
if (part?.type === 'toolCall' && typeof part.name === 'string' && part.name) {
|
|
194
|
-
const key = part.name === 'bash' ? `bash:${leadBinary(part.arguments?.command)}` : part.name;
|
|
195
|
-
byTool[key] = (byTool[key] ?? 0) + 1;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
264
|
}
|
|
199
|
-
|
|
265
|
+
if (parsed.kind === 'token_row') {
|
|
266
|
+
const model = parsed.provider ? `codex/${parsed.provider}` : (state.codexProvider ? `codex/${state.codexProvider}` : 'codex');
|
|
267
|
+
ingestSessionRow(accum, model, parsed.usage ?? {}, row.timestamp, undefined, { st: 'completed' });
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage)
|
|
271
|
+
continue;
|
|
272
|
+
ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools);
|
|
200
273
|
}
|
|
274
|
+
catch { /* skip corrupt lines */ }
|
|
201
275
|
}
|
|
202
|
-
recent.sort((a, b) => b.t - a.t);
|
|
203
|
-
return { messages, totals, byModel, byDay, byDayModel, byTool, byModelMessages, costMeasured, recent: recent.slice(0, 25) };
|
|
204
276
|
}
|
|
205
277
|
// Lead binary of a shell string: first segment head past `cd` chains and
|
|
206
278
|
// VAR=x assignments (`cd /x && git status` → `git`). Falls back to `bash`.
|
|
207
279
|
const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
|
|
208
|
-
function leadBinary(command) {
|
|
280
|
+
export function leadBinary(command) {
|
|
209
281
|
if (typeof command !== 'string' || !command.trim())
|
|
210
282
|
return 'bash';
|
|
211
283
|
for (const seg of command.split(/&&|;|\n|\|(?!\|)/)) {
|
|
@@ -0,0 +1,294 @@
|
|
|
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.
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { RECENT_LIMIT, canonicalModelId, classifySessionLine, codexSessionsDir, costOf, durOf, ingestSessionRow, newSessionAccum, sessionsDir, walkJsonl, } from './usage-ledger.js';
|
|
14
|
+
export function usageDbPath() {
|
|
15
|
+
const override = process.env.TERSIO_USAGE_DB;
|
|
16
|
+
if (override)
|
|
17
|
+
return override;
|
|
18
|
+
return path.join(os.homedir(), '.omp', 'plugins', 'tersio-usage.db');
|
|
19
|
+
}
|
|
20
|
+
function esc(v) {
|
|
21
|
+
return `'${v.replace(/'/g, "''")}'`;
|
|
22
|
+
}
|
|
23
|
+
function intOf(v) {
|
|
24
|
+
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0;
|
|
25
|
+
}
|
|
26
|
+
function nullNum(v) {
|
|
27
|
+
return v === undefined ? 'NULL' : String(v);
|
|
28
|
+
}
|
|
29
|
+
function nullStr(v) {
|
|
30
|
+
return v === undefined ? 'NULL' : esc(v);
|
|
31
|
+
}
|
|
32
|
+
function run(db, sql) {
|
|
33
|
+
execFileSync('sqlite3', [db, sql], { stdio: ['ignore', 'ignore', 'ignore'], timeout: 30000 });
|
|
34
|
+
}
|
|
35
|
+
function query(db, sql) {
|
|
36
|
+
const out = execFileSync('sqlite3', ['-separator', '\t', db, sql], {
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
timeout: 30000,
|
|
39
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
40
|
+
});
|
|
41
|
+
return out
|
|
42
|
+
.split('\n')
|
|
43
|
+
.filter((line) => line.trim())
|
|
44
|
+
.map((line) => line.split('\t'));
|
|
45
|
+
}
|
|
46
|
+
function ensureSchema(db) {
|
|
47
|
+
fs.mkdirSync(path.dirname(db), { recursive: true });
|
|
48
|
+
run(db, `CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);` +
|
|
49
|
+
`CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, mtime REAL NOT NULL, size INTEGER NOT NULL);` +
|
|
50
|
+
`CREATE TABLE IF NOT EXISTS messages (file TEXT NOT NULL, t REAL, model TEXT NOT NULL,` +
|
|
51
|
+
` i INTEGER NOT NULL, o INTEGER NOT NULL, d REAL, cr INTEGER NOT NULL, cw INTEGER NOT NULL,` +
|
|
52
|
+
` 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);`);
|
|
54
|
+
}
|
|
55
|
+
function readFiles(db) {
|
|
56
|
+
const known = {};
|
|
57
|
+
try {
|
|
58
|
+
for (const [p, mtime, size] of query(db, `SELECT path, mtime, size FROM files;`)) {
|
|
59
|
+
known[p] = { mtime: Number(mtime) || 0, size: Number(size) || 0 };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch { /* fresh db */ }
|
|
63
|
+
return known;
|
|
64
|
+
}
|
|
65
|
+
function parseFile(text) {
|
|
66
|
+
const rows = [];
|
|
67
|
+
let codexProvider = null;
|
|
68
|
+
for (const line of text.split('\n')) {
|
|
69
|
+
if (!line.trim())
|
|
70
|
+
continue;
|
|
71
|
+
try {
|
|
72
|
+
const row = JSON.parse(line);
|
|
73
|
+
const parsed = classifySessionLine(row);
|
|
74
|
+
if (parsed.kind === 'codex_provider') {
|
|
75
|
+
if (parsed.provider)
|
|
76
|
+
codexProvider = parsed.provider;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const ts = row.timestamp;
|
|
80
|
+
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
81
|
+
const t = Number.isFinite(ms) ? ms : null;
|
|
82
|
+
if (parsed.kind === 'token_row') {
|
|
83
|
+
const provider = parsed.provider ?? codexProvider;
|
|
84
|
+
const usage = parsed.usage ?? {};
|
|
85
|
+
rows.push({
|
|
86
|
+
t,
|
|
87
|
+
model: canonicalModelId(provider ? `codex/${provider}` : 'codex'),
|
|
88
|
+
i: intOf(usage.input),
|
|
89
|
+
o: intOf(usage.output),
|
|
90
|
+
d: undefined,
|
|
91
|
+
cr: intOf(usage.cacheRead),
|
|
92
|
+
cw: intOf(usage.cacheWrite),
|
|
93
|
+
usd: costOf(usage),
|
|
94
|
+
st: 'completed',
|
|
95
|
+
code: undefined,
|
|
96
|
+
note: undefined,
|
|
97
|
+
tools: [],
|
|
98
|
+
});
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage)
|
|
102
|
+
continue;
|
|
103
|
+
const usage = parsed.usage;
|
|
104
|
+
const dur = durOf(parsed.durMs);
|
|
105
|
+
const run = parsed.run ?? { st: 'completed' };
|
|
106
|
+
const note = run.note?.replace(/[\t\n\r]/g, ' ').slice(0, 180);
|
|
107
|
+
rows.push({
|
|
108
|
+
t,
|
|
109
|
+
model: canonicalModelId(parsed.model),
|
|
110
|
+
i: intOf(usage.input),
|
|
111
|
+
o: intOf(usage.output),
|
|
112
|
+
d: dur,
|
|
113
|
+
cr: intOf(usage.cacheRead),
|
|
114
|
+
cw: intOf(usage.cacheWrite),
|
|
115
|
+
usd: costOf(usage),
|
|
116
|
+
st: run.st,
|
|
117
|
+
code: run.code,
|
|
118
|
+
note,
|
|
119
|
+
tools: parsed.tools ?? [],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch { /* skip corrupt lines */ }
|
|
123
|
+
}
|
|
124
|
+
return rows;
|
|
125
|
+
}
|
|
126
|
+
function insertSql(file, r) {
|
|
127
|
+
return `INSERT INTO messages (file, t, model, i, o, d, cr, cw, usd, st, code, note, tools) VALUES (` +
|
|
128
|
+
`${esc(file)},${r.t === null ? 'NULL' : String(r.t)},${esc(r.model)},${r.i},${r.o},` +
|
|
129
|
+
`${nullNum(r.d)},${r.cr},${r.cw},${nullNum(r.usd)},${esc(r.st)},` +
|
|
130
|
+
`${nullNum(r.code)},${nullStr(r.note)},${esc(JSON.stringify(r.tools))});`;
|
|
131
|
+
}
|
|
132
|
+
// Incremental sync: unchanged transcripts are skipped via mtime+size, changed
|
|
133
|
+
// ones are deleted and re-inserted, removed ones are dropped. True on success
|
|
134
|
+
// (including nothing-to-do), false when sqlite3 or disk is unavailable.
|
|
135
|
+
export function syncUsageDb() {
|
|
136
|
+
let db;
|
|
137
|
+
try {
|
|
138
|
+
db = usageDbPath();
|
|
139
|
+
ensureSchema(db);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const files = [];
|
|
145
|
+
try {
|
|
146
|
+
walkJsonl(sessionsDir(), files, 2000);
|
|
147
|
+
if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
|
|
148
|
+
walkJsonl(codexSessionsDir(), files, 2000);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
const known = readFiles(db);
|
|
155
|
+
const current = {};
|
|
156
|
+
const changed = [];
|
|
157
|
+
for (const file of files) {
|
|
158
|
+
let st;
|
|
159
|
+
try {
|
|
160
|
+
st = fs.statSync(file);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const entry = { mtime: st.mtimeMs, size: st.size };
|
|
166
|
+
current[file] = entry;
|
|
167
|
+
const prev = known[file];
|
|
168
|
+
if (!prev || prev.mtime !== entry.mtime || prev.size !== entry.size)
|
|
169
|
+
changed.push(file);
|
|
170
|
+
}
|
|
171
|
+
const removed = Object.keys(known).filter((p) => current[p] === undefined);
|
|
172
|
+
if (!changed.length && !removed.length)
|
|
173
|
+
return true;
|
|
174
|
+
const chunks = [];
|
|
175
|
+
let chunkBytes = 0;
|
|
176
|
+
const flush = () => {
|
|
177
|
+
if (!chunks.length)
|
|
178
|
+
return true;
|
|
179
|
+
try {
|
|
180
|
+
run(db, `BEGIN;${chunks.join('')}COMMIT;`);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
chunks.length = 0;
|
|
186
|
+
chunkBytes = 0;
|
|
187
|
+
return true;
|
|
188
|
+
};
|
|
189
|
+
const push = (sql) => {
|
|
190
|
+
chunks.push(sql);
|
|
191
|
+
chunkBytes += sql.length;
|
|
192
|
+
if (chunkBytes < 400_000)
|
|
193
|
+
return true;
|
|
194
|
+
return flush();
|
|
195
|
+
};
|
|
196
|
+
for (const file of [...changed, ...removed]) {
|
|
197
|
+
if (!push(`DELETE FROM messages WHERE file=${esc(file)};DELETE FROM files WHERE path=${esc(file)};`))
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
for (const file of changed) {
|
|
202
|
+
const entry = current[file];
|
|
203
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
204
|
+
for (const r of parseFile(text)) {
|
|
205
|
+
if (!push(insertSql(file, r)))
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
if (!push(`INSERT OR REPLACE INTO files (path, mtime, size) VALUES (${esc(file)},${entry.mtime},${entry.size});`))
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
if (!push(`INSERT OR REPLACE INTO meta (k, v) VALUES ('last_sync',${esc(String(Date.now()))});`))
|
|
216
|
+
return false;
|
|
217
|
+
return flush();
|
|
218
|
+
}
|
|
219
|
+
export function readUsageDb() {
|
|
220
|
+
const db = usageDbPath();
|
|
221
|
+
try {
|
|
222
|
+
if (!fs.existsSync(db))
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
let rows;
|
|
229
|
+
try {
|
|
230
|
+
rows = query(db, `SELECT t, model, i, o, d, cr, cw, usd, st, code, note, tools FROM messages;`);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
const accum = newSessionAccum();
|
|
236
|
+
for (const [t, model, i, o, d, cr, cw, usd, st, code, note, tools] of rows) {
|
|
237
|
+
const ts = t === '' ? undefined : Number(t);
|
|
238
|
+
let toolNames = [];
|
|
239
|
+
try {
|
|
240
|
+
const parsed = JSON.parse(tools || '[]');
|
|
241
|
+
if (Array.isArray(parsed))
|
|
242
|
+
toolNames = parsed.filter((x) => typeof x === 'string');
|
|
243
|
+
}
|
|
244
|
+
catch { /* keep empty */ }
|
|
245
|
+
const usage = {
|
|
246
|
+
input: Number(i) || 0,
|
|
247
|
+
output: Number(o) || 0,
|
|
248
|
+
cacheRead: Number(cr) || 0,
|
|
249
|
+
cacheWrite: Number(cw) || 0,
|
|
250
|
+
};
|
|
251
|
+
if (usd !== '') {
|
|
252
|
+
const measured = Number(usd);
|
|
253
|
+
if (Number.isFinite(measured))
|
|
254
|
+
usage.cost = measured;
|
|
255
|
+
}
|
|
256
|
+
ingestSessionRow(accum, model || 'unknown', usage, Number.isFinite(ts) ? ts : undefined, d === '' ? undefined : Number(d), {
|
|
257
|
+
st: st === 'error' || st === 'aborted' ? st : 'completed',
|
|
258
|
+
code: code === '' ? undefined : Number(code),
|
|
259
|
+
note: note === '' ? undefined : note,
|
|
260
|
+
}, toolNames);
|
|
261
|
+
}
|
|
262
|
+
accum.recent.sort((a, b) => b.t - a.t);
|
|
263
|
+
let syncedAt = 0;
|
|
264
|
+
try {
|
|
265
|
+
const meta = query(db, `SELECT v FROM meta WHERE k='last_sync';`);
|
|
266
|
+
if (meta.length)
|
|
267
|
+
syncedAt = Number(meta[0][0]) || 0;
|
|
268
|
+
}
|
|
269
|
+
catch { /* keep 0 */ }
|
|
270
|
+
return {
|
|
271
|
+
tokens: {
|
|
272
|
+
messages: accum.messages,
|
|
273
|
+
totals: accum.totals,
|
|
274
|
+
byModel: accum.byModel,
|
|
275
|
+
byDay: accum.byDay,
|
|
276
|
+
byDayModel: accum.byDayModel,
|
|
277
|
+
byTool: accum.byTool,
|
|
278
|
+
byModelMessages: accum.byModelMessages,
|
|
279
|
+
costMeasured: accum.costMeasured,
|
|
280
|
+
recent: accum.recent.slice(0, RECENT_LIMIT),
|
|
281
|
+
},
|
|
282
|
+
syncedAt,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
// Delete the store file. Returns 1 when removed, 0 when already absent.
|
|
286
|
+
export function clearUsageDb() {
|
|
287
|
+
try {
|
|
288
|
+
fs.unlinkSync(usageDbPath());
|
|
289
|
+
return 1;
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krtclcdy/tersio",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"description": "✂️ Token-saving OMP add-ons: terse Caveman replies, compact RTK shell output, lean Ponytail code calls — one-command combo presets.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"omp",
|
|
@@ -109,6 +109,23 @@
|
|
|
109
109
|
"review"
|
|
110
110
|
],
|
|
111
111
|
"default": "off"
|
|
112
|
+
},
|
|
113
|
+
"currency": {
|
|
114
|
+
"type": "enum",
|
|
115
|
+
"description": "Display currency for usage/gain reports (--currency overrides per run)",
|
|
116
|
+
"values": [
|
|
117
|
+
"USD",
|
|
118
|
+
"PHP",
|
|
119
|
+
"EUR",
|
|
120
|
+
"GBP",
|
|
121
|
+
"JPY",
|
|
122
|
+
"KRW",
|
|
123
|
+
"SGD",
|
|
124
|
+
"AUD",
|
|
125
|
+
"CAD",
|
|
126
|
+
"INR"
|
|
127
|
+
],
|
|
128
|
+
"default": "USD"
|
|
112
129
|
}
|
|
113
130
|
}
|
|
114
131
|
},
|
package/tersio.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// tersio.ts — Install Tersio (caveman/rtk/ponytail) add-ons on any OMP device.
|
|
3
3
|
// Usage: node tersio.js [install|update|reinstall|doctor|uninstall|version|help] [options]
|
|
4
4
|
// Requires: node/npm and omp CLI
|
|
5
|
-
import { PACKAGE_BIN, PACKAGE_VERSION, applyUpdate, commandArg, gain, dashboardExport, dashboardOpen, dashboardPort, doctor, reset, showHelp, showVersion, uninstall, unknownCommand, update, usage, } from './cli/common.js';
|
|
5
|
+
import { PACKAGE_BIN, PACKAGE_VERSION, applyUpdate, commandArg, gain, dashboardExport, dashboardOpen, dashboardPort, doctor, reset, settings, showHelp, showVersion, uninstall, unknownCommand, update, usage, } from './cli/common.js';
|
|
6
6
|
import { closeRL } from './cli/interactive.js';
|
|
7
7
|
import { runInstall } from './cli/install.js';
|
|
8
8
|
import { runDoctor } from './cli/doctor.js';
|
|
@@ -10,6 +10,7 @@ import { runLatestUpdate } from './cli/update.js';
|
|
|
10
10
|
import { runUninstall } from './cli/uninstall.js';
|
|
11
11
|
import { runUsage } from './cli/usage.js';
|
|
12
12
|
import { runReset } from './cli/reset.js';
|
|
13
|
+
import { runSettings } from './cli/settings.js';
|
|
13
14
|
import { runDashboard } from './cli/dashboard.js';
|
|
14
15
|
function printHelp() {
|
|
15
16
|
console.log(`Usage: ${PACKAGE_BIN} [command] [options]
|
|
@@ -22,6 +23,7 @@ Commands:
|
|
|
22
23
|
usage Ledger-backed usage + savings report
|
|
23
24
|
gain Open the gain dashboard (localhost only)
|
|
24
25
|
reset Clear tersio statistics (usage ledger)
|
|
26
|
+
settings View/change session-start defaults (combo, caveman, rtk, ponytail)
|
|
25
27
|
uninstall Remove the managed extensions
|
|
26
28
|
version Print the package version
|
|
27
29
|
help Show this help
|
|
@@ -40,6 +42,7 @@ Options:
|
|
|
40
42
|
--port <n> (gain: pin port, default ephemeral)
|
|
41
43
|
--open (gain: open browser)
|
|
42
44
|
--export <file> (gain: write HTML file instead of serving)
|
|
45
|
+
--currency <code> (usage|gain: display currency; flag wins, then tersio settings default, then USD)
|
|
43
46
|
--version, -v
|
|
44
47
|
--help, -h`);
|
|
45
48
|
}
|
|
@@ -86,6 +89,10 @@ async function main() {
|
|
|
86
89
|
closeRL();
|
|
87
90
|
return;
|
|
88
91
|
}
|
|
92
|
+
if (settings) {
|
|
93
|
+
await runSettings();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
89
96
|
if (uninstall) {
|
|
90
97
|
await runUninstall();
|
|
91
98
|
closeRL();
|