@womp/spark-agent 3.0.2 → 4.0.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.
Files changed (70) 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/geometry.js +1 -1
  12. package/dist/capabilities/openscad-instructions.d.ts +1 -1
  13. package/dist/capabilities/openscad-instructions.js +4 -1
  14. package/dist/capabilities/scene.d.ts +11 -3
  15. package/dist/capabilities/scene.js +88 -27
  16. package/dist/capabilities.d.ts +1 -2
  17. package/dist/capabilities.js +0 -1
  18. package/dist/client.d.ts +5 -0
  19. package/dist/client.js +22 -0
  20. package/dist/coordinates.d.ts +0 -7
  21. package/dist/coordinates.js +8 -47
  22. package/dist/generated/scene-declarations.d.ts +1 -1
  23. package/dist/generated/scene-declarations.js +1 -1
  24. package/dist/geometry/schemas/flowir-dialect.json +2 -2
  25. package/dist/index.d.ts +0 -1
  26. package/dist/index.js +0 -1
  27. package/dist/models.d.ts +7 -0
  28. package/dist/models.js +10 -0
  29. package/dist/request-limits.d.ts +1 -0
  30. package/dist/request-limits.js +1 -0
  31. package/dist/run-metrics.d.ts +15 -0
  32. package/dist/run-metrics.js +30 -0
  33. package/dist/runtime.d.ts +3 -5
  34. package/dist/scene-metadata.js +1 -1
  35. package/dist/sdk-scene-host.d.ts +11 -13
  36. package/dist/sdk-scene-host.js +1 -0
  37. package/dist/sdk-scene-screenshot.d.ts +4 -6
  38. package/dist/sdk-scene-screenshot.js +14 -15
  39. package/dist/server/artifacts.js +1 -5
  40. package/dist/server/executor.d.ts +0 -1
  41. package/dist/server/executor.js +0 -1
  42. package/dist/server/express.js +17 -5
  43. package/dist/server/scene-actions.d.ts +2 -0
  44. package/dist/server/scene-actions.js +20 -0
  45. package/dist/server/service.d.ts +2 -0
  46. package/dist/server/service.js +10 -0
  47. package/dist/server/sqlite-store.d.ts +3 -1
  48. package/dist/server/sqlite-store.js +26 -3
  49. package/dist/server/standalone.d.ts +2 -0
  50. package/dist/server/standalone.js +54 -3
  51. package/dist/server/store.d.ts +3 -1
  52. package/dist/server/store.js +21 -1
  53. package/dist/server/types.d.ts +5 -0
  54. package/dist/simple-bench-cli.d.ts +2 -0
  55. package/dist/simple-bench-cli.js +64 -0
  56. package/dist/simple-bench-prompts.d.ts +28 -0
  57. package/dist/simple-bench-prompts.js +11 -0
  58. package/dist/simple-bench.d.ts +15 -0
  59. package/dist/simple-bench.js +175 -0
  60. package/dist/types.d.ts +4 -13
  61. package/dist/ui.d.ts +3 -8
  62. package/dist/womp-scene-api.d.ts +14 -3
  63. package/dist/womp-scene-api.js +91 -72
  64. package/package.json +7 -3
  65. package/dist/capabilities/health.d.ts +0 -9
  66. package/dist/capabilities/health.js +0 -27
  67. package/dist/context.d.ts +0 -15
  68. package/dist/context.js +0 -266
  69. package/dist/generated/health-declarations.d.ts +0 -1
  70. package/dist/generated/health-declarations.js +0 -3
@@ -0,0 +1,20 @@
1
+ function nodeDepth(node, nodes) {
2
+ let depth = 0;
3
+ let parentId = node.parentId;
4
+ while (parentId !== null && parentId !== 0) {
5
+ depth += 1;
6
+ parentId = nodes.get(parentId)?.parentId ?? null;
7
+ }
8
+ return depth;
9
+ }
10
+ export function clearSceneContents(scene) {
11
+ const nodes = scene.listNodes().filter(({ id }) => id !== 0);
12
+ const nodesById = new Map(nodes.map((node) => [node.id, node]));
13
+ nodes.sort((a, b) => nodeDepth(b, nodesById) - nodeDepth(a, nodesById));
14
+ for (const node of nodes)
15
+ scene.deleteNode(node.id);
16
+ const materials = scene.listMaterials();
17
+ for (const material of materials)
18
+ scene.deleteMaterial(material.id);
19
+ return nodes.length + materials.length;
20
+ }
@@ -28,6 +28,8 @@ export declare class SparkAgentService {
28
28
  }): Promise<SparkConversation>;
29
29
  listConversations(ownerId: string): Promise<SparkConversationSummary[]>;
30
30
  getConversation(ownerId: string, id: string): Promise<SparkConversation>;
31
+ saveConversationSettings(ownerId: string, id: string, settings: SparkRunSettings): Promise<void>;
32
+ deleteConversation(ownerId: string, id: string): Promise<void>;
31
33
  getRun(ownerId: string, id: string): Promise<SparkRun>;
32
34
  getActiveRun(ownerId: string, conversationId: string): Promise<SparkRun | undefined>;
33
35
  /** The host must establish exclusive ownership before recovering abandoned runs. */
@@ -96,6 +96,16 @@ export class SparkAgentService {
96
96
  }
97
97
  return conversation;
98
98
  }
99
+ async saveConversationSettings(ownerId, id, settings) {
100
+ await this.getConversation(ownerId, id);
101
+ await this.store.saveConversationSettings(id, settings);
102
+ }
103
+ async deleteConversation(ownerId, id) {
104
+ await this.getConversation(ownerId, id);
105
+ if (await this.store.deleteConversation(id) === false) {
106
+ throw new SparkServiceError(409, "Stop the active run before deleting this conversation.");
107
+ }
108
+ }
99
109
  async getRun(ownerId, id) {
100
110
  const run = await this.store.getRun(id);
101
111
  if (run === undefined)
@@ -3,7 +3,7 @@ import type { SparkArtifact, StoredSparkChunk } from "../ui.js";
3
3
  import type { SparkArtifactStore, StoredSparkArtifact } from "./artifacts.js";
4
4
  import type { SceneOperation, SceneOperationStore } from "../scene-operations.js";
5
5
  import { type RepairStateStore } from "../repair-state.js";
6
- import type { SparkAgentStore, SparkConversation, SparkConversationSummary, SparkRun } from "./types.js";
6
+ import type { SparkAgentStore, SparkConversation, SparkConversationSummary, SparkRun, SparkRunSettings } from "./types.js";
7
7
  /** Local development storage. Execution ownership belongs to the host service. */
8
8
  export declare class SqliteSparkStore implements SparkAgentStore, SparkArtifactStore, SceneOperationStore {
9
9
  private readonly artifactBaseUrl;
@@ -21,6 +21,8 @@ export declare class SqliteSparkStore implements SparkAgentStore, SparkArtifactS
21
21
  getConversation(id: string): Promise<SparkConversation | undefined>;
22
22
  listConversations(ownerId: string): Promise<SparkConversationSummary[]>;
23
23
  saveConversation(value: SparkConversation): Promise<void>;
24
+ saveConversationSettings(id: string, settings: SparkRunSettings): Promise<void>;
25
+ deleteConversation(id: string): Promise<boolean>;
24
26
  createRun(value: SparkRun): Promise<void>;
25
27
  getRun(id: string): Promise<SparkRun | undefined>;
26
28
  saveRun(value: SparkRun): Promise<void>;
@@ -159,10 +159,34 @@ export class SqliteSparkStore {
159
159
  });
160
160
  }
161
161
  async saveConversation(value) {
162
- const result = this.database.prepare("UPDATE conversations SET owner_id = ?, updated_at = ?, data = ? WHERE id = ?").run(value.ownerId, value.updatedAt, JSON.stringify(value), value.id);
162
+ const result = this.database.prepare("UPDATE conversations SET owner_id = ?, updated_at = ?, data = json_patch(json_remove(?, '$.settings'), json_object('settings', json_extract(data, '$.settings'))) WHERE id = ?").run(value.ownerId, value.updatedAt, JSON.stringify(value), value.id);
163
163
  if (result.changes === 0)
164
164
  throw new Error(`Conversation ${value.id} does not exist.`);
165
165
  }
166
+ async saveConversationSettings(id, settings) {
167
+ const result = this.database.prepare("UPDATE conversations SET data = json_set(data, '$.settings', json(?)) WHERE id = ?").run(JSON.stringify(settings), id);
168
+ if (result.changes === 0)
169
+ throw new Error(`Conversation ${id} does not exist.`);
170
+ }
171
+ async deleteConversation(id) {
172
+ this.database.exec("BEGIN IMMEDIATE");
173
+ try {
174
+ const active = this.database.prepare("SELECT id FROM conversations WHERE id = ? AND json_extract(data, '$.activeRunId') IS NOT NULL").get(id);
175
+ if (active) {
176
+ this.database.exec("ROLLBACK");
177
+ return false;
178
+ }
179
+ this.database.prepare("DELETE FROM chunks WHERE run_id IN (SELECT id FROM runs WHERE conversation_id = ?)").run(id);
180
+ this.database.prepare("DELETE FROM runs WHERE conversation_id = ?").run(id);
181
+ this.database.prepare("DELETE FROM conversations WHERE id = ?").run(id);
182
+ this.database.exec("COMMIT");
183
+ return true;
184
+ }
185
+ catch (error) {
186
+ this.database.exec("ROLLBACK");
187
+ throw error;
188
+ }
189
+ }
166
190
  async createRun(value) {
167
191
  this.database.prepare("INSERT INTO runs VALUES (?, ?, ?)")
168
192
  .run(value.id, value.conversationId, JSON.stringify(value));
@@ -205,9 +229,8 @@ export class SqliteSparkStore {
205
229
  const id = crypto.randomUUID();
206
230
  const artifact = {
207
231
  id, url: `${this.artifactBaseUrl.replace(/\/$/u, "")}/${id}`,
208
- mediaType: image.mediaType, name: `spark-${image.view}.jpg`,
232
+ mediaType: image.mediaType, name: "spark-screenshot.jpg",
209
233
  width: image.width, height: image.height,
210
- metadata: { view: image.view, targetId: image.targetId },
211
234
  };
212
235
  this.database.prepare("INSERT INTO artifacts VALUES (?, ?, ?, ?)")
213
236
  .run(id, ownerId, JSON.stringify(artifact), image.data);
@@ -11,6 +11,8 @@ export type StandaloneSparkAgentOptions = {
11
11
  /** Stable host-verified identity for the scene behind kakapoUrl. */
12
12
  sceneIdentity?: string;
13
13
  logger?: Pick<Console, "info" | "warn">;
14
+ /** Observe successful renderer captures, including captures not attached to a reply. */
15
+ onScreenshot?: (runId: string) => void;
14
16
  };
15
17
  export type StandaloneSparkAgentApp = Express & {
16
18
  dispose(): Promise<void>;
@@ -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
  }