@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 +8 -9
- package/cli/common.js +14 -1
- package/cli/dashboard.js +367 -35
- package/cli/doctor-fix.js +185 -0
- package/cli/doctor.js +120 -53
- package/cli/install.js +46 -60
- package/cli/rtk-wiring.js +1 -1
- package/cli/settings.js +128 -5
- package/cli/uninstall.js +4 -4
- package/cli/update.js +29 -20
- package/cli/usage.js +1 -1
- package/dashboard/{app.js → charts.js} +74 -361
- package/dashboard/core.js +357 -0
- package/dashboard/settings.js +166 -0
- package/dashboard/share.js +218 -0
- package/dashboard/styles.css +87 -12
- package/dashboard/template.html +162 -57
- package/extensions/ai-addons-updater/index.js +13 -30
- package/extensions/ai-addons-updater/index.ts +11 -29
- package/extensions/lib/utils.js +26 -1
- package/extensions/lib/utils.ts +28 -1
- package/extensions/shared/pricing.js +2 -2
- package/extensions/shared/pricing.ts +2 -1
- package/extensions/shared/usage-ledger.js +3 -2
- package/extensions/shared/usage-ledger.ts +3 -2
- package/extensions/shared/usage-store.js +7 -7
- package/extensions/shared/usage-store.ts +7 -6
- package/gain/dist/brand.webp +0 -0
- package/gain/dist/index.html +65 -0
- package/gain/dist/vite.svg +1 -0
- package/package.json +17 -34
- package/tersio.js +3 -2
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
|
|
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
|
-
|
|
27
|
+
All modes load always. Only the updater stays toggleable:
|
|
28
28
|
|
|
29
29
|
```bash
|
|
30
|
-
omp plugin
|
|
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 `~/.
|
|
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 `~/.
|
|
129
|
-
| `/tersio usage` | Ledger report for this machine (rows in `~/.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
435
|
+
res.end(await readSegment(scripts[req.url]));
|
|
125
436
|
return;
|
|
126
437
|
}
|
|
127
438
|
if (req.url === '/brand.webp') {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 };
|