@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
@@ -2,13 +2,14 @@ import { Type } from "typebox";
2
2
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { extname, join, resolve } from "node:path";
4
4
  import { generateImages, getImageModel, getImageModels } from "../../sdk/ai/index.js";
5
- // App-layer routing helpers (mirror aexol-mcp.ts): obtain backendUrl +
6
- // machineJwt the same way agent-bridge.ts gets them for text models, so image
7
- // generation routes through the backend proxy (POST /image/generation) using
8
- // the backend's own OPENROUTER_API_KEY instead of a local env key.
9
- import { getApiUrl, readConfig } from "../../config.js";
10
- import { loadMachine } from "../../relay/machine-store.js";
11
- import { isJwtExpired } from "../../relay/registration.js";
5
+ import { base64FromImageData } from "../../sdk/ai/utils/image-source.js";
6
+ import { uploadImage } from "../../backend/generated-images.js";
7
+ // Backend routing (backendUrl + machineJwt) is resolved the same way
8
+ // agent-bridge.ts gets them for text models, so image generation routes
9
+ // through the backend proxy (POST /image/generation) using the backend's own
10
+ // OPENROUTER_API_KEY instead of a local env key. Shared with every other
11
+ // CLI-side image hoster (e.g. desktop screenshots).
12
+ import { resolveBackendRouting } from "../../backend/machine-routing.js";
12
13
  const imageReferenceSchema = Type.Union([
13
14
  Type.String({
14
15
  description: "Path to a reference image file (relative to the project working directory or absolute).",
@@ -132,7 +133,10 @@ function saveGeneratedImages(images, prompt, cwd) {
132
133
  mkdirSync(dir, { recursive: true });
133
134
  const base = slugFromPrompt(prompt);
134
135
  const ts = timestamp();
135
- return images.map((image, index) => {
136
+ return images.flatMap((image, index) => {
137
+ // URL-only images are not persisted locally (no bytes available here).
138
+ if (!image.data)
139
+ return [];
136
140
  const ext = extensionForMimeType(image.mimeType);
137
141
  const filename = `${base}-${ts}-${String(index + 1).padStart(2, "0")}.${ext}`;
138
142
  const filepath = join(dir, filename);
@@ -140,7 +144,60 @@ function saveGeneratedImages(images, prompt, cwd) {
140
144
  return filepath;
141
145
  });
142
146
  }
143
- function resultText(count, model, credits, savedPaths) {
147
+ function bytesFromImageData(data) {
148
+ return new Uint8Array(Buffer.from(base64FromImageData(data), "base64"));
149
+ }
150
+ /**
151
+ * Make every generated image reachable through a backend URL so the session
152
+ * can carry a URL instead of base64.
153
+ *
154
+ * Images the backend already stored (`store: true` on `/image/generation`) are
155
+ * passed through untouched — no upload-url, no PUT, no `/confirm`. Only images
156
+ * that came back as base64 (fallback: object storage unavailable) are uploaded
157
+ * from here. Best effort by design: a storage outage (503, network) must not
158
+ * fail image generation — the caller falls back to the local file + inline
159
+ * bytes and annotates the result text.
160
+ *
161
+ * The returned array is aligned 1:1 with `images`.
162
+ */
163
+ async function hostGeneratedImages(images, routing, signal) {
164
+ return Promise.all(images.map(async (image) => {
165
+ // Already stored server-side: the bytes never travelled to the CLI.
166
+ if (image.url && image.imageId)
167
+ return { hosted: { url: image.url, imageId: image.imageId } };
168
+ if (!routing)
169
+ return { error: "not routed through the backend (image kept local)" };
170
+ if (!image.data)
171
+ return { error: "image has no inline bytes (kept local)" };
172
+ try {
173
+ const result = await uploadImage({
174
+ backendUrl: routing.backendUrl,
175
+ token: routing.machineJwt,
176
+ bytes: bytesFromImageData(image.data),
177
+ contentType: image.mimeType || "image/png",
178
+ kind: "generated",
179
+ signal,
180
+ });
181
+ return { result };
182
+ }
183
+ catch (error) {
184
+ return { error: error instanceof Error ? error.message : String(error) };
185
+ }
186
+ }));
187
+ }
188
+ /** Whether the image ended up reachable through a backend URL. */
189
+ function isHosted(outcome) {
190
+ return Boolean(outcome.hosted ?? outcome.result);
191
+ }
192
+ /** Backend-relative url of a hosted image (server-stored or CLI-uploaded). */
193
+ function hostedUrl(outcome) {
194
+ return outcome.hosted?.url ?? outcome.result.url;
195
+ }
196
+ /** Backend image id of a hosted image. */
197
+ function hostedId(outcome) {
198
+ return outcome.hosted?.imageId ?? outcome.result.imageId;
199
+ }
200
+ function resultText(count, model, credits, savedPaths, outcomes) {
144
201
  const lines = [`✓ Generated ${count} image${count === 1 ? "" : "s"}`, `Model: ${model}`];
145
202
  lines.push(`Cost: ${credits ?? 0} credits`);
146
203
  if (savedPaths.length > 0) {
@@ -148,29 +205,29 @@ function resultText(count, model, credits, savedPaths) {
148
205
  for (const path of savedPaths)
149
206
  lines.push(`- ${path}`);
150
207
  }
151
- return lines.join("\n");
152
- }
153
- /**
154
- * Resolve backend routing for image generation, mirroring how agent-bridge.ts
155
- * obtains `backendUrl` + `machineJwt` for text models. The machine JWT is the
156
- * same token the CLI registers with (see serve.ts → registration.record), so
157
- * this is exactly equivalent to `AgentBridgeOptions.machineJwt`.
158
- *
159
- * Returns `null` when routing cannot be resolved (e.g. not logged in); the
160
- * caller then falls back to the SDK's local-key path.
161
- */
162
- async function resolveBackendRouting() {
163
- const cfg = await readConfig();
164
- if (!cfg)
165
- return null;
166
- const machine = await loadMachine();
167
- if (machine?.machineJwt && !isJwtExpired(machine.machineJwt)) {
168
- const backendUrl = process.env.SPECTRAL_BACKEND_URL ||
169
- getApiUrl(cfg.apiUrl).replace(/\/mcp\/?$/, "");
170
- return { backendUrl, machineJwt: machine.machineJwt };
208
+ // Images the backend stored itself never get a local copy, because the
209
+ // bytes were never sent to the CLI — say so instead of staying silent.
210
+ const serverStored = outcomes.filter((outcome) => outcome.hosted);
211
+ if (serverStored.length > 0) {
212
+ lines.push(`Stored in backend object storage: ${serverStored.length} image${serverStored.length === 1 ? "" : "s"}` +
213
+ (savedPaths.length === 0 ? " (no local copy written)" : ""));
214
+ }
215
+ const hosted = outcomes.filter(isHosted);
216
+ if (hosted.length > 0) {
217
+ lines.push("Hosted:");
218
+ for (const outcome of hosted)
219
+ lines.push(`- ${hostedUrl(outcome)}`);
220
+ }
221
+ const failed = outcomes.filter((outcome) => !outcome.hosted && !outcome.result && outcome.error);
222
+ if (failed.length > 0) {
223
+ // Explicit annotation: the image bytes were NOT uploaded, so they exist
224
+ // only as local files (and inline base64 in this result).
225
+ lines.push(`⚠️ ${failed.length} image${failed.length === 1 ? "" : "s"} could not be uploaded to cloud storage and ` +
226
+ "exist only locally (paths above); remote URL unavailable.");
227
+ for (const upload of failed)
228
+ lines.push(`- ${upload.error}`);
171
229
  }
172
- // No usable machine JWT — let the SDK fall back to local OPENROUTER_API_KEY.
173
- return null;
230
+ return lines.join("\n");
174
231
  }
175
232
  export default async function imageGenerationExtension(ext) {
176
233
  const tool = {
@@ -204,6 +261,12 @@ export default async function imageGenerationExtension(ext) {
204
261
  const routing = await resolveBackendRouting();
205
262
  const response = await generateImages(model, context, {
206
263
  ...input,
264
+ // Ask the backend proxy to keep the bytes in its own object
265
+ // storage: they are already there after OpenRouter answered, so
266
+ // this avoids shipping base64 to the CLI and uploading it back.
267
+ // The proxy degrades to base64 on its own when storage is
268
+ // unavailable, in which case the upload below takes over.
269
+ store: true,
207
270
  signal,
208
271
  ...(routing ? { backendUrl: routing.backendUrl, apiKey: routing.machineJwt } : {}),
209
272
  });
@@ -215,13 +278,49 @@ export default async function imageGenerationExtension(ext) {
215
278
  };
216
279
  }
217
280
  const images = response.output.filter((item) => item.type === "image");
281
+ // Local copy first — for images that actually came back as bytes.
282
+ // Server-stored images have no `data` here, so they are skipped
283
+ // (and reported as such in the result text). It remains the
284
+ // fallback when the backend storage is unavailable, never a hard
285
+ // error.
218
286
  const savedPaths = saveGeneratedImages(images, input.prompt, ctx.cwd);
287
+ const outcomes = await hostGeneratedImages(images, routing, signal);
288
+ // Swap base64 for the hosted URL: the session keeps `{ url, imageId }`
289
+ // and never stores `data`. Images that failed to upload keep their
290
+ // inline bytes (fallback, annotated in the result text).
291
+ let imageCursor = 0;
292
+ const blocks = response.output.map((item) => {
293
+ if (item.type !== "image")
294
+ return item;
295
+ const outcome = outcomes[imageCursor++];
296
+ if (isHosted(outcome)) {
297
+ return {
298
+ type: "image",
299
+ url: hostedUrl(outcome),
300
+ imageId: hostedId(outcome),
301
+ mimeType: item.mimeType,
302
+ };
303
+ }
304
+ return item;
305
+ });
306
+ const hosted = outcomes.filter(isHosted);
219
307
  return {
220
308
  content: [
221
- { type: "text", text: resultText(images.length, response.model, response.usage?.credits, savedPaths) },
222
- ...response.output,
309
+ {
310
+ type: "text",
311
+ text: resultText(images.length, response.model, response.usage?.credits, savedPaths, outcomes),
312
+ },
313
+ ...blocks,
223
314
  ],
224
- details: { isError: false, model: response.model, count: images.length, usage: response.usage, savedPaths },
315
+ details: {
316
+ isError: false,
317
+ model: response.model,
318
+ count: images.length,
319
+ usage: response.usage,
320
+ savedPaths,
321
+ imageIds: hosted.map(hostedId),
322
+ urls: hosted.map(hostedUrl),
323
+ },
225
324
  };
226
325
  },
227
326
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/extensions/seo/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAoBpE,wBAA8B,YAAY,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA6D3E"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/extensions/seo/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAqBpE,wBAA8B,YAAY,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA8D3E"}
@@ -16,6 +16,7 @@ import { registerCrawlTools } from "./tools/crawl.js";
16
16
  import { registerKeywordTools } from "./tools/keywords.js";
17
17
  import { registerPerformanceTools } from "./tools/performance.js";
18
18
  import { registerBatchTools } from "./tools/batch.js";
19
+ import { registerBackendBridgeTools } from "./tools/backend-bridge.js";
19
20
  export default async function seoExtension(ext) {
20
21
  const config = loadSeoConfig();
21
22
  // Clean up resources on session shutdown.
@@ -59,6 +60,7 @@ export default async function seoExtension(ext) {
59
60
  { name: "keywords", fn: () => registerKeywordTools(ext, config) },
60
61
  { name: "performance", fn: () => registerPerformanceTools(ext, config) },
61
62
  { name: "batch", fn: () => registerBatchTools(ext, config) },
63
+ { name: "backend-bridge", fn: () => registerBackendBridgeTools(ext, config) },
62
64
  ];
63
65
  let totalRegistered = 0;
64
66
  for (const registrar of registrars) {
@@ -101,6 +103,8 @@ const SEO_TOOL_NAMES = [
101
103
  "seo_keyword_density",
102
104
  "seo_readability",
103
105
  "seo_content_optimize",
106
+ "seo_record_audit",
107
+ "seo_list_audits",
104
108
  "seo_performance",
105
109
  "seo_page_weight",
106
110
  "seo_render_blocking",
@@ -0,0 +1,4 @@
1
+ import type { ExtensionAPI } from "../../../sdk/coding-agent/index.js";
2
+ import type { SeoConfig } from "../config.js";
3
+ export declare function registerBackendBridgeTools(ext: ExtensionAPI, config: SeoConfig): void;
4
+ //# sourceMappingURL=backend-bridge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backend-bridge.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/tools/backend-bridge.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AA4I9C,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,GAAG,IAAI,CAmXrF"}
@@ -0,0 +1,378 @@
1
+ import { runFullAudit } from "./full-audit.js";
2
+ import { generateMarkdownReport } from "./report.js";
3
+ import { errorResult, textResult } from "./helpers.js";
4
+ // ── Auth resolution (mirrors kanban-bridge pattern) ──
5
+ import { getApiUrl, readConfig } from "../../../config.js";
6
+ import { ensureAuthenticated } from "../../../auth-helper.js";
7
+ import { loadMachine } from "../../../relay/machine-store.js";
8
+ import { isJwtExpired } from "../../../relay/registration.js";
9
+ let _reauthAttempted = false;
10
+ async function resolveSeoBridgeConfig() {
11
+ const cfg = await readConfig();
12
+ if (!cfg) {
13
+ if (!_reauthAttempted) {
14
+ _reauthAttempted = true;
15
+ process.stderr.write("[seo-bridge] No config found. Attempting OAuth login...\n");
16
+ const result = await ensureAuthenticated();
17
+ if (result.ok && result.config) {
18
+ return resolveSeoBridgeConfig();
19
+ }
20
+ }
21
+ process.stderr.write("[seo-bridge] No config found. Run `spectral login`.\n");
22
+ return null;
23
+ }
24
+ if (cfg.teamApiKey && !cfg.userJwt && !_reauthAttempted) {
25
+ _reauthAttempted = true;
26
+ process.stderr.write("[seo-bridge] Team API key found but no user JWT. SEO audit requires user auth — skip for now.\n");
27
+ return null;
28
+ }
29
+ let token = "";
30
+ const machine = await loadMachine();
31
+ if (machine?.machineJwt && !isJwtExpired(machine.machineJwt)) {
32
+ token = machine.machineJwt;
33
+ process.stderr.write(`[seo-bridge] Using machine JWT (machine: ${machine.machineName}).\n`);
34
+ }
35
+ else if (cfg.userJwt) {
36
+ token = cfg.userJwt;
37
+ process.stderr.write("[seo-bridge] Using user JWT.\n");
38
+ }
39
+ else {
40
+ process.stderr.write("[seo-bridge] No valid auth token. Run `spectral login`.\n");
41
+ return null;
42
+ }
43
+ const backendUrl = getApiUrl(cfg.apiUrl);
44
+ return { token, backendUrl };
45
+ }
46
+ // ── GraphQL helpers ──
47
+ class GraphQLError extends Error {
48
+ status;
49
+ constructor(message, status) {
50
+ super(message);
51
+ this.name = "GraphQLError";
52
+ this.status = status;
53
+ }
54
+ }
55
+ async function graphqlRequest(backendUrl, token, query, variables) {
56
+ const body = JSON.stringify({ query, variables });
57
+ const res = await fetch(`${backendUrl}/graphql`, {
58
+ method: "POST",
59
+ headers: {
60
+ "Content-Type": "application/json",
61
+ Accept: "application/json",
62
+ Authorization: `Bearer ${token}`,
63
+ },
64
+ body,
65
+ signal: AbortSignal.timeout(30_000),
66
+ });
67
+ if (!res.ok) {
68
+ let detail = "";
69
+ try {
70
+ detail = (await res.text()).slice(0, 300);
71
+ }
72
+ catch { /* */ }
73
+ throw new GraphQLError(`HTTP ${res.status} from GraphQL${detail ? `: ${detail}` : ""}`, res.status);
74
+ }
75
+ const rawBody = await res.text();
76
+ let json;
77
+ try {
78
+ json = JSON.parse(rawBody);
79
+ }
80
+ catch {
81
+ throw new GraphQLError(`GraphQL returned non-JSON: ${rawBody.slice(0, 200)}`);
82
+ }
83
+ if (json.errors && json.errors.length > 0) {
84
+ throw new GraphQLError(json.errors.map((e) => e.message).join("; "));
85
+ }
86
+ return json.data;
87
+ }
88
+ const AUDIT_TASK_FRAGMENT = `
89
+ id
90
+ url
91
+ status
92
+ score
93
+ summary
94
+ result
95
+ report
96
+ error
97
+ createdAt
98
+ completedAt
99
+ `;
100
+ // ── Tool registration ──
101
+ export function registerBackendBridgeTools(ext, config) {
102
+ const processAuditQueueTool = {
103
+ name: "seo_process_audit_queue",
104
+ label: "Process Pending SEO Audits",
105
+ description: "Poll the Aexol Studio backend for pending SEO audit tasks, run each audit locally, " +
106
+ "generate a formatted report, and push the results back to the backend. " +
107
+ "This bridges the gap between the landing UI (where users request audits) and " +
108
+ "the local CLI extension (which runs the actual offline SEO analysis). " +
109
+ "Requires an active `spectral login` session.",
110
+ promptSnippet: "`seo_process_audit_queue` — process all pending SEO audit tasks from Studio",
111
+ promptGuidelines: [
112
+ "When a user asks to run pending SEO audits, process the audit queue, check for SEO tasks, or sync audit results to the backend, use seo_process_audit_queue.",
113
+ ],
114
+ parameters: {
115
+ type: "object",
116
+ properties: {
117
+ limit: {
118
+ type: "number",
119
+ description: "Maximum number of pending audits to process. Default: 5.",
120
+ },
121
+ status: {
122
+ type: "string",
123
+ description: "Filter by status. Default: 'PENDING'.",
124
+ },
125
+ },
126
+ required: [],
127
+ },
128
+ async execute(_toolCallId, params) {
129
+ const { limit: rawLimit, status: rawStatus } = (params ?? {});
130
+ const limit = rawLimit ?? 5;
131
+ const status = rawStatus ?? "PENDING";
132
+ try {
133
+ // 1. Resolve auth
134
+ const auth = await resolveSeoBridgeConfig();
135
+ if (!auth) {
136
+ return errorResult("Not authenticated. Run `spectral login` first, then retry.");
137
+ }
138
+ // 2. Fetch pending tasks
139
+ const data = await graphqlRequest(auth.backendUrl, auth.token, `query($limit: Int, $status: String) {
140
+ seoAuditTasks(limit: $limit, status: $status) {
141
+ ${AUDIT_TASK_FRAGMENT}
142
+ }
143
+ }`, { limit, status });
144
+ const tasks = data.seoAuditTasks;
145
+ if (!tasks || tasks.length === 0) {
146
+ return textResult("No pending SEO audit tasks found.");
147
+ }
148
+ // 3. Process each task
149
+ const results = [];
150
+ let succeeded = 0;
151
+ let failed = 0;
152
+ for (const task of tasks) {
153
+ try {
154
+ process.stderr.write(`[seo-bridge] Running audit for ${task.url} (task ${task.id})...\n`);
155
+ // Update status to RUNNING
156
+ await graphqlRequest(auth.backendUrl, auth.token, `mutation($id: ID!) {
157
+ seoStartAuditProcessing(id: $id) { id status }
158
+ }`, { id: task.id });
159
+ // Run the full audit
160
+ const auditResult = await runFullAudit(task.url, config, [
161
+ "technical",
162
+ "content",
163
+ "schema",
164
+ "images",
165
+ "preload",
166
+ "agentUx",
167
+ "performance",
168
+ ]);
169
+ // Generate markdown report
170
+ const report = generateMarkdownReport(auditResult, config);
171
+ // Push results back
172
+ const score = auditResult.overallScore;
173
+ const summary = auditResult.summary;
174
+ await graphqlRequest(auth.backendUrl, auth.token, `mutation($id: ID!, $score: Int!, $summary: String!, $result: JSON!, $report: String) {
175
+ seoCompleteAudit(id: $id, score: $score, summary: $summary, result: $result, report: $report) {
176
+ id status
177
+ }
178
+ }`, {
179
+ id: task.id,
180
+ score,
181
+ summary,
182
+ result: auditResult,
183
+ report,
184
+ });
185
+ succeeded++;
186
+ results.push(`✓ ${task.url} — Score: ${score}/100 — ${auditResult.allIssues.length} issues`);
187
+ process.stderr.write(`[seo-bridge] ✓ Completed ${task.url} (score: ${score}/100)\n`);
188
+ }
189
+ catch (err) {
190
+ failed++;
191
+ const errMsg = err instanceof Error ? err.message : String(err);
192
+ results.push(`✗ ${task.url} — Error: ${errMsg}`);
193
+ process.stderr.write(`[seo-bridge] ✗ Failed ${task.url}: ${errMsg}\n`);
194
+ // Report failure to backend
195
+ try {
196
+ await graphqlRequest(auth.backendUrl, auth.token, `mutation($id: ID!, $error: String!) {
197
+ seoFailAudit(id: $id, error: $error) { id status }
198
+ }`, { id: task.id, error: errMsg.slice(0, 500) });
199
+ }
200
+ catch {
201
+ // Best effort
202
+ }
203
+ }
204
+ }
205
+ const summary = [
206
+ `SEO Audit Queue — Processed ${tasks.length} task(s)`,
207
+ `Succeeded: ${succeeded} | Failed: ${failed}`,
208
+ "",
209
+ ...results,
210
+ ].join("\n");
211
+ return textResult(summary, { succeeded, failed, results });
212
+ }
213
+ catch (err) {
214
+ return errorResult(err instanceof Error ? err.message : String(err));
215
+ }
216
+ },
217
+ };
218
+ ext.registerTool(processAuditQueueTool);
219
+ const recordAuditTool = {
220
+ name: "seo_record_audit",
221
+ label: "Run SEO Audit & Save to Studio",
222
+ description: "Run a full SEO audit on a URL and save the results to the Aexol Studio backend " +
223
+ "so they appear on the SEO dashboard. This runs all 7 audit categories (technical, " +
224
+ "content, schema, images, preload, agent UX, performance) and persists the score, " +
225
+ "summary, and full report. Use this when the user asks to audit a site and wants " +
226
+ "results saved for later reference.",
227
+ parameters: {
228
+ type: "object",
229
+ properties: {
230
+ url: {
231
+ type: "string",
232
+ description: "The URL to audit (must include https://). Example: https://aexol.com",
233
+ },
234
+ },
235
+ required: ["url"],
236
+ },
237
+ execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
238
+ const { url } = (params ?? {});
239
+ if (!url) {
240
+ return errorResult("Missing required parameter: url");
241
+ }
242
+ try {
243
+ const auth = await resolveSeoBridgeConfig();
244
+ if (!auth) {
245
+ return errorResult("Not authenticated. Run `spectral login` first, then retry.");
246
+ }
247
+ // Step 1: Create PENDING task
248
+ process.stderr.write(`[seo-bridge] Creating PENDING audit task for ${url}...\n`);
249
+ const startResp = await graphqlRequest(auth.backendUrl, auth.token, `mutation($url: String!) {
250
+ seoStartAudit(url: $url) { id url status }
251
+ }`, { url });
252
+ const taskId = startResp.seoStartAudit.id;
253
+ process.stderr.write(`[seo-bridge] Created PENDING task ${taskId} for ${url}\n`);
254
+ // Step 2: Transition to RUNNING
255
+ await graphqlRequest(auth.backendUrl, auth.token, `mutation($id: ID!) {
256
+ seoStartAuditProcessing(id: $id) { id status }
257
+ }`, { id: taskId });
258
+ process.stderr.write(`[seo-bridge] Task ${taskId} is now RUNNING\n`);
259
+ // Step 3: Run the local audit
260
+ process.stderr.write(`[seo-bridge] Running full audit on ${url}...\n`);
261
+ let auditResult;
262
+ let report;
263
+ let score;
264
+ let summary;
265
+ try {
266
+ auditResult = await runFullAudit(url, config, [
267
+ "technical",
268
+ "content",
269
+ "schema",
270
+ "images",
271
+ "preload",
272
+ "agentUx",
273
+ "performance",
274
+ ]);
275
+ report = generateMarkdownReport(auditResult, config);
276
+ score = auditResult.overallScore;
277
+ summary = auditResult.summary;
278
+ }
279
+ catch (auditErr) {
280
+ const errMsg = auditErr instanceof Error ? auditErr.message : String(auditErr);
281
+ await graphqlRequest(auth.backendUrl, auth.token, `mutation($id: ID!, $error: String!) {
282
+ seoFailAudit(id: $id, error: $error) { id url status error }
283
+ }`, { id: taskId, error: errMsg.slice(0, 500) }).catch(() => { });
284
+ throw auditErr;
285
+ }
286
+ process.stderr.write(`[seo-bridge] Audit complete — Score: ${score}/100, ${auditResult.allIssues.length} issues. Saving to backend...\n`);
287
+ // Step 4: Complete the audit
288
+ await graphqlRequest(auth.backendUrl, auth.token, `mutation($id: ID!, $score: Int!, $summary: String!, $result: JSON!, $report: String) {
289
+ seoCompleteAudit(id: $id, score: $score, summary: $summary, result: $result, report: $report) {
290
+ id url status score
291
+ }
292
+ }`, {
293
+ id: taskId,
294
+ score,
295
+ summary,
296
+ result: auditResult,
297
+ report,
298
+ });
299
+ process.stderr.write(`[seo-bridge] \u2713 Saved audit for ${url} to Studio.\n`);
300
+ return textResult(`\u2713 SEO audit complete and saved for ${url}\n` +
301
+ ` Score: ${score}/100\n` +
302
+ ` Issues: ${auditResult.allIssues.length}\n` +
303
+ ` Summary: ${summary}\n\n` +
304
+ `View the full report on your SEO dashboard at https://aexol.com/account/admin/seo/`, { url, score, issues: auditResult.allIssues.length, summary });
305
+ }
306
+ catch (err) {
307
+ const errMsg = err instanceof Error ? err.message : String(err);
308
+ process.stderr.write(`[seo-bridge] \u2717 Failed to record audit for ${url}: ${errMsg}\n`);
309
+ return errorResult(`Failed to audit ${url}: ${errMsg}`);
310
+ }
311
+ },
312
+ };
313
+ const listAuditsTool = {
314
+ name: "seo_list_audits",
315
+ label: "List Past SEO Audits",
316
+ description: "Query past SEO audits from the Aexol Studio backend. Use this to check if a URL " +
317
+ "has already been audited, compare scores over time, or reference previous findings. " +
318
+ "Returns audit id, url, score, issue count, and date for each result.",
319
+ parameters: {
320
+ type: "object",
321
+ properties: {
322
+ limit: {
323
+ type: "number",
324
+ description: "Maximum number of audits to return. Default: 10.",
325
+ },
326
+ url: {
327
+ type: "string",
328
+ description: "Filter by URL (partial match supported). Use to check if a specific site has been audited.",
329
+ },
330
+ status: {
331
+ type: "string",
332
+ description: "Filter by status. Default: COMPLETED. Use for finding pending/failed audits.",
333
+ },
334
+ },
335
+ },
336
+ execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
337
+ const { limit: rawLimit, url: urlFilter, status: rawStatus } = (params ?? {});
338
+ const limit = rawLimit ?? 10;
339
+ const status = rawStatus ?? "COMPLETED";
340
+ try {
341
+ const auth = await resolveSeoBridgeConfig();
342
+ if (!auth) {
343
+ return errorResult("Not authenticated. Run `spectral login` first, then retry.");
344
+ }
345
+ const data = await graphqlRequest(auth.backendUrl, auth.token, `query($limit: Int, $status: String) {
346
+ seoAuditTasks(limit: $limit, status: $status) {
347
+ ${AUDIT_TASK_FRAGMENT}
348
+ }
349
+ }`, { limit, status });
350
+ const tasks = data.seoAuditTasks;
351
+ let filtered = tasks ?? [];
352
+ if (urlFilter) {
353
+ const lower = urlFilter.toLowerCase();
354
+ filtered = filtered.filter((t) => t.url.toLowerCase().includes(lower));
355
+ }
356
+ if (filtered.length === 0) {
357
+ const hint = urlFilter
358
+ ? `No audits found matching "${urlFilter}".`
359
+ : "No past audits found. Run `seo_audit` first, then use `seo_record_audit` to save results.";
360
+ return textResult(hint);
361
+ }
362
+ const lines = filtered.map((t) => {
363
+ const scoreStr = t.score != null ? `${t.score}/100` : "\u2014";
364
+ const dateStr = t.completedAt
365
+ ? new Date(t.completedAt).toISOString().split("T")[0]
366
+ : new Date(t.createdAt).toISOString().split("T")[0];
367
+ return ` \u2022 ${t.url} \u2014 Score: ${scoreStr} \u2014 ${t.status} \u2014 ${dateStr} (id: ${t.id})`;
368
+ });
369
+ return textResult(`Past SEO Audits (${filtered.length} result${filtered.length !== 1 ? "s" : ""}):\n\n${lines.join("\n")}`);
370
+ }
371
+ catch (err) {
372
+ return errorResult(err instanceof Error ? err.message : String(err));
373
+ }
374
+ },
375
+ };
376
+ ext.registerTool(recordAuditTool);
377
+ ext.registerTool(listAuditsTool);
378
+ }
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import type { ExtensionAPI } from "../../../sdk/coding-agent/index.js";
8
8
  import type { SeoConfig } from "../config.js";
9
+ import type { FullAuditResult } from "../types.js";
9
10
  export type AuditCategory = "technical" | "content" | "schema" | "images" | "preload" | "agentUx" | "performance";
10
11
  export declare function registerFullAuditTools(ext: ExtensionAPI, config: SeoConfig): void;
12
+ export declare function runFullAudit(url: string, config: SeoConfig, categories: AuditCategory[]): Promise<FullAuditResult>;
11
13
  //# sourceMappingURL=full-audit.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"full-audit.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/tools/full-audit.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAe9C,MAAM,MAAM,aAAa,GAAG,WAAW,GAAG,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,CAAC;AAIlH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,GAAG,IAAI,CAqEjF"}
1
+ {"version":3,"file":"full-audit.d.ts","sourceRoot":"","sources":["../../../../src/extensions/seo/tools/full-audit.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAc,eAAe,EAAkC,MAAM,aAAa,CAAC;AAc/F,MAAM,MAAM,aAAa,GAAG,WAAW,GAAG,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,CAAC;AAIlH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,GAAG,IAAI,CAqEjF;AAED,wBAAsB,YAAY,CAChC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,SAAS,EACjB,UAAU,EAAE,aAAa,EAAE,GAC1B,OAAO,CAAC,eAAe,CAAC,CAsa1B"}
@@ -74,7 +74,7 @@ export function registerFullAuditTools(ext, config) {
74
74
  };
75
75
  ext.registerTool(fullAuditTool);
76
76
  }
77
- async function runFullAudit(url, config, categories) {
77
+ export async function runFullAudit(url, config, categories) {
78
78
  // Fetch the page once and reuse for all sub-audits.
79
79
  const fetchResult = await fetchPage(url, config);
80
80
  const allIssues = [];