Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 35 additions & 15 deletions internal/cli/clamp.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,47 @@ 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
// stays faithful and --full skips it, and those are cli decisions.
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)
Expand All @@ -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
}

Expand Down
50 changes: 39 additions & 11 deletions internal/cli/clamp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand All @@ -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")
}
Expand All @@ -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")
}
}
Loading