kompactor 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kompactor/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Kompactor: cut the tokens Claude Code spends on noise."""
2
+
3
+ __version__ = "0.5.0"
kompactor/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ import sys
2
+ from .cli import main
3
+
4
+ sys.exit(main())
kompactor/_launch.py ADDED
@@ -0,0 +1,22 @@
1
+ """Path-independent entry point: `python /path/to/kompactor/_launch.py ...`.
2
+
3
+ Hooks call Kompactor through this file so they work the same whether Kompactor was
4
+ pip-installed, cloned, or bundled inside the VS Code extension."""
5
+
6
+ import os
7
+ import sys
8
+
9
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10
+
11
+ if len(sys.argv) == 4 and sys.argv[1] == "shell":
12
+ # Shell prefix (kompactor-shell): if Kompactor can't even load, run the command untouched.
13
+ _shell, _full = sys.argv[2], sys.argv[3]
14
+ try:
15
+ from kompactor import shellwrap
16
+ except BaseException:
17
+ os.execvp(_shell, [_shell, "-c", _full])
18
+ sys.exit(shellwrap.main([_shell, _full]))
19
+
20
+ from kompactor.cli import main # noqa: E402
21
+
22
+ sys.exit(main())
kompactor/cli.py ADDED
@@ -0,0 +1,446 @@
1
+ """kompactor command line."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+
9
+ from . import __version__, config, estimate, filters, hooks, installer, outline, store, tokens
10
+
11
+
12
+ def _bar(frac: float, width: int = 24) -> str:
13
+ frac = max(0.0, min(1.0, frac))
14
+ full = int(frac * width)
15
+ return "█" * full + "░" * (width - full)
16
+
17
+
18
+ def _pct(a, b) -> str:
19
+ return f"{100 * a / b:5.1f}%" if b else " n/a"
20
+
21
+
22
+ def _fmt(n) -> str:
23
+ n = float(n)
24
+ for unit, div in (("B", 1e9), ("M", 1e6), ("k", 1e3)):
25
+ if abs(n) >= div:
26
+ return f"{n / div:.1f}{unit}"
27
+ return f"{n:.0f}"
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ def cmd_run(a):
34
+ """Run a command and print its compacted output (manual use / non-Claude agents)."""
35
+ cmd = " ".join(a.command)
36
+ p = subprocess.run(cmd, shell=True, capture_output=True, text=True, errors="replace")
37
+ raw = p.stdout + (("\n" + p.stderr) if p.stderr else "")
38
+ sid = store.stash(cmd, raw)
39
+ out = filters.compress(cmd, raw, p.returncode, stash_id=sid, opts=config.load(os.getcwd()))
40
+ r, k = tokens.count(raw), tokens.count(out)
41
+ store.record("run", r, k, cmd)
42
+ print(out)
43
+ if r > k:
44
+ print(f"[kompactor: {r}›{k} tokens; full output: kompactor show {sid}]", file=sys.stderr)
45
+ return p.returncode
46
+
47
+
48
+ def cmd_filter(a):
49
+ """Compact stdin as if it were the output of --cmd."""
50
+ raw = sys.stdin.read()
51
+ print(filters.compress(a.cmd or "", raw, a.exit_code, opts=config.load(os.getcwd())))
52
+ return 0
53
+
54
+
55
+ def cmd_show(a):
56
+ row = store.get_stash(a.id)
57
+ if not row:
58
+ print(f"no stashed output #{a.id}", file=sys.stderr)
59
+ return 1
60
+ cmd, out = row
61
+ lines = out.splitlines()
62
+ if a.lines:
63
+ s, _, e = a.lines.partition("-")
64
+ lines = lines[int(s) - 1:int(e or s)]
65
+ print(f"$ {cmd}\n" + "\n".join(lines))
66
+ return 0
67
+
68
+
69
+ def cmd_search(a):
70
+ rows = store.search_stash(a.query)
71
+ if not rows:
72
+ print("no matches")
73
+ return 1
74
+ for sid, cmd, hits in rows:
75
+ print(f"#{sid} $ {cmd[:100]}")
76
+ for n, ln in hits[:a.max]:
77
+ print(f" {n}: {ln[:200]}")
78
+ if len(hits) > a.max:
79
+ print(f" … +{len(hits) - a.max} more (kompactor show {sid})")
80
+ return 0
81
+
82
+
83
+ def cmd_outline(a):
84
+ for f in a.files:
85
+ if len(a.files) > 1:
86
+ print(f"{f}:")
87
+ print(outline.render_outline(f))
88
+ return 0
89
+
90
+
91
+ def cmd_map(a):
92
+ print(outline.repo_map(a.root, a.budget, a.depth))
93
+ return 0
94
+
95
+
96
+ def cmd_symbol(a):
97
+ hits = outline.find_symbol(a.name, a.root, a.paths or None)
98
+ if not hits:
99
+ print(f"no definition found for {a.name}", file=sys.stderr)
100
+ return 1
101
+ for f, s, e, src in hits:
102
+ print(f"── {f}:{s}-{e}")
103
+ print(src)
104
+ return 0
105
+
106
+
107
+ def cmd_graph(a):
108
+ from . import graph
109
+ root = config.project_root(a.root or os.getcwd()) or os.getcwd()
110
+ if a.action == "build":
111
+ graph.build(root, quiet=False, full=a.full)
112
+ return 0
113
+ if a.action in ("query", "callers", "deps", "impact") and not a.args:
114
+ print(f"usage: kompactor graph {a.action} <{'words' if a.action == 'query' else 'name or path'}>", file=sys.stderr)
115
+ return 2
116
+ try:
117
+ if a.action == "report":
118
+ print(graph.report(root, a.budget or 1800))
119
+ elif a.action == "query":
120
+ print(graph.query(root, " ".join(a.args), a.budget or 1500))
121
+ elif a.action == "callers":
122
+ print(graph.callers_text(root, a.args[0], a.budget or 1200))
123
+ elif a.action == "deps":
124
+ print(graph.deps_text(root, a.args[0], a.budget or 1200))
125
+ elif a.action == "impact":
126
+ print(graph.impact_text(root, a.args[0], a.budget or 1200))
127
+ elif a.action == "path":
128
+ if len(a.args) != 2:
129
+ print("usage: kompactor graph path <from> <to>", file=sys.stderr)
130
+ return 2
131
+ print(graph.path_text(root, a.args[0], a.args[1]))
132
+ elif a.action == "map":
133
+ graph.ensure(root)
134
+ print(graph.session_map(root, a.budget or 800))
135
+ elif a.action == "unused":
136
+ print(graph.unused_text(root, a.budget or 1500))
137
+ elif a.action == "explain":
138
+ print(graph.explain_text(root, a.args[0] if a.args else "", a.budget or 1200))
139
+ elif a.action == "stats":
140
+ v = graph.viz(root)
141
+ print(json.dumps({k: v[k] for k in ("files", "symbols", "refs", "units", "approx_source_tokens",
142
+ "map_tokens", "meta")}, indent=2))
143
+ except FileNotFoundError as e:
144
+ print(e, file=sys.stderr)
145
+ return 1
146
+ return 0
147
+
148
+
149
+ def cmd_hook(a):
150
+ return hooks.run(a.name)
151
+
152
+
153
+ def cmd_gain(a):
154
+ with store.connect() as db:
155
+ rows = db.execute("SELECT feature, COUNT(*), SUM(raw_tokens), SUM(kept_tokens) FROM events GROUP BY feature "
156
+ "ORDER BY SUM(raw_tokens) - SUM(kept_tokens) DESC").fetchall()
157
+ top = db.execute("SELECT detail, raw_tokens, kept_tokens FROM events WHERE feature='bash' "
158
+ "ORDER BY raw_tokens - kept_tokens DESC LIMIT 8").fetchall()
159
+ if not rows:
160
+ print("No savings recorded yet. Install the hooks (`kompactor install`) and use Claude Code, "
161
+ "or run `kompactor estimate` to see what Kompactor would have saved on your past sessions.")
162
+ return 0
163
+ tr = sum(r[2] for r in rows)
164
+ tk = sum(r[3] for r in rows)
165
+ print("Kompactor live savings (tool output entering Claude's context)\n")
166
+ print(f" {'feature':<14}{'events':>8}{'before':>10}{'after':>10}{'saved':>9}")
167
+ for f, n, r, k in rows:
168
+ print(f" {f:<14}{n:>8}{_fmt(r):>10}{_fmt(k):>10}{_pct(r - k, r):>9}")
169
+ print(f" {'TOTAL':<14}{sum(r[1] for r in rows):>8}{_fmt(tr):>10}{_fmt(tk):>10}{_pct(tr - tk, tr):>9}")
170
+ print(f"\n {_bar((tr - tk) / tr if tr else 0)} {_fmt(tr - tk)} tokens kept out of context")
171
+ print(" (each one is also re-sent on every later turn until compaction, so billed savings are larger;"
172
+ " see `kompactor estimate`)")
173
+ if top:
174
+ print("\n biggest wins:")
175
+ for d, r, k in top:
176
+ print(f" {_fmt(r):>6} › {_fmt(k):<6} {d[:70]}")
177
+ return 0
178
+
179
+
180
+ def cmd_estimate(a):
181
+ style_pct = {"off": 0, "lite": 0.10, "full": 0.25}[a.style]
182
+ s = estimate.run(a.root, a.project, a.limit, style_pct)
183
+ if not s["calls"]:
184
+ print("No Claude Code transcripts with usage data found under ~/.claude/projects.")
185
+ return 1
186
+ if a.json:
187
+ print(json.dumps({k: v for k, v in s.items() if k != "top_cmds"}, default=dict, indent=2))
188
+ return 0
189
+ tool, billed, cost = s["tool_tokens"], s["billed"], s["cost"]
190
+ st_cost = s.get("style_cost", 0)
191
+ print(f"Kompactor estimate — replayed {int(s['sessions'])} sessions, {int(s['calls'])} API calls\n")
192
+ print(f" Tool output in context {_fmt(tool):>8} tokens (" +
193
+ ", ".join(f"{k} {_pct(v, tool).strip()}" for k, v in sorted(s['tool_by_name'].items(), key=lambda x: -x[1])[:4])
194
+ + ")")
195
+ print(f" Total billed tokens {_fmt(billed):>8} (input + cache write + cache read + output)")
196
+ print()
197
+ print(f" Tool-output tokens removed {_fmt(s['saved_tool']):>8} {_bar(s['saved_tool'] / tool if tool else 0)} {_pct(s['saved_tool'], tool)}")
198
+ print(f" Billed tokens saved {_fmt(s['saved_billed']):>8} {_bar(s['saved_billed'] / billed)} {_pct(s['saved_billed'], billed)}")
199
+ print(f" Cost saved (context) {'':>8} {_bar(s['saved_cost'] / cost)} {_pct(s['saved_cost'], cost)}")
200
+ if st_cost:
201
+ tot = s["saved_cost"] + st_cost
202
+ print(f" + output style '{a.style}' (assumed −{int(style_pct * 100)}% output){'':>1} {_bar(tot / cost)} {_pct(tot, cost)} total cost")
203
+ print("\n by feature (billed tokens saved):")
204
+ for f, v in sorted(s["by_feature"].items(), key=lambda x: -x[1]):
205
+ print(f" {f:<18}{int(s['count_feature'][f]):>6}× {_fmt(v):>8} {_pct(v, billed)}")
206
+ if s["top_cmds"]:
207
+ print("\n biggest single wins (tokens before › after, Bash):")
208
+ for _, cmd, r, k in sorted(s["top_cmds"], reverse=True)[:6]:
209
+ print(f" {_fmt(r):>6} › {_fmt(k):<6} {cmd}")
210
+ print("\n Method: Bash/Read results replayed through Kompactor's filters; savings weighted by how many later API"
211
+ "\n calls would re-send them (cache reads at 0.1×, writes 1.25×, output 5× input price). Read-outline"
212
+ f"\n assumes {int(estimate.OUTLINE_FOLLOWUP * 100)}% of the file is still read afterwards. Output-style savings are an assumption,"
213
+ "\n not measured.")
214
+ return 0
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+
219
+
220
+ def cmd_install(a):
221
+ target = installer.install("project" if a.project else "user", statusline=not a.no_statusline,
222
+ uninstall=a.uninstall, shell_prefix=not a.no_shell_prefix)
223
+ print(f"{'Removed' if a.uninstall else 'Installed'} Kompactor hooks in {target}")
224
+ if not a.uninstall:
225
+ print("New Claude Code sessions (CLI and VS Code) will use them. Settings: `kompactor ui`.")
226
+ _prefix_note(installer.status())
227
+ return 0
228
+
229
+
230
+ def _prefix_note(st):
231
+ note = {"kompactor": "Shell prefix: on (failing tests and builds are compacted too)",
232
+ "other": "Shell prefix: another tool already sets CLAUDE_CODE_SHELL_PREFIX, so failing commands are "
233
+ "not compacted",
234
+ "missing": "Shell prefix: its script is missing; run `kompactor setup` again",
235
+ "none": "Shell prefix: off (only commands that succeed are compacted)"}[st["shell_prefix"]]
236
+ print(f" {note}")
237
+
238
+
239
+ def cmd_setup(a):
240
+ """One-step setup: enable hooks for CLI + VS Code, then open the dashboard."""
241
+ target = installer.install(statusline=config.read_file()["general"].get("statusline", True))
242
+ st = installer.status()
243
+ print("✓ Kompactor plugin is enabled; hooks are active." if st["plugin_enabled"] else f"✓ Hooks installed in {target}")
244
+ print(f" Claude Code CLI: {'found' if st['claude_cli'] else 'not found'}")
245
+ print(f" VS Code extension: {st['vscode_extension'] or 'not found'} (uses the same settings)")
246
+ _prefix_note(st)
247
+ if a.no_ui:
248
+ return 0
249
+ print("Opening the dashboard to choose your quality level…")
250
+ from . import server
251
+ server.serve(a.port, open_browser=True)
252
+ return 0
253
+
254
+
255
+ def cmd_ui(a):
256
+ from . import server
257
+ server.serve(a.port, open_browser=not a.no_browser)
258
+ return 0
259
+
260
+
261
+ def cmd_statusline(a):
262
+ """Claude Code statusLine command: one line of running savings for this session."""
263
+ try:
264
+ event = json.loads(sys.stdin.read() or "{}")
265
+ except ValueError:
266
+ event = {}
267
+ sid = event.get("session_id", "")
268
+ cfg = config.load((event.get("workspace") or {}).get("current_dir") or event.get("cwd") or os.getcwd())
269
+ name = config.PRESETS[cfg["preset"]]["name"]
270
+ tot = store.session_totals(sid) if sid else {}
271
+ if tot.get("saved", 0) > 0:
272
+ parts = [f"Kompactor −{hooks._fmt(tot['saved'])}"]
273
+ if tot["context"] and tot["in_context_saved"] > 0:
274
+ parts.append(f"ctx −{100 * tot['in_context_saved'] / (tot['context'] + tot['in_context_saved']):.0f}%")
275
+ if tot["last_turn_saved"] > 0:
276
+ parts.append(f"last turn −{hooks._fmt(tot['last_turn_saved'])}")
277
+ parts.append(name)
278
+ print(" · ".join(parts))
279
+ else:
280
+ print(f"Kompactor · {name}")
281
+ return 0
282
+
283
+
284
+ def cmd_config(a):
285
+ if a.key and a.value is not None:
286
+ try:
287
+ config.save(a.key, a.value)
288
+ except (KeyError, ValueError) as e:
289
+ print(f"unknown or invalid setting: {e}", file=sys.stderr)
290
+ return 1
291
+ eff = config.load(os.getcwd())
292
+ print(f"preset {eff['preset']} · {config.PRESETS[eff['preset']]['name']} languages: {', '.join(eff['languages']) or '-'}")
293
+ for k in config.KNOBS:
294
+ print(f" {k:<20} {eff[k]}")
295
+ print(f"(edit in the dashboard: kompactor ui · file: {config.path()})")
296
+ return 0
297
+
298
+
299
+ def cmd_presets(a):
300
+ from . import server
301
+ data = config.read_file()
302
+ presets = {p: {"name": v["name"], "blurb": v["blurb"]} for p, v in config.PRESETS.items()}
303
+ savings = server.preset_savings()
304
+ if a.json:
305
+ print(json.dumps({"current": data["preset"], "presets": presets, "savings": savings}))
306
+ return 0
307
+ for p, v in presets.items():
308
+ mark = "●" if p == data["preset"] else " "
309
+ print(f" {mark} {p} {v['name']:<12} ≈{savings[p]:>2}% on samples {v['blurb']}")
310
+ print("Change with `kompactor config preset N` or in the dashboard (`kompactor ui`).")
311
+ return 0
312
+
313
+
314
+ def cmd_doctor(a):
315
+ print(f"kompactor {__version__} python {sys.version.split()[0]} home {store.home()}")
316
+ print(f"tokenizer: {'tiktoken' if tokens._ENC else 'heuristic (pip install tiktoken for exact counts)'}")
317
+ with store.connect() as db:
318
+ print(f"sqlite fts5: {'yes' if db.fts else 'no (search falls back to LIKE)'}")
319
+ st = installer.status()
320
+ for k, v in st.items():
321
+ print(f"{k}: {v}")
322
+ log = store.home() / "hook-errors.log"
323
+ if log.exists():
324
+ print("recent hook errors:\n" + "".join(log.read_text().splitlines(True)[-5:]))
325
+ return 0
326
+
327
+
328
+ def main(argv=None):
329
+ argv = sys.argv[1:] if argv is None else argv
330
+ if argv[:1] == ["prove"]: # has its own parser
331
+ from . import prove
332
+ return prove.main(argv[1:])
333
+ ap = argparse.ArgumentParser(prog="kompactor", description="Cut the tokens Claude Code spends on noise.")
334
+ ap.add_argument("--version", action="version", version=__version__)
335
+ sp = ap.add_subparsers(dest="cmd", required=True)
336
+
337
+ p = sp.add_parser("estimate", help="replay past Claude Code sessions and show what Kompactor would save")
338
+ p.add_argument("--root", help="transcripts dir (default ~/.claude/projects)")
339
+ p.add_argument("--project", help="only this project folder under the root")
340
+ p.add_argument("--limit", type=int, help="only the N most recent transcripts")
341
+ p.add_argument("--style", choices=["off", "lite", "full"], default="lite")
342
+ p.add_argument("--json", action="store_true")
343
+ p.set_defaults(fn=cmd_estimate)
344
+
345
+ p = sp.add_parser("gain", help="live savings recorded by the hooks")
346
+ p.set_defaults(fn=cmd_gain)
347
+
348
+ p = sp.add_parser("setup", help="one-step setup: enable for Claude Code (CLI + VS Code) and open the dashboard")
349
+ p.add_argument("--no-ui", action="store_true")
350
+ p.add_argument("--port", type=int, default=7420)
351
+ p.set_defaults(fn=cmd_setup)
352
+
353
+ p = sp.add_parser("ui", help="open the settings & savings dashboard")
354
+ p.add_argument("--port", type=int, default=7420)
355
+ p.add_argument("--no-browser", action="store_true")
356
+ p.set_defaults(fn=cmd_ui)
357
+
358
+ p = sp.add_parser("install", help="add Kompactor hooks to Claude Code settings")
359
+ p.add_argument("--project", action="store_true", help="write .claude/settings.json instead of ~/.claude")
360
+ p.add_argument("--uninstall", action="store_true")
361
+ p.add_argument("--no-statusline", action="store_true")
362
+ p.add_argument("--no-shell-prefix", action="store_true",
363
+ help="don't set CLAUDE_CODE_SHELL_PREFIX (then commands that fail are not compacted)")
364
+ p.set_defaults(fn=cmd_install)
365
+
366
+ p = sp.add_parser("statusline", help="(internal) Claude Code status line")
367
+ p.set_defaults(fn=cmd_statusline)
368
+
369
+ p = sp.add_parser("map", help="ranked repo skeleton within a token budget")
370
+ p.add_argument("root", nargs="?", default=".")
371
+ p.add_argument("--budget", type=int, default=4000)
372
+ p.add_argument("--depth", type=int, default=1, help="nesting depth of symbols to include")
373
+ p.set_defaults(fn=cmd_map)
374
+
375
+ p = sp.add_parser("outline", help="symbols of a file with line ranges")
376
+ p.add_argument("files", nargs="+")
377
+ p.set_defaults(fn=cmd_outline)
378
+
379
+ p = sp.add_parser("symbol", help="print the source of a definition (Name or Class.method)")
380
+ p.add_argument("name")
381
+ p.add_argument("paths", nargs="*")
382
+ p.add_argument("--root", default=".")
383
+ p.set_defaults(fn=cmd_symbol)
384
+
385
+ p = sp.add_parser("run", help="run a command and print compacted output")
386
+ p.add_argument("command", nargs=argparse.REMAINDER)
387
+ p.set_defaults(fn=cmd_run)
388
+
389
+ p = sp.add_parser("filter", help="compact stdin")
390
+ p.add_argument("--cmd", help="command that produced the input (picks the filter)")
391
+ p.add_argument("--exit-code", type=int, default=0)
392
+ p.set_defaults(fn=cmd_filter)
393
+
394
+ p = sp.add_parser("show", help="print a stashed raw output")
395
+ p.add_argument("id", type=int)
396
+ p.add_argument("--lines", help="e.g. 120-180")
397
+ p.set_defaults(fn=cmd_show)
398
+
399
+ p = sp.add_parser("search", help="full-text search across stashed raw outputs")
400
+ p.add_argument("query")
401
+ p.add_argument("--max", type=int, default=15)
402
+ p.set_defaults(fn=cmd_search)
403
+
404
+ p = sp.add_parser("config", help="show or set config (kompactor config preset 4); easier: kompactor ui")
405
+ p.add_argument("key", nargs="?")
406
+ p.add_argument("value", nargs="?")
407
+ p.set_defaults(fn=cmd_config)
408
+
409
+ p = sp.add_parser("graph", help="code graph: build, report, query, callers, deps, impact, path, map")
410
+ p.add_argument("action", choices=["build", "report", "query", "callers", "deps", "impact", "path", "explain",
411
+ "unused", "map", "stats"])
412
+ p.add_argument("args", nargs="*")
413
+ p.add_argument("--root", help="project directory (default: current project)")
414
+ p.add_argument("--budget", type=int, help="max tokens of output")
415
+ p.add_argument("--full", action="store_true", help="rebuild from scratch")
416
+ p.set_defaults(fn=cmd_graph)
417
+
418
+ p = sp.add_parser("debt", help="ledger of shortcut:/FIXME/HACK/TODO comments")
419
+ p.add_argument("--root")
420
+ p.set_defaults(fn=lambda a: print(__import__("kompactor.graph", fromlist=["graph"]).debt_text(
421
+ config.project_root(a.root or os.getcwd()) or os.getcwd())) or 0)
422
+
423
+ sp.add_parser("prove", help="A/B test: run real Claude Code tasks with and without Kompactor (see --help)")
424
+
425
+ p = sp.add_parser("presets", help="list quality-vs-tokens levels")
426
+ p.add_argument("--json", action="store_true")
427
+ p.set_defaults(fn=cmd_presets)
428
+
429
+ p = sp.add_parser("hook", help="(internal) Claude Code hook entrypoint")
430
+ p.add_argument("name", choices=list(hooks.HANDLERS))
431
+ p.set_defaults(fn=cmd_hook)
432
+
433
+ p = sp.add_parser("doctor", help="check installation")
434
+ p.set_defaults(fn=cmd_doctor)
435
+
436
+ a = ap.parse_args(argv)
437
+ if a.cmd == "run" and a.command[:1] == ["--"]:
438
+ a.command = a.command[1:]
439
+ try:
440
+ return a.fn(a) or 0
441
+ except BrokenPipeError:
442
+ return 0
443
+
444
+
445
+ if __name__ == "__main__":
446
+ sys.exit(main())