@krtclcdy/tersio 2.25.3 → 2.25.5

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 (61) hide show
  1. package/README.md +14 -31
  2. package/dashboard/dist/index.html +18 -16
  3. package/dist/cli/backups.js +194 -0
  4. package/dist/cli/common.js +2 -2
  5. package/dist/cli/dashboard.js +125 -98
  6. package/dist/cli/doctor-fix.js +42 -45
  7. package/dist/cli/doctor.js +70 -8
  8. package/dist/cli/hosts.js +4 -4
  9. package/dist/cli/install.js +106 -38
  10. package/dist/cli/manifest.js +45 -16
  11. package/dist/cli/profile.js +1 -1
  12. package/dist/cli/uninstall.js +21 -5
  13. package/dist/cli/update.js +3 -3
  14. package/dist/cli/usage.js +75 -4
  15. package/dist/extensions/{caveman-session → caveman}/index.js +10 -3
  16. package/dist/extensions/{combo-toggle → combo}/index.js +3 -3
  17. package/dist/extensions/{tersio-commands → commands}/index.js +1 -1
  18. package/dist/extensions/rtk/filter.js +87 -0
  19. package/dist/extensions/{rtk-session/index.js → rtk/session.js} +4 -3
  20. package/dist/extensions/{opencode/server.js → server.js} +52 -8
  21. package/dist/extensions/shared/agent-stats.js +1262 -0
  22. package/dist/extensions/shared/brand-marks.js +154 -0
  23. package/dist/extensions/shared/host.js +18 -12
  24. package/dist/extensions/shared/plugin-settings.js +1 -1
  25. package/dist/extensions/shared/pricing.js +91 -27
  26. package/dist/extensions/shared/session-state.js +3 -1
  27. package/dist/extensions/shared/usage-ledger.js +79 -17
  28. package/dist/extensions/shared/usage-store.js +106 -22
  29. package/dist/extensions/{lib → shared}/utils.js +1 -1
  30. package/dist/extensions/{ai-addons-updater → updater}/index.js +15 -5
  31. package/dist/probe-catalog.js +48 -0
  32. package/dist/probe-catalog188.js +44 -0
  33. package/dist/tersio.js +2 -2
  34. package/extensions/{caveman-session → caveman}/index.ts +10 -3
  35. package/extensions/{combo-toggle → combo}/index.ts +3 -3
  36. package/extensions/{tersio-commands → commands}/index.ts +1 -1
  37. package/extensions/rtk/filter.ts +90 -0
  38. package/extensions/{rtk-session/index.ts → rtk/session.ts} +4 -3
  39. package/extensions/{opencode/server.ts → server.ts} +46 -9
  40. package/extensions/shared/agent-stats.ts +1657 -0
  41. package/extensions/shared/brand-marks.ts +163 -0
  42. package/extensions/shared/host.ts +26 -13
  43. package/extensions/shared/plugin-settings.ts +1 -1
  44. package/extensions/shared/pricing.ts +107 -28
  45. package/extensions/shared/session-state.ts +3 -1
  46. package/extensions/shared/types.ts +10 -2
  47. package/extensions/shared/usage-ledger.ts +94 -19
  48. package/extensions/shared/usage-store.ts +106 -22
  49. package/extensions/{lib → shared}/utils.ts +1 -1
  50. package/extensions/{ai-addons-updater → updater}/index.ts +17 -7
  51. package/package.json +13 -12
  52. package/dist/cli/headroom-install.js +0 -266
  53. package/dist/cli/headroom.js +0 -492
  54. package/dist/extensions/headroom-session/index.js +0 -93
  55. package/dist/extensions/shared/headroom.js +0 -131
  56. /package/dist/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
  57. /package/dist/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
  58. /package/dist/extensions/{caveman-session → caveman}/rule.md +0 -0
  59. /package/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
  60. /package/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
  61. /package/extensions/{caveman-session → caveman}/rule.md +0 -0
package/dist/cli/usage.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // cli/usage.ts — ledger + session-token usage report, tokscale-style.
2
- import { co2GramsFor, displayModelId, energyWhFor, foldModelsByLabel, importSessionTokens, ledgerPath, priceFor, readResetWatermark, readRtkAdoption, readRtkRecallDiagnostics, readUsage, refreshPricesIfStale, sessionsDir, usdCost, } from '../extensions/shared/usage-ledger.js';
3
- import { resolveRtkBinary } from '../extensions/lib/utils.js';
2
+ import { PRICE_TIERS_FIRM, co2GramsFor, displayModelId, energyWhFor, foldModelsByLabel, importSessionTokens, ledgerPath, priceFor, pricesOverridePath, readResetWatermark, readRtkAdoption, readRtkRecallDiagnostics, readUsage, refreshPricesIfStale, sessionsDir, usdCost, } from '../extensions/shared/usage-ledger.js';
3
+ import { resolveRtkBinary } from '../extensions/shared/utils.js';
4
4
  import { readRtkGain } from '../extensions/shared/rtk-gain.js';
5
5
  import { readUsageDb, reparseGuardReport, syncUsageDb, usageDbPath } from '../extensions/shared/usage-store.js';
6
6
  import { withInteractiveSpinner } from './interactive.js';
@@ -39,11 +39,13 @@ export function summarizeUsage(rows) {
39
39
  const source = stored ? (synced ? 'stored' : 'stored-stale') : 'live';
40
40
  const watermark = readResetWatermark();
41
41
  let usd = 0;
42
+ let estimatedUsd = 0;
42
43
  let priced = true;
43
44
  let savedUsd = 0;
44
45
  const byModelUsd = {};
45
46
  const byModelBucketUsd = {};
46
47
  const byHost = {};
48
+ const byAgent = {};
47
49
  const byDayModel = {};
48
50
  let co2g = 0;
49
51
  let energyWh = 0;
@@ -55,9 +57,26 @@ export function summarizeUsage(rows) {
55
57
  };
56
58
  // Price by exact key first (gateways can differ), then fold every map under the display label.
57
59
  const folded = foldModelsByLabel(session.byModel, session.byModelMessages);
60
+ const priceTiers = {};
61
+ // Named in the loop so the list is built in one pass, then sorted by token volume below.
62
+ const weakModels = [];
63
+ const measuredModels = [];
58
64
  for (const [model, t] of Object.entries(session.byModel)) {
59
- const c = usdCost(t, model);
65
+ // A model is measured only when the host priced every one of its messages; a partly measured
66
+ // model would read its unpriced messages as zero, which is the one error worse than a guess.
67
+ const covered = (session.byModelCostMessages[model] ?? 0) >= (session.byModelMessages[model] ?? 0);
68
+ const measured = covered ? session.byModelMeasured[model] : undefined;
69
+ const c = usdCost(t, model, measured);
60
70
  usd += c.usd;
71
+ priceTiers[c.tier] = (priceTiers[c.tier] ?? 0) + 1;
72
+ if (c.tier === 'measured')
73
+ measuredModels.push(model);
74
+ // A tier that is not firm is an estimate, so it is kept out of the firm total rather than
75
+ // summed beside a measured cost that would make the whole figure read as a bill.
76
+ if (!PRICE_TIERS_FIRM.includes(c.tier)) {
77
+ estimatedUsd += c.usd;
78
+ weakModels.push(model);
79
+ }
61
80
  const label = displayModelId(model);
62
81
  byModelUsd[label] = (byModelUsd[label] ?? 0) + c.usd;
63
82
  byModelBucketUsd[label] ??= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
@@ -67,6 +86,11 @@ export function summarizeUsage(rows) {
67
86
  byHost[label][host] ??= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
68
87
  addTok(byHost[label][host], hb);
69
88
  }
89
+ for (const [agent, ab] of Object.entries(session.byAgent[model] ?? {})) {
90
+ byAgent[agent] ??= { tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, msgs: 0 };
91
+ addTok(byAgent[agent].tokens, ab);
92
+ byAgent[agent].msgs += session.byAgentMessages[model]?.[agent] ?? 0;
93
+ }
70
94
  co2g += co2GramsFor(model, t.output);
71
95
  energyWh += energyWhFor(model, t.output);
72
96
  if (!c.priced)
@@ -81,6 +105,10 @@ export function summarizeUsage(rows) {
81
105
  }
82
106
  }
83
107
  const byTool = Object.entries(session.byTool).sort((a, b) => b[1] - a[1]).slice(0, 10);
108
+ // Heaviest first, then capped to five: the line is guidance, not a dump of the model table.
109
+ const weakKeys = weakModels
110
+ .toSorted((a, b) => tokenTotal(session.byModel[b]) - tokenTotal(session.byModel[a]))
111
+ .slice(0, 5);
84
112
  return {
85
113
  total: rows.length,
86
114
  byKind,
@@ -94,6 +122,7 @@ export function summarizeUsage(rows) {
94
122
  byModelBucketUsd,
95
123
  byModelMessages: folded.byModelMessages,
96
124
  byHost,
125
+ byAgent,
97
126
  byDay: session.byDay,
98
127
  byDayModel,
99
128
  byTool,
@@ -106,6 +135,10 @@ export function summarizeUsage(rows) {
106
135
  rtkRecall: readRtkRecallDiagnostics(resolveRtkBinary()),
107
136
  usd,
108
137
  priced,
138
+ estimatedUsd,
139
+ priceTiers,
140
+ weakModels: weakKeys,
141
+ measuredModels,
109
142
  savedUsd,
110
143
  costMeasured: session.costMeasured,
111
144
  co2g,
@@ -119,6 +152,10 @@ export function summarizeUsage(rows) {
119
152
  function fmt(n) {
120
153
  return n.toLocaleString('en-US');
121
154
  }
155
+ /** Every billed bucket, so a model sorts by how much of the estimate it carries. */
156
+ function tokenTotal(t) {
157
+ return t.input + t.output + t.cacheRead + t.cacheWrite;
158
+ }
122
159
  function fmtShort(n) {
123
160
  if (n >= 1e12)
124
161
  return (n / 1e12).toFixed(1) + 'T';
@@ -163,12 +200,34 @@ function printReport(report) {
163
200
  }
164
201
  const t = report.tokens;
165
202
  const a = report.rtkAdoption;
203
+ // A tier that is not firm is named, so a figure that is partly a guess never reads as a bill.
204
+ const weak = ['tail', 'default'].reduce((sum, tier) => sum + (report.priceTiers[tier] ?? 0), 0);
205
+ const overridden = report.priceTiers.override ?? 0;
206
+ // The count stays in the cell; the names go on their own line below, because the cell is a
207
+ // fixed 118 columns and a list of model ids would be truncated to nothing.
208
+ const listed = report.weakModels.map((m) => displayModelId(m));
209
+ const measuredCount = report.measuredModels.length;
210
+ const costNote = weak > 0
211
+ ? ` · ${weak} model${weak === 1 ? '' : 's'} priced from another provider or the default table — add them to ${pricesOverridePath()}`
212
+ : overridden > 0
213
+ ? ` · ${overridden} model${overridden === 1 ? '' : 's'} priced from your override file`
214
+ : measuredCount > 0
215
+ ? ` · ${measuredCount} model${measuredCount === 1 ? '' : 's'} priced by the host`
216
+ : '';
166
217
  for (const l of textTable(['Metric', 'Value'], [
167
218
  ['Tokens', `${fmt(t.input)} in · ${fmt(t.output)} out · ${fmt(t.cacheRead)} cache read · ${fmt(t.cacheWrite)} cache write`],
168
- ['Cost', `${formatCurrency(report.usd, report.currency)}${report.priced ? '' : ' (includes default pricing)'} · ~${formatCurrency(report.savedUsd, report.currency)} cache-saved (est.) · ~${report.co2g.toFixed(1)}g CO2 (est.)`],
219
+ ['Cost', `${formatCurrency(report.usd, report.currency)}${report.priced ? '' : ' (includes default pricing)'} · ~${formatCurrency(report.savedUsd, report.currency)} cache-saved (est.) · ~${report.co2g.toFixed(1)}g CO2 (est.)${costNote}`],
169
220
  ['RTK adoption', `${fmt(a.rtkCalls)}/${fmt(a.eligibleCalls)} eligible Bash calls use RTK (${a.adoptionPct.toFixed(1)}%) · ${fmt(a.missedCalls)} missed · recall ${report.rtkRecall.available ? `${report.rtkRecall.mode} (${report.rtkRecall.entries})` : 'unavailable'}`],
170
221
  ], [false, false], 118))
171
222
  console.log(l);
223
+ if (measuredCount > 0) {
224
+ // Named, because a measured figure is only worth trusting when a reader sees whose it is.
225
+ console.log(` Priced by the host: ${measuredCount} model${measuredCount === 1 ? '' : 's'}`);
226
+ }
227
+ if (weak > 0) {
228
+ // Heaviest first, one per host spelling, so the operator sees exactly what to write.
229
+ console.log(` Unpriced (heaviest first): ${listed.join(', ')}`);
230
+ }
172
231
  const allModels = Object.entries(report.byModel).filter(([, b]) => b.input + b.output + b.cacheRead + b.cacheWrite > 0).sort((a, b) => (b[1].input + b[1].output) - (a[1].input + a[1].output));
173
232
  const models = allModels.slice(0, ROW_CAP);
174
233
  if (models.length) {
@@ -186,6 +245,18 @@ function printReport(report) {
186
245
  if (allModels.length > ROW_CAP)
187
246
  console.log(` showing top ${ROW_CAP} of ${fmt(allModels.length)} models — full set in the dashboard`);
188
247
  }
248
+ const agentKinds = Object.entries(report.byAgent).filter(([, per]) => per.msgs > 0).toSorted((x, y) => y[1].msgs - x[1].msgs);
249
+ if (agentKinds.length > 1) {
250
+ console.log(' BY AGENT');
251
+ const top = agentKinds.reduce((m, [, per]) => Math.max(m, per.tokens.input + per.tokens.output + per.tokens.cacheRead + per.tokens.cacheWrite), 1);
252
+ const arows = agentKinds.map(([kind, per]) => {
253
+ const total = per.tokens.input + per.tokens.output + per.tokens.cacheRead + per.tokens.cacheWrite;
254
+ return [kind === 'subagent' ? 'Subagents' : 'Main agent', fmt(per.msgs), fmt(per.tokens.input), fmt(per.tokens.output), fmt(per.tokens.cacheRead), `${bar(total / top, 8)} ${fmtShort(total)}`];
255
+ });
256
+ for (const line of textTable(['Agent', 'Msgs', 'Input', 'Output', 'Cache read', 'Share'], arows, [false, true, true, true, true, false])) {
257
+ console.log(line);
258
+ }
259
+ }
189
260
  const cmdRows = report.byTool.map(([tool, n]) => ({ name: tool, count: n, saved: null, avgPct: null, avgMs: null }));
190
261
  for (const r of report.rtkGain.byCommand) {
191
262
  cmdRows.push({ name: r.command, count: r.count, saved: r.saved, avgPct: r.avgPct, avgMs: r.avgMs });
@@ -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
  }
@@ -14,7 +14,7 @@ import { activeModesSummary, COMBO_LEVELS, getSharedComboState, hasModeState, no
14
14
  import { hostSelect, injectPromptText, onHostEvent, setExtensionLabel } from '../shared/host.js';
15
15
  import { announceStatus } from '../shared/status.js';
16
16
  import { isComboSetupComplete, readCavemanDefault, readComboDefault, readPluginSettings, readPonytailDefault, readRtkDefault, saveComboSetup, } from '../shared/plugin-settings.js';
17
- import { findHoistedPackage, isPiProcess } from '../lib/utils.js';
17
+ import { findHoistedPackage, isPiProcess } from '../shared/utils.js';
18
18
  const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
19
19
  const PONYTAIL_FALLBACK_INTENSITY = {
20
20
  lite: 'Prefer the simplest correct solution.',
@@ -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
  }
@@ -5,7 +5,7 @@ import { setExtensionLabel } from '../shared/host.js';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { appendUsage, readUsage } from '../shared/usage-ledger.js';
8
- import { checkAddonsSummary, runAddonUpdate } from '../ai-addons-updater/index.js';
8
+ import { checkAddonsSummary, runAddonUpdate } from '../updater/index.js';
9
9
  const HELP = [
10
10
  '/tersio status — active modes + combo level',
11
11
  '/tersio check — add-on version check',
@@ -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 '../shared/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
+ }
@@ -1,7 +1,7 @@
1
1
  import { getSharedComboState, hasModeState, isOmpSubagentPrompt, lastCustomValue, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode, systemPromptIncludes } from '../shared/session-state.js';
2
2
  import { injectPromptText, onHostEvent, setExtensionLabel, stringArrayToolParams } from '../shared/host.js';
3
3
  import { readRtkDefault } from '../shared/plugin-settings.js';
4
- import { resolveRtkBinary } from '../lib/utils.js';
4
+ import { resolveRtkBinary } from '../shared/utils.js';
5
5
  import { announceStatus } from '../shared/status.js';
6
6
  const DEFAULT_ENABLED = false;
7
7
  function asBoolean(value) {
@@ -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
  }
@@ -1,12 +1,13 @@
1
1
  // OpenCode plugin entry: maps its plugin API onto the shared ExtensionApi the mode extensions consume.
2
2
  import { execFile } from 'node:child_process';
3
- import cavemanSessionExtension from '../caveman-session/index.js';
4
- import rtkSessionExtension from '../rtk-session/index.js';
5
- import comboToggleExtension from '../combo-toggle/index.js';
6
- import tersioCommandsExtension from '../tersio-commands/index.js';
7
- import { getSharedComboState } from '../shared/session-state.js';
8
- import { formatStatus } from '../shared/status.js';
9
- import { resolveRtkBinary } from '../lib/utils.js';
3
+ import cavemanSessionExtension from './caveman/index.js';
4
+ import rtkSessionExtension from './rtk/session.js';
5
+ import rtkFilterExtension, { FILTER_BY_TOOL, MIN_FILTER_BYTES, filterThroughRtk, filterableText, rtkActive } from './rtk/filter.js';
6
+ import comboToggleExtension from './combo/index.js';
7
+ import tersioCommandsExtension from './commands/index.js';
8
+ import { getSharedComboState } from './shared/session-state.js';
9
+ import { formatStatus } from './shared/status.js';
10
+ import { resolveRtkBinary } from './shared/utils.js';
10
11
  // The npm name, so the plugin list reads the same as the OMP and pi installs.
11
12
  const plugin = {
12
13
  id: '@krtclcdy/tersio',
@@ -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)