agent-decoder 0.3.2__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 (89) hide show
  1. agent_decoder-0.3.2.dist-info/METADATA +387 -0
  2. agent_decoder-0.3.2.dist-info/RECORD +89 -0
  3. agent_decoder-0.3.2.dist-info/WHEEL +4 -0
  4. agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
  5. agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
  6. clau_decode/__init__.py +8 -0
  7. clau_decode/__main__.py +11 -0
  8. clau_decode/_auth_env.py +118 -0
  9. clau_decode/_pty_preexec.py +42 -0
  10. clau_decode/analytics/__init__.py +0 -0
  11. clau_decode/analytics/aggregator.py +55 -0
  12. clau_decode/analytics/cost.py +76 -0
  13. clau_decode/analytics/extractor.py +32 -0
  14. clau_decode/analytics/fast.py +289 -0
  15. clau_decode/analytics/models.py +49 -0
  16. clau_decode/analytics/pricing.py +162 -0
  17. clau_decode/analytics/prompt.py +34 -0
  18. clau_decode/analytics/service.py +23 -0
  19. clau_decode/analytics/stats.py +117 -0
  20. clau_decode/analytics/tips.py +176 -0
  21. clau_decode/btw_capture.py +290 -0
  22. clau_decode/cli.py +504 -0
  23. clau_decode/config.py +144 -0
  24. clau_decode/db.py +2054 -0
  25. clau_decode/driver_manager.py +387 -0
  26. clau_decode/drivers/__init__.py +27 -0
  27. clau_decode/drivers/base.py +170 -0
  28. clau_decode/drivers/registry.py +113 -0
  29. clau_decode/drivers/tmux_driver.py +488 -0
  30. clau_decode/editor.py +182 -0
  31. clau_decode/events_bus.py +69 -0
  32. clau_decode/locks.py +388 -0
  33. clau_decode/migrate.py +1313 -0
  34. clau_decode/models.py +285 -0
  35. clau_decode/parser.py +466 -0
  36. clau_decode/providers/__init__.py +30 -0
  37. clau_decode/providers/base.py +132 -0
  38. clau_decode/providers/claude.py +81 -0
  39. clau_decode/providers/codex.py +441 -0
  40. clau_decode/providers/registry.py +89 -0
  41. clau_decode/pty_native.py +39 -0
  42. clau_decode/pty_runner.py +2406 -0
  43. clau_decode/pty_screen_state.py +65 -0
  44. clau_decode/recap_runner.py +230 -0
  45. clau_decode/reporter.py +305 -0
  46. clau_decode/scanner.py +125 -0
  47. clau_decode/server.py +2841 -0
  48. clau_decode/static/android-chrome-192x192.png +0 -0
  49. clau_decode/static/android-chrome-512x512.png +0 -0
  50. clau_decode/static/app-icon.png +0 -0
  51. clau_decode/static/apple-touch-icon.png +0 -0
  52. clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
  53. clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
  54. clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
  55. clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
  56. clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
  57. clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
  58. clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
  59. clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
  60. clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
  61. clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
  62. clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
  63. clau_decode/static/assets/client-DTSXvAb8.js +1 -0
  64. clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
  65. clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
  66. clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
  67. clau_decode/static/assets/index-BHdCOIkq.css +10 -0
  68. clau_decode/static/assets/index-DfKltZ6z.js +5 -0
  69. clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
  70. clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
  71. clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
  72. clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
  73. clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
  74. clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
  75. clau_decode/static/assets/react-Bi_azaFt.js +9 -0
  76. clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
  77. clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
  78. clau_decode/static/assets/shared-DdVGprvF.js +1 -0
  79. clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
  80. clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
  81. clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
  82. clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
  83. clau_decode/static/favicon-16x16.png +0 -0
  84. clau_decode/static/favicon-32x32.png +0 -0
  85. clau_decode/static/favicon.ico +0 -0
  86. clau_decode/static/index.html +24 -0
  87. clau_decode/static/site.webmanifest +1 -0
  88. clau_decode/update_check.py +51 -0
  89. clau_decode/watcher.py +63 -0
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time as _time
5
+ from dataclasses import dataclass, field
6
+ from decimal import Decimal
7
+
8
+ import httpx
9
+
10
+ from .models import TokenBreakdown
11
+
12
+ _LITELLM_URL = (
13
+ "https://raw.githubusercontent.com/BerriAI/litellm/main/"
14
+ "model_prices_and_context_window.json"
15
+ )
16
+
17
+ # API pricing as of May 2026 (USD per million tokens).
18
+ # Keys are model name prefixes for flexible matching.
19
+ _HARDCODED_RATES: dict[str, "ModelPricing"] = {}
20
+
21
+
22
+ @dataclass
23
+ class ModelPricing:
24
+ """Per-model pricing in USD per million tokens."""
25
+
26
+ input_per_mtok: Decimal = field(default_factory=lambda: Decimal("0"))
27
+ output_per_mtok: Decimal = field(default_factory=lambda: Decimal("0"))
28
+ cache_write_per_mtok: Decimal = field(default_factory=lambda: Decimal("0"))
29
+ cache_read_per_mtok: Decimal = field(default_factory=lambda: Decimal("0"))
30
+
31
+ def compute_cost(self, breakdown: TokenBreakdown) -> Decimal:
32
+ M = Decimal("1000000")
33
+ return (
34
+ self.input_per_mtok * breakdown.input_tokens / M
35
+ + self.output_per_mtok * breakdown.output_tokens / M
36
+ + self.cache_write_per_mtok * breakdown.cache_creation_tokens / M
37
+ + self.cache_read_per_mtok * breakdown.cache_read_tokens / M
38
+ )
39
+
40
+
41
+ _HARDCODED_RATES = {
42
+ "claude-opus-4-7": ModelPricing(
43
+ input_per_mtok=Decimal("15.00"),
44
+ output_per_mtok=Decimal("75.00"),
45
+ cache_write_per_mtok=Decimal("18.75"),
46
+ cache_read_per_mtok=Decimal("1.50"),
47
+ ),
48
+ "claude-sonnet-4-6": ModelPricing(
49
+ input_per_mtok=Decimal("3.00"),
50
+ output_per_mtok=Decimal("15.00"),
51
+ cache_write_per_mtok=Decimal("3.75"),
52
+ cache_read_per_mtok=Decimal("0.30"),
53
+ ),
54
+ "claude-haiku-4-5": ModelPricing(
55
+ input_per_mtok=Decimal("0.80"),
56
+ output_per_mtok=Decimal("4.00"),
57
+ cache_write_per_mtok=Decimal("1.00"),
58
+ cache_read_per_mtok=Decimal("0.08"),
59
+ ),
60
+ "claude-3-5-sonnet": ModelPricing(
61
+ input_per_mtok=Decimal("3.00"),
62
+ output_per_mtok=Decimal("15.00"),
63
+ cache_write_per_mtok=Decimal("3.75"),
64
+ cache_read_per_mtok=Decimal("0.30"),
65
+ ),
66
+ "claude-3-5-haiku": ModelPricing(
67
+ input_per_mtok=Decimal("0.80"),
68
+ output_per_mtok=Decimal("4.00"),
69
+ cache_write_per_mtok=Decimal("1.00"),
70
+ cache_read_per_mtok=Decimal("0.08"),
71
+ ),
72
+ "claude-3-opus": ModelPricing(
73
+ input_per_mtok=Decimal("15.00"),
74
+ output_per_mtok=Decimal("75.00"),
75
+ cache_write_per_mtok=Decimal("18.75"),
76
+ cache_read_per_mtok=Decimal("1.50"),
77
+ ),
78
+ }
79
+
80
+
81
+ class HardcodedPricingStrategy:
82
+ """Static pricing table — exact match first, then prefix match."""
83
+
84
+ def get_pricing(self, model: str) -> ModelPricing | None:
85
+ if model in _HARDCODED_RATES:
86
+ return _HARDCODED_RATES[model]
87
+ for prefix, pricing in _HARDCODED_RATES.items():
88
+ if model.startswith(prefix):
89
+ return pricing
90
+ return None
91
+
92
+
93
+ class LiteLLMPricingFetcher:
94
+ """Fetch per-model pricing from LiteLLM's published JSON registry."""
95
+
96
+ def __init__(self, url: str = _LITELLM_URL, timeout: float = 10.0) -> None:
97
+ self._url = url
98
+ self._timeout = timeout
99
+
100
+ async def fetch(self) -> dict[str, ModelPricing]:
101
+ """Return a dict mapping model name → ModelPricing."""
102
+ try:
103
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
104
+ response = await client.get(self._url)
105
+ response.raise_for_status()
106
+ raw: dict = json.loads(response.text)
107
+ except Exception:
108
+ return {}
109
+
110
+ result: dict[str, ModelPricing] = {}
111
+ for model, entry in raw.items():
112
+ if not isinstance(entry, dict):
113
+ continue
114
+ if not model.startswith("claude"):
115
+ continue
116
+ inp = entry.get("input_cost_per_token") or 0.0
117
+ out = entry.get("output_cost_per_token") or 0.0
118
+ cw = entry.get("cache_creation_input_token_cost") or 0.0
119
+ cr = entry.get("cache_read_input_token_cost") or 0.0
120
+ result[model] = ModelPricing(
121
+ input_per_mtok=Decimal(str(round(inp * 1_000_000, 6))),
122
+ output_per_mtok=Decimal(str(round(out * 1_000_000, 6))),
123
+ cache_write_per_mtok=Decimal(str(round(cw * 1_000_000, 6))),
124
+ cache_read_per_mtok=Decimal(str(round(cr * 1_000_000, 6))),
125
+ )
126
+ return result
127
+
128
+
129
+ class CachedPricingStrategy:
130
+ """Live pricing from LiteLLM with TTL cache; falls back to hardcoded rates."""
131
+
132
+ def __init__(
133
+ self,
134
+ fetcher: LiteLLMPricingFetcher | None = None,
135
+ fallback: HardcodedPricingStrategy | None = None,
136
+ ttl_seconds: float = 3600.0,
137
+ ) -> None:
138
+ self._fetcher = fetcher or LiteLLMPricingFetcher()
139
+ self._fallback = fallback or HardcodedPricingStrategy()
140
+ self._ttl = ttl_seconds
141
+ self._cached_data: dict[str, ModelPricing] = {}
142
+ self._cache_fetched_at: float = 0.0
143
+
144
+ def _is_cache_stale(self) -> bool:
145
+ return (_time.monotonic() - self._cache_fetched_at) >= self._ttl
146
+
147
+ async def refresh(self) -> None:
148
+ """Fetch fresh pricing from LiteLLM and update the in-memory cache."""
149
+ data = await self._fetcher.fetch()
150
+ if data:
151
+ self._cached_data = data
152
+ self._cache_fetched_at = _time.monotonic()
153
+
154
+ def get_pricing(self, model: str) -> ModelPricing | None:
155
+ # Try live cache first (exact then prefix)
156
+ if model in self._cached_data:
157
+ return self._cached_data[model]
158
+ for key, pricing in self._cached_data.items():
159
+ if model.startswith(key):
160
+ return pricing
161
+ # Fall back to hardcoded
162
+ return self._fallback.get_pricing(model)
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+ from typing import Iterator
3
+ from clau_decode.models import Message
4
+ from .extractor import TokenExtractor
5
+ from .models import PromptCost
6
+
7
+
8
+ class PromptIterator:
9
+ """Yield PromptCost for each non-meta user→assistant pair in a message list."""
10
+
11
+ def __init__(
12
+ self, messages: list[Message], extractor: TokenExtractor | None = None
13
+ ) -> None:
14
+ self._messages = messages
15
+ self._extractor = extractor or TokenExtractor()
16
+
17
+ def __iter__(self) -> Iterator[PromptCost]:
18
+ by_id: dict[str, Message] = {m.id: m for m in self._messages}
19
+ for msg in self._messages:
20
+ if msg.role != "assistant":
21
+ continue
22
+ if msg.is_sidechain:
23
+ continue
24
+ if msg.parent_id is None or msg.parent_id not in by_id:
25
+ continue
26
+ parent = by_id[msg.parent_id]
27
+ if parent.role != "user" or parent.is_meta:
28
+ continue
29
+ yield PromptCost(
30
+ user_message_id=parent.id,
31
+ assistant_message_id=msg.id,
32
+ model=msg.model or "unknown",
33
+ breakdown=self._extractor.extract(msg),
34
+ )
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+ from clau_decode.models import Message
3
+ from .aggregator import DailyAggregator, SessionAggregator
4
+ from .extractor import TokenExtractor
5
+ from .models import DailyBucket, PromptCost, TokenBreakdown
6
+ from .prompt import PromptIterator
7
+
8
+
9
+ class TokenAnalyticsService:
10
+ """Facade: answers analytics questions about a list of messages."""
11
+
12
+ def __init__(self) -> None:
13
+ self._extractor = TokenExtractor()
14
+
15
+ def session_totals(self, messages: list[Message]) -> TokenBreakdown:
16
+ return SessionAggregator(self._extractor).aggregate(messages)
17
+
18
+ def prompt_breakdown(self, messages: list[Message]) -> list[PromptCost]:
19
+ prompts = list(PromptIterator(messages, self._extractor))
20
+ return sorted(prompts, key=lambda p: p.breakdown.total, reverse=True)
21
+
22
+ def daily_buckets(self, messages: list[Message]) -> list[DailyBucket]:
23
+ return DailyAggregator(self._extractor).aggregate(messages)
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+ import statistics
3
+ from collections import Counter, defaultdict
4
+ from typing import Any
5
+
6
+ from clau_decode.models import Message, ToolUseBlock
7
+ from .extractor import TokenExtractor
8
+ from .prompt import PromptIterator
9
+
10
+
11
+ def compute_stats(values: list[int | float]) -> dict[str, Any] | None:
12
+ """Return mean/median/p95/min/max/count for a non-empty list, else None."""
13
+ if not values:
14
+ return None
15
+ sorted_vals = sorted(values)
16
+ n = len(sorted_vals)
17
+ p95_idx = max(0, int(round(0.95 * n)) - 1)
18
+ return {
19
+ "count": n,
20
+ "mean": round(statistics.mean(sorted_vals), 2),
21
+ "median": statistics.median(sorted_vals),
22
+ "p95": sorted_vals[p95_idx],
23
+ "min": sorted_vals[0],
24
+ "max": sorted_vals[-1],
25
+ }
26
+
27
+
28
+ class PromptStatsScanner:
29
+ """Compute per-assistant-response token distribution stats across a message list.
30
+
31
+ prompt_count is the number of assistant API responses (each tool-use
32
+ cycle counted separately), not the number of user messages.
33
+ """
34
+
35
+ def __init__(self) -> None:
36
+ self._extractor = TokenExtractor()
37
+
38
+ def scan(self, messages: list[Message]) -> dict[str, Any]:
39
+ inputs, outputs, totals = [], [], []
40
+ for prompt in PromptIterator(messages, self._extractor):
41
+ bd = prompt.breakdown
42
+ inputs.append(bd.input_tokens)
43
+ outputs.append(bd.output_tokens)
44
+ totals.append(bd.total)
45
+ return {
46
+ "prompt_count": len(inputs),
47
+ "input_tokens": compute_stats(inputs),
48
+ "output_tokens": compute_stats(outputs),
49
+ "total_tokens": compute_stats(totals),
50
+ }
51
+
52
+
53
+ class ModelUsageScanner:
54
+ """Aggregate token usage broken down by model name."""
55
+
56
+ def scan(self, messages: list[Message]) -> list[dict[str, Any]]:
57
+ buckets: dict[str, dict[str, int]] = defaultdict(
58
+ lambda: {
59
+ "message_count": 0,
60
+ "input_tokens": 0,
61
+ "output_tokens": 0,
62
+ "total_tokens": 0,
63
+ }
64
+ )
65
+ for msg in messages:
66
+ if msg.role != "assistant" or not msg.model or not msg.usage:
67
+ continue
68
+ b = buckets[msg.model]
69
+ b["message_count"] += 1
70
+ b["input_tokens"] += msg.usage.input_tokens
71
+ b["output_tokens"] += msg.usage.output_tokens
72
+ b["total_tokens"] += (
73
+ msg.usage.input_tokens
74
+ + msg.usage.output_tokens
75
+ + msg.usage.cache_creation_input_tokens
76
+ + msg.usage.cache_read_input_tokens
77
+ )
78
+ result = [{"model": model, **counts} for model, counts in buckets.items()]
79
+ return sorted(result, key=lambda r: r["total_tokens"], reverse=True)
80
+
81
+
82
+ class ToolUsageScanner:
83
+ """Count tool_use calls by tool name across all messages."""
84
+
85
+ def scan(self, messages: list[Message]) -> list[dict[str, Any]]:
86
+ counts: Counter[str] = Counter()
87
+ for msg in messages:
88
+ for block in msg.content_blocks:
89
+ if isinstance(block, ToolUseBlock):
90
+ counts[block.name] += 1
91
+ return [{"tool": name, "count": count} for name, count in counts.most_common()]
92
+
93
+
94
+ class FileTouchScanner:
95
+ """Count tool invocations by full file path (file_path or path input key).
96
+
97
+ Stores the full path so same-named files in different directories are
98
+ not silently merged. The frontend trims for display.
99
+ """
100
+
101
+ def __init__(self, top_n: int = 20) -> None:
102
+ self._top_n = top_n
103
+
104
+ def scan(self, messages: list[Message]) -> list[dict[str, Any]]:
105
+ counts: Counter[str] = Counter()
106
+ for msg in messages:
107
+ for block in msg.content_blocks:
108
+ if not isinstance(block, ToolUseBlock):
109
+ continue
110
+ path = block.input.get("file_path") or block.input.get("path") or ""
111
+ if not path or not isinstance(path, str):
112
+ continue
113
+ counts[path] += 1
114
+ return [
115
+ {"file": path, "count": count}
116
+ for path, count in counts.most_common(self._top_n)
117
+ ]
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+ from collections import defaultdict
3
+ from dataclasses import dataclass, field
4
+ from typing import Literal, Protocol
5
+
6
+ from clau_decode.models import Message, ToolResultBlock, ToolUseBlock
7
+
8
+
9
+ _SEVERITY_ORDER = {"error": 0, "warning": 1, "info": 2}
10
+ _WRITE_TOOLS = frozenset({"Edit", "MultiEdit", "Write", "NotebookEdit"})
11
+
12
+
13
+ @dataclass
14
+ class Tip:
15
+ rule_id: str
16
+ severity: Literal["info", "warning", "error"]
17
+ title: str
18
+ detail: str
19
+ evidence: list[str] = field(default_factory=list)
20
+
21
+
22
+ class TipRule(Protocol):
23
+ def check(self, messages: list[Message]) -> list[Tip]: ...
24
+
25
+
26
+ class TipRegistry:
27
+ """Run all registered rules and return tips sorted by severity."""
28
+
29
+ def __init__(self) -> None:
30
+ self._rules: list[TipRule] = []
31
+
32
+ def register(self, rule: TipRule) -> None:
33
+ self._rules.append(rule)
34
+
35
+ def run(self, messages: list[Message]) -> list[Tip]:
36
+ tips: list[Tip] = []
37
+ for rule in self._rules:
38
+ tips.extend(rule.check(messages))
39
+ return sorted(tips, key=lambda t: _SEVERITY_ORDER.get(t.severity, 99))
40
+
41
+
42
+ class RepeatedFileReadRule:
43
+ """Flag files that are Read >= threshold times without an intervening write."""
44
+
45
+ def __init__(self, threshold: int = 3) -> None:
46
+ self._threshold = threshold
47
+
48
+ def check(self, messages: list[Message]) -> list[Tip]:
49
+ read_counts: dict[str, int] = defaultdict(int)
50
+ flagged: dict[str, int] = {}
51
+
52
+ for msg in messages:
53
+ for block in msg.content_blocks:
54
+ if not isinstance(block, ToolUseBlock):
55
+ continue
56
+ path = block.input.get("file_path") or block.input.get("path") or ""
57
+ if not path or not isinstance(path, str):
58
+ continue
59
+ if block.name == "Read":
60
+ read_counts[path] += 1
61
+ if read_counts[path] >= self._threshold:
62
+ flagged[path] = read_counts[path]
63
+ elif block.name in _WRITE_TOOLS:
64
+ read_counts[path] = 0
65
+ flagged.pop(path, None)
66
+
67
+ tips = []
68
+ for path, count in sorted(flagged.items(), key=lambda x: -x[1]):
69
+ tips.append(
70
+ Tip(
71
+ rule_id="repeated_file_read",
72
+ severity="warning",
73
+ title=f"File read {count}× without edit: {path.split('/')[-1]}",
74
+ detail=(
75
+ "Reading the same file repeatedly re-sends its full content on every "
76
+ "prompt turn. Consider reading it once and referencing it from context, "
77
+ "or check whether the assistant has already loaded this file."
78
+ ),
79
+ evidence=[f"{path} — read {count} times"],
80
+ )
81
+ )
82
+ return tips
83
+
84
+
85
+ def _result_char_count(block: ToolResultBlock) -> int:
86
+ if block.content is None:
87
+ return 0
88
+ if isinstance(block.content, str):
89
+ return len(block.content)
90
+ return sum(
91
+ len(item.get("text", "")) for item in block.content if isinstance(item, dict)
92
+ )
93
+
94
+
95
+ class OversizedToolResultRule:
96
+ """Flag tool results larger than threshold_chars characters."""
97
+
98
+ def __init__(self, threshold_chars: int = 50_000) -> None:
99
+ self._threshold = threshold_chars
100
+
101
+ def check(self, messages: list[Message]) -> list[Tip]:
102
+ tips = []
103
+ for msg in messages:
104
+ for block in msg.content_blocks:
105
+ if not isinstance(block, ToolResultBlock):
106
+ continue
107
+ size = _result_char_count(block)
108
+ if size < self._threshold:
109
+ continue
110
+ size_k = round(size / 1_000)
111
+ tips.append(
112
+ Tip(
113
+ rule_id="oversized_tool_result",
114
+ severity="warning",
115
+ title=f"Oversized tool result (~{size_k}k chars)",
116
+ detail=(
117
+ "This result was included verbatim in the context window. "
118
+ "Large results consume expensive input tokens on every subsequent "
119
+ "turn. Consider truncating output, using grep/head, or asking "
120
+ "the assistant to request only the relevant section."
121
+ ),
122
+ evidence=[
123
+ f"~{size_k}k chars (tool_use_id: {block.tool_use_id})"
124
+ ],
125
+ )
126
+ )
127
+ return tips
128
+
129
+
130
+ class LowCacheHitRule:
131
+ """Flag corpora where cache-read tokens are < ratio_threshold of all input-type tokens."""
132
+
133
+ def __init__(
134
+ self, ratio_threshold: float = 0.10, min_input_tokens: int = 5_000
135
+ ) -> None:
136
+ self._ratio = ratio_threshold
137
+ self._min_input = min_input_tokens
138
+
139
+ def check(self, messages: list[Message]) -> list[Tip]:
140
+ total_input = 0
141
+ total_cache_read = 0
142
+ for msg in messages:
143
+ if msg.role != "assistant" or not msg.usage:
144
+ continue
145
+ total_input += (
146
+ msg.usage.input_tokens
147
+ + msg.usage.cache_read_input_tokens
148
+ + msg.usage.cache_creation_input_tokens
149
+ )
150
+ total_cache_read += msg.usage.cache_read_input_tokens
151
+
152
+ if total_input < self._min_input:
153
+ return []
154
+
155
+ ratio = total_cache_read / total_input
156
+ if ratio >= self._ratio:
157
+ return []
158
+
159
+ pct = round(ratio * 100, 1)
160
+ threshold_pct = round(self._ratio * 100, 1)
161
+ return [
162
+ Tip(
163
+ rule_id="low_cache_hit",
164
+ severity="info",
165
+ title=f"Low cache hit ratio ({pct}%)",
166
+ detail=(
167
+ f"Less than {threshold_pct:g}% of input tokens came from cache reads. "
168
+ "Pinning large, stable content (system prompts, file context) with "
169
+ "cache_control can substantially reduce cost and latency on long sessions."
170
+ ),
171
+ evidence=[
172
+ f"Cache hit ratio: {pct}% "
173
+ f"({total_cache_read:,} cache-read tokens vs {total_input:,} total input tokens)"
174
+ ],
175
+ )
176
+ ]