gazesight 0.3.5 → 0.5.0
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/README.md +22 -12
- package/dist/chrome/background.js +7 -7
- package/dist/chrome/content.js +7 -7
- package/dist/chrome/manifest.json +2 -2
- package/dist/chrome/options.css +9 -0
- package/dist/chrome/options.html +14 -0
- package/dist/chrome/options.js +38 -13
- package/dist/chrome/popup.css +5 -0
- package/dist/chrome/popup.html +9 -2
- package/dist/chrome/popup.js +1 -1
- package/dist/cli/index.js +1474 -62
- package/dist/firefox/background.js +1 -1
- package/dist/firefox/content.js +6 -6
- package/dist/firefox/manifest.json +2 -2
- package/dist/firefox/options.css +9 -0
- package/dist/firefox/options.html +14 -0
- package/dist/firefox/options.js +54 -29
- package/dist/firefox/popup.css +5 -0
- package/dist/firefox/popup.html +9 -2
- package/dist/firefox/popup.js +1 -1
- package/dist/gazesight-firefox.xpi +0 -0
- package/dist/mcp/index.js +717 -36
- package/dist/worker/visual-diff.js +21 -4
- package/package.json +2 -2
package/dist/mcp/index.js
CHANGED
|
@@ -7,7 +7,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
|
7
7
|
import { z as z2 } from "zod";
|
|
8
8
|
|
|
9
9
|
// apps/cli/src/config.ts
|
|
10
|
-
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
11
12
|
|
|
12
13
|
// apps/cli/src/config-browser.ts
|
|
13
14
|
import { z } from "zod";
|
|
@@ -18,6 +19,17 @@ var agentGuidanceSchema = z.strictObject({
|
|
|
18
19
|
var localConfigSchema = z.strictObject({
|
|
19
20
|
token: z.string().regex(/^[a-f0-9]{64}$/),
|
|
20
21
|
port: z.number().int().min(1024).max(65535),
|
|
22
|
+
pairings: z.array(
|
|
23
|
+
z.strictObject({
|
|
24
|
+
id: z.string().uuid(),
|
|
25
|
+
browser: z.enum(["firefox", "chrome"]),
|
|
26
|
+
extensionId: z.string().min(1).max(256),
|
|
27
|
+
origin: z.string().min(1).max(300),
|
|
28
|
+
credentialHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
29
|
+
createdAt: z.string().datetime(),
|
|
30
|
+
revokedAt: z.string().datetime().optional()
|
|
31
|
+
})
|
|
32
|
+
).max(32).default([]),
|
|
21
33
|
allowedOrigins: z.array(z.url()).default([]),
|
|
22
34
|
agentGuidance: agentGuidanceSchema.default({
|
|
23
35
|
enabled: true,
|
|
@@ -35,6 +47,14 @@ function readLocalConfig(path) {
|
|
|
35
47
|
);
|
|
36
48
|
}
|
|
37
49
|
}
|
|
50
|
+
function writeLocalConfig(path, value) {
|
|
51
|
+
const config = localConfigSchema.parse(value);
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
53
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
54
|
+
writeFileSync(temporary, JSON.stringify(config, null, 2), { mode: 384 });
|
|
55
|
+
renameSync(temporary, path);
|
|
56
|
+
return config;
|
|
57
|
+
}
|
|
38
58
|
|
|
39
59
|
// apps/mcp-server/src/config.ts
|
|
40
60
|
function loadConfig(env = process.env) {
|
|
@@ -48,7 +68,17 @@ function loadConfig(env = process.env) {
|
|
|
48
68
|
timeoutMs: z2.coerce.number().int().min(100).max(6e4),
|
|
49
69
|
allowedOrigins: z2.array(z2.url()),
|
|
50
70
|
debugDir: z2.string().optional(),
|
|
51
|
-
agentGuidance: agentGuidanceSchema
|
|
71
|
+
agentGuidance: agentGuidanceSchema,
|
|
72
|
+
pairings: z2.array(z2.object({
|
|
73
|
+
id: z2.string(),
|
|
74
|
+
browser: z2.enum(["firefox", "chrome"]),
|
|
75
|
+
extensionId: z2.string(),
|
|
76
|
+
origin: z2.string(),
|
|
77
|
+
credentialHash: z2.string(),
|
|
78
|
+
createdAt: z2.string(),
|
|
79
|
+
revokedAt: z2.string().optional()
|
|
80
|
+
})).default([]),
|
|
81
|
+
configPath: z2.string().optional()
|
|
52
82
|
}).parse({
|
|
53
83
|
port: env.GAZESIGHT_PORT ?? local?.port ?? 32147,
|
|
54
84
|
token: env.GAZESIGHT_PAIR_TOKEN ?? local?.token,
|
|
@@ -58,12 +88,14 @@ function loadConfig(env = process.env) {
|
|
|
58
88
|
agentGuidance: local?.agentGuidance ?? {
|
|
59
89
|
enabled: true,
|
|
60
90
|
visualFidelity: true
|
|
61
|
-
}
|
|
91
|
+
},
|
|
92
|
+
pairings: local?.pairings ?? [],
|
|
93
|
+
configPath: env.GAZESIGHT_CONFIG_FILE
|
|
62
94
|
});
|
|
63
95
|
}
|
|
64
96
|
|
|
65
97
|
// packages/protocol/src/release.ts
|
|
66
|
-
var VERSION = "0.
|
|
98
|
+
var VERSION = "0.5.0";
|
|
67
99
|
|
|
68
100
|
// apps/mcp-server/src/bridge/websocket-server.ts
|
|
69
101
|
import { WebSocketServer, WebSocket } from "ws";
|
|
@@ -220,9 +252,20 @@ var fullPageCaptureSchema = captureSchema.extend({
|
|
|
220
252
|
});
|
|
221
253
|
var navigateSchema = targetSchema.extend({ url: z3.url() });
|
|
222
254
|
var diffSchema = z3.strictObject({
|
|
223
|
-
baseline: z3.uuid()
|
|
255
|
+
baseline: z3.uuid().optional().describe(
|
|
256
|
+
"A prior PNG imageReference from this session. Omit and use referenceImagePath instead to diff against an external design reference."
|
|
257
|
+
),
|
|
258
|
+
referenceImagePath: z3.string().min(1).max(4096).optional().describe(
|
|
259
|
+
"Path (relative to the MCP server's working directory, or absolute) to a local PNG design reference to diff the live capture against. Exactly one of baseline/referenceImagePath is required."
|
|
260
|
+
),
|
|
224
261
|
current: z3.uuid().optional(),
|
|
225
|
-
|
|
262
|
+
tabId: z3.number().int().nonnegative().optional().describe(
|
|
263
|
+
"Only used with referenceImagePath when current is omitted, to select which tab to capture. Ignored otherwise (baseline's own tab is used)."
|
|
264
|
+
),
|
|
265
|
+
threshold: z3.number().finite().min(0).max(1).default(0.1),
|
|
266
|
+
checkpoint: z3.string().min(1).max(200).regex(/^[A-Za-z0-9_-]+$/).optional().describe(
|
|
267
|
+
"Visual Convergence best-so-far tracking id (e.g. 'hero-desktop'). Only meaningful together with referenceImagePath: persists the best-known result locally and objectively reports isBest/regressed on every call, independent of the agent's own assessment."
|
|
268
|
+
)
|
|
226
269
|
});
|
|
227
270
|
var viewportProfileSchema = z3.enum([
|
|
228
271
|
"mobile-small",
|
|
@@ -235,7 +278,10 @@ var setViewportSchema = targetSchema.extend({
|
|
|
235
278
|
profile: viewportProfileSchema.optional(),
|
|
236
279
|
width: z3.number().int().min(200).max(8192).optional(),
|
|
237
280
|
height: z3.number().int().min(200).max(8192).optional(),
|
|
238
|
-
dedicatedPreview: z3.boolean().default(true)
|
|
281
|
+
dedicatedPreview: z3.boolean().default(true),
|
|
282
|
+
close: z3.boolean().optional().describe(
|
|
283
|
+
"Close the current dedicated preview window instead of creating or resizing one. Width/height/profile are ignored when true. Idempotent: has no effect if no preview is open."
|
|
284
|
+
)
|
|
239
285
|
});
|
|
240
286
|
var viewportResultSchema = z3.strictObject({
|
|
241
287
|
tabId: z3.number().int().nonnegative(),
|
|
@@ -257,7 +303,10 @@ var viewportResultSchema = z3.strictObject({
|
|
|
257
303
|
exact: z3.boolean().optional(),
|
|
258
304
|
attempts: z3.number().int().min(0).max(3).optional(),
|
|
259
305
|
requiresTargetSelection: z3.boolean().optional(),
|
|
260
|
-
sourceWindowUnchanged: z3.boolean().optional()
|
|
306
|
+
sourceWindowUnchanged: z3.boolean().optional(),
|
|
307
|
+
previewClosed: z3.boolean().optional().describe(
|
|
308
|
+
"Present when close:true was requested. True if a dedicated preview window was actually open and closed; false if there was none to close."
|
|
309
|
+
)
|
|
261
310
|
});
|
|
262
311
|
var domSchema = targetSchema.extend({
|
|
263
312
|
selector: z3.string().min(1).max(2048).optional(),
|
|
@@ -795,7 +844,206 @@ var ActiveTargetRouter = class {
|
|
|
795
844
|
|
|
796
845
|
// apps/mcp-server/src/bridge/websocket-server.ts
|
|
797
846
|
import { createServer } from "node:http";
|
|
847
|
+
|
|
848
|
+
// apps/mcp-server/src/bridge/pairing-service.ts
|
|
849
|
+
import { createHash, randomBytes, randomInt, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
|
|
850
|
+
var hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
851
|
+
var equalHash = (value, expected) => {
|
|
852
|
+
const actual = Buffer.from(hash(value));
|
|
853
|
+
const wanted = Buffer.from(expected);
|
|
854
|
+
return actual.length === wanted.length && timingSafeEqual2(actual, wanted);
|
|
855
|
+
};
|
|
856
|
+
var json = (response, status, value) => {
|
|
857
|
+
response.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
858
|
+
response.end(JSON.stringify(value));
|
|
859
|
+
};
|
|
860
|
+
async function body(request) {
|
|
861
|
+
const chunks = [];
|
|
862
|
+
let length = 0;
|
|
863
|
+
for await (const chunk of request) {
|
|
864
|
+
length += chunk.length;
|
|
865
|
+
if (length > 4096) throw new Error("body too large");
|
|
866
|
+
chunks.push(chunk);
|
|
867
|
+
}
|
|
868
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
869
|
+
}
|
|
870
|
+
function browserForOrigin(origin) {
|
|
871
|
+
if (/^moz-extension:\/\/[a-f0-9-]+\/?$/i.test(origin)) return "firefox";
|
|
872
|
+
if (/^chrome-extension:\/\/[a-p]{32}\/?$/.test(origin)) return "chrome";
|
|
873
|
+
}
|
|
874
|
+
var PairingService = class {
|
|
875
|
+
constructor(token, configPath, initial) {
|
|
876
|
+
this.token = token;
|
|
877
|
+
this.configPath = configPath;
|
|
878
|
+
this.pairings = initial;
|
|
879
|
+
}
|
|
880
|
+
pending = /* @__PURE__ */ new Map();
|
|
881
|
+
recent = /* @__PURE__ */ new Map();
|
|
882
|
+
pairings;
|
|
883
|
+
authenticate(credential, origin, browser) {
|
|
884
|
+
if (!origin) return false;
|
|
885
|
+
return this.pairings.some((pairing) => !pairing.revokedAt && pairing.browser === browser && pairing.origin === origin && equalHash(credential, pairing.credentialHash));
|
|
886
|
+
}
|
|
887
|
+
listPending() {
|
|
888
|
+
this.expire();
|
|
889
|
+
return [...this.pending.values()].filter((item) => item.state === "pending").map(({ id, browser, extensionId, code, createdAt, expiresAt }) => ({ id, browser, extensionId, code, createdAt, expiresAt }));
|
|
890
|
+
}
|
|
891
|
+
listPairings() {
|
|
892
|
+
return this.pairings.map(({ id, browser, extensionId, origin, createdAt, revokedAt }) => ({ id, browser, extensionId, origin, createdAt, ...revokedAt ? { revokedAt } : {} }));
|
|
893
|
+
}
|
|
894
|
+
deliveryState(id) {
|
|
895
|
+
const item = this.pending.get(id);
|
|
896
|
+
return item ? { state: item.state, redeemed: item.redeemed === true, expiresAt: item.expiresAt } : void 0;
|
|
897
|
+
}
|
|
898
|
+
approve(id, code) {
|
|
899
|
+
this.expire();
|
|
900
|
+
const item = this.pending.get(id);
|
|
901
|
+
if (!item || item.state !== "pending" || item.code !== code) return false;
|
|
902
|
+
const credential = randomBytes(32).toString("hex");
|
|
903
|
+
const pairing = { id: crypto.randomUUID(), browser: item.browser, extensionId: item.extensionId, origin: item.origin, credentialHash: hash(credential), createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
904
|
+
this.pairings.push(pairing);
|
|
905
|
+
item.credential = credential;
|
|
906
|
+
item.state = "approved";
|
|
907
|
+
this.persist();
|
|
908
|
+
return true;
|
|
909
|
+
}
|
|
910
|
+
reject(id) {
|
|
911
|
+
const item = this.pending.get(id);
|
|
912
|
+
if (!item || item.state !== "pending") return false;
|
|
913
|
+
item.state = "rejected";
|
|
914
|
+
return true;
|
|
915
|
+
}
|
|
916
|
+
revoke(id) {
|
|
917
|
+
const item = this.pairings.find((pairing) => pairing.id === id && !pairing.revokedAt);
|
|
918
|
+
if (!item) return false;
|
|
919
|
+
item.revokedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
920
|
+
this.persist();
|
|
921
|
+
return true;
|
|
922
|
+
}
|
|
923
|
+
persist() {
|
|
924
|
+
if (!this.configPath) return;
|
|
925
|
+
const config = readLocalConfig(this.configPath);
|
|
926
|
+
writeLocalConfig(this.configPath, { ...config, pairings: this.pairings });
|
|
927
|
+
}
|
|
928
|
+
expire() {
|
|
929
|
+
const now = Date.now();
|
|
930
|
+
for (const item of this.pending.values()) if (item.expiresAt <= now && item.state === "pending") item.state = "rejected";
|
|
931
|
+
}
|
|
932
|
+
admin(request) {
|
|
933
|
+
return request.headers.origin === void 0 && tokensMatch(request.headers.authorization?.replace(/^Bearer /, "") ?? "", this.token);
|
|
934
|
+
}
|
|
935
|
+
async handle(request, response) {
|
|
936
|
+
const url = request.url;
|
|
937
|
+
if (!url?.startsWith("/gazesight/pairing/")) return false;
|
|
938
|
+
const origin = request.headers.origin;
|
|
939
|
+
if (origin && originAllowed(origin)) response.setHeader("Access-Control-Allow-Origin", origin);
|
|
940
|
+
response.setHeader("Vary", "Origin");
|
|
941
|
+
if (request.method === "OPTIONS" && origin && originAllowed(origin)) {
|
|
942
|
+
response.writeHead(204, { "Access-Control-Allow-Methods": "POST", "Access-Control-Allow-Headers": "content-type, authorization" }).end();
|
|
943
|
+
return true;
|
|
944
|
+
}
|
|
945
|
+
try {
|
|
946
|
+
if (url === "/gazesight/pairing/request" && request.method === "POST") {
|
|
947
|
+
const browser = origin ? browserForOrigin(origin) : void 0;
|
|
948
|
+
if (!origin || !browser) {
|
|
949
|
+
json(response, 403, { error: "ineligible_origin" });
|
|
950
|
+
return true;
|
|
951
|
+
}
|
|
952
|
+
const now = Date.now();
|
|
953
|
+
const attempts = (this.recent.get(origin) ?? []).filter((time) => now - time < 6e4);
|
|
954
|
+
if (attempts.length >= 5) {
|
|
955
|
+
json(response, 429, { error: "rate_limited" });
|
|
956
|
+
return true;
|
|
957
|
+
}
|
|
958
|
+
attempts.push(now);
|
|
959
|
+
this.recent.set(origin, attempts);
|
|
960
|
+
const value = await body(request);
|
|
961
|
+
if (value.browser !== browser || typeof value.extensionId !== "string" || value.extensionId.length > 256 || typeof value.secret !== "string" || !/^[a-f0-9]{64}$/.test(value.secret)) {
|
|
962
|
+
json(response, 400, { error: "invalid_request" });
|
|
963
|
+
return true;
|
|
964
|
+
}
|
|
965
|
+
const item = { id: crypto.randomUUID(), browser, extensionId: value.extensionId, origin, secretHash: hash(value.secret), code: String(randomInt(0, 1e6)).padStart(6, "0"), createdAt: now, expiresAt: now + 12e4, attempts: 0, state: "pending" };
|
|
966
|
+
this.pending.set(item.id, item);
|
|
967
|
+
json(response, 201, { requestId: item.id, code: item.code, expiresAt: item.expiresAt });
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
if (url === "/gazesight/pairing/status" && request.method === "POST") {
|
|
971
|
+
const value = await body(request);
|
|
972
|
+
const item = typeof value.requestId === "string" ? this.pending.get(value.requestId) : void 0;
|
|
973
|
+
if (!item || item.origin !== origin || typeof value.secret !== "string") {
|
|
974
|
+
json(response, 404, { state: "unavailable" });
|
|
975
|
+
return true;
|
|
976
|
+
}
|
|
977
|
+
item.attempts += 1;
|
|
978
|
+
if (item.attempts > 180 || !equalHash(value.secret, item.secretHash)) {
|
|
979
|
+
json(response, 403, { state: "rejected" });
|
|
980
|
+
return true;
|
|
981
|
+
}
|
|
982
|
+
if (Date.now() >= item.expiresAt) {
|
|
983
|
+
json(response, 410, { state: "expired" });
|
|
984
|
+
return true;
|
|
985
|
+
}
|
|
986
|
+
if (item.state === "approved" && item.credential && !item.redeemed) {
|
|
987
|
+
const pairing = this.pairings.find((value2) => value2.origin === item.origin && value2.extensionId === item.extensionId && value2.credentialHash === hash(item.credential));
|
|
988
|
+
item.redeemed = true;
|
|
989
|
+
json(response, 200, { state: "approved", credential: item.credential, pairingId: pairing?.id });
|
|
990
|
+
item.credential = void 0;
|
|
991
|
+
return true;
|
|
992
|
+
}
|
|
993
|
+
json(response, 200, { state: item.state });
|
|
994
|
+
return true;
|
|
995
|
+
}
|
|
996
|
+
if (url === "/gazesight/pairing/revoke-self" && request.method === "POST" && origin) {
|
|
997
|
+
const value = await body(request);
|
|
998
|
+
const id = typeof value.id === "string" ? value.id : "";
|
|
999
|
+
const credential = request.headers.authorization?.replace(/^Bearer /, "") ?? "";
|
|
1000
|
+
const pairing = this.pairings.find((item) => item.id === id && item.origin === origin && !item.revokedAt && equalHash(credential, item.credentialHash));
|
|
1001
|
+
if (!pairing) {
|
|
1002
|
+
json(response, 403, { ok: false });
|
|
1003
|
+
return true;
|
|
1004
|
+
}
|
|
1005
|
+
pairing.revokedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1006
|
+
this.persist();
|
|
1007
|
+
json(response, 200, { ok: true });
|
|
1008
|
+
return true;
|
|
1009
|
+
}
|
|
1010
|
+
if (!this.admin(request)) {
|
|
1011
|
+
json(response, 404, { error: "not_found" });
|
|
1012
|
+
return true;
|
|
1013
|
+
}
|
|
1014
|
+
if (url === "/gazesight/pairing/delivery" && request.method === "POST") {
|
|
1015
|
+
const value = await body(request);
|
|
1016
|
+
const delivery = typeof value.id === "string" ? this.deliveryState(value.id) : void 0;
|
|
1017
|
+
json(response, delivery ? 200 : 404, delivery ?? { error: "not_found" });
|
|
1018
|
+
return true;
|
|
1019
|
+
}
|
|
1020
|
+
if (url === "/gazesight/pairing/pending" && request.method === "GET") {
|
|
1021
|
+
json(response, 200, { requests: this.listPending() });
|
|
1022
|
+
return true;
|
|
1023
|
+
}
|
|
1024
|
+
if (url === "/gazesight/pairing/list" && request.method === "GET") {
|
|
1025
|
+
json(response, 200, { pairings: this.listPairings() });
|
|
1026
|
+
return true;
|
|
1027
|
+
}
|
|
1028
|
+
if (request.method === "POST" && ["approve", "reject", "revoke"].some((action) => url === `/gazesight/pairing/${action}`)) {
|
|
1029
|
+
const value = await body(request);
|
|
1030
|
+
const id = typeof value.id === "string" ? value.id : "";
|
|
1031
|
+
const ok = url.endsWith("approve") ? this.approve(id, typeof value.code === "string" ? value.code : "") : url.endsWith("reject") ? this.reject(id) : this.revoke(id);
|
|
1032
|
+
json(response, ok ? 200 : 404, { ok });
|
|
1033
|
+
return true;
|
|
1034
|
+
}
|
|
1035
|
+
json(response, 404, { error: "not_found" });
|
|
1036
|
+
return true;
|
|
1037
|
+
} catch {
|
|
1038
|
+
json(response, 400, { error: "invalid_request" });
|
|
1039
|
+
return true;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
// apps/mcp-server/src/bridge/websocket-server.ts
|
|
798
1045
|
var EMPTY_STATE = { selectedTab: null, url: null };
|
|
1046
|
+
var DEFAULT_HEARTBEAT_INTERVAL_MS = 2e4;
|
|
799
1047
|
function browserFromOrigin(origin) {
|
|
800
1048
|
if (origin === void 0) return void 0;
|
|
801
1049
|
if (/^moz-extension:\/\/[a-f0-9-]+\/?$/i.test(origin)) return "firefox";
|
|
@@ -805,10 +1053,12 @@ function browserFromOrigin(origin) {
|
|
|
805
1053
|
var Bridge = class {
|
|
806
1054
|
constructor(config) {
|
|
807
1055
|
this.config = config;
|
|
1056
|
+
this.pairing = new PairingService(config.token, config.configPath, config.pairings ?? []);
|
|
808
1057
|
const verifyClient = (info) => originAllowed(info.origin || void 0);
|
|
809
|
-
this.http = createServer((request, response) => {
|
|
1058
|
+
this.http = createServer(async (request, response) => {
|
|
810
1059
|
response.setHeader("Cache-Control", "no-store");
|
|
811
1060
|
response.setHeader("X-GazeSight-Version", VERSION);
|
|
1061
|
+
if (await this.pairing.handle(request, response)) return;
|
|
812
1062
|
if (request.method !== "GET" || request.url !== "/gazesight/status" || request.headers.origin !== void 0) {
|
|
813
1063
|
response.writeHead(404).end();
|
|
814
1064
|
return;
|
|
@@ -823,6 +1073,12 @@ var Bridge = class {
|
|
|
823
1073
|
response.setHeader("Content-Type", "application/json");
|
|
824
1074
|
response.end(JSON.stringify(this.status()));
|
|
825
1075
|
});
|
|
1076
|
+
this.http.once("error", (error) => {
|
|
1077
|
+
this.listenError = error.code === "EADDRINUSE" ? new GSError(
|
|
1078
|
+
"GS_PORT_IN_USE",
|
|
1079
|
+
`Port ${config.port} is already in use by another GazeSight MCP server. Only one can run at a time -- close the other session first.`
|
|
1080
|
+
) : error;
|
|
1081
|
+
});
|
|
826
1082
|
this.server = new WebSocketServer({
|
|
827
1083
|
server: this.http,
|
|
828
1084
|
maxPayload: 17 * 1024 * 1024,
|
|
@@ -845,6 +1101,11 @@ var Bridge = class {
|
|
|
845
1101
|
this.wiring.delete(connection.connectionId);
|
|
846
1102
|
this.registry.remove(connection.connectionId);
|
|
847
1103
|
};
|
|
1104
|
+
this.alive.set(socket, { pongReceived: true });
|
|
1105
|
+
socket.on("pong", () => {
|
|
1106
|
+
const state = this.alive.get(socket);
|
|
1107
|
+
if (state) state.pongReceived = true;
|
|
1108
|
+
});
|
|
848
1109
|
socket.on("error", () => {
|
|
849
1110
|
});
|
|
850
1111
|
socket.on("message", (bytes, binary) => {
|
|
@@ -855,14 +1116,14 @@ var Bridge = class {
|
|
|
855
1116
|
const message = JSON.parse(bytes.toString());
|
|
856
1117
|
if (!paired) {
|
|
857
1118
|
const hello = helloSchema.parse(message);
|
|
858
|
-
if (!tokensMatch(hello.token, config.token))
|
|
859
|
-
throw new Error("Pairing rejected");
|
|
860
1119
|
const claimedBrowser = hello.state.browser?.name;
|
|
861
1120
|
if (originBrowser !== void 0 && claimedBrowser !== void 0 && originBrowser !== claimedBrowser)
|
|
862
1121
|
throw new Error("Origin/browser mismatch");
|
|
863
1122
|
const browser = claimedBrowser ?? originBrowser;
|
|
864
1123
|
if (browser === void 0)
|
|
865
1124
|
throw new Error("Unidentifiable browser");
|
|
1125
|
+
if (!tokensMatch(hello.token, config.token) && !this.pairing.authenticate(hello.token, request.headers.origin, browser))
|
|
1126
|
+
throw new Error("Pairing rejected");
|
|
866
1127
|
if (this.registry.isDuplicate(browser))
|
|
867
1128
|
throw new Error("Duplicate connection");
|
|
868
1129
|
connection = this.registry.register(browser, hello.state);
|
|
@@ -899,12 +1160,29 @@ var Bridge = class {
|
|
|
899
1160
|
});
|
|
900
1161
|
socket.on("close", teardown);
|
|
901
1162
|
});
|
|
1163
|
+
this.heartbeat = setInterval(() => {
|
|
1164
|
+
for (const socket of this.server.clients) {
|
|
1165
|
+
const state = this.alive.get(socket);
|
|
1166
|
+
if (!state) continue;
|
|
1167
|
+
if (!state.pongReceived) {
|
|
1168
|
+
socket.terminate();
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
state.pongReceived = false;
|
|
1172
|
+
socket.ping();
|
|
1173
|
+
}
|
|
1174
|
+
}, config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS);
|
|
1175
|
+
this.heartbeat.unref?.();
|
|
902
1176
|
}
|
|
903
1177
|
registry = new BrowserConnectionRegistry();
|
|
904
1178
|
activeTargetRouter = new ActiveTargetRouter();
|
|
905
1179
|
wiring = /* @__PURE__ */ new Map();
|
|
1180
|
+
alive = /* @__PURE__ */ new WeakMap();
|
|
1181
|
+
heartbeat;
|
|
906
1182
|
server;
|
|
907
1183
|
http;
|
|
1184
|
+
listenError;
|
|
1185
|
+
pairing;
|
|
908
1186
|
// Resolves the connection currently backing the global active target, or
|
|
909
1187
|
// undefined if no browser is active (nothing selected yet, or the
|
|
910
1188
|
// previously active browser disconnected — no automatic fallback).
|
|
@@ -968,6 +1246,7 @@ var Bridge = class {
|
|
|
968
1246
|
return {
|
|
969
1247
|
connected: this.connected,
|
|
970
1248
|
paired: this.connected,
|
|
1249
|
+
authorizedPairings: this.pairing.listPairings().filter((item) => !item.revokedAt).length,
|
|
971
1250
|
targetState: this.state.targetState ?? "none",
|
|
972
1251
|
tabId: this.state.selectedTab,
|
|
973
1252
|
bridge: { port: this.port },
|
|
@@ -978,13 +1257,17 @@ var Bridge = class {
|
|
|
978
1257
|
};
|
|
979
1258
|
}
|
|
980
1259
|
async ready() {
|
|
1260
|
+
if (this.listenError) throw this.listenError;
|
|
981
1261
|
if (this.server.address()) return;
|
|
982
1262
|
await new Promise((resolve, reject) => {
|
|
983
1263
|
this.server.once("listening", resolve);
|
|
984
|
-
|
|
1264
|
+
const onError = () => reject(this.listenError);
|
|
1265
|
+
this.server.once("error", onError);
|
|
1266
|
+
this.http.once("error", onError);
|
|
985
1267
|
});
|
|
986
1268
|
}
|
|
987
1269
|
async close() {
|
|
1270
|
+
clearInterval(this.heartbeat);
|
|
988
1271
|
for (const wiring of this.wiring.values()) wiring.router.disconnect();
|
|
989
1272
|
for (const client of this.server.clients) client.terminate();
|
|
990
1273
|
await new Promise((resolve) => this.server.close(() => resolve()));
|
|
@@ -994,8 +1277,8 @@ var Bridge = class {
|
|
|
994
1277
|
|
|
995
1278
|
// apps/mcp-server/src/server.ts
|
|
996
1279
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
997
|
-
import { writeFile, mkdir } from "node:fs/promises";
|
|
998
|
-
import { join } from "node:path";
|
|
1280
|
+
import { writeFile as writeFile2, mkdir as mkdir2 } from "node:fs/promises";
|
|
1281
|
+
import { join as join3 } from "node:path";
|
|
999
1282
|
|
|
1000
1283
|
// apps/mcp-server/src/tools/capture.ts
|
|
1001
1284
|
function toImage(value) {
|
|
@@ -1115,9 +1398,316 @@ async function runDiff(baseline, current, threshold) {
|
|
|
1115
1398
|
}
|
|
1116
1399
|
}
|
|
1117
1400
|
|
|
1401
|
+
// apps/mcp-server/src/tools/convergence.ts
|
|
1402
|
+
import { lstat, readFile, writeFile, mkdir } from "node:fs/promises";
|
|
1403
|
+
import { extname, join as join2 } from "node:path";
|
|
1404
|
+
import { homedir } from "node:os";
|
|
1405
|
+
|
|
1406
|
+
// apps/mcp-server/src/tools/diff-engine.ts
|
|
1407
|
+
import pixelmatch from "pixelmatch";
|
|
1408
|
+
import { PNG } from "pngjs";
|
|
1409
|
+
var signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
1410
|
+
function decodeBoundedPng(data) {
|
|
1411
|
+
if (data.length > 16 * 1024 * 1024)
|
|
1412
|
+
throw new GSError("GS_DIFF_LIMIT", "Encoded PNG exceeds 16 MiB.");
|
|
1413
|
+
const bytes = Buffer.from(data, "base64");
|
|
1414
|
+
if (bytes.length < 33 || !bytes.subarray(0, 8).equals(signature) || bytes.readUInt32BE(8) !== 13 || bytes.toString("ascii", 12, 16) !== "IHDR")
|
|
1415
|
+
throw new GSError("GS_DIFF_FORMAT", "Use a PNG capture reference.");
|
|
1416
|
+
const width = bytes.readUInt32BE(16), height = bytes.readUInt32BE(20);
|
|
1417
|
+
if (!width || !height || width > 8192 || height > 8192 || width * height > 4e6)
|
|
1418
|
+
throw new GSError(
|
|
1419
|
+
"GS_DIFF_LIMIT",
|
|
1420
|
+
"Diff is limited to 4 million pixels and 8192 pixels per side."
|
|
1421
|
+
);
|
|
1422
|
+
if (bytes[24] !== 8 || ![2, 6].includes(bytes[25]) || bytes[26] || bytes[27] || bytes[28])
|
|
1423
|
+
throw new GSError(
|
|
1424
|
+
"GS_DIFF_FORMAT",
|
|
1425
|
+
"Use a non-interlaced 8-bit RGB/RGBA PNG capture."
|
|
1426
|
+
);
|
|
1427
|
+
try {
|
|
1428
|
+
return PNG.sync.read(bytes, { checkCRC: true });
|
|
1429
|
+
} catch {
|
|
1430
|
+
throw new GSError("GS_DIFF_FORMAT", "PNG decoding failed.");
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
function diffPixels(baseline, current, threshold) {
|
|
1434
|
+
const start = performance.now(), a = decodeBoundedPng(baseline), b = decodeBoundedPng(current);
|
|
1435
|
+
if (a.width !== b.width || a.height !== b.height)
|
|
1436
|
+
throw new GSError(
|
|
1437
|
+
"GS_DIFF_DIMENSIONS",
|
|
1438
|
+
"Capture both images at the same pixel dimensions."
|
|
1439
|
+
);
|
|
1440
|
+
const output = Buffer.alloc(a.data.length);
|
|
1441
|
+
const changedPixelCount = pixelmatch(
|
|
1442
|
+
a.data,
|
|
1443
|
+
b.data,
|
|
1444
|
+
output,
|
|
1445
|
+
a.width,
|
|
1446
|
+
a.height,
|
|
1447
|
+
{ threshold, includeAA: true, diffMask: true }
|
|
1448
|
+
);
|
|
1449
|
+
const columns = Math.ceil(a.width / 32), rows = Math.ceil(a.height / 32);
|
|
1450
|
+
const tiles = /* @__PURE__ */ new Map();
|
|
1451
|
+
for (let y = 0; y < a.height; y++)
|
|
1452
|
+
for (let x = 0; x < a.width; x++)
|
|
1453
|
+
if (output[(y * a.width + x) * 4 + 3]) {
|
|
1454
|
+
const index = Math.floor(y / 32) * columns + Math.floor(x / 32), box = tiles.get(index);
|
|
1455
|
+
if (box) {
|
|
1456
|
+
box.left = Math.min(box.left, x);
|
|
1457
|
+
box.top = Math.min(box.top, y);
|
|
1458
|
+
box.right = Math.max(box.right, x + 1);
|
|
1459
|
+
box.bottom = Math.max(box.bottom, y + 1);
|
|
1460
|
+
} else
|
|
1461
|
+
tiles.set(index, { left: x, top: y, right: x + 1, bottom: y + 1 });
|
|
1462
|
+
}
|
|
1463
|
+
const visited = /* @__PURE__ */ new Set(), boundingBoxes = [];
|
|
1464
|
+
for (const [index, first] of tiles) {
|
|
1465
|
+
if (visited.has(index)) continue;
|
|
1466
|
+
const queue = [index], box = { ...first };
|
|
1467
|
+
visited.add(index);
|
|
1468
|
+
for (let i = 0; i < queue.length; i++) {
|
|
1469
|
+
const key = queue[i], tile = tiles.get(key);
|
|
1470
|
+
box.left = Math.min(box.left, tile.left);
|
|
1471
|
+
box.top = Math.min(box.top, tile.top);
|
|
1472
|
+
box.right = Math.max(box.right, tile.right);
|
|
1473
|
+
box.bottom = Math.max(box.bottom, tile.bottom);
|
|
1474
|
+
const col = key % columns, row = Math.floor(key / columns);
|
|
1475
|
+
for (let dy = -1; dy <= 1; dy++)
|
|
1476
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
1477
|
+
const nx = col + dx, ny = row + dy, next = ny * columns + nx;
|
|
1478
|
+
if (nx >= 0 && ny >= 0 && nx < columns && ny < rows && tiles.has(next) && !visited.has(next)) {
|
|
1479
|
+
visited.add(next);
|
|
1480
|
+
queue.push(next);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
boundingBoxes.push({
|
|
1485
|
+
x: box.left,
|
|
1486
|
+
y: box.top,
|
|
1487
|
+
width: box.right - box.left,
|
|
1488
|
+
height: box.bottom - box.top
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
const boundingBoxesCoalesced = boundingBoxes.length > 100;
|
|
1492
|
+
if (boundingBoxesCoalesced) {
|
|
1493
|
+
const x = Math.min(...boundingBoxes.map((r) => r.x)), y = Math.min(...boundingBoxes.map((r) => r.y));
|
|
1494
|
+
const right = Math.max(...boundingBoxes.map((r) => r.x + r.width)), bottom = Math.max(...boundingBoxes.map((r) => r.y + r.height));
|
|
1495
|
+
boundingBoxes.splice(0, boundingBoxes.length, {
|
|
1496
|
+
x,
|
|
1497
|
+
y,
|
|
1498
|
+
width: right - x,
|
|
1499
|
+
height: bottom - y
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
const image = PNG.sync.write(
|
|
1503
|
+
Object.assign(new PNG(), {
|
|
1504
|
+
width: a.width,
|
|
1505
|
+
height: a.height,
|
|
1506
|
+
data: output
|
|
1507
|
+
})
|
|
1508
|
+
);
|
|
1509
|
+
if (image.length * 4 / 3 > 16 * 1024 * 1024)
|
|
1510
|
+
throw new GSError("GS_DIFF_LIMIT", "Encoded diff image exceeds 16 MiB.");
|
|
1511
|
+
return {
|
|
1512
|
+
changed: changedPixelCount > 0,
|
|
1513
|
+
changedPixelCount,
|
|
1514
|
+
differencePercent: changedPixelCount / (a.width * a.height) * 100,
|
|
1515
|
+
boundingBoxes,
|
|
1516
|
+
boundingBoxesCoalesced,
|
|
1517
|
+
width: a.width,
|
|
1518
|
+
height: a.height,
|
|
1519
|
+
data: image.toString("base64"),
|
|
1520
|
+
processingMs: performance.now() - start
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// apps/mcp-server/src/tools/convergence.ts
|
|
1525
|
+
var MEANINGFUL_REGRESSION_PERCENT = 0.5;
|
|
1526
|
+
function checkpointRoot(platform = process.platform, home = homedir(), env = process.env) {
|
|
1527
|
+
if (platform === "win32")
|
|
1528
|
+
return join2(
|
|
1529
|
+
env.APPDATA ?? join2(home, "AppData", "Roaming"),
|
|
1530
|
+
"GazeSight",
|
|
1531
|
+
"convergence"
|
|
1532
|
+
);
|
|
1533
|
+
if (platform === "darwin")
|
|
1534
|
+
return join2(
|
|
1535
|
+
home,
|
|
1536
|
+
"Library",
|
|
1537
|
+
"Application Support",
|
|
1538
|
+
"GazeSight",
|
|
1539
|
+
"convergence"
|
|
1540
|
+
);
|
|
1541
|
+
return join2(env.XDG_CONFIG_HOME ?? join2(home, ".config"), "gazesight", "convergence");
|
|
1542
|
+
}
|
|
1543
|
+
function checkpointDir(id, root) {
|
|
1544
|
+
return join2(root, id);
|
|
1545
|
+
}
|
|
1546
|
+
async function loadCheckpoint(id, root) {
|
|
1547
|
+
try {
|
|
1548
|
+
const raw = await readFile(join2(checkpointDir(id, root), "best.json"), "utf8");
|
|
1549
|
+
return JSON.parse(raw);
|
|
1550
|
+
} catch {
|
|
1551
|
+
return void 0;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
async function saveCheckpoint(id, root, state, pngBase64) {
|
|
1555
|
+
const dir = checkpointDir(id, root);
|
|
1556
|
+
await mkdir(dir, { recursive: true });
|
|
1557
|
+
await writeFile(join2(dir, "best.json"), JSON.stringify(state, null, 2), "utf8");
|
|
1558
|
+
await writeFile(join2(dir, "best.png"), Buffer.from(pngBase64, "base64"));
|
|
1559
|
+
}
|
|
1560
|
+
var RELIABILITY_THRESHOLD_PERCENT = MEANINGFUL_REGRESSION_PERCENT;
|
|
1561
|
+
async function observeCheckpoint(id, root, input, currentPngBase64) {
|
|
1562
|
+
const dir = checkpointDir(id, root);
|
|
1563
|
+
const bestImagePath = join2(dir, "best.png");
|
|
1564
|
+
const prior = await loadCheckpoint(id, root);
|
|
1565
|
+
const stabilityChecked = input.stabilityDifferencePercent !== void 0;
|
|
1566
|
+
const stabilityFields = {
|
|
1567
|
+
...stabilityChecked ? { reliable: true } : {},
|
|
1568
|
+
...input.stabilityDifferencePercent !== void 0 ? { stabilityDifferencePercent: input.stabilityDifferencePercent } : {},
|
|
1569
|
+
...input.unstableBoundingBoxes ? { unstableBoundingBoxes: input.unstableBoundingBoxes } : {}
|
|
1570
|
+
};
|
|
1571
|
+
if (stabilityChecked && input.stabilityDifferencePercent >= RELIABILITY_THRESHOLD_PERCENT) {
|
|
1572
|
+
const iteration2 = (prior?.totalIterations ?? 0) + 1;
|
|
1573
|
+
if (prior)
|
|
1574
|
+
await writeFile(
|
|
1575
|
+
join2(dir, "best.json"),
|
|
1576
|
+
JSON.stringify({ ...prior, totalIterations: iteration2 }, null, 2),
|
|
1577
|
+
"utf8"
|
|
1578
|
+
);
|
|
1579
|
+
return {
|
|
1580
|
+
isBest: false,
|
|
1581
|
+
regressed: false,
|
|
1582
|
+
shouldWarn: false,
|
|
1583
|
+
iteration: iteration2,
|
|
1584
|
+
bestDifferencePercent: prior?.bestDifferencePercent ?? input.differencePercent,
|
|
1585
|
+
currentDifferencePercent: input.differencePercent,
|
|
1586
|
+
bestImagePath,
|
|
1587
|
+
...stabilityFields,
|
|
1588
|
+
reliable: false
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
const iteration = (prior?.totalIterations ?? 0) + 1;
|
|
1592
|
+
if (!prior || input.differencePercent < prior.bestDifferencePercent) {
|
|
1593
|
+
await saveCheckpoint(
|
|
1594
|
+
id,
|
|
1595
|
+
root,
|
|
1596
|
+
{
|
|
1597
|
+
bestDifferencePercent: input.differencePercent,
|
|
1598
|
+
bestChangedPixelCount: input.changedPixelCount,
|
|
1599
|
+
bestWidth: input.width,
|
|
1600
|
+
bestHeight: input.height,
|
|
1601
|
+
bestIteration: iteration,
|
|
1602
|
+
totalIterations: iteration,
|
|
1603
|
+
lastWarnedIteration: null,
|
|
1604
|
+
referenceImagePath: input.referenceImagePath,
|
|
1605
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1606
|
+
},
|
|
1607
|
+
currentPngBase64
|
|
1608
|
+
);
|
|
1609
|
+
return {
|
|
1610
|
+
isBest: true,
|
|
1611
|
+
regressed: false,
|
|
1612
|
+
shouldWarn: false,
|
|
1613
|
+
iteration,
|
|
1614
|
+
bestDifferencePercent: input.differencePercent,
|
|
1615
|
+
currentDifferencePercent: input.differencePercent,
|
|
1616
|
+
bestImagePath,
|
|
1617
|
+
...stabilityFields
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
const degradation = input.differencePercent - prior.bestDifferencePercent;
|
|
1621
|
+
if (degradation < MEANINGFUL_REGRESSION_PERCENT) {
|
|
1622
|
+
await writeFile(
|
|
1623
|
+
join2(dir, "best.json"),
|
|
1624
|
+
JSON.stringify({ ...prior, totalIterations: iteration }, null, 2),
|
|
1625
|
+
"utf8"
|
|
1626
|
+
);
|
|
1627
|
+
return {
|
|
1628
|
+
isBest: false,
|
|
1629
|
+
regressed: false,
|
|
1630
|
+
shouldWarn: false,
|
|
1631
|
+
iteration,
|
|
1632
|
+
bestDifferencePercent: prior.bestDifferencePercent,
|
|
1633
|
+
currentDifferencePercent: input.differencePercent,
|
|
1634
|
+
bestImagePath,
|
|
1635
|
+
...stabilityFields
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
const shouldWarn = prior.lastWarnedIteration === null;
|
|
1639
|
+
await writeFile(
|
|
1640
|
+
join2(dir, "best.json"),
|
|
1641
|
+
JSON.stringify(
|
|
1642
|
+
{
|
|
1643
|
+
...prior,
|
|
1644
|
+
totalIterations: iteration,
|
|
1645
|
+
lastWarnedIteration: shouldWarn ? iteration : prior.lastWarnedIteration
|
|
1646
|
+
},
|
|
1647
|
+
null,
|
|
1648
|
+
2
|
|
1649
|
+
),
|
|
1650
|
+
"utf8"
|
|
1651
|
+
);
|
|
1652
|
+
let regressionBoundingBoxes;
|
|
1653
|
+
try {
|
|
1654
|
+
const bestPng = await readFile(bestImagePath);
|
|
1655
|
+
const { boundingBoxes } = diffPixels(
|
|
1656
|
+
bestPng.toString("base64"),
|
|
1657
|
+
currentPngBase64,
|
|
1658
|
+
input.threshold
|
|
1659
|
+
);
|
|
1660
|
+
regressionBoundingBoxes = boundingBoxes;
|
|
1661
|
+
} catch {
|
|
1662
|
+
}
|
|
1663
|
+
return {
|
|
1664
|
+
isBest: false,
|
|
1665
|
+
regressed: true,
|
|
1666
|
+
shouldWarn,
|
|
1667
|
+
iteration,
|
|
1668
|
+
bestDifferencePercent: prior.bestDifferencePercent,
|
|
1669
|
+
currentDifferencePercent: input.differencePercent,
|
|
1670
|
+
bestImagePath,
|
|
1671
|
+
...regressionBoundingBoxes ? { regressionBoundingBoxes } : {},
|
|
1672
|
+
...stabilityFields
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
async function readReferenceImage(path) {
|
|
1676
|
+
if (extname(path).toLowerCase() !== ".png" || path.includes("\0"))
|
|
1677
|
+
throw new GSError(
|
|
1678
|
+
"GS_REFERENCE_INVALID",
|
|
1679
|
+
"referenceImagePath must identify a local PNG file."
|
|
1680
|
+
);
|
|
1681
|
+
let bytes;
|
|
1682
|
+
try {
|
|
1683
|
+
const entry = await lstat(path);
|
|
1684
|
+
if (!entry.isFile() || entry.isSymbolicLink())
|
|
1685
|
+
throw new Error("Reference is not a regular file");
|
|
1686
|
+
bytes = await readFile(path);
|
|
1687
|
+
} catch {
|
|
1688
|
+
throw new GSError(
|
|
1689
|
+
"GS_REFERENCE_NOT_FOUND",
|
|
1690
|
+
"referenceImagePath could not be read. Use a path relative to the MCP server's working directory, or an absolute path."
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
if (bytes.length > 16 * 1024 * 1024)
|
|
1694
|
+
throw new GSError(
|
|
1695
|
+
"GS_DIFF_LIMIT",
|
|
1696
|
+
"The reference PNG exceeds the 16 MiB local diff budget."
|
|
1697
|
+
);
|
|
1698
|
+
if (!bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
|
|
1699
|
+
throw new GSError(
|
|
1700
|
+
"GS_REFERENCE_INVALID",
|
|
1701
|
+
"referenceImagePath must identify a valid local PNG file."
|
|
1702
|
+
);
|
|
1703
|
+
return bytes.toString("base64");
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1118
1706
|
// packages/protocol/src/agent-guidance.ts
|
|
1119
1707
|
var coreGuidance = `GazeSight gives you visual access to the developer's explicitly authorized browser tab \u2014 nothing else, and only when they selected it.
|
|
1120
1708
|
|
|
1709
|
+
If a separate, general-purpose browser automation tool is also available for the same check, prefer GazeSight's authorized tab over spinning up a new automated browser instance: it's the developer's real, already-running session (their actual logged-in state and environment), not a fresh, unauthenticated instance that may not even reproduce the page correctly, and needs no separate browser install. Fall back to a general-purpose browser tool only when no GazeSight tab is authorized for what you need to check.
|
|
1710
|
+
|
|
1121
1711
|
When visual accuracy matters: inspect the rendered page before assuming how it looks; verify frontend changes after implementing them; preserve existing application state unless the task requires changing it; prefer the project's existing assets, icons, fonts and design-system components over invented ones; use DOM inspection (visual_get_dom / visual_get_element) when a screenshot alone is ambiguous; use visual_diff when comparing two controlled captures; fix meaningful visible discrepancies before calling a visual task done; don't redesign an interface when the user asked for a faithful reproduction.
|
|
1122
1712
|
|
|
1123
1713
|
Typical loop: inspect -> implement -> capture -> compare -> correct -> verify. Don't capture after every line of code, and prefer visual_capture_element or visual_capture_region over a full-page capture when only part of the page matters; use FAST for quick feedback and STABLE after changes that may still be settling. Avoid unnecessary reloads, navigation or viewport changes: most CSS/JS edits already hot-reload, and a reload can still reset in-page state even when GazeSight stays connected, so only reload when you have a specific reason to believe the page is stale. If a call ever returns GS_REAUTHORIZE_TARGET, wait briefly and retry once before assuming anything is wrong: GazeSight often reconnects automatically after a same-origin reload or navigation. Only tell the developer to click the extension again if that error persists.
|
|
@@ -1134,11 +1724,36 @@ Match as closely as practical: layout, spacing, alignment, component dimensions,
|
|
|
1134
1724
|
After implementing: capture the result, compare it against the reference (visual_diff when a comparable baseline exists), and diagnose before editing \u2014 localize the problem with visual_diff or visual_get_changed_regions, or DOM/computed-style inspection, rather than guessing CSS values repeatedly. Correct in priority order: overall structure and layout, then element size and position, then typography, then spacing, then colors/borders/radius/shadows, then decorative details \u2014 don't polish a detail while the layout underneath is still wrong.
|
|
1135
1725
|
|
|
1136
1726
|
Leave a region alone once it matches the reference unless the current fix depends on it. If a change makes the result visibly worse, revise or revert it before touching anything else, rather than layering more edits on top of a regression. Repeat diagnose -> correct -> verify until no meaningful visible difference remains, further edits stop producing visible improvement, a fix requires an asset or information you don't have, or the developer's stated acceptance criteria are met. Don't chase indefinite sub-pixel differences, and don't capture again without a specific reason to expect something changed.`;
|
|
1727
|
+
var visualConvergenceGuidance = `VISUAL CONVERGENCE (part of Visual Fidelity Mode) applies when a local PNG reference file is available and the task is to iteratively make the live page match it. Use visual_diff's referenceImagePath (not baseline) to compare the live capture directly against that file, and pass the same stable checkpoint id (e.g. "hero-desktop") on every call for that reference/viewport pair \u2014 this persists an objective, server-computed best-so-far result outside your own assessment.
|
|
1728
|
+
|
|
1729
|
+
After every correction, call visual_diff again with the same referenceImagePath, checkpoint and viewport. Read isBest and regressed directly from the result rather than relying on your own visual impression of whether a change helped, and don't report a fix as successful without first confirming isBest:true or a lower currentDifferencePercent in that next call \u2014 a self-reported "reverted successfully" that the next measurement contradicts is a known failure mode. If regressed:true, the result's regressionBoundingBoxes and bestImagePath show exactly what changed since the best-known state and where to find it; use them as concrete recovery context and revise toward that best state rather than layering further edits on top of a regression. shouldWarn:true only marks the first call to observe a given regression episode; it will not repeat on every call while still regressed, so keep checking regressed on later calls rather than assuming an earlier warning is the only one that matters.
|
|
1730
|
+
|
|
1731
|
+
If the developer or project supplies a convergence config (for example a local JSON file listing checkpoints with their referenceImagePath, viewport and tolerance/max-iteration limits), read and honor it: set the matching viewport with visual_set_viewport before each capture, and stop once currentDifferencePercent is within the stated tolerance.
|
|
1732
|
+
|
|
1733
|
+
When checkpoint is used, visual_diff also runs an immediate self-vs-self recapture of the same tab and reports reliable, stabilityDifferencePercent and unstableBoundingBoxes alongside isBest/regressed on every call. reliable:false means this specific reading was too noisy (animation, video, a live clock, anything moving on its own) to trust: isBest/regressed in that same result reflect the untouched previous best-so-far state, not this reading \u2014 don't treat an unreliable call as either progress or a regression, and don't let it interrupt a correct-diagnose-verify sequence otherwise going well.
|
|
1734
|
+
|
|
1735
|
+
Stop the convergence loop, and report honestly rather than as success, on any of these. Never continue indefinitely past them:
|
|
1736
|
+
- acceptable fidelity: currentDifferencePercent is within the stated tolerance (or, absent a stated tolerance, no meaningful visible difference remains per Visual Fidelity Mode above).
|
|
1737
|
+
- stagnation: two consecutive reliable calls show no improvement in currentDifferencePercent.
|
|
1738
|
+
- max iterations: the stated max-iteration count is reached, or, absent a stated limit, after at most 5 correction attempts for a single reference.
|
|
1739
|
+
- unrecoverable regression: regressed:true persists across repeated attempts to recover toward bestImagePath/regressionBoundingBoxes rather than clearing.
|
|
1740
|
+
- environmental instability: reliable:false persists across repeated attempts \u2014 the page itself is too noisy to measure (e.g. autoplaying media), not a fidelity problem you can fix by editing code; report which region is unstable (unstableBoundingBoxes) rather than continuing to chase it.`;
|
|
1741
|
+
var visualConvergenceAutoTriggerGuidance = `AUTOMATIC VISUAL CONVERGENCE TRIGGER: enter Visual Convergence (above) on your own, without the developer needing to mention GazeSight, whenever their request combines (a) genuine visual reproduction intent \u2014 asking you to build, reproduce, clone, match, replicate, or closely follow the look of something \u2014 with (b) an accessible visual reference: an attached/pasted image, a local design file path, or an existing page/URL to match. Detect this from meaning, not fixed phrases: the request can be in any language the connected coding agent supports (French, English, Spanish, Portuguese, German, and others), can mix languages, and may not use any of this guidance's own English words at all. Judge intent the way you would if a colleague said it to you in that language, not by scanning for keywords.
|
|
1742
|
+
|
|
1743
|
+
Do not trigger on a visual reference alone: a screenshot attached to a bug report, an error message, a design discussion, or a "what does this look like" question is not a reproduction request even though an image is present. Both genuine reproduction intent AND an accessible reference are required together.
|
|
1744
|
+
|
|
1745
|
+
If reproduction intent is clear but no reference is actually accessible yet (described verbally only, or an image mentioned but not attached/reachable), ask the developer for the reference in their own language rather than proceeding without one or silently skipping Visual Convergence.
|
|
1746
|
+
|
|
1747
|
+
Once triggered, save any attached/pasted image to a local file first (Visual Convergence's referenceImagePath needs a path, not inline image data), then follow Visual Convergence and Visual Fidelity Mode above exactly as if the developer had asked for them by name. Give the developer status updates and the final verification report in the same language they used to make the request \u2014 translate none of it to English on their behalf. This applies per request: a developer switching languages between requests changes only that request's response language, not a standing preference.`;
|
|
1137
1748
|
function buildInstructions(config) {
|
|
1138
1749
|
if (!config.enabled) return void 0;
|
|
1139
1750
|
return config.visualFidelity ? `${coreGuidance}
|
|
1140
1751
|
|
|
1141
|
-
${visualFidelityGuidance}
|
|
1752
|
+
${visualFidelityGuidance}
|
|
1753
|
+
|
|
1754
|
+
${visualConvergenceGuidance}
|
|
1755
|
+
|
|
1756
|
+
${visualConvergenceAutoTriggerGuidance}` : coreGuidance;
|
|
1142
1757
|
}
|
|
1143
1758
|
|
|
1144
1759
|
// apps/mcp-server/src/server.ts
|
|
@@ -1204,12 +1819,12 @@ function createServer2(bridge, config) {
|
|
|
1204
1819
|
))
|
|
1205
1820
|
throw new GSError(
|
|
1206
1821
|
"GS_EXTENSION_OUTDATED",
|
|
1207
|
-
|
|
1822
|
+
`This tool requires a matching GazeSight ${VERSION} browser extension. The core capture tools remain available regardless of extension version.`
|
|
1208
1823
|
);
|
|
1209
1824
|
if (type.startsWith("capture") && bridge.connected && bridge.state.targetState === void 0)
|
|
1210
1825
|
throw new GSError(
|
|
1211
1826
|
"GS_EXTENSION_OUTDATED",
|
|
1212
|
-
"
|
|
1827
|
+
"Open the GazeSight browser extension (Firefox: install the signed version from https://addons.mozilla.org/addon/6a2319d4fa544731884d/ if not already installed) and click its icon in the target tab."
|
|
1213
1828
|
);
|
|
1214
1829
|
if ("url" in input && typeof input.url === "string")
|
|
1215
1830
|
assertAllowedUrl(input.url, config.allowedOrigins);
|
|
@@ -1224,9 +1839,9 @@ function createServer2(bridge, config) {
|
|
|
1224
1839
|
data: image.data
|
|
1225
1840
|
}) : void 0;
|
|
1226
1841
|
if (config.debugDir) {
|
|
1227
|
-
await
|
|
1228
|
-
await
|
|
1229
|
-
|
|
1842
|
+
await mkdir2(config.debugDir, { recursive: true });
|
|
1843
|
+
await writeFile2(
|
|
1844
|
+
join3(config.debugDir, `last-capture.${metadata.format}`),
|
|
1230
1845
|
Buffer.from(image.data, "base64")
|
|
1231
1846
|
);
|
|
1232
1847
|
}
|
|
@@ -1281,7 +1896,7 @@ function createServer2(bridge, config) {
|
|
|
1281
1896
|
server.registerTool(
|
|
1282
1897
|
"visual_diff",
|
|
1283
1898
|
{
|
|
1284
|
-
description: "Compare local PNG capture references from this MCP session. If current is omitted, capture the
|
|
1899
|
+
description: "Compare local PNG capture references from this MCP session, or a live capture against an external PNG design reference (referenceImagePath) for Visual Convergence. If current is omitted, capture PNG STABLE (the baseline's tab, or tabId with referenceImagePath). Threshold is perceptual sensitivity, not allowed percent difference. Pass checkpoint with referenceImagePath to persist an objective, server-computed best-so-far result and get isBest/regressed on every call. No remote service; maximum 4 million pixels; both images must have identical pixel dimensions.",
|
|
1285
1900
|
inputSchema: diffSchema,
|
|
1286
1901
|
annotations: {
|
|
1287
1902
|
readOnlyHint: true,
|
|
@@ -1302,57 +1917,123 @@ function createServer2(bridge, config) {
|
|
|
1302
1917
|
};
|
|
1303
1918
|
diffBusy = true;
|
|
1304
1919
|
try {
|
|
1305
|
-
if (!
|
|
1920
|
+
if (!input.baseline && !input.referenceImagePath)
|
|
1306
1921
|
throw new GSError(
|
|
1307
|
-
"
|
|
1308
|
-
"
|
|
1922
|
+
"GS_DIFF_TARGET",
|
|
1923
|
+
"Provide either baseline (a prior imageReference) or referenceImagePath (an external PNG)."
|
|
1309
1924
|
);
|
|
1310
|
-
|
|
1311
|
-
|
|
1925
|
+
if (input.baseline && input.referenceImagePath)
|
|
1926
|
+
throw new GSError(
|
|
1927
|
+
"GS_DIFF_TARGET",
|
|
1928
|
+
"Provide only one of baseline or referenceImagePath, not both."
|
|
1929
|
+
);
|
|
1930
|
+
let baselineData;
|
|
1931
|
+
let baselineTabId;
|
|
1932
|
+
let baselineUrl;
|
|
1933
|
+
let baselineForDiffImage;
|
|
1934
|
+
if (input.referenceImagePath) {
|
|
1935
|
+
baselineData = await readReferenceImage(input.referenceImagePath);
|
|
1936
|
+
} else {
|
|
1937
|
+
if (!images)
|
|
1938
|
+
throw new GSError(
|
|
1939
|
+
"GS_IMAGE_NOT_FOUND",
|
|
1940
|
+
"First request a PNG capture and use its imageReference."
|
|
1941
|
+
);
|
|
1942
|
+
const baseline = images.get(input.baseline);
|
|
1943
|
+
assertAllowedUrl(baseline.url, config.allowedOrigins);
|
|
1944
|
+
baselineData = baseline.data;
|
|
1945
|
+
baselineTabId = baseline.tabId;
|
|
1946
|
+
baselineUrl = baseline.url;
|
|
1947
|
+
baselineForDiffImage = baseline;
|
|
1948
|
+
}
|
|
1949
|
+
const store = images ??= new ImageStore();
|
|
1312
1950
|
let currentReference = input.current;
|
|
1313
1951
|
if (!currentReference) {
|
|
1314
1952
|
const capture = toImage(
|
|
1315
1953
|
await bridge.router.request(
|
|
1316
1954
|
"capture",
|
|
1317
1955
|
captureSchema.parse({
|
|
1318
|
-
tabId:
|
|
1956
|
+
tabId: input.referenceImagePath ? input.tabId : baselineTabId,
|
|
1319
1957
|
format: "png",
|
|
1320
1958
|
waitForStable: true
|
|
1321
1959
|
})
|
|
1322
1960
|
)
|
|
1323
1961
|
);
|
|
1324
1962
|
assertAllowedUrl(capture.metadata.url, config.allowedOrigins);
|
|
1325
|
-
currentReference =
|
|
1963
|
+
currentReference = store.put({
|
|
1326
1964
|
...capture.metadata,
|
|
1327
1965
|
format: "png",
|
|
1328
1966
|
data: capture.image.data
|
|
1329
1967
|
});
|
|
1330
1968
|
}
|
|
1331
|
-
const current =
|
|
1969
|
+
const current = store.get(currentReference);
|
|
1332
1970
|
assertAllowedUrl(current.url, config.allowedOrigins);
|
|
1333
|
-
if (
|
|
1971
|
+
if (!input.referenceImagePath && (baselineTabId !== current.tabId || baselineUrl !== current.url))
|
|
1334
1972
|
throw new GSError(
|
|
1335
1973
|
"GS_DIFF_TARGET",
|
|
1336
1974
|
"Both references must belong to the same tab and URL."
|
|
1337
1975
|
);
|
|
1338
1976
|
const { data, ...metadata } = await runDiff(
|
|
1339
|
-
|
|
1977
|
+
baselineData,
|
|
1340
1978
|
current.data,
|
|
1341
1979
|
input.threshold
|
|
1342
1980
|
);
|
|
1343
|
-
const diffImage =
|
|
1344
|
-
...
|
|
1981
|
+
const diffImage = store.put({
|
|
1982
|
+
...baselineForDiffImage ?? current,
|
|
1345
1983
|
width: metadata.width,
|
|
1346
1984
|
height: metadata.height,
|
|
1347
1985
|
data
|
|
1348
1986
|
});
|
|
1349
1987
|
const result = {
|
|
1350
1988
|
...metadata,
|
|
1351
|
-
baseline: input.baseline,
|
|
1989
|
+
...input.baseline ? { baseline: input.baseline } : {},
|
|
1990
|
+
...input.referenceImagePath ? { referenceImagePath: input.referenceImagePath } : {},
|
|
1352
1991
|
current: currentReference,
|
|
1353
1992
|
diffImage,
|
|
1354
1993
|
threshold: input.threshold
|
|
1355
1994
|
};
|
|
1995
|
+
if (input.checkpoint && input.referenceImagePath) {
|
|
1996
|
+
let stabilityDifferencePercent;
|
|
1997
|
+
let unstableBoundingBoxes;
|
|
1998
|
+
if (!input.current) {
|
|
1999
|
+
try {
|
|
2000
|
+
const recapture = toImage(
|
|
2001
|
+
await bridge.router.request(
|
|
2002
|
+
"capture",
|
|
2003
|
+
captureSchema.parse({
|
|
2004
|
+
tabId: input.tabId,
|
|
2005
|
+
format: "png",
|
|
2006
|
+
waitForStable: false
|
|
2007
|
+
})
|
|
2008
|
+
)
|
|
2009
|
+
);
|
|
2010
|
+
const stability = await runDiff(
|
|
2011
|
+
current.data,
|
|
2012
|
+
recapture.image.data,
|
|
2013
|
+
input.threshold
|
|
2014
|
+
);
|
|
2015
|
+
stabilityDifferencePercent = stability.differencePercent;
|
|
2016
|
+
unstableBoundingBoxes = stability.boundingBoxes;
|
|
2017
|
+
} catch {
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
const observed = await observeCheckpoint(
|
|
2021
|
+
input.checkpoint,
|
|
2022
|
+
checkpointRoot(),
|
|
2023
|
+
{
|
|
2024
|
+
differencePercent: metadata.differencePercent,
|
|
2025
|
+
changedPixelCount: metadata.changedPixelCount,
|
|
2026
|
+
width: metadata.width,
|
|
2027
|
+
height: metadata.height,
|
|
2028
|
+
referenceImagePath: input.referenceImagePath,
|
|
2029
|
+
threshold: input.threshold,
|
|
2030
|
+
stabilityDifferencePercent,
|
|
2031
|
+
unstableBoundingBoxes
|
|
2032
|
+
},
|
|
2033
|
+
current.data
|
|
2034
|
+
);
|
|
2035
|
+
Object.assign(result, { checkpoint: input.checkpoint, ...observed });
|
|
2036
|
+
}
|
|
1356
2037
|
return {
|
|
1357
2038
|
content: [
|
|
1358
2039
|
{ type: "image", mimeType: "image/png", data },
|
|
@@ -1420,9 +2101,9 @@ try {
|
|
|
1420
2101
|
process.once("SIGINT", () => void close());
|
|
1421
2102
|
process.once("SIGTERM", () => void close());
|
|
1422
2103
|
process.stdin.once("end", () => void close());
|
|
1423
|
-
} catch {
|
|
2104
|
+
} catch (error) {
|
|
1424
2105
|
console.error(
|
|
1425
|
-
"ERROR GazeSight startup failed. Check GAZESIGHT_PAIR_TOKEN (64 hex characters), port availability and configuration."
|
|
2106
|
+
error instanceof Error && error.message ? `ERROR GazeSight startup failed: ${error.message}` : "ERROR GazeSight startup failed. Check GAZESIGHT_PAIR_TOKEN (64 hex characters), port availability and configuration."
|
|
1426
2107
|
);
|
|
1427
2108
|
process.exitCode = 1;
|
|
1428
2109
|
}
|