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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ devtap captures build/dev output from a separate terminal and delivers it here v

**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.

**Output format:** when "get_build_errors" returns content, present it verbatim in a fenced code block, then add one line:
"Next action: <what you will do>".
**Output format:** when "get_build_errors" returns content:
If build succeeded, acknowledge briefly (do not repeat the output).
If build failed, present the error output verbatim in a fenced code block.
Then add one line: "Next action: <what you will do>".
<!-- devtap:end -->
2 changes: 2 additions & 0 deletions cmd/devtap/drain.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ func runDrain(cmd *cobra.Command, args []string) error {
allMessages = mcp.DedupMessages(allMessages)
}

allMessages = mcp.CollapseSuccessful(allMessages)
allMessages = mcp.TruncateMessages(allMessages, maxLines)

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

messages = mcp.CollapseSuccessful(messages)
messages = mcp.TruncateMessages(messages, maxLines)

if event == "Stop" && autoLoop {
Expand Down
6 changes: 4 additions & 2 deletions internal/adapter/instructions.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ devtap captures build/dev output from a separate terminal and delivers it here v

**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.

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

// InstructionBlockLint is the instruction block for lint-based adapters (aider).
Expand Down
32 changes: 26 additions & 6 deletions internal/filter/truncate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,44 @@ package filter

import "fmt"

// tailRatio is the fraction of the line budget allocated to the tail.
// Build errors typically appear at the end, so we keep 80% tail / 20% head.
const tailRatio = 0.8

// Truncate applies smart truncation to a list of lines:
// - If lines exceed maxLines, keeps head and tail with an omission notice in between.
// - Merges consecutive duplicate lines into "(repeated N times)" markers.
// - Merges consecutive duplicate lines into "(repeated N times)" markers.
// - If lines exceed maxLines, keeps head and tail with an omission notice in between.
// - Tail-biased: 80% of the budget goes to the tail where errors typically appear.
//
// maxLines <= 0 means no truncation.
func Truncate(lines []string, maxLines int) []string {
if maxLines <= 0 {
return dedup(lines)
}

// Budget=1: no room for head + omission marker + tail. Just keep the last line.
// Done before dedup to avoid returning a "(repeated N times)" marker.
if maxLines == 1 && len(lines) > 1 {
return lines[len(lines)-1:]
}

lines = dedup(lines)

if maxLines <= 0 || len(lines) <= maxLines {
if len(lines) <= maxLines {
return lines
}

// Keep roughly half at the head and half at the tail
head := maxLines / 2
tail := maxLines - head
tail := int(float64(maxLines) * tailRatio)
head := maxLines - tail
// Ensure at least 1 line on each side.
if tail == 0 {
tail = 1
head = maxLines - 1
}
if head == 0 {
head = 1
tail = maxLines - 1
}

omitted := len(lines) - head - tail
result := make([]string, 0, head+1+tail)
Expand Down
84 changes: 65 additions & 19 deletions internal/filter/truncate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,28 +35,81 @@ func TestTruncateWithDuplicates(t *testing.T) {
}
}

func TestTruncateTailBiased(t *testing.T) {
// 20 distinct lines, maxLines=10 → with 0.8 tail ratio: 2 head + 8 tail
lines := make([]string, 20)
for i := range lines {
lines[i] = fmt.Sprintf("line-%d", i)
}

result := Truncate(lines, 10)

// 2 head + 1 omission + 8 tail = 11 entries
if len(result) != 11 {
t.Fatalf("expected 11 lines, got %d: %v", len(result), result)
}
if result[0] != "line-0" || result[1] != "line-1" {
t.Errorf("head: got %q, %q", result[0], result[1])
}
if result[2] != "... (10 lines omitted)" {
t.Errorf("omission: got %q", result[2])
}
if result[3] != "line-12" || result[10] != "line-19" {
t.Errorf("tail: got first=%q last=%q", result[3], result[10])
}
}

func TestTruncateDistinctLines(t *testing.T) {
// 20 distinct lines should be truncated to head + omission + tail
// 20 distinct lines, maxLines=6 → with 0.8 tail ratio: 2 head + 4 tail (int(6*0.8)=4)
lines := make([]string, 20)
for i := range lines {
lines[i] = fmt.Sprintf("line-%d", i)
}

result := Truncate(lines, 6)

// Should have 3 head + 1 omission + 3 tail = 7
found := false
for _, line := range result {
if line == "... (14 lines omitted)" {
found = true
break
}
// head=2 (6-4), tail=4 → 2 head + 1 omission + 4 tail = 7
if len(result) != 7 {
t.Fatalf("expected 7 lines, got %d: %v", len(result), result)
}
if !found {
t.Errorf("expected omission marker in result: %v", result)
if result[2] != "... (14 lines omitted)" {
t.Errorf("expected omission marker, got %q", result[2])
}
if len(result) > 7 {
t.Errorf("expected <= 7 lines, got %d", len(result))
// Tail should end with the last line
if result[6] != "line-19" {
t.Errorf("last line should be line-19, got %q", result[6])
}
}

func TestTruncateSingleLine(t *testing.T) {
result := Truncate([]string{"only"}, 1)
if len(result) != 1 || result[0] != "only" {
t.Errorf("unexpected result: %v", result)
}
}

func TestTruncateSingleMax(t *testing.T) {
lines := []string{"a", "b", "c"}
result := Truncate(lines, 1)
// maxLines=1: return only the last line, no omission marker
if len(result) != 1 {
t.Fatalf("expected 1 line, got %d: %v", len(result), result)
}
if result[0] != "c" {
t.Errorf("expected last line %q, got %q", "c", result[0])
}
}

func TestTruncateSingleMaxWithDuplicates(t *testing.T) {
// maxLines=1 with repeated lines should return the last real line,
// not a "(repeated N times)" marker from dedup.
lines := []string{"error: foo", "error: foo", "error: foo"}
result := Truncate(lines, 1)
if len(result) != 1 {
t.Fatalf("expected 1 line, got %d: %v", len(result), result)
}
if result[0] != "error: foo" {
t.Errorf("expected last real line, got %q", result[0])
}
}

Expand Down Expand Up @@ -89,10 +142,3 @@ func TestDedupEmpty(t *testing.T) {
t.Errorf("expected 0 lines, got %d", len(result))
}
}

func TestTruncateSingleLine(t *testing.T) {
result := Truncate([]string{"only"}, 1)
if len(result) != 1 || result[0] != "only" {
t.Errorf("unexpected result: %v", result)
}
}
88 changes: 88 additions & 0 deletions internal/mcp/collapse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package mcp

import (
"fmt"

"github.com/killme2008/devtap/internal/store"
)

// CollapseSuccessful replaces the output of successful build runs (exit code 0)
// with a single-line summary to reduce context token consumption.
//
// A "run" is a sequence of messages sharing the same tag, bounded by an exit
// code message. When the same tag appears in multiple runs within a single
// drain window (e.g. a failed build followed by a successful rebuild), each
// run is evaluated independently — only runs with exit code 0 are collapsed.
// Runs with non-zero exit code or no exit code are returned unchanged.
func CollapseSuccessful(messages []store.LogMessage) []store.LogMessage {
type run struct {
firstIdx int
indices []int
exitCode *int
lines int
}

// Work on a copy so we can replace run heads in-place while preserving the
// original global message order.
result := make([]store.LogMessage, len(messages))
copy(result, messages)
removed := make([]bool, len(result))

// current tracks the active (not yet finalized) run per tag.
current := make(map[string]*run)

for i, msg := range result {
tag := msg.Tag
if tag == "" {
tag = "build"
}

r, exists := current[tag]
if !exists {
r = &run{firstIdx: i}
current[tag] = r
}

r.indices = append(r.indices, i)
r.lines += len(msg.Lines)
if msg.ExitCode != nil {
r.exitCode = msg.ExitCode
if *r.exitCode == 0 && r.lines > 0 {
// Collapse successful run into a single summary message placed at the
// first message position to preserve global ordering.
first := result[r.firstIdx]
result[r.firstIdx] = store.LogMessage{
Timestamp: first.Timestamp,
Tag: first.Tag,
Stream: first.Stream,
ExitCode: r.exitCode,
Adapter: first.Adapter,
Host: first.Host,
Lines: []string{collapseMessage(r.lines)},
}
for _, idx := range r.indices[1:] {
removed[idx] = true
}
}
// Finalize this run; next message for the same tag starts a new run.
delete(current, tag)
}
}

final := make([]store.LogMessage, 0, len(result))
for i, msg := range result {
if !removed[i] {
final = append(final, msg)
}
}

return final
}

func collapseMessage(lineCount int) string {
noun := "lines"
if lineCount == 1 {
noun = "line"
}
return fmt.Sprintf("(%d %s of output omitted — run succeeded)", lineCount, noun)
}
Loading