@aexol/spectral 0.9.20 → 0.9.21

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/memory/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAcjE,MAAM,CAAC,OAAO,UAAU,mBAAmB,CAAC,GAAG,EAAE,YAAY,QAmB5D"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/memory/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAejE,MAAM,CAAC,OAAO,UAAU,mBAAmB,CAAC,GAAG,EAAE,YAAY,QAoB5D"}
@@ -5,6 +5,7 @@ import { registerCompactionTrigger } from "./hooks/compaction-trigger.js";
5
5
  import { registerInterAgentReceiver } from "./hooks/inter-agent-receiver.js";
6
6
  import { registerObserverTrigger } from "./hooks/observer-trigger.js";
7
7
  import { Runtime } from "./runtime.js";
8
+ import { registerToolOutputCompressor } from "./tool-output-compressor.js";
8
9
  import { registerRecallTool } from "./tools/recall-observation.js";
9
10
  import { registerReadProjectObservationsTool } from "./tools/read-project-observations.js";
10
11
  import { registerReceiveAgentObservationsTool } from "./tools/receive-agent-observations.js";
@@ -17,6 +18,7 @@ export default function observationalMemory(ext) {
17
18
  registerObserverTrigger(ext, runtime);
18
19
  registerCompactionTrigger(ext, runtime);
19
20
  registerCompactionHook(ext, runtime);
21
+ registerToolOutputCompressor(ext, runtime);
20
22
  registerStatusCommand(ext, runtime);
21
23
  registerViewCommand(ext, runtime);
22
24
  registerRecallTool(ext);
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Tool Output Compressor — RTK-inspired pre-processing that intercepts
3
+ * tool results and compresses raw output before it enters the LLM context.
4
+ *
5
+ * This is complementary to the conversation-level compaction pipeline:
6
+ * - Compressor: compresses raw tool output (pre-context)
7
+ * - Compaction: summarizes + prunes conversation history (post-context)
8
+ * - Unified compaction: narrative summary + observation extraction (at compaction boundary)
9
+ *
10
+ * Strategies (mirroring RTK's four-pronged approach):
11
+ *
12
+ * 1. SMART FILTERING — strip noise from structured outputs
13
+ * - Code: remove comments, collapse whitespace, skip blank lines
14
+ * - Git: strip progress meters, hints, verbose enumerations
15
+ * - Shell: remove ANSI escape codes, carriage returns
16
+ *
17
+ * 2. GROUPING — aggregate similar items
18
+ * - ls/find: group files by directory
19
+ * - grep/rg: group matches by file, collapse repeated patterns
20
+ * - Lint errors: group by rule + file
21
+ *
22
+ * 3. TRUNCATION — keep relevant context, cut redundancy
23
+ * - Read: keep N lines with line-number markers
24
+ * - Bash: keep first + last N lines of long output
25
+ * - Test runners: keep only failures + summary
26
+ *
27
+ * 4. DEDUPLICATION — collapse repeated lines with counts
28
+ * - Log output: deduplicate repeated lines
29
+ * - Docker/build output: collapse progress lines
30
+ */
31
+ import type { ExtensionAPI } from "../sdk/coding-agent/index.js";
32
+ import type { Runtime } from "./runtime.js";
33
+ export interface CompressorConfig {
34
+ /** Enable compression (default: true) */
35
+ enabled: boolean;
36
+ /** Maximum characters for any single tool result before truncation */
37
+ maxResultChars: number;
38
+ /** Maximum lines for read results before truncation */
39
+ maxReadLines: number;
40
+ /** Maximum lines for directory listings */
41
+ maxLsLines: number;
42
+ /** Maximum lines for grep/find results before grouping */
43
+ maxSearchLines: number;
44
+ /** Enable ANSI escape code stripping */
45
+ stripAnsi: boolean;
46
+ /** Enable comment stripping for code files */
47
+ stripComments: boolean;
48
+ /** Enable test-runner failure-only filtering */
49
+ testFailuresOnly: boolean;
50
+ /** Enable git output compacting */
51
+ compactGit: boolean;
52
+ }
53
+ export declare const DEFAULT_COMPRESSOR_CONFIG: CompressorConfig;
54
+ export interface CompressionStats {
55
+ originalChars: number;
56
+ compressedChars: number;
57
+ savingsPercent: number;
58
+ toolName: string;
59
+ }
60
+ export declare function registerToolOutputCompressor(ext: ExtensionAPI, runtime: Runtime): void;
61
+ //# sourceMappingURL=tool-output-compressor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-output-compressor.d.ts","sourceRoot":"","sources":["../../src/memory/tool-output-compressor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAUjE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAO5C,MAAM,WAAW,gBAAgB;IAChC,yCAAyC;IACzC,OAAO,EAAE,OAAO,CAAC;IACjB,sEAAsE;IACtE,cAAc,EAAE,MAAM,CAAC;IACvB,uDAAuD;IACvD,YAAY,EAAE,MAAM,CAAC;IACrB,2CAA2C;IAC3C,UAAU,EAAE,MAAM,CAAC;IACnB,0DAA0D;IAC1D,cAAc,EAAE,MAAM,CAAC;IACvB,wCAAwC;IACxC,SAAS,EAAE,OAAO,CAAC;IACnB,8CAA8C;IAC9C,aAAa,EAAE,OAAO,CAAC;IACvB,gDAAgD;IAChD,gBAAgB,EAAE,OAAO,CAAC;IAC1B,mCAAmC;IACnC,UAAU,EAAE,OAAO,CAAC;CACpB;AAED,eAAO,MAAM,yBAAyB,EAAE,gBAUvC,CAAC;AAghBF,MAAM,WAAW,gBAAgB;IAChC,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,4BAA4B,CAC3C,GAAG,EAAE,YAAY,EACjB,OAAO,EAAE,OAAO,GACd,IAAI,CA4HN"}
@@ -0,0 +1,569 @@
1
+ /**
2
+ * Tool Output Compressor — RTK-inspired pre-processing that intercepts
3
+ * tool results and compresses raw output before it enters the LLM context.
4
+ *
5
+ * This is complementary to the conversation-level compaction pipeline:
6
+ * - Compressor: compresses raw tool output (pre-context)
7
+ * - Compaction: summarizes + prunes conversation history (post-context)
8
+ * - Unified compaction: narrative summary + observation extraction (at compaction boundary)
9
+ *
10
+ * Strategies (mirroring RTK's four-pronged approach):
11
+ *
12
+ * 1. SMART FILTERING — strip noise from structured outputs
13
+ * - Code: remove comments, collapse whitespace, skip blank lines
14
+ * - Git: strip progress meters, hints, verbose enumerations
15
+ * - Shell: remove ANSI escape codes, carriage returns
16
+ *
17
+ * 2. GROUPING — aggregate similar items
18
+ * - ls/find: group files by directory
19
+ * - grep/rg: group matches by file, collapse repeated patterns
20
+ * - Lint errors: group by rule + file
21
+ *
22
+ * 3. TRUNCATION — keep relevant context, cut redundancy
23
+ * - Read: keep N lines with line-number markers
24
+ * - Bash: keep first + last N lines of long output
25
+ * - Test runners: keep only failures + summary
26
+ *
27
+ * 4. DEDUPLICATION — collapse repeated lines with counts
28
+ * - Log output: deduplicate repeated lines
29
+ * - Docker/build output: collapse progress lines
30
+ */
31
+ import { debugLog } from "./debug-log.js";
32
+ export const DEFAULT_COMPRESSOR_CONFIG = {
33
+ enabled: true,
34
+ maxResultChars: 8000,
35
+ maxReadLines: 500,
36
+ maxLsLines: 100,
37
+ maxSearchLines: 200,
38
+ stripAnsi: true,
39
+ stripComments: true,
40
+ testFailuresOnly: true,
41
+ compactGit: true,
42
+ };
43
+ // ============================================================================
44
+ // ANSI Stripping
45
+ // ============================================================================
46
+ const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g;
47
+ const CARRIAGE_RETURN_RE = /\r/g;
48
+ function stripAnsi(input) {
49
+ return input.replace(ANSI_RE, "").replace(CARRIAGE_RETURN_RE, "");
50
+ }
51
+ const COMMENT_PATTERNS = {
52
+ ts: { line: "//", blockStart: "/*", blockEnd: "*/" },
53
+ tsx: { line: "//", blockStart: "/*", blockEnd: "*/" },
54
+ js: { line: "//", blockStart: "/*", blockEnd: "*/" },
55
+ jsx: { line: "//", blockStart: "/*", blockEnd: "*/" },
56
+ mjs: { line: "//", blockStart: "/*", blockEnd: "*/" },
57
+ cjs: { line: "//", blockStart: "/*", blockEnd: "*/" },
58
+ py: { line: "#", blockStart: '"""', blockEnd: '"""' },
59
+ pyw: { line: "#", blockStart: '"""', blockEnd: '"""' },
60
+ rs: { line: "//", blockStart: "/*", blockEnd: "*/" },
61
+ go: { line: "//", blockStart: "/*", blockEnd: "*/" },
62
+ java: { line: "//", blockStart: "/*", blockEnd: "*/" },
63
+ rb: { line: "#", blockStart: "=begin", blockEnd: "=end" },
64
+ sh: { line: "#", blockStart: null, blockEnd: null },
65
+ bash: { line: "#", blockStart: null, blockEnd: null },
66
+ zsh: { line: "#", blockStart: null, blockEnd: null },
67
+ c: { line: "//", blockStart: "/*", blockEnd: "*/" },
68
+ h: { line: "//", blockStart: "/*", blockEnd: "*/" },
69
+ cpp: { line: "//", blockStart: "/*", blockEnd: "*/" },
70
+ hpp: { line: "//", blockStart: "/*", blockEnd: "*/" },
71
+ };
72
+ /** Data formats that must never have comment stripping applied */
73
+ const DATA_EXTENSIONS = new Set([
74
+ "json", "jsonc", "json5", "yaml", "yml", "toml", "xml", "csv",
75
+ "tsv", "graphql", "gql", "sql", "md", "markdown", "txt",
76
+ "env", "lock", "css", "scss", "less", "html", "htm",
77
+ ]);
78
+ function getExtension(filePath) {
79
+ const dot = filePath.lastIndexOf(".");
80
+ if (dot < 0)
81
+ return "";
82
+ return filePath.slice(dot + 1).toLowerCase();
83
+ }
84
+ function stripComments(content, ext) {
85
+ if (!ext || DATA_EXTENSIONS.has(ext))
86
+ return content;
87
+ const patterns = COMMENT_PATTERNS[ext];
88
+ if (!patterns)
89
+ return content;
90
+ const lines = content.split("\n");
91
+ const result = [];
92
+ let inBlockComment = false;
93
+ for (const line of lines) {
94
+ const trimmed = line.trim();
95
+ // Handle block comments
96
+ if (patterns.blockStart && patterns.blockEnd) {
97
+ if (!inBlockComment && trimmed.includes(patterns.blockStart)) {
98
+ inBlockComment = true;
99
+ }
100
+ if (inBlockComment) {
101
+ if (trimmed.includes(patterns.blockEnd)) {
102
+ inBlockComment = false;
103
+ }
104
+ continue;
105
+ }
106
+ }
107
+ // Skip line comments
108
+ if (patterns.line && trimmed.startsWith(patterns.line)) {
109
+ continue;
110
+ }
111
+ // Skip blank lines
112
+ if (!trimmed)
113
+ continue;
114
+ // Remove trailing whitespace
115
+ result.push(line.replace(/\s+$/, ""));
116
+ }
117
+ return result.join("\n");
118
+ }
119
+ // ============================================================================
120
+ // Smart Truncation
121
+ // ============================================================================
122
+ function smartTruncate(text, maxChars) {
123
+ if (text.length <= maxChars)
124
+ return text;
125
+ const half = Math.floor(maxChars / 2);
126
+ const head = text.slice(0, half);
127
+ const tail = text.slice(text.length - half);
128
+ const omitted = text.length - head.length - tail.length;
129
+ return `${head}\n\n[... ${omitted} characters truncated ...]\n\n${tail}`;
130
+ }
131
+ function smartTruncateLines(text, maxLines) {
132
+ const lines = text.split("\n");
133
+ if (lines.length <= maxLines)
134
+ return text;
135
+ const keepFirst = Math.floor(maxLines / 3);
136
+ const keepLast = Math.floor(maxLines / 3);
137
+ const middle = maxLines - keepFirst - keepLast - 1;
138
+ const head = lines.slice(0, keepFirst);
139
+ const tail = lines.slice(lines.length - keepLast);
140
+ const omitted = lines.length - keepFirst - keepLast;
141
+ return [
142
+ ...head,
143
+ `[... ${omitted} lines omitted ...]`,
144
+ ...tail,
145
+ ].join("\n");
146
+ }
147
+ function deduplicateRepeatedLines(text) {
148
+ const lines = text.split("\n");
149
+ const stats = { totalLines: 0, uniqueLines: 0, collapsedRuns: 0 };
150
+ if (lines.length === 0)
151
+ return { result: text, stats };
152
+ const result = [];
153
+ let runCount = 0;
154
+ let lastLine = "";
155
+ for (const line of lines) {
156
+ stats.totalLines++;
157
+ if (line === lastLine) {
158
+ runCount++;
159
+ }
160
+ else {
161
+ if (runCount > 2) {
162
+ // Collapse: replace the repeated run with a count marker
163
+ result.pop(); // Remove the last duplicate
164
+ result.push(`${lastLine} [repeated ${runCount - 1} more times]`);
165
+ stats.collapsedRuns++;
166
+ }
167
+ result.push(line);
168
+ stats.uniqueLines++;
169
+ runCount = 1;
170
+ lastLine = line;
171
+ }
172
+ }
173
+ // Handle trailing run
174
+ if (runCount > 2) {
175
+ result.pop();
176
+ result.push(`${lastLine} [repeated ${runCount - 1} more times]`);
177
+ stats.collapsedRuns++;
178
+ }
179
+ return {
180
+ result: result.join("\n"),
181
+ stats,
182
+ };
183
+ }
184
+ // ============================================================================
185
+ // Read Compressor
186
+ // ============================================================================
187
+ function compressReadOutput(event, config) {
188
+ let content = event.content
189
+ .filter((c) => c.type === "text")
190
+ .map((c) => c.text)
191
+ .join("\n");
192
+ if (!content)
193
+ return "";
194
+ // Strip ANSI
195
+ if (config.stripAnsi) {
196
+ content = stripAnsi(content);
197
+ }
198
+ // Strip comments for code files
199
+ if (config.stripComments) {
200
+ const filePath = event.input?.path ?? "";
201
+ const ext = getExtension(filePath);
202
+ if (ext && !DATA_EXTENSIONS.has(ext)) {
203
+ content = stripComments(content, ext);
204
+ }
205
+ }
206
+ // Truncate long reads
207
+ const lines = content.split("\n");
208
+ if (lines.length > config.maxReadLines) {
209
+ content = smartTruncateLines(content, config.maxReadLines);
210
+ }
211
+ // Truncate by char count
212
+ if (content.length > config.maxResultChars) {
213
+ content = smartTruncate(content, config.maxResultChars);
214
+ }
215
+ return content;
216
+ }
217
+ // ============================================================================
218
+ // Bash Compressor
219
+ // ============================================================================
220
+ /** Commands whose output should be filtered to failures only */
221
+ const TEST_COMMANDS = [
222
+ "jest", "vitest", "pytest", "go test", "cargo test",
223
+ "rspec", "rake test", "playwright test", "npx playwright",
224
+ "npm test", "npm run test", "yarn test", "pnpm test",
225
+ "bun test",
226
+ ];
227
+ /** Commands where git-like compacting should apply */
228
+ const GIT_COMMANDS = [
229
+ "git status", "git diff", "git log", "git show",
230
+ "git push", "git pull", "git fetch", "git add",
231
+ "git commit", "git branch", "git stash",
232
+ ];
233
+ /** Patterns to strip from git output */
234
+ const GIT_NOISE_PATTERNS = [
235
+ /^Enumerating objects:/,
236
+ /^Counting objects:/,
237
+ /^Compressing objects:/,
238
+ /^Writing objects:/,
239
+ /^Delta compression using/,
240
+ /^Total \d+/,
241
+ /^\(use "git/,
242
+ /^\(create\/copy files/,
243
+ ];
244
+ function detectCommandType(command) {
245
+ const base = command.split(/\s+/).slice(0, 3).join(" ").toLowerCase();
246
+ for (const testCmd of TEST_COMMANDS) {
247
+ if (base.startsWith(testCmd))
248
+ return "test";
249
+ }
250
+ for (const gitCmd of GIT_COMMANDS) {
251
+ if (base.startsWith(gitCmd))
252
+ return "git";
253
+ }
254
+ if (/\b(build|compile|tsc|esbuild|webpack|vite build|next build)\b/.test(base)) {
255
+ return "build";
256
+ }
257
+ return "other";
258
+ }
259
+ function filterGitOutput(content) {
260
+ const lines = content.split("\n");
261
+ const filtered = [];
262
+ for (const line of lines) {
263
+ const trimmed = line.trim();
264
+ if (!trimmed)
265
+ continue;
266
+ // Skip noise lines
267
+ if (GIT_NOISE_PATTERNS.some((p) => p.test(trimmed)))
268
+ continue;
269
+ filtered.push(line);
270
+ }
271
+ if (filtered.length === 0)
272
+ return "ok";
273
+ return filtered.join("\n");
274
+ }
275
+ function filterTestOutput(content) {
276
+ const lines = content.split("\n");
277
+ const result = [];
278
+ let failureSection = false;
279
+ let summarySection = false;
280
+ for (const line of lines) {
281
+ // Start of failure section
282
+ if (/^FAIL|^\s*●|^\s*\d+\)|^\s*FAILED|^AssertionError|^Error:/i.test(line)) {
283
+ failureSection = true;
284
+ }
285
+ // Start of summary section
286
+ if (/^(Test Suites|Tests|Snapshots|Time|Ran all):/i.test(line)) {
287
+ summarySection = true;
288
+ failureSection = false;
289
+ }
290
+ // Keep lines in failure or summary sections
291
+ if (failureSection || summarySection) {
292
+ result.push(line);
293
+ }
294
+ }
295
+ // If no failures found, just return a summary
296
+ if (result.length === 0) {
297
+ const summaryLines = lines.filter((l) => /^(Tests|Test Suites|Snapshots|Time|Ran):/i.test(l.trim()));
298
+ if (summaryLines.length > 0) {
299
+ return summaryLines.join("\n");
300
+ }
301
+ return "All tests passed.";
302
+ }
303
+ return result.join("\n");
304
+ }
305
+ function compressBashOutput(event, config) {
306
+ let content = event.content
307
+ .filter((c) => c.type === "text")
308
+ .map((c) => c.text)
309
+ .join("\n");
310
+ if (!content)
311
+ return "";
312
+ // Strip ANSI
313
+ if (config.stripAnsi) {
314
+ content = stripAnsi(content);
315
+ }
316
+ // Detect command type and apply specialized filters
317
+ const command = event.input?.command ?? "";
318
+ const cmdType = detectCommandType(command);
319
+ switch (cmdType) {
320
+ case "git":
321
+ if (config.compactGit) {
322
+ content = filterGitOutput(content);
323
+ }
324
+ break;
325
+ case "test":
326
+ if (config.testFailuresOnly) {
327
+ content = filterTestOutput(content);
328
+ }
329
+ break;
330
+ case "build":
331
+ // For build output, deduplicate repeated progress lines
332
+ {
333
+ const deduped = deduplicateRepeatedLines(content);
334
+ content = deduped.result;
335
+ }
336
+ break;
337
+ }
338
+ // Deduplicate repeated lines for all outputs
339
+ {
340
+ const deduped = deduplicateRepeatedLines(content);
341
+ if (deduped.stats.collapsedRuns > 0) {
342
+ content = deduped.result;
343
+ }
344
+ }
345
+ // Truncate by char count
346
+ if (content.length > config.maxResultChars) {
347
+ content = smartTruncate(content, config.maxResultChars);
348
+ }
349
+ return content;
350
+ }
351
+ // ============================================================================
352
+ // Ls Compressor
353
+ // ============================================================================
354
+ function compressLsOutput(event, config) {
355
+ let content = event.content
356
+ .filter((c) => c.type === "text")
357
+ .map((c) => c.text)
358
+ .join("\n");
359
+ if (!content)
360
+ return "";
361
+ if (config.stripAnsi) {
362
+ content = stripAnsi(content);
363
+ }
364
+ const lines = content.split("\n");
365
+ if (lines.length > config.maxLsLines) {
366
+ // Group by directory when listing many files
367
+ const groups = new Map();
368
+ for (const line of lines) {
369
+ const trimmed = line.trim();
370
+ if (!trimmed)
371
+ continue;
372
+ const slashIdx = trimmed.lastIndexOf("/");
373
+ const dir = slashIdx >= 0 ? trimmed.slice(0, slashIdx) : ".";
374
+ groups.set(dir, (groups.get(dir) ?? 0) + 1);
375
+ }
376
+ const grouped = [];
377
+ for (const [dir, count] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
378
+ grouped.push(`${dir}/ (${count} files)`);
379
+ }
380
+ return grouped.join("\n");
381
+ }
382
+ return content;
383
+ }
384
+ // ============================================================================
385
+ // Grep Compressor
386
+ // ============================================================================
387
+ function compressGrepOutput(event, config) {
388
+ let content = event.content
389
+ .filter((c) => c.type === "text")
390
+ .map((c) => c.text)
391
+ .join("\n");
392
+ if (!content)
393
+ return "";
394
+ if (config.stripAnsi) {
395
+ content = stripAnsi(content);
396
+ }
397
+ // Group matches by file
398
+ const lines = content.split("\n");
399
+ const byFile = new Map();
400
+ for (const line of lines) {
401
+ const trimmed = line.trim();
402
+ if (!trimmed)
403
+ continue;
404
+ // Standard grep format: "file:line:text"
405
+ const colonIdx = trimmed.indexOf(":");
406
+ if (colonIdx > 0) {
407
+ const file = trimmed.slice(0, colonIdx);
408
+ byFile.set(file, (byFile.get(file) ?? 0) + 1);
409
+ }
410
+ }
411
+ if (byFile.size > 1 && lines.length > config.maxSearchLines) {
412
+ // Group by file with count, show first few matches per file
413
+ const grouped = [];
414
+ for (const [file, count] of [...byFile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
415
+ const fileMatches = lines.filter((l) => l.startsWith(file));
416
+ const preview = fileMatches.slice(0, 3).join("\n ");
417
+ grouped.push(`${file} (${count} matches):\n ${preview}${count > 3 ? `\n ... +${count - 3} more` : ""}`);
418
+ }
419
+ return grouped.join("\n\n");
420
+ }
421
+ return content;
422
+ }
423
+ // ============================================================================
424
+ // Find Compressor
425
+ // ============================================================================
426
+ function compressFindOutput(event, config) {
427
+ let content = event.content
428
+ .filter((c) => c.type === "text")
429
+ .map((c) => c.text)
430
+ .join("\n");
431
+ if (!content)
432
+ return "";
433
+ if (config.stripAnsi) {
434
+ content = stripAnsi(content);
435
+ }
436
+ // Group by directory
437
+ const lines = content.split("\n").filter((l) => l.trim());
438
+ if (lines.length > config.maxSearchLines) {
439
+ const groups = new Map();
440
+ for (const line of lines) {
441
+ const trimmed = line.trim();
442
+ if (!trimmed)
443
+ continue;
444
+ const slashIdx = trimmed.lastIndexOf("/");
445
+ const dir = slashIdx >= 0 ? trimmed.slice(0, slashIdx) : ".";
446
+ if (!groups.has(dir))
447
+ groups.set(dir, []);
448
+ groups.get(dir).push(slashIdx >= 0 ? trimmed.slice(slashIdx + 1) : trimmed);
449
+ }
450
+ const grouped = [];
451
+ for (const [dir, files] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
452
+ if (files.length === 1) {
453
+ grouped.push(`${dir}/${files[0]}`);
454
+ }
455
+ else if (files.length <= 5) {
456
+ grouped.push(`${dir}/ (${files.length} files: ${files.join(", ")})`);
457
+ }
458
+ else {
459
+ grouped.push(`${dir}/ (${files.length} files: ${files.slice(0, 3).join(", ")}... +${files.length - 3} more)`);
460
+ }
461
+ }
462
+ return grouped.join("\n");
463
+ }
464
+ return content;
465
+ }
466
+ export function registerToolOutputCompressor(ext, runtime) {
467
+ const config = { ...DEFAULT_COMPRESSOR_CONFIG };
468
+ ext.on("tool_result", (_event, _ctx) => {
469
+ if (!config.enabled)
470
+ return;
471
+ // Only apply compression for known tool types
472
+ if (_event.toolName === "read") {
473
+ const event = _event;
474
+ const original = event.content
475
+ .filter((c) => c.type === "text")
476
+ .map((c) => c.text)
477
+ .join("\n");
478
+ const compressed = compressReadOutput(event, config);
479
+ if (compressed && compressed !== original) {
480
+ debugLog("compressor.read", {
481
+ originalLen: original.length,
482
+ compressedLen: compressed.length,
483
+ savings: ((1 - compressed.length / Math.max(1, original.length)) * 100).toFixed(1) + "%",
484
+ });
485
+ return {
486
+ content: [...event.content.filter((c) => c.type !== "text"), { type: "text", text: compressed }],
487
+ };
488
+ }
489
+ return;
490
+ }
491
+ if (_event.toolName === "bash") {
492
+ const event = _event;
493
+ const original = event.content
494
+ .filter((c) => c.type === "text")
495
+ .map((c) => c.text)
496
+ .join("\n");
497
+ const compressed = compressBashOutput(event, config);
498
+ if (compressed && compressed !== original) {
499
+ const command = event.input?.command ?? "";
500
+ debugLog("compressor.bash", {
501
+ command: command.slice(0, 80),
502
+ originalLen: original.length,
503
+ compressedLen: compressed.length,
504
+ savings: ((1 - compressed.length / Math.max(1, original.length)) * 100).toFixed(1) + "%",
505
+ });
506
+ return {
507
+ content: [...event.content.filter((c) => c.type !== "text"), { type: "text", text: compressed }],
508
+ };
509
+ }
510
+ return;
511
+ }
512
+ if (_event.toolName === "ls") {
513
+ const event = _event;
514
+ const original = event.content
515
+ .filter((c) => c.type === "text")
516
+ .map((c) => c.text)
517
+ .join("\n");
518
+ const compressed = compressLsOutput(event, config);
519
+ if (compressed && compressed !== original) {
520
+ debugLog("compressor.ls", {
521
+ originalLen: original.length,
522
+ compressedLen: compressed.length,
523
+ });
524
+ return {
525
+ content: [...event.content.filter((c) => c.type !== "text"), { type: "text", text: compressed }],
526
+ };
527
+ }
528
+ return;
529
+ }
530
+ if (_event.toolName === "grep") {
531
+ const event = _event;
532
+ const original = event.content
533
+ .filter((c) => c.type === "text")
534
+ .map((c) => c.text)
535
+ .join("\n");
536
+ const compressed = compressGrepOutput(event, config);
537
+ if (compressed && compressed !== original) {
538
+ debugLog("compressor.grep", {
539
+ originalLen: original.length,
540
+ compressedLen: compressed.length,
541
+ });
542
+ return {
543
+ content: [...event.content.filter((c) => c.type !== "text"), { type: "text", text: compressed }],
544
+ };
545
+ }
546
+ return;
547
+ }
548
+ if (_event.toolName === "find") {
549
+ const event = _event;
550
+ const original = event.content
551
+ .filter((c) => c.type === "text")
552
+ .map((c) => c.text)
553
+ .join("\n");
554
+ const compressed = compressFindOutput(event, config);
555
+ if (compressed && compressed !== original) {
556
+ debugLog("compressor.find", {
557
+ originalLen: original.length,
558
+ compressedLen: compressed.length,
559
+ });
560
+ return {
561
+ content: [...event.content.filter((c) => c.type !== "text"), { type: "text", text: compressed }],
562
+ };
563
+ }
564
+ return;
565
+ }
566
+ // Unknown tool types: do nothing
567
+ return;
568
+ });
569
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.9.20",
3
+ "version": "0.9.21",
4
4
  "description": "AI coding agent for Aexol with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,