From 88c8fbceadd8efe2e2a64931eac2d232a59d2f52 Mon Sep 17 00:00:00 2001 From: Hongyi Shen Date: Mon, 20 Jul 2026 11:11:53 -0700 Subject: [PATCH] Keep assistant prose whole; clamp only user pastes hard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clamp cut every oversized entry to head 2 KiB + tail 1 KiB — right for a user-pasted log (own words at the edges, blob in the middle), exactly wrong for a generated answer whose tables, numbers, and conclusions sit in the middle. A cross-read into context lost the substance it was fetched for: on a real session, --last 1 rendered 3728 B against 4891 B of actual answer, the elided middle being the verdict tables. Split the clamp ceiling by author, the only signal used: user messages stay at 4 KiB, while assistant turns and compaction summaries render whole up to a 32 KiB policy ceiling that still catches a pathological inline blob. Default reads now match --full for any normal-sized answer. --- internal/cli/clamp.go | 50 ++++++++++++++++++++++++++------------ internal/cli/clamp_test.go | 50 +++++++++++++++++++++++++++++--------- 2 files changed, 74 insertions(+), 26 deletions(-) diff --git a/internal/cli/clamp.go b/internal/cli/clamp.go index 39a1b13..a541af8 100644 --- a/internal/cli/clamp.go +++ b/internal/cli/clamp.go @@ -8,19 +8,39 @@ import ( "github.com/wilbeibi/catchup/internal/session" ) -// Entry clamping: a single oversized entry — almost always a pasted log, -// stack trace, or blob — keeps its head and tail with one marker line -// between them. The thresholds are fixed on purpose: no tunables and no -// content classification. Position beats statistics (people put their own -// words at the edges of a paste and the blob in the middle), and a clamp is -// recoverable where a deletion is not — the marker says exactly how to get -// the full text back. +// Entry clamping: a single oversized entry keeps its head and tail with one +// marker line between them, and the clamp is recoverable where a deletion is +// not — the marker says exactly how to get the full text back. +// +// The threshold splits by who wrote the entry, because "position beats +// statistics" holds for one author and not the other. A user message is +// usually a pasted log, stack trace, or blob: the person's own words sit at +// the edges and the blob in the middle, so head+tail is the right cut, and a +// small ceiling is right. An assistant message (and a compaction summary) is +// generated prose whose tables, numbers, and conclusions sit in the *middle* — +// head+tail is exactly wrong there — so it renders whole up to a far higher +// ceiling that still catches a pathological inline blob (an echoed file, a +// dumped payload). That ceiling (32 KiB) is an intentionally chosen policy +// threshold, not a derived one: above it, an entry is assumed a dumped payload +// rather than prose. No tunables, no content sniffing: the only signal is role. const ( - clampMaxBytes = 4096 // entries at or under this render whole - clampHeadBytes = 2048 - clampTailBytes = 1024 + clampPastedMaxBytes = 4096 // user messages: pasted content, head+tail is right + clampGeneratedMaxBytes = 32768 // assistant + compaction: prose kept whole; chosen policy ceiling, not derived + clampHeadBytes = 2048 + clampTailBytes = 1024 ) +// clampMax is the byte ceiling above which an entry is clamped, chosen by +// author: only a user message carries the pasted blobs the head+tail cut was +// designed for. Everything else — assistant turns, compaction summaries — is +// generated prose and gets the high ceiling. +func clampMax(e session.Entry) int { + if e.Kind == session.KindMessage && e.Role == session.RoleUser { + return clampPastedMaxBytes + } + return clampGeneratedMaxBytes +} + // clampEntries returns t with every oversized entry reduced to head + marker // + tail. Entries are copied on first change, so the caller's thread is // never mutated. It is applied by the cli, never by the renderer: --json @@ -28,7 +48,7 @@ const ( func clampEntries(t session.Thread) session.Thread { var out []session.Entry for i, e := range t.Entries { - clamped, ok := clampText(e.Text) + clamped, ok := clampText(e.Text, clampMax(e)) if !ok { if out != nil { out = append(out, e) @@ -49,10 +69,10 @@ func clampEntries(t session.Thread) session.Thread { // clampText reduces text to its first clampHeadBytes and last clampTailBytes // around a marker naming what was elided; ok is false when text is already -// within clampMaxBytes. Cuts land on line boundaries when the window has -// any, and never split a UTF-8 rune. -func clampText(text string) (string, bool) { - if len(text) <= clampMaxBytes { +// within maxBytes. Cuts land on line boundaries when the window has any, and +// never split a UTF-8 rune. +func clampText(text string, maxBytes int) (string, bool) { + if len(text) <= maxBytes { return "", false } diff --git a/internal/cli/clamp_test.go b/internal/cli/clamp_test.go index 2e6986a..76f0e34 100644 --- a/internal/cli/clamp_test.go +++ b/internal/cli/clamp_test.go @@ -9,12 +9,12 @@ import ( ) func TestClampText(t *testing.T) { - if _, ok := clampText(strings.Repeat("a", clampMaxBytes)); ok { + if _, ok := clampText(strings.Repeat("a", clampPastedMaxBytes), clampPastedMaxBytes); ok { t.Fatal("text at the threshold must not clamp") } text := "HEAD first line\n" + strings.Repeat("middle filler line\n", 400) + "TAIL last line" - got, ok := clampText(text) + got, ok := clampText(text, clampPastedMaxBytes) if !ok { t.Fatalf("%d-byte text should clamp", len(text)) } @@ -34,7 +34,7 @@ func TestClampText(t *testing.T) { // A single giant line (a blob with no newlines) still clamps, and the // cuts never split a multi-byte rune. blob := strings.Repeat("é", 4000) // 8000 bytes, zero newlines - got, ok = clampText(blob) + got, ok = clampText(blob, clampPastedMaxBytes) if !ok { t.Fatal("newline-free blob should clamp") } @@ -43,21 +43,49 @@ func TestClampText(t *testing.T) { } } +func TestClampMax(t *testing.T) { + assistant := session.Entry{Kind: session.KindMessage, Role: session.RoleAssistant} + user := session.Entry{Kind: session.KindMessage, Role: session.RoleUser} + compact := session.Entry{Kind: session.KindCompact} + + if got := clampMax(user); got != clampPastedMaxBytes { + t.Errorf("user message: got ceiling %d, want %d", got, clampPastedMaxBytes) + } + // Assistant turns and compaction summaries are generated prose: the high + // ceiling, so a mid-sized analysis with tables in its middle renders whole. + if got := clampMax(assistant); got != clampGeneratedMaxBytes { + t.Errorf("assistant message: got ceiling %d, want %d", got, clampGeneratedMaxBytes) + } + if got := clampMax(compact); got != clampGeneratedMaxBytes { + t.Errorf("compaction summary: got ceiling %d, want %d", got, clampGeneratedMaxBytes) + } +} + func TestClampEntries(t *testing.T) { - big := strings.Repeat("x\n", 3000) // 6000 bytes + bigUser := strings.Repeat("x\n", 3000) // 6000 bytes, over the pasted ceiling + proseAsst := strings.Repeat("word ", 1400) // 7000 bytes: over 4KB, under 32KB + blobAsst := strings.Repeat("y\n", 20000) // 40000 bytes, over the generated ceiling thread := session.Thread{Entries: []session.Entry{ - {Kind: session.KindMessage, Role: session.RoleUser, Text: big}, - {Kind: session.KindMessage, Role: session.RoleAssistant, Text: "small reply"}, + {Kind: session.KindMessage, Role: session.RoleUser, Text: bigUser}, + {Kind: session.KindMessage, Role: session.RoleAssistant, Text: proseAsst}, + {Kind: session.KindMessage, Role: session.RoleAssistant, Text: blobAsst}, }} got := clampEntries(thread) - if got.Entries[0].Text == big || !strings.Contains(got.Entries[0].Text, "elided") { - t.Error("oversized entry was not clamped") + if got.Entries[0].Text == bigUser || !strings.Contains(got.Entries[0].Text, "elided") { + t.Error("oversized user entry was not clamped") + } + // The whole point of the role split: a mid-sized assistant analysis, whose + // tables and conclusions sit in the middle, now renders whole instead of + // losing its center to a head+tail cut. + if got.Entries[1].Text != proseAsst { + t.Error("mid-sized assistant entry was clamped; it should render whole") } - if got.Entries[1].Text != "small reply" { - t.Errorf("small entry changed: %q", got.Entries[1].Text) + // A pathological assistant blob past the high ceiling still clamps. + if got.Entries[2].Text == blobAsst || !strings.Contains(got.Entries[2].Text, "elided") { + t.Error("oversized assistant blob was not clamped") } - if thread.Entries[0].Text != big { + if thread.Entries[0].Text != bigUser { t.Error("clampEntries mutated the caller's thread") } }