@krtclcdy/tersio 2.22.0 → 2.23.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.
Files changed (47) hide show
  1. package/README.md +38 -30
  2. package/cli/common.js +43 -39
  3. package/cli/currency.js +2 -2
  4. package/cli/dashboard.js +60 -116
  5. package/cli/doctor-fix.js +35 -29
  6. package/cli/doctor.js +26 -15
  7. package/cli/install.js +15 -34
  8. package/cli/profile.js +2 -1
  9. package/cli/settings.js +6 -6
  10. package/cli/update.js +12 -11
  11. package/cli/usage.js +6 -1
  12. package/dashboard/dist/index.html +65 -0
  13. package/extensions/ai-addons-updater/index.js +7 -3
  14. package/extensions/ai-addons-updater/index.ts +15 -3
  15. package/extensions/caveman-session/index.js +17 -12
  16. package/extensions/caveman-session/index.ts +17 -12
  17. package/extensions/caveman-session/rule.md +90 -0
  18. package/extensions/combo-toggle/index.js +22 -2
  19. package/extensions/combo-toggle/index.ts +26 -2
  20. package/extensions/lib/utils.js +20 -4
  21. package/extensions/lib/utils.ts +18 -4
  22. package/extensions/rtk-session/index.js +11 -10
  23. package/extensions/rtk-session/index.ts +10 -11
  24. package/extensions/shared/plugin-settings.js +37 -3
  25. package/extensions/shared/plugin-settings.ts +34 -3
  26. package/extensions/shared/rtk-gain.js +2 -2
  27. package/extensions/shared/rtk-gain.ts +3 -3
  28. package/extensions/shared/session-state.js +5 -3
  29. package/extensions/shared/session-state.ts +4 -3
  30. package/extensions/shared/types.ts +5 -0
  31. package/extensions/shared/usage-ledger.js +114 -4
  32. package/extensions/shared/usage-ledger.ts +122 -4
  33. package/extensions/tersio-commands/index.js +10 -13
  34. package/extensions/tersio-commands/index.ts +10 -13
  35. package/package.json +21 -12
  36. package/tersio.js +10 -10
  37. package/dashboard/charts.js +0 -1208
  38. package/dashboard/core.js +0 -357
  39. package/dashboard/settings.js +0 -166
  40. package/dashboard/share.js +0 -218
  41. package/dashboard/styles.css +0 -379
  42. package/dashboard/template.html +0 -515
  43. package/extensions/shared/mode-reinforcement.ts +0 -29
  44. package/gain/dist/brand.webp +0 -0
  45. package/gain/dist/index.html +0 -65
  46. package/gain/dist/vite.svg +0 -1
  47. /package/dashboard/{brand.webp → dist/brand.webp} +0 -0
package/README.md CHANGED
@@ -38,6 +38,8 @@ Then restart OMP and enable a preset:
38
38
 
39
39
  Individual toggles: `/caveman full` · `/rtk on` · `/ponytail full`. Everything starts off until you enable it.
40
40
 
41
+ When Tersio is installed through OMP, the first interactive launch asks for a session-start Combo preset once (`off`, `medium`, `balanced`, or `max`) and saves it as `comboDefault`. Running `tersio install` performs the same setup through the CLI.
42
+
41
43
  Session-start defaults (prompted during `install`, or flags):
42
44
 
43
45
  ```bash
@@ -61,39 +63,45 @@ Windows/WSL have separate OMP homes — install from the environment where OMP r
61
63
 
62
64
  ## 📊 Benchmarks
63
65
 
64
- Measured savings against the same workload without the modes. Token counts are real `o200k_base` BPE tokens (js-tiktoken), not chars÷4 estimates. Sample protocol, per-sample data, and caveats in [BENCHMARK.md](./BENCHMARK.md).
66
+ Measured surfaces use different boundaries. Do not treat reply-text, code-diff, and shell-output reductions as total bill savings. Full protocol and caveats are in [BENCHMARK.md](./BENCHMARK.md).
65
67
 
66
68
  | Measured surface (n) | Baseline → Tersio | Δ |
67
69
  |---|---|---|
68
- | Terse reply, caveman lite — BPE tok (3 samples, p50) | 173 → 69 tok | **−60.1%** |
69
- | Terse reply, caveman full — BPE tok (3 samples, p50) | 173 → 49 tok | **−71.7%** |
70
- | Terse reply, caveman ultra — BPE tok (3 samples, p50) | 173 → 25 tok | **−85.5%** |
71
- | Code diff, ponytail lite — BPE tok (2 tasks) | 326–462 → 57–121 tok | **−73.8…−82.5%** |
72
- | Code diff, ponytail full — BPE tok (2 tasks) | 326–462 → 55–88 tok | **−81.0…−83.1%** |
73
- | Code diff, ponytail ultra — BPE tok (2 tasks) | 326–462 → 40–88 tok | **−81.0…−87.7%** |
74
- | Shell output, rtk — `git status` — BPE tok | 182 → 72 tok | **−60.4%** |
75
- | Shell output, rtk — repo `grep` — BPE tok | 376 → 274 tok | **−27.1%** |
76
- | Shell output, rtk — `find` — BPE tok | 205 → 168 tok | **−18.0%** |
77
- | Shell output, rtk — `git diff` — BPE tok | 12,684 → 8,504 tok | **−33.0%** |
78
- | Shell output, rtk — `npm test` (passthrough) — BPE tok | 2,597 → 2,577 tok | **−0.8%** |
70
+ | Terse reply, caveman lite — BPE tokens (3 samples, p50) | 173 → 69 | **−60.1% reply text** |
71
+ | Terse reply, caveman full — BPE tokens (3 samples, p50) | 173 → 49 | **−71.7% reply text** |
72
+ | Terse reply, caveman ultra — BPE tokens (3 samples, p50) | 173 → 25 | **−85.5% reply text** |
73
+ | Code diff, ponytail lite — BPE tokens (2 tasks) | 326–462 → 57–121 | **−73.8…−82.5% diff** |
74
+ | Code diff, ponytail full — BPE tokens (2 tasks) | 326–462 → 55–88 | **−81.0…−83.1% diff** |
75
+ | Code diff, ponytail ultra — BPE tokens (2 tasks) | 326–462 → 40–88 | **−81.0…−87.7% diff** |
76
+ | Shell output, rtk `git status` | 182 → 72 | **−60.4% command output** |
77
+ | Shell output, rtk repo `grep` | 376 → 274 | **−27.1% command output** |
78
+ | Shell output, rtk `find` | 205 → 168 | **−18.0% command output** |
79
+ | Shell output, rtk `git diff` | 12,684 → 8,504 | **−33.0% command output** |
80
+
81
+ The Command tools table reports weighted RTK command-output savings and OMP-specific adoption from executed Bash records. It does not estimate provider billing.
82
+ Upstream Ponytail's fair agentic benchmark reports 54% less code, 22% fewer tokens, 20% lower cost, and 100% retained safety. RTK estimates command-output tokens, not bill savings. Caveman and Ponytail compliance varies by model. Measure your own sessions with `tersio usage` and the dashboard.
79
83
 
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).
81
84
 
82
- ## 📈 Gain Dashboard
85
+ ## 📈 Dashboard
83
86
 
84
- `tersio gain --open` serves a local dashboard (127.0.0.1 only) that charts your own savings from `~/.tersio/usage.db`. Three views:
87
+ `tersio dashboard --open` serves a local dashboard (127.0.0.1 only) that charts your own savings from `~/.tersio/usage.db`. Three views:
85
88
 
86
89
  **Live feed and savings.** Token throughput, cost, savings bento, and recent activity — the top of the dashboard.
87
90
 
88
- ![Gain dashboard: live token feed, cost, savings, and activity](/assets/GainHero.webp)
91
+ ![Dashboard: live token feed, cost, savings, and activity](/assets/GainHero.webp)
89
92
 
90
93
  **Top models and recent requests.** Per-model cost breakdown plus the last requests with elapsed time and token speed.
91
94
 
92
- ![Gain dashboard: top models with per-model cost, recent requests with elapsed time and token speed](/assets/GainModels.webp)
95
+ ![Dashboard: top models with per-model cost, recent requests with elapsed time and token speed](/assets/GainModels.webp)
93
96
 
94
97
  **Command tools.** Token savings, average rate, and timing per shell tool.
95
98
 
96
- ![Gain dashboard: command tools with token savings, average rate, and timing](/assets/GainTools.webp)
99
+ ![Dashboard: command tools with token savings, average rate, and timing](/assets/GainTools.webp)
100
+
101
+ ### Measurement references
102
+
103
+ - **[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.
104
+ - **[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.
97
105
 
98
106
  ## 🖥️ CLI
99
107
 
@@ -104,12 +112,12 @@ Break-even math, balanced preset (caveman full + rtk + ponytail): a mixed turn c
104
112
  | `tersio reinstall` | Fresh install, preserving the Ponytail package |
105
113
  | `tersio doctor` | Check OMP, extension, Ponytail, and RTK health (`--fix` repairs, `--fix=<scope>` one scope, `--dry-run` previews) |
106
114
  | `tersio usage` | Ledger-backed usage + savings report |
107
- | `tersio gain` | Open the gain dashboard (`--open`, `--export <file>`, `--port <n>`, `--currency <code>`; serves localhost only) |
115
+ | `tersio dashboard` | Open the Dashboard (`--open`, `--export <file>`, `--port <n>`, `--currency <code>`; serves localhost only) |
108
116
  | `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 |
109
117
  | `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) |
110
118
  | `tersio version` | Print version |
111
119
 
112
- 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.
120
+ 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.
113
121
 
114
122
  ## ⌨️ Commands reference
115
123
 
@@ -118,13 +126,13 @@ Mode switches live on their own commands; bare `/tersio` prints status.
118
126
  | Command | Purpose |
119
127
  |---|---|
120
128
  | `/combo off\|medium\|balanced\|max\|status` | One preset for all three (start here): medium = lite/lite/on, balanced = full/full/on, max = ultra/ultra/on; per-mode tweaks drop to `custom`. |
121
- | `/caveman lite\|full\|ultra\|wenyan\|off\|status` | Terse replies (substance stays); `ultra` uses fragments only, `wenyan` a compressed classical-Chinese-inspired register. |
122
- | `/rtk on\|off\|status` | Compact shell output via the real `rtk` binary; exact bytes (checksums, patches) bypass it by policy. |
123
- | `/ponytail off\|lite\|full\|ultra\|review\|status` | Minimal code (YAGNI): `lite` nudges, `full` enforces, `ultra` challenges the requirement, `review` audits for over-engineering (upstream command). |
129
+ | `/caveman lite\|full\|ultra\|wenyan-lite\|wenyan-full\|wenyan-ultra\|off\|status` | Terse replies with upstream-aligned Caveman levels. Legacy `wenyan` restores as `wenyan-full`. |
130
+ | `/rtk on\|off\|status` | Controls RTK session guidance, `rtk_run`, and the automatic hook through shared `RTK_DISABLED` state. Exact bytes bypass RTK. |
131
+ | `/ponytail off\|lite\|full\|ultra\|status` | Minimal code using upstream Ponytail modes. Review runs separately through `/ponytail-review`. |
124
132
  | `/tersio status` | Active modes + combo level; state persists, propagates to subagents, and lights the Combo footer indicator. |
125
133
  | `/tersio check` | Add-on version check |
126
134
  | `/tersio update <ponytail\|rtk\|caveman\|all> [--dry-run]` | Update add-ons, preview with dry-run |
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). |
135
+ | `/tersio dashboard` | Open the Dashboard. |
128
136
  | `/tersio usage` | Ledger report for this machine (rows in `~/.tersio/usage.db`, local only) |
129
137
  | `/tersio help` | This table |
130
138
  | `/ai-addons` | Add-on updater alias, still works. |
@@ -133,11 +141,11 @@ Mode switches live on their own commands; bare `/tersio` prints status.
133
141
 
134
142
  | What | Path |
135
143
  |---|---|
136
- | Caveman / RTK / Updater / Combo extensions | `~/.omp/agent/extensions/{caveman-session,rtk-session,ai-addons-updater,combo-toggle}/` |
144
+ | Caveman / RTK / Combo / Tersio commands | `~/.omp/plugins/node_modules/@krtclcdy/tersio/extensions/{caveman-session,rtk-session,combo-toggle,tersio-commands}/` — loaded from Tersio's OMP plugin manifest |
137
145
  | 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 |
138
- | Ponytail package (bundled tersio dependency — one Plugins row, updates with `tersio update`) | `~/.omp/plugins/node_modules/@dietrichgebert/ponytail/` |
139
- | RTK binary | `~/.bun/bin/rtk` (`rtk.exe` on Windows) |
140
- | Extension registrations | `~/.omp/agent/config.yml` |
146
+ | Ponytail package (bundled Tersio dependency — one Plugins row, updates with `tersio update`) | `~/.omp/plugins/node_modules/@dietrichgebert/ponytail/` — loaded as a nested plugin dependency |
147
+ | RTK binary | Installer writes `~/.bun/bin/rtk` (`rtk.exe` on Windows). Runtime resolves `PATH` first, then this managed path. |
148
+ | Legacy config cleanup | `~/.omp/agent/config.yml` — doctor removes retired or duplicate extension entries |
141
149
 
142
150
  The installer writes `<file>.bak` before replacing an extension source; the updater keeps `rtk.bak` / `rule.md.bak` and restores them if the replacement fails validation.
143
151
 
@@ -145,11 +153,11 @@ User-level installs also register the package in `~/.omp/plugins` (visible in OM
145
153
 
146
154
  ## 🛠️ Troubleshooting
147
155
 
148
- **Ponytail or Combo command missing:** run `tersio reinstall` in OMP's environment, restart OMP, then `tersio doctor` (repairs `config.yml` registrations).
156
+ **Ponytail or Combo command missing:** confirm `~/.omp/plugins/package.json` lists `@krtclcdy/tersio`, then run `tersio reinstall`, restart OMP, and check `tersio doctor`.
149
157
 
150
158
  **RTK missing or not executable:** run `tersio reinstall`, then `tersio doctor`. On Linux/macOS: `chmod +x ~/.bun/bin/rtk`.
151
159
 
152
- **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.
160
+ **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.
153
161
 
154
162
  **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.
155
163
 
package/cli/common.js CHANGED
@@ -16,9 +16,8 @@ const OMP_BIN = IS_WINDOWS ? 'omp.cmd' : 'omp';
16
16
  const PACKAGE_NAME = '@krtclcdy/tersio';
17
17
  const PACKAGE_BIN = 'tersio';
18
18
  const { version: PACKAGE_VERSION } = createRequire(import.meta.url)('../package.json');
19
- // Session-start mode defaults for fresh installs.
20
- const CAVEMAN_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra', 'wenyan']);
21
- const PONYTAIL_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra', 'review']);
19
+ const CAVEMAN_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']);
20
+ const PONYTAIL_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra']);
22
21
  const RTK_DEFAULTS = new Set(['on', 'off']);
23
22
  const DIAG_SCHEDULES = new Set(['manual', 'daily', 'weekly', 'monthly']);
24
23
  // ponytail: Combo preset implies all three modes. Mirrors COMBO_LEVELS in
@@ -48,7 +47,7 @@ function flagValue(name) {
48
47
  return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
49
48
  }
50
49
  // --- CLI flags ---
51
- const COMMANDS = { install: true, update: true, reinstall: true, doctor: true, uninstall: true, usage: true, gain: true, reset: true, settings: true, version: true, help: true };
50
+ const COMMANDS = { install: true, update: true, reinstall: true, doctor: true, uninstall: true, usage: true, dashboard: true, reset: true, settings: true, version: true, help: true };
52
51
  const args = process.argv.slice(2);
53
52
  const commandArg = args.find((arg) => !arg.startsWith('-'));
54
53
  const command = commandArg?.toLowerCase() || null;
@@ -75,13 +74,13 @@ const verbose = args.includes('--verbose');
75
74
  const doctor = command === 'doctor' || args.includes('--doctor');
76
75
  const fix = args.includes('--fix') || args.some((a) => a.startsWith('--fix='));
77
76
  const usage = command === 'usage' || args.includes('--usage');
78
- const gain = command === 'gain';
77
+ const dashboard = command === 'dashboard';
79
78
  const reset = command === 'reset';
80
79
  const settings = command === 'settings';
81
80
  const dashboardPort = Number.parseInt(flagValue('--port') ?? '', 10) || 0;
82
81
  const dashboardOpen = args.includes('--open');
83
82
  const dashboardExport = flagValue('--export') ?? null;
84
- // Display currency for usage/gain: --currency flag wins, then the stored
83
+ // Display currency for usage/dashboard: --currency flag wins, then the stored
85
84
  // plugin default (tersio settings), then USD.
86
85
  const currency = parseCurrencyFlag(flagValue('--currency')) ?? readStoredCurrency();
87
86
  const currencyGiven = flagValue('--currency') !== undefined;
@@ -167,7 +166,26 @@ async function ensureExtensionInConfig(configPath, extensionPath, label, options
167
166
  const legacyTail = `${normalizedPath.slice(0, -3)}.js`.split('/').slice(-2).join('/');
168
167
  lines = lines.filter((l) => !l.trim().replace(/^\.\//, '').endsWith(legacyTail));
169
168
  }
170
- if (lines.some((l) => l.includes(normalizedPath))) {
169
+ const matchingEntries = lines.filter((entry) => entry.trim().replace(/^-\s*/, '').replace(/^['"]|['"]$/g, '') === normalizedPath);
170
+ if (matchingEntries.length > 1) {
171
+ if (options.dryRun) {
172
+ if (verbose && !options.quiet)
173
+ console.log(` [dry-run] would remove ${matchingEntries.length - 1} duplicate ${label} registration${matchingEntries.length === 2 ? '' : 's'}`);
174
+ return true;
175
+ }
176
+ let kept = false;
177
+ lines = lines.filter((entry) => {
178
+ if (entry.trim().replace(/^-\s*/, '').replace(/^['"]|['"]$/g, '') !== normalizedPath)
179
+ return true;
180
+ if (kept)
181
+ return false;
182
+ kept = true;
183
+ return true;
184
+ });
185
+ await writeConfigLines(configPath, lines, ` [write] Removed duplicate ${label} registration`, options);
186
+ return true;
187
+ }
188
+ if (matchingEntries.length === 1) {
171
189
  debug(`${label} already in config.yml`);
172
190
  return false;
173
191
  }
@@ -191,43 +209,29 @@ async function ensureExtensionInConfig(configPath, extensionPath, label, options
191
209
  await writeConfigLines(configPath, lines, ` [write] Added ${label} to config.yml`, options);
192
210
  return true;
193
211
  }
194
- async function ensureExtensionAfterConfigEntry(configPath, extensionPath, afterPath, label, options = {}) {
195
- const normalizedPath = extensionPath.replace(/\\/g, '/');
196
- const normalizedAfterPath = afterPath.replace(/\\/g, '/');
197
- const line = ` - ${normalizedPath}`;
212
+ async function removeExtensionFromConfig(configPath, extensionPath, label, options = {}) {
213
+ const normalizedPath = extensionPath.replace(/\\/g, '/').replace(/^\.\//, '');
214
+ const extensionSuffix = normalizedPath.includes('/extensions/')
215
+ ? normalizedPath.slice(normalizedPath.indexOf('/extensions/') + 1)
216
+ : normalizedPath;
198
217
  const raw = await readTextIfExists(configPath);
199
- let lines = (raw || '').split('\n');
200
- // Same legacy-twin drop as ensureExtensionInConfig above.
201
- if (normalizedPath.endsWith('.ts')) {
202
- const legacyTail = `${normalizedPath.slice(0, -3)}.js`.split('/').slice(-2).join('/');
203
- lines = lines.filter((l) => !l.trim().replace(/^\.\//, '').endsWith(legacyTail));
204
- }
205
- const existingIndex = lines.findIndex((entry) => entry.includes(normalizedPath));
206
- const afterIndex = lines.findIndex((entry) => entry.includes(normalizedAfterPath));
207
- if (existingIndex !== -1 && afterIndex !== -1 && existingIndex === afterIndex + 1)
218
+ if (raw === null)
219
+ return false;
220
+ const lines = raw.split('\n');
221
+ const matches = (entry) => {
222
+ const value = entry.trim().replace(/^-\s*/, '').replace(/^['"]|['"]$/g, '').replace(/\\/g, '/').replace(/^\.\//, '');
223
+ return value === extensionSuffix || value.endsWith(`/${extensionSuffix}`);
224
+ };
225
+ const kept = lines.filter((entry) => !matches(entry));
226
+ const removed = lines.length - kept.length;
227
+ if (removed === 0)
208
228
  return false;
209
229
  if (options.dryRun) {
210
230
  if (verbose && !options.quiet)
211
- console.log(` [dry-run] would place ${label} after Ponytail in config.yml`);
231
+ console.log(` [dry-run] would remove ${label} registration${removed === 1 ? '' : 's'}`);
212
232
  return true;
213
233
  }
214
- if (existingIndex !== -1)
215
- lines.splice(existingIndex, 1);
216
- const refreshedAfterIndex = lines.findIndex((entry) => entry.includes(normalizedAfterPath));
217
- if (refreshedAfterIndex !== -1) {
218
- lines.splice(refreshedAfterIndex + 1, 0, line);
219
- }
220
- else {
221
- const extensionsIndex = lines.findIndex((entry) => EXTENSIONS_KEY_RE.test(entry));
222
- if (extensionsIndex === -1) {
223
- lines.push('extensions:', line, '');
224
- }
225
- else {
226
- normalizeExtensionsKey(lines);
227
- lines.splice(extensionsIndex + 1, 0, line);
228
- }
229
- }
230
- await writeConfigLines(configPath, lines, ` [write] Placed ${label} after Ponytail in config.yml`, options);
234
+ await writeConfigLines(configPath, kept, ` [write] Removed duplicate ${label} registration`, options);
231
235
  return true;
232
236
  }
233
237
  function readPonytailConfig() {
@@ -298,4 +302,4 @@ function relTime(ageMs) {
298
302
  const weeks = Math.floor(days / 7);
299
303
  return `${weeks} week${weeks === 1 ? '' : 's'} ago`;
300
304
  }
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, };
305
+ 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, removeExtensionFromConfig, 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,5 +1,4 @@
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.
@@ -14,24 +13,17 @@ import { clearUsageLedger, markReset, readUsage } from '../extensions/shared/usa
14
13
  import { pricesCachePath } from '../extensions/shared/pricing.js';
15
14
  import { summarizeUsage } from './usage.js';
16
15
  import { isCurrencyCode } from './currency.js';
17
- import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_VERSION, RTK_BINARY_NAME, } from './common.js';
16
+ import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_VERSION, } from './common.js';
18
17
  import { storedProfile, writePluginSettings } from './profile.js';
19
- import { withInteractiveSpinner } from './interactive.js';
20
- const DASH_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dashboard');
21
- const TEMPLATE = path.join(DASH_DIR, 'template.html');
22
- const STYLES = path.join(DASH_DIR, 'styles.css');
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');
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);
18
+ import { PACKAGE_NAME } from './common.js';
19
+ import { resolveRtkBinary } from '../extensions/lib/utils.js';
20
+ const DASHBOARD_DIST = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dashboard', 'dist');
21
+ const DASHBOARD_INDEX = path.join(DASHBOARD_DIST, 'index.html');
22
+ const DASHBOARD_BRAND = path.join(DASHBOARD_DIST, 'brand.webp');
23
+ function requireDashboardBundle() {
24
+ if (existsSync(DASHBOARD_INDEX))
25
+ return;
26
+ throw new Error('Dashboard bundle missing. Run `bun run build` before using `tersio dashboard`.');
35
27
  }
36
28
  function contentType(file) {
37
29
  if (file.endsWith('.html'))
@@ -53,9 +45,9 @@ function contentType(file) {
53
45
  async function readSegment(file) {
54
46
  return fs.readFile(file, 'utf8');
55
47
  }
56
- async function faviconDataUri() {
57
- const png = await fs.readFile(BRAND);
58
- return `data:image/webp;base64,${png.toString('base64')}`;
48
+ async function brandDataUri() {
49
+ const brand = await fs.readFile(DASHBOARD_BRAND);
50
+ return `data:image/webp;base64,${brand.toString('base64')}`;
59
51
  }
60
52
  function dataJson() {
61
53
  return JSON.stringify(summarizeUsage(readUsage()));
@@ -109,8 +101,8 @@ function ompDefaultModel() {
109
101
  }
110
102
  }
111
103
  function healthJson() {
112
- const rtkBin = path.join(BUN_BIN_DIR, RTK_BINARY_NAME);
113
- const rtkPresent = existsSync(rtkBin);
104
+ const rtkBin = resolveRtkBinary();
105
+ const rtkPresent = rtkBin !== null;
114
106
  const detectedOmpPath = ompPath();
115
107
  return JSON.stringify({
116
108
  tersio: PACKAGE_VERSION,
@@ -119,7 +111,7 @@ function healthJson() {
119
111
  omp: ompVersion(detectedOmpPath),
120
112
  ompPath: detectedOmpPath,
121
113
  provider: ompDefaultModel(),
122
- rtk: { present: rtkPresent, version: rtkPresent ? rtkVersion(rtkBin) : null, path: rtkBin },
114
+ rtk: { present: rtkPresent, version: rtkPresent ? rtkVersion(rtkBin) : null, path: rtkBin || 'not found in PATH' },
123
115
  home: tersioHomePath(),
124
116
  });
125
117
  }
@@ -194,26 +186,39 @@ function relAge(ms) {
194
186
  function computeDoctorRows() {
195
187
  const rows = [];
196
188
  const extDir = path.join(OMP_AGENT_DIR, 'extensions');
197
- const rtkBin = path.join(BUN_BIN_DIR, RTK_BINARY_NAME);
189
+ const rtkBin = resolveRtkBinary();
198
190
  const ponytailPkg = path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
191
+ const tersioPluginDir = path.join(OMP_PLUGINS_DIR, 'node_modules', ...PACKAGE_NAME.split('/'));
199
192
  const ok = (p) => existsSync(p);
193
+ const configPath = path.join(OMP_AGENT_DIR, 'config.yml');
200
194
  const ext = (label, p) => {
201
195
  rows.push({ label, ok: ok(p), detail: ok(p) ? 'installed' : p, group: 'Extensions' });
202
196
  };
203
197
  const addon = (label, present, detail, group = 'Add-ons') => {
204
198
  rows.push({ label, ok: present, detail, group });
205
199
  };
206
- ext('Caveman extension', path.join(extDir, 'caveman-session', 'index.ts'));
207
- ext('RTK extension', path.join(extDir, 'rtk-session', 'index.ts'));
200
+ let explicitEntries = [];
201
+ try {
202
+ explicitEntries = readFileSync(configPath, 'utf8').split('\n')
203
+ .map((line) => line.trim().replace(/^-\s*/, '').replace(/^['"]|['"]$/g, ''))
204
+ .filter((line) => line.startsWith('/') || line.startsWith('.'));
205
+ }
206
+ catch { /* missing config is reported by extension rows */ }
207
+ const duplicateExtensions = [...new Set(explicitEntries.filter((entry, index) => explicitEntries.indexOf(entry) !== index))];
208
+ addon('Unique config registrations', duplicateExtensions.length === 0, duplicateExtensions.length ? duplicateExtensions.join(', ') : 'ok', 'Extensions');
209
+ const retiredReinforcement = explicitEntries.filter((entry) => entry.endsWith('/shared/mode-reinforcement.ts')).length;
210
+ addon('No retired reinforcement', retiredReinforcement === 0, retiredReinforcement ? `${retiredReinforcement} registration(s)` : 'ok', 'Extensions');
211
+ ext('Caveman extension', path.join(tersioPluginDir, 'extensions', 'caveman-session', 'index.ts'));
212
+ ext('RTK extension', path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts'));
208
213
  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');
214
+ const rule = path.join(tersioPluginDir, 'extensions', 'caveman-session', 'rule.md');
210
215
  const ruleAge = ageStr(rule);
211
216
  const ruleAt = absTime(rule);
212
217
  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);
218
+ const rtkVer = rtkBin ? rtkVersion(rtkBin) : null;
219
+ const rtkAge = rtkBin ? ageStr(rtkBin) : null;
220
+ const rtkAt = rtkBin ? absTime(rtkBin) : null;
221
+ addon('RTK binary', rtkVer !== null, rtkVer && rtkAge && rtkAt ? `${rtkVer} (updated ${rtkAge} · ${rtkAt})` : rtkBin || 'not found in PATH');
217
222
  const wiring = path.join(extDir, 'rtk.ts');
218
223
  addon('RTK OMP wiring (rtk.ts)', ok(wiring), ok(wiring) ? 'loaded' : wiring);
219
224
  let ponytailVer = null;
@@ -273,7 +278,7 @@ function readDiagSchedule() {
273
278
  return readDiagReport()?.schedule ?? 'manual';
274
279
  }
275
280
  // Persist the dashboard's picker choice so close → reopen keeps it: each
276
- // `tersio gain` run serves a fresh ephemeral port (a new origin), so the
281
+ // `tersio dashboard` run serves a fresh ephemeral port (a new origin), so the
277
282
  // browser's localStorage alone cannot survive a restart. The stored default
278
283
  // feeds data.json and `tersio usage` on the next run.
279
284
  async function saveDashboardCurrency(raw) {
@@ -310,45 +315,22 @@ function escapeInline(json) {
310
315
  return json.replace(/<\/(script)/gi, '<\\/$1');
311
316
  }
312
317
  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}"`);
318
+ requireDashboardBundle();
319
+ const [bundle, icon] = await Promise.all([readSegment(DASHBOARD_INDEX), brandDataUri()]);
320
+ const inline = bundle
321
+ .replace('window.__TERSIO_SNAP = null;', () => `window.__TERSIO_SNAP = ${escapeInline(JSON.stringify({ data: JSON.parse(dataJson()), health: JSON.parse(healthJson()), doctor: getDoctorReport(false) }))};`)
322
+ .replace(/href="brand\.webp"/g, () => `href="${icon}"`)
323
+ .replace(/src="brand\.webp"/g, () => `src="${icon}"`)
324
+ .replace('fetch("brand.webp")', () => `Promise.resolve({ ok: true, blob: async () => new Blob([atob("${icon.split(',')[1]}")], { type: "image/webp" }) })`);
341
325
  await fs.writeFile(exportFile, inline, 'utf8');
342
- console.log(`[ok] gain exported → ${exportFile}`);
326
+ console.log(`[ok] Dashboard exported → ${exportFile}`);
343
327
  }
344
328
  async function runDashboard(options) {
345
329
  if (options.exportFile) {
346
330
  await exportDashboard(options.exportFile);
347
331
  return;
348
332
  }
349
- const html = useGainBundle()
350
- ? null
351
- : await withInteractiveSpinner('Loading dashboard template', () => readSegment(TEMPLATE));
333
+ requireDashboardBundle();
352
334
  const server = http.createServer(async (req, res) => {
353
335
  if (req.url === '/reset' && req.method === 'POST') {
354
336
  const rows = clearUsageLedger();
@@ -419,67 +401,29 @@ async function runDashboard(options) {
419
401
  res.end(JSON.stringify(getDoctorReport(fresh)));
420
402
  return;
421
403
  }
422
- if (req.url === '/styles.css') {
423
- res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8' });
424
- res.end(await readSegment(STYLES));
425
- return;
426
- }
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) {
434
- res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8' });
435
- res.end(await readSegment(scripts[req.url]));
436
- return;
437
- }
438
- if (req.url === '/brand.webp') {
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 */ }
448
- }
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 */ }
404
+ const name = (req.url ?? '/').split('?')[0];
405
+ const file = path.normalize(path.join(DASHBOARD_DIST, name === '/' ? 'index.html' : name.slice(1)));
406
+ if (file === DASHBOARD_DIST || file.startsWith(DASHBOARD_DIST + path.sep)) {
407
+ try {
408
+ const body = await fs.readFile(file);
409
+ res.writeHead(200, { 'Content-Type': contentType(file) });
410
+ res.end(body);
411
+ return;
466
412
  }
467
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
468
- res.end(await readSegment(GAIN_INDEX));
469
- return;
413
+ catch { /* fall through to index */ }
470
414
  }
471
415
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
472
- res.end(html ?? '');
416
+ res.end(await readSegment(DASHBOARD_INDEX));
473
417
  });
474
418
  server.listen(options.port, '127.0.0.1', () => {
475
419
  try {
476
- process.title = 'tersio gain';
420
+ process.title = 'tersio dashboard';
477
421
  }
478
422
  catch { /* non-POSIX shells keep node */ }
479
423
  const address = server.address();
480
424
  const port = typeof address === 'object' && address ? address.port : options.port;
481
425
  const url = `http://127.0.0.1:${port}`;
482
- console.log(`[ok] gain live → ${url} (Ctrl-C to stop)`);
426
+ console.log(`[ok] Dashboard live → ${url} (Ctrl-C to stop)`);
483
427
  if (options.open)
484
428
  openBrowser(url);
485
429
  });