@womp/spark-agent 4.1.2 → 4.2.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.
@@ -1,5 +1,6 @@
1
1
  import { tool } from "ai";
2
2
  import { z } from "zod";
3
+ import { boundedResultText } from "./bounded-output.js";
3
4
  import { completedInputString } from "./tool-input-label.js";
4
5
  import { UserWait } from "./execution-lifetime.js";
5
6
  function errorMessage(error) {
@@ -246,9 +247,14 @@ export function createSparkTools(options) {
246
247
  return { ...entry, artifact: { id, mediaType, ...(name ? { name } : {}) } };
247
248
  }),
248
249
  };
250
+ // `print` and a returned value are each already refused over 1 MiB; the
251
+ // rest of the envelope -- a rejected call's `error`, and the receipts --
252
+ // was not, and one unbounded compiler diagnostic cost a whole turn. Only
253
+ // model input is bounded: persistence and the UI stream keep the
254
+ // original (./bounded-output.ts).
249
255
  return {
250
256
  type: "content",
251
- value: [{ type: "text", text: JSON.stringify(modelOutput) }, ...images],
257
+ value: [{ type: "text", text: boundedResultText(modelOutput) }, ...images],
252
258
  };
253
259
  },
254
260
  }),
@@ -0,0 +1,37 @@
1
+ export declare const MODEL_RESULT_BYTE_LIMIT: number;
2
+ export declare const MODEL_ERROR_BYTE_LIMIT: number;
3
+ export declare function byteLength(text: string): number;
4
+ /**
5
+ * `text` cut to at most `limit` bytes on a character boundary. A cut that lands
6
+ * inside a multi-byte sequence decodes to a trailing replacement character,
7
+ * which is dropped.
8
+ */
9
+ export declare function truncateUtf8(text: string, limit: number): string;
10
+ /**
11
+ * `text` bounded to `limit` bytes with the omission stated in the same shape as
12
+ * the existing print budget message, so a model reading it knows the text is
13
+ * incomplete rather than wrong.
14
+ */
15
+ export declare function boundedText(text: string, limit: number, label: string): string;
16
+ /**
17
+ * The serialized tool result the model reads, bounded to
18
+ * `MODEL_RESULT_BYTE_LIMIT`. Persistence, the UI stream and the host's own
19
+ * receipts keep the full output; only model input is bounded here.
20
+ *
21
+ * Stages, in order, so the most useful part survives: bound `error`, then bound
22
+ * each receipt's `details`, then keep only as many receipts as the budget
23
+ * allows, and finally fall back to the envelope with a note in place of the
24
+ * logs. Every stage leaves valid JSON -- a truncated JSON document would cost
25
+ * the model more than the bytes it saves.
26
+ */
27
+ export declare function boundedResultText<Output extends {
28
+ version: number;
29
+ ok: boolean;
30
+ purpose: string;
31
+ logs: string[];
32
+ receipts: {
33
+ id: string;
34
+ details?: Record<string, unknown>;
35
+ }[];
36
+ error?: string;
37
+ }>(output: Output, limit?: number): string;
@@ -0,0 +1,110 @@
1
+ // Byte budgets for everything a tool call hands back to the model.
2
+ //
3
+ // WHY THIS FILE EXISTS. `execute_javascript` already refuses a printed payload
4
+ // or a returned value over 1 MiB (agent-tools.ts `jsonValue` and `print`), but
5
+ // nothing bounded the rest of the envelope: the `error` string and the receipts.
6
+ // One rejected `geometry.write` over a one-character enum value produced a
7
+ // compiler diagnostic that repeated the offending source line once per
8
+ // diagnostic, and the resulting tool result cost a whole turn -- 65,136 output
9
+ // tokens, a run that ended `incomplete`, ~235 KB rendered into the panel and an
10
+ // unresponsive tab. A bad argument must cost one tool result, not a turn, so the
11
+ // same budget that guards `print` now guards the whole serialized result.
12
+ //
13
+ // These helpers truncate and say so. They never throw: an over-long diagnostic
14
+ // is still the most useful thing the model can read, so it is cut and labelled
15
+ // rather than replaced.
16
+ const encoder = new TextEncoder();
17
+ const decoder = new TextDecoder();
18
+ export const MODEL_RESULT_BYTE_LIMIT = 1024 * 1024;
19
+ export const MODEL_ERROR_BYTE_LIMIT = 8 * 1024;
20
+ export function byteLength(text) {
21
+ return encoder.encode(text).byteLength;
22
+ }
23
+ /**
24
+ * `text` cut to at most `limit` bytes on a character boundary. A cut that lands
25
+ * inside a multi-byte sequence decodes to a trailing replacement character,
26
+ * which is dropped.
27
+ */
28
+ export function truncateUtf8(text, limit) {
29
+ const bytes = encoder.encode(text);
30
+ if (bytes.byteLength <= limit)
31
+ return text;
32
+ return decoder.decode(bytes.subarray(0, limit)).replace(/�$/u, "");
33
+ }
34
+ /**
35
+ * `text` bounded to `limit` bytes with the omission stated in the same shape as
36
+ * the existing print budget message, so a model reading it knows the text is
37
+ * incomplete rather than wrong.
38
+ */
39
+ export function boundedText(text, limit, label) {
40
+ const total = byteLength(text);
41
+ if (total <= limit)
42
+ return text;
43
+ const kept = truncateUtf8(text, limit);
44
+ return `${kept}\n[${label} truncated at ${limit} bytes; ${total - byteLength(kept)} bytes omitted.]`;
45
+ }
46
+ /**
47
+ * The serialized tool result the model reads, bounded to
48
+ * `MODEL_RESULT_BYTE_LIMIT`. Persistence, the UI stream and the host's own
49
+ * receipts keep the full output; only model input is bounded here.
50
+ *
51
+ * Stages, in order, so the most useful part survives: bound `error`, then bound
52
+ * each receipt's `details`, then keep only as many receipts as the budget
53
+ * allows, and finally fall back to the envelope with a note in place of the
54
+ * logs. Every stage leaves valid JSON -- a truncated JSON document would cost
55
+ * the model more than the bytes it saves.
56
+ */
57
+ export function boundedResultText(output, limit = MODEL_RESULT_BYTE_LIMIT) {
58
+ const errorBounded = typeof output.error === "string"
59
+ ? { ...output, error: boundedText(output.error, MODEL_ERROR_BYTE_LIMIT, "Error") }
60
+ : output;
61
+ const asText = (value) => JSON.stringify(value) ?? "null";
62
+ let text = asText(errorBounded);
63
+ if (byteLength(text) <= limit)
64
+ return text;
65
+ const detailsLimit = Math.max(1024, Math.floor(limit / Math.max(errorBounded.receipts.length, 1)));
66
+ const trimmedReceipts = errorBounded.receipts.map((receipt) => {
67
+ if (receipt.details === undefined)
68
+ return receipt;
69
+ const details = asText(receipt.details);
70
+ if (byteLength(details) <= detailsLimit)
71
+ return receipt;
72
+ return {
73
+ ...receipt,
74
+ details: {
75
+ omitted: `This receipt's details were ${byteLength(details)} bytes, over the ${detailsLimit} byte limit for one receipt in a tool result. Read the receipt from the host if the outcome matters.`,
76
+ },
77
+ };
78
+ });
79
+ text = asText({ ...errorBounded, receipts: trimmedReceipts });
80
+ if (byteLength(text) <= limit)
81
+ return text;
82
+ const kept = [];
83
+ let used = byteLength(asText({ ...errorBounded, receipts: [] }));
84
+ for (const receipt of trimmedReceipts) {
85
+ const size = byteLength(asText(receipt)) + 1;
86
+ if (used + size > limit)
87
+ break;
88
+ kept.push(receipt);
89
+ used += size;
90
+ }
91
+ if (kept.length < trimmedReceipts.length) {
92
+ text = asText({
93
+ ...errorBounded,
94
+ receipts: kept,
95
+ receiptsOmitted: `${trimmedReceipts.length - kept.length} of ${trimmedReceipts.length} receipts were omitted to keep this result under the ${limit} byte limit.`,
96
+ });
97
+ if (byteLength(text) <= limit)
98
+ return text;
99
+ }
100
+ return asText({
101
+ version: errorBounded.version,
102
+ ok: errorBounded.ok,
103
+ purpose: errorBounded.purpose,
104
+ value: null,
105
+ logs: [],
106
+ receipts: [],
107
+ artifacts: [],
108
+ error: boundedText(`This result exceeded the ${limit} byte limit and was omitted. Print a filtered summary instead of whole objects.${typeof errorBounded.error === "string" ? ` The call also failed: ${errorBounded.error}` : ""}`, MODEL_ERROR_BYTE_LIMIT, "Error"),
109
+ });
110
+ }
@@ -15,20 +15,20 @@ export function createGeometryCapability(host, reference) {
15
15
  name: "geometry-nodes",
16
16
  version: "5",
17
17
  description: "Create and edit visual Geometry Nodes graphs with adjustable parameters and animation.",
18
- instructions: `Use womp.geometry for requested Geometry Nodes or parametric geometry workflows, not Workbench Flow graphs.
19
- Keep follow-up edits to an existing Geometry Nodes object in its graph, preserving its adjustable parameters and instances.
20
- Read the target after any scene edit this build needs. read(0) reads the scene root as a creation container.
21
- write updates an existing shared graph, attaches a new graph to an Area without one, or creates a new Area beneath the root/Group target. Use the returned objectId for follow-up edits.
22
- Read geometry-nodes-reference before writing source. Supply the complete FlowIR text and the exact revision returned by the latest read. JavaScript calls execute JavaScript; source is a FlowIR string, not JavaScript or JSON.
23
- After a stale revision rejection, read the latest graph and adapt the edit while preserving intervening user changes. Ask only when the edits conflict or remain ambiguous; never retry the old replacement with a refreshed revision token.
24
- If source is null and diagnostics are present, the existing graph cannot be edited without loss. Explain the limitation; never treat it as empty or create a replacement to bypass the refusal.
25
- Preserve unrelated statements, parameter names, and FlowIR node IDs from the read. Shared graph edits affect all listed instances; report the affected objects. Valid overrides migrate by parameter name and type. Removed or type-changed parameters return to graph defaults and are reported.
26
- Use min/max/step for naturally bounded parameters and omit ranges for free-form positions/rotations. Meaningful parameter names become editor labels. Do not invent label fields.
27
- SetMaterial assigns one scene material to every primitive it receives. Its Material input is a scene material ID, not a color; 0 selects the scene default material.
28
- Obtain that ID from the scene-editing skill inside one scene.edit, where those lookups live: materialList with materialGetName and materialGetColor to reuse an existing material, materialCreate with materialSetColor and materialSetName for a new one, or listSuperMaterials with materialImport for a library material. Prefer reusing a material that already matches the request over creating another, and do not recolor a shared material unless every object using it should change.
29
- A scene edit changes the scene revision, so create or import the material before reading the geometry target, then write, all within one JavaScript call. Each call has a fresh scope and cannot read an earlier call's variables.
30
- Positions and sizes are millimetres; Boolean Blend uses the conversion in geometry-nodes-reference. Coordinates are right-handed, +X right, +Y up, +Z forward. Rotations are degrees, intrinsic X-Z-Y.
31
- On compiler errors, repair the source in this same conversation and retry with a fresh read. A rejected write made no change. Committed/unknown effects remain in execution receipts even if a later call fails; inspect before retrying.
18
+ instructions: `Use womp.geometry for requested Geometry Nodes or parametric geometry workflows, not Workbench Flow graphs.
19
+ Keep follow-up edits to an existing Geometry Nodes object in its graph, preserving its adjustable parameters and instances.
20
+ Read the target after any scene edit this build needs. read(0) reads the scene root as a creation container.
21
+ write updates an existing shared graph, attaches a new graph to an Area without one, or creates a new Area beneath the root/Group target. Use the returned objectId for follow-up edits.
22
+ Read geometry-nodes-reference before writing source. Supply the complete FlowIR text and the exact revision returned by the latest read. JavaScript calls execute JavaScript; source is a FlowIR string, not JavaScript or JSON.
23
+ After a stale revision rejection, read the latest graph and adapt the edit while preserving intervening user changes. Ask only when the edits conflict or remain ambiguous; never retry the old replacement with a refreshed revision token.
24
+ If source is null and diagnostics are present, the existing graph cannot be edited without loss. Explain the limitation; never treat it as empty or create a replacement to bypass the refusal.
25
+ Preserve unrelated statements, parameter names, and FlowIR node IDs from the read. Shared graph edits affect all listed instances; report the affected objects. Valid overrides migrate by parameter name and type. Removed or type-changed parameters return to graph defaults and are reported.
26
+ Use min/max/step for naturally bounded parameters and omit ranges for free-form positions/rotations. Meaningful parameter names become editor labels. Do not invent label fields.
27
+ SetMaterial assigns one scene material to every primitive it receives. Its Material input is a scene material ID, not a color; 0 selects the scene default material.
28
+ Obtain that ID from the scene-editing skill inside one scene.edit, where those lookups live: materialList with materialGetName and materialGetColor to reuse an existing material, materialCreate with materialSetColor and materialSetName for a new one, or listSuperMaterials with materialImport for a library material. Prefer reusing a material that already matches the request over creating another, and do not recolor a shared material unless every object using it should change.
29
+ A scene edit changes the scene revision, so create or import the material before reading the geometry target, then write, all within one JavaScript call. Each call has a fresh scope and cannot read an earlier call's variables.
30
+ Positions and sizes are millimetres; Boolean Blend uses the conversion in geometry-nodes-reference. Coordinates are right-handed, +X right, +Y up, +Z forward. Rotations are degrees, intrinsic X-Z-Y.
31
+ On compiler errors, repair the source in this same conversation and retry with a fresh read. A rejected write made no change. Committed/unknown effects remain in execution receipts even if a later call fails; inspect before retrying.
32
32
  After applying, read the returned object and use the scene screenshot API with emitImage to inspect the applied result.`,
33
33
  declarations: WOMP_GEOMETRY_DECLARATIONS,
34
34
  examples: [
@@ -79,38 +79,38 @@ export function createOperationsCapability(host) {
79
79
  name: "generation",
80
80
  version: "7",
81
81
  description: "Generate or edit images and meshes, inspect jobs, and wait for their outputs.",
82
- instructions: `Read operations.catalog() for the currently available models and selected defaults.
83
- Use generation for inline chat results. If the user is working in their Flow graph and flow-graphs is available,
84
- add the generation to that graph instead unless they request an inline result. When they want repeated variations,
85
- offer a reusable Flow workflow if available; accepting that offer means editing the graph, not submitting another inline job.
86
- Use generate() for image/mesh generation, image editing, upscaling, or background removal. Model IDs must
87
- come from the catalog and support the requested operation. An unavailable or restricted explicit choice
88
- is an error; do not silently substitute another model. Omit model IDs to use the user's defaults.
89
- For text-to-mesh, imageParams configures the image step and params configures the mesh step. For
90
- image-to-mesh, an optional prompt requests an image-editing step first. Advanced params are JSON
91
- values passed to the existing service; use documented options and do not invent provider settings.
92
- Author the full text-to-mesh image prompt yourself, preserving the user's requested subject and details.
93
- For a standalone object, describe a clear three-quarter view with a plain background and even lighting
94
- unless the user requests otherwise. Do not rely on another model to expand or rewrite this prompt.
95
- Inputs are image references returned by the available assets API or a previous completed operation.
96
- Do not fabricate references, replace a missing input with another image, or pass arbitrary URLs.
97
- Assign a short, distinct key to each intended job. Reuse that key when checking the same submission;
98
- never choose a new key merely to retry a timeout or unknown outcome. A key cannot identify two
99
- different requests. Host receipts and saved message IDs record submitted effects.
100
- generate() waits for cost confirmation and then for completion, failure, or cancellation.
101
- Use exactly one generation per execute_javascript call and print its result. Put follow-up work in
102
- another call. Do not poll, start background work, use Promise.all, or report success before completion.
103
- The host waits without consuming model steps. Stop ends agent execution; the host handles cancellation of its accepted jobs.
104
- Refresh recovery reconnects the same job. It never resumes the interrupted JavaScript stack.
105
- Read the recovered status and artifact references, then continue any requested remaining work in a new call; do not repeat generation.
106
- generate() returns the terminal status and job.outputs artifact references directly, including after recovery.
107
- Completed generations are shown automatically to the user; image bytes are intentionally omitted from your context. Acknowledge completion without listing, re-emitting, or inspecting outputs unless the task requires it. Use returned artifact references for follow-up work.
108
- For a simple generation request, finish with a brief acknowledgement of the completed status.
109
- Inspect images deliberately when needed for verification or reference comparisons; never claim visual verification without inspecting the image.
110
- Omitted image bytes are expected; missing artifact references are not. If a completed job has no outputs, recover the references or report the missing result.
111
- Reserve list() for older jobs or missing references. Follow nextFrom even when a page has no jobs.
112
- Verify an uncertain submission's job status, never issue a replacement request.
113
- Only returned outputs exist. Generating a mesh does not import it into the scene. Use the assets skill for requested imports.
82
+ instructions: `Read operations.catalog() for the currently available models and selected defaults.
83
+ Use generation for inline chat results. If the user is working in their Flow graph and flow-graphs is available,
84
+ add the generation to that graph instead unless they request an inline result. When they want repeated variations,
85
+ offer a reusable Flow workflow if available; accepting that offer means editing the graph, not submitting another inline job.
86
+ Use generate() for image/mesh generation, image editing, upscaling, or background removal. Model IDs must
87
+ come from the catalog and support the requested operation. An unavailable or restricted explicit choice
88
+ is an error; do not silently substitute another model. Omit model IDs to use the user's defaults.
89
+ For text-to-mesh, imageParams configures the image step and params configures the mesh step. For
90
+ image-to-mesh, an optional prompt requests an image-editing step first. Advanced params are JSON
91
+ values passed to the existing service; use documented options and do not invent provider settings.
92
+ Author the full text-to-mesh image prompt yourself, preserving the user's requested subject and details.
93
+ For a standalone object, describe a clear three-quarter view with a plain background and even lighting
94
+ unless the user requests otherwise. Do not rely on another model to expand or rewrite this prompt.
95
+ Inputs are image references returned by the available assets API or a previous completed operation.
96
+ Do not fabricate references, replace a missing input with another image, or pass arbitrary URLs.
97
+ Assign a short, distinct key to each intended job. Reuse that key when checking the same submission;
98
+ never choose a new key merely to retry a timeout or unknown outcome. A key cannot identify two
99
+ different requests. Host receipts and saved message IDs record submitted effects.
100
+ generate() waits for cost confirmation and then for completion, failure, or cancellation.
101
+ Use exactly one generation per execute_javascript call and print its result. Put follow-up work in
102
+ another call. Do not poll, start background work, use Promise.all, or report success before completion.
103
+ The host waits without consuming model steps. Stop ends agent execution; the host handles cancellation of its accepted jobs.
104
+ Refresh recovery reconnects the same job. It never resumes the interrupted JavaScript stack.
105
+ Read the recovered status and artifact references, then continue any requested remaining work in a new call; do not repeat generation.
106
+ generate() returns the terminal status and job.outputs artifact references directly, including after recovery.
107
+ Completed generations are shown automatically to the user; image bytes are intentionally omitted from your context. Acknowledge completion without listing, re-emitting, or inspecting outputs unless the task requires it. Use returned artifact references for follow-up work.
108
+ For a simple generation request, finish with a brief acknowledgement of the completed status.
109
+ Inspect images deliberately when needed for verification or reference comparisons; never claim visual verification without inspecting the image.
110
+ Omitted image bytes are expected; missing artifact references are not. If a completed job has no outputs, recover the references or report the missing result.
111
+ Reserve list() for older jobs or missing references. Follow nextFrom even when a page has no jobs.
112
+ Verify an uncertain submission's job status, never issue a replacement request.
113
+ Only returned outputs exist. Generating a mesh does not import it into the scene. Use the assets skill for requested imports.
114
114
  Never resume stopped work or resubmit a job because cancellation or submission is uncertain.`,
115
115
  declarations: WOMP_OPERATIONS_DECLARATIONS,
116
116
  examples: [
@@ -147,7 +147,12 @@ Never resume stopped work or resubmit a job because cancellation or submission i
147
147
  failures = 0;
148
148
  }
149
149
  catch (error) {
150
- context.signal.throwIfAborted();
150
+ // An aborted wait still stops here, but with the host's own
151
+ // error: crow answers a cancelled generation with a sentence
152
+ // explaining it, and `throwIfAborted` replaced that with the
153
+ // bare abort reason.
154
+ if (context.signal.aborted)
155
+ throw error;
151
156
  if (++failures >= 3)
152
157
  throw error;
153
158
  await new Promise((resolve, reject) => {
@@ -1,6 +1,27 @@
1
1
  import { z } from "zod";
2
2
  import { WOMP_SCENE_DECLARATIONS } from "../generated/scene-declarations.js";
3
3
  import { unverifiedSceneHealth } from "../scene-health.js";
4
+ // A node id, as `scene.read()` publishes them: the scene root is 0, so a health
5
+ // read has to accept it. `openscad.ts` and `geometry.ts` spell an object id the
6
+ // same way; `positive()` here refused an id this capability had just handed out.
7
+ const objectId = z.number().int().nonnegative();
8
+ const healthObjectIds = z.array(objectId).default([]);
9
+ function display(value) {
10
+ if (typeof value === "number" || typeof value === "boolean")
11
+ return String(value);
12
+ return JSON.stringify(value) ?? String(value);
13
+ }
14
+ /** Ids for a health read, or a sentence -- never a raw validation dump. */
15
+ function readHealthObjectIds(objectIds) {
16
+ const parsed = healthObjectIds.safeParse(objectIds);
17
+ if (parsed.success)
18
+ return parsed.data;
19
+ const invalid = Array.isArray(objectIds)
20
+ ? objectIds.filter((value) => objectId.safeParse(value).success === false)
21
+ : [objectIds];
22
+ const named = invalid.slice(0, 3).map(display).join(", ");
23
+ throw new Error(`scene.health takes object ids from scene.read(): whole numbers from 0 up, in an array. ${named} ${invalid.length === 1 ? "is not one" : "are not ids"}.`);
24
+ }
4
25
  const cameraVector = z.object({ x: z.number().finite(), y: z.number().finite(), z: z.number().finite() }).strict();
5
26
  const screenshotOptions = z.object({
6
27
  camera: z.object({
@@ -16,82 +37,82 @@ export function createSceneCapability(host) {
16
37
  skills: [
17
38
  {
18
39
  name: "scene-editing",
19
- version: "29",
40
+ version: "30",
20
41
  description: "Inspect the open scene and edit its objects, materials, curves, and environment.",
21
- instructions: `Read the scene first and keep its revision and object IDs. read() includes health.
22
- Use womp.scene.health() to refresh whole-scene health or womp.scene.health(objectIds) to check specific objects (mapped to their owning Areas).
23
- Health is a separate snapshot, not proof of consistency with read().revision or that the latest edit finished evaluating. Inspect post-edit health in receipts before claiming success; refresh only when needed.
24
- Unavailable, stale, or unverified feedback must not trigger blind regeneration or a claim that the model passed. Hosts without health services return unverified health with a reason.
25
- For confirmed primitive or depth overflow, repair the same target while preserving design, dimensions, active parameter overrides, and unrelated objects. At most two geometry repairs are allowed per target in one logical run.
26
- Empty Areas may be pending, intentionally empty, mesh-only, or failed. Overflow flags take precedence over possibly stale counts.
27
- max_total_depth reports the engine's tier-dependent nesting allowance, not measured depth. Compiled Boolean depth includes generated grouping; organizational Groups do not add a level. Use flag_depth_overflow to detect evaluator rejection.
28
- Call scene.edit(callback, expectedRevision)
29
- for one coherent undoable change. The callback receives a draft; all node/material/curve functions and enums
30
- are properties of that draft. The draft expires when the callback ends. Await asynchronous methods.
31
- Do not start unrelated operations from inside an edit. A stale revision requires reading again.
32
- Preserve the existing scene unless asked to remove or replace content. Use semantic object names and reuse
33
- known IDs when editing rather than creating duplicates. Distances follow the current project's units
34
- (read().unitMultiplier is 1 for millimetres, 0.1 for centimetres, approximately 0.0393701 for inches).
35
- Convert a dimension in millimetres to project units by multiplying it by read().unitMultiplier.
36
- The SDK-backed draft clamps dimension writes to 0.01–1000 mm per component before converting to native units; this is an adapter limit, not a scene-wide size limit. Read back dimensions when physical size matters.
37
- Area size is a dimensionless scale; Group size stays fixed at one. Lights must be created at root (parentId 0); their size has x/y dimensions.
38
- Light power must be a finite number in [0, 100].
39
- nodeCreate(Curve, parentId) creates an empty curve with zero blend. curvePointAdd(curveId, point, index?) returns the new index; omit index to append or supply an integer from 0 through point count to insert before that position.
40
- Point defaults: position/rotation/handles zero, profile size {x:1,y:1,z:1} in project units, the curve's material, fixed false, round 0. Pass {} for defaults.
41
- Removing all points is supported. One point renders a single profile primitive, not a path.
42
- Curves render sampled profiles, not a continuous sweep. For smooth curves, start with nodeSetDensity(curveId, 250) and nodeSetBlend(curveId, 3).
43
- Density increases sampling and primitive cost; the 3 mm blend softens joins, not sample positions. Adjust blend to preserve thin features and the intended silhouette; these settings do not guarantee continuity.
44
- nodeSetUnionResolution(unionId, resolution) sets Area/Union resolution, not child-part resolution, and clamps to 0.01–0.65; higher values increase detail and voxel cost. Material emission ranges from 0 to 1.
45
- nodeSetBlend and mirror goop take radii in millimetres before parent scaling, regardless of project units, and clamp to 0–500 mm. This does not apply to the FlowIR Boolean Blend port, which takes native units; geometry-nodes-reference gives its conversion.
46
- V1/V2 curve transforms are read-only;
47
- edit their points instead. Handles and closure are available only on V3 Bezier curves.
48
- Typed rotations are right-handed intrinsic XYZ Euler degrees, relative to the parent (R = Rx Ry Rz), including curve points and mirrors.
49
- Points and curve mirrors use the owning curve frame; primitive mirrors use the primitive's parent frame.
50
- lookAt(origin, target) aligns local +Z; both points must be in the same parent-local space. For a segment along a different local axis,
51
- use alignAxis(direction, 'x'|'y'|'z'); cylinders and cones extend along local Y. These return rotations, not world-space transforms.
52
- Alignment chooses a deterministic roll using Y-up (Z-up fallback when parallel); coincident/non-finite directions are errors.
53
- The SDK handles native XZY conversion; do not swap Euler components or convert again. Geometry Nodes source remains native XZY.
54
- Colors use RGB values from 0 to 255. Use the exposed enums instead of guessing values.
55
- For visible geometry color, create or clone a material, call materialSetColor, and assign it with nodeSetMaterialId only to
56
- Primitive, Curve, Text, SVG, Mesh, Field, Decal or OpenSCAD nodes. Assigning a curve material recolors its existing points; use curvePointSetMaterialId for per-point overrides afterward.
57
- Areas/Unions, Groups, Lights and Sockets do not accept direct materials. Color ordinary containers through their supported children.
58
- Geometry Nodes materials belong in the graph: create or import the material here, then assign it inside the graph with the FlowIR SetMaterial node, passing the numeric ID this skill returns.
59
- Never replace a parametric graph with ordinary primitives to color it. Ordinary Stain/CSG children do not modify graph output.
60
- nodeSetColor writes the node color field (used by lights); it does not replace a geometry object's material color.
61
- Do not recolor a shared material unless every object using it should change. Create and assign the material in the same edit as the scene geometry that uses it; a Geometry Nodes graph instead consumes a material ID created in an earlier edit.
62
- Create primitives, curves, and text inside Areas/Unions. Respect tree order for subtraction and intersection.
63
- Groups are organizational only; move and rotate their child Areas individually, not the Group. Use unions for blended masses.
64
- Sphere uses size.x as uniform diameter (y/z do not stretch it), including new or resized spherical curve points. Cylinder/Cone/Pyramid use x as base diameter/width and y as height;
65
- Torus uses x as outer diameter and y as tube thickness. For these four primitives, nodeSetSize accepts {x, y}; z follows x even when supplied. Cube uses all three dimensions. Node and curve-point round are dimensionless fractions clamped to [0, 1], not radii in project units; rounding does not set size. Cube rounding reaches half its shortest dimension at 1, so a shallow Cube retains broad flat faces rather than becoming an ellipsoid.
66
- Build and verify coarse geometry and unfamiliar operations before replicating details. Check one representative result, then repeat it.
42
+ instructions: `Read the scene first and keep its revision and object IDs. read() includes health.
43
+ Use womp.scene.health() to refresh whole-scene health or womp.scene.health(objectIds) to check specific objects (mapped to their owning Areas).
44
+ Health is a separate snapshot, not proof of consistency with read().revision or that the latest edit finished evaluating. Inspect post-edit health in receipts before claiming success; refresh only when needed.
45
+ Unavailable, stale, or unverified feedback must not trigger blind regeneration or a claim that the model passed. Hosts without health services return unverified health with a reason.
46
+ For confirmed primitive or depth overflow, repair the same target while preserving design, dimensions, active parameter overrides, and unrelated objects. At most two geometry repairs are allowed per target in one logical run.
47
+ Empty Areas may be pending, intentionally empty, mesh-only, or failed. Overflow flags take precedence over possibly stale counts.
48
+ max_total_depth reports the engine's tier-dependent nesting allowance, not measured depth. Compiled Boolean depth includes generated grouping; organizational Groups do not add a level. Use flag_depth_overflow to detect evaluator rejection.
49
+ Call scene.edit(callback, expectedRevision)
50
+ for one coherent undoable change. The callback receives a draft; all node/material/curve functions and enums
51
+ are properties of that draft. The draft expires when the callback ends. Await asynchronous methods.
52
+ Do not start unrelated operations from inside an edit. A stale revision requires reading again.
53
+ Preserve the existing scene unless asked to remove or replace content. Use semantic object names and reuse
54
+ known IDs when editing rather than creating duplicates. Distances follow the current project's units
55
+ (read().unitMultiplier is 1 for millimetres, 0.1 for centimetres, approximately 0.0393701 for inches).
56
+ Convert a dimension in millimetres to project units by multiplying it by read().unitMultiplier.
57
+ The SDK-backed draft clamps dimension writes to 0.01–1000 mm per component before converting to native units; this is an adapter limit, not a scene-wide size limit. Read back dimensions when physical size matters.
58
+ Area size is a dimensionless scale; Group size stays fixed at one. Lights must be created at root (parentId 0); their size has x/y dimensions.
59
+ Light power must be a finite number in [0, 100].
60
+ nodeCreate(Curve, parentId) creates an empty curve with zero blend. curvePointAdd(curveId, point, index?) returns the new index; omit index to append or supply an integer from 0 through point count to insert before that position.
61
+ Point defaults: position/rotation/handles zero, profile size {x:1,y:1,z:1} in project units, the curve's material, fixed false, round 0. Pass {} for defaults.
62
+ Removing all points is supported. One point renders a single profile primitive, not a path.
63
+ Curves render sampled profiles, not a continuous sweep. For smooth curves, start with nodeSetDensity(curveId, 250) and nodeSetBlend(curveId, 3).
64
+ Density increases sampling and primitive cost; the 3 mm blend softens joins, not sample positions. Adjust blend to preserve thin features and the intended silhouette; these settings do not guarantee continuity.
65
+ nodeSetUnionResolution(unionId, resolution) sets Area/Union resolution, not child-part resolution, and clamps to 0.01–0.65; higher values increase detail and voxel cost. Material emission ranges from 0 to 1.
66
+ nodeSetBlend and mirror goop take radii in millimetres before parent scaling, regardless of project units, and clamp to 0–500 mm. This does not apply to the FlowIR Boolean Blend port, which takes native units; geometry-nodes-reference gives its conversion.
67
+ V1/V2 curve transforms are read-only;
68
+ edit their points instead. Handles and closure are available only on V3 Bezier curves.
69
+ Typed rotations are right-handed intrinsic XYZ Euler degrees, relative to the parent (R = Rx Ry Rz), including curve points and mirrors.
70
+ Points and curve mirrors use the owning curve frame; primitive mirrors use the primitive's parent frame.
71
+ lookAt(origin, target) aligns local +Z; both points must be in the same parent-local space. For a segment along a different local axis,
72
+ use alignAxis(direction, 'x'|'y'|'z'); cylinders and cones extend along local Y. These return rotations, not world-space transforms.
73
+ Alignment chooses a deterministic roll using Y-up (Z-up fallback when parallel); coincident/non-finite directions are errors.
74
+ The SDK handles native XZY conversion; do not swap Euler components or convert again. Geometry Nodes source remains native XZY.
75
+ Colors use RGB values from 0 to 255. Use the exposed enums instead of guessing values.
76
+ For visible geometry color, create or clone a material, call materialSetColor, and assign it with nodeSetMaterialId only to
77
+ Primitive, Curve, Text, SVG, Mesh, Field, Decal or OpenSCAD nodes. Assigning a curve material recolors its existing points; use curvePointSetMaterialId for per-point overrides afterward.
78
+ Areas/Unions, Groups, Lights and Sockets do not accept direct materials. Color ordinary containers through their supported children.
79
+ Geometry Nodes materials belong in the graph: create or import the material here, then assign it inside the graph with the FlowIR SetMaterial node, passing the numeric ID this skill returns.
80
+ Never replace a parametric graph with ordinary primitives to color it. Ordinary Stain/CSG children do not modify graph output.
81
+ nodeSetColor writes the node color field (used by lights); it does not replace a geometry object's material color.
82
+ Do not recolor a shared material unless every object using it should change. Create and assign the material in the same edit as the scene geometry that uses it; a Geometry Nodes graph instead consumes a material ID created in an earlier edit.
83
+ Create primitives, curves, and text inside Areas/Unions. Respect tree order for subtraction and intersection.
84
+ Groups are organizational only; move and rotate their child Areas individually, not the Group. Use unions for blended masses.
85
+ Sphere uses size.x as uniform diameter (y/z do not stretch it), including new or resized spherical curve points. Cylinder/Cone/Pyramid use x as base diameter/width and y as height;
86
+ Torus uses x as outer diameter and y as tube thickness. For these four primitives, nodeSetSize accepts {x, y}; z follows x even when supplied. Cube uses all three dimensions. Node and curve-point round are dimensionless fractions clamped to [0, 1], not radii in project units; rounding does not set size. Cube rounding reaches half its shortest dimension at 1, so a shallow Cube retains broad flat faces rather than becoming an ellipsoid.
87
+ Build and verify coarse geometry and unfamiliar operations before replicating details. Check one representative result, then repeat it.
67
88
  Reference fidelity:
68
89
  When recreating an image, treat its visible geometry and level of detail as the target—not merely its subject, colors, or list of parts. Preserve the user's chosen workflow and editability requirements.
69
90
  Establish a comparison view matching the reference's angle, projection, and subject framing as closely as practical. Match silhouette, proportions, pose, and negative spaces first; then local contours, thickness, curvature, surface transitions, and defining details. Infer unseen geometry conservatively.
70
91
  Compare each rendered pass directly with the source. Identify the largest remaining visible shape mismatch, edit it, and verify that it improved before moving to smaller details. Use close-ups when the comparison view cannot resolve a feature; return to the comparison view afterward. Adjust sampling or resolution when visible faceting or segmentation requires it—not as a substitute for reshaping.
71
92
  Do not stop at semantic resemblance or after a fixed number of screenshots. Continue while substantial visible mismatches remain and supported edits are making progress, within the available budget. If progress stalls or the chosen representation cannot achieve the requested fidelity, explain the specific limitation and ask before changing workflows. Before finishing, compare the latest render with the reference and address the largest remaining visible mismatch; if substantial differences remain, identify the concrete stopping reason rather than treating an approximation disclaimer as completion.
72
93
 
73
- nodeClone is shallow (no children). await cloneSubtree(id, { parentId?, name?, index? }) copies the complete hierarchy with fresh IDs,
74
- preserving local transforms, order and metadata, remapping internal references and sharing external materials/assets.
75
- Cloning requires a host transport with atomic scene patches; an unsupported host rejects it without changing the draft.
76
- For primitives, nodeSetBlend joins against all accumulated preceding geometry in the Area, not only the nearest part. A Union's blend affects its outer join, not joins between its children; top-level Area blend does not smooth its contents.
77
- For organic joins, overlap primitives and tune blend on the joining children while preserving intended gaps; keep crisp accessories separate.
78
- Check font, HDR, and material catalogs before selecting names. A host can explicitly report a catalog as
79
- unavailable. Selecting an HDR resets its intensity; apply a custom intensity after selecting it.
80
- For library materials, use listSuperMaterials(), then await materialImport(name) inside scene.edit and assign the returned numeric ID the way that object requires: nodeSetMaterialId or curvePointSetMaterialId for scene nodes, the FlowIR SetMaterial node for a Geometry Nodes graph. A failed import rolls back the draft.
81
- Filter large read results with JavaScript and print only useful details.
82
- After a major edit, capture a screenshot outside the transaction and emitImage its reference.
83
- screenshot({ camera: { position: {x,y,z}, target: {x,y,z}, projection: 'perspective', horizontalFovDegrees: 60 } })
84
- sets a world-space camera in the same Womp coordinates and project units as scene positions (Y-up).
85
- For orthographic framing use projection: 'orthographic' and orthographicWidth in project units instead of FOV.
86
- Direction is derived from position/target; do not use coincident points or an exactly vertical viewing direction.
94
+ nodeClone is shallow (no children). await cloneSubtree(id, { parentId?, name?, index? }) copies the complete hierarchy with fresh IDs,
95
+ preserving local transforms, order and metadata, remapping internal references and sharing external materials/assets.
96
+ Cloning requires a host transport with atomic scene patches; an unsupported host rejects it without changing the draft.
97
+ For primitives, nodeSetBlend joins against all accumulated preceding geometry in the Area, not only the nearest part. A Union's blend affects its outer join, not joins between its children; top-level Area blend does not smooth its contents.
98
+ For organic joins, overlap primitives and tune blend on the joining children while preserving intended gaps; keep crisp accessories separate.
99
+ Check font, HDR, and material catalogs before selecting names. A host can explicitly report a catalog as
100
+ unavailable. Selecting an HDR resets its intensity; apply a custom intensity after selecting it.
101
+ For library materials, use listSuperMaterials(), then await materialImport(name) inside scene.edit and assign the returned numeric ID the way that object requires: nodeSetMaterialId or curvePointSetMaterialId for scene nodes, the FlowIR SetMaterial node for a Geometry Nodes graph. A failed import rolls back the draft.
102
+ Filter large read results with JavaScript and print only useful details.
103
+ After a major edit, capture a screenshot outside the transaction and emitImage its reference.
104
+ screenshot({ camera: { position: {x,y,z}, target: {x,y,z}, projection: 'perspective', horizontalFovDegrees: 60 } })
105
+ sets a world-space camera in the same Womp coordinates and project units as scene positions (Y-up).
106
+ For orthographic framing use projection: 'orthographic' and orthographicWidth in project units instead of FOV.
107
+ Direction is derived from position/target; do not use coincident points or an exactly vertical viewing direction.
87
108
  Omitted projection/lens settings come from the active camera. Pass the camera again for repeatable captures.
88
109
  For object-focused screenshots, identify the requested object and frame it with an explicit camera position and target using its bounds; reuse established framing when appropriate. Choose a view that reveals the requested features without clipping the subject.
89
110
  Omitting camera captures the active viewport without auto-fit. Use it when the user asks about the current viewport or clearly refers to what is visible on screen, not on the assumption that it shows the requested object.
90
111
  Respect the host's camera-lock restrictions. Verify silhouette,
91
- connections, openings, proportions, and materials before claiming completion. Use the owning top-level Area for scene-space bounds;
92
- OpenSCAD part bounds are local to the part; individual primitive and curve bounds are unsupported. Renderer bounds and resize calls
93
- must finish before subsequent writes. Inside scene.edit, complete renderer-backed bounds reads before the first
94
- draft mutation; the SDK rejects them once the draft is dirty. Inspect committed results when renderer state has not caught up.
112
+ connections, openings, proportions, and materials before claiming completion. Use the owning top-level Area for scene-space bounds;
113
+ OpenSCAD part bounds are local to the part; individual primitive and curve bounds are unsupported. Renderer bounds and resize calls
114
+ must finish before subsequent writes. Inside scene.edit, complete renderer-backed bounds reads before the first
115
+ draft mutation; the SDK rejects them once the draft is dirty. Inspect committed results when renderer state has not caught up.
95
116
  A failed script can retain earlier committed effects. Use receipts and inspect unknown outcomes before retrying.`,
96
117
  declarations: WOMP_SCENE_DECLARATIONS,
97
118
  examples: [
@@ -104,7 +125,7 @@ A failed script can retain earlier committed effects. Use receipts and inspect u
104
125
  ],
105
126
  createApi: (context) => {
106
127
  const health = async (objectIds) => {
107
- const ids = z.array(z.number().int().positive()).default([]).parse(objectIds);
128
+ const ids = readHealthObjectIds(objectIds);
108
129
  context.signal.throwIfAborted();
109
130
  if (!host.health)
110
131
  return unverifiedSceneHealth([], "This host does not provide scene health services.");
@@ -0,0 +1,8 @@
1
+ /**
2
+ * True when every component is strictly between 0 and 1 and none is an integer
3
+ * -- which no color anyone means in 0-255 terms can be, and no saturated 0-1
4
+ * color is either, since 0 and 1 are integers.
5
+ */
6
+ export declare function looksLikeUnitColor(components: readonly number[]): boolean;
7
+ /** Refuses a 0-1 color, carrying the 0-255 conversion so it can be corrected in one step. */
8
+ export declare function assertColor255(components: readonly number[], label?: string): void;
package/dist/color.js ADDED
@@ -0,0 +1,29 @@
1
+ // The one place that says what a color component means here.
2
+ //
3
+ // WHY THIS FILE EXISTS. Every model-facing color in this package is 0-255 per
4
+ // channel, converted to the engine's 0-1 by dividing by 255. A model handed a
5
+ // unitless `{ r, g, b }` next to `nodeSetPos`/`nodeSetRot`, which are in project
6
+ // units, has every reason to read it as 0-1: `nodeSetColor({ r: 0.2, g: 0.4,
7
+ // b: 0.6 })` used to divide again and read back as `{ r: 0, g: 0, b: 1 }` --
8
+ // silently near-black, with no error and no warning. Saturated colors survive
9
+ // either reading (`{ r: 1, g: 0, b: 0 }` and `{ r: 255, g: 0, b: 0 }` are both
10
+ // legible), which is what hid it.
11
+ //
12
+ // So a color that can only be a 0-1 one is refused, and nothing is auto-scaled:
13
+ // multiplying by 255 would reinterpret a legitimate near-black instead of
14
+ // asking, and guessing is what made this a defect in the first place.
15
+ /**
16
+ * True when every component is strictly between 0 and 1 and none is an integer
17
+ * -- which no color anyone means in 0-255 terms can be, and no saturated 0-1
18
+ * color is either, since 0 and 1 are integers.
19
+ */
20
+ export function looksLikeUnitColor(components) {
21
+ return components.every((value) => Number.isFinite(value) && value > 0 && value < 1 && Number.isInteger(value) === false);
22
+ }
23
+ /** Refuses a 0-1 color, carrying the 0-255 conversion so it can be corrected in one step. */
24
+ export function assertColor255(components, label = "Color") {
25
+ if (looksLikeUnitColor(components) === false)
26
+ return;
27
+ const [r = 0, g = 0, b = 0] = components;
28
+ throw new Error(`${label} components are 0-255, so {r:${r}, g:${g}, b:${b}} is near-black. Use {r:${Math.round(r * 255)}, g:${Math.round(g * 255)}, b:${Math.round(b * 255)}} for that color, or {r:0, g:0, b:0} for black.`);
29
+ }