@krtclcdy/tersio 2.18.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 +2 -2
- 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 +38 -24
- package/dashboard/app.js +297 -70
- package/dashboard/styles.css +58 -1
- package/dashboard/template.html +75 -3
- package/extensions/shared/rtk-gain.js +3 -6
- package/extensions/shared/usage-ledger.js +124 -103
- package/extensions/shared/usage-store.js +294 -0
- package/package.json +18 -1
- package/tersio.js +8 -1
package/dashboard/styles.css
CHANGED
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
.mrow:hover { background: var(--track); box-shadow: var(--shadow-xs); }
|
|
127
127
|
.stripcell { transition: background .25s; }
|
|
128
128
|
.stripcell:hover { box-shadow: var(--shadow-xs); }
|
|
129
|
-
button, .tilt, .cell, .stripcell { cursor: pointer; }
|
|
129
|
+
button, .tilt, .cell, .stripcell, .modelcard, .mrow { cursor: pointer; }
|
|
130
130
|
.btn-push { transition: transform .12s, background .2s; }
|
|
131
131
|
.btn-push:hover { background: var(--accent-soft); }
|
|
132
132
|
.btn-push:active { transform: scale(.96); }
|
|
@@ -245,3 +245,60 @@ html[data-theme="dark"] .brandmark.placeholder { background: #fff !important; co
|
|
|
245
245
|
.toast-desc { font-size: 12px; color: var(--dim); margin: 1px 0 0; }
|
|
246
246
|
@keyframes toast-in { from { opacity: 0; transform: translateY(8px) scale(.98); } }
|
|
247
247
|
@keyframes toast-out { to { opacity: 0; transform: translateY(6px) scale(.98); } }
|
|
248
|
+
/* Model detail dialog (shadcn chart language, native modal). KPI strip,
|
|
249
|
+
gradient area chart with range tabs, rounded bars, token-mix donut. */
|
|
250
|
+
.dlg.mdlg { width: min(880px, calc(100vw - 32px)); max-height: min(88vh, 960px); padding: 0; overflow: hidden; display: none; flex-direction: column; }
|
|
251
|
+
.dlg.mdlg[open] { display: flex; }
|
|
252
|
+
.dlg.mdlg .dlg-head { padding: 20px 20px 0; }
|
|
253
|
+
.md-body { overflow-y: auto; padding: 0 20px 20px; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--line) transparent; display: grid; gap: 12px; margin-top: 16px; }
|
|
254
|
+
.md-rankpill { border: 1px solid var(--line); background: var(--accent-soft); color: var(--accent); border-radius: 999px; padding: 4px 12px; font-size: 12px; font-weight: 700; white-space: nowrap; }
|
|
255
|
+
.md-kpis { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-flow: dense; gap: 8px; }
|
|
256
|
+
@media (min-width: 640px) { .md-kpis { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
|
|
257
|
+
.md-kpi { border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; background: var(--panel); }
|
|
258
|
+
.md-kpi p { margin: 0; }
|
|
259
|
+
.md-kpi .k { font-size: 10px; text-transform: uppercase; letter-spacing: .14em; color: var(--dim); }
|
|
260
|
+
.md-kpi .v { font-size: 22px; font-weight: 800; letter-spacing: -0.02em; margin-top: 4px; font-variant-numeric: tabular-nums; }
|
|
261
|
+
.md-kpi .d { font-size: 11px; margin-top: 2px; font-variant-numeric: tabular-nums; }
|
|
262
|
+
.md-kpi .d.up { color: var(--accent); }
|
|
263
|
+
.md-kpi .d.flat { color: var(--dim); }
|
|
264
|
+
.md-chart { position: relative; border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: var(--panel); }
|
|
265
|
+
.md-chart-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
|
266
|
+
.md-chart-title { font-size: 13px; font-weight: 700; letter-spacing: -0.01em; margin: 0; }
|
|
267
|
+
.md-chart-sub { font-size: 11px; color: var(--dim); margin: 2px 0 0; text-transform: uppercase; letter-spacing: .12em; }
|
|
268
|
+
.md-trend { font-size: 11px; font-weight: 700; white-space: nowrap; border-radius: 999px; padding: 3px 10px; background: var(--track); color: var(--dim); }
|
|
269
|
+
.md-trend.up { background: var(--accent-soft); color: var(--accent); }
|
|
270
|
+
#mdSpark { width: 100%; height: 190px; display: block; }
|
|
271
|
+
#mdSpark .grid { stroke: var(--line); stroke-width: 1; }
|
|
272
|
+
#mdSpark .area { fill: url(#mdGrad); stroke: none; }
|
|
273
|
+
#mdSpark .line { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
|
274
|
+
#mdSpark .dot { fill: var(--accent); stroke: var(--panel); stroke-width: 2; }
|
|
275
|
+
#mdSpark .hoverdot { fill: var(--accent); stroke: var(--panel); stroke-width: 2; }
|
|
276
|
+
.md-tip { position: absolute; pointer-events: none; background: var(--panel); color: var(--ink); border: 1px solid var(--line); box-shadow: 0 8px 24px rgba(0, 0, 0, .28); border-radius: 8px; padding: 6px 10px; font-size: 11px; line-height: 1.5; white-space: nowrap; max-width: calc(100% - 28px); overflow: hidden; text-overflow: ellipsis; transform: translate(-50%, -110%); display: none; z-index: 5; font-variant-numeric: tabular-nums; }
|
|
277
|
+
.md-cols { display: grid; grid-template-columns: minmax(0, 1fr); grid-auto-flow: dense; gap: 12px; }
|
|
278
|
+
@media (min-width: 720px) { .md-cols { grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); } }
|
|
279
|
+
.md-months { display: flex; justify-content: space-between; margin-top: 6px; font-size: 10px; color: var(--dim); text-transform: uppercase; letter-spacing: .12em; }
|
|
280
|
+
.md-bars { display: flex; align-items: flex-end; gap: 3px; height: 132px; background-image: linear-gradient(var(--line) 1px, transparent 1px); background-size: 100% 33px; background-position: 0 bottom; }
|
|
281
|
+
.md-bars > span { flex: 1; min-width: 3px; border-radius: 4px 4px 2px 2px; background: var(--accent); opacity: .4; cursor: default; }
|
|
282
|
+
.md-bars > span:hover { opacity: 1; }
|
|
283
|
+
.md-bars > span.top { opacity: 1; box-shadow: 0 0 8px var(--accent-soft); }
|
|
284
|
+
.md-bars > span.zero { background: var(--track); opacity: 1; box-shadow: none; }
|
|
285
|
+
.md-note { font-size: 11px; color: var(--dim); margin: 8px 0 0; font-variant-numeric: tabular-nums; }
|
|
286
|
+
.md-donut-wrap { position: relative; width: 150px; height: 150px; margin: 4px auto 8px; }
|
|
287
|
+
#mdMix { width: 150px; height: 150px; display: block; transform: rotate(-90deg); }
|
|
288
|
+
#mdMix circle { fill: none; stroke-width: 16; }
|
|
289
|
+
#mdMix .tk { stroke: var(--track); }
|
|
290
|
+
.md-donut-center { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; }
|
|
291
|
+
.md-donut-center p { margin: 0; font-size: 17px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
|
292
|
+
.md-legend { display: grid; gap: 6px; font-size: 11px; }
|
|
293
|
+
.md-legend .row { display: flex; align-items: center; gap: 8px; }
|
|
294
|
+
.md-legend .dot { width: 8px; height: 8px; border-radius: 999px; flex-shrink: 0; }
|
|
295
|
+
.md-legend .pct { margin-left: auto; color: var(--dim); font-variant-numeric: tabular-nums; }
|
|
296
|
+
.md-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-flow: dense; gap: 8px; }
|
|
297
|
+
@media (min-width: 640px) { .md-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
|
|
298
|
+
.md-stat { border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; background: var(--panel); }
|
|
299
|
+
.md-stat p { margin: 0; }
|
|
300
|
+
.md-stat .k { font-size: 10px; text-transform: uppercase; letter-spacing: .14em; color: var(--dim); }
|
|
301
|
+
.md-stat .v { font-size: 20px; font-weight: 800; letter-spacing: -0.02em; margin-top: 4px; font-variant-numeric: tabular-nums; }
|
|
302
|
+
.md-stat .s { font-size: 11px; color: var(--dim); margin-top: 2px; }
|
|
303
|
+
#mdBadge .brandmark { width: 44px; height: 44px; border-radius: 12px; }
|
|
304
|
+
@media (prefers-reduced-motion: reduce) { .md-bars > span { transition: none; } }
|
package/dashboard/template.html
CHANGED
|
@@ -83,6 +83,10 @@
|
|
|
83
83
|
<p class="dlg-name">Session transcripts <span class="dlg-own">host-owned · never touched</span></p>
|
|
84
84
|
<p class="mono dlg-file" id="pathSessions" title="">–</p>
|
|
85
85
|
</div>
|
|
86
|
+
<div class="dlg-path">
|
|
87
|
+
<p class="dlg-name">Usage DB <span class="dlg-own">tersio-owned · reset clears</span></p>
|
|
88
|
+
<p class="mono dlg-file" id="pathUsageDb" title="">–</p>
|
|
89
|
+
</div>
|
|
86
90
|
</div>
|
|
87
91
|
<div class="dlg-danger">
|
|
88
92
|
<p class="dlg-label">Danger zone</p>
|
|
@@ -98,6 +102,76 @@
|
|
|
98
102
|
</div>
|
|
99
103
|
</dialog>
|
|
100
104
|
|
|
105
|
+
<!-- Model detail dialog (shadcn chart language, native modal): kpi strip,
|
|
106
|
+
gradient area chart with range tabs, rounded bars, token-mix donut. -->
|
|
107
|
+
<dialog id="modelDialog" class="dlg mdlg" aria-labelledby="mdTitle">
|
|
108
|
+
<div class="dlg-head">
|
|
109
|
+
<div class="min-w-0 flex items-center gap-3">
|
|
110
|
+
<span id="mdBadge"></span>
|
|
111
|
+
<div class="min-w-0">
|
|
112
|
+
<p class="dlg-title truncate" id="mdTitle">Model</p>
|
|
113
|
+
<p class="dlg-desc mono" id="mdSub">–</p>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
117
|
+
<span class="md-rankpill mono" id="mdRank">#–</span>
|
|
118
|
+
<button id="mdClose" class="btn-push flex shrink-0 items-center p-2 rounded-xl" style="border: 1px solid var(--line); color: var(--ink)" aria-label="Close model details">
|
|
119
|
+
<i data-lucide="x" class="size-4"></i>
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
<div class="md-body">
|
|
124
|
+
<div class="md-kpis" id="mdKpis"></div>
|
|
125
|
+
<div class="md-chart">
|
|
126
|
+
<div class="md-chart-head">
|
|
127
|
+
<div class="min-w-0">
|
|
128
|
+
<p class="md-chart-title">Token volume</p>
|
|
129
|
+
<p class="md-chart-sub mono" id="mdMomRange">–</p>
|
|
130
|
+
</div>
|
|
131
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
132
|
+
<span class="md-trend mono" id="mdTrend"></span>
|
|
133
|
+
<span class="seg mono text-xs flex items-center gap-1 p-1 rounded-[10px]" style="border: 1px solid var(--line)" role="group" aria-label="Chart range">
|
|
134
|
+
<button data-mdspan="30" class="px-2.5 py-1" role="button">30D</button>
|
|
135
|
+
<button data-mdspan="90" class="px-2.5 py-1" role="button">90D</button>
|
|
136
|
+
<button data-mdspan="all" class="on px-2.5 py-1" role="button">ALL</button>
|
|
137
|
+
</span>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
<svg id="mdSpark" viewBox="0 0 560 190" preserveAspectRatio="none" aria-hidden="true"></svg>
|
|
141
|
+
<div class="md-tip mono" id="mdTip"></div>
|
|
142
|
+
<div class="md-months mono" id="mdMonths" aria-hidden="true"></div>
|
|
143
|
+
</div>
|
|
144
|
+
<div class="md-cols">
|
|
145
|
+
<div class="md-chart">
|
|
146
|
+
<div class="md-chart-head">
|
|
147
|
+
<div class="min-w-0">
|
|
148
|
+
<p class="md-chart-title">Daily activity</p>
|
|
149
|
+
<p class="md-chart-sub mono" id="mdBarsSub">tokens per day</p>
|
|
150
|
+
</div>
|
|
151
|
+
<span class="md-trend mono" id="mdActive"></span>
|
|
152
|
+
</div>
|
|
153
|
+
<div class="md-bars" id="mdBars" aria-hidden="true"></div>
|
|
154
|
+
<div class="md-months mono" id="mdBarsMonths" aria-hidden="true"></div>
|
|
155
|
+
<div class="md-note mono" id="mdBarsNote"></div>
|
|
156
|
+
</div>
|
|
157
|
+
<div class="md-chart">
|
|
158
|
+
<div class="md-chart-head">
|
|
159
|
+
<div class="min-w-0">
|
|
160
|
+
<p class="md-chart-title">Token mix</p>
|
|
161
|
+
<p class="md-chart-sub mono">input vs output vs cache</p>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
<div class="md-donut-wrap">
|
|
165
|
+
<svg id="mdMix" viewBox="0 0 140 140" aria-hidden="true"></svg>
|
|
166
|
+
<div class="md-donut-center"><p class="mono" id="mdMixTotal">–</p></div>
|
|
167
|
+
</div>
|
|
168
|
+
<div class="md-legend mono" id="mdMixLegend"></div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
<div class="md-grid" id="mdStats"></div>
|
|
172
|
+
</div>
|
|
173
|
+
</dialog>
|
|
174
|
+
|
|
101
175
|
<!-- Hero: centered total -->
|
|
102
176
|
<section class="mx-auto max-w-6xl text-center pt-14 pb-10 relative">
|
|
103
177
|
<div class="hero-glow" aria-hidden="true"></div>
|
|
@@ -279,19 +353,17 @@
|
|
|
279
353
|
<i data-lucide="terminal" class="size-4" style="color: var(--accent)"></i>
|
|
280
354
|
<h2 class="display font-bold tracking-tight text-xl truncate min-w-0">Command tools</h2>
|
|
281
355
|
<div class="flex items-center gap-2 ml-auto min-w-0">
|
|
282
|
-
<select id="cmdProject" class="mono text-xs px-2 py-1.5 rounded-[10px]" style="border: 1px solid var(--line); background: var(--panel); color: var(--ink); max-width: 190px" aria-label="Filter command tools by project"></select>
|
|
283
356
|
<span class="mono text-xs truncate min-w-0" style="color: var(--dim)" id="toolsScope">tool calls in sessions</span>
|
|
284
357
|
</div>
|
|
285
358
|
</div>
|
|
286
359
|
<p class="mono text-xs mb-4 truncate" style="color: var(--dim)">count · tokens saved · avg rate · avg time · share of executions</p>
|
|
287
360
|
<div class="overflow-x-auto">
|
|
288
361
|
<table class="w-full mono text-[13px]" id="cmdTable">
|
|
289
|
-
<colgroup><col style="width:40px"><col><col style="width:
|
|
362
|
+
<colgroup><col style="width:40px"><col><col style="width:90px"><col style="width:90px"><col style="width:90px"><col style="width:90px"><col style="width:140px"></colgroup>
|
|
290
363
|
<thead>
|
|
291
364
|
<tr class="text-left text-[11px] uppercase tracking-[0.14em]" style="color: var(--dim)">
|
|
292
365
|
<th class="font-normal text-right pr-3 py-2 w-10">#</th>
|
|
293
366
|
<th class="font-normal py-2 pr-3" aria-sort="none"><button class="thsort" data-sort="name">Tool / Command <span class="arr">⇅</span></button></th>
|
|
294
|
-
<th class="font-normal py-2 pr-3" aria-sort="none"><button class="thsort" data-sort="project">Project <span class="arr">⇅</span></button></th>
|
|
295
367
|
<th class="font-normal text-right py-2 pr-3" aria-sort="descending"><button class="thsort num" data-sort="count">Count <span class="arr">↓</span></button></th>
|
|
296
368
|
<th class="font-normal text-right py-2 pr-3" aria-sort="none"><button class="thsort num" data-sort="saved">Saved <span class="arr">⇅</span></button></th>
|
|
297
369
|
<th class="font-normal text-right py-2 pr-3" aria-sort="none"><button class="thsort num" data-sort="avgPct">Avg% <span class="arr">⇅</span></button></th>
|
|
@@ -8,10 +8,8 @@
|
|
|
8
8
|
// isn't metered); their gains are bench-measured in BENCHMARK.md and cited
|
|
9
9
|
// as static figures wherever RTK rows appear.
|
|
10
10
|
//
|
|
11
|
-
// Rows
|
|
12
|
-
//
|
|
13
|
-
// count as everyone's and let high-saving commands from other projects crowd
|
|
14
|
-
// every other project out of the table entirely.
|
|
11
|
+
// Rows group by command alone, machine-wide: one row per command no matter
|
|
12
|
+
// which repo ran it. Project filtering stays out — wasted column, same logic.
|
|
15
13
|
import { execFileSync } from 'node:child_process';
|
|
16
14
|
import fs from 'node:fs';
|
|
17
15
|
import os from 'node:os';
|
|
@@ -58,9 +56,8 @@ export function readRtkGain(limit = 10, cutoffMs) {
|
|
|
58
56
|
? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
|
|
59
57
|
: '';
|
|
60
58
|
const [[commands = '0', saved = '0', input = '0', avgPct = '0', totalMs = '0'] = []] = query(rtkDbPath(), `SELECT COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(SUM(input_tokens),0), COALESCE(AVG(savings_pct),0), COALESCE(SUM(exec_time_ms),0) FROM commands ${where};`);
|
|
61
|
-
const byCommand = query(rtkDbPath(), `SELECT substr(${CMD_ONE_LINE}, 1, ${MAX_CMD_CHARS}),
|
|
59
|
+
const byCommand = query(rtkDbPath(), `SELECT substr(${CMD_ONE_LINE}, 1, ${MAX_CMD_CHARS}), COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(AVG(savings_pct),0), COALESCE(AVG(exec_time_ms),0) FROM commands ${where} GROUP BY rtk_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ${Math.max(1, Math.floor(limit))};`).map(([command, count, savedRow, pct, ms]) => ({
|
|
62
60
|
command,
|
|
63
|
-
project,
|
|
64
61
|
count: Number(count) || 0,
|
|
65
62
|
saved: Number(savedRow) || 0,
|
|
66
63
|
avgPct: Number(pct) || 0,
|
|
@@ -50,17 +50,14 @@ 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
|
}
|
|
59
56
|
// Measured cost for one message. OMP writes usage.cost as
|
|
60
57
|
// { input, output, cacheRead, cacheWrite, total }; a bare number shows up in
|
|
61
58
|
// older fixtures. Anything else means "not recorded" — deliberately not 0, so
|
|
62
59
|
// the dashboard can tell a genuinely free run from an unrecorded one.
|
|
63
|
-
function costOf(usage) {
|
|
60
|
+
export function costOf(usage) {
|
|
64
61
|
const c = usage.cost;
|
|
65
62
|
if (typeof c === 'number')
|
|
66
63
|
return Number.isFinite(c) ? c : undefined;
|
|
@@ -73,7 +70,7 @@ function costOf(usage) {
|
|
|
73
70
|
}
|
|
74
71
|
// First line only: real messages carry multi-line provider errors, and this
|
|
75
72
|
// ends up in a tooltip.
|
|
76
|
-
function statusOf(msg) {
|
|
73
|
+
export function statusOf(msg) {
|
|
77
74
|
const stop = typeof msg.stopReason === 'string' ? msg.stopReason : '';
|
|
78
75
|
const note = typeof msg.errorMessage === 'string' && msg.errorMessage.trim()
|
|
79
76
|
? msg.errorMessage.split('\n')[0].trim().slice(0, 180)
|
|
@@ -108,7 +105,7 @@ export function codexSessionsDir() {
|
|
|
108
105
|
return path.join(codexHome, 'sessions');
|
|
109
106
|
return path.join(os.homedir(), '.codex', 'sessions');
|
|
110
107
|
}
|
|
111
|
-
function dayKey(ts) {
|
|
108
|
+
export function dayKey(ts) {
|
|
112
109
|
const ms = typeof ts === 'number' ? ts : Date.parse(ts);
|
|
113
110
|
if (!Number.isFinite(ms))
|
|
114
111
|
return null;
|
|
@@ -116,7 +113,7 @@ function dayKey(ts) {
|
|
|
116
113
|
const p = (n) => String(n).padStart(2, '0');
|
|
117
114
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
118
115
|
}
|
|
119
|
-
function walkJsonl(dir, out, cap) {
|
|
116
|
+
export function walkJsonl(dir, out, cap) {
|
|
120
117
|
let entries;
|
|
121
118
|
try {
|
|
122
119
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -137,17 +134,101 @@ function walkJsonl(dir, out, cap) {
|
|
|
137
134
|
// Recent requests are their own full-width table in the dashboard, so this is
|
|
138
135
|
// a payload bound rather than a "few highlights" bound. Rows are small
|
|
139
136
|
// (model, tokens, timing, status), so a few hundred cost little to ship.
|
|
140
|
-
const RECENT_LIMIT = 200;
|
|
141
|
-
|
|
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) {
|
|
142
143
|
const watermark = readResetWatermark();
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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();
|
|
151
232
|
const files = [];
|
|
152
233
|
walkJsonl(sessionsDir(), files, 2000);
|
|
153
234
|
// A sessions override signals an isolated environment (tests, fixtures):
|
|
@@ -156,46 +237,6 @@ export function importSessionTokens() {
|
|
|
156
237
|
walkJsonl(codexSessionsDir(), files, 2000);
|
|
157
238
|
}
|
|
158
239
|
let codexProvider = null;
|
|
159
|
-
const recent = [];
|
|
160
|
-
// Rows from before the reset watermark (and rows with no usable timestamp,
|
|
161
|
-
function ingest(model, usage, ts, durMs, run) {
|
|
162
|
-
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
163
|
-
if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
|
|
164
|
-
return false;
|
|
165
|
-
addInto(totals, usage);
|
|
166
|
-
byModel[model] ??= zeroBreakdown();
|
|
167
|
-
addInto(byModel[model], usage);
|
|
168
|
-
byModelMessages[model] = (byModelMessages[model] ?? 0) + 1;
|
|
169
|
-
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
170
|
-
if (Number.isFinite(ms)) {
|
|
171
|
-
const outcome = run ?? { st: 'completed' };
|
|
172
|
-
recent.push({
|
|
173
|
-
m: model,
|
|
174
|
-
i: num(usage.input),
|
|
175
|
-
o: num(usage.output),
|
|
176
|
-
t: ms,
|
|
177
|
-
d: durOf(durMs),
|
|
178
|
-
cr: num(usage.cacheRead),
|
|
179
|
-
cw: num(usage.cacheWrite),
|
|
180
|
-
usd: costOf(usage),
|
|
181
|
-
st: outcome.st,
|
|
182
|
-
code: outcome.code,
|
|
183
|
-
note: outcome.note,
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
const day = ts !== undefined ? dayKey(ts) : null;
|
|
187
|
-
if (day) {
|
|
188
|
-
byDay[day] ??= zeroBreakdown();
|
|
189
|
-
addInto(byDay[day], usage);
|
|
190
|
-
const sum = ['input', 'output', 'cacheRead', 'cacheWrite'].reduce((a, k) => a + (typeof usage[k] === 'number' && Number.isFinite(usage[k]) ? Math.floor(usage[k]) : 0), 0);
|
|
191
|
-
if (sum > 0) {
|
|
192
|
-
byDayModel[day] ??= {};
|
|
193
|
-
byDayModel[day][model] = (byDayModel[day][model] ?? 0) + sum;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
messages += 1;
|
|
197
|
-
return true;
|
|
198
|
-
}
|
|
199
240
|
for (const file of files) {
|
|
200
241
|
let text;
|
|
201
242
|
try {
|
|
@@ -204,59 +245,39 @@ export function importSessionTokens() {
|
|
|
204
245
|
catch {
|
|
205
246
|
continue;
|
|
206
247
|
}
|
|
207
|
-
|
|
208
|
-
|
|
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;
|
|
209
263
|
continue;
|
|
210
|
-
try {
|
|
211
|
-
const row = JSON.parse(line);
|
|
212
|
-
const codexRow = row;
|
|
213
|
-
const payload = codexRow.payload;
|
|
214
|
-
if (payload && typeof payload === 'object') {
|
|
215
|
-
if (codexRow.type === 'session_meta' && typeof payload.model_provider === 'string') {
|
|
216
|
-
codexProvider = payload.model_provider;
|
|
217
|
-
}
|
|
218
|
-
else if (payload.type === 'token_count') {
|
|
219
|
-
const last = payload.info?.last_token_usage;
|
|
220
|
-
if (last) {
|
|
221
|
-
ingest(codexProvider ? `codex/${codexProvider}` : 'codex', {
|
|
222
|
-
input: last['input_tokens'],
|
|
223
|
-
output: last['output_tokens'],
|
|
224
|
-
cacheRead: last['cached_input_tokens'],
|
|
225
|
-
cacheWrite: last['cache_write_input_tokens'],
|
|
226
|
-
}, row.timestamp);
|
|
227
|
-
continue;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
const msg = row.message;
|
|
232
|
-
if (!msg || msg.role !== 'assistant' || !msg.usage)
|
|
233
|
-
continue;
|
|
234
|
-
const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
|
|
235
|
-
const explicitDur = msg.duration;
|
|
236
|
-
const computedDur = (typeof msg.completedAt === 'number' && typeof msg.timestamp === 'number') ? msg.completedAt - msg.timestamp : undefined;
|
|
237
|
-
const counted = ingest(model, msg.usage, row.timestamp, typeof explicitDur === 'number' ? explicitDur : computedDur, statusOf(msg));
|
|
238
|
-
const measured = costOf(msg.usage);
|
|
239
|
-
if (counted && measured !== undefined)
|
|
240
|
-
costMeasured += measured;
|
|
241
|
-
if (!counted)
|
|
242
|
-
continue;
|
|
243
|
-
for (const part of msg.content ?? []) {
|
|
244
|
-
if (part?.type === 'toolCall' && typeof part.name === 'string' && part.name) {
|
|
245
|
-
const key = part.name === 'bash' ? `bash:${leadBinary(part.arguments?.command)}` : part.name;
|
|
246
|
-
byTool[key] = (byTool[key] ?? 0) + 1;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
264
|
}
|
|
250
|
-
|
|
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);
|
|
251
273
|
}
|
|
274
|
+
catch { /* skip corrupt lines */ }
|
|
252
275
|
}
|
|
253
|
-
recent.sort((a, b) => b.t - a.t);
|
|
254
|
-
return { messages, totals, byModel, byDay, byDayModel, byTool, byModelMessages, costMeasured, recent: recent.slice(0, RECENT_LIMIT) };
|
|
255
276
|
}
|
|
256
277
|
// Lead binary of a shell string: first segment head past `cd` chains and
|
|
257
278
|
// VAR=x assignments (`cd /x && git status` → `git`). Falls back to `bash`.
|
|
258
279
|
const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
|
|
259
|
-
function leadBinary(command) {
|
|
280
|
+
export function leadBinary(command) {
|
|
260
281
|
if (typeof command !== 'string' || !command.trim())
|
|
261
282
|
return 'bash';
|
|
262
283
|
for (const seg of command.split(/&&|;|\n|\|(?!\|)/)) {
|