@krtclcdy/tersio 2.23.0 → 2.24.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 (55) hide show
  1. package/README.md +58 -45
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/common.js +16 -20
  5. package/cli/currency.js +8 -12
  6. package/cli/dashboard.js +49 -37
  7. package/cli/doctor-fix.js +28 -17
  8. package/cli/doctor.js +52 -41
  9. package/cli/hosts.js +118 -0
  10. package/cli/install.js +180 -117
  11. package/cli/interactive.js +27 -1
  12. package/cli/layer.js +24 -0
  13. package/cli/manifest.js +36 -0
  14. package/cli/omp-layer.js +51 -0
  15. package/cli/pi-layer.js +37 -0
  16. package/cli/pi-wiring.js +92 -0
  17. package/cli/profile.js +44 -46
  18. package/cli/reset.js +3 -26
  19. package/cli/rtk-wiring.js +10 -15
  20. package/cli/uninstall.js +135 -44
  21. package/cli/update.js +12 -19
  22. package/dashboard/dist/index.html +16 -16
  23. package/extensions/ai-addons-updater/index.js +31 -24
  24. package/extensions/ai-addons-updater/index.ts +32 -32
  25. package/extensions/caveman-session/index.js +6 -6
  26. package/extensions/caveman-session/index.ts +6 -6
  27. package/extensions/combo-toggle/index.js +42 -40
  28. package/extensions/combo-toggle/index.ts +36 -47
  29. package/extensions/lib/utils.js +36 -9
  30. package/extensions/lib/utils.ts +37 -9
  31. package/extensions/rtk-session/index.js +9 -9
  32. package/extensions/rtk-session/index.ts +9 -14
  33. package/extensions/shared/carbon.js +5 -8
  34. package/extensions/shared/carbon.ts +5 -8
  35. package/extensions/shared/host.js +68 -0
  36. package/extensions/shared/host.ts +104 -0
  37. package/extensions/shared/omp-prompt.js +12 -0
  38. package/extensions/shared/plugin-settings.js +43 -30
  39. package/extensions/shared/plugin-settings.ts +45 -26
  40. package/extensions/shared/pricing.js +26 -15
  41. package/extensions/shared/pricing.ts +28 -16
  42. package/extensions/shared/rtk-gain.js +11 -22
  43. package/extensions/shared/rtk-gain.ts +11 -22
  44. package/extensions/shared/session-state.js +21 -25
  45. package/extensions/shared/session-state.ts +22 -26
  46. package/extensions/shared/types.ts +10 -3
  47. package/extensions/shared/usage-ledger.js +24 -36
  48. package/extensions/shared/usage-ledger.ts +29 -50
  49. package/extensions/shared/usage-store.js +14 -12
  50. package/extensions/shared/usage-store.ts +16 -14
  51. package/extensions/tersio-commands/index.js +2 -1
  52. package/extensions/tersio-commands/index.ts +2 -1
  53. package/package.json +4 -2
  54. package/tersio.js +4 -4
  55. package/extensions/shared/mode-reinforcement.js +0 -26
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ![Banner](/assets/Banner.webp)
2
2
 
3
- # ✂️ Tersio — Token-saving OMP Add-ons
3
+ # ✂️ Tersio — Token Saver OMP & Usage Tracker
4
4
 
5
5
  [![npm version](https://shieldcn.dev/npm/@krtclcdy%2Ftersio.svg?variant=branded&size=xs&logo=npm)](https://www.npmjs.com/package/@krtclcdy/tersio)
6
6
  [![node](https://shieldcn.dev/badge/node-%3E%3D20.12-22c55e.svg?variant=branded&size=xs&logo=nodedotjs)](https://nodejs.org)
@@ -8,29 +8,38 @@
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
- Terse replies, compact shell output, and minimal code decisions for [Oh My Pi (OMP)](https://github.com/can1357/oh-my-pi) — one-command combo presets.
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.
12
14
 
13
15
  ## ⚡ Getting Started
14
16
 
17
+ All install paths in one table, with update and remove commands: [INSTALL.md](./INSTALL.md).
18
+
15
19
  **Default** (macOS/Linux/WSL) — one line. Installs the CLI via npm, then runs the main installer (scope + Combo preset menus) in the same pass:
16
20
 
17
21
  ```bash
18
22
  curl -fsSL https://github.com/KurutoDenzeru/tersio/releases/latest/download/install.sh | sh
19
23
  ```
20
24
 
21
- Prefer OMP-managed updates? Install as an OMP plugin instead:
25
+ Then run the CLI. Bare `tersio` opens a menu and asks which agent to install into:
22
26
 
23
27
  ```bash
24
- omp plugin install @krtclcdy/tersio
28
+ tersio # menu — picks Oh My Pi or pi
29
+ tersio install --host pi # pi, non-interactive
30
+ tersio install --host omp # Oh My Pi, non-interactive
25
31
  ```
26
32
 
27
- All modes load always. Only the updater stays toggleable:
33
+ Or let the host install the package itself. Same result, host-managed updates:
28
34
 
29
35
  ```bash
30
- omp plugin features @krtclcdy/tersio --disable updater
36
+ omp plugin install @krtclcdy/tersio
37
+ pi install npm:@krtclcdy/tersio
31
38
  ```
32
39
 
33
- Then restart OMP and enable a preset:
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.
41
+
42
+ Then restart the agent and enable a preset:
34
43
 
35
44
  ```text
36
45
  /combo balanced
@@ -40,14 +49,6 @@ Individual toggles: `/caveman full` · `/rtk on` · `/ponytail full`. Everything
40
49
 
41
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
51
 
43
- Session-start defaults (prompted during `install`, or flags):
44
-
45
- ```bash
46
- tersio install --combo-default balanced --caveman-default lite --rtk-default on
47
- ```
48
-
49
- Defaults apply to fresh sessions only — anything persisted with `/combo`, `/caveman`, or `/rtk` wins. Stored as plugin settings (`omp plugin config get @krtclcdy/tersio comboDefault`).
50
-
51
52
  One-off use without installing:
52
53
 
53
54
  ```bash
@@ -56,32 +57,34 @@ npm exec --yes --prefer-online --package=@krtclcdy/tersio@latest -- tersio insta
56
57
 
57
58
  ### Requirements
58
59
 
59
- - [OMP](https://github.com/can1357/oh-my-pi)
60
+ - [OMP](https://github.com/can1357/oh-my-pi) or [pi](https://github.com/earendil-works/pi)
60
61
  - Node.js 20.12+ with npm
61
62
 
62
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/`.
63
64
 
64
65
  ## 📊 Benchmarks
65
66
 
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).
67
-
68
- | Measured surface (n) | Baseline → Tersio | Δ |
69
- |---|---|---|
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.
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).
68
+
69
+ | Mode | Surface (n) | Base → Tersio | Δ |
70
+ |---|---|---|---|
71
+ | `/caveman full` | Terse reply, o200k tokens (3 samples, p50) | 124 → 107 | **−13.7% reply text** |
72
+ | `/caveman ultra` | Terse reply, o200k tokens (3 samples, p50) | 124 → 109 | **−12.1% reply text** |
73
+ | `/ponytail lite` | Code reply, o200k tokens (3 samples, p50) | 306 → 177 | **−42.2% code** |
74
+ | `/ponytail full` | Code reply, o200k tokens (3 samples, p50) | 306 → 208 | **−32.0% code** |
75
+ | `/ponytail ultra` | Code reply, o200k tokens (3 samples, p50) | 306 → 112 | **−63.4% code** |
76
+ | `/combo medium` | Code reply, o200k tokens (3 samples, p50) | 306 → 132 | **−56.9% code** |
77
+ | `/rtk on` — `bun run test` | Command output (3 runs, p50) | 4,003 → 54 | **−98.7% output** |
78
+ | `/rtk on` — `find . -name '*.test.ts'` | Command output (3 runs, p50) | 10,908 → 248 | **−97.7% output** |
79
+ | `/rtk on` — `ls -la .` | Command output (3 runs, p50) | 983 → 275 | **−72.0% output** |
80
+ | `/rtk on` — `git status` | Command output (3 runs, p50) | 72 → 18 | **−75.0% output** |
81
+ | `/rtk on` — `bun run build` | Command output (3 runs, p50) | 140 → 114 | **−18.6% output** |
82
+
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.
84
+
85
+ The Command tools dashboard reports weighted RTK command-output savings and OMP-specific adoption from executed Bash records. It does not estimate provider billing.
82
86
  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.
83
87
 
84
-
85
88
  ## 📈 Dashboard
86
89
 
87
90
  `tersio dashboard --open` serves a local dashboard (127.0.0.1 only) that charts your own savings from `~/.tersio/usage.db`. Three views:
@@ -109,12 +112,11 @@ Upstream Ponytail's fair agentic benchmark reports 54% less code, 22% fewer toke
109
112
  |---|---|
110
113
  | `tersio install` | Install (user scope: all OMP sessions) |
111
114
  | `tersio update` | Refresh the CLI, extensions, and add-ons (RTK binary, Caveman rule, Ponytail) |
112
- | `tersio reinstall` | Fresh install, preserving the Ponytail package |
113
115
  | `tersio doctor` | Check OMP, extension, Ponytail, and RTK health (`--fix` repairs, `--fix=<scope>` one scope, `--dry-run` previews) |
114
116
  | `tersio usage` | Ledger-backed usage + savings report |
115
117
  | `tersio dashboard` | Open the Dashboard (`--open`, `--export <file>`, `--port <n>`, `--currency <code>`; serves localhost only) |
116
118
  | `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 |
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) |
119
+ | `tersio uninstall` | Remove extensions, registrations, the Ponytail plugin, and the `rtk.ts` OMP wiring (`--keep-ponytail` keeps Ponytail; `--remove-rtk` also removes the RTK binary) |
118
120
  | `tersio version` | Print version |
119
121
 
120
122
  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.
@@ -139,25 +141,36 @@ Mode switches live on their own commands; bare `/tersio` prints status.
139
141
 
140
142
  ## 🗂️ Files and backups
141
143
 
144
+ Shared, and identical for every host:
145
+
142
146
  | What | Path |
143
147
  |---|---|
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 |
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 |
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 |
148
+ | Session-start defaults | `~/.tersio/settings.json` — combo, caveman, rtk, ponytail, and the display currency |
149
+ | RTK binary | `~/.bun/bin/rtk` (`rtk.exe` on Windows). The runtime resolves `PATH` first, then this managed path. |
150
+ | Usage ledger and price cache | `~/.tersio/` — local only, nothing leaves the machine |
149
151
 
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.
152
+ Per host, written by `tersio install --host <omp|pi>`:
151
153
 
152
- User-level installs also register the package in `~/.omp/plugins` (visible in OMP **Settings → Plugins**), directly through the plugin manifest. Active modes reassert after Ponytail's prompt block on every top-level turn, including after history compaction.
154
+ | Host | Extensions | Ponytail |
155
+ |---|---|---|
156
+ | 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
+ | 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 |
158
+
159
+ Both hosts carry the same `shared/` and `lib/` modules beside those directories, and both load the same five extensions from the same sources.
160
+
161
+ 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
+
163
+ An OMP install also registers the package in `~/.omp/plugins`, so it shows under OMP **Settings → Plugins**. Active modes reassert after Ponytail's prompt block on every top-level turn, including after history compaction.
153
164
 
154
165
  ## 🛠️ Troubleshooting
155
166
 
156
- **Ponytail or Combo command missing:** confirm `~/.omp/plugins/package.json` lists `@krtclcdy/tersio`, then run `tersio reinstall`, restart OMP, and check `tersio doctor`.
167
+ **Ponytail or Combo command missing on OMP:** confirm `~/.omp/plugins/package.json` lists `@krtclcdy/tersio`, then run `tersio install`, restart OMP, and check `tersio doctor`. A damaged tree is repaired by `tersio doctor --fix extensions`.
168
+
169
+ **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.
157
170
 
158
- **RTK missing or not executable:** run `tersio reinstall`, then `tersio doctor`. On Linux/macOS: `chmod +x ~/.bun/bin/rtk`.
171
+ **RTK missing or not executable:** run `tersio install`, then `tersio doctor`. On Linux/macOS: `chmod +x ~/.bun/bin/rtk`.
159
172
 
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.
173
+ **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.
161
174
 
162
175
  **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.
163
176
 
@@ -0,0 +1,42 @@
1
+ // The host registry: one entry per agent, carrying only what the CLI needs to find it, install it and undo it.
2
+ // How the shell rewrite reaches the model. Every host routes it through rtk's own module (`rtk init -g --agent <id>`), so...
3
+ export const REWRITE_WIRING = 'rtk extension · auto-rewrite';
4
+ const HOSTS = [
5
+ {
6
+ id: 'omp',
7
+ label: 'Oh My Pi (OMP)',
8
+ autoDetect: false,
9
+ configDir: '.omp',
10
+ binaries: ['omp'],
11
+ // Earlier versions wrote these. The tree injects the modes every turn, so a skill is a second copy and a rules file is...
12
+ retired: [
13
+ { path: '.omp/agent/AGENTS.md', kind: 'merged' },
14
+ { path: '.omp/agent/skills/tersio-caveman', kind: 'ours' },
15
+ { path: '.omp/agent/skills/tersio-ponytail', kind: 'ours' },
16
+ { path: '.omp/agent/skills/tersio-rtk', kind: 'ours' },
17
+ ],
18
+ caveats: 'Reference host. The modes are injected by the extension tree in ~/.omp/agent/extensions, so no static copy is written and the rewrite is rtk\'s own module (rtk init -g --agent omp).',
19
+ source: 'https://omp.sh/docs/context-files',
20
+ nativeInstall: { command: 'omp install @krtclcdy/tersio', note: 'or let `tersio install` write the layer for you' },
21
+ },
22
+ {
23
+ id: 'pi',
24
+ label: 'Pi',
25
+ configDir: '.pi/agent',
26
+ configDirEnv: 'PI_CODING_AGENT_DIR',
27
+ binaries: ['pi'],
28
+ retired: [
29
+ { path: '.pi/agent/AGENTS.md', kind: 'merged' },
30
+ { path: '.pi/agent/skills/tersio-caveman', kind: 'ours' },
31
+ { path: '.pi/agent/skills/tersio-ponytail', kind: 'ours' },
32
+ { path: '.pi/agent/skills/tersio-rtk', kind: 'ours' },
33
+ ],
34
+ caveats: 'AGENTS.override.md replaces rather than merges. Never write SYSTEM.md — it replaces the default system prompt outright. The rewrite is rtk\'s own module, written by `rtk init -g --agent pi`; rtk owns that format, so no tersio release is needed when it changes.',
35
+ source: 'https://pi.dev/docs/latest/extensions',
36
+ nativeInstall: { command: 'pi install npm:@krtclcdy/tersio', note: 'declares the `pi` manifest in package.json' },
37
+ },
38
+ ];
39
+ function byId(id) {
40
+ return HOSTS.find((h) => h.id === id);
41
+ }
42
+ export { HOSTS, byId };
package/cli/agents.js ADDED
@@ -0,0 +1,266 @@
1
+ // Host selection, persistence, and the filesystem side of asking what a host has on disk.
2
+ import fsSync from 'node:fs';
3
+ import path from 'node:path';
4
+ import { HOSTS, REWRITE_WIRING } from './agent-hosts.js';
5
+ import { reportOmpLayer } from './omp-layer.js';
6
+ import { reportPiLayer } from './pi-layer.js';
7
+ const SELECTION_FILE = 'agents.json';
8
+ // Marker pair for the one file we still merge into: a user's own AGENTS.md.
9
+ const START = '<!-- tersio:start -->';
10
+ const END = '<!-- tersio:end -->';
11
+ function selectionPath(home) {
12
+ return path.join(home, '.tersio', SELECTION_FILE);
13
+ }
14
+ // Host ids only, in registry order, de-duplicated, unknown ids dropped.
15
+ export function normalizeIds(ids) {
16
+ const wanted = new Set(ids);
17
+ return HOSTS.filter((h) => wanted.has(h.id)).map((h) => h.id);
18
+ }
19
+ // `$HOME`-relative form of a path, for previews that must stay readable.
20
+ export function displayPath(target, home) {
21
+ if (!home)
22
+ return target;
23
+ const prefix = home.endsWith(path.sep) ? home : `${home}${path.sep}`;
24
+ if (!target.startsWith(prefix))
25
+ return target;
26
+ return `~/${target.slice(prefix.length).split(path.sep).join('/')}`;
27
+ }
28
+ // The install marker goes in the *label*: clack renders hints on the cursor row only.
29
+ export function agentChoices(state) {
30
+ return HOSTS.map((host) => {
31
+ const info = state?.get(host.id);
32
+ if (!info) {
33
+ return {
34
+ value: host.id,
35
+ label: host.id === 'omp' ? `${host.label} (extensions + Ponytail)` : host.label,
36
+ hint: REWRITE_WIRING,
37
+ };
38
+ }
39
+ const mark = info.installed ? 'installed' : 'not installed';
40
+ const size = info.dirs > 0 ? `${info.dirs} dir${info.dirs === 1 ? '' : 's'}` : 'nothing';
41
+ return { value: host.id, label: `${host.label} — ${size} · ${mark}`, hint: REWRITE_WIRING };
42
+ });
43
+ }
44
+ // An uninstall row for a host with nothing of ours answers a question nobody asked.
45
+ export function installedRows(state) {
46
+ return agentChoices(state).filter((choice) => state.get(choice.value)?.installed !== false);
47
+ }
48
+ // True when the path is a directory. The layer's artifacts are directories.
49
+ function isDir(target) {
50
+ try {
51
+ return fsSync.statSync(target).isDirectory();
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ // Reads one host's layer off the filesystem.
58
+ export function hostLayer(host, home) {
59
+ if (host.id === 'omp') {
60
+ const report = reportOmpLayer(home, isDir);
61
+ return {
62
+ extDir: path.join(home, '.omp', 'agent', 'extensions'),
63
+ plugin: report.pluginPath,
64
+ present: report.extensions,
65
+ missing: report.missing,
66
+ installed: report.installed,
67
+ };
68
+ }
69
+ const report = reportPiLayer(home, isDir);
70
+ return {
71
+ extDir: path.join(home, '.pi', 'agent', 'extensions'),
72
+ plugin: null,
73
+ present: report.extensions,
74
+ missing: report.missing,
75
+ installed: report.installed,
76
+ };
77
+ }
78
+ // Both menus seed from the disk, not from the saved selection.
79
+ export function installedState(home) {
80
+ const state = new Map();
81
+ for (const host of HOSTS) {
82
+ const layer = hostLayer(host, home);
83
+ // A partial tree is a partial install; omp's row also needs its plugin registration, which can be pruned while the...
84
+ state.set(host.id, {
85
+ dirs: layer.present.length,
86
+ installed: layer.installed && layer.present.length > 0,
87
+ });
88
+ }
89
+ return state;
90
+ }
91
+ // Host ids tersio has already installed something for.
92
+ export function installedHostIds(state) {
93
+ return normalizeIds([...state].filter(([, info]) => info.installed).map(([id]) => id));
94
+ }
95
+ // An explicit `--agent` wins outright, even for a host not installed.
96
+ export async function resolveAgentSelection(opts) {
97
+ const fromFlag = normalizeIds(opts.flag ?? []);
98
+ if (fromFlag.length > 0) {
99
+ return { ids: fromFlag, source: 'flag', addedByDetection: [] };
100
+ }
101
+ const stored = normalizeIds(opts.stored);
102
+ const detected = normalizeIds(opts.detected);
103
+ const union = normalizeIds([...stored, ...detected]);
104
+ const addedByDetection = union.filter((id) => !stored.includes(id));
105
+ if (opts.ask) {
106
+ const answer = await opts.ask();
107
+ if (answer !== null) {
108
+ return { ids: normalizeIds(answer), source: 'prompt', addedByDetection };
109
+ }
110
+ }
111
+ return { ids: union, source: union.length > 0 ? 'auto' : 'none', addedByDetection };
112
+ }
113
+ export function readSelection(home) {
114
+ try {
115
+ const raw = fsSync.readFileSync(selectionPath(home), 'utf8');
116
+ const parsed = JSON.parse(raw);
117
+ if (parsed === null || typeof parsed !== 'object')
118
+ return { hosts: [], updatedAt: 0 };
119
+ const record = parsed;
120
+ const hosts = Array.isArray(record.hosts) ? record.hosts.filter((h) => typeof h === 'string') : [];
121
+ const updatedAt = typeof record.updatedAt === 'number' ? record.updatedAt : 0;
122
+ return { hosts: normalizeIds(hosts), updatedAt };
123
+ }
124
+ catch {
125
+ return { hosts: [], updatedAt: 0 };
126
+ }
127
+ }
128
+ export function writeSelection(home, ids) {
129
+ const target = selectionPath(home);
130
+ fsSync.mkdirSync(path.dirname(target), { recursive: true });
131
+ const payload = { hosts: normalizeIds(ids), updatedAt: Date.now() };
132
+ fsSync.writeFileSync(target, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
133
+ }
134
+ // A config dir, or a binary on PATH. Detection only widens the default set.
135
+ export function detectHosts(home, env = process.env) {
136
+ const found = [];
137
+ for (const host of HOSTS) {
138
+ if (host.autoDetect === false)
139
+ continue;
140
+ const dir = path.join(home, host.configDir);
141
+ if (isDir(dir)) {
142
+ found.push(host.id);
143
+ continue;
144
+ }
145
+ const relocated = host.configDirEnv ? env[host.configDirEnv] : undefined;
146
+ const onPath = relocated && relocated.trim() !== ''
147
+ ? false
148
+ : host.binaries.some((b) => hasBinary(b, env));
149
+ if (onPath)
150
+ found.push(host.id);
151
+ }
152
+ return found;
153
+ }
154
+ // A host's binary on PATH, or null. Shared with the dashboard.
155
+ export function findHostBinary(host, env = process.env) {
156
+ if (process.platform === 'win32') {
157
+ const relocated = host.configDirEnv ? env[host.configDirEnv] : undefined;
158
+ if (relocated && relocated.trim() !== '')
159
+ return null;
160
+ }
161
+ const raw = env.PATH ?? env.Path ?? env.path ?? '';
162
+ if (!raw)
163
+ return null;
164
+ const sep = process.platform === 'win32' ? ';' : ':';
165
+ const exts = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
166
+ for (const binary of host.binaries) {
167
+ if (process.platform === 'win32' && binary === 'cmd')
168
+ continue;
169
+ for (const dir of raw.split(sep)) {
170
+ if (!dir)
171
+ continue;
172
+ for (const ext of exts) {
173
+ const candidate = path.join(dir, binary + ext);
174
+ try {
175
+ if (fsSync.statSync(candidate).isFile())
176
+ return candidate;
177
+ }
178
+ catch { /* keep looking */ }
179
+ }
180
+ }
181
+ }
182
+ return null;
183
+ }
184
+ function hasBinary(name, env) {
185
+ // A bare `cmd` resolves to the system shell on Windows.
186
+ if (process.platform === 'win32' && name === 'cmd')
187
+ return false;
188
+ const raw = env.PATH ?? env.Path ?? env.path ?? '';
189
+ if (!raw)
190
+ return false;
191
+ const sep = process.platform === 'win32' ? ';' : ':';
192
+ const exts = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
193
+ for (const dir of raw.split(sep)) {
194
+ if (!dir)
195
+ continue;
196
+ for (const ext of exts) {
197
+ try {
198
+ if (fsSync.statSync(path.join(dir, name + ext)).isFile())
199
+ return true;
200
+ }
201
+ catch { /* keep looking */ }
202
+ }
203
+ }
204
+ return false;
205
+ }
206
+ // Paths an earlier version wrote and this one no longer does. `ours` is removed outright;
207
+ export function clearRetiredPaths(host, home, options = {}) {
208
+ const removed = [];
209
+ for (const entry of host.retired ?? []) {
210
+ const target = path.join(home, entry.path);
211
+ if (entry.kind === 'merged') {
212
+ let current = null;
213
+ try {
214
+ current = fsSync.readFileSync(target, 'utf8');
215
+ }
216
+ catch {
217
+ current = null;
218
+ }
219
+ if (current === null)
220
+ continue;
221
+ const stripped = removeMarkedBlock(current);
222
+ if (stripped === current)
223
+ continue;
224
+ if (!options.dryRun) {
225
+ if (stripped === null)
226
+ fsSync.rmSync(target, { force: true });
227
+ else
228
+ fsSync.writeFileSync(target, stripped, 'utf8');
229
+ }
230
+ removed.push(target);
231
+ continue;
232
+ }
233
+ if (!isDir(target) && !existsSyncSafe(target))
234
+ continue;
235
+ if (!options.dryRun) {
236
+ try {
237
+ fsSync.rmSync(target, { recursive: true, force: true });
238
+ }
239
+ catch {
240
+ continue;
241
+ }
242
+ }
243
+ removed.push(target);
244
+ }
245
+ return removed;
246
+ }
247
+ // Removes the marked block and the blank line it introduced. Null when nothing is left.
248
+ function removeMarkedBlock(existing) {
249
+ const from = existing.indexOf(START);
250
+ const to = existing.indexOf(END);
251
+ if (from === -1 || to === -1 || to <= from)
252
+ return existing;
253
+ const before = existing.slice(0, from).replace(/\n+$/, '\n');
254
+ const after = existing.slice(to + END.length).replace(/^\n+/, '');
255
+ const merged = `${before}${after}`;
256
+ return merged.trim() ? merged : null;
257
+ }
258
+ function existsSyncSafe(p) {
259
+ try {
260
+ return fsSync.existsSync(p);
261
+ }
262
+ catch {
263
+ return false;
264
+ }
265
+ }
266
+ export { selectionPath };
package/cli/common.js CHANGED
@@ -20,8 +20,8 @@ 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
- // ponytail: Combo preset implies all three modes. Mirrors COMBO_LEVELS in
24
- // extensions/shared/session-state.ts (rtk as boolean here). Single source.
23
+ // Mirrors COMBO_LEVELS in extensions/shared/session-state.ts (rtk as a
24
+ // boolean here), so the combo preset implies all three modes.
25
25
  const COMBO_PRESET_MODES = {
26
26
  off: { caveman: 'off', rtk: false, ponytail: 'off' },
27
27
  medium: { caveman: 'lite', rtk: true, ponytail: 'lite' },
@@ -47,13 +47,12 @@ function flagValue(name) {
47
47
  return args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length);
48
48
  }
49
49
  // --- CLI flags ---
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 };
50
+ const COMMANDS = { install: true, update: true, doctor: true, uninstall: true, usage: true, dashboard: true, reset: true, settings: true, version: true, help: true };
51
51
  const args = process.argv.slice(2);
52
52
  const commandArg = args.find((arg) => !arg.startsWith('-'));
53
53
  const command = commandArg?.toLowerCase() || null;
54
- // Positional after the command: `tersio settings diagnosis` jumps straight
55
- // to one prompt instead of walking the whole chain. Index-based so flag
56
- // values (e.g. `--combo-default balanced`) never count as positionals.
54
+ // `tersio settings diagnosis` jumps to one prompt instead of walking the
55
+ // chain. Index-based, so flag values never count as positionals.
57
56
  const settingArg = (() => {
58
57
  const at = args.indexOf(commandArg ?? '');
59
58
  const next = at >= 0 ? args[at + 1] : undefined;
@@ -64,12 +63,11 @@ const settingArg = (() => {
64
63
  const unknownCommand = command !== null && !COMMANDS[command];
65
64
  const install = command === 'install';
66
65
  const update = command === 'update';
67
- const reinstall = command === 'reinstall';
68
66
  const showVersion = command === 'version' || args.includes('--version') || args.includes('-v');
69
67
  const showHelp = command === 'help' || args.includes('--help') || args.includes('-h');
70
68
  const applyUpdate = args.includes('--apply-update');
71
69
  const dryRun = args.includes('--dry-run');
72
- const yes = args.includes('--yes') || args.includes('-y') || install || update || reinstall || applyUpdate;
70
+ const yes = args.includes('--yes') || args.includes('-y') || install || update || applyUpdate;
73
71
  const verbose = args.includes('--verbose');
74
72
  const doctor = command === 'doctor' || args.includes('--doctor');
75
73
  const fix = args.includes('--fix') || args.some((a) => a.startsWith('--fix='));
@@ -80,16 +78,15 @@ const settings = command === 'settings';
80
78
  const dashboardPort = Number.parseInt(flagValue('--port') ?? '', 10) || 0;
81
79
  const dashboardOpen = args.includes('--open');
82
80
  const dashboardExport = flagValue('--export') ?? null;
83
- // Display currency for usage/dashboard: --currency flag wins, then the stored
84
- // plugin default (tersio settings), then USD.
81
+ // Display currency: --currency flag, then the stored default, then USD.
85
82
  const currency = parseCurrencyFlag(flagValue('--currency')) ?? readStoredCurrency();
86
83
  const currencyGiven = flagValue('--currency') !== undefined;
87
84
  const uninstall = command === 'uninstall' || args.includes('--uninstall');
88
85
  const removePonytail = args.includes('--remove-ponytail');
89
86
  const keepPonytail = args.includes('--keep-ponytail');
90
87
  const removeRtk = args.includes('--remove-rtk');
91
- // Project scope was removed: tersio installs user-level (all OMP sessions)
92
- // only. A bare `--scope user` still parses (old scripts); anything else fails.
88
+ // User scope only (all OMP sessions). A bare `--scope user` still parses for
89
+ // old scripts; anything else fails.
93
90
  const legacyScope = flagValue('--scope')?.toLowerCase() ?? null;
94
91
  if (legacyScope !== null && legacyScope !== 'user') {
95
92
  console.error(`[fail] Invalid --scope: ${legacyScope}. Project scope was removed; tersio installs user-level only.`);
@@ -136,8 +133,8 @@ async function writeIfChanged(dest, content, options = {}) {
136
133
  console.log(` [write] ${dest}`);
137
134
  return true;
138
135
  }
139
- // omp ships 'extensions: null'; appending list items under a null scalar breaks
140
- // YAML parsing. Normalize null/~/[]/empty to a mapping key first.
136
+ // omp ships 'extensions: null', and list items under a null scalar break YAML:
137
+ // normalize null/~/[]/empty to a mapping key first.
141
138
  const EXTENSIONS_KEY_RE = /^\s*extensions\s*:/i;
142
139
  const EXTENSIONS_NULL_RE = /^\s*extensions\s*:\s*(?:\[\s*\]|null|~)?\s*$/i;
143
140
  function normalizeExtensionsKey(lines) {
@@ -159,9 +156,8 @@ async function ensureExtensionInConfig(configPath, extensionPath, label, options
159
156
  const line = ` - ${normalizedPath}`;
160
157
  const raw = await readTextIfExists(configPath);
161
158
  let lines = (raw || '').split('\n');
162
- // Drop legacy compiled twins (same dir/name, .js): previous installs
163
- // registered them, and OMP must never load both copies. Match on the
164
- // trailing path so absolute and relative entries are both caught.
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.
165
161
  if (normalizedPath.endsWith('.ts')) {
166
162
  const legacyTail = `${normalizedPath.slice(0, -3)}.js`.split('/').slice(-2).join('/');
167
163
  lines = lines.filter((l) => !l.trim().replace(/^\.\//, '').endsWith(legacyTail));
@@ -281,8 +277,8 @@ async function ensurePonytailConfigValue(key, value, options = {}) {
281
277
  return true;
282
278
  }, `would set Ponytail ${key}=${value}`, `Set Ponytail ${key}=${value}`, options);
283
279
  }
284
- // Read plugins/package.json tolerantly; corrupt or missing files start fresh
285
- // with installer-managed defaults.
280
+ // Read plugins/package.json tolerantly; a corrupt or missing file starts
281
+ // fresh with installer-managed defaults.
286
282
  async function readPluginsPackage(pkgPath) {
287
283
  const pkg = parseJsonObject(await readTextIfExists(pkgPath)) ?? {};
288
284
  return { ...pkg, name: pkg.name || 'omp-plugins', private: true, dependencies: pkg.dependencies || {} };
@@ -302,4 +298,4 @@ function relTime(ageMs) {
302
298
  const weeks = Math.floor(days / 7);
303
299
  return `${weeks} week${weeks === 1 ? '' : 's'} ago`;
304
300
  }
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, };
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, };
package/cli/currency.js CHANGED
@@ -3,8 +3,7 @@
3
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
- import os from 'node:os';
7
- import path from 'node:path';
6
+ import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
8
7
  const CURRENCY_CODES = [
9
8
  'USD',
10
9
  'PHP',
@@ -36,18 +35,14 @@ function parseCurrencyFlag(raw) {
36
35
  // USD when missing, corrupt, or holding an unknown code. Sync so flag
37
36
  // parsing in cli/common.ts can fall back to it at startup.
38
37
  function readStoredCurrency() {
39
- const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
40
- const lock = path.join(home, '.omp', 'plugins', 'omp-plugins.lock.json');
41
- if (!existsSync(lock))
38
+ const file = tersioSettingsFile();
39
+ if (!existsSync(file))
42
40
  return DEFAULT_CURRENCY;
43
41
  try {
44
- const parsed = JSON.parse(readFileSync(lock, 'utf8'));
45
- if (!parsed || typeof parsed !== 'object' || !('settings' in parsed))
42
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
43
+ if (!parsed || typeof parsed !== 'object')
46
44
  return DEFAULT_CURRENCY;
47
- const entry = parsed.settings?.['@krtclcdy/tersio'];
48
- if (!entry || typeof entry !== 'object')
49
- return DEFAULT_CURRENCY;
50
- const raw = entry.currency;
45
+ const raw = parsed.currency;
51
46
  return typeof raw === 'string' && isCurrencyCode(raw.trim().toUpperCase())
52
47
  ? raw.trim().toUpperCase()
53
48
  : DEFAULT_CURRENCY;
@@ -68,7 +63,8 @@ const CURRENCY_META = {
68
63
  CAD: { symbol: 'C$', decimals: 2 },
69
64
  INR: { symbol: '₹', decimals: 2 },
70
65
  };
71
- const FX_SNAPSHOT_RATES = {
66
+ // Exported so a test can hold it against the Dashboard's copy of the same table.
67
+ export const FX_SNAPSHOT_RATES = {
72
68
  USD: 1,
73
69
  PHP: 58.7,
74
70
  EUR: 0.92,