This project uses a fork-based workflow. Direct pushes to main are not
allowed — all changes go through pull requests reviewed by maintainers.
# Fork the repo on GitHub (click the "Fork" button), then:
git clone https://github.com/<your-username>/token-saver.git
cd token-saver/extensiongit checkout -b my-featureEdit the code, add tests, and make sure everything passes locally:
# Lint
ruff check .
ruff format --check .
# Tests
python3 -m pytest tests/ -vgit add -A
git commit -m "Add my feature"
git push origin my-featureGo to your fork on GitHub and click "New pull request". Target the main
branch of the upstream repository. Fill in the PR template.
- A maintainer must approve your PR before CI runs (GitHub's "Require approval for first-time contributors" setting).
- CI runs ruff check, ruff format --check, and pytest on Python 3.10, 3.12, and 3.13.
- Both CI and a maintainer review must pass before merging.
- Only maintainers can merge to
main.
git remote add upstream https://github.com/<org>/token-saver.git
git fetch upstream
git rebase upstream/mainThis project uses Ruff for linting and
formatting. Configuration is in pyproject.toml.
# Check for lint issues
ruff check .
# Auto-fix safe issues
ruff check --fix .
# Check formatting
ruff format --check .
# Auto-format
ruff format .All code must pass ruff check and ruff format --check before merging.
Adding a new processor is a single-file operation. Create one file in
src/processors/ and it will be automatically discovered.
- Create
src/processors/my_tool.py - Run
python3 -m pytest tests/ -vto verify
That's it. No other files need editing.
"""MyTool output processor: describe what it handles."""
import re
from .base import Processor
class MyToolProcessor(Processor):
priority = 40 # See priority conventions below
hook_patterns = [
r"^mytool\s+(subcommand1|subcommand2)\b",
]
@property
def name(self) -> str:
return "my_tool"
def can_handle(self, command: str) -> bool:
return bool(re.search(r"\bmytool\s+(subcommand1|subcommand2)\b", command))
def process(self, command: str, output: str) -> str:
if not output or not output.strip():
return output
# Your compression logic here
return output| Attribute | Type | Description |
|---|---|---|
priority |
int |
Determines processor ordering. Lower = checked first. |
hook_patterns |
list[str] |
Regex patterns for the pre-tool hook to intercept matching commands. |
name |
str (property) |
Identifier returned by the engine to report which processor handled a command. |
can_handle(command) |
method | Returns True if this processor should handle the given command string. |
process(command, output) |
method | Takes the command and its raw output, returns a compressed version. |
| Range | Category | Examples |
|---|---|---|
| 10-19 | High priority overrides | PackageListProcessor (15) — must run before BuildOutputProcessor |
| 20-29 | Core processors | git (20), test (21), build (25), lint (27) |
| 30-49 | Specialized tools | network (30), docker (31), kubectl (32), terraform (33), env (34), search (35), system_info (36) |
| 50-69 | Content-based | file_listing (50), file_content (51) |
| 999 | Generic fallback | GenericProcessor — always last, do not use this value |
When choosing a priority:
- Pick a value in the appropriate range for your processor category.
- If your processor must run before another (e.g., to avoid misrouting), use a lower number.
- Leave gaps between values to allow future insertions without renumbering.
These are regex patterns that the pre-tool hook uses to decide whether to intercept a command for compression. They should match the start of commands your processor handles.
- Use
^anchors so patterns match the beginning of the command. - Patterns are compiled with
re.compile()and matched withre.search(). - GenericProcessor should have
hook_patterns = [](it's a fallback).
processors/__init__.pyscans all.pyfiles in theprocessors/directory.- It imports each module and finds all non-abstract
Processorsubclasses. - Instances are sorted by
priority(ascending). discover_processors()returns the sorted list (used byengine.py).collect_hook_patterns()collects allhook_patterns(used byhook_pretool.py).
Tests for processors live in tests/test_processors.py. Each
processor has its own test class. Add a new class following this pattern:
class TestMyToolProcessor:
def setup_method(self):
self.p = MyToolProcessor()
def test_can_handle(self):
# Commands your processor should match
assert self.p.can_handle("mytool subcommand1")
assert self.p.can_handle("mytool subcommand2 --flag")
# Commands it should NOT match
assert not self.p.can_handle("othertool run")
assert not self.p.can_handle("ls -la")
def test_empty_output(self):
result = self.p.process("mytool subcommand1", "")
assert result == ""
def test_short_output_unchanged(self):
output = "one line of output"
result = self.p.process("mytool subcommand1", output)
assert result == output
def test_compression_logic(self):
# Build a realistic output that should be compressed
lines = [f"processing item {i}" for i in range(100)]
lines.append("Done: 100 items processed")
output = "\n".join(lines)
result = self.p.process("mytool subcommand1", output)
assert len(result) < len(output)
# Verify important information is preserved
assert "100 items processed" in resultDon't forget to add the import at the top of test_processors.py:
from src.processors.my_tool import MyToolProcessorYou should also add an integration test in test_engine.py to verify
your processor is picked up by the engine:
def test_mytool_output_compressed(self):
output = "\n".join(f"item {i}" for i in range(100))
compressed, processor, was_compressed = self.engine.compress(
"mytool subcommand1", output
)
assert was_compressed
assert processor == "my_tool"And a hook test in test_hooks.py to verify the hook intercepts your
commands:
def test_mytool_commands_compressible(self):
assert is_compressible("mytool subcommand1")
assert is_compressible("mytool subcommand2 --verbose")| Category | What to check |
|---|---|
can_handle |
Positive matches, negative matches, edge cases |
| Empty/short output | Returns input unchanged |
| Compression | Output is shorter, key information is preserved |
| Error preservation | Error messages, stack traces, failure details are never dropped |
| Edge cases | Unicode, very long lines, unusual formatting |
python3 -m pytest tests/ -vAll existing tests must continue to pass after adding a new processor.