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