File Coverage

lib/MCP/Run/Compress.pm
Criterion Covered Total %
statement 110 136 80.8
branch 30 50 60.0
condition 25 36 69.4
subroutine 8 8 100.0
pod 1 3 33.3
total 174 233 74.6


line stmt bran cond sub pod time code
1             package MCP::Run::Compress;
2             our $VERSION = '0.003';
3 1     1   84885 use Mojo::Base -base;
  1         8411  
  1         3  
4              
5             # ABSTRACT: Output compression for LLMs
6              
7              
8 1     1   745 use Text::Trim qw(trim);
  1         535  
  1         56  
9 1     1   5 use List::Util qw(max min);
  1         1  
  1         5493  
10              
11             has filters => sub { +{} };
12              
13             sub register_filter {
14 306     306 0 225 my $self = shift;
15 306         476 my %args = @_;
16              
17 306         280 my $command = $args{command};
18 306         250 delete $args{command};
19              
20             $self->filters->{$command} = {
21             strip_ansi => $args{strip_ansi} // 0,
22             strip_lines_matching => $args{strip_lines_matching} // [],
23             keep_lines_matching => $args{keep_lines_matching} // [],
24             truncate_lines_at => $args{truncate_lines_at} // 0,
25             max_lines => $args{max_lines} // 0,
26             tail_lines => $args{tail_lines} // 0,
27             head_lines => $args{head_lines} // 0,
28             on_empty => $args{on_empty} // '',
29             replace => $args{replace} // [],
30             match_output => $args{match_output} // [],
31             filter_stderr => $args{filter_stderr} // 0,
32             output_detect => $args{output_detect} // undef,
33             transform => $args{transform} // undef,
34 306   50     3426 };
      100        
      50        
      100        
      50        
      100        
      100        
      100        
      50        
      100        
      100        
      100        
      100        
35              
36 306         1002 return;
37             }
38              
39             sub _build_default_filters {
40 9     9   9 my $self = shift;
41              
42             # ls: ultra-compact for long listing - only keep type + filename
43             $self->register_filter(
44             command => '^ls\b',
45             output_detect => qr(^[d-][rwx-]{9}.*\s+\d+\s+),
46             strip_lines_matching => [
47             qr(^\s*$),
48             qr(^total\s+\d+),
49             qr(^\s*Device:),
50             qr(^\s*Inode:),
51             qr(^\s*Birth:),
52             qr(^/node_modules/),
53             qr(^/\.git/),
54             qr(^/\.target/),
55             qr(^/\.next/),
56             qr(^/\.nuxt/),
57             qr(^/\.cache/),
58             qr(^/__pycache__/),
59             qr(^/\.DS_Store/),
60             qr(^/vendor/bundle/),
61             ],
62             transform => sub {
63 9     9   8 my ($line) = @_;
64 9 100       22 if ($line =~ m{^([d-])[rwx-]{9}\s+\d+\s+\S+\s+\S+\s+\d+\s+\w+\s+\d+\s+[\d:]+\s+(.+)$}) {
65 6         13 return $1 . " " . $2;
66             }
67 3         5 return $line;
68             },
69 9         161 truncate_lines_at => 100,
70             max_lines => 50,
71             );
72              
73             # stat: strip device/inode/birth (Linux)
74 9         39 $self->register_filter(
75             command => '^stat\b',
76             strip_lines_matching => [
77             qr(^\s*$),
78             qr(^\s*Device:),
79             qr(^\s*Inode:),
80             qr(^\s*Birth:),
81             ],
82             truncate_lines_at => 120,
83             max_lines => 20,
84             );
85              
86             # grep: truncate lines, group by file
87 9         15 $self->register_filter(
88             command => '^grep\b',
89             truncate_lines_at => 150,
90             max_lines => 100,
91             );
92              
93             # make: strip entering/leaving directory
94 9         69 $self->register_filter(
95             command => '^make\b',
96             strip_lines_matching => [
97             qr(^\s*$),
98             qr(^make\[\d+\]:),
99             qr(^Entering directory),
100             qr(^Leaving directory),
101             qr(Nothing to be done),
102             qr(^make:\s+Nothing to be done),
103             ],
104             match_output => [
105             { pattern => qr(^make\[\d+\]:.*?make\[\d+\]:), message => 'make: circular dependency detected' },
106             { pattern => qr(^gcc.*?Error), message => 'make: compilation error' },
107             ],
108             max_lines => 50,
109             on_empty => 'make: ok',
110             );
111              
112             # git status: compact output
113 9         45 $self->register_filter(
114             command => '^git\s+status\b',
115             strip_lines_matching => [
116             qr(^\s*$),
117             qr(^On branch),
118             qr(^Your branch is),
119             qr(^Initial commit),
120             ],
121             max_lines => 30,
122             );
123              
124             # git diff: keep only diff content
125 9         38 $self->register_filter(
126             command => '^git\s+diff\b',
127             strip_lines_matching => [
128             qr(^\s*$),
129             qr(^diff --git),
130             qr(^index ),
131             qr(^---\s+a/),
132             qr(^\+\+\+\s+b/),
133             ],
134             truncate_lines_at => 150,
135             max_lines => 200,
136             );
137              
138             # cat: detect and filter code
139 9         21 $self->register_filter(
140             command => '^cat\b',
141             strip_lines_matching => [qr(^\s*$)],
142             truncate_lines_at => 500,
143             max_lines => 100,
144             );
145              
146             # find: strip permission denied, limit results
147 9         28 $self->register_filter(
148             command => '^find\b',
149             strip_lines_matching => [
150             qr(^\s*$),
151             qr(^find:.*permission denied),
152             ],
153             max_lines => 50,
154             );
155              
156             # ps: compact output
157 9         25 $self->register_filter(
158             command => '^ps\b',
159             strip_lines_matching => [
160             qr(^\s*$),
161             ],
162             truncate_lines_at => 120,
163             max_lines => 30,
164             );
165              
166             # df: truncate wide columns
167 9         20 $self->register_filter(
168             command => '^df\b',
169             strip_lines_matching => [qr(^\s*$)],
170             truncate_lines_at => 80,
171             max_lines => 20,
172             );
173              
174             # docker ps: compact output
175 9         20 $self->register_filter(
176             command => '^docker\s+(ps|images)\b',
177             strip_lines_matching => [qr(^\s*$)],
178             truncate_lines_at => 120,
179             max_lines => 30,
180             );
181              
182             # terraform plan: strip refresh progress
183 9         35 $self->register_filter(
184             command => '^terraform\s+plan\b',
185             strip_lines_matching => [
186             qr(^\s*$),
187             qr(^Refreshing state\.\.\.),
188             qr(^Terraform used the),
189             qr(^tfe-outputs:),
190             ],
191             max_lines => 100,
192             );
193              
194             # terraform apply: strip progress
195 9         43 $self->register_filter(
196             command => '^terraform\s+apply\b',
197             strip_lines_matching => [
198             qr(^\s*$),
199             qr(^Refreshing state\.\.\.),
200             qr(^Terraform will perform),
201             qr(^Proceeding with the following),
202             qr(^tfe-outputs:),
203             ],
204             max_lines => 100,
205             );
206              
207             # docker build: strip build progress
208 9         29 $self->register_filter(
209             command => '^docker\s+build\b',
210             strip_lines_matching => [
211             qr(^\s*$),
212             qr(^#\s*\d+\s+\[\s*\d+\s+/\s*\d+\]),
213             qr(^Step \d+/\d+:),
214             ],
215             max_lines => 50,
216             );
217              
218             # docker run: compact output
219 9         58 $self->register_filter(
220             command => '^docker\s+run\b',
221             strip_lines_matching => [
222             qr(^\s*$),
223             qr(^Unable to find image),
224             qr(^Pulling from library/),
225             qr(^Digest:),
226             qr(^Status:),
227             ],
228             max_lines => 30,
229             );
230              
231             # kubectl get: compact output
232 9         20 $self->register_filter(
233             command => '^kubectl\s+get\b',
234             strip_lines_matching => [qr(^\s*$)],
235             truncate_lines_at => 150,
236             max_lines => 50,
237             );
238              
239             # kubectl describe: strip noise
240 9         93 $self->register_filter(
241             command => '^kubectl\s+describe\b',
242             strip_lines_matching => [
243             qr(^\s*$),
244             qr(^Name:\s+\w+),
245             qr(^Namespace:\s+\w+),
246             qr(^Labels:\s*$),
247             qr(^Annotations:\s*$/),
248             ],
249             truncate_lines_at => 200,
250             max_lines => 100,
251             );
252              
253             # cargo build: strip compile progress
254 9         35 $self->register_filter(
255             command => '^cargo\s+build\b',
256             strip_lines_matching => [
257             qr(^\s*$),
258             qr(^Compiling\s+\w+),
259             qr(^Fresh\s+\w+),
260             qr(^Finished\s+),
261             ],
262             max_lines => 50,
263             );
264              
265             # cargo test: compact output
266 9         79 $self->register_filter(
267             command => '^cargo\s+test\b',
268             strip_lines_matching => [
269             qr(^\s*$),
270             qr(^Compiling\s+\w+),
271             qr(^Running\s+/),
272             qr(^test result:),
273             ],
274             max_lines => 100,
275             );
276              
277             # cpanm: strip cpanm noise
278 9         77 $self->register_filter(
279             command => '^cpanm\b',
280             strip_lines_matching => [
281             qr(^\s*$),
282             qr(^--> ),
283             qr(^OK$),
284             qr(^FAIL$),
285             qr(^Working on),
286             qr(^Fetching),
287             qr(^Configuring),
288             qr(^Building and testing),
289             ],
290             match_output => [
291             { pattern => qr{^\s*Successfully installed}, message => 'cpanm: ok' },
292             ],
293             max_lines => 30,
294             );
295              
296             # npm install: strip noise
297 9         40 $self->register_filter(
298             command => '^npm\s+install\b',
299             strip_lines_matching => [
300             qr(^\s*$),
301             qr(^added\s+\d+\s+packages?),
302             qr(^found\s+\d+\s+packages?),
303             qr(^npm warn),
304             ],
305             max_lines => 30,
306             );
307              
308             # yarn: similar to npm
309 9         53 $self->register_filter(
310             command => '^(yarn|pnpm)\s+(install|add)\b',
311             strip_lines_matching => [
312             qr(^\s*$),
313             qr(^Done in\s+),
314             qr(^Resolving completed),
315             qr(^Linking completed),
316             ],
317             max_lines => 30,
318             );
319              
320             # pip install: strip progress
321 9         37 $self->register_filter(
322             command => '^pip\s+install\b',
323             strip_lines_matching => [
324             qr(^\s*$),
325             qr(^Collecting\s+),
326             qr(^Downloading\s+),
327             qr(^Installing collected packages:),
328             qr(^Successfully installed),
329             ],
330             max_lines => 50,
331             );
332              
333             # pytest: compact output
334 9         63 $self->register_filter(
335             command => '^pytest\b',
336             strip_lines_matching => [
337             qr(^\s*$),
338             qr(^=+.*=+$/),
339             qr(^Coverage report:),
340             qr(^HTML report:),
341             ],
342             truncate_lines_at => 150,
343             max_lines => 100,
344             );
345              
346             # curl: strip headers
347 9         52 $self->register_filter(
348             command => '^curl\b',
349             filter_stderr => 1,
350             strip_lines_matching => [
351             qr(^\s*$),
352             qr(^ % Total),
353             qr(^ Resolving),
354             qr(^Connected to),
355             qr(^HTTP/\d[\d.]*\s+\d+),
356             ],
357             truncate_lines_at => 200,
358             max_lines => 50,
359             );
360              
361             # wget: strip progress
362 9         57 $self->register_filter(
363             command => '^wget\b',
364             strip_lines_matching => [
365             qr(^\s*$),
366             qr(^--\d{4}-\d{2}-\d{2}),
367             qr(^Resolving),
368             qr(^Connecting to),
369             qr(^Length:\s+\d+),
370             qr(^Saving to:),
371             qr(^\d+%\s+\[),
372             ],
373             truncate_lines_at => 200,
374             max_lines => 50,
375             );
376              
377             # helm install: strip progress
378 9         47 $self->register_filter(
379             command => '^helm\s+(install|upgrade)\b',
380             strip_lines_matching => [
381             qr(^\s*$),
382             qr(^NAME:\s+\w+),
383             qr(^NAMESPACE:\s+\w+),
384             qr(^STATUS:\s+),
385             qr(^REVISION:\s+\d+),
386             qr(^NOTES:$),
387             ],
388             max_lines => 50,
389             );
390              
391             # ansible-playbook: strip progress
392 9         68 $self->register_filter(
393             command => '^ansible-playbook\b',
394             strip_lines_matching => [
395             qr(^\s*$),
396             qr(^PLAY\s+\[),
397             qr(^TASK\s+\[),
398             qr(^RUNNING HANDLER),
399             qr(^changed:\s+\[\d+\]),
400             qr(^ok:\s+\[\d+\]),
401             qr(^fatal:\s+\[\d+\]),
402             qr(^PLAY RECAP),
403             ],
404             max_lines => 100,
405             );
406              
407             # rsync: strip progress
408 9         39 $self->register_filter(
409             command => '^rsync\b',
410             strip_lines_matching => [
411             qr(^\s*$),
412             qr(^sent\s+\d+\s+bytes),
413             qr(^received\s+\d+\s+bytes),
414             qr(^total size is),
415             ],
416             max_lines => 30,
417             );
418              
419             # iptables -L: compact
420 9         20 $self->register_filter(
421             command => '^iptables\s+-L\b',
422             strip_lines_matching => [qr(^\s*$)],
423             truncate_lines_at => 150,
424             max_lines => 50,
425             );
426              
427             # ping: strip progress
428 9         32 $self->register_filter(
429             command => '^ping\b',
430             strip_lines_matching => [
431             qr(^\s*$),
432             qr(^PING\s+),
433             qr(^64 bytes from),
434             qr(^---.*ping statistics---),
435             ],
436             max_lines => 20,
437             );
438              
439             # git log: compact
440 9         45 $self->register_filter(
441             command => '^git\s+log\b',
442             strip_lines_matching => [
443             qr(^\s*$),
444             qr(^commit\s+[a-f0-9]+),
445             qr(^Author:\s+),
446             qr(^Date:\s+),
447             ],
448             head_lines => 20,
449             tail_lines => 10,
450             max_lines => 30,
451             );
452              
453             # git branch: compact
454 9         21 $self->register_filter(
455             command => '^git\s+branch\b',
456             strip_lines_matching => [qr(^\s*$)],
457             max_lines => 30,
458             );
459              
460             # git stash: compact
461 9         23 $self->register_filter(
462             command => '^git\s+stash\b',
463             strip_lines_matching => [qr(^\s*$)],
464             max_lines => 30,
465             );
466              
467 9         9 return;
468             }
469              
470             sub new {
471 9     9 1 294944 my $self = shift->SUPER::new(@_);
472 9         62 $self->_build_default_filters;
473 9         14 return $self;
474             }
475              
476             sub compress {
477 9     9 0 109 my ($self, $command, $stdout, $stderr) = @_;
478              
479 9         7 my $matched_filter;
480              
481 9         9 for my $key (keys %{$self->filters}) {
  9         10  
482 198         273 my $filter = $self->filters->{$key};
483              
484             # Check if command matches
485 198 100       1899 unless ($command =~ /$key/) {
486 190         237 next;
487             }
488              
489             # Check output_detect if present - only apply filter if output matches
490 8 100       18 if (my $detect = $filter->{output_detect}) {
491 1         5 my @lines = split(/\n/, $stdout);
492 1         2 my $has_match = grep { /$detect/ } @lines;
  9         27  
493 1 50       3 unless ($has_match) {
494 0         0 next; # Skip this filter, try next one
495             }
496             }
497              
498 8         8 $matched_filter = $filter;
499 8         9 last; # Found matching filter
500             }
501              
502 9 100       24 if (!$matched_filter) {
503 1         4 return ($stdout, $stderr);
504             }
505              
506 8   50     18 my ($out, $err) = ($stdout, $stderr // '');
507              
508             # Filter stderr into stdout if configured
509 8 50       13 if ($matched_filter->{filter_stderr}) {
510 0 0       0 $out .= "\n$err" if length $err;
511 0         0 $err = '';
512             }
513              
514             # Stage 1: Strip ANSI
515 8 50       13 if ($matched_filter->{strip_ansi}) {
516 0         0 $out =~ s/\x1b\[[0-9;]*[a-zA-Z]//g;
517 0         0 $err =~ s/\x1b\[[0-9;]*[a-zA-Z]//g;
518             }
519              
520             # Stage 2: Match output short-circuit
521 8         7 for my $match (@{$matched_filter->{match_output}}) {
  8         16  
522 4 50       26 if ($out =~ /$match->{pattern}/) {
523 0         0 return ($match->{message}, $err);
524             }
525             }
526              
527             # Stage 3: Transform lines (if transform coderef provided)
528 8 100       15 if ($matched_filter->{transform}) {
529 1         4 $out = join("\n", map { $matched_filter->{transform}->($_) } split(/\n/, $out));
  9         12  
530             }
531              
532             # Stage 4: Strip lines matching
533 8 100       8 if (@{$matched_filter->{strip_lines_matching}}) {
  8         27  
534 7         30 my @out_lines = split(/\n/, $out);
535             @out_lines = grep {
536 7         13 my $keep = 1;
  100         70  
537 100         78 for my $pattern (@{$matched_filter->{strip_lines_matching}}) {
  100         102  
538 317 100       515 if (/$pattern/) {
539 15         15 $keep = 0;
540 15         12 last;
541             }
542             }
543 100         100 $keep;
544             } @out_lines;
545 7         24 $out = join("\n", @out_lines);
546             }
547              
548             # Stage 5: Keep lines matching (if any)
549 8 50       8 if (@{$matched_filter->{keep_lines_matching}}) {
  8         12  
550 0         0 my @out_lines = split(/\n/, $out);
551             @out_lines = grep {
552 0         0 my $keep = 0;
  0         0  
553 0         0 for my $pattern (@{$matched_filter->{keep_lines_matching}}) {
  0         0  
554 0 0       0 if (/$pattern/) {
555 0         0 $keep = 1;
556 0         0 last;
557             }
558             }
559 0         0 $keep;
560             } @out_lines;
561 0         0 $out = join("\n", @out_lines);
562             }
563              
564             # Stage 6: Truncate lines at N chars
565 8 100       13 if ($matched_filter->{truncate_lines_at} > 0) {
566 5         11 my $max = $matched_filter->{truncate_lines_at};
567 5 100       13 $out = join("\n", map { length $_ > $max ? substr($_, 0, $max) . '...' : $_ } split(/\n/, $out));
  28         43  
568 5 0       15 $err = join("\n", map { length $_ > $max ? substr($_, 0, $max) . '...' : $_ } split(/\n/, $err)) if length $err;
  0 50       0  
569             }
570              
571             # Stage 7: Head/Tail lines
572 8 50       20 if ($matched_filter->{head_lines} > 0) {
    50          
573 0         0 my @lines = split(/\n/, $out);
574 0         0 my $head = $matched_filter->{head_lines};
575 0   0     0 my $tail = $matched_filter->{tail_lines} // 0;
576 0         0 my $omit = @lines - $head - $tail;
577 0 0 0     0 if ($omit > 0 && $tail > 0) {
    0          
578 0         0 $out = (join("\n", @lines[0..$head-1])) . "\n... $omit lines omitted ...\n" . (join("\n", @lines[-$tail..-1]));
579             }
580             elsif ($omit > 0) {
581 0         0 $out = join("\n", @lines[0..min($head, @lines)-1]);
582             }
583             }
584             elsif ($matched_filter->{tail_lines} > 0) {
585 0         0 my @lines = split(/\n/, $out);
586 0         0 $out = join("\n", @lines[-min($matched_filter->{tail_lines}, @lines)..-1]);
587             }
588              
589             # Stage 8: Max lines
590 8 50       10 if ($matched_filter->{max_lines} > 0) {
591 8         17 my @out_lines = split(/\n/, $out);
592 8 100       16 if (@out_lines > $matched_filter->{max_lines}) {
593 1         21 $out = join("\n", @out_lines[0..$matched_filter->{max_lines}-1]) . "\n... " . (@out_lines - $matched_filter->{max_lines}) . " more lines ...";
594             }
595             }
596              
597             # Stage 9: On empty
598 8 50 66     24 if (!length trim($out) && $matched_filter->{on_empty}) {
599 1         18 $out = $matched_filter->{on_empty};
600             }
601              
602 8         166 return ($out, $err);
603             }
604              
605             1;
606              
607             __END__