@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.
- package/README.md +59 -33
- package/dist/agent-run-cli.d.ts +2 -0
- package/dist/agent-run-cli.js +30 -0
- package/dist/agent-run-input.d.ts +11 -0
- package/dist/agent-run-input.js +59 -0
- package/dist/agent-run.d.ts +81 -0
- package/dist/agent-run.js +234 -0
- package/dist/agent-tools.js +3 -2
- package/dist/agent.d.ts +0 -2
- package/dist/agent.js +4 -36
- package/dist/capabilities/geometry.js +1 -1
- package/dist/capabilities/openscad-instructions.d.ts +1 -1
- package/dist/capabilities/openscad-instructions.js +4 -1
- package/dist/capabilities/scene.d.ts +11 -3
- package/dist/capabilities/scene.js +88 -27
- package/dist/capabilities.d.ts +1 -2
- package/dist/capabilities.js +0 -1
- package/dist/client.d.ts +5 -0
- package/dist/client.js +22 -0
- package/dist/coordinates.d.ts +0 -7
- package/dist/coordinates.js +8 -47
- package/dist/generated/scene-declarations.d.ts +1 -1
- package/dist/generated/scene-declarations.js +1 -1
- package/dist/geometry/schemas/flowir-dialect.json +2 -2
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/models.d.ts +7 -0
- package/dist/models.js +10 -0
- package/dist/request-limits.d.ts +1 -0
- package/dist/request-limits.js +1 -0
- package/dist/run-metrics.d.ts +15 -0
- package/dist/run-metrics.js +30 -0
- package/dist/runtime.d.ts +3 -5
- package/dist/scene-metadata.js +1 -1
- package/dist/sdk-scene-host.d.ts +11 -13
- package/dist/sdk-scene-host.js +1 -0
- package/dist/sdk-scene-screenshot.d.ts +4 -6
- package/dist/sdk-scene-screenshot.js +14 -15
- package/dist/server/artifacts.js +1 -5
- package/dist/server/executor.d.ts +0 -1
- package/dist/server/executor.js +0 -1
- package/dist/server/express.js +17 -5
- package/dist/server/scene-actions.d.ts +2 -0
- package/dist/server/scene-actions.js +20 -0
- package/dist/server/service.d.ts +2 -0
- package/dist/server/service.js +10 -0
- package/dist/server/sqlite-store.d.ts +3 -1
- package/dist/server/sqlite-store.js +26 -3
- package/dist/server/standalone.d.ts +2 -0
- package/dist/server/standalone.js +54 -3
- package/dist/server/store.d.ts +3 -1
- package/dist/server/store.js +21 -1
- package/dist/server/types.d.ts +5 -0
- package/dist/simple-bench-cli.d.ts +2 -0
- package/dist/simple-bench-cli.js +64 -0
- package/dist/simple-bench-prompts.d.ts +28 -0
- package/dist/simple-bench-prompts.js +11 -0
- package/dist/simple-bench.d.ts +15 -0
- package/dist/simple-bench.js +175 -0
- package/dist/types.d.ts +4 -13
- package/dist/ui.d.ts +3 -8
- package/dist/womp-scene-api.d.ts +14 -3
- package/dist/womp-scene-api.js +91 -72
- package/package.json +7 -3
- package/dist/capabilities/health.d.ts +0 -9
- package/dist/capabilities/health.js +0 -27
- package/dist/context.d.ts +0 -15
- package/dist/context.js +0 -266
- package/dist/generated/health-declarations.d.ts +0 -1
- 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
|
+
}
|
package/dist/server/service.d.ts
CHANGED
|
@@ -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. */
|
package/dist/server/service.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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() {
|
package/dist/server/store.d.ts
CHANGED
|
@@ -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>;
|
package/dist/server/store.js
CHANGED
|
@@ -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
|
-
|
|
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))
|
package/dist/server/types.d.ts
CHANGED
|
@@ -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,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 {
|
|
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
|
}
|