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.
- package/LICENSE +21 -0
- package/README.md +96 -2
- package/extensions/command/parse.ts +82 -0
- package/extensions/core/audit.ts +53 -0
- package/extensions/core/blame.ts +32 -0
- package/extensions/core/command.ts +86 -0
- package/extensions/core/compact.ts +207 -0
- package/extensions/core/config.ts +109 -0
- package/extensions/core/diff.ts +93 -0
- package/extensions/core/dust.ts +24 -0
- package/extensions/core/elide.ts +31 -0
- package/extensions/core/files.ts +15 -0
- package/extensions/core/generic.ts +37 -0
- package/extensions/core/gitlog.ts +75 -0
- package/extensions/core/gitstatus.ts +52 -0
- package/extensions/core/gradle.ts +73 -0
- package/extensions/core/lsd.ts +64 -0
- package/extensions/core/paths.ts +50 -0
- package/extensions/core/rg.ts +126 -0
- package/extensions/core/rtk.ts +50 -0
- package/extensions/core/runners.ts +50 -0
- package/extensions/core/rustc.ts +99 -0
- package/extensions/core/save.ts +11 -0
- package/extensions/core/stats.ts +31 -0
- package/extensions/core/tests.ts +46 -0
- package/extensions/core/tree.ts +44 -0
- package/extensions/core/tsc.ts +49 -0
- package/extensions/squeeze.ts +111 -0
- package/package.json +32 -4
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The persisted settings: <agent dir>/squeeze.json.
|
|
3
|
+
*
|
|
4
|
+
* A hand-edited file can hold anything, so `sanitizeConfig` keeps valid values and defaults the
|
|
5
|
+
* rest. A missing file is not a problem (defaults); a broken one is reported, never thrown.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { KINDS, type Kind } from "./compact.ts";
|
|
11
|
+
|
|
12
|
+
export interface Config {
|
|
13
|
+
/** Master switch: when false nothing is compacted. */
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
/** Show the savings in the status line. */
|
|
16
|
+
showStatus: boolean;
|
|
17
|
+
/** Which compactors run. */
|
|
18
|
+
kinds: Record<Kind, boolean>;
|
|
19
|
+
/** Rewrite commands to their `rtk` form (git, cargo, docker, …) when the rtk binary is installed. */
|
|
20
|
+
rtk: boolean;
|
|
21
|
+
/** Output shorter than this many characters is never touched. */
|
|
22
|
+
minChars: number;
|
|
23
|
+
/** Longest line kept whole in search results. */
|
|
24
|
+
lineChars: number;
|
|
25
|
+
/** Match lines kept per file. */
|
|
26
|
+
perGroup: number;
|
|
27
|
+
/** Lines in a whole compacted result. */
|
|
28
|
+
total: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_CONFIG: Config = {
|
|
32
|
+
enabled: true,
|
|
33
|
+
showStatus: true,
|
|
34
|
+
rtk: true,
|
|
35
|
+
kinds: Object.fromEntries(KINDS.map((k) => [k, true])) as Record<Kind, boolean>,
|
|
36
|
+
minChars: 400,
|
|
37
|
+
lineChars: 240,
|
|
38
|
+
perGroup: 15,
|
|
39
|
+
total: 250,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const clampInt = (v: unknown, min: number, max: number, fallback: number): number =>
|
|
43
|
+
typeof v === "number" && Number.isFinite(v) ? Math.min(max, Math.max(min, Math.round(v))) : fallback;
|
|
44
|
+
|
|
45
|
+
const bool = (v: unknown, fallback: boolean): boolean => (typeof v === "boolean" ? v : fallback);
|
|
46
|
+
|
|
47
|
+
export function sanitizeConfig(raw: unknown): Config {
|
|
48
|
+
const r = (typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {}) as Record<string, unknown>;
|
|
49
|
+
const k = (typeof r.kinds === "object" && r.kinds !== null ? r.kinds : {}) as Record<string, unknown>;
|
|
50
|
+
const d = DEFAULT_CONFIG;
|
|
51
|
+
return {
|
|
52
|
+
enabled: bool(r.enabled, d.enabled),
|
|
53
|
+
showStatus: bool(r.showStatus, d.showStatus),
|
|
54
|
+
rtk: bool(r.rtk, d.rtk),
|
|
55
|
+
kinds: Object.fromEntries(KINDS.map((kind) => [kind, bool(k[kind], d.kinds[kind])])) as Record<Kind, boolean>,
|
|
56
|
+
minChars: clampInt(r.minChars, 100, 1_000_000, d.minChars),
|
|
57
|
+
lineChars: clampInt(r.lineChars, 40, 4000, d.lineChars),
|
|
58
|
+
perGroup: clampInt(r.perGroup, 1, 500, d.perGroup),
|
|
59
|
+
total: clampInt(r.total, 20, 5000, d.total),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Where pi keeps its config: PI_CODING_AGENT_DIR, then $XDG_CONFIG_HOME/pi/agent, then ~/.pi/agent. */
|
|
64
|
+
export function resolveAgentDir(env: Record<string, string | undefined>, home: string): string {
|
|
65
|
+
if (env.PI_CODING_AGENT_DIR) return env.PI_CODING_AGENT_DIR;
|
|
66
|
+
if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "pi", "agent");
|
|
67
|
+
return join(home, ".pi", "agent");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const configPath = (env: Record<string, string | undefined> = process.env, home = homedir()): string =>
|
|
71
|
+
join(resolveAgentDir(env, home), "squeeze.json");
|
|
72
|
+
|
|
73
|
+
/** The config, plus a `problem` when the file exists but cannot be used. */
|
|
74
|
+
export async function readConfig(path = configPath()): Promise<{ config: Config; problem?: string }> {
|
|
75
|
+
let raw: string;
|
|
76
|
+
try {
|
|
77
|
+
raw = await readFile(path, "utf8");
|
|
78
|
+
} catch (e) {
|
|
79
|
+
const code = (e as { code?: string } | null)?.code;
|
|
80
|
+
return code === "ENOENT" ? { config: sanitizeConfig({}) } : { config: sanitizeConfig({}), problem: `cannot read ${path} (${code ?? "error"})` };
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return { config: sanitizeConfig(JSON.parse(raw)) };
|
|
84
|
+
} catch {
|
|
85
|
+
return { config: sanitizeConfig({}), problem: `${path} is not valid JSON` };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function loadConfig(path = configPath()): Promise<Config> {
|
|
90
|
+
return (await readConfig(path)).config;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Atomic: written to a temp file and renamed, so a crash or a second pi session never leaves half a file. */
|
|
94
|
+
export async function saveConfig(config: Config, path = configPath()): Promise<void> {
|
|
95
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
96
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
97
|
+
try {
|
|
98
|
+
await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
99
|
+
await rename(tmp, path);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
await Promise.allSettled([rm(tmp, { force: true })]); // the original error matters more
|
|
102
|
+
throw e;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The compaction options this config stands for. */
|
|
107
|
+
export function optionsOf(c: Config) {
|
|
108
|
+
return { limits: { lineChars: c.lineChars, perGroup: c.perGroup, total: c.total }, minChars: c.minChars, kinds: c.kinds };
|
|
109
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `git diff` output: the file headers shrink to one line each (`path (new, +12 −0)`), the
|
|
3
|
+
* `index` / mode / `---` / `+++` lines go, and every hunk keeps all of its changed lines but
|
|
4
|
+
* only one line of context on each side of a change. Long files are cut with a marker.
|
|
5
|
+
*
|
|
6
|
+
* The parse is exact: hunk lengths come from the `@@` headers, so any line that does not fit
|
|
7
|
+
* the unified-diff grammar makes the whole output pass through untouched.
|
|
8
|
+
*/
|
|
9
|
+
import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
10
|
+
|
|
11
|
+
/** Lines kept per file: a new file is mostly boilerplate to the reader, a change is not. */
|
|
12
|
+
const NEW_FILE_LINES = 40;
|
|
13
|
+
const FILE_LINES = 120;
|
|
14
|
+
|
|
15
|
+
const HEADER = /^(index |new file mode |deleted file mode |old mode |new mode |similarity index |dissimilarity index |rename from |rename to |copy from |copy to )/;
|
|
16
|
+
const HUNK = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/;
|
|
17
|
+
|
|
18
|
+
type File = { path: string; status: string; added: number; removed: number; body: string[]; binary: boolean };
|
|
19
|
+
|
|
20
|
+
function parseFile(lines: string[], start: number): { file: File; next: number } | undefined {
|
|
21
|
+
const m = /^diff --git a\/(.+) b\/(.+)$/.exec(lines[start]!);
|
|
22
|
+
if (!m) return undefined;
|
|
23
|
+
const file: File = { path: m[2]!, status: "", added: 0, removed: 0, body: [], binary: false };
|
|
24
|
+
let i = start + 1;
|
|
25
|
+
for (; i < lines.length; i++) {
|
|
26
|
+
const l = lines[i]!;
|
|
27
|
+
if (l.startsWith("diff --git ") || l.startsWith("@@")) break;
|
|
28
|
+
if (l.startsWith("--- ") || l.startsWith("+++ ")) continue;
|
|
29
|
+
if (l.startsWith("new file mode")) file.status = "new";
|
|
30
|
+
else if (l.startsWith("deleted file mode")) file.status = "deleted";
|
|
31
|
+
else if (l.startsWith("rename to ")) file.status = `renamed from ${m[1]}`;
|
|
32
|
+
else if (/^Binary files .* differ$/.test(l)) file.binary = true;
|
|
33
|
+
else if (!HEADER.test(l)) return undefined;
|
|
34
|
+
}
|
|
35
|
+
while (i < lines.length && lines[i]!.startsWith("@@")) {
|
|
36
|
+
const h = HUNK.exec(lines[i]!);
|
|
37
|
+
if (!h) return undefined;
|
|
38
|
+
let old = h[1] === undefined ? 1 : Number(h[1]);
|
|
39
|
+
let neu = h[2] === undefined ? 1 : Number(h[2]);
|
|
40
|
+
const hunk = [lines[i]!];
|
|
41
|
+
i++;
|
|
42
|
+
while (old > 0 || neu > 0 || lines[i]?.startsWith("\\")) {
|
|
43
|
+
const l = lines[i];
|
|
44
|
+
if (l === undefined) return undefined;
|
|
45
|
+
if (l.startsWith("\\")) hunk.push(l);
|
|
46
|
+
else if (l.startsWith("+")) (neu--, file.added++, hunk.push(l));
|
|
47
|
+
else if (l.startsWith("-")) (old--, file.removed++, hunk.push(l));
|
|
48
|
+
else if (l.startsWith(" ") || l === "") (old--, neu--, hunk.push(l));
|
|
49
|
+
else return undefined;
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
file.body.push(...trimContext(hunk));
|
|
53
|
+
}
|
|
54
|
+
return { file, next: i };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One context line on each side of a change; the `@@` header stays. */
|
|
58
|
+
function trimContext(hunk: string[]): string[] {
|
|
59
|
+
const isChange = (l: string) => l.startsWith("+") || l.startsWith("-") || l.startsWith("\\");
|
|
60
|
+
const keep = hunk.map((l, i) => i === 0 || isChange(l) || (i > 1 && isChange(hunk[i - 1]!)) || (i + 1 < hunk.length && isChange(hunk[i + 1]!)));
|
|
61
|
+
return hunk.filter((_, i) => keep[i]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function compactDiff(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
65
|
+
const lines = output.split("\n");
|
|
66
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
67
|
+
if (!lines[0]?.startsWith("diff --git ")) return undefined;
|
|
68
|
+
|
|
69
|
+
const files: File[] = [];
|
|
70
|
+
for (let i = 0; i < lines.length; ) {
|
|
71
|
+
const r = parseFile(lines, i);
|
|
72
|
+
if (!r) return undefined;
|
|
73
|
+
files.push(r.file);
|
|
74
|
+
i = r.next;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const added = files.reduce((n, f) => n + f.added, 0);
|
|
78
|
+
const removed = files.reduce((n, f) => n + f.removed, 0);
|
|
79
|
+
const out = [`${files.length} file${files.length === 1 ? "" : "s"} changed, +${added} −${removed} (context trimmed to 1 line)`];
|
|
80
|
+
let cut = false;
|
|
81
|
+
for (const f of files) {
|
|
82
|
+
const tag = f.binary ? "binary" : f.status;
|
|
83
|
+
out.push(`${f.path} (${tag ? `${tag}, ` : ""}+${f.added} −${f.removed})`);
|
|
84
|
+
const max = f.status === "new" ? NEW_FILE_LINES : FILE_LINES;
|
|
85
|
+
for (const l of f.body.slice(0, max)) out.push(cutLine(l, limits.lineChars));
|
|
86
|
+
if (f.body.length > max) {
|
|
87
|
+
out.push(`… [+${f.body.length - max} more lines in ${f.path}]`);
|
|
88
|
+
cut = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const capped = capLines(out, limits.total * 2);
|
|
92
|
+
return { text: capped.text, truncated: cut || capped.truncated };
|
|
93
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dust` without the bar chart: the size, the tree and the share stay, the 35 columns of
|
|
3
|
+
* `█░` bars go.
|
|
4
|
+
*
|
|
5
|
+
* 26Mi ┌── lib 14%
|
|
6
|
+
*
|
|
7
|
+
* Strict: a line without the bar column makes it return undefined.
|
|
8
|
+
*/
|
|
9
|
+
import { capLines, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
10
|
+
|
|
11
|
+
const ROW = /^(\s*\S+\s+.*?)\s*│[█▓▒░ ]+│\s*(\d+%)\s*$/;
|
|
12
|
+
|
|
13
|
+
export function compactDust(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
14
|
+
const lines = output.split("\n").map((l) => l.replace(/^\r\s*\r/, "").replace(/\r$/, ""));
|
|
15
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
16
|
+
if (lines.length < 3) return undefined;
|
|
17
|
+
const out: string[] = [];
|
|
18
|
+
for (const l of lines) {
|
|
19
|
+
const m = ROW.exec(l);
|
|
20
|
+
if (!m) return undefined;
|
|
21
|
+
out.push(`${m[1]} ${m[2]}`);
|
|
22
|
+
}
|
|
23
|
+
return capLines(out, limits.total);
|
|
24
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Shared limits and truncation helpers: every cut leaves a visible marker behind. */
|
|
2
|
+
|
|
3
|
+
export type Limits = {
|
|
4
|
+
/** Longest line kept whole; longer ones are cut with a note. */
|
|
5
|
+
lineChars: number;
|
|
6
|
+
/** Lines kept per file or group. */
|
|
7
|
+
perGroup: number;
|
|
8
|
+
/** Lines in the whole result. */
|
|
9
|
+
total: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_LIMITS: Limits = { lineChars: 240, perGroup: 15, total: 250 };
|
|
13
|
+
|
|
14
|
+
export type Squeezed = {
|
|
15
|
+
text: string;
|
|
16
|
+
/** True when content was left out (not merely reformatted): the full output is worth saving. */
|
|
17
|
+
truncated: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function cutLine(line: string, max: number): string {
|
|
21
|
+
if (line.length <= max) return line;
|
|
22
|
+
return `${line.slice(0, max)}… [+${line.length - max} chars]`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Caps `lines` at `total`, replacing the rest with a one-line marker. */
|
|
26
|
+
export function capLines(lines: string[], total: number): Squeezed {
|
|
27
|
+
if (lines.length <= total) return { text: lines.join("\n"), truncated: false };
|
|
28
|
+
const kept = lines.slice(0, total);
|
|
29
|
+
kept.push(`… [+${lines.length - total} more lines]`);
|
|
30
|
+
return { text: kept.join("\n"), truncated: true };
|
|
31
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** File checks for the compactors that need to know what is on disk. */
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** A check whether `path`, relative to `cwd`, is an existing file. Without a cwd nothing is. */
|
|
6
|
+
export function fileIn(cwd: string | undefined): (path: string) => boolean {
|
|
7
|
+
if (!cwd) return () => false;
|
|
8
|
+
return (path) => {
|
|
9
|
+
try {
|
|
10
|
+
return statSync(resolve(cwd, path)).isFile();
|
|
11
|
+
} catch {
|
|
12
|
+
return false; // missing, not a directory on the way, a NUL byte, no permission
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clean-up for any command output, without leaving anything out that a reader would miss:
|
|
3
|
+
* ANSI colour codes go, progress bars redrawn with `\r` keep their last frame, a line
|
|
4
|
+
* repeated 4+ times in a row becomes `line [×N]`, and 3+ blank lines become one.
|
|
5
|
+
*
|
|
6
|
+
* Never used on commands that print file content (cat, head, jq, curl…): there, every
|
|
7
|
+
* byte is the point.
|
|
8
|
+
*/
|
|
9
|
+
import type { Limits, Squeezed } from "./elide.ts";
|
|
10
|
+
|
|
11
|
+
// CSI sequences (colours, cursor moves) and OSC sequences (titles, hyperlinks).
|
|
12
|
+
const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
|
|
13
|
+
|
|
14
|
+
/** Commands whose output is content, not a report. Checked anywhere in the command line. */
|
|
15
|
+
const CONTENT = /(?:^|[\s|;&(])(cat|head|tail|sed|awk|jq|yq|bat|less|more|curl|wget|base64|xxd|od|hexdump|tee|printf|echo|diff|cmp|sort|uniq|cut|tr|wc)(?=\s|$)/;
|
|
16
|
+
|
|
17
|
+
export const isContent = (command: string): boolean => CONTENT.test(command);
|
|
18
|
+
|
|
19
|
+
export function compactGeneric(output: string, _limits?: Limits): Squeezed | undefined {
|
|
20
|
+
const lines = output.replace(ANSI, "").split("\n").map((l) => {
|
|
21
|
+
const frame = l.replace(/\r+$/, "");
|
|
22
|
+
return (frame.includes("\r") ? frame.slice(frame.lastIndexOf("\r") + 1) : frame).trimEnd();
|
|
23
|
+
});
|
|
24
|
+
const out: string[] = [];
|
|
25
|
+
for (let i = 0; i < lines.length; ) {
|
|
26
|
+
let j = i;
|
|
27
|
+
while (j < lines.length && lines[j] === lines[i]) j++;
|
|
28
|
+
const run = j - i;
|
|
29
|
+
if (lines[i] === "") {
|
|
30
|
+
const keep = run >= 3 ? 1 : run;
|
|
31
|
+
for (let k = 0; k < keep; k++) out.push("");
|
|
32
|
+
} else if (run >= 4) out.push(`${lines[i]} [×${run}]`);
|
|
33
|
+
else for (let k = 0; k < run; k++) out.push(lines[i]!);
|
|
34
|
+
i = j;
|
|
35
|
+
}
|
|
36
|
+
return { text: out.join("\n"), truncated: false };
|
|
37
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `git log` and `git show` in their default format, one line per commit:
|
|
3
|
+
*
|
|
4
|
+
* 0a845ae 2026-11-01 Sorin Albu-Irimies docs(readme): add the badges
|
|
5
|
+
*
|
|
6
|
+
* The message body stays (indented, at most 8 lines per commit). Strict: anything that is
|
|
7
|
+
* not the default format (`--stat`, `-p` and `--oneline` are handled elsewhere or skipped)
|
|
8
|
+
* returns undefined.
|
|
9
|
+
*/
|
|
10
|
+
import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
11
|
+
import { compactDiff } from "./diff.ts";
|
|
12
|
+
|
|
13
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
14
|
+
const BODY_LINES = 8;
|
|
15
|
+
|
|
16
|
+
type Commit = { hash: string; author: string; date: string; message: string[] };
|
|
17
|
+
|
|
18
|
+
/** Parses the commit blocks in `lines`; returns the commits and the index of the first line after them. */
|
|
19
|
+
function parseCommits(lines: string[]): { commits: Commit[]; next: number } | undefined {
|
|
20
|
+
const commits: Commit[] = [];
|
|
21
|
+
let i = 0;
|
|
22
|
+
while (i < lines.length) {
|
|
23
|
+
const m = /^commit ([0-9a-f]{7,64})(?: \(.*\))?$/.exec(lines[i]!);
|
|
24
|
+
if (!m) break;
|
|
25
|
+
i++;
|
|
26
|
+
if (lines[i]?.startsWith("Merge: ")) i++;
|
|
27
|
+
const a = /^Author: (.*?)(?: <[^>]*>)?$/.exec(lines[i++] ?? "");
|
|
28
|
+
const d = /^Date: +\w{3} (\w{3}) +(\d+) [\d:]+ (\d{4}) [+-]\d{4}$/.exec(lines[i++] ?? "");
|
|
29
|
+
if (!a || !d || lines[i++] !== "") return undefined;
|
|
30
|
+
const month = MONTHS.indexOf(d[1]!) + 1;
|
|
31
|
+
if (!month) return undefined;
|
|
32
|
+
const message: string[] = [];
|
|
33
|
+
for (; i < lines.length; i++) {
|
|
34
|
+
if (lines[i]!.startsWith(" ")) message.push(lines[i]!.slice(4));
|
|
35
|
+
else if (lines[i] === "" && lines.slice(i).find((l) => l !== "")?.startsWith(" ")) message.push("");
|
|
36
|
+
else break;
|
|
37
|
+
}
|
|
38
|
+
if (lines[i] === "") i++;
|
|
39
|
+
commits.push({ hash: m[1]!.slice(0, 7), author: a[1]!, date: `${d[3]}-${String(month).padStart(2, "0")}-${d[2]!.padStart(2, "0")}`, message });
|
|
40
|
+
}
|
|
41
|
+
return commits.length ? { commits, next: i } : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function render(commits: Commit[], limits: Limits): string[] {
|
|
45
|
+
// One author for the whole list: say so once instead of on every line.
|
|
46
|
+
const solo = commits.length > 1 && commits.every((c) => c.author === commits[0]!.author);
|
|
47
|
+
const out: string[] = solo ? [`by ${commits[0]!.author}`] : [];
|
|
48
|
+
for (const c of commits) {
|
|
49
|
+
const [subject = "", ...rest] = c.message;
|
|
50
|
+
out.push(cutLine(`${c.hash} ${c.date}${solo ? "" : ` ${c.author}`} ${subject}`, limits.lineChars));
|
|
51
|
+
const body = rest.filter((l, i) => !(l === "" && (i === 0 || rest[i - 1] === "")));
|
|
52
|
+
for (const l of body.slice(0, BODY_LINES)) out.push(l ? cutLine(` ${l}`, limits.lineChars) : "");
|
|
53
|
+
if (body.length > BODY_LINES) out.push(` … [+${body.length - BODY_LINES} more lines]`);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function compactLog(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
59
|
+
const lines = output.split("\n");
|
|
60
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
61
|
+
const parsed = parseCommits(lines);
|
|
62
|
+
if (!parsed || parsed.next !== lines.length) return undefined;
|
|
63
|
+
return capLines(render(parsed.commits, limits), limits.total);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `git show` of a commit: the commit line above, then the (compacted) diff. */
|
|
67
|
+
export function compactShow(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
68
|
+
const lines = output.split("\n");
|
|
69
|
+
const at = lines.findIndex((l) => l.startsWith("diff --git "));
|
|
70
|
+
if (at < 0) return compactLog(output, limits);
|
|
71
|
+
const parsed = parseCommits(lines.slice(0, at));
|
|
72
|
+
const diff = compactDiff(lines.slice(at).join("\n"), limits);
|
|
73
|
+
if (!parsed || parsed.next !== at || !diff) return undefined;
|
|
74
|
+
return { text: `${render(parsed.commits, limits).join("\n")}\n${diff.text}`, truncated: diff.truncated };
|
|
75
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `git status` (long form) without the hints:
|
|
3
|
+
*
|
|
4
|
+
* ## main...origin/main [ahead 2]
|
|
5
|
+
* staged:
|
|
6
|
+
* M src/a.ts
|
|
7
|
+
* untracked:
|
|
8
|
+
* notes.txt
|
|
9
|
+
*
|
|
10
|
+
* Strict: a line it does not know makes it return undefined.
|
|
11
|
+
*/
|
|
12
|
+
import { capLines, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
13
|
+
|
|
14
|
+
const SECTIONS: Record<string, string> = {
|
|
15
|
+
"Changes to be committed:": "staged",
|
|
16
|
+
"Changes not staged for commit:": "unstaged",
|
|
17
|
+
"Untracked files:": "untracked",
|
|
18
|
+
"Unmerged paths:": "unmerged",
|
|
19
|
+
"Ignored files:": "ignored",
|
|
20
|
+
};
|
|
21
|
+
const CODES: Record<string, string> = { modified: "M", "new file": "A", deleted: "D", renamed: "R", copied: "C", "both modified": "UU", "both added": "AA", "deleted by us": "DU", "deleted by them": "UD", "added by us": "AU", "added by them": "UA", "both deleted": "DD", "typechange": "T" };
|
|
22
|
+
const FOOTER = /^(no changes added to commit|nothing added to commit|nothing to commit|nothing to commit, working tree clean)/;
|
|
23
|
+
|
|
24
|
+
export function compactStatus(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
25
|
+
const lines = output.split("\n");
|
|
26
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
27
|
+
let head = "";
|
|
28
|
+
let track = "";
|
|
29
|
+
const out: string[] = [];
|
|
30
|
+
let section = "";
|
|
31
|
+
for (const l of lines) {
|
|
32
|
+
let m: RegExpExecArray | null;
|
|
33
|
+
if ((m = /^On branch (.+)$/.exec(l))) head = m[1]!;
|
|
34
|
+
else if ((m = /^HEAD detached (?:at|from) (.+)$/.exec(l))) head = `(detached ${m[1]})`;
|
|
35
|
+
else if ((m = /^Your branch is up to date with '(.+)'\.$/.exec(l))) track = `...${m[1]}`;
|
|
36
|
+
else if ((m = /^Your branch is (ahead of|behind) '(.+)' by (\d+) commits?(?:, and can be fast-forwarded)?\.$/.exec(l))) track = `...${m[2]} [${m[1]!.split(" ")[0]} ${m[3]}]`;
|
|
37
|
+
else if ((m = /^Your branch and '(.+)' have diverged,$/.exec(l))) track = `...${m[1]} [diverged]`;
|
|
38
|
+
else if ((m = /^and have (\d+) and (\d+) different commits each, respectively\.$/.exec(l))) track = track.replace("[diverged]", `[ahead ${m[1]}, behind ${m[2]}]`);
|
|
39
|
+
else if (SECTIONS[l]) {
|
|
40
|
+
section = SECTIONS[l]!;
|
|
41
|
+
out.push(`${section}:`);
|
|
42
|
+
} else if (/^\s+\(use "git .*\)$/.test(l) || l === "" || FOOTER.test(l) || /^\s*\(all conflicts fixed.*\)$/.test(l)) continue;
|
|
43
|
+
else if ((m = /^\t([a-z ]+?):\s+(.+)$/.exec(l)) && CODES[m[1]!]) out.push(` ${CODES[m[1]!]} ${m[2]}`);
|
|
44
|
+
else if (section === "untracked" || section === "ignored") {
|
|
45
|
+
if (!l.startsWith("\t")) return undefined;
|
|
46
|
+
out.push(` ${l.slice(1)}`);
|
|
47
|
+
} else return undefined;
|
|
48
|
+
}
|
|
49
|
+
if (!head) return undefined;
|
|
50
|
+
const clean = out.length === 0 ? [" clean"] : [];
|
|
51
|
+
return capLines([`## ${head}${track}${clean[0] ?? ""}`, ...out], limits.total);
|
|
52
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gradle` / `./gradlew` output: every `> Task :x UP-TO-DATE` line (and NO-SOURCE, FROM-CACHE,
|
|
3
|
+
* SKIPPED, plain executed ones) becomes one counting line; failed tasks, compiler errors and
|
|
4
|
+
* warnings, test failures and the `BUILD` result stay. The daemon, configuration-cache and
|
|
5
|
+
* "Run with --scan" hints go. Kotlin `w:` / `e:` lines lose their shared directory prefix.
|
|
6
|
+
*/
|
|
7
|
+
import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
8
|
+
|
|
9
|
+
const TASK = /^> Task (\S+)(?: ([A-Z-]+))?$/;
|
|
10
|
+
const NOISE = [
|
|
11
|
+
/^Starting a Gradle Daemon/,
|
|
12
|
+
/^Daemon will be stopped/,
|
|
13
|
+
/^Calculating task graph/,
|
|
14
|
+
/^(Reusing|Configuration) cache/,
|
|
15
|
+
/^Configuration cache entry/,
|
|
16
|
+
/^Isolated Projects is an incubating/,
|
|
17
|
+
/^Consider enabling configuration cache/,
|
|
18
|
+
/^> Run with --(stacktrace|info|debug|scan)/,
|
|
19
|
+
/^> Get more help at/,
|
|
20
|
+
/^> Configure project /,
|
|
21
|
+
/^Deprecated Gradle features were used/,
|
|
22
|
+
/^You can use '--warning-mode all'/,
|
|
23
|
+
/^For more on this, please refer to/,
|
|
24
|
+
];
|
|
25
|
+
const KOTLIN = /^([we]): (?:file:\/\/)?(\/\S+?):(\d+):(\d+)(.*)$/;
|
|
26
|
+
|
|
27
|
+
export function compactGradle(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
28
|
+
const lines = output.split("\n");
|
|
29
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
30
|
+
if (!lines.some((l) => TASK.test(l)) || !lines.some((l) => /^BUILD (SUCCESSFUL|FAILED)/.test(l))) return undefined;
|
|
31
|
+
|
|
32
|
+
const states = new Map<string, number>();
|
|
33
|
+
const ran: string[] = [];
|
|
34
|
+
const kept: string[] = [];
|
|
35
|
+
let tasks = 0;
|
|
36
|
+
for (const l of lines) {
|
|
37
|
+
const t = TASK.exec(l);
|
|
38
|
+
if (t) {
|
|
39
|
+
tasks++;
|
|
40
|
+
if (t[2] === "FAILED") kept.push(l);
|
|
41
|
+
else if (t[2]) states.set(t[2], (states.get(t[2]) ?? 0) + 1);
|
|
42
|
+
else (states.set("executed", (states.get("executed") ?? 0) + 1), ran.push(t[1]!));
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (NOISE.some((n) => n.test(l))) continue;
|
|
46
|
+
kept.push(l);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Kotlin diagnostics: strip the directory every file shares.
|
|
50
|
+
const paths = kept.map((l) => KOTLIN.exec(l)?.[2]).filter((p): p is string => !!p);
|
|
51
|
+
if (paths.length > 1) {
|
|
52
|
+
const dirs = paths.map((p) => p.slice(0, p.lastIndexOf("/") + 1));
|
|
53
|
+
let prefix = dirs[0]!;
|
|
54
|
+
for (const d of dirs) while (!d.startsWith(prefix)) prefix = prefix.slice(0, prefix.lastIndexOf("/", prefix.length - 2) + 1);
|
|
55
|
+
if (prefix.length > 1)
|
|
56
|
+
for (let i = 0; i < kept.length; i++) {
|
|
57
|
+
const m = KOTLIN.exec(kept[i]!);
|
|
58
|
+
if (m) kept[i] = `${m[1]}: …/${m[2]!.slice(prefix.length)}:${m[3]}:${m[4]}${m[5]}`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Blank lines only separate: never two in a row, never first.
|
|
63
|
+
const body: string[] = [];
|
|
64
|
+
for (const l of kept) {
|
|
65
|
+
if (l === "" && (body.length === 0 || body.at(-1) === "")) continue;
|
|
66
|
+
body.push(cutLine(l, limits.lineChars));
|
|
67
|
+
}
|
|
68
|
+
while (body.at(-1) === "") body.pop();
|
|
69
|
+
|
|
70
|
+
const parts = [...states].map(([k, v]) => `${v} ${k.toLowerCase()}`);
|
|
71
|
+
const head = `gradle: ${tasks} tasks (${parts.join(", ")})${ran.length ? ` ran: ${ran.slice(0, 12).join(" ")}${ran.length > 12 ? ` … +${ran.length - 12}` : ""}` : ""}`;
|
|
72
|
+
return capLines([head, ...body], limits.total);
|
|
73
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lsd -l`, `eza -l` and `ls -l` listings reduced to what a model uses: type, size, modified
|
|
3
|
+
* time (no year or seconds) and name. Owner, group and permission bits are dropped.
|
|
4
|
+
*
|
|
5
|
+
* d Oct 24 16:40 command
|
|
6
|
+
* - 6.0KB Oct 24 16:40 tokenburn.ts
|
|
7
|
+
*
|
|
8
|
+
* Strict: a single line that does not parse makes it return undefined.
|
|
9
|
+
*/
|
|
10
|
+
import { capLines, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
11
|
+
|
|
12
|
+
type Row = { type: string; size: string; when: string; name: string };
|
|
13
|
+
type Parser = (line: string) => Row | undefined;
|
|
14
|
+
|
|
15
|
+
const kindOf = (k: string) => (k === "d" ? "d" : k === "l" ? "l" : "-");
|
|
16
|
+
|
|
17
|
+
/** lsd: `.rw-r--r-- user group 1.9 KB Sun Nov 1 23:14:32 2026 name` */
|
|
18
|
+
const LSD = /^([dl.\-bcps])[rwxsStT\-.+@]{9}\S*\s+\S+\s+\S+\s+([\d.,]+ ?[A-Za-z]*)\s+\w{3} (\w{3})\s+(\d+) (\d\d:\d\d):\d\d \d{4}\s+(.+)$/;
|
|
19
|
+
/** eza: `.rw-r--r--@ 1.9k user [group] 1 Nov 23:14 name` (a year instead of the time when old) */
|
|
20
|
+
const EZA = /^([dl.\-bcps])[rwxsStT\-.+@]{9}\S*\s+(-|[\d.,]+[kMGTPE]?)\s+\S+(?:\s+\S+)?\s+(\d{1,2}) (\w{3})\s+(\d\d:\d\d|\d{4})\s+(.+)$/;
|
|
21
|
+
/** ls: `-rw-r--r--@ 1 user group 1934 Nov 1 23:14 name` */
|
|
22
|
+
const LS = /^([dl\-bcps])[rwxsStT\-]{9}[@+.]?\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\w{3})\s+(\d{1,2})\s+(\d\d:\d\d|\d{4})\s+(.+)$/;
|
|
23
|
+
|
|
24
|
+
/** Bytes as ls -lh would say them. */
|
|
25
|
+
export function human(bytes: number): string {
|
|
26
|
+
const units = ["B", "K", "M", "G", "T"];
|
|
27
|
+
let n = bytes;
|
|
28
|
+
let u = 0;
|
|
29
|
+
while (n >= 1024 && u < units.length - 1) (n /= 1024), u++;
|
|
30
|
+
return u === 0 ? `${n}B` : `${n < 10 ? n.toFixed(1) : Math.round(n)}${units[u]}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const PARSERS: Parser[] = [
|
|
34
|
+
(l: string) => {
|
|
35
|
+
const m = LSD.exec(l);
|
|
36
|
+
return m ? { type: kindOf(m[1]!), size: m[2]!.replace(/\s+/g, ""), when: `${m[3]} ${m[4]} ${m[5]}`, name: m[6]! } : undefined;
|
|
37
|
+
},
|
|
38
|
+
(l: string) => {
|
|
39
|
+
const m = EZA.exec(l);
|
|
40
|
+
return m ? { type: kindOf(m[1]!), size: m[2]!, when: `${m[4]} ${m[3]} ${m[5]}`, name: m[6]! } : undefined;
|
|
41
|
+
},
|
|
42
|
+
(l: string) => {
|
|
43
|
+
const m = LS.exec(l);
|
|
44
|
+
return m ? { type: kindOf(m[1]!), size: human(Number(m[2])), when: `${m[3]} ${m[4]} ${m[5]}`, name: m[6]! } : undefined;
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export function compactLsd(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
49
|
+
const lines = output.split("\n");
|
|
50
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
51
|
+
if (/^total \d+/.test(lines[0] ?? "")) lines.shift(); // ls -l
|
|
52
|
+
if (lines.length < 3) return undefined;
|
|
53
|
+
const parse = PARSERS.find((p) => p(lines[0]!));
|
|
54
|
+
if (!parse) return undefined;
|
|
55
|
+
const out: string[] = [];
|
|
56
|
+
for (const l of lines) {
|
|
57
|
+
const row = parse(l);
|
|
58
|
+
if (!row) return undefined;
|
|
59
|
+
if (row.name === "." || row.name === "..") continue;
|
|
60
|
+
const meta = row.type === "d" ? "" : `${row.size} `;
|
|
61
|
+
out.push(`${row.type} ${meta}${row.when} ${row.name}`);
|
|
62
|
+
}
|
|
63
|
+
return capLines(out, limits.total);
|
|
64
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fd` / `find` output (one path per line) grouped by directory:
|
|
3
|
+
*
|
|
4
|
+
* src/core/
|
|
5
|
+
* a.ts b.ts c.ts
|
|
6
|
+
*
|
|
7
|
+
* Names with spaces are quoted. A directory with a single entry keeps its full path.
|
|
8
|
+
*/
|
|
9
|
+
import { capLines, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
|
|
10
|
+
|
|
11
|
+
function split(path: string): [string, string] {
|
|
12
|
+
const dir = path.endsWith("/");
|
|
13
|
+
const body = dir ? path.slice(0, -1) : path;
|
|
14
|
+
const i = body.lastIndexOf("/");
|
|
15
|
+
if (i < 0) return ["", path];
|
|
16
|
+
return [body.slice(0, i + 1), body.slice(i + 1) + (dir ? "/" : "")];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function compactPaths(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
|
|
20
|
+
const lines = output.split("\n");
|
|
21
|
+
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
22
|
+
if (lines.length < 2 || lines.some((l) => l === "" || l.includes("\t"))) return undefined;
|
|
23
|
+
|
|
24
|
+
const groups = new Map<string, string[]>();
|
|
25
|
+
for (const l of lines) {
|
|
26
|
+
const [dir, name] = split(l);
|
|
27
|
+
const g = groups.get(dir);
|
|
28
|
+
if (g) g.push(name);
|
|
29
|
+
else groups.set(dir, [name]);
|
|
30
|
+
}
|
|
31
|
+
const out: string[] = [];
|
|
32
|
+
for (const [dir, names] of groups) {
|
|
33
|
+
if (names.length === 1) {
|
|
34
|
+
out.push(`${dir}${names[0]}`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
out.push(dir || "./");
|
|
38
|
+
const quoted = names.map((n) => (/\s/.test(n) ? JSON.stringify(n) : n));
|
|
39
|
+
let row = " ";
|
|
40
|
+
for (const n of quoted) {
|
|
41
|
+
if (row.length + n.length > 100 && row.trim()) {
|
|
42
|
+
out.push(row.trimEnd());
|
|
43
|
+
row = " ";
|
|
44
|
+
}
|
|
45
|
+
row += `${n} `;
|
|
46
|
+
}
|
|
47
|
+
if (row.trim()) out.push(row.trimEnd());
|
|
48
|
+
}
|
|
49
|
+
return capLines(out, limits.total);
|
|
50
|
+
}
|