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.

Potentially problematic release.


This version of kompactor might be problematic. Click here for more details.

kompactor/outline.py ADDED
@@ -0,0 +1,274 @@
1
+ """Code skeletons, repo maps and symbol extraction - no dependencies.
2
+
3
+ Python is parsed with `ast`; other languages use declaration regexes plus
4
+ brace/indent matching, which is good enough for navigation (the agent can
5
+ always Read the exact lines afterwards).
6
+ """
7
+
8
+ import ast
9
+ import os
10
+ import re
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ SOURCE_EXT = {".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".go", ".rs", ".java", ".kt", ".kts", ".scala",
15
+ ".swift", ".c", ".h", ".cc", ".cpp", ".hpp", ".cs", ".rb", ".php", ".lua", ".dart", ".sh", ".vue",
16
+ ".svelte", ".ex", ".exs"}
17
+
18
+ DECL = re.compile(
19
+ r"^(?P<indent>\s*)(?:export\s+)?(?:default\s+)?(?:pub(?:\([^)]*\))?\s+)?(?:public\s+|private\s+|protected\s+|internal\s+"
20
+ r"|static\s+|abstract\s+|final\s+|override\s+|open\s+|sealed\s+|data\s+|async\s+|unsafe\s+|inline\s+|virtual\s+)*"
21
+ r"(?P<kind>class|interface|struct|enum|trait|impl|type|fn|func|function\*?|def|defp|defmodule|module|object|record"
22
+ r"|protocol|extension|namespace|const|let|var|val)\s+(?P<rest>.*)$")
23
+ # Methods inside classes for JS/TS/Java/C#/Kotlin: `name(args) {` / `Type name(args) {`
24
+ METHOD = re.compile(r"^(?P<indent>\s+)(?:(?:public|private|protected|static|async|override|get|set|readonly|final"
25
+ r"|virtual|abstract|synchronized)\s+)*(?:[\w<>\[\],.?]+\s+)?(?P<name>#?[A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?"
26
+ r"\([^;]*\)\s*(?::\s*[^={]+)?\s*(?:throws [\w., ]+)?\{?\s*$")
27
+ NOT_METHOD = {"if", "for", "while", "switch", "catch", "return", "else", "do", "try", "function", "new", "await"}
28
+
29
+
30
+ def _sig_py(node, src_lines):
31
+ strip = lambda ln: re.sub(r"\s+#.*$", "", ln).strip() # noqa: E731 (drop trailing comments)
32
+ line = strip(src_lines[node.lineno - 1])
33
+ # include multi-line signatures up to the colon
34
+ i = node.lineno
35
+ while not line.endswith(":") and i < len(src_lines) and i < node.lineno + 8:
36
+ line += " " + strip(src_lines[i])
37
+ i += 1
38
+ return re.sub(r"\s+", " ", line)
39
+
40
+
41
+ def outline_python(src: str):
42
+ tree = ast.parse(src)
43
+ lines = src.splitlines()
44
+ out = []
45
+
46
+ def walk(body, depth):
47
+ for n in body:
48
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
49
+ doc = ast.get_docstring(n)
50
+ sig = "".join(f"@{ast.unparse(d)} " for d in n.decorator_list) + _sig_py(n, lines)
51
+ if doc:
52
+ sig += f' # {doc.strip().splitlines()[0][:80]}'
53
+ out.append((n.lineno, n.end_lineno, depth, sig))
54
+ if isinstance(n, ast.ClassDef):
55
+ walk(n.body, depth + 1)
56
+ elif depth == 0 and isinstance(n, (ast.Assign, ast.AnnAssign)) and n.lineno == n.end_lineno:
57
+ txt = lines[n.lineno - 1].strip()
58
+ if re.match(r"^[A-Z_][A-Z0-9_]*\s*[:=]", txt): # module constants only
59
+ out.append((n.lineno, n.end_lineno, depth, txt[:120]))
60
+ walk(tree.body, 0)
61
+ return out
62
+
63
+
64
+ def _depth(ws: str, unit: int) -> int:
65
+ return ws.count("\t") + ws.count(" ") // unit
66
+
67
+
68
+ def outline_generic(src: str):
69
+ out = []
70
+ lines = src.splitlines()
71
+ spaces = [len(ln) - len(ln.lstrip(" ")) for ln in lines if ln.startswith(" ") and ln.strip()]
72
+ unit = min([n for n in spaces if n >= 2] or [4])
73
+ for i, ln in enumerate(lines, 1):
74
+ if len(ln) > 400:
75
+ continue
76
+ m = DECL.match(ln)
77
+ if m:
78
+ kind, indent = m.group("kind"), _depth(m.group("indent"), unit)
79
+ if (kind in ("const", "let", "var", "val") and indent > 0) or ln.rstrip().endswith(","):
80
+ continue
81
+ if kind in ("const", "let", "var", "val") and not re.search(r"=\s*(async\s*)?(\(|function|\w+\s*=>|class|\{$|\[$|new |require|import|styled|create)", ln):
82
+ if not re.match(r"^\s*(export\s+)?const [A-Z_][A-Z0-9_]*\s*[=:]", ln):
83
+ continue
84
+ out.append((i, _block_end(lines, i), indent, _clip(ln)))
85
+ continue
86
+ mm = METHOD.match(ln)
87
+ if mm and mm.group("name") not in NOT_METHOD and ln.rstrip().endswith("{"):
88
+ out.append((i, _block_end(lines, i), _depth(mm.group("indent"), unit), _clip(ln)))
89
+ return out
90
+
91
+
92
+ def _clip(ln: str) -> str:
93
+ s = ln.strip().rstrip("{").rstrip()
94
+ return s if len(s) <= 160 else s[:157] + "..."
95
+
96
+
97
+ def _block_end(lines, start):
98
+ """1-based end line of the block starting at `start` (brace or indent based)."""
99
+ depth, seen = 0, False
100
+ for j in range(start - 1, min(len(lines), start + 5000)):
101
+ code = re.sub(r"(\"(\\.|[^\"\\])*\"|'(\\.|[^'\\])*'|`[^`]*`|//.*$)", "", lines[j])
102
+ depth += code.count("{") - code.count("}")
103
+ seen |= "{" in code
104
+ if seen and depth <= 0:
105
+ return j + 1
106
+ if not seen and code.rstrip().endswith(";"):
107
+ return j + 1
108
+ if not seen and j >= start: # no brace on the declaration line or the next one
109
+ break
110
+ # indentation-based languages (ruby/elixir/lua `end`, or none)
111
+ base = len(lines[start - 1]) - len(lines[start - 1].lstrip())
112
+ for j in range(start, len(lines)):
113
+ s = lines[j]
114
+ if s.strip() and len(s) - len(s.lstrip()) <= base:
115
+ return j + (1 if s.strip() in ("end", "}", "};") else 0)
116
+ return len(lines)
117
+
118
+
119
+ def outline(path: str, src: str = None):
120
+ """List of (start, end, depth, signature) for a file."""
121
+ if src is None:
122
+ src = Path(path).read_text(errors="replace")
123
+ if path.endswith(".py"):
124
+ try:
125
+ return outline_python(src)
126
+ except SyntaxError:
127
+ pass
128
+ return outline_generic(src)
129
+
130
+
131
+ def render_outline(path: str, src: str = None, max_items: int = 400) -> str:
132
+ items = outline(path, src)
133
+ rows = [f"{' ' * d}{sig} [L{s}-{e}]" if e and e > s else f"{' ' * d}{sig} [L{s}]"
134
+ for s, e, d, sig in items[:max_items]]
135
+ if len(items) > max_items:
136
+ rows.append(f"… +{len(items) - max_items} more symbols")
137
+ return "\n".join(rows)
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # repo map
142
+
143
+
144
+ IGNORE_DIRS = {".git", "node_modules", "dist", "build", "out", "target", "vendor", ".venv", "venv", "__pycache__",
145
+ ".next", ".nuxt", "coverage", ".mypy_cache", ".pytest_cache", ".tox", ".idea", ".gradle"}
146
+
147
+
148
+ def list_files(root: str):
149
+ try:
150
+ res = subprocess.run(["git", "ls-files", "-co", "--exclude-standard"], cwd=root, capture_output=True,
151
+ text=True, timeout=20)
152
+ if res.returncode == 0:
153
+ return [f for f in res.stdout.splitlines() if f]
154
+ except (OSError, subprocess.TimeoutExpired):
155
+ pass
156
+ files = []
157
+ for d, dirs, fs in os.walk(root):
158
+ dirs[:] = [x for x in dirs if x not in IGNORE_DIRS and not x.startswith(".")]
159
+ files += [os.path.relpath(os.path.join(d, f), root) for f in fs]
160
+ return files
161
+
162
+
163
+ TESTY = re.compile(r"(^|/)(tests?|__tests__|test-d|spec|specs|fixtures?|examples?|e2e|benchmarks?)(/|$)|[._-](test|spec)\.\w+$|(^|/)test_[^/]+$")
164
+
165
+
166
+ def _mappable(path: str, sig: str) -> bool:
167
+ """In JS/TS only exported values make the map; locals are noise there."""
168
+ if Path(path).suffix in (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs") and re.match(r"(const|let|var)\s", sig):
169
+ return False
170
+ return True
171
+
172
+
173
+ def repo_map(root: str = ".", budget_tokens: int = 4000, depth_limit: int = 1) -> str:
174
+ """Compact map of every source file's top-level symbols, trimmed to a token budget.
175
+
176
+ Files are ordered by how often their symbols are referenced elsewhere (a cheap
177
+ PageRank stand-in), so the most central code survives trimming.
178
+ """
179
+ from . import tokens
180
+
181
+ files = [f for f in list_files(root) if Path(f).suffix in SOURCE_EXT]
182
+ entries, words = [], {}
183
+ for f in files:
184
+ p = os.path.join(root, f)
185
+ try:
186
+ if os.path.getsize(p) > 400_000:
187
+ continue
188
+ src = Path(p).read_text(errors="replace")
189
+ except OSError:
190
+ continue
191
+ words[f] = set(re.findall(r"[A-Za-z_]\w{3,}", src))
192
+ items = [it for it in outline(f, src) if it[2] <= depth_limit and _mappable(f, it[3])]
193
+ entries.append((f, items))
194
+
195
+ # rank files by how many *other* files reference the symbols they define
196
+ defined = {}
197
+ for f, items in entries:
198
+ for _, _, _, sig in items:
199
+ m = re.search(r"(?:def|class|function|func|fn|interface|struct|type|enum|trait|const|let|var)\s+\*?([A-Za-z_]\w{3,})", sig)
200
+ if m:
201
+ defined.setdefault(m.group(1), set()).add(f)
202
+ score = {f: 0.0 for f, _ in entries}
203
+ for name, owners in defined.items():
204
+ users = sum(1 for f, ws in words.items() if name in ws and f not in owners)
205
+ for f in owners:
206
+ score[f] += users
207
+ for f in score:
208
+ if TESTY.search(f):
209
+ score[f] = score[f] * 0.05 - 1
210
+
211
+ entries.sort(key=lambda e: (-score[e[0]], e[0]))
212
+ out, used = [], 0
213
+ skipped = []
214
+ for f, items in entries:
215
+ block = [f"{f}:"] + [f" {' ' * d}{sig}" for _, _, d, sig in items[:60]]
216
+ if len(items) > 60:
217
+ block.append(f" … +{len(items) - 60} more")
218
+ cost = tokens.count("\n".join(block))
219
+ if used + cost > budget_tokens and out:
220
+ skipped.append(f)
221
+ continue
222
+ out += block
223
+ used += cost
224
+ if skipped:
225
+ out.append(f"[{len(skipped)} lower-ranked files omitted to fit budget: {', '.join(skipped[:15])}"
226
+ f"{' …' if len(skipped) > 15 else ''}]")
227
+ return "\n".join(out)
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # symbol lookup
232
+
233
+
234
+ NAME = re.compile(r"\b(?:def|class|function\*?|func|fn|interface|struct|type|enum|trait|const|let|var|val|impl|module"
235
+ r"|object|record|protocol|namespace|defp?|defmodule)\s+(?:\([^)]*\)\s*)?\*?([A-Za-z_$][\w$.]*)")
236
+
237
+
238
+ def _name(sig: str):
239
+ sig = re.sub(r"^(@\S+(\([^)]*\))?\s+)+", "", sig)
240
+ m = NAME.search(sig)
241
+ if m:
242
+ return m.group(1).split(".")[-1]
243
+ m = re.match(r"^(?:[\w<>\[\],.?]+\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(", sig)
244
+ return m.group(1) if m else None
245
+
246
+
247
+ def find_symbol(name: str, root: str = ".", paths=None, max_hits: int = 5):
248
+ """Yield (file, start, end, source) for definitions named `name` (supports Class.method)."""
249
+ parts = name.split(".")
250
+ leaf = parts[-1]
251
+ hits = []
252
+ for f in paths or list_files(root):
253
+ if Path(f).suffix not in SOURCE_EXT:
254
+ continue
255
+ p = f if os.path.isabs(f) else os.path.join(root, f)
256
+ try:
257
+ src = Path(p).read_text(errors="replace")
258
+ except OSError:
259
+ continue
260
+ if leaf not in src:
261
+ continue
262
+ lines = src.splitlines()
263
+ items = outline(f, src)
264
+ for idx, (s, e, d, sig) in enumerate(items):
265
+ if _name(sig) != leaf:
266
+ continue
267
+ if len(parts) > 1: # require an enclosing parent with the given name
268
+ parents = [it for it in items[:idx] if it[2] < d and it[0] <= s <= (it[1] or s)]
269
+ if not parents or parts[-2] not in parents[-1][3]:
270
+ continue
271
+ hits.append((f, s, e, "\n".join(lines[s - 1:e])))
272
+ if len(hits) >= max_hits:
273
+ return hits
274
+ return hits
kompactor/prove.py ADDED
@@ -0,0 +1,317 @@
1
+ """A/B proof: run the same Claude Code tasks with and without Kompactor.
2
+
3
+ kompactor prove --tasks bench/tasks.json --repos /path/to/checkouts [--reps 1] [--preset 3]
4
+
5
+ Each task runs through headless `claude -p` in both conditions with identical
6
+ prompts, tools, turn limits and budget caps. The only difference is Kompactor's
7
+ hooks, passed with --settings. Every run gets a fresh session id and its own
8
+ Kompactor data folder, so no state leaks between runs. Recorded per run: tokens
9
+ (input + cache writes + cache reads + output, as Claude Code bills them), cost,
10
+ turns, wall time, whether the answer contains the expected facts, and Kompactor's
11
+ own ledger. Writes results.json and REPORT.md to --out.
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import shlex
18
+ import sqlite3
19
+ import statistics
20
+ import subprocess
21
+ import sys
22
+ import threading
23
+ import time
24
+ import uuid
25
+ from concurrent.futures import ThreadPoolExecutor
26
+ from pathlib import Path
27
+
28
+ from . import __version__, config, installer
29
+
30
+
31
+ PLUGIN_ROOT = Path(__file__).resolve().parent.parent
32
+
33
+
34
+ def hook_settings() -> dict:
35
+ cmd = installer.launcher()
36
+ hooks = {}
37
+ for event, matcher, handler in installer.HOOKS:
38
+ entry = {"hooks": [{"type": "command", "command": f"{cmd} hook {handler}", "timeout": 15}]}
39
+ hooks.setdefault(event, []).append({"matcher": matcher, **entry} if matcher else entry)
40
+ return {"hooks": hooks}
41
+
42
+
43
+ def _setup(repo: Path, steps, undo=False):
44
+ for step in steps or []:
45
+ if step[0] == "write":
46
+ p = repo / step[1]
47
+ if undo:
48
+ p.unlink(missing_ok=True)
49
+ else:
50
+ p.parent.mkdir(parents=True, exist_ok=True)
51
+ p.write_text(step[2])
52
+
53
+
54
+ def _ledger(home: Path) -> dict:
55
+ db = home / "kompactor.db"
56
+ if not db.exists():
57
+ return {"events": 0, "saved": 0, "by_feature": {}}
58
+ c = sqlite3.connect(db)
59
+ try:
60
+ rows = c.execute("SELECT feature, COUNT(*), SUM(raw_tokens - kept_tokens) FROM events GROUP BY feature").fetchall()
61
+ finally:
62
+ c.close()
63
+ return {"events": sum(r[1] for r in rows), "saved": sum(r[2] or 0 for r in rows),
64
+ "by_feature": {r[0]: {"count": r[1], "saved": r[2]} for r in rows}}
65
+
66
+
67
+ def _tool_trace(session_id: str) -> list:
68
+ """Tool calls Claude made in a run and how big each result was, from its transcript."""
69
+ files = list(Path.home().glob(f".claude/projects/*/{session_id}.jsonl"))
70
+ if not files:
71
+ return []
72
+ uses, out = {}, []
73
+ for line in files[0].read_text(errors="replace").splitlines():
74
+ try:
75
+ d = json.loads(line)
76
+ except ValueError:
77
+ continue
78
+ m = d.get("message")
79
+ content = m.get("content") if isinstance(m, dict) else None
80
+ for b in content if isinstance(content, list) else []:
81
+ if b.get("type") == "tool_use":
82
+ inp = b.get("input") or {}
83
+ uses[b.get("id")] = (b.get("name"), inp.get("command") or inp.get("file_path") or inp.get("pattern") or "")
84
+ elif b.get("type") == "tool_result" and b.get("tool_use_id") in uses:
85
+ c = b.get("content")
86
+ text = c if isinstance(c, str) else json.dumps(c)
87
+ name, arg = uses[b["tool_use_id"]]
88
+ out.append({"tool": name, "arg": str(arg)[:160], "result_chars": len(text),
89
+ "kompactor": "[kompactor" in text})
90
+ return out
91
+
92
+
93
+ def run_once(task: dict, repo: Path, condition: str, work: Path, preset: int, model=None, budget=1.0,
94
+ overrides=None) -> dict:
95
+ run_dir = work / f"{task['id']}-{condition}-{uuid.uuid4().hex[:6]}"
96
+ home = run_dir / "kompactor"
97
+ home.mkdir(parents=True)
98
+ env = dict(os.environ, KOMPACTOR_HOME=str(home))
99
+ env.pop(installer.PREFIX_ENV, None)
100
+ env.pop("CLAUDE_CODE_SESSION_ID", None) # when run from inside Claude Code; each run has its own session
101
+ tools = list(task["tools"])
102
+ session_id = str(uuid.uuid4())
103
+ args = ["claude", "-p", task["prompt"], "--output-format", "json", "--max-turns", str(task.get("max_turns", 10)),
104
+ "--session-id", session_id, "--max-budget-usd", str(budget), "--strict-mcp-config"]
105
+ if condition == "kompactor":
106
+ (home / "config.json").write_text(json.dumps({"preset": preset, "overrides": overrides or {}}))
107
+ if (PLUGIN_ROOT / ".claude-plugin" / "plugin.json").exists(): # exactly what a plugin user gets: hooks + skill
108
+ args += ["--plugin-dir", str(PLUGIN_ROOT)]
109
+ else:
110
+ settings = run_dir / "settings.json"
111
+ settings.write_text(json.dumps(hook_settings()))
112
+ args += ["--settings", str(settings)]
113
+ tools += sorted({"Bash(kompactor:*)", f"Bash({installer.cli_name()}:*)"})
114
+ prefix = run_dir / "kompactor-shell" # what `kompactor setup` installs as ~/.kompactor/bin/kompactor-shell
115
+ prefix.write_text(installer.SHELL_PREFIX.format(py=shlex.quote(sys.executable or "python3"),
116
+ script=shlex.quote(str(Path(installer.__file__).with_name("_launch.py")))))
117
+ prefix.chmod(0o755)
118
+ env[installer.PREFIX_ENV] = str(prefix)
119
+ args += ["--allowedTools", ",".join(tools)]
120
+ if model:
121
+ args += ["--model", model]
122
+ t0 = time.time()
123
+ p = subprocess.run(args, cwd=repo, env=env, capture_output=True, text=True, timeout=1200,
124
+ stdin=subprocess.DEVNULL)
125
+ wall = time.time() - t0
126
+ try:
127
+ data = json.loads(p.stdout)
128
+ except ValueError:
129
+ data = {"is_error": True, "result": (p.stdout or p.stderr)[-500:]}
130
+ if data.get("is_error") and not data.get("num_turns"):
131
+ raise RuntimeError(f"claude failed to start ({task['id']}, {condition}): {(p.stderr or p.stdout)[-300:]}")
132
+ usage = data.get("usage") or {}
133
+ by_model = data.get("modelUsage") or {}
134
+ if by_model: # includes helper models Claude Code used during the run
135
+ tok = {k: sum(m.get(k, 0) or 0 for m in by_model.values()) for k in
136
+ ("inputTokens", "outputTokens", "cacheReadInputTokens", "cacheCreationInputTokens")}
137
+ tokens = {"input": tok["inputTokens"], "output": tok["outputTokens"], "cache_read": tok["cacheReadInputTokens"],
138
+ "cache_write": tok["cacheCreationInputTokens"]}
139
+ else:
140
+ tokens = {"input": usage.get("input_tokens", 0), "output": usage.get("output_tokens", 0),
141
+ "cache_read": usage.get("cache_read_input_tokens", 0),
142
+ "cache_write": usage.get("cache_creation_input_tokens", 0)}
143
+ tokens["total"] = sum(tokens.values())
144
+ answer = data.get("result") or ""
145
+ missing = [e for e in task.get("expect", []) if e.lower() not in answer.lower()]
146
+ return {"task": task["id"], "lang": task.get("lang", ""), "condition": condition, "tokens": tokens,
147
+ "cost": data.get("total_cost_usd", 0) or 0, "turns": data.get("num_turns", 0), "wall_s": round(wall, 1),
148
+ "correct": not missing and not data.get("is_error"), "missing": missing, "error": bool(data.get("is_error")),
149
+ "subtype": data.get("subtype", ""), "models": sorted(by_model), "answer": answer[:1500],
150
+ "tools": _tool_trace(session_id), "kompactor": _ledger(home) if condition == "kompactor" else None}
151
+
152
+
153
+ def summarize(results: list) -> dict:
154
+ out = {}
155
+ for r in results:
156
+ out.setdefault(r["task"], {"lang": r["lang"], "baseline": [], "kompactor": []})[r["condition"]].append(r)
157
+ rows = []
158
+ for task, d in out.items():
159
+ b, k = d["baseline"], d["kompactor"]
160
+ if not b or not k:
161
+ continue
162
+ mean = lambda rs, f: statistics.mean(f(r) for r in rs) # noqa: E731
163
+ rows.append({
164
+ "task": task, "lang": d["lang"], "runs": len(b),
165
+ "base_tokens": mean(b, lambda r: r["tokens"]["total"]), "k_tokens": mean(k, lambda r: r["tokens"]["total"]),
166
+ "base_range": (min(r["tokens"]["total"] for r in b), max(r["tokens"]["total"] for r in b)),
167
+ "k_range": (min(r["tokens"]["total"] for r in k), max(r["tokens"]["total"] for r in k)),
168
+ "base_tool_chars": mean(b, lambda r: sum(t["result_chars"] for t in r.get("tools", []))),
169
+ "k_tool_chars": mean(k, lambda r: sum(t["result_chars"] for t in r.get("tools", []))),
170
+ "base_cost": mean(b, lambda r: r["cost"]), "k_cost": mean(k, lambda r: r["cost"]),
171
+ "base_turns": mean(b, lambda r: r["turns"]), "k_turns": mean(k, lambda r: r["turns"]),
172
+ "base_output": mean(b, lambda r: r["tokens"]["output"]), "k_output": mean(k, lambda r: r["tokens"]["output"]),
173
+ "base_correct": sum(r["correct"] for r in b), "k_correct": sum(r["correct"] for r in k),
174
+ "k_saved_ledger": mean(k, lambda r: (r["kompactor"] or {}).get("saved", 0)),
175
+ "k_events": mean(k, lambda r: (r["kompactor"] or {}).get("events", 0)),
176
+ })
177
+ return {"rows": rows}
178
+
179
+
180
+ def _pct(a, b):
181
+ return f"{100 * (b - a) / a:+.0f}%" if a else "n/a"
182
+
183
+
184
+ def report_md(results: list, meta: dict) -> str:
185
+ rows = summarize(results)["rows"]
186
+ tb = sum(r["base_tokens"] for r in rows)
187
+ tk = sum(r["k_tokens"] for r in rows)
188
+ cb = sum(r["base_cost"] for r in rows)
189
+ ck = sum(r["k_cost"] for r in rows)
190
+ ob = sum(r["base_output"] for r in rows)
191
+ ok = sum(r["k_output"] for r in rows)
192
+ lines = [
193
+ "# Kompactor A/B evidence",
194
+ "",
195
+ f"Real headless Claude Code sessions (`claude -p`, Claude Code {meta.get('claude_version', '?')}, "
196
+ f"model {', '.join(meta.get('models', [])) or 'default'}), Kompactor {meta['kompactor_version']} at level "
197
+ f"{meta['preset']} ({config.PRESETS[meta['preset']]['name']}). {meta['reps']} run(s) per task and condition; "
198
+ f"same prompt, tools, turn limit and budget cap in both conditions. Generated {meta['date']}.",
199
+ "",
200
+ f"**Total across {len(rows)} tasks: {tb:,.0f} › {tk:,.0f} tokens ({_pct(tb, tk)}), "
201
+ f"cost ${cb:.2f} › ${ck:.2f} ({_pct(cb, ck)}), output tokens {_pct(ob, ok)}. "
202
+ f"Correct answers: {sum(r['base_correct'] for r in rows)}/{sum(r['runs'] for r in rows)} without, "
203
+ f"{sum(r['k_correct'] for r in rows)}/{sum(r['runs'] for r in rows)} with Kompactor.**",
204
+ "",
205
+ "| Task | Lang | Tokens without (min–max) | Tokens with (min–max) | Δ tokens | Cost without | Cost with | Δ cost "
206
+ "| Tool output chars | Turns | Correct |",
207
+ "|---|---|---:|---:|---:|---:|---:|---:|---|---|---|",
208
+ ]
209
+ for r in rows:
210
+ lines.append(f"| {r['task']} | {r['lang']} | {r['base_tokens']:,.0f} ({r['base_range'][0] / 1000:.0f}k–"
211
+ f"{r['base_range'][1] / 1000:.0f}k) | {r['k_tokens']:,.0f} ({r['k_range'][0] / 1000:.0f}k–"
212
+ f"{r['k_range'][1] / 1000:.0f}k) | {_pct(r['base_tokens'], r['k_tokens'])} | ${r['base_cost']:.3f} | "
213
+ f"${r['k_cost']:.3f} | {_pct(r['base_cost'], r['k_cost'])} | {r['base_tool_chars'] / 1000:.1f}k › "
214
+ f"{r['k_tool_chars'] / 1000:.1f}k | {r['base_turns']:.1f} › {r['k_turns']:.1f} | "
215
+ f"{r['base_correct']}/{r['runs']} › {r['k_correct']}/{r['runs']} |")
216
+ lines += ["", "Tokens = input + cache writes + cache reads + output, summed over every API call in the session "
217
+ "(what Claude Code bills). Δ is relative to the run without Kompactor. Correct means the final answer "
218
+ "contained every expected fact (see `bench/tasks.json`).", "",
219
+ "## What happened in each run", "",
220
+ "Tool calls Claude made (from the session transcript), result size, and what Kompactor changed.", ""]
221
+ for r in sorted(results, key=lambda r: (r["task"], r["condition"])):
222
+ calls = "; ".join(f"{t['tool']} {t['arg'][:60]} ({t['result_chars']:,} chars{', compacted' if t['kompactor'] else ''})"
223
+ for t in r.get("tools", [])[:6]) or "no tool calls"
224
+ led = ""
225
+ if r["condition"] == "kompactor" and r["kompactor"]:
226
+ led = " · Kompactor: " + (", ".join(f"{f} ×{v['count']} (−{v['saved']:,} tokens)"
227
+ for f, v in r["kompactor"]["by_feature"].items()) or "nothing to compact")
228
+ lines.append(f"- **{r['task']} · {r['condition']}** ({r['tokens']['total']:,} tokens, {r['turns']} turns): "
229
+ f"{calls}{led}")
230
+ lines += ["", "## Answers", ""]
231
+ for r in results:
232
+ lines.append(f"<details><summary>{r['task']} · {r['condition']} · "
233
+ f"{'correct' if r['correct'] else 'missing ' + ', '.join(r['missing'])}</summary>\n\n"
234
+ f"```\n{r['answer'].strip()[:1200]}\n```\n</details>\n")
235
+ return "\n".join(lines)
236
+
237
+
238
+ def main(argv=None) -> int:
239
+ ap = argparse.ArgumentParser(prog="kompactor prove", description=__doc__.split("\n\n")[0])
240
+ ap.add_argument("--tasks", required=True)
241
+ ap.add_argument("--repos", required=True, help="folder containing the task repos")
242
+ ap.add_argument("--out", default="evidence")
243
+ ap.add_argument("--reps", type=int, default=1)
244
+ ap.add_argument("--preset", type=int, default=3)
245
+ ap.add_argument("--only", help="comma-separated task ids")
246
+ ap.add_argument("--model")
247
+ ap.add_argument("--budget", type=float, default=1.0, help="max USD per run (passed to claude)")
248
+ ap.add_argument("--parallel", type=int, default=1, help="tasks to run at the same time")
249
+ ap.add_argument("--report", action="store_true", help="rewrite REPORT.md from the saved results, run nothing")
250
+ ap.add_argument("--set", action="append", default=[], metavar="KEY=VALUE",
251
+ help="override a Kompactor setting for the runs, e.g. --set bash_plain=true")
252
+ a = ap.parse_args(argv)
253
+ tasks = json.loads(Path(a.tasks).read_text())["tasks"]
254
+ overrides = {}
255
+ for kv in a.set:
256
+ k, _, v = kv.partition("=")
257
+ overrides[k] = config.coerce(k, v)
258
+ if a.only:
259
+ keep = set(a.only.split(","))
260
+ tasks = [t for t in tasks if t["id"] in keep]
261
+ out = Path(a.out).resolve() # claude runs inside each repo, so every path must be absolute
262
+ out.mkdir(parents=True, exist_ok=True)
263
+ work = out / "runs"
264
+ work.mkdir(exist_ok=True)
265
+ results_file = out / "results.json"
266
+ results = json.loads(results_file.read_text())["results"] if results_file.exists() else []
267
+ for r in results: # runs recorded before the rename from Kompact
268
+ if "kompact" in r:
269
+ r.setdefault("kompactor", r.pop("kompact"))
270
+ for t in r.get("tools", []):
271
+ if "kompact" in t:
272
+ t.setdefault("kompactor", t.pop("kompact"))
273
+ if a.report:
274
+ meta = json.loads(results_file.read_text()).get("meta", {}) if results_file.exists() else {}
275
+ results_file.write_text(json.dumps({"meta": meta, "results": results}, indent=1))
276
+ (out / "REPORT.md").write_text(report_md(results, meta))
277
+ print((out / "REPORT.md").read_text().split("\n\n|")[0])
278
+ return 0
279
+ claude_version = subprocess.run(["claude", "--version"], capture_output=True, text=True).stdout.strip()
280
+ lock = threading.Lock()
281
+ have = {}
282
+ for r in results:
283
+ have[(r["task"], r["condition"])] = have.get((r["task"], r["condition"]), 0) + 1
284
+
285
+ def do_task(task):
286
+ repo = Path(a.repos, task["repo"]).resolve()
287
+ with lock:
288
+ _setup(repo, task.get("setup"))
289
+ try:
290
+ for rep in range(a.reps):
291
+ for condition in ("baseline", "kompactor"): # interleaved so drift affects both equally
292
+ if have.get((task["id"], condition), 0) > rep:
293
+ continue
294
+ r = run_once(task, repo, condition, work, a.preset, a.model, a.budget, overrides)
295
+ with lock:
296
+ results.append(r)
297
+ results_file.write_text(json.dumps({"results": results}, indent=1))
298
+ print(f"[prove] {task['id']} · {condition} · run {rep + 1}: {r['tokens']['total']:,} tokens · "
299
+ f"${r['cost']:.3f} · {r['turns']} turns · "
300
+ f"{'correct' if r['correct'] else 'MISSING ' + ', '.join(r['missing'])}", flush=True)
301
+ finally:
302
+ with lock:
303
+ _setup(repo, task.get("setup"), undo=True)
304
+
305
+ with ThreadPoolExecutor(max_workers=max(1, a.parallel)) as pool:
306
+ list(pool.map(do_task, tasks))
307
+ meta = {"kompactor_version": __version__, "preset": a.preset, "overrides": overrides, "reps": a.reps,
308
+ "claude_version": claude_version,
309
+ "models": sorted({m for r in results for m in r.get("models", [])}), "date": time.strftime("%Y-%m-%d")}
310
+ results_file.write_text(json.dumps({"meta": meta, "results": results}, indent=1))
311
+ (out / "REPORT.md").write_text(report_md(results, meta))
312
+ print((out / "REPORT.md").read_text().split("\n\n|")[0])
313
+ return 0
314
+
315
+
316
+ if __name__ == "__main__":
317
+ sys.exit(main())
kompactor/rules.py ADDED
@@ -0,0 +1,54 @@
1
+ """Reply-style rules injected at session start (and into subagents).
2
+
3
+ Two independent dials, kept deliberately short because these tokens are paid
4
+ on every turn (caveman's rules cost ~1,160; these cost ~20-160). Kompactor's own
5
+ commands aren't advertised here: compacted output and outlines say how to get
6
+ the full text, and the code map (given on first exploration) names the graph.
7
+
8
+ - prose: off | lite | full | ultra (caveman / claude-token-efficient lineage)
9
+ - code: normal | lean | strict (ponytail lineage: reuse first, smallest diff)
10
+
11
+ Code, commands, paths, errors, numbers, negations and security warnings are
12
+ never shortened, and commit messages and docs stay normal prose.
13
+ """
14
+
15
+ ANSWER_FIRST = "Lead with the answer; no preamble, recap, restated question or praise."
16
+ PROSE = {
17
+ "off": [],
18
+ "lite": [ANSWER_FIRST],
19
+ "full": [ANSWER_FIRST, "Short sentences, fragments fine, lists over paragraphs; no narration between tool calls."],
20
+ "ultra": [ANSWER_FIRST, "Telegraphic: one line per point, articles optional, no narration. Explain only if asked."],
21
+ }
22
+
23
+ LEAN = [
24
+ "Before editing, check callers, tests and config (`{cli} graph impact <file|symbol>`).",
25
+ "Prefer no change, then reuse (codebase, stdlib, installed packages), then the smallest diff. Fix root causes.",
26
+ "No unrequested abstractions, options or refactors; one focused test for non-trivial logic.",
27
+ "Never trade away validation, error handling, security or accessibility.",
28
+ ]
29
+ CODE = {
30
+ "normal": [],
31
+ "lean": LEAN,
32
+ "strict": LEAN + ["If a smaller alternative would do, propose it first. Mark deliberate limits `shortcut: <limit>`."],
33
+ }
34
+
35
+ KEEP = "Keep code, commands, paths, errors, numbers and warnings exact; commits and docs stay normal prose."
36
+
37
+ PLAIN = ("Run tests, builds and linters plainly, without | head, | tail or | grep: Kompactor keeps every failure, "
38
+ "so one run is enough.")
39
+
40
+
41
+ def build(prose: str = "lite", code: str = "lean", cli: str = "kompactor", plain: bool = False) -> str:
42
+ lines = list(PROSE.get(prose, []))
43
+ if lines:
44
+ lines.append(KEEP)
45
+ lines += CODE.get(code, [])
46
+ if plain:
47
+ lines.append(PLAIN)
48
+ if not lines:
49
+ return ""
50
+ return "[kompactor]\n" + "\n".join(f"- {ln.format(cli=cli)}" for ln in lines)
51
+
52
+
53
+ # Back-compat for callers that look up a style by name.
54
+ STYLES = {name: build(name, "normal") for name in PROSE}