mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
chore: initialize my-pi extension bundle
This commit is contained in:
@@ -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.");
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.");
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.");
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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.");
|
||||
@@ -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.");
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.");
|
||||
@@ -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.");
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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] ?? "",
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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))}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}...`;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user