@aexol/spectral 0.9.19 → 0.9.20

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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.9.19",
3
+ "version": "0.9.20",
4
4
  "description": "AI coding agent for Aexol with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,