V2/token optimization (#11)

* v2.0: token-optimization overhaul

Additive (backward-compatible flags):
- file_read: skeleton mode, strip (imports/license/block_comments),
  compact_line_numbers, 8-char etag with prefix-match compat
- ast_query: format=verbose|compact|location, pagination cursor
- file_search: cluster mode, pagination cursor
- lsp_query (references): compact output

Breaking (v2):
- Preambles removed; opt-in verbose=true restores
- edit_apply: response=count|diff|none, default count
- ping tool removed
- symbol_at/find_definition/find_references merged into lsp_query
- Tool descriptions trimmed -83%, help moved to filepuff://help/<tool>
- Batch file_read dedups by etag

Protocol:
- ResourceLink returned for file_read >64 KiB (force_inline override)
- OnAfterInitialize hook reads capabilities.experimental.filepuff
  for session defaults (default_format, default_max_results,
  default_cluster, compact_refs, line_numbers,
  resource_link_threshold)

* fix: drop --max-total-count from ripgrep args

The flag does not exist in stable ripgrep (confirmed up to 15.1.0 --
"unrecognized flag --max-total-count, similar flags that are
available: --max-count"). Every file_search call failed on hosts with
stock rg. --max-count is per-file, not a drop-in replacement, so rely
on the in-process truncation in parseOutput that was already the
documented safety net.
This commit is contained in:
2026-04-19 19:56:49 +01:00
committed by GitHub
parent b131c1edd3
commit 5ad975ee7a
26 changed files with 4909 additions and 507 deletions
+131
View File
@@ -0,0 +1,131 @@
package search
import (
"strings"
"testing"
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
"log/slog"
"os"
)
func newTestSearcher(t *testing.T) *Searcher {
t.Helper()
cfg := &config.Config{
WorkspaceRoot: t.TempDir(),
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
// Create a Searcher directly without requiring rg
return &Searcher{cfg: cfg, logger: logger, rgPath: "rg"}
}
func makeSearchResults(lines []int) *SearchResults {
results := make([]Result, len(lines))
for i, l := range lines {
results[i] = Result{
File: "/workspace/foo.go",
Line: l,
MatchText: "match at line " + itoa2(l),
}
}
return &SearchResults{Results: results}
}
// itoa2 simple int to string for test
func itoa2(n int) string {
if n == 0 {
return "0"
}
digits := []byte{}
for n > 0 {
digits = append([]byte{byte('0' + n%10)}, digits...)
n /= 10
}
return string(digits)
}
// TestFormatResultsVerboseBackwardCompat verifies that Verbose=true restores the v1 preamble.
func TestFormatResultsVerboseBackwardCompat(t *testing.T) {
s := newTestSearcher(t)
sr := makeSearchResults([]int{1, 5, 10})
// With Verbose=true, the preamble is emitted (v1 behaviour).
out := s.FormatResultsWithOptions(sr, FormatOptions{Verbose: true})
if !strings.Contains(out, "Found 3 matches in 1 files") {
t.Errorf("expected header with Verbose=true, got:\n%s", out)
}
if !strings.Contains(out, "L1│") {
t.Errorf("expected L1 match line, got:\n%s", out)
}
}
// TestFormatResultsDefaultNoPreamble verifies the v2 default has no preamble.
func TestFormatResultsDefaultNoPreamble(t *testing.T) {
s := newTestSearcher(t)
sr := makeSearchResults([]int{1, 5, 10})
out := s.FormatResults(sr)
if strings.Contains(out, "Found ") {
t.Errorf("v2 default should NOT emit preamble, got:\n%s", out)
}
if !strings.Contains(out, "L1│") {
t.Errorf("expected L1 match line, got:\n%s", out)
}
}
func TestFormatResultsClusterSingleLine(t *testing.T) {
s := newTestSearcher(t)
// Non-consecutive lines → each should appear separately
sr := makeSearchResults([]int{1, 5, 10})
out := s.FormatResultsWithOptions(sr, FormatOptions{Cluster: true})
if !strings.Contains(out, "L1│") {
t.Errorf("expected L1 cluster, got:\n%s", out)
}
if !strings.Contains(out, "L5│") {
t.Errorf("expected L5 cluster, got:\n%s", out)
}
// Should NOT have " │" context lines in cluster mode
if strings.Contains(out, " │") {
t.Errorf("cluster mode should not have context-line decoration, got:\n%s", out)
}
}
func TestFormatResultsClusterConsecutive(t *testing.T) {
s := newTestSearcher(t)
// Lines 3,4,5 are consecutive → should be clustered as L3-5
sr := makeSearchResults([]int{3, 4, 5, 10})
out := s.FormatResultsWithOptions(sr, FormatOptions{Cluster: true})
if !strings.Contains(out, "L3-5│") {
t.Errorf("expected L3-5 cluster, got:\n%s", out)
}
if !strings.Contains(out, "L10│") {
t.Errorf("expected L10 separate cluster, got:\n%s", out)
}
}
func TestFormatResultsClusterAdjacentMerge(t *testing.T) {
s := newTestSearcher(t)
// Lines 7 and 8 differ by 1 → merge
sr := makeSearchResults([]int{7, 8})
out := s.FormatResultsWithOptions(sr, FormatOptions{Cluster: true})
if !strings.Contains(out, "L7-8│") {
t.Errorf("expected L7-8 cluster, got:\n%s", out)
}
}
func TestFormatResultsCursorLine(t *testing.T) {
s := newTestSearcher(t)
sr := makeSearchResults([]int{1})
cursorText := "[cursor: abc123, remaining: 5]"
out := s.FormatResultsWithOptions(sr, FormatOptions{CursorLine: cursorText})
if !strings.Contains(out, cursorText) {
t.Errorf("expected cursor footer in output, got:\n%s", out)
}
}
func TestFormatResultsNoMatchesVerbose(t *testing.T) {
s := newTestSearcher(t)
sr := &SearchResults{Results: nil}
out := s.FormatResults(sr)
if out != "No matches found." {
t.Errorf("expected 'No matches found.', got: %s", out)
}
}
+97 -26
View File
@@ -219,11 +219,9 @@ func (s *Searcher) buildArgs(req *Request) []string {
args = append(args, "--no-ignore")
}
// Global result cap — --max-total-count stops rg early across all files.
// Requires ripgrep >= 13.0. In-process truncation in parseOutput is kept as a safety net.
if req.MaxResults > 0 {
args = append(args, fmt.Sprintf("--max-total-count=%d", req.MaxResults))
}
// Result cap enforced in-process by parseOutput. rg has no cross-file
// total-count flag in stable releases, so we don't pass one; --max-count is
// per-file and would miss results unevenly.
// Add pattern
args = append(args, "--", req.Pattern)
@@ -356,8 +354,21 @@ func (s *Searcher) parseOutput(output *bytes.Buffer, maxResults int) (*SearchRes
return results, nil
}
// FormatResults formats search results for display.
// FormatOptions controls how search results are rendered.
type FormatOptions struct {
Cluster bool // coalesce consecutive matches into line-range blocks
CursorLine string // if non-empty, appended as a footer line
Verbose bool // if true, emit "Found N matches in M files:" preamble (opt-in)
}
// FormatResults formats search results for display (backward-compat wrapper).
func (s *Searcher) FormatResults(results *SearchResults) string {
return s.FormatResultsWithOptions(results, FormatOptions{})
}
// FormatResultsWithOptions formats search results with configurable output.
// By default the "Found N matches in M files:" preamble is omitted; set opts.Verbose=true to restore it.
func (s *Searcher) FormatResultsWithOptions(results *SearchResults, opts FormatOptions) string {
if len(results.Results) == 0 {
return "No matches found."
}
@@ -374,14 +385,18 @@ func (s *Searcher) FormatResults(results *SearchResults) string {
fileResults[r.File] = append(fileResults[r.File], r)
}
// Write summary
totalMatches := len(results.Results)
fileCount := len(fileResults)
sb.WriteString(fmt.Sprintf("Found %d matches in %d files", totalMatches, fileCount))
if results.Truncated {
sb.WriteString(fmt.Sprintf(" (truncated, total: %d)", results.Total))
// Write preamble only when Verbose is requested.
if opts.Verbose {
totalMatches := len(results.Results)
fileCount := len(fileResults)
sb.WriteString(fmt.Sprintf("Found %d matches in %d files", totalMatches, fileCount))
if results.Truncated {
sb.WriteString(fmt.Sprintf(" (truncated, total: %d)", results.Total))
}
sb.WriteString(":\n\n")
} else if results.Truncated {
sb.WriteString(fmt.Sprintf("(truncated, showing subset of %d total matches)\n\n", results.Total))
}
sb.WriteString(":\n\n")
// Write results grouped by file
for _, file := range fileOrder {
@@ -395,26 +410,82 @@ func (s *Searcher) FormatResults(results *SearchResults) string {
sb.WriteString(fmt.Sprintf("**%s**\n", relPath))
for _, r := range fileResults[file] {
// Write context before
for _, ctx := range r.Context.Before {
sb.WriteString(fmt.Sprintf(" │ %s\n", truncateLine(ctx, 200)))
}
// Write match line
sb.WriteString(fmt.Sprintf("L%d│ %s\n", r.Line, truncateLine(r.MatchText, 200)))
// Write context after
for _, ctx := range r.Context.After {
sb.WriteString(fmt.Sprintf(" │ %s\n", truncateLine(ctx, 200)))
}
if opts.Cluster {
writeClusteredResults(&sb, fileResults[file])
} else {
writeVerboseResults(&sb, fileResults[file])
}
sb.WriteString("\n")
}
if opts.CursorLine != "" {
sb.WriteString(opts.CursorLine)
sb.WriteString("\n")
}
return sb.String()
}
// writeVerboseResults writes results in the standard verbose format.
func writeVerboseResults(sb *strings.Builder, results []Result) {
for _, r := range results {
// Write context before
for _, ctx := range r.Context.Before {
fmt.Fprintf(sb, " │ %s\n", truncateLine(ctx, 200))
}
// Write match line
fmt.Fprintf(sb, "L%d│ %s\n", r.Line, truncateLine(r.MatchText, 200))
// Write context after
for _, ctx := range r.Context.After {
fmt.Fprintf(sb, " │ %s\n", truncateLine(ctx, 200))
}
}
}
// writeClusteredResults coalesces consecutive or adjacent match lines into
// a single "L12-14│ <first-match-text>" entry. Context lines are dropped
// in cluster mode to maximise information density.
func writeClusteredResults(sb *strings.Builder, results []Result) {
if len(results) == 0 {
return
}
type clusterEntry struct {
startLine int
endLine int
firstText string
}
var clusters []clusterEntry
cur := clusterEntry{
startLine: results[0].Line,
endLine: results[0].Line,
firstText: results[0].MatchText,
}
for _, r := range results[1:] {
// Merge if adjacent (within 1 line gap)
if r.Line <= cur.endLine+1 {
if r.Line > cur.endLine {
cur.endLine = r.Line
}
} else {
clusters = append(clusters, cur)
cur = clusterEntry{startLine: r.Line, endLine: r.Line, firstText: r.MatchText}
}
}
clusters = append(clusters, cur)
for _, c := range clusters {
text := truncateLine(c.firstText, 200)
if c.startLine == c.endLine {
fmt.Fprintf(sb, "L%d│ %s\n", c.startLine, text)
} else {
fmt.Fprintf(sb, "L%d-%d│ %s\n", c.startLine, c.endLine, text)
}
}
}
// truncateLine truncates a line if it exceeds maxLen.
func truncateLine(s string, maxLen int) string {
if len(s) <= maxLen {
+2 -2
View File
@@ -94,8 +94,8 @@ func TestBuildArgs(t *testing.T) {
MaxResults: 10,
Regex: true,
},
expected: []string{"--json", "--max-total-count=10", "--", "test", "."},
notExpected: []string{"--ignore-case", "--fixed-strings"},
expected: []string{"--json", "--", "test", "."},
notExpected: []string{"--ignore-case", "--fixed-strings", "--max-total-count=10", "--max-count=10"},
},
}