@aexol/spectral 0.9.212 → 0.9.215

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.
Files changed (110) hide show
  1. package/dist/backend/generated-images.d.ts +90 -0
  2. package/dist/backend/generated-images.d.ts.map +1 -0
  3. package/dist/backend/generated-images.js +214 -0
  4. package/dist/backend/machine-routing.d.ts +25 -0
  5. package/dist/backend/machine-routing.d.ts.map +1 -0
  6. package/dist/backend/machine-routing.js +35 -0
  7. package/dist/commands/serve.d.ts.map +1 -1
  8. package/dist/commands/serve.js +20 -2
  9. package/dist/extensions/browser/browser-service.d.ts +75 -1
  10. package/dist/extensions/browser/browser-service.d.ts.map +1 -1
  11. package/dist/extensions/browser/browser-service.js +600 -34
  12. package/dist/extensions/browser/index.d.ts +10 -4
  13. package/dist/extensions/browser/index.d.ts.map +1 -1
  14. package/dist/extensions/browser/index.js +44 -5
  15. package/dist/extensions/browser/stream/stream-tool.d.ts +10 -0
  16. package/dist/extensions/browser/stream/stream-tool.d.ts.map +1 -1
  17. package/dist/extensions/browser/stream/stream-tool.js +42 -14
  18. package/dist/extensions/browser/tools/core.d.ts.map +1 -1
  19. package/dist/extensions/browser/tools/core.js +63 -2
  20. package/dist/extensions/desktop-control/index.d.ts +55 -0
  21. package/dist/extensions/desktop-control/index.d.ts.map +1 -0
  22. package/dist/extensions/desktop-control/index.js +633 -0
  23. package/dist/extensions/desktop-control/keys.d.ts +33 -0
  24. package/dist/extensions/desktop-control/keys.d.ts.map +1 -0
  25. package/dist/extensions/desktop-control/keys.js +170 -0
  26. package/dist/extensions/desktop-control/linux.d.ts +26 -0
  27. package/dist/extensions/desktop-control/linux.d.ts.map +1 -0
  28. package/dist/extensions/desktop-control/linux.js +195 -0
  29. package/dist/extensions/desktop-control/macos.d.ts +53 -0
  30. package/dist/extensions/desktop-control/macos.d.ts.map +1 -0
  31. package/dist/extensions/desktop-control/macos.js +382 -0
  32. package/dist/extensions/desktop-control/safety.d.ts +89 -0
  33. package/dist/extensions/desktop-control/safety.d.ts.map +1 -0
  34. package/dist/extensions/desktop-control/safety.js +157 -0
  35. package/dist/extensions/desktop-control/types.d.ts +79 -0
  36. package/dist/extensions/desktop-control/types.d.ts.map +1 -0
  37. package/dist/extensions/desktop-control/types.js +10 -0
  38. package/dist/extensions/desktop-control/windows.d.ts +31 -0
  39. package/dist/extensions/desktop-control/windows.d.ts.map +1 -0
  40. package/dist/extensions/desktop-control/windows.js +276 -0
  41. package/dist/extensions/desktop-screenshot/index.d.ts +103 -0
  42. package/dist/extensions/desktop-screenshot/index.d.ts.map +1 -1
  43. package/dist/extensions/desktop-screenshot/index.js +363 -17
  44. package/dist/extensions/image-generation/index.d.ts.map +1 -1
  45. package/dist/extensions/image-generation/index.js +133 -34
  46. package/dist/extensions/seo/index.d.ts.map +1 -1
  47. package/dist/extensions/seo/index.js +4 -0
  48. package/dist/extensions/seo/tools/backend-bridge.d.ts +4 -0
  49. package/dist/extensions/seo/tools/backend-bridge.d.ts.map +1 -0
  50. package/dist/extensions/seo/tools/backend-bridge.js +378 -0
  51. package/dist/extensions/seo/tools/full-audit.d.ts +2 -0
  52. package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
  53. package/dist/extensions/seo/tools/full-audit.js +1 -1
  54. package/dist/extensions/seo/tools/report.d.ts +10 -0
  55. package/dist/extensions/seo/tools/report.d.ts.map +1 -1
  56. package/dist/extensions/seo/tools/report.js +1 -1
  57. package/dist/extensions/spectral-vision-fallback.d.ts.map +1 -1
  58. package/dist/extensions/spectral-vision-fallback.js +11 -1
  59. package/dist/generated/zeus/const.d.ts.map +1 -1
  60. package/dist/generated/zeus/const.js +121 -4
  61. package/dist/generated/zeus/index.d.ts +527 -0
  62. package/dist/generated/zeus/index.d.ts.map +1 -1
  63. package/dist/mcp/tool-registrar.d.ts.map +1 -1
  64. package/dist/mcp/tool-registrar.js +5 -2
  65. package/dist/mcp/types.d.ts +5 -0
  66. package/dist/mcp/types.d.ts.map +1 -1
  67. package/dist/relay/dispatcher.d.ts +12 -1
  68. package/dist/relay/dispatcher.d.ts.map +1 -1
  69. package/dist/relay/dispatcher.js +44 -42
  70. package/dist/sdk/ai/providers/faux.js +1 -1
  71. package/dist/sdk/ai/providers/openai-completions.d.ts.map +1 -1
  72. package/dist/sdk/ai/providers/openai-completions.js +57 -16
  73. package/dist/sdk/ai/providers/openrouter-images.d.ts.map +1 -1
  74. package/dist/sdk/ai/providers/openrouter-images.js +45 -9
  75. package/dist/sdk/ai/providers/simple-options.d.ts.map +1 -1
  76. package/dist/sdk/ai/providers/simple-options.js +2 -0
  77. package/dist/sdk/ai/types.d.ts +37 -1
  78. package/dist/sdk/ai/types.d.ts.map +1 -1
  79. package/dist/sdk/ai/utils/image-source.d.ts +121 -0
  80. package/dist/sdk/ai/utils/image-source.d.ts.map +1 -0
  81. package/dist/sdk/ai/utils/image-source.js +410 -0
  82. package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
  83. package/dist/sdk/coding-agent/core/extensions/native-extensions.js +14 -0
  84. package/dist/sdk/coding-agent/core/extensions/types.d.ts +9 -0
  85. package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
  86. package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
  87. package/dist/sdk/coding-agent/core/system-prompt.js +15 -9
  88. package/dist/sdk/coding-agent/modes/rpc/rpc-mode.d.ts.map +1 -1
  89. package/dist/sdk/coding-agent/modes/rpc/rpc-mode.js +16 -3
  90. package/dist/sdk/coding-agent/utils/image-resize.d.ts.map +1 -1
  91. package/dist/sdk/coding-agent/utils/image-resize.js +7 -3
  92. package/dist/server/agent-bridge.d.ts +14 -0
  93. package/dist/server/agent-bridge.d.ts.map +1 -1
  94. package/dist/server/agent-bridge.js +105 -26
  95. package/dist/server/handlers/queue.d.ts.map +1 -1
  96. package/dist/server/handlers/queue.js +11 -34
  97. package/dist/server/image-attachments.d.ts +109 -0
  98. package/dist/server/image-attachments.d.ts.map +1 -0
  99. package/dist/server/image-attachments.js +195 -0
  100. package/dist/server/session-stream.d.ts +67 -0
  101. package/dist/server/session-stream.d.ts.map +1 -1
  102. package/dist/server/session-stream.js +222 -23
  103. package/dist/server/storage.d.ts.map +1 -1
  104. package/dist/server/storage.js +10 -31
  105. package/dist/server/ui/terminal-confirm.d.ts +145 -0
  106. package/dist/server/ui/terminal-confirm.d.ts.map +1 -0
  107. package/dist/server/ui/terminal-confirm.js +333 -0
  108. package/dist/server/wire.d.ts +30 -5
  109. package/dist/server/wire.d.ts.map +1 -1
  110. package/package.json +1 -1
@@ -18,13 +18,17 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
18
  import { tmpdir } from "node:os";
19
19
  import { join } from "node:path";
20
20
  import { randomUUID } from "node:crypto";
21
+ import { uploadImage } from "../../backend/generated-images.js";
22
+ import { resolveBackendRouting } from "../../backend/machine-routing.js";
23
+ import { resizeImage } from "../../sdk/coding-agent/utils/image-resize.js";
24
+ import { formatImageBytes, MAX_INLINE_IMAGE_BASE64_CHARS, } from "../../server/image-attachments.js";
21
25
  const SCREENSHOTS_DIR = ".screenshots";
22
- function tmpDir() {
26
+ export function tmpDir() {
23
27
  const d = join(tmpdir(), "spectral-screenshots");
24
28
  mkdirSync(d, { recursive: true });
25
29
  return d;
26
30
  }
27
- function saveScreenshot(buf, format, cwd) {
31
+ export function saveScreenshot(buf, format, cwd) {
28
32
  const dir = join(cwd, SCREENSHOTS_DIR);
29
33
  mkdirSync(dir, { recursive: true });
30
34
  const now = new Date();
@@ -176,6 +180,221 @@ function darwinWindowScreenshot(windowId, options) {
176
180
  });
177
181
  });
178
182
  }
183
+ // ===========================================================================
184
+ // macOS coordinate-space normalization (Retina pixels → logical points)
185
+ // ===========================================================================
186
+ /**
187
+ * `screencapture` returns images in *physical* pixels — 2× (or 3×) the
188
+ * logical resolution on Retina displays — while the `desktop_*` tools
189
+ * (CGEvent) operate in *logical* screen points. A model reading coordinates
190
+ * straight off an unnormalized capture would click ~2× too far. The helpers
191
+ * below probe the display geometry (JXA/AppKit, logical points), resample
192
+ * the capture to a 1:1 logical-points image with `sips`, and produce
193
+ * metadata that states the applied scale explicitly.
194
+ */
195
+ const COORD_SPACE_NOTE = "Coordinates for desktop_* tools are logical screen points — the image above is already scaled 1:1 to that space.";
196
+ const DARWIN_GEOMETRY_TTL_MS = 10_000;
197
+ let darwinGeometryCache = null;
198
+ /** Test hook: forget the cached geometry so the next probe hits osascript again. */
199
+ export function resetDarwinScreenGeometryCache() {
200
+ darwinGeometryCache = null;
201
+ }
202
+ function runCommand(cmd, args, timeoutMs) {
203
+ return new Promise((resolve, reject) => {
204
+ execFile(cmd, args, { timeout: timeoutMs }, (err, stdout) => {
205
+ if (err)
206
+ return reject(err);
207
+ resolve(stdout);
208
+ });
209
+ });
210
+ }
211
+ // NSScreen reports logical points. AppKit's global space is bottom-left
212
+ // origin while CGEvent's is top-left origin of the primary display, so y is
213
+ // flipped before the geometry is reported (the desktop_* tools are CGEvent).
214
+ const DARWIN_GEOMETRY_JXA = [
215
+ "ObjC.import('AppKit');",
216
+ "const screens = $.NSScreen.screens;",
217
+ "const primaryF = screens.objectAtIndex(0).frame;",
218
+ "const mainF = $.NSScreen.mainScreen.frame;",
219
+ "JSON.stringify({",
220
+ " count: Number(screens.count),",
221
+ " primary: { x: Number(primaryF.origin.x), y: Number(primaryF.origin.y), w: Number(primaryF.size.width), h: Number(primaryF.size.height) },",
222
+ " main: { x: Number(mainF.origin.x), y: Number(mainF.origin.y), w: Number(mainF.size.width), h: Number(mainF.size.height) },",
223
+ " backingScale: Number($.NSScreen.mainScreen.backingScaleFactor)",
224
+ "});",
225
+ ].join("\n");
226
+ /**
227
+ * Logical display geometry via JXA/AppKit, cached for a few seconds (display
228
+ * arrangements rarely change mid-session). Resolves to `null` when the probe
229
+ * fails — captures are then left untouched and the tool result says the
230
+ * scale is unverified. Exported for tests.
231
+ */
232
+ export async function darwinScreenGeometry() {
233
+ const now = Date.now();
234
+ if (darwinGeometryCache && now - darwinGeometryCache.at < DARWIN_GEOMETRY_TTL_MS) {
235
+ return darwinGeometryCache.geometry;
236
+ }
237
+ const geometry = await (async () => {
238
+ try {
239
+ const out = await runCommand("osascript", ["-l", "JavaScript", "-e", DARWIN_GEOMETRY_JXA], 5000);
240
+ const raw = JSON.parse(out.trim());
241
+ if (!raw || raw.count < 1 || !raw.primary || !raw.main || !Number.isFinite(raw.backingScale))
242
+ return null;
243
+ const toCgY = (r) => raw.primary.h - (r.y + r.h);
244
+ return {
245
+ displayCount: raw.count,
246
+ primaryOrigin: { x: raw.primary.x, y: toCgY(raw.primary) },
247
+ main: { x: raw.main.x, y: toCgY(raw.main), width: raw.main.w, height: raw.main.h },
248
+ backingScaleFactor: raw.backingScale,
249
+ };
250
+ }
251
+ catch {
252
+ return null;
253
+ }
254
+ })();
255
+ darwinGeometryCache = { at: now, geometry };
256
+ return geometry;
257
+ }
258
+ async function probeDarwinImageSize(path) {
259
+ try {
260
+ const out = await runCommand("sips", ["-g", "pixelWidth", "-g", "pixelHeight", path], 5000);
261
+ const w = /pixelWidth:\s*(\d+)/.exec(out);
262
+ const h = /pixelHeight:\s*(\d+)/.exec(out);
263
+ if (!w || !h)
264
+ return null;
265
+ return { width: Number(w[1]), height: Number(h[1]) };
266
+ }
267
+ catch {
268
+ return null;
269
+ }
270
+ }
271
+ function nearSize(a, b) {
272
+ return Math.abs(a - b) <= Math.max(2, b * 0.02);
273
+ }
274
+ function fmtScale(scale) {
275
+ return `${Math.round(scale * 100) / 100}×`;
276
+ }
277
+ /**
278
+ * Normalize a macOS capture so image pixels map 1:1 onto the logical screen
279
+ * points the desktop_* tools accept. Full-screen captures are resampled to
280
+ * the main screen's logical width when the file is a Retina (backing-scale)
281
+ * capture; window captures are resampled by the display's backing scale
282
+ * factor (their coordinates are window-local, which the metadata states).
283
+ * Never throws: on any probe failure the original buffer comes back with a
284
+ * caution note instead of a silently wrong scale. Exported for tests.
285
+ */
286
+ export async function normalizeDarwinScreenshot(buf, format, mode) {
287
+ const unchanged = (metadata, details = {}) => ({ buffer: buf, metadata, details });
288
+ try {
289
+ const geometry = await darwinScreenGeometry();
290
+ const inPath = join(tmpDir(), `${randomUUID()}.${format}`);
291
+ writeFileSync(inPath, buf);
292
+ const size = await probeDarwinImageSize(inPath);
293
+ if (!size) {
294
+ const factor = geometry && geometry.backingScaleFactor > 1 ? Math.round(geometry.backingScaleFactor) : 2;
295
+ return unchanged(geometry
296
+ ? `Note: image size could not be verified (main screen: ${geometry.main.width}×${geometry.main.height} pt at ` +
297
+ `${fmtScale(geometry.backingScaleFactor)} backing scale) — divide read-off coordinates by ${factor} on a ` +
298
+ "Retina Mac before using desktop_* tools."
299
+ : "Note: image size and screen geometry could not be verified — divide read-off coordinates by 2 on a " +
300
+ "Retina Mac before using desktop_* tools.");
301
+ }
302
+ let targetWidth = null;
303
+ let scaleApplied;
304
+ if (mode === "screen" && geometry) {
305
+ const logicalW = geometry.main.width;
306
+ if (nearSize(size.width, logicalW * geometry.backingScaleFactor) && logicalW < size.width) {
307
+ targetWidth = Math.round(logicalW);
308
+ scaleApplied = size.width / logicalW;
309
+ }
310
+ else if (nearSize(size.width, logicalW)) {
311
+ scaleApplied = 1;
312
+ }
313
+ }
314
+ else if (mode === "window" && geometry && geometry.backingScaleFactor > 1.01) {
315
+ targetWidth = Math.round(size.width / geometry.backingScaleFactor);
316
+ scaleApplied = geometry.backingScaleFactor;
317
+ }
318
+ let outBuf = buf;
319
+ let outWidth = size.width;
320
+ let outHeight = size.height;
321
+ let resampled = false;
322
+ if (targetWidth !== null && targetWidth > 0 && targetWidth < size.width) {
323
+ const outPath = join(tmpDir(), `${randomUUID()}.${format}`);
324
+ await runCommand("sips", ["--resampleWidth", String(targetWidth), inPath, "--out", outPath], 15000);
325
+ outBuf = readFileSync(outPath);
326
+ outWidth = targetWidth;
327
+ outHeight = Math.round(size.height * (targetWidth / size.width));
328
+ resampled = true;
329
+ }
330
+ const displayNote = geometry
331
+ ? `${geometry.displayCount} display(s); primary display global origin (${geometry.primaryOrigin.x}, ${geometry.primaryOrigin.y}) pt in the global CGEvent space` +
332
+ (geometry.main.x !== 0 || geometry.main.y !== 0
333
+ ? `; main screen top-left at (${geometry.main.x}, ${geometry.main.y}) pt`
334
+ : "")
335
+ : "";
336
+ const windowLocalNote = "Coordinates for desktop_* tools are global logical screen points — these are window-local, " +
337
+ "so offset them by the window's position (or capture the full desktop) before clicking.";
338
+ let metadata;
339
+ if (mode === "screen") {
340
+ if (resampled && geometry) {
341
+ metadata =
342
+ `Image ${size.width}×${size.height} px is a ${fmtScale(scaleApplied ?? 2)} Retina capture → resampled to ` +
343
+ `${outWidth}×${outHeight} px for the ${geometry.main.width}×${geometry.main.height} pt logical display. ` +
344
+ `${COORD_SPACE_NOTE} ${displayNote}.`;
345
+ }
346
+ else if (scaleApplied === 1 && geometry) {
347
+ metadata =
348
+ `Image ${outWidth}×${outHeight} px on the ${geometry.main.width}×${geometry.main.height} pt logical display ` +
349
+ `(scale 1× — no resampling needed). ${COORD_SPACE_NOTE} ${displayNote}.`;
350
+ }
351
+ else if (geometry) {
352
+ metadata =
353
+ `Image ${outWidth}×${outHeight} px does not match the main screen's geometry ` +
354
+ `(${geometry.main.width}×${geometry.main.height} pt at ${fmtScale(geometry.backingScaleFactor)}; a secondary display?) — ` +
355
+ "desktop_* tools take logical screen points, so verify the scale before converting read-off coordinates.";
356
+ }
357
+ else {
358
+ metadata =
359
+ `Image ${outWidth}×${outHeight} px; screen geometry could not be probed — desktop_* tools take logical ` +
360
+ "screen points, so on a Retina Mac (2×) divide read-off coordinates by 2 first.";
361
+ }
362
+ }
363
+ else {
364
+ metadata = resampled && geometry
365
+ ? `Window image ${size.width}×${size.height} px at ${fmtScale(scaleApplied ?? geometry.backingScaleFactor)} backing scale → resampled to ` +
366
+ `${outWidth}×${outHeight} px (window-local logical points, assuming the window sits on the main display). ` +
367
+ windowLocalNote
368
+ : geometry
369
+ ? `Window image ${outWidth}×${outHeight} px at ${fmtScale(geometry.backingScaleFactor)} backing scale ` +
370
+ `(window-local logical points, no resampling needed). ${windowLocalNote}`
371
+ : `Window image ${outWidth}×${outHeight} px; screen geometry could not be probed — on a Retina Mac (2×) ` +
372
+ "window captures are 2× physical pixels, so divide read-off coordinates by 2 before clicking.";
373
+ }
374
+ return {
375
+ buffer: outBuf,
376
+ metadata,
377
+ details: {
378
+ imageWidthPx: outWidth,
379
+ imageHeightPx: outHeight,
380
+ ...(geometry
381
+ ? {
382
+ logicalWidthPt: geometry.main.width,
383
+ logicalHeightPt: geometry.main.height,
384
+ displayCount: geometry.displayCount,
385
+ primaryOriginPt: geometry.primaryOrigin,
386
+ }
387
+ : {}),
388
+ ...(scaleApplied !== undefined ? { scaleApplied } : {}),
389
+ resampled,
390
+ },
391
+ };
392
+ }
393
+ catch {
394
+ return unchanged("Note: coordinate-space normalization failed — desktop_* tools take logical screen points; " +
395
+ "on a Retina Mac (2×) divide read-off coordinates by 2 before clicking.");
396
+ }
397
+ }
179
398
  function parseLinuxDisplaysOutput(output) {
180
399
  return output
181
400
  .split("\n")
@@ -272,9 +491,9 @@ function linuxWindowScreenshot(windowId, options) {
272
491
  * System.Windows.Forms / CopyFromScreen is initialized even in sessions that
273
492
  * default to MTA (avoids COM failures).
274
493
  */
275
- function runPowerShell(psPath) {
494
+ export function runPowerShell(psPath, opts) {
276
495
  return new Promise((resolve, reject) => {
277
- const child = crossSpawn("powershell.exe", ["-NoProfile", "-Sta", "-ExecutionPolicy", "Bypass", "-File", psPath], { timeout: 15000, windowsHide: true });
496
+ const child = crossSpawn("powershell.exe", ["-NoProfile", "-Sta", "-ExecutionPolicy", "Bypass", "-File", psPath], { timeout: opts?.timeoutMs ?? 15000, windowsHide: true });
278
497
  let stdout = "";
279
498
  let stderr = "";
280
499
  child.stdout?.on("data", (chunk) => { stdout += chunk.toString(); });
@@ -412,7 +631,7 @@ function win32WindowScreenshot(hwnd, options) {
412
631
  // ===========================================================================
413
632
  // Unified API
414
633
  // ===========================================================================
415
- function screenshot(options = {}) {
634
+ export function screenshot(options = {}) {
416
635
  switch (process.platform) {
417
636
  case "darwin": return darwinFullScreenshot(options);
418
637
  case "linux": return linuxFullScreenshot(options);
@@ -420,7 +639,7 @@ function screenshot(options = {}) {
420
639
  default: return Promise.reject(new Error(`Unsupported platform: ${process.platform}`));
421
640
  }
422
641
  }
423
- function screenshotWindow(windowId, options, platform) {
642
+ export function screenshotWindow(windowId, options, platform) {
424
643
  switch (platform) {
425
644
  case "darwin": return darwinWindowScreenshot(windowId, options);
426
645
  case "linux": return linuxWindowScreenshot(windowId, options);
@@ -428,7 +647,7 @@ function screenshotWindow(windowId, options, platform) {
428
647
  default: return Promise.reject(new Error(`Window screenshot not supported on ${platform}`));
429
648
  }
430
649
  }
431
- function listWindows() {
650
+ export function listWindows() {
432
651
  switch (process.platform) {
433
652
  case "darwin": return listDarwinWindows();
434
653
  case "linux": return listLinuxWindows();
@@ -439,7 +658,7 @@ function listWindows() {
439
658
  // ===========================================================================
440
659
  // Helpers
441
660
  // ===========================================================================
442
- function formatWindowList(windows, maxRows = 40) {
661
+ export function formatWindowList(windows, maxRows = 40) {
443
662
  if (windows.length === 0)
444
663
  return "No visible windows found.";
445
664
  const rows = windows.slice(0, maxRows);
@@ -454,6 +673,41 @@ function formatWindowList(windows, maxRows = 40) {
454
673
  return `${header}\n${body}`;
455
674
  }
456
675
  // ===========================================================================
676
+ // Inline fallback sizing
677
+ // ===========================================================================
678
+ /**
679
+ * Last-resort inline carrier for a screenshot that could not be hosted.
680
+ *
681
+ * Returns the image unchanged when its base64 payload already fits
682
+ * `MAX_INLINE_IMAGE_BASE64_CHARS`, a downscaled/re-encoded copy when resizing
683
+ * gets it under the cap, and `null` when even that fails (no image resizer
684
+ * available, or the capture is absurdly large). Callers MUST treat `null` as
685
+ * "do not attach base64" and report the failure — never as "attach it anyway".
686
+ *
687
+ * Exported for tests.
688
+ */
689
+ export async function fitInlineScreenshot(image, maxBase64Chars = MAX_INLINE_IMAGE_BASE64_CHARS) {
690
+ const data = image.data;
691
+ if (!data)
692
+ return null;
693
+ if (data.length <= maxBase64Chars)
694
+ return image;
695
+ let resized = null;
696
+ try {
697
+ resized = await resizeImage(image, { maxBytes: maxBase64Chars });
698
+ }
699
+ catch {
700
+ resized = null;
701
+ }
702
+ if (!resized || resized.data.length > maxBase64Chars)
703
+ return null;
704
+ return {
705
+ type: "image",
706
+ data: resized.data,
707
+ mimeType: resized.mimeType || image.mimeType,
708
+ };
709
+ }
710
+ // ===========================================================================
457
711
  // Extension registration
458
712
  // ===========================================================================
459
713
  export default async function desktopScreenshotExtension(ext) {
@@ -477,15 +731,101 @@ export default async function desktopScreenshotExtension(ext) {
477
731
  const p = params;
478
732
  const format = p.format ?? "png";
479
733
  const mimeType = (format === "jpg" || format === "jpeg") ? "image/jpeg" : "image/png";
480
- const makeImageResult = (buf, label) => {
481
- const base64 = buf.toString("base64");
734
+ /**
735
+ * A screenshot is worth megabytes of base64. When the CLI can reach
736
+ * the backend we upload it and put only the hosted url into the
737
+ * context; otherwise we fall back to inline base64, downscaled to
738
+ * the relay cap (best effort — a storage outage must never fail
739
+ * the screenshot, but it must also never dump megabytes of base64
740
+ * into the session history).
741
+ */
742
+ const makeImageResult = async (buf, label, coordMeta) => {
482
743
  const savedPath = saveScreenshot(buf, format, ctx.cwd);
744
+ const routing = await resolveBackendRouting();
745
+ let imageBlock = {
746
+ type: "image",
747
+ data: buf.toString("base64"),
748
+ mimeType,
749
+ };
750
+ let hostedUrl;
751
+ if (routing) {
752
+ try {
753
+ const upload = await uploadImage({
754
+ backendUrl: routing.backendUrl,
755
+ token: routing.machineJwt,
756
+ // Copy: `buf` may be a pooled Buffer view.
757
+ bytes: new Uint8Array(buf),
758
+ contentType: mimeType,
759
+ kind: "attachment",
760
+ signal: _signal,
761
+ });
762
+ imageBlock = {
763
+ type: "image",
764
+ url: upload.url,
765
+ imageId: upload.imageId,
766
+ mimeType,
767
+ };
768
+ hostedUrl = upload.url;
769
+ }
770
+ catch (error) {
771
+ process.stderr.write(`[desktop_screenshot] could not host the screenshot (${error instanceof Error ? error.message : String(error)}); ` +
772
+ "falling back to a downscaled inline image.\n");
773
+ }
774
+ }
775
+ // Inline fallback (no backend routing, or the upload failed):
776
+ // a full-screen retina PNG is worth several megabytes of
777
+ // base64, which would be dumped into the tool result AND
778
+ // persisted in the session JSONL. Downscale it to fit the
779
+ // relay cap, or refuse it with a clear message instead of
780
+ // silently shipping megabytes of base64.
781
+ if (!hostedUrl) {
782
+ const fitted = await fitInlineScreenshot(imageBlock);
783
+ if (!fitted) {
784
+ const base64Chars = imageBlock.data?.length ?? 0;
785
+ return {
786
+ content: [
787
+ {
788
+ type: "text",
789
+ text: `❌ ${label} was saved to ${savedPath} but NOT attached: the image is too large to send ` +
790
+ `inline (${formatImageBytes(base64Chars)} of base64, limit ` +
791
+ `${formatImageBytes(MAX_INLINE_IMAGE_BASE64_CHARS)}) and backend hosting ` +
792
+ `${routing ? "failed" : "is not configured"}. ` +
793
+ "Retry with format=jpg, a smaller window, or once image hosting is available.",
794
+ },
795
+ ],
796
+ details: {
797
+ format,
798
+ sizeBytes: buf.length,
799
+ platform: process.platform,
800
+ savedPath,
801
+ hosted: false,
802
+ inlineTooLarge: true,
803
+ isError: true,
804
+ },
805
+ };
806
+ }
807
+ imageBlock = fitted;
808
+ }
483
809
  return {
484
810
  content: [
485
- { type: "text", text: `✓ ${label} (${format.toUpperCase()}, ${(buf.length / 1024).toFixed(1)} KB).\nSaved: ${savedPath}\nThe image is attached and will be analyzed by the vision model.` },
486
- { type: "image", data: base64, mimeType },
811
+ {
812
+ type: "text",
813
+ text: `✓ ${label} (${format.toUpperCase()}, ${(buf.length / 1024).toFixed(1)} KB).\n` +
814
+ `Saved: ${savedPath}` +
815
+ (hostedUrl ? `\nHosted: ${hostedUrl}` : "") +
816
+ (coordMeta?.metadata ? `\n\n${coordMeta.metadata}` : "") +
817
+ "\nThe image is attached and will be analyzed by the vision model.",
818
+ },
819
+ imageBlock,
487
820
  ],
488
- details: { format, sizeBytes: buf.length, platform: process.platform, savedPath },
821
+ details: {
822
+ format,
823
+ sizeBytes: buf.length,
824
+ platform: process.platform,
825
+ savedPath,
826
+ ...(hostedUrl ? { url: hostedUrl, hosted: true } : { hosted: false }),
827
+ ...(coordMeta?.details ?? {}),
828
+ },
489
829
  };
490
830
  };
491
831
  const makeError = (msg) => ({
@@ -509,11 +849,17 @@ export default async function desktopScreenshotExtension(ext) {
509
849
  details: { isError: true, searched: p.windowTitle },
510
850
  };
511
851
  }
512
- const buf = await screenshotWindow(match.id, { format }, process.platform);
513
- return makeImageResult(buf, `Window "${match.title}" (${match.owner})`);
852
+ const raw = await screenshotWindow(match.id, { format }, process.platform);
853
+ const coordMeta = process.platform === "darwin"
854
+ ? await normalizeDarwinScreenshot(raw, format, "window")
855
+ : undefined;
856
+ return await makeImageResult(coordMeta?.buffer ?? raw, `Window "${match.title}" (${match.owner})`, coordMeta);
514
857
  }
515
- const buf = await screenshot({ format });
516
- return makeImageResult(buf, "Desktop screenshot");
858
+ const raw = await screenshot({ format });
859
+ const coordMeta = process.platform === "darwin"
860
+ ? await normalizeDarwinScreenshot(raw, format, "screen")
861
+ : undefined;
862
+ return await makeImageResult(coordMeta?.buffer ?? raw, "Desktop screenshot", coordMeta);
517
863
  }
518
864
  catch (err) {
519
865
  return makeError(err instanceof Error ? err.message : String(err));
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/extensions/image-generation/index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAmB,YAAY,EAAkB,MAAM,iCAAiC,CAAC;AAyLrG,wBAA8B,wBAAwB,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAwDvF"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/extensions/image-generation/index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAmB,YAAY,EAAkB,MAAM,iCAAiC,CAAC;AA0QrG,wBAA8B,wBAAwB,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAiGvF"}