pi-rtk-optimizer 0.7.0 → 0.7.1

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/index-test.ts CHANGED
@@ -28,9 +28,33 @@ mock.module("@mariozechner/pi-tui", () => ({
28
28
  visibleWidth: (text: string) => text.length,
29
29
  }));
30
30
 
31
- const { createBoundedNoticeTracker, shouldInjectSourceFilterTroubleshootingNote } = await import("./index.ts");
31
+ const indexModule = await import("./index.ts");
32
+ const { createBoundedNoticeTracker, shouldInjectSourceFilterTroubleshootingNote } = indexModule;
33
+ const rtkIntegrationExtension = indexModule.default;
32
34
  const { DEFAULT_RTK_INTEGRATION_CONFIG } = await import("./types.ts");
33
35
 
36
+ type Notification = { message: string; level: "info" | "warning" | "error" };
37
+ type ExtensionHandler = (event: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<Record<string, unknown> | void>;
38
+
39
+ function createNotificationContext(notifications: Notification[]): Record<string, unknown> {
40
+ return {
41
+ hasUI: true,
42
+ ui: {
43
+ notify(message: string, level: "info" | "warning" | "error") {
44
+ notifications.push({ message, level });
45
+ },
46
+ },
47
+ };
48
+ }
49
+
50
+ function firstText(content: unknown): string {
51
+ if (!Array.isArray(content) || content.length === 0) {
52
+ return "";
53
+ }
54
+ const block = content[0] as { type?: string; text?: string };
55
+ return block.type === "text" && typeof block.text === "string" ? block.text : "";
56
+ }
57
+
34
58
  function configWith(overrides: {
35
59
  enabled?: boolean;
36
60
  compactionEnabled?: boolean;
@@ -160,4 +184,175 @@ runTest("source-filter note skipped when all read filtering safeguards are disab
160
184
  );
161
185
  });
162
186
 
187
+ await runTest("session_start refreshes RTK provenance and runtime guard skips missing rewrites", async () => {
188
+ const handlers: Record<string, ExtensionHandler> = {};
189
+ const notifications: Notification[] = [];
190
+ const execCommands: string[] = [];
191
+ let rtkAvailable = false;
192
+ let rewriteCalls = 0;
193
+
194
+ rtkIntegrationExtension({
195
+ exec: async (command: string, args: string[]) => {
196
+ execCommands.push(command);
197
+ if (command === "which" || command === "where") {
198
+ return { code: 0, stdout: "/opt/rtk/bin/rtk\n", stderr: "" };
199
+ }
200
+ if (args[0] === "--version") {
201
+ return rtkAvailable
202
+ ? { code: 0, stdout: "rtk 1.0.0", stderr: "" }
203
+ : { code: 1, stdout: "", stderr: "missing rtk" };
204
+ }
205
+ if (args[0] === "rewrite") {
206
+ rewriteCalls += 1;
207
+ return { code: 3, stdout: "rtk git status", stderr: "" };
208
+ }
209
+ return { code: 1, stdout: "", stderr: "unexpected" };
210
+ },
211
+ on(eventName: string, handler: ExtensionHandler) {
212
+ handlers[eventName] = handler;
213
+ },
214
+ registerCommand() {},
215
+ } as never);
216
+
217
+ const sessionStartHandler = handlers.session_start;
218
+ const toolCallHandler = handlers.tool_call;
219
+ assert.ok(sessionStartHandler);
220
+ assert.ok(toolCallHandler);
221
+
222
+ await sessionStartHandler({}, createNotificationContext(notifications));
223
+ const skippedEvent = { toolName: "bash", input: { command: "git status" } };
224
+ await toolCallHandler(skippedEvent, createNotificationContext(notifications));
225
+
226
+ assert.equal((skippedEvent.input as { command: string }).command, "git status");
227
+ assert.equal(rewriteCalls, 0);
228
+ assert.ok(notifications.some((notice) => notice.message.includes("rtk binary unavailable")));
229
+
230
+ rtkAvailable = true;
231
+ await sessionStartHandler({}, createNotificationContext(notifications));
232
+ const rewrittenEvent = { toolName: "bash", input: { command: "git status" } };
233
+ await toolCallHandler(rewrittenEvent, createNotificationContext(notifications));
234
+
235
+ assert.equal(rewriteCalls, 1);
236
+ assert.ok((rewrittenEvent.input as { command: string }).command.includes("rtk git status"));
237
+ assert.ok(execCommands.includes("/opt/rtk/bin/rtk"));
238
+ });
239
+
240
+ await runTest("tool execution lifecycle sanitizes streamed bash output", async () => {
241
+ const handlers: Record<string, ExtensionHandler> = {};
242
+
243
+ rtkIntegrationExtension({
244
+ exec: async () => ({ code: 0, stdout: "rtk 1.0.0", stderr: "" }),
245
+ on(eventName: string, handler: ExtensionHandler) {
246
+ handlers[eventName] = handler;
247
+ },
248
+ registerCommand() {},
249
+ } as never);
250
+
251
+ const startHandler = handlers.tool_execution_start;
252
+ const updateHandler = handlers.tool_execution_update;
253
+ const endHandler = handlers.tool_execution_end;
254
+ assert.ok(startHandler);
255
+ assert.ok(updateHandler);
256
+ assert.ok(endHandler);
257
+
258
+ await startHandler(
259
+ { toolName: "bash", toolCallId: "bash-1", args: { command: "rtk git status" } },
260
+ {},
261
+ );
262
+ const updateEvent = {
263
+ toolName: "bash",
264
+ toolCallId: "bash-1",
265
+ args: { command: "rtk git status" },
266
+ partialResult: {
267
+ content: [
268
+ {
269
+ type: "text",
270
+ text: "[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings\n\nworking tree clean\n",
271
+ },
272
+ ],
273
+ },
274
+ };
275
+ await updateHandler(updateEvent, {});
276
+ assert.equal(firstText(updateEvent.partialResult.content), "working tree clean\n");
277
+
278
+ const endEvent = {
279
+ toolName: "bash",
280
+ toolCallId: "bash-1",
281
+ result: { content: [{ type: "text", text: "📄 src/file.ts\n✅ Files are identical\n" }] },
282
+ };
283
+ await endHandler(endEvent, {});
284
+ assert.equal(firstText(endEvent.result.content), "> src/file.ts\n[OK] Files are identical\n");
285
+ });
286
+
287
+ await runTest("tool_result lifecycle merges compaction metadata with existing details", async () => {
288
+ const handlers: Record<string, ExtensionHandler> = {};
289
+ const notifications: Notification[] = [];
290
+
291
+ rtkIntegrationExtension({
292
+ exec: async () => ({ code: 0, stdout: "rtk 1.0.0", stderr: "" }),
293
+ on(eventName: string, handler: ExtensionHandler) {
294
+ handlers[eventName] = handler;
295
+ },
296
+ registerCommand() {},
297
+ } as never);
298
+
299
+ const toolResultHandler = handlers.tool_result;
300
+ assert.ok(toolResultHandler);
301
+ const result = await toolResultHandler(
302
+ {
303
+ toolName: "grep",
304
+ input: { pattern: "TODO" },
305
+ content: [{ type: "text", text: "src/a.ts:1:TODO\nsrc/b.ts:2:TODO\n" }],
306
+ details: { metadata: { requestId: "abc" }, traceId: "trace-1" },
307
+ },
308
+ createNotificationContext(notifications),
309
+ );
310
+
311
+ assert.ok(result);
312
+ assert.ok(firstText(result.content).startsWith("2 matches in 2 files:"));
313
+ assert.equal((result.details as { traceId?: string }).traceId, "trace-1");
314
+ const details = result.details as { rtkCompaction?: { applied: boolean }; metadata?: Record<string, unknown> };
315
+ assert.equal(details.rtkCompaction?.applied, true);
316
+ assert.deepEqual(details.metadata?.requestId, "abc");
317
+ assert.equal((details.metadata?.rtkCompaction as { applied?: boolean } | undefined)?.applied, true);
318
+ assert.equal(notifications.length, 0);
319
+ });
320
+
321
+ await runTest("tool_call surfaces RTK rewrite errors through existing UI warning path", async () => {
322
+ const handlers: Record<string, (event: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<Record<string, unknown> | void>> = {};
323
+ const notifications: Notification[] = [];
324
+
325
+ rtkIntegrationExtension({
326
+ exec: async (_command: string, args: string[]) => {
327
+ if (args[0] === "--version") {
328
+ return { code: 0, stdout: "rtk 1.0.0", stderr: "" };
329
+ }
330
+
331
+ return { code: 2, stdout: "", stderr: "denied unsafe rewrite" };
332
+ },
333
+ on(eventName: string, handler: (event: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<Record<string, unknown> | void>) {
334
+ handlers[eventName] = handler;
335
+ },
336
+ registerCommand() {},
337
+ } as never);
338
+
339
+ const toolCallHandler = handlers.tool_call;
340
+ assert.ok(toolCallHandler);
341
+ const event = { toolName: "bash", input: { command: "git status" } };
342
+ await toolCallHandler(event, {
343
+ hasUI: true,
344
+ ui: {
345
+ notify(message: string, level: "info" | "warning" | "error") {
346
+ notifications.push({ message, level });
347
+ },
348
+ },
349
+ });
350
+
351
+ assert.equal((event.input as { command: string }).command, "git status");
352
+ assert.equal(notifications.length, 1);
353
+ assert.equal(notifications[0]?.level, "warning");
354
+ assert.ok(notifications[0]?.message.includes("rtk rewrite skipped"));
355
+ assert.ok(notifications[0]?.message.includes("denied unsafe rewrite"));
356
+ });
357
+
163
358
  console.log("All index tests passed.");
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import { clearOutputMetrics, getOutputMetricsSummary } from "./output-metrics.js
13
13
  import { compactToolResult, type ToolResultCompactionMetadata } from "./output-compactor.js";
14
14
  import { toRecord } from "./record-utils.js";
15
15
  import { applyRtkCommandEnvironment } from "./rtk-command-environment.js";
16
+ import { resolveRtkExecutable, type RtkExecutableResolution } from "./rtk-executable-resolver.js";
16
17
  import { applyRewrittenCommandShellSafetyFixups } from "./rewrite-pipeline-safety.js";
17
18
  import { shouldRequireRtkAvailabilityForCommandHandling, shouldSkipCommandHandlingWhenRtkMissing } from "./runtime-guard.js";
18
19
  import { sanitizeStreamingBashExecutionResult } from "./tool-execution-sanitizer.js";
@@ -115,6 +116,12 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
115
116
  return `RTK rewrite: ${original} -> ${rewritten}`;
116
117
  };
117
118
 
119
+ const formatRewriteWarning = (command: string, warning: string): string => {
120
+ const target = trimMessage(command, 100);
121
+ const detail = trimMessage(warning, 120);
122
+ return `${EXTENSION_NAME}: rtk rewrite skipped for '${target}' (${detail}).`;
123
+ };
124
+
118
125
  const warnOnce = (
119
126
  ctx: ExtensionContext | ExtensionCommandContext,
120
127
  message: string,
@@ -190,12 +197,18 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
190
197
  };
191
198
 
192
199
  const refreshRuntimeStatus = async (): Promise<RuntimeStatus> => {
200
+ let executableResolution: RtkExecutableResolution | undefined;
193
201
  try {
194
- const result = await pi.exec("rtk", ["--version"], { timeout: 5000 });
202
+ executableResolution = await resolveRtkExecutable(pi);
203
+ const result = await pi.exec(executableResolution.command, ["--version"], { timeout: 5000 });
195
204
  if (result.code === 0) {
196
205
  runtimeStatus = {
197
206
  rtkAvailable: true,
198
207
  lastCheckedAt: Date.now(),
208
+ rtkExecutablePath: executableResolution.resolvedPath,
209
+ rtkExecutableCommand: executableResolution.command,
210
+ rtkExecutableResolver: executableResolution.resolver,
211
+ rtkExecutableResolutionWarning: executableResolution.warning,
199
212
  };
200
213
  missingRtkWarningShown = false;
201
214
  return runtimeStatus;
@@ -208,6 +221,10 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
208
221
  rtkAvailable: false,
209
222
  lastCheckedAt: Date.now(),
210
223
  lastError: detail || `exit ${result.code}`,
224
+ rtkExecutablePath: executableResolution.resolvedPath,
225
+ rtkExecutableCommand: executableResolution.command,
226
+ rtkExecutableResolver: executableResolution.resolver,
227
+ rtkExecutableResolutionWarning: executableResolution.warning,
211
228
  };
212
229
  return runtimeStatus;
213
230
  } catch (error) {
@@ -216,6 +233,10 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
216
233
  rtkAvailable: false,
217
234
  lastCheckedAt: Date.now(),
218
235
  lastError: trimMessage(message),
236
+ rtkExecutablePath: executableResolution?.resolvedPath,
237
+ rtkExecutableCommand: executableResolution?.command,
238
+ rtkExecutableResolver: executableResolution?.resolver,
239
+ rtkExecutableResolutionWarning: executableResolution?.warning,
219
240
  };
220
241
  return runtimeStatus;
221
242
  }
@@ -303,10 +324,13 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
303
324
  }
304
325
 
305
326
  trackBashCommand(eventRecord.toolCallId, eventRecord.args);
306
- sanitizeStreamingBashExecutionResult(
327
+ const sanitization = sanitizeStreamingBashExecutionResult(
307
328
  eventRecord.partialResult,
308
329
  getTrackedBashCommand(eventRecord.toolCallId),
309
330
  );
331
+ if (sanitization.changed) {
332
+ eventRecord.partialResult = sanitization.result;
333
+ }
310
334
  });
311
335
 
312
336
  pi.on("tool_execution_end", async (event) => {
@@ -317,7 +341,13 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
317
341
 
318
342
  try {
319
343
  if (config.enabled && config.outputCompaction.enabled) {
320
- sanitizeStreamingBashExecutionResult(eventRecord.result, getTrackedBashCommand(eventRecord.toolCallId));
344
+ const sanitization = sanitizeStreamingBashExecutionResult(
345
+ eventRecord.result,
346
+ getTrackedBashCommand(eventRecord.toolCallId),
347
+ );
348
+ if (sanitization.changed) {
349
+ eventRecord.result = sanitization.result;
350
+ }
321
351
  }
322
352
  } finally {
323
353
  forgetTrackedBashCommand(eventRecord.toolCallId);
@@ -362,8 +392,22 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
362
392
  return {};
363
393
  }
364
394
 
365
- const decision = await computeRewriteDecision(event.input.command, config, pi);
395
+ let executableResolution: RtkExecutableResolution | undefined;
396
+ if (runtimeStatus.rtkExecutableCommand) {
397
+ const resolver: RtkExecutableResolution["resolver"] =
398
+ runtimeStatus.rtkExecutableResolver === "where" ? "where" : "which";
399
+ executableResolution = {
400
+ command: runtimeStatus.rtkExecutableCommand,
401
+ resolvedPath: runtimeStatus.rtkExecutablePath,
402
+ resolver,
403
+ warning: runtimeStatus.rtkExecutableResolutionWarning,
404
+ };
405
+ }
406
+ const decision = await computeRewriteDecision(event.input.command, config, pi, { executableResolution });
366
407
  if (!decision.changed) {
408
+ if (decision.warning) {
409
+ warnOnce(ctx, formatRewriteWarning(decision.originalCommand, decision.warning));
410
+ }
367
411
  return {};
368
412
  }
369
413
 
@@ -190,6 +190,10 @@ function hasLossyCompaction(techniques: string[]): boolean {
190
190
  );
191
191
  }
192
192
 
193
+ function normalizeTechniqueResult(result: string | null, currentText: string): string {
194
+ return result === null ? currentText : result;
195
+ }
196
+
193
197
  function compactBashText(
194
198
  text: string,
195
199
  command: string | undefined,
@@ -207,45 +211,45 @@ function compactBashText(
207
211
  }
208
212
  }
209
213
 
210
- const withoutRtkHookWarnings = stripRtkHookWarnings(nextText, command);
211
- if (withoutRtkHookWarnings !== null && withoutRtkHookWarnings !== nextText) {
214
+ const withoutRtkHookWarnings = normalizeTechniqueResult(stripRtkHookWarnings(nextText, command), nextText);
215
+ if (withoutRtkHookWarnings !== nextText) {
212
216
  nextText = withoutRtkHookWarnings;
213
217
  techniques.push("rtk-hook-warning");
214
218
  }
215
219
 
216
- const withoutRtkEmoji = sanitizeRtkEmojiOutput(nextText, command);
217
- if (withoutRtkEmoji !== null && withoutRtkEmoji !== nextText) {
220
+ const withoutRtkEmoji = normalizeTechniqueResult(sanitizeRtkEmojiOutput(nextText, command), nextText);
221
+ if (withoutRtkEmoji !== nextText) {
218
222
  nextText = withoutRtkEmoji;
219
223
  techniques.push("rtk-emoji");
220
224
  }
221
225
 
222
226
  if (compaction.filterBuildOutput) {
223
- const compacted = filterBuildOutput(nextText, command);
224
- if (compacted !== null && compacted !== nextText) {
227
+ const compacted = normalizeTechniqueResult(filterBuildOutput(nextText, command), nextText);
228
+ if (compacted !== nextText) {
225
229
  nextText = compacted;
226
230
  techniques.push("build");
227
231
  }
228
232
  }
229
233
 
230
234
  if (compaction.aggregateTestOutput) {
231
- const compacted = aggregateTestOutput(nextText, command);
232
- if (compacted !== null && compacted !== nextText) {
235
+ const compacted = normalizeTechniqueResult(aggregateTestOutput(nextText, command), nextText);
236
+ if (compacted !== nextText) {
233
237
  nextText = compacted;
234
238
  techniques.push("test");
235
239
  }
236
240
  }
237
241
 
238
242
  if (compaction.compactGitOutput) {
239
- const compacted = compactGitOutput(nextText, command);
240
- if (compacted !== null && compacted !== nextText) {
243
+ const compacted = normalizeTechniqueResult(compactGitOutput(nextText, command), nextText);
244
+ if (compacted !== nextText) {
241
245
  nextText = compacted;
242
246
  techniques.push("git");
243
247
  }
244
248
  }
245
249
 
246
250
  if (compaction.aggregateLinterOutput) {
247
- const compacted = aggregateLinterOutput(nextText, command);
248
- if (compacted !== null && compacted !== nextText) {
251
+ const compacted = normalizeTechniqueResult(aggregateLinterOutput(nextText, command), nextText);
252
+ if (compacted !== nextText) {
249
253
  nextText = compacted;
250
254
  techniques.push("linter");
251
255
  }
@@ -288,7 +292,10 @@ function compactReadText(
288
292
  compaction.sourceCodeFiltering !== "none" &&
289
293
  shouldApplyReadSourceFiltering(text, config)
290
294
  ) {
291
- const filtered = filterSourceCode(nextText, language, compaction.sourceCodeFiltering);
295
+ const filtered = normalizeTechniqueResult(
296
+ filterSourceCode(nextText, language, compaction.sourceCodeFiltering),
297
+ nextText,
298
+ );
292
299
  if (filtered !== nextText) {
293
300
  nextText = filtered;
294
301
  techniques.push(`source:${compaction.sourceCodeFiltering}`);
@@ -332,8 +339,8 @@ function compactGrepText(text: string, config: RtkIntegrationConfig): { text: st
332
339
  }
333
340
 
334
341
  if (compaction.groupSearchOutput) {
335
- const grouped = groupSearchResults(nextText);
336
- if (grouped !== null && grouped !== nextText) {
342
+ const grouped = normalizeTechniqueResult(groupSearchResults(nextText), nextText);
343
+ if (grouped !== nextText) {
337
344
  nextText = grouped;
338
345
  techniques.push("search");
339
346
  }