@krtclcdy/tersio 2.8.0 → 2.10.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 CHANGED
@@ -1,15 +1,34 @@
1
- # Tersio ✂️
1
+ ![Fontora](/TersioBanner.webp)
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@krtclcdy%2Ftersio?color=cb0000)](https://www.npmjs.com/package/@krtclcdy/tersio)
4
- [![release](https://img.shields.io/github/v/release/KurutoDenzeru/tersio?color=7c3aed)](https://github.com/KurutoDenzeru/tersio/releases)
5
- [![license](https://img.shields.io/badge/license-MIT-6e7681)](./LICENSE)
6
- [![node](https://img.shields.io/node/v/@krtclcdy%2Ftersio?color=30363d)](https://nodejs.org)
7
- [![CI](https://github.com/KurutoDenzeru/tersio/actions/workflows/ci.yml/badge.svg)](https://github.com/KurutoDenzeru/tersio/actions)
8
- [![issues](https://img.shields.io/github/issues/KurutoDenzeru/tersio?color=d97706)](https://github.com/KurutoDenzeru/tersio/issues)
3
+ # ✂️ Tersio — Token-saving OMP Add-ons
9
4
 
10
- Terse replies, compact shell output, and minimal code decisions for [Oh My Pi (OMP)](https://github.com/can1357/oh-my-pi).
5
+ [![npm version](https://shieldcn.dev/npm/@krtclcdy%2Ftersio.svg?variant=branded&size=xs&logo=npm)](https://www.npmjs.com/package/@krtclcdy/tersio)
6
+ [![node](https://shieldcn.dev/badge/node-%3E%3D20.12-22c55e.svg?variant=branded&size=xs&logo=nodedotjs)](https://nodejs.org)
7
+ [![npm downloads](https://shieldcn.dev/npm/dm/@krtclcdy%2Ftersio.svg?variant=branded&size=xs&logo=npm)](https://www.npmjs.com/package/@krtclcdy/tersio)
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
+ [![MIT](https://shieldcn.dev/badge/license-MIT-2563eb.svg?variant=branded&size=xs&logo=opensourceinitiative)](./LICENSE)
11
10
 
12
- ## Install
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.
12
+
13
+ ## ✨ Features
14
+
15
+ - **Caveman** — Shortens replies, keeps technical substance. Modes: `lite`, `full`, `ultra`, `wenyan`.
16
+ - **RTK** — Routes noisy shell commands through the RTK binary for compact output; exact bytes bypass it by policy.
17
+ - **Ponytail** — Minimal, YAGNI-oriented code decisions, from nudge (`lite`) to requirement-challenger (`ultra`).
18
+ - **Combo** — Toggles all three at once. Presets: `off`, `medium`, `balanced`, `max`.
19
+ - **Updater** — `/tersio check` and `/tersio update` cover Ponytail, RTK, and Caveman in-session, with dry-run and backups.
20
+ - **Gain dashboard** — `tersio gain` serves a local-only savings dashboard: cost with currency conversion, activity graph, command tools, and model breakdowns.
21
+ - **Usage ledger** — `tersio usage` prints a ledger-backed savings report for the machine, local only.
22
+
23
+ ## 🧱 Tech Stack
24
+
25
+ - [TypeScript](https://www.typescriptlang.org/) + [Node.js](https://nodejs.org/): CLI and OMP extensions, compiled to dependency-free JS for install.
26
+ - Vanilla dashboard: dependency-free HTML/CSS/JS export — no build step, no runtime deps.
27
+ - [js-tiktoken](https://github.com/dqbd/tiktoken): Real `o200k_base` BPE token counts for benchmarks (dev only).
28
+ - [tokscale](https://github.com/junhoyeo/tokscale): Token-bucket convention (input / output / cache read / cache write) for parsing OMP session transcripts.
29
+ - [EcoLogits](https://github.com/genai-impact/ecologits): CO2 model (v0.8.2 port — per-model params, provider grids, served ÷32 amortization).
30
+
31
+ ## ⚡ Getting Started
13
32
 
14
33
  **Default** (macOS/Linux/WSL) — one line. Installs the CLI via npm, then runs the main installer (scope + Combo preset menus) in the same pass:
15
34
 
@@ -46,19 +65,20 @@ tersio install --combo-default balanced --caveman-default lite --rtk-default on
46
65
 
47
66
  Defaults apply to fresh sessions only — anything persisted with `/tersio combo`, `/tersio caveman`, or `/tersio rtk` wins. Stored as plugin settings (`omp plugin config get @krtclcdy/tersio comboDefault`).
48
67
 
49
- ## What it installs
68
+ One-off use without installing:
50
69
 
51
- | Add-on | What it does |
52
- |---|---|
53
- | **Caveman** | Shortens replies, keeps technical substance. Modes: `lite`, `full`, `ultra`, `wenyan` |
54
- | **RTK** | Routes noisy shell commands through the RTK binary for compact output |
55
- | **Ponytail** | Minimal, YAGNI-oriented code decisions |
56
- | **Combo** | Toggles all three at once. Presets: `off`, `medium`, `balanced`, `max` |
57
- | **Updater** | `/tersio check` and `/tersio update` cover Ponytail, RTK, and Caveman in-session, with dry-run and backups |
70
+ ```bash
71
+ npm exec --yes --prefer-online --package=@krtclcdy/tersio@latest -- tersio install
72
+ ```
58
73
 
59
- 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.
74
+ ### Requirements
75
+
76
+ - [OMP](https://github.com/can1357/oh-my-pi)
77
+ - Node.js 20.12+ with npm
78
+
79
+ 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/`.
60
80
 
61
- ## Benchmarks
81
+ ## 📊 Benchmarks
62
82
 
63
83
  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).
64
84
 
@@ -73,7 +93,7 @@ Measured savings against the same workload without the modes. Token counts are r
73
93
 
74
94
  Break-even math, balanced preset (caveman full + rtk + ponytail): `⌈overhead ÷ saving-per-turn⌉ = ⌈480 ÷ 306⌉ = 2 turns`, where a mixed turn (reply + `git status`-class command + code task) costs 944 tok baseline vs 638 tok with Tersio — a 0.68× ratio. Every turn after turn 2 nets ≈ −32%. RTK keeps diffs and failing-test output exact by design (−0.4% and −1.5% there), concentrating savings where noise lives.
75
95
 
76
- ## CLI
96
+ ## 🖥️ CLI
77
97
 
78
98
  | Command | Purpose |
79
99
  |---|---|
@@ -88,7 +108,7 @@ Break-even math, balanced preset (caveman full + rtk + ponytail): `⌈overhead
88
108
 
89
109
  Flags: `--dry-run`, `--yes`/`-y`, `--verbose`, `--scope`, `--combo-default`/`--caveman-default`/`--rtk-default`/`--ponytail-default`. Legacy `--doctor` / `--uninstall` forms still work.
90
110
 
91
- ## Commands reference
111
+ ## ⌨️ Commands reference
92
112
 
93
113
  Everything runs under one root. Bare `/tersio` prints status.
94
114
 
@@ -106,7 +126,7 @@ Everything runs under one root. Bare `/tersio` prints status.
106
126
  | `/tersio help` | This table |
107
127
  | `/caveman`, `/rtk`, `/combo`, `/ai-addons` | Legacy aliases, still work; prefer `/tersio`. |
108
128
 
109
- ## Files and backups
129
+ ## 🗂️ Files and backups
110
130
 
111
131
  | What | Path |
112
132
  |---|---|
@@ -117,27 +137,24 @@ Everything runs under one root. Bare `/tersio` prints status.
117
137
 
118
138
  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.
119
139
 
120
- ## Requirements
140
+ 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.
121
141
 
122
- - [OMP](https://github.com/can1357/oh-my-pi)
123
- - Node.js 20.12+ with npm
142
+ ## 🛠️ Troubleshooting
124
143
 
125
- 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/`.
144
+ **Ponytail or Combo command missing:** run `tersio reinstall` in OMP's environment, restart OMP, then `tersio doctor` (repairs `config.yml` registrations).
126
145
 
127
- One-off use without installing:
146
+ **RTK missing or not executable:** run `tersio reinstall`, then `tersio doctor`. On Linux/macOS: `chmod +x ~/.bun/bin/rtk`.
128
147
 
129
- ```bash
130
- npm exec --yes --prefer-online --package=@krtclcdy/tersio@latest -- tersio install
131
- ```
148
+ **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.
132
149
 
133
- ## Troubleshooting
150
+ ## 🤝🏻 Contributing
134
151
 
135
- **Ponytail or Combo command missing:** run `tersio reinstall` in OMP's environment, restart OMP, then `tersio doctor` (repairs `config.yml` registrations).
152
+ Contributions are always welcome, whether you're fixing bugs, improving docs, or shipping new features that make the project better for everyone.
136
153
 
137
- **RTK missing or not executable:** run `tersio reinstall`, then `tersio doctor`. On Linux/macOS: `chmod +x ~/.bun/bin/rtk`.
154
+ Check out [Contributing.md](Contributing.md) to learn how to get started and follow the recommended workflow.
138
155
 
139
- **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.
156
+ ## ⚖️ License
140
157
 
141
- ## License
158
+ This project is released under the MIT License, giving you the freedom to use, modify, and distribute the code with minimal restrictions.
142
159
 
143
- [MIT](./LICENSE)
160
+ For the full legal text, see the [LICENSE](LICENSE) file.
package/cli/banner.js ADDED
@@ -0,0 +1,82 @@
1
+ // cli/banner.ts — mini pixel-scissor welcome (16x10 px -> 5 half-block rows).
2
+ // OMP-style launch moment for interactive runs: clack intro + note box.
3
+ // TTY-only and skipped on --dry-run, so piped output stays byte-identical.
4
+ import { intro as clackIntro, note as clackNote } from '@clack/prompts';
5
+ import { PACKAGE_VERSION, dryRun } from './common.js';
6
+ function tty() {
7
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
8
+ }
9
+ // Pixel classes: B/b blades, P pivot, L/R handle rings, l shafts.
10
+ const GRID = [
11
+ '..BB........bb..',
12
+ '..BBB......bbb..',
13
+ '....BB....bb....',
14
+ '.....BBBbbb.....',
15
+ '......BPPb......',
16
+ '....L.lPPl..R...',
17
+ '..LLLLLl.lRRRRR.',
18
+ '..LL.LL...RR.RR.',
19
+ '.LL...LL.RR...RR',
20
+ '..LL.LL...RR.RR.',
21
+ ];
22
+ // [truecolor rgb, ansi256] per class. Left ring red, right ring yellow.
23
+ const PALETTE = {
24
+ B: ['190;200;210', '252'],
25
+ b: ['190;200;210', '252'],
26
+ P: ['212;175;55', '172'],
27
+ L: ['239;68;68', '196'],
28
+ R: ['250;204;21', '220'],
29
+ l: ['190;200;210', '252'],
30
+ };
31
+ function bannerTier() {
32
+ if (process.env.NO_COLOR !== undefined)
33
+ return 'plain';
34
+ if (/truecolor|24bit/i.test(process.env.COLORTERM ?? ''))
35
+ return 'true';
36
+ if (/256color/i.test(process.env.TERM ?? ''))
37
+ return '256';
38
+ return 'plain';
39
+ }
40
+ function paint(ka, kb, tier) {
41
+ if (!ka && !kb)
42
+ return ' ';
43
+ if (tier === 'plain') {
44
+ if (ka && kb)
45
+ return '█';
46
+ return ka ? '▀' : '▄';
47
+ }
48
+ const fg = (k) => (tier === 'true' ? `\x1b[38;2;${PALETTE[k][0]}m` : `\x1b[38;5;${PALETTE[k][1]}m`);
49
+ const bg = (k) => (tier === 'true' ? `\x1b[48;2;${PALETTE[k][0]}m` : `\x1b[48;5;${PALETTE[k][1]}m`);
50
+ if (ka && kb && ka === kb)
51
+ return `${fg(ka)}█\x1b[0m`;
52
+ if (ka && kb)
53
+ return `${fg(ka)}${bg(kb)}▀\x1b[0m`;
54
+ if (ka)
55
+ return `${fg(ka)}▀\x1b[0m`;
56
+ return `${fg(kb)}▄\x1b[0m`;
57
+ }
58
+ // Five 16-wide rows. Pure: unit-testable without a TTY.
59
+ function bannerLines(tier = bannerTier()) {
60
+ const out = [];
61
+ for (let r = 0; r < GRID.length; r += 2) {
62
+ let line = '';
63
+ for (let x = 0; x < GRID[r].length; x++) {
64
+ const ka = GRID[r][x] === '.' ? null : GRID[r][x];
65
+ const kb = GRID[r + 1][x] === '.' ? null : GRID[r + 1][x];
66
+ line += paint(ka, kb, tier);
67
+ }
68
+ out.push(line);
69
+ }
70
+ return out;
71
+ }
72
+ let shown = false;
73
+ // OMP-style welcome: intro line + note box with art and tips.
74
+ // No-op unless interactive (and never on --dry-run).
75
+ function printWelcome() {
76
+ if (shown || dryRun || !tty())
77
+ return;
78
+ shown = true;
79
+ clackIntro(`✂ tersio v${PACKAGE_VERSION}`);
80
+ clackNote(`${bannerLines().join('\n')}\n\n /tersio combo medium toggle all three\n /tersio caveman full terse replies\n /tersio rtk on compact shell output`, 'cut token bloat');
81
+ }
82
+ export { bannerLines, bannerTier, printWelcome };
package/cli/dashboard.js CHANGED
@@ -11,10 +11,13 @@ import { fileURLToPath } from 'node:url';
11
11
  import { readUsage } from '../extensions/shared/usage-ledger.js';
12
12
  import { summarizeUsage } from './usage.js';
13
13
  import { withInteractiveSpinner } from './interactive.js';
14
- const TEMPLATE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'dashboard.html');
15
- const BRAND = path.join(path.dirname(fileURLToPath(import.meta.url)), 'brand.webp');
16
- async function templateHtml() {
17
- return fs.readFile(TEMPLATE, 'utf8');
14
+ const DASH_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dashboard');
15
+ const TEMPLATE = path.join(DASH_DIR, 'template.html');
16
+ const STYLES = path.join(DASH_DIR, 'styles.css');
17
+ const APP = path.join(DASH_DIR, 'app.js');
18
+ const BRAND = path.join(DASH_DIR, 'brand.webp');
19
+ async function readSegment(file) {
20
+ return fs.readFile(file, 'utf8');
18
21
  }
19
22
  async function faviconDataUri() {
20
23
  const png = await fs.readFile(BRAND);
@@ -29,22 +32,39 @@ function openBrowser(url) {
29
32
  }
30
33
  async function runDashboard(options) {
31
34
  if (options.exportFile) {
32
- const html = await templateHtml();
33
- const inline = html
34
- .replace("fetch('data.json')", `Promise.resolve({ json: function () { return ${dataJson()}; } }).then(function (r) { return r.json(); }).then`)
35
- .replace('href="brand.webp"', `href="${await faviconDataUri()}"`)
36
- .replace('src="brand.webp"', `src="${await faviconDataUri()}"`);
35
+ const [template, css, js, icon] = await Promise.all([
36
+ readSegment(TEMPLATE), readSegment(STYLES), readSegment(APP), faviconDataUri(),
37
+ ]);
38
+ // Replacer functions throughout: session data routinely contains `$'`
39
+ // sequences (shell quoting in tool details), which String.replace would
40
+ // expand as match-suffix patterns and corrupt the file.
41
+ const inline = template
42
+ .replace('<link rel="stylesheet" href="styles.css">', () => `<style>\n${css}</style>`)
43
+ .replace('<script src="app.js" defer></script>', () => `<script>\n${js}</script>`)
44
+ .replace("fetch('data.json')", () => `Promise.resolve({ json: function () { return ${dataJson()}; } })`)
45
+ .replace('href="brand.webp"', () => `href="${icon}"`)
46
+ .replace('src="brand.webp"', () => `src="${icon}"`);
37
47
  await fs.writeFile(options.exportFile, inline, 'utf8');
38
48
  console.log(`[ok] gain exported → ${options.exportFile}`);
39
49
  return;
40
50
  }
41
- const html = await withInteractiveSpinner('Loading dashboard template', templateHtml);
51
+ const html = await withInteractiveSpinner('Loading dashboard template', () => readSegment(TEMPLATE));
42
52
  const server = http.createServer(async (req, res) => {
43
53
  if (req.url === '/data.json') {
44
54
  res.writeHead(200, { 'Content-Type': 'application/json' });
45
55
  res.end(dataJson());
46
56
  return;
47
57
  }
58
+ if (req.url === '/styles.css') {
59
+ res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8' });
60
+ res.end(await readSegment(STYLES));
61
+ return;
62
+ }
63
+ if (req.url === '/app.js') {
64
+ res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8' });
65
+ res.end(await readSegment(APP));
66
+ return;
67
+ }
48
68
  if (req.url === '/brand.webp') {
49
69
  try {
50
70
  const png = await fs.readFile(BRAND);
package/cli/doctor.js CHANGED
@@ -101,8 +101,10 @@ async function runDoctor() {
101
101
  section('Environment');
102
102
  check('Node', true, process.version);
103
103
  check('OMP CLI', ompVersion !== null, ompVersion ?? '');
104
- if (updateVersion)
104
+ if (typeof updateVersion === 'string')
105
105
  warnLine('Tersio CLI', `${updateVersion} available — run tersio update`);
106
+ else if (updateVersion === 'unknown')
107
+ warnLine('Tersio CLI', `${PACKAGE_VERSION} (version check unreachable)`);
106
108
  else
107
109
  check('Tersio CLI', true, PACKAGE_VERSION);
108
110
  console.log(` Home: ${HOME}`);
package/cli/install.js CHANGED
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { BUN_BIN_DIR, COMBO_PRESET_MODES, EXTENSIONS_KEY_RE, HOME, IS_WINDOWS, OMP_AGENT_DIR, OMP_PLUGINS_DIR, OMP_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, SCOPE_MAP, applyUpdate, cavemanDefaultFlag, comboDefaultFlag, command, dryRun, install, ponytailDefaultFlag, profileFlagsGiven, reinstall, rtkDefaultFlag, scopeFlag, verbose, yes, dashboardExport, dashboardPort, debug, ensureExtensionAfterConfigEntry, ensureExtensionInConfig, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
7
7
  import { ask, askInteractiveChoice, askInteractiveConfirm, closeRL, execNetwork, tty, withInteractiveSpinner, } from './interactive.js';
8
+ import { printWelcome } from './banner.js';
8
9
  import { checkForUpdate, runLatestUpdate } from './update.js';
9
10
  import { runUninstall } from './uninstall.js';
10
11
  import { runDoctor } from './doctor.js';
@@ -549,8 +550,9 @@ let updatePromptDone = false;
549
550
  // update offer first (when pending), then a Clack menu over every command.
550
551
  // Scripts, pipes, --yes, and --dry-run keep the old straight-to-install path.
551
552
  async function runCommandMenu() {
553
+ printWelcome();
552
554
  const newer = await checkForUpdate();
553
- if (newer && !dryRun) {
555
+ if (typeof newer === 'string' && !dryRun) {
554
556
  const answer = await askInteractiveConfirm(`tersio ${newer} is available (installed ${PACKAGE_VERSION}). Install it now?`);
555
557
  if (answer.status === 'confirmed' && answer.value) {
556
558
  await runLatestUpdate();
@@ -582,8 +584,13 @@ async function runCommandMenu() {
582
584
  await runInstall();
583
585
  break;
584
586
  case 'check': {
585
- const latest = await checkForUpdate();
586
- console.log(latest ? ` [update] tersio ${latest} available (installed ${PACKAGE_VERSION})` : ` [ok] tersio ${PACKAGE_VERSION} is the latest`);
587
+ const latest = await checkForUpdate(true);
588
+ if (typeof latest === 'string')
589
+ console.log(` [update] tersio ${latest} available (installed ${PACKAGE_VERSION})`);
590
+ else if (latest === 'unknown')
591
+ console.log(` [warn] could not reach the npm registry — run \`tersio update\` to retry`);
592
+ else
593
+ console.log(` [ok] tersio ${PACKAGE_VERSION} is the latest`);
587
594
  closeRL();
588
595
  break;
589
596
  }
@@ -614,6 +621,7 @@ async function runInstall() {
614
621
  await runCommandMenu();
615
622
  return;
616
623
  }
624
+ printWelcome();
617
625
  if (reinstall) {
618
626
  await runUninstall({ yes: true, removePonytail: false, removeRtk: true });
619
627
  }
@@ -627,7 +635,7 @@ async function runInstall() {
627
635
  // the apply-update payload, which is itself an update run.
628
636
  if (tty() && !applyUpdate && command !== 'uninstall') {
629
637
  const newer = await checkForUpdate();
630
- if (newer) {
638
+ if (typeof newer === 'string') {
631
639
  // Bare `tersio` with an update pending: offer it now (Y/n) instead of
632
640
  // burying the banner above the install prompts. Yes runs the full
633
641
  // update and stops here — the fresh binary owns what follows.
package/cli/update.js CHANGED
@@ -5,6 +5,7 @@ import { spawn } from 'node:child_process';
5
5
  import { BUN_BIN_DIR, IS_WINDOWS, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_BIN, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, SCOPE_MAP, dryRun, execP, parseJsonObject, scopeFlag, verbose, } from './common.js';
6
6
  import { execNetwork } from './interactive.js';
7
7
  import { readTextIfExists } from '../extensions/lib/utils.js';
8
+ import { refreshPrices } from '../extensions/shared/pricing.js';
8
9
  import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, httpsGet, normalizeRtkVersion, sha256Hex, } from '../extensions/lib/utils.js';
9
10
  const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
10
11
  function newerThan(a, b) {
@@ -33,13 +34,15 @@ async function latestPublishedVersion() {
33
34
  return null;
34
35
  }
35
36
  }
36
- // Returns a newer published version, or null. Reads the registry at most once
37
- // per TTL (cached under OMP_PLUGINS_DIR) and stays silent on failure.
38
- async function checkForUpdate() {
37
+ // Returns a newer published version, null when up to date, or 'unknown' when
38
+ // the registry cannot be reached. Reads the registry at most once per TTL
39
+ // (cached under OMP_PLUGINS_DIR) unless force skips the cache — an explicit
40
+ // "check for updates" must never trust a stale cache.
41
+ async function checkForUpdate(force = false) {
39
42
  const cachePath = path.join(OMP_PLUGINS_DIR, 'tersio-update-check.json');
40
43
  const cached = parseJsonObject(await readTextIfExists(cachePath));
41
44
  const cacheFresh = typeof cached?.lastCheck === 'number' && Date.now() - cached.lastCheck < UPDATE_CHECK_TTL_MS;
42
- if (cacheFresh && cached?.latest)
45
+ if (!force && cacheFresh && cached?.latest)
43
46
  return newerThan(cached.latest, PACKAGE_VERSION) ? cached.latest : null;
44
47
  const latest = await latestPublishedVersion();
45
48
  if (latest) {
@@ -47,8 +50,9 @@ async function checkForUpdate() {
47
50
  await fs.writeFile(cachePath, JSON.stringify({ latest, lastCheck: Date.now() }) + '\n', 'utf8').catch(() => { });
48
51
  return newerThan(latest, PACKAGE_VERSION) ? latest : null;
49
52
  }
50
- // Registry unreachable: fall back to a stale cache rather than staying silent.
51
- return cached?.latest && newerThan(cached.latest, PACKAGE_VERSION) ? cached.latest : null;
53
+ // Registry unreachable: a stale cache naming a newer release is still
54
+ // actionable; otherwise report unknown so callers never claim "latest".
55
+ return cached?.latest && newerThan(cached.latest, PACKAGE_VERSION) ? cached.latest : 'unknown';
52
56
  }
53
57
  // Race a probe against a timeout; slow or failing probes resolve null so the
54
58
  // update plan stays best-effort and never blocks the update itself.
@@ -192,6 +196,13 @@ async function runLatestUpdate() {
192
196
  try {
193
197
  await execInherit(npmCommand, npmCommandArgs);
194
198
  console.log('\n=== Update complete ===');
199
+ if (!dryRun) {
200
+ try {
201
+ if (await refreshPrices())
202
+ console.log(' [ok] model price table refreshed');
203
+ }
204
+ catch { /* pricing is best-effort; update already succeeded */ }
205
+ }
195
206
  }
196
207
  catch (e) {
197
208
  const err = e;
package/cli/usage.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // cli/usage.ts — ledger + session-token usage report, tokscale-style.
2
- import { co2Grams, importSessionTokens, priceFor, readUsage, usdCost, } from '../extensions/shared/usage-ledger.js';
2
+ import { co2GramsFor, energyWhFor, importSessionTokens, priceFor, readUsage, usdCost, } from '../extensions/shared/usage-ledger.js';
3
+ import { readRtkGain } from '../extensions/shared/rtk-gain.js';
3
4
  import { withInteractiveSpinner } from './interactive.js';
5
+ import { PACKAGE_VERSION } from './common.js';
4
6
  export function summarizeUsage(rows) {
5
7
  const byKind = {};
6
8
  const detailCounts = {};
@@ -13,9 +15,15 @@ export function summarizeUsage(rows) {
13
15
  let usd = 0;
14
16
  let priced = true;
15
17
  let savedUsd = 0;
18
+ const byModelUsd = {};
19
+ let co2g = 0;
20
+ let energyWh = 0;
16
21
  for (const [model, t] of Object.entries(session.byModel)) {
17
22
  const c = usdCost(t, model);
18
23
  usd += c.usd;
24
+ byModelUsd[model] = c.usd;
25
+ co2g += co2GramsFor(model, t.output);
26
+ energyWh += energyWhFor(model, t.output);
19
27
  if (!c.priced)
20
28
  priced = false;
21
29
  savedUsd += (t.cacheRead / 1e6) * priceFor(model).price.input;
@@ -30,49 +38,106 @@ export function summarizeUsage(rows) {
30
38
  messages: session.messages,
31
39
  tokens: session.totals,
32
40
  byModel: session.byModel,
41
+ byModelUsd,
42
+ byModelMessages: session.byModelMessages,
33
43
  byDay: session.byDay,
34
44
  byDayModel: session.byDayModel,
35
45
  byTool,
46
+ rtkGain: readRtkGain(),
36
47
  usd,
37
48
  priced,
38
49
  savedUsd,
39
50
  costMeasured: session.costMeasured,
40
- co2g: co2Grams(session.totals.output),
51
+ co2g,
52
+ energyWh,
53
+ version: PACKAGE_VERSION,
41
54
  };
42
55
  }
43
56
  function fmt(n) {
44
57
  return n.toLocaleString('en-US');
45
58
  }
59
+ function fmtShort(n) {
60
+ if (n >= 1e12)
61
+ return (n / 1e12).toFixed(1) + 'T';
62
+ if (n >= 1e9)
63
+ return (n / 1e9).toFixed(1) + 'B';
64
+ if (n >= 1e6)
65
+ return (n / 1e6).toFixed(1) + 'M';
66
+ if (n >= 1e3)
67
+ return (n / 1e3).toFixed(1) + 'K';
68
+ return String(Math.round(n));
69
+ }
70
+ function fmtMs(ms) {
71
+ if (ms < 1000)
72
+ return `${Math.round(ms)}ms`;
73
+ return `${(ms / 1000).toFixed(1)}s`;
74
+ }
75
+ function bar(frac, width = 12) {
76
+ const filled = Math.max(0, Math.min(width, Math.round(frac * width)));
77
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
78
+ }
79
+ function pad(s, n) {
80
+ return s.length >= n ? s : s + ' '.repeat(n - s.length);
81
+ }
82
+ // Box-drawing table, plain text (piped-safe, byte-stable). Numeric columns
83
+ // right-align; long cells truncate with an ellipsis.
84
+ function table(headers, rows, right = [], maxW = 32) {
85
+ const cells = [headers, ...rows].map((r) => r.map((c) => (c.length > maxW ? c.slice(0, maxW - 1) + '…' : c)));
86
+ const widths = headers.map((_, i) => Math.max(...cells.map((r) => r[i].length)));
87
+ const line = (l, m, r) => l + widths.map((w) => '─'.repeat(w + 2)).join(m) + r;
88
+ const row = (cs) => '│ ' + cs.map((c, i) => (right[i] ? c.padStart(widths[i]) : pad(c, widths[i]))).join(' │ ') + ' │';
89
+ const out = [line('┌', '┬', '┐'), row(cells[0]), line('├', '┼', '┤')];
90
+ for (const r of cells.slice(1))
91
+ out.push(row(r));
92
+ out.push(line('└', '┴', '┘'));
93
+ return out.map((l) => ' ' + l);
94
+ }
46
95
  function printReport(report) {
47
- console.log('\n=== Tersio Usage ===');
96
+ console.log(`\n=== Tersio Usage v${report.version} · ${fmt(report.messages)} msgs ===`);
48
97
  if (report.empty) {
49
98
  console.log(' No ledger rows or session tokens yet — run session commands first.');
50
99
  return;
51
100
  }
52
101
  const t = report.tokens;
53
- console.log(` Tokens input ${fmt(t.input)} · output ${fmt(t.output)} · cache read ${fmt(t.cacheRead)} · cache write ${fmt(t.cacheWrite)} (${report.messages} assistant messages)`);
54
- console.log(` Cost $${report.usd.toFixed(2)}${report.priced ? '' : ' (includes default pricing for unknown models)'} · ~$${report.savedUsd.toFixed(2)} saved by cache (est.) · CO2 ~${report.co2g.toFixed(1)}g (est.)`);
55
- const models = Object.entries(report.byModel).sort((a, b) => (b[1].input + b[1].output) - (a[1].input + a[1].output)).slice(0, 5);
102
+ console.log(` TOKENS ${fmt(t.input)} in · ${fmt(t.output)} out · ${fmt(t.cacheRead)} cache read${t.cacheWrite > 0 ? ` · ${fmt(t.cacheWrite)} written` : ''}`);
103
+ console.log(` COST $${report.usd.toFixed(2)}${report.priced ? '' : ' (includes default pricing)'} · ~$${report.savedUsd.toFixed(2)} cache-saved (est.) · ~${report.co2g.toFixed(1)}g CO2 (est.)`);
104
+ 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);
56
105
  if (models.length) {
57
- console.log(' By model');
58
- for (const [model, b] of models) {
59
- console.log(` ${model}: in ${fmt(b.input)} · out ${fmt(b.output)} · cache r/w ${fmt(b.cacheRead)}/${fmt(b.cacheWrite)}`);
106
+ console.log(' BY MODEL');
107
+ const top = models.reduce((m, [, b]) => Math.max(m, b.input + b.output + b.cacheRead + b.cacheWrite), 1);
108
+ const mrows = models.map(([model, b]) => {
109
+ const mt = b.input + b.output + b.cacheRead + b.cacheWrite;
110
+ const hit = b.input + b.cacheRead ? (b.cacheRead / (b.input + b.cacheRead)) * 100 : 0;
111
+ const usd = report.byModelUsd[model] ?? 0;
112
+ return [model, `in ${fmt(b.input)}`, `out ${fmt(b.output)}`, `${fmt(b.cacheRead)}/${fmt(b.cacheWrite)}`, `$${usd.toFixed(2)}`, `${hit.toFixed(1)}%`, `${bar(mt / top, 8)} ${fmtShort(mt)}`];
113
+ });
114
+ for (const l of table(['Model', 'Input', 'Output', 'Cache r/w', 'USD', 'Hit', 'Share'], mrows, [false, true, true, true, true, true, false])) {
115
+ console.log(l);
60
116
  }
61
117
  }
62
- if (report.byTool.length) {
63
- console.log(' Top tools');
64
- for (const [tool, n] of report.byTool) {
65
- console.log(` ${n}× ${tool}`);
66
- }
118
+ const cmdRows = report.byTool.map(([tool, n]) => ({ name: tool, count: n, saved: null, avgPct: null, avgMs: null }));
119
+ for (const r of report.rtkGain.byCommand) {
120
+ cmdRows.push({ name: r.command, count: r.count, saved: r.saved, avgPct: r.avgPct, avgMs: r.avgMs });
67
121
  }
68
- if (report.total) {
69
- console.log(' Activity');
70
- for (const [kind, n] of Object.entries(report.byKind)) {
71
- console.log(` ${kind}: ${n}`);
72
- }
73
- console.log(' Top commands');
74
- for (const [detail, n] of report.byDetail) {
75
- console.log(` ${n}× ${detail}`);
122
+ cmdRows.sort((a, b) => b.count - a.count);
123
+ if (cmdRows.length) {
124
+ const g = report.rtkGain;
125
+ const scope = g.commands
126
+ ? ` COMMAND TOOLS ${fmt(report.byTool.length)} tools · ${fmt(g.commands)} commands · ${fmtShort(g.saved)} saved`
127
+ : ' COMMAND TOOLS';
128
+ console.log(scope);
129
+ const top = cmdRows.reduce((m, r) => Math.max(m, r.count), 1);
130
+ const crows = cmdRows.slice(0, 15).map((r, idx) => [
131
+ String(idx + 1),
132
+ r.name,
133
+ fmt(r.count),
134
+ r.saved === null ? '–' : fmtShort(r.saved),
135
+ r.avgPct === null ? '–' : `${r.avgPct.toFixed(1)}%`,
136
+ r.avgMs === null ? '–' : fmtMs(r.avgMs),
137
+ bar(r.count / top, 8),
138
+ ]);
139
+ for (const l of table(['#', 'Tool/Command', 'Count', 'Saved', 'Avg%', 'Time', 'Impact'], crows, [true, false, true, true, true, true, false], 30)) {
140
+ console.log(l);
76
141
  }
77
142
  }
78
143
  }