@krtclcdy/tersio 2.25.3 → 2.25.4

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/README.md CHANGED
@@ -39,7 +39,7 @@ opencode plugin add @krtclcdy/tersio
39
39
 
40
40
  Pick one install method per host, not both. Using both registers every command twice.
41
41
 
42
- All hosts load the same five extensions from the same sources and read their session-start defaults from `~/.tersio/settings.json`. All five load always.
42
+ All hosts load the same six extensions from the same sources and read their session-start defaults from `~/.tersio/settings.json`. All six load always.
43
43
 
44
44
  One-off use without installing:
45
45
 
@@ -155,7 +155,7 @@ Per host, written by `tersio install --host <omp|pi|opencode>`:
155
155
  | Host | Extensions | Ponytail |
156
156
  |---|---|---|
157
157
  | Oh My Pi | `~/.omp/agent/extensions/{caveman-session,rtk-session,combo-toggle,tersio-commands,ai-addons-updater}/` | nested plugin dependency under `~/.omp/plugins/node_modules/`, one Plugins row |
158
- | pi | `~/.pi/agent/extensions/{caveman-session,rtk-session,combo-toggle,tersio-commands,ai-addons-updater}/` — pi auto-discovers `<agent-dir>/extensions`, so there is nothing to register | `npm:@dietrichgebert/ponytail`, installed as a pi package |
158
+ | pi | `~/.pi/agent/extensions/{caveman-session,rtk-session,rtk-filter,combo-toggle,tersio-commands,ai-addons-updater}/` — pi auto-discovers `<agent-dir>/extensions`, so there is nothing to register | `npm:@dietrichgebert/ponytail`, installed as a pi package |
159
159
  | OpenCode | `~/.config/opencode/plugins/tersio/` — registered in `opencode.json` plugins, so there is nothing else to register | bundled with Tersio; no separate package |
160
160
 
161
161
  All three hosts carry the same `shared/` and `lib/` modules and the same mode extensions from the same sources.
@@ -146,7 +146,7 @@ async function fixRtk(binDir) {
146
146
  console.log(' Doctor --fix: repairing RTK binary + wiring');
147
147
  const binDest = path.join(binDir, RTK_BINARY_NAME);
148
148
  if (dryRun) {
149
- sayTagged(` [dry-run] would download a checksum-verified rtk to ${binDest} and wire it into OMP`);
149
+ sayTagged(` [dry-run] would download a checksum-verified rtk to ${binDest} and wire it into the installed hosts`);
150
150
  return;
151
151
  }
152
152
  const spec = rtkPlatformSpec();
@@ -252,7 +252,7 @@ async function runDoctorFix(tally) {
252
252
  console.log('\n Doctor --fix: repairs done — rechecking.');
253
253
  const again = await runDoctor(true);
254
254
  if (again.missing + again.warn === 0)
255
- console.log(' Doctor --fix: all checks pass. Restart OMP.');
255
+ console.log(' Doctor --fix: all checks pass. Restart your agent.');
256
256
  else
257
257
  console.log(` Doctor --fix: still ${again.missing} missing + ${again.warn} warn — rerun or repair manually.`);
258
258
  }
@@ -356,6 +356,12 @@ 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
+ // pi's built-in grep and glob never reach the bash hook, so this one filters their output.
360
+ async function stepRtkFilter(extDir, options) {
361
+ if (!options.quiet)
362
+ console.log(' RTK filter — install built-in tool output filtering');
363
+ await copySources(extDir, filesUnder('rtk-filter'), 'rtk-filter/index.ts', options);
364
+ }
359
365
  // rule.md ships with the package; the other bodies track upstream skills.
360
366
  // rule.md is a deliberate fork of upstream skills/caveman/SKILL.md; a blind sync would drop the mode switch line and the STE100 rules.
361
367
  const CAVEMAN_RULES = [
@@ -452,6 +458,7 @@ async function stepOpencode(options) {
452
458
  console.log(` OpenCode — write the plugin tree (${dir})`);
453
459
  await stepSharedSessionState(dir, options);
454
460
  await copySources(dir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
461
+ await stepRtkFilter(dir, options);
455
462
  await copySources(dir, filesUnder('caveman-session'), 'caveman-session/index.ts', options);
456
463
  await stepCombo(dir, options);
457
464
  await copySources(dir, filesUnder('tersio-commands'), 'tersio-commands/index.ts', options);
@@ -661,6 +668,7 @@ async function stepPiLayer(options) {
661
668
  await stepSharedSessionState(extDir, options);
662
669
  // OMP loads rtk-session from its plugin manifest, so only the pi tree copies it.
663
670
  await copySources(extDir, filesUnder('rtk-session'), 'rtk-session/index.ts', options);
671
+ await stepRtkFilter(extDir, options);
664
672
  await stepCaveman(extDir, options);
665
673
  await stepCombo(extDir, options);
666
674
  await stepTersioCommands(extDir, options);
@@ -700,14 +708,30 @@ async function stepPonytailForPi(agentDir, options) {
700
708
  }
701
709
  if (options.dryRun)
702
710
  return;
711
+ // Only when pi still declares the npm spec: with nothing to remove, `pi remove` still
712
+ // shells out to npm, which warns on stderr and exits non-zero.
713
+ const declared = await piDeclaredPackages(agentDir);
714
+ if (declared.some(isNpmPonytailPackage)) {
715
+ try {
716
+ await execNetwork('Removing the pi npm copy of Ponytail', 'pi', ['remove', 'npm:@dietrichgebert/ponytail'], { timeout: 120000 });
717
+ }
718
+ catch (e) {
719
+ sayTagged(` [warn] pi remove ponytail: ${shortError(e)}`);
720
+ }
721
+ }
722
+ await registerPiLocalPonytail(agentDir, dest, options);
723
+ }
724
+ // pi declares its package sources in settings.json; [] when the file is absent or unreadable.
725
+ async function piDeclaredPackages(agentDir) {
703
726
  try {
704
- await execNetwork('Removing the pi npm copy of Ponytail', 'pi', ['remove', 'npm:@dietrichgebert/ponytail'], { timeout: 120000 });
727
+ const settings = JSON.parse(await fs.readFile(path.join(agentDir, 'settings.json'), 'utf8'));
728
+ return Array.isArray(settings.packages) ? settings.packages : [];
705
729
  }
706
- catch (e) {
707
- sayTagged(` [warn] pi remove ponytail: ${shortError(e)}`);
730
+ catch {
731
+ return [];
708
732
  }
709
- await registerPiLocalPonytail(agentDir, dest, options);
710
733
  }
734
+ const isNpmPonytailPackage = (value) => typeof value === 'string' && value.startsWith('npm:@dietrichgebert/ponytail');
711
735
  // pi lists package sources in settings; swap the npm spec for the local dir.
712
736
  async function registerPiLocalPonytail(agentDir, dest, options) {
713
737
  const settingsPath = path.join(agentDir, 'settings.json');
@@ -719,7 +743,7 @@ async function registerPiLocalPonytail(agentDir, dest, options) {
719
743
  return;
720
744
  }
721
745
  const packages = (Array.isArray(settings.packages) ? settings.packages : []);
722
- const kept = packages.filter((p) => typeof p !== 'string' || !p.startsWith('npm:@dietrichgebert/ponytail'));
746
+ const kept = packages.filter((p) => !isNpmPonytailPackage(p));
723
747
  if (!kept.includes(dest))
724
748
  kept.push(dest);
725
749
  settings.packages = kept;
@@ -727,6 +751,10 @@ async function registerPiLocalPonytail(agentDir, dest, options) {
727
751
  if (!options.quiet)
728
752
  sayTagged(` [write] pi package source → ${dest}`);
729
753
  }
754
+ // Name the preset this run actually chose, instead of a hardcoded level.
755
+ function comboHint(host, comboDefault) {
756
+ return comboDefault === 'off' ? `\nDone — restart ${host}.` : `\nDone — restart ${host}, then /combo ${comboDefault}.`;
757
+ }
730
758
  async function runInstall() {
731
759
  // Without this guard a picked command re-enters here with command === null and loops straight back into the picker.
732
760
  if (command === null && tty() && !yes && !updatePromptDone) {
@@ -830,7 +858,7 @@ async function runInstall() {
830
858
  if (failures.length > 0)
831
859
  console.log(`\nDone with ${failures.length} failure(s) — see [fail] lines above.`);
832
860
  else
833
- console.log('\nDone — restart OpenCode, then /combo balanced.');
861
+ console.log(comboHint('OpenCode', profile.comboDefault));
834
862
  closeRL();
835
863
  return;
836
864
  }
@@ -841,7 +869,7 @@ async function runInstall() {
841
869
  if (failures.length > 0)
842
870
  console.log(`\nDone with ${failures.length} failure(s) — see [fail] lines above.`);
843
871
  else
844
- console.log('\nDone — restart pi, then /combo medium.');
872
+ console.log(comboHint('pi', profile.comboDefault));
845
873
  closeRL();
846
874
  return;
847
875
  }
@@ -851,6 +879,7 @@ async function runInstall() {
851
879
  await capture('ponytail', () => stepPonytail(OMP_PLUGINS_DIR, installOptions));
852
880
  await capture('rtk', () => stepRtk(BUN_BIN_DIR, installOptions));
853
881
  await capture('rtk session', () => stepRtkSession(userExtDir, installOptions));
882
+ await capture('rtk filter', () => stepRtkFilter(userExtDir, installOptions));
854
883
  await capture('caveman', () => stepCaveman(path.join(OMP_PLUGINS_DIR, 'node_modules', '@krtclcdy', 'tersio', 'extensions'), installOptions));
855
884
  await capture('combo', () => stepCombo(userExtDir, installOptions));
856
885
  await capture('commands', () => stepTersioCommands(userExtDir, installOptions));
@@ -862,7 +891,7 @@ async function runInstall() {
862
891
  console.log(` add-ons: ${failures.length} failed (${failures.join(', ')}) — see [fail] lines above`);
863
892
  }
864
893
  else {
865
- console.log(failures.length === 0 ? '\nDone — restart OMP, then /combo medium.' : `\nDone with ${failures.length} failure(s) — see [fail] lines above.`);
894
+ console.log(failures.length === 0 ? comboHint('OMP', profile.comboDefault) : `\nDone with ${failures.length} failure(s) — see [fail] lines above.`);
866
895
  }
867
896
  closeRL();
868
897
  }
@@ -24,6 +24,7 @@ export const TREE_FILES = [
24
24
  'caveman-session/rule-ultra.md',
25
25
  'caveman-session/rule-megacave.md',
26
26
  'rtk-session/index.ts',
27
+ 'rtk-filter/index.ts',
27
28
  'combo-toggle/index.ts',
28
29
  'tersio-commands/index.ts',
29
30
  'ai-addons-updater/index.ts',
@@ -6,7 +6,7 @@ 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_ULTRA, CAVEMAN_REMOTE_MEGACAVE, 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_MEGACAVE, RTK_RELEASE_API, fetchJson, findFile, findHoistedPackage, httpsGet, httpsDownload, 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';
@@ -18,8 +18,8 @@ const CAVEMAN_TRACKED = [
18
18
  { file: cavemanRule('rule-ultra.md'), remote: CAVEMAN_REMOTE_ULTRA },
19
19
  { file: cavemanRule('rule-megacave.md'), remote: CAVEMAN_REMOTE_MEGACAVE },
20
20
  ];
21
- const HOST_NAME = isPiProcess() ? 'Pi' : 'OMP';
22
- const RELOAD_MSG = `Reminder: restart ${HOST_NAME} (or reload extensions) for updates to take effect.`;
21
+ // Host-neutral: this updater runs inside pi, OMP, and OpenCode alike.
22
+ const RELOAD_MSG = 'Reminder: restart your agent (or reload extensions) for updates to take effect.';
23
23
  // Ponytail is a hoisted dependency, so the installed copy is found by walking up rather than by naming a host directory.
24
24
  function ponytailLocal() {
25
25
  return findHoistedPackage('@dietrichgebert/ponytail', EXTENSION_DIR, 'package.json');
@@ -9,7 +9,12 @@ const CAVERN_DIR = dirname(fileURLToPath(import.meta.url));
9
9
  // Upstream ships one skill per register: default, ultracave, megacave.
10
10
  const RULE_FILES = { lite: 'rule.md', full: 'rule.md', ultra: 'rule-ultra.md', 'megacave-lite': 'rule-megacave.md', 'megacave-full': 'rule-megacave.md', 'megacave-ultra': 'rule-megacave.md' };
11
11
  const DEFAULT_MODE = 'off';
12
+ // A rule file is immutable for the process, so read each level once; reinstalling needs a restart.
13
+ const instructionCache = new Map();
12
14
  function buildInstruction(mode) {
15
+ const cached = instructionCache.get(mode);
16
+ if (cached)
17
+ return cached;
13
18
  const name = RULE_FILES[mode] ?? RULE_FILES.full;
14
19
  // Fail loud: a wrong rule reads to the model as the real one.
15
20
  let body;
@@ -17,9 +22,11 @@ function buildInstruction(mode) {
17
22
  body = readFileSync(join(CAVERN_DIR, name), 'utf8');
18
23
  }
19
24
  catch {
20
- body = `Caveman ${mode} requested, but ${name} is missing. Run: tersio doctor --fix extensions`;
25
+ return `Caveman ${mode} requested, but ${name} is missing. Run: tersio doctor --fix extensions`;
21
26
  }
22
- return `Caveman ${mode} active for this session.\n${body}`;
27
+ const instruction = `Caveman ${mode} active for this session.\n${body}`;
28
+ instructionCache.set(mode, instruction);
29
+ return instruction;
23
30
  }
24
31
  function resolveMode(entries, fallback = DEFAULT_MODE) {
25
32
  return lastCustomValue(entries, 'caveman-mode', (data) => normalizeMode('caveman', data?.mode)) ?? fallback;
@@ -110,6 +117,6 @@ export default function cavemanSessionExtension(pi) {
110
117
  // The previous level's block is replaced, not stacked on.
111
118
  const stale = lastInjected;
112
119
  lastInjected = instruction;
113
- return injectPromptText(pi, event, instruction, stale);
120
+ return injectPromptText(pi, event, { text: instruction, staleMarker: stale, sectionTag: 'caveman' });
114
121
  });
115
122
  }
@@ -92,7 +92,7 @@ export default function comboToggleExtension(pi) {
92
92
  reconcile(ctx, resetEmpty);
93
93
  }
94
94
  pi.registerCommand?.('combo', {
95
- description: 'Toggle all 3 OMP add-ons at once. Usage: /combo <off|medium|balanced|max|status>',
95
+ description: 'Toggle all 3 Tersio modes at once. Usage: /combo <off|medium|balanced|max|status>',
96
96
  handler: async (args, ctx) => {
97
97
  listen(ctx);
98
98
  const arg = String(args || '').trim().toLowerCase();
@@ -169,7 +169,7 @@ export default function comboToggleExtension(pi) {
169
169
  // The previous level's block is replaced, not stacked on.
170
170
  const stale = lastInjected;
171
171
  lastInjected = instruction;
172
- return injectPromptText(pi, event, instruction, stale);
172
+ return injectPromptText(pi, event, { text: instruction, staleMarker: stale, sectionTag: 'ponytail' });
173
173
  });
174
174
  // Slash commands only; natural-language input caused accidental toggles and has no reload context.
175
175
  }
@@ -2,6 +2,7 @@
2
2
  import { execFile } from 'node:child_process';
3
3
  import cavemanSessionExtension from '../caveman-session/index.js';
4
4
  import rtkSessionExtension from '../rtk-session/index.js';
5
+ import rtkFilterExtension, { FILTER_BY_TOOL, MIN_FILTER_BYTES, filterThroughRtk, filterableText, rtkActive } from '../rtk-filter/index.js';
5
6
  import comboToggleExtension from '../combo-toggle/index.js';
6
7
  import tersioCommandsExtension from '../tersio-commands/index.js';
7
8
  import { getSharedComboState } from '../shared/session-state.js';
@@ -35,6 +36,7 @@ const plugin = {
35
36
  return list;
36
37
  };
37
38
  const persist = (sid) => {
39
+ // SAFETY: the storage API is generic over its payload; the value round-trips unchanged.
38
40
  void ctx.storage.set(`entries:${sid}`, sessionEntriesOf(sid)).catch(() => { });
39
41
  };
40
42
  const stubCtx = (sid) => ({
@@ -110,7 +112,7 @@ const plugin = {
110
112
  lifecycle.set(event, list);
111
113
  },
112
114
  };
113
- for (const extension of [cavemanSessionExtension, rtkSessionExtension, comboToggleExtension, tersioCommandsExtension])
115
+ for (const extension of [cavemanSessionExtension, rtkSessionExtension, rtkFilterExtension, comboToggleExtension, tersioCommandsExtension])
114
116
  extension(host);
115
117
  // The first hook of an unseen session runs its session_start handlers.
116
118
  const sidOf = (value) => typeof value === 'string' && value ? value : undefined;
@@ -200,8 +202,50 @@ const plugin = {
200
202
  if (rewritten)
201
203
  input.command = rewritten;
202
204
  });
205
+ // Built-in grep and glob output never reaches the bash hook; mutate result.content in place.
206
+ try {
207
+ await ctx.tool.hook('execute.after', async (event) => {
208
+ const hook = event;
209
+ if (hook.status !== 'completed')
210
+ return;
211
+ const filter = FILTER_BY_TOOL.get(String(hook.tool ?? '').toLowerCase());
212
+ if (!filter || !hook.result)
213
+ return;
214
+ const text = filterableText(hook.result.content);
215
+ if (text === null || text.length < MIN_FILTER_BYTES)
216
+ return;
217
+ // Read the mode from the payload's own session, not the last one to run a start hook.
218
+ const sid = typeof hook.sessionID === 'string' ? hook.sessionID : activeSid;
219
+ if (sid !== undefined)
220
+ await ensureSession(sid);
221
+ if (!rtkActive(sid === undefined ? undefined : sessionEntriesOf(sid)))
222
+ return;
223
+ const bin = resolveRtkBinary();
224
+ if (!bin)
225
+ return;
226
+ const filtered = await filterThroughRtk(execRtk, { bin, filter, text });
227
+ if (filtered)
228
+ hook.result.content = [{ type: 'text', text: filtered }];
229
+ });
230
+ }
231
+ catch {
232
+ // Hosts without the after-hook keep the unfiltered result.
233
+ }
203
234
  },
204
235
  };
236
+ /** execFile with the promise shape `filterThroughRtk` expects. */
237
+ function execRtk(cmd, args, opts) {
238
+ return new Promise((resolve) => {
239
+ execFile(cmd, args, { signal: opts?.signal, timeout: opts?.timeout }, (error, stdout) => {
240
+ const out = stdout ?? '';
241
+ if (!error)
242
+ return resolve({ stdout: out, code: 0 });
243
+ // A non-numeric code is a spawn failure (ENOENT, EACCES, killed) and must not read as success.
244
+ const raw = error.code;
245
+ resolve({ stdout: out, code: typeof raw === 'number' ? raw : 1 });
246
+ });
247
+ });
248
+ }
205
249
  function rewriteCommand(command) {
206
250
  const bin = resolveRtkBinary();
207
251
  if (!bin)
@@ -0,0 +1,87 @@
1
+ // pi's built-in grep and glob never reach the bash hook; `read` is absent because rtk has no filter for it.
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { resolveRtkBinary } from '../lib/utils.js';
6
+ import { readRtkDefault } from '../shared/plugin-settings.js';
7
+ import { getSharedComboState, lastCustomValue, sessionEntries } from '../shared/session-state.js';
8
+ /** Tool name to `rtk pipe --filter` name. Exported so the OpenCode host reuses one map. */
9
+ export const FILTER_BY_TOOL = new Map([
10
+ ['grep', 'grep'],
11
+ ['glob', 'find'],
12
+ ['find', 'find'],
13
+ ]);
14
+ /** Below this the rtk spawn costs more than the filter saves. */
15
+ export const MIN_FILTER_BYTES = 1200;
16
+ const FILTER_TIMEOUT_MS = 5000;
17
+ /** Absolute, so a minimal PATH in the host cannot break the spawn. */
18
+ const SHELL = '/bin/sh';
19
+ function modeOf(value) {
20
+ if (value === true)
21
+ return true;
22
+ if (value === false)
23
+ return false;
24
+ return null;
25
+ }
26
+ /** True when rtk is on for the session: a persisted entry wins, then a live switch, then the configured default. */
27
+ export function rtkActive(entries) {
28
+ const persisted = lastCustomValue(entries, 'rtk-mode', (data) => modeOf(data?.enabled));
29
+ if (persisted !== null)
30
+ return persisted;
31
+ return getSharedComboState().rtk === 'on' || readRtkDefault();
32
+ }
33
+ /** The text in a result's content parts, or null when the parts are not plain text. */
34
+ export function filterableText(parts) {
35
+ if (!Array.isArray(parts) || parts.length === 0)
36
+ return null;
37
+ // An image or a mixed result is not text to filter.
38
+ if (parts.some((part) => part?.type !== 'text'))
39
+ return null;
40
+ return parts.map((part) => part?.text ?? '').join('\n');
41
+ }
42
+ /** Pipe text through one rtk filter; null on any failure, so the caller keeps its original. */
43
+ export async function filterThroughRtk(exec, request) {
44
+ const { bin, filter, text, signal } = request;
45
+ let dir;
46
+ try {
47
+ dir = mkdtempSync(join(tmpdir(), 'tersio-rtk-'));
48
+ const file = join(dir, 'in.txt');
49
+ writeFileSync(file, text, 'utf8');
50
+ // `rtk pipe` reads stdin only and execFile has no stdin, so one redirect.
51
+ const result = await exec(SHELL, ['-c', `"${bin}" pipe -f ${filter} < "${file}"`], { signal, timeout: FILTER_TIMEOUT_MS });
52
+ if (result.code !== 0)
53
+ return null;
54
+ const out = result.stdout;
55
+ if (!out.trim() || out.length >= text.length)
56
+ return null;
57
+ return out;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ finally {
63
+ if (dir)
64
+ rmSync(dir, { recursive: true, force: true });
65
+ }
66
+ }
67
+ export default function rtkFilterExtension(pi) {
68
+ const bin = resolveRtkBinary();
69
+ const exec = pi.exec;
70
+ if (!bin || !exec)
71
+ return;
72
+ pi.on('tool_result', async (event, ctx) => {
73
+ const filter = FILTER_BY_TOOL.get(String(event?.toolName ?? '').toLowerCase());
74
+ if (!filter || event?.isError)
75
+ return;
76
+ const text = filterableText(event?.content);
77
+ if (text === null || text.length < MIN_FILTER_BYTES)
78
+ return;
79
+ if (!rtkActive(sessionEntries(ctx)))
80
+ return;
81
+ const filtered = await filterThroughRtk(exec, { bin, filter, text, signal: ctx?.signal });
82
+ if (!filtered)
83
+ return;
84
+ // Built-in grep and glob declare no output schema, so no structuredContent is lost.
85
+ return { content: [{ type: 'text', text: filtered }] };
86
+ });
87
+ }
@@ -22,7 +22,8 @@ function setRtkProcessEnabled(enabled) {
22
22
  else
23
23
  process.env.RTK_DISABLED = '1';
24
24
  }
25
- const RTK_PROMPT = `RTK guidance active. RTK automatically rewrites eligible Bash calls through the installed rtk hook. Prefer rtk for noisy shell output, but use exact raw output for state changes, checksums, patches, and diagnostics that need full bytes.`;
25
+ // rtk's awareness contract: state the output contract, never tell the model to type `rtk`.
26
+ const RTK_PROMPT = `RTK guidance active. Bash output, and large grep and glob results, are condensed to save tokens: every signal kept, costly noise dropped. Treat a condensed result as complete. Re-run something unfiltered only when its result is empty, garbled, or contradicts its exit code.`;
26
27
  export default function rtkSessionExtension(pi) {
27
28
  let enabled = DEFAULT_ENABLED;
28
29
  function fail(text) {
@@ -125,6 +126,6 @@ export default function rtkSessionExtension(pi) {
125
126
  // Skip when already present, so a host that re-presents the prompt cannot stack it.
126
127
  if (systemPromptIncludes(event.systemPrompt, RTK_PROMPT))
127
128
  return;
128
- return injectPromptText(pi, event, RTK_PROMPT);
129
+ return injectPromptText(pi, event, { text: RTK_PROMPT, sectionTag: 'rtk' });
129
130
  });
130
131
  }
@@ -16,15 +16,21 @@ const piAdapter = {
16
16
  return undefined;
17
17
  return options[shown.indexOf(picked)]?.label ?? picked;
18
18
  },
19
- inject(event, text, staleMarker) {
20
- const marker = staleMarker?.replace(/^[^\w]+/, '').trim();
19
+ inject(event, block) {
20
+ const marker = block.staleMarker?.replace(/^[^\w]+/, '').trim();
21
21
  const dropStale = (parts) => (marker ? parts.filter((part) => !part.includes(marker)) : parts);
22
22
  const options = event.systemPromptOptions;
23
+ // A named section is replaced in place; returning systemPrompt replaces the whole prompt.
24
+ const sections = options?.sections;
25
+ if (block.sectionTag && sections && typeof sections === 'object') {
26
+ sections[block.sectionTag] = block.text;
27
+ return undefined;
28
+ }
23
29
  if (options && typeof options.appendSystemPrompt === 'string') {
24
- options.appendSystemPrompt = dropStale([options.appendSystemPrompt]).concat(text).filter(Boolean).join('\n\n');
30
+ options.appendSystemPrompt = dropStale([options.appendSystemPrompt]).concat(block.text).filter(Boolean).join('\n\n');
25
31
  return undefined;
26
32
  }
27
- return { systemPrompt: `${dropStale(asPromptArray(event.systemPrompt)).join('\n\n')}\n\n${text}` };
33
+ return { systemPrompt: `${dropStale(asPromptArray(event.systemPrompt)).join('\n\n')}\n\n${block.text}` };
28
34
  },
29
35
  stringArray(_pi, name, spec) {
30
36
  const array = { type: 'array', items: { type: 'string' } };
@@ -43,10 +49,10 @@ const ompAdapter = {
43
49
  return undefined;
44
50
  return ui.select(title, options, dialogOptions);
45
51
  },
46
- inject(event, text, staleMarker) {
47
- const marker = staleMarker?.replace(/^[^\w]+/, '').trim();
52
+ inject(event, block) {
53
+ const marker = block.staleMarker?.replace(/^[^\w]+/, '').trim();
48
54
  const dropStale = (parts) => (marker ? parts.filter((part) => !part.includes(marker)) : parts);
49
- return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), text] };
55
+ return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), block.text] };
50
56
  },
51
57
  stringArray(pi, name, spec) {
52
58
  const { z } = pi.zod;
@@ -63,10 +69,10 @@ const opencodeAdapter = {
63
69
  return undefined;
64
70
  return ui.select(title, options, dialogOptions);
65
71
  },
66
- inject(event, text, staleMarker) {
67
- const marker = staleMarker?.replace(/^[^\w]+/, '').trim();
72
+ inject(event, block) {
73
+ const marker = block.staleMarker?.replace(/^[^\w]+/, '').trim();
68
74
  const dropStale = (parts) => (marker ? parts.filter((part) => !part.includes(marker)) : parts);
69
- return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), text] };
75
+ return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), block.text] };
70
76
  },
71
77
  stringArray(_pi, name, spec) {
72
78
  const array = { type: 'array', items: { type: 'string' } };
@@ -99,8 +105,8 @@ export function onHostEvent(pi, event, handler) {
99
105
  export async function hostSelect(pi, ui, title, options, dialogOptions) {
100
106
  return adapterFor(pi).select(ui, title, options, dialogOptions);
101
107
  }
102
- export function injectPromptText(pi, event, text, staleMarker) {
103
- return adapterFor(pi).inject(event, text, staleMarker);
108
+ export function injectPromptText(pi, event, block) {
109
+ return adapterFor(pi).inject(event, block);
104
110
  }
105
111
  export function stringArrayToolParams(pi, name, spec = {}) {
106
112
  return adapterFor(pi).stringArray(pi, name, spec);
@@ -76,7 +76,9 @@ export function normalizeInputCommand(value) {
76
76
  return String(value || '').trim().toLowerCase().replace(/[.!?\s]+$/, '');
77
77
  }
78
78
  export function asPromptArray(systemPrompt) {
79
- return Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt];
79
+ const parts = Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt];
80
+ // OMP can hand back a hole in the array; a non-string breaks every marker check.
81
+ return parts.filter((part) => typeof part === 'string');
80
82
  }
81
83
  export function systemPromptIncludes(systemPrompt, marker) {
82
84
  return asPromptArray(systemPrompt).some((prompt) => typeof prompt === 'string' && prompt.includes(marker));
@@ -18,7 +18,6 @@ import {
18
18
  findHoistedPackage,
19
19
  httpsGet,
20
20
  httpsDownload,
21
- isPiProcess,
22
21
  sha256Hex,
23
22
  parseChecksum,
24
23
  normalizeRtkVersion,
@@ -39,8 +38,8 @@ const CAVEMAN_TRACKED: ReadonlyArray<{ file: string; remote: string }> = [
39
38
  { file: cavemanRule('rule-ultra.md'), remote: CAVEMAN_REMOTE_ULTRA },
40
39
  { file: cavemanRule('rule-megacave.md'), remote: CAVEMAN_REMOTE_MEGACAVE },
41
40
  ];
42
- const HOST_NAME = isPiProcess() ? 'Pi' : 'OMP';
43
- const RELOAD_MSG = `Reminder: restart ${HOST_NAME} (or reload extensions) for updates to take effect.`;
41
+ // Host-neutral: this updater runs inside pi, OMP, and OpenCode alike.
42
+ const RELOAD_MSG = 'Reminder: restart your agent (or reload extensions) for updates to take effect.';
44
43
 
45
44
  // Ponytail is a hoisted dependency, so the installed copy is found by walking up rather than by naming a host directory.
46
45
  function ponytailLocal(): string | null {
@@ -13,14 +13,21 @@ const RULE_FILES = { lite: 'rule.md', full: 'rule.md', ultra: 'rule-ultra.md', '
13
13
 
14
14
  const DEFAULT_MODE = 'off';
15
15
 
16
+ // A rule file is immutable for the process, so read each level once; reinstalling needs a restart.
17
+ const instructionCache = new Map<string, string>();
18
+
16
19
  function buildInstruction(mode: string): string {
20
+ const cached = instructionCache.get(mode);
21
+ if (cached) return cached;
17
22
  const name = RULE_FILES[mode as keyof typeof RULE_FILES] ?? RULE_FILES.full;
18
23
  // Fail loud: a wrong rule reads to the model as the real one.
19
24
  let body: string;
20
25
  try { body = readFileSync(join(CAVERN_DIR, name), 'utf8'); } catch {
21
- body = `Caveman ${mode} requested, but ${name} is missing. Run: tersio doctor --fix extensions`;
26
+ return `Caveman ${mode} requested, but ${name} is missing. Run: tersio doctor --fix extensions`;
22
27
  }
23
- return `Caveman ${mode} active for this session.\n${body}`;
28
+ const instruction = `Caveman ${mode} active for this session.\n${body}`;
29
+ instructionCache.set(mode, instruction);
30
+ return instruction;
24
31
  }
25
32
 
26
33
  function resolveMode(entries: SessionEntry[] | null | undefined, fallback: string = DEFAULT_MODE): string {
@@ -118,6 +125,6 @@ export default function cavemanSessionExtension(pi: ExtensionApi): void {
118
125
  // The previous level's block is replaced, not stacked on.
119
126
  const stale = lastInjected;
120
127
  lastInjected = instruction;
121
- return injectPromptText(pi, event, instruction, stale);
128
+ return injectPromptText(pi, event, { text: instruction, staleMarker: stale, sectionTag: 'caveman' });
122
129
  });
123
130
  }
@@ -113,7 +113,7 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
113
113
  }
114
114
 
115
115
  pi.registerCommand?.('combo', {
116
- description: 'Toggle all 3 OMP add-ons at once. Usage: /combo <off|medium|balanced|max|status>',
116
+ description: 'Toggle all 3 Tersio modes at once. Usage: /combo <off|medium|balanced|max|status>',
117
117
  handler: async (args, ctx) => {
118
118
  listen(ctx);
119
119
  const arg = String(args || '').trim().toLowerCase();
@@ -197,7 +197,7 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
197
197
  // The previous level's block is replaced, not stacked on.
198
198
  const stale = lastInjected;
199
199
  lastInjected = instruction;
200
- return injectPromptText(pi, event, instruction, stale);
200
+ return injectPromptText(pi, event, { text: instruction, staleMarker: stale, sectionTag: 'ponytail' });
201
201
  });
202
202
 
203
203
  // Slash commands only; natural-language input caused accidental toggles and has no reload context.
@@ -3,6 +3,7 @@ import { execFile } from 'node:child_process';
3
3
  import type { Plugin } from '@opencode/plugin';
4
4
  import cavemanSessionExtension from '../caveman-session/index.ts';
5
5
  import rtkSessionExtension from '../rtk-session/index.ts';
6
+ import rtkFilterExtension, { FILTER_BY_TOOL, MIN_FILTER_BYTES, filterThroughRtk, filterableText, rtkActive } from '../rtk-filter/index.ts';
6
7
  import comboToggleExtension from '../combo-toggle/index.ts';
7
8
  import tersioCommandsExtension from '../tersio-commands/index.ts';
8
9
  import { getSharedComboState } from '../shared/session-state.ts';
@@ -54,6 +55,7 @@ const plugin: Plugin.Plugin = {
54
55
  return list;
55
56
  };
56
57
  const persist = (sid: string): void => {
58
+ // SAFETY: the storage API is generic over its payload; the value round-trips unchanged.
57
59
  void ctx.storage.set(`entries:${sid}`, sessionEntriesOf(sid) as unknown as never).catch(() => {});
58
60
  };
59
61
  const stubCtx = (sid?: string): ExtensionCtx => ({
@@ -120,7 +122,7 @@ const plugin: Plugin.Plugin = {
120
122
  },
121
123
  };
122
124
 
123
- for (const extension of [cavemanSessionExtension, rtkSessionExtension, comboToggleExtension, tersioCommandsExtension]) extension(host);
125
+ for (const extension of [cavemanSessionExtension, rtkSessionExtension, rtkFilterExtension, comboToggleExtension, tersioCommandsExtension]) extension(host);
124
126
 
125
127
  // The first hook of an unseen session runs its session_start handlers.
126
128
  const sidOf = (value: unknown): string | undefined =>
@@ -206,9 +208,44 @@ const plugin: Plugin.Plugin = {
206
208
  const rewritten = await rewriteCommand(input.command);
207
209
  if (rewritten) input.command = rewritten;
208
210
  });
211
+
212
+ // Built-in grep and glob output never reaches the bash hook; mutate result.content in place.
213
+ try {
214
+ await ctx.tool.hook('execute.after', async (event) => {
215
+ const hook = event as { tool?: unknown; status?: unknown; sessionID?: unknown; result?: { content?: unknown } };
216
+ if (hook.status !== 'completed') return;
217
+ const filter = FILTER_BY_TOOL.get(String(hook.tool ?? '').toLowerCase());
218
+ if (!filter || !hook.result) return;
219
+ const text = filterableText(hook.result.content);
220
+ if (text === null || text.length < MIN_FILTER_BYTES) return;
221
+ // Read the mode from the payload's own session, not the last one to run a start hook.
222
+ const sid = typeof hook.sessionID === 'string' ? hook.sessionID : activeSid;
223
+ if (sid !== undefined) await ensureSession(sid);
224
+ if (!rtkActive(sid === undefined ? undefined : sessionEntriesOf(sid))) return;
225
+ const bin = resolveRtkBinary();
226
+ if (!bin) return;
227
+ const filtered = await filterThroughRtk(execRtk, { bin, filter, text });
228
+ if (filtered) hook.result.content = [{ type: 'text', text: filtered }];
229
+ });
230
+ } catch {
231
+ // Hosts without the after-hook keep the unfiltered result.
232
+ }
209
233
  },
210
234
  };
211
235
 
236
+ /** execFile with the promise shape `filterThroughRtk` expects. */
237
+ function execRtk(cmd: string, args: string[], opts?: { signal?: AbortSignal; timeout?: number }): Promise<{ stdout: string; code: number }> {
238
+ return new Promise((resolve) => {
239
+ execFile(cmd, args, { signal: opts?.signal, timeout: opts?.timeout }, (error, stdout) => {
240
+ const out = stdout ?? '';
241
+ if (!error) return resolve({ stdout: out, code: 0 });
242
+ // A non-numeric code is a spawn failure (ENOENT, EACCES, killed) and must not read as success.
243
+ const raw = (error as { code?: unknown }).code;
244
+ resolve({ stdout: out, code: typeof raw === 'number' ? raw : 1 });
245
+ });
246
+ });
247
+ }
248
+
212
249
  function rewriteCommand(command: string): Promise<string | null> {
213
250
  const bin = resolveRtkBinary();
214
251
  if (!bin) return Promise.resolve(null);