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/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