opencode-slim-output 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 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
- # Temporary Holding Version
1
+ # opencode-slim-output
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ bun 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,25 @@
1
1
  {
2
2
  "name": "opencode-slim-output",
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": "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": "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": ["opencode", "opencode-plugin", "output", "stack-trace", "wsl"],
14
+ "files": ["plugin.ts", "src", "README.md", "LICENSE"],
15
+ "scripts": {
16
+ "test": "bun test",
17
+ "typecheck": "tsc --noEmit"
18
+ },
19
+ "devDependencies": {
20
+ "@opencode/plugin": "latest",
21
+ "bun-types": "^1.4.2",
22
+ "typescript": "^5.6.0"
23
+ },
24
+ "license": "MIT"
25
+ }
package/plugin.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { Plugin } from "@opencode/plugin";
2
+ import { slimOutput } from "./src/slim";
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
+ }