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.
- agent_decoder-0.3.2.dist-info/METADATA +387 -0
- agent_decoder-0.3.2.dist-info/RECORD +89 -0
- agent_decoder-0.3.2.dist-info/WHEEL +4 -0
- agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
- agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
- clau_decode/__init__.py +8 -0
- clau_decode/__main__.py +11 -0
- clau_decode/_auth_env.py +118 -0
- clau_decode/_pty_preexec.py +42 -0
- clau_decode/analytics/__init__.py +0 -0
- clau_decode/analytics/aggregator.py +55 -0
- clau_decode/analytics/cost.py +76 -0
- clau_decode/analytics/extractor.py +32 -0
- clau_decode/analytics/fast.py +289 -0
- clau_decode/analytics/models.py +49 -0
- clau_decode/analytics/pricing.py +162 -0
- clau_decode/analytics/prompt.py +34 -0
- clau_decode/analytics/service.py +23 -0
- clau_decode/analytics/stats.py +117 -0
- clau_decode/analytics/tips.py +176 -0
- clau_decode/btw_capture.py +290 -0
- clau_decode/cli.py +504 -0
- clau_decode/config.py +144 -0
- clau_decode/db.py +2054 -0
- clau_decode/driver_manager.py +387 -0
- clau_decode/drivers/__init__.py +27 -0
- clau_decode/drivers/base.py +170 -0
- clau_decode/drivers/registry.py +113 -0
- clau_decode/drivers/tmux_driver.py +488 -0
- clau_decode/editor.py +182 -0
- clau_decode/events_bus.py +69 -0
- clau_decode/locks.py +388 -0
- clau_decode/migrate.py +1313 -0
- clau_decode/models.py +285 -0
- clau_decode/parser.py +466 -0
- clau_decode/providers/__init__.py +30 -0
- clau_decode/providers/base.py +132 -0
- clau_decode/providers/claude.py +81 -0
- clau_decode/providers/codex.py +441 -0
- clau_decode/providers/registry.py +89 -0
- clau_decode/pty_native.py +39 -0
- clau_decode/pty_runner.py +2406 -0
- clau_decode/pty_screen_state.py +65 -0
- clau_decode/recap_runner.py +230 -0
- clau_decode/reporter.py +305 -0
- clau_decode/scanner.py +125 -0
- clau_decode/server.py +2841 -0
- clau_decode/static/android-chrome-192x192.png +0 -0
- clau_decode/static/android-chrome-512x512.png +0 -0
- clau_decode/static/app-icon.png +0 -0
- clau_decode/static/apple-touch-icon.png +0 -0
- clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
- clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
- clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
- clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
- clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
- clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
- clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
- clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
- clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
- clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
- clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
- clau_decode/static/assets/client-DTSXvAb8.js +1 -0
- clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
- clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
- clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
- clau_decode/static/assets/index-BHdCOIkq.css +10 -0
- clau_decode/static/assets/index-DfKltZ6z.js +5 -0
- clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
- clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
- clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
- clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
- clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
- clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
- clau_decode/static/assets/react-Bi_azaFt.js +9 -0
- clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
- clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
- clau_decode/static/assets/shared-DdVGprvF.js +1 -0
- clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
- clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
- clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
- clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
- clau_decode/static/favicon-16x16.png +0 -0
- clau_decode/static/favicon-32x32.png +0 -0
- clau_decode/static/favicon.ico +0 -0
- clau_decode/static/index.html +24 -0
- clau_decode/static/site.webmanifest +1 -0
- clau_decode/update_check.py +51 -0
- 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
|
+
]
|