@krtclcdy/tersio 2.4.1 → 2.6.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.
@@ -0,0 +1,171 @@
1
+ // cli/uninstall.ts — remove managed extensions, plugins, and binaries.
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { cancel as clackCancel, confirm as clackConfirm } from '@clack/prompts';
5
+ import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, RTK_BINARY_NAME, dryRun, keepPonytail, removePonytail, removeRtk, yes, debug, writeConfigLines, } from './common.js';
6
+ import { ask, closeRL, tty } from './interactive.js';
7
+ import { readTextIfExists } from '../extensions/lib/utils.js';
8
+ // Read-modify-write a JSON file. mutate returns true when it changed
9
+ // something; no change (or unreadable file) means no output at all.
10
+ async function updateJsonFile(filePath, mutate, dryRunNote, writeNote, dryRun) {
11
+ const raw = await readTextIfExists(filePath);
12
+ if (!raw)
13
+ return;
14
+ let data;
15
+ try {
16
+ data = JSON.parse(raw);
17
+ }
18
+ catch {
19
+ debug(`Could not parse ${filePath}`);
20
+ return;
21
+ }
22
+ if (!mutate(data))
23
+ return;
24
+ if (dryRun) {
25
+ console.log(` [dry-run] ${dryRunNote}`);
26
+ return;
27
+ }
28
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
29
+ console.log(` [write] ${writeNote}`);
30
+ }
31
+ function dropKey(section, key) {
32
+ if (!section || typeof section !== 'object')
33
+ return false;
34
+ const record = section;
35
+ if (!(key in record))
36
+ return false;
37
+ delete record[key];
38
+ return true;
39
+ }
40
+ async function removeUninstallTarget(target, shouldDryRun, recursive = true) {
41
+ try {
42
+ if (shouldDryRun) {
43
+ console.log(` [dry-run] would remove ${target}`);
44
+ return;
45
+ }
46
+ if (recursive)
47
+ await fs.rm(target, { recursive: true, force: true });
48
+ else
49
+ await fs.unlink(target);
50
+ console.log(` [rm] ${target}`);
51
+ }
52
+ catch {
53
+ debug(`Could not remove ${target}`);
54
+ }
55
+ }
56
+ async function runUninstall(options = {}) {
57
+ const shouldDryRun = options.dryRun ?? dryRun;
58
+ const confirmed = (options.yes ?? yes) || shouldDryRun;
59
+ // Ponytail ships with Tersio's presets, so a full uninstall removes it too;
60
+ // --keep-ponytail opts out and reinstall always preserves it.
61
+ const shouldRemovePonytail = options.removePonytail ?? (removePonytail || !keepPonytail);
62
+ const shouldRemoveRtk = options.removeRtk ?? removeRtk;
63
+ console.log('\n=== Tersio Uninstall ===\n');
64
+ const extDir = path.join(OMP_AGENT_DIR, 'extensions');
65
+ const configPath = path.join(OMP_AGENT_DIR, 'config.yml');
66
+ const rtkBin = path.join(BUN_BIN_DIR, RTK_BINARY_NAME);
67
+ const pluginsDir = OMP_PLUGINS_DIR;
68
+ const ponytailPkgDir = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail');
69
+ const targets = [
70
+ 'caveman-session',
71
+ 'rtk-session',
72
+ 'ai-addons-updater',
73
+ 'combo-toggle',
74
+ 'shared',
75
+ // Only consumed by ai-addons-updater (removed above); otherwise orphaned.
76
+ 'lib',
77
+ // Legacy always-on combo helper; imports shared/session-state.js, so it
78
+ // breaks with a module-not-found warning once the shared dir is removed.
79
+ 'aaa-combo-boot',
80
+ ].map((dir) => path.join(extDir, dir));
81
+ console.log('Will remove:');
82
+ for (const t of targets) {
83
+ console.log(` ${t}`);
84
+ }
85
+ if (shouldRemovePonytail) {
86
+ console.log(` ${ponytailPkgDir} (ponytail plugin package)`);
87
+ }
88
+ if (shouldRemoveRtk) {
89
+ console.log(` ${rtkBin}`);
90
+ }
91
+ if (!confirmed) {
92
+ if (tty()) {
93
+ closeRL();
94
+ const confirmedChoice = await clackConfirm({ message: 'Remove the listed Tersio files?', initialValue: false });
95
+ if (typeof confirmedChoice !== 'boolean') {
96
+ clackCancel('Aborted.');
97
+ closeRL();
98
+ return false;
99
+ }
100
+ if (!confirmedChoice) {
101
+ console.log('Aborted.');
102
+ closeRL();
103
+ return false;
104
+ }
105
+ }
106
+ else {
107
+ const answer = await ask('\nProceed? [y/N]: ');
108
+ if (!answer.toLowerCase().startsWith('y')) {
109
+ console.log('Aborted.');
110
+ closeRL();
111
+ return false;
112
+ }
113
+ }
114
+ }
115
+ // Remove extension directories (independent paths, so concurrently).
116
+ await Promise.all(targets.map((t) => removeUninstallTarget(t, shouldDryRun)));
117
+ // Remove Combo and mode-reinforcement registrations; Ponytail only when requested.
118
+ const configRaw = await readTextIfExists(configPath);
119
+ if (configRaw) {
120
+ let lines = configRaw.split('\n');
121
+ const before = lines.length;
122
+ lines = lines.filter((l) => {
123
+ if (l.includes('combo-toggle') || l.includes('mode-reinforcement'))
124
+ return false;
125
+ if (shouldRemovePonytail && l.includes('ponytail') && l.includes('pi-extension'))
126
+ return false;
127
+ return true;
128
+ });
129
+ if (lines.length !== before) {
130
+ if (shouldDryRun)
131
+ console.log(` [dry-run] would remove ${before - lines.length} config.yml entries`);
132
+ else
133
+ await writeConfigLines(configPath, lines, ` [write] Updated config.yml (removed ${before - lines.length} entries)`);
134
+ }
135
+ }
136
+ // Remove the Ponytail plugin package the installer added (dep, files,
137
+ // lock entry); the config.yml entry was filtered above.
138
+ if (shouldRemovePonytail) {
139
+ const pluginsPkgPath = path.join(pluginsDir, 'package.json');
140
+ await updateJsonFile(pluginsPkgPath, (data) => dropKey(data.dependencies, '@dietrichgebert/ponytail'), `would remove @dietrichgebert/ponytail from ${pluginsPkgPath}`, 'Removed @dietrichgebert/ponytail from plugins/package.json', shouldDryRun);
141
+ try {
142
+ if (shouldDryRun)
143
+ console.log(` [dry-run] would remove ${ponytailPkgDir}`);
144
+ else {
145
+ await fs.rm(ponytailPkgDir, { recursive: true, force: true });
146
+ console.log(` [rm] ${ponytailPkgDir}`);
147
+ await fs.rm(path.dirname(ponytailPkgDir));
148
+ console.log(` [rm] ${path.dirname(ponytailPkgDir)} (empty scope)`);
149
+ }
150
+ }
151
+ catch {
152
+ debug('Could not remove ponytail package dir (scope may hold other packages)');
153
+ }
154
+ const lockPath = path.join(pluginsDir, 'omp-plugins.lock.json');
155
+ await updateJsonFile(lockPath, (data) => dropKey(data.plugins, '@dietrichgebert/ponytail'), `would remove @dietrichgebert/ponytail from ${lockPath}`, `Removed @dietrichgebert/ponytail from ${lockPath}`, shouldDryRun);
156
+ }
157
+ // Remove our plugin registration from ~/.omp/plugins
158
+ const pluginsPkgPath = path.join(pluginsDir, 'package.json');
159
+ await updateJsonFile(pluginsPkgPath, (data) => dropKey(data.dependencies, PACKAGE_NAME), `would remove ${PACKAGE_NAME} from ${pluginsPkgPath}`, `Removed ${PACKAGE_NAME} from plugins/package.json`, shouldDryRun);
160
+ const selfPluginDir = path.join(pluginsDir, 'node_modules', PACKAGE_NAME);
161
+ if ((await readTextIfExists(path.join(selfPluginDir, 'package.json'))) !== null) {
162
+ await removeUninstallTarget(selfPluginDir, shouldDryRun);
163
+ }
164
+ // Remove RTK binary if requested
165
+ if (shouldRemoveRtk)
166
+ await removeUninstallTarget(rtkBin, shouldDryRun, false);
167
+ console.log('\nDone. Restart OMP for changes to take effect.');
168
+ return true;
169
+ }
170
+ const SCOPE_MAP = { user: '1', project: '2', both: '3' };
171
+ export { runUninstall };
package/cli/update.js ADDED
@@ -0,0 +1,202 @@
1
+ // cli/update.ts — update check, per-add-on plan, latest-installer run.
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
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
+ import { execNetwork } from './interactive.js';
7
+ import { readTextIfExists } from '../extensions/lib/utils.js';
8
+ import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, httpsGet, normalizeRtkVersion, sha256Hex, } from '../extensions/lib/utils.js';
9
+ const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
10
+ function newerThan(a, b) {
11
+ const pa = a.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
12
+ const pb = b.replace(/^v/, '').split('.').map((n) => parseInt(n, 10) || 0);
13
+ for (let i = 0; i < 3; i++) {
14
+ if ((pa[i] || 0) !== (pb[i] || 0))
15
+ return (pa[i] || 0) > (pb[i] || 0);
16
+ }
17
+ return false;
18
+ }
19
+ async function latestPublishedVersion() {
20
+ try {
21
+ const args = IS_WINDOWS
22
+ ? ['/d', '/s', '/c', 'npm', 'view', PACKAGE_NAME, 'version', '--prefer-online']
23
+ : ['view', PACKAGE_NAME, 'version', '--prefer-online'];
24
+ const r = await execNetwork('Checking npm registry for tersio updates', IS_WINDOWS ? process.env.ComSpec || 'cmd.exe' : 'npm', args, {
25
+ timeout: 4000,
26
+ maxBuffer: 1024 * 1024,
27
+ windowsHide: true,
28
+ shell: false,
29
+ });
30
+ return r.stdout.trim() || null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
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() {
39
+ const cachePath = path.join(OMP_PLUGINS_DIR, 'tersio-update-check.json');
40
+ const cached = parseJsonObject(await readTextIfExists(cachePath));
41
+ const cacheFresh = typeof cached?.lastCheck === 'number' && Date.now() - cached.lastCheck < UPDATE_CHECK_TTL_MS;
42
+ if (cacheFresh && cached?.latest)
43
+ return newerThan(cached.latest, PACKAGE_VERSION) ? cached.latest : null;
44
+ const latest = await latestPublishedVersion();
45
+ if (latest) {
46
+ await fs.mkdir(OMP_PLUGINS_DIR, { recursive: true }).catch(() => { });
47
+ await fs.writeFile(cachePath, JSON.stringify({ latest, lastCheck: Date.now() }) + '\n', 'utf8').catch(() => { });
48
+ return newerThan(latest, PACKAGE_VERSION) ? latest : null;
49
+ }
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;
52
+ }
53
+ // Race a probe against a timeout; slow or failing probes resolve null so the
54
+ // update plan stays best-effort and never blocks the update itself.
55
+ async function settle(work, ms) {
56
+ let timer;
57
+ try {
58
+ return await Promise.race([
59
+ work.then((value) => value, () => null),
60
+ new Promise((resolve) => { timer = setTimeout(() => resolve(null), ms); }),
61
+ ]);
62
+ }
63
+ finally {
64
+ clearTimeout(timer);
65
+ }
66
+ }
67
+ // Plain `npm view <spec> version` with no spinner, for background probes.
68
+ async function npmViewVersion(spec, timeoutMs) {
69
+ try {
70
+ const exe = IS_WINDOWS ? process.env.ComSpec || 'cmd.exe' : 'npm';
71
+ const args = IS_WINDOWS
72
+ ? ['/d', '/s', '/c', 'npm', 'view', spec, 'version', '--prefer-online']
73
+ : ['view', spec, 'version', '--prefer-online'];
74
+ const r = await execP(exe, args, { timeout: timeoutMs, maxBuffer: 1024 * 1024, windowsHide: true, shell: false });
75
+ return r.stdout.trim() || null;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ // Run a child with inherited stdio for live output. Used when the child
82
+ // renders its own interactive UI (the delegated installer runs Clack
83
+ // spinners), which a concurrent outer spinner would corrupt into stray bars.
84
+ async function execInherit(cmd, args) {
85
+ const code = await new Promise((resolve, reject) => {
86
+ const child = spawn(cmd, args, { stdio: 'inherit' });
87
+ child.on('error', reject);
88
+ child.on('close', resolve);
89
+ });
90
+ if (code !== 0)
91
+ throw new Error(`${cmd} exited with code ${code}`);
92
+ }
93
+ // Current → latest per add-on. Every probe is capped and nullable; the plan
94
+ // prints `unknown` for anything unreachable and the update proceeds anyway.
95
+ async function probeUpdatePlan(cliLatest) {
96
+ const rtkBin = path.join(BUN_BIN_DIR, RTK_BINARY_NAME);
97
+ const ponytailPkg = path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
98
+ const ruleDest = path.join(OMP_AGENT_DIR, 'extensions', 'caveman-session', 'rule.md');
99
+ const [rtkLocal, rtkRelease, ruleLocalText, ruleRemoteText, ponytailLocalText, ponytailLatest] = await Promise.all([
100
+ settle(execP(rtkBin, ['--version'], { timeout: 5000 }).then((r) => normalizeRtkVersion(r.stdout.trim() || r.stderr.trim()) || null), 6000),
101
+ settle(fetchJson(RTK_RELEASE_API).then((r) => normalizeRtkVersion(r.tag_name) || null), 4000),
102
+ readTextIfExists(ruleDest),
103
+ settle(httpsGet(CAVEMAN_REMOTE_RULE), 4000),
104
+ readTextIfExists(ponytailPkg),
105
+ settle(npmViewVersion('@dietrichgebert/ponytail', 4000), 5000),
106
+ ]);
107
+ const shortHash = (text) => text === null ? null : sha256Hex(text).slice(0, 8);
108
+ return {
109
+ cli: cliLatest,
110
+ rtk: [rtkLocal, rtkRelease],
111
+ rule: [shortHash(ruleLocalText), shortHash(ruleRemoteText)],
112
+ ponytail: [ponytailLocalText ? parseJsonObject(ponytailLocalText)?.version ?? null : null, ponytailLatest],
113
+ };
114
+ }
115
+ function planLine(name, current, latest) {
116
+ if (!current && !latest)
117
+ console.log(` ${name}: unknown`);
118
+ else if (!latest)
119
+ console.log(` ${name}: ${current} (latest unknown)`);
120
+ else if (current === latest)
121
+ console.log(` ${name}: ${current} (up to date)`);
122
+ else
123
+ console.log(` ${name}: ${current ?? 'missing'} → ${latest}`);
124
+ }
125
+ async function runLatestUpdate() {
126
+ const updateScope = scopeFlag || 'user';
127
+ if (!Object.hasOwn(SCOPE_MAP, updateScope)) {
128
+ console.error(`[fail] Invalid --scope: ${updateScope}. Use: user, project, both`);
129
+ process.exitCode = 1;
130
+ return;
131
+ }
132
+ const forwardedArgs = ['--yes', '--scope', updateScope];
133
+ if (dryRun)
134
+ forwardedArgs.push('--dry-run');
135
+ if (verbose)
136
+ forwardedArgs.push('--verbose');
137
+ const npmCommand = IS_WINDOWS ? process.env.ComSpec || 'cmd.exe' : 'npm';
138
+ console.log('=== Updating Tersio ===');
139
+ // Resolve the target explicitly: `npm view --prefer-online` beats the local
140
+ // metadata cache, so a stale `@latest` can never pin an older release.
141
+ const cliLatest = await latestPublishedVersion();
142
+ const target = cliLatest && /^\d+\.\d+\.\d+$/.test(cliLatest) ? `@${cliLatest}` : '@latest';
143
+ // Current → latest per add-on. Best-effort; unreachable probes print as
144
+ // unknown and never block the update.
145
+ const plan = await probeUpdatePlan(cliLatest);
146
+ planLine('Tersio', PACKAGE_VERSION, plan.cli);
147
+ planLine('RTK', plan.rtk[0], plan.rtk[1]);
148
+ planLine('Caveman rule', plan.rule[0], plan.rule[1]);
149
+ planLine('Ponytail', plan.ponytail[0], plan.ponytail[1]);
150
+ console.log('');
151
+ // The npx delegation below only refreshes the OMP-side files; the globally
152
+ // installed CLI keeps its old version until npm -g runs. Refresh both.
153
+ const globalArgs = IS_WINDOWS
154
+ ? ['/d', '/s', '/c', 'npm', 'install', '-g', `${PACKAGE_NAME}${target}`, '--no-audit', '--no-fund', '--prefer-online']
155
+ : ['install', '-g', `${PACKAGE_NAME}${target}`, '--no-audit', '--no-fund', '--prefer-online'];
156
+ if (dryRun) {
157
+ console.log(` [dry-run] would run: npm install -g ${PACKAGE_NAME}${target} --no-audit --no-fund --prefer-online`);
158
+ }
159
+ else {
160
+ try {
161
+ const globalResult = await execNetwork(`Updating global Tersio CLI to ${PACKAGE_NAME}${target}`, npmCommand, globalArgs, {
162
+ timeout: 300000,
163
+ maxBuffer: 10 * 1024 * 1024,
164
+ windowsHide: true,
165
+ shell: false,
166
+ });
167
+ if (globalResult.stdout)
168
+ process.stdout.write(globalResult.stdout);
169
+ if (globalResult.stderr)
170
+ process.stderr.write(globalResult.stderr);
171
+ console.log(` [ok] CLI updated to ${PACKAGE_NAME}${target}`);
172
+ }
173
+ catch (e) {
174
+ console.log(` [warn] Global CLI update skipped: ${e.message}`);
175
+ console.log(` [hint] Manual: npm install -g ${PACKAGE_NAME}${target} --no-audit --no-fund --prefer-online`);
176
+ }
177
+ }
178
+ const npmArgs = [
179
+ 'exec',
180
+ '--yes',
181
+ '--prefer-online',
182
+ `--package=${PACKAGE_NAME}${target}`,
183
+ '--',
184
+ PACKAGE_BIN,
185
+ '--apply-update',
186
+ ...forwardedArgs,
187
+ ];
188
+ const npmCommandArgs = IS_WINDOWS ? ['/d', '/s', '/c', 'npm', ...npmArgs] : npmArgs;
189
+ // Inherited stdio, no outer spinner: the delegated installer renders its
190
+ // own Clack UI live, which capture-then-dump would corrupt into stray bars.
191
+ console.log(` Running ${PACKAGE_NAME}${target} installer...`);
192
+ try {
193
+ await execInherit(npmCommand, npmCommandArgs);
194
+ console.log('\n=== Update complete ===');
195
+ }
196
+ catch (e) {
197
+ const err = e;
198
+ console.error(`\n[fail] Could not run ${PACKAGE_NAME}${target}: ${err.message}`);
199
+ process.exitCode = 1;
200
+ }
201
+ }
202
+ export { checkForUpdate, runLatestUpdate };
package/cli/usage.js ADDED
@@ -0,0 +1,71 @@
1
+ // cli/usage.ts — ledger + session-token usage report, tokscale-style.
2
+ import { co2Grams, importSessionTokens, readUsage, usdCost, } from '../extensions/shared/usage-ledger.js';
3
+ import { withInteractiveSpinner } from './interactive.js';
4
+ export function summarizeUsage(rows) {
5
+ const byKind = {};
6
+ const detailCounts = {};
7
+ for (const r of rows) {
8
+ byKind[r.kind] = (byKind[r.kind] ?? 0) + 1;
9
+ detailCounts[r.detail] = (detailCounts[r.detail] ?? 0) + 1;
10
+ }
11
+ const byDetail = Object.entries(detailCounts).sort((a, b) => b[1] - a[1]).slice(0, 10);
12
+ const session = importSessionTokens();
13
+ let usd = 0;
14
+ let priced = true;
15
+ for (const [model, t] of Object.entries(session.byModel)) {
16
+ const c = usdCost(t, model);
17
+ usd += c.usd;
18
+ if (!c.priced)
19
+ priced = false;
20
+ }
21
+ return {
22
+ total: rows.length,
23
+ byKind,
24
+ byDetail,
25
+ lastWrite: rows.length ? new Date(rows[rows.length - 1].ts).toISOString() : null,
26
+ empty: rows.length === 0 && session.messages === 0,
27
+ messages: session.messages,
28
+ tokens: session.totals,
29
+ byModel: session.byModel,
30
+ byDay: session.byDay,
31
+ usd,
32
+ priced,
33
+ co2g: co2Grams(session.totals.output),
34
+ };
35
+ }
36
+ function fmt(n) {
37
+ return n.toLocaleString('en-US');
38
+ }
39
+ function printReport(report) {
40
+ console.log('\n=== Tersio Usage ===');
41
+ if (report.empty) {
42
+ console.log(' No ledger rows or session tokens yet — run session commands first.');
43
+ return;
44
+ }
45
+ const t = report.tokens;
46
+ 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.)`);
48
+ 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
+ if (models.length) {
50
+ console.log(' By model');
51
+ for (const [model, b] of models) {
52
+ console.log(` ${model}: in ${fmt(b.input)} · out ${fmt(b.output)} · cache r/w ${fmt(b.cacheRead)}/${fmt(b.cacheWrite)}`);
53
+ }
54
+ }
55
+ if (report.total) {
56
+ console.log(' Activity');
57
+ for (const [kind, n] of Object.entries(report.byKind)) {
58
+ console.log(` ${kind}: ${n}`);
59
+ }
60
+ console.log(' Top commands');
61
+ for (const [detail, n] of report.byDetail) {
62
+ console.log(` ${n}× ${detail}`);
63
+ }
64
+ console.log(` Total rows: ${report.total} · last write: ${report.lastWrite}`);
65
+ }
66
+ }
67
+ async function runUsage() {
68
+ const report = await withInteractiveSpinner('Reading usage ledger', async () => summarizeUsage(readUsage()));
69
+ printReport(report);
70
+ }
71
+ export { runUsage };
@@ -82,6 +82,33 @@ function checkCaveman() {
82
82
  return `Caveman ${status}: local=${localHash || '—'} remote=${remoteHash}`;
83
83
  });
84
84
  }
85
+ export async function checkAddonsSummary(ctx) {
86
+ return checkAddons(ctx);
87
+ }
88
+ export async function runAddonUpdate(pi, ctx, target, dryRun = false) {
89
+ const results = [];
90
+ const updaters = {
91
+ ponytail: () => updatePonytail(pi, ctx, dryRun),
92
+ rtk: () => updateRtk(ctx, dryRun),
93
+ caveman: () => updateCaveman(ctx, dryRun),
94
+ };
95
+ if (target === 'all') {
96
+ notify(ctx, `ai-addons update all${dryRun ? ' dry-run' : ''}: starting ponytail → rtk → caveman sequentially…`, 'info');
97
+ for (const name of ['ponytail', 'rtk', 'caveman'])
98
+ results.push(await updaters[name]());
99
+ if (!dryRun)
100
+ results.push(RELOAD_MSG);
101
+ notify(ctx, `ai-addons update all ${dryRun ? 'dry-run ' : ''}complete.${dryRun ? '' : ` ${RELOAD_MSG}`}`, 'info');
102
+ }
103
+ else if (Object.hasOwn(updaters, target)) {
104
+ results.push(await updaters[target]());
105
+ }
106
+ else {
107
+ const m = 'Usage: /ai-addons update <ponytail|rtk|caveman|all> [--dry-run]';
108
+ return report(ctx, m, 'warning');
109
+ }
110
+ return results.join('\n\n');
111
+ }
85
112
  async function checkAddons(ctx) {
86
113
  const [ponytail, rtk, caveman] = await Promise.all([checkPonytail(), checkRtk(), checkCaveman()]);
87
114
  const lines = [];
@@ -294,29 +321,7 @@ export default function aiAddonsUpdaterExtension(pi) {
294
321
  return summary;
295
322
  }
296
323
  if (sub === 'update' && cleanParts[1]) {
297
- const target = cleanParts.slice(1).join(' ');
298
- const results = [];
299
- const updaters = {
300
- ponytail: () => updatePonytail(pi, ctx, dryRun),
301
- rtk: () => updateRtk(ctx, dryRun),
302
- caveman: () => updateCaveman(ctx, dryRun),
303
- };
304
- if (target === 'all') {
305
- notify(ctx, `ai-addons update all${dryRun ? ' dry-run' : ''}: starting ponytail → rtk → caveman sequentially…`, 'info');
306
- for (const name of ['ponytail', 'rtk', 'caveman'])
307
- results.push(await updaters[name]());
308
- if (!dryRun)
309
- results.push(RELOAD_MSG);
310
- notify(ctx, `ai-addons update all ${dryRun ? 'dry-run ' : ''}complete.${dryRun ? '' : ` ${RELOAD_MSG}`}`, 'info');
311
- }
312
- else if (Object.hasOwn(updaters, target)) {
313
- results.push(await updaters[target]());
314
- }
315
- else {
316
- const m = 'Usage: /ai-addons update <ponytail|rtk|caveman|all> [--dry-run]';
317
- return report(ctx, m, 'warning');
318
- }
319
- return results.join('\n\n');
324
+ return runAddonUpdate(pi, ctx, cleanParts.slice(1).join(' '), dryRun);
320
325
  }
321
326
  const m = 'Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]';
322
327
  return report(ctx, m, 'warning');
@@ -0,0 +1,156 @@
1
+ // Shared usage ledger: append-only JSON-lines file. One source of truth read
2
+ // by `tersio usage`, `/tersio usage`, and the gain dashboard.
3
+ // Best-effort by design: a ledger failure never breaks the caller, and
4
+ // corrupt lines are skipped on read (same pattern as the config normalizer).
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ export function ledgerPath() {
9
+ const override = process.env.TERSIO_USAGE_FILE;
10
+ if (override)
11
+ return override;
12
+ return path.join(os.homedir(), '.omp', 'plugins', 'tersio-usage.jsonl');
13
+ }
14
+ export function appendUsage(kind, detail) {
15
+ const row = JSON.stringify({ ts: Date.now(), kind, detail }) + '\n';
16
+ try {
17
+ fs.mkdirSync(path.dirname(ledgerPath()), { recursive: true });
18
+ fs.appendFileSync(ledgerPath(), row, 'utf8');
19
+ }
20
+ catch { /* ledger is best-effort; never break the caller */ }
21
+ }
22
+ function zeroBreakdown() {
23
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
24
+ }
25
+ function addInto(into, u) {
26
+ for (const k of ['input', 'output', 'cacheRead', 'cacheWrite']) {
27
+ const v = u[k];
28
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0)
29
+ into[k] += Math.floor(v);
30
+ }
31
+ }
32
+ export function sessionsDir() {
33
+ const override = process.env.TERSIO_SESSIONS_DIR;
34
+ if (override)
35
+ return override;
36
+ return path.join(os.homedir(), '.omp', 'agent', 'sessions');
37
+ }
38
+ function dayKey(ts) {
39
+ const ms = typeof ts === 'number' ? ts : Date.parse(ts);
40
+ if (!Number.isFinite(ms))
41
+ return null;
42
+ const d = new Date(ms);
43
+ const p = (n) => String(n).padStart(2, '0');
44
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
45
+ }
46
+ function walkJsonl(dir, out, cap) {
47
+ let entries;
48
+ try {
49
+ entries = fs.readdirSync(dir, { withFileTypes: true });
50
+ }
51
+ catch {
52
+ return;
53
+ }
54
+ for (const e of entries) {
55
+ if (out.length >= cap)
56
+ return;
57
+ const full = path.join(dir, e.name);
58
+ if (e.isDirectory())
59
+ walkJsonl(full, out, cap);
60
+ else if (e.isFile() && e.name.endsWith('.jsonl'))
61
+ out.push(full);
62
+ }
63
+ }
64
+ export function importSessionTokens() {
65
+ const byModel = {};
66
+ const byDay = {};
67
+ const totals = zeroBreakdown();
68
+ let messages = 0;
69
+ const files = [];
70
+ walkJsonl(sessionsDir(), files, 2000);
71
+ for (const file of files) {
72
+ let text;
73
+ try {
74
+ text = fs.readFileSync(file, 'utf8');
75
+ }
76
+ catch {
77
+ continue;
78
+ }
79
+ for (const line of text.split('\n')) {
80
+ if (!line.trim())
81
+ continue;
82
+ try {
83
+ const row = JSON.parse(line);
84
+ const msg = row.message;
85
+ if (!msg || msg.role !== 'assistant' || !msg.usage)
86
+ continue;
87
+ const model = typeof msg.model === 'string' && msg.model ? msg.model : 'unknown';
88
+ addInto(totals, msg.usage);
89
+ byModel[model] ??= zeroBreakdown();
90
+ addInto(byModel[model], msg.usage);
91
+ const day = row.timestamp !== undefined ? dayKey(row.timestamp) : null;
92
+ if (day) {
93
+ byDay[day] ??= zeroBreakdown();
94
+ addInto(byDay[day], msg.usage);
95
+ }
96
+ messages += 1;
97
+ }
98
+ catch { /* skip corrupt lines */ }
99
+ }
100
+ }
101
+ return { messages, totals, byModel, byDay };
102
+ }
103
+ const PRICE_TABLE = [
104
+ { match: ':free', price: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } },
105
+ { match: 'opus', price: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 } },
106
+ { match: 'sonnet', price: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 } },
107
+ { match: 'haiku', price: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 } },
108
+ { match: 'gpt', price: { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 2.5 } },
109
+ { match: 'gemini', price: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 } },
110
+ { match: 'glm', price: { input: 0.5, output: 1, cacheRead: 0.05, cacheWrite: 0.5 } },
111
+ ];
112
+ const DEFAULT_PRICE = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 };
113
+ export function priceFor(model) {
114
+ const name = model.toLowerCase();
115
+ for (const row of PRICE_TABLE) {
116
+ if (name.includes(row.match))
117
+ return { price: row.price, known: true };
118
+ }
119
+ return { price: DEFAULT_PRICE, known: false };
120
+ }
121
+ export function usdCost(t, model) {
122
+ const { price, known } = model ? priceFor(model) : { price: DEFAULT_PRICE, known: false };
123
+ const m = 1 / 1_000_000;
124
+ return {
125
+ usd: (t.input * price.input + t.output * price.output + t.cacheRead * price.cacheRead + t.cacheWrite * price.cacheWrite) * m,
126
+ priced: model ? known : false,
127
+ };
128
+ }
129
+ // ponytail: CO2 is a rough server-energy multiple on output tokens, not metered.
130
+ // Always render with ~est. and never merge with measured figures.
131
+ export const CO2_G_PER_1K_OUTPUT = 0.2;
132
+ export function co2Grams(outputTokens) {
133
+ return (outputTokens / 1000) * CO2_G_PER_1K_OUTPUT;
134
+ }
135
+ export function readUsage() {
136
+ let text;
137
+ try {
138
+ text = fs.readFileSync(ledgerPath(), 'utf8');
139
+ }
140
+ catch {
141
+ return [];
142
+ }
143
+ const rows = [];
144
+ for (const line of text.split('\n')) {
145
+ if (!line.trim())
146
+ continue;
147
+ try {
148
+ const row = JSON.parse(line);
149
+ if (typeof row.ts === 'number' && typeof row.kind === 'string' && typeof row.detail === 'string') {
150
+ rows.push({ ts: row.ts, kind: row.kind, detail: row.detail });
151
+ }
152
+ }
153
+ catch { /* skip corrupt lines */ }
154
+ }
155
+ return rows;
156
+ }