@krtclcdy/tersio 2.20.1 → 2.22.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 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:
@@ -82,7 +81,7 @@ Break-even math, balanced preset (caveman full + rtk + ponytail): a mixed turn c
82
81
 
83
82
  ## 📈 Gain 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 gain --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
 
@@ -103,7 +102,7 @@ Break-even math, balanced preset (caveman full + rtk + ponytail): a mixed turn c
103
102
  | `tersio install` | Install (user scope: all OMP sessions) |
104
103
  | `tersio update` | Refresh the CLI, extensions, and add-ons (RTK binary, Caveman rule, Ponytail) |
105
104
  | `tersio reinstall` | Fresh install, preserving the Ponytail package |
106
- | `tersio doctor` | Check OMP, extension, Ponytail, and RTK health |
105
+ | `tersio doctor` | Check OMP, extension, Ponytail, and RTK health (`--fix` repairs, `--fix=<scope>` one scope, `--dry-run` previews) |
107
106
  | `tersio usage` | Ledger-backed usage + savings report |
108
107
  | `tersio gain` | Open the gain dashboard (`--open`, `--export <file>`, `--port <n>`, `--currency <code>`; serves localhost only) |
109
108
  | `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 |
@@ -125,8 +124,8 @@ Mode switches live on their own commands; bare `/tersio` prints status.
125
124
  | `/tersio status` | Active modes + combo level; state persists, propagates to subagents, and lights the Combo footer indicator. |
126
125
  | `/tersio check` | Add-on version check |
127
126
  | `/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) |
127
+ | `/tersio gain` | Savings summary; the gain dashboard charts `~/.tersio/usage.db` (`tersio gain --open` serves on 127.0.0.1, `--export` writes one file). |
128
+ | `/tersio usage` | Ledger report for this machine (rows in `~/.tersio/usage.db`, local only) |
130
129
  | `/tersio help` | This table |
131
130
  | `/ai-addons` | Add-on updater alias, still works. |
132
131
 
@@ -136,7 +135,7 @@ Mode switches live on their own commands; bare `/tersio` prints status.
136
135
  |---|---|
137
136
  | Caveman / RTK / Updater / Combo extensions | `~/.omp/agent/extensions/{caveman-session,rtk-session,ai-addons-updater,combo-toggle}/` |
138
137
  | 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/` |
138
+ | Ponytail package (bundled tersio dependency — one Plugins row, updates with `tersio update`) | `~/.omp/plugins/node_modules/@dietrichgebert/ponytail/` |
140
139
  | RTK binary | `~/.bun/bin/rtk` (`rtk.exe` on Windows) |
141
140
  | Extension registrations | `~/.omp/agent/config.yml` |
142
141
 
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 = {
@@ -51,6 +52,16 @@ const COMMANDS = { install: true, update: true, reinstall: true, doctor: true, u
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';
@@ -62,6 +73,7 @@ const dryRun = args.includes('--dry-run');
62
73
  const yes = args.includes('--yes') || args.includes('-y') || install || update || reinstall || applyUpdate;
63
74
  const verbose = args.includes('--verbose');
64
75
  const doctor = command === 'doctor' || args.includes('--doctor');
76
+ const fix = args.includes('--fix') || args.some((a) => a.startsWith('--fix='));
65
77
  const usage = command === 'usage' || args.includes('--usage');
66
78
  const gain = command === 'gain';
67
79
  const reset = command === 'reset';
@@ -89,6 +101,7 @@ const comboDefaultFlag = parseEnum(flagValue('--combo-default'), COMBO_DEFAULTS,
89
101
  const cavemanDefaultFlag = parseEnum(flagValue('--caveman-default'), CAVEMAN_DEFAULTS, '--caveman-default');
90
102
  const ponytailDefaultFlag = parseEnum(flagValue('--ponytail-default'), PONYTAIL_DEFAULTS, '--ponytail-default');
91
103
  const rtkDefaultFlag = parseEnum(flagValue('--rtk-default'), RTK_DEFAULTS, '--rtk-default');
104
+ const diagScheduleFlag = parseEnum(flagValue('--diag-schedule'), DIAG_SCHEDULES, '--diag-schedule');
92
105
  const profileFlagsGiven = [comboDefaultFlag, cavemanDefaultFlag, rtkDefaultFlag, ponytailDefaultFlag]
93
106
  .some((flag) => flag !== undefined);
94
107
  function debug(...a) {
@@ -285,4 +298,4 @@ function relTime(ageMs) {
285
298
  const weeks = Math.floor(days / 7);
286
299
  return `${weeks} week${weeks === 1 ? '' : 's'} ago`;
287
300
  }
288
- 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, dryRun, yes, verbose, doctor, 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, gain, 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/dashboard.js CHANGED
@@ -4,20 +4,52 @@
4
4
  // Note: no Promise.withResolvers here — engines still allow Node 20.12,
5
5
  // which lacks it; the listening server itself keeps the process alive.
6
6
  import { spawn } from 'node:child_process';
7
+ import { execFileSync } from 'node:child_process';
8
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
7
9
  import { promises as fs } from 'node:fs';
8
10
  import http from 'node:http';
9
11
  import path from 'node:path';
10
12
  import { fileURLToPath } from 'node:url';
11
13
  import { clearUsageLedger, markReset, readUsage } from '../extensions/shared/usage-ledger.js';
14
+ import { pricesCachePath } from '../extensions/shared/pricing.js';
12
15
  import { summarizeUsage } from './usage.js';
13
16
  import { isCurrencyCode } from './currency.js';
17
+ import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_VERSION, RTK_BINARY_NAME, } from './common.js';
14
18
  import { storedProfile, writePluginSettings } from './profile.js';
15
19
  import { withInteractiveSpinner } from './interactive.js';
16
20
  const DASH_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dashboard');
17
21
  const TEMPLATE = path.join(DASH_DIR, 'template.html');
18
22
  const STYLES = path.join(DASH_DIR, 'styles.css');
19
- const APP = path.join(DASH_DIR, 'app.js');
23
+ const CORE_JS = path.join(DASH_DIR, 'core.js');
24
+ const CHARTS_JS = path.join(DASH_DIR, 'charts.js');
25
+ const SETTINGS_JS = path.join(DASH_DIR, 'settings.js');
26
+ const SHARE_JS = path.join(DASH_DIR, 'share.js');
20
27
  const BRAND = path.join(DASH_DIR, 'brand.webp');
28
+ // shadcn gain app (gain/). When its production bundle exists the server
29
+ // binds to it; otherwise it falls back to the legacy dashboard/ segments.
30
+ const GAIN_DIST = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'gain', 'dist');
31
+ const GAIN_INDEX = path.join(GAIN_DIST, 'index.html');
32
+ const GAIN_BRAND = path.join(GAIN_DIST, 'brand.webp');
33
+ function useGainBundle() {
34
+ return existsSync(GAIN_INDEX);
35
+ }
36
+ function contentType(file) {
37
+ if (file.endsWith('.html'))
38
+ return 'text/html; charset=utf-8';
39
+ if (file.endsWith('.js'))
40
+ return 'text/javascript; charset=utf-8';
41
+ if (file.endsWith('.css'))
42
+ return 'text/css; charset=utf-8';
43
+ if (file.endsWith('.webp'))
44
+ return 'image/webp';
45
+ if (file.endsWith('.svg'))
46
+ return 'image/svg+xml';
47
+ if (file.endsWith('.png'))
48
+ return 'image/png';
49
+ if (file.endsWith('.woff2'))
50
+ return 'font/woff2';
51
+ return 'application/octet-stream';
52
+ }
21
53
  async function readSegment(file) {
22
54
  return fs.readFile(file, 'utf8');
23
55
  }
@@ -28,6 +60,218 @@ async function faviconDataUri() {
28
60
  function dataJson() {
29
61
  return JSON.stringify(summarizeUsage(readUsage()));
30
62
  }
63
+ // Local-only health + diagnosis for the settings modal. No network: version
64
+ // probes run with short timeouts, file checks are existsSync.
65
+ function ompPath() {
66
+ const names = process.platform === 'win32' ? ['omp.cmd', 'omp.exe', 'omp.bat', 'omp'] : ['omp'];
67
+ for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
68
+ for (const name of names) {
69
+ const candidate = path.join(dir, name);
70
+ try {
71
+ if (statSync(candidate).isFile())
72
+ return candidate;
73
+ }
74
+ catch { /* continue searching PATH */ }
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+ function ompVersion(bin = ompPath()) {
80
+ if (!bin)
81
+ return null;
82
+ try {
83
+ if (process.platform === 'win32') {
84
+ const exe = process.env.ComSpec || 'cmd.exe';
85
+ return execFileSync(exe, ['/d', '/s', '/c', bin, '--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim() || null;
86
+ }
87
+ return execFileSync(bin, ['--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim() || null;
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
93
+ function rtkVersion(bin) {
94
+ try {
95
+ return execFileSync(bin, ['--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim().split(/\r?\n/)[0] || null;
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ function ompDefaultModel() {
102
+ try {
103
+ const text = readFileSync(path.join(OMP_AGENT_DIR, 'config.yml'), 'utf8');
104
+ const m = text.match(/^\s*default\s*:\s*(.+?)\s*$/m);
105
+ return m ? m[1].replace(/^['"]|['"]$/g, '') : null;
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ function healthJson() {
112
+ const rtkBin = path.join(BUN_BIN_DIR, RTK_BINARY_NAME);
113
+ const rtkPresent = existsSync(rtkBin);
114
+ const detectedOmpPath = ompPath();
115
+ return JSON.stringify({
116
+ tersio: PACKAGE_VERSION,
117
+ node: process.version,
118
+ platform: `${process.platform}/${process.arch}`,
119
+ omp: ompVersion(detectedOmpPath),
120
+ ompPath: detectedOmpPath,
121
+ provider: ompDefaultModel(),
122
+ rtk: { present: rtkPresent, version: rtkPresent ? rtkVersion(rtkBin) : null, path: rtkBin },
123
+ home: tersioHomePath(),
124
+ });
125
+ }
126
+ function tersioHomePath() {
127
+ return path.join(process.env.HOME || process.env.USERPROFILE || '', '.tersio');
128
+ }
129
+ const DIAG_TTL_MS = {
130
+ manual: 0,
131
+ daily: 24 * 3600 * 1000,
132
+ weekly: 7 * 24 * 3600 * 1000,
133
+ monthly: 30 * 24 * 3600 * 1000,
134
+ };
135
+ function diagPaths() {
136
+ // Hermetic env (tests) overrides the DB path: keep the report next to it
137
+ // so test runs never touch the real ~/.tersio/diag.json.
138
+ const dbOverride = process.env.TERSIO_USAGE_DB;
139
+ if (dbOverride)
140
+ return { report: path.join(path.dirname(dbOverride), 'diag.json') };
141
+ const home = process.env.HOME || process.env.USERPROFILE || '';
142
+ return { report: path.join(home, '.tersio', 'diag.json') };
143
+ }
144
+ function readDiagReport() {
145
+ try {
146
+ const raw = JSON.parse(readFileSync(diagPaths().report, 'utf8'));
147
+ if (!Array.isArray(raw.rows) || typeof raw.checkedAt !== 'number')
148
+ return null;
149
+ const schedule = raw.schedule === 'daily' || raw.schedule === 'weekly' || raw.schedule === 'monthly' ? raw.schedule : 'manual';
150
+ return { rows: raw.rows, checkedAt: raw.checkedAt, schedule };
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ function writeDiagReport(report) {
157
+ try {
158
+ mkdirSync(path.dirname(diagPaths().report), { recursive: true });
159
+ writeFileSync(diagPaths().report, JSON.stringify(report) + '\n', 'utf8');
160
+ }
161
+ catch { /* best-effort */ }
162
+ }
163
+ function ageStr(p) {
164
+ try {
165
+ return relAge(Date.now() - statSync(p).mtimeMs);
166
+ }
167
+ catch {
168
+ return null;
169
+ }
170
+ }
171
+ function absTime(p) {
172
+ try {
173
+ const d = new Date(statSync(p).mtimeMs);
174
+ const q = (n) => String(n).padStart(2, '0');
175
+ const h24 = d.getHours();
176
+ return `${q(d.getMonth() + 1)}-${q(d.getDate())}-${d.getFullYear()}, ${q(h24 % 12 || 12)}:${q(d.getMinutes())} ${h24 >= 12 ? 'PM' : 'AM'}`;
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
182
+ function relAge(ms) {
183
+ const mins = Math.floor(ms / 60000);
184
+ if (mins < 1)
185
+ return 'just now';
186
+ if (mins < 60)
187
+ return `${mins} min${mins === 1 ? '' : 's'} ago`;
188
+ const hours = Math.floor(mins / 60);
189
+ if (hours < 24)
190
+ return `${hours} hour${hours === 1 ? '' : 's'} ago`;
191
+ const days = Math.floor(hours / 24);
192
+ return `${days} day${days === 1 ? '' : 's'} ago`;
193
+ }
194
+ function computeDoctorRows() {
195
+ const rows = [];
196
+ const extDir = path.join(OMP_AGENT_DIR, 'extensions');
197
+ const rtkBin = path.join(BUN_BIN_DIR, RTK_BINARY_NAME);
198
+ const ponytailPkg = path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
199
+ const ok = (p) => existsSync(p);
200
+ const ext = (label, p) => {
201
+ rows.push({ label, ok: ok(p), detail: ok(p) ? 'installed' : p, group: 'Extensions' });
202
+ };
203
+ const addon = (label, present, detail, group = 'Add-ons') => {
204
+ rows.push({ label, ok: present, detail, group });
205
+ };
206
+ ext('Caveman extension', path.join(extDir, 'caveman-session', 'index.ts'));
207
+ ext('RTK extension', path.join(extDir, 'rtk-session', 'index.ts'));
208
+ ext('Ponytail extension', path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js'));
209
+ const rule = path.join(extDir, 'caveman-session', 'rule.md');
210
+ const ruleAge = ageStr(rule);
211
+ const ruleAt = absTime(rule);
212
+ addon('Caveman rule', ruleAge !== null, ruleAge && ruleAt ? `(updated ${ruleAge} · ${ruleAt})` : rule);
213
+ const rtkVer = ok(rtkBin) ? rtkVersion(rtkBin) : null;
214
+ const rtkAge = ageStr(rtkBin);
215
+ const rtkAt = absTime(rtkBin);
216
+ addon('RTK binary', rtkVer !== null, rtkVer && rtkAge && rtkAt ? `${rtkVer} (updated ${rtkAge} · ${rtkAt})` : rtkBin);
217
+ const wiring = path.join(extDir, 'rtk.ts');
218
+ addon('RTK OMP wiring (rtk.ts)', ok(wiring), ok(wiring) ? 'loaded' : wiring);
219
+ let ponytailVer = null;
220
+ try {
221
+ ponytailVer = JSON.parse(readFileSync(ponytailPkg, 'utf8')).version ?? null;
222
+ }
223
+ catch {
224
+ ponytailVer = null;
225
+ }
226
+ const ponytailAge = ageStr(ponytailPkg);
227
+ const ponytailAt = absTime(ponytailPkg);
228
+ addon('Ponytail', ponytailVer !== null, ponytailVer && ponytailAge && ponytailAt ? `${ponytailVer} (updated ${ponytailAge} · ${ponytailAt})` : ponytailPkg);
229
+ const prices = pricesCachePath();
230
+ const pricesAge = ageStr(prices);
231
+ const pricesAt = absTime(prices);
232
+ addon('Prices feed', pricesAge !== null, pricesAge && pricesAt ? `(pulled ${pricesAge} · ${pricesAt})` : prices, 'Usage');
233
+ return rows;
234
+ }
235
+ // Retained diagnosis: recompute when forced, missing, or older than the
236
+ // schedule. Otherwise return the saved report so users never re-run.
237
+ // Labels retired from the report. A saved report containing any of them
238
+ // is stale by definition and always recomputes.
239
+ const RETIRED_DIAG_LABELS = new Set([
240
+ 'OMP CLI',
241
+ 'Tersio CLI',
242
+ 'Tersio commands',
243
+ 'Mode reinforcement',
244
+ 'Combo registered',
245
+ 'Combo extension',
246
+ 'Self plugin',
247
+ 'Ponytail registered',
248
+ 'Usage store',
249
+ ]);
250
+ function isRetiredReport(report) {
251
+ return report.rows.some((r) => RETIRED_DIAG_LABELS.has(r.label));
252
+ }
253
+ // Retained diagnosis: recompute when forced, missing, retired, or older
254
+ // than the schedule. Otherwise return the saved report.
255
+ function getDoctorReport(force) {
256
+ const saved = readDiagReport();
257
+ const ttl = saved ? DIAG_TTL_MS[saved.schedule] : 0;
258
+ // Manual always recomputes on open; dated schedules reuse the saved
259
+ // report until stale. Otherwise a retained snapshot hides new rows.
260
+ if (!force && saved && !isRetiredReport(saved) && ttl > 0 && Date.now() - saved.checkedAt < ttl)
261
+ return saved;
262
+ const report = { rows: computeDoctorRows(), checkedAt: Date.now(), schedule: saved?.schedule ?? 'manual' };
263
+ writeDiagReport(report);
264
+ return report;
265
+ }
266
+ function setDiagSchedule(schedule) {
267
+ const base = readDiagReport() ?? { rows: computeDoctorRows(), checkedAt: Date.now(), schedule };
268
+ base.schedule = schedule;
269
+ writeDiagReport(base);
270
+ return base;
271
+ }
272
+ function readDiagSchedule() {
273
+ return readDiagReport()?.schedule ?? 'manual';
274
+ }
31
275
  // Persist the dashboard's picker choice so close → reopen keeps it: each
32
276
  // `tersio gain` run serves a fresh ephemeral port (a new origin), so the
33
277
  // browser's localStorage alone cannot survive a restart. The stored default
@@ -55,33 +299,56 @@ function openBrowser(url) {
55
299
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
56
300
  spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
57
301
  }
302
+ // Replacer functions throughout: session data routinely contains `$'`
303
+ // sequences (shell quoting in tool details), which String.replace would
304
+ // expand as match-suffix patterns and corrupt the file.
305
+ // RTK-metered command lines routinely contain literal `</script>` (Vue
306
+ // SFC probes), which would close the inlined <script> early and dump the
307
+ // rest of the JSON as page text. Escape it; JSON.parse never sees the
308
+ // backslash form inside a string literal, the browser decodes it first.
309
+ function escapeInline(json) {
310
+ return json.replace(/<\/(script)/gi, '<\\/$1');
311
+ }
312
+ async function exportDashboard(exportFile) {
313
+ if (useGainBundle()) {
314
+ const [bundle, icon] = await Promise.all([readSegment(GAIN_INDEX), faviconDataUri()]);
315
+ const inline = bundle
316
+ .replace('window.__TERSIO_SNAP = null;', () => `window.__TERSIO_SNAP = ${escapeInline(JSON.stringify({ data: JSON.parse(dataJson()), health: JSON.parse(healthJson()), doctor: getDoctorReport(false) }))};`)
317
+ .replace('href="brand.webp"', () => `href="${icon}"`)
318
+ .replace(/src="brand.webp"/g, () => `src="${icon}"`);
319
+ await fs.writeFile(exportFile, inline, 'utf8');
320
+ console.log(`[ok] gain exported → ${exportFile}`);
321
+ return;
322
+ }
323
+ const [template, css, core, charts, settings, share, legacyIcon] = await Promise.all([
324
+ readSegment(TEMPLATE), readSegment(STYLES), readSegment(CORE_JS), readSegment(CHARTS_JS),
325
+ readSegment(SETTINGS_JS), readSegment(SHARE_JS), faviconDataUri(),
326
+ ]);
327
+ const inline = template
328
+ .replace('<link rel="stylesheet" href="styles.css">', () => `<style>\n${css}</style>`)
329
+ .replace('<script src="core.js" defer></script>', () => `<script>\n${core}</script>`)
330
+ .replace('<script src="charts.js" defer></script>', () => `<script>\n${charts}</script>`)
331
+ .replace('<script src="settings.js" defer></script>', () => `<script>\n${settings}</script>`)
332
+ .replace('<script src="share.js" defer></script>', () => `<script>\n${share}</script>`)
333
+ .replace('window.__TERSIO_SNAP = null;', () => `window.__TERSIO_SNAP = ${escapeInline(JSON.stringify({ health: JSON.parse(healthJson()), doctor: getDoctorReport(false) }))};`)
334
+ .replace("fetch('data.json')", () => `Promise.resolve({ json: function () { return ${escapeInline(dataJson())}; } })`)
335
+ // Exported file runs on file:// where load() early-returns before the
336
+ // stub above ever runs, so nothing ever renders. Drop that guard in
337
+ // the export only; template.html keeps it to avoid 404 polling loops.
338
+ .replace("if (window.location.protocol === 'file:') return;", () => `if (false) return;`)
339
+ .replace('href="brand.webp"', () => `href="${legacyIcon}"`)
340
+ .replace(/src="brand.webp"/g, () => `src="${legacyIcon}"`);
341
+ await fs.writeFile(exportFile, inline, 'utf8');
342
+ console.log(`[ok] gain exported → ${exportFile}`);
343
+ }
58
344
  async function runDashboard(options) {
59
345
  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}`);
346
+ await exportDashboard(options.exportFile);
82
347
  return;
83
348
  }
84
- const html = await withInteractiveSpinner('Loading dashboard template', () => readSegment(TEMPLATE));
349
+ const html = useGainBundle()
350
+ ? null
351
+ : await withInteractiveSpinner('Loading dashboard template', () => readSegment(TEMPLATE));
85
352
  const server = http.createServer(async (req, res) => {
86
353
  if (req.url === '/reset' && req.method === 'POST') {
87
354
  const rows = clearUsageLedger();
@@ -114,30 +381,95 @@ async function runDashboard(options) {
114
381
  res.end(dataJson());
115
382
  return;
116
383
  }
384
+ if (req.url === '/health') {
385
+ res.writeHead(200, { 'Content-Type': 'application/json' });
386
+ res.end(healthJson());
387
+ return;
388
+ }
389
+ if (req.url === '/doctor' && req.method === 'POST') {
390
+ let schedule = 'manual';
391
+ try {
392
+ const raw = JSON.parse(await readBody(req)).schedule;
393
+ if (raw === 'daily' || raw === 'weekly' || raw === 'monthly' || raw === 'manual')
394
+ schedule = raw;
395
+ }
396
+ catch { /* keep manual */ }
397
+ const report = schedule === 'manual' ? getDoctorReport(true) : setDiagSchedule(schedule);
398
+ res.writeHead(200, { 'Content-Type': 'application/json' });
399
+ res.end(JSON.stringify(report));
400
+ return;
401
+ }
402
+ if (req.url === '/doctor/fix' && req.method === 'POST') {
403
+ try {
404
+ const { runDoctorRepairs } = await import('./doctor-fix.js');
405
+ const failed = await runDoctorRepairs(['extensions', 'registrations', 'ponytail']);
406
+ getDoctorReport(true);
407
+ res.writeHead(200, { 'Content-Type': 'application/json' });
408
+ res.end(JSON.stringify({ ok: failed.length === 0, failed }));
409
+ }
410
+ catch (e) {
411
+ res.writeHead(200, { 'Content-Type': 'application/json' });
412
+ res.end(JSON.stringify({ ok: false, failed: ['fix'], error: e.message }));
413
+ }
414
+ return;
415
+ }
416
+ if (req.url === '/doctor' || req.url?.startsWith('/doctor?')) {
417
+ const fresh = (req.url.split('?')[1] ?? '').includes('fresh=1');
418
+ res.writeHead(200, { 'Content-Type': 'application/json' });
419
+ res.end(JSON.stringify(getDoctorReport(fresh)));
420
+ return;
421
+ }
117
422
  if (req.url === '/styles.css') {
118
423
  res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8' });
119
424
  res.end(await readSegment(STYLES));
120
425
  return;
121
426
  }
122
- if (req.url === '/app.js') {
427
+ const scripts = {
428
+ '/core.js': CORE_JS,
429
+ '/charts.js': CHARTS_JS,
430
+ '/settings.js': SETTINGS_JS,
431
+ '/share.js': SHARE_JS,
432
+ };
433
+ if (req.url !== undefined && req.url in scripts) {
123
434
  res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8' });
124
- res.end(await readSegment(APP));
435
+ res.end(await readSegment(scripts[req.url]));
125
436
  return;
126
437
  }
127
438
  if (req.url === '/brand.webp') {
128
- try {
129
- const png = await fs.readFile(BRAND);
130
- res.writeHead(200, { 'Content-Type': 'image/webp' });
131
- res.end(png);
439
+ // Prefer the bundled brand; fall back to the legacy dashboard copy.
440
+ for (const file of [GAIN_BRAND, BRAND]) {
441
+ try {
442
+ const png = await fs.readFile(file);
443
+ res.writeHead(200, { 'Content-Type': 'image/webp' });
444
+ res.end(png);
445
+ return;
446
+ }
447
+ catch { /* try the next copy */ }
132
448
  }
133
- catch {
134
- res.writeHead(404);
135
- res.end();
449
+ res.writeHead(404);
450
+ res.end();
451
+ return;
452
+ }
453
+ if (useGainBundle()) {
454
+ // shadcn bundle: index.html at /, sibling files (brand.webp,
455
+ // vite.svg) by path. No path escapes outside gain/dist.
456
+ const name = (req.url ?? '/').split('?')[0];
457
+ const file = path.normalize(path.join(GAIN_DIST, name === '/' ? 'index.html' : name.slice(1)));
458
+ if (file === GAIN_DIST || file.startsWith(GAIN_DIST + path.sep)) {
459
+ try {
460
+ const body = await fs.readFile(file);
461
+ res.writeHead(200, { 'Content-Type': contentType(file) });
462
+ res.end(body);
463
+ return;
464
+ }
465
+ catch { /* fall through to index */ }
136
466
  }
467
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
468
+ res.end(await readSegment(GAIN_INDEX));
137
469
  return;
138
470
  }
139
471
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
140
- res.end(html);
472
+ res.end(html ?? '');
141
473
  });
142
474
  server.listen(options.port, '127.0.0.1', () => {
143
475
  try {
@@ -152,4 +484,4 @@ async function runDashboard(options) {
152
484
  openBrowser(url);
153
485
  });
154
486
  }
155
- export { runDashboard, saveDashboardCurrency };
487
+ export { runDashboard, saveDashboardCurrency, readDiagSchedule, setDiagSchedule };