@womp/spark-agent 3.0.3 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +59 -33
  2. package/dist/agent-run-cli.d.ts +2 -0
  3. package/dist/agent-run-cli.js +30 -0
  4. package/dist/agent-run-input.d.ts +11 -0
  5. package/dist/agent-run-input.js +59 -0
  6. package/dist/agent-run.d.ts +81 -0
  7. package/dist/agent-run.js +234 -0
  8. package/dist/agent-tools.js +3 -2
  9. package/dist/agent.d.ts +0 -2
  10. package/dist/agent.js +4 -36
  11. package/dist/capabilities/account.js +15 -15
  12. package/dist/capabilities/asset-imports.js +50 -50
  13. package/dist/capabilities/assets.js +10 -10
  14. package/dist/capabilities/flow.js +15 -15
  15. package/dist/capabilities/geometry.js +13 -14
  16. package/dist/capabilities/knowledge.js +4 -4
  17. package/dist/capabilities/openscad-instructions.d.ts +1 -1
  18. package/dist/capabilities/openscad-instructions.js +80 -80
  19. package/dist/capabilities/openscad.js +2 -2
  20. package/dist/capabilities/scene.d.ts +11 -3
  21. package/dist/capabilities/scene.js +93 -27
  22. package/dist/capabilities.d.ts +1 -2
  23. package/dist/capabilities.js +0 -1
  24. package/dist/client.d.ts +5 -0
  25. package/dist/client.js +22 -0
  26. package/dist/coordinates.d.ts +0 -7
  27. package/dist/coordinates.js +8 -47
  28. package/dist/generated/scene-declarations.d.ts +1 -1
  29. package/dist/generated/scene-declarations.js +1 -1
  30. package/dist/geometry/schemas/flowir-dialect.json +2 -2
  31. package/dist/index.d.ts +0 -1
  32. package/dist/index.js +0 -1
  33. package/dist/models.d.ts +7 -0
  34. package/dist/models.js +10 -0
  35. package/dist/prompt.d.ts +1 -1
  36. package/dist/prompt.js +53 -1
  37. package/dist/request-limits.d.ts +1 -0
  38. package/dist/request-limits.js +1 -0
  39. package/dist/run-metrics.d.ts +15 -0
  40. package/dist/run-metrics.js +30 -0
  41. package/dist/runtime.d.ts +3 -5
  42. package/dist/scene-metadata.js +1 -1
  43. package/dist/sdk-scene-host.d.ts +11 -13
  44. package/dist/sdk-scene-host.js +1 -0
  45. package/dist/sdk-scene-screenshot.d.ts +4 -6
  46. package/dist/sdk-scene-screenshot.js +14 -15
  47. package/dist/server/artifacts.js +1 -5
  48. package/dist/server/executor.d.ts +0 -1
  49. package/dist/server/executor.js +0 -1
  50. package/dist/server/express.js +17 -5
  51. package/dist/server/scene-actions.d.ts +2 -0
  52. package/dist/server/scene-actions.js +20 -0
  53. package/dist/server/service.d.ts +2 -0
  54. package/dist/server/service.js +10 -0
  55. package/dist/server/sqlite-store.d.ts +3 -1
  56. package/dist/server/sqlite-store.js +26 -3
  57. package/dist/server/standalone.d.ts +2 -0
  58. package/dist/server/standalone.js +54 -3
  59. package/dist/server/store.d.ts +3 -1
  60. package/dist/server/store.js +21 -1
  61. package/dist/server/types.d.ts +5 -0
  62. package/dist/simple-bench-cli.d.ts +2 -0
  63. package/dist/simple-bench-cli.js +64 -0
  64. package/dist/simple-bench-prompts.d.ts +28 -0
  65. package/dist/simple-bench-prompts.js +11 -0
  66. package/dist/simple-bench.d.ts +15 -0
  67. package/dist/simple-bench.js +175 -0
  68. package/dist/types.d.ts +4 -13
  69. package/dist/ui.d.ts +3 -8
  70. package/dist/womp-scene-api.d.ts +14 -3
  71. package/dist/womp-scene-api.js +98 -75
  72. package/package.json +7 -3
  73. package/dist/capabilities/health.d.ts +0 -9
  74. package/dist/capabilities/health.js +0 -27
  75. package/dist/context.d.ts +0 -15
  76. package/dist/context.js +0 -266
  77. package/dist/generated/health-declarations.d.ts +0 -1
  78. package/dist/generated/health-declarations.js +0 -3
@@ -5,7 +5,7 @@ import express from "express";
5
5
  import { getSparkModel, SPARK_MODELS } from "../models.js";
6
6
  import { KakapoAPI, KakapoRpcError } from "@womp/kakapo-sdk";
7
7
  import { captureSdkSceneScreenshot } from "../sdk-scene-screenshot.js";
8
- import { composeCapabilities, createAccountCapability, createGeometryCapability, createOpenScadCapability, createSceneCapability, createSceneHealthCapability, } from "../capabilities.js";
8
+ import { composeCapabilities, createAccountCapability, createGeometryCapability, createOpenScadCapability, createSceneCapability, } from "../capabilities.js";
9
9
  import { loadDemoSceneMaterials } from "./demo-scene-materials.js";
10
10
  import { createDemoAccountHost } from "./demo-account.js";
11
11
  import { createSdkSceneEditor, createSdkSceneHost, } from "../sdk-scene-host.js";
@@ -20,6 +20,7 @@ import { createSparkRunExecutor } from "./executor.js";
20
20
  import { createSparkAgentRouter } from "./express.js";
21
21
  import { SparkAgentService } from "./service.js";
22
22
  import { SqliteSparkStore } from "./sqlite-store.js";
23
+ import { clearSceneContents } from "./scene-actions.js";
23
24
  export async function checkStandaloneServices(api) {
24
25
  const [fonts, openScad] = await Promise.allSettled([
25
26
  api.listFonts(),
@@ -99,6 +100,7 @@ export async function createStandaloneSparkAgentApp(options) {
99
100
  const sceneHost = createSdkSceneHost({
100
101
  ...editorOptions,
101
102
  editor,
103
+ health: createSdkSceneHealthHost(editor, onHealth),
102
104
  onSceneCommitted: async (_operation, api) => {
103
105
  const sceneHealth = await readStableSdkSceneHealth(api, getSdkSceneAreaIds(api), context.signal);
104
106
  onHealth(sceneHealth);
@@ -108,13 +110,13 @@ export async function createStandaloneSparkAgentApp(options) {
108
110
  screenshot: async (settings, invocation) => {
109
111
  invocation.signal.throwIfAborted();
110
112
  const image = await captureSdkSceneScreenshot(api, settings);
113
+ options.onScreenshot?.(context.runId);
111
114
  invocation.signal.throwIfAborted();
112
115
  return artifactStore.write(context.ownerId, image);
113
116
  },
114
117
  });
115
118
  const capabilityBindings = {
116
119
  scene: createSceneCapability(sceneHost),
117
- health: createSceneHealthCapability(createSdkSceneHealthHost(editor, onHealth)),
118
120
  geometry: createGeometryCapability(createSdkGeometryHost({
119
121
  editor,
120
122
  repairs,
@@ -208,7 +210,21 @@ export async function createStandaloneSparkAgentApp(options) {
208
210
  };
209
211
  },
210
212
  });
211
- const service = new SparkAgentService({ store, executeRun });
213
+ let clearingScene = false;
214
+ let activeRuns = 0;
215
+ const service = new SparkAgentService({ store, executeRun: async (context) => {
216
+ if (clearingScene)
217
+ throw new Error("The scene is being cleared. Try again when it finishes.");
218
+ activeRuns++;
219
+ try {
220
+ const run = await executeRun(context);
221
+ return { ...run, result: run.result.finally(() => { activeRuns--; }) };
222
+ }
223
+ catch (error) {
224
+ activeRuns--;
225
+ throw error;
226
+ }
227
+ } });
212
228
  try {
213
229
  for (const run of await store.listInterruptedRuns())
214
230
  await service.recoverInterruptedRun(run.id);
@@ -222,6 +238,41 @@ export async function createStandaloneSparkAgentApp(options) {
222
238
  app.get("/health", (_request, response) => {
223
239
  response.json({ status: "ok" });
224
240
  });
241
+ app.post(`${apiBasePath}/scene/clear`, async (request, response) => {
242
+ // Custom headers require a CORS preflight, preventing cross-site form submissions.
243
+ if (request.get("X-Spark-Action") !== "clear-scene") {
244
+ response.status(403).json({ error: "Use the standalone UI to clear the scene." });
245
+ return;
246
+ }
247
+ if (clearingScene || activeRuns > 0) {
248
+ response.status(409).json({ error: "Stop the active agent run before clearing the scene." });
249
+ return;
250
+ }
251
+ clearingScene = true;
252
+ const api = new KakapoAPI({ url: options.kakapoUrl, requestTimeoutMs: 30_000 });
253
+ try {
254
+ await api.connect();
255
+ const { save } = await api.editSceneAndSave(clearSceneContents);
256
+ const warning = save.status === "failed" || save.status === "unknown"
257
+ ? `Scene cleared, but saving ${save.status === "failed" ? "failed" : "could not be confirmed"}: ${save.error} Do not repeat the clear operation.`
258
+ : undefined;
259
+ response.json({ ok: true, warning });
260
+ }
261
+ catch (error) {
262
+ response.status(500).json({ error: error instanceof Error ? error.message : "Could not clear the scene." });
263
+ }
264
+ finally {
265
+ api.disconnect();
266
+ clearingScene = false;
267
+ }
268
+ });
269
+ app.use(apiBasePath, (request, response, next) => {
270
+ if (clearingScene && request.method === "POST" && request.path.endsWith("/messages")) {
271
+ response.status(409).json({ error: "The scene is being cleared. Try again when it finishes." });
272
+ return;
273
+ }
274
+ next();
275
+ });
225
276
  app.use(apiBasePath, createSparkAgentRouter({ service, artifactStore }));
226
277
  return Object.assign(app, {
227
278
  async dispose() {
@@ -1,4 +1,4 @@
1
- import type { SparkAgentStore, SparkConversation, SparkConversationSummary, SparkRun } from "./types.js";
1
+ import type { SparkAgentStore, SparkConversation, SparkConversationSummary, SparkRun, SparkRunSettings } from "./types.js";
2
2
  import type { StoredSparkChunk } from "../ui.js";
3
3
  export declare class MemorySparkAgentStore implements SparkAgentStore {
4
4
  private readonly conversations;
@@ -8,6 +8,8 @@ export declare class MemorySparkAgentStore implements SparkAgentStore {
8
8
  getConversation(id: string): Promise<SparkConversation | undefined>;
9
9
  listConversations(ownerId: string): Promise<SparkConversationSummary[]>;
10
10
  saveConversation(conversation: SparkConversation): Promise<void>;
11
+ saveConversationSettings(id: string, settings: SparkRunSettings): Promise<void>;
12
+ deleteConversation(id: string): Promise<boolean>;
11
13
  createRun(run: SparkRun): Promise<void>;
12
14
  getRun(id: string): Promise<SparkRun | undefined>;
13
15
  saveRun(run: SparkRun): Promise<void>;
@@ -28,7 +28,27 @@ export class MemorySparkAgentStore {
28
28
  if (this.conversations.has(conversation.id) === false) {
29
29
  throw new Error(`Conversation ${conversation.id} does not exist.`);
30
30
  }
31
- this.conversations.set(conversation.id, copy(conversation));
31
+ const { settings: _settings, ...history } = conversation;
32
+ const settings = this.conversations.get(conversation.id).settings;
33
+ this.conversations.set(conversation.id, copy({ ...history, ...(settings ? { settings } : {}) }));
34
+ }
35
+ async saveConversationSettings(id, settings) {
36
+ const conversation = this.conversations.get(id);
37
+ if (conversation === undefined)
38
+ throw new Error(`Conversation ${id} does not exist.`);
39
+ conversation.settings = copy(settings);
40
+ }
41
+ async deleteConversation(id) {
42
+ if (this.conversations.get(id)?.activeRunId !== undefined)
43
+ return false;
44
+ for (const [runId, run] of this.runs) {
45
+ if (run.conversationId !== id)
46
+ continue;
47
+ this.chunks.delete(runId);
48
+ this.runs.delete(runId);
49
+ }
50
+ this.conversations.delete(id);
51
+ return true;
32
52
  }
33
53
  async createRun(run) {
34
54
  if (this.runs.has(run.id))
@@ -6,6 +6,7 @@ export type SparkConversation = {
6
6
  ownerId: string;
7
7
  title: string;
8
8
  messages: SparkUIMessage[];
9
+ settings?: SparkRunSettings;
9
10
  activeRunId?: string;
10
11
  createdAt: string;
11
12
  updatedAt: string;
@@ -45,6 +46,10 @@ export interface SparkAgentStore {
45
46
  getConversation(id: string): Promise<SparkConversation | undefined>;
46
47
  listConversations(ownerId: string): Promise<SparkConversationSummary[]>;
47
48
  saveConversation(conversation: SparkConversation): Promise<void>;
49
+ /** Update preferences without replacing history; saveConversation must preserve them. */
50
+ saveConversationSettings(id: string, settings: SparkRunSettings): Promise<void>;
51
+ /** Atomically delete an idle conversation and its runs/chunks; false if active. */
52
+ deleteConversation(id: string): Promise<boolean>;
48
53
  createRun(run: SparkRun): Promise<void>;
49
54
  getRun(id: string): Promise<SparkRun | undefined>;
50
55
  saveRun(run: SparkRun): Promise<void>;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import { loadEnvFile } from "node:process";
4
+ import { parseArgs } from "node:util";
5
+ import path from "node:path";
6
+ import { SPARK_MODELS } from "./models.js";
7
+ import { BENCH_PROMPTS } from "./simple-bench-prompts.js";
8
+ import { runSimpleBench } from "./simple-bench.js";
9
+ async function main() {
10
+ const { values } = parseArgs({ options: {
11
+ model: { type: "string", default: "gpt-6-astra" }, reasoning: { type: "string", default: "high" },
12
+ "kakapo-url": { type: "string" }, output: { type: "string" }, only: { type: "string" },
13
+ "timeout-minutes": { type: "string", default: "30" },
14
+ help: { type: "boolean" },
15
+ } });
16
+ if (values.help) {
17
+ console.log(`simple-bench [options]
18
+
19
+ Runs ${BENCH_PROMPTS.length} prompts sequentially against a dedicated Kakapo scene.
20
+ WARNING: removes all objects/materials before every prompt and makes paid model calls.
21
+ Stop other agents/editors using this Kakapo instance first.
22
+
23
+ --model <id> Default: gpt-6-astra. Models: ${SPARK_MODELS.map((model) => model.id).join(", ")}
24
+ --reasoning <effort> Default: high
25
+ --kakapo-url <url> Default: KAKAPO_URL from .env, or ws://127.0.0.1:5502
26
+ --output <directory> New directory only; default: .spark/bench/<timestamp>
27
+ --only <id> ${BENCH_PROMPTS.map((prompt) => prompt.id).join(", ")}
28
+ --timeout-minutes <n> Per-case timeout; default: 30`);
29
+ return;
30
+ }
31
+ const model = SPARK_MODELS.find((item) => item.id === values.model);
32
+ if (!model)
33
+ throw new Error("Select a supported model using --model. See --help.");
34
+ const reasoning = values.reasoning;
35
+ if (!model.reasoningEfforts.includes(reasoning))
36
+ throw new Error("Unsupported reasoning effort for this model.");
37
+ const timeoutMs = Number(values["timeout-minutes"]) * 60_000;
38
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
39
+ throw new Error("Invalid timeout.");
40
+ if (existsSync(".env"))
41
+ loadEnvFile();
42
+ const key = { openai: "OPENAI_API_KEY", anthropic: "ANTHROPIC_API_KEY", google: "GEMINI_API_KEY" }[model.provider];
43
+ if (!process.env[key]?.trim())
44
+ throw new Error(`${key} is required in the environment or .env.`);
45
+ const controller = new AbortController();
46
+ const stop = () => controller.abort(new Error("Benchmark interrupted."));
47
+ process.once("SIGINT", stop);
48
+ process.once("SIGTERM", stop);
49
+ const output = path.resolve(values.output ?? `.spark/bench/${new Date().toISOString().replaceAll(":", "-")}`);
50
+ console.log(`Results: ${output}`);
51
+ try {
52
+ if (!await runSimpleBench({
53
+ settings: { modelId: model.id, reasoningEffort: reasoning },
54
+ kakapoUrl: values["kakapo-url"] ?? process.env.KAKAPO_URL ?? "ws://127.0.0.1:5502",
55
+ output, only: values.only, timeoutMs, signal: controller.signal,
56
+ }))
57
+ process.exitCode = 1;
58
+ }
59
+ finally {
60
+ process.removeListener("SIGINT", stop);
61
+ process.removeListener("SIGTERM", stop);
62
+ }
63
+ }
64
+ main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
@@ -0,0 +1,28 @@
1
+ export declare const BENCH_PROMPTS: readonly [{
2
+ readonly id: "armchair";
3
+ readonly prompt: "Make a mustard-yellow upholstered armchair, 85 cm wide, 90 cm deep, and 85 cm tall. Give it a thick square seat cushion, a slightly reclined backrest, rounded armrests, and four short tapered wooden legs.";
4
+ }, {
5
+ readonly id: "desk-lamp";
6
+ readonly prompt: "Make a matte-black desk lamp, 45 cm tall, with a circular base 18 cm in diameter, two slender articulated arms, and a conical shade 15 cm wide. Give the shade a white interior and a warm-white bulb.";
7
+ }, {
8
+ readonly id: "coffee-mug";
9
+ readonly prompt: "Make a teal ceramic mug, 10 cm tall and 9 cm in diameter, with a gently rounded base, an open top, a thick rim, and a large C-shaped handle. Give it a cream-colored interior.";
10
+ }, {
11
+ readonly id: "bookshelf";
12
+ readonly prompt: "Make a light-oak bookshelf, 180 cm tall, 90 cm wide, and 30 cm deep. Give it five evenly spaced open compartments, thick rectangular side panels, a solid back panel, and a recessed base.";
13
+ }, {
14
+ readonly id: "backpack";
15
+ readonly prompt: "Make a navy-blue backpack, 45 cm tall, 30 cm wide, and 18 cm deep. Give it a rounded rectangular body, a large front zipper pocket, two padded shoulder straps, a top carry handle, and light-gray zipper pulls.";
16
+ }, {
17
+ readonly id: "toaster";
18
+ readonly prompt: "Make a cream-colored two-slot toaster, 28 cm wide, 18 cm deep, and 20 cm tall. Give it rounded corners, a brushed-metal top, two long bread slots, a silver side lever, and a circular browning dial.";
19
+ }, {
20
+ readonly id: "plant-pot";
21
+ readonly prompt: "Make a terracotta plant pot, 25 cm tall, tapering from a 30 cm opening to a 20 cm base. Give it a thick rolled rim and dark soil containing a small plant with six broad green leaves on short stems.";
22
+ }, {
23
+ readonly id: "suitcase";
24
+ readonly prompt: "Make a burgundy hard-shell suitcase, 65 cm tall, 42 cm wide, and 26 cm deep. Give it rounded corners, five vertical ribs on each broad face, four black spinner wheels, a silver telescoping handle, and a black side carry handle.";
25
+ }, {
26
+ readonly id: "dining-table";
27
+ readonly prompt: "Make a rectangular walnut dining table, 160 cm long, 90 cm wide, and 75 cm tall. Give it a 4 cm thick tabletop with softly rounded corners, four square tapered legs, and a recessed wooden apron beneath the top.";
28
+ }];
@@ -0,0 +1,11 @@
1
+ export const BENCH_PROMPTS = [
2
+ { id: "armchair", prompt: "Make a mustard-yellow upholstered armchair, 85 cm wide, 90 cm deep, and 85 cm tall. Give it a thick square seat cushion, a slightly reclined backrest, rounded armrests, and four short tapered wooden legs." },
3
+ { id: "desk-lamp", prompt: "Make a matte-black desk lamp, 45 cm tall, with a circular base 18 cm in diameter, two slender articulated arms, and a conical shade 15 cm wide. Give the shade a white interior and a warm-white bulb." },
4
+ { id: "coffee-mug", prompt: "Make a teal ceramic mug, 10 cm tall and 9 cm in diameter, with a gently rounded base, an open top, a thick rim, and a large C-shaped handle. Give it a cream-colored interior." },
5
+ { id: "bookshelf", prompt: "Make a light-oak bookshelf, 180 cm tall, 90 cm wide, and 30 cm deep. Give it five evenly spaced open compartments, thick rectangular side panels, a solid back panel, and a recessed base." },
6
+ { id: "backpack", prompt: "Make a navy-blue backpack, 45 cm tall, 30 cm wide, and 18 cm deep. Give it a rounded rectangular body, a large front zipper pocket, two padded shoulder straps, a top carry handle, and light-gray zipper pulls." },
7
+ { id: "toaster", prompt: "Make a cream-colored two-slot toaster, 28 cm wide, 18 cm deep, and 20 cm tall. Give it rounded corners, a brushed-metal top, two long bread slots, a silver side lever, and a circular browning dial." },
8
+ { id: "plant-pot", prompt: "Make a terracotta plant pot, 25 cm tall, tapering from a 30 cm opening to a 20 cm base. Give it a thick rolled rim and dark soil containing a small plant with six broad green leaves on short stems." },
9
+ { id: "suitcase", prompt: "Make a burgundy hard-shell suitcase, 65 cm tall, 42 cm wide, and 26 cm deep. Give it rounded corners, five vertical ribs on each broad face, four black spinner wheels, a silver telescoping handle, and a black side carry handle." },
10
+ { id: "dining-table", prompt: "Make a rectangular walnut dining table, 160 cm long, 90 cm wide, and 75 cm tall. Give it a 4 cm thick tabletop with softly rounded corners, four square tapered legs, and a recessed wooden apron beneath the top." },
11
+ ];
@@ -0,0 +1,15 @@
1
+ import type { SparkRunSettings } from "./server/types.js";
2
+ export { createBenchMetrics } from "./run-metrics.js";
3
+ type BenchCost = {
4
+ costUsd: number | null;
5
+ costStatus: "estimated" | "partial" | "unavailable";
6
+ };
7
+ export declare function totalBenchCost(results: readonly BenchCost[]): BenchCost;
8
+ export declare function runSimpleBench(options: {
9
+ settings: SparkRunSettings;
10
+ kakapoUrl: string;
11
+ output: string;
12
+ only?: string;
13
+ timeoutMs: number;
14
+ signal: AbortSignal;
15
+ }): Promise<boolean>;
@@ -0,0 +1,175 @@
1
+ import { once } from "node:events";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { KakapoAPI } from "@womp/kakapo-sdk";
5
+ import { z } from "zod";
6
+ import { SparkChatTransport } from "./client.js";
7
+ import { createStandaloneSparkAgentApp } from "./server/standalone.js";
8
+ import { BENCH_PROMPTS } from "./simple-bench-prompts.js";
9
+ import { createBenchMetrics } from "./run-metrics.js";
10
+ export { createBenchMetrics } from "./run-metrics.js";
11
+ export function totalBenchCost(results) {
12
+ const known = results.filter((result) => result.costUsd !== null);
13
+ return {
14
+ costUsd: known.length ? known.reduce((sum, result) => sum + result.costUsd, 0) : null,
15
+ costStatus: !known.length ? "unavailable" : results.some((result) => result.costStatus !== "estimated") ? "partial" : "estimated",
16
+ };
17
+ }
18
+ function costLabel(cost) {
19
+ return cost.costUsd === null ? "cost unavailable" : `$${cost.costUsd.toFixed(4)} USD (${cost.costStatus})`;
20
+ }
21
+ export async function runSimpleBench(options) {
22
+ const prompts = BENCH_PROMPTS.filter((item) => !options.only || item.id === options.only);
23
+ if (!prompts.length)
24
+ throw new Error("Unknown prompt ID. Use --help to list the available prompts.");
25
+ // Never reuse a result directory or overwrite a previous benchmark.
26
+ await mkdir(path.dirname(options.output), { recursive: true });
27
+ await mkdir(options.output);
28
+ const captures = new Map();
29
+ const app = await createStandaloneSparkAgentApp({
30
+ kakapoUrl: options.kakapoUrl,
31
+ storagePath: path.join(options.output, "history.sqlite"),
32
+ openAiApiKey: process.env.OPENAI_API_KEY,
33
+ anthropicApiKey: process.env.ANTHROPIC_API_KEY,
34
+ geminiApiKey: process.env.GEMINI_API_KEY,
35
+ onScreenshot: (runId) => captures.set(runId, (captures.get(runId) ?? 0) + 1),
36
+ });
37
+ const server = app.listen(0, "127.0.0.1");
38
+ const results = [];
39
+ const startedAt = new Date().toISOString();
40
+ const report = async () => {
41
+ await writeFile(path.join(options.output, "results.json"), JSON.stringify({
42
+ version: 1, startedAt, nodeVersion: process.version, settings: options.settings,
43
+ timeoutMs: options.timeoutMs, camera: "fitted-perspective-preserving-direction", plannedPrompts: prompts.map((item) => item.id),
44
+ totalCost: totalBenchCost(results), results,
45
+ }, null, 2));
46
+ const columns = ["id", "status", "steps", "readSkill", "executeJavaScript", "screenshots", "durationMs", "costUsd", "costStatus", "image", "error"];
47
+ const quote = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`;
48
+ const total = { id: "TOTAL", ...totalBenchCost(results) };
49
+ await writeFile(path.join(options.output, "results.csv"), [columns.join(","), ...[...results, total].map((result) => columns.map((key) => quote(result[key])).join(","))].join("\n"));
50
+ };
51
+ try {
52
+ await once(server, "listening");
53
+ const address = server.address();
54
+ if (!address || typeof address === "string")
55
+ throw new Error("Benchmark server did not start.");
56
+ const base = `http://127.0.0.1:${address.port}/agent`;
57
+ const transport = new SparkChatTransport({ api: base, getSettings: () => options.settings });
58
+ for (const item of prompts) {
59
+ options.signal.throwIfAborted();
60
+ const result = { ...item, status: "failed", steps: 0, readSkill: 0, executeJavaScript: 0, screenshots: 0, durationMs: null, costUsd: null, costStatus: "unavailable" };
61
+ const metrics = createBenchMetrics();
62
+ let started;
63
+ let ended;
64
+ let usageComplete = false;
65
+ const signal = AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs)]);
66
+ try {
67
+ console.log(`Running ${item.id}…`);
68
+ const clear = await fetch(`${base}/scene/clear`, { method: "POST", headers: { "X-Spark-Action": "clear-scene" }, signal });
69
+ const cleared = await clear.json();
70
+ if (!clear.ok || cleared.warning)
71
+ throw new Error(cleared.error ?? cleared.warning ?? "Scene cleanup failed.");
72
+ const conversation = await transport.createConversation({ title: `simple-bench: ${item.id}` });
73
+ result.conversationId = conversation.id;
74
+ started = performance.now();
75
+ const stream = await transport.sendMessages({
76
+ trigger: "submit-message", chatId: conversation.id, messageId: undefined,
77
+ messages: [{ id: crypto.randomUUID(), role: "user", parts: [{ type: "text", text: item.prompt }] }],
78
+ abortSignal: signal,
79
+ });
80
+ for await (const value of stream) {
81
+ const chunk = value;
82
+ metrics.accept(chunk);
83
+ if (chunk.type === "data-run-status") {
84
+ result.runId = chunk.data.runId;
85
+ result.status = chunk.data.status;
86
+ if (chunk.data.error)
87
+ result.error = chunk.data.error;
88
+ }
89
+ if (chunk.type === "error")
90
+ result.error = chunk.errorText;
91
+ }
92
+ ended = performance.now();
93
+ usageComplete = result.status === "completed";
94
+ const saved = await transport.getConversation(conversation.id);
95
+ await writeFile(path.join(options.output, `${item.id}.conversation.json`), JSON.stringify(saved, null, 2));
96
+ const reply = saved.messages.filter((message) => message.role === "assistant").at(-1);
97
+ await writeFile(path.join(options.output, `${item.id}.md`), reply?.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n\n") ?? "");
98
+ if (result.status !== "completed")
99
+ throw new Error(result.error ?? `Run ended with status ${result.status}.`);
100
+ const api = new KakapoAPI({ url: options.kakapoUrl, requestTimeoutMs: 30_000 });
101
+ try {
102
+ await api.connect();
103
+ const ids = api.listNodes({ parentId: 0 })
104
+ .filter((node) => node.kind !== "light" && (!("hidden" in node) || !node.hidden))
105
+ .map((node) => node.id);
106
+ const bounds = await api.getNodesBoundingBox(ids);
107
+ const viewport = await api.captureScreenshot({});
108
+ const { camera } = await api.refreshScene();
109
+ const direction = z.object({ x: z.number().finite(), y: z.number().finite(), z: z.number().finite() }).parse(camera.dir);
110
+ const directionLength = Math.hypot(direction.x, direction.y, direction.z);
111
+ if (directionLength === 0)
112
+ throw new Error("Cannot frame a scene with a zero camera direction.");
113
+ const target = {
114
+ x: (bounds.min.x + bounds.max.x) / 2,
115
+ y: (bounds.min.y + bounds.max.y) / 2,
116
+ z: (bounds.min.z + bounds.max.z) / 2,
117
+ };
118
+ const radius = Math.hypot(bounds.max.x - bounds.min.x, bounds.max.y - bounds.min.y, bounds.max.z - bounds.min.z) / 2;
119
+ const halfAngle = Math.atan(Math.tan(Math.PI / 6) * Math.min(1, viewport.height / viewport.width));
120
+ const offset = Math.max(0.1, radius * 1.1 / Math.sin(halfAngle)) / directionLength;
121
+ const image = await api.captureScreenshot({ camera: {
122
+ position: { x: target.x - direction.x * offset, y: target.y - direction.y * offset, z: target.z - direction.z * offset },
123
+ target, projection: "perspective", horizontalFovDegrees: 60,
124
+ }, timeoutMs: 30_000 });
125
+ result.image = `${item.id}.jpg`;
126
+ await writeFile(path.join(options.output, result.image), image.data);
127
+ }
128
+ finally {
129
+ api.disconnect();
130
+ }
131
+ }
132
+ catch (error) {
133
+ ended ??= performance.now();
134
+ result.error = error instanceof Error ? error.message : String(error);
135
+ result.status = signal.aborted ? "cancelled" : "failed";
136
+ if (result.conversationId) {
137
+ try {
138
+ await transport.cancelRun(result.conversationId);
139
+ }
140
+ catch (cancelError) {
141
+ result.error += ` Cancellation failed: ${String(cancelError)}`;
142
+ }
143
+ }
144
+ }
145
+ finally {
146
+ result.durationMs = started === undefined ? null : Math.round((ended ?? performance.now()) - started);
147
+ Object.assign(result, metrics.snapshot());
148
+ if (started === undefined) {
149
+ result.costUsd = 0;
150
+ result.costStatus = "estimated";
151
+ }
152
+ else if (!usageComplete && result.costUsd !== null)
153
+ result.costStatus = "partial";
154
+ result.screenshots = result.runId ? captures.get(result.runId) ?? 0 : 0;
155
+ results.push(result);
156
+ await report();
157
+ console.log(`${item.id}: ${result.status}, ${result.steps} steps, ${result.durationMs ?? 0} ms, ${costLabel(result)}`);
158
+ console.log(`Benchmark total: ${costLabel(totalBenchCost(results))}`);
159
+ }
160
+ // Do not clear again after an uncertain mutation, failed cleanup or cancelled run.
161
+ if (result.status !== "completed")
162
+ break;
163
+ }
164
+ return results.every((result) => result.status === "completed") && results.length === prompts.length;
165
+ }
166
+ finally {
167
+ try {
168
+ await app.dispose();
169
+ }
170
+ finally {
171
+ server.closeAllConnections();
172
+ await new Promise((resolve) => server.close(() => resolve()));
173
+ }
174
+ }
175
+ }
package/dist/types.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ReasoningEffort } from "./models.js";
2
- import type { ScreenshotView } from "@womp/kakapo-sdk";
2
+ import type { ScreenshotImage } from "@womp/kakapo-sdk";
3
+ import type { SceneScreenshotOptions } from "./capabilities/scene.js";
3
4
  export type SparkImage = {
4
5
  name: string;
5
6
  mediaType: string;
@@ -15,14 +16,7 @@ export type SparkRunInput = {
15
16
  reasoningEffort: ReasoningEffort;
16
17
  context: SparkContextMessage[];
17
18
  };
18
- export type ExecutionScreenshot = {
19
- data: Uint8Array;
20
- mediaType: "image/jpeg";
21
- width: number;
22
- height: number;
23
- view: ScreenshotView | "current";
24
- targetId: number;
25
- };
19
+ export type ExecutionScreenshot = ScreenshotImage;
26
20
  export type ExecutionResult = {
27
21
  ok: boolean;
28
22
  purpose: string;
@@ -70,8 +64,5 @@ export interface SparkRuntime {
70
64
  connect(): Promise<void>;
71
65
  disconnect(): void;
72
66
  execute(code: string, purpose: string): Promise<ExecutionResult>;
73
- screenshot(input: {
74
- targetId?: number;
75
- view?: ScreenshotView;
76
- }): Promise<ExecutionScreenshot>;
67
+ screenshot(input: SceneScreenshotOptions): Promise<ExecutionScreenshot>;
77
68
  }
package/dist/ui.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { InferUIMessageChunk, UIMessage } from "ai";
2
+ import type { SceneScreenshotOptions } from "./capabilities/scene.js";
2
3
  import { z } from "zod";
3
4
  import type { ExecutionReceipt, SparkSkill } from "./capabilities.js";
4
5
  export { DEFAULT_MODEL_ID, getSparkModel, REASONING_EFFORT_LABELS, resolveReasoningEffort, SPARK_MODELS, } from "./models.js";
@@ -95,14 +96,8 @@ export type ExecuteJavaScriptOutput = {
95
96
  }[];
96
97
  error?: string;
97
98
  };
98
- export type ScreenshotInput = {
99
- targetId?: number;
100
- view?: "front" | "back" | "left" | "right" | "top" | "bottom" | "isometric";
101
- };
102
- export type ScreenshotOutput = SparkArtifact & {
103
- targetId: number;
104
- view: string;
105
- };
99
+ export type ScreenshotInput = SceneScreenshotOptions;
100
+ export type ScreenshotOutput = SparkArtifact;
106
101
  export type SparkUITools = {
107
102
  execute_javascript: {
108
103
  input: ExecuteJavaScriptInput;
@@ -79,7 +79,10 @@ export type WompSceneApiContext = {
79
79
  };
80
80
  export type WompSceneApi = ReturnType<typeof createWompSceneApi>;
81
81
  export declare function sceneNodeType(kind: NodeKind): number;
82
+ /** Align local +Z toward target. Both points are in the same parent-local coordinates. */
82
83
  declare function lookAt(origin: Vec3, target: Vec3): Vec3;
84
+ /** XYZ degrees aligning the selected positive local axis to a parent-local direction. */
85
+ declare function alignAxis(direction: Vec3, axis: "x" | "y" | "z"): Vec3;
83
86
  export declare function createWompSceneApi(context: WompSceneApiContext): {
84
87
  listSuperMaterials: () => string[];
85
88
  _getParentId: (childId: number) => number;
@@ -120,6 +123,12 @@ export declare function createWompSceneApi(context: WompSceneApiContext): {
120
123
  nodeCreate: (type: number, parentId?: number) => number;
121
124
  nodeDelete: (id: number) => void;
122
125
  nodeClone: (id: number) => number;
126
+ /** Deep clone; preserves sibling order and remaps internal references. Await before other edits. */
127
+ cloneSubtree: (id: number, options?: {
128
+ parentId?: number;
129
+ name?: string;
130
+ index?: number;
131
+ }) => Promise<number>;
123
132
  nodeList: () => number[];
124
133
  nodeChildren: (parentId: number) => number[];
125
134
  nodeSetParent: (id: number, parentId: number, index?: number) => void;
@@ -139,7 +148,9 @@ export declare function createWompSceneApi(context: WompSceneApiContext): {
139
148
  nodeSetHide: (id: number, hidden: boolean) => void;
140
149
  nodeGetOp: (id: number) => number;
141
150
  nodeSetOp: (id: number, operation: number) => void;
151
+ /** Blend radius in millimetres before parent scaling, independent of project units. */
142
152
  nodeGetBlend: (id: number) => number;
153
+ /** Set blend radius in millimetres before parent scaling; clamps to 0–500. */
143
154
  nodeSetBlend: (id: number, blend: number) => void;
144
155
  nodeGetRound: (id: number) => number;
145
156
  nodeSetRound: (id: number, round: number) => void;
@@ -148,7 +159,7 @@ export declare function createWompSceneApi(context: WompSceneApiContext): {
148
159
  nodeGetMaterialId: (id: number) => number | null;
149
160
  nodeSetMaterialId: (id: number, materialId: number) => void;
150
161
  nodeGetResolution: (id: number) => number;
151
- nodeSetResolution: (id: number, resolution: number) => void;
162
+ nodeSetUnionResolution: (unionId: number, resolution: number) => void;
152
163
  nodeGetLightType: (id: number) => number;
153
164
  nodeSetLightType: (id: number, lightType: number) => void;
154
165
  nodeGetPower: (id: number) => number;
@@ -190,8 +201,7 @@ export declare function createWompSceneApi(context: WompSceneApiContext): {
190
201
  curvePointSetHandleIn: (curveId: number, pointIndex: number, value: Vec3) => void;
191
202
  curvePointSetHandleOut: (curveId: number, pointIndex: number, value: Vec3) => void;
192
203
  curvePointCount: (curveId: number) => number;
193
- curvePointAdd: (curveId: number, point?: CurvePointInput) => number;
194
- curvePointInsert: (curveId: number, pointIndex: number, point?: CurvePointInput) => number;
204
+ curvePointAdd: (curveId: number, point: CurvePointInput, pointIndex?: number) => number;
195
205
  curvePointRemove: (curveId: number, pointIndex: number) => void;
196
206
  curvePointGetName: (curveId: number, pointIndex: number) => string;
197
207
  curvePointSetName: (curveId: number, pointIndex: number, name: string) => void;
@@ -262,6 +272,7 @@ export declare function createWompSceneApi(context: WompSceneApiContext): {
262
272
  aabbCenter: (box: AABB) => Vec3;
263
273
  aabbRadius: (box: AABB) => number;
264
274
  lookAt: typeof lookAt;
275
+ alignAxis: typeof alignAxis;
265
276
  scenePropertiesRead: () => {
266
277
  hdr_name: string | undefined;
267
278
  hdr_multiplier: number | undefined;