headroom-ai 0.2.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- headroom/__init__.py +212 -0
- headroom/cache/__init__.py +76 -0
- headroom/cache/anthropic.py +517 -0
- headroom/cache/base.py +342 -0
- headroom/cache/compression_feedback.py +613 -0
- headroom/cache/compression_store.py +814 -0
- headroom/cache/dynamic_detector.py +1026 -0
- headroom/cache/google.py +884 -0
- headroom/cache/openai.py +584 -0
- headroom/cache/registry.py +175 -0
- headroom/cache/semantic.py +451 -0
- headroom/ccr/__init__.py +77 -0
- headroom/ccr/context_tracker.py +582 -0
- headroom/ccr/mcp_server.py +319 -0
- headroom/ccr/response_handler.py +772 -0
- headroom/ccr/tool_injection.py +415 -0
- headroom/cli.py +219 -0
- headroom/client.py +977 -0
- headroom/compression/__init__.py +42 -0
- headroom/compression/detector.py +424 -0
- headroom/compression/handlers/__init__.py +22 -0
- headroom/compression/handlers/base.py +219 -0
- headroom/compression/handlers/code_handler.py +506 -0
- headroom/compression/handlers/json_handler.py +418 -0
- headroom/compression/masks.py +345 -0
- headroom/compression/universal.py +465 -0
- headroom/config.py +474 -0
- headroom/exceptions.py +192 -0
- headroom/integrations/__init__.py +159 -0
- headroom/integrations/agno/__init__.py +53 -0
- headroom/integrations/agno/hooks.py +345 -0
- headroom/integrations/agno/model.py +625 -0
- headroom/integrations/agno/providers.py +154 -0
- headroom/integrations/langchain/__init__.py +106 -0
- headroom/integrations/langchain/agents.py +326 -0
- headroom/integrations/langchain/chat_model.py +1002 -0
- headroom/integrations/langchain/langsmith.py +324 -0
- headroom/integrations/langchain/memory.py +319 -0
- headroom/integrations/langchain/providers.py +200 -0
- headroom/integrations/langchain/retriever.py +371 -0
- headroom/integrations/langchain/streaming.py +341 -0
- headroom/integrations/mcp/__init__.py +37 -0
- headroom/integrations/mcp/server.py +533 -0
- headroom/memory/__init__.py +37 -0
- headroom/memory/extractor.py +390 -0
- headroom/memory/fast_store.py +621 -0
- headroom/memory/fast_wrapper.py +311 -0
- headroom/memory/inline_extractor.py +229 -0
- headroom/memory/store.py +434 -0
- headroom/memory/worker.py +260 -0
- headroom/memory/wrapper.py +321 -0
- headroom/models/__init__.py +39 -0
- headroom/models/registry.py +687 -0
- headroom/parser.py +293 -0
- headroom/pricing/__init__.py +51 -0
- headroom/pricing/anthropic_prices.py +81 -0
- headroom/pricing/litellm_pricing.py +113 -0
- headroom/pricing/openai_prices.py +91 -0
- headroom/pricing/registry.py +188 -0
- headroom/providers/__init__.py +61 -0
- headroom/providers/anthropic.py +621 -0
- headroom/providers/base.py +131 -0
- headroom/providers/cohere.py +362 -0
- headroom/providers/google.py +427 -0
- headroom/providers/litellm.py +297 -0
- headroom/providers/openai.py +566 -0
- headroom/providers/openai_compatible.py +521 -0
- headroom/proxy/__init__.py +19 -0
- headroom/proxy/server.py +2683 -0
- headroom/py.typed +0 -0
- headroom/relevance/__init__.py +124 -0
- headroom/relevance/base.py +106 -0
- headroom/relevance/bm25.py +255 -0
- headroom/relevance/embedding.py +255 -0
- headroom/relevance/hybrid.py +259 -0
- headroom/reporting/__init__.py +5 -0
- headroom/reporting/generator.py +549 -0
- headroom/storage/__init__.py +41 -0
- headroom/storage/base.py +125 -0
- headroom/storage/jsonl.py +220 -0
- headroom/storage/sqlite.py +289 -0
- headroom/telemetry/__init__.py +91 -0
- headroom/telemetry/collector.py +764 -0
- headroom/telemetry/models.py +880 -0
- headroom/telemetry/toin.py +1579 -0
- headroom/tokenizer.py +80 -0
- headroom/tokenizers/__init__.py +75 -0
- headroom/tokenizers/base.py +210 -0
- headroom/tokenizers/estimator.py +198 -0
- headroom/tokenizers/huggingface.py +317 -0
- headroom/tokenizers/mistral.py +245 -0
- headroom/tokenizers/registry.py +398 -0
- headroom/tokenizers/tiktoken_counter.py +248 -0
- headroom/transforms/__init__.py +106 -0
- headroom/transforms/base.py +57 -0
- headroom/transforms/cache_aligner.py +357 -0
- headroom/transforms/code_compressor.py +1313 -0
- headroom/transforms/content_detector.py +335 -0
- headroom/transforms/content_router.py +1158 -0
- headroom/transforms/llmlingua_compressor.py +638 -0
- headroom/transforms/log_compressor.py +529 -0
- headroom/transforms/pipeline.py +297 -0
- headroom/transforms/rolling_window.py +350 -0
- headroom/transforms/search_compressor.py +365 -0
- headroom/transforms/smart_crusher.py +2682 -0
- headroom/transforms/text_compressor.py +259 -0
- headroom/transforms/tool_crusher.py +338 -0
- headroom/utils.py +215 -0
- headroom_ai-0.2.13.dist-info/METADATA +315 -0
- headroom_ai-0.2.13.dist-info/RECORD +114 -0
- headroom_ai-0.2.13.dist-info/WHEEL +4 -0
- headroom_ai-0.2.13.dist-info/entry_points.txt +2 -0
- headroom_ai-0.2.13.dist-info/licenses/LICENSE +190 -0
- headroom_ai-0.2.13.dist-info/licenses/NOTICE +43 -0
|
@@ -0,0 +1,1158 @@
|
|
|
1
|
+
"""Content router for intelligent compression strategy selection.
|
|
2
|
+
|
|
3
|
+
This module provides the ContentRouter, which analyzes content and routes it
|
|
4
|
+
to the optimal compressor. It handles mixed content by splitting, routing
|
|
5
|
+
each section to the appropriate compressor, and reassembling.
|
|
6
|
+
|
|
7
|
+
Supported Compressors:
|
|
8
|
+
- CodeAwareCompressor: Source code (AST-preserving)
|
|
9
|
+
- SmartCrusher: JSON arrays
|
|
10
|
+
- SearchCompressor: grep/ripgrep results
|
|
11
|
+
- LogCompressor: Build/test output
|
|
12
|
+
- LLMLinguaCompressor: Plain text (ML-based)
|
|
13
|
+
- TextCompressor: Plain text (heuristic-based)
|
|
14
|
+
|
|
15
|
+
Routing Strategy:
|
|
16
|
+
1. Use source hint if available (highest confidence)
|
|
17
|
+
2. Check for mixed content (split and route sections)
|
|
18
|
+
3. Detect content type (JSON, code, search, logs, text)
|
|
19
|
+
4. Route to appropriate compressor
|
|
20
|
+
5. Reassemble and return with routing metadata
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
>>> from headroom.transforms import ContentRouter
|
|
24
|
+
>>> router = ContentRouter()
|
|
25
|
+
>>> result = router.compress(content) # Auto-routes to best compressor
|
|
26
|
+
>>> print(result.strategy_used)
|
|
27
|
+
>>> print(result.routing_log)
|
|
28
|
+
|
|
29
|
+
Pipeline Usage:
|
|
30
|
+
>>> pipeline = TransformPipeline([
|
|
31
|
+
... ContentRouter(), # Handles all content types
|
|
32
|
+
... RollingWindow(), # Final size constraint
|
|
33
|
+
... ])
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import logging
|
|
39
|
+
import re
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from enum import Enum
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
from ..config import TransformResult
|
|
45
|
+
from ..tokenizer import Tokenizer
|
|
46
|
+
from .base import Transform
|
|
47
|
+
from .content_detector import ContentType, detect_content_type
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CompressionStrategy(Enum):
|
|
53
|
+
"""Available compression strategies."""
|
|
54
|
+
|
|
55
|
+
CODE_AWARE = "code_aware"
|
|
56
|
+
SMART_CRUSHER = "smart_crusher"
|
|
57
|
+
SEARCH = "search"
|
|
58
|
+
LOG = "log"
|
|
59
|
+
LLMLINGUA = "llmlingua"
|
|
60
|
+
TEXT = "text"
|
|
61
|
+
DIFF = "diff"
|
|
62
|
+
MIXED = "mixed"
|
|
63
|
+
PASSTHROUGH = "passthrough"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class RoutingDecision:
|
|
68
|
+
"""Record of a single routing decision."""
|
|
69
|
+
|
|
70
|
+
content_type: ContentType
|
|
71
|
+
strategy: CompressionStrategy
|
|
72
|
+
original_tokens: int
|
|
73
|
+
compressed_tokens: int
|
|
74
|
+
confidence: float = 1.0
|
|
75
|
+
section_index: int = 0
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def compression_ratio(self) -> float:
|
|
79
|
+
if self.original_tokens == 0:
|
|
80
|
+
return 1.0
|
|
81
|
+
return self.compressed_tokens / self.original_tokens
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ContentSection:
|
|
86
|
+
"""A typed section of content."""
|
|
87
|
+
|
|
88
|
+
content: str
|
|
89
|
+
content_type: ContentType
|
|
90
|
+
language: str | None = None
|
|
91
|
+
start_line: int = 0
|
|
92
|
+
end_line: int = 0
|
|
93
|
+
is_code_fence: bool = False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class RouterCompressionResult:
|
|
98
|
+
"""Result from ContentRouter with routing metadata.
|
|
99
|
+
|
|
100
|
+
Attributes:
|
|
101
|
+
compressed: The compressed content.
|
|
102
|
+
original: Original content before compression.
|
|
103
|
+
strategy_used: Primary strategy used for compression.
|
|
104
|
+
routing_log: List of routing decisions made.
|
|
105
|
+
sections_processed: Number of content sections processed.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
compressed: str
|
|
109
|
+
original: str
|
|
110
|
+
strategy_used: CompressionStrategy
|
|
111
|
+
routing_log: list[RoutingDecision] = field(default_factory=list)
|
|
112
|
+
sections_processed: int = 1
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def total_original_tokens(self) -> int:
|
|
116
|
+
"""Total tokens before compression."""
|
|
117
|
+
return sum(r.original_tokens for r in self.routing_log)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def total_compressed_tokens(self) -> int:
|
|
121
|
+
"""Total tokens after compression."""
|
|
122
|
+
return sum(r.compressed_tokens for r in self.routing_log)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def compression_ratio(self) -> float:
|
|
126
|
+
"""Overall compression ratio."""
|
|
127
|
+
if self.total_original_tokens == 0:
|
|
128
|
+
return 1.0
|
|
129
|
+
return self.total_compressed_tokens / self.total_original_tokens
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def tokens_saved(self) -> int:
|
|
133
|
+
"""Number of tokens saved."""
|
|
134
|
+
return max(0, self.total_original_tokens - self.total_compressed_tokens)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def savings_percentage(self) -> float:
|
|
138
|
+
"""Percentage of tokens saved."""
|
|
139
|
+
if self.total_original_tokens == 0:
|
|
140
|
+
return 0.0
|
|
141
|
+
return (self.tokens_saved / self.total_original_tokens) * 100
|
|
142
|
+
|
|
143
|
+
def summary(self) -> str:
|
|
144
|
+
"""Human-readable routing summary."""
|
|
145
|
+
if self.strategy_used == CompressionStrategy.MIXED:
|
|
146
|
+
strategies = {r.strategy.value for r in self.routing_log}
|
|
147
|
+
return (
|
|
148
|
+
f"Mixed content: {self.sections_processed} sections, "
|
|
149
|
+
f"routed to {strategies}. "
|
|
150
|
+
f"{self.total_original_tokens:,}→{self.total_compressed_tokens:,} tokens "
|
|
151
|
+
f"({self.savings_percentage:.0f}% saved)"
|
|
152
|
+
)
|
|
153
|
+
else:
|
|
154
|
+
return (
|
|
155
|
+
f"Pure {self.strategy_used.value}: "
|
|
156
|
+
f"{self.total_original_tokens:,}→{self.total_compressed_tokens:,} tokens "
|
|
157
|
+
f"({self.savings_percentage:.0f}% saved)"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class ContentRouterConfig:
|
|
163
|
+
"""Configuration for intelligent content routing.
|
|
164
|
+
|
|
165
|
+
Attributes:
|
|
166
|
+
enable_code_aware: Enable AST-based code compression.
|
|
167
|
+
enable_llmlingua: Enable ML-based text compression.
|
|
168
|
+
enable_smart_crusher: Enable JSON array compression.
|
|
169
|
+
enable_search_compressor: Enable search result compression.
|
|
170
|
+
enable_log_compressor: Enable build/test log compression.
|
|
171
|
+
prefer_code_aware_for_code: Use CodeAware over LLMLingua for code.
|
|
172
|
+
mixed_content_threshold: Min distinct types to consider "mixed".
|
|
173
|
+
min_section_tokens: Minimum tokens for a section to compress.
|
|
174
|
+
fallback_strategy: Strategy when no compressor matches.
|
|
175
|
+
skip_user_messages: Never compress user messages (they're the subject).
|
|
176
|
+
skip_recent_messages: Don't compress last N messages (likely the subject).
|
|
177
|
+
protect_analysis_context: Detect "analyze/review" intent, skip compression.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
# Enable/disable specific compressors
|
|
181
|
+
enable_code_aware: bool = True
|
|
182
|
+
enable_llmlingua: bool = True
|
|
183
|
+
enable_smart_crusher: bool = True
|
|
184
|
+
enable_search_compressor: bool = True
|
|
185
|
+
enable_log_compressor: bool = True
|
|
186
|
+
|
|
187
|
+
# Routing preferences
|
|
188
|
+
prefer_code_aware_for_code: bool = True
|
|
189
|
+
mixed_content_threshold: int = 2 # Min types to consider mixed
|
|
190
|
+
min_section_tokens: int = 20 # Min tokens to compress a section
|
|
191
|
+
|
|
192
|
+
# Fallback
|
|
193
|
+
fallback_strategy: CompressionStrategy = CompressionStrategy.PASSTHROUGH
|
|
194
|
+
|
|
195
|
+
# Protection: Don't compress content that's likely the subject of analysis
|
|
196
|
+
skip_user_messages: bool = True # User messages contain what they want analyzed
|
|
197
|
+
protect_recent_code: int = 4 # Don't compress CODE in last N messages (0 = disabled)
|
|
198
|
+
protect_analysis_context: bool = True # Detect "analyze/review" intent, protect code
|
|
199
|
+
|
|
200
|
+
# CCR (Compress-Cache-Retrieve) settings for SmartCrusher
|
|
201
|
+
ccr_enabled: bool = True # Enable CCR marker injection for reversible compression
|
|
202
|
+
ccr_inject_marker: bool = True # Add retrieval markers to compressed content
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# Patterns for detecting mixed content
|
|
206
|
+
_CODE_FENCE_PATTERN = re.compile(r"^```(\w*)\s*$", re.MULTILINE)
|
|
207
|
+
_JSON_BLOCK_START = re.compile(r"^\s*[\[{]", re.MULTILINE)
|
|
208
|
+
_SEARCH_RESULT_PATTERN = re.compile(r"^\S+:\d+:", re.MULTILINE)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def is_mixed_content(content: str) -> bool:
|
|
212
|
+
"""Detect if content contains multiple distinct types.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
content: Content to analyze.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
True if content appears to be mixed (multiple types).
|
|
219
|
+
"""
|
|
220
|
+
indicators = {
|
|
221
|
+
"has_code_fences": bool(_CODE_FENCE_PATTERN.search(content)),
|
|
222
|
+
"has_json_blocks": bool(_JSON_BLOCK_START.search(content)),
|
|
223
|
+
"has_prose": len(re.findall(r"[A-Z][a-z]+\s+\w+\s+\w+", content)) > 5,
|
|
224
|
+
"has_search_results": bool(_SEARCH_RESULT_PATTERN.search(content)),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
# Mixed if 2+ indicators are true
|
|
228
|
+
return sum(indicators.values()) >= 2
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def split_into_sections(content: str) -> list[ContentSection]:
|
|
232
|
+
"""Parse mixed content into typed sections.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
content: Mixed content to split.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
List of ContentSection objects.
|
|
239
|
+
"""
|
|
240
|
+
sections: list[ContentSection] = []
|
|
241
|
+
lines = content.split("\n")
|
|
242
|
+
|
|
243
|
+
i = 0
|
|
244
|
+
while i < len(lines):
|
|
245
|
+
line = lines[i]
|
|
246
|
+
|
|
247
|
+
# Code fence: ```language
|
|
248
|
+
if match := _CODE_FENCE_PATTERN.match(line):
|
|
249
|
+
language = match.group(1) or "unknown"
|
|
250
|
+
code_lines = []
|
|
251
|
+
start_line = i
|
|
252
|
+
i += 1
|
|
253
|
+
|
|
254
|
+
while i < len(lines) and not lines[i].startswith("```"):
|
|
255
|
+
code_lines.append(lines[i])
|
|
256
|
+
i += 1
|
|
257
|
+
|
|
258
|
+
sections.append(
|
|
259
|
+
ContentSection(
|
|
260
|
+
content="\n".join(code_lines),
|
|
261
|
+
content_type=ContentType.SOURCE_CODE,
|
|
262
|
+
language=language,
|
|
263
|
+
start_line=start_line,
|
|
264
|
+
end_line=i,
|
|
265
|
+
is_code_fence=True,
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
i += 1 # Skip closing ```
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
# JSON block
|
|
272
|
+
if line.strip().startswith(("[", "{")):
|
|
273
|
+
json_content, end_i = _extract_json_block(lines, i)
|
|
274
|
+
if json_content:
|
|
275
|
+
sections.append(
|
|
276
|
+
ContentSection(
|
|
277
|
+
content=json_content,
|
|
278
|
+
content_type=ContentType.JSON_ARRAY,
|
|
279
|
+
start_line=i,
|
|
280
|
+
end_line=end_i,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
i = end_i + 1
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
# Search result lines
|
|
287
|
+
if _SEARCH_RESULT_PATTERN.match(line):
|
|
288
|
+
search_lines = []
|
|
289
|
+
start_line = i
|
|
290
|
+
while i < len(lines) and _SEARCH_RESULT_PATTERN.match(lines[i]):
|
|
291
|
+
search_lines.append(lines[i])
|
|
292
|
+
i += 1
|
|
293
|
+
sections.append(
|
|
294
|
+
ContentSection(
|
|
295
|
+
content="\n".join(search_lines),
|
|
296
|
+
content_type=ContentType.SEARCH_RESULTS,
|
|
297
|
+
start_line=start_line,
|
|
298
|
+
end_line=i - 1,
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
# Collect text until next special section
|
|
304
|
+
text_lines = [line]
|
|
305
|
+
start_line = i
|
|
306
|
+
i += 1
|
|
307
|
+
|
|
308
|
+
while i < len(lines):
|
|
309
|
+
next_line = lines[i]
|
|
310
|
+
# Stop if we hit a special section
|
|
311
|
+
if (
|
|
312
|
+
_CODE_FENCE_PATTERN.match(next_line)
|
|
313
|
+
or next_line.strip().startswith(("[", "{"))
|
|
314
|
+
or _SEARCH_RESULT_PATTERN.match(next_line)
|
|
315
|
+
):
|
|
316
|
+
break
|
|
317
|
+
text_lines.append(next_line)
|
|
318
|
+
i += 1
|
|
319
|
+
|
|
320
|
+
# Only add non-empty text sections
|
|
321
|
+
text_content = "\n".join(text_lines)
|
|
322
|
+
if text_content.strip():
|
|
323
|
+
sections.append(
|
|
324
|
+
ContentSection(
|
|
325
|
+
content=text_content,
|
|
326
|
+
content_type=ContentType.PLAIN_TEXT,
|
|
327
|
+
start_line=start_line,
|
|
328
|
+
end_line=i - 1,
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return sections
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _extract_json_block(lines: list[str], start: int) -> tuple[str | None, int]:
|
|
336
|
+
"""Extract a complete JSON block from lines.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
lines: All lines of content.
|
|
340
|
+
start: Starting line index.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
Tuple of (json_content, end_line_index) or (None, start) if invalid.
|
|
344
|
+
"""
|
|
345
|
+
bracket_count = 0
|
|
346
|
+
brace_count = 0
|
|
347
|
+
json_lines = []
|
|
348
|
+
|
|
349
|
+
for i in range(start, len(lines)):
|
|
350
|
+
line = lines[i]
|
|
351
|
+
json_lines.append(line)
|
|
352
|
+
|
|
353
|
+
bracket_count += line.count("[") - line.count("]")
|
|
354
|
+
brace_count += line.count("{") - line.count("}")
|
|
355
|
+
|
|
356
|
+
if bracket_count <= 0 and brace_count <= 0 and json_lines:
|
|
357
|
+
return "\n".join(json_lines), i
|
|
358
|
+
|
|
359
|
+
# Didn't find complete JSON
|
|
360
|
+
return None, start
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def generate_source_hint(tool_name: str, tool_input: dict[str, Any]) -> str:
|
|
364
|
+
"""Generate a source hint from tool metadata.
|
|
365
|
+
|
|
366
|
+
This enables higher-confidence routing decisions.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
tool_name: Name of the tool that produced the output.
|
|
370
|
+
tool_input: Input parameters to the tool.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
Source hint string (e.g., "file:auth.py", "tool:grep").
|
|
374
|
+
"""
|
|
375
|
+
# File read operations
|
|
376
|
+
if tool_name in ("Read", "read_file", "cat", "ReadFile"):
|
|
377
|
+
file_path = tool_input.get("file_path", tool_input.get("path", ""))
|
|
378
|
+
if file_path:
|
|
379
|
+
return f"file:{file_path}"
|
|
380
|
+
|
|
381
|
+
# Search operations
|
|
382
|
+
if tool_name in ("Grep", "grep", "ripgrep", "rg", "search", "Search"):
|
|
383
|
+
return "tool:grep"
|
|
384
|
+
|
|
385
|
+
# Glob operations
|
|
386
|
+
if tool_name in ("Glob", "glob", "find"):
|
|
387
|
+
return "tool:glob"
|
|
388
|
+
|
|
389
|
+
# Build/test operations
|
|
390
|
+
if tool_name == "Bash":
|
|
391
|
+
command = str(tool_input.get("command", ""))
|
|
392
|
+
if any(cmd in command for cmd in ["pytest", "npm test", "cargo test", "go test"]):
|
|
393
|
+
return "tool:pytest"
|
|
394
|
+
if any(cmd in command for cmd in ["npm run build", "cargo build", "make"]):
|
|
395
|
+
return "tool:build"
|
|
396
|
+
if "git diff" in command:
|
|
397
|
+
return "tool:git-diff"
|
|
398
|
+
if "git log" in command:
|
|
399
|
+
return "tool:git-log"
|
|
400
|
+
|
|
401
|
+
# Web fetch
|
|
402
|
+
if tool_name in ("WebFetch", "fetch", "curl", "WebSearch"):
|
|
403
|
+
return "tool:web"
|
|
404
|
+
|
|
405
|
+
return ""
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class ContentRouter(Transform):
|
|
409
|
+
"""Intelligent router that selects optimal compression strategy.
|
|
410
|
+
|
|
411
|
+
ContentRouter is the recommended entry point for Headroom's compression.
|
|
412
|
+
It analyzes content and routes it to the most appropriate compressor,
|
|
413
|
+
handling mixed content by splitting and reassembling.
|
|
414
|
+
|
|
415
|
+
Key Features:
|
|
416
|
+
- Automatic content type detection
|
|
417
|
+
- Source hint support for high-confidence routing
|
|
418
|
+
- Mixed content handling (split → route → reassemble)
|
|
419
|
+
- Graceful fallback when compressors unavailable
|
|
420
|
+
- Rich routing metadata for debugging
|
|
421
|
+
|
|
422
|
+
Example:
|
|
423
|
+
>>> router = ContentRouter()
|
|
424
|
+
>>>
|
|
425
|
+
>>> # Automatically uses CodeAwareCompressor
|
|
426
|
+
>>> result = router.compress(python_code)
|
|
427
|
+
>>> print(result.strategy_used) # CompressionStrategy.CODE_AWARE
|
|
428
|
+
>>>
|
|
429
|
+
>>> # Automatically uses SmartCrusher
|
|
430
|
+
>>> result = router.compress(json_array)
|
|
431
|
+
>>> print(result.strategy_used) # CompressionStrategy.SMART_CRUSHER
|
|
432
|
+
>>>
|
|
433
|
+
>>> # Splits and routes each section
|
|
434
|
+
>>> result = router.compress(readme_with_code)
|
|
435
|
+
>>> print(result.strategy_used) # CompressionStrategy.MIXED
|
|
436
|
+
|
|
437
|
+
Pipeline Integration:
|
|
438
|
+
>>> pipeline = TransformPipeline([
|
|
439
|
+
... ContentRouter(), # Handles ALL content types
|
|
440
|
+
... RollingWindow(), # Final size constraint
|
|
441
|
+
... ])
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
name: str = "content_router"
|
|
445
|
+
|
|
446
|
+
def __init__(self, config: ContentRouterConfig | None = None):
|
|
447
|
+
"""Initialize content router.
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
config: Router configuration. Uses defaults if None.
|
|
451
|
+
"""
|
|
452
|
+
self.config = config or ContentRouterConfig()
|
|
453
|
+
|
|
454
|
+
# Lazy-loaded compressors
|
|
455
|
+
self._code_compressor: Any = None
|
|
456
|
+
self._smart_crusher: Any = None
|
|
457
|
+
self._search_compressor: Any = None
|
|
458
|
+
self._log_compressor: Any = None
|
|
459
|
+
self._llmlingua: Any = None
|
|
460
|
+
self._text_compressor: Any = None
|
|
461
|
+
|
|
462
|
+
def compress(
|
|
463
|
+
self,
|
|
464
|
+
content: str,
|
|
465
|
+
source_hint: str | None = None,
|
|
466
|
+
context: str = "",
|
|
467
|
+
) -> RouterCompressionResult:
|
|
468
|
+
"""Compress content using optimal strategy.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
content: Content to compress.
|
|
472
|
+
source_hint: Optional hint about content source.
|
|
473
|
+
Examples: "file:auth.py", "tool:grep", "tool:pytest"
|
|
474
|
+
context: Optional context for relevance-aware compression.
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
RouterCompressionResult with compressed content and routing metadata.
|
|
478
|
+
"""
|
|
479
|
+
if not content or not content.strip():
|
|
480
|
+
return RouterCompressionResult(
|
|
481
|
+
compressed=content,
|
|
482
|
+
original=content,
|
|
483
|
+
strategy_used=CompressionStrategy.PASSTHROUGH,
|
|
484
|
+
routing_log=[],
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
# Determine strategy
|
|
488
|
+
strategy = self._determine_strategy(content, source_hint)
|
|
489
|
+
|
|
490
|
+
if strategy == CompressionStrategy.MIXED:
|
|
491
|
+
return self._compress_mixed(content, context)
|
|
492
|
+
else:
|
|
493
|
+
return self._compress_pure(content, strategy, context)
|
|
494
|
+
|
|
495
|
+
def _determine_strategy(
|
|
496
|
+
self,
|
|
497
|
+
content: str,
|
|
498
|
+
source_hint: str | None,
|
|
499
|
+
) -> CompressionStrategy:
|
|
500
|
+
"""Determine the compression strategy.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
content: Content to analyze.
|
|
504
|
+
source_hint: Optional source hint.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
Selected compression strategy.
|
|
508
|
+
"""
|
|
509
|
+
# 1. Source hint takes priority
|
|
510
|
+
if source_hint:
|
|
511
|
+
strategy = self._strategy_from_hint(source_hint)
|
|
512
|
+
if strategy:
|
|
513
|
+
return strategy
|
|
514
|
+
|
|
515
|
+
# 2. Check for mixed content
|
|
516
|
+
if is_mixed_content(content):
|
|
517
|
+
return CompressionStrategy.MIXED
|
|
518
|
+
|
|
519
|
+
# 3. Detect content type
|
|
520
|
+
detection = detect_content_type(content)
|
|
521
|
+
return self._strategy_from_detection(detection)
|
|
522
|
+
|
|
523
|
+
def _strategy_from_hint(self, hint: str) -> CompressionStrategy | None:
|
|
524
|
+
"""Get strategy from source hint.
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
hint: Source hint string.
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
Strategy if determinable, None otherwise.
|
|
531
|
+
"""
|
|
532
|
+
hint_lower = hint.lower()
|
|
533
|
+
|
|
534
|
+
# File hints
|
|
535
|
+
if hint_lower.startswith("file:"):
|
|
536
|
+
file_path = hint_lower[5:]
|
|
537
|
+
if file_path.endswith((".py", ".pyw")):
|
|
538
|
+
return CompressionStrategy.CODE_AWARE
|
|
539
|
+
if file_path.endswith((".js", ".jsx", ".ts", ".tsx", ".mjs")):
|
|
540
|
+
return CompressionStrategy.CODE_AWARE
|
|
541
|
+
if file_path.endswith((".go", ".rs", ".java", ".c", ".cpp", ".h", ".hpp")):
|
|
542
|
+
return CompressionStrategy.CODE_AWARE
|
|
543
|
+
if file_path.endswith(".json"):
|
|
544
|
+
return CompressionStrategy.SMART_CRUSHER
|
|
545
|
+
if file_path.endswith((".md", ".txt", ".rst")):
|
|
546
|
+
return CompressionStrategy.TEXT
|
|
547
|
+
if file_path.endswith((".log", ".out")):
|
|
548
|
+
return CompressionStrategy.LOG
|
|
549
|
+
|
|
550
|
+
# Tool hints
|
|
551
|
+
if hint_lower.startswith("tool:"):
|
|
552
|
+
tool = hint_lower[5:]
|
|
553
|
+
if tool in ("grep", "rg", "ripgrep", "ag", "search"):
|
|
554
|
+
return CompressionStrategy.SEARCH
|
|
555
|
+
if tool in ("pytest", "jest", "cargo-test", "go-test", "npm-test"):
|
|
556
|
+
return CompressionStrategy.LOG
|
|
557
|
+
if tool in ("build", "make", "cargo-build", "npm-build"):
|
|
558
|
+
return CompressionStrategy.LOG
|
|
559
|
+
if tool in ("git-diff", "diff"):
|
|
560
|
+
return CompressionStrategy.DIFF
|
|
561
|
+
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
def _strategy_from_detection(self, detection: Any) -> CompressionStrategy:
|
|
565
|
+
"""Get strategy from content detection result.
|
|
566
|
+
|
|
567
|
+
Args:
|
|
568
|
+
detection: Result from detect_content_type.
|
|
569
|
+
|
|
570
|
+
Returns:
|
|
571
|
+
Selected strategy.
|
|
572
|
+
"""
|
|
573
|
+
mapping = {
|
|
574
|
+
ContentType.SOURCE_CODE: CompressionStrategy.CODE_AWARE,
|
|
575
|
+
ContentType.JSON_ARRAY: CompressionStrategy.SMART_CRUSHER,
|
|
576
|
+
ContentType.SEARCH_RESULTS: CompressionStrategy.SEARCH,
|
|
577
|
+
ContentType.BUILD_OUTPUT: CompressionStrategy.LOG,
|
|
578
|
+
ContentType.GIT_DIFF: CompressionStrategy.DIFF,
|
|
579
|
+
ContentType.PLAIN_TEXT: CompressionStrategy.TEXT,
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
strategy = mapping.get(detection.content_type, self.config.fallback_strategy)
|
|
583
|
+
|
|
584
|
+
# Override: prefer CodeAware for code if configured
|
|
585
|
+
if (
|
|
586
|
+
strategy == CompressionStrategy.CODE_AWARE
|
|
587
|
+
and not self.config.prefer_code_aware_for_code
|
|
588
|
+
):
|
|
589
|
+
strategy = CompressionStrategy.LLMLINGUA
|
|
590
|
+
|
|
591
|
+
return strategy
|
|
592
|
+
|
|
593
|
+
def _compress_mixed(
|
|
594
|
+
self,
|
|
595
|
+
content: str,
|
|
596
|
+
context: str,
|
|
597
|
+
) -> RouterCompressionResult:
|
|
598
|
+
"""Compress mixed content by splitting and routing sections.
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
content: Mixed content to compress.
|
|
602
|
+
context: User context for relevance.
|
|
603
|
+
|
|
604
|
+
Returns:
|
|
605
|
+
RouterCompressionResult with reassembled content.
|
|
606
|
+
"""
|
|
607
|
+
sections = split_into_sections(content)
|
|
608
|
+
|
|
609
|
+
if not sections:
|
|
610
|
+
return RouterCompressionResult(
|
|
611
|
+
compressed=content,
|
|
612
|
+
original=content,
|
|
613
|
+
strategy_used=CompressionStrategy.PASSTHROUGH,
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
compressed_sections: list[str] = []
|
|
617
|
+
routing_log: list[RoutingDecision] = []
|
|
618
|
+
|
|
619
|
+
for i, section in enumerate(sections):
|
|
620
|
+
# Get strategy for this section
|
|
621
|
+
strategy = self._strategy_from_detection_type(section.content_type)
|
|
622
|
+
|
|
623
|
+
# Compress section
|
|
624
|
+
original_tokens = len(section.content.split())
|
|
625
|
+
compressed_content, compressed_tokens = self._apply_strategy_to_content(
|
|
626
|
+
section.content, strategy, context, section.language
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
# Preserve code fence markers
|
|
630
|
+
if section.is_code_fence and section.language:
|
|
631
|
+
compressed_content = f"```{section.language}\n{compressed_content}\n```"
|
|
632
|
+
|
|
633
|
+
compressed_sections.append(compressed_content)
|
|
634
|
+
routing_log.append(
|
|
635
|
+
RoutingDecision(
|
|
636
|
+
content_type=section.content_type,
|
|
637
|
+
strategy=strategy,
|
|
638
|
+
original_tokens=original_tokens,
|
|
639
|
+
compressed_tokens=compressed_tokens,
|
|
640
|
+
section_index=i,
|
|
641
|
+
)
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
return RouterCompressionResult(
|
|
645
|
+
compressed="\n\n".join(compressed_sections),
|
|
646
|
+
original=content,
|
|
647
|
+
strategy_used=CompressionStrategy.MIXED,
|
|
648
|
+
routing_log=routing_log,
|
|
649
|
+
sections_processed=len(sections),
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
def _compress_pure(
|
|
653
|
+
self,
|
|
654
|
+
content: str,
|
|
655
|
+
strategy: CompressionStrategy,
|
|
656
|
+
context: str,
|
|
657
|
+
) -> RouterCompressionResult:
|
|
658
|
+
"""Compress pure (non-mixed) content.
|
|
659
|
+
|
|
660
|
+
Args:
|
|
661
|
+
content: Content to compress.
|
|
662
|
+
strategy: Selected strategy.
|
|
663
|
+
context: User context.
|
|
664
|
+
|
|
665
|
+
Returns:
|
|
666
|
+
RouterCompressionResult.
|
|
667
|
+
"""
|
|
668
|
+
original_tokens = len(content.split())
|
|
669
|
+
|
|
670
|
+
compressed, compressed_tokens = self._apply_strategy_to_content(content, strategy, context)
|
|
671
|
+
|
|
672
|
+
return RouterCompressionResult(
|
|
673
|
+
compressed=compressed,
|
|
674
|
+
original=content,
|
|
675
|
+
strategy_used=strategy,
|
|
676
|
+
routing_log=[
|
|
677
|
+
RoutingDecision(
|
|
678
|
+
content_type=self._content_type_from_strategy(strategy),
|
|
679
|
+
strategy=strategy,
|
|
680
|
+
original_tokens=original_tokens,
|
|
681
|
+
compressed_tokens=compressed_tokens,
|
|
682
|
+
)
|
|
683
|
+
],
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
def _apply_strategy_to_content(
|
|
687
|
+
self,
|
|
688
|
+
content: str,
|
|
689
|
+
strategy: CompressionStrategy,
|
|
690
|
+
context: str,
|
|
691
|
+
language: str | None = None,
|
|
692
|
+
) -> tuple[str, int]:
|
|
693
|
+
"""Apply a compression strategy to content.
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
content: Content to compress.
|
|
697
|
+
strategy: Strategy to use.
|
|
698
|
+
context: User context.
|
|
699
|
+
language: Language hint for code.
|
|
700
|
+
|
|
701
|
+
Returns:
|
|
702
|
+
Tuple of (compressed_content, compressed_token_count).
|
|
703
|
+
"""
|
|
704
|
+
try:
|
|
705
|
+
if strategy == CompressionStrategy.CODE_AWARE:
|
|
706
|
+
if self.config.enable_code_aware:
|
|
707
|
+
compressor = self._get_code_compressor()
|
|
708
|
+
if compressor:
|
|
709
|
+
result = compressor.compress(content, language=language, context=context)
|
|
710
|
+
return result.compressed, result.compressed_tokens
|
|
711
|
+
# Fallback to LLMLingua
|
|
712
|
+
return self._try_llmlingua(content, context)
|
|
713
|
+
|
|
714
|
+
elif strategy == CompressionStrategy.SMART_CRUSHER:
|
|
715
|
+
if self.config.enable_smart_crusher:
|
|
716
|
+
crusher = self._get_smart_crusher()
|
|
717
|
+
if crusher:
|
|
718
|
+
result = crusher.crush(content, query=context)
|
|
719
|
+
return result.compressed, len(result.compressed.split())
|
|
720
|
+
|
|
721
|
+
elif strategy == CompressionStrategy.SEARCH:
|
|
722
|
+
if self.config.enable_search_compressor:
|
|
723
|
+
compressor = self._get_search_compressor()
|
|
724
|
+
if compressor:
|
|
725
|
+
result = compressor.compress(content, context=context)
|
|
726
|
+
return result.compressed, len(result.compressed.split())
|
|
727
|
+
|
|
728
|
+
elif strategy == CompressionStrategy.LOG:
|
|
729
|
+
if self.config.enable_log_compressor:
|
|
730
|
+
compressor = self._get_log_compressor()
|
|
731
|
+
if compressor:
|
|
732
|
+
result = compressor.compress(content)
|
|
733
|
+
return result.compressed, result.compressed_line_count
|
|
734
|
+
|
|
735
|
+
elif strategy == CompressionStrategy.LLMLINGUA:
|
|
736
|
+
return self._try_llmlingua(content, context)
|
|
737
|
+
|
|
738
|
+
elif strategy == CompressionStrategy.TEXT:
|
|
739
|
+
compressor = self._get_text_compressor()
|
|
740
|
+
if compressor:
|
|
741
|
+
result = compressor.compress(content, context=context)
|
|
742
|
+
return result.compressed, result.compressed_line_count
|
|
743
|
+
|
|
744
|
+
except Exception as e:
|
|
745
|
+
logger.warning("Compression with %s failed: %s", strategy.value, e)
|
|
746
|
+
|
|
747
|
+
# Fallback: return unchanged
|
|
748
|
+
return content, len(content.split())
|
|
749
|
+
|
|
750
|
+
def _try_llmlingua(self, content: str, context: str) -> tuple[str, int]:
|
|
751
|
+
"""Try LLMLingua compression with fallback.
|
|
752
|
+
|
|
753
|
+
Args:
|
|
754
|
+
content: Content to compress.
|
|
755
|
+
context: User context.
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
Tuple of (compressed, token_count).
|
|
759
|
+
"""
|
|
760
|
+
if self.config.enable_llmlingua:
|
|
761
|
+
compressor = self._get_llmlingua()
|
|
762
|
+
if compressor:
|
|
763
|
+
try:
|
|
764
|
+
result = compressor.compress(content, context=context)
|
|
765
|
+
return result.compressed, result.compressed_tokens
|
|
766
|
+
except Exception as e:
|
|
767
|
+
logger.debug("LLMLingua failed: %s", e)
|
|
768
|
+
|
|
769
|
+
# Fallback to text compressor
|
|
770
|
+
compressor = self._get_text_compressor()
|
|
771
|
+
if compressor:
|
|
772
|
+
result = compressor.compress(content, context=context)
|
|
773
|
+
return result.compressed, result.compressed_line_count
|
|
774
|
+
|
|
775
|
+
return content, len(content.split())
|
|
776
|
+
|
|
777
|
+
def _strategy_from_detection_type(self, content_type: ContentType) -> CompressionStrategy:
|
|
778
|
+
"""Get strategy from ContentType enum."""
|
|
779
|
+
mapping = {
|
|
780
|
+
ContentType.SOURCE_CODE: CompressionStrategy.CODE_AWARE,
|
|
781
|
+
ContentType.JSON_ARRAY: CompressionStrategy.SMART_CRUSHER,
|
|
782
|
+
ContentType.SEARCH_RESULTS: CompressionStrategy.SEARCH,
|
|
783
|
+
ContentType.BUILD_OUTPUT: CompressionStrategy.LOG,
|
|
784
|
+
ContentType.GIT_DIFF: CompressionStrategy.DIFF,
|
|
785
|
+
ContentType.PLAIN_TEXT: CompressionStrategy.TEXT,
|
|
786
|
+
}
|
|
787
|
+
return mapping.get(content_type, self.config.fallback_strategy)
|
|
788
|
+
|
|
789
|
+
def _content_type_from_strategy(self, strategy: CompressionStrategy) -> ContentType:
|
|
790
|
+
"""Get ContentType from strategy."""
|
|
791
|
+
mapping = {
|
|
792
|
+
CompressionStrategy.CODE_AWARE: ContentType.SOURCE_CODE,
|
|
793
|
+
CompressionStrategy.SMART_CRUSHER: ContentType.JSON_ARRAY,
|
|
794
|
+
CompressionStrategy.SEARCH: ContentType.SEARCH_RESULTS,
|
|
795
|
+
CompressionStrategy.LOG: ContentType.BUILD_OUTPUT,
|
|
796
|
+
CompressionStrategy.DIFF: ContentType.GIT_DIFF,
|
|
797
|
+
CompressionStrategy.TEXT: ContentType.PLAIN_TEXT,
|
|
798
|
+
CompressionStrategy.LLMLINGUA: ContentType.PLAIN_TEXT,
|
|
799
|
+
CompressionStrategy.PASSTHROUGH: ContentType.PLAIN_TEXT,
|
|
800
|
+
}
|
|
801
|
+
return mapping.get(strategy, ContentType.PLAIN_TEXT)
|
|
802
|
+
|
|
803
|
+
# Lazy compressor getters
|
|
804
|
+
|
|
805
|
+
def _get_code_compressor(self) -> Any:
|
|
806
|
+
"""Get CodeAwareCompressor (lazy load)."""
|
|
807
|
+
if self._code_compressor is None:
|
|
808
|
+
try:
|
|
809
|
+
from .code_compressor import CodeAwareCompressor, _check_tree_sitter_available
|
|
810
|
+
|
|
811
|
+
if _check_tree_sitter_available():
|
|
812
|
+
self._code_compressor = CodeAwareCompressor()
|
|
813
|
+
else:
|
|
814
|
+
logger.debug("tree-sitter not available")
|
|
815
|
+
except ImportError:
|
|
816
|
+
logger.debug("CodeAwareCompressor not available")
|
|
817
|
+
return self._code_compressor
|
|
818
|
+
|
|
819
|
+
def _get_smart_crusher(self) -> Any:
|
|
820
|
+
"""Get SmartCrusher (lazy load) with CCR config."""
|
|
821
|
+
if self._smart_crusher is None:
|
|
822
|
+
try:
|
|
823
|
+
from ..config import CCRConfig
|
|
824
|
+
from .smart_crusher import SmartCrusher
|
|
825
|
+
|
|
826
|
+
# Pass CCR config for marker injection
|
|
827
|
+
ccr_config = CCRConfig(
|
|
828
|
+
enabled=self.config.ccr_enabled,
|
|
829
|
+
inject_retrieval_marker=self.config.ccr_inject_marker,
|
|
830
|
+
)
|
|
831
|
+
self._smart_crusher = SmartCrusher(ccr_config=ccr_config)
|
|
832
|
+
except ImportError:
|
|
833
|
+
logger.debug("SmartCrusher not available")
|
|
834
|
+
return self._smart_crusher
|
|
835
|
+
|
|
836
|
+
def _get_search_compressor(self) -> Any:
|
|
837
|
+
"""Get SearchCompressor (lazy load)."""
|
|
838
|
+
if self._search_compressor is None:
|
|
839
|
+
try:
|
|
840
|
+
from .search_compressor import SearchCompressor
|
|
841
|
+
|
|
842
|
+
self._search_compressor = SearchCompressor()
|
|
843
|
+
except ImportError:
|
|
844
|
+
logger.debug("SearchCompressor not available")
|
|
845
|
+
return self._search_compressor
|
|
846
|
+
|
|
847
|
+
def _get_log_compressor(self) -> Any:
|
|
848
|
+
"""Get LogCompressor (lazy load)."""
|
|
849
|
+
if self._log_compressor is None:
|
|
850
|
+
try:
|
|
851
|
+
from .log_compressor import LogCompressor
|
|
852
|
+
|
|
853
|
+
self._log_compressor = LogCompressor()
|
|
854
|
+
except ImportError:
|
|
855
|
+
logger.debug("LogCompressor not available")
|
|
856
|
+
return self._log_compressor
|
|
857
|
+
|
|
858
|
+
def _get_llmlingua(self) -> Any:
|
|
859
|
+
"""Get LLMLinguaCompressor (lazy load)."""
|
|
860
|
+
if self._llmlingua is None:
|
|
861
|
+
try:
|
|
862
|
+
from .llmlingua_compressor import (
|
|
863
|
+
LLMLinguaCompressor,
|
|
864
|
+
_check_llmlingua_available,
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
if _check_llmlingua_available():
|
|
868
|
+
self._llmlingua = LLMLinguaCompressor()
|
|
869
|
+
except ImportError:
|
|
870
|
+
logger.debug("LLMLinguaCompressor not available")
|
|
871
|
+
return self._llmlingua
|
|
872
|
+
|
|
873
|
+
def _get_text_compressor(self) -> Any:
|
|
874
|
+
"""Get TextCompressor (lazy load)."""
|
|
875
|
+
if self._text_compressor is None:
|
|
876
|
+
try:
|
|
877
|
+
from .text_compressor import TextCompressor
|
|
878
|
+
|
|
879
|
+
self._text_compressor = TextCompressor()
|
|
880
|
+
except ImportError:
|
|
881
|
+
logger.debug("TextCompressor not available")
|
|
882
|
+
return self._text_compressor
|
|
883
|
+
|
|
884
|
+
# Transform interface
|
|
885
|
+
|
|
886
|
+
def apply(
|
|
887
|
+
self,
|
|
888
|
+
messages: list[dict[str, Any]],
|
|
889
|
+
tokenizer: Tokenizer,
|
|
890
|
+
**kwargs: Any,
|
|
891
|
+
) -> TransformResult:
|
|
892
|
+
"""Apply intelligent routing to messages.
|
|
893
|
+
|
|
894
|
+
Args:
|
|
895
|
+
messages: Messages to transform.
|
|
896
|
+
tokenizer: Tokenizer for counting.
|
|
897
|
+
**kwargs: Additional arguments (context, source_hints).
|
|
898
|
+
|
|
899
|
+
Returns:
|
|
900
|
+
TransformResult with routed and compressed messages.
|
|
901
|
+
"""
|
|
902
|
+
tokens_before = sum(tokenizer.count_text(str(m.get("content", ""))) for m in messages)
|
|
903
|
+
context = kwargs.get("context", "")
|
|
904
|
+
source_hints = kwargs.get("source_hints", {}) # message_id -> hint
|
|
905
|
+
|
|
906
|
+
transformed_messages: list[dict[str, Any]] = []
|
|
907
|
+
transforms_applied: list[str] = []
|
|
908
|
+
warnings: list[str] = []
|
|
909
|
+
|
|
910
|
+
# Check for analysis intent in the most recent user message
|
|
911
|
+
analysis_intent = False
|
|
912
|
+
if self.config.protect_analysis_context:
|
|
913
|
+
analysis_intent = self._detect_analysis_intent(messages)
|
|
914
|
+
|
|
915
|
+
num_messages = len(messages)
|
|
916
|
+
|
|
917
|
+
for i, message in enumerate(messages):
|
|
918
|
+
role = message.get("role", "")
|
|
919
|
+
content = message.get("content", "")
|
|
920
|
+
|
|
921
|
+
# Handle list content (Anthropic format with content blocks)
|
|
922
|
+
if isinstance(content, list):
|
|
923
|
+
transformed_message = self._process_content_blocks(
|
|
924
|
+
message, content, context, transforms_applied
|
|
925
|
+
)
|
|
926
|
+
transformed_messages.append(transformed_message)
|
|
927
|
+
continue
|
|
928
|
+
|
|
929
|
+
# Skip non-string content (other types)
|
|
930
|
+
if not isinstance(content, str):
|
|
931
|
+
transformed_messages.append(message)
|
|
932
|
+
continue
|
|
933
|
+
|
|
934
|
+
# Protection 1: Never compress user messages
|
|
935
|
+
if self.config.skip_user_messages and role == "user":
|
|
936
|
+
transformed_messages.append(message)
|
|
937
|
+
transforms_applied.append("router:protected:user_message")
|
|
938
|
+
continue
|
|
939
|
+
|
|
940
|
+
if not content or len(content.split()) < 50:
|
|
941
|
+
# Skip small content
|
|
942
|
+
transformed_messages.append(message)
|
|
943
|
+
continue
|
|
944
|
+
|
|
945
|
+
# Get source hint if available
|
|
946
|
+
source_hint = source_hints.get(i) or source_hints.get(str(i))
|
|
947
|
+
|
|
948
|
+
# Detect content type for protection decisions
|
|
949
|
+
detection = detect_content_type(content)
|
|
950
|
+
is_code = detection.content_type == ContentType.SOURCE_CODE
|
|
951
|
+
|
|
952
|
+
# Protection 2: Don't compress recent CODE
|
|
953
|
+
messages_from_end = num_messages - i
|
|
954
|
+
if (
|
|
955
|
+
self.config.protect_recent_code > 0
|
|
956
|
+
and messages_from_end <= self.config.protect_recent_code
|
|
957
|
+
and is_code
|
|
958
|
+
):
|
|
959
|
+
transformed_messages.append(message)
|
|
960
|
+
transforms_applied.append("router:protected:recent_code")
|
|
961
|
+
continue
|
|
962
|
+
|
|
963
|
+
# Protection 3: Don't compress CODE when analysis intent detected
|
|
964
|
+
if analysis_intent and is_code:
|
|
965
|
+
transformed_messages.append(message)
|
|
966
|
+
transforms_applied.append("router:protected:analysis_context")
|
|
967
|
+
continue
|
|
968
|
+
|
|
969
|
+
# Route and compress
|
|
970
|
+
result = self.compress(content, source_hint=source_hint, context=context)
|
|
971
|
+
|
|
972
|
+
if result.compression_ratio < 0.9:
|
|
973
|
+
transformed_messages.append({**message, "content": result.compressed})
|
|
974
|
+
transforms_applied.append(
|
|
975
|
+
f"router:{result.strategy_used.value}:{result.compression_ratio:.2f}"
|
|
976
|
+
)
|
|
977
|
+
else:
|
|
978
|
+
transformed_messages.append(message)
|
|
979
|
+
|
|
980
|
+
tokens_after = sum(
|
|
981
|
+
tokenizer.count_text(str(m.get("content", ""))) for m in transformed_messages
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
return TransformResult(
|
|
985
|
+
messages=transformed_messages,
|
|
986
|
+
tokens_before=tokens_before,
|
|
987
|
+
tokens_after=tokens_after,
|
|
988
|
+
transforms_applied=transforms_applied if transforms_applied else ["router:noop"],
|
|
989
|
+
warnings=warnings,
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
def _process_content_blocks(
|
|
993
|
+
self,
|
|
994
|
+
message: dict[str, Any],
|
|
995
|
+
content_blocks: list[Any],
|
|
996
|
+
context: str,
|
|
997
|
+
transforms_applied: list[str],
|
|
998
|
+
) -> dict[str, Any]:
|
|
999
|
+
"""Process content blocks (Anthropic format) for tool_result compression.
|
|
1000
|
+
|
|
1001
|
+
Handles tool_result blocks by compressing their string content using
|
|
1002
|
+
the appropriate strategy (typically SmartCrusher for JSON arrays).
|
|
1003
|
+
|
|
1004
|
+
Args:
|
|
1005
|
+
message: The original message.
|
|
1006
|
+
content_blocks: List of content blocks.
|
|
1007
|
+
context: Context for compression.
|
|
1008
|
+
transforms_applied: List to append transform names to.
|
|
1009
|
+
|
|
1010
|
+
Returns:
|
|
1011
|
+
Transformed message with compressed content blocks.
|
|
1012
|
+
"""
|
|
1013
|
+
import json
|
|
1014
|
+
|
|
1015
|
+
new_blocks = []
|
|
1016
|
+
any_compressed = False
|
|
1017
|
+
|
|
1018
|
+
for block in content_blocks:
|
|
1019
|
+
if not isinstance(block, dict):
|
|
1020
|
+
new_blocks.append(block)
|
|
1021
|
+
continue
|
|
1022
|
+
|
|
1023
|
+
block_type = block.get("type")
|
|
1024
|
+
|
|
1025
|
+
# Handle tool_result blocks
|
|
1026
|
+
if block_type == "tool_result":
|
|
1027
|
+
tool_content = block.get("content", "")
|
|
1028
|
+
|
|
1029
|
+
# Only process string content
|
|
1030
|
+
if isinstance(tool_content, str) and len(tool_content) > 500:
|
|
1031
|
+
# Try to detect if it's JSON array data (SmartCrusher target)
|
|
1032
|
+
try:
|
|
1033
|
+
parsed = json.loads(tool_content)
|
|
1034
|
+
if isinstance(parsed, list) and len(parsed) > 10:
|
|
1035
|
+
# Route to SmartCrusher for arrays
|
|
1036
|
+
result = self.compress(
|
|
1037
|
+
tool_content,
|
|
1038
|
+
source_hint="json_array",
|
|
1039
|
+
context=context,
|
|
1040
|
+
)
|
|
1041
|
+
if result.compression_ratio < 0.9:
|
|
1042
|
+
new_blocks.append(
|
|
1043
|
+
{
|
|
1044
|
+
**block,
|
|
1045
|
+
"content": result.compressed,
|
|
1046
|
+
}
|
|
1047
|
+
)
|
|
1048
|
+
transforms_applied.append(
|
|
1049
|
+
f"router:tool_result:{result.strategy_used.value}"
|
|
1050
|
+
)
|
|
1051
|
+
any_compressed = True
|
|
1052
|
+
continue
|
|
1053
|
+
except (json.JSONDecodeError, TypeError):
|
|
1054
|
+
# Not JSON, try general compression
|
|
1055
|
+
pass
|
|
1056
|
+
|
|
1057
|
+
# Try general compression for large non-JSON content
|
|
1058
|
+
result = self.compress(tool_content, context=context)
|
|
1059
|
+
if result.compression_ratio < 0.9:
|
|
1060
|
+
new_blocks.append({**block, "content": result.compressed})
|
|
1061
|
+
transforms_applied.append(
|
|
1062
|
+
f"router:tool_result:{result.strategy_used.value}"
|
|
1063
|
+
)
|
|
1064
|
+
any_compressed = True
|
|
1065
|
+
continue
|
|
1066
|
+
|
|
1067
|
+
# Keep block unchanged
|
|
1068
|
+
new_blocks.append(block)
|
|
1069
|
+
|
|
1070
|
+
if any_compressed:
|
|
1071
|
+
return {**message, "content": new_blocks}
|
|
1072
|
+
return message
|
|
1073
|
+
|
|
1074
|
+
def _detect_analysis_intent(self, messages: list[dict[str, Any]]) -> bool:
|
|
1075
|
+
"""Detect if user wants to analyze/review code.
|
|
1076
|
+
|
|
1077
|
+
Looks at the most recent user message for analysis keywords.
|
|
1078
|
+
|
|
1079
|
+
Args:
|
|
1080
|
+
messages: Conversation messages.
|
|
1081
|
+
|
|
1082
|
+
Returns:
|
|
1083
|
+
True if analysis intent detected.
|
|
1084
|
+
"""
|
|
1085
|
+
# Analysis keywords that suggest user wants full code details
|
|
1086
|
+
analysis_keywords = {
|
|
1087
|
+
"analyze",
|
|
1088
|
+
"analyse",
|
|
1089
|
+
"review",
|
|
1090
|
+
"audit",
|
|
1091
|
+
"inspect",
|
|
1092
|
+
"security",
|
|
1093
|
+
"vulnerability",
|
|
1094
|
+
"bug",
|
|
1095
|
+
"issue",
|
|
1096
|
+
"problem",
|
|
1097
|
+
"explain",
|
|
1098
|
+
"understand",
|
|
1099
|
+
"how does",
|
|
1100
|
+
"what does",
|
|
1101
|
+
"debug",
|
|
1102
|
+
"fix",
|
|
1103
|
+
"error",
|
|
1104
|
+
"wrong",
|
|
1105
|
+
"broken",
|
|
1106
|
+
"refactor",
|
|
1107
|
+
"improve",
|
|
1108
|
+
"optimize",
|
|
1109
|
+
"clean up",
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
# Find most recent user message
|
|
1113
|
+
for message in reversed(messages):
|
|
1114
|
+
if message.get("role") == "user":
|
|
1115
|
+
content = message.get("content", "")
|
|
1116
|
+
if isinstance(content, str):
|
|
1117
|
+
content_lower = content.lower()
|
|
1118
|
+
for keyword in analysis_keywords:
|
|
1119
|
+
if keyword in content_lower:
|
|
1120
|
+
return True
|
|
1121
|
+
break
|
|
1122
|
+
|
|
1123
|
+
return False
|
|
1124
|
+
|
|
1125
|
+
def should_apply(
|
|
1126
|
+
self,
|
|
1127
|
+
messages: list[dict[str, Any]],
|
|
1128
|
+
tokenizer: Tokenizer,
|
|
1129
|
+
**kwargs: Any,
|
|
1130
|
+
) -> bool:
|
|
1131
|
+
"""Check if routing should be applied.
|
|
1132
|
+
|
|
1133
|
+
Always returns True - the router handles all content types.
|
|
1134
|
+
"""
|
|
1135
|
+
return True
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def route_and_compress(
|
|
1139
|
+
content: str,
|
|
1140
|
+
source_hint: str | None = None,
|
|
1141
|
+
context: str = "",
|
|
1142
|
+
) -> str:
|
|
1143
|
+
"""Convenience function for one-off routing and compression.
|
|
1144
|
+
|
|
1145
|
+
Args:
|
|
1146
|
+
content: Content to compress.
|
|
1147
|
+
source_hint: Optional source hint.
|
|
1148
|
+
context: Optional context.
|
|
1149
|
+
|
|
1150
|
+
Returns:
|
|
1151
|
+
Compressed content.
|
|
1152
|
+
|
|
1153
|
+
Example:
|
|
1154
|
+
>>> compressed = route_and_compress(mixed_content)
|
|
1155
|
+
"""
|
|
1156
|
+
router = ContentRouter()
|
|
1157
|
+
result = router.compress(content, source_hint=source_hint, context=context)
|
|
1158
|
+
return result.compressed
|