@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.
- package/README.md +58 -45
- package/cli/agent-hosts.js +42 -0
- package/cli/agents.js +266 -0
- package/cli/common.js +16 -20
- package/cli/currency.js +8 -12
- package/cli/dashboard.js +49 -37
- package/cli/doctor-fix.js +28 -17
- package/cli/doctor.js +52 -41
- package/cli/hosts.js +118 -0
- package/cli/install.js +180 -117
- package/cli/interactive.js +27 -1
- package/cli/layer.js +24 -0
- package/cli/manifest.js +36 -0
- package/cli/omp-layer.js +51 -0
- package/cli/pi-layer.js +37 -0
- package/cli/pi-wiring.js +92 -0
- package/cli/profile.js +44 -46
- package/cli/reset.js +3 -26
- package/cli/rtk-wiring.js +10 -15
- package/cli/uninstall.js +135 -44
- package/cli/update.js +12 -19
- package/dashboard/dist/index.html +16 -16
- package/extensions/ai-addons-updater/index.js +31 -24
- package/extensions/ai-addons-updater/index.ts +32 -32
- package/extensions/caveman-session/index.js +6 -6
- package/extensions/caveman-session/index.ts +6 -6
- package/extensions/combo-toggle/index.js +42 -40
- package/extensions/combo-toggle/index.ts +36 -47
- package/extensions/lib/utils.js +36 -9
- package/extensions/lib/utils.ts +37 -9
- package/extensions/rtk-session/index.js +9 -9
- package/extensions/rtk-session/index.ts +9 -14
- package/extensions/shared/carbon.js +5 -8
- package/extensions/shared/carbon.ts +5 -8
- package/extensions/shared/host.js +68 -0
- package/extensions/shared/host.ts +104 -0
- package/extensions/shared/omp-prompt.js +12 -0
- package/extensions/shared/plugin-settings.js +43 -30
- package/extensions/shared/plugin-settings.ts +45 -26
- package/extensions/shared/pricing.js +26 -15
- package/extensions/shared/pricing.ts +28 -16
- package/extensions/shared/rtk-gain.js +11 -22
- package/extensions/shared/rtk-gain.ts +11 -22
- package/extensions/shared/session-state.js +21 -25
- package/extensions/shared/session-state.ts +22 -26
- package/extensions/shared/types.ts +10 -3
- package/extensions/shared/usage-ledger.js +24 -36
- package/extensions/shared/usage-ledger.ts +29 -50
- package/extensions/shared/usage-store.js +14 -12
- package/extensions/shared/usage-store.ts +16 -14
- package/extensions/tersio-commands/index.js +2 -1
- package/extensions/tersio-commands/index.ts +2 -1
- package/package.json +4 -2
- package/tersio.js +4 -4
- package/extensions/shared/mode-reinforcement.js +0 -26
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|

|
|
2
2
|
|
|
3
|
-
# ✂️ Tersio — Token
|
|
3
|
+
# ✂️ Tersio — Token Saver OMP & Usage Tracker
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@krtclcdy/tersio)
|
|
6
6
|
[](https://nodejs.org)
|
|
@@ -8,29 +8,38 @@
|
|
|
8
8
|
[](https://github.com/KurutoDenzeru/tersio/actions)
|
|
9
9
|
[](./LICENSE)
|
|
10
10
|
|
|
11
|
-
|
|
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
|
-
|
|
25
|
+
Then run the CLI. Bare `tersio` opens a menu and asks which agent to install into:
|
|
22
26
|
|
|
23
27
|
```bash
|
|
24
|
-
|
|
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
|
-
|
|
33
|
+
Or let the host install the package itself. Same result, host-managed updates:
|
|
28
34
|
|
|
29
35
|
```bash
|
|
30
|
-
omp plugin
|
|
36
|
+
omp plugin install @krtclcdy/tersio
|
|
37
|
+
pi install npm:@krtclcdy/tersio
|
|
31
38
|
```
|
|
32
39
|
|
|
33
|
-
|
|
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-
|
|
67
|
-
|
|
68
|
-
|
|
|
69
|
-
|
|
70
|
-
| Terse reply,
|
|
71
|
-
| Terse reply,
|
|
72
|
-
|
|
|
73
|
-
|
|
|
74
|
-
|
|
|
75
|
-
|
|
|
76
|
-
|
|
|
77
|
-
|
|
|
78
|
-
|
|
|
79
|
-
|
|
|
80
|
-
|
|
81
|
-
|
|
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,
|
|
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
|
-
|
|
|
145
|
-
| RTK
|
|
146
|
-
|
|
|
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
|
-
|
|
152
|
+
Per host, written by `tersio install --host <omp|pi>`:
|
|
151
153
|
|
|
152
|
-
|
|
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
|
|
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
|
|
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:**
|
|
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
|
-
//
|
|
24
|
-
//
|
|
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,
|
|
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
|
-
//
|
|
55
|
-
//
|
|
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 ||
|
|
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
|
|
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
|
-
//
|
|
92
|
-
//
|
|
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'
|
|
140
|
-
//
|
|
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):
|
|
163
|
-
//
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
40
|
-
|
|
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(
|
|
45
|
-
if (!parsed || typeof parsed !== 'object'
|
|
42
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
43
|
+
if (!parsed || typeof parsed !== 'object')
|
|
46
44
|
return DEFAULT_CURRENCY;
|
|
47
|
-
const
|
|
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
|
-
|
|
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,
|