@krtclcdy/tersio 2.23.0 → 2.24.1

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 (58) hide show
  1. package/README.md +56 -45
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/banner.js +2 -3
  5. package/cli/common.js +16 -20
  6. package/cli/currency.js +11 -19
  7. package/cli/dashboard.js +216 -40
  8. package/cli/doctor-fix.js +40 -26
  9. package/cli/doctor.js +41 -41
  10. package/cli/hosts.js +114 -0
  11. package/cli/install.js +284 -179
  12. package/cli/interactive.js +62 -2
  13. package/cli/layer.js +22 -0
  14. package/cli/manifest.js +33 -0
  15. package/cli/omp-layer.js +51 -0
  16. package/cli/pi-layer.js +37 -0
  17. package/cli/pi-wiring.js +92 -0
  18. package/cli/profile.js +48 -48
  19. package/cli/reset.js +5 -32
  20. package/cli/rtk-wiring.js +7 -17
  21. package/cli/settings.js +3 -8
  22. package/cli/uninstall.js +162 -52
  23. package/cli/update.js +19 -27
  24. package/cli/usage.js +15 -5
  25. package/dashboard/dist/index.html +18 -18
  26. package/extensions/ai-addons-updater/index.js +37 -29
  27. package/extensions/ai-addons-updater/index.ts +39 -38
  28. package/extensions/caveman-session/index.js +19 -34
  29. package/extensions/caveman-session/index.ts +20 -35
  30. package/extensions/combo-toggle/index.js +47 -49
  31. package/extensions/combo-toggle/index.ts +42 -58
  32. package/extensions/lib/utils.js +40 -9
  33. package/extensions/lib/utils.ts +40 -9
  34. package/extensions/rtk-session/index.js +34 -38
  35. package/extensions/rtk-session/index.ts +32 -41
  36. package/extensions/shared/carbon.js +3 -11
  37. package/extensions/shared/carbon.ts +4 -11
  38. package/extensions/shared/host.js +58 -0
  39. package/extensions/shared/host.ts +94 -0
  40. package/extensions/shared/omp-prompt.js +12 -0
  41. package/extensions/shared/plugin-settings.js +33 -30
  42. package/extensions/shared/plugin-settings.ts +35 -26
  43. package/extensions/shared/pricing.js +25 -16
  44. package/extensions/shared/pricing.ts +27 -17
  45. package/extensions/shared/rtk-gain.js +6 -22
  46. package/extensions/shared/rtk-gain.ts +6 -22
  47. package/extensions/shared/session-state.js +44 -36
  48. package/extensions/shared/session-state.ts +48 -35
  49. package/extensions/shared/types.ts +10 -3
  50. package/extensions/shared/usage-ledger.js +160 -51
  51. package/extensions/shared/usage-ledger.ts +170 -67
  52. package/extensions/shared/usage-store.js +178 -24
  53. package/extensions/shared/usage-store.ts +176 -25
  54. package/extensions/tersio-commands/index.js +3 -6
  55. package/extensions/tersio-commands/index.ts +3 -6
  56. package/package.json +6 -5
  57. package/tersio.js +4 -6
  58. package/extensions/shared/mode-reinforcement.js +0 -26
@@ -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, confirm as clackConfirm, select as clackSelect, spinner as clackSpinner, tasks as clackTasks } from '@clack/prompts';
3
+ import { cancel as clackCancel, confirm as clackConfirm, log as clackLog, 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;
@@ -67,6 +67,32 @@ async function askInteractiveConfirm(message, initialValue = true) {
67
67
  }
68
68
  return { status: 'confirmed', value: answer };
69
69
  }
70
+ // Confirm a destructive run: a Clack dialog at a terminal, a plain prompt in a
71
+ // pipe or script. False means the user declined or aborted.
72
+ async function confirmDestructive(message) {
73
+ if (tty()) {
74
+ closeRL();
75
+ const answer = await clackConfirm({ message, initialValue: false });
76
+ if (typeof answer !== 'boolean') {
77
+ clackCancel('Aborted.');
78
+ closeRL();
79
+ return false;
80
+ }
81
+ if (!answer) {
82
+ console.log('Aborted.');
83
+ closeRL();
84
+ return false;
85
+ }
86
+ return true;
87
+ }
88
+ const typed = await ask('\nProceed? [y/N]: ');
89
+ if (!typed.toLowerCase().startsWith('y')) {
90
+ console.log('Aborted.');
91
+ closeRL();
92
+ return false;
93
+ }
94
+ return true;
95
+ }
70
96
  // Run collecting work under one TTY-only Clack task. Callers print after the
71
97
  // task completes, keeping normal output out of the spinner animation.
72
98
  async function runInteractivePhase(title, collect) {
@@ -77,7 +103,41 @@ async function runInteractivePhase(title, collect) {
77
103
  await clackTasks([{ title, task: async () => { result = await collect(); } }]);
78
104
  return result;
79
105
  }
106
+ const TAG_KIND = {
107
+ fail: 'error', warn: 'warn', hint: 'info', ok: 'success', note: 'message',
108
+ write: 'step', rm: 'step', migrate: 'step', skip: 'info', 'dry-run': 'info',
109
+ };
110
+ function sayTagged(line) {
111
+ const m = /^\s*\[([\w-]+)\]\s?([\s\S]*)$/.exec(line);
112
+ const kind = (m && TAG_KIND[m[1]]) || 'info';
113
+ if (tty() && m)
114
+ clackLog[kind](m[2]);
115
+ else
116
+ console.log(line);
117
+ }
118
+ // One live-updating task line instead of a spinner per step.
119
+ async function withInteractiveTask(title, work) {
120
+ if (!tty())
121
+ return work(() => { });
122
+ closeRL();
123
+ let result;
124
+ const spin = clackSpinner();
125
+ spin.start(title);
126
+ try {
127
+ result = await work((message) => spin.message(message));
128
+ spin.stop('done');
129
+ }
130
+ catch (e) {
131
+ spin.error(shortSpinnerError(e));
132
+ throw e;
133
+ }
134
+ return result;
135
+ }
136
+ function shortSpinnerError(e) {
137
+ const msg = e instanceof Error ? e.message : String(e);
138
+ return (msg.split('\n')[0] || 'failed').slice(0, 120);
139
+ }
80
140
  async function execNetwork(label, cmd, args, opts = {}) {
81
141
  return withInteractiveSpinner(label, () => execP(cmd, args, opts));
82
142
  }
83
- export { ask, closeRL, tty, withInteractiveSpinner, execNetwork, askInteractiveChoice, askInteractiveConfirm, runInteractivePhase, };
143
+ export { ask, closeRL, tty, withInteractiveSpinner, withInteractiveTask, execNetwork, sayTagged, askInteractiveChoice, askInteractiveConfirm, confirmDestructive, runInteractivePhase, };
package/cli/layer.js ADDED
@@ -0,0 +1,22 @@
1
+ // cli/layer.ts — the shared shape of an extension tree.
2
+ import path from 'node:path';
3
+ // Turns a `[dir, label]` table into resolved paths under `extDir`.
4
+ export function layerEntries(extDir, table) {
5
+ return table.map(([dir, label]) => ({ label, path: path.join(extDir, dir) }));
6
+ }
7
+ // Directory names only, for callers that key off the names alone.
8
+ export function layerDirs(table) {
9
+ return table.map(([dir]) => dir);
10
+ }
11
+ // A port source sits two levels below extensions/, one level once installed.
12
+ export function rewritePortImports(content) {
13
+ return content.replace(/from '\.\.\/\.\.\/(shared|lib)\//g, "from '../$1/");
14
+ }
15
+ // What of the layer is on disk.
16
+ export function reportLayer(extDir, tables, exists) {
17
+ const all = tables.flatMap((table) => layerEntries(extDir, table));
18
+ return {
19
+ present: all.filter((entry) => exists(entry.path)),
20
+ missing: all.filter((entry) => !exists(entry.path)),
21
+ };
22
+ }
@@ -0,0 +1,33 @@
1
+ // Single list of install writes, shared by installer, doctor, and tests.
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ export const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
5
+ /** Every file in the tree, as paths relative to the extensions directory. */
6
+ export const TREE_FILES = [
7
+ 'shared/host.ts',
8
+ 'shared/session-state.ts',
9
+ 'shared/types.ts',
10
+ 'shared/plugin-settings.ts',
11
+ 'shared/usage-ledger.ts',
12
+ 'shared/pricing.ts',
13
+ 'shared/carbon.ts',
14
+ 'lib/utils.ts',
15
+ 'caveman-session/index.ts',
16
+ 'caveman-session/rule.md',
17
+ 'rtk-session/index.ts',
18
+ 'combo-toggle/index.ts',
19
+ 'tersio-commands/index.ts',
20
+ 'ai-addons-updater/index.ts',
21
+ ];
22
+ export function sourcePath(relative) {
23
+ return path.join(EXT_DIR, ...relative.split('/'));
24
+ }
25
+ /** The entries under one directory, as source paths and tree-relative targets. */
26
+ export function filesUnder(dir) {
27
+ const found = [];
28
+ for (const file of TREE_FILES) {
29
+ if (file.startsWith(`${dir}/`))
30
+ found.push([sourcePath(file), file]);
31
+ }
32
+ return found;
33
+ }
@@ -0,0 +1,51 @@
1
+ // The Oh My Pi layer as data: the extension directories, the RTK wiring and the Ponytail package, named in one place...
2
+ import path from 'node:path';
3
+ import { layerEntries, reportLayer } from './layer.js';
4
+ const INSTALLED_EXTENSIONS = [
5
+ ['caveman-session', 'caveman session mode'],
6
+ ['rtk-session', 'rtk session mode'],
7
+ ['ai-addons-updater', 'add-on updater'],
8
+ ['combo-toggle', 'combo preset switch'],
9
+ ['tersio-commands', '/tersio command'],
10
+ ['shared', 'session bridge, usage and pricing store'],
11
+ // Read by the add-on updater; the updater itself is removed above.
12
+ ['lib', 'shared installer and updater helpers'],
13
+ ];
14
+ const RETIRED_EXTENSIONS = [
15
+ // Legacy always-on combo helper; imports shared/session-state.js, so it breaks with a module-not-found warning once the...
16
+ ['aaa-combo-boot', 'retired combo boot helper'],
17
+ ];
18
+ // `rtkBinaryName` is passed in because it is platform-dependent and already resolved by the caller.
19
+ export function ompLayer(home, rtkBinaryName) {
20
+ const agentDir = path.join(home, '.omp', 'agent');
21
+ const extDir = path.join(agentDir, 'extensions');
22
+ const pluginsDir = path.join(home, '.omp', 'plugins');
23
+ return {
24
+ extDir,
25
+ configFile: path.join(agentDir, 'config.yml'),
26
+ pluginsDir,
27
+ ponytailPackage: path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail'),
28
+ rtkBinary: path.join(home, '.bun', 'bin', rtkBinaryName),
29
+ rtkExtension: path.join(extDir, 'rtk.ts'),
30
+ installed: layerEntries(extDir, INSTALLED_EXTENSIONS),
31
+ retired: layerEntries(extDir, RETIRED_EXTENSIONS),
32
+ };
33
+ }
34
+ // Every extension directory the layer owns, installed and retired together.
35
+ export function ompExtensionTargets(layer) {
36
+ return [...layer.installed, ...layer.retired].map((entry) => entry.path);
37
+ }
38
+ export function tersioPluginDir(home) {
39
+ return path.join(home, '.omp', 'plugins', 'node_modules', '@krtclcdy', 'tersio');
40
+ }
41
+ export function reportOmpLayer(home, exists) {
42
+ const extDir = path.join(home, '.omp', 'agent', 'extensions');
43
+ const pluginPath = tersioPluginDir(home);
44
+ const { present, missing } = reportLayer(extDir, [INSTALLED_EXTENSIONS], exists);
45
+ return {
46
+ pluginPath: exists(pluginPath) ? pluginPath : null,
47
+ extensions: present,
48
+ missing,
49
+ installed: exists(pluginPath),
50
+ };
51
+ }
@@ -0,0 +1,37 @@
1
+ // cli/pi-layer.ts — the Pi extension layer as data: the five extension directories plus shared/ and lib/.
2
+ import path from 'node:path';
3
+ import { layerDirs, layerEntries, reportLayer } from './layer.js';
4
+ const INSTALLED_EXTENSIONS = [
5
+ ['caveman-session', 'caveman session mode'],
6
+ ['rtk-session', 'rtk session mode'],
7
+ ['ai-addons-updater', 'add-on updater'],
8
+ ['combo-toggle', 'combo preset switch'],
9
+ ['tersio-commands', '/tersio command'],
10
+ ];
11
+ // Imported by the extension directories but never loaded by Pi itself, so they stay out of the install list and only...
12
+ const MODULE_DIRECTORIES = [
13
+ ['shared', 'Pi types, session bridge, usage and pricing store'],
14
+ ['lib', 'shared installer and updater helpers'],
15
+ ];
16
+ export const PI_EXTENSION_DIRS = layerDirs(INSTALLED_EXTENSIONS);
17
+ export const PI_MODULE_DIRS = layerDirs(MODULE_DIRECTORIES);
18
+ export function piLayer(home) {
19
+ const relocated = process.env.PI_CODING_AGENT_DIR;
20
+ const base = relocated && relocated.trim() !== '' ? relocated : path.join(home, '.pi', 'agent');
21
+ const extDir = path.join(base, 'extensions');
22
+ return {
23
+ extDir,
24
+ rtkExtension: path.join(extDir, 'rtk.ts'),
25
+ installed: layerEntries(extDir, INSTALLED_EXTENSIONS),
26
+ modules: layerEntries(extDir, MODULE_DIRECTORIES),
27
+ };
28
+ }
29
+ // Every directory the layer owns. Removal walks this list and nothing else.
30
+ export function piExtensionTargets(layer) {
31
+ return [...layer.installed, ...layer.modules].map((entry) => entry.path);
32
+ }
33
+ // Reports the layer without needing a platform rtk binary name.
34
+ export function reportPiLayer(home, exists) {
35
+ const { present, missing } = reportLayer(piLayer(home).extDir, [INSTALLED_EXTENSIONS, MODULE_DIRECTORIES], exists);
36
+ return { extensions: present, missing, installed: present.length > 0 };
37
+ }
@@ -0,0 +1,92 @@
1
+ // Install and remove Tersio's Pi extension tree: Pi loads ~/.pi/agent/extensions/<dir>/index.ts through jiti, no build...
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { readTextIfExists } from '../extensions/lib/utils.js';
5
+ import { rewritePortImports } from './layer.js';
6
+ import { piExtensionTargets, piLayer } from './pi-layer.js';
7
+ // `$HOME`-relative form of a path, for output that must stay readable.
8
+ function rel(target, home) {
9
+ if (!home)
10
+ return target;
11
+ const prefix = home.endsWith(path.sep) ? home : `${home}${path.sep}`;
12
+ return target.startsWith(prefix) ? `~/${target.slice(prefix.length).split(path.sep).join('/')}` : target;
13
+ }
14
+ export function piExtensionDir(home) {
15
+ return piLayer(home).extDir;
16
+ }
17
+ // Writes only when the content differs, leaving a `.bak` of what it replaced.
18
+ async function writeIfChanged(target, content, options) {
19
+ const current = await readTextIfExists(target);
20
+ if (current === content)
21
+ return false;
22
+ if (options.dryRun)
23
+ return true;
24
+ await fs.mkdir(path.dirname(target), { recursive: true });
25
+ if (current !== null)
26
+ await fs.copyFile(target, `${target}.bak`).catch(() => { });
27
+ await fs.writeFile(target, content, 'utf8');
28
+ return true;
29
+ }
30
+ async function exists(target) {
31
+ try {
32
+ await fs.stat(target);
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ // Returns the paths it changed, so a no-op re-run is not counted as work.
40
+ export async function installPiTersio(home, tree, options = {}) {
41
+ const extDir = piExtensionDir(home);
42
+ const written = [];
43
+ for (const [from, to] of tree.sources) {
44
+ const content = await readTextIfExists(from);
45
+ if (content === null) {
46
+ // Named by its path under the extension dir, never by the repo source.
47
+ if (!options.quiet) {
48
+ console.log(` [fail] Pi extension source missing: ${to}`);
49
+ console.log(' [hint] Reinstall tersio — extensions/pi ships with the CLI');
50
+ }
51
+ continue;
52
+ }
53
+ if (await writeIfChanged(path.join(extDir, to), rewritePortImports(content), options))
54
+ written.push(to);
55
+ }
56
+ for (const [dir, rule] of tree.rules) {
57
+ if (rule === null)
58
+ continue;
59
+ const target = path.join(dir, 'rule.md');
60
+ if (await writeIfChanged(path.join(extDir, target), rule, options))
61
+ written.push(target);
62
+ }
63
+ if (written.length > 0 && !options.quiet) {
64
+ console.log(` ${options.dryRun ? '[dry-run] would write' : '[write]'} Pi — ${written.length} file(s) under ${rel(extDir, home)}`);
65
+ }
66
+ return written;
67
+ }
68
+ // Removes the Pi tree: every directory the layer owns, the `.bak` copies, and the flat module a pre-tree install wrote at...
69
+ export async function removePiTersio(home, options = {}) {
70
+ let removed = false;
71
+ for (const target of [...piExtensionTargets(piLayer(home)), path.join(piExtensionDir(home), 'tersio.ts')]) {
72
+ if (!(await exists(target)))
73
+ continue;
74
+ if (!options.quiet) {
75
+ console.log(` ${options.dryRun ? '[dry-run] would remove' : '[rm]'} ${rel(target, home)}`);
76
+ }
77
+ if (!options.dryRun) {
78
+ await fs.rm(target, { recursive: true, force: true });
79
+ await fs.rm(`${target}.bak`, { force: true, recursive: true });
80
+ }
81
+ removed = true;
82
+ }
83
+ return removed;
84
+ }
85
+ // True when any module of the Pi tree is present.
86
+ export async function piTersioInstalled(home) {
87
+ for (const entry of piLayer(home).installed) {
88
+ if (await exists(path.join(entry.path, 'index.ts')))
89
+ return true;
90
+ }
91
+ return false;
92
+ }
package/cli/profile.js CHANGED
@@ -1,12 +1,13 @@
1
- // cli/profile.ts — session-start defaults profile (combo/caveman/rtk/ponytail)
2
- // plus the display-currency default for usage/dashboard reports.
3
- // Single source for reading/writing the omp plugin settings lock entry.
4
- // Extracted from cli/install.ts so both install and settings share it.
5
- import { promises as fs } from 'node:fs';
1
+ // cli/profile.ts — session-start defaults and the display-currency default,
2
+ // stored in ~/.tersio/settings.json. Shared by install and settings.
3
+ import { existsSync, promises as fs } from 'node:fs';
6
4
  import path from 'node:path';
7
- import { CAVEMAN_DEFAULTS, COMBO_PRESET_MODES, OMP_PLUGINS_DIR, PACKAGE_NAME, PONYTAIL_DEFAULTS, verbose, } from './common.js';
5
+ import { CAVEMAN_DEFAULTS, COMBO_PRESET_MODES, PACKAGE_NAME, PONYTAIL_DEFAULTS, verbose, } from './common.js';
6
+ import os from 'node:os';
8
7
  import { DEFAULT_CURRENCY, isCurrencyCode } from './currency.js';
9
8
  import { readTextIfExists } from '../extensions/lib/utils.js';
9
+ import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
10
+ export const BACKUP_SCHEDULES = new Set(['manual', 'daily', 'weekly', 'monthly']);
10
11
  function defaultProfile() {
11
12
  return {
12
13
  comboDefault: 'off',
@@ -14,31 +15,17 @@ function defaultProfile() {
14
15
  rtkDefault: false,
15
16
  ponytailDefault: 'off',
16
17
  currency: DEFAULT_CURRENCY,
18
+ backupSchedule: 'monthly',
17
19
  };
18
20
  }
19
- // Stored profile from the live lock file: update/reinstall runs without flags
20
- // or prompts must preserve the user's choice, never reset it to off.
21
+ // Seed once from OMP plugin settings so a pre-~/.tersio install keeps its values.
21
22
  async function storedProfile() {
22
23
  const base = defaultProfile();
23
- const raw = await readTextIfExists(path.join(OMP_PLUGINS_DIR, 'omp-plugins.lock.json'));
24
- if (!raw)
24
+ const stored = (await readTextIfExists(tersioSettingsFile())) !== null
25
+ ? parseStored(await readTextIfExists(tersioSettingsFile()))
26
+ : parseStored(await readTextIfExists(legacyOmpLockPath()));
27
+ if (!stored)
25
28
  return base;
26
- let stored;
27
- try {
28
- const parsed = JSON.parse(raw);
29
- if (!parsed || typeof parsed !== 'object' || !('settings' in parsed))
30
- return base;
31
- const settings = parsed.settings;
32
- if (!settings || typeof settings !== 'object' || !(PACKAGE_NAME in settings))
33
- return base;
34
- const entry = settings[PACKAGE_NAME];
35
- if (!entry || typeof entry !== 'object')
36
- return base;
37
- stored = entry;
38
- }
39
- catch {
40
- return base;
41
- }
42
29
  if (typeof stored.comboDefault === 'string' && stored.comboDefault in COMBO_PRESET_MODES)
43
30
  base.comboDefault = stored.comboDefault;
44
31
  if (typeof stored.cavemanDefault === 'string' && CAVEMAN_DEFAULTS.has(stored.cavemanDefault))
@@ -50,42 +37,55 @@ async function storedProfile() {
50
37
  if (typeof stored.currency === 'string' && isCurrencyCode(stored.currency.trim().toUpperCase())) {
51
38
  base.currency = stored.currency.trim().toUpperCase();
52
39
  }
40
+ if (typeof stored.backupSchedule === 'string' && BACKUP_SCHEDULES.has(stored.backupSchedule)) {
41
+ base.backupSchedule = stored.backupSchedule;
42
+ }
53
43
  return base;
54
44
  }
55
- // Persist the profile as omp plugin settings so `omp plugin config get`
56
- // reflects the choice and the extensions pick it up on session start.
57
- async function writePluginSettings(profile, options) {
58
- const pluginsDir = OMP_PLUGINS_DIR;
59
- const lockPath = path.join(pluginsDir, 'omp-plugins.lock.json');
60
- let config = {};
61
- const existing = await readTextIfExists(lockPath);
62
- if (existing) {
63
- try {
64
- config = JSON.parse(existing);
65
- }
66
- catch {
67
- config = {};
68
- }
45
+ function legacyOmpLockPath() {
46
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
47
+ return [
48
+ path.join(os.homedir(), '.omp', 'plugins', 'omp-plugins.lock.json'),
49
+ path.join(configHome, 'omp', 'plugins', 'omp-plugins.lock.json'),
50
+ ].find((p) => existsSync(p)) ?? path.join(os.homedir(), '.omp', 'plugins', 'omp-plugins.lock.json');
51
+ }
52
+ function parseStored(raw) {
53
+ if (!raw)
54
+ return null;
55
+ try {
56
+ const parsed = JSON.parse(raw);
57
+ if (!parsed || typeof parsed !== 'object')
58
+ return null;
59
+ const direct = parsed;
60
+ const nested = direct.settings?.[PACKAGE_NAME];
61
+ return (nested ?? direct);
69
62
  }
70
- config.plugins = config.plugins || {};
71
- config.settings = config.settings || {};
72
- config.settings[PACKAGE_NAME] = {
73
- ...(config.settings[PACKAGE_NAME] || {}),
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ // Persist the profile where every host reads it. `omp plugin config get` no
68
+ // longer reports it; `tersio settings` does.
69
+ async function writePluginSettings(profile, options) {
70
+ const file = tersioSettingsFile();
71
+ const values = {
72
+ ...(parseStored(await readTextIfExists(file)) ?? {}),
74
73
  comboDefault: profile.comboDefault,
75
74
  comboSetupComplete: true,
76
75
  cavemanDefault: profile.cavemanDefault,
77
76
  rtkDefault: profile.rtkDefault,
78
77
  ponytailDefault: profile.ponytailDefault,
79
78
  currency: profile.currency,
79
+ backupSchedule: profile.backupSchedule,
80
80
  };
81
81
  if (options.dryRun) {
82
82
  if (verbose && !options.quiet)
83
- console.log(` [dry-run] would write plugin settings (${PACKAGE_NAME}) to ${lockPath}`);
83
+ console.log(` [dry-run] would write session defaults to ${file}`);
84
84
  return;
85
85
  }
86
- await fs.mkdir(pluginsDir, { recursive: true });
87
- await fs.writeFile(lockPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
88
- console.log(` [write] Plugin settings in ${lockPath}`);
86
+ await fs.mkdir(path.dirname(file), { recursive: true });
87
+ await fs.writeFile(file, `${JSON.stringify(values, null, 2)}\n`, 'utf8');
88
+ console.log(` [write] Session defaults in ${file}`);
89
89
  }
90
90
  function formatProfile(profile) {
91
91
  return `combo=${profile.comboDefault} (caveman=${profile.cavemanDefault} · rtk=${profile.rtkDefault ? 'on' : 'off'} · ponytail=${profile.ponytailDefault}) · currency=${profile.currency}`;
package/cli/reset.js CHANGED
@@ -1,11 +1,6 @@
1
- // cli/reset.ts — clear tersio-owned statistics: the usage ledger plus a reset
2
- // watermark that filters session-derived and RTK-metered rows out of every
3
- // derived view. Session transcripts (~/.omp/agent/sessions) and the RTK
4
- // database are host/tool-owned and are never touched; the watermark only
5
- // changes what tersio shows.
6
- import { cancel as clackCancel, confirm as clackConfirm } from '@clack/prompts';
1
+ // Clears tersio-owned stats only; transcripts and RTK data stay.
7
2
  import { dryRun, yes } from './common.js';
8
- import { ask, closeRL, tty } from './interactive.js';
3
+ import { confirmDestructive, sayTagged } from './interactive.js';
9
4
  import { clearUsageLedger, importSessionTokens, ledgerPath, markReset, readUsage, sessionsDir } from '../extensions/shared/usage-ledger.js';
10
5
  import { clearUsageDb, usageDbPath } from '../extensions/shared/usage-store.js';
11
6
  import { readRtkGain, rtkDbPath } from '../extensions/shared/rtk-gain.js';
@@ -25,34 +20,12 @@ async function runReset() {
25
20
  console.log('Nothing to reset — no statistics recorded.');
26
21
  return true;
27
22
  }
28
- if (!yes) {
29
- if (tty()) {
30
- closeRL();
31
- const confirmedChoice = await clackConfirm({ message: 'Clear tersio statistics?', initialValue: false });
32
- if (typeof confirmedChoice !== 'boolean') {
33
- clackCancel('Aborted.');
34
- closeRL();
35
- return false;
36
- }
37
- if (!confirmedChoice) {
38
- console.log('Aborted.');
39
- closeRL();
40
- return false;
41
- }
42
- }
43
- else {
44
- const answer = await ask('\nProceed? [y/N]: ');
45
- if (!answer.toLowerCase().startsWith('y')) {
46
- console.log('Aborted.');
47
- closeRL();
48
- return false;
49
- }
50
- }
51
- }
23
+ if (!yes && !(await confirmDestructive('Clear tersio statistics?')))
24
+ return false;
52
25
  const cleared = clearUsageLedger();
53
26
  const dbCleared = clearUsageDb();
54
27
  const ts = markReset();
55
- console.log(`[ok] reset — removed ${cleared} usage rows${dbCleared ? ' + usage.db' : ''}; statistics view starts at ${new Date(ts).toISOString()}`);
28
+ sayTagged(`[ok] reset — removed ${cleared} usage rows${dbCleared ? ' + usage.db' : ''}; statistics view starts at ${new Date(ts).toISOString()}`);
56
29
  return true;
57
30
  }
58
31
  export { runReset };
package/cli/rtk-wiring.js CHANGED
@@ -1,12 +1,4 @@
1
- // cli/rtk-wiring.ts — wire the installed rtk binary into OMP.
2
- // `rtk init -g --agent omp` writes rtk's tool_call extension to
3
- // ~/.omp/agent/extensions/rtk.ts, and we register that path in config.yml
4
- // (OMP only loads listed extensions — rtk init does not register it).
5
- // Once loaded, OMP rewrites bash commands to rtk before execution, so every
6
- // rewritten call lands in rtk's history.db and shows metered savings.
7
- // Fail-open: a failed wire only downgrades to manual `rtk` prefixing.
8
- // Deliberately free of cli/common.ts imports (argv side effects) so tests
9
- // can load it directly.
1
+ // OMP loads only listed extensions, so register rtk's path; fail-open.
10
2
  import { promises as fs } from 'node:fs';
11
3
  import os from 'node:os';
12
4
  import path from 'node:path';
@@ -34,8 +26,7 @@ function execFileP(cmd, args, timeout) {
34
26
  });
35
27
  });
36
28
  }
37
- // Idempotent: rtk rewrites its extension file on every init run, so
38
- // reinstalling tersio refreshes the wiring for free.
29
+ // Idempotent: rtk rewrites its file on every init run.
39
30
  export async function wireRtkOmp(rtkBin, options = {}) {
40
31
  if (!options.dryRun)
41
32
  debugWire(options, 'Wiring rtk → OMP (bash tool_call rewrite)…');
@@ -47,14 +38,13 @@ export async function wireRtkOmp(rtkBin, options = {}) {
47
38
  await ensureRtkInConfig(options);
48
39
  return true;
49
40
  }
50
- // The rtk.ts path rtk init writes (mirrors OMP_AGENT_DIR in cli/common.ts
51
- // without importing it — see header note on argv side effects).
41
+ // The rtk.ts path rtk init writes (OMP_AGENT_DIR inlined; see header note).
52
42
  function rtkExtensionPath() {
53
43
  const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
54
44
  return path.join(home, '.omp', 'agent', 'extensions', 'rtk.ts');
55
45
  }
56
- // OMP loads only listed extensions: append rtk.ts after the existing entries
57
- // (or create the key) so a fresh wire takes effect on next OMP start.
46
+ // Append rtk.ts after the existing entries (or create the key) so the wire
47
+ // takes effect on the next OMP start.
58
48
  export async function ensureRtkInConfig(options) {
59
49
  const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
60
50
  const configPath = path.join(home, '.omp', 'agent', 'config.yml');
@@ -98,8 +88,8 @@ async function runRtkInit(rtkBin, options) {
98
88
  return true;
99
89
  }
100
90
  catch (first) {
101
- // A freshly written binary can lose its first exec to macOS Gatekeeper
102
- // verification; give it one settle-and-retry before reporting failure.
91
+ // A fresh binary can lose its first exec to macOS Gatekeeper, so settle and
92
+ // retry once before reporting failure.
103
93
  await new Promise((resolve) => setTimeout(resolve, 2000));
104
94
  try {
105
95
  await execFileP(rtkBin, ['init', '-g', '--agent', 'omp'], 30000);
package/cli/settings.js CHANGED
@@ -1,8 +1,4 @@
1
- // cli/settings.ts — view/edit session-start defaults (combo/caveman/rtk/ponytail)
2
- // plus the display-currency default for usage/dashboard reports.
3
- // Non-interactive: `tersio settings --combo-default balanced` (plus per-mode
4
- // override flags and --currency). Interactive: clack selects seeded from the
5
- // stored profile.
1
+ // cli/settings.ts — view/edit session-start defaults and the display currency.
6
2
  import path from 'node:path';
7
3
  import { CAVEMAN_DEFAULTS, COMBO_DEFAULTS, COMBO_PRESET_MODES, OMP_PLUGINS_DIR, PACKAGE_NAME, PONYTAIL_DEFAULTS, cavemanDefaultFlag, comboDefaultFlag, currency, currencyGiven, diagScheduleFlag, dryRun, ponytailDefaultFlag, profileFlagsGiven, rtkDefaultFlag, settingArg, } from './common.js';
8
4
  import { CURRENCY_CODES } from './currency.js';
@@ -44,6 +40,7 @@ async function askProfile(current) {
44
40
  rtkDefault: preset.rtk,
45
41
  ponytailDefault: preset.ponytail,
46
42
  currency: current.currency,
43
+ backupSchedule: current.backupSchedule,
47
44
  };
48
45
  const caveman = await askInteractiveChoice('Caveman default', [...CAVEMAN_DEFAULTS].map((v) => ({
49
46
  value: v, label: v,
@@ -162,9 +159,7 @@ async function runSingleSetting(name, current, nextDiag) {
162
159
  }
163
160
  function printSettingsTable(current) {
164
161
  console.log('\n=== Tersio Settings ===');
165
- // Currency lives here no longer: the dashboard owns displaying and
166
- // persisting it (its picker POSTs to /currency); settings can still set
167
- // the stored default via --currency or the prompt below.
162
+ // The dashboard owns displaying currency; settings only sets the default.
168
163
  const rows = [
169
164
  ['combo', current.comboDefault, [...COMBO_DEFAULTS].join(' | ')],
170
165
  ['caveman', current.cavemanDefault, [...CAVEMAN_DEFAULTS].join(' | ')],