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/samples.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Realistic sample outputs used by the dashboard's live preview, so users can
|
|
2
|
+
see what Claude would receive at each quality/savings setting."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _pytest():
|
|
6
|
+
passed = "\n".join(f"tests/test_api.py::test_endpoint_{i} PASSED{' ' * 30}[{i:3d}%]" for i in range(1, 61))
|
|
7
|
+
return f"""============================= test session starts ==============================
|
|
8
|
+
platform linux -- Python 3.12.4, pytest-8.3.2, pluggy-1.5.0
|
|
9
|
+
rootdir: /home/dev/shop-api
|
|
10
|
+
configfile: pyproject.toml
|
|
11
|
+
plugins: anyio-4.4.0, cov-5.0.0
|
|
12
|
+
collected 61 items
|
|
13
|
+
|
|
14
|
+
{passed}
|
|
15
|
+
tests/test_orders.py::test_create_order FAILED [100%]
|
|
16
|
+
|
|
17
|
+
=================================== FAILURES ===================================
|
|
18
|
+
______________________________ test_create_order _______________________________
|
|
19
|
+
|
|
20
|
+
client = <FlaskClient <Flask 'shop'>>
|
|
21
|
+
|
|
22
|
+
def test_create_order(client):
|
|
23
|
+
> resp = client.post("/orders", json={{"sku": "A-1", "qty": 2}})
|
|
24
|
+
|
|
25
|
+
tests/test_orders.py:14:
|
|
26
|
+
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
|
|
27
|
+
.venv/lib/python3.12/site-packages/werkzeug/test.py:1167: in post
|
|
28
|
+
return self.open(*args, method="POST", **kw)
|
|
29
|
+
.venv/lib/python3.12/site-packages/flask/testing.py:235: in open
|
|
30
|
+
response = super().open(
|
|
31
|
+
.venv/lib/python3.12/site-packages/werkzeug/test.py:1116: in open
|
|
32
|
+
response_parts = self.run_wsgi_app(request.environ, buffered=buffered)
|
|
33
|
+
.venv/lib/python3.12/site-packages/werkzeug/test.py:988: in run_wsgi_app
|
|
34
|
+
rv = run_wsgi_app(self.application, environ, buffered=buffered)
|
|
35
|
+
.venv/lib/python3.12/site-packages/werkzeug/test.py:1264: in run_wsgi_app
|
|
36
|
+
app_rv = app(environ, start_response)
|
|
37
|
+
.venv/lib/python3.12/site-packages/flask/app.py:1498: in __call__
|
|
38
|
+
return self.wsgi_app(environ, start_response)
|
|
39
|
+
.venv/lib/python3.12/site-packages/flask/app.py:1476: in wsgi_app
|
|
40
|
+
response = self.handle_exception(e)
|
|
41
|
+
.venv/lib/python3.12/site-packages/flask/app.py:1473: in wsgi_app
|
|
42
|
+
response = self.full_dispatch_request()
|
|
43
|
+
.venv/lib/python3.12/site-packages/flask/app.py:882: in full_dispatch_request
|
|
44
|
+
rv = self.handle_user_exception(e)
|
|
45
|
+
.venv/lib/python3.12/site-packages/flask/app.py:880: in full_dispatch_request
|
|
46
|
+
rv = self.dispatch_request()
|
|
47
|
+
.venv/lib/python3.12/site-packages/flask/app.py:865: in dispatch_request
|
|
48
|
+
return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
|
|
49
|
+
shop/orders.py:41: in create_order
|
|
50
|
+
total = price_for(sku) * payload["qty"]
|
|
51
|
+
shop/pricing.py:18: in price_for
|
|
52
|
+
return CATALOG[sku].price
|
|
53
|
+
E KeyError: 'A-1'
|
|
54
|
+
|
|
55
|
+
shop/pricing.py:18: KeyError
|
|
56
|
+
=========================== short test summary info ============================
|
|
57
|
+
FAILED tests/test_orders.py::test_create_order - KeyError: 'A-1'
|
|
58
|
+
========================= 1 failed, 60 passed in 2.31s ========================="""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _gotest():
|
|
62
|
+
runs = "\n".join(f"=== RUN TestHandler/case_{i}\n--- PASS: TestHandler/case_{i} (0.00s)" for i in range(1, 41))
|
|
63
|
+
return f"""go: downloading github.com/stretchr/testify v1.9.0
|
|
64
|
+
go: downloading github.com/davecgh/go-spew v1.1.1
|
|
65
|
+
go: downloading gopkg.in/yaml.v3 v3.0.1
|
|
66
|
+
? example.com/shop/cmd/server [no test files]
|
|
67
|
+
? example.com/shop/internal/version [no test files]
|
|
68
|
+
=== RUN TestHandler
|
|
69
|
+
{runs}
|
|
70
|
+
--- PASS: TestHandler (0.02s)
|
|
71
|
+
=== RUN TestCheckout
|
|
72
|
+
--- FAIL: TestCheckout (0.00s)
|
|
73
|
+
panic: runtime error: invalid memory address or nil pointer dereference [recovered]
|
|
74
|
+
panic: runtime error: invalid memory address or nil pointer dereference
|
|
75
|
+
[signal SIGSEGV: segmentation violation code=0x1 addr=0x18 pc=0x6b2f1a]
|
|
76
|
+
|
|
77
|
+
goroutine 21 [running]:
|
|
78
|
+
testing.tRunner.func1.2({{0x6f1e40, 0x9a8b30}})
|
|
79
|
+
/usr/local/go/src/testing/testing.go:1631 +0x24a
|
|
80
|
+
testing.tRunner.func1()
|
|
81
|
+
/usr/local/go/src/testing/testing.go:1634 +0x377
|
|
82
|
+
panic({{0x6f1e40?, 0x9a8b30?}})
|
|
83
|
+
/usr/local/go/src/runtime/panic.go:770 +0x132
|
|
84
|
+
example.com/shop/internal/cart.(*Cart).Total(0x0)
|
|
85
|
+
/home/dev/shop/internal/cart/cart.go:58 +0x1a
|
|
86
|
+
example.com/shop/internal/checkout.Checkout(0xc0000a6000)
|
|
87
|
+
/home/dev/shop/internal/checkout/checkout.go:33 +0x45
|
|
88
|
+
example.com/shop/internal/checkout.TestCheckout(0xc0001044e0)
|
|
89
|
+
/home/dev/shop/internal/checkout/checkout_test.go:21 +0x5d
|
|
90
|
+
testing.tRunner(0xc0001044e0, 0x7486a8)
|
|
91
|
+
/usr/local/go/src/testing/testing.go:1689 +0xfb
|
|
92
|
+
created by testing.(*T).Run in goroutine 1
|
|
93
|
+
/usr/local/go/src/testing/testing.go:1742 +0x390
|
|
94
|
+
FAIL example.com/shop/internal/checkout 0.014s
|
|
95
|
+
ok example.com/shop/internal/cart 0.009s
|
|
96
|
+
ok example.com/shop/internal/http 0.031s
|
|
97
|
+
FAIL"""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _vitest():
|
|
101
|
+
ok = "\n".join(f" ✓ src/components/ProductCard.test.tsx > ProductCard > renders variant {i} {3 + i % 7}ms"
|
|
102
|
+
for i in range(1, 46))
|
|
103
|
+
return f"""
|
|
104
|
+
RUN v2.0.5 /home/dev/storefront
|
|
105
|
+
|
|
106
|
+
{ok}
|
|
107
|
+
❯ src/hooks/useCart.test.ts (3 tests | 1 failed) 18ms
|
|
108
|
+
× useCart > adds item and updates total 9ms
|
|
109
|
+
→ expected 0 to be 2400 // Object.is equality
|
|
110
|
+
|
|
111
|
+
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
|
|
112
|
+
|
|
113
|
+
FAIL src/hooks/useCart.test.ts > useCart > adds item and updates total
|
|
114
|
+
AssertionError: expected 0 to be 2400 // Object.is equality
|
|
115
|
+
|
|
116
|
+
- Expected
|
|
117
|
+
+ Received
|
|
118
|
+
|
|
119
|
+
- 2400
|
|
120
|
+
+ 0
|
|
121
|
+
|
|
122
|
+
❯ src/hooks/useCart.test.ts:27:32
|
|
123
|
+
25| act(() => result.current.add({{ sku: 'A-1', price: 1200 }}, 2));
|
|
124
|
+
26|
|
|
125
|
+
27| expect(result.current.total).toBe(2400);
|
|
126
|
+
| ^
|
|
127
|
+
28| }});
|
|
128
|
+
at Object.toBe (node_modules/@vitest/expect/dist/index.js:1235:20)
|
|
129
|
+
at Proxy.<anonymous> (node_modules/chai/chai.js:1661:16)
|
|
130
|
+
at Proxy.methodWrapper (node_modules/chai/chai.js:1584:25)
|
|
131
|
+
at node_modules/@vitest/runner/dist/index.js:146:14
|
|
132
|
+
at node_modules/@vitest/runner/dist/index.js:61:7
|
|
133
|
+
at runTest (node_modules/@vitest/runner/dist/index.js:960:17)
|
|
134
|
+
at processTicksAndRejections (node:internal/process/task_queues:95:5)
|
|
135
|
+
|
|
136
|
+
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
|
|
137
|
+
|
|
138
|
+
Test Files 1 failed | 4 passed (5)
|
|
139
|
+
Tests 1 failed | 47 passed (48)
|
|
140
|
+
Start at 10:42:11
|
|
141
|
+
Duration 1.94s"""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _gitlog():
|
|
145
|
+
authors = ["Priya Shah <priya@shop.dev>", "Marco Rossi <marco@shop.dev>", "Lee Kim <lee@shop.dev>"]
|
|
146
|
+
msgs = ["fix cart total rounding", "add checkout retry", "bump vite to 5.4", "refactor pricing service",
|
|
147
|
+
"handle empty SKU", "add order webhook", "tidy logging", "split ProductCard", "cache catalog lookups",
|
|
148
|
+
"go: upgrade to 1.23"]
|
|
149
|
+
out = []
|
|
150
|
+
for i in range(24):
|
|
151
|
+
out.append(f"commit {('%040x' % (0x9f3a1c * (i + 7)))[-40:]}\nAuthor: {authors[i % 3]}\n"
|
|
152
|
+
f"Date: Tue Oct {20 - i % 19} 1{i % 10}:0{i % 6}:12 2026 +0000\n\n {msgs[i % 10]}\n\n"
|
|
153
|
+
f" {'Longer explanation of why this change was needed and how it was tested.' if i % 2 else ''}\n")
|
|
154
|
+
return "\n".join(out)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _npm_build():
|
|
158
|
+
chunks = "\n".join(f"dist/assets/{n}-{abs(hash(n)) % 10**8:08x}.js {12 + i * 3.7:6.2f} kB │ gzip: {4 + i:5.2f} kB"
|
|
159
|
+
for i, n in enumerate(["index", "vendor", "react-dom", "router", "cart", "checkout", "product",
|
|
160
|
+
"account", "search", "icons", "charts", "i18n", "auth", "admin"]))
|
|
161
|
+
deps = "\n".join(f"npm http fetch GET 200 https://registry.npmjs.org/{p} {20 + i}ms (cache revalidated)"
|
|
162
|
+
for i, p in enumerate(["react", "react-dom", "vite", "typescript", "zod", "@tanstack/react-query",
|
|
163
|
+
"clsx", "date-fns", "eslint", "vitest", "msw", "postcss"] * 6))
|
|
164
|
+
return f"""{deps}
|
|
165
|
+
npm WARN deprecated inflight@1.0.6: This module is not supported, and leaks memory.
|
|
166
|
+
npm WARN deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported
|
|
167
|
+
npm WARN deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported
|
|
168
|
+
|
|
169
|
+
added 412 packages, and audited 413 packages in 9s
|
|
170
|
+
|
|
171
|
+
> storefront@1.4.0 build
|
|
172
|
+
> tsc -b && vite build
|
|
173
|
+
|
|
174
|
+
vite v5.4.2 building for production...
|
|
175
|
+
transforming...
|
|
176
|
+
✓ 1324 modules transformed.
|
|
177
|
+
rendering chunks...
|
|
178
|
+
computing gzip size...
|
|
179
|
+
dist/index.html 0.62 kB │ gzip: 0.38 kB
|
|
180
|
+
dist/assets/index-4c1e2b9a.css 18.40 kB │ gzip: 4.12 kB
|
|
181
|
+
{chunks}
|
|
182
|
+
(!) Some chunks are larger than 500 kB after minification.
|
|
183
|
+
✓ built in 6.81s"""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _gitdiff():
|
|
187
|
+
body = "\n".join(f" const line{i} = compute({i});" for i in range(1, 19))
|
|
188
|
+
lock = "\n".join(f'+ "node_modules/pkg-{i}": {{ "version": "1.{i}.0", "resolved": "https://registry.npmjs.org/pkg-{i}/-/pkg-{i}-1.{i}.0.tgz" }},'
|
|
189
|
+
for i in range(60))
|
|
190
|
+
return f"""diff --git a/src/hooks/useCart.ts b/src/hooks/useCart.ts
|
|
191
|
+
index 3f2a1c4..9b7e2d0 100644
|
|
192
|
+
--- a/src/hooks/useCart.ts
|
|
193
|
+
+++ b/src/hooks/useCart.ts
|
|
194
|
+
@@ -10,30 +10,31 @@ export function useCart() {{
|
|
195
|
+
{body}
|
|
196
|
+
- const total = items.reduce((sum, i) => sum + i.price, 0);
|
|
197
|
+
+ const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
|
|
198
|
+
{body}
|
|
199
|
+
diff --git a/package-lock.json b/package-lock.json
|
|
200
|
+
index 11aa22b..33cc44d 100644
|
|
201
|
+
--- a/package-lock.json
|
|
202
|
+
+++ b/package-lock.json
|
|
203
|
+
@@ -1,6 +1,66 @@
|
|
204
|
+
{lock}"""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
SAMPLES = [
|
|
208
|
+
{"id": "pytest", "short": "pytest", "lang": "python", "label": "Python · pytest -v (1 failure)", "cmd": "pytest -v", "text": _pytest()},
|
|
209
|
+
{"id": "gotest", "short": "go test", "lang": "go", "label": "Go · go test -v ./... (panic)", "cmd": "go test -v ./...", "text": _gotest()},
|
|
210
|
+
{"id": "vitest", "short": "vitest", "lang": "react", "label": "React · vitest run (1 failure)", "cmd": "npx vitest run",
|
|
211
|
+
"text": _vitest()},
|
|
212
|
+
{"id": "build", "short": "npm build", "lang": "typescript", "label": "TypeScript · npm install && npm run build",
|
|
213
|
+
"cmd": "npm install && npm run build", "text": _npm_build()},
|
|
214
|
+
{"id": "gitdiff", "short": "git diff", "lang": "typescript", "label": "git diff (with lock file)", "cmd": "git diff",
|
|
215
|
+
"text": _gitdiff()},
|
|
216
|
+
{"id": "gitlog", "short": "git log", "lang": "any", "label": "git log -n 24", "cmd": "git log -n 24", "text": _gitlog()},
|
|
217
|
+
]
|
kompactor/server.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Local settings & savings dashboard: `kompactor ui`.
|
|
2
|
+
|
|
3
|
+
Stdlib only. Binds to 127.0.0.1, checks the Host header (DNS rebinding) and a
|
|
4
|
+
per-launch token on every write (CSRF), so other websites can't change settings.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import secrets
|
|
10
|
+
import socket
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import webbrowser
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from urllib.parse import parse_qs, urlparse
|
|
17
|
+
|
|
18
|
+
import difflib
|
|
19
|
+
import mimetypes
|
|
20
|
+
|
|
21
|
+
from . import __version__, config, estimate, filters, graph, installer, langs, samples, store, tokens
|
|
22
|
+
|
|
23
|
+
TOKEN = secrets.token_urlsafe(24)
|
|
24
|
+
UI_FILE = Path(__file__).with_name("ui.html")
|
|
25
|
+
STATIC = Path(__file__).with_name("static")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# API
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def preview(knobs: dict, languages) -> list:
|
|
33
|
+
out = []
|
|
34
|
+
for s in samples.SAMPLES:
|
|
35
|
+
cfg = dict(knobs, languages=languages)
|
|
36
|
+
raw = tokens.count(s["text"])
|
|
37
|
+
if not cfg.get("bash", True) or raw < cfg.get("bash_min_tokens", 0):
|
|
38
|
+
text = s["text"]
|
|
39
|
+
else:
|
|
40
|
+
text = filters.compress(s["cmd"], s["text"], 0, stash_id=42, opts=cfg)
|
|
41
|
+
if tokens.count(text) > raw * 0.9:
|
|
42
|
+
text = s["text"]
|
|
43
|
+
out.append({"id": s["id"], "label": s["label"], "short": s.get("short", s["id"]), "lang": s["lang"],
|
|
44
|
+
"cmd": s["cmd"], "raw": raw,
|
|
45
|
+
"kept": tokens.count(text), "before": s["text"], "after": text,
|
|
46
|
+
"segments": segments(s["text"], text)})
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def segments(before: str, after: str) -> list:
|
|
51
|
+
"""Line-level view of what was kept, dropped or added (notes) — for the preview."""
|
|
52
|
+
a, b = filters.clean(before), after.split("\n")
|
|
53
|
+
out = []
|
|
54
|
+
for op, i1, i2, j1, j2 in difflib.SequenceMatcher(None, a, b, autojunk=False).get_opcodes():
|
|
55
|
+
if op == "equal":
|
|
56
|
+
out += [["keep", ln] for ln in a[i1:i2]]
|
|
57
|
+
else:
|
|
58
|
+
out += [["drop", ln] for ln in a[i1:i2]]
|
|
59
|
+
out += [["note", ln] for ln in b[j1:j2]]
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def preset_savings() -> dict:
|
|
64
|
+
res = {}
|
|
65
|
+
for p, preset in config.PRESETS.items():
|
|
66
|
+
rows = preview({k: preset[k] for k in config.KNOBS}, [])
|
|
67
|
+
r, k = sum(x["raw"] for x in rows), sum(x["kept"] for x in rows)
|
|
68
|
+
res[p] = round(100 * (r - k) / r) if r else 0
|
|
69
|
+
return res
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def state() -> dict:
|
|
73
|
+
data = config.read_file()
|
|
74
|
+
with store.connect() as db:
|
|
75
|
+
projects = [r[0] for r in db.execute(
|
|
76
|
+
"SELECT project FROM events WHERE project != '' GROUP BY project ORDER BY MAX(ts) DESC LIMIT 30")]
|
|
77
|
+
projects = list(dict.fromkeys(projects + list(data["projects"])))
|
|
78
|
+
cwd_project = config.project_root(os.getcwd())
|
|
79
|
+
if cwd_project and (Path(cwd_project) / ".git").exists() and cwd_project not in projects:
|
|
80
|
+
projects.append(cwd_project)
|
|
81
|
+
return {
|
|
82
|
+
"version": __version__,
|
|
83
|
+
"config": data,
|
|
84
|
+
"presets": {p: {"name": v["name"], "blurb": v["blurb"], **{k: v[k] for k in config.KNOBS}}
|
|
85
|
+
for p, v in config.PRESETS.items()},
|
|
86
|
+
"preset_savings": preset_savings(),
|
|
87
|
+
"knobs": {k: list(v) for k, v in config.KNOB_INFO.items()},
|
|
88
|
+
"knob_order": config.KNOBS,
|
|
89
|
+
"languages": {k: v["label"] for k, v in langs.PROFILES.items()},
|
|
90
|
+
"projects": [{"path": p, "detected": langs.detect(p) if os.path.isdir(p) else [],
|
|
91
|
+
"override": data["projects"].get(p),
|
|
92
|
+
"repo_file": config.repo_settings(p) if os.path.isdir(p) else {}} for p in projects],
|
|
93
|
+
"setup": installer.status(),
|
|
94
|
+
"home": str(store.home()),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def stats(params: dict) -> dict:
|
|
99
|
+
days = float(params.get("days", ["30"])[0])
|
|
100
|
+
project = params.get("project", [""])[0]
|
|
101
|
+
since = time.time() - days * 86400
|
|
102
|
+
where, args = "ts > ?", [since]
|
|
103
|
+
if project:
|
|
104
|
+
where += " AND project = ?"
|
|
105
|
+
args.append(project)
|
|
106
|
+
with store.connect() as db:
|
|
107
|
+
tot = db.execute(f"SELECT COUNT(*), COALESCE(SUM(raw_tokens),0), COALESCE(SUM(kept_tokens),0) FROM events "
|
|
108
|
+
f"WHERE {where}", args).fetchone()
|
|
109
|
+
feats = db.execute(f"SELECT feature, COUNT(*), SUM(raw_tokens), SUM(kept_tokens) FROM events WHERE {where} "
|
|
110
|
+
f"GROUP BY feature ORDER BY SUM(raw_tokens)-SUM(kept_tokens) DESC", args).fetchall()
|
|
111
|
+
turns = db.execute(f"SELECT session, idx, ts, project, raw_tokens, kept_tokens, context_tokens, features "
|
|
112
|
+
f"FROM turns WHERE {where} ORDER BY ts DESC LIMIT 60", args).fetchall()
|
|
113
|
+
top = db.execute(f"SELECT detail, COUNT(*), SUM(raw_tokens), SUM(kept_tokens) FROM events WHERE {where} "
|
|
114
|
+
f"AND feature='bash' GROUP BY detail ORDER BY SUM(raw_tokens)-SUM(kept_tokens) DESC LIMIT 10",
|
|
115
|
+
args).fetchall()
|
|
116
|
+
sessions = db.execute(f"SELECT COUNT(DISTINCT session) FROM events WHERE {where}", args).fetchone()[0]
|
|
117
|
+
return {
|
|
118
|
+
"events": tot[0], "raw": tot[1], "kept": tot[2], "saved": tot[1] - tot[2], "sessions": sessions,
|
|
119
|
+
"features": [{"feature": f[0], "count": f[1], "raw": f[2], "kept": f[3]} for f in feats],
|
|
120
|
+
"turns": [{"session": t[0], "idx": t[1], "ts": t[2], "project": t[3], "raw": t[4], "kept": t[5],
|
|
121
|
+
"context": t[6], "features": json.loads(t[7] or "{}")} for t in reversed(turns)],
|
|
122
|
+
"top": [{"cmd": t[0], "runs": t[1], "raw": t[2], "kept": t[3]} for t in top],
|
|
123
|
+
"live": store.read_live(),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def graph_state(project: str) -> dict:
|
|
128
|
+
if not project or not os.path.isdir(project):
|
|
129
|
+
return {"exists": False}
|
|
130
|
+
if not graph.exists(project):
|
|
131
|
+
return {"exists": False, "project": project}
|
|
132
|
+
v = graph.viz(project, 70)
|
|
133
|
+
g = graph.Graph(project)
|
|
134
|
+
dead, public = graph.unused(project)
|
|
135
|
+
v.update(exists=True, project=project, cycles=len(graph.import_cycles(g)),
|
|
136
|
+
unused={"dead": len(dead), "public": len(public)},
|
|
137
|
+
hubs=[{"name": n, "refs": c, "files": len(g.ref_files[n])} for n, c in g.ref_count.most_common(300)
|
|
138
|
+
if n in g.by_name and len(n) > 2][:10],
|
|
139
|
+
core=[{"unit": u, "importers": len(g.in_units.get(u, ())), "symbols": graph._key_symbols(g, u)[:6]}
|
|
140
|
+
for u in g.core_units()[:12]])
|
|
141
|
+
return v
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def save_config(body: dict) -> dict:
|
|
145
|
+
data = config.read_file()
|
|
146
|
+
if "preset" in body:
|
|
147
|
+
p = int(body["preset"])
|
|
148
|
+
if p not in config.PRESETS:
|
|
149
|
+
raise ValueError("preset must be 1-5")
|
|
150
|
+
data["preset"] = p
|
|
151
|
+
if "overrides" in body:
|
|
152
|
+
data["overrides"] = {k: config.coerce(k, v) for k, v in body["overrides"].items() if k in config.KNOBS}
|
|
153
|
+
if "languages" in body:
|
|
154
|
+
mode = body["languages"].get("mode", "auto")
|
|
155
|
+
sel = [s for s in body["languages"].get("selected", []) if s in langs.PROFILES]
|
|
156
|
+
data["languages"] = {"mode": "manual" if mode == "manual" else "auto", "selected": sel}
|
|
157
|
+
if "general" in body:
|
|
158
|
+
data["general"].update({k: bool(v) for k, v in body["general"].items() if k in config.GENERAL})
|
|
159
|
+
if "project" in body:
|
|
160
|
+
path, ov = body["project"].get("path", ""), body["project"].get("override")
|
|
161
|
+
if path:
|
|
162
|
+
if ov:
|
|
163
|
+
data["projects"][path] = {"preset": int(ov.get("preset", data["preset"])),
|
|
164
|
+
"overrides": {k: config.coerce(k, v) for k, v in
|
|
165
|
+
ov.get("overrides", {}).items() if k in config.KNOBS}}
|
|
166
|
+
else:
|
|
167
|
+
data["projects"].pop(path, None)
|
|
168
|
+
config.write_file(data)
|
|
169
|
+
return {"ok": True, "config": data}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# HTTP
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class Handler(BaseHTTPRequestHandler):
|
|
177
|
+
server_version = "kompactor"
|
|
178
|
+
|
|
179
|
+
def log_message(self, *a): # quiet
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
def _host_ok(self) -> bool:
|
|
183
|
+
host = (self.headers.get("Host") or "").split(":")[0]
|
|
184
|
+
return host in ("127.0.0.1", "localhost")
|
|
185
|
+
|
|
186
|
+
def _send(self, code: int, body, ctype="application/json"):
|
|
187
|
+
data = body if isinstance(body, bytes) else json.dumps(body).encode()
|
|
188
|
+
self.send_response(code)
|
|
189
|
+
self.send_header("Content-Type", ctype)
|
|
190
|
+
self.send_header("Content-Length", str(len(data)))
|
|
191
|
+
self.send_header("Cache-Control", "no-store")
|
|
192
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
193
|
+
self.end_headers()
|
|
194
|
+
self.wfile.write(data)
|
|
195
|
+
|
|
196
|
+
def do_GET(self):
|
|
197
|
+
if not self._host_ok():
|
|
198
|
+
return self._send(403, {"error": "bad host"})
|
|
199
|
+
u = urlparse(self.path)
|
|
200
|
+
try:
|
|
201
|
+
if u.path in ("/", "/index.html"):
|
|
202
|
+
html = UI_FILE.read_text().replace("__KOMPACTOR_TOKEN__", TOKEN)
|
|
203
|
+
return self._send(200, html.encode(), "text/html; charset=utf-8")
|
|
204
|
+
if u.path == "/api/ping":
|
|
205
|
+
return self._send(200, {"kompactor": __version__})
|
|
206
|
+
if u.path == "/api/state":
|
|
207
|
+
return self._send(200, state())
|
|
208
|
+
if u.path == "/api/stats":
|
|
209
|
+
return self._send(200, stats(parse_qs(u.query)))
|
|
210
|
+
if u.path == "/api/graph":
|
|
211
|
+
return self._send(200, graph_state(parse_qs(u.query).get("project", [""])[0]))
|
|
212
|
+
if u.path in ("/usage", "/usage.html", "/guide"):
|
|
213
|
+
self.send_response(302)
|
|
214
|
+
self.send_header("Location", "/static/usage.html")
|
|
215
|
+
self.send_header("Content-Length", "0")
|
|
216
|
+
self.end_headers()
|
|
217
|
+
return None
|
|
218
|
+
if u.path.startswith("/static/"):
|
|
219
|
+
f = (STATIC / u.path[len("/static/"):]).resolve()
|
|
220
|
+
if STATIC.resolve() in f.parents and f.is_file():
|
|
221
|
+
ctype = mimetypes.guess_type(f.name)[0] or "application/octet-stream"
|
|
222
|
+
if f.suffix == ".woff2":
|
|
223
|
+
ctype = "font/woff2"
|
|
224
|
+
return self._send(200, f.read_bytes(), ctype)
|
|
225
|
+
return self._send(404, {"error": "not found"})
|
|
226
|
+
return self._send(404, {"error": "not found"})
|
|
227
|
+
except Exception as e: # pragma: no cover - surfaced in the UI
|
|
228
|
+
return self._send(500, {"error": str(e)})
|
|
229
|
+
|
|
230
|
+
def do_POST(self):
|
|
231
|
+
if not self._host_ok() or self.headers.get("X-Kompactor-Token") != TOKEN:
|
|
232
|
+
return self._send(403, {"error": "forbidden"})
|
|
233
|
+
try:
|
|
234
|
+
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}")
|
|
235
|
+
u = urlparse(self.path).path
|
|
236
|
+
if u == "/api/config":
|
|
237
|
+
return self._send(200, save_config(body))
|
|
238
|
+
if u == "/api/preview":
|
|
239
|
+
knobs = {k: config.coerce(k, v) for k, v in body.get("knobs", {}).items() if k in config.KNOBS}
|
|
240
|
+
return self._send(200, {"samples": preview(knobs, body.get("languages") or [])})
|
|
241
|
+
if u == "/api/setup":
|
|
242
|
+
action = body.get("action")
|
|
243
|
+
target = installer.install(statusline=bool(body.get("statusline", True)),
|
|
244
|
+
uninstall=action == "uninstall")
|
|
245
|
+
return self._send(200, {"ok": True, "file": str(target), "setup": installer.status()})
|
|
246
|
+
if u == "/api/selftest":
|
|
247
|
+
return self._send(200, {"results": installer.selftest()})
|
|
248
|
+
if u.startswith("/api/graph/"):
|
|
249
|
+
project = body.get("project", "")
|
|
250
|
+
if not project or not os.path.isdir(project):
|
|
251
|
+
raise ValueError("unknown project")
|
|
252
|
+
action = u.rsplit("/", 1)[1]
|
|
253
|
+
if action == "build":
|
|
254
|
+
graph.build(project)
|
|
255
|
+
return self._send(200, graph_state(project))
|
|
256
|
+
text = {"query": lambda: graph.query(project, body.get("q", ""), 1800),
|
|
257
|
+
"report": lambda: graph.report(project, 2500),
|
|
258
|
+
"unused": lambda: graph.unused_text(project, 2500),
|
|
259
|
+
"debt": lambda: graph.debt_text(project, 2500),
|
|
260
|
+
"impact": lambda: graph.impact_text(project, body.get("q", ""), 1800),
|
|
261
|
+
"explain": lambda: graph.explain_text(project, body.get("q", ""), 1800)}.get(action)
|
|
262
|
+
if not text:
|
|
263
|
+
return self._send(404, {"error": "not found"})
|
|
264
|
+
return self._send(200, {"text": text()})
|
|
265
|
+
if u == "/api/estimate":
|
|
266
|
+
s = estimate.run(limit=int(body.get("limit") or 0) or None, style_pct=0)
|
|
267
|
+
return self._send(200, {"sessions": s["sessions"], "calls": s["calls"],
|
|
268
|
+
"tool_tokens": s["tool_tokens"], "saved_tool": s["saved_tool"],
|
|
269
|
+
"billed": s["billed"], "saved_billed": s["saved_billed"],
|
|
270
|
+
"cost": s["cost"], "saved_cost": s["saved_cost"],
|
|
271
|
+
"by_feature": dict(s["by_feature"])})
|
|
272
|
+
return self._send(404, {"error": "not found"})
|
|
273
|
+
except (ValueError, KeyError, RuntimeError) as e:
|
|
274
|
+
return self._send(400, {"error": str(e)})
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _free_port(preferred: int) -> int:
|
|
278
|
+
for port in [preferred] + list(range(preferred + 1, preferred + 20)):
|
|
279
|
+
with socket.socket() as s:
|
|
280
|
+
try:
|
|
281
|
+
s.bind(("127.0.0.1", port))
|
|
282
|
+
return port
|
|
283
|
+
except OSError:
|
|
284
|
+
continue
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def serve(port: int = 7420, open_browser: bool = True, print_url: bool = True) -> None:
|
|
289
|
+
port = _free_port(port)
|
|
290
|
+
httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
|
291
|
+
url = f"http://127.0.0.1:{httpd.server_address[1]}/"
|
|
292
|
+
if print_url:
|
|
293
|
+
print(f"Kompactor dashboard: {url} (Ctrl+C to stop)", flush=True)
|
|
294
|
+
if open_browser:
|
|
295
|
+
threading.Timer(0.3, lambda: webbrowser.open(url)).start()
|
|
296
|
+
try:
|
|
297
|
+
httpd.serve_forever()
|
|
298
|
+
except KeyboardInterrupt:
|
|
299
|
+
pass
|
|
300
|
+
finally:
|
|
301
|
+
httpd.server_close()
|
kompactor/shellwrap.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Shell prefix: compact test, build and lint output even when the command fails.
|
|
2
|
+
|
|
3
|
+
Claude Code only lets hooks rewrite the output of commands that succeed. When a
|
|
4
|
+
command exits non-zero it skips PostToolUse, fires PostToolUseFailure (which can
|
|
5
|
+
add context but not replace anything) and cuts the output to its first and last
|
|
6
|
+
few thousand characters, which usually drops the failures at the end of a test
|
|
7
|
+
run. Claude Code's CLAUDE_CODE_SHELL_PREFIX setting runs every Bash command as
|
|
8
|
+
`<prefix> '<shell command>'`. Kompactor's prefix (~/.kompactor/bin/kompactor-shell)
|
|
9
|
+
sends finite, noisy commands here: they run to completion, the output is
|
|
10
|
+
compacted, and the exit code is passed through unchanged. Permission rules
|
|
11
|
+
still see Claude's original command, because the prefix applies only when the
|
|
12
|
+
command runs.
|
|
13
|
+
|
|
14
|
+
Everything else, and any problem inside Kompactor before the command starts,
|
|
15
|
+
runs the command untouched.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import shlex
|
|
20
|
+
import signal
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from collections import deque
|
|
24
|
+
|
|
25
|
+
from . import config, filters, installer, store, tokens
|
|
26
|
+
|
|
27
|
+
HEAD_BYTES = 16 << 20 # keep at most 16 MB from the start and 8 MB from the end of the output
|
|
28
|
+
TAIL_BYTES = 8 << 20
|
|
29
|
+
SHOWN_LIMIT = 30000 # Claude Code saves longer successful outputs to a file and shows a 2 KB preview
|
|
30
|
+
PREVIEW_CHARS = 2048
|
|
31
|
+
ERROR_LIMIT = 10000 # failed commands: Claude Code keeps about this many characters
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def user_command(full: str) -> str:
|
|
35
|
+
"""Claude's own command inside the string Claude Code hands the prefix:
|
|
36
|
+
`source <snapshot> ... && eval '<command>' < /dev/null && pwd -P >| <cwd file>`."""
|
|
37
|
+
i = full.find(" eval ")
|
|
38
|
+
if i < 0:
|
|
39
|
+
return ""
|
|
40
|
+
try:
|
|
41
|
+
lex = shlex.shlex(full[i + 6:], posix=True)
|
|
42
|
+
lex.whitespace_split = True
|
|
43
|
+
return lex.get_token() or ""
|
|
44
|
+
except ValueError:
|
|
45
|
+
return ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def decide(full: str):
|
|
49
|
+
"""(command, config) when the command should run through Kompactor, else (None, None)."""
|
|
50
|
+
if os.environ.get("KOMPACTOR_RAW"):
|
|
51
|
+
return None, None
|
|
52
|
+
cmd = user_command(full)
|
|
53
|
+
if not cmd or cmd.lstrip().startswith("KOMPACTOR_RAW=1") or not filters.wrappable(cmd):
|
|
54
|
+
return None, None
|
|
55
|
+
cfg = config.load(os.getcwd())
|
|
56
|
+
if not cfg.get("bash") or not cfg.get("bash_wrap"):
|
|
57
|
+
return None, None
|
|
58
|
+
words = " ".join(filters.argv0(cmd))
|
|
59
|
+
if any(words.startswith(c.strip()) for c in cfg.get("never_compact", []) if c.strip()):
|
|
60
|
+
return None, None
|
|
61
|
+
return cmd, cfg
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def shown_tokens(text: str, code: int) -> int:
|
|
65
|
+
"""Roughly what Claude would have read without Kompactor (the honest baseline for the ledger)."""
|
|
66
|
+
if code != 0:
|
|
67
|
+
if len(text) > ERROR_LIMIT:
|
|
68
|
+
return tokens.count(text[:ERROR_LIMIT // 2]) + tokens.count(text[-ERROR_LIMIT // 2:])
|
|
69
|
+
return tokens.count(text)
|
|
70
|
+
if len(text) > int(os.environ.get("BASH_MAX_OUTPUT_LENGTH") or SHOWN_LIMIT):
|
|
71
|
+
return tokens.count(text[:PREVIEW_CHARS]) + 60
|
|
72
|
+
return tokens.count(text)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def compact(cmd: str, raw: str, code: int, cfg: dict, stopped: bool = False) -> str:
|
|
76
|
+
raw_tokens = tokens.count(raw)
|
|
77
|
+
if raw_tokens < cfg["bash_min_tokens"]:
|
|
78
|
+
return raw
|
|
79
|
+
sid = store.stash(cmd, raw)
|
|
80
|
+
new = filters.compress(cmd, raw, code, stash_id=sid, opts=cfg)
|
|
81
|
+
kept = tokens.count(new)
|
|
82
|
+
if kept > raw_tokens * 0.9 and len(raw) <= (ERROR_LIMIT if code else SHOWN_LIMIT):
|
|
83
|
+
return raw # barely compressible, and Claude Code would show all of it anyway
|
|
84
|
+
note = "; the command was stopped before it finished" if stopped else ""
|
|
85
|
+
new = new.rstrip("\n") + f"\n[kompactor: {raw_tokens}›{kept} tokens{note}; full output: {installer.cli_name()} show {sid}]\n"
|
|
86
|
+
store.record("bash", shown_tokens(raw, code), kept, cmd, os.environ.get("CLAUDE_CODE_SESSION_ID", ""),
|
|
87
|
+
project=cfg["project"])
|
|
88
|
+
return new
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run(shell: str, full: str, cmd: str, cfg: dict) -> int:
|
|
92
|
+
proc = subprocess.Popen([shell, "-c", full], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
93
|
+
stopped = []
|
|
94
|
+
|
|
95
|
+
def forward(sig, _frame): # a timeout or Esc in Claude Code: stop the command, still report what it printed
|
|
96
|
+
stopped.append(sig)
|
|
97
|
+
try:
|
|
98
|
+
proc.send_signal(sig)
|
|
99
|
+
except OSError:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
|
|
103
|
+
signal.signal(sig, forward)
|
|
104
|
+
head, tail, tail_size, dropped = bytearray(), deque(), 0, 0
|
|
105
|
+
try:
|
|
106
|
+
while True:
|
|
107
|
+
chunk = proc.stdout.read1(65536)
|
|
108
|
+
if not chunk:
|
|
109
|
+
break
|
|
110
|
+
room = HEAD_BYTES - len(head)
|
|
111
|
+
if room > 0:
|
|
112
|
+
head += chunk[:room]
|
|
113
|
+
chunk = chunk[room:]
|
|
114
|
+
if chunk:
|
|
115
|
+
tail.append(chunk)
|
|
116
|
+
tail_size += len(chunk)
|
|
117
|
+
while tail_size - len(tail[0]) >= TAIL_BYTES:
|
|
118
|
+
tail_size -= len(tail[0])
|
|
119
|
+
dropped += len(tail.popleft())
|
|
120
|
+
except OSError:
|
|
121
|
+
pass
|
|
122
|
+
code = proc.wait()
|
|
123
|
+
if code < 0:
|
|
124
|
+
code = 128 - code
|
|
125
|
+
raw = head.decode("utf-8", "replace")
|
|
126
|
+
if dropped:
|
|
127
|
+
raw += f"\n… [kompactor: {dropped} bytes from the middle were not kept] …\n"
|
|
128
|
+
raw += b"".join(tail).decode("utf-8", "replace")
|
|
129
|
+
try:
|
|
130
|
+
text = compact(cmd, raw, code, cfg, bool(stopped))
|
|
131
|
+
except Exception: # never lose the output to a Kompactor bug
|
|
132
|
+
text = raw
|
|
133
|
+
try:
|
|
134
|
+
sys.stdout.write(text)
|
|
135
|
+
sys.stdout.flush()
|
|
136
|
+
except OSError:
|
|
137
|
+
pass
|
|
138
|
+
return code
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main(argv) -> int:
|
|
142
|
+
shell, full = argv[0], argv[1]
|
|
143
|
+
try:
|
|
144
|
+
cmd, cfg = decide(full)
|
|
145
|
+
except Exception:
|
|
146
|
+
cmd = cfg = None
|
|
147
|
+
if not cmd:
|
|
148
|
+
os.execvp(shell, [shell, "-c", full])
|
|
149
|
+
return run(shell, full, cmd, cfg)
|