chore: initialize my-pi extension bundle

This commit is contained in:
云服务部-叶林立
2026-08-18 12:30:48 +08:00
parent 7b7b332912
commit 0637139a16
62 changed files with 11055 additions and 1 deletions
@@ -0,0 +1,453 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { clearOutputMetrics, getOutputMetricsSummary, trackOutputSavings } from "./output-metrics.ts";
import { mock, runTest } from "./test-helpers.test.ts";
import { matchesCommandPatterns, normalizeCommandForDetection } from "./techniques/command-detection.ts";
import { compactPath } from "./techniques/path-utils.ts";
import { filterAggressive } from "./techniques/source.ts";
import { aggregateTestOutput } from "./techniques/test-output.ts";
import { applyWindowsBashCompatibilityFixes } from "./windows-command-helpers.ts";
import { applyRewrittenCommandShellSafetyFixups } from "./rewrite-pipeline-safety.ts";
import { applyRtkCommandEnvironment } from "./rtk-command-environment.ts";
import { sanitizeStreamingBashExecutionResult } from "./tool-execution-sanitizer.ts";
mock.module("@earendil-works/pi-coding-agent", {
namedExports: {
getAgentDir: () => "/tmp/.pi/agent",
},
});
const {
ensureConfigExists,
getRtkIntegrationConfigPath,
loadRtkIntegrationConfig,
normalizeRtkIntegrationConfig,
saveRtkIntegrationConfig,
} = await import("./config-store.ts");
function makeTempConfigPath(): string {
return `${getRtkIntegrationConfigPath()}.test-${Date.now()}-${Math.random().toString(16).slice(2)}.json`;
}
function cleanupFile(path: string): void {
for (const candidate of [path, `${path}.tmp`]) {
try {
if (existsSync(candidate)) {
unlinkSync(candidate);
}
} catch {
// Ignore cleanup failures in tests.
}
}
}
runTest("config-store normalizes invalid values and clamps numeric ranges", () => {
const normalized = normalizeRtkIntegrationConfig({
enabled: "yes",
mode: "invalid",
rewriteGitGithub: false,
outputCompaction: {
stripAnsi: false,
sourceCodeFilteringEnabled: "sometimes",
sourceCodeFiltering: "extreme",
truncate: {
enabled: true,
maxChars: 12,
},
smartTruncate: {
enabled: true,
maxLines: 999_999,
},
trackSavings: false,
},
});
assert.equal(normalized.enabled, true);
assert.equal(normalized.mode, "rewrite");
assert.equal(Object.hasOwn(normalized, "rewriteGitGithub"), false);
assert.equal(normalized.outputCompaction.stripAnsi, false);
assert.equal(normalized.outputCompaction.readCompaction.enabled, true);
assert.equal(normalized.outputCompaction.sourceCodeFilteringEnabled, true);
assert.equal(normalized.outputCompaction.sourceCodeFiltering, "minimal");
assert.equal(normalized.outputCompaction.truncate.maxChars, 1_000);
assert.equal(normalized.outputCompaction.smartTruncate.maxLines, 4_000);
assert.equal(normalized.outputCompaction.trackSavings, false);
});
runTest("config-store uses safer read defaults when readCompaction is explicit", () => {
const normalized = normalizeRtkIntegrationConfig({
outputCompaction: {
readCompaction: { enabled: false },
},
});
assert.equal(normalized.outputCompaction.readCompaction.enabled, false);
assert.equal(normalized.outputCompaction.sourceCodeFilteringEnabled, false);
assert.equal(normalized.outputCompaction.sourceCodeFiltering, "none");
assert.equal(normalized.outputCompaction.smartTruncate.enabled, false);
});
runTest("config-store can ensure, save, and reload isolated config files", () => {
const tempPath = makeTempConfigPath();
cleanupFile(tempPath);
try {
const ensured = ensureConfigExists(tempPath);
assert.equal(ensured.error, undefined);
assert.equal(existsSync(tempPath), true);
const defaultLoad = loadRtkIntegrationConfig(tempPath);
assert.equal(defaultLoad.warning, undefined);
assert.equal(defaultLoad.config.mode, "rewrite");
assert.equal(defaultLoad.config.outputCompaction.readCompaction.enabled, false);
const saved = saveRtkIntegrationConfig(
{
...defaultLoad.config,
mode: "suggest",
outputCompaction: {
...defaultLoad.config.outputCompaction,
truncate: {
...defaultLoad.config.outputCompaction.truncate,
maxChars: 250_000,
},
},
},
tempPath,
);
assert.equal(saved.success, true);
const reloaded = loadRtkIntegrationConfig(tempPath);
assert.equal(reloaded.config.mode, "suggest");
assert.equal(reloaded.config.outputCompaction.truncate.maxChars, 200_000);
assert.ok(readFileSync(tempPath, "utf-8").endsWith("\n"));
} finally {
cleanupFile(tempPath);
}
});
runTest("config-store falls back to defaults when JSON is invalid", () => {
const tempPath = makeTempConfigPath();
cleanupFile(tempPath);
try {
writeFileSync(tempPath, "{not valid json", "utf-8");
const loaded = loadRtkIntegrationConfig(tempPath);
assert.equal(loaded.config.mode, "rewrite");
assert.ok((loaded.warning ?? "").includes(tempPath));
assert.ok((loaded.warning ?? "").includes("Failed to parse"));
} finally {
cleanupFile(tempPath);
}
});
runTest("config-store malformed-file defaults are isolated from caller mutation", () => {
const tempPath = makeTempConfigPath();
cleanupFile(tempPath);
try {
writeFileSync(tempPath, "{not valid json", "utf-8");
const firstLoad = loadRtkIntegrationConfig(tempPath);
firstLoad.config.outputCompaction.truncate.maxChars = 42_424;
firstLoad.config.outputCompaction.readCompaction.enabled = true;
const secondLoad = loadRtkIntegrationConfig(tempPath);
assert.equal(secondLoad.config.outputCompaction.truncate.maxChars, 12_000);
assert.equal(secondLoad.config.outputCompaction.readCompaction.enabled, false);
} finally {
cleanupFile(tempPath);
}
});
runTest("output metrics summarize tracked savings and clear state", () => {
clearOutputMetrics();
assert.equal(getOutputMetricsSummary(), "RTK output compaction metrics: no data yet.");
const first = trackOutputSavings("1234567890", "12345", "bash", ["ansi", "truncate"]);
assert.equal(first.tool, "bash");
assert.equal(first.techniques, "ansi,truncate");
assert.equal(first.savingsPercent, 50);
trackOutputSavings("123456", "1234", "read", []);
const summary = getOutputMetricsSummary();
assert.ok(summary.includes("calls=2, saved=7 chars (43.8%)"));
assert.ok(summary.includes("- bash: 1 calls, saved 5 chars (50.0%)"));
assert.ok(summary.includes("- read: 1 calls, saved 2 chars (33.3%)"));
clearOutputMetrics();
assert.equal(getOutputMetricsSummary(), "RTK output compaction metrics: no data yet.");
});
runTest("aggressive source filtering ignores string and inline comment braces while tracking implementation blocks", () => {
const withLiteralBrace = [
"function first() {",
' const value = "{";',
" return value;",
"}",
"function second() {",
" return true;",
"}",
].join("\n");
const withoutLiteralBrace = [
"function first() {",
' const value = "plain";',
" return value;",
"}",
"function second() {",
" return true;",
"}",
].join("\n");
const withInlineCommentBrace = [
"function first() {",
" const value = 1; // {",
" return value;",
"}",
"function second() {",
" return true;",
"}",
].join("\n");
const withoutInlineCommentBrace = [
"function first() {",
" const value = 1; // no brace",
" return value;",
"}",
"function second() {",
" return true;",
"}",
].join("\n");
assert.equal(filterAggressive(withLiteralBrace, "typescript"), filterAggressive(withoutLiteralBrace, "typescript"));
assert.equal(filterAggressive(withInlineCommentBrace, "typescript"), filterAggressive(withoutInlineCommentBrace, "typescript"));
});
runTest("test output fallback counts unicode pass and fail symbols", () => {
const result = aggregateTestOutput("✓ creates user\n✔ updates user\n✕ deletes user\n✗ archives user\n", "bun test");
assert.ok(result?.includes("PASS: 2 passed"));
assert.ok(result?.includes("FAIL: 2 failed"));
});
runTest("command detection ignores env prefixes, blank lines, and chained suffixes", () => {
assert.equal(normalizeCommandForDetection("NODE_ENV=test FOO=bar npm test && echo done"), "npm test");
assert.equal(normalizeCommandForDetection("\n\n PYTHONPATH=src git status\n echo later"), "git status");
assert.equal(normalizeCommandForDetection(" "), null);
assert.equal(matchesCommandPatterns("CI=1 bun test | head -5", [/^bun test/]), true);
assert.equal(matchesCommandPatterns("echo hello", [/^bun test/]), false);
});
runTest("RTK command environment preserves explicit leading RTK_DB_PATH overrides", () => {
const command = 'RTK_DB_PATH="/custom/history.db" rtk git diff';
assert.equal(applyRtkCommandEnvironment(command), command);
const singleQuotedCommand = "RTK_DB_PATH='/custom/it'\\''s/history.db' rtk git diff";
assert.equal(applyRtkCommandEnvironment(singleQuotedCommand), singleQuotedCommand);
const exportedCommand = 'export RTK_DB_PATH="/custom/history.db"; rtk git diff';
assert.equal(applyRtkCommandEnvironment(exportedCommand), exportedCommand);
});
runTest("RTK command environment respects inherited RTK_DB_PATH values", () => {
const previousRtkDbPath = process.env.RTK_DB_PATH;
const command = "rtk git status";
try {
process.env.RTK_DB_PATH = "/persistent/shared/history.db";
assert.equal(applyRtkCommandEnvironment(command), command);
} finally {
if (previousRtkDbPath === undefined) {
delete process.env.RTK_DB_PATH;
} else {
process.env.RTK_DB_PATH = previousRtkDbPath;
}
}
});
runTest("RTK command environment ignores blank inherited RTK_DB_PATH values", () => {
const previousRtkDbPath = process.env.RTK_DB_PATH;
try {
process.env.RTK_DB_PATH = " ";
assert.match(applyRtkCommandEnvironment("rtk git status"), /^export RTK_DB_PATH=/);
} finally {
if (previousRtkDbPath === undefined) {
delete process.env.RTK_DB_PATH;
} else {
process.env.RTK_DB_PATH = previousRtkDbPath;
}
}
});
runTest("RTK command environment single-quotes hostile temp paths", () => {
const previousTmpDir = process.env.TMPDIR;
const previousTmp = process.env.TMP;
const previousTemp = process.env.TEMP;
const hostilePath = process.platform === "win32" ? "C:\\Temp\\$(touch owned)`bad`'dir" : "/tmp/$(touch owned)`bad`'dir";
try {
process.env.TMPDIR = hostilePath;
process.env.TMP = hostilePath;
process.env.TEMP = hostilePath;
const rewritten = applyRtkCommandEnvironment("rtk git status");
assert.ok(rewritten.startsWith("export RTK_DB_PATH='"));
assert.ok(rewritten.includes("$(touch owned)`bad`'\\''dir"));
assert.ok(rewritten.endsWith("; rtk git status"));
assert.equal(/^export RTK_DB_PATH=\"/.test(rewritten), false);
} finally {
process.env.TMPDIR = previousTmpDir;
process.env.TMP = previousTmp;
process.env.TEMP = previousTemp;
}
});
runTest("path compaction preserves the tail and handles Windows separators", () => {
const unixPath = "/Users/example/projects/pi-rtk-optimizer/src/techniques/path-utils.ts";
const compactUnixPath = compactPath(unixPath, 28);
assert.ok(compactUnixPath.length <= 28);
assert.ok(compactUnixPath.endsWith("path-utils.ts"));
assert.ok(compactUnixPath.includes("/"));
const windowsPath = "C:\\Users\\Administrator\\Documents\\pi-rtk-optimizer\\src\\windows-command-helpers.ts";
const compactWindowsPath = compactPath(windowsPath, 30);
assert.ok(compactWindowsPath.length <= 30);
assert.equal(compactWindowsPath.includes("\\"), true);
assert.ok(compactWindowsPath.endsWith("windows-command-helpers.ts"));
assert.equal(compactPath("src/file.ts", 40), "src/file.ts");
});
runTest("windows bash compatibility rewrites only when the runtime is Windows", () => {
const command = "cd /d C:\\Users\\Administrator\\project && python script.py";
const fixed = applyWindowsBashCompatibilityFixes(command, "win32");
assert.deepEqual(fixed.applied, ["cd-/d", "python-utf8"]);
assert.equal(
fixed.command,
'PYTHONIOENCODING=utf-8 cd "C:/Users/Administrator/project" && python script.py',
);
const unchanged = applyWindowsBashCompatibilityFixes(command, "linux");
assert.deepEqual(unchanged.applied, []);
assert.equal(unchanged.command, command);
const alreadyUtf8 = applyWindowsBashCompatibilityFixes("PYTHONIOENCODING=utf-8 python script.py", "win32");
assert.deepEqual(alreadyUtf8.applied, []);
assert.equal(alreadyUtf8.command, "PYTHONIOENCODING=utf-8 python script.py");
});
runTest("windows bash compatibility rewrites compound cd slash-d operators", () => {
assert.equal(
applyWindowsBashCompatibilityFixes("cd /d C:\\work || echo failed", "win32").command,
'cd "C:/work" || echo failed',
);
assert.equal(
applyWindowsBashCompatibilityFixes("cd /d C:\\work ; echo done", "win32").command,
'cd "C:/work" ; echo done',
);
assert.equal(
applyWindowsBashCompatibilityFixes("cd /d C:\\work | cat", "win32").command,
'cd "C:/work" | cat',
);
assert.equal(
applyWindowsBashCompatibilityFixes('cd /d "C:\\work space" || echo failed', "win32").command,
'cd "C:/work space" || echo failed',
);
});
runTest("rewrite pipeline safety buffers rewritten Windows producer commands", () => {
const rewritten = applyRewrittenCommandShellSafetyFixups("rtk git diff | grep TODO", "win32");
assert.ok(rewritten.includes('mktemp'));
assert.ok(rewritten.includes('trap'));
assert.ok(rewritten.includes('rtk git diff > "$__pi_rtk_pipe_tmp"'));
assert.ok(rewritten.includes('(grep TODO) < "$__pi_rtk_pipe_tmp"'));
assert.equal(
applyRewrittenCommandShellSafetyFixups("rtk git diff | grep TODO", "linux"),
"rtk git diff | grep TODO",
);
assert.equal(applyRewrittenCommandShellSafetyFixups("git diff | grep TODO", "win32"), "git diff | grep TODO");
});
runTest("rewrite pipeline safety buffers leading pipelines before compound suffixes", () => {
const andCommand = applyRewrittenCommandShellSafetyFixups("rtk git diff | grep TODO && echo done", "win32");
assert.ok(andCommand.includes('(grep TODO) < "$__pi_rtk_pipe_tmp"'));
assert.ok(andCommand.endsWith("&& echo done"));
const orCommand = applyRewrittenCommandShellSafetyFixups("rtk git diff | grep TODO || echo none", "win32");
assert.ok(orCommand.includes('(grep TODO) < "$__pi_rtk_pipe_tmp"'));
assert.ok(orCommand.endsWith("|| echo none"));
const semicolonCommand = applyRewrittenCommandShellSafetyFixups("rtk git diff | grep TODO; echo done", "win32");
assert.ok(semicolonCommand.includes('(grep TODO) < "$__pi_rtk_pipe_tmp"'));
assert.ok(semicolonCommand.endsWith("; echo done"));
});
runTest("rewrite pipeline safety keeps exported RTK_DB_PATH on rewritten producer commands", () => {
const envScopedCommand = applyRtkCommandEnvironment("rtk git diff agent/extensions/pi-multi-auth/account-manager.ts | head -200");
const rewritten = applyRewrittenCommandShellSafetyFixups(envScopedCommand, "win32");
assert.ok(rewritten.startsWith("export RTK_DB_PATH="));
assert.equal(rewritten.startsWith("RTK_DB_PATH="), false);
assert.ok(rewritten.includes("; {"));
assert.ok(
rewritten.includes('rtk git diff agent/extensions/pi-multi-auth/account-manager.ts > "$__pi_rtk_pipe_tmp"'),
);
assert.ok(rewritten.includes('(head -200) < "$__pi_rtk_pipe_tmp"'));
assert.equal(applyRewrittenCommandShellSafetyFixups(envScopedCommand, "linux"), envScopedCommand);
});
runTest("rewrite pipeline safety buffers explicit RTK_DB_PATH export preludes", () => {
const command = 'export RTK_DB_PATH="/custom/history.db"; rtk git diff | head -200';
const rewritten = applyRewrittenCommandShellSafetyFixups(command, "win32");
assert.ok(rewritten.startsWith('export RTK_DB_PATH="/custom/history.db"; {'));
assert.ok(rewritten.includes('rtk git diff > "$__pi_rtk_pipe_tmp"'));
assert.ok(rewritten.includes('(head -200) < "$__pi_rtk_pipe_tmp"'));
assert.equal(applyRewrittenCommandShellSafetyFixups(command, "linux"), command);
});
runTest("RTK command environment uses export prelude for shell compound commands", () => {
const rewritten = applyRtkCommandEnvironment('for d in a b; do echo "$d"; done');
assert.ok(/^export RTK_DB_PATH=/.test(rewritten));
assert.ok(/; for d in a b; do echo "\$d"; done$/.test(rewritten));
});
runTest("streaming sanitizer strips ANSI codes and preserves non-text blocks", () => {
const ansiResult = {
content: [
{ type: "text", text: "\x1B[32mworking tree clean\x1B[0m\n" },
{ type: "image", url: "ignored" },
],
};
const ansiSanitization = sanitizeStreamingBashExecutionResult(ansiResult, "rtk git status");
assert.equal(ansiSanitization.changed, true);
assert.equal(
((ansiSanitization.result as typeof ansiResult).content[0] as { text: string }).text,
"working tree clean\n",
);
assert.equal((ansiResult.content[0] as { text: string }).text, "\x1B[32mworking tree clean\x1B[0m\n");
assert.deepEqual((ansiSanitization.result as typeof ansiResult).content[1], { type: "image", url: "ignored" });
const plainResult = {
content: [
{
type: "text",
text: "[rtk] warning: builtin filters: parse failure\n\nworking tree clean\n",
},
],
};
const plainSanitization = sanitizeStreamingBashExecutionResult(plainResult, "rtk git status");
assert.equal(plainSanitization.changed, false);
assert.equal(plainSanitization.result, plainResult);
assert.equal(
(plainResult.content[0] as { text: string }).text,
"[rtk] warning: builtin filters: parse failure\n\nworking tree clean\n",
);
});
console.log("All additional coverage tests passed.");
+3
View File
@@ -0,0 +1,3 @@
export function toOnOff(value: boolean, truthyLabel = "on", falsyLabel = "off"): string {
return value ? truthyLabel : falsyLabel;
}
@@ -0,0 +1,49 @@
import type { AutocompleteItem } from "@earendil-works/pi-tui";
interface CompletionDefinition {
name: string;
description: string;
}
const TOP_LEVEL_SUBCOMMANDS: CompletionDefinition[] = [
{ name: "show", description: "Show current RTK config + runtime summary" },
{ name: "path", description: "Show RTK config file path" },
{ name: "verify", description: "Check whether rtk binary is available" },
{ name: "stats", description: "Show output compaction metrics" },
{ name: "clear-stats", description: "Clear output compaction metrics" },
{ name: "reset", description: "Reset RTK settings to defaults" },
{ name: "help", description: "Show usage help" },
];
function startsWithFilter(value: string, prefix: string): boolean {
if (!prefix) {
return true;
}
return value.startsWith(prefix);
}
function mapCompletions(values: CompletionDefinition[]): AutocompleteItem[] {
return values.map((entry) => ({
value: entry.name,
label: entry.name,
description: entry.description,
}));
}
export function getRtkArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
const normalized = argumentPrefix.trimStart().toLowerCase();
if (!normalized) {
return mapCompletions(TOP_LEVEL_SUBCOMMANDS);
}
if (normalized.includes(" ")) {
return null;
}
const filtered = TOP_LEVEL_SUBCOMMANDS.filter((entry) => startsWithFilter(entry.name, normalized));
if (filtered.length === 0) {
return null;
}
return mapCompletions(filtered);
}
+27
View File
@@ -0,0 +1,27 @@
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import { getRtkArgumentCompletions } from "./command-completions.js";
import { createLazyModuleLoader } from "./lazy-module-loader.js";
import type { RtkIntegrationConfig, RuntimeStatus } from "./types.js";
export interface RtkIntegrationController {
getConfig(): RtkIntegrationConfig;
setConfig(next: RtkIntegrationConfig, ctx: ExtensionCommandContext): void;
getConfigPath(): string;
getRuntimeStatus(): RuntimeStatus;
refreshRuntimeStatus(): Promise<RuntimeStatus>;
getMetricsSummary(): string;
clearMetrics(): void;
}
const loadCommandModalModule = createLazyModuleLoader<typeof import("./config-modal.js")>("./config-modal.js");
export function registerRtkIntegrationCommand(pi: ExtensionAPI, controller: RtkIntegrationController): void {
pi.registerCommand("rtk", {
description: "Configure RTK rewrite and output compaction integration",
getArgumentCompletions: getRtkArgumentCompletions,
handler: async (args, ctx) => {
const { handleRtkIntegrationCommand } = await loadCommandModalModule();
await handleRtkIntegrationCommand(args, ctx, controller);
},
});
}
@@ -0,0 +1,224 @@
import assert from "node:assert/strict";
import { computeRewriteDecision } from "./command-rewriter.ts";
import { resolveRtkRewrite } from "./rtk-rewrite-provider.ts";
import { cloneDefaultConfig, runTest } from "./test-helpers.test.ts";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
function createMockPi(execResult: { code: number; stdout?: string; stderr?: string }): ExtensionAPI {
return {
exec: async (command: string) => {
if (command === "which" || command === "where") {
return { code: 0, stdout: "/usr/local/bin/rtk\n", stderr: "" };
}
return execResult;
},
} as unknown as ExtensionAPI;
}
await runTest("rtk rewrite uses resolved POSIX executable path", async () => {
const calls: Array<{ command: string; args: string[] }> = [];
const pi = {
exec: async (command: string, args: string[]) => {
calls.push({ command, args });
if (command === "which") {
return { code: 0, stdout: "/opt/rtk/bin/rtk\n", stderr: "" };
}
return { code: 3, stdout: "rtk git status", stderr: "" };
},
} as unknown as ExtensionAPI;
const result = await resolveRtkRewrite(pi, "git status", { platform: "linux" });
assert.equal(result.changed, true);
assert.equal(result.rewrittenCommand, "rtk git status");
assert.equal(result.executableResolution?.resolvedPath, "/opt/rtk/bin/rtk");
assert.deepEqual(calls.map((call) => call.command), ["which", "/opt/rtk/bin/rtk"]);
});
await runTest("rtk rewrite uses resolved Windows executable path", async () => {
const calls: Array<{ command: string; args: string[] }> = [];
const pi = {
exec: async (command: string, args: string[]) => {
calls.push({ command, args });
if (command === "where") {
return { code: 0, stdout: "C:\\Tools\\rtk.exe\r\nC:\\Other\\rtk.exe\r\n", stderr: "" };
}
return { code: 3, stdout: "rtk git status", stderr: "" };
},
} as unknown as ExtensionAPI;
const result = await resolveRtkRewrite(pi, "git status", { platform: "win32" });
assert.equal(result.changed, true);
assert.equal(result.executableResolution?.resolvedPath, "C:\\Tools\\rtk.exe");
assert.deepEqual(calls.map((call) => call.command), ["where", "C:\\Tools\\rtk.exe"]);
});
await runTest("rtk rewrite preserves behavior when executable path resolution fails", async () => {
const calls: Array<{ command: string; args: string[] }> = [];
const pi = {
exec: async (command: string, args: string[]) => {
calls.push({ command, args });
if (command === "which") {
return { code: 1, stdout: "", stderr: "not found" };
}
return { code: 3, stdout: "rtk git status", stderr: "" };
},
} as unknown as ExtensionAPI;
const result = await resolveRtkRewrite(pi, "git status", { platform: "linux" });
assert.equal(result.changed, true);
assert.equal(result.executableResolution?.command, "rtk");
assert.ok(result.executableResolution?.warning?.includes("which failed"));
assert.deepEqual(calls.map((call) => call.command), ["which", "rtk"]);
});
await runTest("empty command unchanged", async () => {
const config = cloneDefaultConfig();
const decision = await computeRewriteDecision("", config, createMockPi({ code: 1 }));
assert.equal(decision.changed, false);
assert.equal(decision.reason, "empty");
});
await runTest("already rtk unchanged", async () => {
const config = cloneDefaultConfig();
const decision = await computeRewriteDecision("rtk status", config, createMockPi({ code: 1 }));
assert.equal(decision.changed, false);
assert.equal(decision.reason, "already_rtk");
});
await runTest("env-prefixed rtk command is treated as already RTK and never re-rewritten", async () => {
let execCallCount = 0;
const pi = {
exec: async () => {
execCallCount += 1;
return { code: 0, stdout: "rtk rtk status", stderr: "" };
},
} as unknown as ExtensionAPI;
const command = "CI=1 RTK_DB_PATH=/tmp/history.db rtk status";
const decision = await computeRewriteDecision(command, cloneDefaultConfig(), pi, {
executableResolution: { command: "rtk", resolver: "which" },
});
assert.equal(decision.changed, false);
assert.equal(decision.rewrittenCommand, command);
assert.equal(decision.reason, "already_rtk");
assert.equal(execCallCount, 0);
});
await runTest("rtk unsupported heredoc result leaves command unchanged", async () => {
const config = cloneDefaultConfig();
const decision = await computeRewriteDecision("cat <<EOF", config, createMockPi({ code: 1 }));
assert.equal(decision.changed, false);
assert.equal(decision.reason, "no_match");
});
await runTest("quoted heredoc marker is delegated to RTK rewrite", async () => {
const config = cloneDefaultConfig();
const command = 'echo "<<not heredoc" && git status';
const decision = await computeRewriteDecision(
command,
config,
createMockPi({ code: 3, stdout: 'echo "<<not heredoc" && rtk git status' }),
);
assert.equal(decision.changed, true);
assert.equal(decision.rewrittenCommand, 'echo "<<not heredoc" && rtk git status');
assert.equal(decision.reason, "ok");
});
await runTest("rg rewrite delegates to rtk grep proxy", async () => {
const config = cloneDefaultConfig();
const command = "cd /workspace && rg -n --glob '!node_modules/**' --glob '!dist/**' \"needle\" src";
const rewritten = "cd /workspace && rtk grep -n --glob '!node_modules/**' --glob '!dist/**' \"needle\" src";
const decision = await computeRewriteDecision(
command,
config,
createMockPi({
code: 3,
stdout: rewritten,
}),
);
assert.equal(decision.changed, true);
assert.equal(decision.rewrittenCommand, rewritten);
assert.equal(decision.reason, "ok");
});
await runTest("legacy category toggles do not pre-filter RTK rewrite source of truth", async () => {
const config = { ...cloneDefaultConfig(), rewriteGitGithub: false };
const decision = await computeRewriteDecision("git status", config, createMockPi({ code: 3, stdout: "rtk git status" }));
assert.equal(decision.changed, true);
assert.equal(decision.rewrittenCommand, "rtk git status");
assert.equal(decision.reason, "ok");
});
await runTest("rtk exit 0 rewrites", async () => {
const config = cloneDefaultConfig();
const decision = await computeRewriteDecision("git status", config, createMockPi({ code: 0, stdout: "rtk git status" }));
assert.equal(decision.changed, true);
assert.equal(decision.rewrittenCommand, "rtk git status");
assert.equal(decision.reason, "ok");
});
await runTest("rtk exit 3 rewrites", async () => {
const config = cloneDefaultConfig();
const decision = await computeRewriteDecision("git status", config, createMockPi({ code: 3, stdout: "rtk git status" }));
assert.equal(decision.changed, true);
assert.equal(decision.rewrittenCommand, "rtk git status");
assert.equal(decision.reason, "ok");
});
await runTest("exit 1 leaves unchanged", async () => {
const config = cloneDefaultConfig();
const decision = await computeRewriteDecision("git status", config, createMockPi({ code: 1 }));
assert.equal(decision.changed, false);
assert.equal(decision.reason, "no_match");
});
await runTest("exit 2 leaves unchanged and surfaces RTK detail", async () => {
const config = cloneDefaultConfig();
const decision = await computeRewriteDecision("git status", config, createMockPi({ code: 2, stderr: "denied" }));
assert.equal(decision.changed, false);
assert.equal(decision.reason, "no_match");
assert.equal(decision.warning, "denied");
});
await runTest("unknown category passes through to RTK", async () => {
const config = cloneDefaultConfig();
const pi = createMockPi({ code: 0, stdout: "rtk custom" });
const decision = await computeRewriteDecision("custom-cmd", config, pi);
assert.equal(decision.changed, true);
assert.equal(decision.rewrittenCommand, "rtk custom");
assert.equal(decision.reason, "ok");
});
await runTest("exec error/timeout leaves unchanged and surfaces error detail", async () => {
const config = cloneDefaultConfig();
const pi = {
exec: async () => {
throw new Error("timeout");
},
} as unknown as ExtensionAPI;
const decision = await computeRewriteDecision("git status", config, pi);
assert.equal(decision.changed, false);
assert.equal(decision.reason, "no_match");
assert.equal(decision.warning, "timeout");
});
await runTest("compound commands forwarded to RTK", async () => {
const config = cloneDefaultConfig();
let capturedArgs: string[] = [];
const pi = {
exec: async (_cmd: string, args: string[]) => {
capturedArgs = args;
return { code: 0, stdout: "rtk result" };
},
} as unknown as ExtensionAPI;
const decision = await computeRewriteDecision("git status && cargo test", config, pi);
assert.equal(decision.changed, true);
assert.deepEqual(capturedArgs, ["rewrite", "git status && cargo test"]);
});
console.log("All command-rewriter tests passed.");
+48
View File
@@ -0,0 +1,48 @@
import { resolveRtkRewrite, type RtkRewriteProviderOptions } from "./rtk-rewrite-provider.js";
import { splitLeadingEnvAssignments } from "./shell-env-prefix.js";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { RtkIntegrationConfig } from "./types.js";
export interface RewriteDecision {
changed: boolean;
originalCommand: string;
rewrittenCommand: string;
reason: "ok" | "empty" | "already_rtk" | "no_match";
warning?: string;
}
export async function computeRewriteDecision(
command: string,
_config: RtkIntegrationConfig,
pi: ExtensionAPI,
rewriteOptions: RtkRewriteProviderOptions = {},
): Promise<RewriteDecision> {
if (!command || !command.trim()) {
return { changed: false, originalCommand: command, rewrittenCommand: command, reason: "empty" };
}
const trimmedStart = command.trimStart();
const effectiveCommand = splitLeadingEnvAssignments(trimmedStart).command.trimStart();
if (effectiveCommand === "rtk" || effectiveCommand.startsWith("rtk ")) {
return { changed: false, originalCommand: command, rewrittenCommand: command, reason: "already_rtk" };
}
const result = await resolveRtkRewrite(pi, command, rewriteOptions);
if (result.changed && result.rewrittenCommand) {
return {
changed: true,
originalCommand: command,
rewrittenCommand: result.rewrittenCommand,
reason: "ok",
};
}
return {
changed: false,
originalCommand: command,
rewrittenCommand: command,
reason: "no_match",
warning: result.error,
};
}
+240
View File
@@ -0,0 +1,240 @@
import assert from "node:assert/strict";
import { cloneDefaultConfig, mock, runTest } from "./test-helpers.test.ts";
mock.module("@earendil-works/pi-coding-agent", {
namedExports: {
getAgentDir: () => "/tmp/.pi/agent",
getSettingsListTheme: () => ({}),
},
});
const settingsListInputs: string[] = [];
const settingsListUpdates: Array<{ id: string; value: string }> = [];
mock.module("@earendil-works/pi-tui", {
namedExports: {
Box: class {
addChild(): void {}
},
Container: class {
addChild(): void {}
render(): string[] {
return ["settings-content"];
}
invalidate(): void {}
},
SettingsList: class {
handleInput(data: string): void {
settingsListInputs.push(data);
}
updateValue(id: string, value: string): void {
settingsListUpdates.push({ id, value });
}
},
Spacer: class {},
Text: class {},
truncateToWidth: (text: string, width: number) => text.slice(0, width),
visibleWidth: (text: string) => text.length,
},
});
function stripAnsi(text: string): string {
return text.replace(/\x1b\[[0-9;]*m/g, "");
}
const { registerRtkIntegrationCommand } = await import("./command-register.ts");
const { ZellijModal, ZellijSettingsModal } = await import("./zellij-modal.ts");
const { getRtkArgumentCompletions } = await import("./command-completions.ts");
type Notification = { message: string; level: "info" | "warning" | "error" };
interface CommandContextStub {
hasUI: boolean;
ui: {
notify(message: string, level: "info" | "warning" | "error"): void;
custom<T>(): Promise<T>;
};
}
function createNotifyContext(hasUI: boolean): { ctx: CommandContextStub; notifications: Notification[] } {
const notifications: Notification[] = [];
return {
ctx: {
hasUI,
ui: {
notify(message: string, level: "info" | "warning" | "error") {
notifications.push({ message, level });
},
async custom<T>(): Promise<T> {
throw new Error("custom UI should not be invoked in config-modal tests");
},
},
},
notifications,
};
}
function lastNotification(notifications: Notification[]): Notification {
return notifications[notifications.length - 1] as Notification;
}
function createThemeStub(): { fg: (_name: string, text: string) => string; bold: (text: string) => string } {
return {
fg: (_name: string, text: string) => text,
bold: (text: string) => text,
};
}
runTest("zellij settings modal renders overlay frame and delegates non-enter input", () => {
settingsListInputs.length = 0;
settingsListUpdates.length = 0;
const settingsModal = new ZellijSettingsModal(
{
title: "RTK Integration Settings",
settings: [
{
id: "enabled",
label: "Enabled",
description: "Enable integration",
currentValue: "on",
values: ["on", "off"],
},
],
onChange: () => {},
onClose: () => {},
helpText: "Esc: close",
},
createThemeStub() as never,
);
const modal = new ZellijModal(settingsModal, {
titleBar: {
left: { text: "RTK Integration Settings", maxWidth: 30, color: "accent" },
right: { text: "pi-rtk-optimizer", maxWidth: 20, color: "dim" },
},
helpUndertitle: { text: "Esc: close", color: "dim" },
overlay: { anchor: "center", width: 86, maxHeight: "85%", margin: 1 },
});
const rendered = modal.renderModal(86);
settingsModal.handleInput("\r");
settingsModal.handleInput("j");
settingsModal.updateValue("enabled", "off");
assert.equal(rendered.visibleWidth, 86);
assert.equal(rendered.contentWidth, 82);
assert.ok(stripAnsi(rendered.lines[0] ?? "").includes("RTK Integration Settings"));
assert.ok(stripAnsi(rendered.lines[rendered.lines.length - 1] ?? "").includes("Esc: close"));
assert.deepEqual(modal.getOverlayOptions(), {
overlay: true,
overlayOptions: { anchor: "center", width: 86, maxHeight: "85%", margin: 1 },
});
assert.deepEqual(settingsListInputs, ["j"]);
assert.deepEqual(settingsListUpdates, [{ id: "enabled", value: "off" }]);
});
runTest("command completions return top-level and filtered RTK subcommands", () => {
const topLevel = getRtkArgumentCompletions("");
assert.ok(Array.isArray(topLevel));
assert.ok(topLevel.some((item) => item.value === "show"));
assert.ok(topLevel.some((item) => item.value === "clear-stats"));
const filtered = getRtkArgumentCompletions("st");
assert.deepEqual(
filtered?.map((item) => item.value),
["stats"],
);
assert.equal(getRtkArgumentCompletions("show extra"), null);
assert.equal(getRtkArgumentCompletions("zzz"), null);
});
await runTest("config modal command handlers route RTK subcommands to controller actions", async () => {
const config = cloneDefaultConfig();
const controllerState = {
config,
cleared: 0,
refreshed: 0,
lastSavedMode: "",
};
const controller = {
getConfig: () => controllerState.config,
setConfig: (next: typeof config, _ctx: unknown) => {
controllerState.config = next;
controllerState.lastSavedMode = next.mode;
},
getConfigPath: () => "C:/tmp/pi-rtk-optimizer/config.json",
getRuntimeStatus: () => ({ rtkAvailable: false, lastError: "not found" }),
refreshRuntimeStatus: async () => {
controllerState.refreshed += 1;
return { rtkAvailable: true, rtkExecutablePath: "C:/Tools/rtk.exe" };
},
getMetricsSummary: () => "metrics summary",
clearMetrics: () => {
controllerState.cleared += 1;
},
};
let registeredName = "";
type RegisteredCommandDefinition = {
description: string;
getArgumentCompletions?: (argumentPrefix: string) => Array<{ value: string; label: string; description?: string }> | null;
handler: (args: string, ctx: CommandContextStub) => Promise<void>;
};
let definition: RegisteredCommandDefinition | undefined;
registerRtkIntegrationCommand(
{
registerCommand(name: string, nextDefinition: RegisteredCommandDefinition) {
registeredName = name;
definition = nextDefinition;
},
} as never,
controller as never,
);
assert.equal(registeredName, "rtk");
if (!definition) {
throw new Error("Expected /rtk command definition to be registered");
}
assert.ok(definition.description.includes("Configure RTK rewrite"));
assert.ok(typeof definition.getArgumentCompletions === "function");
const infoCtx = createNotifyContext(true);
await definition.handler("help", infoCtx.ctx);
assert.ok(lastNotification(infoCtx.notifications).message.includes("Usage: /rtk"));
await definition.handler("show", infoCtx.ctx);
assert.ok(lastNotification(infoCtx.notifications).message.includes("mode=rewrite"));
assert.ok(lastNotification(infoCtx.notifications).message.includes("rewriteSource=rtk"));
assert.equal(lastNotification(infoCtx.notifications).message.includes("categories="), false);
await definition.handler("path", infoCtx.ctx);
assert.equal(lastNotification(infoCtx.notifications).message, "rtk config: C:/tmp/pi-rtk-optimizer/config.json");
await definition.handler("verify", infoCtx.ctx);
assert.equal(controllerState.refreshed, 1);
assert.equal(lastNotification(infoCtx.notifications).level, "info");
assert.ok(lastNotification(infoCtx.notifications).message.includes("available at C:/Tools/rtk.exe"));
await definition.handler("stats", infoCtx.ctx);
assert.equal(lastNotification(infoCtx.notifications).message, "metrics summary");
await definition.handler("clear-stats", infoCtx.ctx);
assert.equal(controllerState.cleared, 1);
assert.equal(lastNotification(infoCtx.notifications).message, "RTK metrics cleared.");
await definition.handler("reset", infoCtx.ctx);
assert.equal(controllerState.lastSavedMode, "rewrite");
assert.equal(lastNotification(infoCtx.notifications).message, "RTK integration settings reset to defaults.");
await definition.handler("unknown", infoCtx.ctx);
assert.equal(lastNotification(infoCtx.notifications).level, "warning");
assert.ok(lastNotification(infoCtx.notifications).message.includes("Usage: /rtk"));
const headlessCtx = createNotifyContext(false);
await definition.handler("", headlessCtx.ctx);
assert.equal(lastNotification(headlessCtx.notifications).message, "/rtk requires interactive TUI mode.");
});
console.log("All config-modal tests passed.");
+586
View File
@@ -0,0 +1,586 @@
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import type { SettingItem } from "@earendil-works/pi-tui";
import { toOnOff } from "./boolean-format.js";
import type { RtkIntegrationController } from "./command-register.js";
import { ZellijModal, ZellijSettingsModal } from "./zellij-modal.js";
import {
DEFAULT_RTK_INTEGRATION_CONFIG,
RTK_SOURCE_FILTER_LEVELS,
type RtkIntegrationConfig,
type RuntimeStatus,
} from "./types.js";
interface SettingValueSyncTarget {
updateValue(id: string, value: string): void;
}
const ON_OFF = ["on", "off"];
const MODE_VALUES = ["rewrite", "suggest"];
const SOURCE_FILTER_VALUES = [...RTK_SOURCE_FILTER_LEVELS];
const TRUNCATE_MAX_CHAR_VALUES = ["4000", "8000", "12000", "20000", "50000", "100000", "200000"];
const SMART_TRUNCATE_LINE_VALUES = ["40", "80", "120", "160", "220", "320", "500", "1000", "2000", "4000"];
const RTK_USAGE_TEXT =
"Usage: /rtk [show|path|verify|stats|clear-stats|reset|help] (or run /rtk with no args to open settings modal)";
const SETTINGS_TAB_DEFINITIONS = [
{
label: "General",
settingIds: ["enabled", "mode", "showRewriteNotifications", "guardWhenRtkMissing"],
},
{
label: "Compaction",
settingIds: [
"outputCompactionEnabled",
"outputStripAnsi",
"outputAggregateTestOutput",
"outputFilterBuildOutput",
"outputCompactGitOutput",
"outputAggregateLinterOutput",
"outputGroupSearchOutput",
"outputTrackSavings",
],
},
{
label: "Read & Source",
settingIds: [
"outputReadCompactionEnabled",
"outputSourceFilteringEnabled",
"outputSourceFiltering",
"outputPreserveExactSkillReads",
],
},
{
label: "Truncation",
settingIds: [
"outputTruncateEnabled",
"outputTruncateMaxChars",
"outputSmartTruncate",
"outputSmartTruncateMaxLines",
],
},
] as const;
function buildTabbedSettingGroups(settings: SettingItem[]): Array<{ label: string; settings: SettingItem[] }> {
const byId = new Map(settings.map((setting) => [setting.id, setting]));
const assignedIds = new Set<string>();
const tabs = SETTINGS_TAB_DEFINITIONS.map(({ label, settingIds }) => ({
label,
settings: settingIds.map((id) => {
const setting = byId.get(id);
if (!setting) {
throw new Error(`Missing setting item for tab '${label}': ${id}`);
}
if (assignedIds.has(id)) {
throw new Error(`Setting item assigned to multiple tabs: ${id}`);
}
assignedIds.add(id);
return setting;
}),
}));
const unassignedIds = settings.map((setting) => setting.id).filter((id) => !assignedIds.has(id));
if (unassignedIds.length > 0) {
throw new Error(`Unassigned setting items: ${unassignedIds.join(", ")}`);
}
return tabs;
}
function parseSourceFilterLevel(
value: string,
): RtkIntegrationConfig["outputCompaction"]["sourceCodeFiltering"] | undefined {
return SOURCE_FILTER_VALUES.includes(value as (typeof SOURCE_FILTER_VALUES)[number])
? (value as RtkIntegrationConfig["outputCompaction"]["sourceCodeFiltering"])
: undefined;
}
function parseIntegerInRange(value: string, min: number, max: number): number | undefined {
if (!/^\d+$/.test(value)) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
return undefined;
}
return parsed;
}
function summarizeRuntimeStatus(runtimeStatus: RuntimeStatus): string {
const runtime = runtimeStatus.rtkAvailable
? "rtk=available"
: `rtk=missing${runtimeStatus.lastError ? ` (${runtimeStatus.lastError})` : ""}`;
const executable = runtimeStatus.rtkExecutablePath
? `, rtkPath=${runtimeStatus.rtkExecutablePath}`
: runtimeStatus.rtkExecutableResolutionWarning
? `, rtkPath=unresolved (${runtimeStatus.rtkExecutableResolutionWarning})`
: "";
return `${runtime}${executable}`;
}
function summarizeConfig(config: RtkIntegrationConfig, runtimeStatus: RuntimeStatus): string {
return `enabled=${config.enabled}, commandRewriting=${config.commandRewritingEnabled}, mode=${config.mode}, rewriteSource=rtk, rewriteNotice=${config.showRewriteNotifications}, compaction=${config.outputCompaction.enabled}, readCompaction=${config.outputCompaction.readCompaction.enabled}, sourceFilterEnabled=${config.outputCompaction.sourceCodeFilteringEnabled}, preserveSkillReads=${config.outputCompaction.preserveExactSkillReads}, sourceFilter=${config.outputCompaction.sourceCodeFiltering}, ${summarizeRuntimeStatus(runtimeStatus)}`;
}
function buildSettingItems(config: RtkIntegrationConfig): SettingItem[] {
return [
{
id: "enabled",
label: "RTK integration enabled",
description: "Master switch for rewrite, suggestions, and output compaction",
currentValue: toOnOff(config.enabled),
values: ON_OFF,
},
{
id: "commandRewritingEnabled",
label: "RTK command rewriting",
description: "Optional RTK CLI command rewriting; off keeps FFF in full control of search",
currentValue: toOnOff(config.commandRewritingEnabled),
values: ON_OFF,
},
{
id: "mode",
label: "Rewrite mode",
description: "rewrite = auto-rewrite bash commands, suggest = notify only",
currentValue: config.mode,
values: MODE_VALUES,
},
{
id: "showRewriteNotifications",
label: "Show rewrite notifications",
description: "Show 'RTK rewrite: old -> new' notice in TUI",
currentValue: toOnOff(config.showRewriteNotifications),
values: ON_OFF,
},
{
id: "guardWhenRtkMissing",
label: "Guard when rtk missing",
description: "If on, raw commands run unchanged when rtk binary is unavailable",
currentValue: toOnOff(config.guardWhenRtkMissing),
values: ON_OFF,
},
{
id: "outputCompactionEnabled",
label: "Output compaction enabled",
description: "Compact bash/read tool results to reduce token usage; search results are untouched",
currentValue: toOnOff(config.outputCompaction.enabled),
values: ON_OFF,
},
{
id: "outputStripAnsi",
label: "Strip ANSI in output",
description: "Remove color/control codes from tool output before further compaction",
currentValue: toOnOff(config.outputCompaction.stripAnsi),
values: ON_OFF,
},
{
id: "outputReadCompactionEnabled",
label: "Read compaction enabled",
description: "If off, read tool output stays exact; build/test/git/grep compaction can still run",
currentValue: toOnOff(config.outputCompaction.readCompaction.enabled),
values: ON_OFF,
},
{
id: "outputTruncateEnabled",
label: "Hard truncation enabled",
description: "Apply max character cap after other compaction techniques",
currentValue: toOnOff(config.outputCompaction.truncate.enabled),
values: ON_OFF,
},
{
id: "outputTruncateMaxChars",
label: "Hard truncation max chars",
description: "Maximum characters kept when hard truncation is enabled",
currentValue: String(config.outputCompaction.truncate.maxChars),
values: TRUNCATE_MAX_CHAR_VALUES,
},
{
id: "outputSourceFilteringEnabled",
label: "Read source filtering enabled",
description: "If off, read output skips source-code filtering regardless of selected level",
currentValue: toOnOff(config.outputCompaction.sourceCodeFilteringEnabled),
values: ON_OFF,
},
{
id: "outputPreserveExactSkillReads",
label: "Preserve exact skill reads",
description: "If on, read results under the global Pi skills directory (default: ~/.pi/agent/skills, respects PI_CODING_AGENT_DIR), ~/.agents/skills, .pi/skills, and ancestor .agents/skills skip read compaction",
currentValue: toOnOff(config.outputCompaction.preserveExactSkillReads),
values: ON_OFF,
},
{
id: "outputSourceFiltering",
label: "Read source filtering",
description: "none|minimal|aggressive for read output compaction",
currentValue: config.outputCompaction.sourceCodeFiltering,
values: SOURCE_FILTER_VALUES,
},
{
id: "outputSmartTruncate",
label: "Read smart truncation",
description: "Keep signatures/imports when read output has many lines",
currentValue: toOnOff(config.outputCompaction.smartTruncate.enabled),
values: ON_OFF,
},
{
id: "outputSmartTruncateMaxLines",
label: "Read smart truncation max lines",
description: "Target max lines for smart truncation in read outputs",
currentValue: String(config.outputCompaction.smartTruncate.maxLines),
values: SMART_TRUNCATE_LINE_VALUES,
},
{
id: "outputAggregateTestOutput",
label: "Aggregate test output",
description: "Summarize test command output to failures and key totals",
currentValue: toOnOff(config.outputCompaction.aggregateTestOutput),
values: ON_OFF,
},
{
id: "outputFilterBuildOutput",
label: "Filter build output",
description: "Reduce build noise and keep key error/warning lines",
currentValue: toOnOff(config.outputCompaction.filterBuildOutput),
values: ON_OFF,
},
{
id: "outputCompactGitOutput",
label: "Compact git output",
description: "Condense git command output for lower token usage",
currentValue: toOnOff(config.outputCompaction.compactGitOutput),
values: ON_OFF,
},
{
id: "outputAggregateLinterOutput",
label: "Aggregate linter output",
description: "Summarize linter output by file and issue type",
currentValue: toOnOff(config.outputCompaction.aggregateLinterOutput),
values: ON_OFF,
},
{
id: "outputTrackSavings",
label: "Track output savings",
description: "Collect in-session compaction metrics for /rtk stats",
currentValue: toOnOff(config.outputCompaction.trackSavings),
values: ON_OFF,
},
];
}
function applySetting(config: RtkIntegrationConfig, id: string, value: string): RtkIntegrationConfig {
switch (id) {
case "enabled":
return { ...config, enabled: value === "on" };
case "commandRewritingEnabled":
return { ...config, commandRewritingEnabled: value === "on" };
case "mode":
return { ...config, mode: value === "suggest" ? "suggest" : "rewrite" };
case "showRewriteNotifications":
return { ...config, showRewriteNotifications: value === "on" };
case "guardWhenRtkMissing":
return { ...config, guardWhenRtkMissing: value === "on" };
case "outputCompactionEnabled":
return {
...config,
outputCompaction: { ...config.outputCompaction, enabled: value === "on" },
};
case "outputStripAnsi":
return {
...config,
outputCompaction: { ...config.outputCompaction, stripAnsi: value === "on" },
};
case "outputReadCompactionEnabled":
return {
...config,
outputCompaction: {
...config.outputCompaction,
readCompaction: { enabled: value === "on" },
},
};
case "outputTruncateEnabled":
return {
...config,
outputCompaction: {
...config.outputCompaction,
truncate: {
...config.outputCompaction.truncate,
enabled: value === "on",
},
},
};
case "outputTruncateMaxChars": {
const parsed = parseIntegerInRange(value, 1_000, 200_000);
return {
...config,
outputCompaction: {
...config.outputCompaction,
truncate: {
...config.outputCompaction.truncate,
maxChars: parsed ?? config.outputCompaction.truncate.maxChars,
},
},
};
}
case "outputSourceFilteringEnabled":
return {
...config,
outputCompaction: {
...config.outputCompaction,
sourceCodeFilteringEnabled: value === "on",
},
};
case "outputPreserveExactSkillReads":
return {
...config,
outputCompaction: {
...config.outputCompaction,
preserveExactSkillReads: value === "on",
},
};
case "outputSourceFiltering": {
const parsedValue = parseSourceFilterLevel(value);
return {
...config,
outputCompaction: {
...config.outputCompaction,
sourceCodeFiltering: parsedValue ?? config.outputCompaction.sourceCodeFiltering,
},
};
}
case "outputSmartTruncate":
return {
...config,
outputCompaction: {
...config.outputCompaction,
smartTruncate: {
...config.outputCompaction.smartTruncate,
enabled: value === "on",
},
},
};
case "outputSmartTruncateMaxLines": {
const parsed = parseIntegerInRange(value, 40, 4_000);
return {
...config,
outputCompaction: {
...config.outputCompaction,
smartTruncate: {
...config.outputCompaction.smartTruncate,
maxLines: parsed ?? config.outputCompaction.smartTruncate.maxLines,
},
},
};
}
case "outputAggregateTestOutput":
return {
...config,
outputCompaction: {
...config.outputCompaction,
aggregateTestOutput: value === "on",
},
};
case "outputFilterBuildOutput":
return {
...config,
outputCompaction: {
...config.outputCompaction,
filterBuildOutput: value === "on",
},
};
case "outputCompactGitOutput":
return {
...config,
outputCompaction: {
...config.outputCompaction,
compactGitOutput: value === "on",
},
};
case "outputAggregateLinterOutput":
return {
...config,
outputCompaction: {
...config.outputCompaction,
aggregateLinterOutput: value === "on",
},
};
case "outputTrackSavings":
return {
...config,
outputCompaction: {
...config.outputCompaction,
trackSavings: value === "on",
},
};
default:
return config;
}
}
function syncSettingValues(settingsList: SettingValueSyncTarget, config: RtkIntegrationConfig): void {
settingsList.updateValue("enabled", toOnOff(config.enabled));
settingsList.updateValue("commandRewritingEnabled", toOnOff(config.commandRewritingEnabled));
settingsList.updateValue("mode", config.mode);
settingsList.updateValue("showRewriteNotifications", toOnOff(config.showRewriteNotifications));
settingsList.updateValue("guardWhenRtkMissing", toOnOff(config.guardWhenRtkMissing));
settingsList.updateValue("outputCompactionEnabled", toOnOff(config.outputCompaction.enabled));
settingsList.updateValue("outputStripAnsi", toOnOff(config.outputCompaction.stripAnsi));
settingsList.updateValue("outputReadCompactionEnabled", toOnOff(config.outputCompaction.readCompaction.enabled));
settingsList.updateValue("outputTruncateEnabled", toOnOff(config.outputCompaction.truncate.enabled));
settingsList.updateValue("outputTruncateMaxChars", String(config.outputCompaction.truncate.maxChars));
settingsList.updateValue("outputSourceFilteringEnabled", toOnOff(config.outputCompaction.sourceCodeFilteringEnabled));
settingsList.updateValue("outputPreserveExactSkillReads", toOnOff(config.outputCompaction.preserveExactSkillReads));
settingsList.updateValue("outputSourceFiltering", config.outputCompaction.sourceCodeFiltering);
settingsList.updateValue("outputSmartTruncate", toOnOff(config.outputCompaction.smartTruncate.enabled));
settingsList.updateValue("outputSmartTruncateMaxLines", String(config.outputCompaction.smartTruncate.maxLines));
settingsList.updateValue("outputAggregateTestOutput", toOnOff(config.outputCompaction.aggregateTestOutput));
settingsList.updateValue("outputFilterBuildOutput", toOnOff(config.outputCompaction.filterBuildOutput));
settingsList.updateValue("outputCompactGitOutput", toOnOff(config.outputCompaction.compactGitOutput));
settingsList.updateValue("outputAggregateLinterOutput", toOnOff(config.outputCompaction.aggregateLinterOutput));
settingsList.updateValue("outputTrackSavings", toOnOff(config.outputCompaction.trackSavings));
}
async function openSettingsModal(ctx: ExtensionCommandContext, controller: RtkIntegrationController): Promise<void> {
const overlayOptions = { anchor: "center" as const, width: 86, maxHeight: "85%" as const, margin: 1 };
await ctx.ui.custom<void>(
(tui, theme, _keybindings, done) => {
let current = controller.getConfig();
let settingsModal: ZellijSettingsModal | null = null;
const allSettings = buildSettingItems(current);
const tabs = buildTabbedSettingGroups(allSettings);
settingsModal = new ZellijSettingsModal(
{
title: "Pi RTK Optimizer",
tabs,
activeTabIndex: 0,
onChange: (id, newValue) => {
current = applySetting(current, id, newValue);
controller.setConfig(current, ctx);
current = controller.getConfig();
if (settingsModal) {
syncSettingValues(settingsModal, current);
}
},
onClose: () => done(),
helpText: `Config: ${controller.getConfigPath()}`,
enableSearch: true,
},
theme,
);
const modal = new ZellijModal(
settingsModal,
{
borderStyle: "rounded",
titleBar: {
left: "Pi RTK Optimizer",
},
helpUndertitle: {
variants: [
"←/→ tabs • Type to search • Enter/Space change • Esc close",
"←/→ tabs • Type to search • Esc close",
"←/→ tabs • Esc close",
],
color: "dim",
},
overlay: overlayOptions,
},
theme,
);
return {
render(width: number) {
return modal.renderModal(width).lines;
},
invalidate() {
modal.invalidate();
},
handleInput(data: string) {
modal.handleInput(data);
tui.requestRender();
},
};
},
{ overlay: true, overlayOptions },
);
}
async function handleArgs(
args: string,
ctx: ExtensionCommandContext,
controller: RtkIntegrationController,
): Promise<boolean> {
const normalized = (args ?? "").trim().toLowerCase();
if (!normalized) {
return false;
}
if (normalized === "help") {
ctx.ui.notify(RTK_USAGE_TEXT, "info");
return true;
}
if (normalized === "show") {
ctx.ui.notify(`rtk: ${summarizeConfig(controller.getConfig(), controller.getRuntimeStatus())}`, "info");
return true;
}
if (normalized === "path") {
ctx.ui.notify(`rtk config: ${controller.getConfigPath()}`, "info");
return true;
}
if (normalized === "verify") {
const runtimeStatus = await controller.refreshRuntimeStatus();
if (runtimeStatus.rtkAvailable) {
const pathDetail = runtimeStatus.rtkExecutablePath ? ` at ${runtimeStatus.rtkExecutablePath}` : "";
ctx.ui.notify(`RTK binary is available${pathDetail}.`, "info");
} else {
ctx.ui.notify(
`RTK binary is not available${runtimeStatus.lastError ? `: ${runtimeStatus.lastError}` : ""}.`,
"warning",
);
}
return true;
}
if (normalized === "stats") {
ctx.ui.notify(controller.getMetricsSummary(), "info");
return true;
}
if (normalized === "clear-stats") {
controller.clearMetrics();
ctx.ui.notify("RTK metrics cleared.", "info");
return true;
}
if (normalized === "reset") {
controller.setConfig({ ...DEFAULT_RTK_INTEGRATION_CONFIG }, ctx);
ctx.ui.notify("RTK integration settings reset to defaults.", "info");
return true;
}
ctx.ui.notify(RTK_USAGE_TEXT, "warning");
return true;
}
export async function handleRtkIntegrationCommand(
args: string,
ctx: ExtensionCommandContext,
controller: RtkIntegrationController,
): Promise<void> {
if (await handleArgs(args, ctx, controller)) {
return;
}
if (!ctx.hasUI) {
ctx.ui.notify("/rtk requires interactive TUI mode.", "warning");
return;
}
await openSettingsModal(ctx, controller);
}
+223
View File
@@ -0,0 +1,223 @@
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { CONFIG_PATH } from "./constants.js";
import { toRecord } from "./record-utils.js";
import {
DEFAULT_RTK_INTEGRATION_CONFIG,
RTK_MODES,
RTK_SOURCE_FILTER_LEVELS,
type ConfigLoadResult,
type ConfigSaveResult,
type EnsureConfigResult,
type RtkIntegrationConfig,
type RtkSourceFilterLevel,
} from "./types.js";
function toBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function toInteger(value: unknown, fallback: number, min: number, max: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
const rounded = Math.round(value);
return Math.max(min, Math.min(max, rounded));
}
function toMode(value: unknown): RtkIntegrationConfig["mode"] {
return RTK_MODES.includes(value as RtkIntegrationConfig["mode"])
? (value as RtkIntegrationConfig["mode"])
: DEFAULT_RTK_INTEGRATION_CONFIG.mode;
}
function toSourceFilterLevel(value: unknown, fallback: RtkSourceFilterLevel): RtkSourceFilterLevel {
return RTK_SOURCE_FILTER_LEVELS.includes(value as RtkSourceFilterLevel)
? (value as RtkSourceFilterLevel)
: fallback;
}
function hasOwnProperty(source: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(source, key);
}
export function normalizeRtkIntegrationConfig(raw: unknown): RtkIntegrationConfig {
const source = toRecord(raw);
const outputCompactionSource = toRecord(source.outputCompaction);
const readCompactionSource = toRecord(outputCompactionSource.readCompaction);
const truncateSource = toRecord(outputCompactionSource.truncate);
const smartTruncateSource = toRecord(outputCompactionSource.smartTruncate);
const hasReadCompaction = hasOwnProperty(outputCompactionSource, "readCompaction");
const legacyReadCompactionFallback = !hasReadCompaction;
const sourceFilteringFallback = legacyReadCompactionFallback
? true
: DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.sourceCodeFilteringEnabled;
const sourceFilterLevelFallback = legacyReadCompactionFallback
? "minimal"
: DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.sourceCodeFiltering;
const smartTruncateEnabledFallback = legacyReadCompactionFallback
? true
: DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.smartTruncate.enabled;
return {
enabled: toBoolean(source.enabled, DEFAULT_RTK_INTEGRATION_CONFIG.enabled),
commandRewritingEnabled: toBoolean(
source.commandRewritingEnabled,
DEFAULT_RTK_INTEGRATION_CONFIG.commandRewritingEnabled,
),
mode: toMode(source.mode),
guardWhenRtkMissing: toBoolean(
source.guardWhenRtkMissing,
DEFAULT_RTK_INTEGRATION_CONFIG.guardWhenRtkMissing,
),
showRewriteNotifications: toBoolean(
source.showRewriteNotifications,
DEFAULT_RTK_INTEGRATION_CONFIG.showRewriteNotifications,
),
outputCompaction: {
enabled: toBoolean(
outputCompactionSource.enabled,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.enabled,
),
stripAnsi: toBoolean(
outputCompactionSource.stripAnsi,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.stripAnsi,
),
readCompaction: {
enabled: hasReadCompaction
? toBoolean(
readCompactionSource.enabled,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.readCompaction.enabled,
)
: true,
},
sourceCodeFilteringEnabled: toBoolean(
outputCompactionSource.sourceCodeFilteringEnabled,
sourceFilteringFallback,
),
preserveExactSkillReads: toBoolean(
outputCompactionSource.preserveExactSkillReads,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.preserveExactSkillReads,
),
truncate: {
enabled: toBoolean(
truncateSource.enabled,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.truncate.enabled,
),
maxChars: toInteger(
truncateSource.maxChars,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.truncate.maxChars,
1_000,
200_000,
),
},
sourceCodeFiltering: toSourceFilterLevel(
outputCompactionSource.sourceCodeFiltering,
sourceFilterLevelFallback,
),
smartTruncate: {
enabled: toBoolean(
smartTruncateSource.enabled,
smartTruncateEnabledFallback,
),
maxLines: toInteger(
smartTruncateSource.maxLines,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.smartTruncate.maxLines,
40,
4_000,
),
},
aggregateTestOutput: toBoolean(
outputCompactionSource.aggregateTestOutput,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.aggregateTestOutput,
),
filterBuildOutput: toBoolean(
outputCompactionSource.filterBuildOutput,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.filterBuildOutput,
),
compactGitOutput: toBoolean(
outputCompactionSource.compactGitOutput,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.compactGitOutput,
),
aggregateLinterOutput: toBoolean(
outputCompactionSource.aggregateLinterOutput,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.aggregateLinterOutput,
),
trackSavings: toBoolean(
outputCompactionSource.trackSavings,
DEFAULT_RTK_INTEGRATION_CONFIG.outputCompaction.trackSavings,
),
},
};
}
export function ensureConfigExists(configPath = CONFIG_PATH): EnsureConfigResult {
if (existsSync(configPath)) {
return { created: false };
}
try {
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(configPath, `${JSON.stringify(DEFAULT_RTK_INTEGRATION_CONFIG, null, 2)}\n`, "utf-8");
return { created: true };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
created: false,
error: `Failed to create ${configPath}: ${message}`,
};
}
}
export function loadRtkIntegrationConfig(configPath = CONFIG_PATH): ConfigLoadResult {
if (!existsSync(configPath)) {
return { config: structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG) };
}
try {
const rawText = readFileSync(configPath, "utf-8");
const parsed = JSON.parse(rawText) as unknown;
return { config: normalizeRtkIntegrationConfig(parsed) };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
config: structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG),
warning: `Failed to parse ${configPath}: ${message}`,
};
}
}
export function saveRtkIntegrationConfig(
config: RtkIntegrationConfig,
configPath = CONFIG_PATH,
): ConfigSaveResult {
const normalized = normalizeRtkIntegrationConfig(config);
const tmpPath = `${configPath}.tmp`;
try {
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(tmpPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf-8");
renameSync(tmpPath, configPath);
return { success: true };
} catch (error) {
try {
if (existsSync(tmpPath)) {
unlinkSync(tmpPath);
}
} catch (cleanupError) {
// Best-effort cleanup: a stale tmp-file removal failure must not
// mask the original save error reported below.
void cleanupError;
}
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Failed to save ${configPath}: ${message}`,
};
}
}
export function getRtkIntegrationConfigPath(configPath = CONFIG_PATH): string {
return configPath;
}
+6
View File
@@ -0,0 +1,6 @@
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { join } from "node:path";
export const EXTENSION_NAME = "pi-rtk-optimizer";
export const CONFIG_DIR = join(getAgentDir(), "extensions", EXTENSION_NAME);
export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
+445
View File
@@ -0,0 +1,445 @@
import assert from "node:assert/strict";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { mock, runTest } from "./test-helpers.test.ts";
mock.module("@earendil-works/pi-coding-agent", {
namedExports: {
getAgentDir: () => "/tmp/.pi/agent",
getSettingsListTheme: () => ({}),
isToolCallEventType: (toolName: string, event: Record<string, unknown>) => event.toolName === toolName,
},
});
mock.module("@earendil-works/pi-tui", {
namedExports: {
Box: class {},
Container: class {
addChild(): void {}
render(): string[] {
return [];
}
invalidate(): void {}
},
SettingsList: class {
handleInput(): void {}
updateValue(): void {}
},
Spacer: class {},
Text: class {},
truncateToWidth: (text: string) => text,
visibleWidth: (text: string) => text.length,
},
});
const indexModule = await import("./index.ts");
const { createBoundedNoticeTracker, shouldInjectSourceFilterTroubleshootingNote, injectGuidelineIntoPrompt } = indexModule;
const rtkIntegrationExtension = indexModule.default;
const { DEFAULT_RTK_INTEGRATION_CONFIG } = await import("./types.ts");
const { CONFIG_PATH } = await import("./constants.ts");
function writeTestConfig(commandRewritingEnabled: boolean): void {
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
writeFileSync(
CONFIG_PATH,
`${JSON.stringify({ ...DEFAULT_RTK_INTEGRATION_CONFIG, commandRewritingEnabled }, null, 2)}\n`,
"utf-8",
);
}
type Notification = { message: string; level: "info" | "warning" | "error" };
type ExtensionHandler = (event: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<Record<string, unknown> | void>;
function createNotificationContext(notifications: Notification[]): Record<string, unknown> {
return {
hasUI: true,
ui: {
notify(message: string, level: "info" | "warning" | "error") {
notifications.push({ message, level });
},
},
};
}
function firstText(content: unknown): string {
if (!Array.isArray(content) || content.length === 0) {
return "";
}
const block = content[0] as { type?: string; text?: string };
return block.type === "text" && typeof block.text === "string" ? block.text : "";
}
function configWith(overrides: {
enabled?: boolean;
compactionEnabled?: boolean;
readCompactionEnabled?: boolean;
sourceFilteringEnabled?: boolean;
sourceFilteringLevel?: "none" | "minimal" | "aggressive";
smartTruncateEnabled?: boolean;
truncateEnabled?: boolean;
}): typeof DEFAULT_RTK_INTEGRATION_CONFIG {
const base = DEFAULT_RTK_INTEGRATION_CONFIG;
return {
...base,
enabled: overrides.enabled ?? base.enabled,
outputCompaction: {
...base.outputCompaction,
enabled: overrides.compactionEnabled ?? base.outputCompaction.enabled,
readCompaction: {
...base.outputCompaction.readCompaction,
enabled: overrides.readCompactionEnabled ?? base.outputCompaction.readCompaction.enabled,
},
sourceCodeFilteringEnabled:
overrides.sourceFilteringEnabled ?? base.outputCompaction.sourceCodeFilteringEnabled,
sourceCodeFiltering: overrides.sourceFilteringLevel ?? base.outputCompaction.sourceCodeFiltering,
smartTruncate: {
...base.outputCompaction.smartTruncate,
enabled: overrides.smartTruncateEnabled ?? base.outputCompaction.smartTruncate.enabled,
},
truncate: {
...base.outputCompaction.truncate,
enabled: overrides.truncateEnabled ?? base.outputCompaction.truncate.enabled,
},
},
};
}
runTest("bounded notice tracker evicts old entries and supports reset", () => {
const tracker = createBoundedNoticeTracker(2);
assert.equal(tracker.remember("first"), true);
assert.equal(tracker.remember("second"), true);
assert.equal(tracker.remember("first"), false);
assert.equal(tracker.remember("third"), true);
assert.equal(tracker.remember("second"), false);
assert.equal(tracker.remember("first"), true);
tracker.reset();
assert.equal(tracker.remember("third"), true);
});
runTest("bounded notice tracker coerces invalid limits to a safe minimum", () => {
const tracker = createBoundedNoticeTracker(0);
assert.equal(tracker.remember("alpha"), true);
assert.equal(tracker.remember("beta"), true);
assert.equal(tracker.remember("alpha"), true);
});
runTest("source-filter note injected when source filtering is active", () => {
assert.equal(
shouldInjectSourceFilterTroubleshootingNote(
configWith({
readCompactionEnabled: true,
sourceFilteringEnabled: true,
sourceFilteringLevel: "minimal",
smartTruncateEnabled: true,
}),
),
true,
);
assert.equal(
shouldInjectSourceFilterTroubleshootingNote(
configWith({
readCompactionEnabled: true,
sourceFilteringEnabled: true,
sourceFilteringLevel: "aggressive",
smartTruncateEnabled: true,
}),
),
true,
);
});
runTest("source-filter note skipped when extension is disabled", () => {
assert.equal(shouldInjectSourceFilterTroubleshootingNote(configWith({ enabled: false })), false);
});
runTest("source-filter note skipped when compaction is disabled", () => {
assert.equal(shouldInjectSourceFilterTroubleshootingNote(configWith({ compactionEnabled: false })), false);
});
runTest("source-filter note skipped when read compaction is disabled", () => {
assert.equal(
shouldInjectSourceFilterTroubleshootingNote(
configWith({
readCompactionEnabled: false,
sourceFilteringEnabled: true,
sourceFilteringLevel: "minimal",
smartTruncateEnabled: true,
}),
),
false,
);
});
runTest("source-filter note skipped when source filtering flag is off", () => {
assert.equal(
shouldInjectSourceFilterTroubleshootingNote(configWith({ sourceFilteringEnabled: false })),
false,
);
});
runTest("source-filter note skipped when filtering level is 'none'", () => {
assert.equal(
shouldInjectSourceFilterTroubleshootingNote(
configWith({ sourceFilteringEnabled: true, sourceFilteringLevel: "none" }),
),
false,
);
});
runTest("source-filter note skipped when all read filtering safeguards are disabled", () => {
assert.equal(
shouldInjectSourceFilterTroubleshootingNote(
configWith({ smartTruncateEnabled: false, truncateEnabled: false }),
),
false,
);
});
runTest("injectGuidelineIntoPrompt inserts bullet inside Guidelines section when header is at index 0", () => {
const guideline = "Test guideline for RTK troubleshooting.";
const prompt = [
"Guidelines:",
"- Use mcp for MCP discovery first",
"- Be concise in your responses",
"",
"<project_context>",
"Project-specific instructions",
].join("\n");
const result = injectGuidelineIntoPrompt(prompt, guideline);
assert.ok(result.includes(guideline), "guideline must be present");
const bullet = `- ${guideline}`;
assert.ok(result.includes(bullet), "guideline must be a bullet line");
const guidelinesStart = result.indexOf("Guidelines:\n");
const bulletIndex = result.indexOf(bullet);
const projectContextIndex = result.indexOf("\n<project_context>");
assert.equal(guidelinesStart, 0, "Guidelines header must be at index 0");
assert.ok(bulletIndex > guidelinesStart, "bullet must be after Guidelines header");
assert.ok(projectContextIndex !== -1, "project_context section must exist");
assert.ok(bulletIndex < projectContextIndex, "bullet must be before project_context section");
assert.equal(injectGuidelineIntoPrompt(result, guideline), result, "should be idempotent");
});
runTest("injectGuidelineIntoPrompt inserts bullet inside Guidelines section when header is mid-prompt", () => {
const guideline = "Test guideline for RTK troubleshooting.";
const prompt = [
"You are an expert coding assistant.",
"",
"Guidelines:",
"- Be concise in your responses",
"",
"Pi documentation:",
"- Main documentation: /path/to/readme",
].join("\n");
const result = injectGuidelineIntoPrompt(prompt, guideline);
const bullet = `- ${guideline}`;
assert.ok(result.includes(bullet), "guideline must be a bullet line");
const guidelinesStart = result.indexOf("\nGuidelines:\n");
const bulletIndex = result.indexOf(bullet);
const piDocsIndex = result.indexOf("\nPi documentation:");
assert.ok(guidelinesStart !== -1, "Guidelines header must exist");
assert.ok(bulletIndex > guidelinesStart, "bullet must be after Guidelines header");
assert.ok(bulletIndex < piDocsIndex, "bullet must be before Pi documentation section");
});
runTest("injectGuidelineIntoPrompt falls back to appending when no Guidelines section exists", () => {
const guideline = "Test guideline for RTK troubleshooting.";
const prompt = "You are a coding assistant with no guidelines section.";
const result = injectGuidelineIntoPrompt(prompt, guideline);
assert.ok(result.includes(guideline), "guideline must be present");
assert.ok(result.endsWith(guideline), "guideline must be appended at the end");
});
await runTest("session_start refreshes RTK provenance and runtime guard skips missing rewrites", async () => {
writeTestConfig(true);
const handlers: Record<string, ExtensionHandler> = {};
const notifications: Notification[] = [];
const execCommands: string[] = [];
let rtkAvailable = false;
let rewriteCalls = 0;
rtkIntegrationExtension({
exec: async (command: string, args: string[]) => {
execCommands.push(command);
if (command === "which" || command === "where") {
return { code: 0, stdout: "/opt/rtk/bin/rtk\n", stderr: "" };
}
if (args[0] === "--version") {
return rtkAvailable
? { code: 0, stdout: "rtk 1.0.0", stderr: "" }
: { code: 1, stdout: "", stderr: "missing rtk" };
}
if (args[0] === "rewrite") {
rewriteCalls += 1;
return { code: 3, stdout: "rtk git status", stderr: "" };
}
return { code: 1, stdout: "", stderr: "unexpected" };
},
on(eventName: string, handler: ExtensionHandler) {
handlers[eventName] = handler;
},
registerCommand() {},
} as never);
const sessionStartHandler = handlers.session_start;
const toolCallHandler = handlers.tool_call;
assert.ok(sessionStartHandler);
assert.ok(toolCallHandler);
await sessionStartHandler({}, createNotificationContext(notifications));
const skippedEvent = { toolName: "bash", input: { command: "git status" } };
await toolCallHandler(skippedEvent, createNotificationContext(notifications));
assert.equal((skippedEvent.input as { command: string }).command, "git status");
assert.equal(rewriteCalls, 0);
assert.ok(notifications.some((notice) => notice.message.includes("rtk binary unavailable")));
rtkAvailable = true;
await sessionStartHandler({}, createNotificationContext(notifications));
const rewrittenEvent = { toolName: "bash", input: { command: "git status" } };
await toolCallHandler(rewrittenEvent, createNotificationContext(notifications));
assert.equal(rewriteCalls, 1);
assert.ok((rewrittenEvent.input as { command: string }).command.includes("rtk git status"));
assert.ok(execCommands.includes("/opt/rtk/bin/rtk"));
writeTestConfig(false);
});
await runTest("tool execution lifecycle sanitizes streamed bash output", async () => {
const handlers: Record<string, ExtensionHandler> = {};
rtkIntegrationExtension({
exec: async () => ({ code: 0, stdout: "rtk 1.0.0", stderr: "" }),
on(eventName: string, handler: ExtensionHandler) {
handlers[eventName] = handler;
},
registerCommand() {},
} as never);
const startHandler = handlers.tool_execution_start;
const updateHandler = handlers.tool_execution_update;
const endHandler = handlers.tool_execution_end;
assert.ok(startHandler);
assert.ok(updateHandler);
assert.ok(endHandler);
await startHandler(
{ toolName: "bash", toolCallId: "bash-1", args: { command: "rtk git status" } },
{},
);
const updateEvent = {
toolName: "bash",
toolCallId: "bash-1",
args: { command: "rtk git status" },
partialResult: {
content: [
{
type: "text",
text: "\x1B[32mworking tree clean\x1B[0m\n",
},
],
},
};
await updateHandler(updateEvent, {});
assert.equal(firstText(updateEvent.partialResult.content), "working tree clean\n");
const endEvent = {
toolName: "bash",
toolCallId: "bash-1",
result: { content: [{ type: "text", text: "\x1B[31merror: build failed\x1B[0m\n" }] },
};
await endHandler(endEvent, {});
assert.equal(firstText(endEvent.result.content), "error: build failed\n");
});
await runTest("tool_result lifecycle merges compaction metadata with existing details", async () => {
const handlers: Record<string, ExtensionHandler> = {};
const notifications: Notification[] = [];
rtkIntegrationExtension({
exec: async () => ({ code: 0, stdout: "rtk 1.0.0", stderr: "" }),
on(eventName: string, handler: ExtensionHandler) {
handlers[eventName] = handler;
},
registerCommand() {},
} as never);
const toolResultHandler = handlers.tool_result;
assert.ok(toolResultHandler);
const result = await toolResultHandler(
{
toolName: "bash",
input: { command: "printf TODO" },
content: [{ type: "text", text: "\x1B[31msrc/a.ts\n 1: TODO\x1B[0m\n" }],
details: { metadata: { requestId: "abc" }, traceId: "trace-1" },
},
createNotificationContext(notifications),
);
assert.ok(result);
assert.equal(firstText(result.content), "src/a.ts\n 1: TODO\n");
assert.equal((result.details as { traceId?: string }).traceId, "trace-1");
const details = result.details as { rtkCompaction?: { applied: boolean }; metadata?: Record<string, unknown> };
assert.equal(details.rtkCompaction?.applied, true);
assert.deepEqual(details.metadata?.requestId, "abc");
assert.equal((details.metadata?.rtkCompaction as { applied?: boolean } | undefined)?.applied, true);
assert.equal(notifications.length, 0);
});
await runTest("tool_call surfaces RTK rewrite errors through existing UI warning path", async () => {
writeTestConfig(true);
const handlers: Record<string, (event: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<Record<string, unknown> | void>> = {};
const notifications: Notification[] = [];
rtkIntegrationExtension({
exec: async (_command: string, args: string[]) => {
if (args[0] === "--version") {
return { code: 0, stdout: "rtk 1.0.0", stderr: "" };
}
return { code: 2, stdout: "", stderr: "denied unsafe rewrite" };
},
on(eventName: string, handler: (event: Record<string, unknown>, ctx: Record<string, unknown>) => Promise<Record<string, unknown> | void>) {
handlers[eventName] = handler;
},
registerCommand() {},
} as never);
const toolCallHandler = handlers.tool_call;
assert.ok(toolCallHandler);
const event = { toolName: "bash", input: { command: "git status" } };
await toolCallHandler(event, {
hasUI: true,
ui: {
notify(message: string, level: "info" | "warning" | "error") {
notifications.push({ message, level });
},
},
});
assert.equal((event.input as { command: string }).command, "git status");
assert.equal(notifications.length, 1);
assert.equal(notifications[0]?.level, "warning");
assert.ok(notifications[0]?.message.includes("rtk rewrite skipped"));
assert.ok(notifications[0]?.message.includes("denied unsafe rewrite"));
writeTestConfig(false);
});
console.log("All index tests passed.");
+543
View File
@@ -0,0 +1,543 @@
import { isToolCallEventType, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
import {
ensureConfigExists,
getRtkIntegrationConfigPath,
loadRtkIntegrationConfig,
normalizeRtkIntegrationConfig,
saveRtkIntegrationConfig,
} from "./config-store.js";
import { computeRewriteDecision } from "./command-rewriter.js";
import { registerRtkIntegrationCommand } from "./command-register.js";
import { EXTENSION_NAME } from "./constants.js";
import { createLazyModuleLoader } from "./lazy-module-loader.js";
import { clearOutputMetrics, getOutputMetricsSummary } from "./output-metrics.js";
import type { ToolResultCompactionMetadata } from "./output-compactor.js";
import { toRecord } from "./record-utils.js";
import { applyRtkCommandEnvironment } from "./rtk-command-environment.js";
import { resolveRtkExecutable, type RtkExecutableResolution } from "./rtk-executable-resolver.js";
import { applyRewrittenCommandShellSafetyFixups } from "./rewrite-pipeline-safety.js";
import { shouldRequireRtkAvailabilityForCommandHandling, shouldSkipCommandHandlingWhenRtkMissing } from "./runtime-guard.js";
import { sanitizeStreamingBashExecutionResult } from "./tool-execution-sanitizer.js";
import type { RtkIntegrationConfig, RuntimeStatus } from "./types.js";
import { applyWindowsBashCompatibilityFixes } from "./windows-command-helpers.js";
function trimMessage(raw: string, maxLength = 220): string {
const clean = raw.replace(/\s+/g, " ").trim();
if (clean.length <= maxLength) {
return clean;
}
return `${clean.slice(0, maxLength - 1)}`;
}
const SOURCE_FILTER_TROUBLESHOOTING_NOTE =
"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.";
/**
* Inject a guideline bullet into the Guidelines section of the system prompt.
*
* Locates the `Guidelines:` block and inserts the bullet after the last
* existing guideline, preserving the section structure. Falls back to
* appending at the end when the Guidelines section cannot be found.
*/
export function injectGuidelineIntoPrompt(systemPrompt: string, guideline: string): string {
if (!systemPrompt || systemPrompt.includes(guideline)) {
return systemPrompt;
}
const bullet = `- ${guideline}`;
// "Guidelines:" may appear at the very start of the prompt (index 0) or
// after a newline. Check both cases so the header is always detected.
let guidelinesHeaderIndex = systemPrompt.indexOf("\nGuidelines:\n");
let headerLength = "\nGuidelines:\n".length;
if (guidelinesHeaderIndex === -1 && systemPrompt.startsWith("Guidelines:\n")) {
guidelinesHeaderIndex = 0;
headerLength = "Guidelines:\n".length;
}
if (guidelinesHeaderIndex === -1) {
return `${systemPrompt}\n\n${guideline}`;
}
const linesStart = guidelinesHeaderIndex + headerLength;
const remainder = systemPrompt.slice(linesStart);
const lines = remainder.split("\n");
let consumedChars = 0;
for (const line of lines) {
if (line === "") {
break;
}
if (/^[-*+\s]/.test(line)) {
consumedChars += line.length + 1;
continue;
}
break;
}
const insertAt = consumedChars === 0 ? linesStart : linesStart + consumedChars - 1;
const before = systemPrompt.slice(0, insertAt);
const after = systemPrompt.slice(insertAt);
const needsNewlineBefore = before.length > 0 && !before.endsWith("\n");
const needsNewlineAfter = after.length > 0 && !after.startsWith("\n");
return [before, needsNewlineBefore ? "\n" : "", bullet, needsNewlineAfter ? "\n" : "", after].join("");
}
const loadOutputCompactorModule = createLazyModuleLoader<typeof import("./output-compactor.js")>("./output-compactor.js");
export function shouldInjectSourceFilterTroubleshootingNote(config: RtkIntegrationConfig): boolean {
const compaction = config.outputCompaction;
return (
config.enabled &&
compaction.enabled &&
compaction.readCompaction.enabled &&
compaction.sourceCodeFilteringEnabled &&
compaction.sourceCodeFiltering !== "none" &&
(compaction.smartTruncate.enabled || compaction.truncate.enabled)
);
}
function mergeCompactionDetails(
existingDetails: unknown,
compaction: ToolResultCompactionMetadata,
): Record<string, unknown> {
const baseDetails = toRecord(existingDetails);
const baseMetadata = toRecord(baseDetails.metadata);
const nextDetails: Record<string, unknown> = {
...baseDetails,
rtkCompaction: compaction,
metadata: {
...baseMetadata,
rtkCompaction: compaction,
},
};
if (Object.keys(baseDetails).length === 0 && existingDetails !== undefined) {
nextDetails.rawDetails = existingDetails;
}
return nextDetails;
}
export interface BoundedNoticeTracker {
remember(key: string): boolean;
reset(): void;
}
export function createBoundedNoticeTracker(maxEntries: number): BoundedNoticeTracker {
const normalizedLimit = Math.max(1, Math.floor(maxEntries));
const seen = new Set<string>();
const order: string[] = [];
return {
remember(key: string): boolean {
if (seen.has(key)) {
return false;
}
seen.add(key);
order.push(key);
while (order.length > normalizedLimit) {
const evicted = order.shift();
if (evicted !== undefined) {
seen.delete(evicted);
}
}
return true;
},
reset(): void {
seen.clear();
order.length = 0;
},
};
}
export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
const initialLoad = loadRtkIntegrationConfig();
let config: RtkIntegrationConfig = initialLoad.config;
if (!config.enabled) {
return;
}
let pendingLoadWarning = initialLoad.warning;
let runtimeStatus: RuntimeStatus = { rtkAvailable: false };
const warnedMessages = createBoundedNoticeTracker(100);
const suggestionNotices = createBoundedNoticeTracker(200);
const activeBashCommands = new Map<string, string>();
let missingRtkWarningShown = false;
const formatRewriteNotice = (originalCommand: string, rewrittenCommand: string): string => {
const original = trimMessage(originalCommand, 100);
const rewritten = trimMessage(rewrittenCommand, 120);
return `RTK rewrite: ${original} -> ${rewritten}`;
};
const formatRewriteWarning = (command: string, warning: string): string => {
const target = trimMessage(command, 100);
const detail = trimMessage(warning, 120);
return `${EXTENSION_NAME}: rtk rewrite skipped for '${target}' (${detail}).`;
};
const warnOnce = (
ctx: ExtensionContext | ExtensionCommandContext,
message: string,
level: "warning" | "error" = "warning",
): void => {
if (!warnedMessages.remember(message)) {
return;
}
if (ctx.hasUI) {
ctx.ui.notify(message, level);
}
};
const clearTrackedBashCommands = (): void => {
activeBashCommands.clear();
};
const trackBashCommand = (toolCallId: unknown, args: unknown): void => {
if (typeof toolCallId !== "string") {
return;
}
const argsRecord = toRecord(args);
const command = typeof argsRecord.command === "string" ? argsRecord.command.trim() : "";
if (!command) {
activeBashCommands.delete(toolCallId);
return;
}
activeBashCommands.set(toolCallId, command);
};
const getTrackedBashCommand = (toolCallId: unknown): string | undefined => {
if (typeof toolCallId !== "string") {
return undefined;
}
return activeBashCommands.get(toolCallId);
};
const forgetTrackedBashCommand = (toolCallId: unknown): void => {
if (typeof toolCallId !== "string") {
return;
}
activeBashCommands.delete(toolCallId);
};
/**
* Shared guard for bash tool-execution events: skips when compaction is
* disabled, normalizes the event to a record, tracks the bash command, and
* returns the record for further handler-specific processing.
*/
const recordBashEventIfEnabled = (
event: unknown,
): Record<string, unknown> | null => {
if (!config.enabled || !config.outputCompaction.enabled) {
return null;
}
const eventRecord = toRecord(event);
if (eventRecord.toolName !== "bash") {
return null;
}
trackBashCommand(eventRecord.toolCallId, eventRecord.args);
return eventRecord;
};
const refreshConfig = async (ctx?: ExtensionContext | ExtensionCommandContext): Promise<void> => {
const ensured = ensureConfigExists();
if (ensured.error && ctx) {
warnOnce(ctx, ensured.error);
}
const loaded = loadRtkIntegrationConfig();
config = loaded.config;
pendingLoadWarning = loaded.warning;
await refreshRuntimeStatus();
if (pendingLoadWarning && ctx) {
warnOnce(ctx, pendingLoadWarning);
pendingLoadWarning = undefined;
}
};
const setConfig = (next: RtkIntegrationConfig, ctx: ExtensionCommandContext): void => {
config = normalizeRtkIntegrationConfig(next);
const saved = saveRtkIntegrationConfig(config);
if (!saved.success && saved.error) {
ctx.ui.notify(saved.error, "error");
}
};
const refreshRuntimeStatus = async (): Promise<RuntimeStatus> => {
if (!config.commandRewritingEnabled) {
runtimeStatus = { rtkAvailable: false };
return runtimeStatus;
}
let executableResolution: RtkExecutableResolution | undefined;
try {
executableResolution = await resolveRtkExecutable(pi);
const result = await pi.exec(executableResolution.command, ["--version"], { timeout: 5000 });
if (result.code === 0) {
runtimeStatus = {
rtkAvailable: true,
lastCheckedAt: Date.now(),
rtkExecutablePath: executableResolution.resolvedPath,
rtkExecutableCommand: executableResolution.command,
rtkExecutableResolver: executableResolution.resolver,
rtkExecutableResolutionWarning: executableResolution.warning,
};
missingRtkWarningShown = false;
return runtimeStatus;
}
const detail = trimMessage(
`${result.stderr || ""} ${result.stdout || ""} ${result.code ? `(exit ${result.code})` : ""}`,
);
runtimeStatus = {
rtkAvailable: false,
lastCheckedAt: Date.now(),
lastError: detail || `exit ${result.code}`,
rtkExecutablePath: executableResolution.resolvedPath,
rtkExecutableCommand: executableResolution.command,
rtkExecutableResolver: executableResolution.resolver,
rtkExecutableResolutionWarning: executableResolution.warning,
};
return runtimeStatus;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
runtimeStatus = {
rtkAvailable: false,
lastCheckedAt: Date.now(),
lastError: trimMessage(message),
rtkExecutablePath: executableResolution?.resolvedPath,
rtkExecutableCommand: executableResolution?.command,
rtkExecutableResolver: executableResolution?.resolver,
rtkExecutableResolutionWarning: executableResolution?.warning,
};
return runtimeStatus;
}
};
const maybeWarnRtkMissing = (ctx: ExtensionContext): void => {
if (!config.enabled || !config.commandRewritingEnabled || !config.guardWhenRtkMissing) {
return;
}
if (runtimeStatus.rtkAvailable) {
missingRtkWarningShown = false;
return;
}
if (missingRtkWarningShown) {
return;
}
missingRtkWarningShown = true;
const reason = runtimeStatus.lastError ? ` (${runtimeStatus.lastError})` : "";
const handling = config.mode === "suggest" ? "rewrite suggestions" : "command rewrite";
warnOnce(ctx, `${EXTENSION_NAME}: rtk binary unavailable, ${handling} bypassed${reason}.`);
};
const ensureRuntimeStatusFresh = async (): Promise<void> => {
if (!shouldRequireRtkAvailabilityForCommandHandling(config)) {
return;
}
const now = Date.now();
const isStale = !runtimeStatus.lastCheckedAt || now - runtimeStatus.lastCheckedAt > 30_000;
if (isStale) {
await refreshRuntimeStatus();
}
};
const controller = {
getConfig: () => config,
setConfig,
getConfigPath: getRtkIntegrationConfigPath,
getRuntimeStatus: () => runtimeStatus,
refreshRuntimeStatus,
getMetricsSummary: getOutputMetricsSummary,
clearMetrics: clearOutputMetrics,
};
registerRtkIntegrationCommand(pi, controller);
pi.on("session_start", async (_event, ctx) => {
warnedMessages.reset();
suggestionNotices.reset();
clearTrackedBashCommands();
missingRtkWarningShown = false;
await refreshConfig(ctx);
maybeWarnRtkMissing(ctx);
});
pi.on("agent_end", async () => {
clearTrackedBashCommands();
});
pi.on("tool_execution_start", async (event) => {
recordBashEventIfEnabled(event);
});
pi.on("tool_execution_update", async (event) => {
const eventRecord = recordBashEventIfEnabled(event);
if (!eventRecord) {
return;
}
const sanitization = sanitizeStreamingBashExecutionResult(
eventRecord.partialResult,
getTrackedBashCommand(eventRecord.toolCallId),
);
if (sanitization.changed) {
eventRecord.partialResult = sanitization.result;
}
});
pi.on("tool_execution_end", async (event) => {
const eventRecord = toRecord(event);
if (eventRecord.toolName !== "bash") {
return;
}
try {
if (config.enabled && config.outputCompaction.enabled) {
const sanitization = sanitizeStreamingBashExecutionResult(
eventRecord.result,
getTrackedBashCommand(eventRecord.toolCallId),
);
if (sanitization.changed) {
eventRecord.result = sanitization.result;
}
}
} finally {
forgetTrackedBashCommand(eventRecord.toolCallId);
}
});
pi.on("before_agent_start", async (event, ctx) => {
await ensureRuntimeStatusFresh();
maybeWarnRtkMissing(ctx);
if (!shouldInjectSourceFilterTroubleshootingNote(config)) {
return {};
}
if (event.systemPrompt.includes(SOURCE_FILTER_TROUBLESHOOTING_NOTE)) {
return {};
}
const updatedPrompt = injectGuidelineIntoPrompt(event.systemPrompt, SOURCE_FILTER_TROUBLESHOOTING_NOTE);
if (updatedPrompt === event.systemPrompt) {
return {};
}
return {
systemPrompt: updatedPrompt,
};
});
pi.on("tool_call", async (event, ctx) => {
if (!config.enabled || !config.commandRewritingEnabled) {
return {};
}
if (!isToolCallEventType("bash", event)) {
return {};
}
if (config.mode === "rewrite") {
const compatibility = applyWindowsBashCompatibilityFixes(event.input.command);
if (compatibility.command !== event.input.command) {
event.input.command = compatibility.command;
}
}
await ensureRuntimeStatusFresh();
if (shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus)) {
return {};
}
let executableResolution: RtkExecutableResolution | undefined;
if (runtimeStatus.rtkExecutableCommand) {
const resolver: RtkExecutableResolution["resolver"] =
runtimeStatus.rtkExecutableResolver === "where" ? "where" : "which";
executableResolution = {
command: runtimeStatus.rtkExecutableCommand,
resolvedPath: runtimeStatus.rtkExecutablePath,
resolver,
warning: runtimeStatus.rtkExecutableResolutionWarning,
};
}
const decision = await computeRewriteDecision(event.input.command, config, pi, { executableResolution });
if (!decision.changed) {
if (decision.warning) {
warnOnce(ctx, formatRewriteWarning(decision.originalCommand, decision.warning));
}
return {};
}
if (config.mode === "rewrite") {
if (config.showRewriteNotifications && ctx.hasUI) {
ctx.ui.notify(formatRewriteNotice(decision.originalCommand, decision.rewrittenCommand), "info");
}
const envScopedRewrittenCommand = applyRtkCommandEnvironment(decision.rewrittenCommand);
event.input.command = applyRewrittenCommandShellSafetyFixups(envScopedRewrittenCommand);
return {};
}
if (config.mode === "suggest") {
const suggestionKey = `${decision.originalCommand}:${decision.rewrittenCommand}`;
if (suggestionNotices.remember(suggestionKey) && ctx.hasUI) {
ctx.ui.notify(`RTK suggestion: ${decision.rewrittenCommand}`, "info");
}
}
return {};
});
pi.on("tool_result", async (event, ctx) => {
if (!config.enabled || !config.outputCompaction.enabled) {
return {};
}
try {
const { compactToolResult } = await loadOutputCompactorModule();
const outcome = compactToolResult(
{
toolName: event.toolName,
input: event.input,
content: event.content,
},
config,
);
if (!outcome.changed || !outcome.content) {
return {};
}
return {
content: outcome.content,
details: outcome.metadata ? mergeCompactionDetails(event.details, outcome.metadata) : undefined,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warnOnce(ctx, `${EXTENSION_NAME}: output compaction failed, using raw output (${trimMessage(message)}).`);
return {};
}
});
}
@@ -0,0 +1,14 @@
/**
* Creates a memoized lazy loader for a dynamically imported module.
*
* The first call triggers `import(specifier)`; subsequent calls reuse the
* cached promise. This avoids re-importing the module on every invocation
* while keeping the heavy module out of the synchronous startup path.
*/
export function createLazyModuleLoader<T>(specifier: string): () => Promise<T> {
let cached: Promise<T> | undefined;
return (): Promise<T> => {
cached ??= import(specifier) as Promise<T>;
return cached;
};
}
@@ -0,0 +1,629 @@
import assert from "node:assert/strict";
import { join } from "node:path";
import { cloneDefaultConfig, mock, runTest } from "./test-helpers.test.ts";
const TEST_AGENT_DIR = "/tmp/.pi/agent";
mock.module("@earendil-works/pi-coding-agent", {
namedExports: {
getAgentDir: () => TEST_AGENT_DIR,
},
});
const { compactToolResult } = await import("./output-compactor.ts");
function buildReadContent(lineCount: number): string {
const lines: string[] = [];
for (let index = 0; index < lineCount; index += 1) {
if (index % 2 === 0) {
lines.push(`// comment ${index}`);
} else {
lines.push(`const value${index} = ${index};`);
}
}
return `${lines.join("\n")}\n`;
}
function setReadCompaction(config: ReturnType<typeof cloneDefaultConfig>, enabled: boolean): void {
config.outputCompaction.readCompaction = { enabled };
}
function firstTextBlock(content: unknown[] | undefined): string {
if (!Array.isArray(content) || content.length === 0) {
return "";
}
const first = content[0] as { type?: string; text?: string };
if (first?.type !== "text" || typeof first.text !== "string") {
return "";
}
return first.text;
}
const OUTPUT_EMOJI_MARKERS = ["✓", "✔", "❌", "⚠️", "⚠", "📋", "📄", "🔍", "✅", "⏭️", "📌", "📝", "❓", "•"];
function compactBashOutput(command: string, text: string): string {
const result = compactToolResult(
{
toolName: "bash",
input: { command },
content: [{ type: "text", text }],
},
cloneDefaultConfig(),
);
assert.equal(result.changed, true);
return firstTextBlock(result.content);
}
function assertNoOutputEmoji(text: string): void {
for (const marker of OUTPUT_EMOJI_MARKERS) {
assert.equal(text.includes(marker), false, `Unexpected output emoji marker: ${marker}`);
}
}
function assertNoPartialHashlineAnchors(text: string): void {
for (const line of text.split(/\r?\n/)) {
if (/^\s*\d+\s*#[A-Za-z0-9_-]{2,32}:/.test(line)) {
assert.equal(line.endsWith("..."), false, `Anchor line was partially truncated: ${line}`);
}
}
}
runTest("precision read with offset keeps exact output (no source/smart/hard truncation)", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 500;
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = buildReadContent(220);
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts", offset: 1 },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
});
runTest("precision read with limit keeps exact output", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 500;
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = buildReadContent(220);
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts", limit: 200 },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
});
runTest("default read output stays exact when read compaction is disabled by default", () => {
const config = cloneDefaultConfig();
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "aggressive";
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 500;
const content = buildReadContent(220);
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
});
runTest("normal read compacts and adds banner when read compaction is enabled", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "minimal";
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = buildReadContent(220);
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, true);
assert.ok(result.techniques.includes("source:minimal"));
const compacted = firstTextBlock(result.content);
assert.ok(compacted.startsWith("[RTK compacted output:"));
assert.ok(compacted.includes("source:minimal"));
});
runTest("line-anchor read output compacts without corrupting LINE#HASH anchors", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "minimal";
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 5000;
const content = Array.from({ length: 120 }, (_value, index) => {
const lineNumber = index + 1;
const sourceLine = lineNumber % 2 === 0 ? `const value${lineNumber} = ${lineNumber};` : `// comment ${lineNumber}`;
return `${String(lineNumber).padStart(3, " ")}#ZP:${sourceLine}`;
}).join("\n");
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, true);
assert.ok(result.techniques.includes("source:minimal"));
const compacted = firstTextBlock(result.content);
assert.ok(compacted.startsWith("[RTK compacted output:"));
assert.ok(compacted.includes("source:minimal"));
assert.match(compacted, /\n\s*2#ZP:const value2 = 2;/);
assert.equal(compacted.includes("#ZP:// comment"), false);
assertNoPartialHashlineAnchors(compacted);
});
runTest("colon-pipe anchor read output compacts without requiring hashline extension", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "minimal";
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 5000;
const content = [
"Read sample.ts: 120 lines",
"",
...Array.from({ length: 120 }, (_value, index) => {
const lineNumber = index + 1;
const sourceLine = lineNumber % 2 === 0 ? `const value${lineNumber} = ${lineNumber};` : `// comment ${lineNumber}`;
return `${lineNumber}:${(lineNumber % 256).toString(16).padStart(2, "0")}|${sourceLine}`;
}),
].join("\n");
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, true);
assert.ok(result.techniques.includes("source:minimal"));
const compacted = firstTextBlock(result.content);
assert.ok(compacted.includes("Read sample.ts: 120 lines"));
assert.match(compacted, /\n2:02\|const value2 = 2;/);
assert.equal(compacted.includes("|// comment"), false);
});
runTest("compact LINEHASH pipe anchors from oh-my-pi style reads", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "minimal";
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 5000;
const content = Array.from({ length: 120 }, (_value, index) => {
const lineNumber = index + 1;
const hash = lineNumber % 2 === 0 ? "sr" : "ab";
const sourceLine = lineNumber % 2 === 0 ? `const value${lineNumber} = ${lineNumber};` : `// comment ${lineNumber}`;
return `${lineNumber}${hash}|${sourceLine}`;
}).join("\n");
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, true);
assert.ok(result.techniques.includes("source:minimal"));
const compacted = firstTextBlock(result.content);
assert.match(compacted, /\n2sr\|const value2 = 2;/);
assert.equal(compacted.includes("|// comment"), false);
});
runTest("compact hashline-tools file wrapper while preserving non-anchor wrapper lines", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "minimal";
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 5000;
const content = [
"<file>",
...Array.from({ length: 120 }, (_value, index) => {
const lineNumber = index + 1;
const sourceLine = lineNumber % 2 === 0 ? `const value${lineNumber} = ${lineNumber};` : `// comment ${lineNumber}`;
return `${lineNumber}#ZM:${sourceLine}`;
}),
"",
"(End of file - 120 total lines)",
"</file>",
].join("\n");
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, true);
assert.ok(result.techniques.includes("source:minimal"));
const compacted = firstTextBlock(result.content);
assert.ok(compacted.includes("<file>"));
assert.ok(compacted.includes("(End of file - 120 total lines)"));
assert.ok(compacted.includes("</file>"));
assert.match(compacted, /\n2#ZM:const value2 = 2;/);
assert.equal(compacted.includes("#ZM:// comment"), false);
});
runTest("anchor-safe read hard truncation preserves whole hashline anchors", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = false;
config.outputCompaction.smartTruncate.enabled = false;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 350;
const content = Array.from({ length: 120 }, (_value, index) => {
const lineNumber = index + 1;
return `${lineNumber}#ZP:const value${lineNumber} = "${"x".repeat(40)}";`;
}).join("\n");
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, true);
assert.ok(result.techniques.includes("truncate"));
const compacted = firstTextBlock(result.content);
assert.ok(compacted.includes("anchor-safe truncate"));
assertNoPartialHashlineAnchors(compacted);
});
runTest("incidental single anchor-like line does not disable normal read compaction", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "minimal";
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = [`1#ZP:not an anchored read`, buildReadContent(120)].join("\n");
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, true);
assert.ok(result.techniques.includes("source:minimal") || result.techniques.includes("smart-truncate"));
});
runTest("short read output stays exact below threshold", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
const content = buildReadContent(40);
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
});
runTest("read output stays exact at the 80-line boundary with trailing newline", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = buildReadContent(80);
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
});
runTest("read output compacts once the content exceeds the 80-line exactness threshold when read compaction is enabled", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "minimal";
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = buildReadContent(81);
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, true);
assert.ok(result.techniques.includes("source:minimal") || result.techniques.includes("smart-truncate"));
});
runTest("source file reads skip lossy source filtering when truncation safeguards are not needed", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.sourceCodeFilteringEnabled = true;
config.outputCompaction.sourceCodeFiltering = "minimal";
config.outputCompaction.smartTruncate.enabled = false;
config.outputCompaction.truncate.enabled = false;
const content = buildReadContent(120);
const result = compactToolResult(
{
toolName: "read",
input: { path: "sample.ts" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
assert.equal(firstTextBlock(result.content), "");
});
runTest("skill reads stay exact when preserveExactSkillReads is enabled for user skills", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.preserveExactSkillReads = true;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 500;
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = buildReadContent(220);
const result = compactToolResult(
{
toolName: "read",
input: { path: join(TEST_AGENT_DIR, "skills", "example", "SKILL.md") },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
});
runTest("project .pi skill reads stay exact when preserveExactSkillReads is enabled", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.preserveExactSkillReads = true;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 500;
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = buildReadContent(220);
const result = compactToolResult(
{
toolName: "read",
input: { path: ".pi/skills/example/SKILL.md" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
});
runTest("ancestor .agents skill reads stay exact when preserveExactSkillReads is enabled", () => {
const config = cloneDefaultConfig();
setReadCompaction(config, true);
config.outputCompaction.preserveExactSkillReads = true;
config.outputCompaction.truncate.enabled = true;
config.outputCompaction.truncate.maxChars = 500;
config.outputCompaction.smartTruncate.enabled = true;
config.outputCompaction.smartTruncate.maxLines = 40;
const content = buildReadContent(220);
const result = compactToolResult(
{
toolName: "read",
input: { path: "../.agents/skills/example/SKILL.md" },
content: [{ type: "text", text: content }],
},
config,
);
assert.equal(result.changed, false);
assert.deepEqual(result.techniques, []);
});
runTest("build output uses plain-text status markers", () => {
const compacted = compactBashOutput("npm run build", "Compiling app v0.1.0\n");
assert.equal(compacted, "[OK] Build successful (1 units compiled)");
assertNoOutputEmoji(compacted);
});
runTest("git status output uses plain-text labels", () => {
const compacted = compactBashOutput(
"git status --short --branch",
"## main...origin/main\nM staged.ts\n M modified.ts\n?? new.ts\nUU conflict.ts\n",
);
assert.ok(compacted.startsWith("Branch: main\n"));
assert.ok(compacted.includes("Staged: 1 files\n staged.ts\n"));
assert.ok(compacted.includes("Modified: 1 files\n modified.ts\n"));
assert.ok(compacted.includes("Untracked: 1 files\n new.ts\n"));
assert.ok(compacted.includes("Conflicts: 1 files"));
assertNoOutputEmoji(compacted);
});
runTest("git diff output uses plain-text file markers", () => {
const compacted = compactBashOutput(
"git diff",
"diff --git a/src/example.ts b/src/example.ts\n@@ -1 +1 @@\n-oldValue\n+newValue\n",
);
assert.ok(compacted.includes("\n> src/example.ts\n"));
assertNoOutputEmoji(compacted);
});
runTest("linter success output uses plain-text status markers", () => {
const compacted = compactBashOutput("npx eslint .", "");
assert.equal(compacted, "[OK] ESLint: No issues found");
assertNoOutputEmoji(compacted);
});
runTest("test output uses plain-text labels and bullets", () => {
const compacted = compactBashOutput(
"bun test",
"3 passed, 1 failed, 2 skipped\nFAIL src/example.test.ts\n Expected: true\n Received: false\n\n\n",
);
assert.ok(compacted.includes("Test Results:"));
assert.ok(compacted.includes("PASS: 3 passed"));
assert.ok(compacted.includes("FAIL: 1 failed"));
assert.ok(compacted.includes("SKIP: 2 skipped"));
assert.ok(compacted.includes(" - FAIL src/example.test.ts"));
assertNoOutputEmoji(compacted);
});
runTest("all grep output stays intact because FFF owns search", () => {
const text = "src/a.ts [modified in git]\n 1: const match = true;\n\n[Continue with cursor=\"fff_c1\"]";
const result = compactToolResult(
{
toolName: "grep",
input: { pattern: "match" },
content: [{ type: "text", text }],
},
cloneDefaultConfig(),
);
assert.equal(result.changed, false);
assert.equal(result.content, undefined);
assert.deepEqual(result.techniques, []);
});
runTest("git diff compaction skips already-compacted RTK-shaped output", () => {
const result = compactToolResult(
{
toolName: "bash",
input: { command: "git diff -- agent/extensions/pi-mcp-adapter/package.json" },
content: [{ type: "text", text: "agent/extensions/pi-mcp-adapter/package.json | 2 +-\n\n--- Changes ---\n\n> agent/extensions/pi-mcp-adapter/package.json\n @@ -38,7 +38,7 @@\n - \"@earendil-works/pi-coding-agent\": \"^0.58.1\",\n" }],
},
cloneDefaultConfig(),
);
assert.equal(result.changed, false);
assert.equal(result.content, undefined);
assert.deepEqual(result.techniques, []);
});
runTest("non-hook RTK warnings are preserved verbatim", () => {
const result = compactToolResult(
{
toolName: "bash",
input: { command: "FOO=1 rtk git status" },
content: [{ type: "text", text: "[rtk] warning: builtin filters: parse failure\n\nworking tree clean\n" }],
},
cloneDefaultConfig(),
);
assert.equal(result.changed, false);
assert.equal(result.content, undefined);
assert.deepEqual(result.techniques, []);
});
runTest("quoted hook warning text is preserved as payload", () => {
const quotedHookText = 'const warning = "No hook installed — run `rtk init -g` for automatic token savings";\n';
const result = compactToolResult(
{
toolName: "bash",
input: { command: "echo probe" },
content: [{ type: "text", text: quotedHookText }],
},
cloneDefaultConfig(),
);
assert.equal(result.changed, false);
assert.equal(result.content, undefined);
assert.deepEqual(result.techniques, []);
});
console.log("All output-compactor tests passed.");
+680
View File
@@ -0,0 +1,680 @@
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { homedir } from "node:os";
import { dirname, join, resolve, sep } from "node:path";
import {
aggregateLinterOutput,
aggregateTestOutput,
compactGitOutput,
detectLanguage,
filterBuildOutput,
filterSourceCode,
smartTruncate,
stripAnsiFast,
truncate,
} from "./techniques/index.js";
import { trackOutputSavings } from "./output-metrics.js";
import { mapTextContentBlocks, toRecord } from "./record-utils.js";
import type { RtkIntegrationConfig } from "./types.js";
interface ToolResultLikeEvent {
toolName: string;
input?: unknown;
content?: unknown;
}
export interface ToolResultCompactionMetadata {
applied: boolean;
techniques: string[];
truncated: boolean;
originalCharCount: number;
compactedCharCount: number;
originalLineCount: number;
compactedLineCount: number;
}
export interface ToolResultCompactionOutcome {
changed: boolean;
content?: unknown[];
techniques: string[];
metadata?: ToolResultCompactionMetadata;
}
interface AnchoredReadLine {
lineNumber: number;
content: string;
originalLine: string;
}
interface AnchorSafeReadLine {
text: string;
content: string;
}
interface AnchorSafeReadParts {
prefixLines: string[];
anchoredLines: AnchoredReadLine[];
suffixLines: string[];
trailingNewline: boolean;
}
const LOSSY_TECHNIQUE_PREFIXES = [
"build",
"test",
"git",
"linter",
"search",
"truncate",
"smart-truncate",
"source:",
] as const;
const READ_EXACT_OUTPUT_LINE_THRESHOLD = 80;
const READ_COMPACTION_BANNER_PREFIX = "[RTK compacted output:";
const ANCHORED_READ_LINE_MIN_MATCHES = 2;
const ANCHORED_READ_LINE_MIN_RATIO = 0.5;
const ANCHORED_READ_LINE_SAMPLE_LIMIT = 200;
const ANCHORED_READ_LINE_PATTERNS = [
/^\s*(?:>>>|>>|[>+\-*]+)?\s*(\d+)\s*#\s*[A-Za-z0-9_-]{2,32}:(.*)$/,
/^\s*(?:>>>|>>|[>+\-*]+)?\s*(\d+)\s*:\s*[A-Za-z0-9_-]{1,32}\|(.*)$/,
/^\s*(?:>>>|>>|[>+\-*]+)?\s*(\d+)[a-z]{2}\|(.*)$/,
] as const;
const ANCHORED_READ_INFORMATIONAL_LINE_PATTERN = /^\s*(?:$|<\/?file>|\.{3}|\[[^\]]+\]|Read\s+.+:\s+\d+\s+lines\b)/;
const USER_SKILL_ROOTS = [join(getAgentDir(), "skills"), join(homedir(), ".agents", "skills")];
function normalizePathForComparison(path: string): string {
return process.platform === "win32" ? path.toLowerCase() : path;
}
function isPathUnderRoot(targetPath: string, rootPath: string): boolean {
const normalizedTarget = normalizePathForComparison(resolve(targetPath));
const normalizedRoot = normalizePathForComparison(resolve(rootPath));
if (normalizedTarget === normalizedRoot) {
return true;
}
const rootWithSeparator = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
return normalizedTarget.startsWith(rootWithSeparator);
}
function isUnderAnyAncestorAgentsSkills(targetPath: string): boolean {
let currentDir = resolve(process.cwd());
while (true) {
if (isPathUnderRoot(targetPath, join(currentDir, ".agents", "skills"))) {
return true;
}
const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
return false;
}
currentDir = parentDir;
}
}
function isSkillReadPath(filePath: string): boolean {
if (!filePath.trim()) {
return false;
}
const resolvedPath = resolve(filePath);
if (USER_SKILL_ROOTS.some((root) => isPathUnderRoot(resolvedPath, root))) {
return true;
}
if (isPathUnderRoot(resolvedPath, join(process.cwd(), ".pi", "skills"))) {
return true;
}
return isUnderAnyAncestorAgentsSkills(resolvedPath);
}
function toArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function normalizeCommand(input: Record<string, unknown>): string | undefined {
const raw = input.command;
if (typeof raw === "string" && raw.trim()) {
return raw;
}
return undefined;
}
function normalizePath(input: Record<string, unknown>): string {
const raw = input.path;
if (typeof raw === "string") {
return raw;
}
return "";
}
function hasExplicitReadRange(input: Record<string, unknown>): boolean {
return input.offset !== undefined || input.limit !== undefined;
}
function splitReadLines(text: string): { lines: string[]; trailingNewline: boolean } {
if (!text) {
return { lines: [], trailingNewline: false };
}
const trailingNewline = text.endsWith("\n");
const lines = text.split(/\r?\n/);
if (trailingNewline) {
lines.pop();
}
return { lines, trailingNewline };
}
function joinReadLines(lines: string[], trailingNewline: boolean): string {
const joined = lines.join("\n");
return trailingNewline && joined ? `${joined}\n` : joined;
}
function parseAnchoredReadLine(line: string): AnchoredReadLine | undefined {
for (const pattern of ANCHORED_READ_LINE_PATTERNS) {
const match = line.match(pattern);
if (!match) {
continue;
}
const lineNumber = Number.parseInt(match[1] ?? "", 10);
if (!Number.isSafeInteger(lineNumber) || lineNumber <= 0) {
continue;
}
const content = match[2] ?? "";
return {
lineNumber,
content,
originalLine: line,
};
}
return undefined;
}
function parseAnchoredReadLineNumber(line: string): number | undefined {
return parseAnchoredReadLine(line)?.lineNumber;
}
function looksLikeAnchoredLineOutput(text: string, parseLineNumber: (line: string) => number | undefined): boolean {
let matchCount = 0;
let relevantLineCount = 0;
let previousMatchedLineNumber: number | undefined;
let hasIncreasingAnchors = false;
for (const line of splitReadLines(text).lines.slice(0, ANCHORED_READ_LINE_SAMPLE_LIMIT)) {
if (!ANCHORED_READ_INFORMATIONAL_LINE_PATTERN.test(line)) {
relevantLineCount += 1;
}
const lineNumber = parseLineNumber(line);
if (lineNumber === undefined) {
continue;
}
matchCount += 1;
if (previousMatchedLineNumber !== undefined && lineNumber > previousMatchedLineNumber) {
hasIncreasingAnchors = true;
}
previousMatchedLineNumber = lineNumber;
}
if (matchCount < ANCHORED_READ_LINE_MIN_MATCHES || !hasIncreasingAnchors) {
return false;
}
const ratioBase = Math.max(relevantLineCount, matchCount);
return matchCount / ratioBase >= ANCHORED_READ_LINE_MIN_RATIO;
}
function looksLikeAnchoredReadOutput(text: string): boolean {
return looksLikeAnchoredLineOutput(text, parseAnchoredReadLineNumber);
}
function shouldPreserveExactReadOutput(
text: string,
input: Record<string, unknown>,
config: RtkIntegrationConfig,
): boolean {
if (!config.outputCompaction.readCompaction.enabled) {
return true;
}
if (hasExplicitReadRange(input)) {
return true;
}
if (config.outputCompaction.preserveExactSkillReads && isSkillReadPath(normalizePath(input))) {
return true;
}
return countLines(text) <= READ_EXACT_OUTPUT_LINE_THRESHOLD;
}
function shouldApplyReadSourceFiltering(text: string, config: RtkIntegrationConfig): boolean {
const compaction = config.outputCompaction;
const lineCount = countLines(text);
return (
(compaction.smartTruncate.enabled && lineCount > compaction.smartTruncate.maxLines) ||
(compaction.truncate.enabled && text.length > compaction.truncate.maxChars)
);
}
function extractAnchoredReadParts(text: string): AnchorSafeReadParts | undefined {
if (!looksLikeAnchoredReadOutput(text)) {
return undefined;
}
const { lines, trailingNewline } = splitReadLines(text);
const parsedLines = lines.map((line) => parseAnchoredReadLine(line));
const firstAnchorIndex = parsedLines.findIndex((line) => line !== undefined);
if (firstAnchorIndex === -1) {
return undefined;
}
let lastAnchorIndex = firstAnchorIndex;
for (let index = parsedLines.length - 1; index >= firstAnchorIndex; index -= 1) {
if (parsedLines[index] !== undefined) {
lastAnchorIndex = index;
break;
}
}
const anchoredLines: AnchoredReadLine[] = [];
for (let index = firstAnchorIndex; index <= lastAnchorIndex; index += 1) {
const anchoredLine = parsedLines[index];
if (!anchoredLine) {
return undefined;
}
anchoredLines.push(anchoredLine);
}
return {
prefixLines: lines.slice(0, firstAnchorIndex),
anchoredLines,
suffixLines: lines.slice(lastAnchorIndex + 1),
trailingNewline,
};
}
function toAnchorSafeReadLines(anchoredLines: AnchoredReadLine[]): AnchorSafeReadLine[] {
return anchoredLines.map((line) => ({
text: line.originalLine,
content: line.content,
}));
}
function renderAnchorSafeReadBody(lines: AnchorSafeReadLine[]): string {
return lines.map((line) => line.text).join("\n");
}
function renderAnchorSafeReadText(parts: AnchorSafeReadParts, lines: AnchorSafeReadLine[]): string {
return joinReadLines(
[...parts.prefixLines, ...lines.map((line) => line.text), ...parts.suffixLines],
parts.trailingNewline,
);
}
function remapTransformedContentToAnchorSafeLines(
sourceLines: AnchorSafeReadLine[],
transformedContent: string,
): AnchorSafeReadLine[] {
const transformedLines = splitReadLines(transformedContent).lines;
const remappedLines: AnchorSafeReadLine[] = [];
let searchStartIndex = 0;
for (const transformedLine of transformedLines) {
let matchedIndex = -1;
for (let index = searchStartIndex; index < sourceLines.length; index += 1) {
if (sourceLines[index]?.content === transformedLine) {
matchedIndex = index;
break;
}
}
if (matchedIndex === -1) {
remappedLines.push({
text: transformedLine,
content: transformedLine,
});
continue;
}
remappedLines.push(sourceLines[matchedIndex]!);
searchStartIndex = matchedIndex + 1;
}
return remappedLines;
}
function truncateAnchorSafeReadLines(lines: AnchorSafeReadLine[], maxChars: number): AnchorSafeReadLine[] {
if (renderAnchorSafeReadBody(lines).length <= maxChars) {
return lines;
}
const marker = "[RTK anchor-safe truncate: remaining anchored read lines omitted to preserve complete anchors]";
const truncatedLines: AnchorSafeReadLine[] = [];
let charCount = 0;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]!;
const separatorLength = truncatedLines.length > 0 ? 1 : 0;
const nextCharCount = charCount + separatorLength + line.text.length;
const remainingAfter = lines.length - index - 1;
const markerLength = remainingAfter > 0 ? (nextCharCount > 0 ? 1 : 0) + marker.length : 0;
if (nextCharCount + markerLength > maxChars) {
const markerLine = { text: marker, content: marker };
return truncatedLines.length > 0 ? [...truncatedLines, markerLine] : [markerLine];
}
truncatedLines.push(line);
charCount = nextCharCount;
}
return truncatedLines;
}
function compactAnchoredReadText(
text: string,
filePath: string,
config: RtkIntegrationConfig,
): { text: string; techniques: string[] } {
const parts = extractAnchoredReadParts(text);
if (!parts) {
return { text, techniques: [] };
}
let lines = toAnchorSafeReadLines(parts.anchoredLines);
const techniques: string[] = [];
const compaction = config.outputCompaction;
const language = detectLanguage(filePath);
if (
compaction.sourceCodeFilteringEnabled &&
compaction.sourceCodeFiltering !== "none" &&
shouldApplyReadSourceFiltering(text, config)
) {
const currentSource = lines.map((line) => line.content).join("\n");
const filtered = normalizeTechniqueResult(
filterSourceCode(currentSource, language, compaction.sourceCodeFiltering),
currentSource,
);
const filteredLines = remapTransformedContentToAnchorSafeLines(lines, filtered);
if (renderAnchorSafeReadBody(filteredLines) !== renderAnchorSafeReadBody(lines)) {
lines = filteredLines;
techniques.push(`source:${compaction.sourceCodeFiltering}`);
}
}
if (compaction.smartTruncate.enabled && lines.length > compaction.smartTruncate.maxLines) {
const currentSource = lines.map((line) => line.content).join("\n");
const compacted = smartTruncate(currentSource, compaction.smartTruncate.maxLines, language);
const compactedLines = remapTransformedContentToAnchorSafeLines(lines, compacted);
if (renderAnchorSafeReadBody(compactedLines) !== renderAnchorSafeReadBody(lines)) {
lines = compactedLines;
techniques.push("smart-truncate");
}
}
if (compaction.truncate.enabled && renderAnchorSafeReadText(parts, lines).length > compaction.truncate.maxChars) {
const nonBodyOverhead = renderAnchorSafeReadText(parts, []).length;
const bodyMaxChars = Math.max(1, compaction.truncate.maxChars - nonBodyOverhead);
const truncatedLines = truncateAnchorSafeReadLines(lines, bodyMaxChars);
if (renderAnchorSafeReadBody(truncatedLines) !== renderAnchorSafeReadBody(lines)) {
lines = truncatedLines;
techniques.push("truncate");
}
}
return {
text: renderAnchorSafeReadText(parts, lines),
techniques,
};
}
function formatReadCompactionBanner(techniques: string[]): string {
return `${READ_COMPACTION_BANNER_PREFIX} ${techniques.join(", ")}]`;
}
function countLines(text: string): number {
if (!text) {
return 0;
}
const normalized = text.endsWith("\n") ? text.slice(0, -1) : text;
if (!normalized) {
return 1;
}
return normalized.split("\n").length;
}
function hasLossyCompaction(techniques: string[]): boolean {
return techniques.some((technique) =>
LOSSY_TECHNIQUE_PREFIXES.some((prefix) =>
prefix.endsWith(":") ? technique.startsWith(prefix) : technique === prefix,
),
);
}
function normalizeTechniqueResult(result: string | null, currentText: string): string {
return result === null ? currentText : result;
}
interface CompactionState {
text: string;
techniques: string[];
}
/** Strips ANSI escape codes when enabled, recording the "ansi" technique on change. */
function applyAnsiStripping(state: CompactionState, compaction: RtkIntegrationConfig["outputCompaction"]): void {
if (!compaction.stripAnsi) {
return;
}
const stripped = stripAnsiFast(state.text);
if (stripped !== state.text) {
state.text = stripped;
state.techniques.push("ansi");
}
}
/** Applies hard character truncation when enabled and the threshold is exceeded. */
function applyTruncation(state: CompactionState, compaction: RtkIntegrationConfig["outputCompaction"]): void {
if (compaction.truncate.enabled && state.text.length > compaction.truncate.maxChars) {
state.text = truncate(state.text, compaction.truncate.maxChars);
state.techniques.push("truncate");
}
}
/**
* Applies a single nullable-result compaction technique: runs `transform`, keeps
* its result when it differs from the current text, and records `technique`.
* Mirrors the `normalizeTechniqueResult(...) !== current → push` idiom shared
* across the bash/read compactors.
*/
function applyNullableTechnique(
state: CompactionState,
transform: (text: string) => string | null,
technique: string,
): void {
const compacted = normalizeTechniqueResult(transform(state.text), state.text);
if (compacted !== state.text) {
state.text = compacted;
state.techniques.push(technique);
}
}
function applyConditionalTechnique(
state: CompactionState,
enabled: boolean,
transform: (text: string) => string | null,
technique: string,
): void {
if (enabled) {
applyNullableTechnique(state, transform, technique);
}
}
function beginCompaction(
text: string,
config: RtkIntegrationConfig,
): { state: CompactionState; compaction: RtkIntegrationConfig["outputCompaction"] } {
const state: CompactionState = { text, techniques: [] };
const compaction = config.outputCompaction;
applyAnsiStripping(state, compaction);
return { state, compaction };
}
function applyReadCompactionBanner(state: CompactionState): void {
if (state.techniques.length > 0 && !state.text.startsWith(READ_COMPACTION_BANNER_PREFIX)) {
state.text = `${formatReadCompactionBanner(state.techniques)}\n${state.text}`;
}
}
function compactBashText(
text: string,
command: string | undefined,
config: RtkIntegrationConfig,
): { text: string; techniques: string[] } {
const { state, compaction } = beginCompaction(text, config);
applyConditionalTechnique(state, compaction.filterBuildOutput, (t) => filterBuildOutput(t, command), "build");
applyConditionalTechnique(state, compaction.aggregateTestOutput, (t) => aggregateTestOutput(t, command), "test");
applyConditionalTechnique(state, compaction.compactGitOutput, (t) => compactGitOutput(t, command), "git");
applyConditionalTechnique(state, compaction.aggregateLinterOutput, (t) => aggregateLinterOutput(t, command), "linter");
applyTruncation(state, compaction);
return { text: state.text, techniques: state.techniques };
}
function compactReadText(
text: string,
filePath: string,
config: RtkIntegrationConfig,
preserveExactReadOutput: boolean,
): { text: string; techniques: string[] } {
if (preserveExactReadOutput) {
return { text, techniques: [] };
}
const { state, compaction } = beginCompaction(text, config);
if (looksLikeAnchoredReadOutput(state.text)) {
const anchored = compactAnchoredReadText(state.text, filePath, config);
state.text = anchored.text;
state.techniques.push(...anchored.techniques);
applyReadCompactionBanner(state);
return { text: state.text, techniques: state.techniques };
}
const language = detectLanguage(filePath);
// Only apply lossy source filtering when a downstream line/char safeguard would otherwise trigger.
if (
compaction.sourceCodeFilteringEnabled &&
compaction.sourceCodeFiltering !== "none" &&
shouldApplyReadSourceFiltering(text, config)
) {
applyNullableTechnique(
state,
(t) => filterSourceCode(t, language, compaction.sourceCodeFiltering),
`source:${compaction.sourceCodeFiltering}`,
);
}
if (compaction.smartTruncate.enabled) {
const lineCount = state.text.split("\n").length;
if (lineCount > compaction.smartTruncate.maxLines) {
const compacted = smartTruncate(state.text, compaction.smartTruncate.maxLines, language);
if (compacted !== state.text) {
state.text = compacted;
state.techniques.push("smart-truncate");
}
}
}
applyTruncation(state, compaction);
applyReadCompactionBanner(state);
return { text: state.text, techniques: state.techniques };
}
export function compactToolResult(
event: ToolResultLikeEvent,
config: RtkIntegrationConfig,
): ToolResultCompactionOutcome {
if (!config.outputCompaction.enabled) {
return { changed: false, techniques: [] };
}
const input = toRecord(event.input);
const sourceContent = toArray(event.content);
if (sourceContent.length === 0) {
return { changed: false, techniques: [] };
}
const allTechniques = new Set<string>();
const originalChunks: string[] = [];
const filteredChunks: string[] = [];
const { changed, mapped: nextContent } = mapTextContentBlocks(sourceContent, (contentBlock) => {
let transformed = { text: contentBlock.text, techniques: [] as string[] };
if (event.toolName === "bash") {
transformed = compactBashText(contentBlock.text, normalizeCommand(input), config);
} else if (event.toolName === "read") {
const normalizedPath = normalizePath(input);
transformed = compactReadText(
contentBlock.text,
normalizedPath,
config,
shouldPreserveExactReadOutput(contentBlock.text, input, config),
);
}
for (const technique of transformed.techniques) {
allTechniques.add(technique);
}
originalChunks.push(contentBlock.text);
filteredChunks.push(transformed.text);
return transformed.text !== contentBlock.text ? transformed.text : null;
});
if (!changed) {
return { changed: false, techniques: [] };
}
const techniques = Array.from(allTechniques);
const originalText = originalChunks.join("\n");
const compactedText = filteredChunks.join("\n");
if (config.outputCompaction.trackSavings) {
trackOutputSavings(originalText, compactedText, event.toolName, techniques);
}
const metadata: ToolResultCompactionMetadata = {
applied: true,
techniques,
truncated: hasLossyCompaction(techniques),
originalCharCount: originalText.length,
compactedCharCount: compactedText.length,
originalLineCount: countLines(originalText),
compactedLineCount: countLines(compactedText),
};
return {
changed: true,
content: nextContent,
techniques,
metadata,
};
}
+69
View File
@@ -0,0 +1,69 @@
export interface OutputMetricRecord {
timestamp: string;
tool: string;
techniques: string;
originalChars: number;
filteredChars: number;
savingsPercent: number;
}
const outputMetrics: OutputMetricRecord[] = [];
export function trackOutputSavings(
original: string,
filtered: string,
tool: string,
techniques: string[],
): OutputMetricRecord {
const originalChars = original.length;
const filteredChars = filtered.length;
const savingsPercent =
originalChars > 0 ? Math.round((((originalChars - filteredChars) / originalChars) * 100) * 100) / 100 : 0;
const record: OutputMetricRecord = {
timestamp: new Date().toISOString(),
tool,
techniques: techniques.join(",") || "none",
originalChars,
filteredChars,
savingsPercent,
};
outputMetrics.push(record);
return record;
}
export function clearOutputMetrics(): void {
outputMetrics.length = 0;
}
export function getOutputMetricsSummary(): string {
if (outputMetrics.length === 0) {
return "RTK output compaction metrics: no data yet.";
}
const totalOriginal = outputMetrics.reduce((sum, metric) => sum + metric.originalChars, 0);
const totalFiltered = outputMetrics.reduce((sum, metric) => sum + metric.filteredChars, 0);
const totalSaved = totalOriginal - totalFiltered;
const savingsPercent = totalOriginal > 0 ? (totalSaved / totalOriginal) * 100 : 0;
const byTool = new Map<string, { count: number; originalChars: number; filteredChars: number }>();
for (const metric of outputMetrics) {
const existing = byTool.get(metric.tool) ?? { count: 0, originalChars: 0, filteredChars: 0 };
existing.count += 1;
existing.originalChars += metric.originalChars;
existing.filteredChars += metric.filteredChars;
byTool.set(metric.tool, existing);
}
let result = "RTK output compaction metrics\n";
result += `calls=${outputMetrics.length}, saved=${totalSaved.toLocaleString()} chars (${savingsPercent.toFixed(1)}%)\n`;
for (const [tool, stats] of byTool.entries()) {
const toolSaved = stats.originalChars - stats.filteredChars;
const toolSavingsPercent = stats.originalChars > 0 ? (toolSaved / stats.originalChars) * 100 : 0;
result += `- ${tool}: ${stats.count} calls, saved ${toolSaved.toLocaleString()} chars (${toolSavingsPercent.toFixed(1)}%)\n`;
}
return result.trimEnd();
}
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { runTest } from "./test-helpers.test.ts";
type PackageLockPackage = {
version?: unknown;
bin?: Record<string, unknown>;
};
type PackageLock = {
packages?: Record<string, PackageLockPackage>;
};
const packageLockPath = fileURLToPath(new URL("../package-lock.json", import.meta.url));
function loadPackageLock(): PackageLock {
return JSON.parse(readFileSync(packageLockPath, "utf-8")) as PackageLock;
}
function packageEntries(): Array<[string, PackageLockPackage]> {
return Object.entries(loadPackageLock().packages ?? {});
}
runTest("package-lock is install-safe and npm-idempotent", () => {
const entries = packageEntries();
const missingVersions = entries
.filter(([packagePath, metadata]) => packagePath !== "" && typeof metadata.version !== "string")
.map(([packagePath]) => packagePath);
const unnormalizedBinPaths = entries.flatMap(([packagePath, metadata]) =>
Object.entries(metadata.bin ?? {})
.filter(([, binPath]) => typeof binPath === "string" && binPath.startsWith("./"))
.map(([binName, binPath]) => `${packagePath}:${binName}=${binPath}`),
);
assert.deepEqual(
{ missingVersions, unnormalizedBinPaths },
{ missingVersions: [], unnormalizedBinPaths: [] },
[
"package-lock.json must be safe for npm install --omit=dev and stay unchanged after install.",
"Missing versions reproduce npm's Invalid Version failure.",
"Leading ./ bin paths reproduce npm lockfile normalization diffs after install.",
].join(" "),
);
});
console.log("All package-lock integrity tests passed.");
+50
View File
@@ -0,0 +1,50 @@
export function toRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
export interface TextContentBlock {
type: string;
text?: string;
[key: string]: unknown;
}
/**
* Narrows an unknown tool-result content block to a text block carrying a
* string `text` payload. Shared by the compactor and the streaming sanitizer
* so both walk content blocks with one consistent guard.
*/
export function isTextContentBlock(block: unknown): block is TextContentBlock & { text: string } {
if (!block || typeof block !== "object" || Array.isArray(block)) {
return false;
}
const contentBlock = block as TextContentBlock;
return contentBlock.type === "text" && typeof contentBlock.text === "string";
}
/**
* Walks tool-result content blocks, invoking `transform` for each text block.
* Returns the mapped content and whether any text block changed. Non-text
* blocks pass through untouched. Shared by the compactor and the streaming
* sanitizer so both walk content with one consistent loop.
*/
export function mapTextContentBlocks(
content: unknown[],
transform: (block: TextContentBlock & { text: string }) => string | null,
): { changed: boolean; mapped: unknown[] } {
let changed = false;
const mapped = content.map((block) => {
if (!isTextContentBlock(block)) {
return block;
}
const nextText = transform(block);
if (nextText === null || nextText === block.text) {
return block;
}
changed = true;
return { ...block, text: nextText };
});
return { changed, mapped };
}
@@ -0,0 +1,162 @@
import { splitLeadingEnvAssignments } from "./shell-env-prefix.js";
import { advanceQuoteEscapeState, readShellChars, type QuoteEscapeState } from "./shell-quote-state.js";
interface ParsedPipeline {
segments: string[];
separators: string[];
suffix: string;
}
interface ProducerRewritePlan {
command: string;
captureStderr: boolean;
}
interface ShellSafetyTarget {
environmentPrelude: string;
command: string;
}
const SINGLE_QUOTED_SHELL_VALUE_PATTERN = "'(?:'\\\\''|[^'])*'";
const SHELL_ENV_VALUE_PATTERN = `(?:"(?:\\\\.|[^"])*"|${SINGLE_QUOTED_SHELL_VALUE_PATTERN}|[^\\s;]+)`;
const LEADING_RTK_DB_PATH_EXPORT_PRELUDE_PATTERN = new RegExp(
`^(\\s*export\\s+RTK_DB_PATH=${SHELL_ENV_VALUE_PATTERN}\\s*;\\s*)([\\s\\S]*)$`,
"u",
);
function splitLeadingRtkDbPathExportPrelude(command: string): ShellSafetyTarget {
const match = command.match(LEADING_RTK_DB_PATH_EXPORT_PRELUDE_PATTERN);
if (!match) {
return { environmentPrelude: "", command };
}
return {
environmentPrelude: match[1] ?? "",
command: match[2] ?? "",
};
}
function parseSimpleTopLevelPipeline(command: string): ParsedPipeline | null {
const segments: string[] = [];
const separators: string[] = [];
const state: QuoteEscapeState = { quote: null, escaped: false };
let segmentStart = 0;
let suffix = "";
for (let index = 0; index < command.length; index += 1) {
const { character, nextCharacter } = readShellChars(command, index);
const previousCharacter = index > 0 ? (command[index - 1] ?? "") : "";
if (advanceQuoteEscapeState(state, character, "\"'`")) {
continue;
}
if (
(character === "|" && nextCharacter === "|") ||
(character === "&" && nextCharacter === "&") ||
character === ";"
) {
if (separators.length === 0) {
return null;
}
segments.push(command.slice(segmentStart, index));
suffix = command.slice(index);
break;
}
if (character === "|" && previousCharacter !== ">") {
const separatorLength = nextCharacter === "&" ? 2 : 1;
segments.push(command.slice(segmentStart, index));
separators.push(command.slice(index, index + separatorLength));
segmentStart = index + separatorLength;
if (separatorLength === 2) {
index += 1;
}
continue;
}
if (character === "&" && nextCharacter !== ">" && previousCharacter !== ">" && previousCharacter !== "<") {
return null;
}
}
if (separators.length === 0) {
return null;
}
if (!suffix) {
segments.push(command.slice(segmentStart));
}
return { segments, separators, suffix };
}
function extractProducerRewritePlan(segment: string, firstSeparator: string): ProducerRewritePlan | null {
const trimmed = segment.trim();
const { envPrefix, command: commandWithOptionalRedirect } = splitLeadingEnvAssignments(trimmed);
if (!/^rtk\s+/i.test(commandWithOptionalRedirect)) {
return null;
}
const stderrMergeMatch = commandWithOptionalRedirect.match(/^(.*?)(?:\s+)?2>\s*&1\s*$/u);
if (stderrMergeMatch) {
const command = stderrMergeMatch[1]?.trimEnd() ?? "";
return command ? { command: `${envPrefix}${command}`.trim(), captureStderr: true } : null;
}
return {
command: `${envPrefix}${commandWithOptionalRedirect}`.trim(),
captureStderr: firstSeparator === "|&",
};
}
function buildBufferedPipelineCommand(
producer: ProducerRewritePlan,
remainder: string,
): string {
const tempFileVariable = "__pi_rtk_pipe_tmp";
const statusVariable = "__pi_rtk_pipe_status";
const producerRedirect = producer.captureStderr ? `> "$${tempFileVariable}" 2>&1` : `> "$${tempFileVariable}"`;
const cleanupTrap = `rm -f "$${tempFileVariable}"`;
return [
"{",
`${tempFileVariable}="$(mktemp)" || exit $?;`,
`${statusVariable}=0;`,
`trap '${cleanupTrap}' EXIT HUP INT TERM;`,
`${producer.command} ${producerRedirect};`,
`${statusVariable}=$?;`,
`if [ $${statusVariable} -eq 0 ]; then (${remainder}) < "$${tempFileVariable}"; ${statusVariable}=$?; fi;`,
`exit $${statusVariable};`,
"}",
].join(" ");
}
export function applyRewrittenCommandShellSafetyFixups(command: string, platform: string = process.platform): string {
if (platform !== "win32") {
return command;
}
const target = splitLeadingRtkDbPathExportPrelude(command);
const parsedPipeline = parseSimpleTopLevelPipeline(target.command);
if (!parsedPipeline) {
return command;
}
const producer = extractProducerRewritePlan(parsedPipeline.segments[0] ?? "", parsedPipeline.separators[0] ?? "");
if (!producer) {
return command;
}
const remainder = parsedPipeline.segments
.slice(1)
.map((segment, index) => `${index === 0 ? "" : (parsedPipeline.separators[index] ?? "")}${segment}`)
.join("")
.trim();
if (!remainder) {
return command;
}
const suffix = parsedPipeline.suffix ? ` ${parsedPipeline.suffix.trimStart()}` : "";
return `${target.environmentPrelude}${buildBufferedPipelineCommand(producer, remainder)}${suffix}`;
}
@@ -0,0 +1,77 @@
import { join } from "node:path";
import { splitLeadingEnvAssignments } from "./shell-env-prefix.js";
const RTK_DB_PATH_ENV_NAME = "RTK_DB_PATH";
const SINGLE_QUOTED_SHELL_VALUE_PATTERN = "'(?:'\\\\''|[^'])*'";
const SHELL_ENV_VALUE_PATTERN = `(?:"[^"]*"|${SINGLE_QUOTED_SHELL_VALUE_PATTERN}|[^\\s;]+)`;
const RTK_DB_PATH_ASSIGNMENT_PATTERN = new RegExp(
`(?:^|\\s)RTK_DB_PATH=${SHELL_ENV_VALUE_PATTERN}(?=\\s|$)`,
);
const RTK_DB_PATH_EXPORT_PATTERN = new RegExp(`^export\\s+RTK_DB_PATH=${SHELL_ENV_VALUE_PATTERN}(?=\\s*(?:;|$))`);
function resolveTemporaryDirectory(): string {
if (process.platform === "win32") {
const windowsTempDir = process.env.TEMP ?? process.env.TMP;
if (windowsTempDir && windowsTempDir.trim()) {
return windowsTempDir;
}
const localAppData = process.env.LOCALAPPDATA;
if (localAppData && localAppData.trim()) {
return join(localAppData, "Temp");
}
const userProfile = process.env.USERPROFILE;
if (userProfile && userProfile.trim()) {
return join(userProfile, "AppData", "Local", "Temp");
}
const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
if (systemRoot && systemRoot.trim()) {
return join(systemRoot, "Temp");
}
return "C:/Windows/Temp";
}
const posixTempDir = process.env.TMPDIR ?? process.env.TMP;
if (posixTempDir && posixTempDir.trim()) {
return posixTempDir;
}
return "/tmp";
}
function getTemporaryRtkHistoryDbPath(): string {
return join(resolveTemporaryDirectory(), "pi-rtk-optimizer", "history.db");
}
function quoteForShellEnv(value: string): string {
const normalizedValue = process.platform === "win32" ? value.replace(/\\/g, "/") : value;
return `'${normalizedValue.replace(/'/g, `'\\''`)}'`;
}
function hasLeadingRtkDbPathAssignment(command: string): boolean {
const trimmed = command.trimStart();
return (
RTK_DB_PATH_ASSIGNMENT_PATTERN.test(splitLeadingEnvAssignments(trimmed).envPrefix) ||
RTK_DB_PATH_EXPORT_PATTERN.test(trimmed)
);
}
function hasInheritedRtkDbPath(): boolean {
return Boolean(process.env[RTK_DB_PATH_ENV_NAME]?.trim());
}
export function applyRtkCommandEnvironment(command: string): string {
if (!command.trim()) {
return command;
}
if (hasLeadingRtkDbPathAssignment(command) || hasInheritedRtkDbPath()) {
return command;
}
return `export ${RTK_DB_PATH_ENV_NAME}=${quoteForShellEnv(getTemporaryRtkHistoryDbPath())}; ${command}`;
}
@@ -0,0 +1,97 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export type RtkExecutableResolverName = "where" | "which";
export interface RtkExecutableResolution {
command: string;
resolvedPath?: string;
resolver: RtkExecutableResolverName;
warning?: string;
}
interface ResolverCommand {
command: RtkExecutableResolverName;
args: string[];
}
export interface ResolveRtkExecutableOptions {
platform?: typeof process.platform;
timeoutMs?: number;
}
function trimResolutionDetail(value: string | undefined): string {
return (value ?? "").replace(/\s+/g, " ").trim();
}
function stripWrappingQuotes(value: string): string {
if (value.length < 2) {
return value;
}
const first = value[0];
const last = value[value.length - 1];
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
return value.slice(1, -1);
}
return value;
}
export function parseRtkExecutablePath(stdout: string): string | undefined {
for (const line of stdout.split(/\r?\n/)) {
const candidate = stripWrappingQuotes(line.trim());
if (candidate) {
return candidate;
}
}
return undefined;
}
function getResolverCommand(platform: typeof process.platform): ResolverCommand {
if (platform === "win32") {
return { command: "where", args: ["rtk"] };
}
return { command: "which", args: ["rtk"] };
}
function fallbackResolution(resolver: RtkExecutableResolverName, warning: string): RtkExecutableResolution {
return {
command: "rtk",
resolver,
warning,
};
}
export async function resolveRtkExecutable(
pi: ExtensionAPI,
options: ResolveRtkExecutableOptions = {},
): Promise<RtkExecutableResolution> {
const resolver = getResolverCommand(options.platform ?? process.platform);
const timeout = options.timeoutMs ?? 1000;
try {
const result = await pi.exec(resolver.command, resolver.args, { timeout });
const resolvedPath = parseRtkExecutablePath(result.stdout ?? "");
if (result.code === 0 && resolvedPath) {
return {
command: resolvedPath,
resolvedPath,
resolver: resolver.command,
};
}
const detail = trimResolutionDetail(result.stderr || result.stdout || `exit ${result.code}`);
return fallbackResolution(
resolver.command,
`rtk executable path resolution via ${resolver.command} failed${detail ? `: ${detail}` : ""}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return fallbackResolution(
resolver.command,
`rtk executable path resolution via ${resolver.command} failed: ${trimResolutionDetail(message)}`,
);
}
}
@@ -0,0 +1,126 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { resolveRtkExecutable, type RtkExecutableResolution } from "./rtk-executable-resolver.js";
export interface RtkRewriteProviderResult {
changed: boolean;
originalCommand: string;
rewrittenCommand: string;
exitCode: number;
error?: string;
executableResolution?: RtkExecutableResolution;
}
export interface RtkRewriteProviderOptions {
timeoutMs?: number;
resolverTimeoutMs?: number;
platform?: typeof process.platform;
executableResolution?: RtkExecutableResolution;
}
function isAlreadyRtk(command: string): boolean {
const trimmed = command.trimStart();
return trimmed === "rtk" || trimmed.startsWith("rtk ");
}
function normalizeOptions(optionsOrTimeout: number | RtkRewriteProviderOptions): RtkRewriteProviderOptions {
if (typeof optionsOrTimeout === "number") {
return { timeoutMs: optionsOrTimeout };
}
return optionsOrTimeout;
}
export async function resolveRtkRewrite(
pi: ExtensionAPI,
command: string,
optionsOrTimeout: number | RtkRewriteProviderOptions = {},
): Promise<RtkRewriteProviderResult> {
const options = normalizeOptions(optionsOrTimeout);
const timeoutMs = options.timeoutMs ?? 3000;
if (!command || !command.trim()) {
return { changed: false, originalCommand: command, rewrittenCommand: command, exitCode: 1 };
}
if (isAlreadyRtk(command)) {
return { changed: false, originalCommand: command, rewrittenCommand: command, exitCode: 1 };
}
try {
const executableResolution =
options.executableResolution ??
(await resolveRtkExecutable(pi, {
platform: options.platform,
timeoutMs: options.resolverTimeoutMs,
}));
const result = await pi.exec(executableResolution.command, ["rewrite", command], { timeout: timeoutMs });
if (result.code === 1) {
return {
changed: false,
originalCommand: command,
rewrittenCommand: command,
exitCode: 1,
executableResolution,
};
}
if (result.code === 2) {
return {
changed: false,
originalCommand: command,
rewrittenCommand: command,
exitCode: 2,
error: result.stderr?.trim() || "rtk denied rewrite",
executableResolution,
};
}
if (result.code === 0 || result.code === 3) {
const rewritten = result.stdout?.trim();
if (!rewritten) {
return {
changed: false,
originalCommand: command,
rewrittenCommand: command,
exitCode: result.code,
error: "rtk returned empty output",
executableResolution,
};
}
if (rewritten === command) {
return {
changed: false,
originalCommand: command,
rewrittenCommand: command,
exitCode: result.code,
executableResolution,
};
}
return {
changed: true,
originalCommand: command,
rewrittenCommand: rewritten,
exitCode: result.code,
executableResolution,
};
}
return {
changed: false,
originalCommand: command,
rewrittenCommand: command,
exitCode: result.code,
error: `unexpected exit code ${result.code}`,
executableResolution,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
changed: false,
originalCommand: command,
rewrittenCommand: command,
exitCode: -1,
error: message,
};
}
}
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import {
shouldRequireRtkAvailabilityForCommandHandling,
shouldSkipCommandHandlingWhenRtkMissing,
} from "./runtime-guard.ts";
import { cloneDefaultConfig, runTest } from "./test-helpers.test.ts";
import type { RuntimeStatus } from "./types.ts";
function runtimeStatus(rtkAvailable: boolean): RuntimeStatus {
return { rtkAvailable };
}
runTest("rewrite mode still requires RTK availability when guard is enabled", () => {
const config = cloneDefaultConfig();
config.commandRewritingEnabled = true;
config.mode = "rewrite";
config.guardWhenRtkMissing = true;
assert.equal(shouldRequireRtkAvailabilityForCommandHandling(config), true);
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(false)), true);
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(true)), false);
});
runTest("suggest mode uses RTK availability guard to avoid repeated missing-binary rewrite probes", () => {
const config = cloneDefaultConfig();
config.commandRewritingEnabled = true;
config.mode = "suggest";
config.guardWhenRtkMissing = true;
assert.equal(shouldRequireRtkAvailabilityForCommandHandling(config), true);
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(false)), true);
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(true)), false);
});
runTest("guard disabled never blocks command handling", () => {
const config = cloneDefaultConfig();
config.commandRewritingEnabled = true;
config.mode = "rewrite";
config.guardWhenRtkMissing = false;
assert.equal(shouldRequireRtkAvailabilityForCommandHandling(config), false);
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(false)), false);
});
runTest("disabled command rewriting never requires the RTK binary", () => {
const config = cloneDefaultConfig();
config.commandRewritingEnabled = false;
config.guardWhenRtkMissing = true;
assert.equal(shouldRequireRtkAvailabilityForCommandHandling(config), false);
assert.equal(shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus(false)), false);
});
console.log("All runtime-guard tests passed.");
+14
View File
@@ -0,0 +1,14 @@
import type { RtkIntegrationConfig, RuntimeStatus } from "./types.js";
export function shouldRequireRtkAvailabilityForCommandHandling(
config: Pick<RtkIntegrationConfig, "commandRewritingEnabled" | "guardWhenRtkMissing">,
): boolean {
return config.commandRewritingEnabled && config.guardWhenRtkMissing;
}
export function shouldSkipCommandHandlingWhenRtkMissing(
config: Pick<RtkIntegrationConfig, "commandRewritingEnabled" | "guardWhenRtkMissing">,
runtimeStatus: Pick<RuntimeStatus, "rtkAvailable">,
): boolean {
return shouldRequireRtkAvailabilityForCommandHandling(config) && !runtimeStatus.rtkAvailable;
}
+18
View File
@@ -0,0 +1,18 @@
const SINGLE_QUOTED_SHELL_VALUE_PATTERN = "'(?:'\\\\''|[^'])*'";
const ENV_ASSIGNMENT_VALUE_PATTERN = `(?:"[^"]*"|${SINGLE_QUOTED_SHELL_VALUE_PATTERN}|[^\\s]+)`;
const LEADING_ENV_ASSIGNMENT_PATTERN = new RegExp(
`^((?:[A-Za-z_][A-Za-z0-9_]*=${ENV_ASSIGNMENT_VALUE_PATTERN}\\s+)*)`,
);
export interface LeadingEnvAssignmentSplit {
envPrefix: string;
command: string;
}
export function splitLeadingEnvAssignments(input: string): LeadingEnvAssignmentSplit {
const envPrefix = input.match(LEADING_ENV_ASSIGNMENT_PATTERN)?.[1] ?? "";
return {
envPrefix,
command: input.slice(envPrefix.length),
};
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Shared shell quote/escape state machine used by command-parsing helpers.
*
* Both the top-level pipeline splitter and the leading-`cd /d` parser walk a
* command string character-by-character while tracking whether the cursor is
* inside a quoted region and whether the current character is backslash-
* escaped. This helper advances that state for one character so the two parsers
* do not duplicate the transition logic.
*
* `quoteChars` selects which characters open a quote (e.g. `'"\'\`'` for the
* pipeline parser, `'"\'` for the `cd /d` parser), preserving each caller's
* exact quote semantics.
*
* Returns `true` when the character is consumed by the state machine (caller
* should `continue` to the next character); returns `false` when the character
* is a top-level, unquoted, unescaped token the caller must interpret.
*/
export interface QuoteEscapeState {
quote: string | null;
escaped: boolean;
}
export function advanceQuoteEscapeState(
state: QuoteEscapeState,
character: string,
quoteChars: string,
): boolean {
if (state.escaped) {
state.escaped = false;
return true;
}
if (state.quote !== null) {
if (character === "\\" && state.quote !== "'") {
state.escaped = true;
return true;
}
if (character === state.quote) {
state.quote = null;
}
return true;
}
if (character === "\\") {
state.escaped = true;
return true;
}
if (quoteChars.includes(character)) {
state.quote = character;
return true;
}
return false;
}
/**
* Reads the current and next character from a command string at `index`,
* returning empty strings past either end so callers can compare without
* bounds checks. Shared by the command-parsing helpers that walk a command
* character-by-character.
*/
export function readShellChars(
command: string,
index: number,
): { character: string; nextCharacter: string } {
return {
character: command[index] ?? "",
nextCharacter: command[index + 1] ?? "",
};
}
+13
View File
@@ -0,0 +1,13 @@
export function stripAnsi(text: string): string {
return text
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
.replace(/\x1b\][0-9;]*(?:\x07|\x1b\\)/g, "")
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
}
export function stripAnsiFast(text: string): string {
if (!text.includes("\x1b")) {
return text;
}
return stripAnsi(text);
}
+155
View File
@@ -0,0 +1,155 @@
import { matchesCommandPatterns } from "./command-detection.js";
interface BuildStats {
compiled: number;
errors: string[][];
warnings: string[];
}
const BUILD_COMMAND_PATTERNS = [
/^cargo\s+(build|check)\b/,
/^bun\s+build\b/,
/^npm\s+run\s+build\b/,
/^yarn\s+build\b/,
/^pnpm\s+build\b/,
/^(?:npx\s+)?tsc\b/,
/^make\b/,
/^cmake\b/,
/^gradle\b/,
/^mvn\b/,
/^go\s+(build|install)\b/,
/^python\s+setup\.py\s+build\b/,
/^pip\s+install\b/,
] as const;
const SKIP_PATTERNS = [
/^\s*Compiling\s+/,
/^\s*Checking\s+/,
/^\s*Downloading\s+/,
/^\s*Downloaded\s+/,
/^\s*Fetching\s+/,
/^\s*Fetched\s+/,
/^\s*Updating\s+/,
/^\s*Updated\s+/,
/^\s*Building\s+/,
/^\s*Generated\s+/,
/^\s*Creating\s+/,
/^\s*Running\s+/,
];
const ERROR_START_PATTERNS = [/^error\[/, /^error:/, /^\[ERROR\]/, /^FAIL/];
const WARNING_PATTERNS = [/^warning:/, /^\[WARNING\]/, /^warn:/];
function isSkipLine(line: string): boolean {
return SKIP_PATTERNS.some((pattern) => pattern.test(line));
}
function isErrorStart(line: string): boolean {
return ERROR_START_PATTERNS.some((pattern) => pattern.test(line));
}
function isWarning(line: string): boolean {
return WARNING_PATTERNS.some((pattern) => pattern.test(line));
}
export function isBuildCommand(command: string | undefined | null): boolean {
return matchesCommandPatterns(command, BUILD_COMMAND_PATTERNS);
}
export function filterBuildOutput(output: string, command: string | undefined | null): string | null {
if (!isBuildCommand(command)) {
return null;
}
const lines = output.split("\n");
const stats: BuildStats = {
compiled: 0,
errors: [],
warnings: [],
};
let inErrorBlock = false;
let currentError: string[] = [];
let blankCount = 0;
for (const line of lines) {
if (line.match(/^\s*(Compiling|Checking|Building)\s+/)) {
stats.compiled++;
continue;
}
if (isSkipLine(line)) {
continue;
}
if (isErrorStart(line)) {
if (inErrorBlock && currentError.length > 0) {
stats.errors.push([...currentError]);
}
inErrorBlock = true;
currentError = [line];
blankCount = 0;
continue;
}
if (isWarning(line)) {
stats.warnings.push(line);
continue;
}
if (!inErrorBlock) {
continue;
}
if (line.trim() === "") {
blankCount++;
if (blankCount >= 2 && currentError.length > 3) {
stats.errors.push([...currentError]);
inErrorBlock = false;
currentError = [];
} else {
currentError.push(line);
}
continue;
}
if (line.match(/^\s/) || line.match(/^-->/)) {
currentError.push(line);
blankCount = 0;
continue;
}
stats.errors.push([...currentError]);
inErrorBlock = false;
currentError = [];
}
if (inErrorBlock && currentError.length > 0) {
stats.errors.push(currentError);
}
if (stats.errors.length === 0 && stats.warnings.length === 0) {
return `[OK] Build successful (${stats.compiled} units compiled)`;
}
const result: string[] = [];
if (stats.errors.length > 0) {
result.push(`[ERROR] ${stats.errors.length} error(s):`);
for (const error of stats.errors.slice(0, 5)) {
result.push(...error.slice(0, 10));
if (error.length > 10) {
result.push(" ...");
}
}
if (stats.errors.length > 5) {
result.push(`... and ${stats.errors.length - 5} more errors`);
}
}
if (stats.warnings.length > 0) {
result.push(`\n[WARN] ${stats.warnings.length} warning(s)`);
}
return result.join("\n");
}
@@ -0,0 +1,53 @@
const ENV_PREFIX_PATTERN = /^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]+)\s+)*/;
const CHAIN_OPERATORS = ["&&", "||", ";", "|"] as const;
function sliceFirstSegment(command: string): string {
let cutIndex = -1;
for (const operator of CHAIN_OPERATORS) {
const index = command.indexOf(operator);
if (index === -1) {
continue;
}
if (cutIndex === -1 || index < cutIndex) {
cutIndex = index;
}
}
if (cutIndex === -1) {
return command;
}
return command.slice(0, cutIndex);
}
export function normalizeCommandForDetection(command: string | undefined | null): string | null {
if (typeof command !== "string") {
return null;
}
const firstNonEmptyLine = command
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (!firstNonEmptyLine) {
return null;
}
const withoutEnvPrefix = firstNonEmptyLine.replace(ENV_PREFIX_PATTERN, "").trim();
if (!withoutEnvPrefix) {
return null;
}
const firstSegment = sliceFirstSegment(withoutEnvPrefix).trim().toLowerCase();
return firstSegment || null;
}
export function matchesCommandPatterns(
command: string | undefined | null,
patterns: readonly RegExp[],
): boolean {
const normalized = normalizeCommandForDetection(command);
if (!normalized) {
return false;
}
return patterns.some((pattern) => pattern.test(normalized));
}
+231
View File
@@ -0,0 +1,231 @@
import { matchesCommandPatterns, normalizeCommandForDetection } from "./command-detection.js";
const GIT_COMMAND_PATTERNS = [/^git\s+(diff|status|log|show|stash)\b/] as const;
const RAW_GIT_DIFF_PATTERN = /^diff --git /m;
const RAW_GIT_STATUS_PATTERN = /^(?:## |(?:M|A|D|R|C|U|\?| )\S)/m;
export function isGitCommand(command: string | undefined | null): boolean {
return matchesCommandPatterns(command, GIT_COMMAND_PATTERNS);
}
export function compactDiff(output: string, maxLines = 50): string {
const lines = output.split("\n");
const result: string[] = [];
let currentFile = "";
let added = 0;
let removed = 0;
let inHunk = false;
let hunkLines = 0;
const maxHunkLines = 10;
for (const line of lines) {
if (result.length >= maxLines) {
result.push("\n... (more changes truncated)");
break;
}
if (line.startsWith("diff --git")) {
if (currentFile && (added > 0 || removed > 0)) {
result.push(` +${added} -${removed}`);
}
const match = line.match(/diff --git a\/(.+) b\/(.+)/);
currentFile = match?.[2] ?? "unknown";
result.push(`\n> ${currentFile}`);
added = 0;
removed = 0;
inHunk = false;
continue;
}
if (line.startsWith("@@")) {
inHunk = true;
hunkLines = 0;
const hunkInfo = line.match(/@@ .+ @@/)?.[0] ?? "@@";
result.push(` ${hunkInfo}`);
continue;
}
if (!inHunk) {
continue;
}
if (line.startsWith("+") && !line.startsWith("+++")) {
added++;
if (hunkLines < maxHunkLines) {
result.push(` ${line}`);
hunkLines++;
}
} else if (line.startsWith("-") && !line.startsWith("---")) {
removed++;
if (hunkLines < maxHunkLines) {
result.push(` ${line}`);
hunkLines++;
}
} else if (hunkLines < maxHunkLines && !line.startsWith("\\")) {
if (hunkLines > 0) {
result.push(` ${line}`);
hunkLines++;
}
}
if (hunkLines === maxHunkLines) {
result.push(" ... (truncated)");
hunkLines++;
}
}
if (currentFile && (added > 0 || removed > 0)) {
result.push(` +${added} -${removed}`);
}
return result.join("\n");
}
interface StatusStats {
staged: number;
modified: number;
untracked: number;
conflicts: number;
stagedFiles: string[];
modifiedFiles: string[];
untrackedFiles: string[];
}
export function compactStatus(output: string): string {
const lines = output.split("\n");
if (lines.length === 0 || (lines.length === 1 && lines[0]?.trim() === "")) {
return "Clean working tree";
}
const stats: StatusStats = {
staged: 0,
modified: 0,
untracked: 0,
conflicts: 0,
stagedFiles: [],
modifiedFiles: [],
untrackedFiles: [],
};
let branchName = "";
for (const line of lines) {
if (line.startsWith("##")) {
const match = line.match(/## (.+)/);
if (match?.[1]) {
branchName = match[1].split("...")[0] ?? match[1];
}
continue;
}
if (line.length < 3) {
continue;
}
const status = line.slice(0, 2);
const filename = line.slice(3);
const indexStatus = status[0];
const worktreeStatus = status[1];
if (["M", "A", "D", "R", "C"].includes(indexStatus)) {
stats.staged++;
stats.stagedFiles.push(filename);
}
if (indexStatus === "U") {
stats.conflicts++;
}
if (["M", "D"].includes(worktreeStatus)) {
stats.modified++;
stats.modifiedFiles.push(filename);
}
if (status === "??") {
stats.untracked++;
stats.untrackedFiles.push(filename);
}
}
let result = `Branch: ${branchName}\n`;
if (stats.staged > 0) {
result += `Staged: ${stats.staged} files\n`;
for (const file of stats.stagedFiles.slice(0, 5)) {
result += ` ${file}\n`;
}
if (stats.staged > 5) {
result += ` ... +${stats.staged - 5} more\n`;
}
}
if (stats.modified > 0) {
result += `Modified: ${stats.modified} files\n`;
for (const file of stats.modifiedFiles.slice(0, 5)) {
result += ` ${file}\n`;
}
if (stats.modified > 5) {
result += ` ... +${stats.modified - 5} more\n`;
}
}
if (stats.untracked > 0) {
result += `Untracked: ${stats.untracked} files\n`;
for (const file of stats.untrackedFiles.slice(0, 3)) {
result += ` ${file}\n`;
}
if (stats.untracked > 3) {
result += ` ... +${stats.untracked - 3} more\n`;
}
}
if (stats.conflicts > 0) {
result += `Conflicts: ${stats.conflicts} files\n`;
}
return result.trim();
}
export function compactLog(output: string, limit = 20): string {
const lines = output.split("\n");
const result: string[] = [];
for (const line of lines.slice(0, limit)) {
if (line.length > 80) {
result.push(`${line.slice(0, 77)}...`);
} else {
result.push(line);
}
}
if (lines.length > limit) {
result.push(`... and ${lines.length - limit} more commits`);
}
return result.join("\n");
}
export function compactGitOutput(output: string, command: string | undefined | null): string | null {
if (!isGitCommand(command)) {
return null;
}
const normalized = normalizeCommandForDetection(command);
if (!normalized) {
return null;
}
if (normalized.startsWith("git diff")) {
return RAW_GIT_DIFF_PATTERN.test(output) ? compactDiff(output) : null;
}
if (normalized.startsWith("git status")) {
return RAW_GIT_STATUS_PATTERN.test(output) ? compactStatus(output) : null;
}
if (normalized.startsWith("git log")) {
return compactLog(output);
}
return null;
}
+8
View File
@@ -0,0 +1,8 @@
export { stripAnsiFast } from "./ansi.js";
export { truncate } from "./truncate.js";
export { filterBuildOutput } from "./build.js";
export { aggregateTestOutput } from "./test-output.js";
export { aggregateLinterOutput } from "./linter.js";
export { detectLanguage, smartTruncate, filterSourceCode } from "./source.js";
export { compactGitOutput } from "./git.js";
export { groupSearchResults } from "./search.js";
+151
View File
@@ -0,0 +1,151 @@
import { matchesCommandPatterns, normalizeCommandForDetection } from "./command-detection.js";
import { compactPath } from "./path-utils.js";
const LINTER_COMMAND_PATTERNS = [
/^(?:pnpm\s+)?(?:npx\s+)?eslint\b/,
/^(?:npx\s+)?prettier\b/,
/^ruff\b/,
/^pylint\b/,
/^mypy\b/,
/^flake8\b/,
/^black\b/,
/^cargo\s+clippy\b/,
/^golangci-lint\b/,
] as const;
interface Issue {
severity: "ERROR" | "WARNING";
rule: string;
file: string;
line?: number;
message: string;
}
export function isLinterCommand(command: string | undefined | null): boolean {
return matchesCommandPatterns(command, LINTER_COMMAND_PATTERNS);
}
function parseLine(line: string): Issue | null {
const fileLinePattern = /^(.+):(\d+):(\d+):\s*(.+)$/;
const rustPattern = /^(error|warning):\s*(.+?)\s+at\s+(.+):(\d+):(\d+)$/;
const fileLineMatch = line.match(fileLinePattern);
if (fileLineMatch) {
const file = fileLineMatch[1] ?? "unknown";
const lineNumber = Number.parseInt(fileLineMatch[2] ?? "0", 10);
const content = fileLineMatch[4] ?? line;
const severity = /warning/i.test(content) ? "WARNING" : "ERROR";
const rule = content.match(/\[(.+?)\]$/)?.[1] ?? "unknown";
return {
severity,
rule,
file,
line: Number.isNaN(lineNumber) ? undefined : lineNumber,
message: content,
};
}
const rustMatch = line.match(rustPattern);
if (rustMatch) {
const severity = (rustMatch[1]?.toUpperCase() ?? "ERROR") as "ERROR" | "WARNING";
const message = rustMatch[2] ?? line;
const file = rustMatch[3] ?? "unknown";
const lineNumber = Number.parseInt(rustMatch[4] ?? "0", 10);
return {
severity,
rule: "unknown",
file,
line: Number.isNaN(lineNumber) ? undefined : lineNumber,
message,
};
}
return null;
}
function parseIssues(output: string): Issue[] {
const issues: Issue[] = [];
for (const line of output.split("\n")) {
const parsed = parseLine(line);
if (parsed) {
issues.push(parsed);
}
}
return issues;
}
function detectLinterType(command: string | undefined | null): string {
const normalized = normalizeCommandForDetection(command);
if (!normalized) {
return "Linter";
}
if (/(?:^|\s)eslint\b/.test(normalized)) return "ESLint";
if (/^ruff\b/.test(normalized)) return "Ruff";
if (/^pylint\b/.test(normalized)) return "Pylint";
if (/^mypy\b/.test(normalized)) return "MyPy";
if (/^flake8\b/.test(normalized)) return "Flake8";
if (/clippy\b/.test(normalized)) return "Clippy";
if (/^golangci-lint\b/.test(normalized)) return "GolangCI-Lint";
if (/prettier\b/.test(normalized)) return "Prettier";
return "Linter";
}
export function aggregateLinterOutput(output: string, command: string | undefined | null): string | null {
if (!isLinterCommand(command)) {
return null;
}
const linterType = detectLinterType(command);
const issues = parseIssues(output);
if (issues.length === 0) {
return `[OK] ${linterType}: No issues found`;
}
const errors = issues.filter((issue) => issue.severity === "ERROR").length;
const warnings = issues.filter((issue) => issue.severity === "WARNING").length;
const byRule = new Map<string, number>();
for (const issue of issues) {
byRule.set(issue.rule, (byRule.get(issue.rule) ?? 0) + 1);
}
const byFile = new Map<string, Issue[]>();
for (const issue of issues) {
const existing = byFile.get(issue.file) ?? [];
existing.push(issue);
byFile.set(issue.file, existing);
}
let result = `${linterType}: ${errors} errors, ${warnings} warnings in ${byFile.size} files\n`;
result += "═══════════════════════════════════════\n";
result += "Top rules:\n";
const sortedRules = Array.from(byRule.entries())
.sort((left, right) => right[1] - left[1])
.slice(0, 10);
for (const [rule, count] of sortedRules) {
result += ` ${rule} (${count}x)\n`;
}
result += "\nTop files:\n";
const sortedFiles = Array.from(byFile.entries())
.sort((left, right) => right[1].length - left[1].length)
.slice(0, 10);
for (const [file, fileIssues] of sortedFiles) {
result += ` ${compactPath(file, 40)} (${fileIssues.length} issues)\n`;
const fileRules = new Map<string, number>();
for (const issue of fileIssues) {
fileRules.set(issue.rule, (fileRules.get(issue.rule) ?? 0) + 1);
}
const topRules = Array.from(fileRules.entries())
.sort((left, right) => right[1] - left[1])
.slice(0, 3);
for (const [rule, count] of topRules) {
result += ` ${rule} (${count})\n`;
}
}
return result;
}
@@ -0,0 +1,67 @@
function detectPathSeparator(path: string): "/" | "\\" {
return path.includes("\\") && !path.includes("/") ? "\\" : "/";
}
function detectPathPrefix(path: string, separator: "/" | "\\"): string {
if (/^[A-Za-z]:[\\/]/.test(path)) {
return `${path.slice(0, 2)}${separator}`;
}
if (path.startsWith("\\\\") || path.startsWith("//")) {
const parts = path.split(/[\\/]+/).filter((part) => part.length > 0);
if (parts.length >= 2) {
return `${separator}${separator}${parts[0]}${separator}${parts[1]}${separator}`;
}
return `${separator}${separator}`;
}
if (path.startsWith("/") || path.startsWith("\\")) {
return separator;
}
return "";
}
function joinPathSegments(prefix: string, separator: "/" | "\\", segments: string[]): string {
if (segments.length === 0) {
return prefix || "";
}
const joined = segments.join(separator);
return prefix ? `${prefix}${joined}` : joined;
}
export function compactPath(path: string, maxLength: number): string {
if (path.length <= maxLength) {
return path;
}
if (maxLength < 2) {
return path.slice(0, maxLength);
}
const separator = detectPathSeparator(path);
const prefix = detectPathPrefix(path, separator);
const segments = path
.slice(prefix.length)
.split(/[\\/]+/)
.filter((segment) => segment.length > 0);
const lastSegment = segments[segments.length - 1] ?? path.slice(-(maxLength - 1));
const previousSegment = segments[segments.length - 2];
const candidates = [
joinPathSegments(prefix, separator, ["…", ...(previousSegment ? [previousSegment] : []), lastSegment]),
joinPathSegments("", separator, ["…", ...(previousSegment ? [previousSegment] : []), lastSegment]),
joinPathSegments("", separator, ["…", lastSegment]),
`${path.slice(-(maxLength - 1))}`,
];
for (const candidate of candidates) {
if (candidate.length <= maxLength) {
return candidate;
}
}
return `${lastSegment.slice(-(maxLength - 1))}`;
}
+67
View File
@@ -0,0 +1,67 @@
import { compactPath } from "./path-utils.js";
interface SearchResult {
file: string;
lineNumber: string;
content: string;
}
export function groupSearchResults(output: string, maxResults = 50): string | null {
const results: SearchResult[] = [];
for (const line of output.split("\n")) {
if (!line.trim()) {
continue;
}
const match = line.match(/^(.+?):(\d+)?:(.+)$/);
if (!match) {
continue;
}
results.push({
file: match[1] ?? "unknown",
lineNumber: match[2] ?? "?",
content: match[3] ?? "",
});
}
if (results.length === 0) {
return null;
}
const byFile = new Map<string, SearchResult[]>();
for (const result of results) {
const existing = byFile.get(result.file) ?? [];
existing.push(result);
byFile.set(result.file, existing);
}
let outputText = `${results.length} matches in ${byFile.size} files:\n\n`;
const sortedFiles = Array.from(byFile.entries()).sort((left, right) =>
left[0].localeCompare(right[0]),
);
let shown = 0;
for (const [file, matches] of sortedFiles) {
if (shown >= maxResults) {
break;
}
outputText += `> ${compactPath(file, 50)} (${matches.length} matches):\n`;
for (const match of matches.slice(0, 10)) {
let cleaned = match.content.trim();
if (cleaned.length > 70) {
cleaned = `${cleaned.slice(0, 67)}...`;
}
outputText += ` ${match.lineNumber}: ${cleaned}\n`;
shown++;
}
if (matches.length > 10) {
outputText += ` +${matches.length - 10} more\n`;
}
outputText += "\n";
}
if (results.length > shown) {
outputText += `... +${results.length - shown} more\n`;
}
return outputText;
}
+317
View File
@@ -0,0 +1,317 @@
export type Language =
| "typescript"
| "javascript"
| "python"
| "rust"
| "go"
| "java"
| "c"
| "cpp"
| "unknown";
const LANGUAGE_EXTENSIONS: Record<string, Language> = {
".ts": "typescript",
".tsx": "typescript",
".js": "javascript",
".jsx": "javascript",
".mjs": "javascript",
".py": "python",
".pyw": "python",
".rs": "rust",
".go": "go",
".java": "java",
".c": "c",
".h": "c",
".cpp": "cpp",
".hpp": "cpp",
".cc": "cpp",
};
interface CommentPatterns {
line?: string;
blockStart?: string;
blockEnd?: string;
docLine?: string;
docBlockStart?: string;
}
const COMMENT_PATTERNS: Record<Language, CommentPatterns> = {
typescript: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
javascript: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
python: { line: "#", blockStart: '"""', blockEnd: '"""', docBlockStart: '"""' },
rust: { line: "//", blockStart: "/*", blockEnd: "*/", docLine: "///", docBlockStart: "/**" },
go: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
java: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
c: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
cpp: { line: "//", blockStart: "/*", blockEnd: "*/", docBlockStart: "/**" },
unknown: { line: "//", blockStart: "/*", blockEnd: "*/" },
};
const IMPORT_PATTERN = /^(use\s+|import\s+|from\s+|require\(|#include)/;
const SIGNATURE_PATTERN = /^(pub\s+)?(async\s+)?(fn|def|function|func|class|struct|enum|trait|interface|type)\s+\w+/;
const CONST_PATTERN = /^(const|static|let|pub\s+const|pub\s+static)\s+/;
function getCodePortion(line: string, language: Language): string {
const patterns = COMMENT_PATTERNS[language];
let quote: '"' | "'" | "`" | null = null;
let escaped = false;
let code = "";
for (let index = 0; index < line.length; index += 1) {
const character = line[index] ?? "";
if (escaped) {
escaped = false;
continue;
}
if (quote !== null) {
if (character === "\\") {
escaped = true;
continue;
}
if (character === quote) {
quote = null;
}
continue;
}
if (patterns.line && line.startsWith(patterns.line, index)) {
break;
}
if (patterns.blockStart && patterns.blockEnd && line.startsWith(patterns.blockStart, index)) {
const blockEndIndex = line.indexOf(patterns.blockEnd, index + patterns.blockStart.length);
if (blockEndIndex === -1) {
break;
}
index = blockEndIndex + patterns.blockEnd.length - 1;
continue;
}
if (character === '"' || character === "'" || character === "`") {
quote = character;
continue;
}
code += character;
}
return code;
}
function countCodeBraces(line: string, language: Language): { open: number; close: number } {
let open = 0;
let close = 0;
for (const character of getCodePortion(line, language)) {
if (character === "{") {
open += 1;
} else if (character === "}") {
close += 1;
}
}
return { open, close };
}
export function detectLanguage(filePath: string): Language {
const lastDot = filePath.lastIndexOf(".");
if (lastDot === -1) {
return "unknown";
}
const extension = filePath.slice(lastDot).toLowerCase();
return LANGUAGE_EXTENSIONS[extension] ?? "unknown";
}
export function filterMinimal(content: string, language: Language): string {
const patterns = COMMENT_PATTERNS[language];
const lines = content.split("\n");
const result: string[] = [];
let inBlockComment = false;
let inDocstring = false;
let inUserscriptMetadataBlock = false;
const userscriptMetadataStartPattern = /^\/\/\s*==\s*userscript\s*==$/i;
const userscriptMetadataContentPattern = /^\/\/\s*@\w+/;
const userscriptMetadataEndPattern = /^\/\/\s*==\s*\/userscript\s*==$/i;
for (const line of lines) {
const trimmed = line.trim();
const isUserscriptMetadataStart = userscriptMetadataStartPattern.test(trimmed);
const isUserscriptMetadataContent = userscriptMetadataContentPattern.test(trimmed);
const isUserscriptMetadataEnd = userscriptMetadataEndPattern.test(trimmed);
if (isUserscriptMetadataStart) {
inUserscriptMetadataBlock = true;
result.push(line);
continue;
}
if (inUserscriptMetadataBlock) {
result.push(line);
if (isUserscriptMetadataEnd) {
inUserscriptMetadataBlock = false;
} else if (isUserscriptMetadataContent) {
// Preserve metadata key/value lines (e.g. // @name) within the userscript block.
}
continue;
}
if (patterns.blockStart && patterns.blockEnd) {
if (
!inDocstring &&
trimmed.includes(patterns.blockStart) &&
!(patterns.docBlockStart && trimmed.startsWith(patterns.docBlockStart))
) {
inBlockComment = true;
}
if (inBlockComment) {
if (trimmed.includes(patterns.blockEnd)) {
inBlockComment = false;
}
continue;
}
}
if (language === "python" && trimmed.startsWith('"""')) {
inDocstring = !inDocstring;
result.push(line);
continue;
}
if (inDocstring) {
result.push(line);
continue;
}
if (patterns.line && trimmed.startsWith(patterns.line)) {
if (patterns.docLine && trimmed.startsWith(patterns.docLine)) {
result.push(line);
}
continue;
}
if (trimmed.length === 0) {
result.push("");
continue;
}
result.push(line);
}
return result
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
export function filterAggressive(content: string, language: Language): string {
const minimal = filterMinimal(content, language);
const lines = minimal.split("\n");
const result: string[] = [];
let braceDepth = 0;
let inImplementation = false;
for (const line of lines) {
const trimmed = line.trim();
if (IMPORT_PATTERN.test(trimmed)) {
result.push(line);
continue;
}
if (SIGNATURE_PATTERN.test(trimmed)) {
result.push(line);
inImplementation = true;
braceDepth = 0;
continue;
}
const braces = countCodeBraces(line, language);
const codeTrimmed = getCodePortion(line, language).trim();
if (inImplementation) {
braceDepth += braces.open;
braceDepth -= braces.close;
if (braceDepth <= 1 && (codeTrimmed === "{" || codeTrimmed === "}" || codeTrimmed.endsWith("{"))) {
result.push(line);
}
if (braceDepth <= 0) {
inImplementation = false;
if (trimmed.length > 0 && trimmed !== "}") {
result.push(" // ... implementation");
}
}
continue;
}
if (CONST_PATTERN.test(trimmed)) {
result.push(line);
}
}
return result.join("\n").trim();
}
export function smartTruncate(content: string, maxLines: number, _language: Language): string {
const lines = content.split("\n");
if (lines.length <= maxLines) {
return content;
}
const result: string[] = [];
let keptLines = 0;
let skippedSection = false;
for (const line of lines) {
const trimmed = line.trim();
const isImportant =
SIGNATURE_PATTERN.test(trimmed) ||
IMPORT_PATTERN.test(trimmed) ||
trimmed.startsWith("pub ") ||
trimmed.startsWith("export ") ||
trimmed === "}" ||
trimmed === "{";
if (isImportant || keptLines < maxLines / 2) {
if (skippedSection) {
result.push(` // ... ${lines.length - keptLines} lines omitted`);
skippedSection = false;
}
result.push(line);
keptLines += 1;
} else {
skippedSection = true;
}
if (keptLines >= maxLines - 1) {
break;
}
}
if (skippedSection || keptLines < lines.length) {
result.push(`// ... ${lines.length - keptLines} more lines (total: ${lines.length})`);
}
return result.join("\n");
}
export function filterSourceCode(
content: string,
language: Language,
level: "none" | "minimal" | "aggressive",
): string {
switch (level) {
case "none":
return content;
case "minimal":
return filterMinimal(content, language);
case "aggressive":
return filterAggressive(content, language);
default:
return content;
}
}
@@ -0,0 +1,174 @@
import { matchesCommandPatterns } from "./command-detection.js";
interface TestSummary {
passed: number;
failed: number;
skipped: number;
failures: string[];
}
const TEST_COMMAND_PATTERNS = [
/^npm\s+test\b/,
/^pnpm\s+test\b/,
/^yarn\s+test\b/,
/^bun\s+test\b/,
/^cargo\s+test\b/,
/^go\s+test\b/,
/^pytest\b/,
/^python\s+-m\s+pytest\b/,
/^(?:pnpm\s+)?(?:npx\s+)?vitest\b/,
/^(?:npx\s+)?jest\b/,
/^mocha\b/,
/^ava\b/,
/^tap\b/,
] as const;
const TEST_RESULT_PATTERNS = [
/test result:\s*(\w+)\.\s*(\d+)\s*passed;\s*(\d+)\s*failed;/,
/(\d+)\s*passed(?:,\s*(\d+)\s*failed)?(?:,\s*(\d+)\s*skipped)?/i,
/(\d+)\s*pass(?:,\s*(\d+)\s*fail)?(?:,\s*(\d+)\s*skip)?/i,
/tests?:\s*(\d+)\s*passed(?:,\s*(\d+)\s*failed)?(?:,\s*(\d+)\s*skipped)?/i,
];
const FAILURE_START_PATTERNS = [
/^FAIL\s+/,
/^FAILED\s+/,
/^\s*●\s+/,
/^\s*✕\s+/,
/test\s+\w+\s+\.\.\.\s*FAILED/,
/thread\s+'\w+'\s+panicked/,
];
const FALLBACK_PASS_PATTERN = /(?:\b(?:ok|PASS)\b|[✓✔])/;
const FALLBACK_FAIL_PATTERN = /(?:\b(?:FAIL|fail)\b|[✗✕])/;
function isFailureStart(line: string): boolean {
return FAILURE_START_PATTERNS.some((pattern) => pattern.test(line));
}
function extractTestStats(output: string): Partial<TestSummary> {
for (const pattern of TEST_RESULT_PATTERNS) {
const match = output.match(pattern);
if (!match) {
continue;
}
return {
passed: Number.parseInt(match[1] ?? "0", 10) || 0,
failed: Number.parseInt(match[2] ?? "0", 10) || 0,
skipped: Number.parseInt(match[3] ?? "0", 10) || 0,
};
}
return {};
}
export function isTestCommand(command: string | undefined | null): boolean {
return matchesCommandPatterns(command, TEST_COMMAND_PATTERNS);
}
export function aggregateTestOutput(output: string, command: string | undefined | null): string | null {
if (!isTestCommand(command)) {
return null;
}
const lines = output.split("\n");
const summary: TestSummary = {
passed: 0,
failed: 0,
skipped: 0,
failures: [],
};
const stats = extractTestStats(output);
summary.passed = stats.passed ?? 0;
summary.failed = stats.failed ?? 0;
summary.skipped = stats.skipped ?? 0;
if (summary.passed === 0 && summary.failed === 0) {
for (const line of lines) {
if (FALLBACK_PASS_PATTERN.test(line)) {
summary.passed++;
}
if (FALLBACK_FAIL_PATTERN.test(line)) {
summary.failed++;
}
}
}
if (summary.failed > 0) {
let inFailure = false;
let currentFailure: string[] = [];
let blankCount = 0;
for (const line of lines) {
if (isFailureStart(line)) {
if (inFailure && currentFailure.length > 0) {
summary.failures.push(currentFailure.join("\n"));
}
inFailure = true;
currentFailure = [line];
blankCount = 0;
continue;
}
if (!inFailure) {
continue;
}
if (line.trim() === "") {
blankCount++;
if (blankCount >= 2 && currentFailure.length > 3) {
summary.failures.push(currentFailure.join("\n"));
inFailure = false;
currentFailure = [];
} else {
currentFailure.push(line);
}
continue;
}
if (line.match(/^\s/) || line.match(/^-/)) {
currentFailure.push(line);
blankCount = 0;
continue;
}
summary.failures.push(currentFailure.join("\n"));
inFailure = false;
currentFailure = [];
}
if (inFailure && currentFailure.length > 0) {
summary.failures.push(currentFailure.join("\n"));
}
}
const result: string[] = ["Test Results:"];
result.push(` PASS: ${summary.passed} passed`);
if (summary.failed > 0) {
result.push(` FAIL: ${summary.failed} failed`);
}
if (summary.skipped > 0) {
result.push(` SKIP: ${summary.skipped} skipped`);
}
if (summary.failed > 0 && summary.failures.length > 0) {
result.push("\n Failures:");
for (const failure of summary.failures.slice(0, 5)) {
const failureLines = failure.split("\n");
const firstLine = failureLines[0] ?? "";
result.push(` - ${firstLine.slice(0, 70)}${firstLine.length > 70 ? "..." : ""}`);
for (const detailLine of failureLines.slice(1, 4)) {
if (detailLine.trim()) {
result.push(` ${detailLine.slice(0, 65)}${detailLine.length > 65 ? "..." : ""}`);
}
}
if (failureLines.length > 4) {
result.push(` ... (${failureLines.length - 4} more lines)`);
}
}
if (summary.failures.length > 5) {
result.push(` ... and ${summary.failures.length - 5} more failures`);
}
}
return result.join("\n");
}
@@ -0,0 +1,11 @@
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
if (maxLength < 3) {
return "...";
}
return `${text.slice(0, maxLength - 3)}...`;
}
+58
View File
@@ -0,0 +1,58 @@
import { mock as nodeTestMockRaw } from "node:test";
import { DEFAULT_RTK_INTEGRATION_CONFIG, type RtkIntegrationConfig } from "./types.ts";
type TestResult = void | Promise<void>;
type MockModuleOptions = { namedExports?: Record<string, unknown>; defaultExport?: unknown };
function isPromiseLike(value: TestResult): value is Promise<void> {
return Boolean(value && typeof (value as Promise<void>).then === "function");
}
export function runTest(name: string, testFn: () => TestResult): TestResult {
const result = testFn();
if (!isPromiseLike(result)) {
console.log(`[PASS] ${name}`);
return;
}
return result.then(() => {
console.log(`[PASS] ${name}`);
});
}
export function cloneDefaultConfig(): RtkIntegrationConfig {
return structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG);
}
// Runtime-agnostic module-mocking helper. The tests use the node:test-shaped
// API (`mock.module(specifier, { namedExports, defaultExport })`). Bun's
// implementation of `node:test` does not expose `mock.module`, but `bun:test`
// provides an equivalent that accepts a factory function. We detect the
// capability and adapt the options form to the factory form when needed.
const nodeTestMock = nodeTestMockRaw as unknown as { module?: unknown };
let mockModuleImpl: (specifier: string, options: MockModuleOptions) => void;
if (typeof nodeTestMock.module === "function") {
mockModuleImpl = nodeTestMock.module as (specifier: string, options: MockModuleOptions) => void;
} else {
const bunTest = (await import("bun:test")) as unknown as {
mock: { module: (specifier: string, factory: () => Record<string, unknown>) => void };
};
mockModuleImpl = (specifier, options) => {
bunTest.mock.module(specifier, () => {
const moduleExports: Record<string, unknown> = {};
if (options.defaultExport !== undefined) {
moduleExports.default = options.defaultExport;
}
if (options.namedExports) {
Object.assign(moduleExports, options.namedExports);
}
return moduleExports;
});
};
}
export const mock = { module: mockModuleImpl };
@@ -0,0 +1,46 @@
import { mapTextContentBlocks, toRecord } from "./record-utils.js";
import { stripAnsiFast } from "./techniques/ansi.js";
export interface StreamingBashExecutionSanitizationResult {
changed: boolean;
result: unknown;
}
function sanitizeStreamingBashText(text: string, _command: string | undefined | null): string {
return stripAnsiFast(text);
}
/**
* Returns a sanitized shallow copy of streamed bash result blocks before the
* TUI renders them so RTK self-diagnostics never flash in partial or final
* tool output. The input object is not mutated.
*/
export function sanitizeStreamingBashExecutionResult(
result: unknown,
command: string | undefined | null,
): StreamingBashExecutionSanitizationResult {
const resultRecord = toRecord(result);
const sourceContent = Array.isArray(resultRecord.content)
? (resultRecord.content as unknown[])
: null;
if (!sourceContent || sourceContent.length === 0) {
return { changed: false, result };
}
const { changed, mapped: nextContent } = mapTextContentBlocks(sourceContent, (block) => {
const sanitizedText = sanitizeStreamingBashText(block.text, command);
return sanitizedText !== block.text ? sanitizedText : null;
});
if (!changed) {
return { changed: false, result };
}
return {
changed: true,
result: {
...resultRecord,
content: nextContent,
},
};
}
+201
View File
@@ -0,0 +1,201 @@
declare module "@earendil-works/pi-tui" {
export interface SettingItem {
id: string;
label: string;
description: string;
currentValue: string;
values: string[];
}
export interface AutocompleteItem {
value: string;
label: string;
description?: string;
}
export class Box {
constructor(...args: unknown[]);
addChild(child: unknown): void;
}
export class Container {
constructor(...args: unknown[]);
addChild(child: unknown): void;
render(width: number): string[];
invalidate(): void;
}
export class SettingsList {
constructor(...args: unknown[]);
render(width: number): string[];
invalidate(): void;
handleInput(data: string): void;
updateValue(id: string, value: string): void;
}
export class Spacer {
constructor(...args: unknown[]);
}
export class Text {
constructor(...args: unknown[]);
}
export function truncateToWidth(text: string, width: number, suffix?: string, pad?: boolean): string;
export function visibleWidth(text: string): number;
}
declare module "@earendil-works/pi-coding-agent" {
interface UiLike {
notify(message: string, level: "info" | "warning" | "error"): void;
custom<T>(
renderer: (
tui: { requestRender(): void },
theme: Theme,
keybindings: unknown,
done: () => void,
) => {
render(width: number): string[];
invalidate?(): void;
handleInput(data: string): void;
},
options?: Record<string, unknown>,
): Promise<T>;
}
export interface ExtensionContext {
hasUI: boolean;
cwd?: string;
ui: UiLike;
}
export interface ExtensionCommandContext extends ExtensionContext {}
export interface ToolResultEvent {
toolName: string;
input: Record<string, unknown>;
content: Array<Record<string, unknown>>;
details?: unknown;
}
export interface BashToolCallEvent {
toolName: "bash";
input: { command: string } & Record<string, unknown>;
}
type MaybePromise<T> = T | Promise<T>;
export interface Theme {
fg(color: string, text: string): string;
bold(text: string): string;
getFgAnsi?(name: string): string;
}
export function getAgentDir(): string;
export function getSettingsListTheme(): unknown;
export interface ExtensionAPI {
exec(
command: string,
args: string[],
options?: { timeout?: number },
): Promise<{ code: number; stdout: string; stderr: string }>;
on(
eventName: "tool_call",
handler: (
event: Record<string, unknown>,
ctx: ExtensionContext,
) => MaybePromise<Record<string, unknown> | void>,
): void;
on(
eventName: "tool_result",
handler: (
event: ToolResultEvent,
ctx: ExtensionContext,
) => MaybePromise<Record<string, unknown> | void>,
): void;
on(
eventName: "before_agent_start",
handler: (
event: { systemPrompt: string },
ctx: ExtensionContext,
) => MaybePromise<{ systemPrompt: string } | Record<string, unknown> | void>,
): void;
on(
eventName: string,
handler: (event: Record<string, unknown>, ctx: ExtensionContext) => MaybePromise<Record<string, unknown> | void>,
): void;
registerCommand(
name: string,
definition: {
description: string;
getArgumentCompletions?: (argumentPrefix: string) => Array<{ value: string; label: string; description?: string }> | null;
handler: (args: string, ctx: ExtensionCommandContext) => MaybePromise<void>;
},
): void;
}
export function isToolCallEventType(
toolName: "bash",
event: Record<string, unknown>,
): event is BashToolCallEvent;
export function isToolCallEventType(
toolName: string,
event: Record<string, unknown>,
): boolean;
}
declare module "node:assert/strict" {
const assert: {
equal(actual: unknown, expected: unknown, message?: string): void;
deepEqual(actual: unknown, expected: unknown, message?: string): void;
ok(value: unknown, message?: string): void;
};
export default assert;
}
declare module "node:test" {
export const mock: {
module(specifier: string, options: { namedExports?: Record<string, unknown>; defaultExport?: unknown }): void;
};
}
declare module "bun:test" {
export const mock: {
module(specifier: string, factory: () => Record<string, unknown>): void;
};
}
declare const process: {
platform: string;
env: Record<string, string | undefined>;
cwd(): string;
};
declare module "node:os" {
export function homedir(): string;
}
declare module "node:path" {
export function join(...segments: string[]): string;
export function dirname(path: string): string;
export function resolve(...segments: string[]): string;
export const sep: string;
}
declare module "node:fs" {
export function existsSync(path: string): boolean;
export function mkdirSync(path: string, options?: { recursive?: boolean }): void;
export function readFileSync(path: string, encoding: "utf-8"): string;
export function renameSync(oldPath: string, newPath: string): void;
export function unlinkSync(path: string): void;
export function writeFileSync(path: string, data: string, encoding: "utf-8"): void;
}
+94
View File
@@ -0,0 +1,94 @@
export const RTK_MODES = ["rewrite", "suggest"] as const;
export const RTK_SOURCE_FILTER_LEVELS = ["none", "minimal", "aggressive"] as const;
export type RtkMode = (typeof RTK_MODES)[number];
export type RtkSourceFilterLevel = (typeof RTK_SOURCE_FILTER_LEVELS)[number];
export interface RtkOutputCompactionConfig {
enabled: boolean;
stripAnsi: boolean;
readCompaction: {
enabled: boolean;
};
truncate: {
enabled: boolean;
maxChars: number;
};
sourceCodeFilteringEnabled: boolean;
preserveExactSkillReads: boolean;
sourceCodeFiltering: RtkSourceFilterLevel;
smartTruncate: {
enabled: boolean;
maxLines: number;
};
aggregateTestOutput: boolean;
filterBuildOutput: boolean;
compactGitOutput: boolean;
aggregateLinterOutput: boolean;
trackSavings: boolean;
}
export interface RtkIntegrationConfig {
enabled: boolean;
commandRewritingEnabled: boolean;
mode: RtkMode;
guardWhenRtkMissing: boolean;
showRewriteNotifications: boolean;
outputCompaction: RtkOutputCompactionConfig;
}
export const DEFAULT_RTK_INTEGRATION_CONFIG: RtkIntegrationConfig = {
enabled: true,
commandRewritingEnabled: false,
mode: "rewrite",
guardWhenRtkMissing: true,
showRewriteNotifications: true,
outputCompaction: {
enabled: true,
stripAnsi: true,
readCompaction: {
enabled: false,
},
truncate: {
enabled: true,
maxChars: 12_000,
},
sourceCodeFilteringEnabled: false,
preserveExactSkillReads: false,
sourceCodeFiltering: "none",
smartTruncate: {
enabled: false,
maxLines: 220,
},
aggregateTestOutput: true,
filterBuildOutput: true,
compactGitOutput: true,
aggregateLinterOutput: true,
trackSavings: true,
},
};
export interface ConfigLoadResult {
config: RtkIntegrationConfig;
warning?: string;
}
export interface ConfigSaveResult {
success: boolean;
error?: string;
}
export interface EnsureConfigResult {
created: boolean;
error?: string;
}
export interface RuntimeStatus {
rtkAvailable: boolean;
lastCheckedAt?: number;
lastError?: string;
rtkExecutablePath?: string;
rtkExecutableCommand?: string;
rtkExecutableResolver?: string;
rtkExecutableResolutionWarning?: string;
}
@@ -0,0 +1,138 @@
import { advanceQuoteEscapeState, readShellChars, type QuoteEscapeState } from "./shell-quote-state.js";
interface WindowsBashCompatibilityResult {
command: string;
applied: string[];
}
interface LeadingCdSlashDParse {
rawPath: string;
operator: string;
tail: string;
}
const PYTHON_UTF8_ENV_PREFIX = "PYTHONIOENCODING=utf-8";
function normalizeWindowsPathForBash(rawPath: string): string {
const trimmed = rawPath.trim();
const unquoted =
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
? trimmed.slice(1, -1)
: trimmed;
return unquoted.replace(/\\/g, "/");
}
function quoteForBash(value: string): string {
const escaped = value.replace(/"/g, '\\"');
return `"${escaped}"`;
}
function parseLeadingCdSlashD(command: string): LeadingCdSlashDParse | null {
const prefixMatch = command.match(/^\s*cd\s+\/d\s+/i);
if (!prefixMatch) {
return null;
}
const pathStart = prefixMatch[0].length;
const state: QuoteEscapeState = { quote: null, escaped: false };
for (let index = pathStart; index < command.length; index += 1) {
const { character, nextCharacter } = readShellChars(command, index);
if (advanceQuoteEscapeState(state, character, "\"'")) {
continue;
}
if (character === "&" && nextCharacter === "&") {
return {
rawPath: command.slice(pathStart, index),
operator: "&&",
tail: command.slice(index + 2),
};
}
if (character === "|" && nextCharacter === "|") {
return {
rawPath: command.slice(pathStart, index),
operator: "||",
tail: command.slice(index + 2),
};
}
if (character === "|" || character === ";") {
return {
rawPath: command.slice(pathStart, index),
operator: character,
tail: command.slice(index + 1),
};
}
}
return {
rawPath: command.slice(pathStart),
operator: "",
tail: "",
};
}
function rewriteLeadingCdSlashD(command: string): { command: string; changed: boolean } {
const parsed = parseLeadingCdSlashD(command);
if (!parsed) {
return { command, changed: false };
}
const normalizedPath = quoteForBash(normalizeWindowsPathForBash(parsed.rawPath));
if (!parsed.operator) {
return {
command: `cd ${normalizedPath}`,
changed: true,
};
}
return {
command: `cd ${normalizedPath} ${parsed.operator} ${parsed.tail.trimStart()}`,
changed: true,
};
}
function ensurePythonUtf8(command: string): { command: string; changed: boolean } {
if (/\bPYTHONIOENCODING\s*=/.test(command)) {
return { command, changed: false };
}
if (!/(^|[;&|]\s*|&&\s*|\|\|\s*)python(?:3(?:\.\d+)?)?\b/i.test(command)) {
return { command, changed: false };
}
return {
command: `${PYTHON_UTF8_ENV_PREFIX} ${command}`,
changed: true,
};
}
export function applyWindowsBashCompatibilityFixes(
command: string,
platform: string = process.platform,
): WindowsBashCompatibilityResult {
if (platform !== "win32") {
return { command, applied: [] };
}
let nextCommand = command;
const applied: string[] = [];
const cdFix = rewriteLeadingCdSlashD(nextCommand);
if (cdFix.changed) {
nextCommand = cdFix.command;
applied.push("cd-/d");
}
const pythonFix = ensurePythonUtf8(nextCommand);
if (pythonFix.changed) {
nextCommand = pythonFix.command;
applied.push("python-utf8");
}
return { command: nextCommand, applied };
}
File diff suppressed because it is too large Load Diff