@triscope/mcp 0.4.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/LICENSE +21 -0
- package/README.md +31 -0
- package/bin/triscope-mcp-supervised.mjs +114 -0
- package/bin/triscope-mcp.mjs +11 -0
- package/dist/browser.mjs +348 -0
- package/dist/browser.mjs.map +1 -0
- package/dist/logger.mjs +51 -0
- package/dist/logger.mjs.map +1 -0
- package/dist/refs.mjs +396 -0
- package/dist/refs.mjs.map +1 -0
- package/dist/server.mjs +3125 -0
- package/dist/server.mjs.map +1 -0
- package/package.json +49 -0
- package/src/browser.ts +461 -0
- package/src/logger.ts +94 -0
- package/src/optimize.ts +142 -0
- package/src/refs.ts +468 -0
- package/src/server.ts +2678 -0
- package/src/targets.ts +163 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,2678 @@
|
|
|
1
|
+
// Triscope MCP server.
|
|
2
|
+
//
|
|
3
|
+
// Exposes tools that talk to a running triscope dev server (default
|
|
4
|
+
// http://localhost:5173) and the on-disk telemetry sink it writes:
|
|
5
|
+
// - list_elements : GET /__manifest
|
|
6
|
+
// - read_telemetry : read /tmp/<project>-state.json, optional jq-style path
|
|
7
|
+
// - set_knob : POST /__knob (harness polls and applies live)
|
|
8
|
+
// - capture_views : drives a fresh headed Chromium via CDP, calls
|
|
9
|
+
// window.__TRISCOPE__.captureViews(), writes per-camera
|
|
10
|
+
// PNGs to /tmp/<project>-capture-<element>/<camera>.png
|
|
11
|
+
// - run_smoke : spawns `triscope smoke <element>` and returns pass/fail
|
|
12
|
+
//
|
|
13
|
+
// Configuration env vars:
|
|
14
|
+
// TRISCOPE_URL (default http://localhost:5173)
|
|
15
|
+
// TRISCOPE_PROJECT (default: derived from process.cwd()/package.json#name)
|
|
16
|
+
// TRISCOPE_DEBUG_PORT (default 9230)
|
|
17
|
+
// CHROME_BIN (default 'chromium')
|
|
18
|
+
|
|
19
|
+
import { spawn } from 'node:child_process';
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
25
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
26
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
27
|
+
import { PNG } from 'pngjs';
|
|
28
|
+
import { z } from 'zod';
|
|
29
|
+
import { createBrowserPool } from './browser.js';
|
|
30
|
+
import { createLogger } from './logger.js';
|
|
31
|
+
import { coordinateDescent, knobMatches } from './optimize.js';
|
|
32
|
+
import {
|
|
33
|
+
composeFilmstrip,
|
|
34
|
+
diffReference,
|
|
35
|
+
diffReferenceMotion,
|
|
36
|
+
motionMagnitudeFromFrames,
|
|
37
|
+
refsMotionPaths,
|
|
38
|
+
refsPath,
|
|
39
|
+
setReference,
|
|
40
|
+
setReferenceMotion,
|
|
41
|
+
} from './refs.js';
|
|
42
|
+
import { evaluateTargets } from './targets.js';
|
|
43
|
+
|
|
44
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
45
|
+
|
|
46
|
+
export function readProjectName(cwd) {
|
|
47
|
+
if (process.env.TRISCOPE_PROJECT) return process.env.TRISCOPE_PROJECT;
|
|
48
|
+
try {
|
|
49
|
+
const p = join(cwd, 'package.json');
|
|
50
|
+
if (!existsSync(p)) return 'triscope-project';
|
|
51
|
+
const pkg = JSON.parse(readFileSync(p, 'utf8'));
|
|
52
|
+
return String(pkg.name ?? 'triscope-project').replace(/[^A-Za-z0-9._-]/g, '-');
|
|
53
|
+
} catch {
|
|
54
|
+
return 'triscope-project';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEV_URL = (process.env.TRISCOPE_URL ?? 'http://localhost:5173').replace(/\/$/, '');
|
|
59
|
+
const PROJECT = readProjectName(process.cwd());
|
|
60
|
+
const STATE_PATH = join(tmpdir(), `${PROJECT}-state.json`);
|
|
61
|
+
// Inline image payload safety cap. MCP stdio JSON-RPC has practical message
|
|
62
|
+
// size limits and ours exceeded them at 12 cameras × 1280×720 PNG base64 →
|
|
63
|
+
// the server process got OOM-killed mid-response. Override with
|
|
64
|
+
// TRISCOPE_INLINE_PAYLOAD_BUDGET (bytes) if you really need bigger.
|
|
65
|
+
const INLINE_PAYLOAD_BUDGET = Number(process.env.TRISCOPE_INLINE_PAYLOAD_BUDGET ?? 1024 * 1024);
|
|
66
|
+
|
|
67
|
+
// ---- Resilience: ring buffer of recent errors + process-level handlers --
|
|
68
|
+
// A single rogue async exception used to crash the entire MCP server (the
|
|
69
|
+
// process died, Claude Code didn't auto-restart it, and subsequent calls
|
|
70
|
+
// timed out with cryptic "Connection closed"). Now we log and continue so
|
|
71
|
+
// individual tool failures stay isolated from the server lifecycle.
|
|
72
|
+
const SERVER_START_TIME = Date.now();
|
|
73
|
+
const RECENT_ERRORS_CAP = 16;
|
|
74
|
+
const recentErrors: string[] = [];
|
|
75
|
+
const logger = createLogger(PROJECT);
|
|
76
|
+
const browserPool = createBrowserPool({ logger });
|
|
77
|
+
const shutdown = () => browserPool.dispose();
|
|
78
|
+
process.on('exit', shutdown);
|
|
79
|
+
process.on('SIGINT', () => {
|
|
80
|
+
shutdown();
|
|
81
|
+
process.exit(130);
|
|
82
|
+
});
|
|
83
|
+
process.on('SIGTERM', () => {
|
|
84
|
+
shutdown();
|
|
85
|
+
process.exit(143);
|
|
86
|
+
});
|
|
87
|
+
export function recordError(source: string, err: unknown) {
|
|
88
|
+
const detail = (err as any)?.stack ?? (err as any)?.message ?? String(err);
|
|
89
|
+
const msg = `[${new Date().toISOString()}] ${source}: ${detail}`;
|
|
90
|
+
logger.error(source, String((err as any)?.message ?? err), { stack: (err as any)?.stack });
|
|
91
|
+
recentErrors.push(msg);
|
|
92
|
+
if (recentErrors.length > RECENT_ERRORS_CAP) recentErrors.shift();
|
|
93
|
+
}
|
|
94
|
+
process.on('uncaughtException', (err) => recordError('uncaughtException', err));
|
|
95
|
+
process.on('unhandledRejection', (err) => recordError('unhandledRejection', err));
|
|
96
|
+
|
|
97
|
+
export function applyPath(data, path) {
|
|
98
|
+
if (!path) return data;
|
|
99
|
+
const segs = path.replace(/^\./, '').split('.').filter(Boolean);
|
|
100
|
+
let cur = data;
|
|
101
|
+
for (const s of segs) {
|
|
102
|
+
if (cur == null) return undefined;
|
|
103
|
+
cur = cur[s];
|
|
104
|
+
}
|
|
105
|
+
return cur;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function fetchManifest(devUrl: string = DEV_URL): Promise<any> {
|
|
109
|
+
try {
|
|
110
|
+
const r = await fetch(`${devUrl}/__manifest`);
|
|
111
|
+
if (!r.ok) return null;
|
|
112
|
+
const m = await r.json();
|
|
113
|
+
// Shape: { elements: { [name]: { element, labUrl, cameras, knobs } } }
|
|
114
|
+
return m && typeof m === 'object' ? m : null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Short-TTL manifest cache so a batch of set_knob calls (e.g. auto_tune's
|
|
121
|
+
// inner loop) doesn't fire one GET /__manifest per knob just to look up specs.
|
|
122
|
+
// Keyed by devUrl so cross-project calls don't read each other's manifest.
|
|
123
|
+
const _manifestCache = new Map<string, { value: any; at: number }>();
|
|
124
|
+
async function fetchManifestCached(devUrl: string = DEV_URL, ttlMs = 1000): Promise<any> {
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
const hit = _manifestCache.get(devUrl);
|
|
127
|
+
if (hit && now - hit.at < ttlMs) return hit.value;
|
|
128
|
+
const value = await fetchManifest(devUrl);
|
|
129
|
+
_manifestCache.set(devUrl, { value, at: now });
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve the dev-server origin for a per-call request. Precedence: explicit
|
|
135
|
+
* `devUrl` → the origin of `labUrl` → the boot DEV_URL. Lets the
|
|
136
|
+
* dev-server-bound tools (read_telemetry/set_knob/list_elements/get_scene/
|
|
137
|
+
* auto_tune/multi_tune) target a lab/project other than the one the MCP booted
|
|
138
|
+
* against, mirroring the per-call `labUrl` the browser tools already accept.
|
|
139
|
+
*/
|
|
140
|
+
export function resolveDevUrl({ devUrl, labUrl }: { devUrl?: string; labUrl?: string }): string {
|
|
141
|
+
if (devUrl) return devUrl.replace(/\/$/, '');
|
|
142
|
+
if (labUrl) {
|
|
143
|
+
try {
|
|
144
|
+
return new URL(labUrl).origin;
|
|
145
|
+
} catch {
|
|
146
|
+
/* not an absolute URL — fall through to default */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return DEV_URL;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Fetch the LAST telemetry the harness posted, live over HTTP from the dev
|
|
154
|
+
* server (GET /__state), regardless of which /tmp file it was written to. The
|
|
155
|
+
* harness stamps `postedAt` so the caller can tell how stale the snapshot is
|
|
156
|
+
* (the lab tab may be backgrounded/closed). Returns null on any failure.
|
|
157
|
+
*/
|
|
158
|
+
async function fetchState(devUrl: string): Promise<{ data: any; staleMs: number | null } | null> {
|
|
159
|
+
try {
|
|
160
|
+
const r = await fetch(`${devUrl}/__state`, { signal: AbortSignal.timeout(2000) });
|
|
161
|
+
if (!r.ok) return null;
|
|
162
|
+
const data = (await r.json()) as any;
|
|
163
|
+
// GET /__state returns the literal `{}` (HTTP 200) when no lab tab has posted
|
|
164
|
+
// yet (telemetry.ts). Treat an empty object as "no telemetry" → null, so the
|
|
165
|
+
// caller falls through to the disk read / the actionable "open a lab page"
|
|
166
|
+
// error instead of silently returning undefined for every path.
|
|
167
|
+
if (!data || typeof data !== 'object' || Object.keys(data).length === 0) return null;
|
|
168
|
+
const staleMs = typeof data.postedAt === 'number' ? Date.now() - data.postedAt : null;
|
|
169
|
+
return { data, staleMs };
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const STALE_MS = Number(process.env.TRISCOPE_STALE_MS ?? 1500);
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Server-local mirror of @triscope/core's `clampKnob` (kept here to preserve
|
|
179
|
+
* the node-only MCP / browser-only core boundary — the same reason
|
|
180
|
+
* probeStatsFromPng mirrors the harness's sampleGpuProbes rather than importing
|
|
181
|
+
* it). The harness clamp is authoritative; clamping here too means out-of-range
|
|
182
|
+
* values are corrected *before* they persist in /__knob and the agent gets
|
|
183
|
+
* immediate `{requested, final}` feedback instead of having to read telemetry.
|
|
184
|
+
*/
|
|
185
|
+
export function clampKnobValue(
|
|
186
|
+
spec: any,
|
|
187
|
+
value: number | string | boolean,
|
|
188
|
+
): { clamped: boolean; final: number | string | boolean } {
|
|
189
|
+
if (!spec || typeof spec !== 'object') return { clamped: false, final: value };
|
|
190
|
+
switch (spec.type) {
|
|
191
|
+
case 'number': {
|
|
192
|
+
const n = Number(value);
|
|
193
|
+
if (!Number.isFinite(n)) return { clamped: true, final: spec.default };
|
|
194
|
+
let next = n;
|
|
195
|
+
if (typeof spec.step === 'number' && spec.step > 0) {
|
|
196
|
+
next = spec.min + Math.round((next - spec.min) / spec.step) * spec.step;
|
|
197
|
+
}
|
|
198
|
+
next = Math.min(spec.max, Math.max(spec.min, next));
|
|
199
|
+
return { clamped: Math.abs(next - n) >= 1e-9, final: next };
|
|
200
|
+
}
|
|
201
|
+
case 'int': {
|
|
202
|
+
const n = Number(value);
|
|
203
|
+
if (!Number.isFinite(n)) return { clamped: true, final: spec.default };
|
|
204
|
+
const next = Math.min(spec.max, Math.max(spec.min, Math.round(n)));
|
|
205
|
+
return { clamped: next !== n, final: next };
|
|
206
|
+
}
|
|
207
|
+
case 'color':
|
|
208
|
+
return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(String(value))
|
|
209
|
+
? { clamped: false, final: value }
|
|
210
|
+
: { clamped: true, final: spec.default };
|
|
211
|
+
case 'boolean':
|
|
212
|
+
return { clamped: typeof value !== 'boolean', final: Boolean(value) };
|
|
213
|
+
default:
|
|
214
|
+
return { clamped: false, final: value }; // trigger / unknown → passthrough
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Build {element: {knobKey: spec}} from the live manifest's knob arrays. */
|
|
219
|
+
export function knobSpecMapFromManifest(manifest: any): Record<string, Record<string, any>> {
|
|
220
|
+
const out: Record<string, Record<string, any>> = {};
|
|
221
|
+
for (const [el, entry] of Object.entries((manifest?.elements ?? {}) as Record<string, any>)) {
|
|
222
|
+
const byKey: Record<string, any> = {};
|
|
223
|
+
for (const k of entry?.knobs ?? []) {
|
|
224
|
+
if (!k?.name) continue;
|
|
225
|
+
// Index by the advertised name (the display key `element.knob` in a
|
|
226
|
+
// namespaced scene) AND by the element-local key, so set_knob's clamp
|
|
227
|
+
// feedback resolves whether the caller passes `cube.spin` or `spin`.
|
|
228
|
+
byKey[k.name] = k;
|
|
229
|
+
const local = el && k.name.startsWith(`${el}.`) ? k.name.slice(el.length + 1) : k.name;
|
|
230
|
+
if (!(local in byKey)) byKey[local] = k;
|
|
231
|
+
}
|
|
232
|
+
out[el] = byKey;
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function readProjectLabMap(cwd) {
|
|
238
|
+
try {
|
|
239
|
+
const p = join(cwd, 'package.json');
|
|
240
|
+
if (!existsSync(p)) return {};
|
|
241
|
+
const pkg = JSON.parse(readFileSync(p, 'utf8'));
|
|
242
|
+
const m = pkg?.triscope?.labs;
|
|
243
|
+
return m && typeof m === 'object' ? m : {};
|
|
244
|
+
} catch {
|
|
245
|
+
return {};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const PROJECT_LABS = readProjectLabMap(process.cwd());
|
|
250
|
+
|
|
251
|
+
export function absolutize(maybePath, base: string = DEV_URL) {
|
|
252
|
+
if (!maybePath) return null;
|
|
253
|
+
if (/^https?:\/\//.test(maybePath)) return maybePath;
|
|
254
|
+
return `${base}${maybePath.startsWith('/') ? '' : '/'}${maybePath}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function resolveLabUrl({
|
|
258
|
+
element,
|
|
259
|
+
labUrl,
|
|
260
|
+
devUrl,
|
|
261
|
+
}: {
|
|
262
|
+
element?: string;
|
|
263
|
+
labUrl?: string;
|
|
264
|
+
devUrl?: string;
|
|
265
|
+
}): Promise<string> {
|
|
266
|
+
// 1. Explicit arg wins.
|
|
267
|
+
if (labUrl) return absolutize(labUrl);
|
|
268
|
+
const base = resolveDevUrl({ devUrl });
|
|
269
|
+
if (!element) return base;
|
|
270
|
+
// 2. Live manifest from a running lab (on the target dev server).
|
|
271
|
+
const manifest = await fetchManifest(base);
|
|
272
|
+
const entry = manifest?.elements?.[element];
|
|
273
|
+
if (entry?.labUrl) return absolutize(entry.labUrl, base);
|
|
274
|
+
// 3. Per-project escape hatch in package.json#triscope.labs.
|
|
275
|
+
if (PROJECT_LABS[element]) return absolutize(PROJECT_LABS[element], base);
|
|
276
|
+
// 4. Convention fallback.
|
|
277
|
+
return `${base}/labs/${element}.html`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function listElements({ devUrl, labUrl }: { devUrl?: string; labUrl?: string } = {}) {
|
|
281
|
+
const m = await fetchManifest(resolveDevUrl({ devUrl, labUrl }));
|
|
282
|
+
if (!m || !m.elements || Object.keys(m.elements).length === 0) {
|
|
283
|
+
return {
|
|
284
|
+
manifest: null,
|
|
285
|
+
note: 'Dev server is up but no manifest has been posted yet. Load a lab page first.',
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
return { manifest: m };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function readTelemetry(
|
|
292
|
+
path?: string,
|
|
293
|
+
{ devUrl, labUrl }: { devUrl?: string; labUrl?: string } = {},
|
|
294
|
+
) {
|
|
295
|
+
const url = resolveDevUrl({ devUrl, labUrl });
|
|
296
|
+
// Live-first: GET ${url}/__state fetches the dev server's OWN state file over
|
|
297
|
+
// HTTP — works for any project/port without the MCP knowing the /tmp path
|
|
298
|
+
// (the original "No telemetry at /tmp/...-state.json" cross-project failure).
|
|
299
|
+
// Fall back to the local file only for the default dev server (a non-default
|
|
300
|
+
// project's state-file path is unknown to this process).
|
|
301
|
+
let data: any = null;
|
|
302
|
+
let source: 'live' | 'disk' | null = null;
|
|
303
|
+
let staleMs: number | null = null;
|
|
304
|
+
const live = await fetchState(url);
|
|
305
|
+
if (live) {
|
|
306
|
+
data = live.data;
|
|
307
|
+
source = 'live';
|
|
308
|
+
staleMs = live.staleMs;
|
|
309
|
+
} else if (url === DEV_URL && existsSync(STATE_PATH)) {
|
|
310
|
+
data = safeReadState();
|
|
311
|
+
source = 'disk';
|
|
312
|
+
staleMs = typeof data?.postedAt === 'number' ? Date.now() - data.postedAt : null;
|
|
313
|
+
}
|
|
314
|
+
if (data == null) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
`No telemetry from ${url}/__state (and no local file). Is the dev server running with a lab page open?`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const sliced = applyPath(data, path);
|
|
320
|
+
// Back-compat: a `path` query returns the BARE sliced value (what every
|
|
321
|
+
// existing consumer + the E2E expect). For the FULL snapshot (no path) we
|
|
322
|
+
// annotate freshness via non-colliding `__source`/`__staleMs` keys so a stale
|
|
323
|
+
// tab (backgrounded/closed) is detectable without changing the value shape.
|
|
324
|
+
if (path == null && sliced && typeof sliced === 'object' && !Array.isArray(sliced)) {
|
|
325
|
+
return {
|
|
326
|
+
...sliced,
|
|
327
|
+
__source: source,
|
|
328
|
+
...(staleMs != null ? { __staleMs: staleMs } : {}),
|
|
329
|
+
...(staleMs != null && staleMs > STALE_MS
|
|
330
|
+
? { __warning: `telemetry is ${staleMs}ms old — the lab tab may be backgrounded or closed` }
|
|
331
|
+
: {}),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return sliced;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function setKnob(payload) {
|
|
338
|
+
// `payload` is either a single {element,key,value} or {updates:[...]}.
|
|
339
|
+
// The /__knob endpoint accepts arrays natively (telemetry.ts line 94).
|
|
340
|
+
const batched = Array.isArray(payload?.updates);
|
|
341
|
+
const updates: Array<{ element: string; key: string; value: unknown }> = batched
|
|
342
|
+
? payload.updates
|
|
343
|
+
: [payload];
|
|
344
|
+
const url = resolveDevUrl({ devUrl: payload?.devUrl, labUrl: payload?.labUrl });
|
|
345
|
+
|
|
346
|
+
// Best-effort clamp against the live manifest's knob specs so out-of-range
|
|
347
|
+
// values are corrected before they persist + the agent learns what applied.
|
|
348
|
+
const specs = knobSpecMapFromManifest(await fetchManifestCached(url));
|
|
349
|
+
const clampedReports: Array<{
|
|
350
|
+
element: string;
|
|
351
|
+
key: string;
|
|
352
|
+
requested: unknown;
|
|
353
|
+
final: unknown;
|
|
354
|
+
}> = [];
|
|
355
|
+
const outgoing = updates.map((u) => {
|
|
356
|
+
const spec = specs[u.element]?.[u.key];
|
|
357
|
+
if (!spec) return u;
|
|
358
|
+
const c = clampKnobValue(spec, u.value as number | string | boolean);
|
|
359
|
+
if (c.clamped) {
|
|
360
|
+
clampedReports.push({ element: u.element, key: u.key, requested: u.value, final: c.final });
|
|
361
|
+
}
|
|
362
|
+
return { ...u, value: c.final };
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const body = JSON.stringify(batched ? outgoing : outgoing[0]);
|
|
366
|
+
const r = await fetch(`${url}/__knob`, {
|
|
367
|
+
method: 'POST',
|
|
368
|
+
headers: { 'content-type': 'application/json' },
|
|
369
|
+
body,
|
|
370
|
+
});
|
|
371
|
+
if (!r.ok) throw new Error(`__knob returned ${r.status}`);
|
|
372
|
+
return {
|
|
373
|
+
ok: true,
|
|
374
|
+
count: outgoing.length,
|
|
375
|
+
...(clampedReports.length ? { clamped: clampedReports } : {}),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Trim + annotate the telemetry snapshot embedded in a capture response:
|
|
381
|
+
* - flag that `perf.fps` is sampled right after the navigate, so it can read
|
|
382
|
+
* low while WebGPU warms (use read_telemetry for steady-state) — otherwise
|
|
383
|
+
* the capture's fps looks catastrophic vs reality;
|
|
384
|
+
* - drop the per-probe `samples` arrays (the payload bulk — a busy scene's full
|
|
385
|
+
* motion history burns context for nothing; keep the stats + a count).
|
|
386
|
+
* Never throws — a capture must not fail over telemetry cosmetics.
|
|
387
|
+
*/
|
|
388
|
+
function trimCaptureTelemetry(sample: any): any {
|
|
389
|
+
if (!sample || typeof sample !== 'object') return sample;
|
|
390
|
+
try {
|
|
391
|
+
if (sample.perf && typeof sample.perf === 'object') {
|
|
392
|
+
sample.perf = {
|
|
393
|
+
...sample.perf,
|
|
394
|
+
note: 'capture-time fps — reads low right after a navigate while WebGPU warms; use read_telemetry for steady-state',
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
for (const el of Object.values(sample.elements ?? {}) as any[]) {
|
|
398
|
+
for (const probe of Object.values(el?.motion ?? {}) as any[]) {
|
|
399
|
+
if (probe && Array.isArray(probe.samples)) {
|
|
400
|
+
probe.sampleCount = probe.samples.length;
|
|
401
|
+
delete probe.samples;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
} catch {
|
|
406
|
+
/* leave the snapshot as-is */
|
|
407
|
+
}
|
|
408
|
+
return sample;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function captureViews({
|
|
412
|
+
element,
|
|
413
|
+
labUrl,
|
|
414
|
+
inline = true,
|
|
415
|
+
fresh = false,
|
|
416
|
+
}: {
|
|
417
|
+
element?: string;
|
|
418
|
+
labUrl?: string;
|
|
419
|
+
inline?: boolean;
|
|
420
|
+
fresh?: boolean;
|
|
421
|
+
}) {
|
|
422
|
+
// Persistent Chromium: first call cold-starts (~3s), subsequent calls
|
|
423
|
+
// reuse the same browser/page and just navigate if the URL changed.
|
|
424
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
425
|
+
const outDir = join(tmpdir(), `${PROJECT}-capture-${element ?? 'scene'}`);
|
|
426
|
+
mkdirSync(outDir, { recursive: true });
|
|
427
|
+
|
|
428
|
+
const t0 = Date.now();
|
|
429
|
+
const timings: Record<string, number> = {};
|
|
430
|
+
const tNavStart = Date.now();
|
|
431
|
+
const { call } = await browserPool.getPage(target, { reload: fresh });
|
|
432
|
+
timings.navigate = Date.now() - tNavStart;
|
|
433
|
+
const tRenderStart = Date.now();
|
|
434
|
+
const result = await call('Runtime.evaluate', {
|
|
435
|
+
expression: 'window.__TRISCOPE__.captureViews()',
|
|
436
|
+
awaitPromise: true,
|
|
437
|
+
returnByValue: true,
|
|
438
|
+
});
|
|
439
|
+
timings.render = Date.now() - tRenderStart;
|
|
440
|
+
const views = result.result.result.value;
|
|
441
|
+
if (!views || typeof views !== 'object') {
|
|
442
|
+
// By the time we get here browserPool.getPage has already proven the
|
|
443
|
+
// harness mounted, so an empty result means the Element declared zero
|
|
444
|
+
// cameras OR captureViews itself errored without returning.
|
|
445
|
+
throw new Error(
|
|
446
|
+
`captureViews returned no images for element="${element ?? '(scene)'}" at ${target}. ` +
|
|
447
|
+
`Most likely the Element declares no cameras — check the manifest: ` +
|
|
448
|
+
`mcp__triscope__list_elements.`,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
const written = {};
|
|
452
|
+
const base64ByCam = {};
|
|
453
|
+
const tWriteStart = Date.now();
|
|
454
|
+
for (const [cam, dataUrl] of Object.entries(views)) {
|
|
455
|
+
if (typeof dataUrl !== 'string') continue;
|
|
456
|
+
const b64 = dataUrl.replace(/^data:image\/png;base64,/, '');
|
|
457
|
+
const path = join(outDir, `${cam}.png`);
|
|
458
|
+
writeFileSync(path, Buffer.from(b64, 'base64'));
|
|
459
|
+
written[cam] = path;
|
|
460
|
+
base64ByCam[cam] = b64;
|
|
461
|
+
}
|
|
462
|
+
timings.writePngs = Date.now() - tWriteStart;
|
|
463
|
+
const tTelStart = Date.now();
|
|
464
|
+
const telemetry = await call('Runtime.evaluate', {
|
|
465
|
+
expression: 'JSON.stringify(window.__TRISCOPE__.sampleTelemetry())',
|
|
466
|
+
returnByValue: true,
|
|
467
|
+
});
|
|
468
|
+
const sample = trimCaptureTelemetry(JSON.parse(telemetry.result.result.value));
|
|
469
|
+
timings.telemetry = Date.now() - tTelStart;
|
|
470
|
+
|
|
471
|
+
// captureViews() populates window.__TRISCOPE__.lastGpuProbes as a side
|
|
472
|
+
// effect (per-camera luminance/p5/p95/dynamicRange). Surface it in the
|
|
473
|
+
// tool response so consumers don't have to do a second CDP eval.
|
|
474
|
+
// If the harness didn't populate it (lab page that doesn't use
|
|
475
|
+
// @triscope/core runLab — e.g. water3d's scene lab which has a custom
|
|
476
|
+
// capture path), we decode the captured PNGs server-side with pngjs
|
|
477
|
+
// and compute the same scalars. Slower (~5 ms per camera) but means
|
|
478
|
+
// GPU probes are available for any captured PNG.
|
|
479
|
+
const tProbeStart = Date.now();
|
|
480
|
+
let gpuProbes: Record<string, any> | null = null;
|
|
481
|
+
let gpuProbesSource: 'harness' | 'server-fallback' | 'unavailable' = 'unavailable';
|
|
482
|
+
try {
|
|
483
|
+
const probesResp = await call('Runtime.evaluate', {
|
|
484
|
+
expression: 'JSON.stringify(window.__TRISCOPE__.lastGpuProbes ?? null)',
|
|
485
|
+
returnByValue: true,
|
|
486
|
+
});
|
|
487
|
+
const fromHarness = JSON.parse(probesResp.result.result.value);
|
|
488
|
+
if (fromHarness && Object.keys(fromHarness).length > 0) {
|
|
489
|
+
gpuProbes = fromHarness;
|
|
490
|
+
gpuProbesSource = 'harness';
|
|
491
|
+
}
|
|
492
|
+
} catch {
|
|
493
|
+
/* old core — fall through to server-side */
|
|
494
|
+
}
|
|
495
|
+
if (!gpuProbes) {
|
|
496
|
+
gpuProbes = {};
|
|
497
|
+
for (const [cam, b64] of Object.entries(base64ByCam) as [string, string][]) {
|
|
498
|
+
try {
|
|
499
|
+
gpuProbes[cam] = probeStatsFromPng(Buffer.from(b64, 'base64'));
|
|
500
|
+
} catch {
|
|
501
|
+
/* skip cameras whose PNGs fail to decode */
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (Object.keys(gpuProbes).length === 0) gpuProbes = null;
|
|
505
|
+
else gpuProbesSource = 'server-fallback';
|
|
506
|
+
}
|
|
507
|
+
timings.probes = Date.now() - tProbeStart;
|
|
508
|
+
|
|
509
|
+
// Cameras the GPU drew black (luminance < threshold) — set by the harness
|
|
510
|
+
// probe or the server-side fallback. Surfaced top-level so the agent sees a
|
|
511
|
+
// dead pane immediately instead of inspecting per-camera gpuProbes.
|
|
512
|
+
const blackFrames = gpuProbes
|
|
513
|
+
? Object.entries(gpuProbes)
|
|
514
|
+
.filter(([, s]) => (s as any)?.blackFrame)
|
|
515
|
+
.map(([cam]) => cam)
|
|
516
|
+
: [];
|
|
517
|
+
// Cameras that rendered a (near-)UNIFORM frame — dynamicRange ≈ 1 means p95≈p5,
|
|
518
|
+
// i.e. nothing but the flat clear-color is in view (the element drifted out of
|
|
519
|
+
// frame / wasn't drawn). blackFrames misses this when the clear-color sits
|
|
520
|
+
// above the black threshold (the "empty but not black" case), so it reads as a
|
|
521
|
+
// false OK. Excludes genuinely black panes (already in blackFrames).
|
|
522
|
+
const flatFrames = gpuProbes
|
|
523
|
+
? Object.entries(gpuProbes)
|
|
524
|
+
.filter(([, s]) => {
|
|
525
|
+
const p = s as any;
|
|
526
|
+
return p && !p.blackFrame && typeof p.dynamicRange === 'number' && p.dynamicRange <= 1.05;
|
|
527
|
+
})
|
|
528
|
+
.map(([cam]) => cam)
|
|
529
|
+
: [];
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
element: element ?? null,
|
|
533
|
+
dir: outDir,
|
|
534
|
+
files: written,
|
|
535
|
+
cameraOrder: Object.keys(written),
|
|
536
|
+
telemetry: sample,
|
|
537
|
+
gpuProbes,
|
|
538
|
+
gpuProbesSource,
|
|
539
|
+
blackFrames,
|
|
540
|
+
flatFrames,
|
|
541
|
+
inline,
|
|
542
|
+
captureMs: Date.now() - t0,
|
|
543
|
+
timings,
|
|
544
|
+
_base64ByCam: base64ByCam,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Server-side fallback: same math the harness does, but starting from a
|
|
549
|
+
* decoded PNG buffer instead of a 2D canvas. Stride-samples to ~2300 px
|
|
550
|
+
* so the cost is bounded (~5 ms per 1280×720 PNG). */
|
|
551
|
+
// Mean Rec.709 luminance below which a pane counts as a black render. Matches
|
|
552
|
+
// @triscope/core's BLACK_FRAME_LUMINANCE (mirrored across the node boundary).
|
|
553
|
+
const BLACK_FRAME_LUMINANCE = 0.004;
|
|
554
|
+
|
|
555
|
+
export function probeStatsFromPng(pngBuf: Buffer): {
|
|
556
|
+
luminance: number;
|
|
557
|
+
p5: number;
|
|
558
|
+
p95: number;
|
|
559
|
+
dynamicRange: number;
|
|
560
|
+
samples: number;
|
|
561
|
+
blackFrame?: boolean;
|
|
562
|
+
} {
|
|
563
|
+
const img = PNG.sync.read(pngBuf);
|
|
564
|
+
const stride = Math.max(1, Math.floor(Math.sqrt((img.width * img.height) / 2304)));
|
|
565
|
+
const lums: number[] = [];
|
|
566
|
+
let sum = 0;
|
|
567
|
+
for (let y = 0; y < img.height; y += stride) {
|
|
568
|
+
for (let x = 0; x < img.width; x += stride) {
|
|
569
|
+
const i = (y * img.width + x) * 4;
|
|
570
|
+
const r = img.data[i] / 255;
|
|
571
|
+
const g = img.data[i + 1] / 255;
|
|
572
|
+
const b = img.data[i + 2] / 255;
|
|
573
|
+
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
574
|
+
lums.push(lum);
|
|
575
|
+
sum += lum;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
lums.sort((a, b) => a - b);
|
|
579
|
+
const n = lums.length;
|
|
580
|
+
const p5 = lums[Math.floor(n * 0.05)];
|
|
581
|
+
const p95 = lums[Math.floor(n * 0.95)];
|
|
582
|
+
const luminance = +(sum / n).toFixed(4);
|
|
583
|
+
return {
|
|
584
|
+
luminance,
|
|
585
|
+
p5: +p5.toFixed(4),
|
|
586
|
+
p95: +p95.toFixed(4),
|
|
587
|
+
dynamicRange: +(p95 / Math.max(p5, 1 / 255)).toFixed(2),
|
|
588
|
+
samples: n,
|
|
589
|
+
...(luminance < BLACK_FRAME_LUMINANCE ? { blackFrame: true } : {}),
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function captureMotionFramesRaw({
|
|
594
|
+
element,
|
|
595
|
+
camera,
|
|
596
|
+
frames,
|
|
597
|
+
dt,
|
|
598
|
+
mode,
|
|
599
|
+
labUrl,
|
|
600
|
+
}: any): Promise<string[]> {
|
|
601
|
+
// Like captureMotion but for ONE camera, returns the raw base64 PNG frames.
|
|
602
|
+
// Used internally by set_reference_motion + diff_reference_motion.
|
|
603
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
604
|
+
const { call } = await browserPool.getPage(target);
|
|
605
|
+
const result = await call('Runtime.evaluate', {
|
|
606
|
+
expression: `window.__TRISCOPE__.captureMotionFrames(${JSON.stringify(camera)}, ${JSON.stringify({ frames, dt, mode })})`,
|
|
607
|
+
awaitPromise: true,
|
|
608
|
+
returnByValue: true,
|
|
609
|
+
});
|
|
610
|
+
const frames_ = result.result.result.value;
|
|
611
|
+
if (!Array.isArray(frames_) || frames_.length === 0) {
|
|
612
|
+
throw new Error(`captureMotionFrames returned empty for camera "${camera}"`);
|
|
613
|
+
}
|
|
614
|
+
return frames_.map((du) => du.replace(/^data:image\/png;base64,/, ''));
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function captureMotion({
|
|
618
|
+
element,
|
|
619
|
+
camera,
|
|
620
|
+
frames = 6,
|
|
621
|
+
dt = 0.25,
|
|
622
|
+
mode = 'time',
|
|
623
|
+
labUrl,
|
|
624
|
+
fresh = false,
|
|
625
|
+
}: any) {
|
|
626
|
+
// Multi-frame capture per camera through the persistent browser pool.
|
|
627
|
+
// Returns per-camera filmstrip base64 + motionMagnitude scalar + telemetry.
|
|
628
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
629
|
+
const outDir = join(tmpdir(), `${PROJECT}-motion-${element ?? 'scene'}`);
|
|
630
|
+
mkdirSync(outDir, { recursive: true });
|
|
631
|
+
const t0 = Date.now();
|
|
632
|
+
const { call } = await browserPool.getPage(target, { reload: fresh });
|
|
633
|
+
|
|
634
|
+
// Pick the camera set: explicit single name or all cameras for the element.
|
|
635
|
+
let cameraOrder: string[];
|
|
636
|
+
if (camera) {
|
|
637
|
+
cameraOrder = [camera];
|
|
638
|
+
} else {
|
|
639
|
+
const camsProbe = await call('Runtime.evaluate', {
|
|
640
|
+
expression: 'Object.keys(window.__TRISCOPE__.cameras)',
|
|
641
|
+
returnByValue: true,
|
|
642
|
+
});
|
|
643
|
+
cameraOrder = camsProbe.result.result.value ?? [];
|
|
644
|
+
}
|
|
645
|
+
if (!Array.isArray(cameraOrder) || cameraOrder.length === 0) {
|
|
646
|
+
throw new Error('no cameras available — is the harness mounted?');
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const filmstripPaths = {};
|
|
650
|
+
const filmstripBase64 = {};
|
|
651
|
+
const magnitudeByCam = {};
|
|
652
|
+
for (const camName of cameraOrder) {
|
|
653
|
+
const result = await call('Runtime.evaluate', {
|
|
654
|
+
expression: `window.__TRISCOPE__.captureMotionFrames(${JSON.stringify(camName)}, ${JSON.stringify({ frames, dt, mode })})`,
|
|
655
|
+
awaitPromise: true,
|
|
656
|
+
returnByValue: true,
|
|
657
|
+
});
|
|
658
|
+
const dataUrls = result.result.result.value;
|
|
659
|
+
if (!Array.isArray(dataUrls) || dataUrls.length === 0) {
|
|
660
|
+
throw new Error(`captureMotionFrames returned empty for camera "${camName}"`);
|
|
661
|
+
}
|
|
662
|
+
const strip = composeFilmstrip(dataUrls);
|
|
663
|
+
const stripPath = join(outDir, `${camName}.filmstrip.png`);
|
|
664
|
+
writeFileSync(stripPath, strip);
|
|
665
|
+
filmstripPaths[camName] = stripPath;
|
|
666
|
+
filmstripBase64[camName] = strip.toString('base64');
|
|
667
|
+
magnitudeByCam[camName] = motionMagnitudeFromFrames(dataUrls);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const telemetry = await call('Runtime.evaluate', {
|
|
671
|
+
expression: 'JSON.stringify(window.__TRISCOPE__.sampleTelemetry())',
|
|
672
|
+
returnByValue: true,
|
|
673
|
+
});
|
|
674
|
+
const sample = trimCaptureTelemetry(JSON.parse(telemetry.result.result.value));
|
|
675
|
+
|
|
676
|
+
return {
|
|
677
|
+
element: element ?? null,
|
|
678
|
+
frames,
|
|
679
|
+
dt,
|
|
680
|
+
mode,
|
|
681
|
+
dir: outDir,
|
|
682
|
+
filmstrips: filmstripPaths,
|
|
683
|
+
cameraOrder,
|
|
684
|
+
motionMagnitude: magnitudeByCam,
|
|
685
|
+
captureMs: Date.now() - t0,
|
|
686
|
+
telemetry: sample,
|
|
687
|
+
_filmstripBase64: filmstripBase64,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* auto_tune (1D): golden-section search over one knob, minimising
|
|
693
|
+
* (1 - SSIM) between the captured target_camera view and the stored
|
|
694
|
+
* reference for that (element, camera). Each iteration: set_knob → wait
|
|
695
|
+
* for the harness to apply (knobPollMs + telemetry tick + margin) →
|
|
696
|
+
* captureViews → diff_reference. Returns the best knob value found,
|
|
697
|
+
* the final SSIM, the iteration history, and total ms.
|
|
698
|
+
*
|
|
699
|
+
* Why golden-section: derivative-free, deterministic, converges
|
|
700
|
+
* exponentially (range × 1/φ per iter ≈ 0.618). 12 iterations narrow a
|
|
701
|
+
* range to ~0.7% — plenty for shader tuning. Robust to noise because we
|
|
702
|
+
* use SSIM (perceptual) rather than meanAbsDiff (pixel-level).
|
|
703
|
+
*/
|
|
704
|
+
/**
|
|
705
|
+
* Snapshot / restore via git tags.
|
|
706
|
+
*
|
|
707
|
+
* A snapshot freezes a moment in the iteration loop: the git commit you
|
|
708
|
+
* were on + the knob values applied at that moment. Restoring checks out
|
|
709
|
+
* that commit and re-posts the knobs, so a careful tuning state can be
|
|
710
|
+
* recovered after a risky shader rewrite. Tags live under
|
|
711
|
+
* `triscope/snapshot/<name>` so they don't pollute the user's namespace.
|
|
712
|
+
*
|
|
713
|
+
* Guard rails:
|
|
714
|
+
* - snapshot refuses on a dirty working tree — the commit you'd point
|
|
715
|
+
* at wouldn't actually contain your in-progress edits, so the
|
|
716
|
+
* restore would silently revert them.
|
|
717
|
+
* - restore refuses on a dirty WT too, for the same reason in reverse.
|
|
718
|
+
* - PNG refs are intentionally not bundled into the snapshot for the
|
|
719
|
+
* MVP — they live next to the project as before (refs/<el>/<cam>.png)
|
|
720
|
+
* and are recovered with the git checkout. Keeping the JSON small.
|
|
721
|
+
*/
|
|
722
|
+
const SNAPSHOT_TAG_PREFIX = 'triscope/snapshot/';
|
|
723
|
+
|
|
724
|
+
// Windows portability: npm/git/code are .cmd scripts on Win32. child_process
|
|
725
|
+
// .spawn refuses to invoke them without `shell: true`. On Linux/macOS the
|
|
726
|
+
// real binaries are on PATH and shell isn't needed. We set the flag
|
|
727
|
+
// conditionally everywhere we spawn one of those tools.
|
|
728
|
+
const NEED_SHELL = process.platform === 'win32';
|
|
729
|
+
|
|
730
|
+
// For tools that forward an agent-supplied argument to the `triscope` CLI we
|
|
731
|
+
// spawn the binary WITHOUT a shell (explicit .cmd on Windows) so there's no
|
|
732
|
+
// shell to inject into, and we reject any argument that would smuggle a flag.
|
|
733
|
+
const TRISCOPE_BIN = process.platform === 'win32' ? 'triscope.cmd' : 'triscope';
|
|
734
|
+
function rejectFlagArg(value: unknown, label: string): asserts value is string {
|
|
735
|
+
if (typeof value !== 'string' || value.length === 0 || value.startsWith('-')) {
|
|
736
|
+
throw new Error(
|
|
737
|
+
`unsafe ${label}: ${JSON.stringify(value)} (must be a non-empty value not starting with '-')`,
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function git(
|
|
743
|
+
args: string[],
|
|
744
|
+
cwd: string = process.cwd(),
|
|
745
|
+
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
746
|
+
return new Promise((resolve, reject) => {
|
|
747
|
+
const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], shell: NEED_SHELL });
|
|
748
|
+
let stdout = '';
|
|
749
|
+
let stderr = '';
|
|
750
|
+
child.stdout.on('data', (d) => (stdout += d));
|
|
751
|
+
child.stderr.on('data', (d) => (stderr += d));
|
|
752
|
+
child.on('error', reject);
|
|
753
|
+
child.on('exit', (code) =>
|
|
754
|
+
resolve({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() }),
|
|
755
|
+
);
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function assertCleanWt(cwd: string, action: string): Promise<void> {
|
|
760
|
+
const status = await git(['status', '--porcelain'], cwd);
|
|
761
|
+
if (status.stdout.length > 0) {
|
|
762
|
+
throw new Error(
|
|
763
|
+
`${action} refuses to run with a dirty working tree. Commit, stash, or revert your in-progress edits first.\nDirty paths:\n${status.stdout.split('\n').slice(0, 10).join('\n')}`,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Wait until the harness telemetry confirms a knob took the requested value,
|
|
770
|
+
* polling the on-disk state every 50 ms. Replaces fixed sleeps in the tuning
|
|
771
|
+
* loops: a knob usually lands in ~100-600 ms (knob poll + telemetry tick), so
|
|
772
|
+
* this returns as soon as it's applied instead of always sleeping the worst
|
|
773
|
+
* case. Returns false on timeout (caller does a short settle fallback).
|
|
774
|
+
*/
|
|
775
|
+
async function waitForKnobApplied(
|
|
776
|
+
key: string,
|
|
777
|
+
value: number | string | boolean,
|
|
778
|
+
timeoutMs = 2500,
|
|
779
|
+
element?: string,
|
|
780
|
+
devUrl?: string,
|
|
781
|
+
): Promise<boolean> {
|
|
782
|
+
// Telemetry exposes knobs by DISPLAY key. In a namespaced scene the caller may
|
|
783
|
+
// pass the element-local key (`spin`) while telemetry holds `cube.spin`, so
|
|
784
|
+
// also probe the namespaced form — otherwise this never confirms and the
|
|
785
|
+
// tuning loop burns the full timeout every iteration.
|
|
786
|
+
const ns = element ? `${element}.${key}` : null;
|
|
787
|
+
// For the default dev server poll the local file (fast); for a non-default
|
|
788
|
+
// devUrl the file path is unknown here, so poll its /__state over HTTP — else
|
|
789
|
+
// cross-project tuning never confirms and burns the full timeout each round.
|
|
790
|
+
const url = resolveDevUrl({ devUrl });
|
|
791
|
+
const useHttp = url !== DEV_URL;
|
|
792
|
+
const start = Date.now();
|
|
793
|
+
while (Date.now() - start < timeoutMs) {
|
|
794
|
+
try {
|
|
795
|
+
let st: any = null;
|
|
796
|
+
if (useHttp) {
|
|
797
|
+
st = (await fetchState(url))?.data ?? null;
|
|
798
|
+
} else if (existsSync(STATE_PATH)) {
|
|
799
|
+
st = JSON.parse(readFileSync(STATE_PATH, 'utf8'));
|
|
800
|
+
}
|
|
801
|
+
const applied = st?.knobs?.[key] ?? (ns ? st?.knobs?.[ns] : undefined);
|
|
802
|
+
if (applied !== undefined && knobMatches(value, applied)) return true;
|
|
803
|
+
} catch {
|
|
804
|
+
/* state file mid-write / transient fetch — retry */
|
|
805
|
+
}
|
|
806
|
+
await wait(50);
|
|
807
|
+
}
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async function fetchPersistedKnobs(): Promise<Record<string, Record<string, unknown>>> {
|
|
812
|
+
try {
|
|
813
|
+
const r = await fetch(`${DEV_URL}/__knob/current`);
|
|
814
|
+
if (!r.ok) return {};
|
|
815
|
+
return (await r.json()) as Record<string, Record<string, unknown>>;
|
|
816
|
+
} catch {
|
|
817
|
+
return {};
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function snapshot({ name, message }: { name: string; message?: string }) {
|
|
822
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
823
|
+
throw new Error(`snapshot name must be [A-Za-z0-9._-]+ (got "${name}")`);
|
|
824
|
+
}
|
|
825
|
+
const cwd = process.cwd();
|
|
826
|
+
await assertCleanWt(cwd, 'snapshot');
|
|
827
|
+
const head = await git(['rev-parse', 'HEAD'], cwd);
|
|
828
|
+
if (head.code !== 0) throw new Error(`git rev-parse failed: ${head.stderr}`);
|
|
829
|
+
const knobs = await fetchPersistedKnobs();
|
|
830
|
+
const payload = {
|
|
831
|
+
name,
|
|
832
|
+
createdAt: new Date().toISOString(),
|
|
833
|
+
commit: head.stdout,
|
|
834
|
+
message: message ?? '',
|
|
835
|
+
knobs, // { [element]: { [knobKey]: value, ... } }
|
|
836
|
+
};
|
|
837
|
+
const tagName = `${SNAPSHOT_TAG_PREFIX}${name}`;
|
|
838
|
+
// Annotated tag stores the JSON payload as the tag message — no extra
|
|
839
|
+
// working-tree files, no rebase noise, easy to list+read with `git tag`.
|
|
840
|
+
const tagBody = `triscope snapshot v1\n\n${JSON.stringify(payload, null, 2)}`;
|
|
841
|
+
const tag = await git(['tag', '-a', tagName, '-m', tagBody, payload.commit], cwd);
|
|
842
|
+
if (tag.code !== 0) {
|
|
843
|
+
if (tag.stderr.includes('already exists')) {
|
|
844
|
+
throw new Error(
|
|
845
|
+
`snapshot "${name}" already exists. Pick a different name or delete the existing tag: git tag -d ${tagName}`,
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
throw new Error(`git tag failed: ${tag.stderr}`);
|
|
849
|
+
}
|
|
850
|
+
return {
|
|
851
|
+
ok: true,
|
|
852
|
+
tag: tagName,
|
|
853
|
+
...payload,
|
|
854
|
+
hint: 'Restore later with mcp__triscope__restore name=' + name,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function listSnapshots() {
|
|
859
|
+
const cwd = process.cwd();
|
|
860
|
+
const list = await git(
|
|
861
|
+
[
|
|
862
|
+
'tag',
|
|
863
|
+
'--list',
|
|
864
|
+
`${SNAPSHOT_TAG_PREFIX}*`,
|
|
865
|
+
'--format=%(refname:short)|%(creatordate:iso)|%(subject)',
|
|
866
|
+
],
|
|
867
|
+
cwd,
|
|
868
|
+
);
|
|
869
|
+
if (list.code !== 0) throw new Error(`git tag --list failed: ${list.stderr}`);
|
|
870
|
+
const snapshots: Array<{ name: string; tag: string; created: string; subject: string }> = [];
|
|
871
|
+
for (const line of list.stdout.split('\n').filter(Boolean)) {
|
|
872
|
+
const [tag, created, ...subj] = line.split('|');
|
|
873
|
+
snapshots.push({
|
|
874
|
+
name: tag.replace(SNAPSHOT_TAG_PREFIX, ''),
|
|
875
|
+
tag,
|
|
876
|
+
created,
|
|
877
|
+
subject: subj.join('|'),
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
return { count: snapshots.length, snapshots };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function restore({ name }: { name: string }) {
|
|
884
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error(`invalid snapshot name`);
|
|
885
|
+
const cwd = process.cwd();
|
|
886
|
+
await assertCleanWt(cwd, 'restore');
|
|
887
|
+
const tagName = `${SNAPSHOT_TAG_PREFIX}${name}`;
|
|
888
|
+
const show = await git(['cat-file', '-p', tagName], cwd);
|
|
889
|
+
if (show.code !== 0) throw new Error(`snapshot "${name}" not found (tag ${tagName})`);
|
|
890
|
+
// Annotated tag object body: header lines, blank line, then the message
|
|
891
|
+
// we wrote in snapshot(). Find the JSON block.
|
|
892
|
+
const bodyMatch = show.stdout.match(/\n\n([\s\S]*)/);
|
|
893
|
+
const body = bodyMatch?.[1] ?? '';
|
|
894
|
+
const jsonStart = body.indexOf('{');
|
|
895
|
+
if (jsonStart < 0) throw new Error(`snapshot ${name} has no JSON payload`);
|
|
896
|
+
let payload: any;
|
|
897
|
+
try {
|
|
898
|
+
payload = JSON.parse(body.slice(jsonStart));
|
|
899
|
+
} catch {
|
|
900
|
+
throw new Error(`snapshot ${name} payload is not valid JSON`);
|
|
901
|
+
}
|
|
902
|
+
// Checkout the commit the snapshot pointed at (detached HEAD — safe,
|
|
903
|
+
// user can create a branch from there if they want to keep working).
|
|
904
|
+
const checkout = await git(['checkout', payload.commit], cwd);
|
|
905
|
+
if (checkout.code !== 0)
|
|
906
|
+
throw new Error(`git checkout ${payload.commit} failed: ${checkout.stderr}`);
|
|
907
|
+
// Re-post knobs. The harness will pick them up via its 100ms poll once
|
|
908
|
+
// it next mounts (or immediately if already mounted on the same commit).
|
|
909
|
+
const updates: Array<{ element: string; key: string; value: unknown }> = [];
|
|
910
|
+
for (const [elName, kv] of Object.entries(payload.knobs ?? {})) {
|
|
911
|
+
for (const [k, v] of Object.entries(kv as Record<string, unknown>)) {
|
|
912
|
+
updates.push({ element: elName, key: k, value: v });
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
if (updates.length > 0) {
|
|
916
|
+
try {
|
|
917
|
+
await setKnob({ updates });
|
|
918
|
+
} catch {
|
|
919
|
+
/* dev server may be down — knobs will re-hydrate on next runLab */
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return {
|
|
923
|
+
ok: true,
|
|
924
|
+
tag: tagName,
|
|
925
|
+
restoredCommit: payload.commit,
|
|
926
|
+
knobUpdates: updates.length,
|
|
927
|
+
payload,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function autoTune({
|
|
932
|
+
element,
|
|
933
|
+
knob,
|
|
934
|
+
range,
|
|
935
|
+
target_camera,
|
|
936
|
+
max_iterations = 12,
|
|
937
|
+
labUrl,
|
|
938
|
+
}: {
|
|
939
|
+
element: string;
|
|
940
|
+
knob: string;
|
|
941
|
+
range: [number, number];
|
|
942
|
+
target_camera: string;
|
|
943
|
+
max_iterations?: number;
|
|
944
|
+
labUrl?: string;
|
|
945
|
+
}) {
|
|
946
|
+
const refPath = refsPath(process.cwd(), element, target_camera);
|
|
947
|
+
if (!existsSync(refPath)) {
|
|
948
|
+
throw new Error(
|
|
949
|
+
`auto_tune needs a reference image at ${refPath}. Call set_reference ` +
|
|
950
|
+
`with element=${element}, camera=${target_camera} first (or paste a PNG path/base64).`,
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
954
|
+
// Derive the knob origin from the RESOLVED page, not the raw labUrl arg, so
|
|
955
|
+
// an element whose manifest advertises an absolute cross-origin labUrl still
|
|
956
|
+
// tunes the page we capture (capture + knob origins must agree).
|
|
957
|
+
const devUrl = resolveDevUrl({ labUrl: target });
|
|
958
|
+
const { call } = await browserPool.getPage(target);
|
|
959
|
+
|
|
960
|
+
const phi = (1 + Math.sqrt(5)) / 2;
|
|
961
|
+
const invPhi = 1 / phi;
|
|
962
|
+
let [a, b] = range;
|
|
963
|
+
if (!(b > a))
|
|
964
|
+
throw new Error(`auto_tune range must be [min, max] with max > min, got [${a}, ${b}]`);
|
|
965
|
+
|
|
966
|
+
const cache = new Map<string, number>(); // memoize SSIM by knob value
|
|
967
|
+
const history: Array<{ iter: number; knob: number; ssim: number; ms: number }> = [];
|
|
968
|
+
const t0 = Date.now();
|
|
969
|
+
|
|
970
|
+
async function evalAt(x: number): Promise<number> {
|
|
971
|
+
const key = x.toFixed(6);
|
|
972
|
+
if (cache.has(key)) return cache.get(key)!;
|
|
973
|
+
const iterStart = Date.now();
|
|
974
|
+
// 1. Post knob via the harness's /__knob endpoint (same origin as capture).
|
|
975
|
+
await setKnob({ element, key: knob, value: x, devUrl });
|
|
976
|
+
// 2. Wait (event-driven) for the harness to confirm it applied — returns as
|
|
977
|
+
// soon as telemetry reflects the value instead of always sleeping 800ms.
|
|
978
|
+
const applied = await waitForKnobApplied(knob, x, 2500, element, devUrl);
|
|
979
|
+
if (!applied) await wait(300); // settle fallback if telemetry didn't confirm
|
|
980
|
+
// 3. Capture target camera in-tab.
|
|
981
|
+
const cap = await call('Runtime.evaluate', {
|
|
982
|
+
expression: 'window.__TRISCOPE__.captureViews()',
|
|
983
|
+
awaitPromise: true,
|
|
984
|
+
returnByValue: true,
|
|
985
|
+
});
|
|
986
|
+
const views = cap.result.result.value ?? {};
|
|
987
|
+
const b64 = String(views[target_camera] ?? '').replace(/^data:image\/png;base64,/, '');
|
|
988
|
+
if (!b64)
|
|
989
|
+
throw new Error(`auto_tune: captureViews returned no PNG for camera "${target_camera}"`);
|
|
990
|
+
// 4. Diff against reference; we minimise (1 - SSIM).
|
|
991
|
+
const diff = diffReference({
|
|
992
|
+
cwd: process.cwd(),
|
|
993
|
+
element,
|
|
994
|
+
camera: target_camera,
|
|
995
|
+
currentBase64: b64,
|
|
996
|
+
});
|
|
997
|
+
const score = diff.ssim;
|
|
998
|
+
cache.set(key, score);
|
|
999
|
+
history.push({ iter: history.length, knob: x, ssim: score, ms: Date.now() - iterStart });
|
|
1000
|
+
return score;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Initial bracket.
|
|
1004
|
+
let c = b - (b - a) * invPhi;
|
|
1005
|
+
let d = a + (b - a) * invPhi;
|
|
1006
|
+
let fc = await evalAt(c);
|
|
1007
|
+
let fd = await evalAt(d);
|
|
1008
|
+
|
|
1009
|
+
for (let i = 0; i < max_iterations - 2; i++) {
|
|
1010
|
+
// Maximise SSIM ⇔ minimise (1 - SSIM): keep the side with higher SSIM.
|
|
1011
|
+
if (fc > fd) {
|
|
1012
|
+
b = d;
|
|
1013
|
+
d = c;
|
|
1014
|
+
fd = fc;
|
|
1015
|
+
c = b - (b - a) * invPhi;
|
|
1016
|
+
fc = await evalAt(c);
|
|
1017
|
+
} else {
|
|
1018
|
+
a = c;
|
|
1019
|
+
c = d;
|
|
1020
|
+
fc = fd;
|
|
1021
|
+
d = a + (b - a) * invPhi;
|
|
1022
|
+
fd = await evalAt(d);
|
|
1023
|
+
}
|
|
1024
|
+
// Early stop when the bracket is below 1% of the original range.
|
|
1025
|
+
if (Math.abs(b - a) < (range[1] - range[0]) * 0.01) break;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const best = [...cache.entries()]
|
|
1029
|
+
.map(([k, v]) => ({ knob: Number(k), ssim: v }))
|
|
1030
|
+
.sort((x, y) => y.ssim - x.ssim)[0];
|
|
1031
|
+
|
|
1032
|
+
// Leave the knob at the best value so the user sees the converged state.
|
|
1033
|
+
// Must target the same origin as the tuned page (D5: omitting devUrl would
|
|
1034
|
+
// commit `best` to the boot DEV_URL, leaving the actual lab at the last
|
|
1035
|
+
// evaluated value and falsely reporting "left at best").
|
|
1036
|
+
await setKnob({ element, key: knob, value: best.knob, devUrl });
|
|
1037
|
+
|
|
1038
|
+
return {
|
|
1039
|
+
element,
|
|
1040
|
+
knob,
|
|
1041
|
+
target_camera,
|
|
1042
|
+
bestKnobValue: best.knob,
|
|
1043
|
+
bestSsim: best.ssim,
|
|
1044
|
+
iterations: history.length,
|
|
1045
|
+
history,
|
|
1046
|
+
totalMs: Date.now() - t0,
|
|
1047
|
+
hint: 'SSIM 1.0 = identical to reference, 0.9+ = visually close, <0.7 = clearly different. The knob has been left at bestKnobValue in the live lab.',
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* multi_tune: coordinate-descent over 2-N knobs against a reference (SSIM),
|
|
1053
|
+
* reusing the event-driven evalAt loop. Greedy but robust for the smooth,
|
|
1054
|
+
* weakly-coupled knobs shader tuning involves. Hard-capped on total evaluations
|
|
1055
|
+
* so worst-case wall time stays bounded.
|
|
1056
|
+
*/
|
|
1057
|
+
async function multiTune({
|
|
1058
|
+
element,
|
|
1059
|
+
knobs,
|
|
1060
|
+
target_camera,
|
|
1061
|
+
max_cycles = 2,
|
|
1062
|
+
per_knob_iters = 8,
|
|
1063
|
+
compute_interactions = false,
|
|
1064
|
+
max_evaluations,
|
|
1065
|
+
labUrl,
|
|
1066
|
+
}: {
|
|
1067
|
+
element: string;
|
|
1068
|
+
knobs: Array<{ key: string; range: [number, number]; start?: number }>;
|
|
1069
|
+
target_camera: string;
|
|
1070
|
+
max_cycles?: number;
|
|
1071
|
+
per_knob_iters?: number;
|
|
1072
|
+
compute_interactions?: boolean;
|
|
1073
|
+
max_evaluations?: number;
|
|
1074
|
+
labUrl?: string;
|
|
1075
|
+
}) {
|
|
1076
|
+
const cwd = process.cwd();
|
|
1077
|
+
const refPath = refsPath(cwd, element, target_camera);
|
|
1078
|
+
if (!existsSync(refPath)) {
|
|
1079
|
+
throw new Error(
|
|
1080
|
+
`multi_tune needs a reference image at ${refPath}. Call set_reference (element=${element}, camera=${target_camera}) first.`,
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
1084
|
+
// Knob origin derived from the resolved page (see autoTune) — capture + knob
|
|
1085
|
+
// must agree even for an absolute cross-origin manifest labUrl.
|
|
1086
|
+
const devUrl = resolveDevUrl({ labUrl: target });
|
|
1087
|
+
const { call } = await browserPool.getPage(target);
|
|
1088
|
+
const t0 = Date.now();
|
|
1089
|
+
const cache = new Map<string, number>();
|
|
1090
|
+
|
|
1091
|
+
const evalAt = async (values: Record<string, number>): Promise<number> => {
|
|
1092
|
+
const ck = JSON.stringify(values);
|
|
1093
|
+
const hit = cache.get(ck);
|
|
1094
|
+
if (hit !== undefined) return hit;
|
|
1095
|
+
const updates = Object.entries(values).map(([key, value]) => ({ element, key, value }));
|
|
1096
|
+
await setKnob({ updates, devUrl });
|
|
1097
|
+
const last = updates[updates.length - 1];
|
|
1098
|
+
const ok = await waitForKnobApplied(last.key, last.value, 2500, element, devUrl);
|
|
1099
|
+
if (!ok) await wait(300);
|
|
1100
|
+
const cap = await call('Runtime.evaluate', {
|
|
1101
|
+
expression: 'window.__TRISCOPE__.captureViews()',
|
|
1102
|
+
awaitPromise: true,
|
|
1103
|
+
returnByValue: true,
|
|
1104
|
+
});
|
|
1105
|
+
const views = cap.result.result.value ?? {};
|
|
1106
|
+
const b64 = String(views[target_camera] ?? '').replace(/^data:image\/png;base64,/, '');
|
|
1107
|
+
if (!b64) throw new Error(`multi_tune: captureViews returned no PNG for "${target_camera}"`);
|
|
1108
|
+
const diff = diffReference({ cwd, element, camera: target_camera, currentBase64: b64 });
|
|
1109
|
+
cache.set(ck, diff.ssim);
|
|
1110
|
+
return diff.ssim;
|
|
1111
|
+
};
|
|
1112
|
+
|
|
1113
|
+
const specs = knobs.map((kn) => ({
|
|
1114
|
+
key: kn.key,
|
|
1115
|
+
min: kn.range[0],
|
|
1116
|
+
max: kn.range[1],
|
|
1117
|
+
start: kn.start ?? (kn.range[0] + kn.range[1]) / 2,
|
|
1118
|
+
}));
|
|
1119
|
+
const hardCap = max_evaluations ?? Math.min(specs.length * per_knob_iters * max_cycles + 4, 64);
|
|
1120
|
+
const result = await coordinateDescent({
|
|
1121
|
+
knobs: specs,
|
|
1122
|
+
evalAt,
|
|
1123
|
+
maxCycles: max_cycles,
|
|
1124
|
+
perKnobIters: per_knob_iters,
|
|
1125
|
+
maxEvaluations: hardCap,
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
// Optional pairwise interaction matrix (2nd-order mixed difference at the
|
|
1129
|
+
// optimum). Off by default — it adds ~3 evals per knob pair.
|
|
1130
|
+
let interactions: Array<{ a: string; b: string; interaction: number }> | undefined;
|
|
1131
|
+
if (compute_interactions && specs.length >= 2) {
|
|
1132
|
+
interactions = [];
|
|
1133
|
+
const base = await evalAt({ ...result.best });
|
|
1134
|
+
for (let i = 0; i < specs.length; i++) {
|
|
1135
|
+
for (let j = i + 1; j < specs.length; j++) {
|
|
1136
|
+
const A = specs[i];
|
|
1137
|
+
const B = specs[j];
|
|
1138
|
+
const dA = (A.max - A.min) * 0.05;
|
|
1139
|
+
const dB = (B.max - B.min) * 0.05;
|
|
1140
|
+
const clamp = (v: number, s: typeof A) => Math.min(s.max, Math.max(s.min, v));
|
|
1141
|
+
const fa = await evalAt({ ...result.best, [A.key]: clamp(result.best[A.key] + dA, A) });
|
|
1142
|
+
const fb = await evalAt({ ...result.best, [B.key]: clamp(result.best[B.key] + dB, B) });
|
|
1143
|
+
const fab = await evalAt({
|
|
1144
|
+
...result.best,
|
|
1145
|
+
[A.key]: clamp(result.best[A.key] + dA, A),
|
|
1146
|
+
[B.key]: clamp(result.best[B.key] + dB, B),
|
|
1147
|
+
});
|
|
1148
|
+
interactions.push({ a: A.key, b: B.key, interaction: +(fab - fa - fb + base).toFixed(4) });
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// Leave the lab at the converged values (same origin as the tuned page — D5).
|
|
1154
|
+
await setKnob({
|
|
1155
|
+
updates: Object.entries(result.best).map(([key, value]) => ({ element, key, value })),
|
|
1156
|
+
devUrl,
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
return {
|
|
1160
|
+
element,
|
|
1161
|
+
target_camera,
|
|
1162
|
+
best: result.best,
|
|
1163
|
+
bestSsim: +result.bestScore.toFixed(4),
|
|
1164
|
+
cycles: result.cycles,
|
|
1165
|
+
evaluations: result.evaluations,
|
|
1166
|
+
totalMs: Date.now() - t0,
|
|
1167
|
+
interactions,
|
|
1168
|
+
hint: 'SSIM 1.0 = identical, 0.9+ = close. Knobs left at best values in the live lab. interaction>0 = the two knobs reinforce, <0 = they fight (only present when compute_interactions=true).',
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* check_targets: load .claude/element-targets.json and evaluate the per-camera
|
|
1174
|
+
* convergence constraints against the current capture — a machine-readable
|
|
1175
|
+
* "definition of done". SSIM/meanAbsDiff constraints need a stored reference
|
|
1176
|
+
* (else they're skipped, not failed); luminance/dynamicRange come from the GPU
|
|
1177
|
+
* probe and are always evaluable.
|
|
1178
|
+
*/
|
|
1179
|
+
async function checkTargets({ element, labUrl }: { element: string; labUrl?: string }) {
|
|
1180
|
+
const cwd = process.cwd();
|
|
1181
|
+
const file = join(cwd, '.claude', 'element-targets.json');
|
|
1182
|
+
if (!existsSync(file)) {
|
|
1183
|
+
return { checked: 0, allPassed: true, note: `no targets file at ${file}` };
|
|
1184
|
+
}
|
|
1185
|
+
let all: any;
|
|
1186
|
+
try {
|
|
1187
|
+
all = JSON.parse(readFileSync(file, 'utf8'));
|
|
1188
|
+
} catch (e: any) {
|
|
1189
|
+
throw new Error(`invalid ${file}: ${e?.message ?? e}`);
|
|
1190
|
+
}
|
|
1191
|
+
const targetsByCamera = all?.[element];
|
|
1192
|
+
if (!targetsByCamera || typeof targetsByCamera !== 'object') {
|
|
1193
|
+
return { checked: 0, allPassed: true, note: `no targets for element "${element}" in ${file}` };
|
|
1194
|
+
}
|
|
1195
|
+
const cap = await captureViews({ element, labUrl, inline: true });
|
|
1196
|
+
const capturedByCamera: Record<string, any> = {};
|
|
1197
|
+
for (const [camera, probe] of Object.entries((cap.gpuProbes ?? {}) as Record<string, any>)) {
|
|
1198
|
+
capturedByCamera[camera] = { luminance: probe.luminance, dynamicRange: probe.dynamicRange };
|
|
1199
|
+
}
|
|
1200
|
+
// For cameras with an ssim/meanAbsDiff constraint AND a stored reference, diff.
|
|
1201
|
+
for (const camera of Object.keys(targetsByCamera)) {
|
|
1202
|
+
const c = targetsByCamera[camera] ?? {};
|
|
1203
|
+
const wantsRefMetric = c.ssim !== undefined || c.meanAbsDiff !== undefined;
|
|
1204
|
+
if (wantsRefMetric && existsSync(refsPath(cwd, element, camera))) {
|
|
1205
|
+
const b64 = cap._base64ByCam?.[camera];
|
|
1206
|
+
if (b64) {
|
|
1207
|
+
const d = diffReference({ cwd, element, camera, currentBase64: b64 });
|
|
1208
|
+
capturedByCamera[camera] = {
|
|
1209
|
+
...(capturedByCamera[camera] ?? {}),
|
|
1210
|
+
ssim: d.ssim,
|
|
1211
|
+
meanAbsDiff: d.meanAbsDiff,
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
const report = evaluateTargets(targetsByCamera, capturedByCamera);
|
|
1217
|
+
return {
|
|
1218
|
+
element,
|
|
1219
|
+
file,
|
|
1220
|
+
...report,
|
|
1221
|
+
hint: report.allPassed
|
|
1222
|
+
? 'All evaluated constraints pass — this view meets its target.'
|
|
1223
|
+
: 'Some constraints fail (see results[].pass=false). Skipped constraints need a set_reference to evaluate.',
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
async function inspect({ element, camera }: { element: string; camera?: string }) {
|
|
1228
|
+
// Resolve the lab URL the same way capture_views does, then append the
|
|
1229
|
+
// ?inspect=<el>&camera=<name> query so the harness boots in solo view.
|
|
1230
|
+
const baseUrl = await resolveLabUrl({ element });
|
|
1231
|
+
const sep = baseUrl.includes('?') ? '&' : '?';
|
|
1232
|
+
const inspectUrl = `${baseUrl}${sep}inspect=${encodeURIComponent(element)}${camera ? `&camera=${encodeURIComponent(camera)}` : ''}`;
|
|
1233
|
+
const t0 = Date.now();
|
|
1234
|
+
await browserPool.getPage(inspectUrl);
|
|
1235
|
+
return {
|
|
1236
|
+
element,
|
|
1237
|
+
camera: camera ?? null,
|
|
1238
|
+
url: inspectUrl,
|
|
1239
|
+
navMs: Date.now() - t0,
|
|
1240
|
+
hint: 'Right-drag to orbit, scroll to zoom, left-click to pick a mesh. Read .selection from telemetry after the user clicks.',
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async function addElement({
|
|
1245
|
+
name,
|
|
1246
|
+
element,
|
|
1247
|
+
labUrl,
|
|
1248
|
+
}: {
|
|
1249
|
+
name: string;
|
|
1250
|
+
element?: string;
|
|
1251
|
+
labUrl?: string;
|
|
1252
|
+
}) {
|
|
1253
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
1254
|
+
throw new Error('add_element requires a non-empty `name`');
|
|
1255
|
+
}
|
|
1256
|
+
// `element`/`labUrl` only locate the running scene page (any mounted element
|
|
1257
|
+
// resolves to it); `name` is the registry element to mount.
|
|
1258
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
1259
|
+
const { call } = await browserPool.getPage(target);
|
|
1260
|
+
const res = await call('Runtime.evaluate', {
|
|
1261
|
+
expression: `JSON.stringify((function(){var t=window.__TRISCOPE__;if(!t||!t.addElement)return{ok:false,error:'addElement unavailable — rebuild the lab against newer @triscope/core'};var ok=t.addElement(${JSON.stringify(name)});return{ok:ok,name:${JSON.stringify(name)},mounted:t.mountedElements?t.mountedElements():[],available:t.availableElements?t.availableElements():[]};})())`,
|
|
1262
|
+
returnByValue: true,
|
|
1263
|
+
});
|
|
1264
|
+
return JSON.parse(res.result.result.value);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
async function removeElement({
|
|
1268
|
+
name,
|
|
1269
|
+
element,
|
|
1270
|
+
labUrl,
|
|
1271
|
+
}: {
|
|
1272
|
+
name: string;
|
|
1273
|
+
element?: string;
|
|
1274
|
+
labUrl?: string;
|
|
1275
|
+
}) {
|
|
1276
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
1277
|
+
throw new Error('remove_element requires a non-empty `name`');
|
|
1278
|
+
}
|
|
1279
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
1280
|
+
const { call } = await browserPool.getPage(target);
|
|
1281
|
+
const res = await call('Runtime.evaluate', {
|
|
1282
|
+
expression: `JSON.stringify((function(){var t=window.__TRISCOPE__;if(!t||!t.removeElement)return{ok:false,error:'removeElement unavailable — rebuild the lab against newer @triscope/core'};var ok=t.removeElement(${JSON.stringify(name)});return{ok:ok,name:${JSON.stringify(name)},mounted:t.mountedElements?t.mountedElements():[],available:t.availableElements?t.availableElements():[]};})())`,
|
|
1283
|
+
returnByValue: true,
|
|
1284
|
+
});
|
|
1285
|
+
return JSON.parse(res.result.result.value);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
async function inspectScene({
|
|
1289
|
+
element,
|
|
1290
|
+
maxNodes,
|
|
1291
|
+
labUrl,
|
|
1292
|
+
fresh = false,
|
|
1293
|
+
}: {
|
|
1294
|
+
element?: string;
|
|
1295
|
+
maxNodes?: number;
|
|
1296
|
+
labUrl?: string;
|
|
1297
|
+
fresh?: boolean;
|
|
1298
|
+
}) {
|
|
1299
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
1300
|
+
const { call } = await browserPool.getPage(target, { reload: fresh });
|
|
1301
|
+
const n = Number.isFinite(maxNodes) ? maxNodes : 500;
|
|
1302
|
+
const res = await call('Runtime.evaluate', {
|
|
1303
|
+
expression: `JSON.stringify(window.__TRISCOPE__ && window.__TRISCOPE__.queryScene ? window.__TRISCOPE__.queryScene(${n}) : { error: 'queryScene unavailable — the lab is on an older @triscope/core; rebuild it' })`,
|
|
1304
|
+
returnByValue: true,
|
|
1305
|
+
});
|
|
1306
|
+
return JSON.parse(res.result.result.value);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
async function readUniform({
|
|
1310
|
+
element,
|
|
1311
|
+
path,
|
|
1312
|
+
labUrl,
|
|
1313
|
+
}: {
|
|
1314
|
+
element?: string;
|
|
1315
|
+
path: string;
|
|
1316
|
+
labUrl?: string;
|
|
1317
|
+
}) {
|
|
1318
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
1319
|
+
const { call } = await browserPool.getPage(target);
|
|
1320
|
+
const res = await call('Runtime.evaluate', {
|
|
1321
|
+
expression: `JSON.stringify(window.__TRISCOPE__ && window.__TRISCOPE__.readUniform ? window.__TRISCOPE__.readUniform(${JSON.stringify(path)}) : { kind: 'not-found', error: 'readUniform unavailable — rebuild the lab against newer @triscope/core' })`,
|
|
1322
|
+
returnByValue: true,
|
|
1323
|
+
});
|
|
1324
|
+
return JSON.parse(res.result.result.value);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
async function setUniform({
|
|
1328
|
+
element,
|
|
1329
|
+
path,
|
|
1330
|
+
value,
|
|
1331
|
+
labUrl,
|
|
1332
|
+
}: {
|
|
1333
|
+
element?: string;
|
|
1334
|
+
path: string;
|
|
1335
|
+
value: unknown;
|
|
1336
|
+
labUrl?: string;
|
|
1337
|
+
}) {
|
|
1338
|
+
const target = await resolveLabUrl({ element, labUrl });
|
|
1339
|
+
const { call } = await browserPool.getPage(target);
|
|
1340
|
+
const res = await call('Runtime.evaluate', {
|
|
1341
|
+
expression: `JSON.stringify(window.__TRISCOPE__ && window.__TRISCOPE__.setUniform ? window.__TRISCOPE__.setUniform(${JSON.stringify(path)}, ${JSON.stringify(value)}) : { ok: false, kind: 'not-found', error: 'setUniform unavailable — rebuild the lab against newer @triscope/core' })`,
|
|
1342
|
+
returnByValue: true,
|
|
1343
|
+
});
|
|
1344
|
+
return JSON.parse(res.result.result.value);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
async function setSceneParam({
|
|
1348
|
+
cameras,
|
|
1349
|
+
knobs,
|
|
1350
|
+
elements,
|
|
1351
|
+
}: {
|
|
1352
|
+
cameras?: Record<string, { position?: number[]; target?: number[]; fov?: number }>;
|
|
1353
|
+
knobs?: Record<string, unknown>;
|
|
1354
|
+
elements?: Record<string, { enabled?: boolean }>;
|
|
1355
|
+
}) {
|
|
1356
|
+
const delta: Record<string, unknown> = {};
|
|
1357
|
+
if (cameras && Object.keys(cameras).length) delta.cameras = cameras;
|
|
1358
|
+
if (knobs && Object.keys(knobs).length) delta.knobs = knobs;
|
|
1359
|
+
if (elements && Object.keys(elements).length) delta.elements = elements;
|
|
1360
|
+
if (!delta.cameras && !delta.knobs && !delta.elements) {
|
|
1361
|
+
throw new Error('set_scene_param needs `cameras`, `knobs`, and/or `elements`');
|
|
1362
|
+
}
|
|
1363
|
+
const r = await fetch(`${DEV_URL}/__scene`, {
|
|
1364
|
+
method: 'POST',
|
|
1365
|
+
headers: { 'content-type': 'application/json' },
|
|
1366
|
+
body: JSON.stringify(delta),
|
|
1367
|
+
});
|
|
1368
|
+
if (!r.ok) throw new Error(`/__scene returned ${r.status}`);
|
|
1369
|
+
return {
|
|
1370
|
+
ok: true,
|
|
1371
|
+
applied: delta,
|
|
1372
|
+
note: 'Applied by the harness within ~100 ms (no reload) and persisted via /__scene so it survives a reload. Read back with get_scene or read_telemetry .cameras.',
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
async function getScene({ devUrl, labUrl }: { devUrl?: string; labUrl?: string } = {}) {
|
|
1377
|
+
const url = resolveDevUrl({ devUrl, labUrl });
|
|
1378
|
+
let sceneSpec: unknown = {};
|
|
1379
|
+
let live: unknown = {};
|
|
1380
|
+
try {
|
|
1381
|
+
const r = await fetch(`${url}/__scene/current`);
|
|
1382
|
+
if (r.ok) sceneSpec = await r.json();
|
|
1383
|
+
} catch {
|
|
1384
|
+
/* dev server down */
|
|
1385
|
+
}
|
|
1386
|
+
// Live scene state from the dev server's telemetry over HTTP (works for any
|
|
1387
|
+
// project/port; falls back to the local file only for the default dev server).
|
|
1388
|
+
const state = await fetchState(url);
|
|
1389
|
+
const st = state?.data ?? (url === DEV_URL && existsSync(STATE_PATH) ? safeReadState() : null);
|
|
1390
|
+
if (st) live = { cameras: st.cameras, knobs: st.knobs, elements: st.sceneElements };
|
|
1391
|
+
return { sceneSpec, live };
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function safeReadState(): any {
|
|
1395
|
+
try {
|
|
1396
|
+
return JSON.parse(readFileSync(STATE_PATH, 'utf8'));
|
|
1397
|
+
} catch {
|
|
1398
|
+
return null;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
async function openSelection({ editor }: { editor?: string }) {
|
|
1403
|
+
// Read the current selection from the telemetry snapshot. The selection
|
|
1404
|
+
// is written by the inspect-mode click handler in the browser, then
|
|
1405
|
+
// surfaced into /tmp/<project>-state.json on the next telemetry tick.
|
|
1406
|
+
if (!existsSync(STATE_PATH)) {
|
|
1407
|
+
throw new Error(`No telemetry at ${STATE_PATH} — is the dev server running with a lab open?`);
|
|
1408
|
+
}
|
|
1409
|
+
const state: any = JSON.parse(readFileSync(STATE_PATH, 'utf8'));
|
|
1410
|
+
const sel = state?.selection;
|
|
1411
|
+
if (!sel?.source?.file) {
|
|
1412
|
+
throw new Error(
|
|
1413
|
+
'No mesh selected yet. Open the lab in inspect mode and left-click something first.',
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
// Convert vite-served URL to a filesystem path: strip protocol+host so
|
|
1417
|
+
// `code --goto` resolves it relative to cwd (the project the dev server
|
|
1418
|
+
// is serving). Falls through if `file` is already a path.
|
|
1419
|
+
const rawFile = String(sel.source.file);
|
|
1420
|
+
const fsPath = rawFile
|
|
1421
|
+
.replace(/^https?:\/\/[^/]+\//, '') // strip http://host:port/
|
|
1422
|
+
.replace(/^file:\/\//, '') // strip file://
|
|
1423
|
+
.replace(/[?#].*$/, ''); // strip query/hash
|
|
1424
|
+
const absPath = fsPath.startsWith('/') ? fsPath : join(process.cwd(), fsPath);
|
|
1425
|
+
const line = Number(sel.source.line ?? 1);
|
|
1426
|
+
const col = Number(sel.source.col ?? 1);
|
|
1427
|
+
// Editor resolution: explicit arg → $EDITOR → `code --goto`. We assume
|
|
1428
|
+
// `code` is on PATH for VS Code / Cursor / VSCodium users; others can
|
|
1429
|
+
// export $EDITOR (e.g. EDITOR="zed --goto") to override.
|
|
1430
|
+
const cmd = editor ?? process.env.EDITOR ?? 'code';
|
|
1431
|
+
// `code --goto file:line:col` is the standard VSCode invocation.
|
|
1432
|
+
const usesGoto = /code\b/.test(cmd);
|
|
1433
|
+
const args = usesGoto ? ['--goto', `${absPath}:${line}:${col}`] : [`${absPath}:${line}:${col}`];
|
|
1434
|
+
return await new Promise<{
|
|
1435
|
+
ok: boolean;
|
|
1436
|
+
cmd: string;
|
|
1437
|
+
args: string[];
|
|
1438
|
+
file: string;
|
|
1439
|
+
line: number;
|
|
1440
|
+
col: number;
|
|
1441
|
+
stderr?: string;
|
|
1442
|
+
}>((resolve, reject) => {
|
|
1443
|
+
const child = spawn(cmd, args, {
|
|
1444
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1445
|
+
detached: true,
|
|
1446
|
+
shell: NEED_SHELL,
|
|
1447
|
+
});
|
|
1448
|
+
let stderr = '';
|
|
1449
|
+
child.stderr.on('data', (d) => (stderr += d));
|
|
1450
|
+
child.on('error', (err) => reject(new Error(`failed to spawn ${cmd}: ${err.message}`)));
|
|
1451
|
+
// Editor commands usually fork-and-detach; don't wait for exit, just
|
|
1452
|
+
// confirm we spawned without immediate error.
|
|
1453
|
+
setTimeout(() => {
|
|
1454
|
+
try {
|
|
1455
|
+
child.unref();
|
|
1456
|
+
} catch {}
|
|
1457
|
+
resolve({ ok: true, cmd, args, file: absPath, line, col, stderr: stderr || undefined });
|
|
1458
|
+
}, 200);
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
async function runSmoke({ element }) {
|
|
1463
|
+
return new Promise((resolve, reject) => {
|
|
1464
|
+
const args = ['smoke'];
|
|
1465
|
+
if (element) args.push(element);
|
|
1466
|
+
const child = spawn('triscope', args, { stdio: ['ignore', 'pipe', 'pipe'], shell: NEED_SHELL });
|
|
1467
|
+
let out = '';
|
|
1468
|
+
let err = '';
|
|
1469
|
+
child.stdout.on('data', (d) => (out += d));
|
|
1470
|
+
child.stderr.on('data', (d) => (err += d));
|
|
1471
|
+
child.on('error', reject);
|
|
1472
|
+
child.on('exit', (code) => resolve({ exitCode: code ?? 0, stdout: out, stderr: err }));
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
async function importElement({ source, name }: { source: string; name?: string }) {
|
|
1477
|
+
rejectFlagArg(source, 'source');
|
|
1478
|
+
if (name !== undefined) rejectFlagArg(name, 'name');
|
|
1479
|
+
return new Promise((resolve, reject) => {
|
|
1480
|
+
const args = ['import', source];
|
|
1481
|
+
if (name) args.push('--name', name);
|
|
1482
|
+
const child = spawn(TRISCOPE_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false });
|
|
1483
|
+
let out = '';
|
|
1484
|
+
let err = '';
|
|
1485
|
+
child.stdout.on('data', (d) => (out += d));
|
|
1486
|
+
child.stderr.on('data', (d) => (err += d));
|
|
1487
|
+
child.on('error', reject);
|
|
1488
|
+
child.on('exit', (code) => resolve({ exitCode: code ?? 0, stdout: out, stderr: err }));
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
async function scaffoldFromGltf({ file, name }: { file: string; name?: string }) {
|
|
1493
|
+
rejectFlagArg(file, 'file');
|
|
1494
|
+
if (name !== undefined) rejectFlagArg(name, 'name');
|
|
1495
|
+
return new Promise((resolve, reject) => {
|
|
1496
|
+
const args = ['new-gltf', file];
|
|
1497
|
+
if (name) args.push('--name', name);
|
|
1498
|
+
const child = spawn(TRISCOPE_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false });
|
|
1499
|
+
let out = '';
|
|
1500
|
+
let err = '';
|
|
1501
|
+
child.stdout.on('data', (d) => (out += d));
|
|
1502
|
+
child.stderr.on('data', (d) => (err += d));
|
|
1503
|
+
child.on('error', reject);
|
|
1504
|
+
child.on('exit', (code) => resolve({ exitCode: code ?? 0, stdout: out, stderr: err }));
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
const tools = [
|
|
1509
|
+
{
|
|
1510
|
+
name: 'list_elements',
|
|
1511
|
+
description:
|
|
1512
|
+
'List elements registered with the running triscope dev server. Cameras + current knob values appear per element ONLY after a browser has mounted that lab (the harness POSTs the full manifest on mount); a cold call returns just the seeded `{element, labUrl}` from package.json#triscope.labs. Pass devUrl/labUrl to target a different dev server than the one the MCP booted against.',
|
|
1513
|
+
inputSchema: {
|
|
1514
|
+
type: 'object',
|
|
1515
|
+
properties: {
|
|
1516
|
+
devUrl: {
|
|
1517
|
+
type: 'string',
|
|
1518
|
+
description: 'Target dev-server origin (default: the boot one).',
|
|
1519
|
+
},
|
|
1520
|
+
labUrl: { type: 'string', description: 'A lab URL; its origin is used as devUrl.' },
|
|
1521
|
+
},
|
|
1522
|
+
additionalProperties: false,
|
|
1523
|
+
},
|
|
1524
|
+
},
|
|
1525
|
+
{
|
|
1526
|
+
name: 'read_telemetry',
|
|
1527
|
+
description:
|
|
1528
|
+
'Read the latest telemetry snapshot (live over GET /__state, falling back to the on-disk file — so it now works cross-project). Optional jq-style "path" (e.g. ".elements.ship.triangles", ".perf.fps") returns that slice VERBATIM. The FULL snapshot (no path) is annotated with `__source` ("live"/"disk") + `__staleMs` + a `__warning` when old (the lab tab may be backgrounded/closed). Use for hidden numeric state where screenshots lie. Pass devUrl/labUrl to read a different dev server/project.',
|
|
1529
|
+
inputSchema: {
|
|
1530
|
+
type: 'object',
|
|
1531
|
+
properties: {
|
|
1532
|
+
path: { type: 'string', description: 'Dot-separated jq-style path into the snapshot.' },
|
|
1533
|
+
devUrl: {
|
|
1534
|
+
type: 'string',
|
|
1535
|
+
description: 'Target dev-server origin (default: the boot one).',
|
|
1536
|
+
},
|
|
1537
|
+
labUrl: { type: 'string', description: 'A lab URL; its origin is used as devUrl.' },
|
|
1538
|
+
},
|
|
1539
|
+
additionalProperties: false,
|
|
1540
|
+
},
|
|
1541
|
+
},
|
|
1542
|
+
{
|
|
1543
|
+
name: 'set_knob',
|
|
1544
|
+
description:
|
|
1545
|
+
'Live-update one or many knobs in a single round trip. Either pass {element,key,value} for a single update OR {updates:[{element,key,value},...]} to batch. Use absolute values, never deltas. Changes take effect in the running browser within ~100 ms. Out-of-range / wrong-type values are clamped to the knob spec before they apply; when that happens the response includes clamped:[{element,key,requested,final}] so you know what actually took effect. Pass devUrl/labUrl to target a different dev server.',
|
|
1546
|
+
inputSchema: {
|
|
1547
|
+
type: 'object',
|
|
1548
|
+
properties: {
|
|
1549
|
+
element: { type: 'string', description: 'Element name (single-update form).' },
|
|
1550
|
+
key: { type: 'string', description: 'Knob key (single-update form).' },
|
|
1551
|
+
value: { description: 'Absolute value (number, "#aabbcc" color, boolean).' },
|
|
1552
|
+
updates: {
|
|
1553
|
+
type: 'array',
|
|
1554
|
+
description: 'Batch form: array of {element,key,value} entries applied atomically.',
|
|
1555
|
+
items: {
|
|
1556
|
+
type: 'object',
|
|
1557
|
+
properties: {
|
|
1558
|
+
element: { type: 'string' },
|
|
1559
|
+
key: { type: 'string' },
|
|
1560
|
+
value: {},
|
|
1561
|
+
},
|
|
1562
|
+
required: ['element', 'key', 'value'],
|
|
1563
|
+
},
|
|
1564
|
+
},
|
|
1565
|
+
devUrl: {
|
|
1566
|
+
type: 'string',
|
|
1567
|
+
description: 'Target dev-server origin (default: the boot one).',
|
|
1568
|
+
},
|
|
1569
|
+
labUrl: { type: 'string', description: 'A lab URL; its origin is used as devUrl.' },
|
|
1570
|
+
},
|
|
1571
|
+
additionalProperties: false,
|
|
1572
|
+
},
|
|
1573
|
+
},
|
|
1574
|
+
{
|
|
1575
|
+
name: 'capture_views',
|
|
1576
|
+
description:
|
|
1577
|
+
'Spawn Chromium against a lab page (resolved via Element.labUrl in the manifest, package.json#triscope.labs, or /labs/<element>.html as fallback) and render every named camera. Writes PNGs to /tmp/<project>-capture-<element>/<camera>.png AND returns each image inline as MCP image content blocks (so the model sees them directly without a Read call). Set inline=false to return paths only (smaller payload). The response includes blackFrames:[camera,...] (panes the GPU drew black — luminance below threshold, a render failure) AND flatFrames:[camera,...] (panes with dynamicRange≈1, i.e. only the flat clear-color is in view — the element drifted out of frame or was not drawn; blackFrames misses this when the clear-color is above the black threshold).',
|
|
1578
|
+
inputSchema: {
|
|
1579
|
+
type: 'object',
|
|
1580
|
+
properties: {
|
|
1581
|
+
element: {
|
|
1582
|
+
type: 'string',
|
|
1583
|
+
description: 'Element name. URL is resolved via manifest/config.',
|
|
1584
|
+
},
|
|
1585
|
+
labUrl: {
|
|
1586
|
+
type: 'string',
|
|
1587
|
+
description: 'Override the lab URL entirely (highest precedence).',
|
|
1588
|
+
},
|
|
1589
|
+
inline: {
|
|
1590
|
+
type: 'boolean',
|
|
1591
|
+
description:
|
|
1592
|
+
'Return images as inline content blocks. Default false — safer for many-camera elements where the inline base64 payload can blow the MCP stdio message budget. Set true only when you specifically want inline.',
|
|
1593
|
+
default: false,
|
|
1594
|
+
},
|
|
1595
|
+
fresh: {
|
|
1596
|
+
type: 'boolean',
|
|
1597
|
+
description:
|
|
1598
|
+
'Force a full page reload before capturing, so a source edit the pooled page would otherwise miss (e.g. a *.lab.ts change) is picked up. Costs a reload (~1-3s) and drops transient set_uniform writes. Default false.',
|
|
1599
|
+
default: false,
|
|
1600
|
+
},
|
|
1601
|
+
},
|
|
1602
|
+
additionalProperties: false,
|
|
1603
|
+
},
|
|
1604
|
+
},
|
|
1605
|
+
{
|
|
1606
|
+
name: 'set_reference',
|
|
1607
|
+
description:
|
|
1608
|
+
'Save a reference image for an (element, camera) pair under <project>/refs/<element>/<camera>.png. Accepts EITHER a `path` to a file on disk (e.g. a chat-attachment path) OR `base64` inline PNG data. Use this when the user pastes a reference image they want the AI to converge toward.',
|
|
1609
|
+
inputSchema: {
|
|
1610
|
+
type: 'object',
|
|
1611
|
+
properties: {
|
|
1612
|
+
element: { type: 'string', description: 'Element name.' },
|
|
1613
|
+
camera: { type: 'string', description: 'Camera name (must match Element.cameras key).' },
|
|
1614
|
+
path: {
|
|
1615
|
+
type: 'string',
|
|
1616
|
+
description: 'Filesystem path to a PNG/JPEG (one of path or base64 required).',
|
|
1617
|
+
},
|
|
1618
|
+
base64: {
|
|
1619
|
+
type: 'string',
|
|
1620
|
+
description: 'Base64-encoded PNG (with or without data: prefix).',
|
|
1621
|
+
},
|
|
1622
|
+
},
|
|
1623
|
+
required: ['element', 'camera'],
|
|
1624
|
+
additionalProperties: false,
|
|
1625
|
+
},
|
|
1626
|
+
},
|
|
1627
|
+
{
|
|
1628
|
+
name: 'diff_reference',
|
|
1629
|
+
description:
|
|
1630
|
+
'Capture the current view at (element, camera), diff it against the stored reference, and return: numeric meanAbsDiff (0-255) + ssim (1=identical); an 8×8 per-tile SSIM grid + the worstTile {row,col,ssim} so you know WHERE they differ; the side-by-side composite (left=ref, right=current); and a black→blue→yellow→red difference heatmap (hot = divergent region) as a second image. Requires a prior set_reference for the same (element, camera).',
|
|
1631
|
+
inputSchema: {
|
|
1632
|
+
type: 'object',
|
|
1633
|
+
properties: {
|
|
1634
|
+
element: { type: 'string', description: 'Element name.' },
|
|
1635
|
+
camera: { type: 'string', description: 'Camera name.' },
|
|
1636
|
+
labUrl: {
|
|
1637
|
+
type: 'string',
|
|
1638
|
+
description: 'Override the lab URL (otherwise resolved like capture_views).',
|
|
1639
|
+
},
|
|
1640
|
+
fresh: {
|
|
1641
|
+
type: 'boolean',
|
|
1642
|
+
description:
|
|
1643
|
+
'Force a full reload before capturing (pick up source edits). Default false.',
|
|
1644
|
+
},
|
|
1645
|
+
},
|
|
1646
|
+
required: ['element', 'camera'],
|
|
1647
|
+
additionalProperties: false,
|
|
1648
|
+
},
|
|
1649
|
+
},
|
|
1650
|
+
{
|
|
1651
|
+
name: 'set_reference_motion',
|
|
1652
|
+
description:
|
|
1653
|
+
'Capture the CURRENT motion sequence at (element, camera) and save it as the animated reference. Writes <project>/refs/<element>/<camera>.motion.png (filmstrip) + <camera>.motion.json (frames/dt/mode metadata). Use to lock in a known-good animation before risky shader/uniform edits, then diff_reference_motion confirms regressions visually + numerically.',
|
|
1654
|
+
inputSchema: {
|
|
1655
|
+
type: 'object',
|
|
1656
|
+
properties: {
|
|
1657
|
+
element: { type: 'string' },
|
|
1658
|
+
camera: { type: 'string' },
|
|
1659
|
+
frames: { type: 'number', description: 'Default 6.' },
|
|
1660
|
+
dt: { type: 'number', description: 'Seconds between frames. Default 0.25.' },
|
|
1661
|
+
mode: { type: 'string', enum: ['time', 'real'], description: 'Default "time".' },
|
|
1662
|
+
labUrl: { type: 'string' },
|
|
1663
|
+
},
|
|
1664
|
+
required: ['element', 'camera'],
|
|
1665
|
+
additionalProperties: false,
|
|
1666
|
+
},
|
|
1667
|
+
},
|
|
1668
|
+
{
|
|
1669
|
+
name: 'diff_reference_motion',
|
|
1670
|
+
description:
|
|
1671
|
+
'Capture current motion at (element, camera), diff against the saved animated reference. Returns a vertically-stacked composite (reference filmstrip on top, current on bottom) inline AND a scalar motionDiff (0=identical animation, >5=visible drift, >30=clearly different). Requires a prior set_reference_motion for the same (element, camera).',
|
|
1672
|
+
inputSchema: {
|
|
1673
|
+
type: 'object',
|
|
1674
|
+
properties: {
|
|
1675
|
+
element: { type: 'string' },
|
|
1676
|
+
camera: { type: 'string' },
|
|
1677
|
+
frames: { type: 'number' },
|
|
1678
|
+
dt: { type: 'number' },
|
|
1679
|
+
mode: { type: 'string', enum: ['time', 'real'] },
|
|
1680
|
+
labUrl: { type: 'string' },
|
|
1681
|
+
},
|
|
1682
|
+
required: ['element', 'camera'],
|
|
1683
|
+
additionalProperties: false,
|
|
1684
|
+
},
|
|
1685
|
+
},
|
|
1686
|
+
{
|
|
1687
|
+
name: 'capture_motion',
|
|
1688
|
+
description:
|
|
1689
|
+
'Capture N frames per camera spaced by dt seconds, compose each into an inline filmstrip image (frames tiled left-to-right), and return a numeric motionMagnitude per camera (0-255 scale; <1 = static, >5 = visible motion, >20 = vigorous). Use this WHEN THE ELEMENT HAS ANIMATION (shader-driven motion, sail billow, particle systems, oscillation) — a single capture_views frame cannot reveal whether motion is happening. For complementary numeric verification of hidden animated state, read_telemetry .elements.<name>.motion (if the Element declared motionProbes).',
|
|
1690
|
+
inputSchema: {
|
|
1691
|
+
type: 'object',
|
|
1692
|
+
properties: {
|
|
1693
|
+
element: { type: 'string' },
|
|
1694
|
+
camera: {
|
|
1695
|
+
type: 'string',
|
|
1696
|
+
description: 'Single camera. Omit to capture all cameras (one filmstrip each).',
|
|
1697
|
+
},
|
|
1698
|
+
frames: { type: 'number', description: 'Frames per filmstrip. Default 6.' },
|
|
1699
|
+
dt: { type: 'number', description: 'Seconds between captured frames. Default 0.25.' },
|
|
1700
|
+
mode: {
|
|
1701
|
+
type: 'string',
|
|
1702
|
+
enum: ['time', 'real'],
|
|
1703
|
+
description:
|
|
1704
|
+
'"time" (default) is deterministic (steps time.value, fast). "real" runs wall-clock (slower; needed for CPU-integrated state).',
|
|
1705
|
+
},
|
|
1706
|
+
labUrl: {
|
|
1707
|
+
type: 'string',
|
|
1708
|
+
description: 'Override the lab URL (otherwise resolved like capture_views).',
|
|
1709
|
+
},
|
|
1710
|
+
inline: {
|
|
1711
|
+
type: 'boolean',
|
|
1712
|
+
description: 'Include filmstrips as inline images. Default true.',
|
|
1713
|
+
},
|
|
1714
|
+
fresh: {
|
|
1715
|
+
type: 'boolean',
|
|
1716
|
+
description:
|
|
1717
|
+
'Force a full reload before capturing (pick up source edits). Default false.',
|
|
1718
|
+
},
|
|
1719
|
+
},
|
|
1720
|
+
required: ['element'],
|
|
1721
|
+
additionalProperties: false,
|
|
1722
|
+
},
|
|
1723
|
+
},
|
|
1724
|
+
{
|
|
1725
|
+
name: 'health',
|
|
1726
|
+
description:
|
|
1727
|
+
'Server health snapshot. Returns uptime, dev-server reachability, browser-pool state, pid, recent errors (last 16). Call this when other tools misbehave: a "Connection closed" error from a capture tool followed by a healthy health() call means the MCP server is alive but the browser pool needs to recover; a failed health() means the server itself is sick.',
|
|
1728
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
1729
|
+
},
|
|
1730
|
+
{
|
|
1731
|
+
name: 'run_smoke',
|
|
1732
|
+
description:
|
|
1733
|
+
'Run the headed-Chromium smoke harness against a lab page. Returns exit code, stdout, stderr. Use as a CI gate after a batch of knob changes.',
|
|
1734
|
+
inputSchema: {
|
|
1735
|
+
type: 'object',
|
|
1736
|
+
properties: {
|
|
1737
|
+
element: {
|
|
1738
|
+
type: 'string',
|
|
1739
|
+
description: 'Element lab to test (defaults to the scene lab).',
|
|
1740
|
+
},
|
|
1741
|
+
},
|
|
1742
|
+
additionalProperties: false,
|
|
1743
|
+
},
|
|
1744
|
+
},
|
|
1745
|
+
{
|
|
1746
|
+
name: 'import_element',
|
|
1747
|
+
description:
|
|
1748
|
+
'Install a published element package (naming convention `triscope-element-<name>`, or `github:user/repo`) into the current project and wire its lab page + vite input + package.json#triscope.labs. Runs `triscope import`; returns exit code + output. After it succeeds, capture_views/inspect_scene the new element by name.',
|
|
1749
|
+
inputSchema: {
|
|
1750
|
+
type: 'object',
|
|
1751
|
+
properties: {
|
|
1752
|
+
source: { type: 'string', description: 'npm package or github:user/repo[#ref].' },
|
|
1753
|
+
name: { type: 'string', description: 'Override the local element/lab name.' },
|
|
1754
|
+
},
|
|
1755
|
+
required: ['source'],
|
|
1756
|
+
additionalProperties: false,
|
|
1757
|
+
},
|
|
1758
|
+
},
|
|
1759
|
+
{
|
|
1760
|
+
name: 'scaffold_from_gltf',
|
|
1761
|
+
description:
|
|
1762
|
+
'Generate a wired triscope Element from a glTF/GLB asset path: auto-detects bounds + animation clips, copies the asset to /public, and writes src/elements/<name>.ts (GLTFLoader + AnimationMixer + fitted cameras + scale knob + dispose). Runs `triscope new-gltf`; returns exit code + output. Saves ~40-100 lines of loader/dispose boilerplate when bringing in a model.',
|
|
1763
|
+
inputSchema: {
|
|
1764
|
+
type: 'object',
|
|
1765
|
+
properties: {
|
|
1766
|
+
file: { type: 'string', description: 'Path to a .glb or .gltf file.' },
|
|
1767
|
+
name: {
|
|
1768
|
+
type: 'string',
|
|
1769
|
+
description: 'Override the element name (default: file basename).',
|
|
1770
|
+
},
|
|
1771
|
+
},
|
|
1772
|
+
required: ['file'],
|
|
1773
|
+
additionalProperties: false,
|
|
1774
|
+
},
|
|
1775
|
+
},
|
|
1776
|
+
{
|
|
1777
|
+
name: 'inspect_scene',
|
|
1778
|
+
description:
|
|
1779
|
+
'Snapshot the live scene graph: every mesh/light/group with triangleCount, worldPosition, materialKind + materialColor, uniformNames, and the file:line `source` where it was added. Answers "what is in this scene / which object is X / where is its code" in ONE call instead of many capture_views probes. Returns `nodes` (sorted by triangle count, capped at maxNodes — default 120; `truncated`+`total` flag the rest, raise maxNodes to see it) PLUS a separate `lights` array — every light with type/intensity/color/position, ALWAYS included regardless of the cap (a named light is then read/writable via read_uniform/set_uniform "name.intensity").',
|
|
1780
|
+
inputSchema: {
|
|
1781
|
+
type: 'object',
|
|
1782
|
+
properties: {
|
|
1783
|
+
element: {
|
|
1784
|
+
type: 'string',
|
|
1785
|
+
description:
|
|
1786
|
+
'Element/lab to introspect (resolved like capture_views). Omit for the scene lab.',
|
|
1787
|
+
},
|
|
1788
|
+
maxNodes: { type: 'number', description: 'Cap on returned nodes. Default 500.' },
|
|
1789
|
+
labUrl: { type: 'string', description: 'Override the lab URL.' },
|
|
1790
|
+
fresh: {
|
|
1791
|
+
type: 'boolean',
|
|
1792
|
+
description: 'Force a full reload first (pick up source edits). Default false.',
|
|
1793
|
+
},
|
|
1794
|
+
},
|
|
1795
|
+
additionalProperties: false,
|
|
1796
|
+
},
|
|
1797
|
+
},
|
|
1798
|
+
{
|
|
1799
|
+
name: 'read_uniform',
|
|
1800
|
+
description:
|
|
1801
|
+
'Read ANY live material uniform / material property / object property by "objectName|uuid.key" path — even values never declared as knobs (e.g. "ship.metalness", "sun.intensity", a shader uniform "ocean.uChoppiness"). Use inspect_scene first to discover object names + uniformNames. Returns {kind, value} (colors as #hex, vectors as arrays); kind="not-found" if the path doesn\'t resolve.',
|
|
1802
|
+
inputSchema: {
|
|
1803
|
+
type: 'object',
|
|
1804
|
+
properties: {
|
|
1805
|
+
element: { type: 'string', description: 'Element/lab (resolved like capture_views).' },
|
|
1806
|
+
path: { type: 'string', description: '"<objectName|uuid>.<key>" — split on the last dot.' },
|
|
1807
|
+
labUrl: { type: 'string' },
|
|
1808
|
+
},
|
|
1809
|
+
required: ['path'],
|
|
1810
|
+
additionalProperties: false,
|
|
1811
|
+
},
|
|
1812
|
+
},
|
|
1813
|
+
{
|
|
1814
|
+
name: 'set_uniform',
|
|
1815
|
+
description:
|
|
1816
|
+
'Write ANY live material uniform / material property / object property by "objectName|uuid.key" path WITHOUT a source edit or reload — for probing a shader parameter that isn\'t a declared knob. Colors accept "#rrggbb" or a number; vectors accept [x,y,z]. TRANSIENT: not persisted across reloads (use set_knob for values you want to keep). Returns {ok, previous, current}.',
|
|
1817
|
+
inputSchema: {
|
|
1818
|
+
type: 'object',
|
|
1819
|
+
properties: {
|
|
1820
|
+
element: { type: 'string' },
|
|
1821
|
+
path: { type: 'string', description: '"<objectName|uuid>.<key>".' },
|
|
1822
|
+
value: { description: 'Number, "#hex" / numeric color, boolean, or [x,y,z] vector.' },
|
|
1823
|
+
labUrl: { type: 'string' },
|
|
1824
|
+
},
|
|
1825
|
+
required: ['path', 'value'],
|
|
1826
|
+
additionalProperties: false,
|
|
1827
|
+
},
|
|
1828
|
+
},
|
|
1829
|
+
{
|
|
1830
|
+
name: 'inspect',
|
|
1831
|
+
description:
|
|
1832
|
+
'Open the lab for an element in interactive inspect mode (solo full-canvas camera + OrbitControls + click-to-pick). Navigates the running browser via CDP to ?inspect=<element>&camera=<name>. Use when the user asks to "inspect" or "open" an element so they can rotate and click parts of it; subsequent clicks populate .selection in telemetry with source file:line.',
|
|
1833
|
+
inputSchema: {
|
|
1834
|
+
type: 'object',
|
|
1835
|
+
properties: {
|
|
1836
|
+
element: { type: 'string', description: 'Element to inspect (must match the manifest).' },
|
|
1837
|
+
camera: {
|
|
1838
|
+
type: 'string',
|
|
1839
|
+
description: "Starting camera (defaults to the element's first declared camera).",
|
|
1840
|
+
},
|
|
1841
|
+
},
|
|
1842
|
+
required: ['element'],
|
|
1843
|
+
additionalProperties: false,
|
|
1844
|
+
},
|
|
1845
|
+
},
|
|
1846
|
+
{
|
|
1847
|
+
name: 'open_selection',
|
|
1848
|
+
description:
|
|
1849
|
+
'Open the file:line of the currently selected mesh (from inspect mode) in the user\'s editor. Reads .selection.source from the telemetry snapshot and spawns $EDITOR (or `code --goto` by default). Use after the user clicks a mesh and says "open this" / "show me the code".',
|
|
1850
|
+
inputSchema: {
|
|
1851
|
+
type: 'object',
|
|
1852
|
+
properties: {
|
|
1853
|
+
editor: {
|
|
1854
|
+
type: 'string',
|
|
1855
|
+
description: 'Override the editor command. Default: $EDITOR or `code`.',
|
|
1856
|
+
},
|
|
1857
|
+
},
|
|
1858
|
+
additionalProperties: false,
|
|
1859
|
+
},
|
|
1860
|
+
},
|
|
1861
|
+
{
|
|
1862
|
+
name: 'snapshot',
|
|
1863
|
+
description:
|
|
1864
|
+
"Freeze the current tuning state as a git tag (triscope/snapshot/<name>). Stores the HEAD commit + every persisted knob value across all elements, as JSON inside the tag's annotated message — no working-tree files written, no rebase noise. Refuses on a dirty working tree (would silently lose the in-progress edits on restore).",
|
|
1865
|
+
inputSchema: {
|
|
1866
|
+
type: 'object',
|
|
1867
|
+
properties: {
|
|
1868
|
+
name: { type: 'string', description: 'Snapshot name. Must match [A-Za-z0-9._-]+.' },
|
|
1869
|
+
message: {
|
|
1870
|
+
type: 'string',
|
|
1871
|
+
description: 'Optional human note for `git show triscope/snapshot/<name>`.',
|
|
1872
|
+
},
|
|
1873
|
+
},
|
|
1874
|
+
required: ['name'],
|
|
1875
|
+
additionalProperties: false,
|
|
1876
|
+
},
|
|
1877
|
+
},
|
|
1878
|
+
{
|
|
1879
|
+
name: 'restore',
|
|
1880
|
+
description:
|
|
1881
|
+
'Restore a snapshot: git checkout the recorded commit and re-post every knob value via /__knob. Refuses on a dirty working tree. Leaves HEAD detached — branch from there if you want to keep iterating.',
|
|
1882
|
+
inputSchema: {
|
|
1883
|
+
type: 'object',
|
|
1884
|
+
properties: {
|
|
1885
|
+
name: {
|
|
1886
|
+
type: 'string',
|
|
1887
|
+
description: 'Snapshot name (matches `mcp__triscope__list_snapshots`).',
|
|
1888
|
+
},
|
|
1889
|
+
},
|
|
1890
|
+
required: ['name'],
|
|
1891
|
+
additionalProperties: false,
|
|
1892
|
+
},
|
|
1893
|
+
},
|
|
1894
|
+
{
|
|
1895
|
+
name: 'list_snapshots',
|
|
1896
|
+
description:
|
|
1897
|
+
'List every triscope snapshot tag in this repo (name, creation date, message subject). Use to find which one to restore.',
|
|
1898
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
1899
|
+
},
|
|
1900
|
+
{
|
|
1901
|
+
name: 'auto_tune',
|
|
1902
|
+
description:
|
|
1903
|
+
'Find the knob value that maximises SSIM (perceptual similarity) between the captured view and a stored reference image, using derivative-free golden-section search. Requires a prior set_reference for (element, target_camera). Iterations: post_knob → wait → captureViews → diff_reference. Use to converge a single shader parameter on a reference photo without manual bisection.',
|
|
1904
|
+
inputSchema: {
|
|
1905
|
+
type: 'object',
|
|
1906
|
+
properties: {
|
|
1907
|
+
element: { type: 'string', description: 'Element whose knob to tune.' },
|
|
1908
|
+
knob: {
|
|
1909
|
+
type: 'string',
|
|
1910
|
+
description: 'Knob key (must exist on Element.knobs and be type=number).',
|
|
1911
|
+
},
|
|
1912
|
+
range: {
|
|
1913
|
+
type: 'array',
|
|
1914
|
+
description:
|
|
1915
|
+
"Inclusive [min, max] search bracket. Should cover the knob's declared min/max.",
|
|
1916
|
+
items: { type: 'number' },
|
|
1917
|
+
minItems: 2,
|
|
1918
|
+
maxItems: 2,
|
|
1919
|
+
},
|
|
1920
|
+
target_camera: {
|
|
1921
|
+
type: 'string',
|
|
1922
|
+
description:
|
|
1923
|
+
'Camera the SSIM is computed on. Must have a stored reference (set_reference first).',
|
|
1924
|
+
},
|
|
1925
|
+
max_iterations: {
|
|
1926
|
+
type: 'number',
|
|
1927
|
+
description:
|
|
1928
|
+
'Cap on knob evaluations. Default 12 (golden section converges to ~0.7% of range).',
|
|
1929
|
+
},
|
|
1930
|
+
labUrl: {
|
|
1931
|
+
type: 'string',
|
|
1932
|
+
description: 'Override the lab URL (otherwise resolved like capture_views).',
|
|
1933
|
+
},
|
|
1934
|
+
},
|
|
1935
|
+
required: ['element', 'knob', 'range', 'target_camera'],
|
|
1936
|
+
additionalProperties: false,
|
|
1937
|
+
},
|
|
1938
|
+
},
|
|
1939
|
+
{
|
|
1940
|
+
name: 'multi_tune',
|
|
1941
|
+
description:
|
|
1942
|
+
'Converge 2-N coupled knobs on a reference image at once via coordinate descent (golden-section per knob, repeated cycles) maximising SSIM. Use when several knobs jointly shape the look (e.g. choppiness + foamThreshold + exposure) — beats chaining single-knob auto_tune. Requires a prior set_reference for (element, target_camera). Hard-capped on total captures so wall time stays bounded; best with <=4 knobs. Leaves the lab at the converged values.',
|
|
1943
|
+
inputSchema: {
|
|
1944
|
+
type: 'object',
|
|
1945
|
+
properties: {
|
|
1946
|
+
element: { type: 'string' },
|
|
1947
|
+
knobs: {
|
|
1948
|
+
type: 'array',
|
|
1949
|
+
minItems: 1,
|
|
1950
|
+
description:
|
|
1951
|
+
'Knobs to co-tune: [{key, range:[min,max], start?}]. Keep to <=4 for reasonable wall time.',
|
|
1952
|
+
items: {
|
|
1953
|
+
type: 'object',
|
|
1954
|
+
properties: {
|
|
1955
|
+
key: { type: 'string' },
|
|
1956
|
+
range: { type: 'array', items: { type: 'number' }, minItems: 2, maxItems: 2 },
|
|
1957
|
+
start: { type: 'number' },
|
|
1958
|
+
},
|
|
1959
|
+
required: ['key', 'range'],
|
|
1960
|
+
additionalProperties: false,
|
|
1961
|
+
},
|
|
1962
|
+
},
|
|
1963
|
+
target_camera: {
|
|
1964
|
+
type: 'string',
|
|
1965
|
+
description: 'Camera the SSIM is computed on (needs a stored reference).',
|
|
1966
|
+
},
|
|
1967
|
+
max_cycles: { type: 'number', description: 'Passes over all knobs. Default 2.' },
|
|
1968
|
+
per_knob_iters: {
|
|
1969
|
+
type: 'number',
|
|
1970
|
+
description: 'Golden-section evals per knob per cycle. Default 8.',
|
|
1971
|
+
},
|
|
1972
|
+
compute_interactions: {
|
|
1973
|
+
type: 'boolean',
|
|
1974
|
+
description:
|
|
1975
|
+
'Also return a pairwise knob-interaction matrix (extra evals). Default false.',
|
|
1976
|
+
},
|
|
1977
|
+
max_evaluations: {
|
|
1978
|
+
type: 'number',
|
|
1979
|
+
description: 'Hard cap on total captures. Default min(knobs*iters*cycles+4, 64).',
|
|
1980
|
+
},
|
|
1981
|
+
labUrl: { type: 'string' },
|
|
1982
|
+
},
|
|
1983
|
+
required: ['element', 'knobs', 'target_camera'],
|
|
1984
|
+
additionalProperties: false,
|
|
1985
|
+
},
|
|
1986
|
+
},
|
|
1987
|
+
{
|
|
1988
|
+
name: 'check_targets',
|
|
1989
|
+
description:
|
|
1990
|
+
'Evaluate per-camera convergence constraints from .claude/element-targets.json against the current capture — a machine-readable "is this view done?". Constraints per camera: ssim (min), meanAbsDiff (max), luminance {min,max}, dynamicRange {min,max}. ssim/meanAbsDiff need a stored reference (skipped, not failed, when absent); luminance/dynamicRange come from the GPU probe. Returns per-constraint pass/fail + allPassed. No targets file → no-op pass.',
|
|
1991
|
+
inputSchema: {
|
|
1992
|
+
type: 'object',
|
|
1993
|
+
properties: {
|
|
1994
|
+
element: { type: 'string' },
|
|
1995
|
+
labUrl: { type: 'string' },
|
|
1996
|
+
},
|
|
1997
|
+
required: ['element'],
|
|
1998
|
+
additionalProperties: false,
|
|
1999
|
+
},
|
|
2000
|
+
},
|
|
2001
|
+
{
|
|
2002
|
+
name: 'set_scene_param',
|
|
2003
|
+
description:
|
|
2004
|
+
'Live-mutate the scene WITHOUT a code edit or reload (Scene Description Layer): repoint cameras (position/target/fov), override knobs, and show/hide composed elements (enabled true/false — the "solo a track" model). Applied by the harness within ~100 ms and persisted so it survives a reload. NOTE: `elements` toggles visibility of elements already composed into the scene; instantiating a brand-new element type still needs a code edit.',
|
|
2005
|
+
inputSchema: {
|
|
2006
|
+
type: 'object',
|
|
2007
|
+
properties: {
|
|
2008
|
+
cameras: {
|
|
2009
|
+
type: 'object',
|
|
2010
|
+
description: 'Map of cameraName → {position:[x,y,z], target:[x,y,z], fov} (any subset).',
|
|
2011
|
+
additionalProperties: {
|
|
2012
|
+
type: 'object',
|
|
2013
|
+
properties: {
|
|
2014
|
+
position: { type: 'array', items: { type: 'number' }, minItems: 3, maxItems: 3 },
|
|
2015
|
+
target: { type: 'array', items: { type: 'number' }, minItems: 3, maxItems: 3 },
|
|
2016
|
+
fov: { type: 'number' },
|
|
2017
|
+
},
|
|
2018
|
+
additionalProperties: false,
|
|
2019
|
+
},
|
|
2020
|
+
},
|
|
2021
|
+
knobs: {
|
|
2022
|
+
type: 'object',
|
|
2023
|
+
description:
|
|
2024
|
+
'Map of knobKey → value (same effect as set_knob; here for one-call scene edits).',
|
|
2025
|
+
},
|
|
2026
|
+
elements: {
|
|
2027
|
+
type: 'object',
|
|
2028
|
+
description:
|
|
2029
|
+
'Map of elementName → {enabled:boolean} to show/hide a composed element live.',
|
|
2030
|
+
additionalProperties: {
|
|
2031
|
+
type: 'object',
|
|
2032
|
+
properties: { enabled: { type: 'boolean' } },
|
|
2033
|
+
additionalProperties: false,
|
|
2034
|
+
},
|
|
2035
|
+
},
|
|
2036
|
+
},
|
|
2037
|
+
additionalProperties: false,
|
|
2038
|
+
},
|
|
2039
|
+
},
|
|
2040
|
+
{
|
|
2041
|
+
name: 'get_scene',
|
|
2042
|
+
description:
|
|
2043
|
+
'Return the current scene: `sceneSpec` (the persisted SDL overrides from set_scene_param) and `live` (the camera positions/targets/fov + knob values from telemetry). Use to see what a viewpoint currently is before repointing it. Pass devUrl/labUrl to target a different dev server.',
|
|
2044
|
+
inputSchema: {
|
|
2045
|
+
type: 'object',
|
|
2046
|
+
properties: {
|
|
2047
|
+
devUrl: {
|
|
2048
|
+
type: 'string',
|
|
2049
|
+
description: 'Target dev-server origin (default: the boot one).',
|
|
2050
|
+
},
|
|
2051
|
+
labUrl: { type: 'string', description: 'A lab URL; its origin is used as devUrl.' },
|
|
2052
|
+
},
|
|
2053
|
+
additionalProperties: false,
|
|
2054
|
+
},
|
|
2055
|
+
},
|
|
2056
|
+
{
|
|
2057
|
+
name: 'add_element',
|
|
2058
|
+
description:
|
|
2059
|
+
'Instantiate a registered element into a runSceneLab scene LIVE (no reload): mounts it, adds its namespaced `<element>.<camera>` cameras + `<element>.<knob>` knobs, and rebuilds the grid. Returns {ok, mounted, available}. Only works on a multi-element scene booted with runSceneLab — on a single-element runLab page it returns {ok:false}. Use `available` from a prior add/remove (or list_elements) to see mountable names.',
|
|
2060
|
+
inputSchema: {
|
|
2061
|
+
type: 'object',
|
|
2062
|
+
properties: {
|
|
2063
|
+
name: { type: 'string', description: 'Registered element name to mount.' },
|
|
2064
|
+
element: {
|
|
2065
|
+
type: 'string',
|
|
2066
|
+
description: 'Any element in the scene, used only to locate the lab page.',
|
|
2067
|
+
},
|
|
2068
|
+
labUrl: { type: 'string', description: 'Explicit lab URL (alternative to element).' },
|
|
2069
|
+
},
|
|
2070
|
+
required: ['name'],
|
|
2071
|
+
additionalProperties: false,
|
|
2072
|
+
},
|
|
2073
|
+
},
|
|
2074
|
+
{
|
|
2075
|
+
name: 'remove_element',
|
|
2076
|
+
description:
|
|
2077
|
+
'Dispose a mounted element from a runSceneLab scene LIVE (no reload): drops its cameras/knobs/telemetry and rebuilds the grid. The element stays in the registry and can be re-added with add_element. Returns {ok, mounted, available}.',
|
|
2078
|
+
inputSchema: {
|
|
2079
|
+
type: 'object',
|
|
2080
|
+
properties: {
|
|
2081
|
+
name: { type: 'string', description: 'Mounted element name to dispose.' },
|
|
2082
|
+
element: {
|
|
2083
|
+
type: 'string',
|
|
2084
|
+
description: 'Any element in the scene, used only to locate the lab page.',
|
|
2085
|
+
},
|
|
2086
|
+
labUrl: { type: 'string', description: 'Explicit lab URL (alternative to element).' },
|
|
2087
|
+
},
|
|
2088
|
+
required: ['name'],
|
|
2089
|
+
additionalProperties: false,
|
|
2090
|
+
},
|
|
2091
|
+
},
|
|
2092
|
+
];
|
|
2093
|
+
|
|
2094
|
+
export function jsonResult(value) {
|
|
2095
|
+
let text: string;
|
|
2096
|
+
if (value === undefined) text = 'undefined';
|
|
2097
|
+
else if (typeof value === 'string') text = value;
|
|
2098
|
+
else text = JSON.stringify(value, null, 2);
|
|
2099
|
+
return {
|
|
2100
|
+
content: [{ type: 'text', text }],
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
export async function startServer() {
|
|
2105
|
+
const server = new Server(
|
|
2106
|
+
// Reported to every MCP client in the `initialize` handshake — keep in sync
|
|
2107
|
+
// with packages/mcp/package.json version on each release.
|
|
2108
|
+
{ name: 'triscope-mcp', version: '0.4.0' },
|
|
2109
|
+
{ capabilities: { tools: {} } },
|
|
2110
|
+
);
|
|
2111
|
+
|
|
2112
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
2113
|
+
|
|
2114
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
2115
|
+
const { name, arguments: args = {} } = req.params;
|
|
2116
|
+
// One info log per tool entry; on the way out either 'succeeded' or
|
|
2117
|
+
// 'failed' so a hung/dropped call leaves a half-pair in the log (no
|
|
2118
|
+
// succeeded/failed entry → the response never returned, e.g. the
|
|
2119
|
+
// process was OOM-killed during JSON encoding).
|
|
2120
|
+
const toolStart = Date.now();
|
|
2121
|
+
logger.info(`tool:${name}`, 'invoked', { args });
|
|
2122
|
+
const finish = (outcome: 'succeeded' | 'failed', extra?: Record<string, unknown>) =>
|
|
2123
|
+
logger.info(`tool:${name}`, outcome, { ms: Date.now() - toolStart, ...(extra ?? {}) });
|
|
2124
|
+
try {
|
|
2125
|
+
const result = await (async () => {
|
|
2126
|
+
switch (name) {
|
|
2127
|
+
case 'list_elements':
|
|
2128
|
+
return jsonResult(
|
|
2129
|
+
await listElements({
|
|
2130
|
+
devUrl: args.devUrl as string | undefined,
|
|
2131
|
+
labUrl: args.labUrl as string | undefined,
|
|
2132
|
+
}),
|
|
2133
|
+
);
|
|
2134
|
+
case 'read_telemetry':
|
|
2135
|
+
return jsonResult(
|
|
2136
|
+
await readTelemetry(args.path as string | undefined, {
|
|
2137
|
+
devUrl: args.devUrl as string | undefined,
|
|
2138
|
+
labUrl: args.labUrl as string | undefined,
|
|
2139
|
+
}),
|
|
2140
|
+
);
|
|
2141
|
+
case 'set_knob': {
|
|
2142
|
+
const value = z.union([z.number(), z.string(), z.boolean()]);
|
|
2143
|
+
const update = z.object({ element: z.string(), key: z.string(), value });
|
|
2144
|
+
const schema = z.union([z.object({ updates: z.array(update).min(1) }), update]);
|
|
2145
|
+
const parsed = schema.parse(args);
|
|
2146
|
+
const loc = z
|
|
2147
|
+
.object({ devUrl: z.string().optional(), labUrl: z.string().optional() })
|
|
2148
|
+
.parse(args);
|
|
2149
|
+
return jsonResult(await setKnob({ ...parsed, ...loc }));
|
|
2150
|
+
}
|
|
2151
|
+
case 'capture_views': {
|
|
2152
|
+
const res = await captureViews({
|
|
2153
|
+
element: args.element as string | undefined,
|
|
2154
|
+
labUrl: args.labUrl as string | undefined,
|
|
2155
|
+
inline: (args.inline ?? false) as boolean,
|
|
2156
|
+
fresh: (args.fresh ?? false) as boolean,
|
|
2157
|
+
});
|
|
2158
|
+
const { _base64ByCam, ...summary } = res;
|
|
2159
|
+
// Cap inline payload: 12-camera scenes can produce ~20 MB of base64
|
|
2160
|
+
// in a single JSON-RPC message over stdio, which OOM-kills the
|
|
2161
|
+
// server process (no catch, no log — Claude Code then auto-respawns
|
|
2162
|
+
// us and tools are temporarily unavailable). Auto-downgrade to
|
|
2163
|
+
// file paths when over budget and surface a warning so the model
|
|
2164
|
+
// knows to Read the files instead.
|
|
2165
|
+
let inlineBytes = 0;
|
|
2166
|
+
for (const b64 of Object.values(_base64ByCam) as string[]) inlineBytes += b64.length;
|
|
2167
|
+
const inlineCapped = res.inline && inlineBytes > INLINE_PAYLOAD_BUDGET;
|
|
2168
|
+
const finalInline = res.inline && !inlineCapped;
|
|
2169
|
+
const summaryWithWarn = inlineCapped
|
|
2170
|
+
? {
|
|
2171
|
+
...summary,
|
|
2172
|
+
inline: false,
|
|
2173
|
+
inlineCapped: true,
|
|
2174
|
+
inlineWarning: `inline payload would have been ${(inlineBytes / 1048576).toFixed(1)} MB (limit ${(INLINE_PAYLOAD_BUDGET / 1048576).toFixed(0)} MB) — files are on disk, Read them by path.`,
|
|
2175
|
+
}
|
|
2176
|
+
: summary;
|
|
2177
|
+
const text = JSON.stringify(summaryWithWarn, null, 2);
|
|
2178
|
+
if (!finalInline) return { content: [{ type: 'text', text }] };
|
|
2179
|
+
const content: any[] = [{ type: 'text', text }];
|
|
2180
|
+
for (const cam of res.cameraOrder) {
|
|
2181
|
+
const data = _base64ByCam[cam];
|
|
2182
|
+
if (!data) continue;
|
|
2183
|
+
content.push({ type: 'image', data, mimeType: 'image/png' });
|
|
2184
|
+
}
|
|
2185
|
+
return { content };
|
|
2186
|
+
}
|
|
2187
|
+
case 'set_reference': {
|
|
2188
|
+
const parsed = z
|
|
2189
|
+
.object({
|
|
2190
|
+
element: z.string(),
|
|
2191
|
+
camera: z.string(),
|
|
2192
|
+
path: z.string().optional(),
|
|
2193
|
+
base64: z.string().optional(),
|
|
2194
|
+
})
|
|
2195
|
+
.parse(args);
|
|
2196
|
+
const result = setReference({ cwd: process.cwd(), ...parsed } as any);
|
|
2197
|
+
return jsonResult(result);
|
|
2198
|
+
}
|
|
2199
|
+
case 'diff_reference': {
|
|
2200
|
+
const parsed = z
|
|
2201
|
+
.object({
|
|
2202
|
+
element: z.string(),
|
|
2203
|
+
camera: z.string(),
|
|
2204
|
+
labUrl: z.string().optional(),
|
|
2205
|
+
fresh: z.boolean().optional(),
|
|
2206
|
+
})
|
|
2207
|
+
.parse(args);
|
|
2208
|
+
const refExists = existsSync(refsPath(process.cwd(), parsed.element, parsed.camera));
|
|
2209
|
+
if (!refExists) {
|
|
2210
|
+
return {
|
|
2211
|
+
isError: true,
|
|
2212
|
+
content: [
|
|
2213
|
+
{
|
|
2214
|
+
type: 'text',
|
|
2215
|
+
text: `no reference at ${refsPath(process.cwd(), parsed.element, parsed.camera)}. Call set_reference first.`,
|
|
2216
|
+
},
|
|
2217
|
+
],
|
|
2218
|
+
};
|
|
2219
|
+
}
|
|
2220
|
+
const cap = await captureViews({
|
|
2221
|
+
element: parsed.element,
|
|
2222
|
+
labUrl: parsed.labUrl,
|
|
2223
|
+
inline: true,
|
|
2224
|
+
fresh: parsed.fresh ?? false,
|
|
2225
|
+
});
|
|
2226
|
+
const currentBase64 = cap._base64ByCam?.[parsed.camera];
|
|
2227
|
+
if (!currentBase64) {
|
|
2228
|
+
return {
|
|
2229
|
+
isError: true,
|
|
2230
|
+
content: [
|
|
2231
|
+
{
|
|
2232
|
+
type: 'text',
|
|
2233
|
+
text: `camera "${parsed.camera}" not found on element "${parsed.element}". Available: ${cap.cameraOrder.join(', ')}`,
|
|
2234
|
+
},
|
|
2235
|
+
],
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2238
|
+
const diff = diffReference({
|
|
2239
|
+
cwd: process.cwd(),
|
|
2240
|
+
element: parsed.element,
|
|
2241
|
+
camera: parsed.camera,
|
|
2242
|
+
currentBase64,
|
|
2243
|
+
});
|
|
2244
|
+
// Two images: the side-by-side composite (always) and the
|
|
2245
|
+
// difference heatmap (when it fits the inline budget). The heatmap
|
|
2246
|
+
// is what makes the worstTile/tileGrid spatially legible.
|
|
2247
|
+
const compositeBytes = diff.compositeBase64.length;
|
|
2248
|
+
const heatmapBytes = diff.heatmapBase64?.length ?? 0;
|
|
2249
|
+
const heatmapFits = compositeBytes + heatmapBytes <= INLINE_PAYLOAD_BUDGET;
|
|
2250
|
+
const content: any[] = [
|
|
2251
|
+
{
|
|
2252
|
+
type: 'text',
|
|
2253
|
+
text: JSON.stringify(
|
|
2254
|
+
{
|
|
2255
|
+
camera: diff.camera,
|
|
2256
|
+
refPath: diff.refPath,
|
|
2257
|
+
meanAbsDiff: diff.meanAbsDiff,
|
|
2258
|
+
ssim: diff.ssim,
|
|
2259
|
+
worstTile: diff.worstTile,
|
|
2260
|
+
tileStats: diff.tileStats,
|
|
2261
|
+
tileGrid: diff.tileGrid,
|
|
2262
|
+
heatmapIncluded: heatmapFits,
|
|
2263
|
+
hint: 'meanAbsDiff: 0 = identical, ~30 = visibly close, >80 = clearly different. ssim: 1.0 = identical, 0.9+ = visually close, <0.7 = clearly different (prefer SSIM, robust to AA noise). tileGrid is an 8×8 SSIM map (row 0 = top); worstTile points at the most divergent region. The second image is a black→blue→yellow→red difference heatmap — hot = where the frames differ.',
|
|
2264
|
+
},
|
|
2265
|
+
null,
|
|
2266
|
+
2,
|
|
2267
|
+
),
|
|
2268
|
+
},
|
|
2269
|
+
{ type: 'image', data: diff.compositeBase64, mimeType: 'image/png' },
|
|
2270
|
+
];
|
|
2271
|
+
if (heatmapFits && diff.heatmapBase64) {
|
|
2272
|
+
content.push({ type: 'image', data: diff.heatmapBase64, mimeType: 'image/png' });
|
|
2273
|
+
}
|
|
2274
|
+
return { content };
|
|
2275
|
+
}
|
|
2276
|
+
case 'set_reference_motion': {
|
|
2277
|
+
const parsed = z
|
|
2278
|
+
.object({
|
|
2279
|
+
element: z.string(),
|
|
2280
|
+
camera: z.string(),
|
|
2281
|
+
frames: z.number().int().min(2).max(32).optional(),
|
|
2282
|
+
dt: z.number().positive().max(5).optional(),
|
|
2283
|
+
mode: z.enum(['time', 'real']).optional(),
|
|
2284
|
+
labUrl: z.string().optional(),
|
|
2285
|
+
})
|
|
2286
|
+
.parse(args);
|
|
2287
|
+
const opts = {
|
|
2288
|
+
frames: parsed.frames ?? 6,
|
|
2289
|
+
dt: parsed.dt ?? 0.25,
|
|
2290
|
+
mode: parsed.mode ?? 'time',
|
|
2291
|
+
};
|
|
2292
|
+
const frameB64s = await captureMotionFramesRaw({ ...parsed, ...opts });
|
|
2293
|
+
const r = setReferenceMotion({
|
|
2294
|
+
cwd: process.cwd(),
|
|
2295
|
+
element: parsed.element,
|
|
2296
|
+
camera: parsed.camera,
|
|
2297
|
+
frameBase64s: frameB64s,
|
|
2298
|
+
meta: opts,
|
|
2299
|
+
});
|
|
2300
|
+
return jsonResult(r);
|
|
2301
|
+
}
|
|
2302
|
+
case 'diff_reference_motion': {
|
|
2303
|
+
const parsed = z
|
|
2304
|
+
.object({
|
|
2305
|
+
element: z.string(),
|
|
2306
|
+
camera: z.string(),
|
|
2307
|
+
frames: z.number().int().min(2).max(32).optional(),
|
|
2308
|
+
dt: z.number().positive().max(5).optional(),
|
|
2309
|
+
mode: z.enum(['time', 'real']).optional(),
|
|
2310
|
+
labUrl: z.string().optional(),
|
|
2311
|
+
})
|
|
2312
|
+
.parse(args);
|
|
2313
|
+
const { filmstrip, meta } = refsMotionPaths(
|
|
2314
|
+
process.cwd(),
|
|
2315
|
+
parsed.element,
|
|
2316
|
+
parsed.camera,
|
|
2317
|
+
);
|
|
2318
|
+
if (!existsSync(filmstrip)) {
|
|
2319
|
+
return {
|
|
2320
|
+
isError: true,
|
|
2321
|
+
content: [
|
|
2322
|
+
{
|
|
2323
|
+
type: 'text',
|
|
2324
|
+
text: `no motion reference at ${filmstrip}. Call set_reference_motion first.`,
|
|
2325
|
+
},
|
|
2326
|
+
],
|
|
2327
|
+
};
|
|
2328
|
+
}
|
|
2329
|
+
// Inherit frame/dt/mode from saved metadata so the comparison is fair.
|
|
2330
|
+
let savedMeta: any = {};
|
|
2331
|
+
try {
|
|
2332
|
+
savedMeta = existsSync(meta) ? JSON.parse(readFileSync(meta, 'utf8')) : {};
|
|
2333
|
+
} catch {}
|
|
2334
|
+
const opts = {
|
|
2335
|
+
frames: parsed.frames ?? savedMeta.frames ?? 6,
|
|
2336
|
+
dt: parsed.dt ?? savedMeta.dt ?? 0.25,
|
|
2337
|
+
mode: parsed.mode ?? savedMeta.mode ?? 'time',
|
|
2338
|
+
};
|
|
2339
|
+
const frameB64s = await captureMotionFramesRaw({ ...parsed, ...opts });
|
|
2340
|
+
const diff = diffReferenceMotion({
|
|
2341
|
+
cwd: process.cwd(),
|
|
2342
|
+
element: parsed.element,
|
|
2343
|
+
camera: parsed.camera,
|
|
2344
|
+
currentFrames: frameB64s,
|
|
2345
|
+
});
|
|
2346
|
+
return {
|
|
2347
|
+
content: [
|
|
2348
|
+
{
|
|
2349
|
+
type: 'text',
|
|
2350
|
+
text: JSON.stringify(
|
|
2351
|
+
{
|
|
2352
|
+
camera: parsed.camera,
|
|
2353
|
+
refFilmstripPath: diff.refFilmstripPath,
|
|
2354
|
+
refMeta: diff.refMeta,
|
|
2355
|
+
motionDiff: diff.motionDiff,
|
|
2356
|
+
hint: '0 = identical animation, >5 = visible drift, >30 = clearly different',
|
|
2357
|
+
},
|
|
2358
|
+
null,
|
|
2359
|
+
2,
|
|
2360
|
+
),
|
|
2361
|
+
},
|
|
2362
|
+
{ type: 'image', data: diff.compositeBase64, mimeType: 'image/png' },
|
|
2363
|
+
],
|
|
2364
|
+
};
|
|
2365
|
+
}
|
|
2366
|
+
case 'capture_motion': {
|
|
2367
|
+
const parsed = z
|
|
2368
|
+
.object({
|
|
2369
|
+
element: z.string(),
|
|
2370
|
+
camera: z.string().optional(),
|
|
2371
|
+
frames: z.number().int().min(2).max(32).optional(),
|
|
2372
|
+
dt: z.number().positive().max(5).optional(),
|
|
2373
|
+
mode: z.enum(['time', 'real']).optional(),
|
|
2374
|
+
labUrl: z.string().optional(),
|
|
2375
|
+
inline: z.boolean().optional(),
|
|
2376
|
+
fresh: z.boolean().optional(),
|
|
2377
|
+
})
|
|
2378
|
+
.parse(args);
|
|
2379
|
+
const res = await captureMotion(parsed);
|
|
2380
|
+
const { _filmstripBase64, ...summary } = res;
|
|
2381
|
+
// Same inline budget guard as capture_views — filmstrips can be
|
|
2382
|
+
// even bigger (N frames per camera) so easier to blow the cap.
|
|
2383
|
+
let filmstripBytes = 0;
|
|
2384
|
+
for (const b64 of Object.values(_filmstripBase64) as string[])
|
|
2385
|
+
filmstripBytes += b64.length;
|
|
2386
|
+
const userWantsInline = parsed.inline !== false;
|
|
2387
|
+
const filmstripCapped = userWantsInline && filmstripBytes > INLINE_PAYLOAD_BUDGET;
|
|
2388
|
+
const finalInline = userWantsInline && !filmstripCapped;
|
|
2389
|
+
const text = JSON.stringify(
|
|
2390
|
+
{
|
|
2391
|
+
...summary,
|
|
2392
|
+
...(filmstripCapped
|
|
2393
|
+
? {
|
|
2394
|
+
inlineCapped: true,
|
|
2395
|
+
inlineWarning: `filmstrip payload would have been ${(filmstripBytes / 1048576).toFixed(1)} MB (limit ${(INLINE_PAYLOAD_BUDGET / 1048576).toFixed(0)} MB) — files are on disk, Read them by path.`,
|
|
2396
|
+
}
|
|
2397
|
+
: {}),
|
|
2398
|
+
hint: '<1 = static, >5 = visible motion, >20 = vigorous (in motionMagnitude)',
|
|
2399
|
+
},
|
|
2400
|
+
null,
|
|
2401
|
+
2,
|
|
2402
|
+
);
|
|
2403
|
+
if (!finalInline) return { content: [{ type: 'text', text }] };
|
|
2404
|
+
const content: any[] = [{ type: 'text', text }];
|
|
2405
|
+
for (const cam of res.cameraOrder) {
|
|
2406
|
+
const data = _filmstripBase64[cam];
|
|
2407
|
+
if (data) content.push({ type: 'image', data, mimeType: 'image/png' });
|
|
2408
|
+
}
|
|
2409
|
+
return { content };
|
|
2410
|
+
}
|
|
2411
|
+
case 'health': {
|
|
2412
|
+
let devServerOk = false;
|
|
2413
|
+
let manifestElements = [];
|
|
2414
|
+
try {
|
|
2415
|
+
const r = await fetch(`${DEV_URL}/__manifest`, { signal: AbortSignal.timeout(2000) });
|
|
2416
|
+
if (r.ok) {
|
|
2417
|
+
const m: any = await r.json();
|
|
2418
|
+
devServerOk = true;
|
|
2419
|
+
manifestElements = Object.keys(m?.elements ?? {});
|
|
2420
|
+
}
|
|
2421
|
+
} catch {}
|
|
2422
|
+
const mem = process.memoryUsage();
|
|
2423
|
+
return jsonResult({
|
|
2424
|
+
uptimeSec: Math.round((Date.now() - SERVER_START_TIME) / 1000),
|
|
2425
|
+
pid: process.pid,
|
|
2426
|
+
nodeVersion: process.version,
|
|
2427
|
+
project: PROJECT,
|
|
2428
|
+
devServer: { url: DEV_URL, reachable: devServerOk, manifestElements },
|
|
2429
|
+
memoryMB: {
|
|
2430
|
+
rss: +(mem.rss / 1048576).toFixed(1),
|
|
2431
|
+
heapUsed: +(mem.heapUsed / 1048576).toFixed(1),
|
|
2432
|
+
external: +(mem.external / 1048576).toFixed(1),
|
|
2433
|
+
},
|
|
2434
|
+
logPath: logger.logPath,
|
|
2435
|
+
recentErrors,
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
case 'run_smoke':
|
|
2439
|
+
return jsonResult(await runSmoke({ element: args.element }));
|
|
2440
|
+
case 'import_element': {
|
|
2441
|
+
const parsed = z
|
|
2442
|
+
.object({ source: z.string(), name: z.string().optional() })
|
|
2443
|
+
.parse(args);
|
|
2444
|
+
return jsonResult(
|
|
2445
|
+
await importElement({ source: parsed.source as string, name: parsed.name }),
|
|
2446
|
+
);
|
|
2447
|
+
}
|
|
2448
|
+
case 'scaffold_from_gltf': {
|
|
2449
|
+
const parsed = z.object({ file: z.string(), name: z.string().optional() }).parse(args);
|
|
2450
|
+
return jsonResult(
|
|
2451
|
+
await scaffoldFromGltf({ file: parsed.file as string, name: parsed.name }),
|
|
2452
|
+
);
|
|
2453
|
+
}
|
|
2454
|
+
case 'read_uniform': {
|
|
2455
|
+
const parsed = z
|
|
2456
|
+
.object({
|
|
2457
|
+
element: z.string().optional(),
|
|
2458
|
+
path: z.string(),
|
|
2459
|
+
labUrl: z.string().optional(),
|
|
2460
|
+
})
|
|
2461
|
+
.parse(args);
|
|
2462
|
+
return jsonResult(
|
|
2463
|
+
await readUniform({
|
|
2464
|
+
element: parsed.element,
|
|
2465
|
+
path: parsed.path as string,
|
|
2466
|
+
labUrl: parsed.labUrl,
|
|
2467
|
+
}),
|
|
2468
|
+
);
|
|
2469
|
+
}
|
|
2470
|
+
case 'set_uniform': {
|
|
2471
|
+
const parsed = z
|
|
2472
|
+
.object({
|
|
2473
|
+
element: z.string().optional(),
|
|
2474
|
+
path: z.string(),
|
|
2475
|
+
value: z.union([z.number(), z.string(), z.boolean(), z.array(z.number())]),
|
|
2476
|
+
labUrl: z.string().optional(),
|
|
2477
|
+
})
|
|
2478
|
+
.parse(args);
|
|
2479
|
+
return jsonResult(
|
|
2480
|
+
await setUniform({
|
|
2481
|
+
element: parsed.element,
|
|
2482
|
+
path: parsed.path as string,
|
|
2483
|
+
value: parsed.value,
|
|
2484
|
+
labUrl: parsed.labUrl,
|
|
2485
|
+
}),
|
|
2486
|
+
);
|
|
2487
|
+
}
|
|
2488
|
+
case 'inspect_scene': {
|
|
2489
|
+
const parsed = z
|
|
2490
|
+
.object({
|
|
2491
|
+
element: z.string().optional(),
|
|
2492
|
+
maxNodes: z.number().int().min(1).max(5000).optional(),
|
|
2493
|
+
labUrl: z.string().optional(),
|
|
2494
|
+
fresh: z.boolean().optional(),
|
|
2495
|
+
})
|
|
2496
|
+
.parse(args);
|
|
2497
|
+
return jsonResult(await inspectScene(parsed));
|
|
2498
|
+
}
|
|
2499
|
+
case 'inspect': {
|
|
2500
|
+
const parsed = z
|
|
2501
|
+
.object({
|
|
2502
|
+
element: z.string(),
|
|
2503
|
+
camera: z.string().optional(),
|
|
2504
|
+
})
|
|
2505
|
+
.parse(args);
|
|
2506
|
+
return jsonResult(await inspect({ element: parsed.element, camera: parsed.camera }));
|
|
2507
|
+
}
|
|
2508
|
+
case 'open_selection': {
|
|
2509
|
+
const parsed = z
|
|
2510
|
+
.object({
|
|
2511
|
+
editor: z.string().optional(),
|
|
2512
|
+
})
|
|
2513
|
+
.parse(args);
|
|
2514
|
+
return jsonResult(await openSelection(parsed));
|
|
2515
|
+
}
|
|
2516
|
+
case 'snapshot': {
|
|
2517
|
+
const parsed = z
|
|
2518
|
+
.object({
|
|
2519
|
+
name: z.string(),
|
|
2520
|
+
message: z.string().optional(),
|
|
2521
|
+
})
|
|
2522
|
+
.parse(args);
|
|
2523
|
+
return jsonResult(await snapshot({ name: parsed.name, message: parsed.message }));
|
|
2524
|
+
}
|
|
2525
|
+
case 'restore': {
|
|
2526
|
+
const parsed = z.object({ name: z.string() }).parse(args);
|
|
2527
|
+
return jsonResult(await restore({ name: parsed.name }));
|
|
2528
|
+
}
|
|
2529
|
+
case 'list_snapshots':
|
|
2530
|
+
return jsonResult(await listSnapshots());
|
|
2531
|
+
case 'auto_tune': {
|
|
2532
|
+
const parsed = z
|
|
2533
|
+
.object({
|
|
2534
|
+
element: z.string(),
|
|
2535
|
+
knob: z.string(),
|
|
2536
|
+
range: z.tuple([z.number(), z.number()]),
|
|
2537
|
+
target_camera: z.string(),
|
|
2538
|
+
max_iterations: z.number().int().min(2).max(50).optional(),
|
|
2539
|
+
labUrl: z.string().optional(),
|
|
2540
|
+
})
|
|
2541
|
+
.parse(args);
|
|
2542
|
+
return jsonResult(
|
|
2543
|
+
await autoTune({
|
|
2544
|
+
element: parsed.element,
|
|
2545
|
+
knob: parsed.knob,
|
|
2546
|
+
range: [parsed.range[0], parsed.range[1]],
|
|
2547
|
+
target_camera: parsed.target_camera,
|
|
2548
|
+
max_iterations: parsed.max_iterations,
|
|
2549
|
+
labUrl: parsed.labUrl,
|
|
2550
|
+
}),
|
|
2551
|
+
);
|
|
2552
|
+
}
|
|
2553
|
+
case 'multi_tune': {
|
|
2554
|
+
const parsed = z
|
|
2555
|
+
.object({
|
|
2556
|
+
element: z.string(),
|
|
2557
|
+
knobs: z
|
|
2558
|
+
.array(
|
|
2559
|
+
z.object({
|
|
2560
|
+
key: z.string(),
|
|
2561
|
+
range: z.tuple([z.number(), z.number()]),
|
|
2562
|
+
start: z.number().optional(),
|
|
2563
|
+
}),
|
|
2564
|
+
)
|
|
2565
|
+
.min(1),
|
|
2566
|
+
target_camera: z.string(),
|
|
2567
|
+
max_cycles: z.number().int().min(1).max(6).optional(),
|
|
2568
|
+
per_knob_iters: z.number().int().min(2).max(20).optional(),
|
|
2569
|
+
compute_interactions: z.boolean().optional(),
|
|
2570
|
+
max_evaluations: z.number().int().min(2).max(300).optional(),
|
|
2571
|
+
labUrl: z.string().optional(),
|
|
2572
|
+
})
|
|
2573
|
+
.parse(args);
|
|
2574
|
+
return jsonResult(
|
|
2575
|
+
await multiTune({
|
|
2576
|
+
element: parsed.element as string,
|
|
2577
|
+
knobs: (
|
|
2578
|
+
parsed.knobs as Array<{ key: string; range: [number, number]; start?: number }>
|
|
2579
|
+
).map((k) => ({
|
|
2580
|
+
key: k.key,
|
|
2581
|
+
range: [k.range[0], k.range[1]] as [number, number],
|
|
2582
|
+
start: k.start,
|
|
2583
|
+
})),
|
|
2584
|
+
target_camera: parsed.target_camera as string,
|
|
2585
|
+
max_cycles: parsed.max_cycles,
|
|
2586
|
+
per_knob_iters: parsed.per_knob_iters,
|
|
2587
|
+
compute_interactions: parsed.compute_interactions,
|
|
2588
|
+
max_evaluations: parsed.max_evaluations,
|
|
2589
|
+
labUrl: parsed.labUrl,
|
|
2590
|
+
}),
|
|
2591
|
+
);
|
|
2592
|
+
}
|
|
2593
|
+
case 'check_targets': {
|
|
2594
|
+
const parsed = z
|
|
2595
|
+
.object({ element: z.string(), labUrl: z.string().optional() })
|
|
2596
|
+
.parse(args);
|
|
2597
|
+
return jsonResult(
|
|
2598
|
+
await checkTargets({ element: parsed.element as string, labUrl: parsed.labUrl }),
|
|
2599
|
+
);
|
|
2600
|
+
}
|
|
2601
|
+
case 'set_scene_param': {
|
|
2602
|
+
const parsed = z
|
|
2603
|
+
.object({
|
|
2604
|
+
cameras: z
|
|
2605
|
+
.record(
|
|
2606
|
+
z.object({
|
|
2607
|
+
position: z.array(z.number()).optional(),
|
|
2608
|
+
target: z.array(z.number()).optional(),
|
|
2609
|
+
fov: z.number().optional(),
|
|
2610
|
+
}),
|
|
2611
|
+
)
|
|
2612
|
+
.optional(),
|
|
2613
|
+
knobs: z.record(z.union([z.number(), z.string(), z.boolean()])).optional(),
|
|
2614
|
+
elements: z.record(z.object({ enabled: z.boolean().optional() })).optional(),
|
|
2615
|
+
})
|
|
2616
|
+
.parse(args);
|
|
2617
|
+
return jsonResult(await setSceneParam(parsed));
|
|
2618
|
+
}
|
|
2619
|
+
case 'get_scene':
|
|
2620
|
+
return jsonResult(
|
|
2621
|
+
await getScene({
|
|
2622
|
+
devUrl: args.devUrl as string | undefined,
|
|
2623
|
+
labUrl: args.labUrl as string | undefined,
|
|
2624
|
+
}),
|
|
2625
|
+
);
|
|
2626
|
+
case 'add_element': {
|
|
2627
|
+
const parsed = z
|
|
2628
|
+
.object({
|
|
2629
|
+
name: z.string(),
|
|
2630
|
+
element: z.string().optional(),
|
|
2631
|
+
labUrl: z.string().optional(),
|
|
2632
|
+
})
|
|
2633
|
+
.parse(args);
|
|
2634
|
+
return jsonResult(
|
|
2635
|
+
await addElement({
|
|
2636
|
+
name: parsed.name,
|
|
2637
|
+
element: parsed.element,
|
|
2638
|
+
labUrl: parsed.labUrl,
|
|
2639
|
+
}),
|
|
2640
|
+
);
|
|
2641
|
+
}
|
|
2642
|
+
case 'remove_element': {
|
|
2643
|
+
const parsed = z
|
|
2644
|
+
.object({
|
|
2645
|
+
name: z.string(),
|
|
2646
|
+
element: z.string().optional(),
|
|
2647
|
+
labUrl: z.string().optional(),
|
|
2648
|
+
})
|
|
2649
|
+
.parse(args);
|
|
2650
|
+
return jsonResult(
|
|
2651
|
+
await removeElement({
|
|
2652
|
+
name: parsed.name,
|
|
2653
|
+
element: parsed.element,
|
|
2654
|
+
labUrl: parsed.labUrl,
|
|
2655
|
+
}),
|
|
2656
|
+
);
|
|
2657
|
+
}
|
|
2658
|
+
default:
|
|
2659
|
+
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
2660
|
+
}
|
|
2661
|
+
})();
|
|
2662
|
+
finish('succeeded');
|
|
2663
|
+
return result;
|
|
2664
|
+
} catch (err: any) {
|
|
2665
|
+
finish('failed');
|
|
2666
|
+
recordError(`tool:${name}`, err);
|
|
2667
|
+
return {
|
|
2668
|
+
isError: true,
|
|
2669
|
+
content: [{ type: 'text', text: `${name} failed: ${err?.message ?? String(err)}` }],
|
|
2670
|
+
};
|
|
2671
|
+
}
|
|
2672
|
+
});
|
|
2673
|
+
|
|
2674
|
+
const transport = new StdioServerTransport();
|
|
2675
|
+
await server.connect(transport);
|
|
2676
|
+
// eslint-disable-next-line no-console
|
|
2677
|
+
console.error(`[triscope-mcp] connected. dev server: ${DEV_URL}, project: ${PROJECT}`);
|
|
2678
|
+
}
|