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/hooks.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Claude Code hook handlers (used by both the CLI and the VS Code extension,
|
|
2
|
+
which share ~/.claude/settings.json). Each reads the hook JSON on stdin and
|
|
3
|
+
prints a JSON response, or nothing, meaning "carry on unchanged".
|
|
4
|
+
|
|
5
|
+
Every handler fails open: any exception leaves the tool call untouched.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from . import config, filters, graph, installer, langs, outline, rules, store, tokens
|
|
14
|
+
|
|
15
|
+
TEXT_LIMIT = 2_000_000 # don't touch huge/binary files
|
|
16
|
+
BINARY_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".ipynb", ".svg", ".ico", ".zip", ".gz"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _emit(obj) -> None:
|
|
20
|
+
sys.stdout.write(json.dumps(obj))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _fmt(n) -> str:
|
|
24
|
+
n = float(n)
|
|
25
|
+
return f"{n / 1e6:.1f}M" if abs(n) >= 1e6 else f"{n / 1e3:.1f}k" if abs(n) >= 1e3 else f"{n:.0f}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# PostToolUse(Bash): compress output before Claude sees it
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def post_bash(event: dict, cfg: dict) -> None:
|
|
33
|
+
if not cfg["bash"]:
|
|
34
|
+
return
|
|
35
|
+
resp = event.get("tool_response")
|
|
36
|
+
cmd = (event.get("tool_input") or {}).get("command", "")
|
|
37
|
+
if not isinstance(resp, dict) or "stdout" not in resp or resp.get("isImage"):
|
|
38
|
+
return
|
|
39
|
+
if cmd.lstrip().startswith(("kompactor ", "KOMPACTOR_RAW=1", "~/.kompactor/bin/kompactor")) or "_launch.py" in cmd:
|
|
40
|
+
return
|
|
41
|
+
words = " ".join(filters.argv0(cmd))
|
|
42
|
+
if any(words.startswith(c.strip()) for c in cfg.get("never_compact", []) if c.strip()):
|
|
43
|
+
return
|
|
44
|
+
out, err = resp.get("stdout") or "", resp.get("stderr") or ""
|
|
45
|
+
if "[kompactor: " in out[-400:]: # already compacted (shell prefix, or hooks registered twice)
|
|
46
|
+
return
|
|
47
|
+
# Claude Code saves outputs over ~30K characters to a file and shows Claude a 2 KB preview of it;
|
|
48
|
+
# hooks get only the head. Compact the whole output instead, and drop the file reference so Claude
|
|
49
|
+
# sees the compacted text rather than the preview.
|
|
50
|
+
full = None
|
|
51
|
+
if resp.get("persistedOutputPath"):
|
|
52
|
+
if filters.classify(cmd) == "view": # cat/head of a huge file: the preview and saved path serve Claude better
|
|
53
|
+
return
|
|
54
|
+
full = _persisted(resp)
|
|
55
|
+
if full is None:
|
|
56
|
+
return
|
|
57
|
+
shown = None
|
|
58
|
+
if full is not None:
|
|
59
|
+
shown = tokens.count(full[:2048]) + 60
|
|
60
|
+
out = full
|
|
61
|
+
raw_tokens = tokens.count(out) + tokens.count(err)
|
|
62
|
+
if raw_tokens < cfg["bash_min_tokens"]:
|
|
63
|
+
return
|
|
64
|
+
failed = bool(resp.get("interrupted")) or bool(filters.IMPORTANT.search(err[-2000:] + out[-2000:]))
|
|
65
|
+
code = 1 if failed else 0
|
|
66
|
+
sid = store.stash(cmd, out + ("\n--- stderr ---\n" + err if err else ""))
|
|
67
|
+
new_out = filters.compress(cmd, out, code, stash_id=sid, opts=cfg) if out else ""
|
|
68
|
+
new_err = filters.compress(cmd, err, code, stash_id=sid, opts=cfg) if err else ""
|
|
69
|
+
new_out = _fit(new_out, PERSIST_LIMIT) if full is not None else new_out
|
|
70
|
+
kept = tokens.count(new_out) + tokens.count(new_err)
|
|
71
|
+
if kept > raw_tokens * 0.9 and full is None: # not worth changing what Claude sees
|
|
72
|
+
return
|
|
73
|
+
new_out += f"\n[kompactor: {raw_tokens}›{kept} tokens; full output: {installer.cli_name()} show {sid}]"
|
|
74
|
+
store.record("bash", raw_tokens if shown is None else shown, kept, cmd, event.get("session_id", ""),
|
|
75
|
+
project=cfg["project"])
|
|
76
|
+
resp = {k: v for k, v in resp.items() if k not in ("persistedOutputPath", "persistedOutputSize")}
|
|
77
|
+
_emit({"hookSpecificOutput": {"hookEventName": "PostToolUse",
|
|
78
|
+
"updatedToolOutput": {**resp, "stdout": new_out, "stderr": new_err}}})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
PERSIST_READ = 8_000_000
|
|
82
|
+
PERSIST_LIMIT = 25_000 # stay under Claude Code's ~30K-character limit, or it would save our text to a file too
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _persisted(resp: dict):
|
|
86
|
+
path = resp.get("persistedOutputPath")
|
|
87
|
+
if not isinstance(path, str) or not path:
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
91
|
+
return f.read(PERSIST_READ)
|
|
92
|
+
except OSError:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _fit(text: str, limit: int) -> str:
|
|
97
|
+
"""Keep whole lines from the head (2/3) and tail (1/3) of text within limit characters."""
|
|
98
|
+
if len(text) <= limit:
|
|
99
|
+
return text
|
|
100
|
+
lines = text.split("\n")
|
|
101
|
+
head, tail, size, i, j = [], [], 0, 0, len(lines) - 1
|
|
102
|
+
while i <= j:
|
|
103
|
+
from_head = len(head) < 2 * (len(tail) + 1)
|
|
104
|
+
ln = lines[i] if from_head else lines[j]
|
|
105
|
+
if size + len(ln) + 1 > limit - 80:
|
|
106
|
+
break
|
|
107
|
+
size += len(ln) + 1
|
|
108
|
+
if from_head:
|
|
109
|
+
head.append(ln)
|
|
110
|
+
i += 1
|
|
111
|
+
else:
|
|
112
|
+
tail.append(ln)
|
|
113
|
+
j -= 1
|
|
114
|
+
return "\n".join(head + [f"… {j - i + 1} lines omitted …"] + tail[::-1])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# PreToolUse(Read): outline-first for big files, dedupe unchanged re-reads
|
|
119
|
+
#
|
|
120
|
+
# Per (session, file) state machine:
|
|
121
|
+
# (none) --big file--> "outlined" (deny once with outline) --next Read--> "read"
|
|
122
|
+
# (none) ------------> "read" --unchanged re-read--> "dup" (deny once) --next Read--> "read"
|
|
123
|
+
# A deny is always followed by an allow on retry, so Claude can never get stuck.
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _deny(reason: str) -> None:
|
|
127
|
+
_emit({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny",
|
|
128
|
+
"permissionDecisionReason": reason}})
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def pre_read(event: dict, cfg: dict) -> None:
|
|
132
|
+
ledger = [] # savings are logged after the reads transaction commits (one writer at a time)
|
|
133
|
+
reason = _pre_read(event, cfg, ledger)
|
|
134
|
+
for feature, raw, kept, detail, force in ledger:
|
|
135
|
+
store.record(feature, raw, kept, detail, event.get("session_id", ""), force=force, project=cfg["project"])
|
|
136
|
+
if reason:
|
|
137
|
+
_deny(reason)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _pre_read(event: dict, cfg: dict, ledger: list):
|
|
141
|
+
inp = event.get("tool_input") or {}
|
|
142
|
+
path = inp.get("file_path", "")
|
|
143
|
+
session = event.get("session_id", "")
|
|
144
|
+
try:
|
|
145
|
+
st = os.stat(path)
|
|
146
|
+
except OSError:
|
|
147
|
+
return None
|
|
148
|
+
ext = os.path.splitext(path)[1].lower()
|
|
149
|
+
if st.st_size > TEXT_LIMIT or ext in BINARY_EXT:
|
|
150
|
+
return None
|
|
151
|
+
sig = f"{st.st_mtime_ns}:{st.st_size}:{inp.get('offset')}:{inp.get('limit')}"
|
|
152
|
+
partial = inp.get("offset") is not None or inp.get("limit") is not None
|
|
153
|
+
source_ext = set(langs.merged(cfg["languages"], "ext")) | outline.SOURCE_EXT
|
|
154
|
+
|
|
155
|
+
with store.connect() as db:
|
|
156
|
+
row = db.execute("SELECT sig, state, pending FROM reads WHERE session=? AND path=?", (session, path)).fetchone()
|
|
157
|
+
|
|
158
|
+
def put(state, pending=0):
|
|
159
|
+
db.execute("INSERT OR REPLACE INTO reads VALUES (?,?,?,?,?,?)", (session, path, sig, state, pending, time.time()))
|
|
160
|
+
|
|
161
|
+
if row is None:
|
|
162
|
+
if cfg["read_outline"] and not partial and ext in source_ext:
|
|
163
|
+
text = _read(path)
|
|
164
|
+
n = text.count("\n") + 1
|
|
165
|
+
gen = langs.generated_re(cfg["languages"])
|
|
166
|
+
is_gen = bool(gen and gen.search(path))
|
|
167
|
+
if n >= (min(150, cfg["read_outline_lines"]) if is_gen else cfg["read_outline_lines"]):
|
|
168
|
+
skel = outline.render_outline(path, text)
|
|
169
|
+
if skel and cfg.get("read_graph_context") and cfg.get("graph") and cfg["project"]:
|
|
170
|
+
try:
|
|
171
|
+
ctx = graph.file_context(cfg["project"], path)
|
|
172
|
+
except Exception: # the graph is an optional extra
|
|
173
|
+
ctx = ""
|
|
174
|
+
if ctx:
|
|
175
|
+
skel = f"{skel}\n\n{ctx}"
|
|
176
|
+
if skel:
|
|
177
|
+
kind = "a generated file" if is_gen else f"{n} lines"
|
|
178
|
+
msg = (f"[kompactor] Not an error: {path} is {kind} (~{tokens.count(text)} tokens), so here is "
|
|
179
|
+
f"its outline (line ranges in brackets). Read just the ranges you need with offset/limit, "
|
|
180
|
+
f"or repeat the same Read to get the whole file.\n\n{skel}")
|
|
181
|
+
raw, kept = tokens.count(text), tokens.count(msg)
|
|
182
|
+
if raw > kept * 1.5:
|
|
183
|
+
put("outlined", raw - kept)
|
|
184
|
+
ledger.append(("read_outline", raw, kept, path, False))
|
|
185
|
+
return msg
|
|
186
|
+
put("read")
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
prev_sig, state, pending = row
|
|
190
|
+
if state == "outlined":
|
|
191
|
+
if not partial: # Claude wants the whole file after all: undo the claimed saving
|
|
192
|
+
ledger.append(("read_outline", 0, pending, path + " (full read anyway)", True))
|
|
193
|
+
put("read")
|
|
194
|
+
return None
|
|
195
|
+
if state == "dup":
|
|
196
|
+
ledger.append(("read_dedupe", 0, pending, path + " (re-read anyway)", True))
|
|
197
|
+
put("read")
|
|
198
|
+
return None
|
|
199
|
+
if cfg["read_dedupe"] and prev_sig == sig:
|
|
200
|
+
text = _read(path)
|
|
201
|
+
if partial:
|
|
202
|
+
lines = text.splitlines()
|
|
203
|
+
off = int(inp.get("offset") or 1)
|
|
204
|
+
text = "\n".join(lines[max(off - 1, 0): off - 1 + int(inp.get("limit") or 2000)])
|
|
205
|
+
msg = (f"[kompactor] Not an error: {path} is unchanged since you read this range earlier in this session, "
|
|
206
|
+
f"so it is already in your context. If it is not (e.g. after compaction), repeat the same Read.")
|
|
207
|
+
raw, kept = tokens.count(text), tokens.count(msg)
|
|
208
|
+
if raw > kept * 2:
|
|
209
|
+
put("dup", raw - kept)
|
|
210
|
+
ledger.append(("read_dedupe", raw, kept, path, False))
|
|
211
|
+
return msg
|
|
212
|
+
put("read")
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _read(path: str) -> str:
|
|
217
|
+
with open(path, errors="replace") as f:
|
|
218
|
+
return f.read()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# Session lifecycle and per-turn summary
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _rules(cfg: dict) -> str:
|
|
226
|
+
return rules.build(cfg.get("style", "lite"), cfg.get("code_style", "normal"), installer.cli_name(),
|
|
227
|
+
plain=bool(cfg.get("bash") and cfg.get("bash_plain") and cfg.get("bash_wrap")
|
|
228
|
+
and installer.prefix_active()))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def subagent_start(event: dict, cfg: dict) -> None:
|
|
232
|
+
"""Subagents get the same prose and code rules as the main agent (no code map)."""
|
|
233
|
+
if not cfg.get("subagent_rules", True):
|
|
234
|
+
return
|
|
235
|
+
text = rules.build(cfg.get("style", "lite"), cfg.get("code_style", "normal"), installer.cli_name())
|
|
236
|
+
if text:
|
|
237
|
+
_emit({"hookSpecificOutput": {"hookEventName": "SubagentStart", "additionalContext": text}})
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def post_explore(event: dict, cfg: dict) -> None:
|
|
241
|
+
"""The first time Claude searches or reads code in a session, add the code map.
|
|
242
|
+
|
|
243
|
+
Sessions that only run commands never pay for it."""
|
|
244
|
+
budget = cfg.get("session_map_tokens", 0)
|
|
245
|
+
root = cfg.get("project")
|
|
246
|
+
if budget <= 0 or not cfg.get("graph") or not root or not graph.exists(root):
|
|
247
|
+
return
|
|
248
|
+
if not store.once(event.get("session_id", ""), "code-map"):
|
|
249
|
+
return
|
|
250
|
+
text = graph.session_map(root, budget)
|
|
251
|
+
if text:
|
|
252
|
+
store.record("code_map", 0, 0, root, event.get("session_id", ""), force=True, project=root)
|
|
253
|
+
_emit({"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": text}})
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def post_edit(event: dict, cfg: dict) -> None:
|
|
257
|
+
"""Claude edited a file: refresh the code graph in the background (incremental, debounced)."""
|
|
258
|
+
if cfg.get("graph") and cfg.get("project") and graph.exists(cfg["project"]):
|
|
259
|
+
graph.ensure(cfg["project"], max_age=20)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def pre_compact(event: dict, cfg: dict) -> None:
|
|
263
|
+
store.mark_compacted(event.get("session_id", ""))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def session_start(event: dict, cfg: dict) -> None:
|
|
267
|
+
try: # so outputs can point at `~/.kompactor/bin/kompactor` even before `kompactor setup` (plugin installs)
|
|
268
|
+
installer.write_shim(only_if_changed=True)
|
|
269
|
+
except OSError:
|
|
270
|
+
pass
|
|
271
|
+
if event.get("source") in ("compact", "clear"):
|
|
272
|
+
store.mark_compacted(event.get("session_id", ""))
|
|
273
|
+
elif event.get("source") == "startup":
|
|
274
|
+
store.prune(cfg.get("retention_days", 30))
|
|
275
|
+
parts = [_rules(cfg)]
|
|
276
|
+
root = cfg.get("project")
|
|
277
|
+
if cfg.get("graph") and root and event.get("source") in ("startup", "resume", "clear", "compact", None):
|
|
278
|
+
try:
|
|
279
|
+
graph.ensure(root) # build or refresh; the map itself is given on first exploration
|
|
280
|
+
except Exception as e: # never block a session on the graph
|
|
281
|
+
(store.home() / "hook-errors.log").open("a").write(f"{time.ctime()} graph: {e!r}\n")
|
|
282
|
+
text = "\n\n".join(p for p in parts if p)
|
|
283
|
+
if text:
|
|
284
|
+
_emit({"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": text}})
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _context_tokens(transcript_path: str) -> int:
|
|
288
|
+
"""Size of the context Claude last saw, from the newest usage block in the transcript."""
|
|
289
|
+
try:
|
|
290
|
+
with open(transcript_path, "rb") as f:
|
|
291
|
+
f.seek(0, 2)
|
|
292
|
+
f.seek(max(0, f.tell() - 400_000))
|
|
293
|
+
tail = f.read().decode("utf-8", "replace").splitlines()
|
|
294
|
+
except (OSError, TypeError):
|
|
295
|
+
return 0
|
|
296
|
+
for line in reversed(tail):
|
|
297
|
+
if '"usage"' not in line:
|
|
298
|
+
continue
|
|
299
|
+
try:
|
|
300
|
+
u = json.loads(line).get("message", {}).get("usage", {})
|
|
301
|
+
except ValueError:
|
|
302
|
+
continue
|
|
303
|
+
if u:
|
|
304
|
+
return int(u.get("input_tokens", 0) + u.get("cache_read_input_tokens", 0)
|
|
305
|
+
+ u.get("cache_creation_input_tokens", 0))
|
|
306
|
+
return 0
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def turn_message(turn: dict, totals: dict) -> str:
|
|
310
|
+
parts = []
|
|
311
|
+
if turn["saved"] > 0:
|
|
312
|
+
pct = 100 * turn["saved"] / turn["raw"] if turn["raw"] else 0
|
|
313
|
+
parts.append(f"−{_fmt(turn['saved'])} tokens this turn ({_fmt(turn['raw'])} › {_fmt(turn['kept'])}, −{pct:.0f}%)")
|
|
314
|
+
else:
|
|
315
|
+
parts.append("nothing to trim this turn")
|
|
316
|
+
if totals["saved"] > 0:
|
|
317
|
+
parts.append(f"session −{_fmt(totals['saved'])}")
|
|
318
|
+
ctx, live = totals.get("context", 0), totals.get("in_context_saved", 0)
|
|
319
|
+
if ctx and live > 0:
|
|
320
|
+
parts.append(f"context ≈{100 * live / (ctx + live):.0f}% smaller")
|
|
321
|
+
return "Kompactor: " + " · ".join(parts)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def stop(event: dict, cfg: dict) -> None:
|
|
325
|
+
if event.get("stop_hook_active"):
|
|
326
|
+
return
|
|
327
|
+
session = event.get("session_id", "")
|
|
328
|
+
ctx = _context_tokens(event.get("transcript_path", ""))
|
|
329
|
+
turn, totals = store.close_turn(session, cfg["project"], ctx)
|
|
330
|
+
msg = turn_message(turn, totals)
|
|
331
|
+
store.write_live({"ts": time.time(), "session": session, "project": cfg["project"], "preset": cfg["preset"],
|
|
332
|
+
"preset_name": config.PRESETS[cfg["preset"]]["name"], "turn": turn, "session_totals": totals,
|
|
333
|
+
"message": msg})
|
|
334
|
+
if cfg.get("turn_summary") and turn["saved"] > 0:
|
|
335
|
+
_emit({"systemMessage": msg})
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
HANDLERS = {"post-bash": post_bash, "pre-read": pre_read, "pre-compact": pre_compact,
|
|
339
|
+
"session-start": session_start, "stop": stop, "subagent-start": subagent_start, "post-edit": post_edit,
|
|
340
|
+
"post-explore": post_explore}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def run(name: str) -> int:
|
|
344
|
+
try:
|
|
345
|
+
event = json.load(sys.stdin)
|
|
346
|
+
HANDLERS[name](event, config.load(event.get("cwd") or os.getcwd()))
|
|
347
|
+
except Exception as e: # fail open, but leave a trace for `kompactor doctor`
|
|
348
|
+
try:
|
|
349
|
+
with (store.home() / "hook-errors.log").open("a") as f:
|
|
350
|
+
f.write(f"{time.ctime()} {name}: {e!r}\n")
|
|
351
|
+
except OSError:
|
|
352
|
+
pass
|
|
353
|
+
return 0
|