pi-tool-guard 0.1.0
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/README.md +69 -0
- package/aliases.test.ts +199 -0
- package/aliases.ts +164 -0
- package/bash.ts +52 -0
- package/index.ts +166 -0
- package/package.json +43 -0
- package/pipeline.test.ts +346 -0
- package/pipeline.ts +105 -0
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# pi-tool-guard
|
|
2
|
+
|
|
3
|
+
A pi extension that corrects common LLM tool call mistakes: normalizes argument aliases for `edit`/`write`/`read` and strips trailing pipeline extractors from `bash` commands.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:pi-tool-guard
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
### 1. Argument alias normalization (`edit` / `write` / `read`)
|
|
16
|
+
|
|
17
|
+
When the LLM calls a tool with wrong field names, the extension normalizes them before schema validation. No error, no re-execution.
|
|
18
|
+
|
|
19
|
+
| Tool | Canonical | Accepted aliases |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| edit | `path` | `file`, `filePath`, `file_path`, `target`, `filename`, `file_name` |
|
|
22
|
+
| edit | `edits[].oldText` | `old_str`, `old_string`, `oldContent`, `old`, `original`, `search` |
|
|
23
|
+
| edit | `edits[].newText` | `new_str`, `new_string`, `newContent`, `new`, `replacement`, `replace` |
|
|
24
|
+
| write | `path` | `file`, `filePath`, `file_path`, `target`, `filename`, `file_name` |
|
|
25
|
+
| write | `content` | `text`, `body`, `code`, `data`, `fileContent`, `contents` |
|
|
26
|
+
| read | `path` | `file`, `filePath`, `file_path`, `target`, `filename`, `file_name` |
|
|
27
|
+
| read | `offset` | `start`, `startLine`, `start_line`, `from`, `line` |
|
|
28
|
+
| read | `limit` | `lines`, `maxLines`, `max_lines`, `count`, `numLines`, `num_lines` |
|
|
29
|
+
|
|
30
|
+
> **Edit tool shorthand**: top-level `oldText`/`newText` (or aliases) are automatically wrapped into an `edits` array.
|
|
31
|
+
>
|
|
32
|
+
> **Read tool type coercion**: string values for `offset` and `limit` are coerced to numbers.
|
|
33
|
+
|
|
34
|
+
### 2. Bash pipeline extractor stripping
|
|
35
|
+
|
|
36
|
+
When the LLM appends truncation commands (`tail`, `head`, `grep`, etc.) to slow commands, the extension strips them so the full output is available. This prevents the LLM from repeatedly re-running slow commands with different truncation parameters.
|
|
37
|
+
|
|
38
|
+
**Three-case strategy:**
|
|
39
|
+
|
|
40
|
+
| Scenario | Behavior |
|
|
41
|
+
|---|---|
|
|
42
|
+
| Output truncated (has `Full output: <file>`) | Run extractor on the full output file, return filtered result |
|
|
43
|
+
| Fast command (< 10s), no truncation | Pipe result through extractor, return filtered result |
|
|
44
|
+
| Slow command, no truncation | Return full result with notice |
|
|
45
|
+
|
|
46
|
+
**Example:** `vitest run | tail -n 10`
|
|
47
|
+
- If vitest output is truncated → run `tail` on the saved full output file
|
|
48
|
+
- If vitest finishes in < 10s → pipe result through `tail`
|
|
49
|
+
- If vitest is slow but not truncated → return full output + notice
|
|
50
|
+
|
|
51
|
+
**Detected extractors:** `head`, `tail`, `grep`, `egrep`, `fgrep`, `rg`, `sed`, `awk`, `cut`, `sort`, `uniq`, `wc`, `less`, `more`, `column`, `jq`, `yq`, `tr`
|
|
52
|
+
|
|
53
|
+
All trailing extractors are stripped: `npm test | grep FAIL | head -5` → strips `grep FAIL | head -5`
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Architecture
|
|
58
|
+
|
|
59
|
+
Both features use `prepareArguments` on overridden built-in tools — the cleanest pi extension pattern for argument correction:
|
|
60
|
+
|
|
61
|
+
- **edit/write/read**: `createXxxToolDefinition(cwd)` + `prepareArguments` normalizes aliases before schema validation
|
|
62
|
+
- **bash**: `createBashToolDefinition(cwd)` + custom `execute` override:
|
|
63
|
+
1. `prepareArguments` parses the command with [unbash](https://github.com/nicolo-ribaudo/unbash), strips trailing extractors
|
|
64
|
+
2. `execute` runs the stripped command via the original built-in execute
|
|
65
|
+
3. If truncated → runs extractor on the full output file via `pi.exec`
|
|
66
|
+
4. If fast (< 10s) → pipes result through extractor via `pi.exec`
|
|
67
|
+
5. If slow → returns full result with notice
|
|
68
|
+
|
|
69
|
+
No error recovery, no session scanning.
|
package/aliases.test.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { normalizeEditArgs, normalizeWriteArgs, normalizeReadArgs } from "./aliases.ts";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// normalizeEditArgs
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
describe("normalizeEditArgs", () => {
|
|
9
|
+
// ── Alias renaming ───────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
describe("alias renaming", () => {
|
|
12
|
+
it("renames file → path", () => {
|
|
13
|
+
const args = { file: "foo.ts", edits: [{ oldText: "a", newText: "b" }] };
|
|
14
|
+
normalizeEditArgs(args);
|
|
15
|
+
expect(args).toEqual({ path: "foo.ts", edits: [{ oldText: "a", newText: "b" }] });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("renames filePath → path", () => {
|
|
19
|
+
const args = { filePath: "foo.ts", edits: [{ oldText: "a", newText: "b" }] };
|
|
20
|
+
normalizeEditArgs(args);
|
|
21
|
+
expect(args).toHaveProperty("path", "foo.ts");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("renames old_str → oldText inside edits", () => {
|
|
25
|
+
const args = { path: "f", edits: [{ old_str: "a", new_str: "b" }] };
|
|
26
|
+
normalizeEditArgs(args);
|
|
27
|
+
expect(args.edits[0]).toEqual({ oldText: "a", newText: "b" });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("renames old_string → oldText", () => {
|
|
31
|
+
const args = { path: "f", edits: [{ old_string: "a", new_string: "b" }] };
|
|
32
|
+
normalizeEditArgs(args);
|
|
33
|
+
expect(args.edits[0]).toEqual({ oldText: "a", newText: "b" });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("renames search → oldText, replace → newText", () => {
|
|
37
|
+
const args = { path: "f", edits: [{ search: "a", replace: "b" }] };
|
|
38
|
+
normalizeEditArgs(args);
|
|
39
|
+
expect(args.edits[0]).toEqual({ oldText: "a", newText: "b" });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("renames old → oldText, new → newText", () => {
|
|
43
|
+
const args = { path: "f", edits: [{ old: "a", new: "b" }] };
|
|
44
|
+
normalizeEditArgs(args);
|
|
45
|
+
expect(args.edits[0]).toEqual({ oldText: "a", newText: "b" });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ── Pattern A: top-level oldText/newText → wrap into edits ───────────
|
|
50
|
+
|
|
51
|
+
describe("Pattern A: top-level wrap", () => {
|
|
52
|
+
it("wraps oldText/newText into edits", () => {
|
|
53
|
+
const args = { path: "f", oldText: "a", newText: "b" } as Record<string, unknown>;
|
|
54
|
+
normalizeEditArgs(args);
|
|
55
|
+
expect(args.edits).toEqual([{ oldText: "a", newText: "b" }]);
|
|
56
|
+
expect(args.oldText).toBeUndefined();
|
|
57
|
+
expect(args.newText).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("wraps old_str/new_str aliases at top level", () => {
|
|
61
|
+
const args = { path: "f", old_str: "a", new_str: "b" } as Record<string, unknown>;
|
|
62
|
+
normalizeEditArgs(args);
|
|
63
|
+
expect(args.edits).toEqual([{ oldText: "a", newText: "b" }]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("does NOT wrap if edits already exists", () => {
|
|
67
|
+
const args = { path: "f", edits: [{ oldText: "x", newText: "y" }], oldText: "a", newText: "b" } as Record<string, unknown>;
|
|
68
|
+
normalizeEditArgs(args);
|
|
69
|
+
// edits should keep the existing array, oldText/newText left as-is
|
|
70
|
+
expect(args.edits).toEqual([{ oldText: "x", newText: "y" }]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── Pattern B: alias keys at top level → wrap ────────────────────────
|
|
75
|
+
|
|
76
|
+
describe("Pattern B: alias keys at top level", () => {
|
|
77
|
+
it("wraps search/replace at top level", () => {
|
|
78
|
+
const args = { path: "f", search: "a", replace: "b" } as Record<string, unknown>;
|
|
79
|
+
normalizeEditArgs(args);
|
|
80
|
+
expect(args.edits).toEqual([{ oldText: "a", newText: "b" }]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("wraps old/new at top level", () => {
|
|
84
|
+
const args = { path: "f", old: "a", new: "b" } as Record<string, unknown>;
|
|
85
|
+
normalizeEditArgs(args);
|
|
86
|
+
expect(args.edits).toEqual([{ oldText: "a", newText: "b" }]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── Pattern C: edits as JSON string ──────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe("Pattern C: JSON string edits", () => {
|
|
93
|
+
it("parses JSON string edits", () => {
|
|
94
|
+
const args = { path: "f", edits: '[{"oldText":"a","newText":"b"}]' } as Record<string, unknown>;
|
|
95
|
+
normalizeEditArgs(args);
|
|
96
|
+
expect(args.edits).toEqual([{ oldText: "a", newText: "b" }]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("ignores invalid JSON", () => {
|
|
100
|
+
const args = { path: "f", edits: "not-json" } as Record<string, unknown>;
|
|
101
|
+
normalizeEditArgs(args);
|
|
102
|
+
expect(args.edits).toBe("not-json"); // unchanged
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── No-op cases ──────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
describe("no-op", () => {
|
|
109
|
+
it("already correct args pass through", () => {
|
|
110
|
+
const args = { path: "f.ts", edits: [{ oldText: "a", newText: "b" }] };
|
|
111
|
+
normalizeEditArgs(args);
|
|
112
|
+
expect(args).toEqual({ path: "f.ts", edits: [{ oldText: "a", newText: "b" }] });
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// normalizeWriteArgs
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
describe("normalizeWriteArgs", () => {
|
|
122
|
+
it("renames file → path", () => {
|
|
123
|
+
const args = { file: "foo.ts", content: "hello" };
|
|
124
|
+
normalizeWriteArgs(args);
|
|
125
|
+
expect(args).toEqual({ path: "foo.ts", content: "hello" });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("renames text → content", () => {
|
|
129
|
+
const args = { path: "f", text: "hello" };
|
|
130
|
+
normalizeWriteArgs(args);
|
|
131
|
+
expect(args).toEqual({ path: "f", content: "hello" });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("renames body → content", () => {
|
|
135
|
+
const args = { path: "f", body: "hello" };
|
|
136
|
+
normalizeWriteArgs(args);
|
|
137
|
+
expect(args).toEqual({ path: "f", content: "hello" });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("renames code → content", () => {
|
|
141
|
+
const args = { path: "f", code: "console.log(1)" };
|
|
142
|
+
normalizeWriteArgs(args);
|
|
143
|
+
expect(args).toEqual({ path: "f", content: "console.log(1)" });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("already correct args pass through", () => {
|
|
147
|
+
const args = { path: "f.ts", content: "hello" };
|
|
148
|
+
normalizeWriteArgs(args);
|
|
149
|
+
expect(args).toEqual({ path: "f.ts", content: "hello" });
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// normalizeReadArgs
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
describe("normalizeReadArgs", () => {
|
|
158
|
+
it("renames file → path", () => {
|
|
159
|
+
const args = { file: "foo.ts" };
|
|
160
|
+
normalizeReadArgs(args);
|
|
161
|
+
expect(args).toEqual({ path: "foo.ts" });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("renames start → offset", () => {
|
|
165
|
+
const args = { path: "f", start: 10 };
|
|
166
|
+
normalizeReadArgs(args);
|
|
167
|
+
expect(args).toEqual({ path: "f", offset: 10 });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("renames lines → limit", () => {
|
|
171
|
+
const args = { path: "f", lines: 50 };
|
|
172
|
+
normalizeReadArgs(args);
|
|
173
|
+
expect(args).toEqual({ path: "f", limit: 50 });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("coerces string offset to number", () => {
|
|
177
|
+
const args = { path: "f", offset: "10" };
|
|
178
|
+
normalizeReadArgs(args);
|
|
179
|
+
expect(args).toEqual({ path: "f", offset: 10 });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("coerces string limit to number", () => {
|
|
183
|
+
const args = { path: "f", limit: "50" };
|
|
184
|
+
normalizeReadArgs(args);
|
|
185
|
+
expect(args).toEqual({ path: "f", limit: 50 });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("does not coerce non-numeric string", () => {
|
|
189
|
+
const args = { path: "f", offset: "abc" };
|
|
190
|
+
normalizeReadArgs(args);
|
|
191
|
+
expect(args).toEqual({ path: "f", offset: "abc" }); // unchanged
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("already correct args pass through", () => {
|
|
195
|
+
const args = { path: "f.ts", offset: 1, limit: 100 };
|
|
196
|
+
normalizeReadArgs(args);
|
|
197
|
+
expect(args).toEqual({ path: "f.ts", offset: 1, limit: 100 });
|
|
198
|
+
});
|
|
199
|
+
});
|
package/aliases.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// alias maps & normalizers for edit / write / read
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
export const PATH_FIELD_ALIASES = {
|
|
6
|
+
path: ["file", "filePath", "file_path", "target", "filename", "file_name"],
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
export const EDIT_FIELD_ALIASES: Record<string, readonly string[]> = {
|
|
10
|
+
...PATH_FIELD_ALIASES,
|
|
11
|
+
oldText: [
|
|
12
|
+
"old_str",
|
|
13
|
+
"old_string",
|
|
14
|
+
"old_text",
|
|
15
|
+
"oldStr",
|
|
16
|
+
"oldString",
|
|
17
|
+
"oldContent",
|
|
18
|
+
"old_content",
|
|
19
|
+
"old",
|
|
20
|
+
"original",
|
|
21
|
+
"search",
|
|
22
|
+
],
|
|
23
|
+
newText: [
|
|
24
|
+
"new_str",
|
|
25
|
+
"new_string",
|
|
26
|
+
"new_text",
|
|
27
|
+
"newStr",
|
|
28
|
+
"newString",
|
|
29
|
+
"newContent",
|
|
30
|
+
"new_content",
|
|
31
|
+
"new",
|
|
32
|
+
"replacement",
|
|
33
|
+
"replace",
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const WRITE_FIELD_ALIASES: Record<string, readonly string[]> = {
|
|
38
|
+
...PATH_FIELD_ALIASES,
|
|
39
|
+
content: [
|
|
40
|
+
"text",
|
|
41
|
+
"body",
|
|
42
|
+
"code",
|
|
43
|
+
"data",
|
|
44
|
+
"fileContent",
|
|
45
|
+
"file_content",
|
|
46
|
+
"contents",
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const READ_FIELD_ALIASES: Record<string, readonly string[]> = {
|
|
51
|
+
...PATH_FIELD_ALIASES,
|
|
52
|
+
offset: ["start", "startLine", "start_line", "from", "line"],
|
|
53
|
+
limit: ["lines", "maxLines", "max_lines", "count", "numLines", "num_lines"],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// helpers
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
export const buildAliasMap = (aliases: Record<string, readonly string[]>) => {
|
|
61
|
+
const map = new Map<string, string>();
|
|
62
|
+
for (const [canonical, alts] of Object.entries(aliases)) {
|
|
63
|
+
for (const alt of alts) {
|
|
64
|
+
if (!map.has(alt)) map.set(alt, canonical);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return map;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const editAliasMap = buildAliasMap(EDIT_FIELD_ALIASES);
|
|
71
|
+
export const writeAliasMap = buildAliasMap(WRITE_FIELD_ALIASES);
|
|
72
|
+
export const readAliasMap = buildAliasMap(READ_FIELD_ALIASES);
|
|
73
|
+
|
|
74
|
+
export const renameAliasKeys = (
|
|
75
|
+
obj: Record<string, unknown>,
|
|
76
|
+
canonicalKeys: Record<string, readonly string[]>,
|
|
77
|
+
aliasMap: Map<string, string>,
|
|
78
|
+
) => {
|
|
79
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
80
|
+
const canonical = key in canonicalKeys ? key : aliasMap.get(key);
|
|
81
|
+
if (canonical && canonical !== key) {
|
|
82
|
+
obj[canonical] = value;
|
|
83
|
+
delete obj[key];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// normalizers — mutate in place
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
export const normalizeEditArgs = (args: Record<string, unknown>): Record<string, unknown> => {
|
|
93
|
+
renameAliasKeys(args, EDIT_FIELD_ALIASES, editAliasMap);
|
|
94
|
+
|
|
95
|
+
// Pattern A: oldText/newText at top level (no edits array) → wrap
|
|
96
|
+
if (
|
|
97
|
+
!Array.isArray(args.edits) &&
|
|
98
|
+
typeof args.oldText === "string" &&
|
|
99
|
+
typeof args.newText === "string"
|
|
100
|
+
) {
|
|
101
|
+
args.edits = [{ oldText: args.oldText, newText: args.newText }];
|
|
102
|
+
delete args.oldText;
|
|
103
|
+
delete args.newText;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Pattern B: alias keys at top level → wrap into edits
|
|
107
|
+
if (!Array.isArray(args.edits)) {
|
|
108
|
+
const topOld = Object.keys(args).find((k) => editAliasMap.get(k) === "oldText");
|
|
109
|
+
const topNew = Object.keys(args).find((k) => editAliasMap.get(k) === "newText");
|
|
110
|
+
if (
|
|
111
|
+
topOld &&
|
|
112
|
+
topNew &&
|
|
113
|
+
typeof args[topOld] === "string" &&
|
|
114
|
+
typeof args[topNew] === "string"
|
|
115
|
+
) {
|
|
116
|
+
args.edits = [{ oldText: args[topOld], newText: args[topNew] }];
|
|
117
|
+
delete args[topOld];
|
|
118
|
+
delete args[topNew];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Pattern C: edits is a JSON string → parse it
|
|
123
|
+
if (!Array.isArray(args.edits) && typeof args.edits === "string") {
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(args.edits);
|
|
126
|
+
if (Array.isArray(parsed)) {
|
|
127
|
+
args.edits = parsed;
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
// Ignore
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Normalize keys inside each edit object
|
|
135
|
+
if (Array.isArray(args.edits)) {
|
|
136
|
+
for (const edit of args.edits) {
|
|
137
|
+
if (edit && typeof edit === "object") {
|
|
138
|
+
renameAliasKeys(edit as Record<string, unknown>, EDIT_FIELD_ALIASES, editAliasMap);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return args;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const normalizeWriteArgs = (args: Record<string, unknown>): Record<string, unknown> => {
|
|
147
|
+
renameAliasKeys(args, WRITE_FIELD_ALIASES, writeAliasMap);
|
|
148
|
+
return args;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export const normalizeReadArgs = (args: Record<string, unknown>): Record<string, unknown> => {
|
|
152
|
+
renameAliasKeys(args, READ_FIELD_ALIASES, readAliasMap);
|
|
153
|
+
|
|
154
|
+
if (typeof args.offset === "string") {
|
|
155
|
+
const n = parseFloat(args.offset);
|
|
156
|
+
if (!isNaN(n)) args.offset = n;
|
|
157
|
+
}
|
|
158
|
+
if (typeof args.limit === "string") {
|
|
159
|
+
const n = parseFloat(args.limit);
|
|
160
|
+
if (!isNaN(n)) args.limit = n;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return args;
|
|
164
|
+
};
|
package/bash.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// bash extractor execution helpers
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export const FAST_THRESHOLD_MS = 10_000;
|
|
8
|
+
|
|
9
|
+
export function buildNotice(removedNames: string[]): string {
|
|
10
|
+
const list = removedNames.map((r) => `\`${r}\``).join(", ");
|
|
11
|
+
return `[pi-tool-guard] Removed trailing pipeline commands: ${list}. The full output is above — do NOT re-run with different parameters.`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Run an extractor pipeline on a file (e.g. the full output temp file).
|
|
16
|
+
* Returns the filtered text, or undefined on failure.
|
|
17
|
+
*/
|
|
18
|
+
export async function runExtractorOnFile(
|
|
19
|
+
pi: ExtensionAPI,
|
|
20
|
+
filePath: string,
|
|
21
|
+
pipeline: string,
|
|
22
|
+
): Promise<string | undefined> {
|
|
23
|
+
try {
|
|
24
|
+
const result = await pi.exec("bash", ["-c", `cat ${JSON.stringify(filePath)} | ${pipeline}`]);
|
|
25
|
+
if (result.code === 0 && result.stdout.trim()) {
|
|
26
|
+
return result.stdout.trimEnd();
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// Bail silently
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Run an extractor pipeline on inline text via printf + pipe.
|
|
36
|
+
* Returns the filtered text, or undefined on failure.
|
|
37
|
+
*/
|
|
38
|
+
export async function runExtractorOnText(
|
|
39
|
+
pi: ExtensionAPI,
|
|
40
|
+
text: string,
|
|
41
|
+
pipeline: string,
|
|
42
|
+
): Promise<string | undefined> {
|
|
43
|
+
try {
|
|
44
|
+
const result = await pi.exec("bash", ["-c", `printf '%s' ${JSON.stringify(text)} | ${pipeline}`]);
|
|
45
|
+
if (result.code === 0) {
|
|
46
|
+
return result.stdout.trimEnd() || undefined;
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// Bail silently
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type BashToolInput,
|
|
4
|
+
type BashToolDetails,
|
|
5
|
+
type EditToolInput,
|
|
6
|
+
type ReadToolInput,
|
|
7
|
+
type WriteToolInput,
|
|
8
|
+
createBashToolDefinition,
|
|
9
|
+
createEditToolDefinition,
|
|
10
|
+
createReadToolDefinition,
|
|
11
|
+
createWriteToolDefinition,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import {
|
|
14
|
+
normalizeEditArgs,
|
|
15
|
+
normalizeWriteArgs,
|
|
16
|
+
normalizeReadArgs,
|
|
17
|
+
} from "./aliases.ts";
|
|
18
|
+
import {
|
|
19
|
+
stripTrailingExtractors,
|
|
20
|
+
extractFullOutputPath,
|
|
21
|
+
} from "./pipeline.ts";
|
|
22
|
+
import {
|
|
23
|
+
FAST_THRESHOLD_MS,
|
|
24
|
+
buildNotice,
|
|
25
|
+
runExtractorOnFile,
|
|
26
|
+
runExtractorOnText,
|
|
27
|
+
} from "./bash.ts";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// extension
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export default function toolGuardExtension(pi: ExtensionAPI) {
|
|
34
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
35
|
+
// ── Override edit ──────────────────────────────────────────────────
|
|
36
|
+
pi.registerTool({
|
|
37
|
+
...createEditToolDefinition(ctx.cwd),
|
|
38
|
+
prepareArguments: (args: unknown): EditToolInput => {
|
|
39
|
+
if (args && typeof args === "object") {
|
|
40
|
+
normalizeEditArgs(args as Record<string, unknown>);
|
|
41
|
+
}
|
|
42
|
+
return args as EditToolInput;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ── Override write ─────────────────────────────────────────────────
|
|
47
|
+
pi.registerTool({
|
|
48
|
+
...createWriteToolDefinition(ctx.cwd),
|
|
49
|
+
prepareArguments: (args: unknown): WriteToolInput => {
|
|
50
|
+
if (args && typeof args === "object") {
|
|
51
|
+
normalizeWriteArgs(args as Record<string, unknown>);
|
|
52
|
+
}
|
|
53
|
+
return args as WriteToolInput;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── Override read ──────────────────────────────────────────────────
|
|
58
|
+
pi.registerTool({
|
|
59
|
+
...createReadToolDefinition(ctx.cwd),
|
|
60
|
+
prepareArguments: (args: unknown): ReadToolInput => {
|
|
61
|
+
if (args && typeof args === "object") {
|
|
62
|
+
normalizeReadArgs(args as Record<string, unknown>);
|
|
63
|
+
}
|
|
64
|
+
return args as ReadToolInput;
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ── Override bash ──────────────────────────────────────────────────
|
|
69
|
+
const bashDef = createBashToolDefinition(ctx.cwd);
|
|
70
|
+
const originalExecute = bashDef.execute;
|
|
71
|
+
|
|
72
|
+
pi.registerTool({
|
|
73
|
+
...bashDef,
|
|
74
|
+
prepareArguments: (args: unknown): BashToolInput => {
|
|
75
|
+
if (!args || typeof args !== "object") return args as BashToolInput;
|
|
76
|
+
|
|
77
|
+
const input = args as Record<string, unknown>;
|
|
78
|
+
if (typeof input.command !== "string") return args as BashToolInput;
|
|
79
|
+
|
|
80
|
+
const stripped = stripTrailingExtractors(input.command);
|
|
81
|
+
if (stripped) {
|
|
82
|
+
input.command = stripped.cleaned;
|
|
83
|
+
input._piToolGuardRemoved = stripped.removedNames;
|
|
84
|
+
input._piToolGuardPipeline = stripped.removedPipeline;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return input as unknown as BashToolInput;
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
async execute(toolCallId, params, signal, onUpdate, execCtx) {
|
|
91
|
+
const input = params as Record<string, unknown>;
|
|
92
|
+
const removedNames = input._piToolGuardRemoved as string[] | undefined;
|
|
93
|
+
const removedPipeline = input._piToolGuardPipeline as string | undefined;
|
|
94
|
+
|
|
95
|
+
// No extractors — run normally
|
|
96
|
+
if (!removedNames || removedNames.length === 0 || !removedPipeline) {
|
|
97
|
+
return originalExecute(toolCallId, params, signal, onUpdate, execCtx);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Clean transient fields before running
|
|
101
|
+
delete input._piToolGuardRemoved;
|
|
102
|
+
delete input._piToolGuardPipeline;
|
|
103
|
+
|
|
104
|
+
// Run the stripped command
|
|
105
|
+
const start = Date.now();
|
|
106
|
+
let result;
|
|
107
|
+
try {
|
|
108
|
+
result = await originalExecute(toolCallId, params, signal, onUpdate, execCtx);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
// On error: check if output was saved to file, run extractor on it
|
|
111
|
+
const errText = err instanceof Error ? err.message : String(err);
|
|
112
|
+
const fullPath = extractFullOutputPath(errText);
|
|
113
|
+
if (fullPath) {
|
|
114
|
+
const extracted = await runExtractorOnFile(pi, fullPath, removedPipeline);
|
|
115
|
+
if (extracted !== undefined) {
|
|
116
|
+
const notice = buildNotice(removedNames);
|
|
117
|
+
throw new Error(`${extracted}\n\n${notice}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const elapsed = Date.now() - start;
|
|
124
|
+
|
|
125
|
+
// Case 1: Output was truncated → run extractor on the full output file
|
|
126
|
+
const details = result.details as BashToolDetails | undefined;
|
|
127
|
+
if (details?.fullOutputPath) {
|
|
128
|
+
const extracted = await runExtractorOnFile(pi, details.fullOutputPath, removedPipeline);
|
|
129
|
+
if (extracted !== undefined) {
|
|
130
|
+
const notice = buildNotice(removedNames);
|
|
131
|
+
return {
|
|
132
|
+
content: [{ type: "text" as const, text: `${extracted}\n\n${notice}` }],
|
|
133
|
+
details: result.details,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Case 2: Fast command, no truncation → pipe result through extractor
|
|
139
|
+
if (elapsed < FAST_THRESHOLD_MS) {
|
|
140
|
+
const resultText = result.content
|
|
141
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
142
|
+
.map((c) => c.text)
|
|
143
|
+
.join("\n");
|
|
144
|
+
|
|
145
|
+
const extracted = await runExtractorOnText(pi, resultText, removedPipeline);
|
|
146
|
+
if (extracted !== undefined) {
|
|
147
|
+
const notice = buildNotice(removedNames);
|
|
148
|
+
return {
|
|
149
|
+
content: [{ type: "text" as const, text: `${extracted}\n\n${notice}` }],
|
|
150
|
+
details: result.details,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Case 3: Slow, no truncation → return full result with notice
|
|
156
|
+
const notice = buildNotice(removedNames);
|
|
157
|
+
return {
|
|
158
|
+
content: [...result.content, { type: "text" as const, text: `\n\n${notice}` }],
|
|
159
|
+
details: result.details,
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
ctx.ui.notify("pi-tool-guard: overriding edit/write/read/bash with corrections", "info");
|
|
165
|
+
});
|
|
166
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-tool-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Pi extension that corrects LLM tool calls: normalizes argument aliases for edit/write/read and strips trailing pipeline extractors from bash commands",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "vitest run",
|
|
9
|
+
"typecheck": "tsc --noEmit"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pi-package",
|
|
13
|
+
"pi-extension",
|
|
14
|
+
"argument-normalization",
|
|
15
|
+
"alias-correction",
|
|
16
|
+
"tool-override",
|
|
17
|
+
"pipeline-optimization"
|
|
18
|
+
],
|
|
19
|
+
"author": "Jiajun Chen <tychenjiajun@gmail.com>",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"files": [
|
|
22
|
+
"*.ts",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"pi": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./index.ts"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@earendil-works/pi-coding-agent": "^0.79.1",
|
|
36
|
+
"typescript": "^6.0.3",
|
|
37
|
+
"vitest": "^4.1.8"
|
|
38
|
+
},
|
|
39
|
+
"packageManager": "pnpm@11.6.0",
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"unbash": "^4.0.1"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/pipeline.test.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { stripTrailingExtractors, extractFullOutputPath } from "./pipeline.ts";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Real-world cases from ~/.pi/agent/sessions/
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
describe("stripTrailingExtractors", () => {
|
|
9
|
+
|
|
10
|
+
// ── Simple tail/head ─────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
describe("simple tail/head", () => {
|
|
13
|
+
it("npm run build 2>&1 | tail -20", () => {
|
|
14
|
+
const r = stripTrailingExtractors("npm run build 2>&1 | tail -20");
|
|
15
|
+
expect(r).toEqual({
|
|
16
|
+
cleaned: "npm run build 2>&1",
|
|
17
|
+
removedNames: ["tail"],
|
|
18
|
+
removedPipeline: "tail -20",
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("npm run build 2>&1 | tail -5", () => {
|
|
23
|
+
const r = stripTrailingExtractors("npm run build 2>&1 | tail -5");
|
|
24
|
+
expect(r!.cleaned).toBe("npm run build 2>&1");
|
|
25
|
+
expect(r!.removedNames).toEqual(["tail"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("npm run build 2>&1 | tail -30", () => {
|
|
29
|
+
const r = stripTrailingExtractors("npm run build 2>&1 | tail -30");
|
|
30
|
+
expect(r!.cleaned).toBe("npm run build 2>&1");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("npm run typecheck 2>&1 && npm run build 2>&1 | tail -5", () => {
|
|
34
|
+
const r = stripTrailingExtractors("npm run typecheck 2>&1 && npm run build 2>&1 | tail -5");
|
|
35
|
+
expect(r).toBeDefined();
|
|
36
|
+
expect(r!.removedNames).toEqual(["tail"]);
|
|
37
|
+
expect(r!.cleaned).toBe("npm run typecheck 2>&1 && npm run build 2>&1");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("find src -type f -name '*.ts' | head -20", () => {
|
|
41
|
+
const r = stripTrailingExtractors("find src -type f -name '*.ts' | head -20");
|
|
42
|
+
expect(r).toEqual({
|
|
43
|
+
cleaned: "find src -type f -name '*.ts'",
|
|
44
|
+
removedNames: ["head"],
|
|
45
|
+
removedPipeline: "head -20",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("./dist/cli.js 2>&1 | head -30", () => {
|
|
50
|
+
const r = stripTrailingExtractors("./dist/cli.js 2>&1 | head -30");
|
|
51
|
+
expect(r!.cleaned).toBe("./dist/cli.js 2>&1");
|
|
52
|
+
expect(r!.removedNames).toEqual(["head"]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── pytest patterns (the main anti-pattern) ──────────────────────────
|
|
57
|
+
|
|
58
|
+
describe("pytest with tail/head", () => {
|
|
59
|
+
it(".venv/bin/python -m pytest tests/ -v --tb=short 2>&1 | tail -30", () => {
|
|
60
|
+
const r = stripTrailingExtractors(".venv/bin/python -m pytest tests/ -v --tb=short 2>&1 | tail -30");
|
|
61
|
+
expect(r!.cleaned).toBe(".venv/bin/python -m pytest tests/ -v --tb=short 2>&1");
|
|
62
|
+
expect(r!.removedNames).toEqual(["tail"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("pytest with -xvs and tail -60 (repeated anti-pattern)", () => {
|
|
66
|
+
const r = stripTrailingExtractors(".venv/bin/python -m pytest tests/test_time_varying_universe_equivalence.py::TestTimeVaryingUniverseEquivalence::test_metrics_equivalence -xvs 2>&1 | tail -60");
|
|
67
|
+
expect(r!.cleaned).toContain("test_metrics_equivalence -xvs 2>&1");
|
|
68
|
+
expect(r!.removedNames).toEqual(["tail"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("pytest with -xvs and tail -80 (same test, bigger tail)", () => {
|
|
72
|
+
const r = stripTrailingExtractors(".venv/bin/python -m pytest tests/test_time_varying_universe_equivalence.py::TestTimeVaryingUniverseEquivalence::test_metrics_equivalence -xvs 2>&1 | tail -80");
|
|
73
|
+
expect(r!.removedNames).toEqual(["tail"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("pytest with tail -100", () => {
|
|
77
|
+
const r = stripTrailingExtractors(".venv/bin/python -m pytest tests/ -x -q --tb=short 2>&1 | tail -100");
|
|
78
|
+
expect(r!.cleaned).toBe(".venv/bin/python -m pytest tests/ -x -q --tb=short 2>&1");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("pytest with head -100", () => {
|
|
82
|
+
const r = stripTrailingExtractors(".venv/bin/python -m pytest tests/ -v --tb=short 2>&1 | head -100");
|
|
83
|
+
expect(r!.cleaned).toBe(".venv/bin/python -m pytest tests/ -v --tb=short 2>&1");
|
|
84
|
+
expect(r!.removedNames).toEqual(["head"]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("pytest with head -50", () => {
|
|
88
|
+
const r = stripTrailingExtractors(".venv/bin/python -m pytest tests/test_metric_caching_and_list.py -v --tb=short 2>&1 | head -50");
|
|
89
|
+
expect(r!.removedNames).toEqual(["head"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("pytest with head -80", () => {
|
|
93
|
+
const r = stripTrailingExtractors(".venv/bin/python -m pytest tests/test_metric_caching_and_list.py -v --tb=short 2>&1 | head -80");
|
|
94
|
+
expect(r!.removedNames).toEqual(["head"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("pytest with tail -5", () => {
|
|
98
|
+
const r = stripTrailingExtractors("cd /Users/chenjiajun/projects/insight-alpha-server-qlib && uv run pytest -v 2>&1 | tail -5");
|
|
99
|
+
expect(r).toBeDefined();
|
|
100
|
+
expect(r!.cleaned).toBe("cd /Users/chenjiajun/projects/insight-alpha-server-qlib && uv run pytest -v 2>&1");
|
|
101
|
+
expect(r!.removedNames).toEqual(["tail"]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── pytest with grep (context lines) ─────────────────────────────────
|
|
106
|
+
|
|
107
|
+
describe("pytest with grep -A (context lines)", () => {
|
|
108
|
+
it("pytest | grep -A 50 FAILED", () => {
|
|
109
|
+
const r = stripTrailingExtractors('.venv/bin/python -m pytest tests/test_group_return_with_benchmark.py::TestBenchmarkEquivalence::test_benchmark_data_matches -v 2>&1 | grep -A 50 "FAILED"');
|
|
110
|
+
expect(r!.cleaned).toContain("test_benchmark_data_matches -v 2>&1");
|
|
111
|
+
expect(r!.removedNames).toEqual(["grep"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("pytest | grep -A 100 FAILED", () => {
|
|
115
|
+
const r = stripTrailingExtractors('.venv/bin/python -m pytest tests/test_group_return_with_benchmark.py::TestBenchmarkEquivalence::test_benchmark_data_matches -v 2>&1 | grep -A 100 "FAILED"');
|
|
116
|
+
expect(r!.removedNames).toEqual(["grep"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("pytest | grep -A 30 pattern", () => {
|
|
120
|
+
const r = stripTrailingExtractors('.venv/bin/python -m pytest tests/test_group_return_with_benchmark.py::TestBenchmarkEquivalence::test_group_return_benchmark_matches_standalone -v 2>&1 | grep -A 30 "assert"');
|
|
121
|
+
expect(r!.removedNames).toEqual(["grep"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("pytest | grep -A 10 pattern", () => {
|
|
125
|
+
const r = stripTrailingExtractors('.venv/bin/python -m pytest tests/test_time_varying_universe_equivalence.py::TestTimeVaryingUniverseEquivalence::test_metrics_equivalence -xvs 2>&1 | grep -A 10 "Error"');
|
|
126
|
+
expect(r!.removedNames).toEqual(["grep"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("pytest | grep -A 5 pattern", () => {
|
|
130
|
+
const r = stripTrailingExtractors('.venv/bin/python -m pytest tests/test_time_varying_universe_equivalence.py::TestTimeVaryingUniverseEquivalence::test_metrics_equivalence -xvs 2>&1 | grep -A 5 "assert"');
|
|
131
|
+
expect(r!.removedNames).toEqual(["grep"]);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── Multi-pipe extractors ────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
describe("multi-pipe extractors", () => {
|
|
138
|
+
it("npm test | grep FAIL | head -5", () => {
|
|
139
|
+
const r = stripTrailingExtractors("npm test | grep FAIL | head -5");
|
|
140
|
+
expect(r).toEqual({
|
|
141
|
+
cleaned: "npm test",
|
|
142
|
+
removedNames: ["grep", "head"],
|
|
143
|
+
removedPipeline: "grep FAIL | head -5",
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("cmd | grep -v grep | head -5", () => {
|
|
148
|
+
const r = stripTrailingExtractors("ps aux | grep uvicorn | grep -v grep");
|
|
149
|
+
// grep -v grep is also grep — should be stripped
|
|
150
|
+
if (r) {
|
|
151
|
+
expect(r.removedNames).toEqual(["grep", "grep"]);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ── Complex pipelines ────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe("complex pipelines", () => {
|
|
159
|
+
it("curl | python json.tool | head -40", () => {
|
|
160
|
+
const r = stripTrailingExtractors("curl -s http://localhost:8000/tasks 2>&1 | python3 -m json.tool 2>&1 | head -30");
|
|
161
|
+
// python3 -m json.tool is not an extractor, head is
|
|
162
|
+
if (r) {
|
|
163
|
+
expect(r.removedNames).toEqual(["head"]);
|
|
164
|
+
expect(r.cleaned).toContain("python3 -m json.tool 2>&1");
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("cat | cat -v | head -10", () => {
|
|
169
|
+
const r = stripTrailingExtractors("head -c 2000 /tmp/file.js | cat -v | head -10");
|
|
170
|
+
if (r) {
|
|
171
|
+
expect(r.removedNames).toEqual(["head"]);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("grep -oE | sort -u | head -40", () => {
|
|
176
|
+
const r = stripTrailingExtractors("grep -oE '[a-zA-Z_$][a-zA-Z0-9_$]{4,}' /tmp/file.js | sort -u | head -40");
|
|
177
|
+
if (r) {
|
|
178
|
+
// sort -u is an extractor, head is too
|
|
179
|
+
expect(r.removedNames).toContain("head");
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("sed | cat -n", () => {
|
|
184
|
+
const r = stripTrailingExtractors("sed -n '130,140p' /path/to/file.ts | cat -n");
|
|
185
|
+
// cat is not an extractor
|
|
186
|
+
expect(r).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("npm build | grep | head (multi-step)", () => {
|
|
190
|
+
const r = stripTrailingExtractors("pnpm build:css 2>&1 && grep 'color-tag' styles/theme.css | grep -v 'on' | head -10");
|
|
191
|
+
if (r) {
|
|
192
|
+
expect(r.removedNames).toContain("head");
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ── Non-pipeline commands (should return undefined) ──────────────────
|
|
198
|
+
|
|
199
|
+
describe("non-pipeline commands", () => {
|
|
200
|
+
it("simple ls", () => {
|
|
201
|
+
expect(stripTrailingExtractors("ls -la")).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("simple cat", () => {
|
|
205
|
+
expect(stripTrailingExtractors("cat file.txt")).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("simple npm test", () => {
|
|
209
|
+
expect(stripTrailingExtractors("npm test")).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("simple pytest", () => {
|
|
213
|
+
expect(stripTrailingExtractors(".venv/bin/python -m pytest tests/ -v")).toBeUndefined();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("cd && command", () => {
|
|
217
|
+
expect(stripTrailingExtractors("cd /tmp && npm install")).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ── Pipeline with non-extractor at end ───────────────────────────────
|
|
222
|
+
|
|
223
|
+
describe("pipeline with non-extractor at end", () => {
|
|
224
|
+
it("echo hello | cat", () => {
|
|
225
|
+
expect(stripTrailingExtractors("echo hello | cat")).toBeUndefined();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("cat file | sort", () => {
|
|
229
|
+
// sort IS an extractor
|
|
230
|
+
const r = stripTrailingExtractors("cat file | sort");
|
|
231
|
+
expect(r).toBeDefined();
|
|
232
|
+
expect(r!.removedNames).toEqual(["sort"]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("cat file | wc -l", () => {
|
|
236
|
+
// wc IS an extractor
|
|
237
|
+
const r = stripTrailingExtractors("cat file | wc -l");
|
|
238
|
+
expect(r).toBeDefined();
|
|
239
|
+
expect(r!.removedNames).toEqual(["wc"]);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ── Commands with 2>&1 ───────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
describe("stderr redirection", () => {
|
|
246
|
+
it("cmd 2>&1 | tail -30", () => {
|
|
247
|
+
const r = stripTrailingExtractors(".venv/bin/python -m pytest tests/test_factor.py -v --tb=short 2>&1 | tail -40");
|
|
248
|
+
expect(r!.cleaned).toBe(".venv/bin/python -m pytest tests/test_factor.py -v --tb=short 2>&1");
|
|
249
|
+
expect(r!.removedNames).toEqual(["tail"]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("pnpm install 2>&1 | tail -5", () => {
|
|
253
|
+
const r = stripTrailingExtractors("cd /tmp/resume-test && pnpm install 2>&1 | tail -5");
|
|
254
|
+
expect(r).toBeDefined();
|
|
255
|
+
expect(r!.cleaned).toBe("cd /tmp/resume-test && pnpm install 2>&1");
|
|
256
|
+
expect(r!.removedNames).toEqual(["tail"]);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ── view-logs and scripts ────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
describe("script pipelines", () => {
|
|
263
|
+
it("./view-logs.sh all | tail -80", () => {
|
|
264
|
+
const r = stripTrailingExtractors("./view-logs.sh all | tail -80");
|
|
265
|
+
expect(r!.cleaned).toBe("./view-logs.sh all");
|
|
266
|
+
expect(r!.removedNames).toEqual(["tail"]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("./view-logs.sh all | grep -i pattern", () => {
|
|
270
|
+
const r = stripTrailingExtractors('./view-logs.sh all | grep -i "error"');
|
|
271
|
+
expect(r!.cleaned).toBe("./view-logs.sh all");
|
|
272
|
+
expect(r!.removedNames).toEqual(["grep"]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("cat /tmp/server.log | tail -100", () => {
|
|
276
|
+
const r = stripTrailingExtractors("cat /tmp/server.log 2>/dev/null | tail -100");
|
|
277
|
+
expect(r!.removedNames).toEqual(["tail"]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("tail -30 /tmp/server.log (standalone tail, not pipeline)", () => {
|
|
281
|
+
// This is NOT a pipeline — tail is the only command
|
|
282
|
+
expect(stripTrailingExtractors("tail -30 /tmp/server.log")).toBeUndefined();
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ── Edge cases ───────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
describe("edge cases", () => {
|
|
289
|
+
it("empty string", () => {
|
|
290
|
+
expect(stripTrailingExtractors("")).toBeUndefined();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("whitespace only", () => {
|
|
294
|
+
expect(stripTrailingExtractors(" ")).toBeUndefined();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("single pipe with non-extractor", () => {
|
|
298
|
+
expect(stripTrailingExtractors("echo hello | cat")).toBeUndefined();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("command with env vars", () => {
|
|
302
|
+
const r = stripTrailingExtractors("PAT_TOKEN=abc ./scripts/calc.py 2>&1 | head -50");
|
|
303
|
+
expect(r!.removedNames).toEqual(["head"]);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("command with && before pipe", () => {
|
|
307
|
+
const r = stripTrailingExtractors("cd /path && npm test 2>&1 | tail -20");
|
|
308
|
+
expect(r).toBeDefined();
|
|
309
|
+
expect(r!.removedNames).toEqual(["tail"]);
|
|
310
|
+
expect(r!.cleaned).toBe("cd /path && npm test 2>&1");
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// extractFullOutputPath
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
describe("extractFullOutputPath", () => {
|
|
320
|
+
it("extracts path from truncated output", () => {
|
|
321
|
+
const text = "some output\n\n[Showing lines 1-100 of 500. Full output: /tmp/pi-bash-abc123]";
|
|
322
|
+
expect(extractFullOutputPath(text)).toBe("/tmp/pi-bash-abc123");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("extracts path from bytes-limited truncation", () => {
|
|
326
|
+
const text = "some output\n\n[Showing lines 1-100 of 500 (50KB limit). Full output: /tmp/pi-bash-xyz]";
|
|
327
|
+
expect(extractFullOutputPath(text)).toBe("/tmp/pi-bash-xyz");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("extracts path from last-line truncation", () => {
|
|
331
|
+
const text = "output\n\n[Showing last 50KB of line 500 (line is 1.2MB). Full output: /tmp/pi-bash-foo]";
|
|
332
|
+
expect(extractFullOutputPath(text)).toBe("/tmp/pi-bash-foo");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("returns undefined for non-truncated output", () => {
|
|
336
|
+
expect(extractFullOutputPath("no truncation here")).toBeUndefined();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("returns undefined for error without truncation", () => {
|
|
340
|
+
expect(extractFullOutputPath("error output\n\nCommand exited with code 1")).toBeUndefined();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("returns undefined for empty string", () => {
|
|
344
|
+
expect(extractFullOutputPath("")).toBeUndefined();
|
|
345
|
+
});
|
|
346
|
+
});
|
package/pipeline.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { parse } from "unbash";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// pipeline extractor stripping (unbash AST)
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export const EXTRACTOR_COMMANDS = new Set([
|
|
8
|
+
"head",
|
|
9
|
+
"tail",
|
|
10
|
+
"grep",
|
|
11
|
+
"egrep",
|
|
12
|
+
"fgrep",
|
|
13
|
+
"rg",
|
|
14
|
+
"sed",
|
|
15
|
+
"awk",
|
|
16
|
+
"cut",
|
|
17
|
+
"sort",
|
|
18
|
+
"uniq",
|
|
19
|
+
"wc",
|
|
20
|
+
"less",
|
|
21
|
+
"more",
|
|
22
|
+
"column",
|
|
23
|
+
"jq",
|
|
24
|
+
"yq",
|
|
25
|
+
"tr",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export function isExtractor(name: string): boolean {
|
|
29
|
+
return EXTRACTOR_COMMANDS.has(name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface StripResult {
|
|
33
|
+
cleaned: string;
|
|
34
|
+
removedNames: string[];
|
|
35
|
+
removedPipeline: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse a bash command, find trailing extractor commands in pipelines,
|
|
40
|
+
* strip them, and return the cleaned command + pipeline segments.
|
|
41
|
+
* Returns undefined if nothing was changed.
|
|
42
|
+
*/
|
|
43
|
+
export function stripTrailingExtractors(command: string): StripResult | undefined {
|
|
44
|
+
let ast;
|
|
45
|
+
try {
|
|
46
|
+
ast = parse(command);
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const stmt = ast.commands[0];
|
|
52
|
+
if (!stmt) return undefined;
|
|
53
|
+
|
|
54
|
+
// Walk into AndOr → Pipeline → commands
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
|
+
let inner: any = stmt.command;
|
|
57
|
+
while (inner && inner.type === "AndOr") {
|
|
58
|
+
const lastCmd = inner.commands[inner.commands.length - 1];
|
|
59
|
+
if (!lastCmd) return undefined;
|
|
60
|
+
inner = lastCmd;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!inner || inner.type !== "Pipeline") return undefined;
|
|
64
|
+
|
|
65
|
+
const cmds = inner.commands;
|
|
66
|
+
if (cmds.length < 2) return undefined;
|
|
67
|
+
|
|
68
|
+
// Walk backward, collect trailing extractors
|
|
69
|
+
const removedNames: string[] = [];
|
|
70
|
+
let stripFrom = cmds.length;
|
|
71
|
+
|
|
72
|
+
for (let i = cmds.length - 1; i >= 1; i--) {
|
|
73
|
+
const cmd = cmds[i]!;
|
|
74
|
+
if (cmd.type !== "Command") break;
|
|
75
|
+
const name = cmd.name?.text;
|
|
76
|
+
if (name && isExtractor(name)) {
|
|
77
|
+
removedNames.unshift(name);
|
|
78
|
+
stripFrom = i;
|
|
79
|
+
} else {
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (stripFrom === cmds.length) return undefined;
|
|
85
|
+
|
|
86
|
+
const prevCmd = cmds[stripFrom - 1]!;
|
|
87
|
+
const cleaned = command.slice(0, prevCmd.end).trim();
|
|
88
|
+
if (!cleaned) return undefined;
|
|
89
|
+
|
|
90
|
+
// Reconstruct the removed pipeline part (e.g. "grep FAIL | head -5")
|
|
91
|
+
const removedPipeline = cmds
|
|
92
|
+
.slice(stripFrom)
|
|
93
|
+
.map((c: { pos: number; end: number }) => command.slice(c.pos, c.end))
|
|
94
|
+
.join(" | ");
|
|
95
|
+
|
|
96
|
+
return { cleaned, removedNames, removedPipeline };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Extract "Full output: <path>" from bash result text.
|
|
101
|
+
*/
|
|
102
|
+
export function extractFullOutputPath(text: string): string | undefined {
|
|
103
|
+
const match = text.match(/\bFull output:\s*(\S+?)\]?$/m);
|
|
104
|
+
return match?.[1];
|
|
105
|
+
}
|