@krtclcdy/tersio 2.25.2 → 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.
Files changed (57) hide show
  1. package/README.md +3 -3
  2. package/dashboard/dist/index.html +1 -1
  3. package/{cli → dist/cli}/common.js +4 -3
  4. package/{cli → dist/cli}/dashboard.js +30 -13
  5. package/{cli → dist/cli}/doctor-fix.js +2 -4
  6. package/{cli → dist/cli}/doctor.js +13 -6
  7. package/{cli → dist/cli}/install.js +56 -15
  8. package/{cli → dist/cli}/manifest.js +4 -2
  9. package/{cli → dist/cli}/settings.js +5 -23
  10. package/{cli → dist/cli}/uninstall.js +1 -1
  11. package/{cli → dist/cli}/update.js +4 -5
  12. package/{extensions → dist/extensions}/ai-addons-updater/index.js +4 -4
  13. package/{extensions → dist/extensions}/caveman-session/index.js +12 -5
  14. package/dist/extensions/caveman-session/rule-ultra.md +63 -0
  15. package/dist/extensions/caveman-session/rule.md +79 -0
  16. package/{extensions → dist/extensions}/combo-toggle/index.js +39 -29
  17. package/{extensions → dist/extensions}/lib/utils.js +26 -1
  18. package/{extensions → dist/extensions}/opencode/server.js +45 -1
  19. package/dist/extensions/rtk-filter/index.js +87 -0
  20. package/{extensions → dist/extensions}/rtk-session/index.js +3 -2
  21. package/{extensions → dist/extensions}/shared/host.js +18 -12
  22. package/{extensions → dist/extensions}/shared/plugin-settings.js +1 -1
  23. package/{extensions → dist/extensions}/shared/session-state.js +17 -3
  24. package/{extensions → dist/extensions}/shared/status.js +7 -1
  25. package/{tersio.js → dist/tersio.js} +1 -1
  26. package/extensions/ai-addons-updater/index.ts +4 -5
  27. package/extensions/caveman-session/index.ts +12 -5
  28. package/extensions/caveman-session/rule-megacave.md +63 -0
  29. package/extensions/caveman-session/rule.md +4 -2
  30. package/extensions/combo-toggle/index.ts +37 -29
  31. package/extensions/lib/utils.ts +22 -1
  32. package/extensions/opencode/server.ts +38 -1
  33. package/extensions/rtk-filter/index.ts +90 -0
  34. package/extensions/rtk-session/index.ts +3 -2
  35. package/extensions/shared/host.ts +26 -13
  36. package/extensions/shared/plugin-settings.ts +1 -1
  37. package/extensions/shared/session-state.ts +18 -3
  38. package/extensions/shared/status.ts +8 -1
  39. package/extensions/shared/types.ts +10 -2
  40. package/package.json +16 -15
  41. package/extensions/opencode/index.js +0 -104
  42. /package/{cli → dist/cli}/banner.js +0 -0
  43. /package/{cli → dist/cli}/currency.js +0 -0
  44. /package/{cli → dist/cli}/hosts.js +0 -0
  45. /package/{cli → dist/cli}/interactive.js +0 -0
  46. /package/{cli → dist/cli}/profile.js +0 -0
  47. /package/{cli → dist/cli}/reset.js +0 -0
  48. /package/{cli → dist/cli}/rtk-wiring.js +0 -0
  49. /package/{cli → dist/cli}/usage.js +0 -0
  50. /package/{extensions/caveman-session/rule-wenyan.md → dist/extensions/caveman-session/rule-megacave.md} +0 -0
  51. /package/{extensions → dist/extensions}/shared/carbon.js +0 -0
  52. /package/{extensions → dist/extensions}/shared/pricing.js +0 -0
  53. /package/{extensions → dist/extensions}/shared/rtk-gain.js +0 -0
  54. /package/{extensions → dist/extensions}/shared/types.js +0 -0
  55. /package/{extensions → dist/extensions}/shared/usage-ledger.js +0 -0
  56. /package/{extensions → dist/extensions}/shared/usage-store.js +0 -0
  57. /package/{extensions → dist/extensions}/tersio-commands/index.js +0 -0
@@ -9,18 +9,25 @@ import type { ExtensionApi, ExtensionCtx, InputEvent, SessionEntry, SystemPrompt
9
9
 
10
10
  const CAVERN_DIR = dirname(fileURLToPath(import.meta.url));
11
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' };
12
+ 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' };
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 {
@@ -67,7 +74,7 @@ export default function cavemanSessionExtension(pi: ExtensionApi): void {
67
74
  return;
68
75
  }
69
76
  if (!setMode(arg, ctx)) {
70
- ctx?.ui?.notify?.('Usage: /caveman [lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra|off|status]', 'warning');
77
+ ctx?.ui?.notify?.('Usage: /caveman [lite|full|ultra|megacave-lite|megacave-full|megacave-ultra|off|status]', 'warning');
71
78
  }
72
79
  },
73
80
  });
@@ -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
  }
@@ -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.
@@ -2,7 +2,7 @@
2
2
  name: caveman
3
3
  description: >
4
4
  Ultra-compressed communication mode that cuts output tokens while keeping
5
- technical accuracy. Levels: lite, full, ultra and the wenyan variants. Use for
5
+ technical accuracy. Levels: lite, full, ultra and the megacave variants. Use for
6
6
  /caveman, "caveman mode", "talk like caveman", "be brief" or "less tokens".
7
7
  ---
8
8
 
@@ -12,7 +12,7 @@ Respond terse like smart caveman. All technical substance stay. Only fluff die.
12
12
 
13
13
  Default style for this whole session, every response, until user say "stop caveman" or "normal mode". Keep terse on long sessions no filler drift.
14
14
 
15
- Default: **full**. Switch: `/caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra|off`.
15
+ Default: **full**. Switch: `/caveman lite|full|ultra|megacave-lite|megacave-full|megacave-ultra|off`.
16
16
 
17
17
  ## Rules
18
18
 
@@ -28,6 +28,8 @@ Tool calls: fire direct. No preamble, plan, or progress note before or between c
28
28
 
29
29
  Follow explicit reply-language instructions from the user or project. Otherwise preserve the user's dominant language. Never switch because of example text or multilingual context elsewhere. Compress the style, not the language. Every emitted line in that language openings, pre-tool status lines, all not just final reply. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim unless user explicitly ask for translation.
30
30
 
31
+ Ownership: caveman owns reply compression, so project or host prose rules that set article use, sentence length, or filler never override it. Treat those as applying to persisted artifacts instead: code, comments, commits, docs, and issue text. Any project rule naming caveman or tersio directly wins over both.
32
+
31
33
  'Drop articles' = article languages only. Where small markers carry case/role (particles, postpositions), keep them grammar, not filler; compress politeness/filler instead.
32
34
 
33
35
  Answer directly in this style. Skip "caveman mode on", "me caveman think", "Caveman:" prefix or recap redundant with the reply itself. No normal answer plus caveman duplicate. User ask what mode is → say so plainly.
@@ -13,15 +13,18 @@ import {
13
13
  sessionEntries,
14
14
  setSharedComboLevel,
15
15
  setSharedComboListener,
16
- setSharedComboMode,
16
+ setSharedComboModes,
17
17
  systemPromptIncludes,
18
18
  } from '../shared/session-state.ts';
19
19
  import { hostSelect, injectPromptText, onHostEvent, setExtensionLabel } from '../shared/host.ts';
20
20
  import { announceStatus } from '../shared/status.ts';
21
21
  import {
22
22
  isComboSetupComplete,
23
+ readCavemanDefault,
23
24
  readComboDefault,
25
+ readPluginSettings,
24
26
  readPonytailDefault,
27
+ readRtkDefault,
25
28
  saveComboSetup,
26
29
  } from '../shared/plugin-settings.ts';
27
30
  import { findHoistedPackage, isPiProcess } from '../lib/utils.ts';
@@ -64,7 +67,8 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
64
67
 
65
68
  let setupPrompted = false;
66
69
  let lastInjected: string | undefined = undefined;
67
-
70
+ // opencode has no UI event stream, so only its empty branch reconciles.
71
+ const OPENCODE = pi.hostId === 'opencode';
68
72
 
69
73
  // Siblings restore from these entries, so the fallback must write them too.
70
74
  function persistPreset(level: string): void {
@@ -74,23 +78,42 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
74
78
  pi.appendEntry?.('ponytail-mode', { mode: modes.ponytail });
75
79
  pi.appendEntry?.('combo-level', { level });
76
80
  }
81
+ function restoreConfiguredDefaults(): void {
82
+ const settings = readPluginSettings();
83
+ const hasModeDefaults = ['cavemanDefault', 'rtkDefault', 'ponytailDefault'].some((key) => key in settings);
84
+ const combo = readComboDefault();
85
+ const preset = COMBO_LEVELS[combo];
86
+ const modes = hasModeDefaults
87
+ ? { caveman: readCavemanDefault(), rtk: readRtkDefault() ? 'on' : 'off', ponytail: readPonytailDefault() }
88
+ : preset;
89
+ setSharedComboModes(modes);
90
+ if (modes.caveman === 'off' && modes.rtk === 'off' && modes.ponytail === 'off') return;
91
+ pi.appendEntry?.('caveman-mode', { mode: modes.caveman });
92
+ pi.appendEntry?.('rtk-mode', { enabled: modes.rtk === 'on' });
93
+ pi.appendEntry?.('ponytail-mode', { mode: modes.ponytail });
94
+ if (preset && modes.caveman === preset.caveman && modes.rtk === preset.rtk && modes.ponytail === preset.ponytail) {
95
+ pi.appendEntry?.('combo-level', { level: combo });
96
+ }
97
+ }
98
+
77
99
 
78
- function reconcile(ctx?: ExtensionCtx): Readonly<ComboState> {
79
- if (!ctx?.hasUI) return getSharedComboState();
80
- return reconcileSharedComboEntries(sessionEntries(ctx));
100
+ function reconcile(ctx?: ExtensionCtx, resetEmpty = false): Readonly<ComboState> {
101
+ if (!ctx?.hasUI && !(resetEmpty && OPENCODE)) return getSharedComboState();
102
+ const entries = sessionEntries(ctx);
103
+ if (!resetEmpty && entries.length === 0) return getSharedComboState();
104
+ return reconcileSharedComboEntries(entries);
81
105
  }
82
106
  function listen(ctx?: ExtensionCtx): void {
83
107
  if (!ctx?.hasUI) return;
84
108
  setSharedComboListener('combo', (state) => { announceStatus(ctx); return state; });
85
109
  }
86
-
87
- function track(ctx?: ExtensionCtx): void {
110
+ function track(ctx?: ExtensionCtx, resetEmpty = false): void {
88
111
  listen(ctx);
89
- if (ctx?.hasUI) reconcile(ctx);
112
+ if (ctx?.hasUI || (resetEmpty && OPENCODE)) reconcile(ctx, resetEmpty);
90
113
  }
91
114
 
92
115
  pi.registerCommand?.('combo', {
93
- 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>',
94
117
  handler: async (args, ctx) => {
95
118
  listen(ctx);
96
119
  const arg = String(args || '').trim().toLowerCase();
@@ -145,26 +168,11 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
145
168
  }
146
169
 
147
170
  pi.on('session_start', async (_event, ctx) => {
148
- track(ctx);
149
- await runFirstRunSetup(ctx);
150
- // The configured default applies only when no persisted mode state exists.
171
+ track(ctx, true);
172
+ if (!isComboSetupComplete() && ctx?.hasUI && ctx.ui?.select) await runFirstRunSetup(ctx);
173
+ // The configured defaults apply when a fresh session has no persisted mode state.
151
174
  const entries = sessionEntries(ctx);
152
- const persisted = hasModeState(entries);
153
- if (getSharedComboState().level === 'off' && !persisted) {
154
- const fallback = readComboDefault();
155
- if (fallback !== 'off') {
156
- persistPreset(fallback);
157
- setSharedComboLevel(fallback);
158
- }
159
- }
160
- // No sibling restores a standalone ponytail default, so apply it here; skip it when the active preset already writes the same mode.
161
- if (!entries.some((e) => e?.type === 'custom' && e.customType === 'ponytail-mode')) {
162
- const ponytailFallback = readPonytailDefault();
163
- if (ponytailFallback !== 'off' && ponytailFallback !== getSharedComboState().ponytail) {
164
- pi.appendEntry?.('ponytail-mode', { mode: ponytailFallback });
165
- setSharedComboMode('ponytail', ponytailFallback);
166
- }
167
- }
175
+ if (!hasModeState(entries) && (ctx?.hasUI || OPENCODE)) restoreConfiguredDefaults();
168
176
  // Announced last, so any default above is reflected.
169
177
  announceStatus(ctx);
170
178
  });
@@ -189,7 +197,7 @@ export default function comboToggleExtension(pi: ExtensionApi): void {
189
197
  // The previous level's block is replaced, not stacked on.
190
198
  const stale = lastInjected;
191
199
  lastInjected = instruction;
192
- return injectPromptText(pi, event, instruction, stale);
200
+ return injectPromptText(pi, event, { text: instruction, staleMarker: stale, sectionTag: 'ponytail' });
193
201
  });
194
202
 
195
203
  // Slash commands only; natural-language input caused accidental toggles and has no reload context.
@@ -28,7 +28,17 @@ export function homeDir(): string {
28
28
 
29
29
  export const RTK_RELEASE_API = 'https://api.github.com/repos/rtk-ai/rtk/releases/latest';
30
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
+ export const CAVEMAN_REMOTE_MEGACAVE = 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/skills/megacave/SKILL.md';
32
+
33
+ // A fetch that returns an error page or a truncated body must not reach the extension tree.
34
+ export function ruleBodyProblem(text: string): string | null {
35
+ const trimmed = text.trim();
36
+ if (trimmed === '') return 'empty body';
37
+ if (/^\s*<(!doctype|html)/i.test(trimmed)) return 'HTML page, not markdown';
38
+ if (/^\s*404\b/im.test(trimmed)) return 'upstream 404 page';
39
+ if (trimmed.length < 800) return `truncated (${trimmed.length} chars)`;
40
+ return null;
41
+ }
32
42
 
33
43
  export interface RtkPlatformSpec {
34
44
  triple: string;
@@ -83,6 +93,17 @@ export function rtkPlatformSpec(platform: string = process.platform, arch: strin
83
93
  return RTK_PLATFORM_SPECS[`${platform}/${arch}`] || null;
84
94
  }
85
95
 
96
+ // Walk up to the nearest package.json; identical for the source and compiled trees.
97
+ export function findPackageRoot(fromDir: string): string {
98
+ let dir = fromDir;
99
+ for (;;) {
100
+ if (existsSync(path.join(dir, 'package.json'))) return dir;
101
+ const parent = path.dirname(dir);
102
+ if (parent === dir) throw new Error('package root not found');
103
+ dir = parent;
104
+ }
105
+ }
106
+
86
107
  // The 3xx+location redirect rule shared by httpsGet/httpsDownload.
87
108
  function redirectNext(res: { statusCode?: number; headers: { location?: string } }, url: string): string | null {
88
109
  if (res.statusCode !== undefined && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
@@ -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);
@@ -0,0 +1,90 @@
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.ts';
6
+ import { readRtkDefault } from '../shared/plugin-settings.ts';
7
+ import { getSharedComboState, lastCustomValue, sessionEntries } from '../shared/session-state.ts';
8
+ import type { ExtensionApi, ExtensionCtx, SessionEntry, ToolResultEvent } from '../shared/types.ts';
9
+
10
+ /** Tool name to `rtk pipe --filter` name. Exported so the OpenCode host reuses one map. */
11
+ export const FILTER_BY_TOOL = new Map([
12
+ ['grep', 'grep'],
13
+ ['glob', 'find'],
14
+ ['find', 'find'],
15
+ ]);
16
+
17
+ /** Below this the rtk spawn costs more than the filter saves. */
18
+ export const MIN_FILTER_BYTES = 1200;
19
+ const FILTER_TIMEOUT_MS = 5000;
20
+
21
+ /** Absolute, so a minimal PATH in the host cannot break the spawn. */
22
+ const SHELL = '/bin/sh';
23
+
24
+ export type FilterExec = (
25
+ cmd: string,
26
+ args: string[],
27
+ opts?: { signal?: AbortSignal; cwd?: string; timeout?: number },
28
+ ) => Promise<{ stdout: string; code: number }>;
29
+
30
+ function modeOf(value: unknown): boolean | null {
31
+ if (value === true) return true;
32
+ if (value === false) return false;
33
+ return null;
34
+ }
35
+
36
+ /** True when rtk is on for the session: a persisted entry wins, then a live switch, then the configured default. */
37
+ export function rtkActive(entries: SessionEntry[] | null | undefined): boolean {
38
+ const persisted = lastCustomValue(entries, 'rtk-mode', (data) => modeOf(data?.enabled));
39
+ if (persisted !== null) return persisted;
40
+ return getSharedComboState().rtk === 'on' || readRtkDefault();
41
+ }
42
+
43
+ /** The text in a result's content parts, or null when the parts are not plain text. */
44
+ export function filterableText(parts: unknown): string | null {
45
+ if (!Array.isArray(parts) || parts.length === 0) return null;
46
+ // An image or a mixed result is not text to filter.
47
+ if (parts.some((part) => (part as { type?: unknown } | null)?.type !== 'text')) return null;
48
+ return parts.map((part) => (part as { text?: unknown } | null)?.text ?? '').join('\n');
49
+ }
50
+
51
+ /** Pipe text through one rtk filter; null on any failure, so the caller keeps its original. */
52
+ export async function filterThroughRtk(
53
+ exec: FilterExec,
54
+ request: { bin: string; filter: string; text: string; signal?: AbortSignal },
55
+ ): Promise<string | null> {
56
+ const { bin, filter, text, signal } = request;
57
+ let dir: string | undefined;
58
+ try {
59
+ dir = mkdtempSync(join(tmpdir(), 'tersio-rtk-'));
60
+ const file = join(dir, 'in.txt');
61
+ writeFileSync(file, text, 'utf8');
62
+ // `rtk pipe` reads stdin only and execFile has no stdin, so one redirect.
63
+ const result = await exec(SHELL, ['-c', `"${bin}" pipe -f ${filter} < "${file}"`], { signal, timeout: FILTER_TIMEOUT_MS });
64
+ if (result.code !== 0) return null;
65
+ const out = result.stdout;
66
+ if (!out.trim() || out.length >= text.length) return null;
67
+ return out;
68
+ } catch {
69
+ return null;
70
+ } finally {
71
+ if (dir) rmSync(dir, { recursive: true, force: true });
72
+ }
73
+ }
74
+
75
+ export default function rtkFilterExtension(pi: ExtensionApi): void {
76
+ const bin = resolveRtkBinary();
77
+ const exec = pi.exec;
78
+ if (!bin || !exec) return;
79
+ pi.on<ToolResultEvent>('tool_result', async (event, ctx: ExtensionCtx) => {
80
+ const filter = FILTER_BY_TOOL.get(String(event?.toolName ?? '').toLowerCase());
81
+ if (!filter || event?.isError) return;
82
+ const text = filterableText(event?.content);
83
+ if (text === null || text.length < MIN_FILTER_BYTES) return;
84
+ if (!rtkActive(sessionEntries(ctx))) return;
85
+ const filtered = await filterThroughRtk(exec, { bin, filter, text, signal: ctx?.signal });
86
+ if (!filtered) return;
87
+ // Built-in grep and glob declare no output schema, so no structuredContent is lost.
88
+ return { content: [{ type: 'text', text: filtered }] };
89
+ });
90
+ }
@@ -23,7 +23,8 @@ function setRtkProcessEnabled(enabled: boolean): void {
23
23
  else process.env.RTK_DISABLED = '1';
24
24
  }
25
25
 
26
- 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.`;
26
+ // rtk's awareness contract: state the output contract, never tell the model to type `rtk`.
27
+ 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.`;
27
28
 
28
29
  export default function rtkSessionExtension(pi: ExtensionApi): void {
29
30
  let enabled = DEFAULT_ENABLED;
@@ -134,6 +135,6 @@ export default function rtkSessionExtension(pi: ExtensionApi): void {
134
135
  if (!active) return;
135
136
  // Skip when already present, so a host that re-presents the prompt cannot stack it.
136
137
  if (systemPromptIncludes(event.systemPrompt, RTK_PROMPT)) return;
137
- return injectPromptText(pi, event, RTK_PROMPT);
138
+ return injectPromptText(pi, event, { text: RTK_PROMPT, sectionTag: 'rtk' });
138
139
  });
139
140
  }
@@ -36,7 +36,7 @@ interface HostAdapter {
36
36
  label(pi: HostHandle, text: string): void;
37
37
  on<E>(pi: ExtensionApi, event: string, handler: (event: E, ctx: ExtensionCtx) => unknown): void;
38
38
  select(ui: UiApi | undefined, title: string, options: SelectOption[], dialogOptions?: Record<string, unknown>): Promise<string | undefined>;
39
- inject(event: SystemPromptEvent, text: string, staleMarker?: string): PromptInjection | undefined;
39
+ inject(event: SystemPromptEvent, block: PromptBlock): PromptInjection | undefined;
40
40
  stringArray(pi: ExtensionApi, name: string, spec: StringArraySpec): ToolParams;
41
41
  }
42
42
 
@@ -53,15 +53,21 @@ const piAdapter: HostAdapter = {
53
53
  if (picked === undefined) return undefined;
54
54
  return options[shown.indexOf(picked)]?.label ?? picked;
55
55
  },
56
- inject(event, text, staleMarker) {
57
- const marker = staleMarker?.replace(/^[^\w]+/, '').trim();
56
+ inject(event, block) {
57
+ const marker = block.staleMarker?.replace(/^[^\w]+/, '').trim();
58
58
  const dropStale = (parts: string[]): string[] => (marker ? parts.filter((part) => !part.includes(marker)) : parts);
59
59
  const options = event.systemPromptOptions;
60
+ // A named section is replaced in place; returning systemPrompt replaces the whole prompt.
61
+ const sections = options?.sections;
62
+ if (block.sectionTag && sections && typeof sections === 'object') {
63
+ sections[block.sectionTag] = block.text;
64
+ return undefined;
65
+ }
60
66
  if (options && typeof options.appendSystemPrompt === 'string') {
61
- options.appendSystemPrompt = dropStale([options.appendSystemPrompt]).concat(text).filter(Boolean).join('\n\n');
67
+ options.appendSystemPrompt = dropStale([options.appendSystemPrompt]).concat(block.text).filter(Boolean).join('\n\n');
62
68
  return undefined;
63
69
  }
64
- return { systemPrompt: `${dropStale(asPromptArray(event.systemPrompt)).join('\n\n')}\n\n${text}` };
70
+ return { systemPrompt: `${dropStale(asPromptArray(event.systemPrompt)).join('\n\n')}\n\n${block.text}` };
65
71
  },
66
72
  stringArray(_pi, name, spec) {
67
73
  const array: Record<string, unknown> = { type: 'array', items: { type: 'string' } };
@@ -78,10 +84,10 @@ const ompAdapter: HostAdapter = {
78
84
  if (!ui?.select) return undefined;
79
85
  return ui.select(title, options, dialogOptions);
80
86
  },
81
- inject(event, text, staleMarker) {
82
- const marker = staleMarker?.replace(/^[^\w]+/, '').trim();
87
+ inject(event, block) {
88
+ const marker = block.staleMarker?.replace(/^[^\w]+/, '').trim();
83
89
  const dropStale = (parts: string[]): string[] => (marker ? parts.filter((part) => !part.includes(marker)) : parts);
84
- return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), text] };
90
+ return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), block.text] };
85
91
  },
86
92
  stringArray(pi, name, spec) {
87
93
  const { z } = pi.zod as { z: ZodFactory['z'] };
@@ -98,10 +104,10 @@ const opencodeAdapter: HostAdapter = {
98
104
  if (!ui?.select) return undefined;
99
105
  return ui.select(title, options, dialogOptions);
100
106
  },
101
- inject(event, text, staleMarker) {
102
- const marker = staleMarker?.replace(/^[^\w]+/, '').trim();
107
+ inject(event, block) {
108
+ const marker = block.staleMarker?.replace(/^[^\w]+/, '').trim();
103
109
  const dropStale = (parts: string[]): string[] => (marker ? parts.filter((part) => !part.includes(marker)) : parts);
104
- return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), text] };
110
+ return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), block.text] };
105
111
  },
106
112
  stringArray(_pi, name, spec) {
107
113
  const array: Record<string, unknown> = { type: 'array', items: { type: 'string' } };
@@ -141,8 +147,15 @@ export async function hostSelect(
141
147
  return adapterFor(pi).select(ui, title, options, dialogOptions);
142
148
  }
143
149
 
144
- export function injectPromptText(pi: HostHandle, event: SystemPromptEvent, text: string, staleMarker?: string): PromptInjection | undefined {
145
- return adapterFor(pi).inject(event, text, staleMarker);
150
+ /** One prompt block: its text, the section tag that owns it, and prior text to drop. */
151
+ export interface PromptBlock {
152
+ text: string;
153
+ sectionTag?: string;
154
+ staleMarker?: string;
155
+ }
156
+
157
+ export function injectPromptText(pi: HostHandle, event: SystemPromptEvent, block: PromptBlock): PromptInjection | undefined {
158
+ return adapterFor(pi).inject(event, block);
146
159
  }
147
160
 
148
161
  export function stringArrayToolParams(pi: ExtensionApi, name: string, spec: StringArraySpec = {}): ToolParams {
@@ -45,7 +45,7 @@ export function readPluginSettings(): Record<string, unknown> {
45
45
  }
46
46
 
47
47
  const COMBO_LEVELS = new Set(['off', 'medium', 'balanced', 'max']);
48
- const CAVEMAN_MODES = new Set(['off', 'lite', 'full', 'ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']);
48
+ const CAVEMAN_MODES = new Set(['off', 'lite', 'full', 'ultra', 'megacave', 'megacave-lite', 'megacave-full', 'megacave-ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']);
49
49
  const PONYTAIL_MODES = new Set(['off', 'lite', 'full', 'ultra']);
50
50
 
51
51
  function readStringDefault(key: string, valid: Set<string>): string {
@@ -14,11 +14,14 @@ export const COMBO_LEVELS: Record<string, Readonly<ComboState>> = Object.freeze(
14
14
  });
15
15
 
16
16
  const MODE_VALUES: Record<string, Set<string>> = {
17
- caveman: new Set(['off', 'lite', 'full', 'ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']),
17
+ caveman: new Set(['off', 'lite', 'full', 'ultra', 'megacave', 'megacave-lite', 'megacave-full', 'megacave-ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']),
18
18
  rtk: new Set(['off', 'on']),
19
19
  ponytail: new Set(['off', 'lite', 'full', 'ultra']),
20
20
  };
21
21
 
22
+ // Upstream renamed wenyan to megacave and kept the old spelling as an alias.
23
+ const CAVEMAN_LEGACY_PREFIX = /^wenyan/;
24
+
22
25
  type ModeName = 'caveman' | 'rtk' | 'ponytail';
23
26
  type Modes = Record<ModeName, string>;
24
27
 
@@ -43,7 +46,9 @@ export function normalizeMode(name: ModeName, value: unknown): string | null {
43
46
  if (name === 'rtk' && typeof value === 'boolean') return value ? 'on' : 'off';
44
47
  const mode = String(value ?? '').trim().toLowerCase();
45
48
  if (!MODE_VALUES[name]?.has(mode)) return null;
46
- return name === 'caveman' && mode === 'wenyan' ? 'wenyan-full' : mode;
49
+ if (name !== 'caveman') return mode;
50
+ const canonical = mode.replace(CAVEMAN_LEGACY_PREFIX, 'megacave');
51
+ return canonical === 'megacave' ? 'megacave-full' : canonical;
47
52
  }
48
53
 
49
54
  export function deriveLevel(modes: Modes | null | undefined): ComboLevel {
@@ -90,7 +95,9 @@ export function normalizeInputCommand(value: unknown): string {
90
95
  }
91
96
 
92
97
  export function asPromptArray(systemPrompt: string | string[]): string[] {
93
- return Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt];
98
+ const parts = Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt];
99
+ // OMP can hand back a hole in the array; a non-string breaks every marker check.
100
+ return parts.filter((part): part is string => typeof part === 'string');
94
101
  }
95
102
 
96
103
  export function systemPromptIncludes(systemPrompt: string | string[], marker: string): boolean {
@@ -153,6 +160,14 @@ export function setSharedComboMode(name: ModeName, value: unknown): Readonly<Com
153
160
  return publish(normalizedState(modes));
154
161
  }
155
162
 
163
+ export function setSharedComboModes(modes: { caveman?: unknown; rtk?: unknown; ponytail?: unknown }): Readonly<ComboState> {
164
+ return publish(normalizedState({
165
+ caveman: normalizeMode('caveman', modes.caveman) || 'off',
166
+ rtk: normalizeMode('rtk', modes.rtk) || 'off',
167
+ ponytail: normalizeMode('ponytail', modes.ponytail) || 'off',
168
+ }));
169
+ }
170
+
156
171
  export function reconcileSharedComboEntries(entries: SessionEntry[] | null | undefined): Readonly<ComboState> {
157
172
  let modes: Modes = { ...COMBO_LEVELS.off };
158
173
  if (Array.isArray(entries)) {
@@ -17,6 +17,11 @@ export function formatStatus(state: StatusState): string {
17
17
  return `🧩 combo ${label}: 🪨caveman=${caveman.toUpperCase()} ⚡rtk=${rtk.toUpperCase()} 🦥ponytail=${ponytail.toUpperCase()}`;
18
18
  }
19
19
 
20
+ // The string upstream's skill tells the model to relay for `/caveman status`.
21
+ export function formatCavemanStatus(mode: string): string {
22
+ return `Caveman mode: ${mode && mode !== 'off' ? mode : 'unknown'}`;
23
+ }
24
+
20
25
  // Stale ctx access throws, so never let it abort a mode restore.
21
26
  function statusUi(ctx: ExtensionCtx | undefined): UiApi | undefined {
22
27
  try {
@@ -34,7 +39,8 @@ let lastUi: UiApi | undefined;
34
39
 
35
40
  // Paint the host footer bar; same text on the same UI is skipped, a new ctx repaints it.
36
41
  export function announceStatus(ctx: ExtensionCtx | undefined): void {
37
- const text = formatStatus(getSharedComboState());
42
+ const state = getSharedComboState();
43
+ const text = formatStatus(state);
38
44
  const ui = statusUi(ctx) ?? statusUi(remembered);
39
45
  if (statusUi(ctx)) remembered = ctx;
40
46
  if (!ui) return;
@@ -42,4 +48,5 @@ export function announceStatus(ctx: ExtensionCtx | undefined): void {
42
48
  lastText = text;
43
49
  lastUi = ui;
44
50
  ui.setStatus?.('tersio', text);
51
+ ui.setStatus?.('caveman', formatCavemanStatus(state.caveman));
45
52
  }