@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.
- package/dist/extensions/browser/browser-service.d.ts +19 -28
- package/dist/extensions/browser/browser-service.d.ts.map +1 -1
- package/dist/extensions/browser/browser-service.js +117 -46
- package/dist/extensions/browser/config.d.ts +5 -10
- package/dist/extensions/browser/config.d.ts.map +1 -1
- package/dist/extensions/browser/config.js +2 -3
- package/dist/extensions/browser/index.d.ts.map +1 -1
- package/dist/extensions/browser/index.js +3 -0
- package/dist/extensions/browser/record/record-tool.d.ts +4 -0
- package/dist/extensions/browser/record/record-tool.d.ts.map +1 -0
- package/dist/extensions/browser/record/record-tool.js +134 -0
- package/dist/memory/index.d.ts.map +1 -1
- package/dist/memory/index.js +2 -0
- package/dist/memory/tool-output-compressor.d.ts +61 -0
- package/dist/memory/tool-output-compressor.d.ts.map +1 -0
- package/dist/memory/tool-output-compressor.js +569 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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":"
|
|
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
|
-
|
|
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
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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;
|
|
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 @@
|
|
|
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;
|
|
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"}
|
package/dist/memory/index.js
CHANGED
|
@@ -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
|
+
}
|