pi-rtk-optimizer 0.6.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.
@@ -30,10 +30,14 @@ function toMode(value: unknown): RtkIntegrationConfig["mode"] {
30
30
  : DEFAULT_RTK_INTEGRATION_CONFIG.mode;
31
31
  }
32
32
 
33
- function toSourceFilterLevel(value: unknown): RtkSourceFilterLevel {
33
+ function toSourceFilterLevel(value: unknown, fallback: RtkSourceFilterLevel): RtkSourceFilterLevel {
34
34
  return RTK_SOURCE_FILTER_LEVELS.includes(value as RtkSourceFilterLevel)
35
35
  ? (value as RtkSourceFilterLevel)
36
- : DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.sourceCodeFiltering;
36
+ : fallback;
37
+ }
38
+
39
+ function hasOwnProperty(source: Record<string, unknown>, key: string): boolean {
40
+ return Object.prototype.hasOwnProperty.call(source, key);
37
41
  }
38
42
 
39
43
  function toObject(value: unknown): Record<string, unknown> {
@@ -46,8 +50,20 @@ function toObject(value: unknown): Record<string, unknown> {
46
50
  export function normalizeRtkIntegrationConfig(raw: unknown): RtkIntegrationConfig {
47
51
  const source = toObject(raw);
48
52
  const outputCompactionSource = toObject(source.outputCompaction);
53
+ const readCompactionSource = toObject(outputCompactionSource.readCompaction);
49
54
  const truncateSource = toObject(outputCompactionSource.truncate);
50
55
  const smartTruncateSource = toObject(outputCompactionSource.smartTruncate);
56
+ const hasReadCompaction = hasOwnProperty(outputCompactionSource, "readCompaction");
57
+ const legacyReadCompactionFallback = !hasReadCompaction;
58
+ const sourceFilteringFallback = legacyReadCompactionFallback
59
+ ? true
60
+ : DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.sourceCodeFilteringEnabled;
61
+ const sourceFilterLevelFallback = legacyReadCompactionFallback
62
+ ? "minimal"
63
+ : DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.sourceCodeFiltering;
64
+ const smartTruncateEnabledFallback = legacyReadCompactionFallback
65
+ ? true
66
+ : DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.smartTruncate.enabled;
51
67
 
52
68
  return {
53
69
  enabled: toBoolean(source.enabled, DEFAULT_RTK_INTEGRATION_CONFIG.enabled),
@@ -69,9 +85,17 @@ export function normalizeRtkIntegrationConfig(raw: unknown): RtkIntegrationConfi
69
85
  outputCompactionSource.stripAnsi,
70
86
  DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.stripAnsi,
71
87
  ),
88
+ readCompaction: {
89
+ enabled: hasReadCompaction
90
+ ? toBoolean(
91
+ readCompactionSource.enabled,
92
+ DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.readCompaction.enabled,
93
+ )
94
+ : true,
95
+ },
72
96
  sourceCodeFilteringEnabled: toBoolean(
73
97
  outputCompactionSource.sourceCodeFilteringEnabled,
74
- DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.sourceCodeFilteringEnabled,
98
+ sourceFilteringFallback,
75
99
  ),
76
100
  preserveExactSkillReads: toBoolean(
77
101
  outputCompactionSource.preserveExactSkillReads,
@@ -89,11 +113,14 @@ export function normalizeRtkIntegrationConfig(raw: unknown): RtkIntegrationConfi
89
113
  200_000,
90
114
  ),
91
115
  },
92
- sourceCodeFiltering: toSourceFilterLevel(outputCompactionSource.sourceCodeFiltering),
116
+ sourceCodeFiltering: toSourceFilterLevel(
117
+ outputCompactionSource.sourceCodeFiltering,
118
+ sourceFilterLevelFallback,
119
+ ),
93
120
  smartTruncate: {
94
121
  enabled: toBoolean(
95
122
  smartTruncateSource.enabled,
96
- DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.smartTruncate.enabled,
123
+ smartTruncateEnabledFallback,
97
124
  ),
98
125
  maxLines: toInteger(
99
126
  smartTruncateSource.maxLines,
package/src/index-test.ts CHANGED
@@ -28,12 +28,37 @@ 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;
61
+ readCompactionEnabled?: boolean;
37
62
  sourceFilteringEnabled?: boolean;
38
63
  sourceFilteringLevel?: "none" | "minimal" | "aggressive";
39
64
  smartTruncateEnabled?: boolean;
@@ -46,6 +71,10 @@ function configWith(overrides: {
46
71
  outputCompaction: {
47
72
  ...base.outputCompaction,
48
73
  enabled: overrides.compactionEnabled ?? base.outputCompaction.enabled,
74
+ readCompaction: {
75
+ ...base.outputCompaction.readCompaction,
76
+ enabled: overrides.readCompactionEnabled ?? base.outputCompaction.readCompaction.enabled,
77
+ },
49
78
  sourceCodeFilteringEnabled:
50
79
  overrides.sourceFilteringEnabled ?? base.outputCompaction.sourceCodeFilteringEnabled,
51
80
  sourceCodeFiltering: overrides.sourceFilteringLevel ?? base.outputCompaction.sourceCodeFiltering,
@@ -86,13 +115,23 @@ runTest("bounded notice tracker coerces invalid limits to a safe minimum", () =>
86
115
  runTest("source-filter note injected when source filtering is active", () => {
87
116
  assert.equal(
88
117
  shouldInjectSourceFilterTroubleshootingNote(
89
- configWith({ sourceFilteringEnabled: true, sourceFilteringLevel: "minimal" }),
118
+ configWith({
119
+ readCompactionEnabled: true,
120
+ sourceFilteringEnabled: true,
121
+ sourceFilteringLevel: "minimal",
122
+ smartTruncateEnabled: true,
123
+ }),
90
124
  ),
91
125
  true,
92
126
  );
93
127
  assert.equal(
94
128
  shouldInjectSourceFilterTroubleshootingNote(
95
- configWith({ sourceFilteringEnabled: true, sourceFilteringLevel: "aggressive" }),
129
+ configWith({
130
+ readCompactionEnabled: true,
131
+ sourceFilteringEnabled: true,
132
+ sourceFilteringLevel: "aggressive",
133
+ smartTruncateEnabled: true,
134
+ }),
96
135
  ),
97
136
  true,
98
137
  );
@@ -106,6 +145,20 @@ runTest("source-filter note skipped when compaction is disabled", () => {
106
145
  assert.equal(shouldInjectSourceFilterTroubleshootingNote(configWith({ compactionEnabled: false })), false);
107
146
  });
108
147
 
148
+ runTest("source-filter note skipped when read compaction is disabled", () => {
149
+ assert.equal(
150
+ shouldInjectSourceFilterTroubleshootingNote(
151
+ configWith({
152
+ readCompactionEnabled: false,
153
+ sourceFilteringEnabled: true,
154
+ sourceFilteringLevel: "minimal",
155
+ smartTruncateEnabled: true,
156
+ }),
157
+ ),
158
+ false,
159
+ );
160
+ });
161
+
109
162
  runTest("source-filter note skipped when source filtering flag is off", () => {
110
163
  assert.equal(
111
164
  shouldInjectSourceFilterTroubleshootingNote(configWith({ sourceFilteringEnabled: false })),
@@ -131,4 +184,175 @@ runTest("source-filter note skipped when all read filtering safeguards are disab
131
184
  );
132
185
  });
133
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
+
134
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";
@@ -28,13 +29,14 @@ function trimMessage(raw: string, maxLength = 220): string {
28
29
  }
29
30
 
30
31
  const SOURCE_FILTER_TROUBLESHOOTING_NOTE =
31
- "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 source filtering enabled', re-read the file, apply the edit, then ask the user to manually re-enable it in the Pi TUI.";
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.";
32
33
 
33
34
  export function shouldInjectSourceFilterTroubleshootingNote(config: RtkIntegrationConfig): boolean {
34
35
  const compaction = config.outputCompaction;
35
36
  return (
36
37
  config.enabled &&
37
38
  compaction.enabled &&
39
+ compaction.readCompaction.enabled &&
38
40
  compaction.sourceCodeFilteringEnabled &&
39
41
  compaction.sourceCodeFiltering !== "none" &&
40
42
  (compaction.smartTruncate.enabled || compaction.truncate.enabled)
@@ -114,6 +116,12 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
114
116
  return `RTK rewrite: ${original} -> ${rewritten}`;
115
117
  };
116
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
+
117
125
  const warnOnce = (
118
126
  ctx: ExtensionContext | ExtensionCommandContext,
119
127
  message: string,
@@ -189,12 +197,18 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
189
197
  };
190
198
 
191
199
  const refreshRuntimeStatus = async (): Promise<RuntimeStatus> => {
200
+ let executableResolution: RtkExecutableResolution | undefined;
192
201
  try {
193
- 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 });
194
204
  if (result.code === 0) {
195
205
  runtimeStatus = {
196
206
  rtkAvailable: true,
197
207
  lastCheckedAt: Date.now(),
208
+ rtkExecutablePath: executableResolution.resolvedPath,
209
+ rtkExecutableCommand: executableResolution.command,
210
+ rtkExecutableResolver: executableResolution.resolver,
211
+ rtkExecutableResolutionWarning: executableResolution.warning,
198
212
  };
199
213
  missingRtkWarningShown = false;
200
214
  return runtimeStatus;
@@ -207,6 +221,10 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
207
221
  rtkAvailable: false,
208
222
  lastCheckedAt: Date.now(),
209
223
  lastError: detail || `exit ${result.code}`,
224
+ rtkExecutablePath: executableResolution.resolvedPath,
225
+ rtkExecutableCommand: executableResolution.command,
226
+ rtkExecutableResolver: executableResolution.resolver,
227
+ rtkExecutableResolutionWarning: executableResolution.warning,
210
228
  };
211
229
  return runtimeStatus;
212
230
  } catch (error) {
@@ -215,6 +233,10 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
215
233
  rtkAvailable: false,
216
234
  lastCheckedAt: Date.now(),
217
235
  lastError: trimMessage(message),
236
+ rtkExecutablePath: executableResolution?.resolvedPath,
237
+ rtkExecutableCommand: executableResolution?.command,
238
+ rtkExecutableResolver: executableResolution?.resolver,
239
+ rtkExecutableResolutionWarning: executableResolution?.warning,
218
240
  };
219
241
  return runtimeStatus;
220
242
  }
@@ -302,10 +324,13 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
302
324
  }
303
325
 
304
326
  trackBashCommand(eventRecord.toolCallId, eventRecord.args);
305
- sanitizeStreamingBashExecutionResult(
327
+ const sanitization = sanitizeStreamingBashExecutionResult(
306
328
  eventRecord.partialResult,
307
329
  getTrackedBashCommand(eventRecord.toolCallId),
308
330
  );
331
+ if (sanitization.changed) {
332
+ eventRecord.partialResult = sanitization.result;
333
+ }
309
334
  });
310
335
 
311
336
  pi.on("tool_execution_end", async (event) => {
@@ -316,7 +341,13 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
316
341
 
317
342
  try {
318
343
  if (config.enabled && config.outputCompaction.enabled) {
319
- 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
+ }
320
351
  }
321
352
  } finally {
322
353
  forgetTrackedBashCommand(eventRecord.toolCallId);
@@ -361,8 +392,22 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
361
392
  return {};
362
393
  }
363
394
 
364
- 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 });
365
407
  if (!decision.changed) {
408
+ if (decision.warning) {
409
+ warnOnce(ctx, formatRewriteWarning(decision.originalCommand, decision.warning));
410
+ }
366
411
  return {};
367
412
  }
368
413
 
@@ -24,6 +24,10 @@ function buildReadContent(lineCount: number): string {
24
24
  return `${lines.join("\n")}\n`;
25
25
  }
26
26
 
27
+ function setReadCompaction(config: ReturnType<typeof cloneDefaultConfig>, enabled: boolean): void {
28
+ config.outputCompaction.readCompaction = { enabled };
29
+ }
30
+
27
31
  function firstTextBlock(content: unknown[] | undefined): string {
28
32
  if (!Array.isArray(content) || content.length === 0) {
29
33
  return "";
@@ -73,6 +77,7 @@ function assertNoOutputEmoji(text: string): void {
73
77
 
74
78
  runTest("precision read with offset keeps exact output (no source/smart/hard truncation)", () => {
75
79
  const config = cloneDefaultConfig();
80
+ setReadCompaction(config, true);
76
81
  config.outputCompaction.truncate.enabled = true;
77
82
  config.outputCompaction.truncate.maxChars = 500;
78
83
  config.outputCompaction.smartTruncate.enabled = true;
@@ -94,6 +99,7 @@ runTest("precision read with offset keeps exact output (no source/smart/hard tru
94
99
 
95
100
  runTest("precision read with limit keeps exact output", () => {
96
101
  const config = cloneDefaultConfig();
102
+ setReadCompaction(config, true);
97
103
  config.outputCompaction.truncate.enabled = true;
98
104
  config.outputCompaction.truncate.maxChars = 500;
99
105
  config.outputCompaction.smartTruncate.enabled = true;
@@ -113,8 +119,34 @@ runTest("precision read with limit keeps exact output", () => {
113
119
  assert.deepEqual(result.techniques, []);
114
120
  });
115
121
 
116
- runTest("normal read compacts and adds banner", () => {
122
+ runTest("default read output stays exact when read compaction is disabled by default", () => {
123
+ const config = cloneDefaultConfig();
124
+ config.outputCompaction.sourceCodeFilteringEnabled = true;
125
+ config.outputCompaction.sourceCodeFiltering = "aggressive";
126
+ config.outputCompaction.smartTruncate.enabled = true;
127
+ config.outputCompaction.smartTruncate.maxLines = 40;
128
+ config.outputCompaction.truncate.enabled = true;
129
+ config.outputCompaction.truncate.maxChars = 500;
130
+
131
+ const content = buildReadContent(220);
132
+ const result = compactToolResult(
133
+ {
134
+ toolName: "read",
135
+ input: { path: "sample.ts" },
136
+ content: [{ type: "text", text: content }],
137
+ },
138
+ config,
139
+ );
140
+
141
+ assert.equal(result.changed, false);
142
+ assert.deepEqual(result.techniques, []);
143
+ });
144
+
145
+ runTest("normal read compacts and adds banner when read compaction is enabled", () => {
117
146
  const config = cloneDefaultConfig();
147
+ setReadCompaction(config, true);
148
+ config.outputCompaction.sourceCodeFilteringEnabled = true;
149
+ config.outputCompaction.sourceCodeFiltering = "minimal";
118
150
  config.outputCompaction.smartTruncate.enabled = true;
119
151
  config.outputCompaction.smartTruncate.maxLines = 40;
120
152
 
@@ -138,6 +170,7 @@ runTest("normal read compacts and adds banner", () => {
138
170
 
139
171
  runTest("short read output stays exact below threshold", () => {
140
172
  const config = cloneDefaultConfig();
173
+ setReadCompaction(config, true);
141
174
  const content = buildReadContent(40);
142
175
 
143
176
  const result = compactToolResult(
@@ -155,6 +188,7 @@ runTest("short read output stays exact below threshold", () => {
155
188
 
156
189
  runTest("read output stays exact at the 80-line boundary with trailing newline", () => {
157
190
  const config = cloneDefaultConfig();
191
+ setReadCompaction(config, true);
158
192
  config.outputCompaction.smartTruncate.enabled = true;
159
193
  config.outputCompaction.smartTruncate.maxLines = 40;
160
194
 
@@ -172,8 +206,11 @@ runTest("read output stays exact at the 80-line boundary with trailing newline",
172
206
  assert.deepEqual(result.techniques, []);
173
207
  });
174
208
 
175
- runTest("read output compacts once the content exceeds the 80-line exactness threshold", () => {
209
+ runTest("read output compacts once the content exceeds the 80-line exactness threshold when read compaction is enabled", () => {
176
210
  const config = cloneDefaultConfig();
211
+ setReadCompaction(config, true);
212
+ config.outputCompaction.sourceCodeFilteringEnabled = true;
213
+ config.outputCompaction.sourceCodeFiltering = "minimal";
177
214
  config.outputCompaction.smartTruncate.enabled = true;
178
215
  config.outputCompaction.smartTruncate.maxLines = 40;
179
216
 
@@ -193,6 +230,9 @@ runTest("read output compacts once the content exceeds the 80-line exactness thr
193
230
 
194
231
  runTest("source file reads skip lossy source filtering when truncation safeguards are not needed", () => {
195
232
  const config = cloneDefaultConfig();
233
+ setReadCompaction(config, true);
234
+ config.outputCompaction.sourceCodeFilteringEnabled = true;
235
+ config.outputCompaction.sourceCodeFiltering = "minimal";
196
236
  config.outputCompaction.smartTruncate.enabled = false;
197
237
  config.outputCompaction.truncate.enabled = false;
198
238
 
@@ -213,6 +253,7 @@ runTest("source file reads skip lossy source filtering when truncation safeguard
213
253
 
214
254
  runTest("skill reads stay exact when preserveExactSkillReads is enabled for user skills", () => {
215
255
  const config = cloneDefaultConfig();
256
+ setReadCompaction(config, true);
216
257
  config.outputCompaction.preserveExactSkillReads = true;
217
258
  config.outputCompaction.truncate.enabled = true;
218
259
  config.outputCompaction.truncate.maxChars = 500;
@@ -235,6 +276,7 @@ runTest("skill reads stay exact when preserveExactSkillReads is enabled for user
235
276
 
236
277
  runTest("project .pi skill reads stay exact when preserveExactSkillReads is enabled", () => {
237
278
  const config = cloneDefaultConfig();
279
+ setReadCompaction(config, true);
238
280
  config.outputCompaction.preserveExactSkillReads = true;
239
281
  config.outputCompaction.truncate.enabled = true;
240
282
  config.outputCompaction.truncate.maxChars = 500;
@@ -257,6 +299,7 @@ runTest("project .pi skill reads stay exact when preserveExactSkillReads is enab
257
299
 
258
300
  runTest("ancestor .agents skill reads stay exact when preserveExactSkillReads is enabled", () => {
259
301
  const config = cloneDefaultConfig();
302
+ setReadCompaction(config, true);
260
303
  config.outputCompaction.preserveExactSkillReads = true;
261
304
  config.outputCompaction.truncate.enabled = true;
262
305
  config.outputCompaction.truncate.maxChars = 500;