pi-squeeze 0.0.0-stage → 0.1.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.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * `rg -n` / `grep -n` output (`path:line:text`, context lines `path-line-text`) grouped by
3
+ * file, so each path is written once instead of on every match:
4
+ *
5
+ * src/a.ts (3)
6
+ * 12: const x = 1;
7
+ * 13- context
8
+ *
9
+ * Without line numbers (`rg pattern dir`, `path:text`) the path cannot be told from text that
10
+ * has a colon, so `compactRgPlain` only groups when every path is a file that exists.
11
+ *
12
+ * Strict: when any line is not in that format (`--heading`, `-c`, `-l`, `--json`) it returns
13
+ * undefined and the output passes through unchanged.
14
+ */
15
+ import { cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
16
+
17
+ type Row = { path: string; line: number; sep: ":" | "-"; text: string };
18
+
19
+ const MATCH = /^(.+?):(\d+):(.*)$/;
20
+
21
+ export function parseRows(output: string): Row[] | undefined {
22
+ const lines = output.split("\n");
23
+ while (lines.length && lines.at(-1) === "") lines.pop();
24
+ if (!lines.length) return undefined;
25
+ const rows: Row[] = [];
26
+ const files = new Set<string>();
27
+ const pending: { index: number; raw: string }[] = [];
28
+ for (const raw of lines) {
29
+ if (raw === "--") continue;
30
+ const m = MATCH.exec(raw);
31
+ if (m) {
32
+ if (m[1]!.includes(":")) return undefined; // `12:foo:3:bar` is single-file output, not a path
33
+ files.add(m[1]!);
34
+ rows.push({ path: m[1]!, line: Number(m[2]), sep: ":", text: m[3]! });
35
+ } else {
36
+ pending.push({ index: rows.length, raw });
37
+ rows.push(undefined as unknown as Row);
38
+ }
39
+ }
40
+ if (!files.size) return undefined;
41
+ // Context lines (`path-line-text`) only count for files that also had a real match.
42
+ for (const { index, raw } of pending) {
43
+ let row: Row | undefined;
44
+ for (const path of files) {
45
+ if (!raw.startsWith(`${path}-`)) continue;
46
+ const m = /^(\d+)-(.*)$/.exec(raw.slice(path.length + 1));
47
+ if (m) {
48
+ row = { path, line: Number(m[1]), sep: "-", text: m[2]! };
49
+ break;
50
+ }
51
+ }
52
+ if (!row) return undefined;
53
+ rows[index] = row;
54
+ }
55
+ return rows;
56
+ }
57
+
58
+ /** At most this many distinct paths are looked up on disk. */
59
+ const MAX_LOOKUPS = 500;
60
+
61
+ /** `path:text` rows (no line numbers), or undefined unless every path is an existing file. */
62
+ export function parsePlainRows(output: string, isFile: (path: string) => boolean): Row[] | undefined {
63
+ const lines = output.split("\n");
64
+ while (lines.length && lines.at(-1) === "") lines.pop();
65
+ if (!lines.length) return undefined;
66
+ const known = new Map<string, boolean>();
67
+ const rows: Row[] = [];
68
+ for (const raw of lines) {
69
+ const at = raw.indexOf(":");
70
+ if (at <= 0) return undefined;
71
+ const path = raw.slice(0, at);
72
+ let ok = known.get(path);
73
+ if (ok === undefined) {
74
+ if (known.size >= MAX_LOOKUPS) return undefined;
75
+ ok = isFile(path);
76
+ known.set(path, ok);
77
+ }
78
+ if (!ok) return undefined;
79
+ rows.push({ path, line: 0, sep: ":", text: raw.slice(at + 1) });
80
+ }
81
+ return rows;
82
+ }
83
+
84
+ export function compactRg(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
85
+ const rows = parseRows(output);
86
+ return rows && render(rows, limits);
87
+ }
88
+
89
+ export function compactRgPlain(output: string, limits: Limits, isFile: (path: string) => boolean): Squeezed | undefined {
90
+ const rows = parsePlainRows(output, isFile);
91
+ return rows && render(rows, limits);
92
+ }
93
+
94
+ function render(rows: Row[], limits: Limits): Squeezed {
95
+ const groups = new Map<string, Row[]>();
96
+ for (const r of rows) {
97
+ const g = groups.get(r.path);
98
+ if (g) g.push(r);
99
+ else groups.set(r.path, [r]);
100
+ }
101
+ const matchCount = rows.filter((r) => r.sep === ":").length;
102
+ const out: string[] = [`${matchCount} matches in ${groups.size} file${groups.size === 1 ? "" : "s"}`];
103
+ let truncated = false;
104
+ let hiddenFiles = 0;
105
+ for (const [path, list] of groups) {
106
+ if (out.length >= limits.total) {
107
+ hiddenFiles++;
108
+ truncated = true;
109
+ continue;
110
+ }
111
+ const matches = list.filter((r) => r.sep === ":").length;
112
+ out.push(`${path} (${matches})`);
113
+ let shown = 0;
114
+ for (const r of list) {
115
+ if (shown >= limits.perGroup) break;
116
+ out.push(` ${r.line ? `${r.line}${r.sep} ` : ""}${cutLine(r.text.trim(), limits.lineChars)}`);
117
+ shown++;
118
+ }
119
+ if (list.length > shown) {
120
+ out.push(` … [+${list.length - shown} more in this file]`);
121
+ truncated = true;
122
+ }
123
+ }
124
+ if (hiddenFiles) out.push(`… [+${hiddenFiles} more files]`);
125
+ return { text: out.join("\n"), truncated };
126
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The optional rtk layer: commands that have no native compactor here (git, cargo, docker, …) are
3
+ * rewritten to their `rtk` form, which prints a compact output itself.
4
+ *
5
+ * `rtk rewrite <command>` is the single source of truth for what rtk can take over. Exit 0 and 3
6
+ * mean "rewritten" (3: the host should ask for approval, pi has no such step); 1 means no rewrite
7
+ * and 2 a deny. Never rewritten: what a native compactor handles (its output format is ours to
8
+ * parse) and file reads (`rtk read` may drop lines, and a read must stay exact).
9
+ */
10
+ import { spawnSync } from "node:child_process";
11
+ import { handles } from "./compact.ts";
12
+
13
+ export type Run = (command: string) => { status: number | null; stdout: string; missing?: boolean };
14
+
15
+ const spawnRun: Run = (command) => {
16
+ const r = spawnSync("rtk", ["rewrite", command], { encoding: "utf8", timeout: 2000 });
17
+ return { status: r.status, stdout: r.stdout ?? "", missing: (r.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT" };
18
+ };
19
+
20
+ let run: Run = spawnRun;
21
+ /** For tests: replace the process runner (no argument restores it). */
22
+ export const useRunner = (r: Run = spawnRun): void => {
23
+ run = r;
24
+ };
25
+
26
+ const cache = new Map<string, string | undefined>();
27
+ const CACHE_MAX = 500;
28
+ let missing = false;
29
+
30
+ /** The command to run instead of `command`, or undefined to run it as written. */
31
+ export function rewrite(command: string): string | undefined {
32
+ if (missing || command.trim() === "" || handles(command)) return undefined;
33
+ if (cache.has(command)) return cache.get(command);
34
+ const r = run(command);
35
+ if (r.missing) {
36
+ missing = true;
37
+ return undefined;
38
+ }
39
+ const out = r.stdout.trim();
40
+ const rewritten = (r.status === 0 || r.status === 3) && out !== "" && out !== command && !/^rtk read\b/.test(out) ? out : undefined;
41
+ if (cache.size >= CACHE_MAX) cache.clear();
42
+ cache.set(command, rewritten);
43
+ return rewritten;
44
+ }
45
+
46
+ /** For tests: forget what was learned (the cache and a missing binary). */
47
+ export function resetRtk(): void {
48
+ cache.clear();
49
+ missing = false;
50
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Test runners other than cargo: `bun test` and `go test`. Passing tests are counted, not
3
+ * listed; failures (with their source frame and message), skips and the totals stay.
4
+ */
5
+ import { capLines, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
6
+
7
+ /** `(pass) name [1ms]` lines go, and a file header left with nothing under it goes too. */
8
+ export function compactBunTest(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
9
+ const lines = output.split("\n");
10
+ while (lines.length && lines.at(-1) === "") lines.pop();
11
+ if (!lines.some((l) => /^Ran \d+ tests? across \d+ files?\./.test(l))) return undefined;
12
+ const kept: string[] = [];
13
+ for (const l of lines) {
14
+ if (/^\(pass\) /.test(l)) continue;
15
+ // The file header (`path.test.ts:`) is only worth keeping when something stayed under it.
16
+ if (l === "" && (kept.length === 0 || kept.at(-1) === "")) continue;
17
+ kept.push(l);
18
+ }
19
+ const out: string[] = [];
20
+ for (let i = 0; i < kept.length; i++) {
21
+ const header = /^\S+\.(test|spec)\.\w+:$/.test(kept[i]!);
22
+ const empty = kept[i + 1] === undefined || kept[i + 1] === "" || /^\S+\.(test|spec)\.\w+:$/.test(kept[i + 1]!);
23
+ if (header && empty) continue;
24
+ out.push(kept[i]!);
25
+ }
26
+ const tidy = out.filter((l, i) => !(l === "" && (i === 0 || out[i - 1] === "")));
27
+ return capLines(tidy, limits.total);
28
+ }
29
+
30
+ /** `ok pkg 0.3s`, `? pkg [no test files]` and `PASS` collapse into one count line; `=== RUN` and `--- PASS` go. */
31
+ export function compactGoTest(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
32
+ const lines = output.split("\n");
33
+ while (lines.length && lines.at(-1) === "") lines.pop();
34
+ let ok = 0;
35
+ let failed = 0;
36
+ let none = 0;
37
+ const kept: string[] = [];
38
+ for (const l of lines) {
39
+ if (/^ok\s+\S+/.test(l)) ok++;
40
+ else if (/^\?\s+\S+\s+\[no test files\]$/.test(l)) none++;
41
+ else if (/^(FAIL)\s+\S+(\s|$)/.test(l) && /\s\d/.test(l)) {
42
+ failed++;
43
+ kept.push(l);
44
+ } else if (/^(=== (RUN|PAUSE|CONT|NAME)|\s*--- PASS:|PASS$)/.test(l)) continue;
45
+ else kept.push(l);
46
+ }
47
+ if (!ok && !failed && !none) return undefined;
48
+ const parts = [`${ok} ok`, `${failed} failed`, ...(none ? [`${none} without tests`] : [])];
49
+ return capLines([`go test: ${parts.join(", ")}`, ...kept], limits.total);
50
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `cargo build` / `check` / `clippy` output. Errors keep their full block (they are why you
3
+ * ran the command); warnings shrink to one line each, grouped by lint:
4
+ *
5
+ * clippy::needless_return (2)
6
+ * src/lib.rs:2:55 unneeded `return` statement
7
+ *
8
+ * Build progress (`Compiling`, `Checking`, `Finished`) goes, an identical warning reported
9
+ * for several targets is listed once, and the summary lines stay.
10
+ */
11
+ import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
12
+
13
+ const PROGRESS = /^\s+(Compiling|Checking|Downloaded|Downloading|Updating|Locking|Fresh|Blocking|Adding|Building|Documenting|Finished|Fixed|Generated) /;
14
+ const HEAD = /^(warning|error)(?:\[(E\d+)\])?: (.*)$/;
15
+ const WHERE = /^\s*--> (.+?:\d+:\d+)$/;
16
+ const ERROR_LINES = 40;
17
+
18
+ type Diag = { level: "warning" | "error"; code: string; message: string; where: string; lint: string; help: string; block: string[] };
19
+
20
+ function lintOf(block: string[]): string {
21
+ for (const l of block) {
22
+ const a = /#\[(?:warn|deny|forbid)\(([\w:]+)\)\]/.exec(l);
23
+ if (a) return a[1]!;
24
+ const b = /rust-clippy\/[^#]*#(\w+)$/.exec(l);
25
+ if (b) return `clippy::${b[1]}`;
26
+ }
27
+ return "other";
28
+ }
29
+
30
+ function parseBlock(block: string[]): Diag | undefined {
31
+ const h = HEAD.exec(block[0] ?? "");
32
+ const w = block.map((l) => WHERE.exec(l)).find(Boolean);
33
+ if (!h || !w) return undefined;
34
+ const help = block.map((l) => /\^+ help: (.*)$/.exec(l)).find(Boolean)?.[1] ?? "";
35
+ return { level: h[1] as Diag["level"], code: h[2] ?? "", message: h[3]!, where: w[1]!, lint: lintOf(block), help, block };
36
+ }
37
+
38
+ export function compactRustc(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
39
+ const lines = output.split("\n");
40
+ while (lines.length && lines.at(-1) === "") lines.pop();
41
+ const blocks: string[][] = [[]];
42
+ for (const l of lines) {
43
+ if (PROGRESS.test(l)) continue;
44
+ if (l === "") blocks.push([]);
45
+ else blocks.at(-1)!.push(l);
46
+ }
47
+ const diags: Diag[] = [];
48
+ const other: string[] = [];
49
+ let seen = false;
50
+ const order: (Diag | string)[] = [];
51
+ for (const b of blocks) {
52
+ if (!b.length) continue;
53
+ const d = parseBlock(b);
54
+ if (d) {
55
+ seen = true;
56
+ diags.push(d);
57
+ order.push(d);
58
+ } else {
59
+ other.push(...b);
60
+ order.push(b.join("\n"));
61
+ }
62
+ }
63
+ if (!seen) return undefined;
64
+
65
+ const errors = diags.filter((d) => d.level === "error");
66
+ const warnings = new Map<string, Map<string, string>>();
67
+ for (const d of diags) {
68
+ if (d.level !== "warning") continue;
69
+ const group = warnings.get(d.lint) ?? new Map<string, string>();
70
+ warnings.set(d.lint, group);
71
+ group.set(`${d.where} ${d.message}`, cutLine(` ${d.where} ${d.message}${d.help ? ` → ${d.help}` : ""}`, limits.lineChars));
72
+ }
73
+ const out: string[] = [];
74
+ const count = [...warnings.values()].reduce((n, g) => n + g.size, 0);
75
+ out.push(`${errors.length} error${errors.length === 1 ? "" : "s"}, ${count} warning${count === 1 ? "" : "s"}`);
76
+ let cut = false;
77
+ for (const e of errors) {
78
+ out.push("", ...e.block.slice(0, ERROR_LINES).map((l) => cutLine(l, limits.lineChars)));
79
+ if (e.block.length > ERROR_LINES) {
80
+ out.push(` … [+${e.block.length - ERROR_LINES} more lines]`);
81
+ cut = true;
82
+ }
83
+ }
84
+ if (errors.length && warnings.size) out.push("");
85
+ for (const [lint, group] of warnings) {
86
+ out.push(`${lint} (${group.size})`);
87
+ const entries = [...group.values()];
88
+ out.push(...entries.slice(0, limits.perGroup));
89
+ if (entries.length > limits.perGroup) {
90
+ out.push(` … [+${entries.length - limits.perGroup} more]`);
91
+ cut = true;
92
+ }
93
+ }
94
+ // Whatever is not a diagnostic (summaries, `error: could not compile`, notes) stays as it was.
95
+ const rest = other.filter((l) => l !== "");
96
+ if (rest.length) out.push("", ...rest);
97
+ const capped = capLines(out, limits.total);
98
+ return { text: capped.text, truncated: cut || capped.truncated };
99
+ }
@@ -0,0 +1,11 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /** Writes the untouched output to a temp file so a truncated result can be recovered. */
6
+ export function saveFull(text: string, dir = join(tmpdir(), "pi-squeeze")): string {
7
+ mkdirSync(dir, { recursive: true });
8
+ const path = join(dir, `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.txt`);
9
+ writeFileSync(path, text);
10
+ return path;
11
+ }
@@ -0,0 +1,31 @@
1
+ import { KINDS, type Kind, type Result } from "./compact.ts";
2
+
3
+ export type Stats = { calls: number; before: number; after: number; byKind: Record<Kind, { calls: number; saved: number }> };
4
+
5
+ export function emptyStats(): Stats {
6
+ return { calls: 0, before: 0, after: 0, byKind: Object.fromEntries(KINDS.map((k) => [k, { calls: 0, saved: 0 }])) as Stats["byKind"] };
7
+ }
8
+
9
+ export function record(stats: Stats, r: Result): void {
10
+ stats.calls++;
11
+ stats.before += r.before;
12
+ stats.after += r.after;
13
+ stats.byKind[r.kind].calls++;
14
+ stats.byKind[r.kind].saved += r.before - r.after;
15
+ }
16
+
17
+ /** Characters saved so far. */
18
+ export const saved = (s: Stats): number => s.before - s.after;
19
+
20
+ /** Rough token estimate: 4 characters per token. */
21
+ export const tokens = (chars: number): number => Math.round(chars / 4);
22
+
23
+ export function summary(stats: Stats): string {
24
+ if (!stats.calls) return "squeeze: nothing compacted yet in this session";
25
+ const pct = Math.round((saved(stats) / stats.before) * 100);
26
+ const kinds = (Object.entries(stats.byKind) as [Kind, { calls: number; saved: number }][])
27
+ .filter(([, v]) => v.calls)
28
+ .map(([k, v]) => `${k} ${v.calls}× (~${tokens(v.saved)} tokens)`)
29
+ .join(", ");
30
+ return `squeeze: ${stats.calls} outputs compacted, ~${tokens(saved(stats))} tokens saved (${pct}%): ${kinds}`;
31
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `cargo test` output: the build progress, the `test … ok` lines and the `running N tests`
3
+ * headers go; warnings, failures, panics, `Running …` and `test result` lines stay. A one-line
4
+ * summary of the totals comes first.
5
+ */
6
+ import { capLines, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
7
+
8
+ const PROGRESS = /^\s+(Compiling|Downloaded|Downloading|Updating|Locking|Fresh|Blocking|Adding|Packaging|Checking|Building|Finished) /;
9
+ const OK = /^test .* \.\.\. (ok|ignored)(, .*)?$/;
10
+ const RUNNING_N = /^running \d+ tests?$/;
11
+ const RESULT = /^test result: (ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored;/;
12
+
13
+ export function compactTests(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
14
+ const lines = output.split("\n");
15
+ let passed = 0;
16
+ let failed = 0;
17
+ let ignored = 0;
18
+ let binaries = 0;
19
+ let sawProgress = false;
20
+ let inFailures = false;
21
+ const kept: string[] = [];
22
+ for (const l of lines) {
23
+ const r = RESULT.exec(l);
24
+ if (r) {
25
+ binaries++;
26
+ passed += Number(r[2]);
27
+ failed += Number(r[3]);
28
+ ignored += Number(r[4]);
29
+ }
30
+ if (l === "failures:") inFailures = true;
31
+ if (r) inFailures = false;
32
+ if (PROGRESS.test(l)) {
33
+ sawProgress = true;
34
+ continue;
35
+ }
36
+ if (OK.test(l) || RUNNING_N.test(l)) continue;
37
+ const run = /^(\s+Running .*?) \(target\/.*\)$/.exec(l);
38
+ const text = run ? run[1]! : l;
39
+ // Blank lines only separate blocks: they stay only inside the failure report, where they separate panics.
40
+ if (text === "" && !inFailures) continue;
41
+ kept.push(text);
42
+ }
43
+ if (binaries === 0 && !sawProgress) return undefined;
44
+ const head = binaries ? [`cargo test: ${passed} passed, ${failed} failed, ${ignored} ignored (${binaries} test binar${binaries === 1 ? "y" : "ies"})`] : [];
45
+ return capLines([...head, ...kept], limits.total);
46
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Tree output (`cargo tree`, `tree`, `eza --tree`) cut below a depth, with a marker that says
3
+ * how many lines were left out under each cut:
4
+ *
5
+ * ├── ratatui v0.30.2
6
+ * │ ├── instability v0.3.14 (proc-macro)
7
+ * │ │ ├── darling v0.24.1
8
+ * … [+15 lines deeper]
9
+ *
10
+ * A cut of a single line is not worth a marker and is left in.
11
+ */
12
+ import { cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
13
+
14
+ const PREFIX = /^((?:│ | )*)/;
15
+ const BRANCH = /^(?:│ | )*(?:├── |└── )/;
16
+
17
+ /** Levels kept below the root. */
18
+ const DEPTH = 3;
19
+
20
+ export function compactTree(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
21
+ const lines = output.split("\n");
22
+ while (lines.length && lines.at(-1) === "") lines.pop();
23
+ if (lines.length < 5 || !lines.some((l) => BRANCH.test(l))) return undefined;
24
+ const out: string[] = [];
25
+ let hidden: string[] = [];
26
+ const flush = () => {
27
+ if (hidden.length === 1) out.push(cutLine(hidden[0]!, limits.lineChars));
28
+ else if (hidden.length) out.push(`${" ".repeat(DEPTH)}… [+${hidden.length} lines deeper]`);
29
+ hidden = [];
30
+ };
31
+ for (const l of lines) {
32
+ const units = PREFIX.exec(l)![1]!.length / 4;
33
+ const depth = BRANCH.test(l) || units > 0 ? units + 1 : 0;
34
+ if (depth > DEPTH) {
35
+ hidden.push(l);
36
+ continue;
37
+ }
38
+ flush();
39
+ out.push(cutLine(l, limits.lineChars));
40
+ }
41
+ flush();
42
+ if (out.length >= lines.length) return undefined;
43
+ return { text: out.join("\n"), truncated: true };
44
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `tsc` (plain, not `--pretty`) errors grouped by file:
3
+ *
4
+ * a.ts (8)
5
+ * 1:7 TS2322 Type 'string' is not assignable to type 'number'.
6
+ *
7
+ * Continuation lines (indented) stay with their error. The `Found N errors` footer stays.
8
+ */
9
+ import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
10
+
11
+ const ERROR = /^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.*)$/;
12
+ const FOOTER = /^Found \d+ errors?( in \d+ files?)?\.?$|^Errors +Files$|^ +\d+ +\S.*$/;
13
+
14
+ export function compactTsc(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
15
+ const lines = output.split("\n");
16
+ while (lines.length && lines.at(-1) === "") lines.pop();
17
+ const files = new Map<string, string[]>();
18
+ const tail: string[] = [];
19
+ let current: string[] | undefined;
20
+ let errors = 0;
21
+ for (const l of lines) {
22
+ const m = ERROR.exec(l);
23
+ if (m) {
24
+ errors++;
25
+ current = files.get(m[1]!) ?? [];
26
+ files.set(m[1]!, current);
27
+ current.push(cutLine(` ${m[2]}:${m[3]} ${m[4] === "warning" ? "warning " : ""}${m[5]} ${m[6]}`, limits.lineChars));
28
+ } else if (/^\s+\S/.test(l) && current && !tail.length) current.push(cutLine(` ${l.trim()}`, limits.lineChars));
29
+ else if (l === "" || FOOTER.test(l)) {
30
+ tail.push(l);
31
+ current = undefined;
32
+ } else return undefined;
33
+ }
34
+ if (!errors) return undefined;
35
+ const out = [`tsc: ${errors} error${errors === 1 ? "" : "s"} in ${files.size} file${files.size === 1 ? "" : "s"}`];
36
+ let cut = false;
37
+ for (const [file, entries] of files) {
38
+ out.push(`${file} (${entries.filter((e) => !e.startsWith(" ")).length})`);
39
+ out.push(...entries.slice(0, limits.perGroup * 2));
40
+ if (entries.length > limits.perGroup * 2) {
41
+ out.push(` … [+${entries.length - limits.perGroup * 2} more lines]`);
42
+ cut = true;
43
+ }
44
+ }
45
+ const footer = tail.filter((l) => l !== "");
46
+ if (footer.length) out.push("", ...footer);
47
+ const capped = capLines(out, limits.total);
48
+ return { text: capped.text, truncated: cut || capped.truncated };
49
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * pi-squeeze: Squeezes tool output so the agent's context stays small.
3
+ *
4
+ * Pure logic lives in core/ and command/ (tested directly); this file only wires it to pi.
5
+ * Output is only ever replaced when the result is clearly smaller, and a truncated result
6
+ * says where the full output was saved. Settings persist in <agent dir>/squeeze.json.
7
+ */
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { completions, parseCommand } from "./command/parse.ts";
10
+ import { compact, KINDS } from "./core/compact.ts";
11
+ import { type Config, DEFAULT_CONFIG, optionsOf, readConfig, saveConfig } from "./core/config.ts";
12
+ import { rewrite } from "./core/rtk.ts";
13
+ import { fileIn } from "./core/files.ts";
14
+ import { saveFull } from "./core/save.ts";
15
+ import { emptyStats, record, saved, summary, tokens } from "./core/stats.ts";
16
+
17
+ type StatusUi = { ui: { setStatus(key: string, text: string): void } };
18
+
19
+ export const settingsText = (c: Config): string =>
20
+ [c.enabled ? "on" : "off", `rtk ${c.rtk ? "on" : "off"}`, ...KINDS.map((k) => `${k} ${c.kinds[k] ? "on" : "off"}`), `min ${c.minChars} chars`].join(" · ");
21
+
22
+ export default function (pi: ExtensionAPI) {
23
+ let config: Config = { ...DEFAULT_CONFIG, kinds: { ...DEFAULT_CONFIG.kinds } };
24
+ const stats = emptyStats();
25
+ if (process.env.PI_SQUEEZE === "off") config.enabled = false;
26
+
27
+ const showStatus = (ctx: StatusUi) => {
28
+ const n = tokens(saved(stats));
29
+ ctx.ui.setStatus("squeeze", config.enabled && config.showStatus && n > 0 ? `squeeze: ~${n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n} tokens saved` : "");
30
+ };
31
+
32
+ pi.on("session_start", async (_event, ctx) => {
33
+ const { config: loaded, problem } = await readConfig();
34
+ config = loaded;
35
+ if (process.env.PI_SQUEEZE === "off") config.enabled = false;
36
+ if (problem) ctx.ui.notify(`squeeze: ${problem}; using the defaults`, "warning");
37
+ showStatus(ctx);
38
+ });
39
+
40
+ pi.on("tool_call", async (event) => {
41
+ if (!config.enabled || !config.rtk || event.toolName !== "bash") return;
42
+ const input = event.input as { command?: unknown };
43
+ if (typeof input.command !== "string") return;
44
+ const rewritten = rewrite(input.command);
45
+ if (rewritten) input.command = rewritten;
46
+ });
47
+
48
+ pi.on("tool_result", async (event, ctx) => {
49
+ if (!config.enabled || event.toolName !== "bash") return;
50
+ const command = (event.input as { command?: unknown }).command;
51
+ if (typeof command !== "string") return;
52
+ // pi already truncated this one: its text is not the tool's own format any more.
53
+ if ((event.details as { truncation?: unknown } | undefined)?.truncation) return;
54
+ const blocks = event.content;
55
+ if (blocks.length !== 1 || blocks[0]!.type !== "text") return;
56
+ const original = blocks[0]!.text;
57
+
58
+ const r = compact(command, original, { ...optionsOf(config), failed: event.isError, isFile: fileIn(ctx.cwd) });
59
+ if (!r) return;
60
+ record(stats, r);
61
+ showStatus(ctx);
62
+ const text = r.truncated ? `${r.text}\n[squeeze: output shortened; full output: ${saveFull(original)}]` : r.text;
63
+ return { content: [{ type: "text", text }] };
64
+ });
65
+
66
+ pi.registerCommand("squeeze", {
67
+ description: "pi-squeeze: status, on, off, <compactor>|rtk on|off, min <chars>",
68
+ getArgumentCompletions: (prefix: string) => completions(prefix),
69
+ handler: async (args, ctx) => {
70
+ const cmd = parseCommand(args);
71
+ // The setting applies to this session either way; a failed save only means it is not remembered.
72
+ const persist = async () => {
73
+ try {
74
+ await saveConfig(config);
75
+ } catch (error) {
76
+ ctx.ui.notify(`squeeze: could not save the setting (${(error as Error)?.message ?? String(error)}); it applies to this session only`, "warning");
77
+ }
78
+ showStatus(ctx);
79
+ };
80
+ switch (cmd.kind) {
81
+ case "status":
82
+ ctx.ui.notify(`squeeze ${settingsText(config)}\n${summary(stats)}`, "info");
83
+ return;
84
+ case "on":
85
+ case "off":
86
+ config.enabled = cmd.kind === "on";
87
+ await persist();
88
+ ctx.ui.notify(`squeeze ${cmd.kind}`, "info");
89
+ return;
90
+ case "rtk":
91
+ config.rtk = cmd.on;
92
+ await persist();
93
+ ctx.ui.notify(`squeeze: rtk ${cmd.on ? "on" : "off"}`, "info");
94
+ return;
95
+ case "compactor":
96
+ config.kinds[cmd.compactor] = cmd.on;
97
+ await persist();
98
+ ctx.ui.notify(`squeeze: ${cmd.compactor} ${cmd.on ? "on" : "off"}`, "info");
99
+ return;
100
+ case "min":
101
+ config.minChars = cmd.chars;
102
+ await persist();
103
+ ctx.ui.notify(`squeeze: output under ${cmd.chars} characters is left alone`, "info");
104
+ return;
105
+ case "invalid":
106
+ ctx.ui.notify(cmd.message, "warning");
107
+ return;
108
+ }
109
+ },
110
+ });
111
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "pi-squeeze",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "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.",
5
+ "keywords": ["pi-package", "pi-extension", "pi", "tokens", "context", "compaction", "rtk"],
6
+ "license": "MIT",
7
+ "author": "Sorin Albu-Irimies",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/sorinirimies/pi-squeeze.git"
11
+ },
12
+ "homepage": "https://github.com/sorinirimies/pi-squeeze#readme",
13
+ "files": ["extensions", "README.md", "LICENSE"],
14
+ "pi": {
15
+ "extensions": ["./extensions/squeeze.ts"]
16
+ },
17
+ "scripts": {
18
+ "test": "bun test",
19
+ "typecheck": "tsc --noEmit",
20
+ "check": "nu scripts/ci/quality_gate.nu"
21
+ },
22
+ "peerDependencies": {
23
+ "@earendil-works/pi-coding-agent": "*"
24
+ },
25
+ "overrides": {
26
+ "undici": "8.12.3",
27
+ "minimatch": "11.0.3"
28
+ },
29
+ "devDependencies": {
30
+ "@earendil-works/pi-coding-agent": "^1.4.0",
31
+ "@types/bun": "^1.4.4",
32
+ "typescript": "^7.0.2"
33
+ }
34
+ }