cozyclay 3.1.0 → 3.1.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.
- package/CHANGELOG.md +26 -0
- package/bin/agent/agent-routes.mjs +8 -7
- package/bin/agent/plan-tool.mjs +114 -0
- package/bin/agent/script-tool.mjs +2 -0
- package/bin/agent/sculpt-sketch.mjs +190 -0
- package/bin/agent/session-store.mjs +2 -1
- package/bin/agent/sketch-code.mjs +151 -0
- package/bin/agent/sketch-sandbox.mjs +102 -0
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/turn-frames.mjs +4 -0
- package/dist/app/index.html +5 -5
- package/dist/assets/TaskBoard-ngdqiu7u.js +1 -0
- package/dist/assets/app-DmR7jBkz.js +5144 -0
- package/dist/assets/{motion3-compile-B887n77J.js → motion3-compile-BB3_1-EB.js} +1 -1
- package/dist/assets/{scene-asset-sync-COGOlF5T.css → scene-asset-sync-CBx7XRti.css} +1 -1
- package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +13 -0
- package/dist/assets/task-board-ErQvfoaz.js +14 -0
- package/dist/assets/workflow-zQM-EKW1.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/tool-handlers.mjs +2 -2
- package/package.json +3 -1
- package/src/App.jsx +2 -2
- package/src/commands/elements.js +4 -3
- package/src/commands/objects.js +1 -1
- package/src/material-look.js +189 -0
- package/src/material-presets.js +78 -0
- package/src/material-textures.js +157 -0
- package/src/object-look.js +9 -5
- package/src/primitive-look.js +38 -0
- package/src/props.jsx +20 -28
- package/src/render-passes.js +7 -1
- package/src/scene-objects.js +2 -2
- package/src/sculpt-colours.js +65 -0
- package/src/sculpt-compare.js +13 -50
- package/src/sculpt-csg.js +112 -0
- package/src/sculpt-curve.js +123 -0
- package/src/sculpt-cutters.js +43 -0
- package/src/sculpt-detail.js +207 -0
- package/src/sculpt-geometry-loft-convex.js +100 -0
- package/src/sculpt-geometry-shapes.js +250 -0
- package/src/sculpt-geometry.js +33 -15
- package/src/sculpt-loft-rings.js +225 -0
- package/src/sculpt-noise.js +65 -0
- package/src/sculpt-part-fields.js +27 -0
- package/src/sculpt-recipe-read.js +65 -0
- package/src/sculpt-recipe.js +33 -75
- package/src/sculpt-run.js +1 -1
- package/src/sculpt-scatter-items.js +183 -0
- package/src/sculpt-scatter-shape.js +57 -0
- package/src/sculpt-scatter.js +124 -0
- package/src/sculpt-shapes-curved.js +211 -0
- package/src/sculpt-shapes-loft-convex.js +218 -0
- package/src/sculpt-shapes.js +138 -0
- package/src/sculpt-sketch-state.js +76 -0
- package/src/sculpt-sketch.jsx +124 -0
- package/src/sculpt-surface-fields.js +65 -0
- package/src/studio-elements.js +5 -1
- package/src/workflow/AgentPanel.jsx +4 -4
- package/src/workflow/AgentPlanCard.jsx +41 -0
- package/src/workflow/agent-client.js +14 -11
- package/src/workflow/agent-plan.css +93 -0
- package/src/workflow/agent-plan.js +59 -0
- package/tools/run-tests.mjs +3 -3
- package/dist/assets/TaskBoard-DdDXMgZm.js +0 -1
- package/dist/assets/app-B2BpfCl2.js +0 -5088
- package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
- package/dist/assets/task-board-BKaM7d-9.js +0 -14
- package/dist/assets/workflow-B8Dhzexj.js +0 -179
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 3.1.1 - 2026-10-23
|
|
6
|
+
|
|
7
|
+
Sculpt gets new shapes, cutters, detail and materials, and the Studio agent shows its plan and a live sketch.
|
|
8
|
+
|
|
9
|
+
Sculpt
|
|
10
|
+
|
|
11
|
+
- New sculpt shapes: loft, convex hull, lathe, tube and extrude, plus an
|
|
12
|
+
instanced scatter (blade, tuft, flower, leaf, pebble, twig) that fills a
|
|
13
|
+
part with up to 12000 items. Bad points are refused with their path.
|
|
14
|
+
- Boolean cutters: a part with `op: subtract | intersect` cuts its parent for
|
|
15
|
+
windows, doors, arches, hollow bowls and cups, notches, half-domes and
|
|
16
|
+
slices.
|
|
17
|
+
- Surface `detail` (seeded displacement) and colour `gradient` on any shape.
|
|
18
|
+
- Material presets for sculpt parts and primitives: clay, wood, straw, stone,
|
|
19
|
+
brick, concrete, metal, glass, ceramic, plastic, rubber, fabric, paper and
|
|
20
|
+
foliage, each with a matte, satin or gloss finish.
|
|
21
|
+
|
|
22
|
+
Agent
|
|
23
|
+
|
|
24
|
+
- A step checklist for long Studio turns, pinned under the prompt while the
|
|
25
|
+
turn runs and restored with the session.
|
|
26
|
+
- A live ghost sketch of the sculpt in the viewport while the agent is still
|
|
27
|
+
writing the script. It is never picked, saved or exported.
|
|
28
|
+
- The Studio prompt, MCP and Sculpt mode know the new shapes, materials and
|
|
29
|
+
fields.
|
|
30
|
+
|
|
5
31
|
## 3.1.0 - 2026-10-23
|
|
6
32
|
|
|
7
33
|
Hands that hold, throw and catch with weight, Studio tasks in the chat, faster playback, and a leaner first run.
|
|
@@ -379,9 +379,9 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
379
379
|
record.next = value.eventSeq;
|
|
380
380
|
const previous = record.events.at(-1);
|
|
381
381
|
if (value.type === "job.progress" && previous?.type === "job.progress" && previous.jobId === value.jobId) record.events[record.events.length - 1] = value;
|
|
382
|
-
// The panel's live frames (turn-frames.mjs, a script's nested calls) keep one replay slot per run of them.
|
|
382
|
+
// The panel's live frames (turn-frames.mjs, a script's nested calls, a call's sculpt sketch) keep one replay slot per run of them.
|
|
383
383
|
else if (value.type === "thinking.delta" && previous?.type === "thinking.delta") record.events[record.events.length - 1] = { ...value, text: previous.text + value.text };
|
|
384
|
-
else if (
|
|
384
|
+
else if (["tool.progress", "sculpt.sketch"].includes(value.type) && previous?.type === value.type && previous.callId === value.callId) record.events[record.events.length - 1] = value;
|
|
385
385
|
else record.events.push(value);
|
|
386
386
|
if (record.events.length > 256) record.events.splice(0, record.events.length - 256);
|
|
387
387
|
if (["done", "error", "receipt"].includes(value.type)) record.terminal = true;
|
|
@@ -425,8 +425,8 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
425
425
|
};
|
|
426
426
|
const handleStudioTurn = async (req, res, value, path) => {
|
|
427
427
|
const { StudioProtocolError, validateStudioContextFreshness } = await import("../../src/studio-agent-protocol.js");
|
|
428
|
-
const [{ createStudioTools }, { encodeStudioContext }, { createScriptTool }, { runSupervisor, shouldSupervise }] = await Promise.all([
|
|
429
|
-
import("./studio-tools.mjs"), import("../../src/studio-agent-context.js"), import("./script-tool.mjs"), import("./supervisor.mjs"),
|
|
428
|
+
const [{ createStudioTools }, { encodeStudioContext }, { createScriptTool }, { runSupervisor, shouldSupervise }, { createPlanTool }] = await Promise.all([
|
|
429
|
+
import("./studio-tools.mjs"), import("../../src/studio-agent-context.js"), import("./script-tool.mjs"), import("./supervisor.mjs"), import("./plan-tool.mjs"),
|
|
430
430
|
]);
|
|
431
431
|
const hubDeps = await runtime; const hub = hubDeps.liveHub || liveHub;
|
|
432
432
|
if (path === "/agent/stop") {
|
|
@@ -477,7 +477,7 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
477
477
|
let replySegment = "", lastReply = "";
|
|
478
478
|
const send = event => {
|
|
479
479
|
if (event.type === "text.delta") { replySegment += event.text ?? ""; lastReply = replySegment; }
|
|
480
|
-
else if (!["execution_telemetry", "execution_tool_started", "thinking.delta", "tool.compose", "tool.progress"].includes(event.type)) {
|
|
480
|
+
else if (!["execution_telemetry", "execution_tool_started", "thinking.delta", "tool.compose", "tool.progress", "sculpt.sketch"].includes(event.type)) {
|
|
481
481
|
if (event.type === "tool.start") replySegment = "";
|
|
482
482
|
turnFrames.push(event);
|
|
483
483
|
}
|
|
@@ -522,7 +522,8 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
522
522
|
},
|
|
523
523
|
}));
|
|
524
524
|
const turnTransaction = studioTransaction({ hub, context: current, turnId: value.turnId, admission, task: value.task, signal: controller.signal, plain: Boolean(value.main) });
|
|
525
|
-
|
|
525
|
+
// The turn's step checklist (plan-tool.mjs): the model calls it directly or inside a script, and the panel shows it.
|
|
526
|
+
const plan = createPlanTool({ emit: send }), script = createScriptTool({ tools: [...modelTools, plan.nested], signal: controller.signal, emit: send });
|
|
526
527
|
const scriptTool = {
|
|
527
528
|
...script,
|
|
528
529
|
// A script that fails after it edited the scene rolls back its own edits, so
|
|
@@ -604,7 +605,7 @@ export function createAgentHandler({ auth = defaultAuth, codex, models, codexBas
|
|
|
604
605
|
for await (const frame of turnTransaction.run(input => session.modelSession.start(input))({
|
|
605
606
|
surface: "studio", sessionId: value.sessionId, model: modelKey, effort: value.effort,
|
|
606
607
|
text: value.text, attachments: value.attachments, contextText: encodeStudioContext(value.context) + (value.main?.contextText() ?? ""), frameObservation,
|
|
607
|
-
reportCost: Boolean(value.task || value.main), context: value.context, tools: value.main ? value.main.tools({ studio: modelTools, hub, admission }) : [...modelTools, scriptTool], systemPrompt: value.main?.systemPrompt ?? (value.context.scene?.previsMode === "storyboard" ? STUDIO_SYSTEM_PROMPT_STORYBOARD : undefined), signal: controller.signal, emit: send,
|
|
608
|
+
reportCost: Boolean(value.task || value.main), context: value.context, tools: value.main ? value.main.tools({ studio: modelTools, hub, admission }) : [...modelTools, plan.track(scriptTool), plan], systemPrompt: value.main?.systemPrompt ?? (value.context.scene?.previsMode === "storyboard" ? STUDIO_SYSTEM_PROMPT_STORYBOARD : undefined), signal: controller.signal, emit: send,
|
|
608
609
|
meta: persistenceMeta,
|
|
609
610
|
quotaEvent: headers => codex?.parseQuotaHeaders ? quotaEvent(codex, headers) : { type: "quota", plan: null, primary: { usedPercent: null, windowMinutes: null, resetAt: null }, credits: { has: null } },
|
|
610
611
|
})) emitFrame(frame);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// The Studio agent's `plan` tool: a short step checklist the Agent panel shows at the top of a long turn ("수레 몸체 ✓ →
|
|
2
|
+
// 진열 소품 ● → 자전거 → 풀밭 → 조명·카메라"), so the user always sees what is under way and what comes next. It is
|
|
3
|
+
// side-effect free: it never reaches the editor, so the scene, undo history and document stay as they are, and it answers
|
|
4
|
+
// at once. Every change goes to the panel as one plan.update frame with the whole list ({ steps: [{ text, status }] }).
|
|
5
|
+
//
|
|
6
|
+
// One plan per turn (agent-routes.mjs makes one for each Studio turn). A script calls it too, as tools.plan, at no model
|
|
7
|
+
// turn of its own (`nested`): there a refusal is returned rather than thrown, so a bookkeeping slip never fails the
|
|
8
|
+
// script or rolls its edits back. The tool a plan change happened in carries the list in its result (`track`), which is
|
|
9
|
+
// how the persisted session history (session-store.mjs transcriptFromHistory) brings the card back after a reload.
|
|
10
|
+
|
|
11
|
+
const MIN_STEPS = 2, MAX_STEPS = 8, STEP_CHARS = 60;
|
|
12
|
+
const FIELDS = ["steps", "done", "active", "add"];
|
|
13
|
+
|
|
14
|
+
const DESCRIPTION = [
|
|
15
|
+
"Show the user a short step checklist in the Agent panel. It changes nothing in the scene, undo history or document, and answers at once.",
|
|
16
|
+
"For any request with 3 or more visible stages (building a set or several props, a multi-shot sequence), call plan first with short steps, in the same message as your first real call; then mark each step done right after its tool call — inside your script when you use one. Inside a script, tools.plan costs no extra turn.",
|
|
17
|
+
`{ steps: [${MIN_STEPS}-${MAX_STEPS} labels of a few words, in the user's language] } sets the list (again: replaces it) and step 0 is active; { done: i or [i, …], active?: i } updates it (indexes from 0; after done without active, the next step becomes active); { add: [labels] } appends steps.`,
|
|
18
|
+
].join("\n");
|
|
19
|
+
|
|
20
|
+
const PARAMETERS = {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
steps: { type: "array", items: { type: "string" }, description: `${MIN_STEPS}-${MAX_STEPS} short step labels; replaces the plan` },
|
|
24
|
+
done: { anyOf: [{ type: "integer" }, { type: "array", items: { type: "integer" } }], description: "index or indexes (from 0) of the steps now done" },
|
|
25
|
+
active: { type: "integer", description: "index of the step now under way" },
|
|
26
|
+
add: { type: "array", items: { type: "string" }, description: "labels appended as pending steps" },
|
|
27
|
+
},
|
|
28
|
+
required: [],
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const refusal = message => Object.assign(new Error(message), { code: "INVALID_ARGUMENT" });
|
|
33
|
+
const isRecord = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
34
|
+
|
|
35
|
+
/** Labels from `field`, trimmed to one line each, or a refusal that says which one is wrong. */
|
|
36
|
+
function labels(field, value, min) {
|
|
37
|
+
if (!Array.isArray(value) || value.some(text => typeof text !== "string")) throw refusal(`${field} must be an array of short strings (a few words each).`);
|
|
38
|
+
const texts = value.map(text => text.replace(/\s+/g, " ").trim());
|
|
39
|
+
const empty = texts.findIndex(text => !text);
|
|
40
|
+
if (empty !== -1) throw refusal(`${field}[${empty}] is empty.`);
|
|
41
|
+
const long = texts.findIndex(text => text.length > STEP_CHARS);
|
|
42
|
+
if (long !== -1) throw refusal(`${field}[${long}] is ${texts[long].length} characters; keep each step under ${STEP_CHARS} (a few words).`);
|
|
43
|
+
if (texts.length < min) throw refusal(`${field} has ${texts.length} item${texts.length === 1 ? "" : "s"}; a plan has ${MIN_STEPS}-${MAX_STEPS} steps (skip plan for a small request).`);
|
|
44
|
+
return texts;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A step index from `field`, or a refusal naming the plan's range. */
|
|
48
|
+
function index(field, value, count) {
|
|
49
|
+
if (Number.isInteger(value) && value >= 0 && value < count) return value;
|
|
50
|
+
throw refusal(`${field} is ${JSON.stringify(value)}; the plan has ${count} steps, so a step index is 0-${count - 1}.`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The plan after one call: `steps` replaces the list, `add` appends, `done` checks steps off and `active` names the
|
|
55
|
+
* one under way (any other active step goes back to pending). With no active step left, the step after the last one
|
|
56
|
+
* just done (else the first pending one) becomes active, so a new plan starts at step 0.
|
|
57
|
+
*/
|
|
58
|
+
function nextPlan(current, args) {
|
|
59
|
+
if (!isRecord(args)) throw refusal("plan takes an object: { steps: [...] } to set the plan, or { done, active } to update it.");
|
|
60
|
+
const unknown = Object.keys(args).filter(key => !FIELDS.includes(key));
|
|
61
|
+
if (unknown.length) throw refusal(`plan has unknown field${unknown.length > 1 ? "s" : ""} ${unknown.map(key => JSON.stringify(key)).join(", ")}; allowed: ${FIELDS.join(", ")}.`);
|
|
62
|
+
if (!FIELDS.some(key => args[key] !== undefined)) throw refusal(`plan needs steps (${MIN_STEPS}-${MAX_STEPS} short labels) to set the plan, or done/active/add to update it.`);
|
|
63
|
+
let steps = args.steps === undefined ? current : labels("steps", args.steps, MIN_STEPS).map(text => ({ text, status: "pending" }));
|
|
64
|
+
if (!steps.length) throw refusal("There is no plan yet: set one with { steps: [...] } first.");
|
|
65
|
+
if (args.add !== undefined) steps = [...steps, ...labels("add", args.add, 1).map(text => ({ text, status: "pending" }))];
|
|
66
|
+
if (steps.length > MAX_STEPS) throw refusal(`That makes ${steps.length} steps; a plan has at most ${MAX_STEPS}: merge some.`);
|
|
67
|
+
const done = args.done === undefined ? [] : Array.isArray(args.done) ? args.done.map((value, i) => index(`done[${i}]`, value, steps.length)) : [index("done", args.done, steps.length)];
|
|
68
|
+
const active = args.active === undefined ? null : index("active", args.active, steps.length);
|
|
69
|
+
if (done.includes(active)) throw refusal(`Step ${active} cannot be both done and active.`);
|
|
70
|
+
steps = steps.map((step, i) => i === active ? { ...step, status: "active" } : done.includes(i) ? { ...step, status: "done" } : active !== null && step.status === "active" ? { ...step, status: "pending" } : step);
|
|
71
|
+
if (!steps.some(step => step.status === "active")) {
|
|
72
|
+
const from = done.length ? Math.max(...done) + 1 : 0;
|
|
73
|
+
const next = steps.findIndex((step, i) => i >= from && step.status === "pending");
|
|
74
|
+
const advance = next !== -1 ? next : steps.findIndex(step => step.status === "pending");
|
|
75
|
+
if (advance !== -1) steps = steps.map((step, i) => i === advance ? { ...step, status: "active" } : step);
|
|
76
|
+
}
|
|
77
|
+
return steps;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* createPlanTool({ emit }) -> the model-facing `plan` tool (bin/agent/pi-tools.mjs toAgentTools shape), plus
|
|
82
|
+
* `nested` (the same plan for a script's tools.plan: never throws, adds no call to the script's summary or progress)
|
|
83
|
+
* and `track(tool)` (that tool's result carries `plan` when the plan changed during its call, and `planRefused`
|
|
84
|
+
* when a nested call was refused).
|
|
85
|
+
*/
|
|
86
|
+
export function createPlanTool({ emit } = {}) {
|
|
87
|
+
let steps = [], version = 0;
|
|
88
|
+
const refused = [];
|
|
89
|
+
const apply = args => {
|
|
90
|
+
const next = nextPlan(steps, args);
|
|
91
|
+
if (JSON.stringify(next) !== JSON.stringify(steps)) {
|
|
92
|
+
steps = next; version += 1;
|
|
93
|
+
// a failing view never fails the call
|
|
94
|
+
try { emit?.({ type: "plan.update", steps }); } catch { /* advisory */ }
|
|
95
|
+
}
|
|
96
|
+
return { ok: true, plan: steps };
|
|
97
|
+
};
|
|
98
|
+
const tool = { name: "plan", label: "Plan", description: DESCRIPTION, parameters: PARAMETERS, handler: async args => apply(args) };
|
|
99
|
+
return {
|
|
100
|
+
...tool,
|
|
101
|
+
nested: { ...tool, quiet: true, handler: async args => {
|
|
102
|
+
try { return apply(args); } catch (error) {
|
|
103
|
+
refused.push(error.message);
|
|
104
|
+
return { ok: false, code: error.code ?? "INVALID_ARGUMENT", message: error.message };
|
|
105
|
+
}
|
|
106
|
+
} },
|
|
107
|
+
track: inner => ({ ...inner, handler: async (...args) => {
|
|
108
|
+
const seen = version, result = await inner.handler(...args);
|
|
109
|
+
const notes = refused.splice(0);
|
|
110
|
+
if (!isRecord(result) || (seen === version && !notes.length)) return result;
|
|
111
|
+
return { ...result, ...(seen === version ? {} : { plan: steps }), ...(notes.length ? { planRefused: notes } : {}) };
|
|
112
|
+
} }),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -59,6 +59,8 @@ export function createScriptTool({ tools = [], signal, emit: createdEmit } = {})
|
|
|
59
59
|
name: tool.name,
|
|
60
60
|
description: tool.description,
|
|
61
61
|
execute: async (args, { signal: nestedSignal }) => {
|
|
62
|
+
// A quiet tool (plan-tool.mjs) is the panel's own business: no progress frame, no line in the calls summary.
|
|
63
|
+
if (tool.quiet) return publicData(await tool.handler(args ?? {}, {}));
|
|
62
64
|
const signals = [nestedSignal, runSignal].filter(Boolean), index = ++started;
|
|
63
65
|
let result;
|
|
64
66
|
report(index, tool.name, args, "running");
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// The live sketch: while the model is still writing a tool call that builds sculpts, the parts it has written so
|
|
2
|
+
// far go to the browser as `sculpt.sketch` frames, which the viewport draws as translucent ghost clay
|
|
3
|
+
// (src/sculpt-sketch.jsx). A `script` call is cut to its longest runnable prefix (sketch-code.mjs) and dry-run with
|
|
4
|
+
// stub tools in a throwaway sandbox (sketch-sandbox.mjs); a direct run_action object.sculpt call is read from pi-ai's
|
|
5
|
+
// partially parsed arguments. A model writing an 8 KB script spends minutes on it; without this the stage stays
|
|
6
|
+
// empty until it runs. Frames only: nothing here reaches the model or the scene, and a failed sketch is no sketch.
|
|
7
|
+
import { declaredNames, runnablePrefix, streamedCode } from "./sketch-code.mjs";
|
|
8
|
+
import { dryRunSketch, SKETCH_MAX_PARTS } from "./sketch-sandbox.mjs";
|
|
9
|
+
|
|
10
|
+
/** How often a streaming call is sketched again, at most. */
|
|
11
|
+
const SKETCH_MS = 600;
|
|
12
|
+
/** Dry runs in flight across the sidecar: a sketch never competes with the turn for the machine. */
|
|
13
|
+
const MAX_RUNNING = 2;
|
|
14
|
+
const MAX_FRAME_CHARS = 48 * 1024;
|
|
15
|
+
const MAX_GROUPS = 16;
|
|
16
|
+
const MAX_RAW_CHARS = 512 * 1024;
|
|
17
|
+
const COLOR = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
18
|
+
|
|
19
|
+
let running = 0;
|
|
20
|
+
let sculptShapes = null;
|
|
21
|
+
// Lazy, like every src/ import of the sidecar: the shapes are read only once a call is sketched.
|
|
22
|
+
const loadShapes = () => (sculptShapes ??= import("../../src/sculpt-shapes.js")
|
|
23
|
+
.then((module) => ({ shapes: module.SCULPT_SHAPES, keys: module.SCULPT_SHAPE_KEYS }), () => null));
|
|
24
|
+
|
|
25
|
+
const finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
26
|
+
const tidy = (value) => Math.round(value * 1e4) / 1e4;
|
|
27
|
+
const text = (value, max) => (typeof value === "string" && value.trim() ? value.trim().slice(0, max) : undefined);
|
|
28
|
+
|
|
29
|
+
/** [x, y, z] from an array of three numbers or an { x, y, z } object (a missing axis is 0), else null. */
|
|
30
|
+
function vector(value) {
|
|
31
|
+
if (Array.isArray(value)) return value.length === 3 && value.every(finite) ? value.map(tidy) : null;
|
|
32
|
+
if (!value || typeof value !== "object") return null;
|
|
33
|
+
const axes = [value.x, value.y, value.z];
|
|
34
|
+
return axes.some(finite) && axes.every((axis) => axis === undefined || finite(axis)) ? axes.map((axis) => tidy(axis ?? 0)) : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A part id the recipe accepts ("wheelF" -> "wheel-f"), or "" when nothing is left of it. */
|
|
38
|
+
const sketchId = (value) => typeof value !== "string" ? "" : value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
|
|
39
|
+
.replace(/[^a-z0-9-]+/g, "-").replace(/^-+/, "").slice(0, 40);
|
|
40
|
+
|
|
41
|
+
/** A shape field cut to what a ghost needs: numbers rounded, strings and arrays capped, four levels deep. */
|
|
42
|
+
function plain(value, depth = 0) {
|
|
43
|
+
if (finite(value)) return tidy(value);
|
|
44
|
+
if (typeof value === "string") return value.slice(0, 40);
|
|
45
|
+
if (typeof value === "boolean") return value;
|
|
46
|
+
if (depth > 3 || !value || typeof value !== "object") return undefined;
|
|
47
|
+
if (Array.isArray(value)) return value.slice(0, 256).map((entry) => plain(entry, depth + 1) ?? null);
|
|
48
|
+
return Object.fromEntries(Object.entries(value).slice(0, 16).map(([key, entry]) => [key, plain(entry, depth + 1)]).filter(([, entry]) => entry !== undefined));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One part as a ghost draws it: geometry, place and colour; looks (material, detail, gradient) stay out, cutters too. */
|
|
52
|
+
function sketchPart(raw, fallbackId, { shapes, keys }) {
|
|
53
|
+
if (!raw || typeof raw !== "object" || !shapes.includes(raw.shape) || raw.op) return null;
|
|
54
|
+
const size = vector(raw.size);
|
|
55
|
+
if (!size) return null;
|
|
56
|
+
const part = { id: sketchId(raw.id) || fallbackId, shape: raw.shape, size };
|
|
57
|
+
const position = vector(raw.position), rotation = vector(raw.rotation), parent = sketchId(raw.parent);
|
|
58
|
+
if (position) part.position = position;
|
|
59
|
+
if (rotation) part.rotation = rotation;
|
|
60
|
+
if (typeof raw.color === "string" && COLOR.test(raw.color.trim())) part.color = raw.color.trim();
|
|
61
|
+
if (parent) part.parent = parent;
|
|
62
|
+
if (raw.mirror === true) part.mirror = true;
|
|
63
|
+
for (const key of keys) {
|
|
64
|
+
const value = plain(raw[key]);
|
|
65
|
+
if (value !== undefined) part[key] = value;
|
|
66
|
+
}
|
|
67
|
+
return part;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* sculptSketchGroups(value, env) -> [{ key, name?, target?, placement?, parts }]: the dry run's (or a direct call's)
|
|
72
|
+
* groups, loosely normalized and capped at SKETCH_MAX_PARTS parts and MAX_FRAME_CHARS of JSON (the last parts go
|
|
73
|
+
* first). `target` is the sculpt an object.sculpt { id } call rebuilds; `placement` keeps { x, y, z, rot } numbers.
|
|
74
|
+
*/
|
|
75
|
+
export function sculptSketchGroups(value, env) {
|
|
76
|
+
const groups = [], keys = new Set();
|
|
77
|
+
let count = 0;
|
|
78
|
+
for (const raw of (Array.isArray(value?.groups) ? value.groups : []).slice(0, MAX_GROUPS)) {
|
|
79
|
+
const parts = [], ids = new Set();
|
|
80
|
+
for (const entry of Array.isArray(raw?.parts) ? raw.parts : []) {
|
|
81
|
+
if (count >= SKETCH_MAX_PARTS) break;
|
|
82
|
+
const part = sketchPart(entry, `part-${count}`, env);
|
|
83
|
+
if (!part) continue;
|
|
84
|
+
for (let n = 2; ids.has(part.id); n += 1) part.id = `${part.id.replace(/-\d+$/, "").slice(0, 36)}-${n}`;
|
|
85
|
+
ids.add(part.id);
|
|
86
|
+
parts.push(part);
|
|
87
|
+
count += 1;
|
|
88
|
+
}
|
|
89
|
+
if (!parts.length) continue;
|
|
90
|
+
let key = text(raw.key, 16) ?? `g${groups.length}`;
|
|
91
|
+
while (keys.has(key)) key += "+";
|
|
92
|
+
keys.add(key);
|
|
93
|
+
const placement = Object.fromEntries(["x", "y", "z", "rot"].filter((axis) => finite(raw.placement?.[axis])).map((axis) => [axis, tidy(raw.placement[axis])]));
|
|
94
|
+
const name = text(raw.name, 80), target = text(raw.id, 80);
|
|
95
|
+
groups.push({ key, ...(name ? { name } : {}), ...(target ? { target } : {}), ...(Object.keys(placement).length ? { placement } : {}), parts });
|
|
96
|
+
}
|
|
97
|
+
while (groups.length && JSON.stringify(groups).length > MAX_FRAME_CHARS) {
|
|
98
|
+
const last = groups.at(-1);
|
|
99
|
+
last.parts.splice(Math.floor(last.parts.length * 0.8));
|
|
100
|
+
if (!last.parts.length) groups.pop();
|
|
101
|
+
}
|
|
102
|
+
return groups;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The groups a script's code shows so far: its longest runnable cut, dry-run. Undefined when there is nothing new. */
|
|
106
|
+
async function scriptGroups(state, env) {
|
|
107
|
+
const code = streamedCode(state.call, state.raw);
|
|
108
|
+
// The code only grows while it streams; anything else starts the cut over.
|
|
109
|
+
if (state.cut && !code.startsWith(state.cutText)) state.cut = null;
|
|
110
|
+
const cut = runnablePrefix(code, { after: state.cut?.end ?? 0, whole: state.complete }) ?? state.cut;
|
|
111
|
+
if (!cut || cut.source === state.ran) return undefined;
|
|
112
|
+
Object.assign(state, { cut, cutText: code.slice(0, cut.end), ran: cut.source });
|
|
113
|
+
const value = await dryRunSketch(cut.source, declaredNames(cut.source), env);
|
|
114
|
+
return value ? sculptSketchGroups(value, env) : undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The groups a direct run_action object.sculpt call shows so far; while it streams, its last part may be half written. */
|
|
118
|
+
function directGroups(state, env) {
|
|
119
|
+
const args = state.call.arguments;
|
|
120
|
+
if (args?.action !== "object.sculpt") return undefined;
|
|
121
|
+
const inner = args.args ?? {}, parts = Array.isArray(inner.recipe?.parts) ? inner.recipe.parts : [];
|
|
122
|
+
return sculptSketchGroups({ groups: [{ key: "d", name: inner.name, id: inner.id, placement: inner.placement, parts: state.complete ? parts : parts.slice(0, -1) }] }, env);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* createSculptSketch(push, clock) -> { onMessageEvent(event), settled() }. onMessageEvent takes pi's assistant
|
|
127
|
+
* message events, like turn-frames.mjs; push gets { type: "sculpt.sketch", index, callId?, groups } whenever what a
|
|
128
|
+
* `script` or run_action call has written so far changes, at most every SKETCH_MS while it streams and once more
|
|
129
|
+
* when it is complete, and with empty groups when its message fails or ends without running its tools. The browser
|
|
130
|
+
* drops a call's sketch when its tool.done arrives. settled() resolves once no sketch is running (tests).
|
|
131
|
+
*/
|
|
132
|
+
export function createSculptSketch(push, clock = () => Date.now()) {
|
|
133
|
+
const calls = new Map();
|
|
134
|
+
const send = (state, groups) => {
|
|
135
|
+
const json = JSON.stringify(groups);
|
|
136
|
+
if (state.closed || json === (state.sent ?? "[]")) return;
|
|
137
|
+
state.sent = json;
|
|
138
|
+
try { push({ type: "sculpt.sketch", index: state.index, ...(state.callId ? { callId: state.callId } : {}), groups }); } catch { /* advisory */ }
|
|
139
|
+
};
|
|
140
|
+
const clearAll = () => {
|
|
141
|
+
for (const state of calls.values()) { send(state, []); state.closed = true; }
|
|
142
|
+
calls.clear();
|
|
143
|
+
};
|
|
144
|
+
const schedule = (state) => {
|
|
145
|
+
if (state.closed) return;
|
|
146
|
+
if (state.running) { state.again ||= state.complete; return; }
|
|
147
|
+
if (!state.complete && (clock() - state.at < SKETCH_MS || running >= MAX_RUNNING)) return;
|
|
148
|
+
state.at = clock();
|
|
149
|
+
running += 1;
|
|
150
|
+
state.running = (async () => {
|
|
151
|
+
const env = await loadShapes();
|
|
152
|
+
if (!env || state.closed) return;
|
|
153
|
+
const groups = state.kind === "script" ? await scriptGroups(state, env) : directGroups(state, env);
|
|
154
|
+
if (groups) send(state, groups);
|
|
155
|
+
})().catch(() => { /* a failed sketch is no sketch */ }).finally(() => {
|
|
156
|
+
running -= 1;
|
|
157
|
+
state.running = null;
|
|
158
|
+
if (state.again) { state.again = false; schedule(state); }
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
const onEvent = (event) => {
|
|
162
|
+
const type = event?.type;
|
|
163
|
+
if (type === "error" || (type === "done" && event.reason !== "toolUse")) { clearAll(); return; }
|
|
164
|
+
if (type !== "toolcall_start" && type !== "toolcall_delta" && type !== "toolcall_end") return;
|
|
165
|
+
const call = (type === "toolcall_end" && event.toolCall) || event.partial?.content?.[event.contentIndex];
|
|
166
|
+
if (call?.type !== "toolCall" || (call.name !== "script" && call.name !== "run_action")) return;
|
|
167
|
+
// contentIndex restarts with every assistant message, so a start begins a new call
|
|
168
|
+
let state = type === "toolcall_start" ? null : calls.get(event.contentIndex);
|
|
169
|
+
if (!state || state.kind !== call.name) {
|
|
170
|
+
// The call this one replaces at its index is over: a sketch of it still running sends nothing.
|
|
171
|
+
if (calls.has(event.contentIndex)) calls.get(event.contentIndex).closed = true;
|
|
172
|
+
state = { index: event.contentIndex, kind: call.name, callId: typeof call.id === "string" && call.id ? call.id : null, raw: "", at: clock() };
|
|
173
|
+
calls.set(event.contentIndex, state);
|
|
174
|
+
}
|
|
175
|
+
state.call = call;
|
|
176
|
+
// A provider that names the call only at its end: later frames carry the id (the browser drops the index-keyed one).
|
|
177
|
+
if (!state.callId && typeof call.id === "string" && call.id) state.callId = call.id;
|
|
178
|
+
if (type === "toolcall_delta" && typeof call.arguments?.code !== "string" && state.raw.length < MAX_RAW_CHARS) state.raw += event.delta ?? "";
|
|
179
|
+
if (type === "toolcall_end") state.complete = true;
|
|
180
|
+
schedule(state);
|
|
181
|
+
};
|
|
182
|
+
return {
|
|
183
|
+
onMessageEvent(event) {
|
|
184
|
+
try { onEvent(event); } catch { /* a sketch never stops a turn */ }
|
|
185
|
+
},
|
|
186
|
+
async settled() {
|
|
187
|
+
while ([...calls.values()].some((state) => state.running)) await Promise.all([...calls.values()].map((state) => state.running));
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -347,7 +347,8 @@ export function transcriptFromHistory(history = []) {
|
|
|
347
347
|
}
|
|
348
348
|
if (item?.role === "toolResult") {
|
|
349
349
|
const tool = piCalls.get(item.toolCallId);
|
|
350
|
-
|
|
350
|
+
// A result the turn's plan changed in carries the whole list (plan-tool.mjs); the panel keeps its turn's last one.
|
|
351
|
+
if (tool) tool.ok = !item.isError; if (Array.isArray(item.details?.plan)) transcript.push({ kind: "plan", steps: item.details.plan });
|
|
351
352
|
const receiptId = item.details?.receiptId;
|
|
352
353
|
if (receiptId && !seenReceiptIds.has(receiptId)) {
|
|
353
354
|
seenReceiptIds.add(receiptId);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// The runnable part of a `script` the model is still writing, for the live sketch (sculpt-sketch.mjs): the code
|
|
2
|
+
// streamed so far, cut back to the longest stretch of whole lines that compiles once its open brackets are closed.
|
|
3
|
+
// Compile only: node:vm's Script parses the text and never runs it here; the cut then runs in a throwaway sandbox
|
|
4
|
+
// with stub tools (sketch-sandbox.mjs).
|
|
5
|
+
import { Script } from "node:vm";
|
|
6
|
+
|
|
7
|
+
/** Line ends tried per pass, newest first; a long statement still being written fails them all and the last good cut stays. */
|
|
8
|
+
const MAX_CUTS = 48;
|
|
9
|
+
const ESCAPES = { n: "\n", t: "\t", r: "\r", b: "\b", f: "\f", v: "\v", 0: "\0" };
|
|
10
|
+
const OPENERS = { "{": "}", "(": ")", "[": "]" };
|
|
11
|
+
// After one of these a `/` starts a regex literal; after a value it divides.
|
|
12
|
+
const REGEX_AFTER = /(?:^|[(,=:[!&|?{};+\-*%<>~^])$/;
|
|
13
|
+
const REGEX_WORDS = new Set(["return", "typeof", "case", "do", "else", "in", "of", "void", "delete", "throw", "new", "await", "yield"]);
|
|
14
|
+
const DECLARED = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=|,\s*([A-Za-z_$][\w$]*)\s*=\s*[[{]/g;
|
|
15
|
+
|
|
16
|
+
/** A JSON string body decoded up to its closing quote, or up to the last whole character while it is still streaming. */
|
|
17
|
+
function decodeStreamed(body) {
|
|
18
|
+
let out = "";
|
|
19
|
+
for (let at = 0; at < body.length; at += 1) {
|
|
20
|
+
const char = body[at];
|
|
21
|
+
if (char === "\"") return out;
|
|
22
|
+
if (char !== "\\") { out += char; continue; }
|
|
23
|
+
const next = body[at + 1];
|
|
24
|
+
if (next === undefined) return out;
|
|
25
|
+
if (next === "u") {
|
|
26
|
+
const hex = body.slice(at + 2, at + 6);
|
|
27
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex)) return out;
|
|
28
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
29
|
+
at += 5;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
out += ESCAPES[next] ?? next;
|
|
33
|
+
at += 1;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The script's code so far: pi-ai's partially parsed arguments when the provider fills them, else the raw argument JSON. */
|
|
39
|
+
export function streamedCode(call, raw = "") {
|
|
40
|
+
if (typeof call?.arguments?.code === "string") return call.arguments.code;
|
|
41
|
+
const start = /"code"\s*:\s*"/.exec(raw);
|
|
42
|
+
return start ? decodeStreamed(raw.slice(start.index + start[0].length)) : "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Where a template literal's text ends from `at`: { at, open } (open: a `${` expression starts), or null at the end of the text. */
|
|
46
|
+
function templateEnd(code, at) {
|
|
47
|
+
for (let index = at; index < code.length; index += 1) {
|
|
48
|
+
if (code[index] === "\\") index += 1;
|
|
49
|
+
else if (code[index] === "`") return { at: index, open: false };
|
|
50
|
+
else if (code[index] === "$" && code[index + 1] === "{") return { at: index + 1, open: true };
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The index of a regex literal's last flag from its opening `/`, or -1 when the line ends first (then it was a division). */
|
|
56
|
+
function regexEnd(code, at) {
|
|
57
|
+
let inClass = false;
|
|
58
|
+
for (let index = at + 1; index < code.length; index += 1) {
|
|
59
|
+
const char = code[index];
|
|
60
|
+
if (char === "\n") return -1;
|
|
61
|
+
if (char === "\\") index += 1;
|
|
62
|
+
else if (char === "[") inClass = true;
|
|
63
|
+
else if (char === "]") inClass = false;
|
|
64
|
+
else if (char === "/" && !inClass) {
|
|
65
|
+
while (/[a-z]/i.test(code[index + 1] ?? "")) index += 1;
|
|
66
|
+
return index;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return -1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* scanCode(code) -> { close, declarations } | null. close: the text that closes every bracket `code` leaves open (a
|
|
74
|
+
* `try {` gets an empty catch, an open template expression its backquote); declarations: where a `const` or `let`
|
|
75
|
+
* starts a statement outside every bracket. Null when the code ends inside a string, template text or block
|
|
76
|
+
* comment. A heuristic: the compile check that follows is the judge.
|
|
77
|
+
*/
|
|
78
|
+
export function scanCode(code) {
|
|
79
|
+
const stack = [], declarations = [];
|
|
80
|
+
let last = "", word = "", gap = false;
|
|
81
|
+
for (let at = 0; at < code.length; at += 1) {
|
|
82
|
+
const char = code[at];
|
|
83
|
+
if (/\s/.test(char)) {
|
|
84
|
+
if (!gap && !stack.length && (word === "const" || word === "let") && code[at - word.length - 1] !== ".") declarations.push(at - word.length);
|
|
85
|
+
gap = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (char === "/" && code[at + 1] === "/") { const end = code.indexOf("\n", at); if (end < 0) break; at = end; continue; }
|
|
89
|
+
if (char === "/" && code[at + 1] === "*") { const end = code.indexOf("*/", at + 2); if (end < 0) return null; at = end + 1; continue; }
|
|
90
|
+
if (char === "\"" || char === "'") {
|
|
91
|
+
let end = at + 1;
|
|
92
|
+
for (; end < code.length && code[end] !== char && code[end] !== "\n"; end += 1) if (code[end] === "\\") end += 1;
|
|
93
|
+
if (code[end] !== char) return null;
|
|
94
|
+
at = end; last = char; word = ""; gap = false;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const closesTemplate = char === "}" && stack.at(-1) === "}`";
|
|
98
|
+
if (char === "`" || closesTemplate) {
|
|
99
|
+
if (closesTemplate) stack.pop();
|
|
100
|
+
const end = templateEnd(code, at + 1);
|
|
101
|
+
if (!end) return null;
|
|
102
|
+
if (end.open) stack.push("}`");
|
|
103
|
+
at = end.at; last = "`"; word = ""; gap = false;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (char === "/" && (REGEX_AFTER.test(last) || REGEX_WORDS.has(word))) {
|
|
107
|
+
const end = regexEnd(code, at);
|
|
108
|
+
if (end > 0) { at = end; last = "/"; word = ""; gap = false; continue; }
|
|
109
|
+
}
|
|
110
|
+
if (OPENERS[char]) stack.push(char === "{" && word === "try" ? "}catch{}" : OPENERS[char]);
|
|
111
|
+
else if ("})]".includes(char) && stack.at(-1)?.[0] === char) stack.pop();
|
|
112
|
+
word = /[\w$]/.test(char) ? (!gap && /[\w$]/.test(last) ? word : "") + char : "";
|
|
113
|
+
last = char; gap = false;
|
|
114
|
+
}
|
|
115
|
+
return { close: stack.reverse().join(""), declarations };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Whether `text` parses as the body codemode runs it as (an async function), inside the try block the sketch wraps it in. */
|
|
119
|
+
function compiles(text) {
|
|
120
|
+
try { new Script(`(async (tools, console) => { try {\n${text}\n} finally {} })`); return true; } catch { return false; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `text` with its top-level `const`/`let` turned into `var` (padded to the same length), so the sketch's finally
|
|
124
|
+
* block still sees what the cut declared when a later line threw. */
|
|
125
|
+
const hoisted = (text, declarations) => declarations.reduceRight((out, at) => `${out.slice(0, at)}${out.startsWith("const", at) ? "var " : "var"}${out.slice(at + (out.startsWith("const", at) ? 5 : 3))}`, text);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* runnablePrefix(code, { after = 0, whole = false }) -> { end, source } | null: the longest cut of `code` at a line
|
|
129
|
+
* end past `after` (or all of it, when `whole`: the call is complete) whose text compiles with its open brackets
|
|
130
|
+
* closed. A cut inside a line could hold half a number, so a streaming call is cut at line ends only. `source` is
|
|
131
|
+
* that text, hoisted, plus its closers.
|
|
132
|
+
*/
|
|
133
|
+
export function runnablePrefix(code, { after = 0, whole = false } = {}) {
|
|
134
|
+
const cuts = whole ? [code.length] : [];
|
|
135
|
+
for (let at = code.lastIndexOf("\n"); at > after && cuts.length < MAX_CUTS; at = code.lastIndexOf("\n", at - 1)) cuts.push(at);
|
|
136
|
+
for (const end of cuts) {
|
|
137
|
+
const text = code.slice(0, end), scanned = scanCode(text);
|
|
138
|
+
if (!scanned || !text.trim()) continue;
|
|
139
|
+
const body = hoisted(text, scanned.declarations);
|
|
140
|
+
if (compiles(`${body}\n${scanned.close}`)) return { end, source: `${body}\n${scanned.close}` };
|
|
141
|
+
if (scanned.close && compiles(body)) return { end, source: body };
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The names `source` declares (`var S = [`, `, D = [`), whose arrays of parts the sandbox looks at once the cut has run. */
|
|
147
|
+
export function declaredNames(source) {
|
|
148
|
+
const names = new Set();
|
|
149
|
+
for (const match of source.matchAll(DECLARED)) if (names.size < 40) names.add(match[1] ?? match[2]);
|
|
150
|
+
return [...names].filter((name) => !name.startsWith("__cozy"));
|
|
151
|
+
}
|