pi-rtk-optimizer 0.8.1 → 0.8.3

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/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.3] - 2026-06-16
11
+
12
+ ### Fixed
13
+ - Added a runtime-agnostic `mock.module` shim so tests using `node:test` module mocking also pass under Bun's `bun:test` compatibility layer.
14
+ - Deep-cloned fallback default config objects in `config-store.ts` to prevent caller mutations from leaking into subsequent config loads.
15
+
16
+ ## [0.8.2] - 2026-06-01
17
+
18
+ ### Changed
19
+ - Deferred output compactor and configuration modal loading during extension bootstrap.
20
+ - Replaced technique barrel imports with direct module imports.
21
+ - Migrated inline test entrypoints from Bun to Node/tsx.
22
+ - Widened Pi peer dependency ranges to include `^0.77.0 || ^0.78.0`.
23
+
10
24
  ## [0.8.1] - 2026-05-26
11
25
 
12
26
  ### Changed
package/README.md CHANGED
@@ -257,7 +257,7 @@ Automatic fixes applied on Windows:
257
257
 
258
258
  - **Peer dependencies:** `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`
259
259
  - **Runtime:** Node.js ≥20, optional `rtk` binary for command rewriting
260
- - **Development verification:** Node.js ≥20, npm, and Bun for the test scripts
260
+ - **Development verification:** Node.js ≥24 and npm for Node/tsx test scripts using Node's experimental test module mocks
261
261
 
262
262
  ## Development
263
263
 
@@ -268,7 +268,7 @@ npm run build
268
268
  # Full typecheck
269
269
  npm run typecheck
270
270
 
271
- # Run Bun-based tests
271
+ # Run Node/tsx tests
272
272
  npm run test
273
273
 
274
274
  # Full verification
package/package.json CHANGED
@@ -1,67 +1,67 @@
1
- {
2
- "name": "pi-rtk-optimizer",
3
- "version": "0.8.1",
4
- "description": "Pi extension that optimizes RTK command rewriting and tool output compaction for the coding agent.",
5
- "type": "module",
6
- "main": "./index.ts",
7
- "exports": {
8
- ".": "./index.ts"
9
- },
10
- "files": [
11
- "index.ts",
12
- "src",
13
- "config/config.example.json",
14
- "README.md",
15
- "CHANGELOG.md",
16
- "LICENSE"
17
- ],
18
- "scripts": {
19
- "build": "tsc -p tsconfig.json --noCheck",
20
- "typecheck": "tsc -p tsconfig.json",
21
- "test": "bun ./src/output-compactor-test.ts && bun ./src/command-rewriter-test.ts && bun ./src/runtime-guard-test.ts && bun ./src/additional-coverage-test.ts && bun ./src/config-modal-test.ts && bun ./src/index-test.ts",
22
- "check": "npm run typecheck && npm run test && npm run build:check",
23
- "build:check": "esbuild ./index.ts --bundle --platform=node --format=esm --outfile=./.pi-rtk-optimizer-check.mjs --external:@mariozechner/pi-coding-agent --external:@mariozechner/pi-tui && node -e \"import { unlinkSync } from 'node:fs'; unlinkSync('./.pi-rtk-optimizer-check.mjs');\""
24
- },
25
- "keywords": [
26
- "pi-package",
27
- "pi",
28
- "pi-extension",
29
- "pi-coding-agent",
30
- "coding-agent",
31
- "rtk",
32
- "token-optimization",
33
- "tool-compaction",
34
- "output-compaction",
35
- "command-rewrite",
36
- "optimization"
37
- ],
38
- "author": "MasuRii",
39
- "license": "MIT",
40
- "repository": {
41
- "type": "git",
42
- "url": "git+https://github.com/MasuRii/pi-rtk-optimizer.git"
43
- },
44
- "bugs": {
45
- "url": "https://github.com/MasuRii/pi-rtk-optimizer/issues"
46
- },
47
- "homepage": "https://github.com/MasuRii/pi-rtk-optimizer#readme",
48
- "engines": {
49
- "node": ">=20"
50
- },
51
- "publishConfig": {
52
- "access": "public"
53
- },
54
- "devDependencies": {
55
- "esbuild": "0.28.0",
56
- "typescript": "6.0.3"
57
- },
58
- "pi": {
59
- "extensions": [
60
- "./index.ts"
61
- ]
62
- },
63
- "peerDependencies": {
64
- "@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0",
65
- "@earendil-works/pi-tui": "^0.74.0 || ^0.75.0"
66
- }
67
- }
1
+ {
2
+ "name": "pi-rtk-optimizer",
3
+ "version": "0.8.3",
4
+ "description": "Pi extension that optimizes RTK command rewriting and tool output compaction for the coding agent.",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "exports": {
8
+ ".": "./index.ts"
9
+ },
10
+ "files": [
11
+ "index.ts",
12
+ "src",
13
+ "config/config.example.json",
14
+ "README.md",
15
+ "CHANGELOG.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json --noCheck",
20
+ "typecheck": "tsc -p tsconfig.json",
21
+ "test": "bun ./src/output-compactor-test.ts && bun ./src/command-rewriter-test.ts && bun ./src/runtime-guard-test.ts && bun ./src/additional-coverage-test.ts && bun ./src/config-modal-test.ts && bun ./src/index-test.ts",
22
+ "check": "npm run typecheck && npm run test && npm run build:check",
23
+ "build:check": "esbuild ./index.ts --bundle --platform=node --format=esm --outfile=./.pi-rtk-optimizer-check.mjs --external:@mariozechner/pi-coding-agent --external:@mariozechner/pi-tui && node -e \"import { unlinkSync } from 'node:fs'; unlinkSync('./.pi-rtk-optimizer-check.mjs');\""
24
+ },
25
+ "keywords": [
26
+ "pi-package",
27
+ "pi",
28
+ "pi-extension",
29
+ "pi-coding-agent",
30
+ "coding-agent",
31
+ "rtk",
32
+ "token-optimization",
33
+ "tool-compaction",
34
+ "output-compaction",
35
+ "command-rewrite",
36
+ "optimization"
37
+ ],
38
+ "author": "MasuRii",
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/MasuRii/pi-rtk-optimizer.git"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/MasuRii/pi-rtk-optimizer/issues"
46
+ },
47
+ "homepage": "https://github.com/MasuRii/pi-rtk-optimizer#readme",
48
+ "engines": {
49
+ "node": ">=20"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "devDependencies": {
55
+ "esbuild": "0.28.1",
56
+ "typescript": "6.0.3"
57
+ },
58
+ "pi": {
59
+ "extensions": [
60
+ "./index.ts"
61
+ ]
62
+ },
63
+ "peerDependencies": {
64
+ "@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0 || ^0.78.0 || ^0.79.0",
65
+ "@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.78.0 || ^0.79.0"
66
+ }
67
+ }
@@ -1,9 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
- import { mock } from "bun:test";
4
3
 
5
4
  import { clearOutputMetrics, getOutputMetricsSummary, trackOutputSavings } from "./output-metrics.ts";
6
- import { runTest } from "./test-helpers.ts";
5
+ import { mock, runTest } from "./test-helpers.ts";
7
6
  import { matchesCommandPatterns, normalizeCommandForDetection } from "./techniques/command-detection.ts";
8
7
  import { compactPath } from "./techniques/path-utils.ts";
9
8
  import { applyWindowsBashCompatibilityFixes } from "./windows-command-helpers.ts";
@@ -13,9 +12,11 @@ import { sanitizeStreamingBashExecutionResult } from "./tool-execution-sanitizer
13
12
  import { sanitizeRtkEmojiOutput } from "./techniques/emoji.ts";
14
13
  import { stripRtkHookWarnings } from "./techniques/rtk.ts";
15
14
 
16
- mock.module("@earendil-works/pi-coding-agent", () => ({
17
- getAgentDir: () => "/tmp/.pi/agent",
18
- }));
15
+ mock.module("@earendil-works/pi-coding-agent", {
16
+ namedExports: {
17
+ getAgentDir: () => "/tmp/.pi/agent",
18
+ },
19
+ });
19
20
 
20
21
  const {
21
22
  ensureConfigExists,
@@ -141,6 +142,25 @@ runTest("config-store falls back to defaults when JSON is invalid", () => {
141
142
  }
142
143
  });
143
144
 
145
+ runTest("config-store malformed-file defaults are isolated from caller mutation", () => {
146
+ const tempPath = makeTempConfigPath();
147
+ cleanupFile(tempPath);
148
+
149
+ try {
150
+ writeFileSync(tempPath, "{not valid json", "utf-8");
151
+ const firstLoad = loadRtkIntegrationConfig(tempPath);
152
+ firstLoad.config.outputCompaction.truncate.maxChars = 42_424;
153
+ firstLoad.config.outputCompaction.readCompaction.enabled = true;
154
+
155
+ const secondLoad = loadRtkIntegrationConfig(tempPath);
156
+
157
+ assert.equal(secondLoad.config.outputCompaction.truncate.maxChars, 12_000);
158
+ assert.equal(secondLoad.config.outputCompaction.readCompaction.enabled, false);
159
+ } finally {
160
+ cleanupFile(tempPath);
161
+ }
162
+ });
163
+
144
164
  runTest("output metrics summarize tracked savings and clear state", () => {
145
165
  clearOutputMetrics();
146
166
  assert.equal(getOutputMetricsSummary(), "RTK output compaction metrics: no data yet.");
@@ -0,0 +1,31 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { getRtkArgumentCompletions } from "./command-completions.js";
3
+ import type { RtkIntegrationConfig, RuntimeStatus } from "./types.js";
4
+
5
+ export interface RtkIntegrationController {
6
+ getConfig(): RtkIntegrationConfig;
7
+ setConfig(next: RtkIntegrationConfig, ctx: ExtensionCommandContext): void;
8
+ getConfigPath(): string;
9
+ getRuntimeStatus(): RuntimeStatus;
10
+ refreshRuntimeStatus(): Promise<RuntimeStatus>;
11
+ getMetricsSummary(): string;
12
+ clearMetrics(): void;
13
+ }
14
+
15
+ let commandModalModulePromise: Promise<typeof import("./config-modal.js")> | undefined;
16
+
17
+ function loadCommandModalModule(): Promise<typeof import("./config-modal.js")> {
18
+ commandModalModulePromise ??= import("./config-modal.js");
19
+ return commandModalModulePromise;
20
+ }
21
+
22
+ export function registerRtkIntegrationCommand(pi: ExtensionAPI, controller: RtkIntegrationController): void {
23
+ pi.registerCommand("rtk", {
24
+ description: "Configure RTK rewrite and output compaction integration",
25
+ getArgumentCompletions: getRtkArgumentCompletions,
26
+ handler: async (args, ctx) => {
27
+ const { handleRtkIntegrationCommand } = await loadCommandModalModule();
28
+ await handleRtkIntegrationCommand(args, ctx, controller);
29
+ },
30
+ });
31
+ }
@@ -89,6 +89,26 @@ await runTest("already rtk unchanged", async () => {
89
89
  assert.equal(decision.reason, "already_rtk");
90
90
  });
91
91
 
92
+ await runTest("env-prefixed rtk command is treated as already RTK and never re-rewritten", async () => {
93
+ let execCallCount = 0;
94
+ const pi = {
95
+ exec: async () => {
96
+ execCallCount += 1;
97
+ return { code: 0, stdout: "rtk rtk status", stderr: "" };
98
+ },
99
+ } as unknown as ExtensionAPI;
100
+
101
+ const command = "CI=1 RTK_DB_PATH=/tmp/history.db rtk status";
102
+ const decision = await computeRewriteDecision(command, cloneDefaultConfig(), pi, {
103
+ executableResolution: { command: "rtk", resolver: "which" },
104
+ });
105
+
106
+ assert.equal(decision.changed, false);
107
+ assert.equal(decision.rewrittenCommand, command);
108
+ assert.equal(decision.reason, "already_rtk");
109
+ assert.equal(execCallCount, 0);
110
+ });
111
+
92
112
  await runTest("rtk unsupported heredoc result leaves command unchanged", async () => {
93
113
  const config = cloneDefaultConfig();
94
114
  const decision = await computeRewriteDecision("cat <<EOF", config, createMockPi({ code: 1 }));
@@ -109,6 +129,25 @@ await runTest("quoted heredoc marker is delegated to RTK rewrite", async () => {
109
129
  assert.equal(decision.reason, "ok");
110
130
  });
111
131
 
132
+ await runTest("rg rewrite keeps the RTK ripgrep proxy instead of the grep proxy", async () => {
133
+ const config = cloneDefaultConfig();
134
+ const command = "cd /workspace && rg -n --glob '!node_modules/**' --glob '!dist/**' \"needle\" src";
135
+ const decision = await computeRewriteDecision(
136
+ command,
137
+ config,
138
+ createMockPi({
139
+ code: 3,
140
+ stdout: "cd /workspace && rtk grep -n --glob '!node_modules/**' --glob '!dist/**' \"needle\" src",
141
+ }),
142
+ );
143
+ assert.equal(decision.changed, true);
144
+ assert.equal(
145
+ decision.rewrittenCommand,
146
+ "cd /workspace && rtk rg -n --glob '!node_modules/**' --glob '!dist/**' \"needle\" src",
147
+ );
148
+ assert.equal(decision.reason, "ok");
149
+ });
150
+
112
151
  await runTest("legacy category toggles do not pre-filter RTK rewrite source of truth", async () => {
113
152
  const config = { ...cloneDefaultConfig(), rewriteGitGithub: false };
114
153
  const decision = await computeRewriteDecision("git status", config, createMockPi({ code: 3, stdout: "rtk git status" }));
@@ -1,4 +1,5 @@
1
1
  import { resolveRtkRewrite, type RtkRewriteProviderOptions } from "./rtk-rewrite-provider.js";
2
+ import { splitLeadingEnvAssignments } from "./shell-env-prefix.js";
2
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
4
  import type { RtkIntegrationConfig } from "./types.js";
4
5
 
@@ -21,7 +22,8 @@ export async function computeRewriteDecision(
21
22
  }
22
23
 
23
24
  const trimmedStart = command.trimStart();
24
- if (trimmedStart === "rtk" || trimmedStart.startsWith("rtk ")) {
25
+ const effectiveCommand = splitLeadingEnvAssignments(trimmedStart).command.trimStart();
26
+ if (effectiveCommand === "rtk" || effectiveCommand.startsWith("rtk ")) {
25
27
  return { changed: false, originalCommand: command, rewrittenCommand: command, reason: "already_rtk" };
26
28
  }
27
29
 
@@ -1,40 +1,43 @@
1
1
  import assert from "node:assert/strict";
2
- import { mock } from "bun:test";
3
2
 
4
- import { cloneDefaultConfig, runTest } from "./test-helpers.ts";
3
+ import { cloneDefaultConfig, mock, runTest } from "./test-helpers.ts";
5
4
 
6
- mock.module("@earendil-works/pi-coding-agent", () => ({
7
- getAgentDir: () => "/tmp/.pi/agent",
8
- getSettingsListTheme: () => ({}),
9
- }));
5
+ mock.module("@earendil-works/pi-coding-agent", {
6
+ namedExports: {
7
+ getAgentDir: () => "/tmp/.pi/agent",
8
+ getSettingsListTheme: () => ({}),
9
+ },
10
+ });
10
11
 
11
12
  const settingsListInputs: string[] = [];
12
13
  const settingsListUpdates: Array<{ id: string; value: string }> = [];
13
14
 
14
- mock.module("@earendil-works/pi-tui", () => ({
15
- Box: class {
16
- addChild(): void {}
17
- },
18
- Container: class {
19
- addChild(): void {}
20
- render(): string[] {
21
- return ["settings-content"];
22
- }
23
- invalidate(): void {}
24
- },
25
- SettingsList: class {
26
- handleInput(data: string): void {
27
- settingsListInputs.push(data);
28
- }
29
- updateValue(id: string, value: string): void {
30
- settingsListUpdates.push({ id, value });
31
- }
15
+ mock.module("@earendil-works/pi-tui", {
16
+ namedExports: {
17
+ Box: class {
18
+ addChild(): void {}
19
+ },
20
+ Container: class {
21
+ addChild(): void {}
22
+ render(): string[] {
23
+ return ["settings-content"];
24
+ }
25
+ invalidate(): void {}
26
+ },
27
+ SettingsList: class {
28
+ handleInput(data: string): void {
29
+ settingsListInputs.push(data);
30
+ }
31
+ updateValue(id: string, value: string): void {
32
+ settingsListUpdates.push({ id, value });
33
+ }
34
+ },
35
+ Spacer: class {},
36
+ Text: class {},
37
+ truncateToWidth: (text: string, width: number) => text.slice(0, width),
38
+ visibleWidth: (text: string) => text.length,
32
39
  },
33
- Spacer: class {},
34
- Text: class {},
35
- truncateToWidth: (text: string, width: number) => text.slice(0, width),
36
- visibleWidth: (text: string) => text.length,
37
- }));
40
+ });
38
41
 
39
42
  function stripAnsi(text: string): string {
40
43
  return text.replace(/\x1b\[[0-9;]*m/g, "");
@@ -10,7 +10,7 @@ import {
10
10
  type RuntimeStatus,
11
11
  } from "./types.js";
12
12
 
13
- interface RtkIntegrationController {
13
+ export interface RtkIntegrationController {
14
14
  getConfig(): RtkIntegrationConfig;
15
15
  setConfig(next: RtkIntegrationConfig, ctx: ExtensionCommandContext): void;
16
16
  getConfigPath(): string;
@@ -584,21 +584,27 @@ async function handleArgs(
584
584
  return true;
585
585
  }
586
586
 
587
+ export async function handleRtkIntegrationCommand(
588
+ args: string,
589
+ ctx: ExtensionCommandContext,
590
+ controller: RtkIntegrationController,
591
+ ): Promise<void> {
592
+ if (await handleArgs(args, ctx, controller)) {
593
+ return;
594
+ }
595
+
596
+ if (!ctx.hasUI) {
597
+ ctx.ui.notify("/rtk requires interactive TUI mode.", "warning");
598
+ return;
599
+ }
600
+
601
+ await openSettingsModal(ctx, controller);
602
+ }
603
+
587
604
  export function registerRtkIntegrationCommand(pi: ExtensionAPI, controller: RtkIntegrationController): void {
588
605
  pi.registerCommand("rtk", {
589
606
  description: "Configure RTK rewrite and output compaction integration",
590
607
  getArgumentCompletions: getRtkArgumentCompletions,
591
- handler: async (args, ctx) => {
592
- if (await handleArgs(args, ctx, controller)) {
593
- return;
594
- }
595
-
596
- if (!ctx.hasUI) {
597
- ctx.ui.notify("/rtk requires interactive TUI mode.", "warning");
598
- return;
599
- }
600
-
601
- await openSettingsModal(ctx, controller);
602
- },
608
+ handler: (args, ctx) => handleRtkIntegrationCommand(args, ctx, controller),
603
609
  });
604
610
  }
@@ -177,7 +177,7 @@ export function ensureConfigExists(configPath = CONFIG_PATH): EnsureConfigResult
177
177
 
178
178
  export function loadRtkIntegrationConfig(configPath = CONFIG_PATH): ConfigLoadResult {
179
179
  if (!existsSync(configPath)) {
180
- return { config: { ...DEFAULT_RTK_INTEGRATION_CONFIG } };
180
+ return { config: structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG) };
181
181
  }
182
182
 
183
183
  try {
@@ -187,7 +187,7 @@ export function loadRtkIntegrationConfig(configPath = CONFIG_PATH): ConfigLoadRe
187
187
  } catch (error) {
188
188
  const message = error instanceof Error ? error.message : String(error);
189
189
  return {
190
- config: { ...DEFAULT_RTK_INTEGRATION_CONFIG },
190
+ config: structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG),
191
191
  warning: `Failed to parse ${configPath}: ${message}`,
192
192
  };
193
193
  }
package/src/index-test.ts CHANGED
@@ -1,32 +1,35 @@
1
1
  import assert from "node:assert/strict";
2
- import { mock } from "bun:test";
3
-
4
- import { runTest } from "./test-helpers.ts";
5
-
6
- mock.module("@earendil-works/pi-coding-agent", () => ({
7
- getAgentDir: () => "/tmp/.pi/agent",
8
- getSettingsListTheme: () => ({}),
9
- isToolCallEventType: (toolName: string, event: Record<string, unknown>) => event.toolName === toolName,
10
- }));
11
-
12
- mock.module("@earendil-works/pi-tui", () => ({
13
- Box: class {},
14
- Container: class {
15
- addChild(): void {}
16
- render(): string[] {
17
- return [];
18
- }
19
- invalidate(): void {}
2
+
3
+ import { mock, runTest } from "./test-helpers.ts";
4
+
5
+ mock.module("@earendil-works/pi-coding-agent", {
6
+ namedExports: {
7
+ getAgentDir: () => "/tmp/.pi/agent",
8
+ getSettingsListTheme: () => ({}),
9
+ isToolCallEventType: (toolName: string, event: Record<string, unknown>) => event.toolName === toolName,
20
10
  },
21
- SettingsList: class {
22
- handleInput(): void {}
23
- updateValue(): void {}
11
+ });
12
+
13
+ mock.module("@earendil-works/pi-tui", {
14
+ namedExports: {
15
+ Box: class {},
16
+ Container: class {
17
+ addChild(): void {}
18
+ render(): string[] {
19
+ return [];
20
+ }
21
+ invalidate(): void {}
22
+ },
23
+ SettingsList: class {
24
+ handleInput(): void {}
25
+ updateValue(): void {}
26
+ },
27
+ Spacer: class {},
28
+ Text: class {},
29
+ truncateToWidth: (text: string) => text,
30
+ visibleWidth: (text: string) => text.length,
24
31
  },
25
- Spacer: class {},
26
- Text: class {},
27
- truncateToWidth: (text: string) => text,
28
- visibleWidth: (text: string) => text.length,
29
- }));
32
+ });
30
33
 
31
34
  const indexModule = await import("./index.ts");
32
35
  const { createBoundedNoticeTracker, shouldInjectSourceFilterTroubleshootingNote } = indexModule;
package/src/index.ts CHANGED
@@ -7,10 +7,10 @@ import {
7
7
  saveRtkIntegrationConfig,
8
8
  } from "./config-store.js";
9
9
  import { computeRewriteDecision } from "./command-rewriter.js";
10
- import { registerRtkIntegrationCommand } from "./config-modal.js";
10
+ import { registerRtkIntegrationCommand } from "./command-register.js";
11
11
  import { EXTENSION_NAME } from "./constants.js";
12
12
  import { clearOutputMetrics, getOutputMetricsSummary } from "./output-metrics.js";
13
- import { compactToolResult, type ToolResultCompactionMetadata } from "./output-compactor.js";
13
+ import type { ToolResultCompactionMetadata } from "./output-compactor.js";
14
14
  import { toRecord } from "./record-utils.js";
15
15
  import { applyRtkCommandEnvironment } from "./rtk-command-environment.js";
16
16
  import { resolveRtkExecutable, type RtkExecutableResolution } from "./rtk-executable-resolver.js";
@@ -31,6 +31,13 @@ function trimMessage(raw: string, maxLength = 220): string {
31
31
  const SOURCE_FILTER_TROUBLESHOOTING_NOTE =
32
32
  "RTK note: If file edits repeatedly fail because old text does not match, ask the user to manually run '/rtk' in the Pi TUI, disable 'Read compaction enabled', re-read the file, apply the edit, then ask the user to manually re-enable it in the Pi TUI.";
33
33
 
34
+ let outputCompactorModulePromise: Promise<typeof import("./output-compactor.js")> | undefined;
35
+
36
+ function loadOutputCompactorModule(): Promise<typeof import("./output-compactor.js")> {
37
+ outputCompactorModulePromise ??= import("./output-compactor.js");
38
+ return outputCompactorModulePromise;
39
+ }
40
+
34
41
  export function shouldInjectSourceFilterTroubleshootingNote(config: RtkIntegrationConfig): boolean {
35
42
  const compaction = config.outputCompaction;
36
43
  return (
@@ -436,6 +443,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
436
443
  }
437
444
 
438
445
  try {
446
+ const { compactToolResult } = await loadOutputCompactorModule();
439
447
  const outcome = compactToolResult(
440
448
  {
441
449
  toolName: event.toolName,
@@ -1,14 +1,15 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { join } from "node:path";
3
- import { mock } from "bun:test";
4
3
 
5
- import { cloneDefaultConfig, runTest } from "./test-helpers.ts";
4
+ import { cloneDefaultConfig, mock, runTest } from "./test-helpers.ts";
6
5
 
7
6
  const TEST_AGENT_DIR = "/tmp/.pi/agent";
8
7
 
9
- mock.module("@earendil-works/pi-coding-agent", () => ({
10
- getAgentDir: () => TEST_AGENT_DIR,
11
- }));
8
+ mock.module("@earendil-works/pi-coding-agent", {
9
+ namedExports: {
10
+ getAgentDir: () => TEST_AGENT_DIR,
11
+ },
12
+ });
12
13
 
13
14
  const { compactToolResult } = await import("./output-compactor.ts");
14
15
 
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { resolveRtkExecutable, type RtkExecutableResolution } from "./rtk-executable-resolver.js";
3
+ import { splitLeadingEnvAssignments } from "./shell-env-prefix.js";
3
4
 
4
5
  export interface RtkRewriteProviderResult {
5
6
  changed: boolean;
@@ -29,6 +30,113 @@ function normalizeOptions(optionsOrTimeout: number | RtkRewriteProviderOptions):
29
30
  return optionsOrTimeout;
30
31
  }
31
32
 
33
+ interface ShellSegmentSplit {
34
+ segments: string[];
35
+ separators: string[];
36
+ }
37
+
38
+ function splitTopLevelShellSegments(command: string): ShellSegmentSplit {
39
+ const segments: string[] = [];
40
+ const separators: string[] = [];
41
+ let quote: '"' | "'" | "`" | null = null;
42
+ let escaped = false;
43
+ let segmentStart = 0;
44
+
45
+ for (let index = 0; index < command.length; index += 1) {
46
+ const character = command[index] ?? "";
47
+ const nextCharacter = command[index + 1] ?? "";
48
+ const previousCharacter = index > 0 ? (command[index - 1] ?? "") : "";
49
+
50
+ if (escaped) {
51
+ escaped = false;
52
+ continue;
53
+ }
54
+
55
+ if (quote !== null) {
56
+ if (character === "\\" && quote !== "'") {
57
+ escaped = true;
58
+ continue;
59
+ }
60
+ if (character === quote) {
61
+ quote = null;
62
+ }
63
+ continue;
64
+ }
65
+
66
+ if (character === "\\") {
67
+ escaped = true;
68
+ continue;
69
+ }
70
+
71
+ if (character === '"' || character === "'" || character === "`") {
72
+ quote = character;
73
+ continue;
74
+ }
75
+
76
+ const twoCharacterOperator = `${character}${nextCharacter}`;
77
+ const separator =
78
+ twoCharacterOperator === "&&" || twoCharacterOperator === "||" || twoCharacterOperator === "|&"
79
+ ? twoCharacterOperator
80
+ : character === ";" || (character === "|" && previousCharacter !== ">")
81
+ ? character
82
+ : null;
83
+
84
+ if (separator === null) {
85
+ continue;
86
+ }
87
+
88
+ segments.push(command.slice(segmentStart, index));
89
+ separators.push(separator);
90
+ index += separator.length - 1;
91
+ segmentStart = index + 1;
92
+ }
93
+
94
+ segments.push(command.slice(segmentStart));
95
+ return { segments, separators };
96
+ }
97
+
98
+ function startsWithRipgrepCommand(segment: string): boolean {
99
+ const trimmed = segment.trimStart();
100
+ const effectiveCommand = splitLeadingEnvAssignments(trimmed).command.trimStart();
101
+ return /^rg(?=\s|$)/u.test(effectiveCommand);
102
+ }
103
+
104
+ function replaceRtkGrepProxyWithRtkRg(segment: string): string {
105
+ const leadingWhitespace = segment.match(/^\s*/u)?.[0] ?? "";
106
+ const withoutLeadingWhitespace = segment.slice(leadingWhitespace.length);
107
+ const { envPrefix, command } = splitLeadingEnvAssignments(withoutLeadingWhitespace);
108
+ const nextCommand = command.replace(/^(rtk)(\s+)grep(?=\s|$)/u, "$1$2rg");
109
+ return nextCommand === command ? segment : `${leadingWhitespace}${envPrefix}${nextCommand}`;
110
+ }
111
+
112
+ function normalizeRipgrepRewrite(originalCommand: string, rewrittenCommand: string): string {
113
+ const original = splitTopLevelShellSegments(originalCommand);
114
+ const rewritten = splitTopLevelShellSegments(rewrittenCommand);
115
+ if (original.segments.length !== rewritten.segments.length) {
116
+ return rewrittenCommand;
117
+ }
118
+
119
+ let changed = false;
120
+ const rewrittenSegments = rewritten.segments.map((segment, index) => {
121
+ if (!startsWithRipgrepCommand(original.segments[index] ?? "")) {
122
+ return segment;
123
+ }
124
+
125
+ const nextSegment = replaceRtkGrepProxyWithRtkRg(segment);
126
+ changed ||= nextSegment !== segment;
127
+ return nextSegment;
128
+ });
129
+
130
+ if (!changed) {
131
+ return rewrittenCommand;
132
+ }
133
+
134
+ return rewrittenSegments.reduce((accumulator, segment, index) => {
135
+ const separator = rewritten.separators[index - 1];
136
+ return separator === undefined ? segment : `${accumulator}${separator}${segment}`;
137
+ }, "");
138
+ }
139
+
32
140
  export async function resolveRtkRewrite(
33
141
  pi: ExtensionAPI,
34
142
  command: string,
@@ -76,8 +184,8 @@ export async function resolveRtkRewrite(
76
184
  }
77
185
 
78
186
  if (result.code === 0 || result.code === 3) {
79
- const rewritten = result.stdout?.trim();
80
- if (!rewritten) {
187
+ const rewrittenOutput = result.stdout?.trim();
188
+ if (!rewrittenOutput) {
81
189
  return {
82
190
  changed: false,
83
191
  originalCommand: command,
@@ -87,6 +195,7 @@ export async function resolveRtkRewrite(
87
195
  executableResolution,
88
196
  };
89
197
  }
198
+ const rewritten = normalizeRipgrepRewrite(command, rewrittenOutput);
90
199
  if (rewritten === command) {
91
200
  return {
92
201
  changed: false,
@@ -1,6 +1,9 @@
1
+ import { mock as nodeTestMockRaw } from "node:test";
2
+
1
3
  import { DEFAULT_RTK_INTEGRATION_CONFIG, type RtkIntegrationConfig } from "./types.ts";
2
4
 
3
5
  type TestResult = void | Promise<void>;
6
+ type MockModuleOptions = { namedExports?: Record<string, unknown>; defaultExport?: unknown };
4
7
 
5
8
  function isPromiseLike(value: TestResult): value is Promise<void> {
6
9
  return Boolean(value && typeof (value as Promise<void>).then === "function");
@@ -21,3 +24,35 @@ export function runTest(name: string, testFn: () => TestResult): TestResult {
21
24
  export function cloneDefaultConfig(): RtkIntegrationConfig {
22
25
  return structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG);
23
26
  }
27
+
28
+ // Runtime-agnostic module-mocking helper. The tests use the node:test-shaped
29
+ // API (`mock.module(specifier, { namedExports, defaultExport })`). Bun's
30
+ // implementation of `node:test` does not expose `mock.module`, but `bun:test`
31
+ // provides an equivalent that accepts a factory function. We detect the
32
+ // capability and adapt the options form to the factory form when needed.
33
+ const nodeTestMock = nodeTestMockRaw as unknown as { module?: unknown };
34
+
35
+ let mockModuleImpl: (specifier: string, options: MockModuleOptions) => void;
36
+
37
+ if (typeof nodeTestMock.module === "function") {
38
+ mockModuleImpl = nodeTestMock.module as (specifier: string, options: MockModuleOptions) => void;
39
+ } else {
40
+ const bunTest = (await import("bun:test")) as unknown as {
41
+ mock: { module: (specifier: string, factory: () => Record<string, unknown>) => void };
42
+ };
43
+
44
+ mockModuleImpl = (specifier, options) => {
45
+ bunTest.mock.module(specifier, () => {
46
+ const moduleExports: Record<string, unknown> = {};
47
+ if (options.defaultExport !== undefined) {
48
+ moduleExports.default = options.defaultExport;
49
+ }
50
+ if (options.namedExports) {
51
+ Object.assign(moduleExports, options.namedExports);
52
+ }
53
+ return moduleExports;
54
+ });
55
+ };
56
+ }
57
+
58
+ export const mock = { module: mockModuleImpl };
@@ -1,80 +1,82 @@
1
- import { toRecord } from "./record-utils.js";
2
- import { sanitizeRtkEmojiOutput, stripAnsiFast, stripRtkHookWarnings } from "./techniques/index.js";
3
-
4
- interface ToolResultTextBlock {
5
- type: string;
6
- text?: string;
7
- [key: string]: unknown;
8
- }
9
-
10
- export interface StreamingBashExecutionSanitizationResult {
11
- changed: boolean;
12
- result: unknown;
13
- }
14
-
15
- function sanitizeStreamingBashText(text: string, command: string | undefined | null): string {
16
- let nextText = stripAnsiFast(text);
17
-
18
- const withoutRtkHookWarnings = stripRtkHookWarnings(nextText, command);
19
- if (withoutRtkHookWarnings !== null) {
20
- nextText = withoutRtkHookWarnings;
21
- }
22
-
23
- const withoutRtkEmoji = sanitizeRtkEmojiOutput(nextText, command);
24
- if (withoutRtkEmoji !== null) {
25
- nextText = withoutRtkEmoji;
26
- }
27
-
28
- return nextText;
29
- }
30
-
31
- /**
32
- * Returns a sanitized shallow copy of streamed bash result blocks before the
33
- * TUI renders them so RTK self-diagnostics never flash in partial or final
34
- * tool output. The input object is not mutated.
35
- */
36
- export function sanitizeStreamingBashExecutionResult(
37
- result: unknown,
38
- command: string | undefined | null,
39
- ): StreamingBashExecutionSanitizationResult {
40
- const resultRecord = toRecord(result);
41
- const sourceContent = Array.isArray(resultRecord.content) ? resultRecord.content : null;
42
- if (!sourceContent || sourceContent.length === 0) {
43
- return { changed: false, result };
44
- }
45
-
46
- let changed = false;
47
- const nextContent = sourceContent.map((block) => {
48
- if (!block || typeof block !== "object" || Array.isArray(block)) {
49
- return block;
50
- }
51
-
52
- const contentBlock = block as ToolResultTextBlock;
53
- if (contentBlock.type !== "text" || typeof contentBlock.text !== "string") {
54
- return block;
55
- }
56
-
57
- const sanitizedText = sanitizeStreamingBashText(contentBlock.text, command);
58
- if (sanitizedText === contentBlock.text) {
59
- return block;
60
- }
61
-
62
- changed = true;
63
- return {
64
- ...contentBlock,
65
- text: sanitizedText,
66
- };
67
- });
68
-
69
- if (!changed) {
70
- return { changed: false, result };
71
- }
72
-
73
- return {
74
- changed: true,
75
- result: {
76
- ...resultRecord,
77
- content: nextContent,
78
- },
79
- };
80
- }
1
+ import { toRecord } from "./record-utils.js";
2
+ import { stripAnsiFast } from "./techniques/ansi.js";
3
+ import { sanitizeRtkEmojiOutput } from "./techniques/emoji.js";
4
+ import { stripRtkHookWarnings } from "./techniques/rtk.js";
5
+
6
+ interface ToolResultTextBlock {
7
+ type: string;
8
+ text?: string;
9
+ [key: string]: unknown;
10
+ }
11
+
12
+ export interface StreamingBashExecutionSanitizationResult {
13
+ changed: boolean;
14
+ result: unknown;
15
+ }
16
+
17
+ function sanitizeStreamingBashText(text: string, command: string | undefined | null): string {
18
+ let nextText = stripAnsiFast(text);
19
+
20
+ const withoutRtkHookWarnings = stripRtkHookWarnings(nextText, command);
21
+ if (withoutRtkHookWarnings !== null) {
22
+ nextText = withoutRtkHookWarnings;
23
+ }
24
+
25
+ const withoutRtkEmoji = sanitizeRtkEmojiOutput(nextText, command);
26
+ if (withoutRtkEmoji !== null) {
27
+ nextText = withoutRtkEmoji;
28
+ }
29
+
30
+ return nextText;
31
+ }
32
+
33
+ /**
34
+ * Returns a sanitized shallow copy of streamed bash result blocks before the
35
+ * TUI renders them so RTK self-diagnostics never flash in partial or final
36
+ * tool output. The input object is not mutated.
37
+ */
38
+ export function sanitizeStreamingBashExecutionResult(
39
+ result: unknown,
40
+ command: string | undefined | null,
41
+ ): StreamingBashExecutionSanitizationResult {
42
+ const resultRecord = toRecord(result);
43
+ const sourceContent = Array.isArray(resultRecord.content) ? resultRecord.content : null;
44
+ if (!sourceContent || sourceContent.length === 0) {
45
+ return { changed: false, result };
46
+ }
47
+
48
+ let changed = false;
49
+ const nextContent = sourceContent.map((block) => {
50
+ if (!block || typeof block !== "object" || Array.isArray(block)) {
51
+ return block;
52
+ }
53
+
54
+ const contentBlock = block as ToolResultTextBlock;
55
+ if (contentBlock.type !== "text" || typeof contentBlock.text !== "string") {
56
+ return block;
57
+ }
58
+
59
+ const sanitizedText = sanitizeStreamingBashText(contentBlock.text, command);
60
+ if (sanitizedText === contentBlock.text) {
61
+ return block;
62
+ }
63
+
64
+ changed = true;
65
+ return {
66
+ ...contentBlock,
67
+ text: sanitizedText,
68
+ };
69
+ });
70
+
71
+ if (!changed) {
72
+ return { changed: false, result };
73
+ }
74
+
75
+ return {
76
+ changed: true,
77
+ result: {
78
+ ...resultRecord,
79
+ content: nextContent,
80
+ },
81
+ };
82
+ }
@@ -162,6 +162,12 @@ declare module "node:assert/strict" {
162
162
  export default assert;
163
163
  }
164
164
 
165
+ declare module "node:test" {
166
+ export const mock: {
167
+ module(specifier: string, options: { namedExports?: Record<string, unknown>; defaultExport?: unknown }): void;
168
+ };
169
+ }
170
+
165
171
  declare module "bun:test" {
166
172
  export const mock: {
167
173
  module(specifier: string, factory: () => Record<string, unknown>): void;