@aexol/spectral 0.9.212 → 0.9.216
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/backend/generated-images.d.ts +180 -0
- package/dist/backend/generated-images.d.ts.map +1 -0
- package/dist/backend/generated-images.js +483 -0
- package/dist/backend/machine-routing.d.ts +25 -0
- package/dist/backend/machine-routing.d.ts.map +1 -0
- package/dist/backend/machine-routing.js +35 -0
- package/dist/commands/logout.d.ts.map +1 -1
- package/dist/commands/logout.js +4 -0
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +20 -2
- package/dist/extensions/browser/browser-service.d.ts +75 -1
- package/dist/extensions/browser/browser-service.d.ts.map +1 -1
- package/dist/extensions/browser/browser-service.js +600 -34
- package/dist/extensions/browser/index.d.ts +10 -4
- package/dist/extensions/browser/index.d.ts.map +1 -1
- package/dist/extensions/browser/index.js +44 -5
- package/dist/extensions/browser/stream/stream-tool.d.ts +10 -0
- package/dist/extensions/browser/stream/stream-tool.d.ts.map +1 -1
- package/dist/extensions/browser/stream/stream-tool.js +42 -14
- package/dist/extensions/browser/tools/core.d.ts.map +1 -1
- package/dist/extensions/browser/tools/core.js +63 -2
- package/dist/extensions/desktop-control/index.d.ts +55 -0
- package/dist/extensions/desktop-control/index.d.ts.map +1 -0
- package/dist/extensions/desktop-control/index.js +633 -0
- package/dist/extensions/desktop-control/keys.d.ts +33 -0
- package/dist/extensions/desktop-control/keys.d.ts.map +1 -0
- package/dist/extensions/desktop-control/keys.js +170 -0
- package/dist/extensions/desktop-control/linux.d.ts +26 -0
- package/dist/extensions/desktop-control/linux.d.ts.map +1 -0
- package/dist/extensions/desktop-control/linux.js +195 -0
- package/dist/extensions/desktop-control/macos.d.ts +53 -0
- package/dist/extensions/desktop-control/macos.d.ts.map +1 -0
- package/dist/extensions/desktop-control/macos.js +382 -0
- package/dist/extensions/desktop-control/safety.d.ts +89 -0
- package/dist/extensions/desktop-control/safety.d.ts.map +1 -0
- package/dist/extensions/desktop-control/safety.js +157 -0
- package/dist/extensions/desktop-control/types.d.ts +79 -0
- package/dist/extensions/desktop-control/types.d.ts.map +1 -0
- package/dist/extensions/desktop-control/types.js +10 -0
- package/dist/extensions/desktop-control/windows.d.ts +31 -0
- package/dist/extensions/desktop-control/windows.d.ts.map +1 -0
- package/dist/extensions/desktop-control/windows.js +276 -0
- package/dist/extensions/desktop-screenshot/index.d.ts +103 -0
- package/dist/extensions/desktop-screenshot/index.d.ts.map +1 -1
- package/dist/extensions/desktop-screenshot/index.js +363 -17
- package/dist/extensions/image-generation/index.d.ts.map +1 -1
- package/dist/extensions/image-generation/index.js +133 -34
- package/dist/extensions/seo/index.d.ts.map +1 -1
- package/dist/extensions/seo/index.js +4 -0
- package/dist/extensions/seo/tools/backend-bridge.d.ts +4 -0
- package/dist/extensions/seo/tools/backend-bridge.d.ts.map +1 -0
- package/dist/extensions/seo/tools/backend-bridge.js +378 -0
- package/dist/extensions/seo/tools/full-audit.d.ts +2 -0
- package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
- package/dist/extensions/seo/tools/full-audit.js +1 -1
- package/dist/extensions/seo/tools/report.d.ts +10 -0
- package/dist/extensions/seo/tools/report.d.ts.map +1 -1
- package/dist/extensions/seo/tools/report.js +1 -1
- package/dist/extensions/spectral-vision-fallback.d.ts.map +1 -1
- package/dist/extensions/spectral-vision-fallback.js +11 -1
- package/dist/generated/zeus/const.d.ts.map +1 -1
- package/dist/generated/zeus/const.js +121 -4
- package/dist/generated/zeus/index.d.ts +527 -0
- package/dist/generated/zeus/index.d.ts.map +1 -1
- package/dist/mcp/tool-registrar.d.ts.map +1 -1
- package/dist/mcp/tool-registrar.js +5 -2
- package/dist/mcp/types.d.ts +5 -0
- package/dist/mcp/types.d.ts.map +1 -1
- package/dist/relay/dispatcher.d.ts +12 -1
- package/dist/relay/dispatcher.d.ts.map +1 -1
- package/dist/relay/dispatcher.js +44 -42
- package/dist/sdk/ai/providers/faux.js +1 -1
- package/dist/sdk/ai/providers/openai-completions.d.ts.map +1 -1
- package/dist/sdk/ai/providers/openai-completions.js +69 -16
- package/dist/sdk/ai/providers/openrouter-images.d.ts.map +1 -1
- package/dist/sdk/ai/providers/openrouter-images.js +58 -9
- package/dist/sdk/ai/providers/simple-options.d.ts.map +1 -1
- package/dist/sdk/ai/providers/simple-options.js +2 -0
- package/dist/sdk/ai/types.d.ts +37 -1
- package/dist/sdk/ai/types.d.ts.map +1 -1
- package/dist/sdk/ai/utils/hosted-image-resolver.d.ts +60 -0
- package/dist/sdk/ai/utils/hosted-image-resolver.d.ts.map +1 -0
- package/dist/sdk/ai/utils/hosted-image-resolver.js +75 -0
- package/dist/sdk/ai/utils/image-source.d.ts +168 -0
- package/dist/sdk/ai/utils/image-source.d.ts.map +1 -0
- package/dist/sdk/ai/utils/image-source.js +487 -0
- package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/native-extensions.js +14 -0
- package/dist/sdk/coding-agent/core/extensions/types.d.ts +9 -0
- package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/system-prompt.js +15 -9
- package/dist/sdk/coding-agent/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/sdk/coding-agent/modes/rpc/rpc-mode.js +16 -3
- package/dist/sdk/coding-agent/utils/image-resize.d.ts.map +1 -1
- package/dist/sdk/coding-agent/utils/image-resize.js +7 -3
- package/dist/server/agent-bridge.d.ts +14 -0
- package/dist/server/agent-bridge.d.ts.map +1 -1
- package/dist/server/agent-bridge.js +105 -26
- package/dist/server/handlers/queue.d.ts.map +1 -1
- package/dist/server/handlers/queue.js +11 -34
- package/dist/server/image-attachments.d.ts +109 -0
- package/dist/server/image-attachments.d.ts.map +1 -0
- package/dist/server/image-attachments.js +195 -0
- package/dist/server/session-stream.d.ts +67 -0
- package/dist/server/session-stream.d.ts.map +1 -1
- package/dist/server/session-stream.js +222 -23
- package/dist/server/storage.d.ts.map +1 -1
- package/dist/server/storage.js +10 -31
- package/dist/server/ui/terminal-confirm.d.ts +145 -0
- package/dist/server/ui/terminal-confirm.d.ts.map +1 -0
- package/dist/server/ui/terminal-confirm.js +333 -0
- package/dist/server/wire.d.ts +30 -5
- package/dist/server/wire.d.ts.map +1 -1
- 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
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
222
|
-
|
|
309
|
+
{
|
|
310
|
+
type: "text",
|
|
311
|
+
text: resultText(images.length, response.model, response.usage?.credits, savedPaths, outcomes),
|
|
312
|
+
},
|
|
313
|
+
...blocks,
|
|
223
314
|
],
|
|
224
|
-
details: {
|
|
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;
|
|
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 @@
|
|
|
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;
|
|
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 = [];
|