kompactor 0.5.0__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.
- kompactor/__init__.py +3 -0
- kompactor/__main__.py +4 -0
- kompactor/_launch.py +22 -0
- kompactor/cli.py +446 -0
- kompactor/config.py +231 -0
- kompactor/estimate.py +145 -0
- kompactor/filters.py +617 -0
- kompactor/graph.py +1398 -0
- kompactor/hooks.py +353 -0
- kompactor/installer.py +344 -0
- kompactor/langs.py +102 -0
- kompactor/outline.py +274 -0
- kompactor/prove.py +317 -0
- kompactor/rules.py +54 -0
- kompactor/samples.py +217 -0
- kompactor/server.py +301 -0
- kompactor/shellwrap.py +149 -0
- kompactor/static/fonts/OFL.txt +93 -0
- kompactor/static/fonts/plex-mono-400.woff2 +0 -0
- kompactor/static/fonts/plex-mono-500.woff2 +0 -0
- kompactor/static/fonts/plex-sans-400.woff2 +0 -0
- kompactor/static/fonts/plex-sans-500.woff2 +0 -0
- kompactor/static/fonts/plex-sans-600.woff2 +0 -0
- kompactor/static/logo-mark.svg +5 -0
- kompactor/static/usage.html +342 -0
- kompactor/store.py +213 -0
- kompactor/tokens.py +29 -0
- kompactor/ui.html +749 -0
- kompactor-0.5.0.dist-info/METADATA +274 -0
- kompactor-0.5.0.dist-info/RECORD +34 -0
- kompactor-0.5.0.dist-info/WHEEL +5 -0
- kompactor-0.5.0.dist-info/entry_points.txt +2 -0
- kompactor-0.5.0.dist-info/licenses/LICENSE +21 -0
- kompactor-0.5.0.dist-info/top_level.txt +1 -0
kompactor/config.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Configuration: a quality-vs-tokens preset, optional per-knob overrides,
|
|
2
|
+
language profiles, and per-project overrides.
|
|
3
|
+
|
|
4
|
+
Stored in ~/.kompactor/config.json and edited from the dashboard (`kompactor ui`):
|
|
5
|
+
|
|
6
|
+
{
|
|
7
|
+
"preset": 3,
|
|
8
|
+
"overrides": {"bash_max_lines": 250},
|
|
9
|
+
"languages": {"mode": "auto", "selected": ["python", "go", "typescript", "react"]},
|
|
10
|
+
"projects": {"/path/to/repo": {"preset": 4, "overrides": {}}}
|
|
11
|
+
}
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import store
|
|
19
|
+
|
|
20
|
+
PRESETS = {
|
|
21
|
+
1: {"name": "Max quality", "blurb": "Only trims huge outputs and exact noise. Claude sees almost everything.",
|
|
22
|
+
"bash": True, "bash_wrap": True, "bash_plain": False, "bash_min_tokens": 1500, "bash_max_lines": 600, "max_line_chars": 2000, "grep_per_file": 50, "test_hide_passing": False, "test_boilerplate": False,
|
|
23
|
+
"trim_stack_frames": False, "diff_context": -1, "git_log_compact": False, "read_outline": False,
|
|
24
|
+
"read_outline_lines": 1500, "graph": True, "session_map_tokens": 0, "read_graph_context": False, "read_dedupe": True, "style": "off", "code_style": "normal", "subagent_rules": True, "never_compact": []},
|
|
25
|
+
2: {"name": "Quality", "blurb": "Compacts noisy commands; big files are still read whole.",
|
|
26
|
+
"bash": True, "bash_wrap": True, "bash_plain": True, "bash_min_tokens": 1000, "bash_max_lines": 400, "max_line_chars": 1000, "grep_per_file": 25, "test_hide_passing": True, "test_boilerplate": False,
|
|
27
|
+
"trim_stack_frames": False, "diff_context": -1, "git_log_compact": True, "read_outline": True,
|
|
28
|
+
"read_outline_lines": 800, "graph": True, "session_map_tokens": 0, "read_graph_context": True, "read_dedupe": True, "style": "lite", "code_style": "normal", "subagent_rules": True, "never_compact": []},
|
|
29
|
+
3: {"name": "Balanced", "blurb": "Recommended. Outline-first reads, compact tool output, terse replies.",
|
|
30
|
+
"bash": True, "bash_wrap": True, "bash_plain": True, "bash_min_tokens": 300, "bash_max_lines": 200, "max_line_chars": 500, "grep_per_file": 12, "test_hide_passing": True, "test_boilerplate": False,
|
|
31
|
+
"trim_stack_frames": True, "diff_context": -1, "git_log_compact": True, "read_outline": True,
|
|
32
|
+
"read_outline_lines": 400, "graph": True, "session_map_tokens": 500, "read_graph_context": True, "read_dedupe": True, "style": "lite", "code_style": "lean", "subagent_rules": True, "never_compact": []},
|
|
33
|
+
4: {"name": "Savings", "blurb": "Tighter caps, trimmed diff context, smaller file threshold.",
|
|
34
|
+
"bash": True, "bash_wrap": True, "bash_plain": True, "bash_min_tokens": 150, "bash_max_lines": 120, "max_line_chars": 300, "grep_per_file": 8, "test_hide_passing": True, "test_boilerplate": True,
|
|
35
|
+
"trim_stack_frames": True, "diff_context": 3, "git_log_compact": True, "read_outline": True,
|
|
36
|
+
"read_outline_lines": 250, "graph": True, "session_map_tokens": 700, "read_graph_context": True, "read_dedupe": True, "style": "full", "code_style": "lean", "subagent_rules": True, "never_compact": []},
|
|
37
|
+
5: {"name": "Max savings", "blurb": "Most aggressive. Claude may need `kompactor show` more often.",
|
|
38
|
+
"bash": True, "bash_wrap": True, "bash_plain": True, "bash_min_tokens": 80, "bash_max_lines": 80, "max_line_chars": 200, "grep_per_file": 5, "test_hide_passing": True, "test_boilerplate": True,
|
|
39
|
+
"trim_stack_frames": True, "diff_context": 1, "git_log_compact": True, "read_outline": True,
|
|
40
|
+
"read_outline_lines": 150, "graph": True, "session_map_tokens": 900, "read_graph_context": True, "read_dedupe": True, "style": "ultra", "code_style": "lean", "subagent_rules": True, "never_compact": []},
|
|
41
|
+
}
|
|
42
|
+
KNOBS = [k for k in PRESETS[3] if k not in ("name", "blurb")]
|
|
43
|
+
|
|
44
|
+
# UI metadata for every knob: label, help, type and range.
|
|
45
|
+
KNOB_INFO = {
|
|
46
|
+
"bash": ("Compact command output", "Shrink Bash/terminal output before Claude reads it.", "bool"),
|
|
47
|
+
"bash_wrap": ("Compact failing commands too", "Claude Code won't let hooks touch the output of a command that "
|
|
48
|
+
"fails, and cuts long failure output down to its first and last few thousand characters, often "
|
|
49
|
+
"losing the actual errors. With this on, tests, builds, linters and installs run through Kompactor "
|
|
50
|
+
"(Claude Code's shell prefix setting) so failures arrive compacted and complete, with the same exit "
|
|
51
|
+
"code. Servers and watch modes are never touched.", "bool"),
|
|
52
|
+
"bash_plain": ("Let Kompactor trim command output", "Tells Claude to run commands without `| head`, `| tail` or "
|
|
53
|
+
"`| grep`. Kompactor's compaction keeps every failure, while Claude's own trimming can cut the one "
|
|
54
|
+
"line that mattered and lead to a re-run. Only used while failing commands are compacted too "
|
|
55
|
+
"(shell prefix active).", "bool"),
|
|
56
|
+
"bash_min_tokens": ("Leave outputs smaller than", "Outputs under this many tokens are passed through untouched.",
|
|
57
|
+
"int", 0, 5000, 50),
|
|
58
|
+
"bash_max_lines": ("Max lines kept per output", "Head and tail are kept; error lines from the middle are always kept.",
|
|
59
|
+
"int", 40, 1000, 10),
|
|
60
|
+
"max_line_chars": ("Longest line kept", "Longer lines (minified code, giant JSON) are clipped with a '+N chars' note.",
|
|
61
|
+
"int", 120, 4000, 20),
|
|
62
|
+
"grep_per_file": ("Search matches per file", "grep/rg matches shown per file before '+N more'.", "int", 1, 100, 1),
|
|
63
|
+
"test_hide_passing": ("Hide passing tests", "Keep failures and the summary; drop the passing-test lines.", "bool"),
|
|
64
|
+
"test_boilerplate": ("Drop test-runner headers", "Remove session banners, separators and timing lines.", "bool"),
|
|
65
|
+
"trim_stack_frames": ("Trim library stack frames",
|
|
66
|
+
"Collapse site-packages / node_modules / Go runtime frames in tracebacks.", "bool"),
|
|
67
|
+
"diff_context": ("Diff context lines", "Unchanged lines kept around each change in diffs (-1 = all).", "int", -1, 10, 1),
|
|
68
|
+
"git_log_compact": ("One-line git log", "Turn verbose git log into 'sha date author: subject'.", "bool"),
|
|
69
|
+
"read_outline": ("Outline-first file reads",
|
|
70
|
+
"First full read of a big file returns its structure; reading again returns the whole file.", "bool"),
|
|
71
|
+
"read_outline_lines": ("Outline files longer than", "Line count from which a file is outlined first.",
|
|
72
|
+
"int", 50, 3000, 50),
|
|
73
|
+
"graph": ("Code graph", "Index functions, classes, imports and calls so Claude can find code without reading "
|
|
74
|
+
"whole files (`kompactor graph query`).", "bool"),
|
|
75
|
+
"session_map_tokens": ("Code map", "Token budget for a map of the most depended-on modules, given to Claude the "
|
|
76
|
+
"first time it searches or reads code in a session (0 = off). Sessions that only run "
|
|
77
|
+
"commands don't pay for it.", "int", 0, 3000, 100),
|
|
78
|
+
"read_graph_context": ("Show usages in outlines", "Outlines also say which modules import the file and which "
|
|
79
|
+
"tests cover it.", "bool"),
|
|
80
|
+
"read_dedupe": ("Skip unchanged re-reads", "Re-reading an unchanged file gets a short note (once).", "bool"),
|
|
81
|
+
"style": ("Prose style", "How Claude writes: off, lite (answer first, no recap), full (short sentences, no "
|
|
82
|
+
"narration), ultra (telegraphic). Code, commands and errors are never shortened.",
|
|
83
|
+
"choice", ["off", "lite", "full", "ultra"]),
|
|
84
|
+
"code_style": ("Code style", "How much code Claude writes: normal, lean (reuse first, smallest diff, check callers "
|
|
85
|
+
"and tests), strict (also proposes a smaller alternative before building).",
|
|
86
|
+
"choice", ["normal", "lean", "strict"]),
|
|
87
|
+
"subagent_rules": ("Apply rules to subagents", "Subagents get the same prose and code rules.", "bool"),
|
|
88
|
+
"never_compact": ("Never compact these commands", "Commands whose output is always passed through untouched, "
|
|
89
|
+
"comma separated (e.g. terraform plan, kubectl describe).", "list"),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
GENERAL = {"turn_summary": True, "statusline": True, "retention_days": 30}
|
|
93
|
+
|
|
94
|
+
DEFAULT_FILE = {"preset": 3, "overrides": {}, "languages": {"mode": "auto", "selected": []}, "projects": {},
|
|
95
|
+
"general": dict(GENERAL)}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def path() -> Path:
|
|
99
|
+
return store.home() / "config.json"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def read_file() -> dict:
|
|
103
|
+
data = json.loads(json.dumps(DEFAULT_FILE))
|
|
104
|
+
p = path()
|
|
105
|
+
if p.exists():
|
|
106
|
+
try:
|
|
107
|
+
raw = json.loads(p.read_text())
|
|
108
|
+
except (OSError, ValueError):
|
|
109
|
+
raw = {}
|
|
110
|
+
if "preset" not in raw and any(k in raw for k in KNOBS): # v0.1 flat format
|
|
111
|
+
raw = {"overrides": {k: v for k, v in raw.items() if k in KNOBS}}
|
|
112
|
+
for k, v in raw.items():
|
|
113
|
+
if isinstance(v, dict) and isinstance(data.get(k), dict):
|
|
114
|
+
data[k].update(v)
|
|
115
|
+
else:
|
|
116
|
+
data[k] = v
|
|
117
|
+
return data
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def write_file(data: dict) -> None:
|
|
121
|
+
p = path()
|
|
122
|
+
tmp = p.with_suffix(".tmp")
|
|
123
|
+
tmp.write_text(json.dumps(data, indent=2))
|
|
124
|
+
os.replace(tmp, p)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def project_root(cwd: str) -> str:
|
|
128
|
+
"""Nearest ancestor with a VCS or project marker; cwd itself otherwise."""
|
|
129
|
+
if not cwd:
|
|
130
|
+
return ""
|
|
131
|
+
p = Path(cwd).resolve()
|
|
132
|
+
for d in [p, *p.parents]:
|
|
133
|
+
if any((d / m).exists() for m in (".git", "go.mod", "pyproject.toml", "package.json", ".hg")):
|
|
134
|
+
return str(d)
|
|
135
|
+
return str(p)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def coerce(key: str, value):
|
|
139
|
+
info = KNOB_INFO.get(key)
|
|
140
|
+
if not info:
|
|
141
|
+
return value
|
|
142
|
+
if info[2] == "bool":
|
|
143
|
+
return value if isinstance(value, bool) else str(value).lower() in ("1", "true", "yes", "on")
|
|
144
|
+
if info[2] == "int":
|
|
145
|
+
return max(info[3], min(info[4], int(value)))
|
|
146
|
+
if info[2] == "choice" and value not in info[3]:
|
|
147
|
+
raise ValueError(f"{key} must be one of {info[3]}")
|
|
148
|
+
if info[2] == "list":
|
|
149
|
+
items = value if isinstance(value, list) else str(value).split(",")
|
|
150
|
+
return [str(v).strip() for v in items if str(v).strip()][:50]
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
REPO_FILE = ".kompactor.json" # committed to a repo: the level and settings for everyone working in it
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def repo_settings(root: str) -> dict:
|
|
158
|
+
"""{"preset": N, "overrides": {...}} from the repo's .kompactor.json, validated; {} if none."""
|
|
159
|
+
try:
|
|
160
|
+
d = json.loads((Path(root) / REPO_FILE).read_text())
|
|
161
|
+
except (OSError, ValueError, TypeError):
|
|
162
|
+
return {}
|
|
163
|
+
if not isinstance(d, dict):
|
|
164
|
+
return {}
|
|
165
|
+
out = {}
|
|
166
|
+
if str(d.get("preset", "")).isdigit() and int(d["preset"]) in PRESETS:
|
|
167
|
+
out["preset"] = int(d["preset"])
|
|
168
|
+
ov = {}
|
|
169
|
+
for k, v in (d.get("overrides") or {}).items() if isinstance(d.get("overrides"), dict) else ():
|
|
170
|
+
if k in KNOBS:
|
|
171
|
+
try:
|
|
172
|
+
ov[k] = coerce(k, v)
|
|
173
|
+
except (ValueError, TypeError):
|
|
174
|
+
pass
|
|
175
|
+
if ov:
|
|
176
|
+
out["overrides"] = ov
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def load(cwd: str = "") -> dict:
|
|
181
|
+
"""Effective settings for a working directory, most specific last:
|
|
182
|
+
your level and overrides ⊕ the repo's .kompactor.json ⊕ your settings for this project ⊕ env."""
|
|
183
|
+
data = read_file()
|
|
184
|
+
root = project_root(cwd) if cwd else ""
|
|
185
|
+
proj = data["projects"].get(root, {}) if root else {}
|
|
186
|
+
repo = repo_settings(root) if root else {}
|
|
187
|
+
preset = int(proj.get("preset", repo.get("preset", data.get("preset", 3))))
|
|
188
|
+
preset = preset if preset in PRESETS else 3
|
|
189
|
+
cfg = {k: PRESETS[preset][k] for k in KNOBS}
|
|
190
|
+
cfg.update({k: v for k, v in data.get("overrides", {}).items() if k in KNOBS})
|
|
191
|
+
cfg.update(repo.get("overrides", {}))
|
|
192
|
+
cfg.update({k: v for k, v in proj.get("overrides", {}).items() if k in KNOBS})
|
|
193
|
+
for k in KNOBS:
|
|
194
|
+
env = os.environ.get(f"KOMPACTOR_{k.upper()}")
|
|
195
|
+
if env is not None:
|
|
196
|
+
try:
|
|
197
|
+
cfg[k] = coerce(k, env)
|
|
198
|
+
except ValueError:
|
|
199
|
+
pass
|
|
200
|
+
if os.environ.get("KOMPACTOR_PRESET", "").isdigit() and int(os.environ["KOMPACTOR_PRESET"]) in PRESETS:
|
|
201
|
+
preset = int(os.environ["KOMPACTOR_PRESET"])
|
|
202
|
+
cfg.update({k: PRESETS[preset][k] for k in KNOBS})
|
|
203
|
+
cfg["preset"] = preset
|
|
204
|
+
cfg["project"] = root
|
|
205
|
+
cfg.update({k: v for k, v in data.get("general", {}).items() if k in GENERAL})
|
|
206
|
+
|
|
207
|
+
from . import langs
|
|
208
|
+
lang_cfg = data.get("languages", {})
|
|
209
|
+
if lang_cfg.get("mode") == "manual" and lang_cfg.get("selected"):
|
|
210
|
+
cfg["languages"] = list(lang_cfg["selected"])
|
|
211
|
+
else:
|
|
212
|
+
cfg["languages"] = langs.detect(root) if root else []
|
|
213
|
+
return cfg
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def save(key: str, value) -> None:
|
|
217
|
+
"""CLI helper: `kompactor config preset 4` or `kompactor config bash_max_lines 150`."""
|
|
218
|
+
data = read_file()
|
|
219
|
+
if key == "preset":
|
|
220
|
+
data["preset"] = int(value)
|
|
221
|
+
elif key in KNOBS:
|
|
222
|
+
data["overrides"][key] = coerce(key, value)
|
|
223
|
+
elif key in GENERAL:
|
|
224
|
+
data["general"][key] = int(value) if isinstance(GENERAL[key], int) and not isinstance(GENERAL[key], bool) \
|
|
225
|
+
else coerce("bash", value)
|
|
226
|
+
elif key == "languages":
|
|
227
|
+
data["languages"] = ({"mode": "auto", "selected": []} if value == "auto"
|
|
228
|
+
else {"mode": "manual", "selected": [v.strip() for v in str(value).split(",") if v.strip()]})
|
|
229
|
+
else:
|
|
230
|
+
raise KeyError(key)
|
|
231
|
+
write_file(data)
|
kompactor/estimate.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Replay past Claude Code sessions through Kompactor to estimate savings.
|
|
2
|
+
|
|
3
|
+
Reads ~/.claude/projects/**/*.jsonl, finds every Bash and Read tool result,
|
|
4
|
+
and runs them through the same filters the hooks use. A token removed from a
|
|
5
|
+
tool result is not just saved once: it would have been re-sent (as a cache
|
|
6
|
+
read) on every later API call in that session until the next compaction. The
|
|
7
|
+
estimate accounts for that, which is why context savings compound.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import glob
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import filters, outline, tokens
|
|
18
|
+
|
|
19
|
+
# Relative price weights (input = 1). Same ratios across current Claude models.
|
|
20
|
+
W_INPUT, W_CACHE_WRITE, W_CACHE_READ, W_OUTPUT = 1.0, 1.25, 0.1, 5.0
|
|
21
|
+
LINE_NO = re.compile(r"^\s*\d+[\t→]", re.M)
|
|
22
|
+
# After an outline, assume Claude still reads this share of the file by range.
|
|
23
|
+
OUTLINE_FOLLOWUP = 0.4
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _text(content) -> str:
|
|
27
|
+
if isinstance(content, str):
|
|
28
|
+
return content
|
|
29
|
+
if isinstance(content, list):
|
|
30
|
+
return "\n".join(b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text")
|
|
31
|
+
return ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def transcripts(root=None, project=None):
|
|
35
|
+
root = Path(root or Path.home() / ".claude" / "projects")
|
|
36
|
+
pattern = f"{glob.escape(str(root / project)) if project else glob.escape(str(root)) + '/*'}/**/*.jsonl"
|
|
37
|
+
return sorted(glob.glob(pattern, recursive=True), key=os.path.getmtime)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def analyze_file(path: str, stats: dict, opts=None) -> None:
|
|
41
|
+
calls = [] # ordered unique API calls: (usage dict)
|
|
42
|
+
seen_ids = set()
|
|
43
|
+
pending = {} # tool_use_id -> (name, input)
|
|
44
|
+
results = [] # (call_index_when_ingested, name, input, text, is_error)
|
|
45
|
+
boundaries = [] # call indexes where compaction happened
|
|
46
|
+
with open(path, errors="replace") as f:
|
|
47
|
+
for line in f:
|
|
48
|
+
try:
|
|
49
|
+
d = json.loads(line)
|
|
50
|
+
except ValueError:
|
|
51
|
+
continue
|
|
52
|
+
if d.get("type") == "system" and d.get("subtype") == "compact_boundary":
|
|
53
|
+
boundaries.append(len(calls))
|
|
54
|
+
msg = d.get("message")
|
|
55
|
+
if not isinstance(msg, dict):
|
|
56
|
+
continue
|
|
57
|
+
if d.get("type") == "assistant":
|
|
58
|
+
mid = msg.get("id") or d.get("requestId")
|
|
59
|
+
if mid and mid not in seen_ids and isinstance(msg.get("usage"), dict):
|
|
60
|
+
seen_ids.add(mid)
|
|
61
|
+
calls.append(msg["usage"])
|
|
62
|
+
for b in msg.get("content") or []:
|
|
63
|
+
if isinstance(b, dict) and b.get("type") == "tool_use":
|
|
64
|
+
pending[b.get("id")] = (b.get("name"), b.get("input") or {})
|
|
65
|
+
elif d.get("type") == "user" and isinstance(msg.get("content"), list):
|
|
66
|
+
for b in msg["content"]:
|
|
67
|
+
if isinstance(b, dict) and b.get("type") == "tool_result" and b.get("tool_use_id") in pending:
|
|
68
|
+
name, inp = pending.pop(b["tool_use_id"])
|
|
69
|
+
results.append((len(calls), name, inp, _text(b.get("content")), bool(b.get("is_error"))))
|
|
70
|
+
if not calls:
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
stats["sessions"] += 1
|
|
74
|
+
for u in calls:
|
|
75
|
+
i, cw, cr, o = (u.get("input_tokens", 0) or 0, u.get("cache_creation_input_tokens", 0) or 0,
|
|
76
|
+
u.get("cache_read_input_tokens", 0) or 0, u.get("output_tokens", 0) or 0)
|
|
77
|
+
stats["billed"] += i + cw + cr + o
|
|
78
|
+
stats["cost"] += i * W_INPUT + cw * W_CACHE_WRITE + cr * W_CACHE_READ + o * W_OUTPUT
|
|
79
|
+
stats["output"] += o
|
|
80
|
+
stats["calls"] += 1
|
|
81
|
+
|
|
82
|
+
def lifetime(k):
|
|
83
|
+
"""Number of API calls that would re-send a result first ingested at call k."""
|
|
84
|
+
end = next((b for b in boundaries if b > k), len(calls))
|
|
85
|
+
return max(1, end - k)
|
|
86
|
+
|
|
87
|
+
reads = {} # path -> (signature, text) of last read, for dedupe
|
|
88
|
+
for k, name, inp, text, is_err in results:
|
|
89
|
+
n = lifetime(k)
|
|
90
|
+
t = tokens.count(text)
|
|
91
|
+
stats["tool_tokens"] += t
|
|
92
|
+
stats["tool_by_name"][name] += t
|
|
93
|
+
saved, feature = 0, None
|
|
94
|
+
if name == "Bash" and t >= (opts or {}).get("bash_min_tokens", 150):
|
|
95
|
+
cmd = inp.get("command", "")
|
|
96
|
+
new = filters.compress(cmd, text, 1 if is_err else 0, stash_id=1, opts=opts)
|
|
97
|
+
kept = tokens.count(new) + 15 # + the "[kompactor: …]" footer
|
|
98
|
+
if kept < t * 0.9:
|
|
99
|
+
saved, feature = t - kept, "bash:" + filters.classify(cmd)
|
|
100
|
+
stats["top_cmds"].append((saved * n, cmd[:80], t, kept))
|
|
101
|
+
elif name == "Read":
|
|
102
|
+
p = inp.get("file_path", "")
|
|
103
|
+
key = (p, inp.get("offset"), inp.get("limit"))
|
|
104
|
+
if key in reads and reads[key] == text and (opts or {}).get("read_dedupe", True):
|
|
105
|
+
saved, feature = max(0, t - 60), "read:dedupe"
|
|
106
|
+
elif (opts or {}).get("read_outline", True) and inp.get("offset") is None and inp.get("limit") is None \
|
|
107
|
+
and os.path.splitext(p)[1] in outline.SOURCE_EXT:
|
|
108
|
+
src = LINE_NO.sub("", text)
|
|
109
|
+
if src.count("\n") + 1 >= (opts or {}).get("read_outline_lines", 400):
|
|
110
|
+
skel = tokens.count(outline.render_outline(p, src)) + 60
|
|
111
|
+
saved = max(0, int(t * (1 - OUTLINE_FOLLOWUP)) - skel)
|
|
112
|
+
feature = "read:outline"
|
|
113
|
+
reads[key] = text
|
|
114
|
+
elif name in ("Edit", "Write", "MultiEdit", "NotebookEdit"):
|
|
115
|
+
p = inp.get("file_path", "")
|
|
116
|
+
for key in [k_ for k_ in reads if k_[0] == p]:
|
|
117
|
+
del reads[key]
|
|
118
|
+
if saved:
|
|
119
|
+
stats["saved_tool"] += saved
|
|
120
|
+
stats["saved_billed"] += saved * n
|
|
121
|
+
stats["saved_cost"] += saved * W_CACHE_WRITE + saved * (n - 1) * W_CACHE_READ
|
|
122
|
+
stats["by_feature"][feature] += saved * n
|
|
123
|
+
stats["count_feature"][feature] += 1
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def run(root=None, project=None, limit=None, style_pct=None, opts=None):
|
|
127
|
+
"""Replay transcripts. `opts` are effective settings (config.load()); defaults to the current global config."""
|
|
128
|
+
if opts is None:
|
|
129
|
+
from . import config
|
|
130
|
+
opts = config.load()
|
|
131
|
+
stats = defaultdict(float)
|
|
132
|
+
stats.update(tool_by_name=defaultdict(int), by_feature=defaultdict(int), count_feature=defaultdict(int), top_cmds=[])
|
|
133
|
+
files = transcripts(root, project)
|
|
134
|
+
if limit:
|
|
135
|
+
files = files[-limit:]
|
|
136
|
+
for f in files:
|
|
137
|
+
try:
|
|
138
|
+
analyze_file(f, stats, opts)
|
|
139
|
+
except OSError:
|
|
140
|
+
continue
|
|
141
|
+
stats["files"] = len(files)
|
|
142
|
+
if style_pct:
|
|
143
|
+
# Output-style rules are not replayable; apply an assumed prose reduction to output tokens.
|
|
144
|
+
stats["style_cost"] = stats["output"] * style_pct * W_OUTPUT
|
|
145
|
+
return stats
|