@krtclcdy/tersio 2.25.1 → 2.25.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/doctor-fix.js CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, removeExtensionFromConfig, dryRun, verbose, debug, execP, readPluginsPackage, writeIfChanged, } from './common.js';
6
6
  import { execNetwork, sayTagged } from './interactive.js';
7
7
  import { wireRtkOmp, wireRtkOpencode, wireRtkPi, ensureRtkInConfig } from './rtk-wiring.js';
8
- import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
8
+ import { RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
9
9
  import { runLatestUpdate } from './update.js';
10
10
  import { OPENCODE_SERVER_SHIM, TREE_FILES, sourcePath } from './manifest.js';
11
11
  import { detectHosts, hostExtensionsDir } from './hosts.js';
@@ -50,16 +50,20 @@ async function fixExtensionTrees() {
50
50
  }
51
51
  }
52
52
  // The tree alone does nothing on OpenCode without its entrypoint registered in opencode.json.
53
+ // The tersio tree ships the rtk rewrite, so the rtk plugin entry is redundant.
53
54
  async function fixOpencodeEntry() {
54
55
  const { HOME } = await import('./common.js');
55
56
  const dir = path.join(HOME, '.config', 'opencode', 'plugins', 'tersio');
56
57
  const configPath = path.join(HOME, '.config', 'opencode', 'opencode.json');
57
58
  const nested = path.join(dir, 'opencode', 'server.ts');
58
- const nestedText = await readTextIfExists(nested);
59
- if (!nestedText)
59
+ if (!(await readTextIfExists(nested)))
60
60
  return;
61
61
  // The loader resolves <pluginDir>/server.ts, so mirror the entry at the tree root.
62
62
  await writeIfChanged(path.join(dir, 'server.ts'), OPENCODE_SERVER_SHIM, { dryRun, verbose });
63
+ if (dryRun)
64
+ sayTagged(` [dry-run] would remove the redundant rtk plugin entry`);
65
+ else
66
+ await fs.rm(path.join(HOME, '.config', 'opencode', 'plugins', 'rtk.ts'), { force: true }).catch(() => { });
63
67
  const raw = await readTextIfExists(configPath);
64
68
  let config = {};
65
69
  try {
@@ -69,11 +73,11 @@ async function fixOpencodeEntry() {
69
73
  return;
70
74
  }
71
75
  const plugins = Array.isArray(config.plugins) ? [...config.plugins] : [];
72
- const withoutTersio = plugins.filter((p) => typeof p !== 'string' || !p.includes('/opencode/plugins/tersio'));
73
- if (withoutTersio.includes(dir))
76
+ const kept = plugins.filter((p) => typeof p !== 'string' || !p.endsWith('/opencode/plugins/rtk.ts'));
77
+ if (kept.length === plugins.length && kept.includes(dir))
74
78
  return;
75
- withoutTersio.push(dir);
76
- config.plugins = withoutTersio;
79
+ kept.push(dir);
80
+ config.plugins = kept;
77
81
  if (dryRun) {
78
82
  sayTagged(` [dry-run] would register tersio in ${configPath}`);
79
83
  return;
@@ -88,30 +92,17 @@ async function fixExtensions(pluginsDir) {
88
92
  console.log(' Doctor --fix: restoring plugin extension files');
89
93
  const pluginExtDir = path.join(pluginsDir, 'node_modules', '@krtclcdy', 'tersio', 'extensions');
90
94
  await fs.mkdir(pluginExtDir, { recursive: true });
91
- // rule.md is skipped: its live content is the upstream fetch, not the bundle.
92
- for (const file of TREE_FILES.filter((f) => f !== 'caveman-session/rule.md')) {
95
+ // Rule bodies are never overwritten: they can be newer upstream fetches.
96
+ const ruleFile = (f) => /^caveman-session\/rule.*\.md$/.test(f);
97
+ for (const file of TREE_FILES) {
98
+ const dest = path.join(pluginExtDir, ...file.split('/'));
99
+ if (ruleFile(file) && existsSync(dest))
100
+ continue;
93
101
  const text = await readTextIfExists(sourcePath(file));
94
102
  if (text === null)
95
103
  sayTagged(` [warn] bundled source missing: ${sourcePath(file)}`);
96
104
  else
97
- await writeIfChanged(path.join(pluginExtDir, ...file.split('/')), text, { dryRun, verbose });
98
- }
99
- const ruleDest = path.join(pluginExtDir, 'caveman-session', 'rule.md');
100
- if ((await readTextIfExists(ruleDest)) === null) {
101
- const bundled = await readTextIfExists(path.join(EXT_DIR, 'caveman-session', 'rule.md'));
102
- if (bundled !== null) {
103
- await writeIfChanged(ruleDest, bundled, { dryRun, verbose });
104
- }
105
- else {
106
- const rule = await Promise.race([
107
- httpsGet(CAVEMAN_REMOTE_RULE).catch(() => null),
108
- new Promise((resolve) => setTimeout(() => resolve(null), 8000)),
109
- ]);
110
- if (rule !== null)
111
- await writeIfChanged(ruleDest, rule, { dryRun, verbose });
112
- else
113
- console.log(' [warn] Caveman rule unreachable and no bundled rule exists');
114
- }
105
+ await writeIfChanged(dest, text, { dryRun, verbose });
115
106
  }
116
107
  // Writing is not atomic, so re-read rather than assume the tree is whole.
117
108
  if (dryRun)
package/cli/doctor.js CHANGED
@@ -17,7 +17,7 @@ async function runDoctor(recheck = false) {
17
17
  const ponytailPkg = path.join(pluginsDir, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
18
18
  const tersioPluginDir = path.join(pluginsDir, 'node_modules', '@krtclcdy', 'tersio');
19
19
  const cavemanIndex = path.join(tersioPluginDir, 'extensions', 'caveman-session', 'index.ts');
20
- const cavemanRule = path.join(tersioPluginDir, 'extensions', 'caveman-session', 'rule.md');
20
+ const cavemanRule = (name) => path.join(tersioPluginDir, 'extensions', 'caveman-session', name);
21
21
  const rtkIndex = path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts');
22
22
  const updaterIndex = path.join(tersioPluginDir, 'extensions', 'ai-addons-updater', 'index.ts');
23
23
  // Independent probes start concurrently; sections report in fixed order as their data settles. Every probe resolves instead of rejecting.
@@ -28,11 +28,13 @@ async function runDoctor(recheck = false) {
28
28
  ponytailPkgText: readTextIfExists(ponytailPkg),
29
29
  rtkBinText: rtkBin ? readTextIfExists(rtkBin) : Promise.resolve(null),
30
30
  cavemanIndexText: readTextIfExists(cavemanIndex),
31
- cavemanRuleText: readTextIfExists(cavemanRule),
31
+ cavemanRuleText: readTextIfExists(cavemanRule('rule.md')),
32
+ cavemanUltraText: readTextIfExists(cavemanRule('rule-ultra.md')),
33
+ cavemanWenyanText: readTextIfExists(cavemanRule('rule-wenyan.md')),
32
34
  rtkIndexText: readTextIfExists(rtkIndex),
33
35
  updaterIndexText: readTextIfExists(updaterIndex),
34
36
  rtkMtime: rtkBin ? fs.stat(rtkBin).catch(() => null) : Promise.resolve(null),
35
- ruleMtime: fs.stat(cavemanRule).catch(() => null),
37
+ ruleMtime: fs.stat(cavemanRule('rule.md')).catch(() => null),
36
38
  ponytailMtime: fs.stat(ponytailPkg).catch(() => null),
37
39
  pricesMtime: fs.stat(pricesCachePath()).catch(() => null),
38
40
  };
@@ -40,7 +42,7 @@ async function runDoctor(recheck = false) {
40
42
  const err = e;
41
43
  return err.stdout?.trim() || err.stderr?.trim() || null;
42
44
  }) : Promise.resolve(null);
43
- const [[ompPkgText, configText], [cavemanIndexText, rtkIndexText, updaterIndexText, ponytailPkgText], [cavemanRuleText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, ponytailMtime, pricesMtime]] = await runInteractivePhase('Checking installation', () => Promise.all([
45
+ const [[ompPkgText, configText], [cavemanIndexText, rtkIndexText, updaterIndexText, ponytailPkgText], [cavemanRuleText, cavemanUltraText, cavemanWenyanText, ruleMtime, rtkBinText, rtkMtime, rtkVersion, ponytailMtime, pricesMtime]] = await runInteractivePhase('Checking installation', () => Promise.all([
44
46
  Promise.all([
45
47
  probes.ompPkgText,
46
48
  probes.configText,
@@ -53,6 +55,8 @@ async function runDoctor(recheck = false) {
53
55
  ]),
54
56
  Promise.all([
55
57
  probes.cavemanRuleText,
58
+ probes.cavemanUltraText,
59
+ probes.cavemanWenyanText,
56
60
  probes.ruleMtime,
57
61
  probes.rtkBinText,
58
62
  probes.rtkMtime,
@@ -129,7 +133,9 @@ async function runDoctor(recheck = false) {
129
133
  check('Prices feed', pricesMtime !== null, pricesAge || pricesCachePath());
130
134
  section('Add-ons');
131
135
  const ruleAge = ruleMtime ? `(updated ${relTime(Date.now() - ruleMtime.mtimeMs)} · ${absDate(ruleMtime.mtimeMs)})` : '';
132
- check('Caveman rule', cavemanRuleText !== null, ruleAge);
136
+ const cavemanRules = [['rule.md', cavemanRuleText], ['rule-ultra.md', cavemanUltraText], ['rule-wenyan.md', cavemanWenyanText]];
137
+ const missingRules = cavemanRules.filter(([, text]) => text === null).map(([name]) => name);
138
+ check('Caveman rule', missingRules.length === 0, missingRules.length ? `missing: ${missingRules.join(', ')}` : ruleAge);
133
139
  const rtkAge = rtkMtime ? `(updated ${relTime(Date.now() - rtkMtime.mtimeMs)} · ${absDate(rtkMtime.mtimeMs)})` : '';
134
140
  check('RTK binary', rtkBin !== null, rtkBinText === null ? 'not found in PATH' : [rtkVersion, rtkAge].filter(Boolean).join(' '));
135
141
  // rtk_run needs a host exec API, which doctor cannot assume.
package/cli/install.js CHANGED
@@ -13,7 +13,7 @@ import { runReset } from './reset.js';
13
13
  import { runUsage } from './usage.js';
14
14
  import { runDashboard } from './dashboard.js';
15
15
  import { wireRtkOmp, wireRtkOpencode, wireRtkPi } from './rtk-wiring.js';
16
- import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, findHoistedPackage, httpsGet, httpsDownload, parseChecksum, piAgentDir, readTextIfExists, resolveRtkBinary, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
16
+ import { CAVEMAN_REMOTE_ULTRA, CAVEMAN_REMOTE_WENYAN, RTK_RELEASE_API, fetchJson, findFile, findHoistedPackage, httpsGet, httpsDownload, parseChecksum, piAgentDir, readTextIfExists, resolveRtkBinary, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
17
17
  import { formatCliStatus, storedProfile, storedProfileSync, writePluginSettings } from './profile.js';
18
18
  import { runSettings } from './settings.js';
19
19
  import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
@@ -356,39 +356,40 @@ async function stepRtkSession(extDir, options) {
356
356
  console.log(' RTK session — install session mode');
357
357
  await copySources(extDir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
358
358
  }
359
- // One fetch serves the install; dry runs stay offline and preview the bundled rule's destination.
360
- async function fetchCavemanRule(options) {
361
- const bundled = await readTextIfExists(sourcePath('caveman-session/rule.md'));
362
- if (options.dryRun)
363
- return bundled;
364
- try {
365
- return await withInteractiveSpinner('Fetching Caveman rule', () => httpsGet(CAVEMAN_REMOTE_RULE));
366
- }
367
- catch (e) {
368
- if (bundled !== null) {
369
- debug(`Using bundled Caveman rule: ${e.message}`);
370
- return bundled;
371
- }
372
- sayTagged(` [warn] Could not fetch caveman rule: ${e.message}`);
373
- return null;
374
- }
375
- }
376
- async function stepCaveman(extDir, rule, options) {
359
+ // rule.md ships with the package; the other bodies track upstream skills.
360
+ const CAVEMAN_RULES = [
361
+ ['rule.md', null],
362
+ ['rule-ultra.md', CAVEMAN_REMOTE_ULTRA],
363
+ ['rule-wenyan.md', CAVEMAN_REMOTE_WENYAN],
364
+ ];
365
+ async function stepCaveman(extDir, options) {
377
366
  if (!options.quiet)
378
- console.log(' Caveman — fetch rule and install session mode');
367
+ console.log(' Caveman — install session mode and rule bodies');
379
368
  const cavemanDir = path.join(extDir, 'caveman-session');
380
369
  if (!options.dryRun)
381
370
  await fs.mkdir(cavemanDir, { recursive: true });
382
- const ruleDest = path.join(cavemanDir, 'rule.md');
383
- if (rule === null && (await readTextIfExists(ruleDest)) !== null) {
384
- debug('Keeping existing rule.md');
385
- }
386
- else if (rule === null) {
387
- console.log(' [skip] Caveman rule.md unavailable');
388
- sayTagged(` [hint] Manual: ${CAVEMAN_REMOTE_RULE}`);
389
- }
390
- else {
391
- await writeIfChanged(ruleDest, rule, options);
371
+ for (const [name, remote] of CAVEMAN_RULES) {
372
+ const bundled = await readTextIfExists(sourcePath(`caveman-session/${name}`));
373
+ let rule = bundled;
374
+ if (remote && !options.dryRun) {
375
+ try {
376
+ rule = await withInteractiveSpinner(`Fetching Caveman ${name}`, () => httpsGet(remote));
377
+ }
378
+ catch (e) {
379
+ if (bundled === null)
380
+ sayTagged(` [warn] Could not fetch caveman ${name}: ${e.message}`);
381
+ else
382
+ debug(`Using bundled Caveman ${name}: ${e.message}`);
383
+ }
384
+ }
385
+ if (rule === null) {
386
+ if (remote)
387
+ sayTagged(` [hint] Manual: ${remote}`);
388
+ console.log(` [skip] Caveman ${name} unavailable`);
389
+ }
390
+ else {
391
+ await writeIfChanged(path.join(cavemanDir, name), rule, options);
392
+ }
392
393
  }
393
394
  await copySources(extDir, filesUnder('caveman-session').filter(([, to]) => to.endsWith('index.ts')), 'caveman-session/index.ts', options);
394
395
  }
@@ -459,18 +460,22 @@ async function stepOpencode(options) {
459
460
  return;
460
461
  }
461
462
  const plugins = Array.isArray(config.plugins) ? [...config.plugins] : [];
462
- const withoutTersio = plugins.filter((p) => typeof p !== 'string' || !p.includes('/opencode/plugins/tersio'));
463
- if (!withoutTersio.includes(dir))
464
- withoutTersio.push(dir);
465
- config.plugins = withoutTersio;
466
- if (!options.dryRun)
463
+ // The tree ships the rtk rewrite, so its own plugin entry is redundant.
464
+ const kept = plugins.filter((p) => typeof p !== 'string' || !p.endsWith('/opencode/plugins/rtk.ts'));
465
+ if (!kept.includes(dir))
466
+ kept.push(dir);
467
+ config.plugins = kept;
468
+ if (!options.dryRun) {
467
469
  await fs.mkdir(path.dirname(configPath), { recursive: true });
470
+ await fs.rm(path.join(HOME, '.config', 'opencode', 'plugins', 'rtk.ts'), { force: true }).catch(() => { });
471
+ }
468
472
  await writeIfChanged(configPath, `${JSON.stringify(config, null, 2)}\n`, options);
469
473
  }
470
474
  async function resolveProfile(opts = {}) {
471
475
  // Seed from the stored defaults so flag-less runs keep them.
472
476
  const profile = await storedProfile();
473
477
  // One numbered prompt for all three modes, for a real terminal user with no default flags. Never for --apply-update, and never for a script.
478
+ let comboChosen = comboDefaultFlag !== undefined;
474
479
  if (tty() && !profileFlagsGiven && !applyUpdate) {
475
480
  const choice = await askInteractiveChoice('Session-start defaults — Combo preset', [
476
481
  { value: 'off', label: 'off' },
@@ -478,8 +483,10 @@ async function resolveProfile(opts = {}) {
478
483
  { value: 'balanced', label: 'balanced', hint: 'caveman=full, rtk=on, ponytail=full' },
479
484
  { value: 'max', label: 'max', hint: 'caveman=ultra, rtk=on, ponytail=ultra' },
480
485
  ], profile.comboDefault);
481
- if (choice.status === 'selected')
486
+ if (choice.status === 'selected') {
482
487
  profile.comboDefault = choice.value;
488
+ comboChosen = true;
489
+ }
483
490
  else {
484
491
  closeRL();
485
492
  process.exit(130);
@@ -488,10 +495,13 @@ async function resolveProfile(opts = {}) {
488
495
  // Explicit flags always win over the Combo preset.
489
496
  if (comboDefaultFlag !== undefined)
490
497
  profile.comboDefault = comboDefaultFlag;
491
- const preset = COMBO_PRESET_MODES[profile.comboDefault] ?? COMBO_PRESET_MODES.off;
492
- profile.cavemanDefault = preset.caveman;
493
- profile.rtkDefault = preset.rtk;
494
- profile.ponytailDefault = preset.ponytail;
498
+ // A stored per-mode choice survives installs and updates; only a chosen preset rewrites it.
499
+ if (comboChosen) {
500
+ const preset = COMBO_PRESET_MODES[profile.comboDefault] ?? COMBO_PRESET_MODES.off;
501
+ profile.cavemanDefault = preset.caveman;
502
+ profile.rtkDefault = preset.rtk;
503
+ profile.ponytailDefault = preset.ponytail;
504
+ }
495
505
  if (cavemanDefaultFlag !== undefined)
496
506
  profile.cavemanDefault = cavemanDefaultFlag;
497
507
  if (rtkDefaultFlag !== undefined)
@@ -506,7 +516,8 @@ let updatePromptDone = false;
506
516
  // Bare `tersio` at a terminal picks a command; scripts and flags install.
507
517
  async function runCommandMenu() {
508
518
  printWelcome();
509
- const newer = await checkForUpdate();
519
+ // Interactive launches always check; scripts use the cached verdict.
520
+ const newer = await checkForUpdate(true);
510
521
  if (typeof newer === 'string' && !dryRun) {
511
522
  const answer = await askInteractiveConfirm(`tersio ${newer} is available (installed ${PACKAGE_VERSION}). Install it now?`);
512
523
  if (answer.status === 'confirmed' && answer.value) {
@@ -636,50 +647,73 @@ async function stepPiLayer(options) {
636
647
  sayTagged(` [note] pi also has a tersio package (${declared}) — remove one, or both copies load: pi remove npm:${PACKAGE_NAME}`);
637
648
  if (!options.quiet)
638
649
  console.log(` Pi — write the extension tree (${extDir})`);
639
- const cavemanRule = await fetchCavemanRule(options);
640
650
  await stepSharedSessionState(extDir, options);
641
651
  // OMP loads rtk-session from its plugin manifest, so only the pi tree copies it.
642
652
  await copySources(extDir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
643
- await stepCaveman(extDir, cavemanRule, options);
653
+ await stepCaveman(extDir, options);
644
654
  await stepCombo(extDir, options);
645
655
  await stepTersioCommands(extDir, options);
646
656
  await stepUpdater(extDir, options);
647
657
  await stepPonytailForPi(agentDir, options);
648
658
  }
649
- // Ponytail for pi: install the package, or copy the bundled skills offline.
659
+ // A local dir, not an npm spec: pi only version-checks npm entries, so this avoids the update nag.
660
+ const PI_PONYTAIL_FILES = [
661
+ 'pi-extension/index.js',
662
+ 'hooks/ponytail-instructions.js',
663
+ 'hooks/ponytail-config.js',
664
+ 'skills/ponytail/SKILL.md',
665
+ ];
666
+ const PI_PONYTAIL_PACKAGE = `${JSON.stringify({ name: 'ponytail', version: '0.0.0', private: true, pi: { extensions: ['./pi-extension/index.js'], skills: ['./skills'] } }, null, 2)}\n`;
650
667
  async function stepPonytailForPi(agentDir, options) {
651
668
  if (!options.quiet)
652
- console.log(' Ponytail — ensure the pi package');
653
- if (!options.dryRun) {
669
+ console.log(' Ponytail — write the local pi package');
670
+ const root = findHoistedPackage('@dietrichgebert/ponytail', path.dirname(fileURLToPath(import.meta.url)));
671
+ const dest = path.join(agentDir, 'ponytail');
672
+ if (!root) {
673
+ console.log(' [skip] Ponytail package not found next to the CLI');
674
+ console.log(' [hint] The /ponytail command will not work; run: tersio update --host pi');
675
+ return;
676
+ }
677
+ await writeIfChanged(path.join(dest, 'package.json'), PI_PONYTAIL_PACKAGE, options);
678
+ for (const rel of PI_PONYTAIL_FILES) {
679
+ let text;
654
680
  try {
655
- await execNetwork('Installing Ponytail for pi', 'pi', ['install', 'npm:@dietrichgebert/ponytail'], { timeout: 300000 });
656
- return;
681
+ text = await fs.readFile(path.join(root, ...rel.split('/')), 'utf8');
657
682
  }
658
- catch (e) {
659
- sayTagged(` [fail] pi install ponytail: ${shortError(e)}`);
683
+ catch {
684
+ sayTagged(` [fail] Ponytail file missing: ${rel}`);
685
+ return;
660
686
  }
687
+ await writeIfChanged(path.join(dest, ...rel.split('/')), text, options);
661
688
  }
662
- const skills = findHoistedPackage('@dietrichgebert/ponytail', path.dirname(fileURLToPath(import.meta.url)), 'skills');
663
- if (!skills) {
664
- console.log(' [skip] Ponytail package not found next to the CLI — skills not copied');
665
- console.log(' [hint] The /ponytail command will not work; run: pi install npm:@dietrichgebert/ponytail');
689
+ if (options.dryRun)
666
690
  return;
691
+ try {
692
+ await execNetwork('Removing the pi npm copy of Ponytail', 'pi', ['remove', 'npm:@dietrichgebert/ponytail'], { timeout: 120000 });
667
693
  }
668
- const dest = path.join(agentDir, 'skills');
669
- if (options.dryRun) {
670
- if (verbose && !options.quiet)
671
- sayTagged(` [dry-run] would copy Ponytail skills to ${dest}`);
672
- return;
694
+ catch (e) {
695
+ sayTagged(` [warn] pi remove ponytail: ${shortError(e)}`);
673
696
  }
674
- for (const entry of await fs.readdir(skills, { withFileTypes: true })) {
675
- if (!entry.isDirectory())
676
- continue;
677
- const to = path.join(dest, entry.name);
678
- await fs.rm(to, { recursive: true, force: true });
679
- await fs.cp(path.join(skills, entry.name), to, { recursive: true });
680
- if (!options.quiet)
681
- sayTagged(` [write] skill ${entry.name} → ${to}`);
697
+ await registerPiLocalPonytail(agentDir, dest, options);
698
+ }
699
+ // pi lists package sources in settings; swap the npm spec for the local dir.
700
+ async function registerPiLocalPonytail(agentDir, dest, options) {
701
+ const settingsPath = path.join(agentDir, 'settings.json');
702
+ let settings;
703
+ try {
704
+ settings = JSON.parse(await fs.readFile(settingsPath, 'utf8'));
705
+ }
706
+ catch {
707
+ return;
682
708
  }
709
+ const packages = (Array.isArray(settings.packages) ? settings.packages : []);
710
+ const kept = packages.filter((p) => typeof p !== 'string' || !p.startsWith('npm:@dietrichgebert/ponytail'));
711
+ if (!kept.includes(dest))
712
+ kept.push(dest);
713
+ settings.packages = kept;
714
+ await fs.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
715
+ if (!options.quiet)
716
+ sayTagged(` [write] pi package source → ${dest}`);
683
717
  }
684
718
  async function runInstall() {
685
719
  // Without this guard a picked command re-enters here with command === null and loops straight back into the picker.
@@ -799,14 +833,13 @@ async function runInstall() {
799
833
  closeRL();
800
834
  return;
801
835
  }
802
- const cavemanRule = await fetchCavemanRule(installOptions);
803
836
  await capture('shared', () => stepSharedSessionState(userExtDir, installOptions));
804
837
  let selfPlugin = false;
805
838
  await capture('self-plugin', async () => { selfPlugin = await stepSelfPlugin(OMP_PLUGINS_DIR, installOptions); });
806
839
  await capture('ponytail', () => stepPonytail(OMP_PLUGINS_DIR, installOptions));
807
840
  await capture('rtk', () => stepRtk(BUN_BIN_DIR, installOptions));
808
841
  await capture('rtk session', () => stepRtkSession(userExtDir, installOptions));
809
- await capture('caveman', () => stepCaveman(path.join(OMP_PLUGINS_DIR, 'node_modules', '@krtclcdy', 'tersio', 'extensions'), cavemanRule, installOptions));
842
+ await capture('caveman', () => stepCaveman(path.join(OMP_PLUGINS_DIR, 'node_modules', '@krtclcdy', 'tersio', 'extensions'), installOptions));
810
843
  await capture('combo', () => stepCombo(userExtDir, installOptions));
811
844
  await capture('commands', () => stepTersioCommands(userExtDir, installOptions));
812
845
  await capture('updater', () => stepUpdater(userExtDir, installOptions));
@@ -26,9 +26,9 @@ function tty() {
26
26
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
27
27
  }
28
28
  let spinnerDepth = 0;
29
- // Run async work under a TTY-only timer spinner. The spinner is cleared before the caller prints its normal result line, preserving locked output shapes.
29
+ // Clack's spinner stop writes an extra newline under CI, shifting later output by a row.
30
30
  async function withInteractiveSpinner(message, work) {
31
- if (!tty() || spinnerDepth > 0)
31
+ if (!tty() || spinnerDepth > 0 || process.env.CI === 'true')
32
32
  return work(() => { });
33
33
  // Clack manages stdin itself. Close the legacy question interface before its first use; non-interactive callers never reach this branch.
34
34
  closeRL();
package/cli/manifest.js CHANGED
@@ -20,6 +20,8 @@ export const TREE_FILES = [
20
20
  'lib/utils.ts',
21
21
  'caveman-session/index.ts',
22
22
  'caveman-session/rule.md',
23
+ 'caveman-session/rule-ultra.md',
24
+ 'caveman-session/rule-wenyan.md',
23
25
  'rtk-session/index.ts',
24
26
  'combo-toggle/index.ts',
25
27
  'tersio-commands/index.ts',
package/cli/rtk-wiring.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // OMP loads only listed extensions, so register rtk's path; fail-open.
2
- import { promises as fs } from 'node:fs';
2
+ import { existsSync, promises as fs } from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { execFile } from 'node:child_process';
@@ -47,8 +47,11 @@ export async function wireRtkOmp(rtkBin, options = {}) {
47
47
  await ensureRtkInConfig(options);
48
48
  return true;
49
49
  }
50
+ // The Tersio plugin does the same rewrite, so OpenCode needs no rtk plugin of its own.
50
51
  export async function wireRtkOpencode(rtkBin, options = {}) {
51
- return runWire(rtkBin, { host: 'OpenCode', mechanism: 'tool.execute.before', initArgs: ['init', '-g', '--opencode'], hint: 'rtk init -g --opencode' }, options);
52
+ if (!options.dryRun && !options.quiet)
53
+ console.log(' RTK rewrite runs inside the Tersio plugin — no separate entry');
54
+ return existsSync(rtkBin);
52
55
  }
53
56
  export async function wireRtkPi(rtkBin, options = {}) {
54
57
  return runWire(rtkBin, { host: 'pi', mechanism: 'bash tool_call', initArgs: ['init', '-g', '--agent', 'pi'], hint: 'rtk init -g --agent pi' }, options);
package/cli/uninstall.js CHANGED
@@ -4,8 +4,7 @@ import path from 'node:path';
4
4
  import { BUN_BIN_DIR, HOME, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, RTK_BINARY_NAME, args, dryRun, keepPonytail, removePonytail, removeRtk, yes, debug, writeConfigLines, } from './common.js';
5
5
  import { askInteractiveChoice, closeRL, confirmDestructive, tty, sayTagged } from './interactive.js';
6
6
  import { detectHosts, hostHint, hostLabel, parseHostArg } from './hosts.js';
7
- import { findHoistedPackage, piAgentDir, readTextIfExists } from '../extensions/lib/utils.js';
8
- import { fileURLToPath } from 'node:url';
7
+ import { piAgentDir, readTextIfExists } from '../extensions/lib/utils.js';
9
8
  import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
10
9
  // The pi tree mirrors the OMP one. Ponytail and rtk stay: a package and a shared binary are not ours to delete.
11
10
  const PONYTAIL_PKG = '@dietrichgebert/ponytail';
@@ -42,20 +41,30 @@ async function piRemove(spec, shouldDryRun) {
42
41
  sayTagged(` [hint] Manual: pi remove ${spec}`);
43
42
  }
44
43
  }
45
- // Only the skill directories the installer copied, never pi's own skills dir.
44
+ // The installer writes a local package dir; drop it and its settings entry.
46
45
  async function removeCopiedPonytailSkills(shouldDryRun) {
47
- const source = findHoistedPackage(PONYTAIL_PKG, path.dirname(fileURLToPath(import.meta.url)), 'skills');
48
- if (!source)
49
- return;
50
- for (const entry of await fs.readdir(source, { withFileTypes: true })) {
51
- if (entry.isDirectory())
52
- await removeUninstallTarget(path.join(piAgentDir(), 'skills', entry.name), shouldDryRun);
46
+ const agentDir = piAgentDir();
47
+ const settingsPath = path.join(agentDir, 'settings.json');
48
+ try {
49
+ const settings = JSON.parse(await fs.readFile(settingsPath, 'utf8'));
50
+ if (!Array.isArray(settings.packages))
51
+ return;
52
+ const kept = settings.packages.filter((p) => p !== path.join(agentDir, 'ponytail'));
53
+ if (kept.length === settings.packages.length)
54
+ return;
55
+ if (shouldDryRun)
56
+ sayTagged(` [dry-run] would drop the local ponytail entry from ${settingsPath}`);
57
+ else
58
+ await fs.writeFile(settingsPath, `${JSON.stringify({ ...settings, packages: kept }, null, 2)}\n`, 'utf8');
53
59
  }
60
+ catch { /* no settings file, nothing to clean */ }
61
+ await removeUninstallTarget(path.join(agentDir, 'ponytail'), shouldDryRun);
54
62
  }
55
63
  // pi removal delegates to pi remove; always list and confirm first.
56
64
  async function removePiLayer(host, shouldDryRun, shouldRemovePonytail, shouldRemoveRtk, confirmed) {
57
65
  const targets = PI_TREE_DIRS.map((dir) => path.join(piAgentDir(), 'extensions', dir));
58
66
  const rtkWiring = path.join(piAgentDir(), 'extensions', 'rtk.ts');
67
+ const piPonytail = path.join(piAgentDir(), 'ponytail');
59
68
  if (!host.installed && !host.declared) {
60
69
  sayTagged(` [skip] ${host.label} — nothing installed (${host.installCmd})`);
61
70
  return false;
@@ -63,8 +72,10 @@ async function removePiLayer(host, shouldDryRun, shouldRemovePonytail, shouldRem
63
72
  console.log('Will remove:');
64
73
  for (const t of targets)
65
74
  console.log(` ${t}`);
66
- if (shouldRemovePonytail)
75
+ if (shouldRemovePonytail) {
67
76
  console.log(` npm:${PONYTAIL_PKG} (pi package)`);
77
+ console.log(` ${piPonytail} (local Ponytail package)`);
78
+ }
68
79
  if (shouldRemoveRtk)
69
80
  console.log(` ${rtkWiring} (rtk wiring)`);
70
81
  console.log(` ${tersioSettingsFile()} (session defaults)`);
@@ -278,7 +289,8 @@ async function runUninstall(options = {}) {
278
289
  }
279
290
  }
280
291
  // Cleared last: writeConfigLines above would just make a fresh one.
281
- for (const backup of [`${configPath}.bak`, path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'extensions', 'caveman-session', 'rule.md.bak')]) {
292
+ const cavemanDir = path.join(pluginsDir, 'node_modules', PACKAGE_NAME, 'extensions', 'caveman-session');
293
+ for (const backup of [`${configPath}.bak`, path.join(cavemanDir, 'rule.md.bak'), path.join(cavemanDir, 'rule-ultra.md.bak'), path.join(cavemanDir, 'rule-wenyan.md.bak')]) {
282
294
  await removeUninstallTarget(backup, shouldDryRun, false);
283
295
  }
284
296
  // Remove the bundled Ponytail copy; its config.yml entry is already filtered.
package/cli/update.js CHANGED
@@ -6,7 +6,7 @@ import { IS_WINDOWS, OMP_PLUGINS_DIR, PACKAGE_BIN, PACKAGE_NAME, PACKAGE_VERSION
6
6
  import { execNetwork, sayTagged } from './interactive.js';
7
7
  import { readTextIfExists, resolveRtkBinary, tersioDataPath } from '../extensions/lib/utils.js';
8
8
  import { refreshPrices } from '../extensions/shared/pricing.js';
9
- import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, httpsGet, normalizeRtkVersion, sha256Hex, } from '../extensions/lib/utils.js';
9
+ import { CAVEMAN_REMOTE_ULTRA, CAVEMAN_REMOTE_WENYAN, RTK_RELEASE_API, httpsGet, normalizeRtkVersion, sha256Hex, } from '../extensions/lib/utils.js';
10
10
  import { parseHostArg } from './hosts.js';
11
11
  const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
12
12
  function newerThan(a, b) {
@@ -78,20 +78,23 @@ async function execInherit(cmd, args) {
78
78
  async function probeUpdatePlan(cliLatest) {
79
79
  const rtkBin = resolveRtkBinary();
80
80
  const ponytailPkg = path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
81
- const tersioRule = path.join(OMP_PLUGINS_DIR, 'node_modules', '@krtclcdy', 'tersio', 'extensions', 'caveman-session', 'rule.md');
81
+ // rule.md ships with the release; only the tracked bodies can go stale.
82
+ const tersioRule = (name) => path.join(OMP_PLUGINS_DIR, 'node_modules', '@krtclcdy', 'tersio', 'extensions', 'caveman-session', name);
82
83
  const controller = new AbortController();
83
- const [rtkLocal, rtkRelease, ruleLocalText, ruleRemoteText, ponytailLocalText] = await Promise.all([
84
+ const [rtkLocal, rtkRelease, ruleLocal, ruleRemote, ponytailLocalText] = await Promise.all([
84
85
  settle(rtkBin ? execP(rtkBin, ['--version'], { timeout: 5000 }).then((r) => normalizeRtkVersion(r.stdout.trim() || r.stderr.trim()) || null) : Promise.resolve(null), 6000),
85
86
  settle(httpsGet(RTK_RELEASE_API, { signal: controller.signal }).then((text) => normalizeRtkVersion(JSON.parse(text).tag_name) || null), 1500),
86
- readTextIfExists(tersioRule),
87
- settle(httpsGet(CAVEMAN_REMOTE_RULE, { signal: controller.signal }).then((text) => sha256Hex(text).slice(0, 8)), 1500),
87
+ Promise.all([readTextIfExists(tersioRule('rule-ultra.md')), readTextIfExists(tersioRule('rule-wenyan.md'))])
88
+ .then((texts) => (texts.every(Boolean) ? texts.map((t) => sha256Hex(t).slice(0, 8)).join('+') : null)),
89
+ settle(Promise.all([httpsGet(CAVEMAN_REMOTE_ULTRA, { signal: controller.signal }), httpsGet(CAVEMAN_REMOTE_WENYAN, { signal: controller.signal })])
90
+ .then((texts) => texts.map((t) => sha256Hex(t).slice(0, 8)).join('+')), 1500),
88
91
  readTextIfExists(ponytailPkg),
89
92
  ]);
90
93
  controller.abort();
91
94
  return {
92
95
  cli: cliLatest,
93
96
  rtk: [rtkLocal, rtkRelease],
94
- rule: [ruleLocalText ? sha256Hex(ruleLocalText).slice(0, 8) : null, ruleRemoteText],
97
+ rule: [ruleLocal, ruleRemote],
95
98
  ponytail: ponytailLocalText ? parseJsonObject(ponytailLocalText)?.version ?? null : null,
96
99
  };
97
100
  }
@@ -6,13 +6,18 @@ import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { setExtensionLabel } from '../shared/host.js';
9
- import { CAVEMAN_REMOTE_RULE as CAVEMAN_REMOTE, RTK_RELEASE_API, fetchJson, findFile, findHoistedPackage, httpsGet, httpsDownload, isPiProcess, sha256Hex, parseChecksum, normalizeRtkVersion, readTextIfExists, rtkPlatformSpec, resolveRtkBinary, } from '../lib/utils.js';
9
+ import { CAVEMAN_REMOTE_ULTRA, CAVEMAN_REMOTE_WENYAN, RTK_RELEASE_API, fetchJson, findFile, findHoistedPackage, httpsGet, httpsDownload, isPiProcess, sha256Hex, parseChecksum, normalizeRtkVersion, readTextIfExists, rtkPlatformSpec, resolveRtkBinary, } from '../lib/utils.js';
10
10
  const IS_WINDOWS = process.platform === 'win32';
11
11
  const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
12
12
  const PONYTAIL_REMOTE = 'https://raw.githubusercontent.com/DietrichGebert/ponytail/main/package.json';
13
13
  const RTK_BINARY = resolveRtkBinary();
14
- // Beside this file, not a host path: that is the rule.md /caveman reads.
15
- const CAVEMAN_LOCAL = path.join(EXTENSION_DIR, '..', 'caveman-session', 'rule.md');
14
+ // Beside this file, not a host path: these are the rules /caveman reads.
15
+ const cavemanRule = (name) => path.join(EXTENSION_DIR, '..', 'caveman-session', name);
16
+ const CAVEMAN_LOCAL = cavemanRule('rule.md');
17
+ const CAVEMAN_TRACKED = [
18
+ { file: cavemanRule('rule-ultra.md'), remote: CAVEMAN_REMOTE_ULTRA },
19
+ { file: cavemanRule('rule-wenyan.md'), remote: CAVEMAN_REMOTE_WENYAN },
20
+ ];
16
21
  const HOST_NAME = isPiProcess() ? 'Pi' : 'OMP';
17
22
  const RELOAD_MSG = `Reminder: restart ${HOST_NAME} (or reload extensions) for updates to take effect.`;
18
23
  // Ponytail is a hoisted dependency, so the installed copy is found by walking up rather than by naming a host directory.
@@ -82,17 +87,20 @@ function checkRtk() {
82
87
  return `RTK ${status}: local=${localVer || '—'} latest=${latestTag || '—'}`;
83
88
  });
84
89
  }
85
- // Caveman (rule.md)
86
90
  function checkCaveman() {
87
91
  return runCheck('Caveman', async () => {
88
- const remote = await httpsGet(CAVEMAN_REMOTE);
89
- const remoteHash = shortHash(remote);
90
- const local = await readTextIfExists(CAVEMAN_LOCAL);
91
- const localHash = local ? shortHash(local) : null;
92
- const status = !local ? 'rule.md missing'
93
- : localHash === remoteHash ? 'rule.md up to date'
94
- : 'rule.md update available';
95
- return `Caveman ${status}: local=${localHash || '—'} remote=${remoteHash}`;
92
+ const parts = [(await readTextIfExists(CAVEMAN_LOCAL)) ? 'rule.md ok' : 'rule.md missing'];
93
+ for (const { file, remote } of CAVEMAN_TRACKED) {
94
+ const name = path.basename(file);
95
+ try {
96
+ const [remoteText, localText] = await Promise.all([httpsGet(remote), readTextIfExists(file)]);
97
+ parts.push(!localText ? `${name} missing` : shortHash(localText) === shortHash(remoteText) ? `${name} up to date` : `${name} update available`);
98
+ }
99
+ catch {
100
+ parts.push(`${name} check skipped`);
101
+ }
102
+ }
103
+ return `Caveman ${parts.join(', ')}`;
96
104
  });
97
105
  }
98
106
  export async function checkAddonsSummary(ctx) {
@@ -317,41 +325,49 @@ async function updateRtk(ctx, dryRun = false) {
317
325
  }
318
326
  }
319
327
  async function updateCaveman(ctx, dryRun = false) {
320
- let remote;
321
- try {
322
- remote = await httpsGet(CAVEMAN_REMOTE);
323
- }
324
- catch (e) {
325
- return report(ctx, `Caveman update failed: ${e.message}`, 'warning');
326
- }
327
- const remoteHash = shortHash(remote);
328
- const oldLocal = await readTextIfExists(CAVEMAN_LOCAL);
329
- const oldHash = oldLocal ? shortHash(oldLocal) : null;
330
- if (dryRun) {
331
- const m = `Caveman dry-run: would write ${CAVEMAN_LOCAL}\nold=${oldHash || '—'} new=${remoteHash}.`;
332
- return report(ctx, m, 'info');
333
- }
334
- try {
335
- await fs.mkdir(path.dirname(CAVEMAN_LOCAL), { recursive: true });
336
- const backupPath = `${CAVEMAN_LOCAL}.bak`;
337
- if (oldLocal !== null)
338
- await fs.writeFile(backupPath, oldLocal, 'utf8');
339
- await fs.writeFile(CAVEMAN_LOCAL, remote, 'utf8');
340
- const written = await fs.readFile(CAVEMAN_LOCAL, 'utf8');
341
- const writtenHash = shortHash(written);
342
- if (writtenHash !== remoteHash) {
328
+ const results = [];
329
+ for (const { file, remote } of CAVEMAN_TRACKED) {
330
+ const name = path.basename(file);
331
+ let remoteText;
332
+ try {
333
+ remoteText = await httpsGet(remote);
334
+ }
335
+ catch (e) {
336
+ results.push(report(ctx, `Caveman ${name} update failed: ${e.message}`, 'warning'));
337
+ continue;
338
+ }
339
+ const remoteHash = shortHash(remoteText);
340
+ const oldLocal = await readTextIfExists(file);
341
+ const oldHash = oldLocal ? shortHash(oldLocal) : null;
342
+ if (oldHash === remoteHash) {
343
+ results.push(`${name} up to date: ${remoteHash}`);
344
+ continue;
345
+ }
346
+ if (dryRun) {
347
+ results.push(`Caveman dry-run: would write ${file}\nold=${oldHash || '—'} new=${remoteHash}.`);
348
+ continue;
349
+ }
350
+ try {
351
+ await fs.mkdir(path.dirname(file), { recursive: true });
352
+ const backupPath = `${file}.bak`;
343
353
  if (oldLocal !== null)
344
- await fs.writeFile(CAVEMAN_LOCAL, oldLocal, 'utf8');
345
- throw new Error(`written hash ${writtenHash} did not match remote ${remoteHash}${oldLocal !== null ? '; restored backup' : ''}`);
354
+ await fs.writeFile(backupPath, oldLocal, 'utf8');
355
+ await fs.writeFile(file, remoteText, 'utf8');
356
+ const writtenHash = shortHash(await fs.readFile(file, 'utf8'));
357
+ if (writtenHash !== remoteHash) {
358
+ if (oldLocal !== null)
359
+ await fs.writeFile(file, oldLocal, 'utf8');
360
+ throw new Error(`written hash ${writtenHash} did not match remote ${remoteHash}${oldLocal !== null ? '; restored backup' : ''}`);
361
+ }
362
+ results.push(`Caveman ${name} updated → ${file}\nold=${oldHash || '—'} new=${remoteHash}\nbackup=${oldLocal !== null ? backupPath : '—'}\n${RELOAD_MSG}`);
363
+ }
364
+ catch (e) {
365
+ results.push(`Caveman ${name} update failed: ${e.message}`);
346
366
  }
347
- const m = `Caveman rule.md updated → ${CAVEMAN_LOCAL}\nold=${oldHash || '—'} new=${remoteHash}\nbackup=${oldLocal !== null ? backupPath : '—'}\n${RELOAD_MSG}`;
348
- notify(ctx, 'Caveman rule.md updated. ' + RELOAD_MSG, 'info');
349
- return m;
350
- }
351
- catch (e) {
352
- const m = `Caveman update failed: ${e.message}`;
353
- return report(ctx, m, 'warning');
354
367
  }
368
+ const summary = results.join('\n');
369
+ notify(ctx, `Caveman update finished. ${RELOAD_MSG}`, 'info');
370
+ return summary;
355
371
  }
356
372
  export default function aiAddonsUpdaterExtension(pi) {
357
373
  setExtensionLabel(pi, 'AI add-ons updater');
@@ -9,7 +9,8 @@ import { fileURLToPath } from 'node:url';
9
9
 
10
10
  import { setExtensionLabel } from '../shared/host.ts';
11
11
  import {
12
- CAVEMAN_REMOTE_RULE as CAVEMAN_REMOTE,
12
+ CAVEMAN_REMOTE_ULTRA,
13
+ CAVEMAN_REMOTE_WENYAN,
13
14
  RTK_RELEASE_API,
14
15
  RtkRelease,
15
16
  fetchJson,
@@ -31,8 +32,13 @@ const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
31
32
 
32
33
  const PONYTAIL_REMOTE = 'https://raw.githubusercontent.com/DietrichGebert/ponytail/main/package.json';
33
34
  const RTK_BINARY = resolveRtkBinary();
34
- // Beside this file, not a host path: that is the rule.md /caveman reads.
35
- const CAVEMAN_LOCAL = path.join(EXTENSION_DIR, '..', 'caveman-session', 'rule.md');
35
+ // Beside this file, not a host path: these are the rules /caveman reads.
36
+ const cavemanRule = (name: string) => path.join(EXTENSION_DIR, '..', 'caveman-session', name);
37
+ const CAVEMAN_LOCAL = cavemanRule('rule.md');
38
+ const CAVEMAN_TRACKED: ReadonlyArray<{ file: string; remote: string }> = [
39
+ { file: cavemanRule('rule-ultra.md'), remote: CAVEMAN_REMOTE_ULTRA },
40
+ { file: cavemanRule('rule-wenyan.md'), remote: CAVEMAN_REMOTE_WENYAN },
41
+ ];
36
42
  const HOST_NAME = isPiProcess() ? 'Pi' : 'OMP';
37
43
  const RELOAD_MSG = `Reminder: restart ${HOST_NAME} (or reload extensions) for updates to take effect.`;
38
44
 
@@ -129,17 +135,19 @@ function checkRtk(): Promise<AddonStatus> {
129
135
  });
130
136
  }
131
137
 
132
- // Caveman (rule.md)
133
138
  function checkCaveman(): Promise<AddonStatus> {
134
139
  return runCheck('Caveman', async () => {
135
- const remote = await httpsGet(CAVEMAN_REMOTE);
136
- const remoteHash = shortHash(remote);
137
- const local = await readTextIfExists(CAVEMAN_LOCAL);
138
- const localHash = local ? shortHash(local) : null;
139
- const status = !local ? 'rule.md missing'
140
- : localHash === remoteHash ? 'rule.md up to date'
141
- : 'rule.md update available';
142
- return `Caveman ${status}: local=${localHash || '—'} remote=${remoteHash}`;
140
+ const parts: string[] = [(await readTextIfExists(CAVEMAN_LOCAL)) ? 'rule.md ok' : 'rule.md missing'];
141
+ for (const { file, remote } of CAVEMAN_TRACKED) {
142
+ const name = path.basename(file);
143
+ try {
144
+ const [remoteText, localText] = await Promise.all([httpsGet(remote), readTextIfExists(file)]);
145
+ parts.push(!localText ? `${name} missing` : shortHash(localText) === shortHash(remoteText) ? `${name} up to date` : `${name} update available`);
146
+ } catch {
147
+ parts.push(`${name} check skipped`);
148
+ }
149
+ }
150
+ return `Caveman ${parts.join(', ')}`;
143
151
  });
144
152
  }
145
153
 
@@ -352,37 +360,41 @@ async function updateRtk(ctx: AddonUpdaterCtx, dryRun = false): Promise<string>
352
360
  }
353
361
 
354
362
  async function updateCaveman(ctx: AddonUpdaterCtx, dryRun = false): Promise<string> {
355
- let remote: string;
356
- try { remote = await httpsGet(CAVEMAN_REMOTE); }
357
- catch (e) { return report(ctx, `Caveman update failed: ${(e as Error).message}`, 'warning'); }
358
-
359
- const remoteHash = shortHash(remote);
360
- const oldLocal = await readTextIfExists(CAVEMAN_LOCAL);
361
- const oldHash = oldLocal ? shortHash(oldLocal) : null;
362
-
363
- if (dryRun) {
364
- const m = `Caveman dry-run: would write ${CAVEMAN_LOCAL}\nold=${oldHash || '—'} new=${remoteHash}.`;
365
- return report(ctx, m, 'info');
366
- }
363
+ const results: string[] = [];
364
+ for (const { file, remote } of CAVEMAN_TRACKED) {
365
+ const name = path.basename(file);
366
+ let remoteText: string;
367
+ try { remoteText = await httpsGet(remote); }
368
+ catch (e) { results.push(report(ctx, `Caveman ${name} update failed: ${(e as Error).message}`, 'warning')); continue; }
369
+
370
+ const remoteHash = shortHash(remoteText);
371
+ const oldLocal = await readTextIfExists(file);
372
+ const oldHash = oldLocal ? shortHash(oldLocal) : null;
373
+ if (oldHash === remoteHash) { results.push(`${name} up to date: ${remoteHash}`); continue; }
374
+
375
+ if (dryRun) {
376
+ results.push(`Caveman dry-run: would write ${file}\nold=${oldHash || '—'} new=${remoteHash}.`);
377
+ continue;
378
+ }
367
379
 
368
- try {
369
- await fs.mkdir(path.dirname(CAVEMAN_LOCAL), { recursive: true });
370
- const backupPath = `${CAVEMAN_LOCAL}.bak`;
371
- if (oldLocal !== null) await fs.writeFile(backupPath, oldLocal, 'utf8');
372
- await fs.writeFile(CAVEMAN_LOCAL, remote, 'utf8');
373
- const written = await fs.readFile(CAVEMAN_LOCAL, 'utf8');
374
- const writtenHash = shortHash(written);
375
- if (writtenHash !== remoteHash) {
376
- if (oldLocal !== null) await fs.writeFile(CAVEMAN_LOCAL, oldLocal, 'utf8');
377
- throw new Error(`written hash ${writtenHash} did not match remote ${remoteHash}${oldLocal !== null ? '; restored backup' : ''}`);
380
+ try {
381
+ await fs.mkdir(path.dirname(file), { recursive: true });
382
+ const backupPath = `${file}.bak`;
383
+ if (oldLocal !== null) await fs.writeFile(backupPath, oldLocal, 'utf8');
384
+ await fs.writeFile(file, remoteText, 'utf8');
385
+ const writtenHash = shortHash(await fs.readFile(file, 'utf8'));
386
+ if (writtenHash !== remoteHash) {
387
+ if (oldLocal !== null) await fs.writeFile(file, oldLocal, 'utf8');
388
+ throw new Error(`written hash ${writtenHash} did not match remote ${remoteHash}${oldLocal !== null ? '; restored backup' : ''}`);
389
+ }
390
+ results.push(`Caveman ${name} updated → ${file}\nold=${oldHash || '—'} new=${remoteHash}\nbackup=${oldLocal !== null ? backupPath : '—'}\n${RELOAD_MSG}`);
391
+ } catch (e) {
392
+ results.push(`Caveman ${name} update failed: ${(e as Error).message}`);
378
393
  }
379
- const m = `Caveman rule.md updated → ${CAVEMAN_LOCAL}\nold=${oldHash || '—'} new=${remoteHash}\nbackup=${oldLocal !== null ? backupPath : '—'}\n${RELOAD_MSG}`;
380
- notify(ctx, 'Caveman rule.md updated. ' + RELOAD_MSG, 'info');
381
- return m;
382
- } catch (e) {
383
- const m = `Caveman update failed: ${(e as Error).message}`;
384
- return report(ctx, m, 'warning');
385
394
  }
395
+ const summary = results.join('\n');
396
+ notify(ctx, `Caveman update finished. ${RELOAD_MSG}`, 'info');
397
+ return summary;
386
398
  }
387
399
 
388
400
  export default function aiAddonsUpdaterExtension(pi: AddonUpdaterPi): void {
@@ -6,20 +6,20 @@ import { injectPromptText, onHostEvent, setExtensionLabel } from '../shared/host
6
6
  import { announceStatus } from '../shared/status.js';
7
7
  import { readCavemanDefault } from '../shared/plugin-settings.js';
8
8
  const CAVERN_DIR = dirname(fileURLToPath(import.meta.url));
9
- const RULE_PATH = join(CAVERN_DIR, 'rule.md');
10
- // Fail loud: a wrong rule reads to the model as the real one.
11
- function readFullRule() {
9
+ // Upstream ships one skill per register: default, ultracave, megacave.
10
+ const RULE_FILES = { lite: 'rule.md', full: 'rule.md', ultra: 'rule-ultra.md', 'wenyan-lite': 'rule-wenyan.md', 'wenyan-full': 'rule-wenyan.md', 'wenyan-ultra': 'rule-wenyan.md' };
11
+ const DEFAULT_MODE = 'off';
12
+ function buildInstruction(mode) {
13
+ const name = RULE_FILES[mode] ?? RULE_FILES.full;
14
+ // Fail loud: a wrong rule reads to the model as the real one.
15
+ let body;
12
16
  try {
13
- return readFileSync(RULE_PATH, 'utf8');
17
+ body = readFileSync(join(CAVERN_DIR, name), 'utf8');
14
18
  }
15
19
  catch {
16
- return 'Caveman full requested, but rule.md is missing. Run: tersio doctor --fix extensions';
20
+ body = `Caveman ${mode} requested, but ${name} is missing. Run: tersio doctor --fix extensions`;
17
21
  }
18
- }
19
- const DEFAULT_MODE = 'off';
20
- // Upstream ships one SKILL.md; the level is a directive inside it, so swap only the header.
21
- function buildInstruction(mode) {
22
- return `Caveman ${mode} active for this session.\n${readFullRule()}`;
22
+ return `Caveman ${mode} active for this session.\n${body}`;
23
23
  }
24
24
  function resolveMode(entries, fallback = DEFAULT_MODE) {
25
25
  return lastCustomValue(entries, 'caveman-mode', (data) => normalizeMode('caveman', data?.mode)) ?? fallback;
@@ -8,20 +8,19 @@ import { readCavemanDefault } from '../shared/plugin-settings.ts';
8
8
  import type { ExtensionApi, ExtensionCtx, InputEvent, SessionEntry, SystemPromptEvent } from '../shared/types.ts';
9
9
 
10
10
  const CAVERN_DIR = dirname(fileURLToPath(import.meta.url));
11
- const RULE_PATH = join(CAVERN_DIR, 'rule.md');
12
-
13
- // Fail loud: a wrong rule reads to the model as the real one.
14
- function readFullRule(): string {
15
- try { return readFileSync(RULE_PATH, 'utf8'); } catch {
16
- return 'Caveman full requested, but rule.md is missing. Run: tersio doctor --fix extensions';
17
- }
18
- }
11
+ // Upstream ships one skill per register: default, ultracave, megacave.
12
+ const RULE_FILES = { lite: 'rule.md', full: 'rule.md', ultra: 'rule-ultra.md', 'wenyan-lite': 'rule-wenyan.md', 'wenyan-full': 'rule-wenyan.md', 'wenyan-ultra': 'rule-wenyan.md' };
19
13
 
20
14
  const DEFAULT_MODE = 'off';
21
15
 
22
- // Upstream ships one SKILL.md; the level is a directive inside it, so swap only the header.
23
16
  function buildInstruction(mode: string): string {
24
- return `Caveman ${mode} active for this session.\n${readFullRule()}`;
17
+ const name = RULE_FILES[mode as keyof typeof RULE_FILES] ?? RULE_FILES.full;
18
+ // Fail loud: a wrong rule reads to the model as the real one.
19
+ let body: string;
20
+ try { body = readFileSync(join(CAVERN_DIR, name), 'utf8'); } catch {
21
+ body = `Caveman ${mode} requested, but ${name} is missing. Run: tersio doctor --fix extensions`;
22
+ }
23
+ return `Caveman ${mode} active for this session.\n${body}`;
25
24
  }
26
25
 
27
26
  function resolveMode(entries: SessionEntry[] | null | undefined, fallback: string = DEFAULT_MODE): string {
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: ultracave
3
+ description: >
4
+ Caveman at maximum compression: fragments, one word when one word is enough,
5
+ each fact once. Invoke only with /ultracave or /caveman ultra. Stays on until
6
+ "stop caveman" or "normal mode".
7
+ disable-model-invocation: true
8
+ ---
9
+
10
+ # ultracave
11
+
12
+ Respond terse like smart caveman. All technical substance stay. Only fluff die. Then cut again.
13
+
14
+ Ultracave is caveman with the grammar stripped. Payload only.
15
+
16
+ ## Persistence
17
+
18
+ Every response, whole session, until "stop caveman" or "normal mode". Unsure? Still on. `/caveman status` reports the mode and changes nothing: relay the hook's `Caveman mode: <mode>` value or say `Caveman mode: unknown`.
19
+
20
+ ## Floor
21
+
22
+ Never cut: code, commands, paths, API names, error strings (verbatim). not/never/no/only/except. Numbers and units. The user's language. One term per thing. No invented abbreviations, no arrows.
23
+
24
+ ## Rules
25
+
26
+ ### 1. Fragments
27
+
28
+ Drop articles, copulas, connectives when order stays clear.
29
+
30
+ Bad: "The component re-renders because an inline object prop creates a new reference each render."
31
+ Good: "Inline object prop, new ref, re-render. `useMemo`."
32
+
33
+ ### 2. One word when one word is enough
34
+
35
+ Bad: "Yes, that should work, though you'll want a null check first."
36
+ Good: "Yes. Null check first."
37
+
38
+ ### 3. Each fact once
39
+
40
+ No restating, no summary after a list.
41
+
42
+ Good: "Pool reuses open DB connections. No per-request handshake."
43
+
44
+ ### 4. Cut conjunctions only when order survives
45
+
46
+ Bad: "Migrate table drop column backup first."
47
+ Good: "Back up first. Then migrate: drops column."
48
+
49
+ ### 5. Tool runs
50
+
51
+ One line in, one line out. Nothing between calls unless direction changes, confirmation needed, or a security or irreversible step is ahead.
52
+
53
+ ### 6. Never perform
54
+
55
+ No prefix, no announcement, no mangled verbs for flavor. Fragment not shorter than the sentence? Use the sentence.
56
+
57
+ ## When to break the rules
58
+
59
+ Plain prose, then resume: security warning. Irreversible action, confirm first. Any fragment with two readings. User confused. Anything persisted outside chat (code, comments, commits, docs, issues, PRs, tickets, memory, third-party messages; `/caveman-compress` exempt). Harness asks for a status line.
60
+
61
+ ## Pre-send check
62
+
63
+ Two readings? Full sentence. Negations present? Payload verbatim? Flavor word? Cut.
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: megacave
3
+ description: >
4
+ Caveman in Classical Chinese: 文言文 register, far fewer characters, technical
5
+ terms verbatim. Invoke only with /megacave or /caveman wenyan. Stays on until
6
+ "stop caveman" or "normal mode".
7
+ disable-model-invocation: true
8
+ ---
9
+
10
+ # megacave
11
+
12
+ 以文言答。技術之實皆存,唯贅言去之。
13
+
14
+ Megacave is caveman in Classical Chinese. 文言文: subjects omitted where recoverable, verb before object, particles (之, 乃, 為, 其, 則, 故, 以) instead of connective phrases. Fewer characters on screen. Token savings are small and noisy; never claim them.
15
+
16
+ ## Persistence
17
+
18
+ Every response, whole session, until "stop caveman" or "normal mode". Unsure? Still on. `/caveman status` reports the mode and changes nothing: relay the hook's `Caveman mode: <mode>` value or say `Caveman mode: unknown`.
19
+
20
+ ## Floor
21
+
22
+ Never cut or translate: code, commands, paths, API names, error strings, in their original script. Negation (不, 非, 勿, 未, 毋). Numbers and units, Arabic numerals. One term per thing. A clause ambiguous in 文言 becomes 白話.
23
+
24
+ ## Rules
25
+
26
+ ### 1. Classical register
27
+
28
+ 文言, not 白話. Verb before object. Subjects omitted where recoverable.
29
+
30
+ Bad: "你的組件每次渲染都會重新渲染,因為你每次都創建了新的對象引用。請用 useMemo 包起來。"
31
+ Good: "每繪新生對象參照,故重繪;以 `useMemo` 包之則免。"
32
+
33
+ ### 2. Particles carry structure
34
+
35
+ One particle, one relation.
36
+
37
+ Bad: "因為連接池會重複使用已經打開的連接,所以不需要每次請求都新建連接,這樣就省去了握手的開銷。"
38
+ Good: "池蓄已開之連,不逐請而新開,省握手之費。"
39
+
40
+ ### 3. Each fact once
41
+
42
+ Good: "新參照則重繪。`useMemo` 包之。"
43
+
44
+ ### 4. Payload in original script
45
+
46
+ Bad: "以記憶化鉤子包之。"
47
+ Good: "以 `useMemo` 包之。"
48
+
49
+ ### 5. Tool runs
50
+
51
+ One 文言 line in, one out. Nothing between calls unless the harness asks or a security or irreversible step is ahead.
52
+
53
+ ### 6. Never perform
54
+
55
+ No "文言模式啟", no modern answer plus classical copy. 文言 not shorter than 白話 for a sentence? Use 白話.
56
+
57
+ ## When to break the rules
58
+
59
+ 白話 or the user's language, full sentences, then resume: security warning. Irreversible action, confirm first. Any clause with two readings. User confused. Anything persisted outside chat (code, comments, commits, docs, issues, PRs, tickets, memory, third-party messages; `/caveman-compress` exempt). Harness asks for a status line.
60
+
61
+ ## Pre-send check
62
+
63
+ Identifiers verbatim, original script? Negations present? Two readings? 白話. Flavor character? Cut.
@@ -43,27 +43,14 @@ Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
43
43
  |-------|------------|
44
44
  | **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
45
45
  | **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations |
46
- | **ultra** | Strip conjunctions when cause-then-effect stay unambiguous. One word when one word enough. State each fact once. NO prose abbreviations (cfg/impl/req/res/fn/auth), NO arrows (X → Y) measured zero token saving under tokenizer, cost decode clarity. Code symbols, function names, API names, error strings: never touch |
47
- | **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
48
- | **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction chars, not tokens. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
49
- | **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
50
46
 
51
47
  Example "Why React component re-render?"
52
48
  - lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
53
49
  - full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
54
- - ultra: "Inline obj prop, new ref, re-render. `useMemo`."
55
- - wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
56
- - wenyan-full: "每繪新生對象參照,故重繪;以 useMemo 包之則免。"
57
- - wenyan-ultra: "新參照則重繪。useMemo 包之。"
58
50
 
59
51
  Example "Explain database connection pooling."
60
52
  - lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
61
53
  - full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
62
- - ultra: "Pool reuse open DB connections. No per-request handshake."
63
- - wenyan-full: "池蓄已開之連,不逐請而新開,省握手之費。"
64
- - wenyan-ultra: "池蓄連,免逐請新開,省握手。"
65
-
66
- Classical chars = wenyan modes only. Never swap a word to a classical char to shrink at non-wenyan levels.
67
54
 
68
55
  ## Auto-Clarity
69
56
 
@@ -18,7 +18,8 @@ export function homeDir() {
18
18
  return home && home.trim() !== '' ? home : os.homedir();
19
19
  }
20
20
  export const RTK_RELEASE_API = 'https://api.github.com/repos/rtk-ai/rtk/releases/latest';
21
- export const CAVEMAN_REMOTE_RULE = 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/skills/caveman/SKILL.md';
21
+ export const CAVEMAN_REMOTE_ULTRA = 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/skills/ultracave/SKILL.md';
22
+ export const CAVEMAN_REMOTE_WENYAN = 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/skills/megacave/SKILL.md';
22
23
  const RTK_PLATFORM_SPECS = {
23
24
  'win32/x64': { triple: 'x86_64-pc-windows-msvc', ext: '.zip', binary: 'rtk.exe' },
24
25
  'linux/x64': { triple: 'x86_64-unknown-linux-musl', ext: '.tar.gz', binary: 'rtk' },
@@ -27,7 +27,8 @@ export function homeDir(): string {
27
27
  }
28
28
 
29
29
  export const RTK_RELEASE_API = 'https://api.github.com/repos/rtk-ai/rtk/releases/latest';
30
- export const CAVEMAN_REMOTE_RULE = 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/skills/caveman/SKILL.md';
30
+ export const CAVEMAN_REMOTE_ULTRA = 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/skills/ultracave/SKILL.md';
31
+ export const CAVEMAN_REMOTE_WENYAN = 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/skills/megacave/SKILL.md';
31
32
 
32
33
  export interface RtkPlatformSpec {
33
34
  triple: string;
@@ -6,10 +6,10 @@ import comboToggleExtension from '../combo-toggle/index.js';
6
6
  import tersioCommandsExtension from '../tersio-commands/index.js';
7
7
  import { getSharedComboState } from '../shared/session-state.js';
8
8
  import { formatStatus } from '../shared/status.js';
9
- // Plain object, not Plugin.define: define is an identity wrapper, and a value
10
- // import would force every install to vendor the whole @opencode dependency tree.
9
+ import { resolveRtkBinary } from '../lib/utils.js';
10
+ // The npm name, so the plugin list reads the same as the OMP and pi installs.
11
11
  const plugin = {
12
- id: 'tersio',
12
+ id: '@krtclcdy/tersio',
13
13
  async setup(ctx) {
14
14
  // Entries are scoped per session; an unseen ID restores on its first hook.
15
15
  const sessions = new Map();
@@ -20,7 +20,10 @@ const plugin = {
20
20
  if (own.length)
21
21
  return own;
22
22
  // Pre-scoped installs kept one global list; adopt it once, then diverge.
23
+ // Clear it after adoption so it can never shadow saved defaults later.
23
24
  const legacy = (await ctx.storage.get('entries')) ?? [];
25
+ if (legacy.length)
26
+ void ctx.storage.set('entries', []).catch(() => { });
24
27
  return [...legacy];
25
28
  };
26
29
  const sessionEntriesOf = (sid) => {
@@ -185,6 +188,33 @@ const plugin = {
185
188
  for (const handler of inputHandlers)
186
189
  void handler({ text: event.prompt.text, source: 'interactive' });
187
190
  });
191
+ // rtk has its own OpenCode plugin; doing this here keeps one entry in the plugin list.
192
+ await ctx.tool.hook('execute.before', async (event) => {
193
+ const tool = String(event.tool ?? '').toLowerCase();
194
+ if (tool !== 'bash' && tool !== 'shell')
195
+ return;
196
+ const input = event.input;
197
+ if (typeof input?.command !== 'string' || !input.command)
198
+ return;
199
+ const rewritten = await rewriteCommand(input.command);
200
+ if (rewritten)
201
+ input.command = rewritten;
202
+ });
188
203
  },
189
204
  };
205
+ function rewriteCommand(command) {
206
+ const bin = resolveRtkBinary();
207
+ if (!bin)
208
+ return Promise.resolve(null);
209
+ return new Promise((resolve) => {
210
+ execFile(bin, ['rewrite', command], { timeout: 5000 }, (error, stdout) => {
211
+ // A kill or timeout means untrustworthy output; pass the command through.
212
+ const err = error;
213
+ if (err && (err.killed || err.signal))
214
+ return resolve(null);
215
+ const out = String(stdout).trim();
216
+ resolve(out && out !== command ? out : null);
217
+ });
218
+ });
219
+ }
190
220
  export default plugin;
@@ -7,6 +7,7 @@ import comboToggleExtension from '../combo-toggle/index.ts';
7
7
  import tersioCommandsExtension from '../tersio-commands/index.ts';
8
8
  import { getSharedComboState } from '../shared/session-state.ts';
9
9
  import { formatStatus } from '../shared/status.ts';
10
+ import { resolveRtkBinary } from '../lib/utils.ts';
10
11
  import type { ExtensionApi, ExtensionCtx, InputEvent, SessionEntry, SystemPromptEvent } from '../shared/types.ts';
11
12
 
12
13
  interface CommandConfig {
@@ -27,10 +28,9 @@ interface ToolConfig {
27
28
  ) => Promise<{ isError: boolean; content: { type: string; text: string }[]; details: Record<string, unknown> }>;
28
29
  }
29
30
 
30
- // Plain object, not Plugin.define: define is an identity wrapper, and a value
31
- // import would force every install to vendor the whole @opencode dependency tree.
31
+ // The npm name, so the plugin list reads the same as the OMP and pi installs.
32
32
  const plugin: Plugin.Plugin = {
33
- id: 'tersio',
33
+ id: '@krtclcdy/tersio',
34
34
  async setup(ctx) {
35
35
  // Entries are scoped per session; an unseen ID restores on its first hook.
36
36
  const sessions = new Map<string, SessionEntry[]>();
@@ -40,7 +40,9 @@ const plugin: Plugin.Plugin = {
40
40
  const own = ((await ctx.storage.get(`entries:${sid}`)) as SessionEntry[] | undefined) ?? [];
41
41
  if (own.length) return own;
42
42
  // Pre-scoped installs kept one global list; adopt it once, then diverge.
43
+ // Clear it after adoption so it can never shadow saved defaults later.
43
44
  const legacy = ((await ctx.storage.get('entries')) as SessionEntry[] | undefined) ?? [];
45
+ if (legacy.length) void ctx.storage.set('entries', []).catch(() => {});
44
46
  return [...legacy];
45
47
  };
46
48
  const sessionEntriesOf = (sid: string): SessionEntry[] => {
@@ -194,7 +196,31 @@ const plugin: Plugin.Plugin = {
194
196
  if (sid) await ensureSession(sid);
195
197
  for (const handler of inputHandlers) void handler({ text: event.prompt.text, source: 'interactive' });
196
198
  });
199
+
200
+ // rtk has its own OpenCode plugin; doing this here keeps one entry in the plugin list.
201
+ await ctx.tool.hook('execute.before', async (event) => {
202
+ const tool = String(event.tool ?? '').toLowerCase();
203
+ if (tool !== 'bash' && tool !== 'shell') return;
204
+ const input = event.input as { command?: unknown } | null | undefined;
205
+ if (typeof input?.command !== 'string' || !input.command) return;
206
+ const rewritten = await rewriteCommand(input.command);
207
+ if (rewritten) input.command = rewritten;
208
+ });
197
209
  },
198
210
  };
199
211
 
212
+ function rewriteCommand(command: string): Promise<string | null> {
213
+ const bin = resolveRtkBinary();
214
+ if (!bin) return Promise.resolve(null);
215
+ return new Promise((resolve) => {
216
+ execFile(bin, ['rewrite', command], { timeout: 5000 }, (error, stdout) => {
217
+ // A kill or timeout means untrustworthy output; pass the command through.
218
+ const err = error as { killed?: boolean; signal?: unknown } | null;
219
+ if (err && (err.killed || err.signal)) return resolve(null);
220
+ const out = String(stdout).trim();
221
+ resolve(out && out !== command ? out : null);
222
+ });
223
+ });
224
+ }
225
+
200
226
  export default plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krtclcdy/tersio",
3
- "version": "2.25.1",
3
+ "version": "2.25.2",
4
4
  "description": "✂️ Token saver and usage tracker for coding agents: terse Caveman replies, compact RTK shell output, lean Ponytail code calls, and a gain dashboard — one-command combo presets over one shared extension tree.",
5
5
  "keywords": [
6
6
  "token-saving",
@@ -119,7 +119,9 @@
119
119
  "dashboard/dist/**/*",
120
120
  "extensions/**/*.js",
121
121
  "extensions/**/*.ts",
122
- "extensions/caveman-session/rule.md"
122
+ "extensions/caveman-session/rule.md",
123
+ "extensions/caveman-session/rule-ultra.md",
124
+ "extensions/caveman-session/rule-wenyan.md"
123
125
  ],
124
126
  "scripts": {
125
127
  "clean": "rm -f tersio.js cli/*.js extensions/**/*.js && rm -rf dashboard/dist",