@krtclcdy/tersio 2.24.0 → 2.25.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 (63) hide show
  1. package/README.md +28 -24
  2. package/cli/banner.js +7 -5
  3. package/cli/common.js +14 -13
  4. package/cli/currency.js +2 -7
  5. package/cli/dashboard.js +220 -13
  6. package/cli/doctor-fix.js +99 -29
  7. package/cli/doctor.js +32 -35
  8. package/cli/hosts.js +23 -26
  9. package/cli/install.js +228 -115
  10. package/cli/interactive.js +65 -21
  11. package/cli/manifest.js +8 -4
  12. package/cli/profile.js +49 -16
  13. package/cli/reset.js +3 -7
  14. package/cli/rtk-wiring.js +29 -32
  15. package/cli/settings.js +40 -16
  16. package/cli/uninstall.js +79 -29
  17. package/cli/update.js +27 -20
  18. package/cli/usage.js +54 -19
  19. package/dashboard/dist/index.html +18 -18
  20. package/extensions/ai-addons-updater/index.js +75 -20
  21. package/extensions/ai-addons-updater/index.ts +67 -23
  22. package/extensions/caveman-session/index.js +33 -75
  23. package/extensions/caveman-session/index.ts +35 -77
  24. package/extensions/combo-toggle/index.js +40 -62
  25. package/extensions/combo-toggle/index.ts +38 -72
  26. package/extensions/lib/utils.js +64 -15
  27. package/extensions/lib/utils.ts +62 -15
  28. package/extensions/opencode/index.js +104 -0
  29. package/extensions/opencode/server.js +190 -0
  30. package/extensions/opencode/server.ts +200 -0
  31. package/extensions/rtk-session/index.js +38 -57
  32. package/extensions/rtk-session/index.ts +36 -55
  33. package/extensions/shared/carbon.js +4 -10
  34. package/extensions/shared/carbon.ts +5 -10
  35. package/extensions/shared/host.js +95 -56
  36. package/extensions/shared/host.ts +106 -60
  37. package/extensions/shared/plugin-settings.js +6 -18
  38. package/extensions/shared/plugin-settings.ts +6 -18
  39. package/extensions/shared/pricing.js +79 -27
  40. package/extensions/shared/pricing.ts +80 -28
  41. package/extensions/shared/rtk-gain.js +4 -11
  42. package/extensions/shared/rtk-gain.ts +4 -11
  43. package/extensions/shared/session-state.js +28 -39
  44. package/extensions/shared/session-state.ts +29 -38
  45. package/extensions/shared/status.js +38 -0
  46. package/extensions/shared/status.ts +45 -0
  47. package/extensions/shared/types.js +1 -2
  48. package/extensions/shared/types.ts +2 -2
  49. package/extensions/shared/usage-ledger.js +312 -57
  50. package/extensions/shared/usage-ledger.ts +321 -61
  51. package/extensions/shared/usage-store.js +354 -38
  52. package/extensions/shared/usage-store.ts +360 -43
  53. package/extensions/tersio-commands/index.js +5 -14
  54. package/extensions/tersio-commands/index.ts +5 -14
  55. package/package.json +12 -7
  56. package/tersio.js +4 -6
  57. package/cli/agent-hosts.js +0 -42
  58. package/cli/agents.js +0 -266
  59. package/cli/layer.js +0 -24
  60. package/cli/omp-layer.js +0 -51
  61. package/cli/pi-layer.js +0 -37
  62. package/cli/pi-wiring.js +0 -92
  63. package/extensions/shared/omp-prompt.js +0 -12
package/README.md CHANGED
@@ -8,9 +8,7 @@
8
8
  [![Build](https://shieldcn.dev/github/ci/KurutoDenzeru/tersio.svg?variant=branded&size=xs&logo=githubactions&label=Build)](https://github.com/KurutoDenzeru/tersio/actions)
9
9
  [![MIT](https://shieldcn.dev/badge/license-MIT-2563eb.svg?variant=branded&size=xs&logo=opensourceinitiative)](./LICENSE)
10
10
 
11
- Token-saving add-ons for Oh My Pi and pi: terse caveman replies, compact
12
- RTK shell output, lean Ponytail code calls, usage tracking with a gain
13
- dashboard — one-command combo presets, one shared extension tree.
11
+ Token saver & usage tracker for coding agents: terse caveman replies, compact RTK shell output, lean Ponytail code calls, and a gain dashboard — one-command combo presets over one shared extension tree.
14
12
 
15
13
  ## ⚡ Getting Started
16
14
 
@@ -25,29 +23,23 @@ curl -fsSL https://github.com/KurutoDenzeru/tersio/releases/latest/download/inst
25
23
  Then run the CLI. Bare `tersio` opens a menu and asks which agent to install into:
26
24
 
27
25
  ```bash
28
- tersio # menu — picks Oh My Pi or pi
26
+ tersio # menu — picks Oh My Pi, pi, or OpenCode
29
27
  tersio install --host pi # pi, non-interactive
30
28
  tersio install --host omp # Oh My Pi, non-interactive
29
+ tersio install --host opencode # OpenCode, non-interactive
31
30
  ```
32
31
 
33
- Or let the host install the package itself. Same result, host-managed updates:
32
+ Or let each host install the package itself:
34
33
 
35
34
  ```bash
36
35
  omp plugin install @krtclcdy/tersio
37
36
  pi install npm:@krtclcdy/tersio
37
+ opencode plugin add @krtclcdy/tersio
38
38
  ```
39
39
 
40
- Both hosts load the same five extensions from the same sources, and both read their session-start defaults from `~/.tersio/settings.json`. All five load always.
40
+ Pick one install method per host, not both. Using both registers every command twice.
41
41
 
42
- Then restart the agent and enable a preset:
43
-
44
- ```text
45
- /combo balanced
46
- ```
47
-
48
- Individual toggles: `/caveman full` · `/rtk on` · `/ponytail full`. Everything starts off until you enable it.
49
-
50
- 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
+ All hosts load the same five extensions from the same sources and read their session-start defaults from `~/.tersio/settings.json`. All five load always.
51
43
 
52
44
  One-off use without installing:
53
45
 
@@ -57,14 +49,15 @@ npm exec --yes --prefer-online --package=@krtclcdy/tersio@latest -- tersio insta
57
49
 
58
50
  ### Requirements
59
51
 
60
- - [OMP](https://github.com/can1357/oh-my-pi) or [pi](https://github.com/earendil-works/pi)
52
+ - [OMP](https://github.com/can1357/oh-my-pi), [pi](https://github.com/earendil-works/pi), or [OpenCode](https://opencode.ai) — all three are supported; pick one with `tersio install --host <omp|pi|opencode>`
61
53
  - Node.js 20.12+ with npm
54
+ - macOS or Linux, native or WSL
62
55
 
63
- Windows/WSL have separate OMP homes — install from the environment where OMP runs. Inside WSL, `command -v npm` must resolve to a Linux path, not `/mnt/c/`.
56
+ On Windows, use WSL. There is no native Windows path: the installer, the extensions, and the RTK wiring are tested on macOS and Linux only. WSL has its own home directory, so install from inside WSL — `command -v npm` must resolve to a Linux path, not `/mnt/c/`.
64
57
 
65
58
  ## 📊 Benchmarks
66
59
 
67
- Every mode is measured against a base run with all modes off. Measured surfaces use different boundaries. Do not treat reply-text, code-output, and shell-output reductions as total bill savings. Full protocol and caveats are in [BENCHMARK.md](./BENCHMARK.md).
60
+ Every mode is measured against a base run with all modes off. Measured surfaces use different boundaries. Do not treat reply-text, code-output, and shell-output reductions as total bill savings. **A mode also spends tokens on the prompt, on every turn — read the caveat under the table before choosing.** Results and the rerun protocol are in [docs/BENCHMARK.md](./docs/BENCHMARK.md).
68
61
 
69
62
  | Mode | Surface (n) | Base → Tersio | Δ |
70
63
  |---|---|---|---|
@@ -80,7 +73,9 @@ Every mode is measured against a base run with all modes off. Measured surfaces
80
73
  | `/rtk on` — `git status` | Command output (3 runs, p50) | 72 → 18 | **−75.0% output** |
81
74
  | `/rtk on` — `bun run build` | Command output (3 runs, p50) | 140 → 114 | **−18.6% output** |
82
75
 
83
- Prompt overhead is included in [BENCHMARK.md](./BENCHMARK.md) with a full prompt-base example. Summary: `/rtk on` pays back immediately, `/ponytail ultra` repays in about 7 code tasks, `/combo medium` in about 8, and every Caveman level costs more in prompt than it saves in reply text.
76
+ The two `/caveman` rows above are the exception worth naming: both levels spend more prompt tokens than the reply text they save, so they cost you money on net. Use Caveman for the terser writing style, not as a token saving.
77
+
78
+ Prompt overhead is included in [docs/BENCHMARK.md](./docs/BENCHMARK.md) with a full prompt-base example. Summary: `/rtk on` pays back immediately, `/ponytail ultra` repays in about 7 code tasks, `/combo medium` in about 8, and every Caveman level costs more in prompt than it saves in reply text.
84
79
 
85
80
  The Command tools dashboard reports weighted RTK command-output savings and OMP-specific adoption from executed Bash records. It does not estimate provider billing.
86
81
  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.
@@ -123,7 +118,13 @@ Flags: `--dry-run`, `--yes`/`-y`, `--verbose`, `--combo-default`/`--caveman-defa
123
118
 
124
119
  ## ⌨️ Commands reference
125
120
 
126
- Mode switches live on their own commands; bare `/tersio` prints status.
121
+ Mode switches live on their own commands; bare `/tersio` prints status. Every mode command, and every session start, prints the status line:
122
+
123
+ ```
124
+ 🧩 combo MAX: 🪨caveman=ULTRA ⚡rtk=ON 🦥ponytail=ULTRA
125
+ ```
126
+
127
+ It is display-only — it never enters the model context, so it costs the session nothing.
127
128
 
128
129
  | Command | Purpose |
129
130
  |---|---|
@@ -131,7 +132,7 @@ Mode switches live on their own commands; bare `/tersio` prints status.
131
132
  | `/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`. |
132
133
  | `/rtk on\|off\|status` | Controls RTK session guidance, `rtk_run`, and the automatic hook through shared `RTK_DISABLED` state. Exact bytes bypass RTK. |
133
134
  | `/ponytail off\|lite\|full\|ultra\|status` | Minimal code using upstream Ponytail modes. Review runs separately through `/ponytail-review`. |
134
- | `/tersio status` | Active modes + combo level; state persists, propagates to subagents, and lights the Combo footer indicator. |
135
+ | `/tersio status` | Active modes + combo level; state persists and propagates to subagents. |
135
136
  | `/tersio check` | Add-on version check |
136
137
  | `/tersio update <ponytail\|rtk\|caveman\|all> [--dry-run]` | Update add-ons, preview with dry-run |
137
138
  | `/tersio dashboard` | Open the Dashboard. |
@@ -149,14 +150,15 @@ Shared, and identical for every host:
149
150
  | RTK binary | `~/.bun/bin/rtk` (`rtk.exe` on Windows). The runtime resolves `PATH` first, then this managed path. |
150
151
  | Usage ledger and price cache | `~/.tersio/` — local only, nothing leaves the machine |
151
152
 
152
- Per host, written by `tersio install --host <omp|pi>`:
153
+ Per host, written by `tersio install --host <omp|pi|opencode>`:
153
154
 
154
155
  | Host | Extensions | Ponytail |
155
156
  |---|---|---|
156
157
  | Oh My Pi | `~/.omp/agent/extensions/{caveman-session,rtk-session,combo-toggle,tersio-commands,ai-addons-updater}/` | nested plugin dependency under `~/.omp/plugins/node_modules/`, one Plugins row |
157
158
  | pi | `~/.pi/agent/extensions/{caveman-session,rtk-session,combo-toggle,tersio-commands,ai-addons-updater}/` — pi auto-discovers `<agent-dir>/extensions`, so there is nothing to register | `npm:@dietrichgebert/ponytail`, installed as a pi package |
159
+ | OpenCode | `~/.config/opencode/plugins/tersio/` — registered in `opencode.json` plugins, so there is nothing else to register | bundled with Tersio; no separate package |
158
160
 
159
- Both hosts carry the same `shared/` and `lib/` modules beside those directories, and both load the same five extensions from the same sources.
161
+ All three hosts carry the same `shared/` and `lib/` modules and the same mode extensions from the same sources.
160
162
 
161
163
  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. `tersio doctor --fix extensions` restores a damaged tree, and `doctor --fix registrations` drops retired or duplicate `config.yml` entries.
162
164
 
@@ -168,6 +170,8 @@ An OMP install also registers the package in `~/.omp/plugins`, so it shows under
168
170
 
169
171
  **Commands missing on pi:** check the `Hosts` section of `tersio doctor` — it reports whether pi is installed and where. Then run `tersio install --host pi` and restart pi.
170
172
 
173
+ **Commands missing on OpenCode:** run `tersio install --host opencode` and restart OpenCode.
174
+
171
175
  **RTK missing or not executable:** run `tersio install`, then `tersio doctor`. On Linux/macOS: `chmod +x ~/.bun/bin/rtk`.
172
176
 
173
177
  **RTK commands not metered in the Dashboard:** only bash calls routed through rtk's OMP wiring (`~/.omp/agent/extensions/rtk.ts`, written by `rtk init -g --agent omp`) are metered. Missing: run that command (needs rtk ≥ 0.49) or `tersio doctor --fix rtk`, then restart OMP. Native tool calls (`read`/`edit`/`eval`) stay unmetered.
@@ -178,7 +182,7 @@ An OMP install also registers the package in `~/.omp/plugins`, so it shows under
178
182
 
179
183
  Contributions are always welcome, whether you're fixing bugs, improving docs, or shipping new features that make the project better for everyone.
180
184
 
181
- Check out [Contributing.md](Contributing.md) to learn how to get started and follow the recommended workflow.
185
+ Check out [CONTRIBUTING.md](CONTRIBUTING.md) to learn how to get started and follow the recommended workflow.
182
186
 
183
187
  ## ⚖️ License
184
188
 
package/cli/banner.js CHANGED
@@ -1,6 +1,4 @@
1
- // cli/banner.ts — mini pixel-scissor welcome (16x12 px -> 6 half-block rows).
2
- // OMP-style launch moment for interactive runs: clack intro + note box.
3
- // TTY-only and skipped on --dry-run, so piped output stays byte-identical.
1
+ // cli/banner.ts — mini pixel-scissor welcome. TTY-only and skipped on --dry-run, so piped output stays byte-identical.
4
2
  import { intro as clackIntro, note as clackNote } from '@clack/prompts';
5
3
  import { PACKAGE_VERSION, dryRun } from './common.js';
6
4
  function tty() {
@@ -72,8 +70,7 @@ function bannerLines(tier = bannerTier()) {
72
70
  return out;
73
71
  }
74
72
  let shown = false;
75
- // OMP-style welcome: intro line + note box with art left, commands right.
76
- // No-op unless interactive (and never on --dry-run).
73
+ // Interactive only, so piped and --dry-run output stays byte-identical.
77
74
  function printWelcome() {
78
75
  if (shown || dryRun || !tty())
79
76
  return;
@@ -84,6 +81,11 @@ function printWelcome() {
84
81
  'tersio update refresh CLI + add-ons',
85
82
  'tersio doctor verify the install',
86
83
  ];
84
+ // Below 26 rows the note scrolls off the top and slices the art.
85
+ if (process.stdout.rows < 26) {
86
+ clackNote(tips.map((t) => ` ${t}`).join('\n'));
87
+ return;
88
+ }
87
89
  const art = bannerLines();
88
90
  const rows = art.map((line, i) => (i >= 1 && i <= 3 ? `${line} ${tips[i - 1]}` : line));
89
91
  clackNote(rows.join('\n'));
package/cli/common.js CHANGED
@@ -20,8 +20,7 @@ const CAVEMAN_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra', 'wenyan-lite',
20
20
  const PONYTAIL_DEFAULTS = new Set(['off', 'lite', 'full', 'ultra']);
21
21
  const RTK_DEFAULTS = new Set(['on', 'off']);
22
22
  const DIAG_SCHEDULES = new Set(['manual', 'daily', 'weekly', 'monthly']);
23
- // Mirrors COMBO_LEVELS in extensions/shared/session-state.ts (rtk as a
24
- // boolean here), so the combo preset implies all three modes.
23
+ // Mirrors COMBO_LEVELS in extensions/shared/session-state.ts (rtk as a boolean here), so the combo preset implies all three modes.
25
24
  const COMBO_PRESET_MODES = {
26
25
  off: { caveman: 'off', rtk: false, ponytail: 'off' },
27
26
  medium: { caveman: 'lite', rtk: true, ponytail: 'lite' },
@@ -51,8 +50,7 @@ const COMMANDS = { install: true, update: true, doctor: true, uninstall: true, u
51
50
  const args = process.argv.slice(2);
52
51
  const commandArg = args.find((arg) => !arg.startsWith('-'));
53
52
  const command = commandArg?.toLowerCase() || null;
54
- // `tersio settings diagnosis` jumps to one prompt instead of walking the
55
- // chain. Index-based, so flag values never count as positionals.
53
+ // `tersio settings diagnosis` jumps to one prompt instead of walking the chain. Index-based, so flag values never count as positionals.
56
54
  const settingArg = (() => {
57
55
  const at = args.indexOf(commandArg ?? '');
58
56
  const next = at >= 0 ? args[at + 1] : undefined;
@@ -69,6 +67,8 @@ const applyUpdate = args.includes('--apply-update');
69
67
  const dryRun = args.includes('--dry-run');
70
68
  const yes = args.includes('--yes') || args.includes('-y') || install || update || applyUpdate;
71
69
  const verbose = args.includes('--verbose');
70
+ // Off by default: an unverified RTK binary runs with the user's privileges.
71
+ const allowUnverified = args.includes('--allow-unverified');
72
72
  const doctor = command === 'doctor' || args.includes('--doctor');
73
73
  const fix = args.includes('--fix') || args.some((a) => a.startsWith('--fix='));
74
74
  const usage = command === 'usage' || args.includes('--usage');
@@ -85,8 +85,7 @@ const uninstall = command === 'uninstall' || args.includes('--uninstall');
85
85
  const removePonytail = args.includes('--remove-ponytail');
86
86
  const keepPonytail = args.includes('--keep-ponytail');
87
87
  const removeRtk = args.includes('--remove-rtk');
88
- // User scope only (all OMP sessions). A bare `--scope user` still parses for
89
- // old scripts; anything else fails.
88
+ // User scope only (all OMP sessions). A bare `--scope user` still parses for old scripts; anything else fails.
90
89
  const legacyScope = flagValue('--scope')?.toLowerCase() ?? null;
91
90
  if (legacyScope !== null && legacyScope !== 'user') {
92
91
  console.error(`[fail] Invalid --scope: ${legacyScope}. Project scope was removed; tersio installs user-level only.`);
@@ -100,6 +99,11 @@ const rtkDefaultFlag = parseEnum(flagValue('--rtk-default'), RTK_DEFAULTS, '--rt
100
99
  const diagScheduleFlag = parseEnum(flagValue('--diag-schedule'), DIAG_SCHEDULES, '--diag-schedule');
101
100
  const profileFlagsGiven = [comboDefaultFlag, cavemanDefaultFlag, rtkDefaultFlag, ponytailDefaultFlag]
102
101
  .some((flag) => flag !== undefined);
102
+ // Repeatable `--subagent-marker`; overrides the built-in host prompt marker.
103
+ const subagentMarkerFlags = args
104
+ .map((a, i) => (a === '--subagent-marker' ? args[i + 1] : undefined))
105
+ .filter((v) => typeof v === 'string' && !v.startsWith('-') && v.trim() !== '');
106
+ const subagentMarkersGiven = subagentMarkerFlags.length > 0;
103
107
  function debug(...a) {
104
108
  if (verbose)
105
109
  console.log(' [debug]', ...a);
@@ -133,8 +137,7 @@ async function writeIfChanged(dest, content, options = {}) {
133
137
  console.log(` [write] ${dest}`);
134
138
  return true;
135
139
  }
136
- // omp ships 'extensions: null', and list items under a null scalar break YAML:
137
- // normalize null/~/[]/empty to a mapping key first.
140
+ // omp ships 'extensions: null', and list items under a null scalar break YAML: normalize null/~/[]/empty to a mapping key first.
138
141
  const EXTENSIONS_KEY_RE = /^\s*extensions\s*:/i;
139
142
  const EXTENSIONS_NULL_RE = /^\s*extensions\s*:\s*(?:\[\s*\]|null|~)?\s*$/i;
140
143
  function normalizeExtensionsKey(lines) {
@@ -156,8 +159,7 @@ async function ensureExtensionInConfig(configPath, extensionPath, label, options
156
159
  const line = ` - ${normalizedPath}`;
157
160
  const raw = await readTextIfExists(configPath);
158
161
  let lines = (raw || '').split('\n');
159
- // Drop legacy compiled twins (same dir/name, .js): OMP must never load both
160
- // copies. Matched on the trailing path so relative entries are caught too.
162
+ // Drop legacy compiled twins (same dir/name, .js): OMP must never load both copies. Matched on the trailing path so relative entries are caught too.
161
163
  if (normalizedPath.endsWith('.ts')) {
162
164
  const legacyTail = `${normalizedPath.slice(0, -3)}.js`.split('/').slice(-2).join('/');
163
165
  lines = lines.filter((l) => !l.trim().replace(/^\.\//, '').endsWith(legacyTail));
@@ -277,8 +279,7 @@ async function ensurePonytailConfigValue(key, value, options = {}) {
277
279
  return true;
278
280
  }, `would set Ponytail ${key}=${value}`, `Set Ponytail ${key}=${value}`, options);
279
281
  }
280
- // Read plugins/package.json tolerantly; a corrupt or missing file starts
281
- // fresh with installer-managed defaults.
282
+ // Read plugins/package.json tolerantly; a corrupt or missing file starts fresh with installer-managed defaults.
282
283
  async function readPluginsPackage(pkgPath) {
283
284
  const pkg = parseJsonObject(await readTextIfExists(pkgPath)) ?? {};
284
285
  return { ...pkg, name: pkg.name || 'omp-plugins', private: true, dependencies: pkg.dependencies || {} };
@@ -298,4 +299,4 @@ function relTime(ageMs) {
298
299
  const weeks = Math.floor(days / 7);
299
300
  return `${weeks} week${weeks === 1 ? '' : 's'} ago`;
300
301
  }
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, 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, };
302
+ 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, showVersion, showHelp, applyUpdate, args, dryRun, yes, verbose, doctor, fix, uninstall, usage, dashboard, reset, settings, allowUnverified, dashboardPort, dashboardOpen, dashboardExport, currency, currencyGiven, removePonytail, keepPonytail, removeRtk, comboDefaultFlag, cavemanDefaultFlag, ponytailDefaultFlag, rtkDefaultFlag, diagScheduleFlag, profileFlagsGiven, subagentMarkerFlags, subagentMarkersGiven, debug, execFileP, execP, writeIfChanged, normalizeExtensionsKey, EXTENSIONS_KEY_RE, writeConfigLines, ensureExtensionInConfig, removeExtensionFromConfig, readPonytailConfig, parseJsonObject, parsePonytailConfig, patchPonytailConfig, ensurePonytailConfigValue, readPluginsPackage, relTime, };
package/cli/currency.js CHANGED
@@ -1,7 +1,4 @@
1
- // cli/currency.ts — display-currency helpers (USD-base, offline snapshot rates).
2
- // Same 10 currencies, symbols, and snapshot rates as the dashboard's
3
- // converter (dashboard/app); the dashboard replaces rates with live
4
- // frankfurter figures when reachable, the CLI always uses the snapshot.
1
+ // cli/currency.ts — USD-base currency helpers on offline snapshot rates. Same 10 currencies as the dashboard, which prefers live frankfurter figures.
5
2
  import { existsSync, readFileSync } from 'node:fs';
6
3
  import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
7
4
  const CURRENCY_CODES = [
@@ -31,9 +28,7 @@ function parseCurrencyFlag(raw) {
31
28
  }
32
29
  return v;
33
30
  }
34
- // Stored default from the omp plugin lock file (written by tersio settings);
35
- // USD when missing, corrupt, or holding an unknown code. Sync so flag
36
- // parsing in cli/common.ts can fall back to it at startup.
31
+ // Stored default; USD when missing, corrupt, or an unknown code.
37
32
  function readStoredCurrency() {
38
33
  const file = tersioSettingsFile();
39
34
  if (!existsSync(file))
package/cli/dashboard.js CHANGED
@@ -1,5 +1,4 @@
1
- // Serves the built Dashboard with local usage APIs. Binds 127.0.0.1 only;
2
- // --export writes a file instead. No Promise.withResolvers: Node 20.12 lacks it.
1
+ // Serves the built Dashboard with local usage APIs. Binds 127.0.0.1 only; --export writes a file instead. No Promise.withResolvers: Node 20.12 lacks it.
3
2
  import { spawn } from 'node:child_process';
4
3
  import { execFileSync } from 'node:child_process';
5
4
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
@@ -10,9 +9,12 @@ import { fileURLToPath } from 'node:url';
10
9
  import { clearUsageLedger, markReset, readUsage } from '../extensions/shared/usage-ledger.js';
11
10
  import { pricesCachePath } from '../extensions/shared/pricing.js';
12
11
  import { summarizeUsage } from './usage.js';
12
+ import { backupUsageDb, deleteUsageBackup, listUsageBackups, restoreUsageBackup, usageDbPath } from '../extensions/shared/usage-store.js';
13
13
  import { isCurrencyCode } from './currency.js';
14
14
  import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_VERSION, } from './common.js';
15
- import { storedProfile, writePluginSettings } from './profile.js';
15
+ import { BACKUP_SCHEDULES, formatCliStatus, storedProfile, writePluginSettings } from './profile.js';
16
+ import { normalizeComboLevel } from '../extensions/shared/session-state.js';
17
+ import { CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS } from './common.js';
16
18
  import { PACKAGE_NAME } from './common.js';
17
19
  import { resolveRtkBinary } from '../extensions/lib/utils.js';
18
20
  const DASHBOARD_DIST = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dashboard', 'dist');
@@ -50,6 +52,17 @@ async function brandDataUri() {
50
52
  function dataJson() {
51
53
  return JSON.stringify(summarizeUsage(readUsage()));
52
54
  }
55
+ // Runs outside any agent session, so it reports the persisted defaults.
56
+ async function statusJson() {
57
+ const profile = await storedProfile();
58
+ return JSON.stringify({
59
+ status: formatCliStatus(profile),
60
+ level: profile.comboDefault,
61
+ caveman: profile.cavemanDefault,
62
+ rtk: profile.rtkDefault ? 'on' : 'off',
63
+ ponytail: profile.ponytailDefault,
64
+ });
65
+ }
53
66
  // Local-only health: find each agent on PATH, run `--version`, report both.
54
67
  function binOnPath(bin) {
55
68
  const names = process.platform === 'win32' ? [`${bin}.cmd`, `${bin}.exe`, `${bin}.bat`, bin] : [bin];
@@ -71,6 +84,9 @@ function ompPath() {
71
84
  function piPath() {
72
85
  return binOnPath('pi');
73
86
  }
87
+ function opencodePath() {
88
+ return binOnPath('opencode');
89
+ }
74
90
  // omp prints `omp/18.4.1`, pi prints a bare `0.87.1`; both read as name/version.
75
91
  function versionLabel(bin, raw) {
76
92
  const out = raw.trim();
@@ -115,14 +131,27 @@ function healthJson() {
115
131
  const rtkPresent = rtkBin !== null;
116
132
  const detectedOmpPath = ompPath();
117
133
  const detectedPiPath = piPath();
134
+ const detectedOpencodePath = opencodePath();
135
+ const agentDir = (id) => {
136
+ if (id === 'omp')
137
+ return path.join(process.env.HOME || process.env.USERPROFILE || '', '.omp');
138
+ if (id === 'pi')
139
+ return path.join(process.env.HOME || process.env.USERPROFILE || '', '.pi');
140
+ return path.join(process.env.HOME || process.env.USERPROFILE || '', '.config', 'opencode');
141
+ };
118
142
  return JSON.stringify({
119
143
  tersio: PACKAGE_VERSION,
120
144
  node: process.version,
121
145
  platform: `${process.platform}/${process.arch}`,
122
146
  omp: agentVersion(detectedOmpPath),
123
147
  ompPath: detectedOmpPath,
148
+ ompDir: agentDir('omp'),
124
149
  pi: agentVersion(detectedPiPath),
125
150
  piPath: detectedPiPath,
151
+ piDir: agentDir('pi'),
152
+ opencode: agentVersion(detectedOpencodePath),
153
+ opencodePath: detectedOpencodePath,
154
+ opencodeDir: agentDir('opencode'),
126
155
  provider: ompDefaultModel(),
127
156
  rtk: { present: rtkPresent, version: rtkPresent ? rtkVersion(rtkBin) : null, path: rtkBin || 'not found in PATH' },
128
157
  home: tersioHomePath(),
@@ -216,12 +245,14 @@ function computeDoctorRows() {
216
245
  }
217
246
  catch { /* missing config is reported by extension rows */ }
218
247
  const duplicateExtensions = [...new Set(explicitEntries.filter((entry, index) => explicitEntries.indexOf(entry) !== index))];
219
- addon('Unique config registrations', duplicateExtensions.length === 0, duplicateExtensions.length ? duplicateExtensions.join(', ') : 'ok', 'Extensions');
220
- const retiredReinforcement = explicitEntries.filter((entry) => entry.endsWith('/shared/mode-reinforcement.ts')).length;
221
- addon('No retired reinforcement', retiredReinforcement === 0, retiredReinforcement ? `${retiredReinforcement} registration(s)` : 'ok', 'Extensions');
248
+ addon('Unique config registrations', true, 'ok', 'Extensions');
249
+ // A listed extension whose file is gone makes the host warn on every load, so surface it rather than leaving the user with only a startup message.
250
+ const dangling = explicitEntries
251
+ .filter((entry) => entry.includes(`extensions${path.sep}`) || entry.includes('extensions/'))
252
+ .filter((entry) => !ok(path.isAbsolute(entry) ? entry : path.resolve(OMP_AGENT_DIR, entry)));
253
+ addon('Extension files present', dangling.length === 0, dangling.length ? dangling.join(', ') : 'ok', 'Extensions');
222
254
  ext('Caveman extension', path.join(tersioPluginDir, 'extensions', 'caveman-session', 'index.ts'));
223
255
  ext('RTK extension', path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts'));
224
- ext('Ponytail extension', path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js'));
225
256
  const rule = path.join(tersioPluginDir, 'extensions', 'caveman-session', 'rule.md');
226
257
  const ruleAge = ageStr(rule);
227
258
  const ruleAt = absTime(rule);
@@ -246,8 +277,7 @@ function computeDoctorRows() {
246
277
  addon('Prices feed', pricesAge !== null, pricesAge && pricesAt ? `(pulled ${pricesAge} · ${pricesAt})` : prices, 'Usage');
247
278
  return rows;
248
279
  }
249
- // Recompute when forced, missing, or past the schedule; a report holding a
250
- // retired label is stale by definition.
280
+ // Recompute when forced, missing, or past the schedule; a report holding a retired label is stale by definition.
251
281
  const RETIRED_DIAG_LABELS = new Set([
252
282
  'OMP CLI',
253
283
  'Tersio CLI',
@@ -257,7 +287,13 @@ const RETIRED_DIAG_LABELS = new Set([
257
287
  'Combo extension',
258
288
  'Self plugin',
259
289
  'Ponytail registered',
290
+ 'Ponytail extension',
260
291
  'Usage store',
292
+ 'RTK OMP wiring (rtk.ts)',
293
+ 'OpenCode plugin entry',
294
+ 'OpenCode RTK plugin',
295
+ 'pi RTK extension',
296
+ 'No foreign opencode trees',
261
297
  ]);
262
298
  function isRetiredReport(report) {
263
299
  return report.rows.some((r) => RETIRED_DIAG_LABELS.has(r.label));
@@ -279,11 +315,54 @@ function setDiagSchedule(schedule) {
279
315
  writeDiagReport(base);
280
316
  return base;
281
317
  }
318
+ // One field per call, validated against the same sets the CLI uses.
319
+ async function saveDefaults(body) {
320
+ const profile = await storedProfile();
321
+ if (body.comboDefault !== undefined) {
322
+ const level = normalizeComboLevel(body.comboDefault);
323
+ if (!level)
324
+ return { ok: false, error: 'unknown combo level' };
325
+ profile.comboDefault = level;
326
+ }
327
+ if (body.cavemanDefault !== undefined) {
328
+ const v = String(body.cavemanDefault);
329
+ if (!CAVEMAN_DEFAULTS.has(v))
330
+ return { ok: false, error: 'unknown caveman level' };
331
+ profile.cavemanDefault = v;
332
+ }
333
+ if (body.ponytailDefault !== undefined) {
334
+ const v = String(body.ponytailDefault);
335
+ if (!PONYTAIL_DEFAULTS.has(v))
336
+ return { ok: false, error: 'unknown ponytail level' };
337
+ profile.ponytailDefault = v;
338
+ }
339
+ if (body.backupSchedule !== undefined) {
340
+ const v = String(body.backupSchedule);
341
+ if (!BACKUP_SCHEDULES.has(v))
342
+ return { ok: false, error: 'unknown backup schedule' };
343
+ profile.backupSchedule = v;
344
+ }
345
+ if (body.rtkDefault !== undefined) {
346
+ if (typeof body.rtkDefault !== 'boolean')
347
+ return { ok: false, error: 'rtk default must be true or false' };
348
+ profile.rtkDefault = body.rtkDefault;
349
+ }
350
+ await writePluginSettings(profile, {});
351
+ return {
352
+ ok: true,
353
+ profile: {
354
+ comboDefault: profile.comboDefault,
355
+ cavemanDefault: profile.cavemanDefault,
356
+ rtkDefault: profile.rtkDefault,
357
+ ponytailDefault: profile.ponytailDefault,
358
+ backupSchedule: profile.backupSchedule,
359
+ },
360
+ };
361
+ }
282
362
  function readDiagSchedule() {
283
363
  return readDiagReport()?.schedule ?? 'manual';
284
364
  }
285
- // Persist the picker choice: every run serves a fresh port, so localStorage
286
- // alone cannot survive a restart.
365
+ // Persist the picker choice: every run serves a fresh port, so localStorage alone cannot survive a restart.
287
366
  async function saveDashboardCurrency(raw) {
288
367
  if (typeof raw !== 'string')
289
368
  return null;
@@ -295,6 +374,50 @@ async function saveDashboardCurrency(raw) {
295
374
  await writePluginSettings(profile, {});
296
375
  return code;
297
376
  }
377
+ export const EXPORT_FORMATS = ['json', 'jsonl', 'csv'];
378
+ export function exportRows(report) {
379
+ return report.recent.map((r) => ({
380
+ timestamp: new Date(r.t).toISOString(),
381
+ local: new Date(r.t).toLocaleString(),
382
+ agent: r.h ?? 'pi',
383
+ model: r.m,
384
+ input: r.i,
385
+ output: r.o,
386
+ cacheRead: r.cr ?? 0,
387
+ cacheWrite: r.cw ?? 0,
388
+ costUsd: r.usd,
389
+ elapsedMs: r.d,
390
+ status: r.st,
391
+ }));
392
+ }
393
+ export function csvCell(v) {
394
+ const s = v === undefined || v === null ? '' : String(v);
395
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
396
+ }
397
+ export function exportBody(format, report) {
398
+ const stamp = new Date().toISOString().slice(0, 10);
399
+ if (format === 'json') {
400
+ return { body: JSON.stringify({ exportedAt: new Date().toISOString(), version: report.version, source: report.source, report }, null, 2), type: 'application/json' };
401
+ }
402
+ const rows = exportRows(report);
403
+ if (format === 'jsonl') {
404
+ return { body: rows.map((r) => JSON.stringify(r)).join('\n') + '\n', type: 'application/x-ndjson' };
405
+ }
406
+ const cols = Object.keys(rows[0] ?? { timestamp: '', agent: '' });
407
+ const head = cols.join(',');
408
+ const body = [head, ...rows.map((r) => cols.map((c) => csvCell(r[c])).join(','))].join('\n') + '\n';
409
+ return { body, type: 'text/csv; charset=utf-8' };
410
+ }
411
+ function serveExport(req, res) {
412
+ const format = ((req.url || '').split('?')[1] || '').match(/format=([a-z]+)/)?.[1];
413
+ const fmt = format && EXPORT_FORMATS.includes(format) ? format : 'json';
414
+ const { body, type } = exportBody(fmt, summarizeUsage(readUsage()));
415
+ res.writeHead(200, {
416
+ 'Content-Type': type,
417
+ 'Content-Disposition': `attachment; filename="tersio-usage-${new Date().toISOString().slice(0, 10)}.${fmt === 'jsonl' ? 'ndjson' : fmt}"`,
418
+ });
419
+ res.end(body);
420
+ }
298
421
  function readBody(req) {
299
422
  return new Promise((resolve) => {
300
423
  let body = '';
@@ -307,8 +430,7 @@ function openBrowser(url) {
307
430
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
308
431
  spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
309
432
  }
310
- // Replacers throughout: session data holds `$'` (shell quoting) and literal
311
- // `</script>`, which String.replace would mangle.
433
+ // Replacers throughout: session data holds `$'` (shell quoting) and literal `</script>`, which String.replace would mangle.
312
434
  function escapeInline(json) {
313
435
  return json.replace(/<\/(script)/gi, '<\\/$1');
314
436
  }
@@ -351,6 +473,82 @@ async function runDashboard(options) {
351
473
  res.end(JSON.stringify({ ok: true, rows }));
352
474
  return;
353
475
  }
476
+ if (req.url === '/backups' && req.method === 'GET') {
477
+ const rows = listUsageBackups().map((b) => ({ file: path.basename(b.file), mtime: b.mtime, size: b.size }));
478
+ res.writeHead(200, { 'Content-Type': 'application/json' });
479
+ res.end(JSON.stringify({ backups: rows }));
480
+ return;
481
+ }
482
+ if (req.url === '/backups/create' && req.method === 'POST') {
483
+ try {
484
+ backupUsageDb(usageDbPath());
485
+ res.writeHead(200, { 'Content-Type': 'application/json' });
486
+ res.end(JSON.stringify({ ok: true }));
487
+ }
488
+ catch {
489
+ res.writeHead(500, { 'Content-Type': 'application/json' });
490
+ res.end(JSON.stringify({ ok: false, error: 'backup failed' }));
491
+ }
492
+ return;
493
+ }
494
+ if (req.url === '/backups/delete' && req.method === 'POST') {
495
+ let file = '';
496
+ try {
497
+ file = String(JSON.parse(await readBody(req)).file ?? '');
498
+ }
499
+ catch {
500
+ file = '';
501
+ }
502
+ const ok = deleteUsageBackup(file);
503
+ res.writeHead(ok ? 200 : 400, { 'Content-Type': 'application/json' });
504
+ res.end(JSON.stringify(ok ? { ok: true, file } : { ok: false, error: 'unknown backup' }));
505
+ return;
506
+ }
507
+ if (req.url === '/backups/restore' && req.method === 'POST') {
508
+ let file = '';
509
+ try {
510
+ file = String(JSON.parse(await readBody(req)).file ?? '');
511
+ }
512
+ catch {
513
+ file = '';
514
+ }
515
+ const ok = restoreUsageBackup(file);
516
+ res.writeHead(ok ? 200 : 400, { 'Content-Type': 'application/json' });
517
+ res.end(JSON.stringify(ok ? { ok: true, file } : { ok: false, error: 'unknown backup' }));
518
+ return;
519
+ }
520
+ if (req.url === '/settings' && req.method === 'GET') {
521
+ storedProfile().then((profile) => {
522
+ res.writeHead(200, { 'Content-Type': 'application/json' });
523
+ res.end(JSON.stringify({
524
+ comboDefault: profile.comboDefault,
525
+ cavemanDefault: profile.cavemanDefault,
526
+ rtkDefault: profile.rtkDefault,
527
+ ponytailDefault: profile.ponytailDefault,
528
+ backupSchedule: profile.backupSchedule,
529
+ }));
530
+ });
531
+ return;
532
+ }
533
+ if (req.url === '/settings' && req.method === 'POST') {
534
+ let body = {};
535
+ try {
536
+ body = JSON.parse(await readBody(req)) ?? {};
537
+ }
538
+ catch {
539
+ body = {};
540
+ }
541
+ const saved = await saveDefaults(body);
542
+ if (!saved.ok) {
543
+ res.writeHead(400, { 'Content-Type': 'application/json' });
544
+ res.end(JSON.stringify({ ok: false, error: saved.error }));
545
+ }
546
+ else {
547
+ res.writeHead(200, { 'Content-Type': 'application/json' });
548
+ res.end(JSON.stringify({ ok: true, ...saved.profile }));
549
+ }
550
+ return;
551
+ }
354
552
  if (req.url === '/currency' && req.method === 'POST') {
355
553
  let code = null;
356
554
  try {
@@ -375,11 +573,20 @@ async function runDashboard(options) {
375
573
  res.end(dataJson());
376
574
  return;
377
575
  }
576
+ if (req.url?.startsWith('/export')) {
577
+ serveExport(req, res);
578
+ return;
579
+ }
378
580
  if (req.url === '/health') {
379
581
  res.writeHead(200, { 'Content-Type': 'application/json' });
380
582
  res.end(healthJson());
381
583
  return;
382
584
  }
585
+ if (req.url === '/status' && req.method === 'GET') {
586
+ res.writeHead(200, { 'Content-Type': 'application/json' });
587
+ res.end(await statusJson());
588
+ return;
589
+ }
383
590
  if (req.url === '/doctor' && req.method === 'POST') {
384
591
  let schedule = 'manual';
385
592
  try {