From e2817b81a736d51783f418f63a14633febfff39d Mon Sep 17 00:00:00 2001 From: Inzimam Date: Mon, 15 Jun 2026 05:20:05 +0000 Subject: [PATCH 1/2] Make redactAndTruncate line-boundary aware Replace the blunt 70%/20% head/tail char slice with structure-aware truncation (inspired by pi's harness truncate.ts): when the content has line structure (diffs, build/test logs), snap the head back to the last complete line and the tail forward to the next line start, so the critic never reasons over a line cut mid-token. The elision marker now reports both omitted chars and omitted lines. A single very long line with no boundaries falls back to the original hard char slice. Redaction still runs over the full input first, so secrets in the retained head/tail remain masked. Tests (+3): multi-line boundary truncation (all retained lines complete, middle dropped, line count reported), single-long-line char-slice fallback, and secret redaction preserved through truncation. --- src/engine/redaction.ts | 37 ++++++++++++++++++++++++++++++++----- test/redaction.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/engine/redaction.ts b/src/engine/redaction.ts index 5d3daa2..3333173 100644 --- a/src/engine/redaction.ts +++ b/src/engine/redaction.ts @@ -79,12 +79,39 @@ export function redact(input: string): string { return out; } -/** Redact and cap length so a runaway log can't blow up a critic call. */ +/** + * Redact and cap length so a runaway log can't blow up a critic call. + * + * Structure-aware (inspired by pi's harness `truncate.ts`): when the content + * has line structure — diffs, build/test output — the head is snapped back to + * the last line boundary within budget and the tail forward to the next one, so + * the critic never reasons over a line cut mid-token. The elision marker + * reports how much (chars and lines) was dropped. A single very long line with + * no boundaries falls back to a hard head/tail char slice. + */ export function redactAndTruncate(input: string, maxChars = 8_000): string { const redacted = redact(input); if (redacted.length <= maxChars) return redacted; - const head = redacted.slice(0, Math.floor(maxChars * 0.7)); - const tail = redacted.slice(-Math.floor(maxChars * 0.2)); - const omitted = redacted.length - head.length - tail.length; - return `${head}\n...[${omitted} chars truncated]...\n${tail}`; + + const headBudget = Math.floor(maxChars * 0.7); + const tailBudget = Math.floor(maxChars * 0.2); + + // Head: take the budget, then trim back to the last complete line. + let head = redacted.slice(0, headBudget); + const headNl = head.lastIndexOf("\n"); + if (headNl > 0) head = head.slice(0, headNl); + + // Tail: take the budget from the end, then trim forward to start of a line. + let tail = redacted.slice(redacted.length - tailBudget); + const tailNl = tail.indexOf("\n"); + if (tailNl >= 0 && tailNl < tail.length - 1) tail = tail.slice(tailNl + 1); + + const omittedChars = redacted.length - head.length - tail.length; + if (omittedChars <= 0) return redacted; // budgets overlapped; nothing to drop + + const omittedLines = + redacted.slice(head.length, redacted.length - tail.length).split("\n").length - 1; + const linesPart = omittedLines > 0 ? ` / ${omittedLines} lines` : ""; + + return `${head}\n...[${omittedChars} chars${linesPart} truncated]...\n${tail}`; } diff --git a/test/redaction.test.ts b/test/redaction.test.ts index b2bcd21..1de5998 100644 --- a/test/redaction.test.ts +++ b/test/redaction.test.ts @@ -40,6 +40,43 @@ describe("redaction", () => { expect(out).toContain("END"); expect(out).toContain("truncated"); }); + + it("truncates multi-line output on line boundaries, reporting omitted lines", () => { + const lines = Array.from({ length: 300 }, (_, i) => `line-${i}: ${"y".repeat(40)}`); + const out = redactAndTruncate(lines.join("\n"), 600); + + expect(out).toContain("line-0:"); // first line kept + expect(out).toContain("line-299:"); // last line kept + expect(out).toContain("lines truncated"); + expect(out).not.toContain("line-150:"); // a middle line dropped + + // Every retained line in the head/tail segments is complete (no mid-line cut). + const segments = out.split(/\n\.\.\.\[[^\]]*\]\.\.\.\n/); + expect(segments).toHaveLength(2); + for (const seg of segments) { + for (const ln of seg.split("\n").filter(Boolean)) { + expect(ln).toMatch(/^line-\d+: y{40}$/); + } + } + }); + + it("falls back to a hard char slice for a single very long line", () => { + const big = "START" + "x".repeat(5_000) + "END"; + const out = redactAndTruncate(big, 500); + expect(out).toContain("START"); + expect(out).toContain("END"); + expect(out).toContain("truncated"); + expect(out.length).toBeLessThan(1_000); + }); + + it("still redacts secrets in the retained head/tail after truncation", () => { + const text = + "OPENAI_API_KEY=sk-abcdef0123456789abcdef\n" + "log line\n".repeat(2_000) + "trailing FOO=bar"; + const out = redactAndTruncate(text, 400); + expect(out).toContain("OPENAI_API_KEY=[REDACTED]"); + expect(out).not.toContain("sk-abcdef"); + expect(out).toContain("trailing FOO=bar"); + }); }); From 85166774c5cea86384e790432fe6074c8a9ed60d Mon Sep 17 00:00:00 2001 From: Inzimam Date: Mon, 15 Jun 2026 06:52:03 +0000 Subject: [PATCH 2/2] Fix line-boundary guards to drop edge partial lines headNl > 0 left a partial line when the only newline was at position 0; the tail guard kept a partial leading line when the newline sat at the very end. Use headNl >= 0 and tailNl >= 0 so the whole-line guarantee holds in those edge cases. Addresses CodeRabbit review on PR #19. Co-authored-by: Inzimam Ul Haq <27832433+inhaq@users.noreply.github.com> --- src/engine/redaction.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/engine/redaction.ts b/src/engine/redaction.ts index 3333173..f4750f2 100644 --- a/src/engine/redaction.ts +++ b/src/engine/redaction.ts @@ -99,12 +99,12 @@ export function redactAndTruncate(input: string, maxChars = 8_000): string { // Head: take the budget, then trim back to the last complete line. let head = redacted.slice(0, headBudget); const headNl = head.lastIndexOf("\n"); - if (headNl > 0) head = head.slice(0, headNl); + if (headNl >= 0) head = head.slice(0, headNl); // Tail: take the budget from the end, then trim forward to start of a line. let tail = redacted.slice(redacted.length - tailBudget); const tailNl = tail.indexOf("\n"); - if (tailNl >= 0 && tailNl < tail.length - 1) tail = tail.slice(tailNl + 1); + if (tailNl >= 0) tail = tail.slice(tailNl + 1); const omittedChars = redacted.length - head.length - tail.length; if (omittedChars <= 0) return redacted; // budgets overlapped; nothing to drop