@krtclcdy/tersio 2.21.0 → 2.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,17 +18,16 @@
18
18
  curl -fsSL https://github.com/KurutoDenzeru/tersio/releases/latest/download/install.sh | sh
19
19
  ```
20
20
 
21
- Prefer OMP-managed updates and feature flags? Install as an OMP plugin instead:
21
+ Prefer OMP-managed updates? Install as an OMP plugin instead:
22
22
 
23
23
  ```bash
24
24
  omp plugin install @krtclcdy/tersio
25
25
  ```
26
26
 
27
- Subset only:
27
+ All modes load always. Only the updater stays toggleable:
28
28
 
29
29
  ```bash
30
- omp plugin install '@krtclcdy/tersio[caveman,ponytail]'
31
- omp plugin features @krtclcdy/tersio --disable rtk
30
+ omp plugin features @krtclcdy/tersio --disable updater
32
31
  ```
33
32
 
34
33
  Then restart OMP and enable a preset:
@@ -80,21 +79,26 @@ Measured savings against the same workload without the modes. Token counts are r
80
79
 
81
80
  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).
82
81
 
83
- ## 📈 Gain Dashboard
82
+ ## 📈 Dashboard
84
83
 
85
- `tersio gain --open` serves a local dashboard (127.0.0.1 only) that charts your own savings from `~/.omp/plugins/tersio-usage.jsonl`. Three views:
84
+ `tersio dashboard --open` serves a local dashboard (127.0.0.1 only) that charts your own savings from `~/.tersio/usage.db`. Three views:
86
85
 
87
86
  **Live feed and savings.** Token throughput, cost, savings bento, and recent activity — the top of the dashboard.
88
87
 
89
- ![Gain dashboard: live token feed, cost, savings, and activity](/assets/GainHero.webp)
88
+ ![Dashboard: live token feed, cost, savings, and activity](/assets/GainHero.webp)
90
89
 
91
90
  **Top models and recent requests.** Per-model cost breakdown plus the last requests with elapsed time and token speed.
92
91
 
93
- ![Gain dashboard: top models with per-model cost, recent requests with elapsed time and token speed](/assets/GainModels.webp)
92
+ ![Dashboard: top models with per-model cost, recent requests with elapsed time and token speed](/assets/GainModels.webp)
94
93
 
95
94
  **Command tools.** Token savings, average rate, and timing per shell tool.
96
95
 
97
- ![Gain dashboard: command tools with token savings, average rate, and timing](/assets/GainTools.webp)
96
+ ![Dashboard: command tools with token savings, average rate, and timing](/assets/GainTools.webp)
97
+
98
+ ### Measurement references
99
+
100
+ - **[EcoLogits](https://github.com/mlco2/ecologits)** informs Tersio's local CO₂ and energy estimates. Tersio ports the model to TypeScript; EcoLogits is not a runtime dependency.
101
+ - **[Tokscale](https://github.com/junhoyeo/tokscale)** informs Tersio's Input, Output, Cache Read, and Cache Write accounting from local OMP sessions. Tokscale is a reference, not a runtime dependency.
98
102
 
99
103
  ## 🖥️ CLI
100
104
 
@@ -105,12 +109,12 @@ Break-even math, balanced preset (caveman full + rtk + ponytail): a mixed turn c
105
109
  | `tersio reinstall` | Fresh install, preserving the Ponytail package |
106
110
  | `tersio doctor` | Check OMP, extension, Ponytail, and RTK health (`--fix` repairs, `--fix=<scope>` one scope, `--dry-run` previews) |
107
111
  | `tersio usage` | Ledger-backed usage + savings report |
108
- | `tersio gain` | Open the gain dashboard (`--open`, `--export <file>`, `--port <n>`, `--currency <code>`; serves localhost only) |
112
+ | `tersio dashboard` | Open the Dashboard (`--open`, `--export <file>`, `--port <n>`, `--currency <code>`; serves localhost only) |
109
113
  | `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 |
110
114
  | `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) |
111
115
  | `tersio version` | Print version |
112
116
 
113
- Flags: `--dry-run`, `--yes`/`-y`, `--verbose`, `--combo-default`/`--caveman-default`/`--rtk-default`/`--ponytail-default`, `--currency <code>` (usage|gain display currency; flag wins, then the `tersio settings` default, then USD). Legacy `--doctor` / `--uninstall` forms still work.
117
+ Flags: `--dry-run`, `--yes`/`-y`, `--verbose`, `--combo-default`/`--caveman-default`/`--rtk-default`/`--ponytail-default`, `--currency <code>` (usage/dashboard display currency; flag wins, then the `tersio settings` default, then USD). Legacy `--doctor` / `--uninstall` forms still work.
114
118
 
115
119
  ## ⌨️ Commands reference
116
120
 
@@ -125,8 +129,8 @@ Mode switches live on their own commands; bare `/tersio` prints status.
125
129
  | `/tersio status` | Active modes + combo level; state persists, propagates to subagents, and lights the Combo footer indicator. |
126
130
  | `/tersio check` | Add-on version check |
127
131
  | `/tersio update <ponytail\|rtk\|caveman\|all> [--dry-run]` | Update add-ons, preview with dry-run |
128
- | `/tersio gain` | Savings summary; the gain dashboard charts `~/.omp/plugins/tersio-usage.jsonl` (`tersio gain --open` serves on 127.0.0.1, `--export` writes one file). |
129
- | `/tersio usage` | Ledger report for this machine (rows in `~/.omp/plugins/tersio-usage.jsonl`, local only) |
132
+ | `/tersio dashboard` | Open the Dashboard. |
133
+ | `/tersio usage` | Ledger report for this machine (rows in `~/.tersio/usage.db`, local only) |
130
134
  | `/tersio help` | This table |
131
135
  | `/ai-addons` | Add-on updater alias, still works. |
132
136
 
@@ -136,7 +140,7 @@ Mode switches live on their own commands; bare `/tersio` prints status.
136
140
  |---|---|
137
141
  | Caveman / RTK / Updater / Combo extensions | `~/.omp/agent/extensions/{caveman-session,rtk-session,ai-addons-updater,combo-toggle}/` |
138
142
  | 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 |
139
- | Ponytail package | `~/.omp/plugins/node_modules/@dietrichgebert/ponytail/` |
143
+ | Ponytail package (bundled tersio dependency — one Plugins row, updates with `tersio update`) | `~/.omp/plugins/node_modules/@dietrichgebert/ponytail/` |
140
144
  | RTK binary | `~/.bun/bin/rtk` (`rtk.exe` on Windows) |
141
145
  | Extension registrations | `~/.omp/agent/config.yml` |
142
146
 
@@ -150,7 +154,7 @@ User-level installs also register the package in `~/.omp/plugins` (visible in OM
150
154
 
151
155
  **RTK missing or not executable:** run `tersio reinstall`, then `tersio doctor`. On Linux/macOS: `chmod +x ~/.bun/bin/rtk`.
152
156
 
153
- **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.
157
+ **RTK commands not metered in the 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.
154
158
 
155
159
  **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.
156
160
 
package/cli/common.js CHANGED
@@ -20,6 +20,7 @@ const { version: PACKAGE_VERSION } = createRequire(import.meta.url)('../package.
20
20
  const CAVEMAN_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra', 'wenyan']);
21
21
  const PONYTAIL_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra', 'review']);
22
22
  const RTK_DEFAULTS = new Set(['on', 'off']);
23
+ const DIAG_SCHEDULES = new Set(['manual', 'daily', 'weekly', 'monthly']);
23
24
  // ponytail: Combo preset implies all three modes. Mirrors COMBO_LEVELS in
24
25
  // extensions/shared/session-state.ts (rtk as boolean here). Single source.
25
26
  const COMBO_PRESET_MODES = {
@@ -47,10 +48,20 @@ function flagValue(name) {
47
48
  return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
48
49
  }
49
50
  // --- CLI flags ---
50
- const COMMANDS = { install: true, update: true, reinstall: true, doctor: true, uninstall: true, usage: true, gain: true, reset: true, settings: true, version: true, help: true };
51
+ const COMMANDS = { install: true, update: true, reinstall: true, doctor: true, uninstall: true, usage: true, dashboard: true, reset: true, settings: true, version: true, help: true };
51
52
  const args = process.argv.slice(2);
52
53
  const commandArg = args.find((arg) => !arg.startsWith('-'));
53
54
  const command = commandArg?.toLowerCase() || null;
55
+ // Positional after the command: `tersio settings diagnosis` jumps straight
56
+ // to one prompt instead of walking the whole chain. Index-based so flag
57
+ // values (e.g. `--combo-default balanced`) never count as positionals.
58
+ const settingArg = (() => {
59
+ const at = args.indexOf(commandArg ?? '');
60
+ const next = at >= 0 ? args[at + 1] : undefined;
61
+ if (!next || next.startsWith('-'))
62
+ return null;
63
+ return next.toLowerCase();
64
+ })();
54
65
  const unknownCommand = command !== null && !COMMANDS[command];
55
66
  const install = command === 'install';
56
67
  const update = command === 'update';
@@ -64,13 +75,13 @@ const verbose = args.includes('--verbose');
64
75
  const doctor = command === 'doctor' || args.includes('--doctor');
65
76
  const fix = args.includes('--fix') || args.some((a) => a.startsWith('--fix='));
66
77
  const usage = command === 'usage' || args.includes('--usage');
67
- const gain = command === 'gain';
78
+ const dashboard = command === 'dashboard';
68
79
  const reset = command === 'reset';
69
80
  const settings = command === 'settings';
70
81
  const dashboardPort = Number.parseInt(flagValue('--port') ?? '', 10) || 0;
71
82
  const dashboardOpen = args.includes('--open');
72
83
  const dashboardExport = flagValue('--export') ?? null;
73
- // Display currency for usage/gain: --currency flag wins, then the stored
84
+ // Display currency for usage/dashboard: --currency flag wins, then the stored
74
85
  // plugin default (tersio settings), then USD.
75
86
  const currency = parseCurrencyFlag(flagValue('--currency')) ?? readStoredCurrency();
76
87
  const currencyGiven = flagValue('--currency') !== undefined;
@@ -90,6 +101,7 @@ const comboDefaultFlag = parseEnum(flagValue('--combo-default'), COMBO_DEFAULTS,
90
101
  const cavemanDefaultFlag = parseEnum(flagValue('--caveman-default'), CAVEMAN_DEFAULTS, '--caveman-default');
91
102
  const ponytailDefaultFlag = parseEnum(flagValue('--ponytail-default'), PONYTAIL_DEFAULTS, '--ponytail-default');
92
103
  const rtkDefaultFlag = parseEnum(flagValue('--rtk-default'), RTK_DEFAULTS, '--rtk-default');
104
+ const diagScheduleFlag = parseEnum(flagValue('--diag-schedule'), DIAG_SCHEDULES, '--diag-schedule');
93
105
  const profileFlagsGiven = [comboDefaultFlag, cavemanDefaultFlag, rtkDefaultFlag, ponytailDefaultFlag]
94
106
  .some((flag) => flag !== undefined);
95
107
  function debug(...a) {
@@ -286,4 +298,4 @@ function relTime(ageMs) {
286
298
  const weeks = Math.floor(days / 7);
287
299
  return `${weeks} week${weeks === 1 ? '' : 's'} ago`;
288
300
  }
289
- export { IS_WINDOWS, RTK_BINARY_NAME, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, BUN_BIN_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_BIN, PACKAGE_VERSION, CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS, RTK_DEFAULTS, COMBO_PRESET_MODES, COMBO_DEFAULTS, parseEnum, flagValue, COMMANDS, commandArg, command, unknownCommand, install, update, reinstall, showVersion, showHelp, applyUpdate, args, dryRun, yes, verbose, doctor, fix, uninstall, usage, gain, reset, settings, dashboardPort, dashboardOpen, dashboardExport, currency, currencyGiven, removePonytail, keepPonytail, removeRtk, comboDefaultFlag, cavemanDefaultFlag, ponytailDefaultFlag, rtkDefaultFlag, profileFlagsGiven, debug, execFileP, execP, writeIfChanged, normalizeExtensionsKey, EXTENSIONS_KEY_RE, writeConfigLines, ensureExtensionInConfig, ensureExtensionAfterConfigEntry, readPonytailConfig, parseJsonObject, parsePonytailConfig, patchPonytailConfig, ensurePonytailConfigValue, readPluginsPackage, relTime, };
301
+ export { IS_WINDOWS, RTK_BINARY_NAME, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, BUN_BIN_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_BIN, PACKAGE_VERSION, CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS, RTK_DEFAULTS, DIAG_SCHEDULES, COMBO_PRESET_MODES, COMBO_DEFAULTS, parseEnum, flagValue, COMMANDS, commandArg, command, settingArg, unknownCommand, install, update, reinstall, showVersion, showHelp, applyUpdate, args, dryRun, yes, verbose, doctor, fix, uninstall, usage, dashboard, reset, settings, dashboardPort, dashboardOpen, dashboardExport, currency, currencyGiven, removePonytail, keepPonytail, removeRtk, comboDefaultFlag, cavemanDefaultFlag, ponytailDefaultFlag, rtkDefaultFlag, diagScheduleFlag, profileFlagsGiven, debug, execFileP, execP, writeIfChanged, normalizeExtensionsKey, EXTENSIONS_KEY_RE, writeConfigLines, ensureExtensionInConfig, ensureExtensionAfterConfigEntry, readPonytailConfig, parseJsonObject, parsePonytailConfig, patchPonytailConfig, ensurePonytailConfigValue, readPluginsPackage, relTime, };
package/cli/currency.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // cli/currency.ts — display-currency helpers (USD-base, offline snapshot rates).
2
- // Same 10 currencies, symbols, and snapshot rates as the gain dashboard's
3
- // converter (dashboard/app.js); the dashboard replaces rates with live
2
+ // Same 10 currencies, symbols, and snapshot rates as the dashboard's
3
+ // converter (dashboard/app); the dashboard replaces rates with live
4
4
  // frankfurter figures when reachable, the CLI always uses the snapshot.
5
5
  import { existsSync, readFileSync } from 'node:fs';
6
6
  import os from 'node:os';
package/cli/dashboard.js CHANGED
@@ -1,35 +1,269 @@
1
- // cli/dashboard.ts — gain dashboard. Serves the single plain-HTML
2
- // template (no deps, no build) plus a /data.json endpoint from the ledger.
1
+ // cli/dashboard.ts — serves the built shadcn Dashboard with local usage APIs.
3
2
  // Binds 127.0.0.1 only; --export writes a file://-ready file instead.
4
3
  // Note: no Promise.withResolvers here — engines still allow Node 20.12,
5
4
  // which lacks it; the listening server itself keeps the process alive.
6
5
  import { spawn } from 'node:child_process';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
7
8
  import { promises as fs } from 'node:fs';
8
9
  import http from 'node:http';
9
10
  import path from 'node:path';
10
11
  import { fileURLToPath } from 'node:url';
11
12
  import { clearUsageLedger, markReset, readUsage } from '../extensions/shared/usage-ledger.js';
13
+ import { pricesCachePath } from '../extensions/shared/pricing.js';
12
14
  import { summarizeUsage } from './usage.js';
13
15
  import { isCurrencyCode } from './currency.js';
16
+ import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_VERSION, RTK_BINARY_NAME, } from './common.js';
14
17
  import { storedProfile, writePluginSettings } from './profile.js';
15
- import { withInteractiveSpinner } from './interactive.js';
16
- const DASH_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dashboard');
17
- const TEMPLATE = path.join(DASH_DIR, 'template.html');
18
- const STYLES = path.join(DASH_DIR, 'styles.css');
19
- const APP = path.join(DASH_DIR, 'app.js');
20
- const BRAND = path.join(DASH_DIR, 'brand.webp');
18
+ const DASHBOARD_DIST = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dashboard', 'dist');
19
+ const DASHBOARD_INDEX = path.join(DASHBOARD_DIST, 'index.html');
20
+ const DASHBOARD_BRAND = path.join(DASHBOARD_DIST, 'brand.webp');
21
+ function requireDashboardBundle() {
22
+ if (existsSync(DASHBOARD_INDEX))
23
+ return;
24
+ throw new Error('Dashboard bundle missing. Run `bun run build` before using `tersio dashboard`.');
25
+ }
26
+ function contentType(file) {
27
+ if (file.endsWith('.html'))
28
+ return 'text/html; charset=utf-8';
29
+ if (file.endsWith('.js'))
30
+ return 'text/javascript; charset=utf-8';
31
+ if (file.endsWith('.css'))
32
+ return 'text/css; charset=utf-8';
33
+ if (file.endsWith('.webp'))
34
+ return 'image/webp';
35
+ if (file.endsWith('.svg'))
36
+ return 'image/svg+xml';
37
+ if (file.endsWith('.png'))
38
+ return 'image/png';
39
+ if (file.endsWith('.woff2'))
40
+ return 'font/woff2';
41
+ return 'application/octet-stream';
42
+ }
21
43
  async function readSegment(file) {
22
44
  return fs.readFile(file, 'utf8');
23
45
  }
24
- async function faviconDataUri() {
25
- const png = await fs.readFile(BRAND);
26
- return `data:image/webp;base64,${png.toString('base64')}`;
46
+ async function brandDataUri() {
47
+ const brand = await fs.readFile(DASHBOARD_BRAND);
48
+ return `data:image/webp;base64,${brand.toString('base64')}`;
27
49
  }
28
50
  function dataJson() {
29
51
  return JSON.stringify(summarizeUsage(readUsage()));
30
52
  }
53
+ // Local-only health + diagnosis for the settings modal. No network: version
54
+ // probes run with short timeouts, file checks are existsSync.
55
+ function ompPath() {
56
+ const names = process.platform === 'win32' ? ['omp.cmd', 'omp.exe', 'omp.bat', 'omp'] : ['omp'];
57
+ for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
58
+ for (const name of names) {
59
+ const candidate = path.join(dir, name);
60
+ try {
61
+ if (statSync(candidate).isFile())
62
+ return candidate;
63
+ }
64
+ catch { /* continue searching PATH */ }
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+ function ompVersion(bin = ompPath()) {
70
+ if (!bin)
71
+ return null;
72
+ try {
73
+ if (process.platform === 'win32') {
74
+ const exe = process.env.ComSpec || 'cmd.exe';
75
+ return execFileSync(exe, ['/d', '/s', '/c', bin, '--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim() || null;
76
+ }
77
+ return execFileSync(bin, ['--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim() || null;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ function rtkVersion(bin) {
84
+ try {
85
+ return execFileSync(bin, ['--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim().split(/\r?\n/)[0] || null;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ function ompDefaultModel() {
92
+ try {
93
+ const text = readFileSync(path.join(OMP_AGENT_DIR, 'config.yml'), 'utf8');
94
+ const m = text.match(/^\s*default\s*:\s*(.+?)\s*$/m);
95
+ return m ? m[1].replace(/^['"]|['"]$/g, '') : null;
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ function healthJson() {
102
+ const rtkBin = path.join(BUN_BIN_DIR, RTK_BINARY_NAME);
103
+ const rtkPresent = existsSync(rtkBin);
104
+ const detectedOmpPath = ompPath();
105
+ return JSON.stringify({
106
+ tersio: PACKAGE_VERSION,
107
+ node: process.version,
108
+ platform: `${process.platform}/${process.arch}`,
109
+ omp: ompVersion(detectedOmpPath),
110
+ ompPath: detectedOmpPath,
111
+ provider: ompDefaultModel(),
112
+ rtk: { present: rtkPresent, version: rtkPresent ? rtkVersion(rtkBin) : null, path: rtkBin },
113
+ home: tersioHomePath(),
114
+ });
115
+ }
116
+ function tersioHomePath() {
117
+ return path.join(process.env.HOME || process.env.USERPROFILE || '', '.tersio');
118
+ }
119
+ const DIAG_TTL_MS = {
120
+ manual: 0,
121
+ daily: 24 * 3600 * 1000,
122
+ weekly: 7 * 24 * 3600 * 1000,
123
+ monthly: 30 * 24 * 3600 * 1000,
124
+ };
125
+ function diagPaths() {
126
+ // Hermetic env (tests) overrides the DB path: keep the report next to it
127
+ // so test runs never touch the real ~/.tersio/diag.json.
128
+ const dbOverride = process.env.TERSIO_USAGE_DB;
129
+ if (dbOverride)
130
+ return { report: path.join(path.dirname(dbOverride), 'diag.json') };
131
+ const home = process.env.HOME || process.env.USERPROFILE || '';
132
+ return { report: path.join(home, '.tersio', 'diag.json') };
133
+ }
134
+ function readDiagReport() {
135
+ try {
136
+ const raw = JSON.parse(readFileSync(diagPaths().report, 'utf8'));
137
+ if (!Array.isArray(raw.rows) || typeof raw.checkedAt !== 'number')
138
+ return null;
139
+ const schedule = raw.schedule === 'daily' || raw.schedule === 'weekly' || raw.schedule === 'monthly' ? raw.schedule : 'manual';
140
+ return { rows: raw.rows, checkedAt: raw.checkedAt, schedule };
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ }
146
+ function writeDiagReport(report) {
147
+ try {
148
+ mkdirSync(path.dirname(diagPaths().report), { recursive: true });
149
+ writeFileSync(diagPaths().report, JSON.stringify(report) + '\n', 'utf8');
150
+ }
151
+ catch { /* best-effort */ }
152
+ }
153
+ function ageStr(p) {
154
+ try {
155
+ return relAge(Date.now() - statSync(p).mtimeMs);
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ }
161
+ function absTime(p) {
162
+ try {
163
+ const d = new Date(statSync(p).mtimeMs);
164
+ const q = (n) => String(n).padStart(2, '0');
165
+ const h24 = d.getHours();
166
+ return `${q(d.getMonth() + 1)}-${q(d.getDate())}-${d.getFullYear()}, ${q(h24 % 12 || 12)}:${q(d.getMinutes())} ${h24 >= 12 ? 'PM' : 'AM'}`;
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ function relAge(ms) {
173
+ const mins = Math.floor(ms / 60000);
174
+ if (mins < 1)
175
+ return 'just now';
176
+ if (mins < 60)
177
+ return `${mins} min${mins === 1 ? '' : 's'} ago`;
178
+ const hours = Math.floor(mins / 60);
179
+ if (hours < 24)
180
+ return `${hours} hour${hours === 1 ? '' : 's'} ago`;
181
+ const days = Math.floor(hours / 24);
182
+ return `${days} day${days === 1 ? '' : 's'} ago`;
183
+ }
184
+ function computeDoctorRows() {
185
+ const rows = [];
186
+ const extDir = path.join(OMP_AGENT_DIR, 'extensions');
187
+ const rtkBin = path.join(BUN_BIN_DIR, RTK_BINARY_NAME);
188
+ const ponytailPkg = path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
189
+ const ok = (p) => existsSync(p);
190
+ const ext = (label, p) => {
191
+ rows.push({ label, ok: ok(p), detail: ok(p) ? 'installed' : p, group: 'Extensions' });
192
+ };
193
+ const addon = (label, present, detail, group = 'Add-ons') => {
194
+ rows.push({ label, ok: present, detail, group });
195
+ };
196
+ ext('Caveman extension', path.join(extDir, 'caveman-session', 'index.ts'));
197
+ ext('RTK extension', path.join(extDir, 'rtk-session', 'index.ts'));
198
+ ext('Ponytail extension', path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js'));
199
+ const rule = path.join(extDir, 'caveman-session', 'rule.md');
200
+ const ruleAge = ageStr(rule);
201
+ const ruleAt = absTime(rule);
202
+ addon('Caveman rule', ruleAge !== null, ruleAge && ruleAt ? `(updated ${ruleAge} · ${ruleAt})` : rule);
203
+ const rtkVer = ok(rtkBin) ? rtkVersion(rtkBin) : null;
204
+ const rtkAge = ageStr(rtkBin);
205
+ const rtkAt = absTime(rtkBin);
206
+ addon('RTK binary', rtkVer !== null, rtkVer && rtkAge && rtkAt ? `${rtkVer} (updated ${rtkAge} · ${rtkAt})` : rtkBin);
207
+ const wiring = path.join(extDir, 'rtk.ts');
208
+ addon('RTK OMP wiring (rtk.ts)', ok(wiring), ok(wiring) ? 'loaded' : wiring);
209
+ let ponytailVer = null;
210
+ try {
211
+ ponytailVer = JSON.parse(readFileSync(ponytailPkg, 'utf8')).version ?? null;
212
+ }
213
+ catch {
214
+ ponytailVer = null;
215
+ }
216
+ const ponytailAge = ageStr(ponytailPkg);
217
+ const ponytailAt = absTime(ponytailPkg);
218
+ addon('Ponytail', ponytailVer !== null, ponytailVer && ponytailAge && ponytailAt ? `${ponytailVer} (updated ${ponytailAge} · ${ponytailAt})` : ponytailPkg);
219
+ const prices = pricesCachePath();
220
+ const pricesAge = ageStr(prices);
221
+ const pricesAt = absTime(prices);
222
+ addon('Prices feed', pricesAge !== null, pricesAge && pricesAt ? `(pulled ${pricesAge} · ${pricesAt})` : prices, 'Usage');
223
+ return rows;
224
+ }
225
+ // Retained diagnosis: recompute when forced, missing, or older than the
226
+ // schedule. Otherwise return the saved report so users never re-run.
227
+ // Labels retired from the report. A saved report containing any of them
228
+ // is stale by definition and always recomputes.
229
+ const RETIRED_DIAG_LABELS = new Set([
230
+ 'OMP CLI',
231
+ 'Tersio CLI',
232
+ 'Tersio commands',
233
+ 'Mode reinforcement',
234
+ 'Combo registered',
235
+ 'Combo extension',
236
+ 'Self plugin',
237
+ 'Ponytail registered',
238
+ 'Usage store',
239
+ ]);
240
+ function isRetiredReport(report) {
241
+ return report.rows.some((r) => RETIRED_DIAG_LABELS.has(r.label));
242
+ }
243
+ // Retained diagnosis: recompute when forced, missing, retired, or older
244
+ // than the schedule. Otherwise return the saved report.
245
+ function getDoctorReport(force) {
246
+ const saved = readDiagReport();
247
+ const ttl = saved ? DIAG_TTL_MS[saved.schedule] : 0;
248
+ // Manual always recomputes on open; dated schedules reuse the saved
249
+ // report until stale. Otherwise a retained snapshot hides new rows.
250
+ if (!force && saved && !isRetiredReport(saved) && ttl > 0 && Date.now() - saved.checkedAt < ttl)
251
+ return saved;
252
+ const report = { rows: computeDoctorRows(), checkedAt: Date.now(), schedule: saved?.schedule ?? 'manual' };
253
+ writeDiagReport(report);
254
+ return report;
255
+ }
256
+ function setDiagSchedule(schedule) {
257
+ const base = readDiagReport() ?? { rows: computeDoctorRows(), checkedAt: Date.now(), schedule };
258
+ base.schedule = schedule;
259
+ writeDiagReport(base);
260
+ return base;
261
+ }
262
+ function readDiagSchedule() {
263
+ return readDiagReport()?.schedule ?? 'manual';
264
+ }
31
265
  // Persist the dashboard's picker choice so close → reopen keeps it: each
32
- // `tersio gain` run serves a fresh ephemeral port (a new origin), so the
266
+ // `tersio dashboard` run serves a fresh ephemeral port (a new origin), so the
33
267
  // browser's localStorage alone cannot survive a restart. The stored default
34
268
  // feeds data.json and `tersio usage` on the next run.
35
269
  async function saveDashboardCurrency(raw) {
@@ -55,33 +289,33 @@ function openBrowser(url) {
55
289
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
56
290
  spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
57
291
  }
292
+ // Replacer functions throughout: session data routinely contains `$'`
293
+ // sequences (shell quoting in tool details), which String.replace would
294
+ // expand as match-suffix patterns and corrupt the file.
295
+ // RTK-metered command lines routinely contain literal `</script>` (Vue
296
+ // SFC probes), which would close the inlined <script> early and dump the
297
+ // rest of the JSON as page text. Escape it; JSON.parse never sees the
298
+ // backslash form inside a string literal, the browser decodes it first.
299
+ function escapeInline(json) {
300
+ return json.replace(/<\/(script)/gi, '<\\/$1');
301
+ }
302
+ async function exportDashboard(exportFile) {
303
+ requireDashboardBundle();
304
+ const [bundle, icon] = await Promise.all([readSegment(DASHBOARD_INDEX), brandDataUri()]);
305
+ const inline = bundle
306
+ .replace('window.__TERSIO_SNAP = null;', () => `window.__TERSIO_SNAP = ${escapeInline(JSON.stringify({ data: JSON.parse(dataJson()), health: JSON.parse(healthJson()), doctor: getDoctorReport(false) }))};`)
307
+ .replace(/href="brand\.webp"/g, () => `href="${icon}"`)
308
+ .replace(/src="brand\.webp"/g, () => `src="${icon}"`)
309
+ .replace('fetch("brand.webp")', () => `Promise.resolve({ ok: true, blob: async () => new Blob([atob("${icon.split(',')[1]}")], { type: "image/webp" }) })`);
310
+ await fs.writeFile(exportFile, inline, 'utf8');
311
+ console.log(`[ok] Dashboard exported → ${exportFile}`);
312
+ }
58
313
  async function runDashboard(options) {
59
314
  if (options.exportFile) {
60
- const [template, css, js, icon] = await Promise.all([
61
- readSegment(TEMPLATE), readSegment(STYLES), readSegment(APP), faviconDataUri(),
62
- ]);
63
- // Replacer functions throughout: session data routinely contains `$'`
64
- // sequences (shell quoting in tool details), which String.replace would
65
- // expand as match-suffix patterns and corrupt the file.
66
- // RTK-metered command lines routinely contain literal `</script>` (Vue
67
- // SFC probes), which would close the inlined <script> early and dump the
68
- // rest of the JSON as page text. Escape it; JSON.parse never sees the
69
- // backslash form inside a string literal, the browser decodes it first.
70
- const inline = template
71
- .replace('<link rel="stylesheet" href="styles.css">', () => `<style>\n${css}</style>`)
72
- .replace('<script src="app.js" defer></script>', () => `<script>\n${js}</script>`)
73
- .replace("fetch('data.json')", () => `Promise.resolve({ json: function () { return ${dataJson().replace(/<\/(script)/gi, '<\\/$1')}; } })`)
74
- // Exported file runs on file:// where load() early-returns before the
75
- // stub above ever runs, so nothing ever renders. Drop that guard in
76
- // the export only; template.html keeps it to avoid 404 polling loops.
77
- .replace("if (window.location.protocol === 'file:') return;", () => `if (false) return;`)
78
- .replace('href="brand.webp"', () => `href="${icon}"`)
79
- .replace('src="brand.webp"', () => `src="${icon}"`);
80
- await fs.writeFile(options.exportFile, inline, 'utf8');
81
- console.log(`[ok] gain exported → ${options.exportFile}`);
315
+ await exportDashboard(options.exportFile);
82
316
  return;
83
317
  }
84
- const html = await withInteractiveSpinner('Loading dashboard template', () => readSegment(TEMPLATE));
318
+ requireDashboardBundle();
85
319
  const server = http.createServer(async (req, res) => {
86
320
  if (req.url === '/reset' && req.method === 'POST') {
87
321
  const rows = clearUsageLedger();
@@ -114,42 +348,69 @@ async function runDashboard(options) {
114
348
  res.end(dataJson());
115
349
  return;
116
350
  }
117
- if (req.url === '/styles.css') {
118
- res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8' });
119
- res.end(await readSegment(STYLES));
351
+ if (req.url === '/health') {
352
+ res.writeHead(200, { 'Content-Type': 'application/json' });
353
+ res.end(healthJson());
120
354
  return;
121
355
  }
122
- if (req.url === '/app.js') {
123
- res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8' });
124
- res.end(await readSegment(APP));
356
+ if (req.url === '/doctor' && req.method === 'POST') {
357
+ let schedule = 'manual';
358
+ try {
359
+ const raw = JSON.parse(await readBody(req)).schedule;
360
+ if (raw === 'daily' || raw === 'weekly' || raw === 'monthly' || raw === 'manual')
361
+ schedule = raw;
362
+ }
363
+ catch { /* keep manual */ }
364
+ const report = schedule === 'manual' ? getDoctorReport(true) : setDiagSchedule(schedule);
365
+ res.writeHead(200, { 'Content-Type': 'application/json' });
366
+ res.end(JSON.stringify(report));
125
367
  return;
126
368
  }
127
- if (req.url === '/brand.webp') {
369
+ if (req.url === '/doctor/fix' && req.method === 'POST') {
128
370
  try {
129
- const png = await fs.readFile(BRAND);
130
- res.writeHead(200, { 'Content-Type': 'image/webp' });
131
- res.end(png);
371
+ const { runDoctorRepairs } = await import('./doctor-fix.js');
372
+ const failed = await runDoctorRepairs(['extensions', 'registrations', 'ponytail']);
373
+ getDoctorReport(true);
374
+ res.writeHead(200, { 'Content-Type': 'application/json' });
375
+ res.end(JSON.stringify({ ok: failed.length === 0, failed }));
132
376
  }
133
- catch {
134
- res.writeHead(404);
135
- res.end();
377
+ catch (e) {
378
+ res.writeHead(200, { 'Content-Type': 'application/json' });
379
+ res.end(JSON.stringify({ ok: false, failed: ['fix'], error: e.message }));
136
380
  }
137
381
  return;
138
382
  }
383
+ if (req.url === '/doctor' || req.url?.startsWith('/doctor?')) {
384
+ const fresh = (req.url.split('?')[1] ?? '').includes('fresh=1');
385
+ res.writeHead(200, { 'Content-Type': 'application/json' });
386
+ res.end(JSON.stringify(getDoctorReport(fresh)));
387
+ return;
388
+ }
389
+ const name = (req.url ?? '/').split('?')[0];
390
+ const file = path.normalize(path.join(DASHBOARD_DIST, name === '/' ? 'index.html' : name.slice(1)));
391
+ if (file === DASHBOARD_DIST || file.startsWith(DASHBOARD_DIST + path.sep)) {
392
+ try {
393
+ const body = await fs.readFile(file);
394
+ res.writeHead(200, { 'Content-Type': contentType(file) });
395
+ res.end(body);
396
+ return;
397
+ }
398
+ catch { /* fall through to index */ }
399
+ }
139
400
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
140
- res.end(html);
401
+ res.end(await readSegment(DASHBOARD_INDEX));
141
402
  });
142
403
  server.listen(options.port, '127.0.0.1', () => {
143
404
  try {
144
- process.title = 'tersio gain';
405
+ process.title = 'tersio dashboard';
145
406
  }
146
407
  catch { /* non-POSIX shells keep node */ }
147
408
  const address = server.address();
148
409
  const port = typeof address === 'object' && address ? address.port : options.port;
149
410
  const url = `http://127.0.0.1:${port}`;
150
- console.log(`[ok] gain live → ${url} (Ctrl-C to stop)`);
411
+ console.log(`[ok] Dashboard live → ${url} (Ctrl-C to stop)`);
151
412
  if (options.open)
152
413
  openBrowser(url);
153
414
  });
154
415
  }
155
- export { runDashboard, saveDashboardCurrency };
416
+ export { runDashboard, saveDashboardCurrency, readDiagSchedule, setDiagSchedule };
package/cli/doctor-fix.js CHANGED
@@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, dryRun, verbose, debug, ensureExtensionAfterConfigEntry, ensureExtensionInConfig, execP, readPluginsPackage, writeIfChanged, } from './common.js';
5
+ import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, dryRun, verbose, debug, ensureExtensionAfterConfigEntry, ensureExtensionInConfig, execP, readPluginsPackage, writeIfChanged, } from './common.js';
6
6
  import { execNetwork } from './interactive.js';
7
7
  import { wireRtkOmp, ensureRtkInConfig } from './rtk-wiring.js';
8
8
  import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
@@ -23,8 +23,6 @@ const SOURCES = [
23
23
  [path.join(EXT_DIR, 'tersio-commands', 'index.ts'), path.join('tersio-commands', 'index.ts')],
24
24
  [path.join(EXT_DIR, 'shared', 'mode-reinforcement.ts'), path.join('shared', 'mode-reinforcement.ts')],
25
25
  ];
26
- const PONYTAIL_GITHUB_SPEC = 'github:DietrichGebert/ponytail';
27
- const PONYTAIL_NPM_SPEC = '@dietrichgebert/ponytail@latest';
28
26
  async function fixExtensions(extDir) {
29
27
  console.log(' Doctor --fix: restoring extension files');
30
28
  await fs.mkdir(extDir, { recursive: true });
@@ -130,22 +128,24 @@ async function fixRtk(binDir) {
130
128
  throw new Error('rtk wiring failed');
131
129
  }
132
130
  async function fixPonytail(pluginsDir, agentDir) {
133
- console.log(' Doctor --fix: refreshing Ponytail package');
131
+ console.log(' Doctor --fix: restoring bundled Ponytail');
134
132
  await fs.mkdir(pluginsDir, { recursive: true });
135
133
  const pkgPath = path.join(pluginsDir, 'package.json');
136
134
  const pkg = await readPluginsPackage(pkgPath);
137
- pkg.dependencies['@dietrichgebert/ponytail'] = PONYTAIL_GITHUB_SPEC;
135
+ // Drop the legacy separate entry: ponytail is a tersio dependency now.
136
+ if ('@dietrichgebert/ponytail' in pkg.dependencies) {
137
+ delete pkg.dependencies['@dietrichgebert/ponytail'];
138
+ console.log(' [migrate] dropped separate @dietrichgebert/ponytail dependency (now bundled with tersio)');
139
+ }
140
+ if (!(PACKAGE_NAME in pkg.dependencies))
141
+ pkg.dependencies[PACKAGE_NAME] = `^${PACKAGE_VERSION}`;
138
142
  if (dryRun) {
139
- console.log(` [dry-run] would run: omp plugin install ${PONYTAIL_GITHUB_SPEC}`);
140
- console.log(` [dry-run] would run: npm install ${PONYTAIL_NPM_SPEC} --save --no-audit --no-fund`);
143
+ console.log(' [dry-run] would run: npm install --no-audit --no-fund (in plugins dir)');
141
144
  return;
142
145
  }
143
146
  await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
144
- await execNetwork('Installing Ponytail plugin', OMP_BIN, ['plugin', 'install', PONYTAIL_GITHUB_SPEC], { cwd: pluginsDir }).catch((e) => {
145
- console.log(` [warn] omp plugin install failed: ${e.message}`);
146
- });
147
- await execNetwork('Refreshing Ponytail package', 'npm', ['install', PONYTAIL_NPM_SPEC, '--save', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 120000 }).catch((e) => {
148
- throw new Error(`ponytail refresh failed: ${e.message}`);
147
+ await execNetwork('Restoring bundled Ponytail', 'npm', ['install', '--no-audit', '--no-fund'], { cwd: pluginsDir, timeout: 180000 }).catch((e) => {
148
+ throw new Error(`bundled ponytail restore failed: ${e.message}`);
149
149
  });
150
150
  await ensureExtensionInConfig(path.join(agentDir, 'config.yml'), path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js'), 'ponytail', { dryRun, verbose });
151
151
  }