@mrclrchtr/supi-rtk 0.1.0

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/src/rtk.ts ADDED
@@ -0,0 +1,279 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ createBashTool,
5
+ createLocalBashOperations,
6
+ SettingsManager,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ loadSupiConfig,
10
+ recordDebugEvent,
11
+ registerConfigSettings,
12
+ registerContextProvider,
13
+ } from "@mrclrchtr/supi-core";
14
+ import { shouldBypassRtkRewrite } from "./guards.ts";
15
+ import { type RtkRewriteFailureReason, rtkRewriteDetailed } from "./rewrite.ts";
16
+ import { getStats, recordFallback, recordRewrite, resetTracking } from "./tracking.ts";
17
+
18
+ const RTK_SECTION = "rtk";
19
+ const RTK_DEFAULTS = {
20
+ enabled: true,
21
+ rewriteTimeout: 5000,
22
+ };
23
+
24
+ /** Cached RTK availability probe — reset when the extension/session starts. */
25
+ let rtkAvailable: boolean | null = null;
26
+ let warnedAboutUnavailableRtk = false;
27
+
28
+ interface RtkUiContext {
29
+ hasUI: boolean;
30
+ ui: {
31
+ notify(message: string, severity: "info" | "warning" | "error"): void;
32
+ };
33
+ }
34
+
35
+ type RtkRewriteResolution =
36
+ | { kind: "disabled" | "unavailable" | "failed" | "unchanged"; command: string }
37
+ | { kind: "rewritten"; command: string };
38
+
39
+ interface RtkDebugEventDetails {
40
+ command: string;
41
+ cwd: string;
42
+ durationMs: number;
43
+ timeoutMs: number;
44
+ reason?: RtkRewriteFailureReason | "unavailable" | "guarded-passthrough";
45
+ rewrittenCommand?: string;
46
+ }
47
+
48
+ function checkRtkAvailable(): boolean {
49
+ if (rtkAvailable !== null) {
50
+ return rtkAvailable;
51
+ }
52
+
53
+ try {
54
+ execFileSync("rtk", ["--version"], { encoding: "utf-8", timeout: 5000 });
55
+ rtkAvailable = true;
56
+ } catch {
57
+ rtkAvailable = false;
58
+ }
59
+
60
+ return rtkAvailable;
61
+ }
62
+
63
+ function loadRtkConfig(cwd: string) {
64
+ return loadSupiConfig(RTK_SECTION, cwd, RTK_DEFAULTS);
65
+ }
66
+
67
+ /** Register RTK settings with the supi settings registry. */
68
+ function registerRtkSettings(): void {
69
+ registerConfigSettings({
70
+ id: "rtk",
71
+ label: "RTK",
72
+ section: RTK_SECTION,
73
+ defaults: RTK_DEFAULTS,
74
+ buildItems: (settings) => [
75
+ {
76
+ id: "enabled",
77
+ label: "Enabled",
78
+ description: "Enable/disable RTK bash command rewriting",
79
+ currentValue: settings.enabled ? "on" : "off",
80
+ values: ["on", "off"],
81
+ },
82
+ {
83
+ id: "rewriteTimeout",
84
+ label: "Rewrite Timeout",
85
+ description: "Timeout in ms for rtk rewrite calls",
86
+ currentValue: String(settings.rewriteTimeout),
87
+ values: ["1000", "3000", "5000", "10000"],
88
+ },
89
+ ],
90
+ // biome-ignore lint/complexity/useMaxParams: ConfigSettingsOptions interface callback
91
+ persistChange: (_scope, _cwd, settingId, value, helpers) => {
92
+ if (settingId === "enabled") {
93
+ helpers.set("enabled", value === "on");
94
+ } else if (settingId === "rewriteTimeout") {
95
+ const num = Number.parseInt(value, 10);
96
+ helpers.set("rewriteTimeout", Number.isNaN(num) ? 5000 : num);
97
+ }
98
+ },
99
+ });
100
+ }
101
+
102
+ function notifyUnavailableRtkOnce(ctx?: RtkUiContext): void {
103
+ if (!ctx?.hasUI || warnedAboutUnavailableRtk) {
104
+ return;
105
+ }
106
+
107
+ warnedAboutUnavailableRtk = true;
108
+ ctx.ui.notify(
109
+ "RTK is enabled but the rtk binary is not available on PATH. Falling back to normal bash execution.",
110
+ "warning",
111
+ );
112
+ }
113
+
114
+ function recordRtkDebugEvent(
115
+ category: "fallback" | "rewrite" | "unchanged",
116
+ details: RtkDebugEventDetails,
117
+ ): void {
118
+ const message =
119
+ category === "fallback"
120
+ ? `RTK rewrite fell back: ${details.reason ?? "unknown"}`
121
+ : category === "rewrite"
122
+ ? "RTK rewrote command"
123
+ : "RTK rewrite returned the original command";
124
+
125
+ recordDebugEvent({
126
+ source: "rtk",
127
+ level: category === "fallback" ? "warning" : "debug",
128
+ category,
129
+ message,
130
+ cwd: details.cwd,
131
+ data: details,
132
+ rawData: details,
133
+ });
134
+ }
135
+
136
+ /**
137
+ * Resolve the command RTK should execute for the given cwd.
138
+ * Records rewrite/fallback stats and optionally warns once per session when RTK is unavailable.
139
+ */
140
+ function resolveRtkCommand(command: string, cwd: string, ctx?: RtkUiContext): RtkRewriteResolution {
141
+ const config = loadRtkConfig(cwd);
142
+ if (!config.enabled) {
143
+ return { kind: "disabled", command };
144
+ }
145
+
146
+ if (shouldBypassRtkRewrite(command, cwd)) {
147
+ recordRtkDebugEvent("unchanged", {
148
+ command,
149
+ cwd,
150
+ durationMs: 0,
151
+ timeoutMs: config.rewriteTimeout,
152
+ reason: "guarded-passthrough",
153
+ });
154
+ return { kind: "unchanged", command };
155
+ }
156
+
157
+ if (!checkRtkAvailable()) {
158
+ notifyUnavailableRtkOnce(ctx);
159
+ recordRtkDebugEvent("fallback", {
160
+ command,
161
+ cwd,
162
+ durationMs: 0,
163
+ timeoutMs: config.rewriteTimeout,
164
+ reason: "unavailable",
165
+ });
166
+ return { kind: "unavailable", command };
167
+ }
168
+
169
+ const result = rtkRewriteDetailed(command, config.rewriteTimeout);
170
+ if (result.kind === "failed") {
171
+ recordFallback(command);
172
+ recordRtkDebugEvent("fallback", {
173
+ command,
174
+ cwd,
175
+ durationMs: result.durationMs,
176
+ timeoutMs: config.rewriteTimeout,
177
+ reason: result.reason,
178
+ });
179
+ return { kind: "failed", command };
180
+ }
181
+
182
+ if (result.kind === "unchanged") {
183
+ recordRtkDebugEvent("unchanged", {
184
+ command,
185
+ cwd,
186
+ durationMs: result.durationMs,
187
+ timeoutMs: config.rewriteTimeout,
188
+ });
189
+ return { kind: "unchanged", command };
190
+ }
191
+
192
+ recordRewrite(command, result.command);
193
+ recordRtkDebugEvent("rewrite", {
194
+ command,
195
+ rewrittenCommand: result.command,
196
+ cwd,
197
+ durationMs: result.durationMs,
198
+ timeoutMs: config.rewriteTimeout,
199
+ });
200
+ return { kind: "rewritten", command: result.command };
201
+ }
202
+
203
+ function createRtkAwareBashTool(cwd: string, ctx?: RtkUiContext) {
204
+ const settings = SettingsManager.create(cwd);
205
+ const commandPrefix = settings.getShellCommandPrefix();
206
+ return createBashTool(cwd, {
207
+ shellPath: settings.getShellPath(),
208
+ spawnHook: ({ command, cwd: spawnCwd, env }) => {
209
+ let userCommand = command;
210
+ if (commandPrefix) {
211
+ const prefixWithNewline = `${commandPrefix}\n`;
212
+ if (command.startsWith(prefixWithNewline)) {
213
+ userCommand = command.slice(prefixWithNewline.length);
214
+ }
215
+ }
216
+ const resolution = resolveRtkCommand(userCommand, spawnCwd, ctx);
217
+ const finalCommand = commandPrefix
218
+ ? `${commandPrefix}\n${resolution.command}`
219
+ : resolution.command;
220
+ return { command: finalCommand, cwd: spawnCwd, env };
221
+ },
222
+ });
223
+ }
224
+
225
+ export default function rtkExtension(pi: ExtensionAPI) {
226
+ rtkAvailable = null;
227
+ warnedAboutUnavailableRtk = false;
228
+ registerRtkSettings();
229
+
230
+ registerContextProvider({
231
+ id: "rtk",
232
+ label: "RTK",
233
+ getData: getStats,
234
+ });
235
+
236
+ pi.on("session_start", async () => {
237
+ resetTracking();
238
+ rtkAvailable = null;
239
+ warnedAboutUnavailableRtk = false;
240
+ });
241
+
242
+ // Reuse the built-in bash tool metadata/renderers; actual execution uses ctx.cwd per call.
243
+ const baseBashTool = createBashTool(process.cwd());
244
+
245
+ pi.registerTool({
246
+ ...baseBashTool,
247
+ // TODO(rtk-ai/rtk#1813): Remove these promptGuidelines once the upstream issue
248
+ // "vitest run output truncates failures" is fixed in a released RTK version.
249
+ // After that, default compact output will list all failing tests and this
250
+ // guidance will no longer be necessary.
251
+ promptGuidelines: [
252
+ "When running test commands (vitest, jest, pytest, cargo test, etc.), RTK may intercept and compact the output. To see all test failures without truncation, pass `-v` to the command (e.g. `pnpm vitest -v run`). To bypass RTK entirely and get raw output, prefix with `RTK_DISABLED=1`.",
253
+ ],
254
+ // biome-ignore lint/complexity/useMaxParams: pi tool execute signature
255
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
256
+ const bashTool = createRtkAwareBashTool(ctx.cwd, ctx);
257
+ return bashTool.execute(toolCallId, params, signal, onUpdate);
258
+ },
259
+ });
260
+
261
+ pi.on("user_bash", (event, ctx) => {
262
+ if (event.excludeFromContext) {
263
+ return;
264
+ }
265
+
266
+ const resolution = resolveRtkCommand(event.command, event.cwd, ctx);
267
+ if (resolution.kind !== "rewritten") {
268
+ return;
269
+ }
270
+
271
+ const settings = SettingsManager.create(event.cwd);
272
+ const local = createLocalBashOperations({ shellPath: settings.getShellPath() });
273
+ return {
274
+ operations: {
275
+ exec: (_command, cwd, options) => local.exec(resolution.command, cwd, options),
276
+ },
277
+ };
278
+ });
279
+ }
@@ -0,0 +1,36 @@
1
+ let rewriteCount = 0;
2
+ let fallbackCount = 0;
3
+ let estimatedTokensSaved = 0;
4
+
5
+ /** Estimated tokens saved per successful rewrite (conservative). */
6
+ const TOKENS_SAVED_PER_REWRITE = 200;
7
+
8
+ /** Record a successful rewrite for tracking. */
9
+ export function recordRewrite(_command: string, _rewritten: string): void {
10
+ rewriteCount++;
11
+ estimatedTokensSaved += TOKENS_SAVED_PER_REWRITE;
12
+ }
13
+
14
+ /** Record a fallback (non-rewritable or timed-out command). */
15
+ export function recordFallback(_command: string): void {
16
+ fallbackCount++;
17
+ }
18
+
19
+ /** Get current session statistics, or null if no activity yet. */
20
+ export function getStats(): Record<string, string | number> | null {
21
+ if (rewriteCount === 0 && fallbackCount === 0) {
22
+ return null;
23
+ }
24
+ return {
25
+ rewrites: rewriteCount,
26
+ fallbacks: fallbackCount,
27
+ estimatedTokensSaved,
28
+ };
29
+ }
30
+
31
+ /** Reset all tracking state (called on session_start). */
32
+ export function resetTracking(): void {
33
+ rewriteCount = 0;
34
+ fallbackCount = 0;
35
+ estimatedTokensSaved = 0;
36
+ }