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/filters.py ADDED
@@ -0,0 +1,617 @@
1
+ """Command-output compressors.
2
+
3
+ Each filter takes the (ANSI-stripped) output lines and returns fewer lines that
4
+ carry the same information for an agent. Errors, failures and summaries are
5
+ always preserved; progress bars, passing tests, download chatter, library
6
+ stack frames and repeated lines are not. How aggressive each step is comes
7
+ from the quality-vs-tokens preset (see config.PRESETS).
8
+ """
9
+
10
+ import os
11
+ import re
12
+ import shlex
13
+ from collections import OrderedDict
14
+
15
+ from . import langs
16
+
17
+ ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*(\x07|\x1b\\)|\x1b[=>]")
18
+ IMPORTANT = re.compile(r"error|fail|fatal|exception|traceback|panic|denied|not found|cannot|unable|warning: |assert", re.I)
19
+ MAX_LINE = 300
20
+
21
+ DEFAULT_OPTS = {"bash_max_lines": 200, "max_line_chars": 500, "grep_per_file": 12, "test_hide_passing": True, "test_boilerplate": False,
22
+ "trim_stack_frames": True,
23
+ "diff_context": -1, "git_log_compact": True, "languages": []}
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # command classification
27
+
28
+
29
+ WRAPPERS = {"sudo", "time", "nice", "env", "npx", "bunx", "pnpx", "command"}
30
+ RUN_PREFIXES = (("uv", "run"), ("poetry", "run"), ("pipenv", "run"), ("python", "-m"), ("python3", "-m"),
31
+ ("bundle", "exec"), ("npm", "exec"), ("pnpm", "exec"), ("yarn", "exec"), ("pdm", "run"),
32
+ ("hatch", "run"), ("rye", "run"))
33
+ BUILD_TOOLS = {"npm", "pnpm", "yarn", "pip", "pip3", "uv", "poetry", "pdm", "cargo", "go", "mvn", "gradle", "gradlew",
34
+ "docker", "apt", "apt-get", "brew", "bundle", "composer", "make", "cmake", "tsc", "bun", "conda",
35
+ "vite", "next", "webpack", "turbo", "nx", "rollup", "esbuild", "parcel", "expo", "docker-compose"}
36
+
37
+
38
+ SETUP_WORDS = {"cd", "pushd", "popd", "export", "source", ".", "set", "unset", "alias", "ulimit", "umask", "exit", "true", ":"}
39
+
40
+
41
+ def argv0(cmd: str):
42
+ """Return the meaningful words of the first real command in a shell string,
43
+ skipping setup steps like `cd repo &&` or `export X=1;`."""
44
+ words = []
45
+ for seg in re.split(r"&&|;|\|\|", cmd.split("|", 1)[0] if "||" not in cmd else cmd):
46
+ try:
47
+ words = shlex.split(seg)
48
+ except ValueError:
49
+ words = seg.split()
50
+ if words and words[0] not in SETUP_WORDS:
51
+ break
52
+ words = []
53
+ while words and (re.match(r"^\w+=", words[0]) or words[0] in WRAPPERS):
54
+ words = words[1:]
55
+ while len(words) >= 2 and tuple(words[:2]) in RUN_PREFIXES:
56
+ words = words[2:]
57
+ if words:
58
+ words[0] = os.path.basename(words[0])
59
+ return words
60
+
61
+
62
+ def classify(cmd: str) -> str:
63
+ w = argv0(cmd)
64
+ if not w:
65
+ return "generic"
66
+ p, sub = w[0], (w[1] if len(w) > 1 else "")
67
+ if p == "git":
68
+ if sub == "status":
69
+ return "git_status"
70
+ if sub == "log":
71
+ return "git_log"
72
+ if sub in ("diff", "show"):
73
+ return "git_diff"
74
+ if sub in ("push", "pull", "fetch", "clone", "checkout", "switch", "merge", "rebase", "submodule"):
75
+ return "git_net"
76
+ if sub == "grep":
77
+ return "grep"
78
+ if sub == "ls-files":
79
+ return "listing"
80
+ if p in ("pytest", "py.test", "jest", "vitest", "mocha", "ava", "tap", "rspec", "phpunit", "ctest", "tox", "nox",
81
+ "playwright", "cypress", "gotestsum", "unittest"):
82
+ return "test"
83
+ if p in ("go", "cargo", "dotnet", "mix", "deno", "bun", "swift") and sub == "test":
84
+ return "test"
85
+ if p in ("npm", "pnpm", "yarn") and ("test" in sub or (sub == "run" and len(w) > 2 and "test" in w[2])):
86
+ return "test"
87
+ if p in ("make", "gradle", "gradlew", "mvn", "mvnw", "turbo", "nx") and any("test" in x for x in w[1:]):
88
+ return "test"
89
+ if p in BUILD_TOOLS:
90
+ return "build"
91
+ if p in ("find", "fd", "tree") or (p == "ls" and any(a.startswith("-") and "R" in a for a in w[1:])):
92
+ return "listing"
93
+ if p in ("grep", "egrep", "rg", "ag", "ack"):
94
+ return "grep"
95
+ if p in ("cat", "head", "tail", "nl", "bat", "less", "more", "type") or (p == "sed" and "-n" in w[1:]):
96
+ return "view"
97
+ return "generic"
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # generic passes
102
+
103
+
104
+ def clean(text: str):
105
+ text = ANSI.sub("", text)
106
+ lines = []
107
+ for ln in text.split("\n"):
108
+ if "\r" in ln: # progress redraws: keep only the final frame
109
+ parts = [p for p in ln.split("\r") if p.strip()]
110
+ ln = parts[-1] if parts else ""
111
+ lines.append(ln.rstrip())
112
+ while lines and not lines[-1]:
113
+ lines.pop()
114
+ return lines
115
+
116
+
117
+ # A progress line needs a real progress signal: a bar, a spinner, curl's meter, or a
118
+ # transfer speed next to a percentage/count. A bare "50%" is data, not progress.
119
+ BAR = re.compile(r"[█▓▒░━■▇▆▅▄▃▂▁]{3,}|\[[#=>\-.\s]{6,}\]|[⠁-⣿]|--:--:--")
120
+ SPEED = re.compile(r"\d+(\.\d+)?\s?[kKMG]i?B/s|\beta\b|\bETA\b|\d+(\.\d+)?\s?it/s")
121
+ PCT_OR_COUNT = re.compile(r"\b\d{1,3}(\.\d+)?%|\b\d+/\d+\b")
122
+ LEADING_PCT = re.compile(r"^\s*\d{1,3}%\s") # webpack/vite "45% building …"
123
+ TEST_RESULT = re.compile(r"\b(PASS|PASSED|FAIL|FAILED|SKIP|SKIPPED|XFAIL|XPASS|ERROR|ok)\b")
124
+
125
+
126
+ def is_progress(ln: str) -> bool:
127
+ if len(ln) >= 200 or IMPORTANT.search(ln) or TEST_RESULT.search(ln) or ln.startswith(("+", "-", "@@")):
128
+ return False
129
+ return bool(BAR.search(ln) or LEADING_PCT.match(ln) or (SPEED.search(ln) and PCT_OR_COUNT.search(ln)))
130
+
131
+
132
+ def drop_progress(lines):
133
+ """Drop progress bars and download meters (never data, test results, diff lines or errors)."""
134
+ return [ln for ln in lines if not is_progress(ln)]
135
+
136
+
137
+ def _shape(ln: str) -> str:
138
+ return re.sub(r"0x[0-9a-f]+|\d+(\.\d+)?", "#", ln)
139
+
140
+
141
+ LOG_LINE = re.compile(r"^\s*[\[(]?(\d{2,4}[-/.]\d{2}[-/.]\d{2}|\d{2}:\d{2}:\d{2}|#\d+ \d)")
142
+
143
+
144
+ def collapse_repeats(lines, min_run: int = 4, shapes: str = "none"):
145
+ """Collapse runs of identical lines ("[x50]"). With shapes="all" (build output) or
146
+ "logs" (timestamped log lines only), also collapse runs of lines that differ just
147
+ in numbers ("… 120 similar lines …"); never for tables or data, where the
148
+ numbers are the information."""
149
+ out, i = [], 0
150
+ while i < len(lines):
151
+ j = i + 1
152
+ exact = True
153
+ shape_ok = shapes == "all" or (shapes == "logs" and bool(LOG_LINE.match(lines[i])))
154
+ while j < len(lines) and (lines[j] == lines[i] or (shape_ok and _shape(lines[j]) == _shape(lines[i]))):
155
+ exact &= lines[j] == lines[i]
156
+ j += 1
157
+ run = j - i
158
+ if run >= (min_run if exact else max(min_run, 6)) and lines[i].strip():
159
+ if exact:
160
+ out.append(f"{lines[i]} [x{run}]")
161
+ else:
162
+ out += [lines[i], f" … {run - 2} similar lines …", lines[j - 1]]
163
+ elif run >= 2 and not lines[i].strip():
164
+ out.append("") # squeeze blank lines
165
+ else:
166
+ out += lines[i:j]
167
+ i = j
168
+ return out
169
+
170
+
171
+ def truncate_long(lines, limit: int = MAX_LINE):
172
+ return [ln if len(ln) <= limit else f"{ln[:limit]}…[+{len(ln) - limit} chars]" for ln in lines]
173
+
174
+
175
+ def cap(lines, max_lines: int, head: int, tail: int, stash_id=None):
176
+ """Keep head and tail; from the middle keep only lines that look important."""
177
+ if len(lines) <= max_lines:
178
+ return lines
179
+ middle = lines[head:len(lines) - tail]
180
+ keep = [ln for ln in middle if IMPORTANT.search(ln)][:30]
181
+ ref = f"; full output: kompactor show {stash_id}" if stash_id else ""
182
+ note = f"… {len(middle) - len(keep)} lines omitted{ref} …"
183
+ return lines[:head] + [note] + keep + (["… (end of omitted section) …"] if keep else []) + lines[-tail:]
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # stack traces: collapse frames that live in libraries / the runtime
188
+
189
+ PY_FRAME = re.compile(r'^\s*File "([^"]+)", line \d+')
190
+ PYTEST_FRAME = re.compile(r"^(\S+\.py):\d+: in \S+")
191
+ NODE_FRAME = re.compile(r"^\s+at \S")
192
+ GO_FUNC = re.compile(r"^([\w./*()\-\[\]{},]+\(.*\)|created by .+)$")
193
+ GO_FILE = re.compile(r"^\t\S+\.go:\d+")
194
+
195
+
196
+ def _frames(lines):
197
+ """Yield (start, end, header_text, innermost_last) for each stack frame found in lines."""
198
+ i, n = 0, len(lines)
199
+ while i < n:
200
+ ln = lines[i]
201
+ if PY_FRAME.match(ln):
202
+ ind = len(ln) - len(ln.lstrip())
203
+ j = i + 1
204
+ while j < n and lines[j].strip() and len(lines[j]) - len(lines[j].lstrip()) > ind \
205
+ and not PY_FRAME.match(lines[j]):
206
+ j += 1
207
+ yield i, j, ln, True
208
+ i = j
209
+ elif PYTEST_FRAME.match(ln):
210
+ j = i + 1
211
+ while j < n and not PYTEST_FRAME.match(lines[j]) and not lines[j].startswith(("E ", "_ _", "=", "___")):
212
+ j += 1
213
+ yield i, j, ln, True
214
+ i = j
215
+ elif NODE_FRAME.match(ln):
216
+ yield i, i + 1, ln, False
217
+ i += 1
218
+ elif GO_FUNC.match(ln) and i + 1 < n and GO_FILE.match(lines[i + 1]):
219
+ yield i, i + 2, lines[i + 1], False
220
+ i += 2
221
+ else:
222
+ i += 1
223
+
224
+
225
+ def trim_frames(lines, lib_re):
226
+ if lib_re is None:
227
+ return lines
228
+ frames = list(_frames(lines))
229
+ if len(frames) < 3:
230
+ return lines
231
+ out, pos, k = [], 0, 0
232
+ while k < len(frames):
233
+ s, e, head, innermost_last = frames[k]
234
+ if not lib_re.search(head):
235
+ k += 1
236
+ continue
237
+ # gather a run of consecutive library frames
238
+ run = [frames[k]]
239
+ while k + len(run) < len(frames) and frames[k + len(run)][0] == run[-1][1] \
240
+ and lib_re.search(frames[k + len(run)][2]):
241
+ run.append(frames[k + len(run)])
242
+ nxt = k + len(run)
243
+ ends_trace = nxt >= len(frames) or frames[nxt][0] != run[-1][1]
244
+ if len(run) >= 2:
245
+ # Python prints the raising frame last; keep it. Go/Node print it first.
246
+ hide = run[:-1] if ends_trace and innermost_last else run
247
+ out += lines[pos:hide[0][0]]
248
+ out.append(f"{' ' * (len(head) - len(head.lstrip()))}[… {len(hide)} library frames hidden …]")
249
+ pos = hide[-1][1]
250
+ k = nxt
251
+ out += lines[pos:]
252
+ return out
253
+
254
+
255
+ # ---------------------------------------------------------------------------
256
+ # specific filters
257
+
258
+
259
+ def f_git_status(lines, opts):
260
+ if not any(ln.startswith(("On branch", "HEAD detached", "Changes", "Untracked", "nothing to commit")) for ln in lines):
261
+ return lines # already short format
262
+ out, section, groups = [], None, OrderedDict()
263
+ labels = {"Changes to be committed": "staged", "Changes not staged for commit": "unstaged",
264
+ "Untracked files": "untracked", "Unmerged paths": "conflicts"}
265
+ for ln in lines:
266
+ s = ln.strip()
267
+ if not s or s.startswith("(use ") or s.startswith("(commit or discard") or s.startswith("no changes added"):
268
+ continue
269
+ if s.rstrip(":") in labels:
270
+ section = labels[s.rstrip(":")]
271
+ continue
272
+ if ln.startswith(("\t", " ")) and section:
273
+ m = re.match(r"(new file|modified|deleted|renamed|typechange|both modified|both added|deleted by \w+):\s+(.*)", s)
274
+ item = f"{m.group(1).split()[0][0].upper() if m.group(1) != 'renamed' else 'R'} {m.group(2)}" if m else s
275
+ groups.setdefault(section, []).append(item)
276
+ continue
277
+ out.append(s)
278
+ for sec, items in groups.items():
279
+ shown = items[:40] + ([f"… +{len(items) - 40} more"] if len(items) > 40 else [])
280
+ out.append(f"{sec} ({len(items)}): " + ", ".join(shown))
281
+ return out
282
+
283
+
284
+ def f_git_log(lines, opts):
285
+ """One line per commit (sha, date, author, subject). Whatever follows the message (--stat,
286
+ --name-only, --name-status and --numstat file lines, or the patch for -p) is kept as is."""
287
+ if not opts["git_log_compact"] or not lines or not lines[0].startswith("commit "):
288
+ return lines
289
+ out, cur = [], None
290
+ for ln in lines:
291
+ if ln.startswith("commit "):
292
+ if cur:
293
+ out.append(cur)
294
+ cur = {"sha": ln.split()[1][:9], "author": "", "date": "", "msg": None, "extra": "", "files": [],
295
+ "patch": False}
296
+ elif cur is None:
297
+ continue
298
+ elif cur["patch"] or ln.startswith("diff --git "):
299
+ cur["patch"] = True
300
+ cur["files"].append(ln)
301
+ elif ln.startswith("Author:") and cur["msg"] is None:
302
+ cur["author"] = re.sub(r"\s*<.*>", "", ln[7:].strip())
303
+ elif ln.startswith("Date:") and cur["msg"] is None:
304
+ cur["date"] = ln[5:].strip()
305
+ elif ln.startswith("Merge:") and cur["msg"] is None:
306
+ cur["extra"] = " (merge)"
307
+ elif ln.startswith(" "):
308
+ if cur["msg"] is None and ln.strip():
309
+ cur["msg"] = ln.strip()
310
+ elif re.match(r"^\s*\d+ files? changed", ln):
311
+ cur["extra"] += " | " + ln.strip()
312
+ elif ln.strip():
313
+ cur["files"].append(ln)
314
+ if cur:
315
+ out.append(cur)
316
+ res = []
317
+ for c in out:
318
+ res.append(f"{c['sha']} {_short_date(c['date'])} {c['author']}: {c['msg'] or ''}{c['extra']}")
319
+ res.extend(c["files"])
320
+ return res
321
+
322
+
323
+ def _short_date(d: str) -> str:
324
+ m = re.match(r"\w{3} (\w{3}) (\d+) [\d:]+ (\d{4})", d)
325
+ return f"{m.group(3)}-{m.group(1)}-{int(m.group(2)):02d}" if m else d
326
+
327
+
328
+ def f_git_diff(lines, opts):
329
+ lock_re = langs.lockfile_re(opts["languages"])
330
+ gen_re = langs.generated_re(opts["languages"])
331
+ out, skipping, added, removed, name, why = [], False, 0, 0, "", ""
332
+
333
+ def flush():
334
+ if skipping:
335
+ out.append(f"[{name}: {why} diff, +{added} -{removed} lines omitted]")
336
+
337
+ for ln in lines:
338
+ if ln.startswith("diff --git"):
339
+ flush()
340
+ name = ln.split(" b/")[-1]
341
+ why = "lock file" if lock_re.search(name) else "generated file" if gen_re and gen_re.search(name) else ""
342
+ skipping = bool(why)
343
+ added = removed = 0
344
+ out.append(ln)
345
+ continue
346
+ if skipping:
347
+ added += ln.startswith("+") and not ln.startswith("+++")
348
+ removed += ln.startswith("-") and not ln.startswith("---")
349
+ continue
350
+ if ln.startswith("index ") or ln.startswith("similarity index"):
351
+ continue
352
+ out.append(ln)
353
+ flush()
354
+ ctx = opts["diff_context"]
355
+ return trim_diff_context(out, ctx) if ctx is not None and ctx >= 0 else out
356
+
357
+
358
+ def trim_diff_context(lines, ctx: int):
359
+ """Keep only `ctx` unchanged lines around each change inside hunks."""
360
+ out, hunk = [], []
361
+
362
+ def flush():
363
+ if not hunk:
364
+ return
365
+ changed = [i for i, ln in enumerate(hunk) if ln[:1] in "+-"]
366
+ keep = set()
367
+ for i in changed:
368
+ keep.update(range(i - ctx, i + ctx + 1))
369
+ gap = 0
370
+ for i, ln in enumerate(hunk):
371
+ if i in keep or ln[:1] not in " " or ln.startswith("\\"):
372
+ if gap:
373
+ out.append(f" ⋯ {gap} unchanged")
374
+ gap = 0
375
+ out.append(ln)
376
+ else:
377
+ gap += 1
378
+ if gap:
379
+ out.append(f" ⋯ {gap} unchanged")
380
+ hunk.clear()
381
+
382
+ in_hunk = False
383
+ for ln in lines:
384
+ if ln.startswith("@@"):
385
+ flush()
386
+ out.append(ln)
387
+ in_hunk = True
388
+ elif in_hunk and (ln[:1] in " +-\\" or ln == "") and not ln.startswith(("diff --git", "--- ", "+++ ")):
389
+ hunk.append(ln if ln else " ")
390
+ else:
391
+ flush()
392
+ in_hunk = ln.startswith("@@")
393
+ out.append(ln)
394
+ flush()
395
+ return out
396
+
397
+
398
+ GIT_NOISE = re.compile(r"^(remote: )?(Enumerating|Counting|Compressing|Writing|Receiving|Resolving|Total \d|"
399
+ r"Delta compression|Unpacking|Updating files|Filtering content)")
400
+
401
+
402
+ def f_git_net(lines, opts):
403
+ return [ln for ln in lines if not GIT_NOISE.match(ln)]
404
+
405
+
406
+ PASSING = re.compile(
407
+ r"(\bPASS(ED)?\b(?!.*\bFAIL)|^\s*[✓✔√]|^\s*ok\s|^--- PASS|^=== (RUN|PAUSE|CONT|NAME)|\.\.\. ok$|^\s*test \S+ \.\.\. ok"
408
+ r"|^\?\s+\S+\s+\[no test files\]|^PASS\s|\bpassed\b.*\d+ms$|^\s*\.+\s*\[\s*\d+%\]$)")
409
+ FAILED = re.compile(r"\b(FAILED|FAIL|failed|failures?|ERROR|panicked|panic:)\b")
410
+
411
+
412
+ TEST_BOILER = re.compile(
413
+ r"^(platform \w+ -- Python|rootdir:|configfile:|plugins:|cachedir:|collected \d+ items?|"
414
+ r"=+ (test session starts|short test summary info) =+$|(_ )+_?$|\s*⎯{5,}|\s*RUN\s+v\d|\s+Start at\s|\s+Duration\s)")
415
+
416
+
417
+ def f_test(lines, opts):
418
+ if not opts["test_hide_passing"]:
419
+ return lines
420
+ kept = [ln for ln in lines if not PASSING.search(ln) and not (BUILD_NOISE.match(ln) and not IMPORTANT.search(ln))]
421
+ if opts["test_boilerplate"]:
422
+ kept = [ln for ln in kept if not TEST_BOILER.match(ln)]
423
+ dropped = len(lines) - len(kept)
424
+ failed = any(FAILED.search(ln) for ln in kept)
425
+ if not failed and len(kept) > 40:
426
+ # green run: the summary at the bottom is what matters
427
+ kept = [f"… {len(kept) - 25} lines of passing-run output omitted …"] + kept[-25:]
428
+ if dropped:
429
+ kept.append(f"[kompactor: {dropped} passing-test lines hidden]")
430
+ return kept
431
+
432
+
433
+ BUILD_NOISE = re.compile(
434
+ r"^\s*(Downloading|Downloaded|Collecting|Requirement already satisfied|Using cached|Obtaining|Fetching|Fetched|"
435
+ r"Compiling|Checking|Fresh|Installing collected|Preparing metadata|Building wheel|Created wheel|Stored in directory|"
436
+ r"Resolving|Resolved|Prepared|Audited|Get:\d|Hit:\d|Ign:\d|Reading (package|state)|Building dependency tree|"
437
+ r"Unpacking|Selecting previously|Setting up|Processing triggers|\[INFO\] Download|Progress \(\d|"
438
+ r"go: (downloading|finding|extracting)|"
439
+ r"#\d+ (sha256|extracting|resolve|transferring|DONE|CACHED|\[internal\])|npm (http|timing|sill|verb))", re.I)
440
+ PKG_LINE = re.compile(r"^\s*([+\-~]) ([\w.\-\[\]]+)[=@ ]=?\s*v?[\w.+\-]+$") # uv / pnpm "+ pkg==1.2" lines
441
+
442
+
443
+ def f_build(lines, opts):
444
+ out, warned, pkgs = [], OrderedDict(), {"+": [], "-": [], "~": []}
445
+ for ln in lines:
446
+ if BUILD_NOISE.match(ln) and not IMPORTANT.search(ln):
447
+ continue
448
+ m = re.match(r"(npm WARN deprecated|warning:? deprecated|DEPRECATION:)\s*(\S+)", ln, re.I)
449
+ if m:
450
+ warned[m.group(2)] = 1
451
+ continue
452
+ m = PKG_LINE.match(ln)
453
+ if m:
454
+ pkgs[m.group(1)].append(m.group(2))
455
+ continue
456
+ out.append(ln)
457
+ for sign, label in (("+", "added"), ("-", "removed"), ("~", "changed")):
458
+ if pkgs[sign]:
459
+ names = pkgs[sign]
460
+ out.append(f"[{len(names)} packages {label}: {', '.join(names[:10])}{' …' if len(names) > 10 else ''}]")
461
+ if warned:
462
+ out.append(f"[{len(warned)} deprecation warnings: {', '.join(list(warned)[:8])}{' …' if len(warned) > 8 else ''}]")
463
+ return out
464
+
465
+
466
+ def f_listing(lines, opts):
467
+ if len(lines) <= 60:
468
+ return lines
469
+ by_dir = OrderedDict()
470
+ if sum(ln.endswith(":") for ln in lines) >= 2: # `ls -R`: "dir:" headers followed by names
471
+ cur = "."
472
+ for ln in lines:
473
+ if ln.endswith(":"):
474
+ cur = ln[:-1]
475
+ by_dir.setdefault(cur, [])
476
+ elif ln.strip():
477
+ by_dir.setdefault(cur, []).extend(ln.split())
478
+ lines = [f for fs in by_dir.values() for f in fs]
479
+ else:
480
+ for ln in lines:
481
+ d, _, f = ln.strip().rpartition("/")
482
+ by_dir.setdefault(d or ".", []).append(f)
483
+ out = [f"[{len(lines)} paths in {len(by_dir)} directories]"]
484
+ for d, files in by_dir.items():
485
+ shown = ", ".join(files[:8]) + (f", … +{len(files) - 8}" if len(files) > 8 else "")
486
+ out.append(f"{d}/ ({len(files)}): {shown}")
487
+ return out
488
+
489
+
490
+ def f_grep(lines, opts):
491
+ limit = opts["grep_per_file"]
492
+ per_file, out, hidden = {}, [], {}
493
+ for ln in lines:
494
+ m = re.match(r"^([^:\s][^:]*):(\d+[:-])?", ln)
495
+ f = m.group(1) if m else None
496
+ if f:
497
+ per_file[f] = per_file.get(f, 0) + 1
498
+ if per_file[f] > limit:
499
+ hidden[f] = hidden.get(f, 0) + 1
500
+ continue
501
+ out.append(ln)
502
+ for f, n in hidden.items():
503
+ out.append(f"[{f}: +{n} more matches]")
504
+ return truncate_long(out, 220)
505
+
506
+
507
+ FILTERS = {"git_status": f_git_status, "git_log": f_git_log, "git_diff": f_git_diff, "git_net": f_git_net,
508
+ "test": f_test, "build": f_build, "listing": f_listing, "grep": f_grep}
509
+
510
+ # ---------------------------------------------------------------------------
511
+
512
+
513
+ def compress(cmd: str, output: str, exit_code: int = 0, stash_id=None, opts=None, max_lines=None) -> str:
514
+ """Compress a command's combined output. Pure function; safe on any input."""
515
+ o = dict(DEFAULT_OPTS)
516
+ o.update({k: v for k, v in (opts or {}).items() if k in DEFAULT_OPTS})
517
+ if max_lines:
518
+ o["bash_max_lines"] = max_lines
519
+ kind = classify(cmd)
520
+ lines = clean(output)
521
+ m = o["bash_max_lines"]
522
+ if kind == "view" or _piped(cmd):
523
+ # Claude asked for exactly this: a file, or output it shaped itself with a pipe or a format flag
524
+ # (counts, lists, JSON). Keep it verbatim; only cap huge ones, never below 400 lines.
525
+ m = max(m * 2, 400)
526
+ return "\n".join(cap(lines, m, head=m // 2, tail=m // 3, stash_id=stash_id))
527
+ if kind in FILTERS:
528
+ lines = FILTERS[kind](lines, o)
529
+ if o["trim_stack_frames"] and kind != "git_diff":
530
+ lines = trim_frames(lines, langs.library_frame_re(o["languages"]))
531
+ lines = drop_progress(lines)
532
+ lines = collapse_repeats(lines, shapes={"build": "all", "generic": "logs", "test": "logs"}.get(kind, "none"))
533
+ lines = truncate_long(lines, o["max_line_chars"])
534
+ lines = cap(lines, m, head=m // 2, tail=m // 3, stash_id=stash_id)
535
+ return "\n".join(lines)
536
+
537
+
538
+ def _piped(cmd: str) -> bool:
539
+ """Output already shaped by the user's own pipeline/format flags."""
540
+ return bool(re.search(r"(?<!\|)\|(?!\|)", cmd)) or "--format" in cmd or "--pretty" in cmd or "--porcelain" in cmd \
541
+ or "--json" in cmd
542
+
543
+
544
+ # ---------------------------------------------------------------------------
545
+ # commands the shell prefix may buffer: finite and noisy (tests, builds, linters,
546
+ # installs). Never servers, watchers or anything interactive: buffering those would
547
+ # hide their output until they exit.
548
+
549
+ LINTERS = {"tsc", "vue-tsc", "svelte-check", "eslint", "oxlint", "biome", "prettier", "stylelint", "ruff", "mypy",
550
+ "pyright", "basedpyright", "flake8", "pylint", "black", "isort", "bandit", "golangci-lint", "staticcheck",
551
+ "revive", "shellcheck", "hadolint", "rubocop", "markdownlint"}
552
+ FINITE_SCRIPT = re.compile(r"^(build|lint|typecheck|type-check|types|check|test|tsc|compile|ci|validate|verify|e2e|"
553
+ r"format:check|fmt:check)(:[\w:-]+)?$")
554
+ INSTALL_WORDS = {"install", "i", "ci", "add", "sync", "update", "upgrade", "lock", "remove", "uninstall", "audit",
555
+ "outdated", "dedupe", "rebuild", "prune"}
556
+ LONG_RUNNING = re.compile(r"(^|\s)(--watch\w*|-w|--ui|--follow|--serve|--open)(\s|=|$)")
557
+ SAFE_STEPS = {"echo", "printf", "ls", "pwd", "mkdir", "rm", "cp", "mv", "touch", "cat", "which", "true", "false", "git",
558
+ "rmdir", "ln", "chmod", "date", "wc"}
559
+
560
+
561
+ def _wrappable_words(w) -> bool:
562
+ if not w:
563
+ return True
564
+ p, rest = w[0], w[1:]
565
+ sub = rest[0] if rest else ""
566
+ if any(a in ("watch", "dev", "serve", "start", "open", "run-server", "runserver", "up") for a in rest[:2]):
567
+ return False
568
+ if p in ("pytest", "py.test", "jest", "vitest", "mocha", "ava", "tap", "rspec", "phpunit", "ctest", "tox", "nox",
569
+ "playwright", "cypress", "gotestsum", "unittest") or p in LINTERS:
570
+ return True
571
+ if p == "go":
572
+ return sub in ("test", "build", "vet", "generate", "install", "mod")
573
+ if p == "cargo":
574
+ return sub in ("test", "build", "check", "clippy", "fetch", "doc", "fmt", "nextest")
575
+ if p in ("npm", "pnpm", "yarn", "bun"):
576
+ if sub in INSTALL_WORDS or (p == "yarn" and not sub) or sub == "test":
577
+ return True
578
+ if sub in ("run", "run-script"):
579
+ return len(rest) > 1 and bool(FINITE_SCRIPT.match(rest[1]))
580
+ return p != "npm" and bool(FINITE_SCRIPT.match(sub))
581
+ if p in ("pip", "pip3", "poetry", "pdm", "uv", "conda", "bundle", "composer"):
582
+ return sub in INSTALL_WORDS or (sub == "pip" and len(rest) > 1 and rest[1] in INSTALL_WORDS)
583
+ if p in ("make", "ninja", "cmake"):
584
+ return not any(a in ("run", "serve", "dev", "watch", "start") for a in rest)
585
+ if p in ("mvn", "mvnw", "gradle", "gradlew", "./mvnw", "./gradlew"):
586
+ return not any(a in ("run", "bootRun", "spring-boot:run", "jetty:run", "exec:java", "--continuous", "-t")
587
+ for a in rest)
588
+ if p == "docker":
589
+ return sub == "build" or (sub in ("compose", "buildx") and "build" in rest[:2])
590
+ if p in ("next", "vite", "nuxt", "astro", "remix", "expo", "turbo", "nx"):
591
+ return "build" in rest[:3] or any(FINITE_SCRIPT.match(a.split(":")[-1]) for a in rest[1:3] if sub == "run")
592
+ if p in ("webpack", "rollup", "esbuild", "tsup", "parcel"):
593
+ return sub != "serve"
594
+ return False
595
+
596
+
597
+ def wrappable(cmd: str) -> bool:
598
+ """True when every step of `cmd` is a finite, noisy command (or a trivial setup
599
+ step), so running it to completion before Claude sees the output is safe."""
600
+ if not cmd.strip() or "\n" in cmd.strip() or _piped(cmd) or LONG_RUNNING.search(cmd) \
601
+ or re.search(r"(?<![&>|])&(?![&>])", cmd) or re.search(r"(^|\s)(nohup|setsid|disown|exec)\s", cmd):
602
+ return False
603
+ noisy = False
604
+ for seg in re.split(r"&&|\|\||;", cmd):
605
+ try:
606
+ words = shlex.split(seg)
607
+ except ValueError:
608
+ return False
609
+ if not words or words[0] in SETUP_WORDS:
610
+ continue
611
+ w = argv0(seg)
612
+ if w and os.path.basename(w[0]) in SAFE_STEPS:
613
+ continue
614
+ if not _wrappable_words(w):
615
+ return False
616
+ noisy = True
617
+ return noisy