@krtclcdy/tersio 2.22.0 → 2.23.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 (47) hide show
  1. package/README.md +38 -30
  2. package/cli/common.js +43 -39
  3. package/cli/currency.js +2 -2
  4. package/cli/dashboard.js +60 -116
  5. package/cli/doctor-fix.js +35 -29
  6. package/cli/doctor.js +26 -15
  7. package/cli/install.js +15 -34
  8. package/cli/profile.js +2 -1
  9. package/cli/settings.js +6 -6
  10. package/cli/update.js +12 -11
  11. package/cli/usage.js +6 -1
  12. package/dashboard/dist/index.html +65 -0
  13. package/extensions/ai-addons-updater/index.js +7 -3
  14. package/extensions/ai-addons-updater/index.ts +15 -3
  15. package/extensions/caveman-session/index.js +17 -12
  16. package/extensions/caveman-session/index.ts +17 -12
  17. package/extensions/caveman-session/rule.md +90 -0
  18. package/extensions/combo-toggle/index.js +22 -2
  19. package/extensions/combo-toggle/index.ts +26 -2
  20. package/extensions/lib/utils.js +20 -4
  21. package/extensions/lib/utils.ts +18 -4
  22. package/extensions/rtk-session/index.js +11 -10
  23. package/extensions/rtk-session/index.ts +10 -11
  24. package/extensions/shared/plugin-settings.js +37 -3
  25. package/extensions/shared/plugin-settings.ts +34 -3
  26. package/extensions/shared/rtk-gain.js +2 -2
  27. package/extensions/shared/rtk-gain.ts +3 -3
  28. package/extensions/shared/session-state.js +5 -3
  29. package/extensions/shared/session-state.ts +4 -3
  30. package/extensions/shared/types.ts +5 -0
  31. package/extensions/shared/usage-ledger.js +114 -4
  32. package/extensions/shared/usage-ledger.ts +122 -4
  33. package/extensions/tersio-commands/index.js +10 -13
  34. package/extensions/tersio-commands/index.ts +10 -13
  35. package/package.json +21 -12
  36. package/tersio.js +10 -10
  37. package/dashboard/charts.js +0 -1208
  38. package/dashboard/core.js +0 -357
  39. package/dashboard/settings.js +0 -166
  40. package/dashboard/share.js +0 -218
  41. package/dashboard/styles.css +0 -379
  42. package/dashboard/template.html +0 -515
  43. package/extensions/shared/mode-reinforcement.ts +0 -29
  44. package/gain/dist/brand.webp +0 -0
  45. package/gain/dist/index.html +0 -65
  46. package/gain/dist/vite.svg +0 -1
  47. /package/dashboard/{brand.webp → dist/brand.webp} +0 -0
@@ -1,6 +1,4 @@
1
- import os from 'node:os';
2
- import path from 'node:path';
3
- import { activeModesSummary, asPromptArray, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, normalizeMode, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.ts';
1
+ import { activeModesSummary, asPromptArray, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.ts';
4
2
  import { readRtkDefault } from '../shared/plugin-settings.ts';
5
3
  import type { ExtensionApi, ExtensionCtx, InputEvent, SessionEntry, SystemPromptEvent } from '../shared/types.ts';
6
4
 
@@ -17,13 +15,12 @@ function resolveEnabled(entries: SessionEntry[] | null | undefined): boolean | n
17
15
  return lastCustomValue(entries, 'rtk-mode', (data) => asBoolean(data?.enabled));
18
16
  }
19
17
 
20
- const IS_WINDOWS = process.platform === 'win32';
21
- const HOME = os.homedir();
22
- const RTK_BINARY = path.join(HOME, '.bun', 'bin', IS_WINDOWS ? 'rtk.exe' : 'rtk');
18
+ function setRtkProcessEnabled(enabled: boolean): void {
19
+ if (enabled) delete process.env.RTK_DISABLED;
20
+ else process.env.RTK_DISABLED = '1';
21
+ }
23
22
 
24
- const RTK_PROMPT = `RTK mode active for this session.
25
- Use Rust Token Killer for shell output that would otherwise be noisy. Prefer explicit RTK commands in bash: \`rtk git status\`, \`rtk git diff\`, \`rtk read <file>\`, \`rtk grep <pattern> <path>\`, \`rtk find <glob> <path>\`, \`rtk test <cmd...>\`, \`rtk tsc\`, \`rtk lint\`, or \`rtk <tool> ...\` for supported dev commands.
26
- Do not use RTK when exact raw output is required, when a specialized OMP tool is required by system policy, or when the command changes state and RTK would hide important confirmation text. Specialized OMP tools still win: read/glob/grep/edit/lsp stay preferred over shell equivalents.`;
23
+ const RTK_PROMPT = `RTK guidance active. RTK automatically rewrites eligible Bash calls through the installed rtk hook. Prefer rtk for noisy shell output, but use exact raw output for state changes, checksums, patches, and diagnostics that need full bytes.`;
27
24
 
28
25
  interface ZodChain {
29
26
  min: (n: number) => ZodChain;
@@ -52,7 +49,7 @@ export default function rtkSessionExtension(pi: ExtensionApi): void {
52
49
  enabled = Boolean(next);
53
50
  pi.appendEntry?.('rtk-mode', { enabled });
54
51
  setSharedComboMode('rtk', enabled);
55
- syncStatus(ctx);
52
+ setRtkProcessEnabled(enabled);
56
53
  const active = activeModesSummary(getSharedComboState());
57
54
  ctx?.ui?.notify?.(enabled ? `RTK on — compact shell output for this session. Active: ${active}.` : `RTK off. Active: ${active}.`, 'info');
58
55
  }
@@ -63,6 +60,7 @@ export default function rtkSessionExtension(pi: ExtensionApi): void {
63
60
  // it at once so the next turn and the rtk_run gate see it, no reload.
64
61
  function syncFromShared(state: { rtk: string }): void {
65
62
  enabled = state.rtk === 'on';
63
+ setRtkProcessEnabled(enabled);
66
64
  syncStatus();
67
65
  }
68
66
  setSharedComboListener(syncFromShared);
@@ -102,7 +100,7 @@ export default function rtkSessionExtension(pi: ExtensionApi): void {
102
100
  };
103
101
  }
104
102
  onUpdate?.({ content: [{ type: 'text', text: `rtk ${params.args.join(' ')}` }], details: { phase: 'start' } });
105
- const result = await pi.exec!(RTK_BINARY, params.args, { signal, cwd: ctx?.cwd || pi.cwd });
103
+ const result = await pi.exec!('rtk', params.args, { signal, cwd: ctx?.cwd || pi.cwd });
106
104
  const text = [result.stdout, result.stderr].filter(Boolean).join('\n');
107
105
  return {
108
106
  isError: result.code !== 0,
@@ -129,6 +127,7 @@ export default function rtkSessionExtension(pi: ExtensionApi): void {
129
127
  // installer/user-configured default (off unless configured).
130
128
  const persisted = resolveEnabled(entries);
131
129
  enabled = typeof persisted === 'boolean' ? persisted : readRtkDefault();
130
+ setRtkProcessEnabled(enabled);
132
131
  syncStatus(ctx);
133
132
  }
134
133
 
@@ -3,7 +3,7 @@
3
3
  // written by `omp plugin config set @krtclcdy/tersio <key> <value>` and by
4
4
  // the installer profile step. Tolerant: any parse failure yields {} so every
5
5
  // caller falls back to its own default.
6
- import { existsSync, readFileSync } from 'node:fs';
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
7
  import os from 'node:os';
8
8
  import path from 'node:path';
9
9
  export const PLUGIN_NAME = '@krtclcdy/tersio';
@@ -30,8 +30,8 @@ export function readPluginSettings() {
30
30
  return {};
31
31
  }
32
32
  const COMBO_LEVELS = new Set(['off', 'medium', 'balanced', 'max']);
33
- const CAVEMAN_MODES = new Set(['off', 'lite', 'full', 'ultra', 'wenyan']);
34
- const PONYTAIL_MODES = new Set(['off', 'lite', 'full', 'ultra', 'review']);
33
+ const CAVEMAN_MODES = new Set(['off', 'lite', 'full', 'ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']);
34
+ const PONYTAIL_MODES = new Set(['off', 'lite', 'full', 'ultra']);
35
35
  function readStringDefault(key, valid) {
36
36
  const raw = readPluginSettings()[key];
37
37
  return typeof raw === 'string' && valid.has(raw) ? raw : 'off';
@@ -49,3 +49,37 @@ export function readRtkDefault() {
49
49
  export function readPonytailDefault() {
50
50
  return readStringDefault('ponytailDefault', PONYTAIL_MODES);
51
51
  }
52
+ export function isComboSetupComplete() {
53
+ return readPluginSettings().comboSetupComplete === true;
54
+ }
55
+ const COMBO_MODE_DEFAULTS = {
56
+ off: { cavemanDefault: 'off', rtkDefault: false, ponytailDefault: 'off' },
57
+ medium: { cavemanDefault: 'lite', rtkDefault: true, ponytailDefault: 'lite' },
58
+ balanced: { cavemanDefault: 'full', rtkDefault: true, ponytailDefault: 'full' },
59
+ max: { cavemanDefault: 'ultra', rtkDefault: true, ponytailDefault: 'ultra' },
60
+ };
61
+ export function saveComboSetup(level) {
62
+ if (!(level in COMBO_MODE_DEFAULTS))
63
+ return false;
64
+ const lockPath = lockPaths()[0];
65
+ let lock = {};
66
+ if (existsSync(lockPath)) {
67
+ try {
68
+ lock = JSON.parse(readFileSync(lockPath, 'utf8'));
69
+ }
70
+ catch {
71
+ lock = {};
72
+ }
73
+ }
74
+ lock.plugins ||= {};
75
+ lock.settings ||= {};
76
+ lock.settings[PLUGIN_NAME] = {
77
+ ...lock.settings[PLUGIN_NAME],
78
+ comboDefault: level,
79
+ ...COMBO_MODE_DEFAULTS[level],
80
+ comboSetupComplete: true,
81
+ };
82
+ mkdirSync(path.dirname(lockPath), { recursive: true });
83
+ writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf8');
84
+ return true;
85
+ }
@@ -4,7 +4,7 @@
4
4
  // the installer profile step. Tolerant: any parse failure yields {} so every
5
5
  // caller falls back to its own default.
6
6
 
7
- import { existsSync, readFileSync } from 'node:fs';
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
10
  export const PLUGIN_NAME = '@krtclcdy/tersio';
@@ -31,8 +31,8 @@ export function readPluginSettings(): Record<string, unknown> {
31
31
  }
32
32
 
33
33
  const COMBO_LEVELS = new Set(['off', 'medium', 'balanced', 'max']);
34
- const CAVEMAN_MODES = new Set(['off', 'lite', 'full', 'ultra', 'wenyan']);
35
- const PONYTAIL_MODES = new Set(['off', 'lite', 'full', 'ultra', 'review']);
34
+ const CAVEMAN_MODES = new Set(['off', 'lite', 'full', 'ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']);
35
+ const PONYTAIL_MODES = new Set(['off', 'lite', 'full', 'ultra']);
36
36
 
37
37
  function readStringDefault(key: string, valid: Set<string>): string {
38
38
  const raw = readPluginSettings()[key];
@@ -55,3 +55,34 @@ export function readRtkDefault(): boolean {
55
55
  export function readPonytailDefault(): string {
56
56
  return readStringDefault('ponytailDefault', PONYTAIL_MODES);
57
57
  }
58
+
59
+ export function isComboSetupComplete(): boolean {
60
+ return readPluginSettings().comboSetupComplete === true;
61
+ }
62
+
63
+ const COMBO_MODE_DEFAULTS = {
64
+ off: { cavemanDefault: 'off', rtkDefault: false, ponytailDefault: 'off' },
65
+ medium: { cavemanDefault: 'lite', rtkDefault: true, ponytailDefault: 'lite' },
66
+ balanced: { cavemanDefault: 'full', rtkDefault: true, ponytailDefault: 'full' },
67
+ max: { cavemanDefault: 'ultra', rtkDefault: true, ponytailDefault: 'ultra' },
68
+ } as const;
69
+
70
+ export function saveComboSetup(level: string): boolean {
71
+ if (!(level in COMBO_MODE_DEFAULTS)) return false;
72
+ const lockPath = lockPaths()[0];
73
+ let lock: { plugins?: Record<string, unknown>; settings?: Record<string, Record<string, unknown>> } = {};
74
+ if (existsSync(lockPath)) {
75
+ try { lock = JSON.parse(readFileSync(lockPath, 'utf8')) as typeof lock; } catch { lock = {}; }
76
+ }
77
+ lock.plugins ||= {};
78
+ lock.settings ||= {};
79
+ lock.settings[PLUGIN_NAME] = {
80
+ ...lock.settings[PLUGIN_NAME],
81
+ comboDefault: level,
82
+ ...COMBO_MODE_DEFAULTS[level as keyof typeof COMBO_MODE_DEFAULTS],
83
+ comboSetupComplete: true,
84
+ };
85
+ mkdirSync(path.dirname(lockPath), { recursive: true });
86
+ writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf8');
87
+ return true;
88
+ }
@@ -55,8 +55,8 @@ export function readRtkGain(limit = 10, cutoffMs) {
55
55
  const where = cutoffMs && cutoffMs > 0
56
56
  ? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
57
57
  : '';
58
- const [[commands = '0', saved = '0', input = '0', avgPct = '0', totalMs = '0'] = []] = query(rtkDbPath(), `SELECT COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(SUM(input_tokens),0), COALESCE(AVG(savings_pct),0), COALESCE(SUM(exec_time_ms),0) FROM commands ${where};`);
59
- const byCommand = query(rtkDbPath(), `SELECT substr(${CMD_ONE_LINE}, 1, ${MAX_CMD_CHARS}), COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(AVG(savings_pct),0), COALESCE(AVG(exec_time_ms),0) FROM commands ${where} GROUP BY rtk_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ${Math.max(1, Math.floor(limit))};`).map(([command, count, savedRow, pct, ms]) => ({
58
+ const [[commands = '0', saved = '0', input = '0', totalMs = '0'] = []] = query(rtkDbPath(), `SELECT COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(SUM(input_tokens),0), COALESCE(SUM(exec_time_ms),0) FROM commands ${where};`);
59
+ const byCommand = query(rtkDbPath(), `SELECT substr(${CMD_ONE_LINE}, 1, ${MAX_CMD_CHARS}), COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(100.0 * SUM(saved_tokens) / NULLIF(SUM(input_tokens), 0), 0), COALESCE(AVG(exec_time_ms),0) FROM commands ${where} GROUP BY rtk_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ${Math.max(1, Math.floor(limit))};`).map(([command, count, savedRow, pct, ms]) => ({
60
60
  command,
61
61
  count: Number(count) || 0,
62
62
  saved: Number(savedRow) || 0,
@@ -76,13 +76,13 @@ export function readRtkGain(limit = 10, cutoffMs?: number): RtkGain {
76
76
  const where = cutoffMs && cutoffMs > 0
77
77
  ? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
78
78
  : '';
79
- const [[commands = '0', saved = '0', input = '0', avgPct = '0', totalMs = '0'] = []] = query(
79
+ const [[commands = '0', saved = '0', input = '0', totalMs = '0'] = []] = query(
80
80
  rtkDbPath(),
81
- `SELECT COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(SUM(input_tokens),0), COALESCE(AVG(savings_pct),0), COALESCE(SUM(exec_time_ms),0) FROM commands ${where};`,
81
+ `SELECT COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(SUM(input_tokens),0), COALESCE(SUM(exec_time_ms),0) FROM commands ${where};`,
82
82
  );
83
83
  const byCommand = query(
84
84
  rtkDbPath(),
85
- `SELECT substr(${CMD_ONE_LINE}, 1, ${MAX_CMD_CHARS}), COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(AVG(savings_pct),0), COALESCE(AVG(exec_time_ms),0) FROM commands ${where} GROUP BY rtk_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ${Math.max(1, Math.floor(limit))};`,
85
+ `SELECT substr(${CMD_ONE_LINE}, 1, ${MAX_CMD_CHARS}), COUNT(*), COALESCE(SUM(saved_tokens),0), COALESCE(100.0 * SUM(saved_tokens) / NULLIF(SUM(input_tokens), 0), 0), COALESCE(AVG(exec_time_ms),0) FROM commands ${where} GROUP BY rtk_cmd ORDER BY SUM(saved_tokens) DESC LIMIT ${Math.max(1, Math.floor(limit))};`,
86
86
  ).map(([command, count, savedRow, pct, ms]) => ({
87
87
  command,
88
88
  count: Number(count) || 0,
@@ -7,9 +7,9 @@ export const COMBO_LEVELS = Object.freeze({
7
7
  max: Object.freeze({ level: 'max', caveman: 'ultra', rtk: 'on', ponytail: 'ultra' }),
8
8
  });
9
9
  const MODE_VALUES = {
10
- caveman: new Set(['off', 'lite', 'full', 'ultra', 'wenyan']),
10
+ caveman: new Set(['off', 'lite', 'full', 'ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']),
11
11
  rtk: new Set(['off', 'on']),
12
- ponytail: new Set(['off', 'lite', 'full', 'ultra', 'review']),
12
+ ponytail: new Set(['off', 'lite', 'full', 'ultra']),
13
13
  };
14
14
  const MODE_ENTRY_TYPES = {
15
15
  'caveman-mode': 'caveman',
@@ -20,7 +20,9 @@ export function normalizeMode(name, value) {
20
20
  if (name === 'rtk' && typeof value === 'boolean')
21
21
  return value ? 'on' : 'off';
22
22
  const mode = String(value ?? '').trim().toLowerCase();
23
- return MODE_VALUES[name]?.has(mode) ? mode : null;
23
+ if (!MODE_VALUES[name]?.has(mode))
24
+ return null;
25
+ return name === 'caveman' && mode === 'wenyan' ? 'wenyan-full' : mode;
24
26
  }
25
27
  export function deriveLevel(modes) {
26
28
  for (const level of Object.keys(COMBO_LEVELS)) {
@@ -12,9 +12,9 @@ export const COMBO_LEVELS: Record<string, Readonly<ComboState>> = Object.freeze(
12
12
  });
13
13
 
14
14
  const MODE_VALUES: Record<string, Set<string>> = {
15
- caveman: new Set(['off', 'lite', 'full', 'ultra', 'wenyan']),
15
+ caveman: new Set(['off', 'lite', 'full', 'ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']),
16
16
  rtk: new Set(['off', 'on']),
17
- ponytail: new Set(['off', 'lite', 'full', 'ultra', 'review']),
17
+ ponytail: new Set(['off', 'lite', 'full', 'ultra']),
18
18
  };
19
19
 
20
20
  type ModeName = 'caveman' | 'rtk' | 'ponytail';
@@ -34,7 +34,8 @@ interface Bridge {
34
34
  export function normalizeMode(name: ModeName, value: unknown): string | null {
35
35
  if (name === 'rtk' && typeof value === 'boolean') return value ? 'on' : 'off';
36
36
  const mode = String(value ?? '').trim().toLowerCase();
37
- return MODE_VALUES[name]?.has(mode) ? mode : null;
37
+ if (!MODE_VALUES[name]?.has(mode)) return null;
38
+ return name === 'caveman' && mode === 'wenyan' ? 'wenyan-full' : mode;
38
39
  }
39
40
 
40
41
  export function deriveLevel(modes: Modes | null | undefined): ComboLevel {
@@ -22,6 +22,11 @@ export type SessionEntry = {
22
22
  };
23
23
 
24
24
  export interface UiApi {
25
+ select?: (
26
+ title: string,
27
+ options: Array<string | { label: string; description?: string }>,
28
+ dialogOptions?: Record<string, unknown>,
29
+ ) => Promise<string | undefined>;
25
30
  setStatus?: (name: string, value: string | undefined) => void;
26
31
  notify?: (message: string, level?: string) => void;
27
32
  theme?: {
@@ -1,11 +1,12 @@
1
1
  // Shared usage ledger: append-only JSON-lines file. One source of truth read
2
- // by `tersio usage`, `/tersio usage`, and the gain dashboard.
2
+ // by `tersio usage`, `/tersio usage`, and the Dashboard.
3
3
  // Best-effort by design: a ledger failure never breaks the caller, and
4
4
  // corrupt lines are skipped on read (same pattern as the config normalizer).
5
5
  import fs from 'node:fs';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
- import { tersioDataPath } from '../lib/utils.js';
8
+ import { resolveRtkBinary, tersioDataPath } from '../lib/utils.js';
9
+ import { execFileSync } from 'node:child_process';
9
10
  export function ledgerPath() {
10
11
  const override = process.env.TERSIO_USAGE_FILE;
11
12
  if (override)
@@ -137,6 +138,11 @@ export function walkJsonl(dir, out, cap) {
137
138
  // (model, tokens, timing, status), so a few hundred cost little to ship.
138
139
  export const RECENT_LIMIT = 200;
139
140
  const FREE_SUFFIX = /(?::free|-free)$/i;
141
+ const RTK_ELIGIBLE_HEADS = new Set([
142
+ 'rtk', 'git', 'grep', 'rg', 'find', 'cat', 'ls', 'tree', 'diff', 'log',
143
+ 'npm', 'npx', 'pnpm', 'bun', 'bunx', 'cargo', 'go', 'python', 'pytest',
144
+ 'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
145
+ ]);
140
146
  export function newSessionAccum() {
141
147
  return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [] };
142
148
  }
@@ -194,9 +200,14 @@ export function classifySessionLine(row) {
194
200
  const computedDur = (typeof msg.completedAt === 'number' && typeof msg.timestamp === 'number') ? msg.completedAt - msg.timestamp : undefined;
195
201
  const tools = [];
196
202
  for (const part of msg.content ?? []) {
197
- if (part?.type === 'toolCall' && typeof part.name === 'string' && part.name) {
198
- tools.push(part.name === 'bash' ? `bash:${leadBinary(part.arguments?.command)}` : part.name);
203
+ if (part?.type !== 'toolCall' || typeof part.name !== 'string' || !part.name)
204
+ continue;
205
+ if (part.name !== 'bash') {
206
+ tools.push(part.name);
207
+ continue;
199
208
  }
209
+ const command = part.arguments?.command;
210
+ tools.push(`bash:${leadBinary(command)}`);
200
211
  }
201
212
  return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
202
213
  }
@@ -251,6 +262,82 @@ export function importSessionTokens() {
251
262
  accum.recent.sort((a, b) => b.t - a.t);
252
263
  return { messages: accum.messages, totals: accum.totals, byModel: accum.byModel, byDay: accum.byDay, byDayModel: accum.byDayModel, byTool: accum.byTool, byModelMessages: accum.byModelMessages, costMeasured: accum.costMeasured, recent: accum.recent.slice(0, RECENT_LIMIT) };
253
264
  }
265
+ const adoptionFileCache = new Map();
266
+ function parseAdoptionFile(file) {
267
+ const counts = { bashCalls: 0, eligibleCalls: 0, rtkCalls: 0 };
268
+ let text;
269
+ try {
270
+ text = fs.readFileSync(file, 'utf8');
271
+ }
272
+ catch {
273
+ return { counts, sessions: 0 };
274
+ }
275
+ let sawBash = false;
276
+ for (const line of text.split('\n')) {
277
+ if (!line.includes('tool_execution_start'))
278
+ continue;
279
+ try {
280
+ const row = JSON.parse(line);
281
+ if (row.type !== 'custom' || row.customType !== 'tool_execution_start' || row.data?.toolName !== 'bash')
282
+ continue;
283
+ const command = row.data.args?.command;
284
+ if (typeof command !== 'string')
285
+ continue;
286
+ sawBash = true;
287
+ counts.bashCalls += 1;
288
+ const head = leadBinary(command);
289
+ if (RTK_ELIGIBLE_HEADS.has(head))
290
+ counts.eligibleCalls += 1;
291
+ if (head === 'rtk')
292
+ counts.rtkCalls += 1;
293
+ }
294
+ catch { /* skip corrupt line */ }
295
+ }
296
+ return { counts, sessions: sawBash ? 1 : 0 };
297
+ }
298
+ export function clearRtkAdoptionCache() {
299
+ adoptionFileCache.clear();
300
+ }
301
+ export function readRtkAdoption() {
302
+ const files = [];
303
+ walkJsonl(sessionsDir(), files, 2000);
304
+ if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
305
+ walkJsonl(codexSessionsDir(), files, 2000);
306
+ }
307
+ const live = new Set(files);
308
+ for (const file of adoptionFileCache.keys()) {
309
+ if (!live.has(file))
310
+ adoptionFileCache.delete(file);
311
+ }
312
+ let bashCalls = 0;
313
+ let eligibleCalls = 0;
314
+ let rtkCalls = 0;
315
+ let sessions = 0;
316
+ for (const file of files) {
317
+ let stat;
318
+ try {
319
+ stat = fs.statSync(file);
320
+ }
321
+ catch {
322
+ continue;
323
+ }
324
+ let entry = adoptionFileCache.get(file);
325
+ if (!entry || entry.size !== stat.size || entry.mtimeMs !== stat.mtimeMs) {
326
+ const parsed = parseAdoptionFile(file);
327
+ entry = { size: stat.size, mtimeMs: stat.mtimeMs, counts: parsed.counts };
328
+ adoptionFileCache.set(file, entry);
329
+ sessions += parsed.sessions;
330
+ }
331
+ else if (entry.counts.bashCalls > 0) {
332
+ sessions += 1;
333
+ }
334
+ bashCalls += entry.counts.bashCalls;
335
+ eligibleCalls += entry.counts.eligibleCalls;
336
+ rtkCalls += entry.counts.rtkCalls;
337
+ }
338
+ const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
339
+ return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
340
+ }
254
341
  export function processSessionText(accum, state, text) {
255
342
  for (const line of text.split('\n')) {
256
343
  if (!line.trim())
@@ -275,6 +362,29 @@ export function processSessionText(accum, state, text) {
275
362
  catch { /* skip corrupt lines */ }
276
363
  }
277
364
  }
365
+ export function readRtkRecallDiagnostics(binary = resolveRtkBinary()) {
366
+ if (!binary)
367
+ return { mode: 'unknown', entries: 0, available: false };
368
+ try {
369
+ const configPath = process.env.RTK_CONFIG
370
+ || (process.platform === 'darwin'
371
+ ? path.join(os.homedir(), 'Library', 'Application Support', 'rtk', 'config.toml')
372
+ : path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'rtk', 'config.toml'));
373
+ let config = '';
374
+ try {
375
+ config = fs.readFileSync(configPath, 'utf8');
376
+ }
377
+ catch { /* use RTK CLI fallback */ }
378
+ const modeMatch = /^\s*mode\s*=\s*"(sqlite|tee|disabled)"/m.exec(config);
379
+ const mode = (modeMatch?.[1] ?? 'unknown');
380
+ const output = execFileSync(binary, ['recall', '--list'], { encoding: 'utf8', timeout: 5000, windowsHide: true });
381
+ const entries = /\((\d+)\s+(?:entries?|stored)\)/i.exec(output)?.[1] ?? /^\s*(\d+)\s+entries?/im.exec(output)?.[1] ?? '0';
382
+ return { mode, entries: Number(entries) || 0, available: true };
383
+ }
384
+ catch {
385
+ return { mode: 'unknown', entries: 0, available: false };
386
+ }
387
+ }
278
388
  // Lead binary of a shell string: first segment head past `cd` chains and
279
389
  // VAR=x assignments (`cd /x && git status` → `git`). Falls back to `bash`.
280
390
  const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
@@ -1,11 +1,12 @@
1
1
  // Shared usage ledger: append-only JSON-lines file. One source of truth read
2
- // by `tersio usage`, `/tersio usage`, and the gain dashboard.
2
+ // by `tersio usage`, `/tersio usage`, and the Dashboard.
3
3
  // Best-effort by design: a ledger failure never breaks the caller, and
4
4
  // corrupt lines are skipped on read (same pattern as the config normalizer).
5
5
  import fs from 'node:fs';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
- import { tersioDataPath } from '../lib/utils.ts';
8
+ import { resolveRtkBinary, tersioDataPath } from '../lib/utils.ts';
9
+ import { execFileSync } from 'node:child_process';
9
10
 
10
11
  export type UsageKind = 'command' | 'toggle' | 'install' | 'update' | 'rtk-audit';
11
12
 
@@ -104,6 +105,21 @@ export interface SessionTokens {
104
105
  recent: RecentRequest[];
105
106
  }
106
107
 
108
+ export interface RtkAdoption {
109
+ sessions: number;
110
+ bashCalls: number;
111
+ eligibleCalls: number;
112
+ rtkCalls: number;
113
+ missedCalls: number;
114
+ adoptionPct: number;
115
+ }
116
+
117
+ export interface RtkRecallDiagnostics {
118
+ mode: 'sqlite' | 'tee' | 'disabled' | 'unknown';
119
+ entries: number;
120
+ available: boolean;
121
+ }
122
+
107
123
  function zeroBreakdown(): TokenBreakdown {
108
124
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
109
125
  }
@@ -187,6 +203,11 @@ export function walkJsonl(dir: string, out: string[], cap: number): void {
187
203
  // (model, tokens, timing, status), so a few hundred cost little to ship.
188
204
  export const RECENT_LIMIT = 200;
189
205
  const FREE_SUFFIX = /(?::free|-free)$/i;
206
+ const RTK_ELIGIBLE_HEADS = new Set([
207
+ 'rtk', 'git', 'grep', 'rg', 'find', 'cat', 'ls', 'tree', 'diff', 'log',
208
+ 'npm', 'npx', 'pnpm', 'bun', 'bunx', 'cargo', 'go', 'python', 'pytest',
209
+ 'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
210
+ ]);
190
211
  // Session-parse buffer shared by live reads and usage.db syncs: identical
191
212
  // inputs produce identical aggregates, so the store is a cache, never a fork.
192
213
  export interface SessionAccum {
@@ -253,9 +274,13 @@ export function classifySessionLine(row: { timestamp?: string | number; type?: u
253
274
  const computedDur = (typeof msg.completedAt === 'number' && typeof msg.timestamp === 'number') ? msg.completedAt - msg.timestamp : undefined;
254
275
  const tools: string[] = [];
255
276
  for (const part of msg.content ?? []) {
256
- if (part?.type === 'toolCall' && typeof part.name === 'string' && part.name) {
257
- tools.push(part.name === 'bash' ? `bash:${leadBinary((part.arguments as { command?: unknown } | null)?.command)}` : part.name);
277
+ if (part?.type !== 'toolCall' || typeof part.name !== 'string' || !part.name) continue;
278
+ if (part.name !== 'bash') {
279
+ tools.push(part.name);
280
+ continue;
258
281
  }
282
+ const command = (part.arguments as { command?: unknown } | null)?.command;
283
+ tools.push(`bash:${leadBinary(command)}`);
259
284
  }
260
285
  return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
261
286
  }
@@ -305,6 +330,80 @@ export function importSessionTokens(): SessionTokens {
305
330
  accum.recent.sort((a, b) => b.t - a.t);
306
331
  return { messages: accum.messages, totals: accum.totals, byModel: accum.byModel, byDay: accum.byDay, byDayModel: accum.byDayModel, byTool: accum.byTool, byModelMessages: accum.byModelMessages, costMeasured: accum.costMeasured, recent: accum.recent.slice(0, RECENT_LIMIT) };
307
332
  }
333
+
334
+ const adoptionFileCache = new Map<string, { size: number; mtimeMs: number; counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number } }>();
335
+
336
+ function parseAdoptionFile(file: string): { counts: { bashCalls: number; eligibleCalls: number; rtkCalls: number }; sessions: number } {
337
+ const counts = { bashCalls: 0, eligibleCalls: 0, rtkCalls: 0 };
338
+ let text: string;
339
+ try {
340
+ text = fs.readFileSync(file, 'utf8');
341
+ } catch {
342
+ return { counts, sessions: 0 };
343
+ }
344
+ let sawBash = false;
345
+ for (const line of text.split('\n')) {
346
+ if (!line.includes('tool_execution_start')) continue;
347
+ try {
348
+ const row = JSON.parse(line) as {
349
+ type?: string;
350
+ customType?: string;
351
+ data?: { toolName?: unknown; args?: { command?: unknown } };
352
+ };
353
+ if (row.type !== 'custom' || row.customType !== 'tool_execution_start' || row.data?.toolName !== 'bash') continue;
354
+ const command = row.data.args?.command;
355
+ if (typeof command !== 'string') continue;
356
+ sawBash = true;
357
+ counts.bashCalls += 1;
358
+ const head = leadBinary(command);
359
+ if (RTK_ELIGIBLE_HEADS.has(head)) counts.eligibleCalls += 1;
360
+ if (head === 'rtk') counts.rtkCalls += 1;
361
+ } catch { /* skip corrupt line */ }
362
+ }
363
+ return { counts, sessions: sawBash ? 1 : 0 };
364
+ }
365
+
366
+ export function clearRtkAdoptionCache(): void {
367
+ adoptionFileCache.clear();
368
+ }
369
+
370
+ export function readRtkAdoption(): RtkAdoption {
371
+ const files: string[] = [];
372
+ walkJsonl(sessionsDir(), files, 2000);
373
+ if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_CODEX_DIR !== undefined) {
374
+ walkJsonl(codexSessionsDir(), files, 2000);
375
+ }
376
+ const live = new Set(files);
377
+ for (const file of adoptionFileCache.keys()) {
378
+ if (!live.has(file)) adoptionFileCache.delete(file);
379
+ }
380
+ let bashCalls = 0;
381
+ let eligibleCalls = 0;
382
+ let rtkCalls = 0;
383
+ let sessions = 0;
384
+ for (const file of files) {
385
+ let stat: fs.Stats;
386
+ try {
387
+ stat = fs.statSync(file);
388
+ } catch {
389
+ continue;
390
+ }
391
+ let entry = adoptionFileCache.get(file);
392
+ if (!entry || entry.size !== stat.size || entry.mtimeMs !== stat.mtimeMs) {
393
+ const parsed = parseAdoptionFile(file);
394
+ entry = { size: stat.size, mtimeMs: stat.mtimeMs, counts: parsed.counts };
395
+ adoptionFileCache.set(file, entry);
396
+ sessions += parsed.sessions;
397
+ } else if (entry.counts.bashCalls > 0) {
398
+ sessions += 1;
399
+ }
400
+ bashCalls += entry.counts.bashCalls;
401
+ eligibleCalls += entry.counts.eligibleCalls;
402
+ rtkCalls += entry.counts.rtkCalls;
403
+ }
404
+ const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
405
+ return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
406
+ }
308
407
  export function processSessionText(accum: SessionAccum, state: { codexProvider: string | null }, text: string): void {
309
408
  for (const line of text.split('\n')) {
310
409
  if (!line.trim()) continue;
@@ -325,6 +424,25 @@ export function processSessionText(accum: SessionAccum, state: { codexProvider:
325
424
  } catch { /* skip corrupt lines */ }
326
425
  }
327
426
  }
427
+
428
+ export function readRtkRecallDiagnostics(binary: string | null = resolveRtkBinary()): RtkRecallDiagnostics {
429
+ if (!binary) return { mode: 'unknown', entries: 0, available: false };
430
+ try {
431
+ const configPath = process.env.RTK_CONFIG
432
+ || (process.platform === 'darwin'
433
+ ? path.join(os.homedir(), 'Library', 'Application Support', 'rtk', 'config.toml')
434
+ : path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'rtk', 'config.toml'));
435
+ let config = '';
436
+ try { config = fs.readFileSync(configPath, 'utf8'); } catch { /* use RTK CLI fallback */ }
437
+ const modeMatch = /^\s*mode\s*=\s*"(sqlite|tee|disabled)"/m.exec(config);
438
+ const mode = (modeMatch?.[1] ?? 'unknown') as RtkRecallDiagnostics['mode'];
439
+ const output = execFileSync(binary, ['recall', '--list'], { encoding: 'utf8', timeout: 5000, windowsHide: true });
440
+ const entries = /\((\d+)\s+(?:entries?|stored)\)/i.exec(output)?.[1] ?? /^\s*(\d+)\s+entries?/im.exec(output)?.[1] ?? '0';
441
+ return { mode, entries: Number(entries) || 0, available: true };
442
+ } catch {
443
+ return { mode: 'unknown', entries: 0, available: false };
444
+ }
445
+ }
328
446
  // Lead binary of a shell string: first segment head past `cd` chains and
329
447
  // VAR=x assignments (`cd /x && git status` → `git`). Falls back to `bash`.
330
448
  const SKIP_HEADS = ['cd', 'echo', 'export', 'true', 'false'];
@@ -1,4 +1,4 @@
1
- // /tersio root command — status, check, update, gain, usage, help.
1
+ // /tersio root command — status, check, update, dashboard, usage, help.
2
2
  // Mode switches live on their own commands (/caveman, /rtk, /combo, and the
3
3
  // upstream /ponytail): the router keeps no redundant copies. Shared-state
4
4
  // publishes sync sibling mirrors live, so switches notify and take effect
@@ -12,7 +12,7 @@ const HELP = [
12
12
  '/tersio status — active modes + combo level',
13
13
  '/tersio check — add-on version check',
14
14
  '/tersio update <ponytail|rtk|caveman|all> [--dry-run]',
15
- '/tersio gain — savings summary + dashboard hint',
15
+ '/tersio dashboard — open the Dashboard',
16
16
  '/tersio usage — ledger report for this machine',
17
17
  '/tersio help — this table',
18
18
  'mode switches: /caveman /rtk /combo (tersio) + /ponytail (upstream)',
@@ -33,27 +33,24 @@ function usageSummary() {
33
33
  const last = new Date(rows[rows.length - 1].ts).toISOString();
34
34
  return `tersio usage: ${rows.length} rows (${parts.join(', ')}), last write ${last}.`;
35
35
  }
36
- // Gain opens instantaneously in the default browser: export a file://-ready
37
- // snapshot (data inlined, no server to babysit) and open it. Falls back to
38
- // the shell command when export or open fails.
39
- async function openGainDashboard(pi, ctx) {
40
- const file = path.join(os.tmpdir(), `tersio-gain-${Date.now()}.html`);
36
+ async function openDashboard(pi, ctx) {
37
+ const file = path.join(os.tmpdir(), `tersio-dashboard-${Date.now()}.html`);
41
38
  try {
42
- const exported = await pi.exec?.('tersio', ['gain', '--export', file], { cwd: ctx?.cwd });
39
+ const exported = await pi.exec?.('tersio', ['dashboard', '--export', file], { cwd: ctx?.cwd });
43
40
  if (!exported || exported.code !== 0)
44
41
  throw new Error((exported?.stderr || 'export failed').trim());
45
42
  const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
46
43
  await pi.exec?.(openCmd, [file], { cwd: ctx?.cwd });
47
- ctx?.ui?.notify?.(`tersio gain: dashboard opened in your browser.`, 'info');
44
+ ctx?.ui?.notify?.(`tersio dashboard: opened in your browser.`, 'info');
48
45
  }
49
46
  catch (e) {
50
- ctx?.ui?.notify?.(`tersio gain: could not open dashboard (${e.message}). Run 'tersio gain --open' in a shell.`, 'warning');
47
+ ctx?.ui?.notify?.(`tersio dashboard: could not open (${e.message}). Run 'tersio dashboard --open' in a shell.`, 'warning');
51
48
  }
52
49
  }
53
50
  export default function tersioCommandsExtension(pi) {
54
51
  pi.setLabel?.('Tersio unified root command');
55
52
  pi.registerCommand?.('tersio', {
56
- description: 'Tersio root: status|check|update|gain|usage|help (modes: /caveman /rtk /combo /ponytail)',
53
+ description: 'Tersio root: status|check|update|dashboard|usage|help',
57
54
  handler: async (args, ctx) => {
58
55
  const parts = String(args || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
59
56
  const [sub] = [parts[0]];
@@ -82,8 +79,8 @@ export default function tersioCommandsExtension(pi) {
82
79
  await runAddonUpdate(pi, ctx, target, dryRun);
83
80
  return;
84
81
  }
85
- if (sub === 'gain') {
86
- await openGainDashboard(pi, ctx);
82
+ if (sub === 'dashboard') {
83
+ await openDashboard(pi, ctx);
87
84
  return;
88
85
  }
89
86
  if (sub === 'usage') {