pi-rtk-optimizer 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +14 -7
- package/package.json +4 -3
- package/src/command-rewriter-test.ts +120 -0
- package/src/command-rewriter.ts +254 -18
- package/src/index.ts +8 -10
- package/src/output-compactor-test.ts +38 -0
- package/src/output-compactor.ts +7 -1
- package/src/rewrite-bypass.ts +173 -0
- package/src/rewrite-rules.ts +7 -0
- package/src/runtime-guard-test.ts +50 -0
- package/src/runtime-guard.ts +14 -0
- package/src/techniques/source.ts +23 -0
- package/src/types-shims.d.ts +53 -1
- package/src/zellij-modal.ts +1001 -1001
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.3] - 2026-03-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Added rewrite bypass rules for structured `gh` output commands and non-interactive container shell sessions.
|
|
14
|
+
- Added dedicated runtime guard helpers and test coverage for rewrite-mode availability behavior.
|
|
15
|
+
- Added repository lockfile plus additional command rewriter and runtime guard tests.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Updated README documentation to reflect rewrite bypass behavior, runtime guard semantics, source filtering details, and expanded development verification commands.
|
|
19
|
+
- Added a dedicated `typecheck` script and expanded `check` to run typecheck plus the full test suite.
|
|
20
|
+
- Routed `pnpm dlx` commands through the RTK proxy path instead of the generic pnpm wrapper.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- Improved command tokenization so `sed` scripts, shell separators, redirects, and background operators do not break later rewrites.
|
|
24
|
+
- Preserved exact `read` output at the 80-line smart-truncation threshold instead of compacting boundary-sized results.
|
|
25
|
+
- Preserved userscript metadata blocks during source filtering.
|
|
26
|
+
- Limited RTK-missing command suppression to rewrite mode so suggest mode still produces guidance.
|
|
27
|
+
|
|
10
28
|
## [0.3.2] - 2026-03-04
|
|
11
29
|
|
|
12
30
|
### Fixed
|
package/README.md
CHANGED
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
- **Containers** — `docker`, `docker-compose`, `podman` commands
|
|
22
22
|
- **Network** — `curl`, `wget` commands
|
|
23
23
|
- **Package Managers** — `apt`, `brew`, `dnf`, `pacman`, `yum` commands
|
|
24
|
-
- Runtime guard when `rtk` binary is unavailable (falls back to original commands)
|
|
24
|
+
- Runtime guard when `rtk` binary is unavailable (falls back to original commands only in rewrite mode)
|
|
25
|
+
- Safe rewrite bypasses for structured `gh` output commands and non-interactive container shell sessions
|
|
26
|
+
- Improved command parsing for `sed`, shell separators, and `pnpm dlx` proxy rewrites
|
|
25
27
|
|
|
26
28
|
### Output Compaction Pipeline
|
|
27
29
|
|
|
@@ -35,8 +37,8 @@ Multi-stage pipeline to reduce token consumption:
|
|
|
35
37
|
| Git Compaction | Condenses `git status`, `git log`, `git diff` output |
|
|
36
38
|
| Linter Aggregation | Summarizes linting tool output |
|
|
37
39
|
| Search Grouping | Groups `grep`/`rg` results by file |
|
|
38
|
-
| Source Code Filtering | `none`, `minimal`, or `aggressive` comment/whitespace removal |
|
|
39
|
-
| Smart Truncation | Preserves file boundaries and important lines |
|
|
40
|
+
| Source Code Filtering | `none`, `minimal`, or `aggressive` comment/whitespace removal with userscript metadata preservation |
|
|
41
|
+
| Smart Truncation | Preserves file boundaries and important lines while keeping 80-line reads exact |
|
|
40
42
|
| Hard Truncation | Final character limit enforcement |
|
|
41
43
|
|
|
42
44
|
### Interactive Settings
|
|
@@ -220,7 +222,9 @@ src/
|
|
|
220
222
|
├── config-store.ts # Config load/save with normalization
|
|
221
223
|
├── config-modal.ts # TUI settings modal and /rtk handler
|
|
222
224
|
├── command-rewriter.ts # Command tokenization and rewrite logic
|
|
225
|
+
├── rewrite-bypass.ts # Rewrite safety bypass rules for interactive/structured commands
|
|
223
226
|
├── rewrite-rules.ts # Rewrite rule catalog
|
|
227
|
+
├── runtime-guard.ts # Runtime availability guard helpers for rewrite mode
|
|
224
228
|
├── output-compactor.ts # Tool result compaction pipeline
|
|
225
229
|
├── output-metrics.ts # Savings tracking and reporting
|
|
226
230
|
├── command-completions.ts # /rtk subcommand completions
|
|
@@ -259,17 +263,20 @@ Automatic fixes applied on Windows:
|
|
|
259
263
|
## Development
|
|
260
264
|
|
|
261
265
|
```bash
|
|
262
|
-
# Build
|
|
266
|
+
# Build
|
|
263
267
|
npm run build
|
|
264
268
|
|
|
265
|
-
#
|
|
266
|
-
npm run
|
|
269
|
+
# Full typecheck
|
|
270
|
+
npm run typecheck
|
|
267
271
|
|
|
268
272
|
# Run tests
|
|
269
273
|
npm run test
|
|
270
274
|
|
|
271
|
-
# Full
|
|
275
|
+
# Full verification
|
|
272
276
|
npm run check
|
|
277
|
+
|
|
278
|
+
# Bundle sanity check
|
|
279
|
+
npm run build:check
|
|
273
280
|
```
|
|
274
281
|
|
|
275
282
|
## Credits
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-rtk-optimizer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Pi extension that optimizes RTK command rewriting and tool output compaction for the coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noCheck",
|
|
20
20
|
"lint": "npm run build",
|
|
21
|
-
"
|
|
22
|
-
"
|
|
21
|
+
"typecheck": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json",
|
|
22
|
+
"test": "bun ./src/output-compactor-test.ts && bun ./src/command-rewriter-test.ts && bun ./src/runtime-guard-test.ts",
|
|
23
|
+
"check": "npm run lint && npm run typecheck && npm run test",
|
|
23
24
|
"build:check": "bunx esbuild ./index.ts --bundle --platform=node --format=esm --outfile=./.pi-rtk-optimizer-check.mjs --external:@mariozechner/pi-coding-agent --external:@mariozechner/pi-tui && bun -e \"import { unlinkSync } from 'node:fs'; unlinkSync('./.pi-rtk-optimizer-check.mjs');\""
|
|
24
25
|
},
|
|
25
26
|
"keywords": [
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
|
|
3
|
+
import { computeRewriteDecision } from "./command-rewriter.ts";
|
|
4
|
+
import { DEFAULT_RTK_INTEGRATION_CONFIG, type RtkIntegrationConfig } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
function runTest(name: string, testFn: () => void): void {
|
|
7
|
+
testFn();
|
|
8
|
+
console.log(`[PASS] ${name}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function cloneConfig(): RtkIntegrationConfig {
|
|
12
|
+
return structuredClone(DEFAULT_RTK_INTEGRATION_CONFIG);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function expectedProxyExecutable(base: "pnpm"): string {
|
|
16
|
+
return process.platform === "win32" ? `${base}.cmd` : base;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
runTest("pnpm dlx rewrites through RTK proxy instead of generic pnpm wrapper", () => {
|
|
20
|
+
const config = cloneConfig();
|
|
21
|
+
const decision = computeRewriteDecision("pnpm dlx create-vite@latest demo --template react-ts", config);
|
|
22
|
+
|
|
23
|
+
assert.equal(decision.changed, true);
|
|
24
|
+
assert.equal(decision.rule?.id, "pnpm-dlx-proxy");
|
|
25
|
+
assert.equal(
|
|
26
|
+
decision.rewrittenCommand,
|
|
27
|
+
`rtk proxy ${expectedProxyExecutable("pnpm")} dlx create-vite@latest demo --template react-ts`,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
runTest("docker run with shell command bypasses rewrite when interactive flags are missing", () => {
|
|
32
|
+
const config = cloneConfig();
|
|
33
|
+
const decision = computeRewriteDecision("docker run ubuntu bash", config);
|
|
34
|
+
|
|
35
|
+
assert.equal(decision.changed, false);
|
|
36
|
+
assert.equal(decision.rewrittenCommand, "docker run ubuntu bash");
|
|
37
|
+
assert.equal(decision.reason, "no_match");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
runTest("docker run with -it keeps container rewrite enabled", () => {
|
|
41
|
+
const config = cloneConfig();
|
|
42
|
+
const decision = computeRewriteDecision("docker run -it ubuntu bash", config);
|
|
43
|
+
|
|
44
|
+
assert.equal(decision.changed, true);
|
|
45
|
+
assert.equal(decision.rule?.id, "docker");
|
|
46
|
+
assert.equal(decision.rewrittenCommand, "rtk docker run -it ubuntu bash");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
runTest("docker compose exec without -it bypasses interactive shell rewrite", () => {
|
|
50
|
+
const config = cloneConfig();
|
|
51
|
+
const decision = computeRewriteDecision("docker compose exec web bash", config);
|
|
52
|
+
|
|
53
|
+
assert.equal(decision.changed, false);
|
|
54
|
+
assert.equal(decision.rewrittenCommand, "docker compose exec web bash");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
runTest("container rewrites stay enabled for scripted shells and non-shell commands", () => {
|
|
58
|
+
const config = cloneConfig();
|
|
59
|
+
|
|
60
|
+
const scriptedShell = computeRewriteDecision('docker run ubuntu bash -lc "echo hi"', config);
|
|
61
|
+
assert.equal(scriptedShell.changed, true);
|
|
62
|
+
assert.equal(scriptedShell.rule?.id, "docker");
|
|
63
|
+
assert.equal(scriptedShell.rewrittenCommand, 'rtk docker run ubuntu bash -lc "echo hi"');
|
|
64
|
+
|
|
65
|
+
const nonShell = computeRewriteDecision("docker run ubuntu python app.py", config);
|
|
66
|
+
assert.equal(nonShell.changed, true);
|
|
67
|
+
assert.equal(nonShell.rule?.id, "docker");
|
|
68
|
+
assert.equal(nonShell.rewrittenCommand, "rtk docker run ubuntu python app.py");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
runTest("kubectl exec requires interactive flags before rewriting shell sessions", () => {
|
|
72
|
+
const config = cloneConfig();
|
|
73
|
+
const missingFlagsDecision = computeRewriteDecision("kubectl exec pod-123 -- bash", config);
|
|
74
|
+
assert.equal(missingFlagsDecision.changed, false);
|
|
75
|
+
assert.equal(missingFlagsDecision.rewrittenCommand, "kubectl exec pod-123 -- bash");
|
|
76
|
+
|
|
77
|
+
const interactiveDecision = computeRewriteDecision("kubectl exec -it pod-123 -- bash", config);
|
|
78
|
+
assert.equal(interactiveDecision.changed, true);
|
|
79
|
+
assert.equal(interactiveDecision.rule?.id, "kubectl");
|
|
80
|
+
assert.equal(interactiveDecision.rewrittenCommand, "rtk kubectl exec -it pod-123 -- bash");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
runTest("sed scripts keep internal separators intact while later pipe segments still rewrite", () => {
|
|
84
|
+
const config = cloneConfig();
|
|
85
|
+
const decision = computeRewriteDecision("sed -e s/a/b/;d file.txt | git status", config);
|
|
86
|
+
|
|
87
|
+
assert.equal(decision.changed, true);
|
|
88
|
+
assert.equal(decision.rewrittenCommand, "sed -e s/a/b/;d file.txt | rtk git status");
|
|
89
|
+
assert.equal(decision.rule?.id, "git-any");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
runTest("background operators rewrite both command segments without misreading redirect ampersands", () => {
|
|
93
|
+
const config = cloneConfig();
|
|
94
|
+
const backgroundDecision = computeRewriteDecision("git status & cargo test", config);
|
|
95
|
+
assert.equal(backgroundDecision.changed, true);
|
|
96
|
+
assert.equal(backgroundDecision.rewrittenCommand, "rtk git status & rtk cargo test");
|
|
97
|
+
|
|
98
|
+
const redirectDecision = computeRewriteDecision("cargo test 2>&1 | head -5", config);
|
|
99
|
+
assert.equal(redirectDecision.changed, true);
|
|
100
|
+
assert.equal(redirectDecision.rewrittenCommand, "rtk cargo test 2>&1 | head -5");
|
|
101
|
+
assert.equal(redirectDecision.rule?.id, "cargo-any");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
runTest("gh structured output commands bypass RTK rewrites", () => {
|
|
105
|
+
const config = cloneConfig();
|
|
106
|
+
const structuredCommands = [
|
|
107
|
+
"gh pr list --json number,title",
|
|
108
|
+
"gh issue list --jq '.[].title'",
|
|
109
|
+
"gh pr view 123 --template '{{.title}}'",
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
for (const command of structuredCommands) {
|
|
113
|
+
const decision = computeRewriteDecision(command, config);
|
|
114
|
+
assert.equal(decision.changed, false);
|
|
115
|
+
assert.equal(decision.rewrittenCommand, command);
|
|
116
|
+
assert.equal(decision.reason, "no_match");
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
console.log("All command-rewriter tests passed.");
|
package/src/command-rewriter.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { shouldBypassRewriteForCommand } from "./rewrite-bypass.js";
|
|
2
2
|
import { RTK_REWRITE_RULES, type RtkRewriteCategory, type RtkRewriteRule } from "./rewrite-rules.js";
|
|
3
|
+
import type { RtkIntegrationConfig } from "./types.js";
|
|
3
4
|
|
|
4
5
|
const ENV_PREFIX_PATTERN = /^((?:[A-Za-z_][A-Za-z0-9_]*=[^\s]*\s+)*)/;
|
|
6
|
+
const SHELL_ENV_ASSIGNMENT_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
|
|
5
7
|
|
|
6
8
|
type CommandToken =
|
|
7
9
|
| {
|
|
@@ -13,6 +15,14 @@ type CommandToken =
|
|
|
13
15
|
value: string;
|
|
14
16
|
};
|
|
15
17
|
|
|
18
|
+
type SedNextTokenMode = "none" | "defaultScript" | "expressionScript" | "fileArgument" | "inPlaceArgument";
|
|
19
|
+
|
|
20
|
+
interface SegmentParseState {
|
|
21
|
+
commandName?: string;
|
|
22
|
+
sedNextTokenMode: SedNextTokenMode;
|
|
23
|
+
sedScriptSeen: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
export interface RewriteDecision {
|
|
17
27
|
changed: boolean;
|
|
18
28
|
originalCommand: string;
|
|
@@ -69,6 +79,162 @@ function categoryEnabled(config: RtkIntegrationConfig, category: RtkRewriteCateg
|
|
|
69
79
|
}
|
|
70
80
|
}
|
|
71
81
|
|
|
82
|
+
function createSegmentParseState(): SegmentParseState {
|
|
83
|
+
return {
|
|
84
|
+
sedNextTokenMode: "none",
|
|
85
|
+
sedScriptSeen: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeShellWord(word: string): string {
|
|
90
|
+
const unwrapped = word.replace(/^(?:["'`])|(?:["'`])$/g, "");
|
|
91
|
+
const lastPathSeparator = Math.max(unwrapped.lastIndexOf("/"), unwrapped.lastIndexOf("\\"));
|
|
92
|
+
const basename = lastPathSeparator >= 0 ? unwrapped.slice(lastPathSeparator + 1) : unwrapped;
|
|
93
|
+
return basename.toLowerCase();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function shouldProtectSedWord(state: SegmentParseState): boolean {
|
|
97
|
+
return (
|
|
98
|
+
state.commandName === "sed" &&
|
|
99
|
+
!state.sedScriptSeen &&
|
|
100
|
+
(state.sedNextTokenMode === "defaultScript" ||
|
|
101
|
+
state.sedNextTokenMode === "expressionScript" ||
|
|
102
|
+
state.sedNextTokenMode === "inPlaceArgument")
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isQuotedEmptyToken(word: string): boolean {
|
|
107
|
+
return word === "''" || word === '""';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function looksLikeSedBackupExtension(word: string): boolean {
|
|
111
|
+
const normalized = word.replace(/^(?:["'`])|(?:["'`])$/g, "");
|
|
112
|
+
return normalized.startsWith(".") || normalized === "*";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function updateSegmentParseState(state: SegmentParseState, word: string): SegmentParseState {
|
|
116
|
+
if (!word) {
|
|
117
|
+
return state;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!state.commandName) {
|
|
121
|
+
if (SHELL_ENV_ASSIGNMENT_PATTERN.test(word)) {
|
|
122
|
+
return state;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const commandName = normalizeShellWord(word);
|
|
126
|
+
return {
|
|
127
|
+
commandName,
|
|
128
|
+
sedNextTokenMode: "none",
|
|
129
|
+
sedScriptSeen: false,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (state.commandName !== "sed" || state.sedScriptSeen) {
|
|
134
|
+
return state;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (state.sedNextTokenMode === "expressionScript") {
|
|
138
|
+
return {
|
|
139
|
+
...state,
|
|
140
|
+
sedNextTokenMode: "none",
|
|
141
|
+
sedScriptSeen: true,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (state.sedNextTokenMode === "fileArgument") {
|
|
146
|
+
return {
|
|
147
|
+
...state,
|
|
148
|
+
sedNextTokenMode: "none",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (state.sedNextTokenMode === "inPlaceArgument") {
|
|
153
|
+
if (isQuotedEmptyToken(word) || looksLikeSedBackupExtension(word)) {
|
|
154
|
+
return {
|
|
155
|
+
...state,
|
|
156
|
+
sedNextTokenMode: "defaultScript",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
...state,
|
|
162
|
+
sedNextTokenMode: "none",
|
|
163
|
+
sedScriptSeen: true,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (state.sedNextTokenMode === "defaultScript") {
|
|
168
|
+
return {
|
|
169
|
+
...state,
|
|
170
|
+
sedNextTokenMode: "none",
|
|
171
|
+
sedScriptSeen: true,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (word === "--") {
|
|
176
|
+
return {
|
|
177
|
+
...state,
|
|
178
|
+
sedNextTokenMode: "defaultScript",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (word === "-e" || word === "--expression") {
|
|
183
|
+
return {
|
|
184
|
+
...state,
|
|
185
|
+
sedNextTokenMode: "expressionScript",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (word.startsWith("--expression=")) {
|
|
190
|
+
return {
|
|
191
|
+
...state,
|
|
192
|
+
sedScriptSeen: true,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (/^-[A-Za-z]*e[A-Za-z]*$/.test(word)) {
|
|
197
|
+
return {
|
|
198
|
+
...state,
|
|
199
|
+
sedNextTokenMode: "expressionScript",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (word === "-f" || word === "--file") {
|
|
204
|
+
return {
|
|
205
|
+
...state,
|
|
206
|
+
sedNextTokenMode: "fileArgument",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (word.startsWith("--file=") || /^-f.+$/.test(word)) {
|
|
211
|
+
return state;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (word === "-i" || word === "--in-place") {
|
|
215
|
+
return {
|
|
216
|
+
...state,
|
|
217
|
+
sedNextTokenMode: "inPlaceArgument",
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (word.startsWith("--in-place=") || /^-i.+$/.test(word)) {
|
|
222
|
+
return {
|
|
223
|
+
...state,
|
|
224
|
+
sedNextTokenMode: "defaultScript",
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (word.startsWith("-")) {
|
|
229
|
+
return state;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
...state,
|
|
234
|
+
sedScriptSeen: true,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
72
238
|
function tokenizeCommand(command: string): CommandToken[] {
|
|
73
239
|
if (!command) {
|
|
74
240
|
return [];
|
|
@@ -76,11 +242,47 @@ function tokenizeCommand(command: string): CommandToken[] {
|
|
|
76
242
|
|
|
77
243
|
const tokens: CommandToken[] = [];
|
|
78
244
|
let segmentStart = 0;
|
|
245
|
+
let segmentState = createSegmentParseState();
|
|
246
|
+
let currentWordStart: number | null = null;
|
|
247
|
+
let currentWordProtected = false;
|
|
79
248
|
let quote: "'" | '"' | "`" | null = null;
|
|
80
249
|
let escaped = false;
|
|
81
250
|
|
|
251
|
+
const finalizeWord = (endIndexExclusive: number): void => {
|
|
252
|
+
if (currentWordStart === null) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const word = command.slice(currentWordStart, endIndexExclusive);
|
|
257
|
+
segmentState = updateSegmentParseState(segmentState, word);
|
|
258
|
+
currentWordStart = null;
|
|
259
|
+
currentWordProtected = false;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const pushSeparator = (index: number, length: number): void => {
|
|
263
|
+
finalizeWord(index);
|
|
264
|
+
const segment = command.slice(segmentStart, index);
|
|
265
|
+
if (segment.length > 0) {
|
|
266
|
+
tokens.push({ type: "segment", value: segment });
|
|
267
|
+
}
|
|
268
|
+
tokens.push({ type: "separator", value: command.slice(index, index + length) });
|
|
269
|
+
segmentStart = index + length;
|
|
270
|
+
segmentState = createSegmentParseState();
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const beginWord = (index: number): void => {
|
|
274
|
+
if (currentWordStart !== null) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
currentWordStart = index;
|
|
279
|
+
currentWordProtected = shouldProtectSedWord(segmentState);
|
|
280
|
+
};
|
|
281
|
+
|
|
82
282
|
for (let index = 0; index < command.length; index += 1) {
|
|
83
283
|
const char = command[index];
|
|
284
|
+
const nextChar = command[index + 1] ?? "";
|
|
285
|
+
const prevChar = index > 0 ? command[index - 1] ?? "" : "";
|
|
84
286
|
|
|
85
287
|
if (escaped) {
|
|
86
288
|
escaped = false;
|
|
@@ -98,33 +300,63 @@ function tokenizeCommand(command: string): CommandToken[] {
|
|
|
98
300
|
continue;
|
|
99
301
|
}
|
|
100
302
|
|
|
101
|
-
if (char === "
|
|
102
|
-
|
|
303
|
+
if (char === "\\") {
|
|
304
|
+
beginWord(index);
|
|
305
|
+
escaped = true;
|
|
103
306
|
continue;
|
|
104
307
|
}
|
|
105
308
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const segment = command.slice(segmentStart, index);
|
|
109
|
-
if (segment.length > 0) {
|
|
110
|
-
tokens.push({ type: "segment", value: segment });
|
|
111
|
-
}
|
|
112
|
-
tokens.push({ type: "separator", value: command.slice(index, index + 2) });
|
|
113
|
-
segmentStart = index + 2;
|
|
114
|
-
index += 1;
|
|
309
|
+
if (/\s/.test(char)) {
|
|
310
|
+
finalizeWord(index);
|
|
115
311
|
continue;
|
|
116
312
|
}
|
|
117
313
|
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
314
|
+
if (!currentWordProtected) {
|
|
315
|
+
if (char === "&" && nextChar === "&") {
|
|
316
|
+
pushSeparator(index, 2);
|
|
317
|
+
index += 1;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (char === "|" && nextChar === "|") {
|
|
322
|
+
pushSeparator(index, 2);
|
|
323
|
+
index += 1;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (char === "|" && nextChar === "&") {
|
|
328
|
+
pushSeparator(index, 2);
|
|
329
|
+
index += 1;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (char === "|" && prevChar !== ">") {
|
|
334
|
+
pushSeparator(index, 1);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (char === "&" && nextChar !== ">" && prevChar !== ">" && prevChar !== "<") {
|
|
339
|
+
pushSeparator(index, 1);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (char === ";") {
|
|
344
|
+
pushSeparator(index, 1);
|
|
345
|
+
continue;
|
|
122
346
|
}
|
|
123
|
-
tokens.push({ type: "separator", value: ";" });
|
|
124
|
-
segmentStart = index + 1;
|
|
125
347
|
}
|
|
348
|
+
|
|
349
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
350
|
+
beginWord(index);
|
|
351
|
+
quote = char;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
beginWord(index);
|
|
126
356
|
}
|
|
127
357
|
|
|
358
|
+
finalizeWord(command.length);
|
|
359
|
+
|
|
128
360
|
const tail = command.slice(segmentStart);
|
|
129
361
|
if (tail.length > 0 || tokens.length === 0) {
|
|
130
362
|
tokens.push({ type: "segment", value: tail });
|
|
@@ -191,6 +423,10 @@ function rewriteSingleSegmentCommand(
|
|
|
191
423
|
}
|
|
192
424
|
|
|
193
425
|
rule.matcher.lastIndex = 0;
|
|
426
|
+
if (shouldBypassRewriteForCommand(commandBody, rule)) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
|
|
194
430
|
const rewrittenBody = commandBody.replace(rule.matcher, rule.replacement);
|
|
195
431
|
const finalizedRewrittenBody = applyPlatformProxyCommandFixups(rewrittenBody);
|
|
196
432
|
if (finalizedRewrittenBody === commandBody) {
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { registerRtkIntegrationCommand } from "./config-modal.js";
|
|
|
11
11
|
import { EXTENSION_NAME } from "./constants.js";
|
|
12
12
|
import { clearOutputMetrics, getOutputMetricsSummary } from "./output-metrics.js";
|
|
13
13
|
import { compactToolResult, type ToolResultCompactionMetadata } from "./output-compactor.js";
|
|
14
|
+
import { shouldRequireRtkAvailabilityForCommandHandling, shouldSkipCommandHandlingWhenRtkMissing } from "./runtime-guard.js";
|
|
14
15
|
import type { RtkIntegrationConfig, RuntimeStatus } from "./types.js";
|
|
15
16
|
import { applyWindowsBashCompatibilityFixes } from "./windows-command-helpers.js";
|
|
16
17
|
|
|
@@ -86,7 +87,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
|
|
86
87
|
}
|
|
87
88
|
};
|
|
88
89
|
|
|
89
|
-
const refreshConfig = (ctx?: ExtensionContext | ExtensionCommandContext): void => {
|
|
90
|
+
const refreshConfig = async (ctx?: ExtensionContext | ExtensionCommandContext): Promise<void> => {
|
|
90
91
|
const ensured = ensureConfigExists();
|
|
91
92
|
if (ensured.error && ctx) {
|
|
92
93
|
warnOnce(ctx, ensured.error);
|
|
@@ -95,6 +96,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
|
|
95
96
|
const loaded = loadRtkIntegrationConfig();
|
|
96
97
|
config = loaded.config;
|
|
97
98
|
pendingLoadWarning = loaded.warning;
|
|
99
|
+
await refreshRuntimeStatus();
|
|
98
100
|
|
|
99
101
|
if (pendingLoadWarning && ctx) {
|
|
100
102
|
warnOnce(ctx, pendingLoadWarning);
|
|
@@ -162,7 +164,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
|
|
162
164
|
};
|
|
163
165
|
|
|
164
166
|
const ensureRuntimeStatusFresh = async (): Promise<void> => {
|
|
165
|
-
if (!config
|
|
167
|
+
if (!shouldRequireRtkAvailabilityForCommandHandling(config)) {
|
|
166
168
|
return;
|
|
167
169
|
}
|
|
168
170
|
|
|
@@ -186,14 +188,12 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
|
|
186
188
|
registerRtkIntegrationCommand(pi, controller);
|
|
187
189
|
|
|
188
190
|
pi.on("session_start", async (_event, ctx) => {
|
|
189
|
-
refreshConfig(ctx);
|
|
190
|
-
await refreshRuntimeStatus();
|
|
191
|
+
await refreshConfig(ctx);
|
|
191
192
|
maybeWarnRtkMissing(ctx);
|
|
192
193
|
});
|
|
193
194
|
|
|
194
195
|
pi.on("session_switch", async (_event, ctx) => {
|
|
195
|
-
refreshConfig(ctx);
|
|
196
|
-
await refreshRuntimeStatus();
|
|
196
|
+
await refreshConfig(ctx);
|
|
197
197
|
maybeWarnRtkMissing(ctx);
|
|
198
198
|
});
|
|
199
199
|
|
|
@@ -227,7 +227,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
|
|
227
227
|
}
|
|
228
228
|
|
|
229
229
|
await ensureRuntimeStatusFresh();
|
|
230
|
-
if (config
|
|
230
|
+
if (shouldSkipCommandHandlingWhenRtkMissing(config, runtimeStatus)) {
|
|
231
231
|
return {};
|
|
232
232
|
}
|
|
233
233
|
|
|
@@ -278,9 +278,7 @@ export default function rtkIntegrationExtension(pi: ExtensionAPI): void {
|
|
|
278
278
|
|
|
279
279
|
return {
|
|
280
280
|
content: outcome.content,
|
|
281
|
-
details: outcome.metadata
|
|
282
|
-
? mergeCompactionDetails((event as Record<string, unknown>).details, outcome.metadata)
|
|
283
|
-
: undefined,
|
|
281
|
+
details: outcome.metadata ? mergeCompactionDetails(event.details, outcome.metadata) : undefined,
|
|
284
282
|
};
|
|
285
283
|
} catch (error) {
|
|
286
284
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -117,4 +117,42 @@ runTest("short read output stays exact below threshold", () => {
|
|
|
117
117
|
assert.deepEqual(result.techniques, []);
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
+
runTest("read output stays exact at the 80-line boundary with trailing newline", () => {
|
|
121
|
+
const config = cloneConfig();
|
|
122
|
+
config.outputCompaction.smartTruncate.enabled = true;
|
|
123
|
+
config.outputCompaction.smartTruncate.maxLines = 40;
|
|
124
|
+
|
|
125
|
+
const content = buildReadContent(80);
|
|
126
|
+
const result = compactToolResult(
|
|
127
|
+
{
|
|
128
|
+
toolName: "read",
|
|
129
|
+
input: { path: "sample.ts" },
|
|
130
|
+
content: [{ type: "text", text: content }],
|
|
131
|
+
},
|
|
132
|
+
config,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
assert.equal(result.changed, false);
|
|
136
|
+
assert.deepEqual(result.techniques, []);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
runTest("read output compacts once the content exceeds the 80-line exactness threshold", () => {
|
|
140
|
+
const config = cloneConfig();
|
|
141
|
+
config.outputCompaction.smartTruncate.enabled = true;
|
|
142
|
+
config.outputCompaction.smartTruncate.maxLines = 40;
|
|
143
|
+
|
|
144
|
+
const content = buildReadContent(81);
|
|
145
|
+
const result = compactToolResult(
|
|
146
|
+
{
|
|
147
|
+
toolName: "read",
|
|
148
|
+
input: { path: "sample.ts" },
|
|
149
|
+
content: [{ type: "text", text: content }],
|
|
150
|
+
},
|
|
151
|
+
config,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
assert.equal(result.changed, true);
|
|
155
|
+
assert.ok(result.techniques.includes("source:minimal") || result.techniques.includes("smart-truncate"));
|
|
156
|
+
});
|
|
157
|
+
|
|
120
158
|
console.log("All output-compactor tests passed.");
|
package/src/output-compactor.ts
CHANGED
|
@@ -103,7 +103,13 @@ function countLines(text: string): number {
|
|
|
103
103
|
if (!text) {
|
|
104
104
|
return 0;
|
|
105
105
|
}
|
|
106
|
-
|
|
106
|
+
|
|
107
|
+
const normalized = text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
108
|
+
if (!normalized) {
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return normalized.split("\n").length;
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
function hasLossyCompaction(techniques: string[]): boolean {
|