@krtclcdy/tersio 2.24.0 → 2.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +28 -24
  2. package/cli/banner.js +7 -5
  3. package/cli/common.js +14 -13
  4. package/cli/currency.js +2 -7
  5. package/cli/dashboard.js +220 -13
  6. package/cli/doctor-fix.js +99 -29
  7. package/cli/doctor.js +32 -35
  8. package/cli/hosts.js +23 -26
  9. package/cli/install.js +228 -115
  10. package/cli/interactive.js +65 -21
  11. package/cli/manifest.js +8 -4
  12. package/cli/profile.js +49 -16
  13. package/cli/reset.js +3 -7
  14. package/cli/rtk-wiring.js +29 -32
  15. package/cli/settings.js +40 -16
  16. package/cli/uninstall.js +79 -29
  17. package/cli/update.js +27 -20
  18. package/cli/usage.js +54 -19
  19. package/dashboard/dist/index.html +18 -18
  20. package/extensions/ai-addons-updater/index.js +75 -20
  21. package/extensions/ai-addons-updater/index.ts +67 -23
  22. package/extensions/caveman-session/index.js +33 -75
  23. package/extensions/caveman-session/index.ts +35 -77
  24. package/extensions/combo-toggle/index.js +40 -62
  25. package/extensions/combo-toggle/index.ts +38 -72
  26. package/extensions/lib/utils.js +64 -15
  27. package/extensions/lib/utils.ts +62 -15
  28. package/extensions/opencode/index.js +104 -0
  29. package/extensions/opencode/server.js +190 -0
  30. package/extensions/opencode/server.ts +200 -0
  31. package/extensions/rtk-session/index.js +38 -57
  32. package/extensions/rtk-session/index.ts +36 -55
  33. package/extensions/shared/carbon.js +4 -10
  34. package/extensions/shared/carbon.ts +5 -10
  35. package/extensions/shared/host.js +95 -56
  36. package/extensions/shared/host.ts +106 -60
  37. package/extensions/shared/plugin-settings.js +6 -18
  38. package/extensions/shared/plugin-settings.ts +6 -18
  39. package/extensions/shared/pricing.js +79 -27
  40. package/extensions/shared/pricing.ts +80 -28
  41. package/extensions/shared/rtk-gain.js +4 -11
  42. package/extensions/shared/rtk-gain.ts +4 -11
  43. package/extensions/shared/session-state.js +28 -39
  44. package/extensions/shared/session-state.ts +29 -38
  45. package/extensions/shared/status.js +38 -0
  46. package/extensions/shared/status.ts +45 -0
  47. package/extensions/shared/types.js +1 -2
  48. package/extensions/shared/types.ts +2 -2
  49. package/extensions/shared/usage-ledger.js +312 -57
  50. package/extensions/shared/usage-ledger.ts +321 -61
  51. package/extensions/shared/usage-store.js +354 -38
  52. package/extensions/shared/usage-store.ts +360 -43
  53. package/extensions/tersio-commands/index.js +5 -14
  54. package/extensions/tersio-commands/index.ts +5 -14
  55. package/package.json +12 -7
  56. package/tersio.js +4 -6
  57. package/cli/agent-hosts.js +0 -42
  58. package/cli/agents.js +0 -266
  59. package/cli/layer.js +0 -24
  60. package/cli/omp-layer.js +0 -51
  61. package/cli/pi-layer.js +0 -37
  62. package/cli/pi-wiring.js +0 -92
  63. package/extensions/shared/omp-prompt.js +0 -12
package/cli/uninstall.js CHANGED
@@ -1,14 +1,13 @@
1
1
  // cli/uninstall.ts — remove managed extensions, plugins, and binaries.
2
2
  import { promises as fs } from 'node:fs';
3
3
  import path from 'node:path';
4
- import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, RTK_BINARY_NAME, args, dryRun, keepPonytail, removePonytail, removeRtk, yes, debug, writeConfigLines, } from './common.js';
5
- import { askInteractiveChoice, closeRL, confirmDestructive, tty } from './interactive.js';
4
+ import { BUN_BIN_DIR, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, RTK_BINARY_NAME, args, dryRun, keepPonytail, removePonytail, removeRtk, yes, debug, writeConfigLines, } from './common.js';
5
+ import { askInteractiveChoice, closeRL, confirmDestructive, tty, sayTagged } from './interactive.js';
6
6
  import { detectHosts, hostHint, hostLabel, parseHostArg } from './hosts.js';
7
7
  import { findHoistedPackage, piAgentDir, readTextIfExists } from '../extensions/lib/utils.js';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
10
- // The pi tree mirrors the OMP one. Ponytail and rtk stay: a package and a
11
- // shared binary are not ours to delete.
10
+ // The pi tree mirrors the OMP one. Ponytail and rtk stay: a package and a shared binary are not ours to delete.
12
11
  const PONYTAIL_PKG = '@dietrichgebert/ponytail';
13
12
  const PI_TREE_DIRS = [
14
13
  'caveman-session',
@@ -18,11 +17,12 @@ const PI_TREE_DIRS = [
18
17
  'tersio-commands',
19
18
  'shared',
20
19
  'lib',
20
+ 'opencode',
21
21
  ];
22
22
  // One `pi remove`, for both packages the installer adds there.
23
23
  async function piRemove(spec, shouldDryRun) {
24
24
  if (shouldDryRun) {
25
- console.log(` [dry-run] would run: pi remove ${spec}`);
25
+ sayTagged(` [dry-run] would run: pi remove ${spec}`);
26
26
  return;
27
27
  }
28
28
  try {
@@ -35,11 +35,11 @@ async function piRemove(spec, shouldDryRun) {
35
35
  resolve();
36
36
  });
37
37
  });
38
- console.log(` [ok] removed the pi package ${spec}`);
38
+ sayTagged(` [ok] removed the pi package ${spec}`);
39
39
  }
40
40
  catch (e) {
41
- console.log(` [fail] pi remove: ${e.message}`);
42
- console.log(` [hint] Manual: pi remove ${spec}`);
41
+ sayTagged(` [fail] pi remove: ${e.message}`);
42
+ sayTagged(` [hint] Manual: pi remove ${spec}`);
43
43
  }
44
44
  }
45
45
  // Only the skill directories the installer copied, never pi's own skills dir.
@@ -52,13 +52,24 @@ async function removeCopiedPonytailSkills(shouldDryRun) {
52
52
  await removeUninstallTarget(path.join(piAgentDir(), 'skills', entry.name), shouldDryRun);
53
53
  }
54
54
  }
55
- // pi owns its package dir, so removal is `pi remove`; the tree and the defaults
56
- // file are ours. Ponytail goes too unless --keep-ponytail, matching the OMP
57
- // route, so neither host keeps a package we installed on its behalf.
58
- async function removePiLayer(host, shouldDryRun, shouldRemovePonytail) {
55
+ // pi removal delegates to pi remove; always list and confirm first.
56
+ async function removePiLayer(host, shouldDryRun, shouldRemovePonytail, shouldRemoveRtk, confirmed) {
59
57
  const targets = PI_TREE_DIRS.map((dir) => path.join(piAgentDir(), 'extensions', dir));
58
+ const rtkWiring = path.join(piAgentDir(), 'extensions', 'rtk.ts');
60
59
  if (!host.installed && !host.declared) {
61
- console.log(` [skip] ${host.label} — nothing installed (${host.installCmd})`);
60
+ sayTagged(` [skip] ${host.label} — nothing installed (${host.installCmd})`);
61
+ return false;
62
+ }
63
+ console.log('Will remove:');
64
+ for (const t of targets)
65
+ console.log(` ${t}`);
66
+ if (shouldRemovePonytail)
67
+ console.log(` npm:${PONYTAIL_PKG} (pi package)`);
68
+ if (shouldRemoveRtk)
69
+ console.log(` ${rtkWiring} (rtk wiring)`);
70
+ console.log(` ${tersioSettingsFile()} (session defaults)`);
71
+ if (!confirmed && !(await confirmDestructive(`Remove Tersio from ${host.label}?`))) {
72
+ closeRL();
62
73
  return false;
63
74
  }
64
75
  if (host.declared)
@@ -68,6 +79,11 @@ async function removePiLayer(host, shouldDryRun, shouldRemovePonytail) {
68
79
  await removeCopiedPonytailSkills(shouldDryRun);
69
80
  }
70
81
  await Promise.all(targets.map((t) => removeUninstallTarget(t, shouldDryRun)));
82
+ // rtk's wiring is a loose file, not one of the tree dirs, so it survived --remove-rtk on pi while OMP removed its copy.
83
+ if (shouldRemoveRtk) {
84
+ await removeUninstallTarget(rtkWiring, shouldDryRun, false);
85
+ await removeUninstallTarget(path.join(BUN_BIN_DIR, RTK_BINARY_NAME), shouldDryRun, false);
86
+ }
71
87
  await clearSessionDefaults(shouldDryRun);
72
88
  return true;
73
89
  }
@@ -91,11 +107,11 @@ async function updateJsonFile(filePath, mutate, dryRunNote, writeNote, dryRun) {
91
107
  if (!mutate(data))
92
108
  return;
93
109
  if (dryRun) {
94
- console.log(` [dry-run] ${dryRunNote}`);
110
+ sayTagged(` [dry-run] ${dryRunNote}`);
95
111
  return;
96
112
  }
97
113
  await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
98
- console.log(` [write] ${writeNote}`);
114
+ sayTagged(` [write] ${writeNote}`);
99
115
  }
100
116
  function dropKey(section, key) {
101
117
  if (!section || typeof section !== 'object')
@@ -109,14 +125,14 @@ function dropKey(section, key) {
109
125
  async function removeUninstallTarget(target, shouldDryRun, recursive = true) {
110
126
  try {
111
127
  if (shouldDryRun) {
112
- console.log(` [dry-run] would remove ${target}`);
128
+ sayTagged(` [dry-run] would remove ${target}`);
113
129
  return;
114
130
  }
115
131
  if (recursive)
116
132
  await fs.rm(target, { recursive: true, force: true });
117
133
  else
118
134
  await fs.unlink(target);
119
- console.log(` [rm] ${target}`);
135
+ sayTagged(` [rm] ${target}`);
120
136
  }
121
137
  catch {
122
138
  debug(`Could not remove ${target}`);
@@ -163,14 +179,45 @@ async function runUninstall(options = {}) {
163
179
  }
164
180
  const selected = detectHosts().find((h) => h.id === host);
165
181
  if (host === 'pi') {
166
- const removed = await removePiLayer(selected, shouldDryRun, shouldRemovePonytail);
182
+ const removed = await removePiLayer(selected, shouldDryRun, shouldRemovePonytail, shouldRemoveRtk, confirmed);
167
183
  if (removed)
168
- console.log('\nDone. Restart pi for changes to take effect.');
184
+ console.log('\nDone. Restart pi, then /combo medium.');
169
185
  closeRL();
170
186
  return removed;
171
187
  }
188
+ if (host === 'opencode') {
189
+ const dir = path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
190
+ const configPath = path.join(HOME, '.config', 'opencode', 'opencode.json');
191
+ if (!selected.installed && !selected.declared) {
192
+ sayTagged(` [skip] ${selected.label} — nothing installed (${selected.installCmd})`);
193
+ closeRL();
194
+ return false;
195
+ }
196
+ console.log('Will remove:');
197
+ console.log(` ${dir}`);
198
+ console.log(` the tersio entry in ${configPath}`);
199
+ if (!confirmed && !(await confirmDestructive('Remove these Tersio files?'))) {
200
+ closeRL();
201
+ return false;
202
+ }
203
+ await removeUninstallTarget(dir, shouldDryRun);
204
+ if (!shouldDryRun) {
205
+ try {
206
+ const raw = await readTextIfExists(configPath);
207
+ const config = raw ? JSON.parse(raw) : {};
208
+ if (Array.isArray(config.plugins)) {
209
+ config.plugins = config.plugins.filter((p) => typeof p !== 'string' || !p.includes('/opencode/plugins/tersio/'));
210
+ await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
211
+ }
212
+ }
213
+ catch { /* config missing or not JSON; nothing to scrub */ }
214
+ }
215
+ console.log('\nDone. Restart OpenCode to drop the plugin.');
216
+ closeRL();
217
+ return true;
218
+ }
172
219
  if (!selected.installed)
173
- console.log(` [note] ${selected.label} has no tersio install; removing what is left behind.`);
220
+ sayTagged(` [note] ${selected.label} has no tersio install; removing what is left behind.`);
174
221
  const extDir = path.join(OMP_AGENT_DIR, 'extensions');
175
222
  const configPath = path.join(OMP_AGENT_DIR, 'config.yml');
176
223
  const rtkBin = path.join(BUN_BIN_DIR, RTK_BINARY_NAME);
@@ -187,6 +234,8 @@ async function runUninstall(options = {}) {
187
234
  'lib',
188
235
  // Legacy: imports session-state.js, so it warns once that module is gone.
189
236
  'aaa-combo-boot',
237
+ // Foreign to the OMP loader; never shipped here on purpose.
238
+ 'opencode',
190
239
  ].map((dir) => path.join(extDir, dir));
191
240
  console.log('Will remove:');
192
241
  for (const t of targets) {
@@ -213,8 +262,7 @@ async function runUninstall(options = {}) {
213
262
  return false;
214
263
  if (shouldRemovePonytail && l.includes('ponytail') && l.includes('pi-extension'))
215
264
  return false;
216
- // Our rtk wiring, absolute or relative. Leaving it listed would keep
217
- // OMP loading a hook for a package that is no longer installed.
265
+ // Our rtk wiring, absolute or relative. Leaving it listed would keep OMP loading a hook for a package that is no longer installed.
218
266
  const entry = l.trim().replace(/^-\s*/, '').replace(/^['"]|['"]$/g, '');
219
267
  if (entry.endsWith(`extensions${path.sep}rtk.ts`) || entry.endsWith('extensions/rtk.ts'))
220
268
  return false;
@@ -222,23 +270,27 @@ async function runUninstall(options = {}) {
222
270
  });
223
271
  if (lines.length !== before) {
224
272
  if (shouldDryRun)
225
- console.log(` [dry-run] would remove ${before - lines.length} config.yml entries`);
273
+ sayTagged(` [dry-run] would remove ${before - lines.length} config.yml entries`);
226
274
  else
227
275
  await writeConfigLines(configPath, lines, ` [write] Updated config.yml (removed ${before - lines.length} entries)`);
228
276
  }
229
277
  }
278
+ // Cleared last: writeConfigLines above would just make a fresh one.
279
+ for (const backup of [`${configPath}.bak`, path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'extensions', 'caveman-session', 'rule.md.bak')]) {
280
+ await removeUninstallTarget(backup, shouldDryRun, false);
281
+ }
230
282
  // Remove the bundled Ponytail copy; its config.yml entry is already filtered.
231
283
  if (shouldRemovePonytail) {
232
284
  const pluginsPkgPath = path.join(pluginsDir, 'package.json');
233
285
  await updateJsonFile(pluginsPkgPath, (data) => dropKey(data.dependencies, '@dietrichgebert/ponytail'), `would remove @dietrichgebert/ponytail from ${pluginsPkgPath}`, 'Removed @dietrichgebert/ponytail from plugins/package.json', shouldDryRun);
234
286
  try {
235
287
  if (shouldDryRun)
236
- console.log(` [dry-run] would remove ${ponytailPkgDir}`);
288
+ sayTagged(` [dry-run] would remove ${ponytailPkgDir}`);
237
289
  else {
238
290
  await fs.rm(ponytailPkgDir, { recursive: true, force: true });
239
- console.log(` [rm] ${ponytailPkgDir}`);
291
+ sayTagged(` [rm] ${ponytailPkgDir}`);
240
292
  await fs.rm(path.dirname(ponytailPkgDir));
241
- console.log(` [rm] ${path.dirname(ponytailPkgDir)} (empty scope)`);
293
+ sayTagged(` [rm] ${path.dirname(ponytailPkgDir)} (empty scope)`);
242
294
  }
243
295
  }
244
296
  catch {
@@ -254,9 +306,7 @@ async function runUninstall(options = {}) {
254
306
  if ((await readTextIfExists(path.join(selfPluginDir, 'package.json'))) !== null) {
255
307
  await removeUninstallTarget(selfPluginDir, shouldDryRun);
256
308
  }
257
- // Our rtk wiring goes with the rest: leaving the file would keep OMP
258
- // rewriting bash calls to a package this uninstall just removed. The binary
259
- // stays unless --remove-rtk, since it is a shared tool on PATH.
309
+ // Our rtk wiring goes too, or OMP keeps rewriting for a removed package.
260
310
  await removeUninstallTarget(path.join(extDir, 'rtk.ts'), shouldDryRun);
261
311
  if (shouldRemoveRtk)
262
312
  await removeUninstallTarget(rtkBin, shouldDryRun, false);
package/cli/update.js CHANGED
@@ -2,11 +2,12 @@
2
2
  import { promises as fs } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { spawn } from 'node:child_process';
5
- import { IS_WINDOWS, OMP_PLUGINS_DIR, PACKAGE_BIN, PACKAGE_NAME, PACKAGE_VERSION, dryRun, execP, parseJsonObject, verbose, } from './common.js';
6
- import { execNetwork } from './interactive.js';
5
+ import { IS_WINDOWS, OMP_PLUGINS_DIR, PACKAGE_BIN, PACKAGE_NAME, PACKAGE_VERSION, args, dryRun, execP, parseJsonObject, verbose, } from './common.js';
6
+ import { execNetwork, sayTagged } from './interactive.js';
7
7
  import { readTextIfExists, resolveRtkBinary, tersioDataPath } from '../extensions/lib/utils.js';
8
8
  import { refreshPrices } from '../extensions/shared/pricing.js';
9
9
  import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, httpsGet, normalizeRtkVersion, sha256Hex, } from '../extensions/lib/utils.js';
10
+ import { parseHostArg } from './hosts.js';
10
11
  const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
11
12
  function newerThan(a, b) {
12
13
  const pa = a.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
@@ -34,8 +35,7 @@ async function latestPublishedVersion() {
34
35
  return null;
35
36
  }
36
37
  }
37
- // A newer published version, null when up to date, 'unknown' when the registry
38
- // is unreachable. Cached for a TTL unless force, which must not trust a cache.
38
+ // A newer published version, null when up to date, 'unknown' when the registry is unreachable. Cached for a TTL unless force, which must not trust a cache.
39
39
  async function checkForUpdate(force = false) {
40
40
  const cachePath = tersioDataPath('update-check.json', 'tersio-update-check.json');
41
41
  const cached = parseJsonObject(await readTextIfExists(cachePath));
@@ -48,8 +48,7 @@ async function checkForUpdate(force = false) {
48
48
  await fs.writeFile(cachePath, JSON.stringify({ latest, lastCheck: Date.now() }) + '\n', 'utf8').catch(() => { });
49
49
  return newerThan(latest, PACKAGE_VERSION) ? latest : null;
50
50
  }
51
- // Unreachable registry: a stale cache naming a newer release is still
52
- // actionable, else report unknown so nobody claims "latest".
51
+ // Unreachable registry: a stale cache naming a newer release is still actionable, else report unknown so nobody claims "latest".
53
52
  return cached?.latest && newerThan(cached.latest, PACKAGE_VERSION) ? cached.latest : 'unknown';
54
53
  }
55
54
  // Race a probe against a timeout; failures resolve null so nothing blocks.
@@ -75,8 +74,7 @@ async function execInherit(cmd, args) {
75
74
  if (code !== 0)
76
75
  throw new Error(`${cmd} exited with code ${code}`);
77
76
  }
78
- // Current → latest per add-on. Every probe is capped and nullable, and the
79
- // plan prints `unknown` for anything unreachable.
77
+ // Current → latest per add-on. Every probe is capped and nullable, and the plan prints `unknown` for anything unreachable.
80
78
  async function probeUpdatePlan(cliLatest) {
81
79
  const rtkBin = resolveRtkBinary();
82
80
  const ponytailPkg = path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
@@ -97,8 +95,7 @@ async function probeUpdatePlan(cliLatest) {
97
95
  ponytail: ponytailLocalText ? parseJsonObject(ponytailLocalText)?.version ?? null : null,
98
96
  };
99
97
  }
100
- // Every add-on prints a version jump when stale, "up to date" otherwise, and
101
- // "unknown" when its source cannot be reached.
98
+ // Every add-on prints a version jump when stale, "up to date" otherwise, and "unknown" when its source cannot be reached.
102
99
  function planLines(plan) {
103
100
  const stale = [];
104
101
  const status = [];
@@ -131,6 +128,18 @@ async function runLatestUpdate() {
131
128
  forwardedArgs.push('--dry-run');
132
129
  if (verbose)
133
130
  forwardedArgs.push('--verbose');
131
+ // A pinned host refreshes that host's tree; bare update keeps the OMP default.
132
+ let pinned;
133
+ try {
134
+ pinned = parseHostArg(args);
135
+ }
136
+ catch (e) {
137
+ console.error(`[fail] ${e.message}`);
138
+ process.exitCode = 1;
139
+ return;
140
+ }
141
+ if (pinned)
142
+ forwardedArgs.push('--host', pinned);
134
143
  const npmCommand = IS_WINDOWS ? process.env.ComSpec || 'cmd.exe' : 'npm';
135
144
  // `--prefer-online` beats the local cache, so a stale @latest cannot pin down.
136
145
  const cliLatest = await latestPublishedVersion();
@@ -140,8 +149,8 @@ async function runLatestUpdate() {
140
149
  const { stale, status } = planLines(plan);
141
150
  if (dryRun) {
142
151
  console.log(`tersio update (dry-run):\n ${status.join('\n ')}`);
143
- console.log(` [dry-run] would run: npm install -g ${PACKAGE_NAME}${target} --no-audit --no-fund --prefer-online`);
144
- console.log(` [dry-run] would delegate: npm exec --yes --prefer-online --package=${PACKAGE_NAME}${target} -- tersio --apply-update ${forwardedArgs.join(' ')}`);
152
+ sayTagged(` [dry-run] would run: npm install -g ${PACKAGE_NAME}${target} --no-audit --no-fund --prefer-online`);
153
+ sayTagged(` [dry-run] would delegate: npm exec --yes --prefer-online --package=${PACKAGE_NAME}${target} -- tersio --apply-update ${forwardedArgs.join(' ')}`);
145
154
  return;
146
155
  }
147
156
  console.log(`Checking for updates:\n ${status.join('\n ')}`);
@@ -169,8 +178,7 @@ async function runLatestUpdate() {
169
178
  process.exitCode = 1;
170
179
  return;
171
180
  }
172
- // The npx delegation below only refreshes the OMP-side files; the globally
173
- // installed CLI keeps its old version until npm -g runs. Refresh both.
181
+ // The npx delegation below only refreshes the OMP-side files; the globally installed CLI keeps its old version until npm -g runs. Refresh both.
174
182
  const globalArgs = IS_WINDOWS
175
183
  ? ['/d', '/s', '/c', 'npm', 'install', '-g', `${PACKAGE_NAME}${target}`, '--no-audit', '--no-fund', '--prefer-online']
176
184
  : ['install', '-g', `${PACKAGE_NAME}${target}`, '--no-audit', '--no-fund', '--prefer-online'];
@@ -186,8 +194,8 @@ async function runLatestUpdate() {
186
194
  process.stderr.write(globalResult.stderr);
187
195
  }
188
196
  catch (e) {
189
- console.log(` [warn] Global CLI update skipped: ${e.message}`);
190
- console.log(` [hint] Manual: npm install -g ${PACKAGE_NAME}${target} --no-audit --no-fund --prefer-online`);
197
+ sayTagged(` [warn] Global CLI update skipped: ${e.message}`);
198
+ sayTagged(` [hint] Manual: npm install -g ${PACKAGE_NAME}${target} --no-audit --no-fund --prefer-online`);
191
199
  }
192
200
  const npmArgs = [
193
201
  'exec',
@@ -200,12 +208,11 @@ async function runLatestUpdate() {
200
208
  ...forwardedArgs,
201
209
  ];
202
210
  const npmCommandArgs = IS_WINDOWS ? ['/d', '/s', '/c', 'npm', ...npmArgs] : npmArgs;
203
- // Inherited stdio, no outer spinner. The delegated installer runs quiet
204
- // (banner and per-file writes suppressed); this parent owns both the plan
205
- // line above and the closing summary below — one voice, no repeats.
211
+ // Inherited stdio, no outer spinner; the delegated installer runs quiet and this parent owns both the plan line and the closing summary.
206
212
  try {
207
213
  await execInherit(npmCommand, npmCommandArgs);
208
- console.log(`Done — tersio ${plan.cli ?? PACKAGE_VERSION}. Restart OMP.`);
214
+ const hostName = pinned === 'pi' ? 'pi' : pinned === 'opencode' ? 'OpenCode' : 'OMP';
215
+ console.log(`Done — tersio ${plan.cli ?? PACKAGE_VERSION}. Restart ${hostName}.`);
209
216
  if (!dryRun) {
210
217
  try {
211
218
  await refreshPrices();
package/cli/usage.js CHANGED
@@ -1,13 +1,12 @@
1
1
  // cli/usage.ts — ledger + session-token usage report, tokscale-style.
2
- import { co2GramsFor, displayModelId, energyWhFor, importSessionTokens, ledgerPath, priceFor, readResetWatermark, readRtkAdoption, readRtkRecallDiagnostics, readUsage, refreshPricesIfStale, sessionsDir, usdCost, } from '../extensions/shared/usage-ledger.js';
2
+ import { co2GramsFor, displayModelId, energyWhFor, foldModelsByLabel, importSessionTokens, ledgerPath, priceFor, readResetWatermark, readRtkAdoption, readRtkRecallDiagnostics, readUsage, refreshPricesIfStale, sessionsDir, usdCost, } from '../extensions/shared/usage-ledger.js';
3
3
  import { resolveRtkBinary } from '../extensions/lib/utils.js';
4
4
  import { readRtkGain } from '../extensions/shared/rtk-gain.js';
5
- import { readUsageDb, syncUsageDb, usageDbPath } from '../extensions/shared/usage-store.js';
5
+ import { readUsageDb, reparseGuardReport, syncUsageDb, usageDbPath } from '../extensions/shared/usage-store.js';
6
6
  import { withInteractiveSpinner } from './interactive.js';
7
7
  import { PACKAGE_VERSION, currency } from './common.js';
8
8
  import { formatCurrency } from './currency.js';
9
- // RTK's history is machine-wide; rows group by command alone. 2000 groups
10
- // covers a long history while still bounding /data.json.
9
+ // RTK's history is machine-wide; rows group by command alone. 2000 groups covers a long history while still bounding /data.json.
11
10
  const RTK_COMMAND_ROWS = 2000;
12
11
  export function summarizeUsage(rows) {
13
12
  refreshPricesIfStale();
@@ -18,8 +17,7 @@ export function summarizeUsage(rows) {
18
17
  detailCounts[r.detail] = (detailCounts[r.detail] ?? 0) + 1;
19
18
  }
20
19
  const byDetail = Object.entries(detailCounts).sort((a, b) => b[1] - a[1]).slice(0, 10);
21
- // usage.db is a cache of the same per-message rows: sync best-effort, read
22
- // stored, fall back to the live parse when the store is missing or stale.
20
+ // usage.db is a cache of the same per-message rows: sync best-effort, read stored, fall back to the live parse when the store is missing or stale.
23
21
  let synced = false;
24
22
  try {
25
23
  synced = syncUsageDb();
@@ -27,6 +25,9 @@ export function summarizeUsage(rows) {
27
25
  catch {
28
26
  synced = false;
29
27
  }
28
+ const guard = reparseGuardReport();
29
+ if (guard)
30
+ console.warn(`[warn] usage mirror: ${guard}`);
30
31
  let stored = null;
31
32
  try {
32
33
  stored = readUsageDb()?.tokens ?? null;
@@ -42,19 +43,43 @@ export function summarizeUsage(rows) {
42
43
  let savedUsd = 0;
43
44
  const byModelUsd = {};
44
45
  const byModelBucketUsd = {};
46
+ const byHost = {};
47
+ const byDayModel = {};
45
48
  let co2g = 0;
46
49
  let energyWh = 0;
50
+ const addTok = (into, t) => {
51
+ into.input += t.input;
52
+ into.output += t.output;
53
+ into.cacheRead += t.cacheRead;
54
+ into.cacheWrite += t.cacheWrite;
55
+ };
56
+ // Price by exact key first (gateways can differ), then fold every map under the display label.
57
+ const folded = foldModelsByLabel(session.byModel, session.byModelMessages);
47
58
  for (const [model, t] of Object.entries(session.byModel)) {
48
59
  const c = usdCost(t, model);
49
60
  usd += c.usd;
50
- byModelUsd[model] = c.usd;
51
- byModelBucketUsd[model] = c.buckets;
61
+ const label = displayModelId(model);
62
+ byModelUsd[label] = (byModelUsd[label] ?? 0) + c.usd;
63
+ byModelBucketUsd[label] ??= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
64
+ addTok(byModelBucketUsd[label], c.buckets);
65
+ for (const [host, hb] of Object.entries(session.byHost[model] ?? {})) {
66
+ byHost[label] ??= {};
67
+ byHost[label][host] ??= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
68
+ addTok(byHost[label][host], hb);
69
+ }
52
70
  co2g += co2GramsFor(model, t.output);
53
71
  energyWh += energyWhFor(model, t.output);
54
72
  if (!c.priced)
55
73
  priced = false;
56
74
  savedUsd += (t.cacheRead / 1e6) * priceFor(model).price.input;
57
75
  }
76
+ for (const [day, per] of Object.entries(session.byDayModel)) {
77
+ for (const [model, n] of Object.entries(per)) {
78
+ const label = displayModelId(model);
79
+ byDayModel[day] ??= {};
80
+ byDayModel[day][label] = (byDayModel[day][label] ?? 0) + n;
81
+ }
82
+ }
58
83
  const byTool = Object.entries(session.byTool).sort((a, b) => b[1] - a[1]).slice(0, 10);
59
84
  return {
60
85
  total: rows.length,
@@ -64,12 +89,13 @@ export function summarizeUsage(rows) {
64
89
  empty: rows.length === 0 && session.messages === 0,
65
90
  messages: session.messages,
66
91
  tokens: session.totals,
67
- byModel: session.byModel,
92
+ byModel: folded.byModel,
68
93
  byModelUsd,
69
94
  byModelBucketUsd,
70
- byModelMessages: session.byModelMessages,
95
+ byModelMessages: folded.byModelMessages,
96
+ byHost,
71
97
  byDay: session.byDay,
72
- byDayModel: session.byDayModel,
98
+ byDayModel,
73
99
  byTool,
74
100
  recent: session.recent.map((r) => ({
75
101
  ...r,
@@ -113,11 +139,11 @@ function bar(frac, width = 12) {
113
139
  const filled = Math.max(0, Math.min(width, Math.round(frac * width)));
114
140
  return '█'.repeat(filled) + '░'.repeat(width - filled);
115
141
  }
142
+ const ROW_CAP = 15;
116
143
  function pad(s, n) {
117
144
  return s.length >= n ? s : s + ' '.repeat(n - s.length);
118
145
  }
119
- // Box-drawing table, plain text (piped-safe, byte-stable). Numeric columns
120
- // right-align; long cells truncate with an ellipsis. Shared with cli/settings.ts.
146
+ // Box-drawing table, plain text (piped-safe, byte-stable). Numeric columns right-align; long cells truncate with an ellipsis. Shared with cli/settings.ts.
121
147
  function textTable(headers, rows, right = [], maxW = 32) {
122
148
  const cells = [headers, ...rows].map((r) => r.map((c) => (c.length > maxW ? c.slice(0, maxW - 1) + '…' : c)));
123
149
  const widths = headers.map((_, i) => Math.max(...cells.map((r) => r[i].length)));
@@ -136,10 +162,15 @@ function printReport(report) {
136
162
  return;
137
163
  }
138
164
  const t = report.tokens;
139
- console.log(` TOKENS ${fmt(t.input)} in · ${fmt(t.output)} out · ${fmt(t.cacheRead)} cache read · ${fmt(t.cacheWrite)} cache write`);
140
- console.log(` COST ${formatCurrency(report.usd, report.currency)}${report.priced ? '' : ' (includes default pricing)'} · ~${formatCurrency(report.savedUsd, report.currency)} cache-saved (est.) · ~${report.co2g.toFixed(1)}g CO2 (est.)`);
141
- console.log(` RTK ${fmt(report.rtkAdoption.rtkCalls)}/${fmt(report.rtkAdoption.eligibleCalls)} eligible OMP Bash calls use RTK (${report.rtkAdoption.adoptionPct.toFixed(1)}%) · ${fmt(report.rtkAdoption.missedCalls)} missed · recall ${report.rtkRecall.available ? `${report.rtkRecall.mode} (${report.rtkRecall.entries})` : 'unavailable'}`);
142
- const models = Object.entries(report.byModel).filter(([, b]) => b.input + b.output + b.cacheRead + b.cacheWrite > 0).sort((a, b) => (b[1].input + b[1].output) - (a[1].input + a[1].output)).slice(0, 8);
165
+ const a = report.rtkAdoption;
166
+ for (const l of textTable(['Metric', 'Value'], [
167
+ ['Tokens', `${fmt(t.input)} in · ${fmt(t.output)} out · ${fmt(t.cacheRead)} cache read · ${fmt(t.cacheWrite)} cache write`],
168
+ ['Cost', `${formatCurrency(report.usd, report.currency)}${report.priced ? '' : ' (includes default pricing)'} · ~${formatCurrency(report.savedUsd, report.currency)} cache-saved (est.) · ~${report.co2g.toFixed(1)}g CO2 (est.)`],
169
+ ['RTK adoption', `${fmt(a.rtkCalls)}/${fmt(a.eligibleCalls)} eligible Bash calls use RTK (${a.adoptionPct.toFixed(1)}%) · ${fmt(a.missedCalls)} missed · recall ${report.rtkRecall.available ? `${report.rtkRecall.mode} (${report.rtkRecall.entries})` : 'unavailable'}`],
170
+ ], [false, false], 118))
171
+ console.log(l);
172
+ const allModels = Object.entries(report.byModel).filter(([, b]) => b.input + b.output + b.cacheRead + b.cacheWrite > 0).sort((a, b) => (b[1].input + b[1].output) - (a[1].input + a[1].output));
173
+ const models = allModels.slice(0, ROW_CAP);
143
174
  if (models.length) {
144
175
  console.log(' BY MODEL');
145
176
  const top = models.reduce((m, [, b]) => Math.max(m, b.input + b.output + b.cacheRead + b.cacheWrite), 1);
@@ -147,11 +178,13 @@ function printReport(report) {
147
178
  const mt = b.input + b.output + b.cacheRead + b.cacheWrite;
148
179
  const hit = b.input + b.cacheRead ? (b.cacheRead / (b.input + b.cacheRead)) * 100 : 0;
149
180
  const usd = report.byModelUsd[model] ?? 0;
150
- return [displayModelId(model), fmt(b.input), fmt(b.output), `${fmt(b.cacheRead)}/${fmt(b.cacheWrite)}`, formatCurrency(usd, report.currency), `${hit.toFixed(1)}%`, `${bar(mt / top, 8)} ${fmtShort(mt)}`];
181
+ return [model, fmt(b.input), fmt(b.output), `${fmt(b.cacheRead)}/${fmt(b.cacheWrite)}`, formatCurrency(usd, report.currency), `${hit.toFixed(1)}%`, `${bar(mt / top, 8)} ${fmtShort(mt)}`];
151
182
  });
152
183
  for (const l of textTable(['Model', 'Input', 'Output', 'Cache r/w', report.currency, 'Hit', 'Share'], mrows, [false, true, true, true, true, true, false])) {
153
184
  console.log(l);
154
185
  }
186
+ if (allModels.length > ROW_CAP)
187
+ console.log(` showing top ${ROW_CAP} of ${fmt(allModels.length)} models — full set in the dashboard`);
155
188
  }
156
189
  const cmdRows = report.byTool.map(([tool, n]) => ({ name: tool, count: n, saved: null, avgPct: null, avgMs: null }));
157
190
  for (const r of report.rtkGain.byCommand) {
@@ -165,7 +198,7 @@ function printReport(report) {
165
198
  : ' COMMAND TOOLS';
166
199
  console.log(scope);
167
200
  const top = cmdRows.reduce((m, r) => Math.max(m, r.count), 1);
168
- const crows = cmdRows.slice(0, 15).map((r, idx) => [
201
+ const crows = cmdRows.slice(0, ROW_CAP).map((r, idx) => [
169
202
  String(idx + 1),
170
203
  r.name,
171
204
  fmt(r.count),
@@ -177,6 +210,8 @@ function printReport(report) {
177
210
  for (const l of textTable(['#', 'Tool/Command', 'Count', 'Saved', 'Avg%', 'Time', 'Impact'], crows, [true, false, true, true, true, true, false], 30)) {
178
211
  console.log(l);
179
212
  }
213
+ if (cmdRows.length > ROW_CAP)
214
+ console.log(` showing top ${ROW_CAP} of ${fmt(cmdRows.length)} commands — full set in the dashboard`);
180
215
  }
181
216
  }
182
217
  async function runUsage() {