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.
Files changed (114) hide show
  1. headroom/__init__.py +212 -0
  2. headroom/cache/__init__.py +76 -0
  3. headroom/cache/anthropic.py +517 -0
  4. headroom/cache/base.py +342 -0
  5. headroom/cache/compression_feedback.py +613 -0
  6. headroom/cache/compression_store.py +814 -0
  7. headroom/cache/dynamic_detector.py +1026 -0
  8. headroom/cache/google.py +884 -0
  9. headroom/cache/openai.py +584 -0
  10. headroom/cache/registry.py +175 -0
  11. headroom/cache/semantic.py +451 -0
  12. headroom/ccr/__init__.py +77 -0
  13. headroom/ccr/context_tracker.py +582 -0
  14. headroom/ccr/mcp_server.py +319 -0
  15. headroom/ccr/response_handler.py +772 -0
  16. headroom/ccr/tool_injection.py +415 -0
  17. headroom/cli.py +219 -0
  18. headroom/client.py +977 -0
  19. headroom/compression/__init__.py +42 -0
  20. headroom/compression/detector.py +424 -0
  21. headroom/compression/handlers/__init__.py +22 -0
  22. headroom/compression/handlers/base.py +219 -0
  23. headroom/compression/handlers/code_handler.py +506 -0
  24. headroom/compression/handlers/json_handler.py +418 -0
  25. headroom/compression/masks.py +345 -0
  26. headroom/compression/universal.py +465 -0
  27. headroom/config.py +474 -0
  28. headroom/exceptions.py +192 -0
  29. headroom/integrations/__init__.py +159 -0
  30. headroom/integrations/agno/__init__.py +53 -0
  31. headroom/integrations/agno/hooks.py +345 -0
  32. headroom/integrations/agno/model.py +625 -0
  33. headroom/integrations/agno/providers.py +154 -0
  34. headroom/integrations/langchain/__init__.py +106 -0
  35. headroom/integrations/langchain/agents.py +326 -0
  36. headroom/integrations/langchain/chat_model.py +1002 -0
  37. headroom/integrations/langchain/langsmith.py +324 -0
  38. headroom/integrations/langchain/memory.py +319 -0
  39. headroom/integrations/langchain/providers.py +200 -0
  40. headroom/integrations/langchain/retriever.py +371 -0
  41. headroom/integrations/langchain/streaming.py +341 -0
  42. headroom/integrations/mcp/__init__.py +37 -0
  43. headroom/integrations/mcp/server.py +533 -0
  44. headroom/memory/__init__.py +37 -0
  45. headroom/memory/extractor.py +390 -0
  46. headroom/memory/fast_store.py +621 -0
  47. headroom/memory/fast_wrapper.py +311 -0
  48. headroom/memory/inline_extractor.py +229 -0
  49. headroom/memory/store.py +434 -0
  50. headroom/memory/worker.py +260 -0
  51. headroom/memory/wrapper.py +321 -0
  52. headroom/models/__init__.py +39 -0
  53. headroom/models/registry.py +687 -0
  54. headroom/parser.py +293 -0
  55. headroom/pricing/__init__.py +51 -0
  56. headroom/pricing/anthropic_prices.py +81 -0
  57. headroom/pricing/litellm_pricing.py +113 -0
  58. headroom/pricing/openai_prices.py +91 -0
  59. headroom/pricing/registry.py +188 -0
  60. headroom/providers/__init__.py +61 -0
  61. headroom/providers/anthropic.py +621 -0
  62. headroom/providers/base.py +131 -0
  63. headroom/providers/cohere.py +362 -0
  64. headroom/providers/google.py +427 -0
  65. headroom/providers/litellm.py +297 -0
  66. headroom/providers/openai.py +566 -0
  67. headroom/providers/openai_compatible.py +521 -0
  68. headroom/proxy/__init__.py +19 -0
  69. headroom/proxy/server.py +2683 -0
  70. headroom/py.typed +0 -0
  71. headroom/relevance/__init__.py +124 -0
  72. headroom/relevance/base.py +106 -0
  73. headroom/relevance/bm25.py +255 -0
  74. headroom/relevance/embedding.py +255 -0
  75. headroom/relevance/hybrid.py +259 -0
  76. headroom/reporting/__init__.py +5 -0
  77. headroom/reporting/generator.py +549 -0
  78. headroom/storage/__init__.py +41 -0
  79. headroom/storage/base.py +125 -0
  80. headroom/storage/jsonl.py +220 -0
  81. headroom/storage/sqlite.py +289 -0
  82. headroom/telemetry/__init__.py +91 -0
  83. headroom/telemetry/collector.py +764 -0
  84. headroom/telemetry/models.py +880 -0
  85. headroom/telemetry/toin.py +1579 -0
  86. headroom/tokenizer.py +80 -0
  87. headroom/tokenizers/__init__.py +75 -0
  88. headroom/tokenizers/base.py +210 -0
  89. headroom/tokenizers/estimator.py +198 -0
  90. headroom/tokenizers/huggingface.py +317 -0
  91. headroom/tokenizers/mistral.py +245 -0
  92. headroom/tokenizers/registry.py +398 -0
  93. headroom/tokenizers/tiktoken_counter.py +248 -0
  94. headroom/transforms/__init__.py +106 -0
  95. headroom/transforms/base.py +57 -0
  96. headroom/transforms/cache_aligner.py +357 -0
  97. headroom/transforms/code_compressor.py +1313 -0
  98. headroom/transforms/content_detector.py +335 -0
  99. headroom/transforms/content_router.py +1158 -0
  100. headroom/transforms/llmlingua_compressor.py +638 -0
  101. headroom/transforms/log_compressor.py +529 -0
  102. headroom/transforms/pipeline.py +297 -0
  103. headroom/transforms/rolling_window.py +350 -0
  104. headroom/transforms/search_compressor.py +365 -0
  105. headroom/transforms/smart_crusher.py +2682 -0
  106. headroom/transforms/text_compressor.py +259 -0
  107. headroom/transforms/tool_crusher.py +338 -0
  108. headroom/utils.py +215 -0
  109. headroom_ai-0.2.13.dist-info/METADATA +315 -0
  110. headroom_ai-0.2.13.dist-info/RECORD +114 -0
  111. headroom_ai-0.2.13.dist-info/WHEEL +4 -0
  112. headroom_ai-0.2.13.dist-info/entry_points.txt +2 -0
  113. headroom_ai-0.2.13.dist-info/licenses/LICENSE +190 -0
  114. 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