pi-squeeze 0.1.0 → 0.2.0

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.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![Audit](https://github.com/sorinirimies/pi-squeeze/actions/workflows/security.yml/badge.svg)](https://github.com/sorinirimies/pi-squeeze/actions/workflows/security.yml)
7
7
  [![Fuzz](https://github.com/sorinirimies/pi-squeeze/actions/workflows/fuzz.yml/badge.svg)](https://github.com/sorinirimies/pi-squeeze/actions/workflows/fuzz.yml)
8
8
 
9
- Shrinks bash output before it reaches the agent's context: native compactors for git, cargo, rg, ls, test and build output, plus rtk when installed. Full output stays one temp file away.
9
+ Shrinks bash output before it reaches the agent's context: native compactors for git, search, listings, tests, builds (cargo, gradle, Maven, javac, Xcode, tsc, docker), Android (adb, lint, detekt), linters, package installs and logs, plus rtk when installed. Full output stays one temp file away.
10
10
 
11
11
  A [pi](https://github.com/earendil-works/pi-coding-agent) extension.
12
12
 
@@ -23,7 +23,7 @@ It only touches output it fully understands, and otherwise passes it through unc
23
23
 
24
24
  | Command | Becomes |
25
25
  |---|---|
26
- | `rg -n` / `grep -rn` | totals line, then matches grouped by file (path written once, indent trimmed, long lines and busy files cut) |
26
+ | `rg -n` / `grep -rn` / `ack` | totals line, then matches grouped by file (path written once, indent trimmed, long lines and busy files cut) |
27
27
  | `rg pattern dir` / `grep -r` (no `-n`) | the same, but only when every `path:` prefix is a file that exists in the session's directory (a colon in the text is never taken for a path) |
28
28
  | `fd`, `find` | paths grouped by directory |
29
29
  | `lsd -l`, `eza -l`, `ls -l` | type, size, short date and name (owner, group, permissions and the `total` line dropped) |
@@ -39,12 +39,31 @@ It only touches output it fully understands, and otherwise passes it through unc
39
39
  | `cargo audit` | one line per advisory (id, crate, version, kind, title, fix) |
40
40
  | `cargo tree`, `tree`, `eza --tree` | cut below 3 levels, `… [+15 lines deeper]` under each cut |
41
41
  | `tsc` | errors grouped by file (`a.ts (8)`, `1:7 TS2322 …`) |
42
- | `gradle`, `./gradlew` | task lines counted (`11 tasks (5 executed, 4 no-source, 2 up-to-date)`); failures, compiler errors, test failures and the `BUILD` line stay; daemon and `--scan` hints go; Kotlin `w:`/`e:` paths lose their shared directory |
42
+ | `gradle`, `./gradlew` | task lines counted (`11 tasks (5 executed, 4 no-source, 2 up-to-date)`); failures, compiler errors, test failures and the `BUILD` line stay; daemon, configuration-cache, JVM and `--scan` chatter goes; reported files lose their shared directory |
43
43
  | `bun test`, `go test` | passing tests counted, not listed; failures and totals stay |
44
+ | `pytest` | session header, progress dots and `PASSED` lines go; failures, warnings and the short summary stay; full-width `====` banners shrink |
45
+ | `jest`, `vitest` | passing tests and files go, skipped ones are counted; failures with their diff and frame, and the totals, stay |
46
+ | `xcodebuild`, `swift build` / `test` | build steps (`CompileSwift`, `Ld`, `CodeSign` and their command lines) and progress counted, not listed; warnings grouped by message; errors keep frame and notes; passing XCTest / Swift Testing tests counted; the `** BUILD SUCCEEDED **` line stays |
47
+ | `eslint`, `biome`, `ruff`, `flake8`, `mypy`, `pylint`, `ktlint`, `detekt`, `swiftlint`, `shellcheck`, `flutter analyze` | findings grouped by rule and message, each location once (`error eqeqeq: … (5)` then `m.js:2:46 m1.js:2:47 …`), the shared directory said once; frames and fix suggestions go |
48
+ | `npm` / `pnpm` / `yarn` / `bun install`, `pip install`, `uv sync`, `pod install`, `bundle install`, `flutter pub get` | download and progress lines go; per-package lines become one line (`Collecting (9): requests, rich, …`); deprecation warnings become one list of names; errors and the summary stay |
49
+ | `docker build` | `[internal]` steps, layer downloads, `CACHED` / `DONE` lines go; each step keeps its header and what it printed |
50
+ | `docker logs`, `docker compose logs`, `kubectl logs`, `journalctl`, `adb logcat` (not `-f` / `--follow`) | 3+ lines that differ only in timestamps and numbers keep the first and last; framework stack frames fold; logcat drops the date and thread id; past the limit the start goes, not the end |
51
+ | `gradle` on Android | also: Kotlin / KSP warnings, detekt / ktlint findings and Android Lint issues grouped by message with their locations (lint's source lines, explanations and baseline advice go); repeated warnings and `--warning-mode all` deprecations said once with a count, without the upgrade-guide links; R8 `Missing class` and `Duplicate class` lines become one list of classes; "Searched in the following locations" becomes a count; `A failure occurred while executing …` wrappers go and the failure summary does not repeat what was printed above it; framework frames fold |
52
+ | `gradle tasks`, `gradle projects`, `dependencyInsight`, `dependencies` | tasks: names per group, descriptions go; projects: every path once, grouped by the first segment (`:api (261): …`); insight: the attribute tables go; dependencies: cut like a tree |
53
+ | `mvn`, `./mvnw`, `mvnd` | `Downloading` / `Downloaded` lines and their progress counted; `[INFO]` lines go except the result, the test totals and modules that failed; a passing test class takes its output (Spring banners, logs) with it; errors said once, the `[Help 1]` footer goes; `dependency:tree` is cut like a tree |
54
+ | `javac` | errors keep their source line, caret and `symbol:` / `location:`; warnings grouped by kind (`warning rawtypes: found raw type: List (2)`); `Recompile with -Xlint` notes go |
55
+ | a JVM stack trace from anything (`java -jar`, a test JVM, a log) | framework frames (java., kotlin., android., org.junit., org.springframework.…) fold to `… N framework frames`; the exception, each `Caused by:`, your own frames and `... N more` stay; also when the command failed |
56
+ | `adb exec-out uiautomator dump /dev/tty` | one line per node that says something, indented by nesting, with where to tap: `TextView "Network & internet" #title @375,608`; empty wrappers and off-screen nodes go |
57
+ | `adb shell getprop`, `settings list …` | keys that share a prefix written once, every value kept: `aaudio: hw_burst_min_usec=2000 mmap_policy=2` |
58
+ | `adb shell ps -A`, `top -n 1` | kernel threads and idle processes counted, not listed; column padding goes |
59
+ | `adb shell dumpsys …` | lines deeper than an entry's own go with a count; 3+ lines of the same shape keep their ends |
60
+ | `adb shell pm list packages [-f]` | grouped by the first two name segments; with `-f`, name then path, hashed install directories elided |
61
+ | `adb logcat` (`threadtime`, `time`, `brief`) | as logs above; with `adb -s <serial>` too |
62
+ | `… \| tail -50`, `… \| head -n 20` | the command before the pipe picks the compactor |
44
63
  | anything else | colour codes dropped, progress bars (`\r`) keep their last frame, 4+ identical lines become `line [×N]`, 3+ blank lines become one. Never for `cat`, `head`, `jq`, `curl`… (content commands) |
45
64
  | `git`, `cargo`, `docker`… | rewritten through [rtk](https://github.com/rtk-ai/rtk) when it is installed (switch: `/squeeze rtk off`) |
46
65
 
47
- Output with a marker of its own (a `tsc` error list, a gradle build, a `bun test` run, rustc diagnostics) is recognised whatever ran it: `just check`, `npm run build`, `make test`, `… 2>&1`.
66
+ Output with a marker of its own (a `tsc` error list, a gradle or Maven build, `javac` errors, a JVM stack trace, a `uiautomator` dump, a `bun test` run, rustc diagnostics) is recognised whatever ran it: `just check`, `npm run build`, `make test`, `… 2>&1`.
48
67
  A **failed** command is compacted too, but only for test and build output, where the errors are the point.
49
68
  `git -C dir …`, `git --no-pager …`, `--color=never` and `cargo +nightly …` are read as the plain command.
50
69
 
@@ -62,7 +81,7 @@ Safety rules:
62
81
  |---|---|
63
82
  | `/squeeze` or `/squeeze status` | the settings and the tokens saved so far |
64
83
  | `/squeeze [on\|off]` | switch everything |
65
- | `/squeeze [search\|paths\|listing\|diff\|tests\|git\|build\|tree\|generic] [on\|off]` | switch one compactor |
84
+ | `/squeeze [search\|paths\|listing\|diff\|tests\|git\|build\|lint\|install\|logs\|tree\|generic] [on\|off]` | switch one compactor |
66
85
  | `/squeeze rtk [on\|off]` | rewrite git, cargo, docker… through [rtk](https://github.com/rtk-ai/rtk), when installed |
67
86
  | `/squeeze min <chars>` | smallest output worth compacting (default 400) |
68
87
 
@@ -0,0 +1,231 @@
1
+ /**
2
+ * `adb shell pm list packages`: the `package:` prefix goes and names are grouped by their
3
+ * first two segments, each name still complete once its group is read:
4
+ *
5
+ * com.google.android (3): apps.messaging gms gsf
6
+ */
7
+ import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
8
+ import { shape } from "./logs.ts";
9
+
10
+ export function compactPackages(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
11
+ const lines = output.split("\n").map((l) => l.trimEnd());
12
+ while (lines.length && lines.at(-1) === "") lines.pop();
13
+ if (!lines.length || !lines.every((l) => /^package:[\w.]+$/.test(l))) return undefined;
14
+ const groups = new Map<string, string[]>();
15
+ for (const l of lines.map((l) => l.slice("package:".length)).sort()) {
16
+ const parts = l.split(".");
17
+ const key = parts.length > 2 ? parts.slice(0, 2).join(".") : "";
18
+ groups.set(key, [...(groups.get(key) ?? []), key ? parts.slice(2).join(".") : l]);
19
+ }
20
+ const out = [`${lines.length} packages`];
21
+ // A group line may run long: it is still one line of names, cut only far past the usual width.
22
+ const width = limits.lineChars * 8;
23
+ let cut = false;
24
+ for (const [key, names] of groups) {
25
+ const line = key ? `${key} (${names.length}): ${names.join(" ")}` : names.join(" ");
26
+ cut ||= line.length > width;
27
+ out.push(cutLine(line, width));
28
+ }
29
+ const capped = capLines(out, limits.total);
30
+ return { text: capped.text, truncated: cut || capped.truncated };
31
+ }
32
+
33
+ /** `adb shell pm list packages -f`: `package:/path/base.apk=name` → `name /path`, hashed install dirs elided. */
34
+ export function compactPackagePaths(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
35
+ const lines = output.split("\n").map((l) => l.trimEnd());
36
+ while (lines.length && lines.at(-1) === "") lines.pop();
37
+ const rows = lines.map((l) => /^package:(\/\S+)=([\w.]+)$/.exec(l));
38
+ if (!rows.length || rows.some((r) => !r)) return undefined;
39
+ const out = rows
40
+ .map((r) => `${r![2]} ${r![1]!.replace(/\/~~[^/]+==\/[\w.]+-[^/]+==\//, "/…/")}`)
41
+ .sort()
42
+ .map((l) => cutLine(l, limits.lineChars));
43
+ const capped = capLines([`${rows.length} packages`, ...out], limits.total);
44
+ return { text: capped.text, truncated: capped.truncated };
45
+ }
46
+
47
+ /** A value as `key=value` shows it: quoted when empty or holding spaces. */
48
+ const shown = (v: string) => (v === "" || /\s/.test(v) ? JSON.stringify(v) : v);
49
+
50
+ /**
51
+ * Properties and settings (`adb shell getprop`, `settings list global|secure|system`):
52
+ * keys that share a prefix are written once, values all kept:
53
+ *
54
+ * ro.build: version.release=16 version.sdk=36 type=user
55
+ */
56
+ export function compactProperties(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
57
+ const raw = output.split("\n").map((l) => l.trimEnd());
58
+ while (raw.length && raw.at(-1) === "") raw.pop();
59
+ // A getprop value may span lines: they belong to the `[key]: [` line above.
60
+ const lines: string[] = [];
61
+ for (const l of raw) {
62
+ if (lines.length && /^\[[^\]]+\]: \[/.test(lines.at(-1)!) && !/\]$/.test(lines.at(-1)!)) lines[lines.length - 1] += `\\n${l}`;
63
+ else lines.push(l);
64
+ }
65
+ if (lines.length < 5) return undefined;
66
+ const getprop = lines.every((l) => /^\[[^\]]+\]: \[.*\]$/.test(l));
67
+ const pairs = lines.map((l) => (getprop ? /^\[([^\]]+)\]: \[(.*)\]$/.exec(l) : /^([\w.:-]+)=(.*)$/.exec(l)));
68
+ if (pairs.some((p) => !p)) return undefined;
69
+ const sep = getprop ? "." : "_";
70
+ const groups = new Map<string, string[]>();
71
+ for (const p of pairs) {
72
+ const key = p![1]!;
73
+ // getprop keys group by their first two segments (`ro.build`), settings by the first word.
74
+ const parts = key.split(sep);
75
+ const n = Math.min(getprop ? 2 : 1, parts.length - 1);
76
+ const [head, tail] = n > 0 && parts[0] ? [parts.slice(0, n).join(sep), parts.slice(n).join(sep)] : ["", key];
77
+ groups.set(head, [...(groups.get(head) ?? []), `${tail}=${shown(p![2]!)}`]);
78
+ }
79
+ let cut = false;
80
+ const out: string[] = [];
81
+ const item = (i: string) => {
82
+ cut ||= i.length > limits.lineChars;
83
+ return cutLine(i, limits.lineChars);
84
+ };
85
+ for (const [head, items] of groups) {
86
+ if (!head || items.length === 1) {
87
+ out.push(...items.map((i) => item(head ? `${head}${sep}${i}` : i)));
88
+ continue;
89
+ }
90
+ // Long groups wrap rather than lose values: a new line repeats the prefix.
91
+ let line = `${head}:`;
92
+ for (const i of items.map(item)) {
93
+ if (line.length + i.length + 1 > limits.lineChars * 2 && line !== `${head}:`) (out.push(line), (line = `${head}:`));
94
+ line += ` ${i}`;
95
+ }
96
+ out.push(line);
97
+ }
98
+ const capped = capLines(out, limits.total);
99
+ return { text: capped.text, truncated: cut || capped.truncated };
100
+ }
101
+
102
+ /**
103
+ * Process lists (`adb shell ps -A`, `adb shell top -n 1`): kernel threads (no memory of their
104
+ * own) and idle rows of `top` are counted, not listed; column padding goes.
105
+ */
106
+ export function compactProcesses(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
107
+ const lines = output.split("\n").map((l) => l.trimEnd());
108
+ while (lines.length && lines.at(-1) === "") lines.pop();
109
+ const header = lines.findIndex((l) => /^\s*(?:USER\s+PID\s+PPID|PID\s+USER\s+PR\s+NI)\b/.test(l));
110
+ if (header < 0 || lines.length - header < 5) return undefined;
111
+ const cols = lines[header]!.trim().split(/\s+/);
112
+ const ps = cols[0] === "USER";
113
+ const vsz = cols.indexOf("VSZ");
114
+ const cpu = cols.findIndex((c) => /%CPU/.test(c));
115
+ let hidden = 0;
116
+ const rows: string[] = [];
117
+ for (const l of lines.slice(header + 1)) {
118
+ const f = l.trim().split(/\s+/);
119
+ // ps: kernel threads have no virtual memory; top: rows that used no CPU.
120
+ const idle = ps ? f[vsz] === "0" : Number(f[cpu + (cols[cpu] === "S[%CPU]" ? 1 : 0)]) === 0;
121
+ if (idle) hidden++;
122
+ else rows.push(cutLine(f.join(" "), limits.lineChars));
123
+ }
124
+ const squeeze = (l: string) => l.trim().replace(/\s{2,}/g, " ");
125
+ const out = [...lines.slice(0, header).map(squeeze), squeeze(lines[header]!), ...rows];
126
+ if (hidden) out.push(`… ${hidden} ${ps ? "kernel threads" : "idle processes"} not listed`);
127
+ const capped = capLines(out, limits.total);
128
+ return { text: capped.text, truncated: hidden > 0 || capped.truncated };
129
+ }
130
+
131
+ /** Indent deeper than this goes in a dumpsys report (sections, then their entries, stay). */
132
+ const DUMPSYS_DEPTH = 8;
133
+
134
+ /**
135
+ * `adb shell dumpsys …`: lines indented deeper than an entry's own line go (with a count),
136
+ * runs of 3+ lines of the same shape keep their ends, and long reports are capped.
137
+ */
138
+ export function compactDumpsys(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
139
+ const lines = output.split("\n").map((l) => l.trimEnd());
140
+ while (lines.length && lines.at(-1) === "") lines.pop();
141
+ if (lines.length < 20) return undefined;
142
+ const out: string[] = [];
143
+ let deeper = 0;
144
+ const flush = () => {
145
+ if (deeper) out.push(`${" ".repeat(DUMPSYS_DEPTH + 2)}… [+${deeper} deeper lines]`);
146
+ deeper = 0;
147
+ };
148
+ for (const l of lines) {
149
+ if (l.length - l.trimStart().length > DUMPSYS_DEPTH) {
150
+ deeper++;
151
+ continue;
152
+ }
153
+ flush();
154
+ out.push(l);
155
+ }
156
+ flush();
157
+ const runs: string[] = [];
158
+ for (let i = 0; i < out.length; ) {
159
+ const s = shape(out[i]!);
160
+ let j = i + 1;
161
+ while (j < out.length && shape(out[j]!) === s) j++;
162
+ runs.push(...(j - i >= 3 ? [out[i]!, `${out[i]!.match(/^\s*/)![0]}… [${j - i - 2} similar]`, out[j - 1]!] : out.slice(i, j)));
163
+ i = j;
164
+ }
165
+ const capped = capLines(
166
+ runs.map((l) => cutLine(l, limits.lineChars)),
167
+ limits.total,
168
+ );
169
+ return { text: capped.text, truncated: true };
170
+ }
171
+
172
+ const ENTITY: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
173
+ const unescape = (s: string) => s.replace(/&(amp|lt|gt|quot|apos|#(\d+));/g, (_, e: string, n?: string) => (n ? String.fromCodePoint(Number(n)) : ENTITY[e]!));
174
+
175
+ /**
176
+ * `uiautomator dump` XML (`adb exec-out uiautomator dump /dev/tty`): one line per node that
177
+ * says something (text, description, id, or something to tap), indented by nesting, with its
178
+ * centre to tap at; empty wrappers and off-screen nodes go.
179
+ *
180
+ * Button "OK" #ok click @540,1820
181
+ */
182
+ export function compactUiDump(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
183
+ if (!/<hierarchy\b/.test(output)) return undefined;
184
+ const out: string[] = [];
185
+ /** For every open node: did it print (and so indent its children)? */
186
+ const open: boolean[] = [];
187
+ let nodes = 0;
188
+ for (const m of output.matchAll(/<node\b([^>]*?)(\/?)>|<\/node>/g)) {
189
+ if (!m[1] && !m[2] && m[0] === "</node>") {
190
+ open.pop();
191
+ continue;
192
+ }
193
+ nodes++;
194
+ const a: Record<string, string> = {};
195
+ for (const [, k, v] of m[1]!.matchAll(/([\w-]+)="([^"]*)"/g)) a[k!] = unescape(v!);
196
+ const b = /^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$/.exec(a.bounds ?? "");
197
+ const [x1, y1, x2, y2] = (b ?? []).slice(1).map(Number) as number[];
198
+ const visible = !!b && x2! > x1! && y2! > y1!;
199
+ const id = (a["resource-id"] ?? "").replace(/^.*:id\//, "");
200
+ const flags = [
201
+ a.clickable === "true" && "click",
202
+ a["long-clickable"] === "true" && "long-click",
203
+ a.scrollable === "true" && "scroll",
204
+ a.checkable === "true" && (a.checked === "true" ? "checked" : "unchecked"),
205
+ a.selected === "true" && "selected",
206
+ a.focused === "true" && "focused",
207
+ a.enabled === "false" && "disabled",
208
+ a.password === "true" && "password",
209
+ ].filter(Boolean);
210
+ // An id alone names a leaf worth finding (an icon, an input); on a container it is layout.
211
+ const says = visible && (a.text || a["content-desc"] || a.hint || flags.length || (id && m[2]));
212
+ if (says) {
213
+ const depth = open.filter(Boolean).length;
214
+ const parts = [
215
+ (a.class ?? "").split(".").pop(),
216
+ a.text && JSON.stringify(a.text),
217
+ a["content-desc"] && `desc=${JSON.stringify(a["content-desc"])}`,
218
+ a.hint && `hint=${JSON.stringify(a.hint)}`,
219
+ id && `#${id}`,
220
+ ...flags,
221
+ `@${Math.round((x1! + x2!) / 2)},${Math.round((y1! + y2!) / 2)}`,
222
+ ].filter(Boolean);
223
+ out.push(cutLine(`${" ".repeat(depth)}${parts.join(" ")}`, limits.lineChars));
224
+ }
225
+ if (!m[2]) open.push(!!says);
226
+ }
227
+ if (!nodes) return undefined;
228
+ const pkg = /package="([^"]+)"/.exec(output)?.[1];
229
+ const capped = capLines([`ui: ${out.length} of ${nodes} nodes${pkg ? ` (${pkg})` : ""}`, ...out], limits.total);
230
+ return { text: capped.text, truncated: true };
231
+ }
@@ -1,11 +1,20 @@
1
1
  /** Chooses the compactor for a bash command and applies the safety rules. */
2
- import { hasFlag, parse } from "./command.ts";
2
+ import { hasFlag, type Invocation, parse } from "./command.ts";
3
3
  import { DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
4
+ import { compactDumpsys, compactPackagePaths, compactPackages, compactProcesses, compactProperties, compactUiDump } from "./adb.ts";
4
5
  import { compactAudit } from "./audit.ts";
5
6
  import { compactBlame } from "./blame.ts";
6
7
  import { compactDiff } from "./diff.ts";
8
+ import { compactDockerBuild } from "./docker.ts";
7
9
  import { compactDust } from "./dust.ts";
8
- import { compactGradle } from "./gradle.ts";
10
+ import { compactGradle, compactGradleDeps } from "./gradle.ts";
11
+ import { compactInstall } from "./install.ts";
12
+ import { compactJavac } from "./javac.ts";
13
+ import { compactJest } from "./jest.ts";
14
+ import { compactLint, compactLintSniffed } from "./lint.ts";
15
+ import { compactLogs, compactTrace } from "./logs.ts";
16
+ import { compactMaven, compactMavenTree } from "./maven.ts";
17
+ import { compactPytest } from "./pytest.ts";
9
18
  import { compactGeneric, isContent } from "./generic.ts";
10
19
  import { compactLog, compactShow } from "./gitlog.ts";
11
20
  import { compactStatus } from "./gitstatus.ts";
@@ -17,8 +26,9 @@ import { compactBunTest, compactGoTest } from "./runners.ts";
17
26
  import { compactTests } from "./tests.ts";
18
27
  import { compactTree } from "./tree.ts";
19
28
  import { compactTsc } from "./tsc.ts";
29
+ import { compactXcode, compactXcodeSniffed } from "./xcode.ts";
20
30
 
21
- export const KINDS = ["search", "paths", "listing", "diff", "tests", "git", "build", "tree", "generic"] as const;
31
+ export const KINDS = ["search", "paths", "listing", "diff", "tests", "git", "build", "lint", "install", "logs", "tree", "generic"] as const;
22
32
  export type Kind = (typeof KINDS)[number];
23
33
 
24
34
  export type Result = { text: string; kind: Kind; truncated: boolean; before: number; after: number };
@@ -31,14 +41,14 @@ export type Options = {
31
41
  maxRatio?: number;
32
42
  /** Compactors that may run; a missing entry means on. */
33
43
  kinds?: Partial<Record<Kind, boolean>>;
34
- /** The command failed: only test and build output is worth compacting then (the errors are the point). */
44
+ /** The command failed: only test, build, lint, install and log output is worth compacting then (the errors, or the stack trace, are the point). */
35
45
  failed?: boolean;
36
46
  /** Is `path` (relative to the command's directory) an existing file? Lets search output without line numbers be grouped. */
37
47
  isFile?: (path: string) => boolean;
38
48
  };
39
49
 
40
50
 
41
- const SEARCH = new Set(["rg", "grep", "egrep", "ugrep"]);
51
+ const SEARCH = new Set(["rg", "grep", "egrep", "ugrep", "ack"]);
42
52
  const PATHS = new Set(["fd", "fdfind", "find"]);
43
53
  const LISTING = new Set(["lsd", "eza", "exa", "ls"]);
44
54
  const LIST_ONLY = { long: ["--files", "--files-with-matches", "--files-without-match"], short: ["l"] };
@@ -88,10 +98,18 @@ function pickCargo(all: string[]): ReturnType<typeof pick> {
88
98
  const SNIFFERS: { kind: Kind; run: (out: string, l: Limits) => Squeezed | undefined }[] = [
89
99
  { kind: "tests", run: compactBunTest },
90
100
  { kind: "tests", run: compactGoTest },
101
+ { kind: "tests", run: compactPytest },
102
+ { kind: "tests", run: compactJest },
91
103
  { kind: "build", run: compactGradle },
104
+ { kind: "build", run: compactXcodeSniffed },
92
105
  { kind: "build", run: compactTsc },
93
106
  { kind: "build", run: compactRustc },
94
107
  { kind: "build", run: compactAudit },
108
+ { kind: "lint", run: compactLintSniffed },
109
+ { kind: "build", run: compactMaven },
110
+ { kind: "build", run: compactJavac },
111
+ { kind: "tree", run: compactUiDump },
112
+ { kind: "logs", run: compactTrace },
95
113
  ];
96
114
 
97
115
  /** `git` options before the subcommand that do not change its output: `-C dir`, `-c k=v`, `--no-pager`, ... */
@@ -144,9 +162,110 @@ function plainSearch(args: string[], isFile: ((path: string) => boolean) | undef
144
162
  return { kind: "search", run: (out, l) => compactRgPlain(out, l, isFile) };
145
163
  }
146
164
 
165
+ /** `npx tool`, `uv run tool`, `python -m tool`, `xcrun tool`…: the tool is what prints. */
166
+ function unwrap({ program, args }: Invocation): Invocation {
167
+ const next = (rest: string[]): Invocation | undefined => {
168
+ const i = rest.findIndex((a) => !a.startsWith("-"));
169
+ return i < 0 ? undefined : { program: rest[i]!.split("/").pop()!, args: rest.slice(i + 1) };
170
+ };
171
+ let inner: Invocation | undefined;
172
+ if (["npx", "bunx", "pnpx", "uvx"].includes(program)) inner = next(args);
173
+ else if (["uv", "poetry", "pipenv", "pdm", "hatch"].includes(program) && args[0] === "run") inner = next(args.slice(1));
174
+ else if ((program === "pnpm" || program === "yarn") && (args[0] === "exec" || args[0] === "dlx")) inner = next(args.slice(1));
175
+ else if (/^python[\d.]*$/.test(program) && args[0] === "-m" && args[1]) inner = { program: args[1], args: args.slice(2) };
176
+ else if (program === "xcrun" && args[0] && !args[0].startsWith("-")) inner = { program: args[0], args: args.slice(1) };
177
+ return inner ? unwrap(inner) : { program, args };
178
+ }
179
+
180
+ /** `cmd | tail -50` / `cmd | head -n 20`: still `cmd`'s format, only fewer lines of it. */
181
+ export function unpipe(command: string): string {
182
+ return command.replace(/\s*\|\s*(?:tail|head)(?:\s+(?:-n\s*\d+|-\d+|--lines=\d+))?\s*$/, "");
183
+ }
184
+
185
+ const INSTALL: Record<string, string[]> = {
186
+ npm: ["install", "i", "ci", "add", "update", "up"],
187
+ pnpm: ["install", "i", "add", "update", "up"],
188
+ yarn: ["install", "add", "upgrade"],
189
+ bun: ["install", "i", "add", "update"],
190
+ pip: ["install", "download"],
191
+ pip3: ["install", "download"],
192
+ pod: ["install", "update"],
193
+ bundle: ["install", "update"],
194
+ gem: ["install", "update"],
195
+ };
196
+
197
+ /** Linters whose output `compactLint` reads (with their subcommand where they have one). */
198
+ const LINTERS: Record<string, string[] | undefined> = {
199
+ eslint: undefined,
200
+ biome: ["lint", "check", "ci"],
201
+ ruff: ["check"],
202
+ flake8: undefined,
203
+ mypy: undefined,
204
+ pylint: undefined,
205
+ ktlint: undefined,
206
+ detekt: undefined,
207
+ "detekt-cli": undefined,
208
+ swiftlint: ["lint"],
209
+ shellcheck: undefined,
210
+ flutter: ["analyze"],
211
+ dart: ["analyze"],
212
+ };
213
+
214
+ const LOG_COMMANDS = (program: string, args: string[]): boolean =>
215
+ ((program === "docker" || program === "podman") && (args[0] === "logs" || (args[0] === "compose" && args[1] === "logs"))) ||
216
+ (program === "docker-compose" && args[0] === "logs") ||
217
+ (program === "kubectl" && args[0] === "logs") ||
218
+ ["journalctl", "stern", "pidcat"].includes(program);
219
+
220
+ /** `adb [-s serial|-d|-e|-t id] <command>`: logcat, and what `adb shell` prints for packages, properties, processes, dumpsys and UI dumps. */
221
+ function pickAdb(all: string[]): ReturnType<typeof pick> {
222
+ let i = 0;
223
+ while (i < all.length && /^-[sHPLt]$|^-[de]$/.test(all[i]!)) i += /^-[de]$/.test(all[i]!) ? 1 : 2;
224
+ const [sub, ...rest] = all.slice(i);
225
+ // `adb shell "dumpsys battery"` is `adb shell dumpsys battery`.
226
+ const cmd = rest.join(" ").trim().split(/\s+/);
227
+ const line = cmd.join(" ");
228
+ if (sub === "logcat") return { kind: "logs", run: compactLogs };
229
+ if ((sub === "shell" || sub === "exec-out") && /^uiautomator dump \/dev\/(?:tty|stdout)\b/.test(line)) return { kind: "tree", run: compactUiDump };
230
+ if (sub !== "shell") return undefined;
231
+ if (/^(?:pm|cmd package) list packages\b/.test(line)) return cmd.includes("-f") ? { kind: "listing", run: compactPackagePaths } : { kind: "listing", run: compactPackages };
232
+ if (line === "getprop" || /^settings list (?:global|secure|system)$/.test(line)) return { kind: "listing", run: compactProperties };
233
+ if (/^ps(?: -A| -e)*$/.test(line) || /^top\b/.test(line)) return { kind: "listing", run: compactProcesses };
234
+ if (cmd[0] === "dumpsys" && cmd.length > 1) return { kind: "tree", run: compactDumpsys };
235
+ return undefined;
236
+ }
237
+
238
+ /** Package managers, linters, test runners, logs, docker and Xcode. */
239
+ function pickTool({ program, args }: Invocation): ReturnType<typeof pick> {
240
+ const sub = args[0] ?? "";
241
+ const machine = args.some((a) => /json|sarif|junit|checkstyle|xml|github|gitlab|codeclimate|teamcity|tap\b/i.test(a));
242
+ if (machine) return undefined;
243
+ if (INSTALL[program]?.includes(sub) || (program === "yarn" && !sub) || (program === "uv" && (["add", "sync"].includes(sub) || (sub === "pip" && ["install", "sync"].includes(args[1] ?? "")))) || ((program === "flutter" || program === "dart") && sub === "pub" && args[1] === "get"))
244
+ return { kind: "install", run: compactInstall };
245
+ if (program in LINTERS) {
246
+ const subs = LINTERS[program];
247
+ // `ruff .` is `ruff check .` in older versions.
248
+ if (!subs || subs.includes(sub) || (program === "ruff" && sub.startsWith("-"))) return { kind: "lint", run: compactLint };
249
+ return undefined;
250
+ }
251
+ if (program === "pytest" || program === "py.test") return { kind: "tests", run: compactPytest };
252
+ if (program === "jest" || program === "vitest") return { kind: "tests", run: compactJest };
253
+ if (program === "mvn" || program === "mvnw" || program === "mvnd")
254
+ return args.some((a) => /dependency:tree$/.test(a)) ? { kind: "tree", run: compactMavenTree } : { kind: args.includes("test") ? "tests" : "build", run: compactMaven };
255
+ if (program === "javac") return { kind: "build", run: compactJavac };
256
+ if (LOG_COMMANDS(program, args)) return hasFlag(args, ["--follow"], ["f"]) ? undefined : { kind: "logs", run: compactLogs };
257
+ if (program === "adb") return pickAdb(args);
258
+ if (((program === "docker" || program === "podman") && (sub === "build" || (sub === "buildx" && args[1] === "build") || (sub === "compose" && args[1] === "build"))) || (program === "docker-compose" && sub === "build"))
259
+ return { kind: "build", run: compactDockerBuild };
260
+ if (program === "xcodebuild") return { kind: args.includes("test") ? "tests" : "build", run: compactXcode };
261
+ if (program === "swift" && (sub === "build" || sub === "test")) return { kind: sub === "test" ? "tests" : "build", run: compactXcode };
262
+ return undefined;
263
+ }
264
+
147
265
  function pick(command: string, isFile?: (path: string) => boolean): { kind: Kind; run: (out: string, l: Limits) => Squeezed | undefined } | undefined {
148
- const inv = parse(command);
149
- if (!inv) return undefined;
266
+ const parsed = parse(command);
267
+ if (!parsed) return undefined;
268
+ const inv = unwrap(parsed);
150
269
  // Relative paths in the output are relative to the `cd`, which the file check does not know.
151
270
  const local = /^\s*cd\s/.test(command) ? undefined : isFile;
152
271
  const { program, args } = inv;
@@ -154,6 +273,8 @@ function pick(command: string, isFile?: (path: string) => boolean): { kind: Kind
154
273
  const files = hasFlag(args, LIST_ONLY.long, program === "rg" ? LIST_ONLY.short : [...LIST_ONLY.short, "L"]);
155
274
  if (files) return hasFlag(args, NOT_A_LIST.long, NOT_A_LIST.short) ? undefined : { kind: "paths", run: compactPaths };
156
275
  if (hasFlag(args, NOT_PLAIN_SEARCH.long, NOT_PLAIN_SEARCH.short)) return undefined;
276
+ // ack: `-f` / `-g` list files; `-h` drops the path.
277
+ if (program === "ack" && hasFlag(args, ["--no-filename"], ["f", "g", "h"])) return hasFlag(args, [], ["f", "g"]) ? { kind: "paths", run: compactPaths } : undefined;
157
278
  const plain = plainSearch(args, local);
158
279
  return plain ?? { kind: "search", run: compactRg };
159
280
  }
@@ -163,8 +284,8 @@ function pick(command: string, isFile?: (path: string) => boolean): { kind: Kind
163
284
  }
164
285
  if (program === "git") return pickGit(args);
165
286
  if (program === "cargo") return pickCargo(args);
166
- if (program === "tsc" || ((program === "npx" || program === "bunx") && args[0] === "tsc")) return { kind: "build", run: compactTsc };
167
- if (program === "gradle" || program === "gradlew") return { kind: "build", run: compactGradle };
287
+ if (program === "tsc") return { kind: "build", run: compactTsc };
288
+ if (program === "gradle" || program === "gradlew") return args.some((a) => /(^|:)dependencies$/.test(a)) ? { kind: "tree", run: compactGradleDeps } : { kind: "build", run: compactGradle };
168
289
  if (program === "go" && args[0] === "test") return hasFlag(args, ["-json", "--json"]) ? undefined : { kind: "tests", run: compactGoTest };
169
290
  if (program === "bun" && args[0] === "test") return { kind: "tests", run: compactBunTest };
170
291
  if (program === "tree" || ((program === "eza" || program === "exa" || program === "lsd") && hasFlag(args, ["--tree"], ["T"]))) return hasFlag(args, ["-J", "--json", "-H", "--html"]) ? undefined : { kind: "tree", run: compactTree };
@@ -175,18 +296,22 @@ function pick(command: string, isFile?: (path: string) => boolean): { kind: Kind
175
296
  if (hasFlag(args, ["--inode", "--blocks", "--size", "--full-time", "--time-style", "--numeric-uid-gid", "--git", "--header", "--octal-permissions", "--total-size", "--context"], ["i", "s", "n", "g", "o", "H", "Z"])) return undefined;
176
297
  return { kind: "listing", run: compactLsd };
177
298
  }
178
- return undefined;
299
+ return pickTool(inv);
179
300
  }
180
301
 
181
302
  /** True when a native compactor takes care of `command`'s output (whatever the switches say). */
182
- export const handles = (command: string): boolean => pick(command) !== undefined;
303
+ export const handles = (command: string): boolean => pick(unpipe(command)) !== undefined;
304
+
305
+ /** Kinds whose output is worth compacting when the command failed: there the failures (or the crash's stack trace) are the point. */
306
+ const FAILED_OK = new Set<Kind>(["tests", "build", "lint", "install", "logs"]);
183
307
 
184
308
  /** Compacts the output of `command`, or returns undefined to leave it exactly as it is. */
185
- export function compact(command: string, output: string, opts: Options = {}): Result | undefined {
309
+ export function compact(full: string, output: string, opts: Options = {}): Result | undefined {
310
+ const command = unpipe(full);
186
311
  const { limits = DEFAULT_LIMITS, minChars = 400, maxRatio = 0.9, kinds = {}, failed = false, isFile } = opts;
187
312
  if (output.length < minChars) return undefined;
188
313
  const small = (sq: Squeezed | undefined): sq is Squeezed => sq !== undefined && sq.text.length <= output.length * maxRatio;
189
- const allowed = (kind: Kind) => kinds[kind] !== false && (!failed || kind === "tests" || kind === "build");
314
+ const allowed = (kind: Kind) => kinds[kind] !== false && (!failed || FAILED_OK.has(kind));
190
315
  const done = (sq: Squeezed, kind: Kind): Result => ({ text: sq.text, kind, truncated: sq.truncated, before: output.length, after: sq.text.length });
191
316
 
192
317
  const chosen = pick(command, isFile);
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `docker build` (BuildKit plain progress and the classic builder): bookkeeping steps
3
+ * (`[internal]` loads, context transfer, CACHED, DONE, layer pushes, image export) go;
4
+ * each build step keeps its header and what its command printed, without the `#N 0.123`
5
+ * prefix. Errors stay whole.
6
+ */
7
+ import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
8
+ import { stripAnsi } from "./generic.ts";
9
+
10
+ const STEP = /^#(\d+) (.*)$/;
11
+ /** BuildKit lines with nothing to say once the build is over. */
12
+ const QUIET = /^(building with |DONE [\d.]+s|CACHED|transferring |resolve |sha256:[\da-f]+ |extracting sha256:|exporting |writing image |naming to |unpacking to |preparing layers|load build definition|load \.dockerignore|load metadata for |\[auth\] )/;
13
+ /** Classic builder bookkeeping. */
14
+ const CLASSIC = /^( ---> |Removing intermediate container |Sending build context to Docker daemon|Successfully built [\da-f]+$)/;
15
+
16
+ export function compactDockerBuild(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
17
+ const lines = stripAnsi(output).split("\n");
18
+ while (lines.length && lines.at(-1) === "") lines.pop();
19
+ const buildkit = lines.some((l) => /^#\d+ \[/.test(l));
20
+ const classic = lines.some((l) => /^Step \d+\/\d+ : /.test(l));
21
+ if (!buildkit && !classic) return undefined;
22
+ const internal = new Set<string>();
23
+ const cached = new Set<string>();
24
+ /** Where each step's header went, to mark it CACHED. */
25
+ const steps = new Map<string, number>();
26
+ const out: string[] = [];
27
+ for (const l of lines) {
28
+ const m = STEP.exec(l);
29
+ if (!m) {
30
+ if (CLASSIC.test(l) || (l === "" && (out.length === 0 || out.at(-1) === ""))) continue;
31
+ out.push(cutLine(l, limits.lineChars));
32
+ continue;
33
+ }
34
+ const [, id, rest] = m as unknown as [string, string, string];
35
+ if (/^\[internal\]/.test(rest) || internal.has(id)) {
36
+ internal.add(id);
37
+ if (/^ERROR/.test(rest)) out.push(l);
38
+ continue;
39
+ }
40
+ if (rest === "CACHED" && steps.has(id)) {
41
+ cached.add(id);
42
+ out[steps.get(id)!] += " (cached)";
43
+ }
44
+ if (/^\[/.test(rest)) {
45
+ // BuildKit repeats a step's header when its output resumes after another step's.
46
+ if (!steps.has(id)) steps.set(id, out.push(cutLine(`#${id} ${rest}`, limits.lineChars)) - 1);
47
+ } else if (/^ERROR|^error/.test(rest)) out.push(cutLine(l, limits.lineChars));
48
+ else if (!QUIET.test(rest)) {
49
+ // `#7 0.512 npm warn …`: the command's own output.
50
+ const printed = /^[\d.]+(?: (.*))?$/.exec(rest);
51
+ if (printed && !printed[1]?.trim()) continue;
52
+ out.push(cutLine(printed ? ` ${printed[1]}` : `#${id} ${rest}`, limits.lineChars));
53
+ }
54
+ }
55
+ const head = buildkit ? [`docker build: ${steps.size} steps${cached.size ? `, ${cached.size} cached` : ""}`] : [];
56
+ const capped = capLines([...head, ...out], limits.total);
57
+ return { text: capped.text, truncated: capped.truncated };
58
+ }
@@ -1,5 +1,5 @@
1
1
  /** File checks for the compactors that need to know what is on disk. */
2
- import { statSync } from "node:fs";
2
+ import { readFileSync, statSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
 
5
5
  /** A check whether `path`, relative to `cwd`, is an existing file. Without a cwd nothing is. */
@@ -13,3 +13,17 @@ export function fileIn(cwd: string | undefined): (path: string) => boolean {
13
13
  }
14
14
  };
15
15
  }
16
+
17
+ /** Full outputs larger than this are not read back: compacting them would cost more than it saves. */
18
+ export const FULL_OUTPUT_MAX_BYTES = 16 * 1024 * 1024;
19
+
20
+ /** The full output pi saved for a bash run it truncated, or undefined when there is none to read. */
21
+ export function readFullOutput(path: unknown, maxBytes = FULL_OUTPUT_MAX_BYTES): string | undefined {
22
+ if (typeof path !== "string") return undefined;
23
+ try {
24
+ if (statSync(path).size > maxBytes) return undefined;
25
+ return readFileSync(path, "utf8");
26
+ } catch {
27
+ return undefined; // gone already, unreadable
28
+ }
29
+ }
@@ -16,8 +16,11 @@ const CONTENT = /(?:^|[\s|;&(])(cat|head|tail|sed|awk|jq|yq|bat|less|more|curl|w
16
16
 
17
17
  export const isContent = (command: string): boolean => CONTENT.test(command);
18
18
 
19
+ /** Colour codes and terminal hyperlinks out; the text they wrapped stays. */
20
+ export const stripAnsi = (text: string): string => text.replace(ANSI, "");
21
+
19
22
  export function compactGeneric(output: string, _limits?: Limits): Squeezed | undefined {
20
- const lines = output.replace(ANSI, "").split("\n").map((l) => {
23
+ const lines = stripAnsi(output).split("\n").map((l) => {
21
24
  const frame = l.replace(/\r+$/, "");
22
25
  return (frame.includes("\r") ? frame.slice(frame.lastIndexOf("\r") + 1) : frame).trimEnd();
23
26
  });