Skip to content

Commit ff4bf12

Browse files
authored
bash-trim: only dedup when row trimming is needed (#7)
Co-authored-by: mgabor3141 <@mgabor3141>
1 parent af8ec18 commit ff4bf12

6 files changed

Lines changed: 66 additions & 39 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"pi-bash-trim": patch
3+
---
4+
5+
Only apply line deduplication when row trimming is actually needed. Output that fits within the token budget is no longer deduped.

.changeset/safeguard-config-redesign.md

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,4 @@
22
"pi-safeguard": minor
33
---
44

5-
Add user-configurable `commands`, `patterns`, and `instructions` fields to safeguard config. Commands support flat string (flag any invocation) and subcommand prefix arrays (`["gh", "repo", "delete"]`). Patterns are regexes matched against all tool input text. Instructions are natural language appended to the judge system prompt.
6-
7-
Support project-level config at `.pi/extensions/pi-safeguard.json` (additive only — cannot weaken global settings). Global and project configs merge: commands and patterns concatenate, instructions are labeled by source.
8-
9-
Add `\bsafeguard\b` to built-in string patterns to flag attempts to reference or modify the security guardrail.
5+
Add user-configurable `commands`, `patterns`, and `instructions` to safeguard config. Support project-level config at `.pi/extensions/pi-safeguard.json` (additive only — cannot weaken global settings). Add `\bsafeguard\b` to built-in string patterns.

.changeset/safeguard-signal-flagger.md

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,8 @@
22
"pi-safeguard": major
33
---
44

5-
Replace specific pattern matching with signal-based flagging architecture. The flagger is now a wide-net boolean gate (high recall, no reasoning) and the judge sees raw actions only — no flagger bias in evaluations.
5+
Replace pattern matching with signal-based flagging. The flagger is now a wide-net boolean gate (high recall, no reasoning); the judge sees raw actions only — no flagger bias.
66

7-
Broadened sensitive file detection beyond `.env*`:
8-
- Files outside the working directory
9-
- Dotfiles/dotdirs in `$HOME` (`.ssh`, `.aws`, `.gnupg`, etc.)
10-
- System paths (`/etc`, `/usr`, `/var`, `/dev`, `/proc`, etc.)
11-
- Paths containing secret keywords (`secret`, `credential`, `password`, `token`, `.pem`, `.key`, `id_rsa`, `authorized_keys`)
7+
Broadened sensitive file detection beyond `.env*`: files outside cwd, dotfiles in `$HOME`, system paths, paths with secret keywords.
128

13-
New signals:
14-
- `rm -r` and `rm -f` flagged independently (previously required both)
15-
- Interpreter with inline code (`eval`, `bash -c`, `python -c`, `node -e`)
16-
- `chmod u+s`/`g+s` (setuid/setgid)
17-
- `su`, `doas`, `pkexec` (previously only `sudo`)
18-
- Content scanning: private key material, known API key formats (GitHub PAT, OpenAI, AWS, Slack)
9+
New signals: `rm -r`/`rm -f` flagged independently, inline code interpreters (`eval`, `bash -c`, `python -c`, `node -e`), `chmod u+s`/`g+s`, `su`/`doas`/`pkexec`, private key material, known API key formats.

.changeset/safeguard-string-patterns.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"pi-safeguard": minor
33
---
44

5-
Add string pattern matching in addition to AST-based detection. Dangerous keywords like `sudo` are now caught anywhere in tool input text (e.g. scripts being written or edited), not just when they appear as parsed command names. Also fix post-denial circumvention check cascade where a single denial could trigger repeated checks on every subsequent tool call.
5+
Add string pattern matching in addition to AST-based detection — dangerous keywords like `sudo` are now caught anywhere in tool input text, not just as parsed command names. Fix post-denial circumvention check cascade.

packages/bash-trim/src/trim.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -285,26 +285,39 @@ export function trimOutput(fullOutput: string, options?: Partial<TrimOptions>):
285285
const colTrimmed = tokenized.map((lt) => colTrimLine(lt, maxLineWidth, trimmedWidth, headRatio));
286286
const anyColumnsTrimmed = colTrimmed.some((l) => l.trimmed);
287287

288-
// Phase 2: Dedup consecutive similar lines
289-
const colTexts = colTrimmed.map((l) => l.text);
290-
const dedupResult = dedup(colTexts, minDedupLines);
291-
292-
// Rebuild ColTrimmedLine[] from dedup output — summary lines need fresh token counts
288+
// Phase 2: Dedup — only if row trimming is actually needed.
289+
// If the output fits within the token budget, dedup is counter-productive:
290+
// collapsing e.g. 10 similar `ls` rows when nothing is being cut from the
291+
// middle just destroys information for no benefit.
292+
const rowCheckWithoutDedup = trimRows(colTrimmed, maxTotalTokens);
293+
const needsRowTrimming = rowCheckWithoutDedup.omittedLines > 0;
294+
295+
let dedupResult: { lines: string[]; dedupedLines: number; groupCount: number };
293296
let dedupColTrimmed: ColTrimmedLine[];
294-
if (dedupResult.dedupedLines > 0) {
295-
dedupColTrimmed = dedupResult.lines.map((text) => {
296-
// Try to find the original ColTrimmedLine for non-summary lines
297-
const origIdx = colTexts.indexOf(text);
298-
if (origIdx !== -1) return colTrimmed[origIdx];
299-
// Summary line — encode fresh
300-
return { text, tokenCount: encode(text).length, trimmed: false, omittedChars: 0 };
301-
});
297+
298+
if (needsRowTrimming) {
299+
// Dedup to reclaim space before row trimming
300+
const colTexts = colTrimmed.map((l) => l.text);
301+
dedupResult = dedup(colTexts, minDedupLines);
302+
303+
if (dedupResult.dedupedLines > 0) {
304+
dedupColTrimmed = dedupResult.lines.map((text) => {
305+
// Try to find the original ColTrimmedLine for non-summary lines
306+
const origIdx = colTexts.indexOf(text);
307+
if (origIdx !== -1) return colTrimmed[origIdx];
308+
// Summary line — encode fresh
309+
return { text, tokenCount: encode(text).length, trimmed: false, omittedChars: 0 };
310+
});
311+
} else {
312+
dedupColTrimmed = colTrimmed;
313+
}
302314
} else {
315+
dedupResult = { lines: [], dedupedLines: 0, groupCount: 0 };
303316
dedupColTrimmed = colTrimmed;
304317
}
305318

306319
// Phase 3: Row trimming (uses column-trimmed + deduped token counts)
307-
const rowResult = trimRows(dedupColTrimmed, maxTotalTokens);
320+
const rowResult = needsRowTrimming ? trimRows(dedupColTrimmed, maxTotalTokens) : rowCheckWithoutDedup;
308321
const rowsTrimmed = rowResult.omittedLines > 0;
309322

310323
// Column-trim stats for visible lines only (after dedup + row trimming).

packages/bash-trim/test/trim.test.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,31 @@ describe("trimOutput pipeline", () => {
188188
expect(r.columnsTrimmed).toBe(true);
189189
});
190190

191+
it("skips dedup when output fits without row trimming", () => {
192+
// 10 similar lines that would be deduped, but fit well within token budget.
193+
// Dedup should NOT run since no rows need to be trimmed from the middle.
194+
const lines = Array.from(
195+
{ length: 10 },
196+
(_, i) => `2026-03-06 19:29:37.${String(800 + i).padStart(3, "0")} E kernel[0:af9] (IOSurface) SID: 0x0`,
197+
);
198+
const r = trimOutput(lines.join("\n"), { minTokensToTrim: 0 });
199+
expect(r.rowsTrimmed).toBe(false);
200+
expect(r.dedupedLines).toBe(0);
201+
expect(r.dedupGroupCount).toBe(0);
202+
// All 10 lines preserved verbatim
203+
expect(r.text).toBe(lines.join("\n"));
204+
});
205+
206+
it("applies dedup when output would need row trimming", () => {
207+
// Many similar lines that exceed token budget — dedup should kick in
208+
const lines = Array.from(
209+
{ length: 500 },
210+
(_, i) => `2026-03-06 19:29:37.${String(i).padStart(3, "0")} E kernel[0:af9] (IOSurface) SID: 0x0`,
211+
);
212+
const r = trimOutput(lines.join("\n"), { maxTotalTokens: 500 });
213+
expect(r.dedupedLines).toBeGreaterThan(0);
214+
});
215+
191216
it("column-trimmed lines count at trimmed token cost for row budget", () => {
192217
// 30 lines × 1000 chars = lots of raw tokens, but after column trimming
193218
// each line's token count drops drastically → should fit in 2K budget
@@ -273,15 +298,12 @@ describe("fixtures", () => {
273298
expect(r.dedupedLines).toBeGreaterThan(0);
274299
});
275300

276-
it("npm-ls.txt — fits with higher budget", () => {
301+
it("npm-ls.txt — fits with higher budget, dedup skipped", () => {
277302
const input = fixture("npm-ls.txt");
278303
const r = trimOutput(input, { maxTotalTokens: 10_000 });
279304
expect(r.rowsTrimmed).toBe(false);
280-
// Dedup may still collapse similar dependency lines
281-
if (r.dedupedLines > 0) {
282-
expect(r.text).not.toBe(input);
283-
} else {
284-
expect(r.text).toBe(input);
285-
}
305+
// With higher budget, no row trimming needed → dedup is skipped
306+
expect(r.dedupedLines).toBe(0);
307+
expect(r.text).toBe(input);
286308
});
287309
});

0 commit comments

Comments
 (0)