Skip to content

Commit 335b5a8

Browse files
killme2008claude
andcommitted
feat: reduce AI context token consumption by collapsing successful builds, using tail-biased truncation, and relaxing verbatim output instructions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ff96f6e commit 335b5a8

File tree

10 files changed

+477
-20
lines changed

10 files changed

+477
-20
lines changed

CLAUDE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ devtap captures build/dev output from a separate terminal and delivers it here v
8989

9090
**Multi-source mode:** when devtap drains from multiple sources, tags are prefixed with "host/label |" (for example, `[devtap: myhost/local | make]`). "host" is the machine name, "label" identifies the source. Show these prefixes as-is. If output includes source warnings (for example, source unreachable), show those warnings verbatim and continue with output from reachable sources.
9191

92-
**Output format:** when "get_build_errors" returns content, present it verbatim in a fenced code block, then add one line:
93-
"Next action: <what you will do>".
92+
**Output format:** when "get_build_errors" returns content:
93+
If build succeeded, acknowledge briefly (do not repeat the output).
94+
If build failed, present the error output verbatim in a fenced code block.
95+
Then add one line: "Next action: <what you will do>".
9496
<!-- devtap:end -->

cmd/devtap/drain.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ func runDrain(cmd *cobra.Command, args []string) error {
110110
allMessages = mcp.DedupMessages(allMessages)
111111
}
112112

113+
allMessages = mcp.CollapseSuccessful(allMessages)
113114
allMessages = mcp.TruncateMessages(allMessages, maxLines)
114115

115116
// Handle auto-loop Stop hook (Claude Code specific)
@@ -179,6 +180,7 @@ func runDrainSingleSource(cmd *cobra.Command, filterSQL string, maxLines int, ev
179180
return fmt.Errorf("drain: %w", err)
180181
}
181182

183+
messages = mcp.CollapseSuccessful(messages)
182184
messages = mcp.TruncateMessages(messages, maxLines)
183185

184186
if event == "Stop" && autoLoop {

internal/adapter/instructions.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ devtap captures build/dev output from a separate terminal and delivers it here v
2929
3030
**Multi-source mode:** when devtap drains from multiple sources, tags are prefixed with "host/label |" (for example, "[devtap: myhost/local | make]"). "host" is the machine name, "label" identifies the source. Show these prefixes as-is. If output includes source warnings (for example, source unreachable), show those warnings verbatim and continue with output from reachable sources.
3131
32-
**Output format:** when "get_build_errors" returns content, present it verbatim in a fenced code block, then add one line:
33-
"Next action: <what you will do>".
32+
**Output format:** when "get_build_errors" returns content:
33+
If build succeeded, acknowledge briefly (do not repeat the output).
34+
If build failed, present the error output verbatim in a fenced code block.
35+
Then add one line: "Next action: <what you will do>".
3436
<!-- devtap:end -->`
3537

3638
// InstructionBlockLint is the instruction block for lint-based adapters (aider).

internal/filter/truncate.go

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,45 @@ package filter
22

33
import "fmt"
44

5-
// Truncate applies smart truncation to a list of lines:
6-
// - If lines exceed maxLines, keeps head and tail with an omission notice in between.
7-
// - Merges consecutive duplicate lines into "(repeated N times)" markers.
8-
// maxLines <= 0 means no truncation.
5+
// Truncate applies smart truncation with a 50/50 head/tail split.
6+
// See TruncateWithRatio for details.
97
func Truncate(lines []string, maxLines int) []string {
8+
return TruncateWithRatio(lines, maxLines, 0.5)
9+
}
10+
11+
// TruncateWithRatio applies smart truncation to a list of lines:
12+
// - Merges consecutive duplicate lines into "(repeated N times)" markers.
13+
// - If lines exceed maxLines, keeps head and tail with an omission notice in between.
14+
// - tailRatio controls what fraction of the budget goes to the tail (0.0–1.0).
15+
// For build output where errors appear at the end, use tailRatio=0.8.
16+
//
17+
// maxLines <= 0 means no truncation.
18+
func TruncateWithRatio(lines []string, maxLines int, tailRatio float64) []string {
1019
lines = dedup(lines)
1120

1221
if maxLines <= 0 || len(lines) <= maxLines {
1322
return lines
1423
}
1524

16-
// Keep roughly half at the head and half at the tail
17-
head := maxLines / 2
18-
tail := maxLines - head
19-
if tail == 0 {
25+
// Budget=1: no room for head + omission marker + tail. Just keep the last line.
26+
if maxLines == 1 {
27+
return lines[len(lines)-1:]
28+
}
29+
30+
tail := int(float64(maxLines) * tailRatio)
31+
if tail > maxLines {
32+
tail = maxLines
33+
}
34+
head := maxLines - tail
35+
// Ensure at least 1 line on each side when budget allows.
36+
if tail == 0 && maxLines > 1 {
2037
tail = 1
2138
head = maxLines - 1
2239
}
40+
if head == 0 && maxLines > 1 {
41+
head = 1
42+
tail = maxLines - 1
43+
}
2344

2445
omitted := len(lines) - head - tail
2546
result := make([]string, 0, head+1+tail)

internal/filter/truncate_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,99 @@ func TestTruncateSingleLine(t *testing.T) {
9696
t.Errorf("unexpected result: %v", result)
9797
}
9898
}
99+
100+
func TestTruncateWithRatio_TailBiased(t *testing.T) {
101+
// 20 distinct lines, maxLines=10, tailRatio=0.8 → 2 head + 8 tail
102+
lines := make([]string, 20)
103+
for i := range lines {
104+
lines[i] = fmt.Sprintf("line-%d", i)
105+
}
106+
107+
result := TruncateWithRatio(lines, 10, 0.8)
108+
109+
// 2 head + 1 omission + 8 tail = 11 entries
110+
if len(result) != 11 {
111+
t.Fatalf("expected 11 lines, got %d: %v", len(result), result)
112+
}
113+
// First 2 should be head
114+
if result[0] != "line-0" || result[1] != "line-1" {
115+
t.Errorf("head: got %q, %q", result[0], result[1])
116+
}
117+
// Omission marker
118+
if result[2] != "... (10 lines omitted)" {
119+
t.Errorf("omission: got %q", result[2])
120+
}
121+
// Last 8 should be tail
122+
if result[3] != "line-12" || result[10] != "line-19" {
123+
t.Errorf("tail: got first=%q last=%q", result[3], result[10])
124+
}
125+
}
126+
127+
func TestTruncateWithRatio_AllHead(t *testing.T) {
128+
lines := make([]string, 10)
129+
for i := range lines {
130+
lines[i] = fmt.Sprintf("line-%d", i)
131+
}
132+
133+
// tailRatio=0.0 → still gets at least 1 tail line
134+
result := TruncateWithRatio(lines, 4, 0.0)
135+
// 3 head + 1 omission + 1 tail = 5
136+
if len(result) != 5 {
137+
t.Fatalf("expected 5 lines, got %d: %v", len(result), result)
138+
}
139+
if result[4] != "line-9" {
140+
t.Errorf("last line should be tail, got %q", result[4])
141+
}
142+
}
143+
144+
func TestTruncateWithRatio_AllTail(t *testing.T) {
145+
lines := make([]string, 10)
146+
for i := range lines {
147+
lines[i] = fmt.Sprintf("line-%d", i)
148+
}
149+
150+
// tailRatio=1.0 → still gets at least 1 head line
151+
result := TruncateWithRatio(lines, 4, 1.0)
152+
// 1 head + 1 omission + 3 tail = 5
153+
if len(result) != 5 {
154+
t.Fatalf("expected 5 lines, got %d: %v", len(result), result)
155+
}
156+
if result[0] != "line-0" {
157+
t.Errorf("first line should be head, got %q", result[0])
158+
}
159+
if result[4] != "line-9" {
160+
t.Errorf("last line should be tail, got %q", result[4])
161+
}
162+
}
163+
164+
func TestTruncateWithRatio_NoTruncationNeeded(t *testing.T) {
165+
lines := []string{"a", "b", "c"}
166+
result := TruncateWithRatio(lines, 10, 0.8)
167+
if len(result) != 3 {
168+
t.Errorf("expected 3 lines (no truncation), got %d", len(result))
169+
}
170+
}
171+
172+
func TestTruncateWithRatio_SingleMax(t *testing.T) {
173+
lines := []string{"a", "b", "c"}
174+
result := TruncateWithRatio(lines, 1, 0.8)
175+
// maxLines=1: return only the last line, no omission marker
176+
if len(result) != 1 {
177+
t.Fatalf("expected 1 line, got %d: %v", len(result), result)
178+
}
179+
if result[0] != "c" {
180+
t.Errorf("expected last line %q, got %q", "c", result[0])
181+
}
182+
}
183+
184+
func TestTruncateSingleMax_Legacy(t *testing.T) {
185+
// Truncate (50/50 ratio) with maxLines=1 should also return exactly 1 line.
186+
lines := []string{"x", "y", "z"}
187+
result := Truncate(lines, 1)
188+
if len(result) != 1 {
189+
t.Fatalf("expected 1 line, got %d: %v", len(result), result)
190+
}
191+
if result[0] != "z" {
192+
t.Errorf("expected last line %q, got %q", "z", result[0])
193+
}
194+
}

internal/mcp/collapse.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package mcp
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/killme2008/devtap/internal/store"
7+
)
8+
9+
// CollapseSuccessful replaces the output of successful build runs (exit code 0)
10+
// with a single-line summary to reduce context token consumption.
11+
//
12+
// A "run" is a sequence of messages sharing the same tag, bounded by an exit
13+
// code message. When the same tag appears in multiple runs within a single
14+
// drain window (e.g. a failed build followed by a successful rebuild), each
15+
// run is evaluated independently — only runs with exit code 0 are collapsed.
16+
// Runs with non-zero exit code or no exit code are returned unchanged.
17+
func CollapseSuccessful(messages []store.LogMessage) []store.LogMessage {
18+
type run struct {
19+
firstIdx int
20+
indices []int
21+
exitCode *int
22+
lines int
23+
}
24+
25+
// Work on a copy so we can replace run heads in-place while preserving the
26+
// original global message order.
27+
result := make([]store.LogMessage, len(messages))
28+
copy(result, messages)
29+
removed := make([]bool, len(result))
30+
31+
// current tracks the active (not yet finalized) run per tag.
32+
current := make(map[string]*run)
33+
34+
for i, msg := range result {
35+
tag := msg.Tag
36+
if tag == "" {
37+
tag = "build"
38+
}
39+
40+
r, exists := current[tag]
41+
if !exists {
42+
r = &run{firstIdx: i}
43+
current[tag] = r
44+
}
45+
46+
r.indices = append(r.indices, i)
47+
r.lines += len(msg.Lines)
48+
if msg.ExitCode != nil {
49+
r.exitCode = msg.ExitCode
50+
if *r.exitCode == 0 && r.lines > 0 {
51+
// Collapse successful run into a single summary message placed at the
52+
// first message position to preserve global ordering.
53+
first := result[r.firstIdx]
54+
result[r.firstIdx] = store.LogMessage{
55+
Timestamp: first.Timestamp,
56+
Tag: first.Tag,
57+
Stream: first.Stream,
58+
ExitCode: r.exitCode,
59+
Adapter: first.Adapter,
60+
Host: first.Host,
61+
Lines: []string{collapseMessage(r.lines)},
62+
}
63+
for _, idx := range r.indices[1:] {
64+
removed[idx] = true
65+
}
66+
}
67+
// Finalize this run; next message for the same tag starts a new run.
68+
delete(current, tag)
69+
}
70+
}
71+
72+
final := make([]store.LogMessage, 0, len(result))
73+
for i, msg := range result {
74+
if !removed[i] {
75+
final = append(final, msg)
76+
}
77+
}
78+
79+
return final
80+
}
81+
82+
func collapseMessage(lineCount int) string {
83+
noun := "lines"
84+
if lineCount == 1 {
85+
noun = "line"
86+
}
87+
return fmt.Sprintf("(%d %s of output omitted — build succeeded)", lineCount, noun)
88+
}

0 commit comments

Comments
 (0)