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