NAME

MCP::Run::Compress - Output compression for LLMs

VERSION

version 0.107

SYNOPSIS

use MCP::Run::Compress;

my $compressor = MCP::Run::Compress->new;
$compressor->register_filter(
  'command' => '^ls\b',
  'strip_lines_matching' => [qr(^\s*$), qr(^total\s+\d+)],
  'truncate_lines_at' => 120,
  'max_lines' => 50,
);

my ($compressed_stdout, $compressed_stderr) = $compressor->compress($command, $stdout, $stderr);

DESCRIPTION

Automatic output compression for LLM consumption. Applies command-specific filters to reduce token count while preserving essential information.

_parse_command

my $parsed = $self->_parse_command($command);

Parses a command string into structured components for filter matching:

program    - first word (e.g., 'git')
subcommand - second word if not a flag (e.g., 'diff' in 'git diff')
flags      - hashref of parsed flags (e.g., { stat => 1, w => 5 })
args       - remaining non-flag arguments

Supports git-style commands where the subcommand is the first non-flag word.

_co_authored_by

my $model = _co_authored_by();

The Co-Authored-By replacement value from CO_AUTHORED_BY, falling back to ANTHROPIC_MODEL, or undef when neither is set or the value is not a valid trailer.

The value gets spliced into a shell command line -- inside the double quotes of the git commit -m "..." argument -- so it is checked against a narrow positive list of what a Co-Authored-By trailer may look like: a name of one or more words separated by single spaces, each word built from letters, digits and . _ - + : /, optionally followed by an <mail@host> address. Anything else is refused; in particular ", \, $, backtick and newline can never pass, so the value cannot end the quoted string, start a command substitution or append a command of its own.

A refused value means the command is returned unchanged -- no trailer at all. Rejecting beats escaping here: a silently escaped, mangled trailer sits in a real commit where nobody notices it, a missing trailer is honest. For the same reason an invalid CO_AUTHORED_BY does not fall back to ANTHROPIC_MODEL: the explicit setting wins, and is then refused.

_collapse_cr

my $text = _collapse_cr($text);

Reduces carriage-return overwrites to what a terminal would actually show. Build tools (cargo, npm, pip, docker, wget, curl, ...) draw progress by writing thousands of states over each other with \r and no newline at all. Every stage of the pipeline splits on /\n/, so such a bar is a single line to it: max_lines cannot help, and the 28 of 48 filters without truncate_lines_at pass the whole thing through untouched.

Per line, the \r-separated states are reduced to the last non-empty one -- what stayed on screen. Two details matter: \r\n is a line ending and is normalized to \n first, so Windows output is not mistaken for a progress bar; and a bar usually ends on a bare \r, which makes the last state empty, so the last non-empty one wins. Text without a \r is returned unchanged, byte for byte.

_filter_matches

my $bool = $self->_filter_matches($key, $filter, $command, $parsed, $stdout);

Whether one registered filter applies to a command. parsed_command filters are matched structurally, legacy filters by their key as a regex against the raw command.

$stdout is what the output_detect check looks at; pass undef when there is no output yet ("transform_command" only rewrites the command), which skips that check.

_shapes_output

my $bool = $self->_shapes_output($filter);

Whether a filter has anything to say about the output -- i.e. whether any of the pipeline stages would do something for it.

Not every filter does. The git-commit filter carries no output attribute at all; it is registered only for its command_transform. Such a filter must stay out of the output-filter selection, see "_match_filter".

The list is the stages of "_pipeline": a new stage attribute belongs in here too, otherwise a filter using only that attribute is never selected.

_match_filter

my $filter = $self->_match_filter($command, $stdout);

The one filter that shapes the output for this command, or undef when none matches.

Only filters that shape output take part -- a filter registered purely for its command_transform is not an answer to "how is this output compressed?". Letting it compete decided two unrelated questions with one lookup: for `cat msg.txt && git commit -m "fix"` both the anchored cat filter and the deliberately unanchored git-commit filter match, and whoever won, one of the two concerns lost -- the trailer or the filtering, picked by hash order (karr #25). The command rewrite is now "transform_command"'s own decision.

_pipeline

my ($command, $stdout, $stderr) = $self->_pipeline($command, $stdout, $stderr);

The eleven-stage compression pipeline, and the only copy of it. "compress" and "process" are the two shapes it is returned in -- a list for the hook, a hashref for the MCP server -- and nothing else may differ between them.

It used to exist twice, hand-kept in sync. Both copies had to be touched separately for the same fixes, and the test suite reached one through compress() and the other through process(), so a fix landing in only one of them was invisible (karr #23). t/compress.t compares the two entry points over the whole filter table for that reason.

AUTHOR

Torsten Raudssus https://raudssus.de/

COPYRIGHT

Copyright 2026 Torsten Raudssus

LICENSE

This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself.

SUPPORT

Issues

Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-mcp-run/issues.

CONTRIBUTING

Contributions are welcome! Please fork the repository and submit a pull request.

AUTHOR

Torsten Raudssus <getty@cpan.org>

COPYRIGHT AND LICENSE

This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.