@krtclcdy/tersio 2.12.0 → 2.14.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 -12
- package/cli/dashboard.js +2 -1
- package/cli/doctor.js +7 -1
- package/cli/install.js +46 -14
- package/cli/reset.js +18 -13
- package/cli/rtk-wiring.js +58 -0
- package/cli/uninstall.js +7 -2
- package/cli/usage.js +5 -4
- package/dashboard/app.js +27 -2
- package/dashboard/styles.css +18 -0
- package/dashboard/template.html +13 -8
- package/extensions/shared/rtk-gain.js +9 -3
- package/extensions/shared/usage-ledger.js +39 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-

|
|
2
2
|
|
|
3
3
|
# ✂️ Tersio — Token-saving OMP Add-ons
|
|
4
4
|
|
|
@@ -13,7 +13,7 @@ Terse replies, compact shell output, and minimal code decisions for [Oh My Pi (O
|
|
|
13
13
|
## ✨ Features
|
|
14
14
|
|
|
15
15
|
- **Caveman** — Shortens replies, keeps technical substance. Modes: `lite`, `full`, `ultra`, `wenyan`.
|
|
16
|
-
- **RTK** — Routes noisy shell commands through the RTK binary for compact output; exact bytes bypass it by policy.
|
|
16
|
+
- **RTK** — Routes noisy shell commands through the RTK binary for compact output; exact bytes bypass it by policy. The installer wires rtk into OMP automatically (`rtk init -g --agent omp`), so bash tool calls rewrite to `rtk` before execution and meter into the gain dashboard.
|
|
17
17
|
- **Ponytail** — Minimal, YAGNI-oriented code decisions, from nudge (`lite`) to requirement-challenger (`ultra`).
|
|
18
18
|
- **Combo** — Toggles all three at once. Presets: `off`, `medium`, `balanced`, `max`.
|
|
19
19
|
- **Updater** — `/tersio check` and `/tersio update` cover Ponytail, RTK, and Caveman in-session, with dry-run and backups.
|
|
@@ -84,14 +84,19 @@ Measured savings against the same workload without the modes. Token counts are r
|
|
|
84
84
|
|
|
85
85
|
| Measured surface (n) | Baseline → Tersio | Δ |
|
|
86
86
|
|---|---|---|
|
|
87
|
-
| Terse reply, caveman lite — BPE tok (3 samples, p50) |
|
|
88
|
-
| Terse reply, caveman full — BPE tok (3 samples, p50) |
|
|
89
|
-
| Terse reply, caveman ultra — BPE tok (3 samples, p50) |
|
|
90
|
-
| Code diff, ponytail
|
|
91
|
-
|
|
|
92
|
-
|
|
|
93
|
-
|
|
94
|
-
|
|
87
|
+
| Terse reply, caveman lite — BPE tok (3 samples, p50) | 173 → 69 tok | **−60.1%** |
|
|
88
|
+
| Terse reply, caveman full — BPE tok (3 samples, p50) | 173 → 49 tok | **−71.7%** |
|
|
89
|
+
| Terse reply, caveman ultra — BPE tok (3 samples, p50) | 173 → 25 tok | **−85.5%** |
|
|
90
|
+
| Code diff, ponytail lite — BPE tok (2 tasks) | 326–462 → 57–121 tok | **−73.8…−82.5%** |
|
|
91
|
+
| Code diff, ponytail full — BPE tok (2 tasks) | 326–462 → 55–88 tok | **−81.0…−83.1%** |
|
|
92
|
+
| Code diff, ponytail ultra — BPE tok (2 tasks) | 326–462 → 40–88 tok | **−81.0…−87.7%** |
|
|
93
|
+
| Shell output, rtk — `git status` — BPE tok | 182 → 72 tok | **−60.4%** |
|
|
94
|
+
| Shell output, rtk — repo `grep` — BPE tok | 376 → 274 tok | **−27.1%** |
|
|
95
|
+
| Shell output, rtk — `find` — BPE tok | 205 → 168 tok | **−18.0%** |
|
|
96
|
+
| Shell output, rtk — `git diff` — BPE tok | 12,684 → 8,504 tok | **−33.0%** |
|
|
97
|
+
| Shell output, rtk — `npm test` (passthrough) — BPE tok | 2,597 → 2,577 tok | **−0.8%** |
|
|
98
|
+
|
|
99
|
+
Break-even math, balanced preset (caveman full + rtk + ponytail): a mixed turn costs 941 tok baseline vs 399 with Tersio (0.42×, **−57.6% per turn**). Overhead repays in turn 1 with the bundled floor (464 tok); turn 4 with the real installed Ponytail plugin (v4.9.0, 1,264 tok). RTK concentrates savings where noise lives — hook-wired sessions record **−97.5% on summarized test suites** (rtk `history.db`, 4 runs).
|
|
95
100
|
|
|
96
101
|
## 🖥️ CLI
|
|
97
102
|
|
|
@@ -103,8 +108,8 @@ Break-even math, balanced preset (caveman full + rtk + ponytail): `⌈overhead
|
|
|
103
108
|
| `tersio doctor` | Check OMP, extension, Ponytail, and RTK health |
|
|
104
109
|
| `tersio usage` | Ledger-backed usage + savings report |
|
|
105
110
|
| `tersio gain` | Open the gain dashboard (`--open`, `--export <file>`, `--port <n>`; serves localhost only) |
|
|
106
|
-
| `tersio reset` | Clear tersio statistics
|
|
107
|
-
| `tersio uninstall` | Remove extensions, registration, and the Ponytail plugin (`--keep-ponytail` keeps Ponytail; `--remove-rtk` also removes the RTK binary) |
|
|
111
|
+
| `tersio reset` | Clear tersio statistics: usage ledger + a watermark that hides pre-reset rows from every derived view (Y/N confirm, `--dry-run`, `--yes`) — session transcripts and RTK history stay intact on disk |
|
|
112
|
+
| `tersio uninstall` | Remove extensions, registration, and the Ponytail plugin (`--keep-ponytail` keeps Ponytail; `--remove-rtk` also removes the RTK binary and its `rtk.ts` OMP wiring) |
|
|
108
113
|
| `tersio version` | Print version |
|
|
109
114
|
|
|
110
115
|
Flags: `--dry-run`, `--yes`/`-y`, `--verbose`, `--combo-default`/`--caveman-default`/`--rtk-default`/`--ponytail-default`. Legacy `--doctor` / `--uninstall` forms still work.
|
|
@@ -132,6 +137,7 @@ Everything runs under one root. Bare `/tersio` prints status.
|
|
|
132
137
|
| What | Path |
|
|
133
138
|
|---|---|
|
|
134
139
|
| Caveman / RTK / Updater / Combo extensions | `~/.omp/agent/extensions/{caveman-session,rtk-session,ai-addons-updater,combo-toggle}/` |
|
|
140
|
+
| RTK OMP wiring (rtk-owned) | `~/.omp/agent/extensions/rtk.ts` — written by the installer via `rtk init -g --agent omp`; auto-loads, no config entry |
|
|
135
141
|
| Ponytail package | `~/.omp/plugins/node_modules/@dietrichgebert/ponytail/` |
|
|
136
142
|
| RTK binary | `~/.bun/bin/rtk` (`rtk.exe` on Windows) |
|
|
137
143
|
| Extension registrations | `~/.omp/agent/config.yml` |
|
|
@@ -146,6 +152,8 @@ User-level installs also register the package in `~/.omp/plugins` (visible in OM
|
|
|
146
152
|
|
|
147
153
|
**RTK missing or not executable:** run `tersio reinstall`, then `tersio doctor`. On Linux/macOS: `chmod +x ~/.bun/bin/rtk`.
|
|
148
154
|
|
|
155
|
+
**RTK commands not metered in the gain dashboard:** check `tersio doctor` — the `RTK OMP wiring (rtk.ts)` row must be ok. Missing: run `rtk init -g --agent omp` (needs rtk ≥ 0.49) and restart OMP. Native tool calls (`read`/`edit`/`eval`) stay unmetered — only bash tool calls pass through rtk.
|
|
156
|
+
|
|
149
157
|
**Checksum warning or failure:** the installer aborts on RTK checksum mismatch but warns and continues when checksum metadata is unavailable; `/tersio update rtk` aborts when metadata is missing.
|
|
150
158
|
|
|
151
159
|
## 🤝🏻 Contributing
|
package/cli/dashboard.js
CHANGED
|
@@ -8,7 +8,7 @@ import { promises as fs } from 'node:fs';
|
|
|
8
8
|
import http from 'node:http';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
|
-
import { clearUsageLedger, readUsage } from '../extensions/shared/usage-ledger.js';
|
|
11
|
+
import { clearUsageLedger, markReset, readUsage } from '../extensions/shared/usage-ledger.js';
|
|
12
12
|
import { summarizeUsage } from './usage.js';
|
|
13
13
|
import { withInteractiveSpinner } from './interactive.js';
|
|
14
14
|
const DASH_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dashboard');
|
|
@@ -52,6 +52,7 @@ async function runDashboard(options) {
|
|
|
52
52
|
const server = http.createServer(async (req, res) => {
|
|
53
53
|
if (req.url === '/reset' && req.method === 'POST') {
|
|
54
54
|
const rows = clearUsageLedger();
|
|
55
|
+
markReset();
|
|
55
56
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
56
57
|
res.end(JSON.stringify({ ok: true, rows }));
|
|
57
58
|
return;
|
package/cli/doctor.js
CHANGED
|
@@ -39,6 +39,7 @@ async function runDoctor() {
|
|
|
39
39
|
pluginsPkgRaw: readTextIfExists(path.join(pluginsDir, 'package.json')),
|
|
40
40
|
selfPkgText: readTextIfExists(selfPkg),
|
|
41
41
|
rtkBinText: readTextIfExists(rtkBin),
|
|
42
|
+
rtkOmpText: readTextIfExists(path.join(extDir, 'rtk.ts')),
|
|
42
43
|
cavemanIndexText: readTextIfExists(cavemanIndex),
|
|
43
44
|
cavemanRuleText: readTextIfExists(cavemanRule),
|
|
44
45
|
rtkIndexText: readTextIfExists(rtkIndex),
|
|
@@ -75,12 +76,13 @@ async function runDoctor() {
|
|
|
75
76
|
probes.pluginsPkgRaw,
|
|
76
77
|
probes.selfPkgText,
|
|
77
78
|
]));
|
|
78
|
-
const [cavemanRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, ponytailMtime] = await runInteractivePhase('Checking add-ons', () => Promise.all([
|
|
79
|
+
const [cavemanRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, rtkOmpText, ponytailMtime] = await runInteractivePhase('Checking add-ons', () => Promise.all([
|
|
79
80
|
probes.cavemanRuleText,
|
|
80
81
|
probes.ruleMtime,
|
|
81
82
|
probes.rtkBinText,
|
|
82
83
|
probes.rtkMtime,
|
|
83
84
|
rtkVersionProbe,
|
|
85
|
+
probes.rtkOmpText,
|
|
84
86
|
probes.ponytailMtime,
|
|
85
87
|
]));
|
|
86
88
|
// Categorized output with a tally; section headers group related probes.
|
|
@@ -151,6 +153,10 @@ async function runDoctor() {
|
|
|
151
153
|
check('RTK binary', true, bits);
|
|
152
154
|
if (!rtkVersion)
|
|
153
155
|
warnLine('RTK version', 'unavailable — binary may not be executable');
|
|
156
|
+
if (rtkOmpText === null)
|
|
157
|
+
check('RTK OMP wiring (rtk.ts)', false, `run: rtk init -g --agent omp`);
|
|
158
|
+
else
|
|
159
|
+
check('RTK OMP wiring (rtk.ts)', true, path.join(extDir, 'rtk.ts'));
|
|
154
160
|
}
|
|
155
161
|
const ponytailAge = ponytailMtime ? `updated ${relTime(Date.now() - ponytailMtime.mtimeMs)}` : '';
|
|
156
162
|
check('Ponytail', ponytailPkgText !== null, [parseJsonObject(ponytailPkgText)?.version ?? '', ponytailAge].filter(Boolean).join(', '));
|
package/cli/install.js
CHANGED
|
@@ -12,6 +12,7 @@ import { runDoctor } from './doctor.js';
|
|
|
12
12
|
import { runReset } from './reset.js';
|
|
13
13
|
import { runUsage } from './usage.js';
|
|
14
14
|
import { runDashboard } from './dashboard.js';
|
|
15
|
+
import { wireRtkOmp } from './rtk-wiring.js';
|
|
15
16
|
import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
|
|
16
17
|
// Paths to extension source files (relative to this script)
|
|
17
18
|
const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
|
|
@@ -234,6 +235,15 @@ async function verifyRtkArchive(archivePath, assetName, checksumsText) {
|
|
|
234
235
|
function shortError(e) {
|
|
235
236
|
return ((e.stderr) || e.message || '').trim().slice(0, 200);
|
|
236
237
|
}
|
|
238
|
+
async function fileExists(p) {
|
|
239
|
+
try {
|
|
240
|
+
await fs.access(p);
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
237
247
|
async function extractRtkArchive(archivePath, extractDir) {
|
|
238
248
|
await fs.mkdir(extractDir, { recursive: true });
|
|
239
249
|
if (archivePath.endsWith('.zip')) {
|
|
@@ -263,6 +273,9 @@ async function extractRtkArchive(archivePath, extractDir) {
|
|
|
263
273
|
}
|
|
264
274
|
async function stepRtk(binDir, options) {
|
|
265
275
|
console.log('\n[3/8] Installing RTK binary...');
|
|
276
|
+
const binDest = path.join(binDir, RTK_BINARY_NAME);
|
|
277
|
+
// Download failure must not skip wiring: a pre-existing rtk binary is
|
|
278
|
+
// exactly as good for the OMP hook, so wire whatever ends up at binDest.
|
|
266
279
|
try {
|
|
267
280
|
const release = await withInteractiveSpinner('Finding latest RTK release', () => fetchJson(RTK_RELEASE_API));
|
|
268
281
|
const triple = resolveRtkTriple();
|
|
@@ -271,7 +284,6 @@ async function stepRtk(binDir, options) {
|
|
|
271
284
|
const asset = findRtkAsset(release, triple);
|
|
272
285
|
if (!asset)
|
|
273
286
|
return;
|
|
274
|
-
const binDest = path.join(binDir, RTK_BINARY_NAME);
|
|
275
287
|
if (options.dryRun) {
|
|
276
288
|
console.log(` [dry-run] would download ${asset.name} from release ${release.tag_name}`);
|
|
277
289
|
console.log(' [dry-run] would verify checksum against checksums.txt');
|
|
@@ -308,7 +320,7 @@ async function stepRtk(binDir, options) {
|
|
|
308
320
|
debug(`chmod 755 ${binDest}`);
|
|
309
321
|
}
|
|
310
322
|
try {
|
|
311
|
-
const v = (await execP(binDest, ['--version'], { timeout:
|
|
323
|
+
const v = (await execP(binDest, ['--version'], { timeout: 30000, shell: false })).stdout.trim();
|
|
312
324
|
console.log(` [ok] ${binDest} → ${v}`);
|
|
313
325
|
}
|
|
314
326
|
catch {
|
|
@@ -323,6 +335,17 @@ async function stepRtk(binDir, options) {
|
|
|
323
335
|
console.log(` [fail] RTK: ${e.message}`);
|
|
324
336
|
console.log(' [hint] Manual: https://github.com/rtk-ai/rtk/releases');
|
|
325
337
|
}
|
|
338
|
+
if (options.dryRun) {
|
|
339
|
+
await wireRtkOmp(binDest, options);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (!(await fileExists(binDest))) {
|
|
343
|
+
console.log(' [skip] no rtk binary to wire — install rtk, then run: rtk init -g --agent omp');
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
// Binary alone never meters OMP sessions — wire rtk's tool_call
|
|
347
|
+
// extension so bash commands rewrite to rtk and land in history.db.
|
|
348
|
+
await wireRtkOmp(binDest, options);
|
|
326
349
|
}
|
|
327
350
|
// Copy repo source files into the target extension dir. First entry is
|
|
328
351
|
// required (skip label on missing); rest are optional companions.
|
|
@@ -421,13 +444,13 @@ function defaultProfile() {
|
|
|
421
444
|
}
|
|
422
445
|
// Install is always user-level (all OMP sessions). Project scope was removed:
|
|
423
446
|
// session extensions must live in ~/.omp/agent/extensions to load.
|
|
424
|
-
async function resolveProfile() {
|
|
447
|
+
async function resolveProfile(forceReinstall = false) {
|
|
425
448
|
const profile = defaultProfile();
|
|
426
449
|
// Single interactive prompt: the Combo preset implies all three modes.
|
|
427
450
|
// Numbered menu — no typing preset names.
|
|
428
451
|
// Only for a real user at a terminal, only when no default flags were
|
|
429
452
|
// given, and never for --apply-update runs.
|
|
430
|
-
if (tty() && !profileFlagsGiven && !applyUpdate && (install || reinstall)) {
|
|
453
|
+
if (tty() && !profileFlagsGiven && !applyUpdate && (install || forceReinstall || reinstall)) {
|
|
431
454
|
const choice = await askInteractiveChoice('Session-start defaults — Combo preset', [
|
|
432
455
|
{ value: 'off', label: 'off' },
|
|
433
456
|
{ value: 'medium', label: 'medium', hint: 'caveman=lite, rtk=on, ponytail=lite' },
|
|
@@ -535,11 +558,12 @@ async function runCommandMenu() {
|
|
|
535
558
|
updatePromptDone = true;
|
|
536
559
|
const choice = await askInteractiveChoice('Tersio — what next?', [
|
|
537
560
|
{ value: 'install', label: 'Install add-ons', hint: 'user scope + combo defaults' },
|
|
538
|
-
{ value: '
|
|
561
|
+
{ value: 'update', label: 'Update', hint: 'CLI version check, then refresh add-ons (RTK, Caveman rule, Ponytail)' },
|
|
562
|
+
{ value: 'reinstall', label: 'Reinstall', hint: 'clean and reinstall the add-ons, Ponytail package kept' },
|
|
539
563
|
{ value: 'doctor', label: 'Doctor', hint: 'verify the installation' },
|
|
540
564
|
{ value: 'usage', label: 'Usage', hint: 'token usage and savings report' },
|
|
541
565
|
{ value: 'gain', label: 'Gain dashboard', hint: 'open the report in your browser' },
|
|
542
|
-
{ value: 'reset', label: 'Reset statistics', hint: 'clear
|
|
566
|
+
{ value: 'reset', label: 'Reset statistics', hint: 'clear statistics; transcripts and RTK history stay' },
|
|
543
567
|
{ value: 'uninstall', label: 'Uninstall', hint: 'remove tersio' },
|
|
544
568
|
], 'install');
|
|
545
569
|
if (choice.status !== 'selected') {
|
|
@@ -550,8 +574,8 @@ async function runCommandMenu() {
|
|
|
550
574
|
case 'install':
|
|
551
575
|
await runInstall();
|
|
552
576
|
break;
|
|
553
|
-
case '
|
|
554
|
-
// One bound flow: report the CLI version, then offer the
|
|
577
|
+
case 'update': {
|
|
578
|
+
// One bound flow: report the CLI version, then offer the add-on
|
|
555
579
|
// refresh in the same pass — no redundant second "update" option.
|
|
556
580
|
const latest = await checkForUpdate(true);
|
|
557
581
|
if (typeof latest === 'string')
|
|
@@ -560,7 +584,7 @@ async function runCommandMenu() {
|
|
|
560
584
|
console.log(` [warn] could not reach the npm registry — run \`tersio update\` to retry`);
|
|
561
585
|
else
|
|
562
586
|
console.log(` [ok] tersio ${PACKAGE_VERSION} is the latest`);
|
|
563
|
-
const go = await askInteractiveConfirm('Update
|
|
587
|
+
const go = await askInteractiveConfirm('Update add-ons now (RTK, Caveman rule, Ponytail)?');
|
|
564
588
|
if (go.status === 'confirmed' && go.value) {
|
|
565
589
|
await runLatestUpdate();
|
|
566
590
|
}
|
|
@@ -574,6 +598,10 @@ async function runCommandMenu() {
|
|
|
574
598
|
closeRL();
|
|
575
599
|
break;
|
|
576
600
|
}
|
|
601
|
+
case 'reinstall':
|
|
602
|
+
await runInstall({ reinstall: true });
|
|
603
|
+
closeRL();
|
|
604
|
+
break;
|
|
577
605
|
case 'doctor':
|
|
578
606
|
await runDoctor();
|
|
579
607
|
closeRL();
|
|
@@ -596,7 +624,8 @@ async function runCommandMenu() {
|
|
|
596
624
|
break;
|
|
597
625
|
}
|
|
598
626
|
}
|
|
599
|
-
async function runInstall() {
|
|
627
|
+
async function runInstall(overrides = {}) {
|
|
628
|
+
const isReinstall = overrides.reinstall ?? reinstall;
|
|
600
629
|
// Menu-driven installs must fall through: bare `tersio` re-enters here
|
|
601
630
|
// with command === null after the picker, and without this guard the
|
|
602
631
|
// choice loops straight back into runCommandMenu() forever.
|
|
@@ -605,8 +634,11 @@ async function runInstall() {
|
|
|
605
634
|
return;
|
|
606
635
|
}
|
|
607
636
|
printWelcome();
|
|
608
|
-
if (
|
|
609
|
-
|
|
637
|
+
if (isReinstall) {
|
|
638
|
+
// removeRtk stays false: reinstall is about to replace the binary, and
|
|
639
|
+
// deleting it first would leave nothing to wire if the fresh download
|
|
640
|
+
// fails (rate limit, offline). rtk.ts is removed here and re-wired below.
|
|
641
|
+
await runUninstall({ yes: true, removePonytail: false, removeRtk: false });
|
|
610
642
|
}
|
|
611
643
|
if (dryRun)
|
|
612
644
|
console.log('[dry-run] No changes will be written.\n');
|
|
@@ -642,13 +674,13 @@ async function runInstall() {
|
|
|
642
674
|
}
|
|
643
675
|
console.log(' Scope: user (all OMP sessions)');
|
|
644
676
|
// Resolve session defaults: flags > interactive prompt > defaults.
|
|
645
|
-
const profile = await resolveProfile();
|
|
677
|
+
const profile = await resolveProfile(isReinstall);
|
|
646
678
|
const userDir = OMP_AGENT_DIR;
|
|
647
679
|
const userExtDir = path.join(userDir, 'extensions');
|
|
648
680
|
// apply-update is `tersio update`'s payload run: treat it like reinstall so
|
|
649
681
|
// the add-ons refresh too — Ponytail package via npm, self plugin, RTK
|
|
650
682
|
// binary (always re-downloaded), and the Caveman rule (always re-fetched).
|
|
651
|
-
const options = { dryRun, verbose, yes, reinstall:
|
|
683
|
+
const options = { dryRun, verbose, yes, reinstall: isReinstall || applyUpdate };
|
|
652
684
|
// Check prerequisites
|
|
653
685
|
console.log('\nPrerequisites:');
|
|
654
686
|
try {
|
package/cli/reset.js
CHANGED
|
@@ -1,29 +1,33 @@
|
|
|
1
|
-
// cli/reset.ts — clear tersio-owned statistics
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// cli/reset.ts — clear tersio-owned statistics: the usage ledger plus a reset
|
|
2
|
+
// watermark that filters session-derived and RTK-metered rows out of every
|
|
3
|
+
// derived view. Session transcripts (~/.omp/agent/sessions) and the RTK
|
|
4
|
+
// database are host/tool-owned and are never touched; the watermark only
|
|
5
|
+
// changes what tersio shows.
|
|
4
6
|
import { cancel as clackCancel, confirm as clackConfirm } from '@clack/prompts';
|
|
5
7
|
import { dryRun, yes } from './common.js';
|
|
6
8
|
import { ask, closeRL, tty } from './interactive.js';
|
|
7
|
-
import { clearUsageLedger, ledgerPath, readUsage } from '../extensions/shared/usage-ledger.js';
|
|
8
|
-
import {
|
|
9
|
-
import { rtkDbPath } from '../extensions/shared/rtk-gain.js';
|
|
9
|
+
import { clearUsageLedger, importSessionTokens, ledgerPath, markReset, readUsage, sessionsDir } from '../extensions/shared/usage-ledger.js';
|
|
10
|
+
import { readRtkGain, rtkDbPath } from '../extensions/shared/rtk-gain.js';
|
|
10
11
|
async function runReset() {
|
|
11
12
|
console.log('\n=== Tersio Reset ===\n');
|
|
12
13
|
const rows = readUsage().length;
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
const sessions = importSessionTokens();
|
|
15
|
+
const rtk = readRtkGain(1);
|
|
16
|
+
console.log(`Will clear: ${rows} usage ledger rows · ${ledgerPath()}`);
|
|
17
|
+
console.log(`Will hide (view-level watermark): session-derived statistics (${sessions.messages} messages) · RTK metered rows (${rtk.commands})`);
|
|
18
|
+
console.log(`Left intact on disk: sessions ${sessionsDir()}, rtk ${rtkDbPath()}`);
|
|
15
19
|
if (dryRun) {
|
|
16
|
-
console.log('[dry-run]
|
|
20
|
+
console.log('[dry-run] nothing written.');
|
|
17
21
|
return true;
|
|
18
22
|
}
|
|
19
|
-
if (rows === 0) {
|
|
20
|
-
console.log('Nothing to reset —
|
|
23
|
+
if (rows === 0 && sessions.messages === 0 && rtk.commands === 0) {
|
|
24
|
+
console.log('Nothing to reset — no statistics recorded.');
|
|
21
25
|
return true;
|
|
22
26
|
}
|
|
23
27
|
if (!yes) {
|
|
24
28
|
if (tty()) {
|
|
25
29
|
closeRL();
|
|
26
|
-
const confirmedChoice = await clackConfirm({ message: 'Clear
|
|
30
|
+
const confirmedChoice = await clackConfirm({ message: 'Clear tersio statistics?', initialValue: false });
|
|
27
31
|
if (typeof confirmedChoice !== 'boolean') {
|
|
28
32
|
clackCancel('Aborted.');
|
|
29
33
|
closeRL();
|
|
@@ -45,7 +49,8 @@ async function runReset() {
|
|
|
45
49
|
}
|
|
46
50
|
}
|
|
47
51
|
const cleared = clearUsageLedger();
|
|
48
|
-
|
|
52
|
+
const ts = markReset();
|
|
53
|
+
console.log(`[ok] reset — removed ${cleared} usage rows; statistics view starts at ${new Date(ts).toISOString()}`);
|
|
49
54
|
return true;
|
|
50
55
|
}
|
|
51
56
|
export { runReset };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// cli/rtk-wiring.ts — wire the installed rtk binary into OMP.
|
|
2
|
+
// `rtk init -g --agent omp` writes rtk's tool_call extension to
|
|
3
|
+
// ~/.omp/agent/extensions/rtk.ts; OMP auto-loads it and rewrites bash
|
|
4
|
+
// commands to rtk before execution, so every rewritten call lands in
|
|
5
|
+
// rtk's history.db and shows metered savings in the gain dashboard.
|
|
6
|
+
// Fail-open: a failed wire only downgrades to manual `rtk` prefixing.
|
|
7
|
+
// Deliberately free of cli/common.ts imports (argv side effects) so tests
|
|
8
|
+
// can load it directly.
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
function shortWiringError(e) {
|
|
11
|
+
const err = e;
|
|
12
|
+
const why = [err.code !== undefined ? `code ${err.code}` : '', err.signal ?? '', err.stderr?.trim(), err.message]
|
|
13
|
+
.filter(Boolean).join(' · ');
|
|
14
|
+
return why.slice(0, 200);
|
|
15
|
+
}
|
|
16
|
+
function execFileP(cmd, args, timeout) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
execFile(cmd, args, { timeout, encoding: 'utf8' }, (err, stdout, stderr) => {
|
|
19
|
+
if (err) {
|
|
20
|
+
err.stderr = String(stderr || '');
|
|
21
|
+
reject(err);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
resolve({ stdout: String(stdout), stderr: String(stderr) });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
// Idempotent: rtk rewrites its extension file on every init run, so
|
|
30
|
+
// reinstalling tersio refreshes the wiring for free.
|
|
31
|
+
export async function wireRtkOmp(rtkBin, options = {}) {
|
|
32
|
+
console.log(' Wiring rtk → OMP (bash tool_call rewrite)...');
|
|
33
|
+
if (options.dryRun) {
|
|
34
|
+
console.log(' [dry-run] would run: rtk init -g --agent omp');
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
await execFileP(rtkBin, ['init', '-g', '--agent', 'omp'], 30000);
|
|
39
|
+
console.log(' [ok] rtk OMP extension wired (~/.omp/agent/extensions/rtk.ts)');
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
catch (first) {
|
|
43
|
+
// A freshly written binary can lose its first exec to macOS Gatekeeper
|
|
44
|
+
// verification; give it one settle-and-retry before reporting failure.
|
|
45
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
46
|
+
try {
|
|
47
|
+
await execFileP(rtkBin, ['init', '-g', '--agent', 'omp'], 30000);
|
|
48
|
+
console.log(' [ok] rtk OMP extension wired (~/.omp/agent/extensions/rtk.ts)');
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
void first;
|
|
53
|
+
console.log(` [warn] could not wire rtk → OMP: ${shortWiringError(e)}`);
|
|
54
|
+
console.log(' [hint] Manual: rtk init -g --agent omp (needs rtk >= 0.49)');
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
package/cli/uninstall.js
CHANGED
|
@@ -88,6 +88,7 @@ async function runUninstall(options = {}) {
|
|
|
88
88
|
}
|
|
89
89
|
if (shouldRemoveRtk) {
|
|
90
90
|
console.log(` ${rtkBin}`);
|
|
91
|
+
console.log(` ${path.join(extDir, 'rtk.ts')} (rtk OMP wiring)`);
|
|
91
92
|
}
|
|
92
93
|
if (!confirmed) {
|
|
93
94
|
if (tty()) {
|
|
@@ -162,9 +163,13 @@ async function runUninstall(options = {}) {
|
|
|
162
163
|
if ((await readTextIfExists(path.join(selfPluginDir, 'package.json'))) !== null) {
|
|
163
164
|
await removeUninstallTarget(selfPluginDir, shouldDryRun);
|
|
164
165
|
}
|
|
165
|
-
// Remove RTK binary if requested
|
|
166
|
-
|
|
166
|
+
// Remove RTK binary if requested. rtk.ts is rtk-owned but installed by our
|
|
167
|
+
// wiring step; with the binary gone it would pass through harmlessly, so
|
|
168
|
+
// only drop it on a full rtk removal.
|
|
169
|
+
if (shouldRemoveRtk) {
|
|
167
170
|
await removeUninstallTarget(rtkBin, shouldDryRun, false);
|
|
171
|
+
await removeUninstallTarget(path.join(extDir, 'rtk.ts'), shouldDryRun);
|
|
172
|
+
}
|
|
168
173
|
console.log('\nDone. Restart OMP for changes to take effect.');
|
|
169
174
|
return true;
|
|
170
175
|
}
|
package/cli/usage.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// cli/usage.ts — ledger + session-token usage report, tokscale-style.
|
|
2
|
-
import { co2GramsFor, energyWhFor, importSessionTokens, ledgerPath, priceFor, readUsage, sessionsDir, usdCost, } from '../extensions/shared/usage-ledger.js';
|
|
3
|
-
import { readRtkGain
|
|
2
|
+
import { co2GramsFor, energyWhFor, importSessionTokens, ledgerPath, priceFor, readResetWatermark, readUsage, sessionsDir, usdCost, } from '../extensions/shared/usage-ledger.js';
|
|
3
|
+
import { readRtkGain } from '../extensions/shared/rtk-gain.js';
|
|
4
4
|
import { withInteractiveSpinner } from './interactive.js';
|
|
5
5
|
import { PACKAGE_VERSION } from './common.js';
|
|
6
6
|
export function summarizeUsage(rows) {
|
|
@@ -12,6 +12,7 @@ export function summarizeUsage(rows) {
|
|
|
12
12
|
}
|
|
13
13
|
const byDetail = Object.entries(detailCounts).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
14
14
|
const session = importSessionTokens();
|
|
15
|
+
const watermark = readResetWatermark();
|
|
15
16
|
let usd = 0;
|
|
16
17
|
let priced = true;
|
|
17
18
|
let savedUsd = 0;
|
|
@@ -44,7 +45,7 @@ export function summarizeUsage(rows) {
|
|
|
44
45
|
byDayModel: session.byDayModel,
|
|
45
46
|
byTool,
|
|
46
47
|
recent: session.recent,
|
|
47
|
-
rtkGain: readRtkGain(),
|
|
48
|
+
rtkGain: readRtkGain(10, watermark || undefined),
|
|
48
49
|
usd,
|
|
49
50
|
priced,
|
|
50
51
|
savedUsd,
|
|
@@ -52,7 +53,7 @@ export function summarizeUsage(rows) {
|
|
|
52
53
|
co2g,
|
|
53
54
|
energyWh,
|
|
54
55
|
version: PACKAGE_VERSION,
|
|
55
|
-
paths: { ledger: ledgerPath(), sessions: sessionsDir()
|
|
56
|
+
paths: { ledger: ledgerPath(), sessions: sessionsDir() },
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
function fmt(n) {
|
package/dashboard/app.js
CHANGED
|
@@ -778,7 +778,7 @@
|
|
|
778
778
|
renderSpark(byDay, d.usd || 0, d.savedUsd || 0, total);
|
|
779
779
|
document.getElementById('verChip').textContent = 'tersio v' + (d.version || '?');
|
|
780
780
|
var dp = d.paths || {};
|
|
781
|
-
[['pathLedger', dp.ledger], ['pathSessions', dp.sessions]
|
|
781
|
+
[['pathLedger', dp.ledger], ['pathSessions', dp.sessions]].forEach(function(pair) {
|
|
782
782
|
var pel = document.getElementById(pair[0]);
|
|
783
783
|
if (pel && pair[1]) { pel.textContent = pair[1]; pel.title = pair[1]; }
|
|
784
784
|
});
|
|
@@ -887,6 +887,27 @@
|
|
|
887
887
|
observe();
|
|
888
888
|
});
|
|
889
889
|
}
|
|
890
|
+
function toast(title, desc, icon) {
|
|
891
|
+
var wrap = document.querySelector('.toaster');
|
|
892
|
+
if (!wrap) {
|
|
893
|
+
wrap = document.createElement('div');
|
|
894
|
+
wrap.className = 'toaster';
|
|
895
|
+
wrap.popover = 'manual';
|
|
896
|
+
document.body.appendChild(wrap);
|
|
897
|
+
if (wrap.showPopover) wrap.showPopover(); // top layer: paints above the native settings dialog
|
|
898
|
+
}
|
|
899
|
+
var el = document.createElement('div');
|
|
900
|
+
el.className = 'toast';
|
|
901
|
+
el.innerHTML = '<span class="toast-icon"><i data-lucide="' + (icon || 'check') + '" class="size-4"></i></span><div><p class="toast-title"></p><p class="toast-desc"></p></div>';
|
|
902
|
+
el.querySelector('.toast-title').textContent = title;
|
|
903
|
+
el.querySelector('.toast-desc').textContent = desc;
|
|
904
|
+
wrap.appendChild(el);
|
|
905
|
+
if (window.lucide) lucide.createIcons();
|
|
906
|
+
setTimeout(function() {
|
|
907
|
+
el.classList.add('out');
|
|
908
|
+
setTimeout(function() { el.remove(); }, 200);
|
|
909
|
+
}, 4000);
|
|
910
|
+
}
|
|
890
911
|
document.getElementById('reload').addEventListener('click', load);
|
|
891
912
|
(function() {
|
|
892
913
|
var dlg = document.getElementById('settings');
|
|
@@ -909,7 +930,11 @@
|
|
|
909
930
|
fetch('reset', { method: 'POST' }).then(function(r) { return r.json(); }).then(function() {
|
|
910
931
|
label.textContent = 'Reset';
|
|
911
932
|
load();
|
|
912
|
-
|
|
933
|
+
toast('Statistics reset', 'The usage statistics view now starts fresh. Transcripts and RTK history were never touched.', 'rotate-ccw');
|
|
934
|
+
}).catch(function() {
|
|
935
|
+
label.textContent = 'Reset';
|
|
936
|
+
toast('Reset failed', 'Could not reach the server. Try again.', 'circle-alert');
|
|
937
|
+
});
|
|
913
938
|
} else {
|
|
914
939
|
resetBtn.dataset.armed = '1';
|
|
915
940
|
label.textContent = 'Sure?';
|
package/dashboard/styles.css
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
--cell-0: rgba(24, 24, 27, .07);
|
|
6
6
|
--cell-1: rgba(4, 120, 87, .3); --cell-2: rgba(4, 120, 87, .55);
|
|
7
7
|
--cell-3: rgba(4, 120, 87, .8); --cell-4: #047857;
|
|
8
|
+
--danger: #dc2626; --danger-border: rgba(220, 38, 38, .35); --danger-soft: rgba(220, 38, 38, .05);
|
|
8
9
|
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.07);
|
|
9
10
|
}
|
|
10
11
|
html[data-theme="light"] { color-scheme: light; }
|
|
@@ -17,6 +18,7 @@
|
|
|
17
18
|
--cell-0: rgba(255, 255, 255, .07);
|
|
18
19
|
--cell-1: rgba(52, 211, 153, .3); --cell-2: rgba(52, 211, 153, .55);
|
|
19
20
|
--cell-3: rgba(52, 211, 153, .8); --cell-4: #34d399;
|
|
21
|
+
--danger: #f87171; --danger-border: rgba(248, 113, 113, .3); --danger-soft: rgba(248, 113, 113, .07);
|
|
20
22
|
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.55);
|
|
21
23
|
}
|
|
22
24
|
}
|
|
@@ -36,6 +38,7 @@
|
|
|
36
38
|
--cell-0: rgba(255, 255, 255, .07);
|
|
37
39
|
--cell-1: rgba(52, 211, 153, .3); --cell-2: rgba(52, 211, 153, .55);
|
|
38
40
|
--cell-3: rgba(52, 211, 153, .8); --cell-4: #34d399;
|
|
41
|
+
--danger: #f87171; --danger-border: rgba(248, 113, 113, .3); --danger-soft: rgba(248, 113, 113, .07);
|
|
39
42
|
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.55);
|
|
40
43
|
}
|
|
41
44
|
.ticker { animation: tick 36s linear infinite; will-change: transform; }
|
|
@@ -185,6 +188,7 @@
|
|
|
185
188
|
.dlg-desc { font-size: 12px; color: var(--dim); margin: 2px 0 0; }
|
|
186
189
|
.dlg-label { font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--dim); margin: 18px 0 8px; }
|
|
187
190
|
.dlg-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
191
|
+
.dlg-row + .dlg-row { margin-top: 14px; }
|
|
188
192
|
.dlg-name { font-size: 13px; font-weight: 600; margin: 0; }
|
|
189
193
|
.dlg-hint { font-size: 12px; color: var(--dim); margin: 2px 0 0; }
|
|
190
194
|
.dlg-own { font-weight: 400; font-size: 11px; color: var(--dim); }
|
|
@@ -192,4 +196,18 @@
|
|
|
192
196
|
.dlg-path { border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; min-width: 0; overflow: hidden; }
|
|
193
197
|
.dlg-file { font-size: 11px; color: var(--dim); margin: 4px 0 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
194
198
|
#settingsBtn:hover { box-shadow: 0 0 14px var(--accent-soft); border-color: var(--accent); }
|
|
199
|
+
.dlg-danger { margin-top: 16px; border: 1px solid var(--danger-border); background: var(--danger-soft); border-radius: 12px; padding: 12px; }
|
|
200
|
+
.dlg-danger .dlg-label { color: var(--danger); margin: 0 0 10px; }
|
|
201
|
+
#reset { color: var(--danger); }
|
|
202
|
+
#reset:hover { border-color: var(--danger); box-shadow: 0 0 14px var(--danger-border); }
|
|
195
203
|
.dlg .seg button { display: grid; place-items: center; }
|
|
204
|
+
|
|
205
|
+
/* Toaster (shadcn/ui sonner-style, dependency-free) */
|
|
206
|
+
.toaster { position: fixed; inset: auto 16px 16px auto; z-index: 100; display: flex; flex-direction: column; gap: 8px; width: min(360px, calc(100vw - 32px)); border: 0; padding: 0; background: transparent; overflow: visible; }
|
|
207
|
+
.toast { display: flex; align-items: flex-start; gap: 10px; padding: 12px 14px; border: 1px solid var(--line); background: var(--panel); border-radius: 12px; box-shadow: var(--shadow-xs), 0 8px 24px rgba(0, 0, 0, .22); animation: toast-in .18s ease-out; }
|
|
208
|
+
.toast.out { animation: toast-out .18s ease-in forwards; }
|
|
209
|
+
.toast-icon { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; background: var(--accent-soft); color: var(--accent); flex: none; }
|
|
210
|
+
.toast-title { font-size: 13px; font-weight: 600; margin: 0; }
|
|
211
|
+
.toast-desc { font-size: 12px; color: var(--dim); margin: 1px 0 0; }
|
|
212
|
+
@keyframes toast-in { from { opacity: 0; transform: translateY(8px) scale(.98); } }
|
|
213
|
+
@keyframes toast-out { to { opacity: 0; transform: translateY(6px) scale(.98); } }
|
package/dashboard/template.html
CHANGED
|
@@ -36,9 +36,6 @@
|
|
|
36
36
|
<span id="clock">--:--</span>
|
|
37
37
|
</span>
|
|
38
38
|
<span class="hidden sm:block w-px h-5 shrink-0" style="background: var(--line)" aria-hidden="true"></span>
|
|
39
|
-
<button id="reset" class="btn-push mono flex shrink-0 items-center gap-2 text-xs pl-2.5 pr-3 py-2 rounded-xl" style="border: 1px solid var(--line); color: var(--ink)" aria-label="Reset tersio statistics">
|
|
40
|
-
<i data-lucide="rotate-ccw" class="size-3.5"></i><span class="hidden md:inline" id="resetLabel">Reset</span>
|
|
41
|
-
</button>
|
|
42
39
|
<button id="settingsBtn" class="btn-push flex shrink-0 items-center p-2 rounded-xl" style="border: 1px solid var(--line); color: var(--ink)" aria-label="Open settings">
|
|
43
40
|
<i data-lucide="settings" class="size-4"></i>
|
|
44
41
|
</button>
|
|
@@ -49,7 +46,7 @@
|
|
|
49
46
|
<div class="dlg-head">
|
|
50
47
|
<div class="min-w-0">
|
|
51
48
|
<p class="dlg-title" id="settingsTitle">Settings</p>
|
|
52
|
-
<p class="dlg-desc">Appearance, data
|
|
49
|
+
<p class="dlg-desc">Appearance, data, and where statistics live.</p>
|
|
53
50
|
</div>
|
|
54
51
|
<button id="settingsClose" class="btn-push flex shrink-0 items-center p-2 rounded-xl" style="border: 1px solid var(--line); color: var(--ink)" aria-label="Close settings">
|
|
55
52
|
<i data-lucide="x" class="size-4"></i>
|
|
@@ -86,9 +83,17 @@
|
|
|
86
83
|
<p class="dlg-name">Session transcripts <span class="dlg-own">host-owned · never touched</span></p>
|
|
87
84
|
<p class="mono dlg-file" id="pathSessions" title="">–</p>
|
|
88
85
|
</div>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
86
|
+
</div>
|
|
87
|
+
<div class="dlg-danger">
|
|
88
|
+
<p class="dlg-label">Danger zone</p>
|
|
89
|
+
<div class="dlg-row">
|
|
90
|
+
<div class="min-w-0">
|
|
91
|
+
<p class="dlg-name">Reset statistics</p>
|
|
92
|
+
<p class="dlg-hint">Clears tersio statistics. Host-owned files stay intact. Click twice to confirm.</p>
|
|
93
|
+
</div>
|
|
94
|
+
<button id="reset" class="btn-push mono flex shrink-0 items-center gap-2 text-xs pl-2.5 pr-3 py-2 rounded-xl" style="border: 1px solid var(--danger-border)" aria-label="Reset tersio statistics">
|
|
95
|
+
<i data-lucide="rotate-ccw" class="size-3.5"></i><span id="resetLabel">Reset</span>
|
|
96
|
+
</button>
|
|
92
97
|
</div>
|
|
93
98
|
</div>
|
|
94
99
|
</dialog>
|
|
@@ -283,7 +288,7 @@
|
|
|
283
288
|
</span>
|
|
284
289
|
<span class="ml-auto flex items-center gap-1" id="cmdPages" role="navigation" aria-label="Pages"></span>
|
|
285
290
|
</div>
|
|
286
|
-
<p class="mono text-[11px] mt-3" style="color: var(--dim)">
|
|
291
|
+
<p class="mono text-[11px] mt-3" style="color: var(--dim)">bash commands run through the rtk-wired OMP hook meter automatically; rows without metering show –. caveman + ponytail gains are instruction-following, bench-measured in BENCHMARK.md.</p>
|
|
287
292
|
</section>
|
|
288
293
|
|
|
289
294
|
<footer class="mono text-xs mt-12 pt-4" style="color: var(--dim); border-top: 1px solid var(--line)">
|
|
@@ -33,12 +33,18 @@ function query(db, sql) {
|
|
|
33
33
|
.filter((line) => line.trim())
|
|
34
34
|
.map((line) => line.split('\t'));
|
|
35
35
|
}
|
|
36
|
-
export function readRtkGain(limit = 10) {
|
|
36
|
+
export function readRtkGain(limit = 10, cutoffMs) {
|
|
37
37
|
try {
|
|
38
38
|
if (!fs.existsSync(rtkDbPath()))
|
|
39
39
|
return EMPTY;
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// View-level cutoff only: rows stay in rtk's own database untouched.
|
|
41
|
+
// strftime('%s') normalizes ISO-8601 timestamps to epoch seconds; rows
|
|
42
|
+
// with unparseable timestamps (0) drop out of a filtered view.
|
|
43
|
+
const where = cutoffMs && cutoffMs > 0
|
|
44
|
+
? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
|
|
45
|
+
: '';
|
|
46
|
+
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};`);
|
|
47
|
+
const byCommand = query(rtkDbPath(), `SELECT rtk_cmd, COUNT(*), SUM(saved_tokens), AVG(savings_pct), AVG(exec_time_ms) 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]) => ({
|
|
42
48
|
command,
|
|
43
49
|
count: Number(count) || 0,
|
|
44
50
|
saved: Number(savedRow) || 0,
|
|
@@ -11,6 +11,34 @@ export function ledgerPath() {
|
|
|
11
11
|
return override;
|
|
12
12
|
return path.join(os.homedir(), '.omp', 'plugins', 'tersio-usage.jsonl');
|
|
13
13
|
}
|
|
14
|
+
// Reset watermark: tersio-owned timestamp marking the last statistics reset.
|
|
15
|
+
// Session transcripts and the RTK database are host/tool-owned and never
|
|
16
|
+
// touched — instead, every derived view (token stats, command tools) filters
|
|
17
|
+
// rows from before the watermark, so "reset" empties what tersio shows
|
|
18
|
+
// without deleting anything it does not own.
|
|
19
|
+
export function resetMarkerPath() {
|
|
20
|
+
const override = process.env.TERSIO_RESET_FILE;
|
|
21
|
+
if (override)
|
|
22
|
+
return override;
|
|
23
|
+
return path.join(os.homedir(), '.omp', 'plugins', 'tersio-reset.json');
|
|
24
|
+
}
|
|
25
|
+
export function readResetWatermark() {
|
|
26
|
+
try {
|
|
27
|
+
const raw = JSON.parse(fs.readFileSync(resetMarkerPath(), 'utf8'));
|
|
28
|
+
return typeof raw.ts === 'number' && Number.isFinite(raw.ts) && raw.ts > 0 ? raw.ts : 0;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function markReset(now = Date.now()) {
|
|
35
|
+
try {
|
|
36
|
+
fs.mkdirSync(path.dirname(resetMarkerPath()), { recursive: true });
|
|
37
|
+
fs.writeFileSync(resetMarkerPath(), JSON.stringify({ ts: now }) + '\n', 'utf8');
|
|
38
|
+
}
|
|
39
|
+
catch { /* best-effort; never break the caller */ }
|
|
40
|
+
return now;
|
|
41
|
+
}
|
|
14
42
|
export function appendUsage(kind, detail) {
|
|
15
43
|
const row = JSON.stringify({ ts: Date.now(), kind, detail }) + '\n';
|
|
16
44
|
try {
|
|
@@ -71,6 +99,7 @@ function walkJsonl(dir, out, cap) {
|
|
|
71
99
|
}
|
|
72
100
|
}
|
|
73
101
|
export function importSessionTokens() {
|
|
102
|
+
const watermark = readResetWatermark();
|
|
74
103
|
const byModel = {};
|
|
75
104
|
const byDay = {};
|
|
76
105
|
const byDayModel = {};
|
|
@@ -88,13 +117,17 @@ export function importSessionTokens() {
|
|
|
88
117
|
}
|
|
89
118
|
let codexProvider = null;
|
|
90
119
|
const recent = [];
|
|
120
|
+
// Rows from before the reset watermark (and rows with no usable timestamp,
|
|
121
|
+
// which may predate it) stay out of every derived statistic.
|
|
91
122
|
function ingest(model, usage, ts) {
|
|
123
|
+
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
124
|
+
if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
|
|
125
|
+
return false;
|
|
92
126
|
addInto(totals, usage);
|
|
93
127
|
byModel[model] ??= zeroBreakdown();
|
|
94
128
|
addInto(byModel[model], usage);
|
|
95
129
|
byModelMessages[model] = (byModelMessages[model] ?? 0) + 1;
|
|
96
130
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
|
97
|
-
const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
|
|
98
131
|
if (Number.isFinite(ms))
|
|
99
132
|
recent.push({ m: model, i: num(usage.input), o: num(usage.output), t: ms });
|
|
100
133
|
const day = ts !== undefined ? dayKey(ts) : null;
|
|
@@ -108,6 +141,7 @@ export function importSessionTokens() {
|
|
|
108
141
|
}
|
|
109
142
|
}
|
|
110
143
|
messages += 1;
|
|
144
|
+
return true;
|
|
111
145
|
}
|
|
112
146
|
for (const file of files) {
|
|
113
147
|
let text;
|
|
@@ -145,9 +179,11 @@ export function importSessionTokens() {
|
|
|
145
179
|
if (!msg || msg.role !== 'assistant' || !msg.usage)
|
|
146
180
|
continue;
|
|
147
181
|
const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
|
|
148
|
-
ingest(model, msg.usage, row.timestamp);
|
|
149
|
-
if (typeof msg.usage.cost === 'number' && Number.isFinite(msg.usage.cost))
|
|
182
|
+
const counted = ingest(model, msg.usage, row.timestamp);
|
|
183
|
+
if (counted && typeof msg.usage.cost === 'number' && Number.isFinite(msg.usage.cost))
|
|
150
184
|
costMeasured += msg.usage.cost;
|
|
185
|
+
if (!counted)
|
|
186
|
+
continue;
|
|
151
187
|
for (const part of msg.content ?? []) {
|
|
152
188
|
if (part?.type === 'toolCall' && typeof part.name === 'string' && part.name) {
|
|
153
189
|
const key = part.name === 'bash' ? `bash:${leadBinary(part.arguments?.command)}` : part.name;
|
package/package.json
CHANGED