@krtclcdy/tersio 2.6.0 → 2.8.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/cli/dashboard.js CHANGED
@@ -1,4 +1,4 @@
1
- // cli/dashboard.ts — localhost gain dashboard. Serves the single plain-HTML
1
+ // cli/dashboard.ts — gain dashboard. Serves the single plain-HTML
2
2
  // template (no deps, no build) plus a /data.json endpoint from the ledger.
3
3
  // Binds 127.0.0.1 only; --export writes a file://-ready file instead.
4
4
  // Note: no Promise.withResolvers here — engines still allow Node 20.12,
@@ -35,7 +35,7 @@ async function runDashboard(options) {
35
35
  .replace('href="brand.webp"', `href="${await faviconDataUri()}"`)
36
36
  .replace('src="brand.webp"', `src="${await faviconDataUri()}"`);
37
37
  await fs.writeFile(options.exportFile, inline, 'utf8');
38
- console.log(`[ok] dashboard exported → ${options.exportFile}`);
38
+ console.log(`[ok] gain exported → ${options.exportFile}`);
39
39
  return;
40
40
  }
41
41
  const html = await withInteractiveSpinner('Loading dashboard template', templateHtml);
@@ -64,7 +64,7 @@ async function runDashboard(options) {
64
64
  const address = server.address();
65
65
  const port = typeof address === 'object' && address ? address.port : options.port;
66
66
  const url = `http://127.0.0.1:${port}`;
67
- console.log(`[ok] dashboard live → ${url} (Ctrl-C to stop)`);
67
+ console.log(`[ok] gain live → ${url} (Ctrl-C to stop)`);
68
68
  if (options.open)
69
69
  openBrowser(url);
70
70
  });
package/cli/doctor.js CHANGED
@@ -22,6 +22,7 @@ async function runDoctor() {
22
22
  const rtkIndex = path.join(extDir, 'rtk-session', 'index.js');
23
23
  const updaterIndex = path.join(extDir, 'ai-addons-updater', 'index.js');
24
24
  const comboIndex = path.join(extDir, 'combo-toggle', 'index.js');
25
+ const tersioIndex = path.join(extDir, 'tersio-commands', 'index.js');
25
26
  const modeReinforcement = path.join(extDir, 'shared', 'mode-reinforcement.js');
26
27
  const selfPkg = path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'package.json');
27
28
  // Independent probes start concurrently; sections report in fixed order as
@@ -42,6 +43,7 @@ async function runDoctor() {
42
43
  rtkIndexText: readTextIfExists(rtkIndex),
43
44
  updaterIndexText: readTextIfExists(updaterIndex),
44
45
  comboIndexText: readTextIfExists(comboIndex),
46
+ tersioIndexText: readTextIfExists(tersioIndex),
45
47
  modeReinforcementText: readTextIfExists(modeReinforcement),
46
48
  ruleMtime: fs.stat(cavemanRule).catch(() => null),
47
49
  rtkMtime: fs.stat(rtkBin).catch(() => null),
@@ -60,11 +62,12 @@ async function runDoctor() {
60
62
  probes.configText,
61
63
  probes.updateVersion,
62
64
  ]));
63
- const [cavemanIndexText, rtkIndexText, updaterIndexText, comboIndexText, modeReinforcementText, ponytailPkgText, ponytailExtText, pluginsPkgRaw, selfPkgText] = await runInteractivePhase('Checking extensions and plugins', () => Promise.all([
65
+ const [cavemanIndexText, rtkIndexText, updaterIndexText, comboIndexText, tersioIndexText, modeReinforcementText, ponytailPkgText, ponytailExtText, pluginsPkgRaw, selfPkgText] = await runInteractivePhase('Checking extensions and plugins', () => Promise.all([
64
66
  probes.cavemanIndexText,
65
67
  probes.rtkIndexText,
66
68
  probes.updaterIndexText,
67
69
  probes.comboIndexText,
70
+ probes.tersioIndexText,
68
71
  probes.modeReinforcementText,
69
72
  probes.ponytailPkgText,
70
73
  probes.ponytailExtText,
@@ -113,6 +116,7 @@ async function runDoctor() {
113
116
  check('RTK extension', rtkIndexText !== null);
114
117
  check('Updater extension', updaterIndexText !== null);
115
118
  check('Combo extension', comboIndexText !== null);
119
+ check('Tersio commands extension', tersioIndexText !== null);
116
120
  check('Mode reinforcement extension', modeReinforcementText !== null);
117
121
  section('Plugins');
118
122
  check('Ponytail package', ponytailPkgText !== null);
package/cli/install.js CHANGED
@@ -3,10 +3,13 @@ import { promises as fs } from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
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, debug, ensureExtensionAfterConfigEntry, ensureExtensionInConfig, ensurePonytailConfigValue, execP, parseJsonObject, readPluginsPackage, writeIfChanged, } from './common.js';
7
- import { ask, askInteractiveChoice, closeRL, execNetwork, tty, withInteractiveSpinner, } from './interactive.js';
8
- import { checkForUpdate } from './update.js';
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
+ import { ask, askInteractiveChoice, askInteractiveConfirm, closeRL, execNetwork, tty, withInteractiveSpinner, } from './interactive.js';
8
+ import { checkForUpdate, runLatestUpdate } from './update.js';
9
9
  import { runUninstall } from './uninstall.js';
10
+ import { runDoctor } from './doctor.js';
11
+ import { runUsage } from './usage.js';
12
+ import { runDashboard } from './dashboard.js';
10
13
  import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
11
14
  // Paths to extension source files (relative to this script)
12
15
  const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
@@ -15,6 +18,7 @@ const CAVEMAN_INDEX = path.join(EXT_DIR, 'caveman-session', 'index.js');
15
18
  const RTK_SESSION_INDEX = path.join(EXT_DIR, 'rtk-session', 'index.js');
16
19
  const UPDATER_INDEX = path.join(EXT_DIR, 'ai-addons-updater', 'index.js');
17
20
  const COMBO_TOGGLE_INDEX = path.join(EXT_DIR, 'combo-toggle', 'index.js');
21
+ const TERSIO_COMMANDS_INDEX = path.join(EXT_DIR, 'tersio-commands', 'index.js');
18
22
  const MODE_REINFORCEMENT_INDEX = path.join(EXT_DIR, 'shared', 'mode-reinforcement.js');
19
23
  const SHARED_TYPES = path.join(EXT_DIR, 'shared', 'types.js');
20
24
  const LIB_UTILS = path.join(EXT_DIR, 'lib', 'utils.js');
@@ -22,7 +26,7 @@ const SHARED_PLUGIN_SETTINGS = path.join(EXT_DIR, 'shared', 'plugin-settings.js'
22
26
  const PONYTAIL_GITHUB_SPEC = 'github:DietrichGebert/ponytail';
23
27
  const PONYTAIL_NPM_SPEC = '@dietrichgebert/ponytail@latest';
24
28
  async function stepPonytail(pluginsDir, userDir, options) {
25
- console.log('\n[1/7] Installing Ponytail plugin...');
29
+ console.log('\n[1/8] Installing Ponytail plugin...');
26
30
  await fs.mkdir(pluginsDir, { recursive: true });
27
31
  const pkgPath = path.join(pluginsDir, 'package.json');
28
32
  const pkg = await readPluginsPackage(pkgPath);
@@ -124,7 +128,7 @@ async function stepPonytail(pluginsDir, userDir, options) {
124
128
  // Settings → Plugins page (OMP enumerates plugins/package.json dependencies).
125
129
  // Returns true when the package is verified in plugins/node_modules.
126
130
  async function stepSelfPlugin(pluginsDir, options) {
127
- console.log('\n[2/7] Registering tersio as OMP plugin...');
131
+ console.log('\n[2/8] Registering tersio as OMP plugin...');
128
132
  const pkgPath = path.join(pluginsDir, 'package.json');
129
133
  const pkg = await readPluginsPackage(pkgPath);
130
134
  pkg.dependencies[PACKAGE_NAME] = `^${PACKAGE_VERSION}`;
@@ -253,7 +257,7 @@ async function extractRtkArchive(archivePath, extractDir) {
253
257
  return false;
254
258
  }
255
259
  async function stepRtk(binDir, options) {
256
- console.log('\n[3/7] Installing RTK binary...');
260
+ console.log('\n[3/8] Installing RTK binary...');
257
261
  try {
258
262
  const release = await withInteractiveSpinner('Finding latest RTK release', () => fetchJson(RTK_RELEASE_API));
259
263
  const triple = resolveRtkTriple();
@@ -342,14 +346,14 @@ async function stepSharedSessionState(extDir, options) {
342
346
  ], 'shared/session-state.js', options);
343
347
  }
344
348
  async function stepModeReinforcement(extDir, ponytailExtPath, options) {
345
- console.log('\n[7/7] Installing mode reinforcement extension...');
349
+ console.log('\n[8/8] Installing mode reinforcement extension...');
346
350
  const dest = path.join(extDir, 'shared', 'mode-reinforcement.js');
347
351
  if (!await copySources(extDir, [[MODE_REINFORCEMENT_INDEX, path.join('shared', 'mode-reinforcement.js')]], 'shared/mode-reinforcement.js', options))
348
352
  return;
349
353
  await ensureExtensionAfterConfigEntry(path.join(path.dirname(extDir), 'config.yml'), dest, ponytailExtPath, 'mode reinforcement', options);
350
354
  }
351
355
  async function stepRtkSession(extDir, options) {
352
- console.log('\n[4/7] Installing RTK session extension...');
356
+ console.log('\n[4/8] Installing RTK session extension...');
353
357
  await copySources(extDir, [[RTK_SESSION_INDEX, path.join('rtk-session', 'index.js')]], 'rtk-session/index.js', options);
354
358
  }
355
359
  // One rule fetch serves user- and project-level installs; dry runs stay offline
@@ -366,7 +370,7 @@ async function fetchCavemanRule(options) {
366
370
  }
367
371
  }
368
372
  async function stepCaveman(extDir, rule, options) {
369
- console.log('\n[5/7] Installing Caveman session extension...');
373
+ console.log('\n[5/8] Installing Caveman session extension...');
370
374
  const cavemanDir = path.join(extDir, 'caveman-session');
371
375
  if (!options.dryRun)
372
376
  await fs.mkdir(cavemanDir, { recursive: true });
@@ -383,11 +387,15 @@ async function stepCaveman(extDir, rule, options) {
383
387
  }
384
388
  await copySources(extDir, [[CAVEMAN_INDEX, path.join('caveman-session', 'index.js')]], 'caveman-session/index.js', options);
385
389
  }
390
+ async function stepTersioCommands(extDir, options) {
391
+ console.log('\n[7/8] Installing Tersio root-command extension...');
392
+ await copySources(extDir, [[TERSIO_COMMANDS_INDEX, path.join('tersio-commands', 'index.js')]], 'tersio-commands/index.js', options);
393
+ }
386
394
  async function stepUpdater(extDir, options) {
387
395
  await copySources(extDir, [[UPDATER_INDEX, path.join('ai-addons-updater', 'index.js')]], 'ai-addons-updater/index.js', options);
388
396
  }
389
397
  async function stepCombo(extDir, options) {
390
- console.log('\n[6/7] Installing Combo toggle extension...');
398
+ console.log('\n[6/8] Installing Combo toggle extension...');
391
399
  const dest = path.join(extDir, 'combo-toggle', 'index.js');
392
400
  if (!await copySources(extDir, [[COMBO_TOGGLE_INDEX, path.join('combo-toggle', 'index.js')]], 'combo-toggle/index.js', options))
393
401
  return;
@@ -536,7 +544,76 @@ async function writePluginSettings(profile, options) {
536
544
  await fs.writeFile(lockPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
537
545
  console.log(` [write] Plugin settings in ${lockPath}`);
538
546
  }
547
+ let updatePromptDone = false;
548
+ // Bare `tersio` at a terminal is a command picker, not an install run:
549
+ // update offer first (when pending), then a Clack menu over every command.
550
+ // Scripts, pipes, --yes, and --dry-run keep the old straight-to-install path.
551
+ async function runCommandMenu() {
552
+ const newer = await checkForUpdate();
553
+ if (newer && !dryRun) {
554
+ const answer = await askInteractiveConfirm(`tersio ${newer} is available (installed ${PACKAGE_VERSION}). Install it now?`);
555
+ if (answer.status === 'confirmed' && answer.value) {
556
+ await runLatestUpdate();
557
+ closeRL();
558
+ return;
559
+ }
560
+ if (answer.status === 'cancelled') {
561
+ closeRL();
562
+ process.exit(130);
563
+ }
564
+ console.log(`\n [update] staying on ${PACKAGE_VERSION} — run ` + '`tersio update`' + ` anytime`);
565
+ }
566
+ updatePromptDone = true;
567
+ const choice = await askInteractiveChoice('Tersio — what next?', [
568
+ { value: 'install', label: 'Install add-ons', hint: 'user/project scope + combo defaults' },
569
+ { value: 'check', label: 'Check for updates', hint: 'compare installed vs latest release' },
570
+ { value: 'update', label: 'Update everything', hint: 'CLI plus all ai-addons' },
571
+ { value: 'doctor', label: 'Doctor', hint: 'verify the installation' },
572
+ { value: 'usage', label: 'Usage', hint: 'token usage and savings report' },
573
+ { value: 'gain', label: 'Gain dashboard', hint: 'open the report in your browser' },
574
+ { value: 'uninstall', label: 'Uninstall', hint: 'remove tersio' },
575
+ ], 'install');
576
+ if (choice.status !== 'selected') {
577
+ closeRL();
578
+ process.exit(130);
579
+ }
580
+ switch (choice.value) {
581
+ case 'install':
582
+ await runInstall();
583
+ break;
584
+ 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
+ closeRL();
588
+ break;
589
+ }
590
+ case 'update':
591
+ await runLatestUpdate();
592
+ closeRL();
593
+ break;
594
+ case 'doctor':
595
+ await runDoctor();
596
+ closeRL();
597
+ break;
598
+ case 'usage':
599
+ await runUsage();
600
+ closeRL();
601
+ break;
602
+ case 'gain':
603
+ await runDashboard({ port: dashboardPort, open: true, exportFile: dashboardExport });
604
+ closeRL();
605
+ break;
606
+ case 'uninstall':
607
+ await runUninstall();
608
+ closeRL();
609
+ break;
610
+ }
611
+ }
539
612
  async function runInstall() {
613
+ if (command === null && tty() && !yes) {
614
+ await runCommandMenu();
615
+ return;
616
+ }
540
617
  if (reinstall) {
541
618
  await runUninstall({ yes: true, removePonytail: false, removeRtk: true });
542
619
  }
@@ -550,8 +627,27 @@ async function runInstall() {
550
627
  // the apply-update payload, which is itself an update run.
551
628
  if (tty() && !applyUpdate && command !== 'uninstall') {
552
629
  const newer = await checkForUpdate();
553
- if (newer)
554
- console.log(`\n [update] tersio ${newer} available (installed ${PACKAGE_VERSION}) — run \`tersio update\``);
630
+ if (newer) {
631
+ // Bare `tersio` with an update pending: offer it now (Y/n) instead of
632
+ // burying the banner above the install prompts. Yes runs the full
633
+ // update and stops here — the fresh binary owns what follows.
634
+ if (command === null && !dryRun && !yes && !updatePromptDone) {
635
+ const answer = await askInteractiveConfirm(`tersio ${newer} is available (installed ${PACKAGE_VERSION}). Install it now?`);
636
+ if (answer.status === 'confirmed' && answer.value) {
637
+ await runLatestUpdate();
638
+ closeRL();
639
+ return;
640
+ }
641
+ if (answer.status === 'cancelled') {
642
+ closeRL();
643
+ process.exit(130);
644
+ }
645
+ console.log(`\n [update] staying on ${PACKAGE_VERSION} — run \`tersio update\` anytime`);
646
+ }
647
+ else {
648
+ console.log(`\n [update] tersio ${newer} available (installed ${PACKAGE_VERSION}) — run \`tersio update\``);
649
+ }
650
+ }
555
651
  }
556
652
  const scope = await resolveScope();
557
653
  // Resolve session defaults: flags > interactive prompt > defaults.
@@ -583,6 +679,7 @@ async function runInstall() {
583
679
  await stepRtkSession(userExtDir, options);
584
680
  await stepCaveman(userExtDir, cavemanRule, options);
585
681
  await stepCombo(userExtDir, options);
682
+ await stepTersioCommands(userExtDir, options);
586
683
  await stepModeReinforcement(userExtDir, ponytailExtPath, options);
587
684
  await stepUpdater(userExtDir, options);
588
685
  if (selfPlugin)
@@ -594,6 +691,7 @@ async function runInstall() {
594
691
  await stepSharedSessionState(projectExtDir, options);
595
692
  await stepRtkSession(projectExtDir, options);
596
693
  await stepCaveman(projectExtDir, cavemanRule, options);
694
+ await stepTersioCommands(projectExtDir, options);
597
695
  await stepUpdater(projectExtDir, options);
598
696
  console.log(' [note] Ponytail, RTK binary, and Combo toggle require user-level (global) install');
599
697
  }
@@ -1,6 +1,6 @@
1
1
  // cli/interactive.ts — TTY layer: readline, Clack spinners, selects, task phases.
2
2
  import readline from 'node:readline';
3
- import { cancel as clackCancel, select as clackSelect, spinner as clackSpinner, tasks as clackTasks } from '@clack/prompts';
3
+ import { cancel as clackCancel, confirm as clackConfirm, select as clackSelect, spinner as clackSpinner, tasks as clackTasks } from '@clack/prompts';
4
4
  import { execP } from './common.js';
5
5
  const RL = readline.createInterface({ input: process.stdin, output: process.stdout });
6
6
  let rlOpen = true;
@@ -56,6 +56,17 @@ async function askInteractiveChoice(message, options, initialValue) {
56
56
  }
57
57
  return { status: 'selected', value: choice };
58
58
  }
59
+ async function askInteractiveConfirm(message, initialValue = true) {
60
+ if (!tty())
61
+ return { status: 'unavailable' };
62
+ closeRL();
63
+ const answer = await clackConfirm({ message, initialValue });
64
+ if (typeof answer !== 'boolean') {
65
+ clackCancel('Aborted.');
66
+ return { status: 'cancelled' };
67
+ }
68
+ return { status: 'confirmed', value: answer };
69
+ }
59
70
  // Run collecting work under one TTY-only Clack task. Callers print after the
60
71
  // task completes, keeping normal output out of the spinner animation.
61
72
  async function runInteractivePhase(title, collect) {
@@ -69,4 +80,4 @@ async function runInteractivePhase(title, collect) {
69
80
  async function execNetwork(label, cmd, args, opts = {}) {
70
81
  return withInteractiveSpinner(label, () => execP(cmd, args, opts));
71
82
  }
72
- export { ask, closeRL, tty, withInteractiveSpinner, execNetwork, askInteractiveChoice, runInteractivePhase, };
83
+ export { ask, closeRL, tty, withInteractiveSpinner, execNetwork, askInteractiveChoice, askInteractiveConfirm, runInteractivePhase, };
package/cli/uninstall.js CHANGED
@@ -71,6 +71,7 @@ async function runUninstall(options = {}) {
71
71
  'rtk-session',
72
72
  'ai-addons-updater',
73
73
  'combo-toggle',
74
+ 'tersio-commands',
74
75
  'shared',
75
76
  // Only consumed by ai-addons-updater (removed above); otherwise orphaned.
76
77
  'lib',
package/cli/usage.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // cli/usage.ts — ledger + session-token usage report, tokscale-style.
2
- import { co2Grams, importSessionTokens, readUsage, usdCost, } from '../extensions/shared/usage-ledger.js';
2
+ import { co2Grams, importSessionTokens, priceFor, readUsage, usdCost, } from '../extensions/shared/usage-ledger.js';
3
3
  import { withInteractiveSpinner } from './interactive.js';
4
4
  export function summarizeUsage(rows) {
5
5
  const byKind = {};
@@ -12,12 +12,15 @@ export function summarizeUsage(rows) {
12
12
  const session = importSessionTokens();
13
13
  let usd = 0;
14
14
  let priced = true;
15
+ let savedUsd = 0;
15
16
  for (const [model, t] of Object.entries(session.byModel)) {
16
17
  const c = usdCost(t, model);
17
18
  usd += c.usd;
18
19
  if (!c.priced)
19
20
  priced = false;
21
+ savedUsd += (t.cacheRead / 1e6) * priceFor(model).price.input;
20
22
  }
23
+ const byTool = Object.entries(session.byTool).sort((a, b) => b[1] - a[1]).slice(0, 10);
21
24
  return {
22
25
  total: rows.length,
23
26
  byKind,
@@ -28,8 +31,12 @@ export function summarizeUsage(rows) {
28
31
  tokens: session.totals,
29
32
  byModel: session.byModel,
30
33
  byDay: session.byDay,
34
+ byDayModel: session.byDayModel,
35
+ byTool,
31
36
  usd,
32
37
  priced,
38
+ savedUsd,
39
+ costMeasured: session.costMeasured,
33
40
  co2g: co2Grams(session.totals.output),
34
41
  };
35
42
  }
@@ -44,7 +51,7 @@ function printReport(report) {
44
51
  }
45
52
  const t = report.tokens;
46
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)`);
47
- console.log(` Cost $${report.usd.toFixed(2)}${report.priced ? '' : ' (includes default pricing for unknown models)'} · CO2 ~${report.co2g.toFixed(1)}g (est.)`);
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.)`);
48
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);
49
56
  if (models.length) {
50
57
  console.log(' By model');
@@ -52,6 +59,12 @@ function printReport(report) {
52
59
  console.log(` ${model}: in ${fmt(b.input)} · out ${fmt(b.output)} · cache r/w ${fmt(b.cacheRead)}/${fmt(b.cacheWrite)}`);
53
60
  }
54
61
  }
62
+ if (report.byTool.length) {
63
+ console.log(' Top tools');
64
+ for (const [tool, n] of report.byTool) {
65
+ console.log(` ${n}× ${tool}`);
66
+ }
67
+ }
55
68
  if (report.total) {
56
69
  console.log(' Activity');
57
70
  for (const [kind, n] of Object.entries(report.byKind)) {
@@ -61,7 +74,6 @@ function printReport(report) {
61
74
  for (const [detail, n] of report.byDetail) {
62
75
  console.log(` ${n}× ${detail}`);
63
76
  }
64
- console.log(` Total rows: ${report.total} · last write: ${report.lastWrite}`);
65
77
  }
66
78
  }
67
79
  async function runUsage() {
@@ -64,8 +64,11 @@ function walkJsonl(dir, out, cap) {
64
64
  export function importSessionTokens() {
65
65
  const byModel = {};
66
66
  const byDay = {};
67
+ const byDayModel = {};
68
+ const byTool = {};
67
69
  const totals = zeroBreakdown();
68
70
  let messages = 0;
71
+ let costMeasured = 0;
69
72
  const files = [];
70
73
  walkJsonl(sessionsDir(), files, 2000);
71
74
  for (const file of files) {
@@ -92,13 +95,44 @@ export function importSessionTokens() {
92
95
  if (day) {
93
96
  byDay[day] ??= zeroBreakdown();
94
97
  addInto(byDay[day], msg.usage);
98
+ const n = msg.usage;
99
+ const sum = ['input', 'output', 'cacheRead', 'cacheWrite'].reduce((a, k) => a + (typeof n[k] === 'number' && Number.isFinite(n[k]) ? Math.floor(n[k]) : 0), 0);
100
+ if (sum > 0) {
101
+ byDayModel[day] ??= {};
102
+ byDayModel[day][model] = (byDayModel[day][model] ?? 0) + sum;
103
+ }
104
+ }
105
+ if (typeof msg.usage.cost === 'number' && Number.isFinite(msg.usage.cost))
106
+ costMeasured += msg.usage.cost;
107
+ for (const part of msg.content ?? []) {
108
+ if (part?.type === 'toolCall' && typeof part.name === 'string' && part.name) {
109
+ const key = part.name === 'bash' ? `bash:${leadBinary(part.arguments?.command)}` : part.name;
110
+ byTool[key] = (byTool[key] ?? 0) + 1;
111
+ }
95
112
  }
96
113
  messages += 1;
97
114
  }
98
115
  catch { /* skip corrupt lines */ }
99
116
  }
100
117
  }
101
- return { messages, totals, byModel, byDay };
118
+ return { messages, totals, byModel, byDay, byDayModel, byTool, costMeasured };
119
+ }
120
+ // Lead binary of a shell string: first segment head past `cd` chains and
121
+ // VAR=x assignments (`cd /x && git status` → `git`). Falls back to `bash`.
122
+ const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
123
+ function leadBinary(command) {
124
+ if (typeof command !== 'string' || !command.trim())
125
+ return 'bash';
126
+ for (const seg of command.split(/&&|;|\n|\|(?!\|)/)) {
127
+ const toks = seg.trim().split(/\s+/).filter(Boolean);
128
+ let i = 0;
129
+ while (i < toks.length && (toks[i] === 'sudo' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])))
130
+ i++;
131
+ const head = (toks[i] ?? '').replace(/^['"]|['"]$/g, '').split('/').pop() ?? '';
132
+ if (head && !SKIP_HEADS.includes(head))
133
+ return head;
134
+ }
135
+ return 'bash';
102
136
  }
103
137
  const PRICE_TABLE = [
104
138
  { match: ':free', price: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } },
@@ -4,6 +4,8 @@
4
4
  // sibling extensions restore from, then reloads so their local mirrors sync
5
5
  // (same mechanism /combo already uses to drive its siblings).
6
6
  import { COMBO_LEVELS, activeModesSummary, getSharedComboState, normalizeComboLevel, normalizeMode, reconcileSharedComboEntries, sessionEntries, setSharedComboLevel, setSharedComboMode, } from '../shared/session-state.js';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
7
9
  import { appendUsage, readUsage } from '../shared/usage-ledger.js';
8
10
  import { checkAddonsSummary, runAddonUpdate } from '../ai-addons-updater/index.js';
9
11
  const HELP = [
@@ -35,6 +37,23 @@ function usageSummary() {
35
37
  const last = new Date(rows[rows.length - 1].ts).toISOString();
36
38
  return `tersio usage: ${rows.length} rows (${parts.join(', ')}), last write ${last}.`;
37
39
  }
40
+ // Gain opens instantaneously in the default browser: export a file://-ready
41
+ // snapshot (data inlined, no server to babysit) and open it. Falls back to
42
+ // the shell command when export or open fails.
43
+ async function openGainDashboard(pi, ctx) {
44
+ const file = path.join(os.tmpdir(), `tersio-gain-${Date.now()}.html`);
45
+ try {
46
+ const exported = await pi.exec?.('tersio', ['gain', '--export', file], { cwd: ctx?.cwd });
47
+ if (!exported || exported.code !== 0)
48
+ throw new Error((exported?.stderr || 'export failed').trim());
49
+ const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
50
+ await pi.exec?.(openCmd, [file], { cwd: ctx?.cwd });
51
+ ctx?.ui?.notify?.(`tersio gain: dashboard opened in your browser.`, 'info');
52
+ }
53
+ catch (e) {
54
+ ctx?.ui?.notify?.(`tersio gain: could not open dashboard (${e.message}). Run 'tersio gain --open' in a shell.`, 'warning');
55
+ }
56
+ }
38
57
  export default function tersioCommandsExtension(pi) {
39
58
  pi.setLabel?.('Tersio unified root command');
40
59
  async function reload(ctx) {
@@ -138,8 +157,12 @@ export default function tersioCommandsExtension(pi) {
138
157
  await runAddonUpdate(pi, ctx, target, dryRun);
139
158
  return;
140
159
  }
141
- if (sub === 'gain' || sub === 'usage') {
142
- ctx?.ui?.notify?.(`${usageSummary()}\nFull dashboard: run \`tersio dashboard --open\` in a shell.`, 'info');
160
+ if (sub === 'gain') {
161
+ await openGainDashboard(pi, ctx);
162
+ return;
163
+ }
164
+ if (sub === 'usage') {
165
+ ctx?.ui?.notify?.(usageSummary(), 'info');
143
166
  return;
144
167
  }
145
168
  ctx?.ui?.notify?.(`Unknown subcommand: ${sub}.\n${HELP}`, 'warning');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krtclcdy/tersio",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "✂️ Token-saving OMP add-ons: terse Caveman replies, compact RTK shell output, lean Ponytail code calls — one-command combo presets.",
5
5
  "keywords": [
6
6
  "omp",
package/tersio.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // tersio.ts — Install Tersio (caveman/rtk/ponytail) add-ons on any OMP device.
3
3
  // Usage: node tersio.js [install|update|reinstall|doctor|uninstall|version|help] [options]
4
4
  // Requires: node/npm and omp CLI
5
- import { PACKAGE_BIN, PACKAGE_VERSION, applyUpdate, commandArg, dashboard, dashboardExport, dashboardOpen, dashboardPort, doctor, showHelp, showVersion, uninstall, unknownCommand, update, usage, } from './cli/common.js';
5
+ import { PACKAGE_BIN, PACKAGE_VERSION, applyUpdate, commandArg, gain, dashboardExport, dashboardOpen, dashboardPort, doctor, showHelp, showVersion, uninstall, unknownCommand, update, usage, } from './cli/common.js';
6
6
  import { closeRL } from './cli/interactive.js';
7
7
  import { runInstall } from './cli/install.js';
8
8
  import { runDoctor } from './cli/doctor.js';
@@ -19,7 +19,7 @@ Commands:
19
19
  reinstall Clean and reinstall the user-scope add-ons
20
20
  doctor Check the current installation
21
21
  usage Ledger-backed usage + savings report
22
- dashboard Serve the gain dashboard (localhost only)
22
+ gain Open the gain dashboard (localhost only)
23
23
  uninstall Remove the managed extensions
24
24
  version Print the package version
25
25
  help Show this help
@@ -35,9 +35,9 @@ Options:
35
35
  --yes, -y
36
36
  --dry-run
37
37
  --verbose
38
- --port <n> (dashboard: pin port, default ephemeral)
39
- --open (dashboard: open browser)
40
- --export <file> (dashboard: write HTML file instead of serving)
38
+ --port <n> (gain: pin port, default ephemeral)
39
+ --open (gain: open browser)
40
+ --export <file> (gain: write HTML file instead of serving)
41
41
  --version, -v
42
42
  --help, -h`);
43
43
  }
@@ -74,7 +74,7 @@ async function main() {
74
74
  closeRL();
75
75
  return;
76
76
  }
77
- if (dashboard) {
77
+ if (gain) {
78
78
  await runDashboard({ port: dashboardPort, open: dashboardOpen, exportFile: dashboardExport });
79
79
  closeRL();
80
80
  return;