summer-engine 2.6.1 → 2.6.5
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +1 -1
- package/.factory-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +20 -0
- package/GEMINI.md +2 -1
- package/README.md +61 -9
- package/dist/bin/summer.js +1 -1
- package/dist/commands/setup.js +9 -1
- package/dist/commands/skills.js +15 -5
- package/dist/lib/agent-config.d.ts +1 -1
- package/dist/lib/agent-config.js +53 -15
- package/dist/lib/api-client.d.ts +75 -1
- package/dist/lib/api-client.js +191 -8
- package/dist/lib/game-task-plan.js +6 -2
- package/dist/lib/skills-registry.d.ts +66 -66
- package/dist/lib/skills-registry.js +1 -0
- package/dist/mcp/server.js +63 -18
- package/dist/mcp/tools/asset-tools.js +14 -4
- package/dist/mcp/tools/generate-tools.js +6 -0
- package/dist/mcp/tools/project-tools.js +72 -5
- package/dist/mcp/tools/scene-tools.js +6 -2
- package/dist/mcp/tools/visual-tools.d.ts +9 -0
- package/dist/mcp/tools/visual-tools.js +126 -0
- package/dist/mcp/tools/with-engine.d.ts +52 -5
- package/dist/mcp/tools/with-engine.js +117 -15
- package/package.json +1 -1
package/dist/lib/api-client.js
CHANGED
|
@@ -14,6 +14,9 @@ function stringFrom(value) {
|
|
|
14
14
|
function numberFrom(value) {
|
|
15
15
|
return typeof value === "number" ? value : undefined;
|
|
16
16
|
}
|
|
17
|
+
function boolFrom(value) {
|
|
18
|
+
return typeof value === "boolean" ? value : undefined;
|
|
19
|
+
}
|
|
17
20
|
function findSnapshotPayload(value) {
|
|
18
21
|
const record = asRecord(value);
|
|
19
22
|
if (!record)
|
|
@@ -21,7 +24,8 @@ function findSnapshotPayload(value) {
|
|
|
21
24
|
if (typeof record.base64 === "string" ||
|
|
22
25
|
typeof record.image_base64 === "string" ||
|
|
23
26
|
record.op === "ViewportSnapshot" ||
|
|
24
|
-
record.op === "GameSnapshot"
|
|
27
|
+
record.op === "GameSnapshot" ||
|
|
28
|
+
record.op === "ScenePreview") {
|
|
25
29
|
return record;
|
|
26
30
|
}
|
|
27
31
|
if (Array.isArray(record.results)) {
|
|
@@ -63,9 +67,17 @@ function withoutImageData(payload) {
|
|
|
63
67
|
export class EngineApiClient {
|
|
64
68
|
port;
|
|
65
69
|
token;
|
|
66
|
-
|
|
70
|
+
// The project this session is BOUND to — the projectIdHash reported by the
|
|
71
|
+
// engine when this client first connected. Sent on every mutating op so the
|
|
72
|
+
// engine's identity guard (local_api_server.cpp ~271-302) atomically rejects a
|
|
73
|
+
// write aimed at a DIFFERENT project (e.g. after the user switched projects
|
|
74
|
+
// in-place). Health is the only identity signal the MCP has — /api/state/project
|
|
75
|
+
// carries no path and /api/health exposes only projectIdHash, not the raw id.
|
|
76
|
+
boundProjectIdHash;
|
|
77
|
+
constructor(port, token, boundProjectIdHash) {
|
|
67
78
|
this.port = port;
|
|
68
79
|
this.token = token;
|
|
80
|
+
this.boundProjectIdHash = boundProjectIdHash;
|
|
69
81
|
}
|
|
70
82
|
static async connect() {
|
|
71
83
|
const port = await getApiPort();
|
|
@@ -77,7 +89,36 @@ export class EngineApiClient {
|
|
|
77
89
|
if (!health) {
|
|
78
90
|
throw new Error(`Summer Engine is not responding on port ${port}. Make sure it's open.`);
|
|
79
91
|
}
|
|
80
|
-
|
|
92
|
+
// Bind to whatever project is open right now. On a genuine engine restart the
|
|
93
|
+
// token rotates and getClient() rebuilds this client, so a restart naturally
|
|
94
|
+
// rebinds to the current project. An in-place project switch keeps the same
|
|
95
|
+
// token, so the cached client retains its original binding and the engine
|
|
96
|
+
// rejects mismatched mutations until the agent explicitly rebinds.
|
|
97
|
+
return new EngineApiClient(port, token, health.projectIdHash);
|
|
98
|
+
}
|
|
99
|
+
/** The projectIdHash this session is bound to (undefined if none was reported
|
|
100
|
+
* at connect — e.g. no project open yet). */
|
|
101
|
+
getBoundProjectIdHash() {
|
|
102
|
+
return this.boundProjectIdHash;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Re-read health and rebind to the currently-open project. Called by
|
|
106
|
+
* summer_get_project_context so the agent can INTENTIONALLY follow a project
|
|
107
|
+
* switch (the deliberate escape hatch after an identity_mismatch). Returns the
|
|
108
|
+
* new bound hash. Reads carry no identity, so this always reaches the engine
|
|
109
|
+
* even when a mutation would be rejected.
|
|
110
|
+
*/
|
|
111
|
+
async rebind() {
|
|
112
|
+
const health = await checkEngineHealth(this.port);
|
|
113
|
+
if (health) {
|
|
114
|
+
this.boundProjectIdHash = health.projectIdHash;
|
|
115
|
+
}
|
|
116
|
+
return this.boundProjectIdHash;
|
|
117
|
+
}
|
|
118
|
+
/** Identity to attach to a MUTATING command's options so the engine can reject
|
|
119
|
+
* a wrong-project write. Empty when unbound (engine then skips the check). */
|
|
120
|
+
identityOptions() {
|
|
121
|
+
return this.boundProjectIdHash ? { projectIdHash: this.boundProjectIdHash } : {};
|
|
81
122
|
}
|
|
82
123
|
async request(method, path, body) {
|
|
83
124
|
const url = `http://127.0.0.1:${this.port}${path}`;
|
|
@@ -150,7 +191,11 @@ export class EngineApiClient {
|
|
|
150
191
|
// Ops may include long-running work (ImportFromUrlBatch, GitCommit); the
|
|
151
192
|
// engine keeps it "running" and we poll. 120s budget. Resolves legacy-200 or
|
|
152
193
|
// async-202 (Block E).
|
|
153
|
-
|
|
194
|
+
// Attach the bound project identity so the engine rejects a write aimed at a
|
|
195
|
+
// different project (identity_mismatch, atomic — before any op applies).
|
|
196
|
+
const merged = { ...this.identityOptions(), ...(options ?? {}) };
|
|
197
|
+
const body = Object.keys(merged).length ? { ops, options: merged } : { ops };
|
|
198
|
+
return this._requestQueued("POST", "/api/ops", body, 120_000);
|
|
154
199
|
}
|
|
155
200
|
async getSceneState() {
|
|
156
201
|
return this.request("GET", "/api/state/scene");
|
|
@@ -174,11 +219,14 @@ export class EngineApiClient {
|
|
|
174
219
|
// Cold-load on large projects can take 25-40s. 60s budget.
|
|
175
220
|
// The engine reads play params from body.options (tool_net_thread.cpp:503),
|
|
176
221
|
// and the play handler reads options["scene"] — a top-level { scene } is
|
|
177
|
-
// dropped, so the scene MUST be nested inside options.
|
|
178
|
-
|
|
222
|
+
// dropped, so the scene MUST be nested inside options. The bound identity
|
|
223
|
+
// rides in the same options dict so play is refused on a mismatched project.
|
|
224
|
+
const options = { ...this.identityOptions(), ...(scene ? { scene } : {}) };
|
|
225
|
+
return this._requestQueued("POST", "/api/play", Object.keys(options).length ? { options } : {}, 60_000);
|
|
179
226
|
}
|
|
180
227
|
async stop() {
|
|
181
|
-
|
|
228
|
+
const options = this.identityOptions();
|
|
229
|
+
return this._requestQueued("POST", "/api/stop", Object.keys(options).length ? { options } : undefined, 15_000);
|
|
182
230
|
}
|
|
183
231
|
async readFile(path, maxBytes) {
|
|
184
232
|
const params = new URLSearchParams({ path });
|
|
@@ -211,6 +259,18 @@ export class EngineApiClient {
|
|
|
211
259
|
// The terminal result is the apply dict; the image rides as base64 inside it
|
|
212
260
|
// (no raw-binary channel). Legacy/dormant engines answer 200 synchronously
|
|
213
261
|
// with the same payload shape — _requestQueued resolves both.
|
|
262
|
+
//
|
|
263
|
+
// Game capture over local HTTP structurally 409s today with a STRUCTURED
|
|
264
|
+
// reason (`failure_reason:"unsupported_transport"`, `bridge_required:true`,
|
|
265
|
+
// tool_net_thread.cpp:495-503). Detect that specific shape and return it
|
|
266
|
+
// verbatim so the tool can give an honest message — do NOT hardcode "game
|
|
267
|
+
// always fails": once the engine answers 200/202 (P4.4), the normal path
|
|
268
|
+
// below just works.
|
|
269
|
+
if (kind === "game") {
|
|
270
|
+
const bridge = await this._detectBridgeRequired(`/api/snapshot/${kind}`);
|
|
271
|
+
if (bridge)
|
|
272
|
+
return bridge;
|
|
273
|
+
}
|
|
214
274
|
let response;
|
|
215
275
|
try {
|
|
216
276
|
response = await this._requestQueued("GET", `/api/snapshot/${kind}`, undefined, 60_000);
|
|
@@ -218,11 +278,16 @@ export class EngineApiClient {
|
|
|
218
278
|
catch (err) {
|
|
219
279
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
220
280
|
}
|
|
281
|
+
return this._parseSnapshotResponse(response, kind);
|
|
282
|
+
}
|
|
283
|
+
/** Shared: turn an ops/snapshot response envelope into an EngineSnapshot.
|
|
284
|
+
* Used by viewport/game snapshots and by scenePreview. */
|
|
285
|
+
async _parseSnapshotResponse(response, kind) {
|
|
221
286
|
const payload = findSnapshotPayload(response);
|
|
222
287
|
if (!payload) {
|
|
223
288
|
return {
|
|
224
289
|
ok: false,
|
|
225
|
-
error: "Snapshot response did not include
|
|
290
|
+
error: "Snapshot response did not include an image payload.",
|
|
226
291
|
};
|
|
227
292
|
}
|
|
228
293
|
const base64 = stringFrom(payload.base64) ?? stringFrom(payload.image_base64);
|
|
@@ -230,16 +295,40 @@ export class EngineApiClient {
|
|
|
230
295
|
return {
|
|
231
296
|
ok: false,
|
|
232
297
|
error: stringFrom(payload.error) ?? "Snapshot response did not include image data.",
|
|
298
|
+
failureReason: stringFrom(payload.failure_reason),
|
|
299
|
+
sceneHasCamera: boolFrom(payload.scene_has_camera),
|
|
300
|
+
sceneHadLight: boolFrom(payload.scene_had_light),
|
|
301
|
+
usedSyntheticCamera: boolFrom(payload.used_synthetic_camera),
|
|
233
302
|
metadata: withoutImageData(payload),
|
|
234
303
|
};
|
|
235
304
|
}
|
|
236
305
|
const { format, mime, ext } = snapshotFormat(payload);
|
|
237
306
|
const buffer = Buffer.from(base64, "base64");
|
|
238
307
|
const localPath = await this.writeSnapshotFile(kind, buffer, ext);
|
|
308
|
+
// Client-side identity drift check (item 4). The engine's identity guard only
|
|
309
|
+
// acts on options.projectIdHash inside a queued command; a GET snapshot from
|
|
310
|
+
// `fetch` carries no reliable body, so we cannot count on the engine to reject
|
|
311
|
+
// a drifted read. Re-read health and compare to the bound hash — if the engine
|
|
312
|
+
// has switched projects since we bound, this frame may be from the WRONG
|
|
313
|
+
// project. Best-effort: a failed health read never blocks the (already
|
|
314
|
+
// captured) image.
|
|
315
|
+
let projectMismatch;
|
|
316
|
+
if (this.boundProjectIdHash) {
|
|
317
|
+
try {
|
|
318
|
+
const health = await checkEngineHealth(this.port);
|
|
319
|
+
if (health?.projectIdHash && health.projectIdHash !== this.boundProjectIdHash) {
|
|
320
|
+
projectMismatch = true;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// ignore — never fail a capture on a transient health read
|
|
325
|
+
}
|
|
326
|
+
}
|
|
239
327
|
return {
|
|
240
328
|
ok: payload.ok !== false,
|
|
241
329
|
localPath,
|
|
242
330
|
path: localPath,
|
|
331
|
+
base64,
|
|
243
332
|
width: numberFrom(payload.width),
|
|
244
333
|
height: numberFrom(payload.height),
|
|
245
334
|
format,
|
|
@@ -248,16 +337,110 @@ export class EngineApiClient {
|
|
|
248
337
|
op: stringFrom(payload.op),
|
|
249
338
|
provenance: payload.provenance,
|
|
250
339
|
bytes: buffer.byteLength,
|
|
340
|
+
sceneHasCamera: boolFrom(payload.scene_has_camera),
|
|
341
|
+
sceneHadLight: boolFrom(payload.scene_had_light),
|
|
342
|
+
usedSyntheticCamera: boolFrom(payload.used_synthetic_camera),
|
|
343
|
+
projectMismatch,
|
|
251
344
|
metadata: withoutImageData(payload),
|
|
252
345
|
};
|
|
253
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Probe /api/snapshot/game for the structural 409 `bridge_required` shape
|
|
349
|
+
* (tool_net_thread.cpp:495-503). Returns a structured failure only when the
|
|
350
|
+
* engine actually answers 409 with `bridge_required` / `unsupported_transport`;
|
|
351
|
+
* ANY other status (200/202/other) returns null so the caller proceeds to the
|
|
352
|
+
* normal queued path — so the moment the engine starts answering over HTTP
|
|
353
|
+
* (P4.4), game capture just works with no code change here.
|
|
354
|
+
*/
|
|
355
|
+
async _detectBridgeRequired(path) {
|
|
356
|
+
let res;
|
|
357
|
+
try {
|
|
358
|
+
res = await this._fetchRaw("GET", path, undefined, 15_000);
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// Transport error — let the normal path surface it uniformly.
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
if (res.status !== 409)
|
|
365
|
+
return null;
|
|
366
|
+
const body = (await res.json().catch(() => ({})));
|
|
367
|
+
const reason = stringFrom(body.failure_reason);
|
|
368
|
+
const bridge = body.bridge_required === true;
|
|
369
|
+
if (!bridge && reason !== "unsupported_transport") {
|
|
370
|
+
// A 409 we don't recognize — surface it as a plain error rather than
|
|
371
|
+
// silently claiming "bridge required".
|
|
372
|
+
return {
|
|
373
|
+
ok: false,
|
|
374
|
+
error: stringFrom(body.error) ?? "Engine returned 409 for game snapshot.",
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
ok: false,
|
|
379
|
+
failureReason: reason ?? "bridge_required",
|
|
380
|
+
error: stringFrom(body.error) ?? "Game snapshots require the desktop bridge async transport.",
|
|
381
|
+
};
|
|
382
|
+
}
|
|
254
383
|
async viewportSnapshot() {
|
|
255
384
|
return this.snapshot("viewport");
|
|
256
385
|
}
|
|
257
386
|
async gameSnapshot() {
|
|
258
387
|
return this.snapshot("game");
|
|
259
388
|
}
|
|
389
|
+
/**
|
|
390
|
+
* Offscreen scene render via the `ScenePreview` op over /api/ops (no game
|
|
391
|
+
* boot; physics/animations are static). Mirrors the web previewScene op input
|
|
392
|
+
* (snapshot-tools.ts): { op:'ScenePreview', scene_path?, framing?, size?,
|
|
393
|
+
* node_path? } — scene_path optional, engine defaults to the open scene. The
|
|
394
|
+
* result carries image_base64 + mime (+ width/height) plus the P4.3 confession
|
|
395
|
+
* fields (scene_has_camera / scene_had_light / used_synthetic_camera).
|
|
396
|
+
*/
|
|
397
|
+
async scenePreview(input) {
|
|
398
|
+
const opInput = { op: "ScenePreview" };
|
|
399
|
+
const trimmed = input?.scenePath?.trim();
|
|
400
|
+
if (trimmed && trimmed !== "." && trimmed !== "./")
|
|
401
|
+
opInput.scene_path = trimmed;
|
|
402
|
+
if (input?.framing)
|
|
403
|
+
opInput.framing = input.framing;
|
|
404
|
+
if (input?.size)
|
|
405
|
+
opInput.size = input.size;
|
|
406
|
+
if (input?.nodePath)
|
|
407
|
+
opInput.node_path = input.nodePath;
|
|
408
|
+
let response;
|
|
409
|
+
try {
|
|
410
|
+
// executeOps stamps the bound identity so a drifted project is rejected
|
|
411
|
+
// (identity_mismatch) before rendering — ScenePreview reads no game state
|
|
412
|
+
// but still targets a project's resources.
|
|
413
|
+
response = await this.executeOps([opInput]);
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
417
|
+
}
|
|
418
|
+
return this._parseSnapshotResponse(response, "scene");
|
|
419
|
+
}
|
|
260
420
|
getPort() {
|
|
261
421
|
return this.port;
|
|
262
422
|
}
|
|
423
|
+
/**
|
|
424
|
+
* Cheap drift probe for the MCP client cache. The engine mints a fresh
|
|
425
|
+
* api-token on every launch (local_api_server.cpp::_generate_api_token) and can
|
|
426
|
+
* bind a different port (tool_net_thread.cpp::start increments 6550..6565 when
|
|
427
|
+
* the old socket lingers), so a client built before an engine restart holds
|
|
428
|
+
* dead credentials. Re-read the on-disk creds and report whether they no longer
|
|
429
|
+
* match this client's snapshot.
|
|
430
|
+
*
|
|
431
|
+
* Empty / unreadable creds (engine mid-write, or just closed) are treated as
|
|
432
|
+
* "no drift" so a transient read never thrashes the cache — the live request
|
|
433
|
+
* will fail and trigger a reconnect+retry if the client really is stale.
|
|
434
|
+
*/
|
|
435
|
+
async credentialsChanged() {
|
|
436
|
+
try {
|
|
437
|
+
const [port, token] = await Promise.all([getApiPort(), getApiToken()]);
|
|
438
|
+
if (!token)
|
|
439
|
+
return false;
|
|
440
|
+
return port !== this.port || token !== this.token;
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
263
446
|
}
|
|
@@ -308,10 +308,14 @@ function buildMcpToolPlan(mode, target, assetPolicy, verification, goal = "") {
|
|
|
308
308
|
assets.push("summer_search_assets", "summer_import_asset");
|
|
309
309
|
}
|
|
310
310
|
if (verification !== "none") {
|
|
311
|
-
|
|
311
|
+
// Rung 1-2 of the ladder: does it compile / does it look right.
|
|
312
|
+
verify.push("summer_get_script_errors", "summer_get_diagnostics", "summer_screenshot");
|
|
312
313
|
}
|
|
313
314
|
if (verification === "full") {
|
|
314
|
-
|
|
315
|
+
// Rung 3: run it and read runtime errors. Ordered as the composed loop —
|
|
316
|
+
// clear -> play -> read runtime errors -> look at the live frame -> stop.
|
|
317
|
+
// (There is no single "verify" tool; the agent composes these.)
|
|
318
|
+
verify.push("summer_clear_console", "summer_play", "summer_get_debugger_errors", "summer_screenshot", "summer_stop");
|
|
315
319
|
}
|
|
316
320
|
return { start, mutate, assets, verify };
|
|
317
321
|
}
|