@krtclcdy/tersio 2.22.1 → 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 (59) hide show
  1. package/README.md +62 -46
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/common.js +55 -55
  5. package/cli/currency.js +8 -12
  6. package/cli/dashboard.js +76 -49
  7. package/cli/doctor-fix.js +61 -44
  8. package/cli/doctor.js +66 -44
  9. package/cli/hosts.js +118 -0
  10. package/cli/install.js +187 -143
  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 +45 -46
  18. package/cli/reset.js +3 -26
  19. package/cli/rtk-wiring.js +10 -15
  20. package/cli/settings.js +1 -1
  21. package/cli/uninstall.js +135 -44
  22. package/cli/update.js +24 -30
  23. package/cli/usage.js +6 -1
  24. package/dashboard/dist/index.html +16 -16
  25. package/extensions/ai-addons-updater/index.js +36 -25
  26. package/extensions/ai-addons-updater/index.ts +36 -24
  27. package/extensions/caveman-session/index.js +23 -18
  28. package/extensions/caveman-session/index.ts +23 -18
  29. package/extensions/caveman-session/rule.md +90 -0
  30. package/extensions/combo-toggle/index.js +63 -41
  31. package/extensions/combo-toggle/index.ts +61 -48
  32. package/extensions/lib/utils.js +56 -13
  33. package/extensions/lib/utils.ts +55 -13
  34. package/extensions/rtk-session/index.js +20 -19
  35. package/extensions/rtk-session/index.ts +18 -24
  36. package/extensions/shared/carbon.js +5 -8
  37. package/extensions/shared/carbon.ts +5 -8
  38. package/extensions/shared/host.js +68 -0
  39. package/extensions/shared/host.ts +104 -0
  40. package/extensions/shared/omp-prompt.js +12 -0
  41. package/extensions/shared/plugin-settings.js +63 -16
  42. package/extensions/shared/plugin-settings.ts +67 -17
  43. package/extensions/shared/pricing.js +26 -15
  44. package/extensions/shared/pricing.ts +28 -16
  45. package/extensions/shared/rtk-gain.js +13 -24
  46. package/extensions/shared/rtk-gain.ts +14 -25
  47. package/extensions/shared/session-state.js +26 -28
  48. package/extensions/shared/session-state.ts +26 -29
  49. package/extensions/shared/types.ts +15 -3
  50. package/extensions/shared/usage-ledger.js +136 -38
  51. package/extensions/shared/usage-ledger.ts +149 -52
  52. package/extensions/shared/usage-store.js +14 -12
  53. package/extensions/shared/usage-store.ts +16 -14
  54. package/extensions/tersio-commands/index.js +2 -1
  55. package/extensions/tersio-commands/index.ts +2 -1
  56. package/package.json +15 -7
  57. package/tersio.js +6 -6
  58. package/extensions/shared/mode-reinforcement.js +0 -26
  59. package/extensions/shared/mode-reinforcement.ts +0 -29
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
@@ -38,13 +47,7 @@ Then restart OMP and enable a preset:
38
47
 
39
48
  Individual toggles: `/caveman full` · `/rtk on` · `/ponytail full`. Everything starts off until you enable it.
40
49
 
41
- Session-start defaults (prompted during `install`, or flags):
42
-
43
- ```bash
44
- tersio install --combo-default balanced --caveman-default lite --rtk-default on
45
- ```
46
-
47
- 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
+ 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.
48
51
 
49
52
  One-off use without installing:
50
53
 
@@ -54,30 +57,33 @@ npm exec --yes --prefer-online --package=@krtclcdy/tersio@latest -- tersio insta
54
57
 
55
58
  ### Requirements
56
59
 
57
- - [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)
58
61
  - Node.js 20.12+ with npm
59
62
 
60
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/`.
61
64
 
62
65
  ## 📊 Benchmarks
63
66
 
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).
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).
65
68
 
66
- | Measured surface (n) | Baseline → Tersio | Δ |
67
- |---|---|---|
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%** |
79
-
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).
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.
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.
81
87
 
82
88
  ## 📈 Dashboard
83
89
 
@@ -106,12 +112,11 @@ Break-even math, balanced preset (caveman full + rtk + ponytail): a mixed turn c
106
112
  |---|---|
107
113
  | `tersio install` | Install (user scope: all OMP sessions) |
108
114
  | `tersio update` | Refresh the CLI, extensions, and add-ons (RTK binary, Caveman rule, Ponytail) |
109
- | `tersio reinstall` | Fresh install, preserving the Ponytail package |
110
115
  | `tersio doctor` | Check OMP, extension, Ponytail, and RTK health (`--fix` repairs, `--fix=<scope>` one scope, `--dry-run` previews) |
111
116
  | `tersio usage` | Ledger-backed usage + savings report |
112
117
  | `tersio dashboard` | Open the Dashboard (`--open`, `--export <file>`, `--port <n>`, `--currency <code>`; serves localhost only) |
113
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 |
114
- | `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) |
115
120
  | `tersio version` | Print version |
116
121
 
117
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.
@@ -123,9 +128,9 @@ Mode switches live on their own commands; bare `/tersio` prints status.
123
128
  | Command | Purpose |
124
129
  |---|---|
125
130
  | `/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`. |
126
- | `/caveman lite\|full\|ultra\|wenyan\|off\|status` | Terse replies (substance stays); `ultra` uses fragments only, `wenyan` a compressed classical-Chinese-inspired register. |
127
- | `/rtk on\|off\|status` | Compact shell output via the real `rtk` binary; exact bytes (checksums, patches) bypass it by policy. |
128
- | `/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). |
131
+ | `/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
+ | `/rtk on\|off\|status` | Controls RTK session guidance, `rtk_run`, and the automatic hook through shared `RTK_DISABLED` state. Exact bytes bypass RTK. |
133
+ | `/ponytail off\|lite\|full\|ultra\|status` | Minimal code using upstream Ponytail modes. Review runs separately through `/ponytail-review`. |
129
134
  | `/tersio status` | Active modes + combo level; state persists, propagates to subagents, and lights the Combo footer indicator. |
130
135
  | `/tersio check` | Add-on version check |
131
136
  | `/tersio update <ponytail\|rtk\|caveman\|all> [--dry-run]` | Update add-ons, preview with dry-run |
@@ -136,25 +141,36 @@ Mode switches live on their own commands; bare `/tersio` prints status.
136
141
 
137
142
  ## 🗂️ Files and backups
138
143
 
144
+ Shared, and identical for every host:
145
+
139
146
  | What | Path |
140
147
  |---|---|
141
- | Caveman / RTK / Updater / Combo extensions | `~/.omp/agent/extensions/{caveman-session,rtk-session,ai-addons-updater,combo-toggle}/` |
142
- | 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 |
143
- | Ponytail package (bundled tersio dependency — one Plugins row, updates with `tersio update`) | `~/.omp/plugins/node_modules/@dietrichgebert/ponytail/` |
144
- | RTK binary | `~/.bun/bin/rtk` (`rtk.exe` on Windows) |
145
- | Extension registrations | `~/.omp/agent/config.yml` |
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 |
151
+
152
+ Per host, written by `tersio install --host <omp|pi>`:
146
153
 
147
- 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.
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.
148
160
 
149
- 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.
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.
150
164
 
151
165
  ## 🛠️ Troubleshooting
152
166
 
153
- **Ponytail or Combo command missing:** run `tersio reinstall` in OMP's environment, restart OMP, then `tersio doctor` (repairs `config.yml` registrations).
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.
154
170
 
155
- **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`.
156
172
 
157
- **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.
158
174
 
159
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.
160
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 };