@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 +2 -2
- package/dist/cli/doctor-fix.js +1 -1
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/install.js +37 -8
- package/dist/cli/manifest.js +1 -0
- package/dist/extensions/ai-addons-updater/index.js +3 -3
- package/dist/extensions/caveman-session/index.js +10 -3
- package/dist/extensions/combo-toggle/index.js +2 -2
- package/dist/extensions/opencode/server.js +45 -1
- package/dist/extensions/rtk-filter/index.js +87 -0
- package/dist/extensions/rtk-session/index.js +3 -2
- package/dist/extensions/shared/host.js +18 -12
- package/dist/extensions/shared/session-state.js +3 -1
- package/extensions/ai-addons-updater/index.ts +2 -3
- package/extensions/caveman-session/index.ts +10 -3
- package/extensions/combo-toggle/index.ts +2 -2
- package/extensions/opencode/server.ts +38 -1
- package/extensions/rtk-filter/index.ts +90 -0
- package/extensions/rtk-session/index.ts +3 -2
- package/extensions/shared/host.ts +26 -13
- package/extensions/shared/session-state.ts +3 -1
- package/extensions/shared/types.ts +10 -2
- package/package.json +2 -1
- package/dist/cli/headroom-install.js +0 -266
- package/dist/cli/headroom.js +0 -492
- package/dist/extensions/headroom-session/index.js +0 -93
- package/dist/extensions/shared/headroom.js +0 -131
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
|
|
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.
|
package/dist/cli/doctor-fix.js
CHANGED
|
@@ -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
|
|
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();
|
package/dist/cli/doctor.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/cli/install.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
707
|
-
|
|
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) =>
|
|
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('
|
|
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('
|
|
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 ? '
|
|
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
|
}
|
package/dist/cli/manifest.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
22
|
-
const RELOAD_MSG =
|
|
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
|
-
|
|
25
|
+
return `Caveman ${mode} requested, but ${name} is missing. Run: tersio doctor --fix extensions`;
|
|
21
26
|
}
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
103
|
-
return adapterFor(pi).inject(event,
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
const RELOAD_MSG =
|
|
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
|
-
|
|
26
|
+
return `Caveman ${mode} requested, but ${name} is missing. Run: tersio doctor --fix extensions`;
|
|
22
27
|
}
|
|
23
|
-
|
|
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
|
|
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);
|