@aexol/spectral 0.9.19 → 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,14 +1,3 @@
1
- /**
2
- * BrowserService — singleton managing Playwright browser lifecycle.
3
- *
4
- * One browser instance, one persistent context, multiple pages (tabs).
5
- * Collects console messages and network requests automatically.
6
- * Follows the same singleton pattern as fetcher-mcp's BrowserService.
7
- *
8
- * Playwright is loaded lazily on first use (not at import time). This allows
9
- * the module to load in standalone Bun binaries where playwright is unavailable.
10
- * Tools will report a clear "Browser unavailable" error instead of crashing.
11
- */
12
1
  import type { Browser, BrowserContext, Page } from "playwright";
13
2
  import type { BrowserConfig } from "./config.js";
14
3
  export interface NetworkRequest {
@@ -33,39 +22,29 @@ export declare class BrowserService {
33
22
  private networkBuffer;
34
23
  private nextNetworkIndex;
35
24
  private pendingDialogs;
25
+ private _recording;
36
26
  constructor(config?: Partial<BrowserConfig>);
37
- /** Get or create the singleton instance. */
38
27
  static getInstance(config?: Partial<BrowserConfig>): BrowserService;
39
- /** Reset singleton (for testing). */
40
28
  static resetInstance(): void;
41
- /** Check whether Playwright is installed and loadable (static, does NOT start a browser). */
42
29
  static isAvailable(): boolean;
43
- /**
44
- * Human-readable message explaining why the browser is unavailable.
45
- * Returns null when the browser is available.
46
- */
47
30
  static unavailabilityReason(): string | null;
48
- /** Ensure browser, context, and at least one page are ready. */
49
31
  ensureBrowser(): Promise<{
50
32
  browser: Browser;
51
33
  context: BrowserContext;
52
34
  page: Page;
53
35
  }>;
54
- /** Get the currently active page. */
36
+ getBrowser(): Browser;
37
+ getActiveContext(): BrowserContext;
55
38
  getActivePage(): Page;
56
- /** Navigate to a URL on the active page. */
57
39
  navigate(url: string): Promise<{
58
- title: string;
59
40
  url: string;
41
+ title: string;
60
42
  }>;
61
- /** Navigate back. */
62
43
  navigateBack(): Promise<{
63
- title: string;
64
44
  url: string;
45
+ title: string;
65
46
  }>;
66
- /** Close the active page. If last page, create a blank one. */
67
47
  closePage(): Promise<void>;
68
- /** Resize the viewport. */
69
48
  resize(width: number, height: number): Promise<void>;
70
49
  listTabs(): Array<{
71
50
  index: number;
@@ -95,9 +74,21 @@ export declare class BrowserService {
95
74
  getNetworkRequests(): NetworkRequest[];
96
75
  getNetworkRequest(index: number): NetworkRequest | undefined;
97
76
  clearNetworkRequests(): void;
98
- /** Close browser, context, and all pages. */
99
77
  close(): Promise<void>;
100
- /** Reset console/network buffers. */
78
+ startRecording(outputDir?: string): Promise<{
79
+ outputPath: string;
80
+ startedAt: number;
81
+ }>;
82
+ stopRecording(): Promise<{
83
+ outputPath: string;
84
+ durationSeconds: number;
85
+ }>;
86
+ getRecordStatus(): {
87
+ active: boolean;
88
+ outputPath?: string;
89
+ startedAt?: number;
90
+ elapsedSeconds?: number;
91
+ };
101
92
  resetBuffers(): void;
102
93
  getConfig(): BrowserConfig;
103
94
  }
@@ -1 +1 @@
1
- {"version":3,"file":"browser-service.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/browser-service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"browser-service.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/browser-service.ts"],"names":[],"mappings":"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"}
@@ -1,14 +1,3 @@
1
- /**
2
- * BrowserService — singleton managing Playwright browser lifecycle.
3
- *
4
- * One browser instance, one persistent context, multiple pages (tabs).
5
- * Collects console messages and network requests automatically.
6
- * Follows the same singleton pattern as fetcher-mcp's BrowserService.
7
- *
8
- * Playwright is loaded lazily on first use (not at import time). This allows
9
- * the module to load in standalone Bun binaries where playwright is unavailable.
10
- * Tools will report a clear "Browser unavailable" error instead of crashing.
11
- */
12
1
  import { createRequire } from "node:module";
13
2
  import { getBrowserConfig } from "./config.js";
14
3
  const _require = createRequire(import.meta.url);
@@ -40,21 +29,19 @@ export class BrowserService {
40
29
  networkBuffer = new Map();
41
30
  nextNetworkIndex = 0;
42
31
  pendingDialogs = [];
32
+ _recording = null;
43
33
  constructor(config) {
44
34
  this.config = { ...getBrowserConfig(), ...config };
45
35
  }
46
- /** Get or create the singleton instance. */
47
36
  static getInstance(config) {
48
37
  if (!instance) {
49
38
  instance = new BrowserService(config);
50
39
  }
51
40
  return instance;
52
41
  }
53
- /** Reset singleton (for testing). */
54
42
  static resetInstance() {
55
43
  instance = null;
56
44
  }
57
- /** Check whether Playwright is installed and loadable (static, does NOT start a browser). */
58
45
  static isAvailable() {
59
46
  try {
60
47
  loadPlaywright();
@@ -64,10 +51,6 @@ export class BrowserService {
64
51
  return false;
65
52
  }
66
53
  }
67
- /**
68
- * Human-readable message explaining why the browser is unavailable.
69
- * Returns null when the browser is available.
70
- */
71
54
  static unavailabilityReason() {
72
55
  if (_pwError)
73
56
  return _pwError;
@@ -81,7 +64,6 @@ export class BrowserService {
81
64
  return err instanceof Error ? err.message : String(err);
82
65
  }
83
66
  }
84
- /** Ensure browser, context, and at least one page are ready. */
85
67
  async ensureBrowser() {
86
68
  if (this.browser?.isConnected() && this.context && this.pages.length > 0) {
87
69
  const page = this.pages[this.activePageIndex];
@@ -110,7 +92,6 @@ export class BrowserService {
110
92
  this.context = await this.browser.newContext({
111
93
  viewport: this.config.viewport,
112
94
  });
113
- // Auto-collect console messages and network requests
114
95
  this.context.on("page", (page) => {
115
96
  page.on("console", (msg) => {
116
97
  this.consoleBuffer.push({
@@ -129,34 +110,38 @@ export class BrowserService {
129
110
  this.activePageIndex = 0;
130
111
  return { browser: this.browser, context: this.context, page };
131
112
  }
132
- /** Get the currently active page. */
113
+ getBrowser() {
114
+ if (!this.browser?.isConnected())
115
+ throw new Error("Browser is not connected.");
116
+ return this.browser;
117
+ }
118
+ getActiveContext() {
119
+ if (!this.context)
120
+ throw new Error("No active context.");
121
+ return this.context;
122
+ }
133
123
  getActivePage() {
134
- if (this.pages.length === 0) {
135
- throw new Error("No browser pages available. Call ensureBrowser() first.");
136
- }
124
+ if (this.pages.length === 0)
125
+ throw new Error("No active page.");
126
+ if (this.activePageIndex >= this.pages.length)
127
+ this.activePageIndex = 0;
137
128
  return this.pages[this.activePageIndex];
138
129
  }
139
- /** Navigate to a URL on the active page. */
140
130
  async navigate(url) {
141
- const { page } = await this.ensureBrowser();
142
- // Auto-prepend https if missing
143
- const normalizedUrl = url.startsWith("http") ? url : `https://${url}`;
144
- await page.goto(normalizedUrl, {
145
- timeout: this.config.navigationTimeout,
131
+ const page = this.getActivePage();
132
+ const start = Date.now();
133
+ await page.goto(url, {
146
134
  waitUntil: "domcontentloaded",
135
+ timeout: this.config.navigationTimeout,
147
136
  });
148
- return { title: await page.title(), url: page.url() };
137
+ return { url: page.url(), title: await page.title() };
149
138
  }
150
- /** Navigate back. */
151
139
  async navigateBack() {
152
140
  const page = this.getActivePage();
153
141
  await page.goBack({ timeout: this.config.navigationTimeout });
154
- return { title: await page.title(), url: page.url() };
142
+ return { url: page.url(), title: await page.title() };
155
143
  }
156
- /** Close the active page. If last page, create a blank one. */
157
144
  async closePage() {
158
- if (this.pages.length === 0)
159
- return;
160
145
  const page = this.pages[this.activePageIndex];
161
146
  await page.close();
162
147
  this.pages.splice(this.activePageIndex, 1);
@@ -169,13 +154,11 @@ export class BrowserService {
169
154
  this.activePageIndex = Math.min(this.activePageIndex, this.pages.length - 1);
170
155
  }
171
156
  }
172
- /** Resize the viewport. */
173
157
  async resize(width, height) {
174
158
  const page = this.getActivePage();
175
159
  await page.setViewportSize({ width, height });
176
160
  this.config.viewport = { width, height };
177
161
  }
178
- // --- Tab management ---
179
162
  listTabs() {
180
163
  return this.pages.map((p, i) => ({
181
164
  index: i,
@@ -214,10 +197,8 @@ export class BrowserService {
214
197
  await page.bringToFront();
215
198
  return { index };
216
199
  }
217
- // --- Dialog interception ---
218
200
  installDialogInterceptor(page) {
219
201
  page.on("dialog", (dialog) => {
220
- // Hold the dialog until agent handles it via browser_handle_dialog
221
202
  this.pendingDialogs.push({
222
203
  message: dialog.message(),
223
204
  type: dialog.type(),
@@ -246,7 +227,6 @@ export class BrowserService {
246
227
  }
247
228
  return dialog.message;
248
229
  }
249
- // --- Console messages ---
250
230
  getConsoleMessages(level, all = false) {
251
231
  const msgs = all ? [...this.consoleBuffer] : [...this.consoleBuffer];
252
232
  if (level) {
@@ -259,7 +239,6 @@ export class BrowserService {
259
239
  clearConsoleMessages() {
260
240
  this.consoleBuffer = [];
261
241
  }
262
- // --- Network requests ---
263
242
  trackNetworkRequest(page) {
264
243
  page.on("request", (request) => {
265
244
  const index = this.nextNetworkIndex++;
@@ -282,7 +261,6 @@ export class BrowserService {
282
261
  entry.statusText = response.statusText();
283
262
  entry.responseHeaders = response.headers();
284
263
  }).catch(() => {
285
- // Request may fail or be aborted
286
264
  });
287
265
  });
288
266
  }
@@ -296,8 +274,6 @@ export class BrowserService {
296
274
  this.networkBuffer.clear();
297
275
  this.nextNetworkIndex = 0;
298
276
  }
299
- // --- Lifecycle ---
300
- /** Close browser, context, and all pages. */
301
277
  async close() {
302
278
  for (const page of this.pages) {
303
279
  try {
@@ -321,8 +297,103 @@ export class BrowserService {
321
297
  catch { /* ignore */ }
322
298
  this.browser = null;
323
299
  }
300
+ this._recording = null;
301
+ }
302
+ // ── Recording ──────────────────────────────────────────────
303
+ async startRecording(outputDir) {
304
+ if (this._recording) {
305
+ throw new Error("Recording is already active. Use stopRecording() first.");
306
+ }
307
+ const dir = outputDir ?? this.config.recordOutputDir;
308
+ const { mkdirSync } = await import("node:fs");
309
+ mkdirSync(dir, { recursive: true });
310
+ // Save current URL before tearing down
311
+ let currentUrl = "about:blank";
312
+ if (this.pages.length > 0 && this.activePageIndex < this.pages.length) {
313
+ try {
314
+ currentUrl = this.pages[this.activePageIndex].url() || currentUrl;
315
+ }
316
+ catch { /* ignore */ }
317
+ }
318
+ // Close existing context (finalizes any prior video)
319
+ if (this.context) {
320
+ await Promise.allSettled(this.pages.map((p) => p.close().catch(() => { })));
321
+ this.pages = [];
322
+ await this.context.close().catch(() => { });
323
+ this.context = null;
324
+ }
325
+ // Create new browser+context with recording enabled
326
+ const pw = loadPlaywright();
327
+ const launchOpts = {
328
+ headless: this.config.headless,
329
+ args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
330
+ };
331
+ if (!this.browser?.isConnected()) {
332
+ switch (this.config.browserName) {
333
+ case "firefox":
334
+ this.browser = await pw.firefox.launch(launchOpts);
335
+ break;
336
+ case "webkit":
337
+ this.browser = await pw.webkit.launch(launchOpts);
338
+ break;
339
+ default:
340
+ this.browser = await pw.chromium.launch(launchOpts);
341
+ break;
342
+ }
343
+ }
344
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
345
+ const outputPath = `${dir}/recording-${timestamp}.webm`;
346
+ this.context = await this.browser.newContext({
347
+ viewport: this.config.recordViewport ?? this.config.viewport,
348
+ recordVideo: {
349
+ dir,
350
+ size: this.config.recordViewport ?? this.config.viewport,
351
+ },
352
+ });
353
+ this.context.on("page", (page) => {
354
+ page.on("console", (msg) => {
355
+ this.consoleBuffer.push({ type: msg.type(), text: msg.text(), pageUrl: page.url() });
356
+ });
357
+ this.trackNetworkRequest(page);
358
+ this.installDialogInterceptor(page);
359
+ });
360
+ const page = await this.context.newPage();
361
+ this.trackNetworkRequest(page);
362
+ this.installDialogInterceptor(page);
363
+ this.pages = [page];
364
+ this.activePageIndex = 0;
365
+ await page.goto(currentUrl, { waitUntil: "domcontentloaded", timeout: this.config.navigationTimeout }).catch(() => { });
366
+ this._recording = { dir, startedAt: Date.now(), outputPath };
367
+ return { outputPath, startedAt: this._recording.startedAt };
368
+ }
369
+ async stopRecording() {
370
+ if (!this._recording) {
371
+ throw new Error("No active recording.");
372
+ }
373
+ const { outputPath, startedAt } = this._recording;
374
+ const durationSeconds = Math.round((Date.now() - startedAt) / 1000);
375
+ // Close the recording context — Playwright finalizes the .webm on context.close()
376
+ if (this.context) {
377
+ await Promise.allSettled(this.pages.map((p) => p.close().catch(() => { })));
378
+ this.pages = [];
379
+ await this.context.close().catch(() => { });
380
+ this.context = null;
381
+ }
382
+ this._recording = null;
383
+ // Create a fresh non-recording context for continued use
384
+ await this.ensureBrowser();
385
+ return { outputPath, durationSeconds };
386
+ }
387
+ getRecordStatus() {
388
+ if (!this._recording)
389
+ return { active: false };
390
+ return {
391
+ active: true,
392
+ outputPath: this._recording.outputPath,
393
+ startedAt: this._recording.startedAt,
394
+ elapsedSeconds: Math.round((Date.now() - this._recording.startedAt) / 1000),
395
+ };
324
396
  }
325
- /** Reset console/network buffers. */
326
397
  resetBuffers() {
327
398
  this.clearConsoleMessages();
328
399
  this.clearNetworkRequests();
@@ -1,24 +1,19 @@
1
- /**
2
- * Browser extension configuration.
3
- */
4
1
  export interface BrowserConfig {
5
- /** Default browser name to launch. */
6
2
  browserName: "chromium" | "firefox" | "webkit";
7
- /** Whether to run headless. */
8
3
  headless: boolean;
9
- /** Default viewport size. */
10
4
  viewport: {
11
5
  width: number;
12
6
  height: number;
13
7
  };
14
- /** Default navigation timeout (ms). */
15
8
  navigationTimeout: number;
16
- /** Default action timeout (ms). */
17
9
  actionTimeout: number;
18
- /** Directory to save screenshots and output files. */
19
10
  outputDir: string;
20
- /** Whether to enable debug mode (keep browser visible, verbose logging). */
21
11
  debug: boolean;
12
+ recordOutputDir: string;
13
+ recordViewport?: {
14
+ width: number;
15
+ height: number;
16
+ };
22
17
  }
23
18
  export declare function getBrowserConfig(): BrowserConfig;
24
19
  //# sourceMappingURL=config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/config.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,MAAM,WAAW,aAAa;IAC5B,sCAAsC;IACtC,WAAW,EAAE,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC;IAC/C,+BAA+B;IAC/B,QAAQ,EAAE,OAAO,CAAC;IAClB,6BAA6B;IAC7B,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5C,uCAAuC;IACvC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,mCAAmC;IACnC,aAAa,EAAE,MAAM,CAAC;IACtB,sDAAsD;IACtD,SAAS,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,wBAAgB,gBAAgB,IAAI,aAAa,CAahD"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC;IAC/C,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5C,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,OAAO,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CACpD;AACD,wBAAgB,gBAAgB,IAAI,aAAa,CAehD"}
@@ -1,6 +1,3 @@
1
- /**
2
- * Browser extension configuration.
3
- */
4
1
  export function getBrowserConfig() {
5
2
  return {
6
3
  browserName: process.env.BROWSER_NAME ?? "chromium",
@@ -13,5 +10,7 @@ export function getBrowserConfig() {
13
10
  actionTimeout: Number(process.env.BROWSER_ACTION_TIMEOUT) || 5_000,
14
11
  outputDir: process.env.BROWSER_OUTPUT_DIR ?? ".spectral/browser-output",
15
12
  debug: process.env.BROWSER_DEBUG === "true" ? true : false,
13
+ recordOutputDir: process.env.BROWSER_RECORD_DIR ?? ".spectral/browser-recordings",
14
+ recordViewport: undefined, // defaults to viewport via code
16
15
  };
17
16
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAcpE,wBAA8B,gBAAgB,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA4D/E"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAepE,wBAA8B,gBAAgB,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA6D/E"}
@@ -42,6 +42,7 @@ import { registerDialogTools } from "./tools/dialog.js";
42
42
  import { registerWaitTools } from "./tools/wait.js";
43
43
  import { registerDropTools } from "./tools/drop.js";
44
44
  import { registerStreamTools } from "./stream/stream-tool.js";
45
+ import { registerRecordTools } from "./record/record-tool.js";
45
46
  export default async function browserExtension(ext) {
46
47
  const available = BrowserService.isAvailable();
47
48
  if (!available) {
@@ -77,6 +78,7 @@ export default async function browserExtension(ext) {
77
78
  { name: "wait", fn: () => registerWaitTools(ext, bs) },
78
79
  { name: "drop", fn: () => registerDropTools(ext, bs) },
79
80
  { name: "stream", fn: () => registerStreamTools(ext, bs) },
81
+ { name: "record", fn: () => registerRecordTools(ext, bs) },
80
82
  ];
81
83
  let totalRegistered = 0;
82
84
  for (const registrar of registrars) {
@@ -113,6 +115,7 @@ function countRegisteredTools(ext) {
113
115
  "browser_wait_for",
114
116
  "browser_drop",
115
117
  "browser_stream_start", "browser_stream_stop", "browser_stream_status",
118
+ "browser_record_start", "browser_record_stop", "browser_record_status",
116
119
  ];
117
120
  for (const name of knownTools) {
118
121
  if (ext.getToolDefinition(name))
@@ -0,0 +1,4 @@
1
+ import type { ExtensionAPI } from "../../../sdk/coding-agent/index.js";
2
+ import type { BrowserService } from "../browser-service.js";
3
+ export declare function registerRecordTools(ext: ExtensionAPI, bs: BrowserService): void;
4
+ //# sourceMappingURL=record-tool.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"record-tool.d.ts","sourceRoot":"","sources":["../../../../src/extensions/browser/record/record-tool.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAE5D,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,YAAY,EAAE,EAAE,EAAE,cAAc,GAAG,IAAI,CAwI/E"}
@@ -0,0 +1,134 @@
1
+ export function registerRecordTools(ext, bs) {
2
+ const startTool = {
3
+ name: "browser_record_start",
4
+ label: "Start Browser Recording",
5
+ description: "Start recording the browser session as a .webm video. " +
6
+ "Uses Playwright's built-in video recording (actual page capture, not a canvas overlay). " +
7
+ "Recording continues across navigation, clicks, typing — everything the agent does in the browser. " +
8
+ "Use browser_record_stop to finalize and save the video.",
9
+ promptSnippet: "`browser_record_start { outputDir? }` — start recording browser as .webm video",
10
+ parameters: {
11
+ type: "object",
12
+ properties: {
13
+ outputDir: {
14
+ type: "string",
15
+ description: "Directory to save the recording. Default: .spectral/browser-recordings/ (configurable via BROWSER_RECORD_DIR env).",
16
+ },
17
+ },
18
+ },
19
+ async execute(_toolCallId, params) {
20
+ const p = params;
21
+ try {
22
+ const { outputPath, startedAt } = await bs.startRecording(p.outputDir);
23
+ return {
24
+ content: [
25
+ {
26
+ type: "text",
27
+ text: [
28
+ "✓ Browser recording started",
29
+ `Output: ${outputPath}`,
30
+ `Started at: ${new Date(startedAt).toISOString()}`,
31
+ "",
32
+ "All browser actions (navigation, clicks, typing, etc.) are now being recorded.",
33
+ "Use browser_record_stop to finalize and save the video.",
34
+ ].join("\n"),
35
+ },
36
+ ],
37
+ details: { outputPath, startedAt },
38
+ };
39
+ }
40
+ catch (err) {
41
+ const msg = err instanceof Error ? err.message : String(err);
42
+ return {
43
+ content: [{ type: "text", text: `Failed to start recording: ${msg}` }],
44
+ details: { isError: true },
45
+ };
46
+ }
47
+ },
48
+ };
49
+ const stopTool = {
50
+ name: "browser_record_stop",
51
+ label: "Stop Browser Recording",
52
+ description: "Stop the active recording, finalize the .webm video, and create a fresh non-recording browser context " +
53
+ "for continued use. Returns the video file path and recording duration.",
54
+ promptSnippet: "`browser_record_stop` — stop recording and save .webm video",
55
+ parameters: {
56
+ type: "object",
57
+ properties: {},
58
+ },
59
+ async execute() {
60
+ try {
61
+ const { outputPath, durationSeconds } = await bs.stopRecording();
62
+ return {
63
+ content: [
64
+ {
65
+ type: "text",
66
+ text: [
67
+ "✓ Recording stopped and saved",
68
+ `File: ${outputPath}`,
69
+ `Duration: ${durationSeconds}s (${Math.floor(durationSeconds / 60)}m ${durationSeconds % 60}s)`,
70
+ "",
71
+ "A fresh browser context has been created — you can continue browsing.",
72
+ ].join("\n"),
73
+ },
74
+ ],
75
+ details: { outputPath, durationSeconds },
76
+ };
77
+ }
78
+ catch (err) {
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ return {
81
+ content: [{ type: "text", text: `Failed to stop recording: ${msg}` }],
82
+ details: { isError: true },
83
+ };
84
+ }
85
+ },
86
+ };
87
+ const statusTool = {
88
+ name: "browser_record_status",
89
+ label: "Browser Recording Status",
90
+ description: "Check whether a browser recording is currently active.",
91
+ promptSnippet: "`browser_record_status` — check recording status",
92
+ parameters: {
93
+ type: "object",
94
+ properties: {},
95
+ },
96
+ async execute() {
97
+ const status = bs.getRecordStatus();
98
+ if (!status.active) {
99
+ return {
100
+ content: [
101
+ {
102
+ type: "text",
103
+ text: "No active recording. Use browser_record_start to begin.",
104
+ },
105
+ ],
106
+ details: {},
107
+ };
108
+ }
109
+ return {
110
+ content: [
111
+ {
112
+ type: "text",
113
+ text: [
114
+ "● Recording active",
115
+ `Output: ${status.outputPath}`,
116
+ `Started: ${status.startedAt ? new Date(status.startedAt).toISOString() : "?"}`,
117
+ `Elapsed: ${status.elapsedSeconds ?? 0}s`,
118
+ ].join("\n"),
119
+ },
120
+ ],
121
+ details: status,
122
+ };
123
+ },
124
+ };
125
+ for (const tool of [startTool, stopTool, statusTool]) {
126
+ try {
127
+ ext.registerTool(tool);
128
+ }
129
+ catch (err) {
130
+ const msg = err instanceof Error ? err.message : String(err);
131
+ process.stderr.write(`[browser-record] Failed to register ${tool.name}: ${msg}\n`);
132
+ }
133
+ }
134
+ }
@@ -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.19",
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,