@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.
- package/README.md +14 -31
- package/dashboard/dist/index.html +18 -16
- package/dist/cli/backups.js +194 -0
- package/dist/cli/common.js +2 -2
- package/dist/cli/dashboard.js +125 -98
- package/dist/cli/doctor-fix.js +42 -45
- package/dist/cli/doctor.js +70 -8
- package/dist/cli/hosts.js +4 -4
- package/dist/cli/install.js +106 -38
- package/dist/cli/manifest.js +45 -16
- package/dist/cli/profile.js +1 -1
- package/dist/cli/uninstall.js +21 -5
- package/dist/cli/update.js +3 -3
- package/dist/cli/usage.js +75 -4
- package/dist/extensions/{caveman-session → caveman}/index.js +10 -3
- package/dist/extensions/{combo-toggle → combo}/index.js +3 -3
- package/dist/extensions/{tersio-commands → commands}/index.js +1 -1
- package/dist/extensions/rtk/filter.js +87 -0
- package/dist/extensions/{rtk-session/index.js → rtk/session.js} +4 -3
- package/dist/extensions/{opencode/server.js → server.js} +52 -8
- package/dist/extensions/shared/agent-stats.js +1262 -0
- package/dist/extensions/shared/brand-marks.js +154 -0
- package/dist/extensions/shared/host.js +18 -12
- package/dist/extensions/shared/plugin-settings.js +1 -1
- package/dist/extensions/shared/pricing.js +91 -27
- package/dist/extensions/shared/session-state.js +3 -1
- package/dist/extensions/shared/usage-ledger.js +79 -17
- package/dist/extensions/shared/usage-store.js +106 -22
- package/dist/extensions/{lib → shared}/utils.js +1 -1
- package/dist/extensions/{ai-addons-updater → updater}/index.js +15 -5
- package/dist/probe-catalog.js +48 -0
- package/dist/probe-catalog188.js +44 -0
- package/dist/tersio.js +2 -2
- package/extensions/{caveman-session → caveman}/index.ts +10 -3
- package/extensions/{combo-toggle → combo}/index.ts +3 -3
- package/extensions/{tersio-commands → commands}/index.ts +1 -1
- package/extensions/rtk/filter.ts +90 -0
- package/extensions/{rtk-session/index.ts → rtk/session.ts} +4 -3
- package/extensions/{opencode/server.ts → server.ts} +46 -9
- package/extensions/shared/agent-stats.ts +1657 -0
- package/extensions/shared/brand-marks.ts +163 -0
- package/extensions/shared/host.ts +26 -13
- package/extensions/shared/plugin-settings.ts +1 -1
- package/extensions/shared/pricing.ts +107 -28
- package/extensions/shared/session-state.ts +3 -1
- package/extensions/shared/types.ts +10 -2
- package/extensions/shared/usage-ledger.ts +94 -19
- package/extensions/shared/usage-store.ts +106 -22
- package/extensions/{lib → shared}/utils.ts +1 -1
- package/extensions/{ai-addons-updater → updater}/index.ts +17 -7
- package/package.json +13 -12
- 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/dist/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
- /package/dist/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
- /package/dist/extensions/{caveman-session → caveman}/rule.md +0 -0
- /package/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
- /package/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
- /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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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 '../
|
|
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
|
|
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 '../
|
|
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 '../
|
|
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
|
-
|
|
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 '
|
|
4
|
-
import rtkSessionExtension from '
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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)
|