opencode-slim-output 0.0.0-stage → 0.1.1
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 +88 -2
- package/package.json +34 -4
- package/plugin.ts +74 -0
- package/src/slim.ts +279 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mateo Cerri
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,89 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-slim-output
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An OpenCode plugin that shrinks noisy tool output before it reaches the
|
|
4
|
+
model: external stack frames collapse to a counted line, absolute paths
|
|
5
|
+
become relative to the working directory, repeated lines get folded. No
|
|
6
|
+
LLM, no binaries, near-zero latency.
|
|
7
|
+
|
|
8
|
+
It sits next to [snip](https://github.com/edouard-claude/snip), which
|
|
9
|
+
summarizes per tool for `bash`, and
|
|
10
|
+
[openslimedit](https://github.com/ASidorenkoCode/openslimedit), which
|
|
11
|
+
compresses tool definitions. Neither prunes stack traces across languages
|
|
12
|
+
on `bash`, `grep`, `glob` and `read`, which is the gap this one fills.
|
|
13
|
+
|
|
14
|
+
A Python traceback before (11 lines, 3 from your code):
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
Traceback (most recent call last):
|
|
18
|
+
File "/home/you/proj/app/service.py", line 42, in run
|
|
19
|
+
handle()
|
|
20
|
+
File "/home/you/proj/.venv/lib/python3.12/site-packages/requests/api.py", line 123, in handle
|
|
21
|
+
return go()
|
|
22
|
+
File "/home/you/proj/.venv/lib/python3.12/site-packages/urllib3/connection.py", line 99, in go
|
|
23
|
+
sock.connect()
|
|
24
|
+
File "/usr/lib/python3.12/socket.py", line 456, in connect
|
|
25
|
+
self._real_connect()
|
|
26
|
+
ConnectionError: boom
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
After:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
Traceback (most recent call last):
|
|
33
|
+
File "./app/service.py", line 42, in run
|
|
34
|
+
handle()
|
|
35
|
+
... +3 frames omitted (site-packages/stdlib)
|
|
36
|
+
ConnectionError: boom
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Your frames and the error survive. The stdlib plumbing does not.
|
|
40
|
+
|
|
41
|
+
Collapsing is conservative by design: a line folds only when three signals
|
|
42
|
+
agree. The command gate runs each detector where its noise is expected
|
|
43
|
+
(`pytest` and `python` for Python frames, `dotnet` for .NET, `jest` and
|
|
44
|
+
`tsc` for JS, and so on). The anchor requires a wrapper proving a real
|
|
45
|
+
stack is present (`Traceback (most recent call last):`,
|
|
46
|
+
`NullReferenceException`, `FAIL`, `panicked`, …), so prose that merely
|
|
47
|
+
mentions a file is left alone. And the frame shape must match fully (`File
|
|
48
|
+
"…", line N`, `at Ns.Type.Method(…)`, `path/to/file.go:123`, …) in a run
|
|
49
|
+
of at least `minFrames`. Anything else passes through untouched; unknown
|
|
50
|
+
frames are kept, and only known-external paths (`site-packages`,
|
|
51
|
+
`node_modules`, `System.*`, `*.dll`, `jdk.internal`, …) collapse, each
|
|
52
|
+
leaving a counted marker behind.
|
|
53
|
+
|
|
54
|
+
The hook reads the V2 result shape — a string `content` or an array of
|
|
55
|
+
text parts — and rewrites it in place, which keeps the rest of the result
|
|
56
|
+
(metadata, non-text parts) exactly as the tool returned it. Outputs under
|
|
57
|
+
40 characters skip processing entirely. Two deliberate limits remain:
|
|
58
|
+
results truncate at `maxLines` and `maxLineLen` with an overflow marker,
|
|
59
|
+
and the raw output is not kept anywhere, so rerun the command when you
|
|
60
|
+
need it all. Path shortening of the `compact_path` kind is avoided on
|
|
61
|
+
purpose — a path the model cannot open costs more than the characters it
|
|
62
|
+
saves.
|
|
63
|
+
|
|
64
|
+
```jsonc
|
|
65
|
+
// ~/.config/opencode/opencode.jsonc
|
|
66
|
+
{
|
|
67
|
+
"$schema": "https://opencode.ai/config.json",
|
|
68
|
+
"plugins": [
|
|
69
|
+
{ "package": "opencode-slim-output",
|
|
70
|
+
"options": { "maxLines": 120, "maxLineLen": 160, "maxFrames": 40 } },
|
|
71
|
+
],
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Options beyond the three above are `minFrames` (default 3), `enabled`, and
|
|
76
|
+
`tools` (default `["shell", "bash", "grep", "glob", "read"]`). Restart
|
|
77
|
+
opencode after changing them.
|
|
78
|
+
|
|
79
|
+
## Develop
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
npm install
|
|
83
|
+
bun test # 14 tests, incl. adversarial prose fixtures and hook wiring
|
|
84
|
+
bun run typecheck
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT — © 2026 [Mateo Cerri](https://github.com/mateocerri)
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-slim-output",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "OpenCode plugin: fail-closed post-processor that collapses external stack frames, relativizes paths and dedups repetitive output.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "plugin.ts",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mateocerri/opencode-slim-output.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://www.npmjs.com/package/opencode-slim-output",
|
|
12
|
+
"author": "Mateo Cerri",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"opencode",
|
|
15
|
+
"opencode-plugin",
|
|
16
|
+
"output",
|
|
17
|
+
"stack-trace",
|
|
18
|
+
"wsl"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
"plugin.ts",
|
|
22
|
+
"src",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test \"tests/*.test.ts\"",
|
|
28
|
+
"typecheck": "npx tsc --noEmit"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@opencode/plugin": "latest",
|
|
32
|
+
"@types/node": "^24.19.2",
|
|
33
|
+
"typescript": "^5.6.0"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT"
|
|
36
|
+
}
|
package/plugin.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin";
|
|
2
|
+
import { slimOutput } from "./src/slim.ts";
|
|
3
|
+
|
|
4
|
+
interface SlimPluginOptions {
|
|
5
|
+
maxLines?: number;
|
|
6
|
+
maxLineLen?: number;
|
|
7
|
+
maxFrames?: number;
|
|
8
|
+
minFrames?: number;
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
tools?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_TOOLS = ["shell", "bash", "grep", "glob", "read"];
|
|
14
|
+
|
|
15
|
+
// OpenCode V2 port of the V1 SlimOutputPlugin. Same contract: fail-closed
|
|
16
|
+
// post-processor on tool results. Everything defensive: any doubt about the
|
|
17
|
+
// event shape means passthrough, output untouched.
|
|
18
|
+
export default Plugin.define({
|
|
19
|
+
id: "slim-output",
|
|
20
|
+
async setup(ctx) {
|
|
21
|
+
const o = (ctx.options ?? {}) as SlimPluginOptions;
|
|
22
|
+
if (o.enabled === false) return;
|
|
23
|
+
const covered = new Set(o.tools ?? DEFAULT_TOOLS);
|
|
24
|
+
|
|
25
|
+
await ctx.tool.hook("execute.after", async (event) => {
|
|
26
|
+
try {
|
|
27
|
+
const e = event as unknown as Record<string, unknown>;
|
|
28
|
+
const input = (e.input ?? e.args ?? {}) as Record<string, unknown>;
|
|
29
|
+
const tool =
|
|
30
|
+
(typeof e.tool === "string" && e.tool) ||
|
|
31
|
+
(typeof input.tool === "string" && input.tool) ||
|
|
32
|
+
"";
|
|
33
|
+
if (!tool || !covered.has(tool)) return;
|
|
34
|
+
|
|
35
|
+
// V2 result shape: { output?, content?: string | parts[], ... }.
|
|
36
|
+
// Anything else means passthrough (fail-closed).
|
|
37
|
+
const result = e.result as { content?: unknown } | undefined;
|
|
38
|
+
if (!result || typeof result !== "object") return;
|
|
39
|
+
const command = (typeof input.command === "string" && input.command) || tool;
|
|
40
|
+
const cwd =
|
|
41
|
+
(typeof e.directory === "string" && e.directory) ||
|
|
42
|
+
(typeof input.cwd === "string" && input.cwd) ||
|
|
43
|
+
undefined;
|
|
44
|
+
const opts = {
|
|
45
|
+
cwd,
|
|
46
|
+
command,
|
|
47
|
+
maxLines: o.maxLines,
|
|
48
|
+
maxLineLen: o.maxLineLen,
|
|
49
|
+
maxFrames: o.maxFrames,
|
|
50
|
+
minFrames: o.minFrames,
|
|
51
|
+
};
|
|
52
|
+
if (typeof result.content === "string") {
|
|
53
|
+
if (result.content.length < 40) return; // tiny outputs: skip for speed
|
|
54
|
+
result.content = slimOutput(result.content, opts);
|
|
55
|
+
} else if (Array.isArray(result.content)) {
|
|
56
|
+
for (const part of result.content) {
|
|
57
|
+
if (
|
|
58
|
+
part &&
|
|
59
|
+
typeof part === "object" &&
|
|
60
|
+
(part as { type?: unknown }).type === "text" &&
|
|
61
|
+
typeof (part as { text?: unknown }).text === "string"
|
|
62
|
+
) {
|
|
63
|
+
const p = part as { text: string };
|
|
64
|
+
if (p.text.length >= 40) p.text = slimOutput(p.text, opts);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
} catch {
|
|
70
|
+
// fail-closed: leave output untouched
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
});
|
package/src/slim.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// slim-output core: fail-closed post-processor for tool output.
|
|
2
|
+
// Pure functions, no opencode deps. Rules:
|
|
3
|
+
// - Non-frame lines are NEVER deleted (errors/summaries always survive).
|
|
4
|
+
// - Frame collapsing requires: command gate + anchor + run of >= minFrames.
|
|
5
|
+
// - Ambiguous input -> passthrough. Never invent paths.
|
|
6
|
+
|
|
7
|
+
export interface SlimOptions {
|
|
8
|
+
cwd?: string;
|
|
9
|
+
command?: string;
|
|
10
|
+
maxLines?: number;
|
|
11
|
+
maxLineLen?: number;
|
|
12
|
+
maxFrames?: number;
|
|
13
|
+
minFrames?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type StackKind = "py" | "js" | "dotnet" | "java" | "rust" | "go";
|
|
17
|
+
|
|
18
|
+
const DEFAULTS = { maxLines: 120, maxLineLen: 160, maxFrames: 40, minFrames: 3 };
|
|
19
|
+
|
|
20
|
+
// ---- command gates: which detectors may run for a given command/tool ----
|
|
21
|
+
const GATES: Record<StackKind, RegExp> = {
|
|
22
|
+
py: /(pytest|python|ruff|mypy|basedpyright|ty|uv|poetry|pip)/i,
|
|
23
|
+
js: /(jest|vitest|tsc|eslint|biome|oxlint|node|bun|deno|npm|yarn|pnpm|nx|turbo|next|playwright|prettier)/i,
|
|
24
|
+
dotnet: /(dotnet)/i,
|
|
25
|
+
java: /(mvn|gradle|gradlew|java|spring)/i,
|
|
26
|
+
rust: /(cargo|rustc|nextest)/i,
|
|
27
|
+
go: /(^|\s)(go|golangci-lint)(\s|$)/i,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ---- anchors: wrapper lines proving a stack is present (per kind) ----
|
|
31
|
+
const ANCHORS: Record<StackKind, RegExp> = {
|
|
32
|
+
py: /Traceback \(most recent call last\)|^\S*(Error|Exception|AssertionError)\b/m,
|
|
33
|
+
js: /\b(FAIL|Error:|Expected:|Received:|Test Suites:|●)/m,
|
|
34
|
+
dotnet: /\b(Exception|Failed|error|Error)\b|^\s*at\s+\w+(\.\w+)+\(/m,
|
|
35
|
+
java: /\b(Exception|Caused by:|FAILED|BUILD FAILURE)\b|^\s*at\s+[\w.$]+\(/m,
|
|
36
|
+
rust: /\b(panicked|failures:|test result:)/m,
|
|
37
|
+
go: /\b(FAIL|ok\s+\S+|--- FAIL|panic:|goroutine \d+)\b/m,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ---- frame shapes: a line must match FULLY to count as a frame ----
|
|
41
|
+
const FRAMES: Record<StackKind, RegExp[]> = {
|
|
42
|
+
py: [/^\s*File "([^"]+)", line \d+.*$/],
|
|
43
|
+
js: [/^\s*at\s+.+\([^()]*:\d+:\d+\)\s*$/, /^\s*at\s+\S+\s*$/],
|
|
44
|
+
dotnet: [/^\s*at\s+[\w.<>$`']+\.[\w.<>$`'()]+\(.*\)(\s+in\s+.*:\d+)?\s*$/],
|
|
45
|
+
java: [/^\s*at\s+[\w.$]+\.[\w$<>]+\(.*\)(\s+\(.*\))?\s*$/],
|
|
46
|
+
rust: [/^\s*\d+:\s+\S+.*$/, /^\s*at\s+.*:\d+:\d+\s*$/],
|
|
47
|
+
go: [/^\s*[\w./\\-]+\.go:\d+.*$/, /^\s*goroutine \d+.*$/],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ---- external classifiers: frames matching these are collapsible ----
|
|
51
|
+
// Name-based entries are anchored so they can't match project identifiers
|
|
52
|
+
// (e.g. `Object.<anonymous>` must NOT match, `com.sunshine` must NOT match).
|
|
53
|
+
const EXTERNAL: Record<StackKind, RegExp> = {
|
|
54
|
+
py: /site-packages|dist-packages|\/venv\/|conda|lib\/python\d|<frozen|importlib[./]|runpy[./]|pkg_resources/,
|
|
55
|
+
js: /node_modules|node:internal|internal\/process|\(<anonymous>\)|\(anonymous\)|node:sea/,
|
|
56
|
+
dotnet: /\bSystem\.|\bMicrosoft\.|mscorlib|\.dll\b|dotnet[\\/]shared|C:\\Windows|NuGet/,
|
|
57
|
+
java: /java\.base|jdk\.internal|\bsun\.|org\.junit|org\.gradle|org\.apache\b|com\.sun\.|kotlin\.|scala\./,
|
|
58
|
+
rust: /core::|std::|alloc::|rustc\/|cargo\/registry/,
|
|
59
|
+
go: /\/go\/src\/|GOROOT|golang\.org\/toolchain|pkg\/mod\/|GOMODCACHE/,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Path segments that are external regardless of stack kind.
|
|
63
|
+
const EXTERNAL_HINT = /(node_modules|\.venv|\/venv\/|site-packages|dist-packages|pkg\/mod\/|\/go\/src\/)/;
|
|
64
|
+
|
|
65
|
+
const EXTERNAL_LABEL: Record<StackKind, string> = {
|
|
66
|
+
py: "site-packages/stdlib",
|
|
67
|
+
js: "node_modules/node:internal",
|
|
68
|
+
dotnet: "System/Microsoft/.dll",
|
|
69
|
+
java: "jdk/internal",
|
|
70
|
+
rust: "std/core/registry",
|
|
71
|
+
go: "stdlib/mod cache",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[a-zA-Z]/g;
|
|
75
|
+
|
|
76
|
+
export function stripAnsi(s: string): string {
|
|
77
|
+
return s.replace(ANSI_RE, "");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function escapeRegExp(s: string): string {
|
|
81
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Relativize only the exact cwd prefix — never strip bare src/lib segments. */
|
|
85
|
+
export function relativizePaths(text: string, cwd?: string): string {
|
|
86
|
+
if (!cwd || cwd === "/" || cwd.length < 2) return text;
|
|
87
|
+
const norm = cwd.replace(/\/+$/, "");
|
|
88
|
+
return text.split(norm).join(".");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type LineClass =
|
|
92
|
+
| { frame: false }
|
|
93
|
+
| { frame: true; kind: StackKind; external: boolean };
|
|
94
|
+
|
|
95
|
+
function activeKinds(command: string | undefined, text: string): StackKind[] {
|
|
96
|
+
const kinds = (Object.keys(GATES) as StackKind[]).filter((k) => {
|
|
97
|
+
if (!command) return true; // unknown source: allow, anchor still required
|
|
98
|
+
if (k === "go") return GATES.go.test(` ${command} `);
|
|
99
|
+
return GATES[k].test(command);
|
|
100
|
+
});
|
|
101
|
+
return kinds.filter((k) => ANCHORS[k].test(text));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
function classifyLine(line: string, kinds: StackKind[]): LineClass {
|
|
106
|
+
for (const kind of kinds) {
|
|
107
|
+
if (FRAMES[kind].some((re) => re.test(line))) {
|
|
108
|
+
// Path hints win over everything: relativized venv/node_modules paths
|
|
109
|
+
// start with ./ but are still external.
|
|
110
|
+
if (EXTERNAL_HINT.test(line)) return { frame: true, kind, external: true };
|
|
111
|
+
if (EXTERNAL[kind].test(line)) return { frame: true, kind, external: true };
|
|
112
|
+
// Unknown frames are kept (fail-closed): only known-external collapses.
|
|
113
|
+
return { frame: true, kind, external: false };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { frame: false };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Code-context line shown under a frame (e.g. Python prints the source line
|
|
121
|
+
* after each `File "..."` line). Absorbed into the surrounding frame run so
|
|
122
|
+
* interleaved tracebacks still form a collapsible run. Emitted verbatim.
|
|
123
|
+
*/
|
|
124
|
+
function isContextLine(line: string): boolean {
|
|
125
|
+
if (!/^\s+\S/.test(line)) return false;
|
|
126
|
+
if (line.length > 200) return false;
|
|
127
|
+
return !/(Error|Exception|assert|Traceback|^\s*(at\b|File\s|Caused by:))/m.test(line);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Collapse external sub-runs inside frame runs. Non-frame lines untouched. */
|
|
131
|
+
export function collapseStacks(
|
|
132
|
+
lines: string[],
|
|
133
|
+
opts: SlimOptions = {},
|
|
134
|
+
): { lines: string[]; collapsed: number } {
|
|
135
|
+
const minFrames = opts.minFrames ?? DEFAULTS.minFrames;
|
|
136
|
+
const maxFrames = opts.maxFrames ?? DEFAULTS.maxFrames;
|
|
137
|
+
const kinds = activeKinds(opts.command, lines.join("\n"));
|
|
138
|
+
if (kinds.length === 0) return { lines, collapsed: 0 };
|
|
139
|
+
|
|
140
|
+
const cls: LineClass[] = lines.map((l) => classifyLine(l, kinds));
|
|
141
|
+
const out: string[] = [];
|
|
142
|
+
let collapsed = 0;
|
|
143
|
+
let keptFrames = 0;
|
|
144
|
+
let i = 0;
|
|
145
|
+
|
|
146
|
+
while (i < lines.length) {
|
|
147
|
+
if (!cls[i].frame) {
|
|
148
|
+
out.push(lines[i]);
|
|
149
|
+
i++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
// frame run boundaries: absorb single context lines (source snippets
|
|
153
|
+
// printed under frames) so interleaved tracebacks stay one run
|
|
154
|
+
let j = i;
|
|
155
|
+
while (j < lines.length) {
|
|
156
|
+
if ((cls[j] as LineClass).frame) {
|
|
157
|
+
j++;
|
|
158
|
+
} else if (
|
|
159
|
+
j + 1 < lines.length &&
|
|
160
|
+
(cls[j + 1] as LineClass).frame &&
|
|
161
|
+
isContextLine(lines[j])
|
|
162
|
+
) {
|
|
163
|
+
j++; // absorb context line into run
|
|
164
|
+
} else break;
|
|
165
|
+
}
|
|
166
|
+
const run = lines.slice(i, j);
|
|
167
|
+
const runCls = cls.slice(i, j) as Extract<LineClass, { frame: true }>[];
|
|
168
|
+
|
|
169
|
+
if (run.length < minFrames) {
|
|
170
|
+
// too short to trust -> keep verbatim
|
|
171
|
+
out.push(...run);
|
|
172
|
+
keptFrames += run.length;
|
|
173
|
+
i = j;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Group run into units: each frame plus its attached context line(s).
|
|
178
|
+
// Context attached to an external frame is dropped with the block;
|
|
179
|
+
// context attached to a project frame is kept verbatim.
|
|
180
|
+
type Unit = { external: boolean; kind: StackKind; text: string[] };
|
|
181
|
+
const units: Unit[] = [];
|
|
182
|
+
let k = 0;
|
|
183
|
+
while (k < run.length) {
|
|
184
|
+
const c = runCls[k] as LineClass;
|
|
185
|
+
if (c.frame) {
|
|
186
|
+
const text = [run[k]];
|
|
187
|
+
let n = k + 1;
|
|
188
|
+
while (
|
|
189
|
+
n < run.length &&
|
|
190
|
+
!(runCls[n] as LineClass).frame &&
|
|
191
|
+
isContextLine(run[n])
|
|
192
|
+
) {
|
|
193
|
+
text.push(run[n]);
|
|
194
|
+
n++;
|
|
195
|
+
}
|
|
196
|
+
units.push({ external: c.external, kind: c.kind, text });
|
|
197
|
+
k = n;
|
|
198
|
+
} else {
|
|
199
|
+
units.push({ external: false, kind: kinds[0], text: [run[k]] });
|
|
200
|
+
k++;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// walk units, collapse external blocks (any length >= 1)
|
|
205
|
+
for (const u of units) {
|
|
206
|
+
if (!u.external) {
|
|
207
|
+
for (const t of u.text) {
|
|
208
|
+
if (keptFrames < maxFrames) {
|
|
209
|
+
out.push(t);
|
|
210
|
+
keptFrames++;
|
|
211
|
+
} else {
|
|
212
|
+
collapsed++;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const label = EXTERNAL_LABEL[u.kind] ?? "external";
|
|
218
|
+
out.push(`... +1 external frames omitted (${label})`);
|
|
219
|
+
collapsed += 1;
|
|
220
|
+
}
|
|
221
|
+
// merge adjacent markers into a single counted marker
|
|
222
|
+
for (let m = out.length - 1; m > 0; m--) {
|
|
223
|
+
const a = out[m].match(/^\.\.\. \+(\d+) external frames omitted \((.+)\)$/);
|
|
224
|
+
const b = out[m - 1].match(/^\.\.\. \+(\d+) external frames omitted \((.+)\)$/);
|
|
225
|
+
if (a && b && a[2] === b[2]) {
|
|
226
|
+
out[m - 1] = `... +${Number(a[1]) + Number(b[1])} frames omitted (${a[2]})`;
|
|
227
|
+
out.splice(m, 1);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
i = j;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { lines: out, collapsed };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Collapse consecutive duplicate lines: a,a,a -> a (x3). */
|
|
237
|
+
export function dedupConsecutive(lines: string[]): string[] {
|
|
238
|
+
const out: string[] = [];
|
|
239
|
+
let i = 0;
|
|
240
|
+
while (i < lines.length) {
|
|
241
|
+
let j = i + 1;
|
|
242
|
+
while (j < lines.length && lines[j] === lines[i]) j++;
|
|
243
|
+
const n = j - i;
|
|
244
|
+
out.push(n > 1 ? `${lines[i]} (x${n})` : lines[i]);
|
|
245
|
+
i = j;
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function truncateLine(line: string, max: number): string {
|
|
251
|
+
if (line.length <= max) return line;
|
|
252
|
+
const head = Math.ceil(max * 0.6);
|
|
253
|
+
const tail = max - head - 1;
|
|
254
|
+
return line.slice(0, head) + "…" + line.slice(line.length - tail);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function slimOutput(text: string, opts: SlimOptions = {}): string {
|
|
258
|
+
if (!text) return text;
|
|
259
|
+
const maxLines = opts.maxLines ?? DEFAULTS.maxLines;
|
|
260
|
+
const maxLineLen = opts.maxLineLen ?? DEFAULTS.maxLineLen;
|
|
261
|
+
|
|
262
|
+
let clean = relativizePaths(stripAnsi(text).replace(/\r\n/g, "\n"), opts.cwd);
|
|
263
|
+
let lines = clean.split("\n");
|
|
264
|
+
|
|
265
|
+
const { lines: collapsedLines, collapsed } = collapseStacks(lines, opts);
|
|
266
|
+
lines = dedupConsecutive(collapsedLines);
|
|
267
|
+
lines = lines.map((l) => truncateLine(l, maxLineLen));
|
|
268
|
+
|
|
269
|
+
let truncated = 0;
|
|
270
|
+
if (lines.length > maxLines) {
|
|
271
|
+
truncated = lines.length - maxLines;
|
|
272
|
+
lines = lines.slice(0, maxLines);
|
|
273
|
+
lines.push(`... +${truncated} more lines (truncated by slim-output)`);
|
|
274
|
+
}
|
|
275
|
+
if (collapsed > 0 && truncated === 0) {
|
|
276
|
+
// marker already inline per collapsed run; nothing extra needed
|
|
277
|
+
}
|
|
278
|
+
return lines.join("\n");
|
|
279
|
+
}
|