@womp/spark-agent 4.2.2 → 4.3.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/.env.example +6 -6
- package/README.md +457 -457
- package/dist/agent-run.d.ts +1 -1
- package/dist/capabilities/flow.js +3 -3
- package/dist/capabilities/geometry.js +14 -14
- package/dist/capabilities/openscad-instructions.d.ts +1 -1
- package/dist/capabilities/openscad-instructions.js +2 -1
- package/dist/capabilities/operations.js +33 -32
- package/dist/capabilities/scene.js +74 -74
- package/dist/client.js +8 -0
- package/dist/geometry/schemas/backend-schema.json +3393 -3393
- package/dist/geometry/schemas/flowir-dialect.json +1023 -1023
- package/dist/geometry.js +6 -6
- package/dist/models.d.ts +12 -5
- package/dist/models.js +16 -7
- package/dist/pricing.js +15 -0
- package/dist/prompt.d.ts +1 -1
- package/dist/prompt.js +65 -70
- package/dist/server/express.js +9 -1
- package/dist/server/fixtures/materials/matte-blue.json +14 -14
- package/dist/server/fixtures/materials/polished-copper.json +14 -14
- package/dist/server/message-fold.d.ts +59 -0
- package/dist/server/message-fold.js +345 -0
- package/dist/server/scene-initialization.d.ts +3 -0
- package/dist/server/scene-initialization.js +44 -0
- package/dist/server/service.d.ts +18 -1
- package/dist/server/service.js +25 -17
- package/dist/server/sqlite-store.js +37 -37
- package/dist/server/standalone.js +3 -0
- package/package.json +139 -139
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// A run's assistant message, rebuilt from its stored chunks a step at a time.
|
|
2
|
+
//
|
|
3
|
+
// WHY. The AI SDK's `readUIMessageStream` deep-copies (`structuredClone`) the
|
|
4
|
+
// whole message on every chunk it writes, so replaying a long run in one pass
|
|
5
|
+
// costs chunks x message size, all of it in already-resolved promises that
|
|
6
|
+
// never yield. `SparkAgentService.finishRun` did exactly that once per run: a
|
|
7
|
+
// 150-step run with 20 KB tool outputs held the process's event loop for
|
|
8
|
+
// 10-13 s as it ended, and a host that serves other requests (crow) went
|
|
9
|
+
// silent for everyone while a thread write in flight timed out.
|
|
10
|
+
//
|
|
11
|
+
// HOW. The fold keeps a SETTLED message: the result of every chunk up to a
|
|
12
|
+
// sequence at which a new step starts and no text, reasoning or tool input is
|
|
13
|
+
// open. Each read folds only the chunks after that sequence onto it, with the
|
|
14
|
+
// same SDK reader, and moves the settled point to the last such step start it
|
|
15
|
+
// sees. `foldAllChunks` reads a whole run that way, one step start at a time,
|
|
16
|
+
// and yields to the event loop as it goes.
|
|
17
|
+
//
|
|
18
|
+
// A PART LEFT OPEN ACROSS STEPS (a provider that never ends a text, reasoning
|
|
19
|
+
// or tool input) means nothing settles until it closes. `foldAllChunks` then
|
|
20
|
+
// reads only where a step starts with nothing open, so the rest of the run is
|
|
21
|
+
// folded in one pass, as a full replay is; a live reader (`read` after every
|
|
22
|
+
// chunk) re-folds a long unsettled tail only after it has grown by a quarter
|
|
23
|
+
// (`UNSETTLED_TAIL_THROTTLE`); and the reader is fed through a stream that
|
|
24
|
+
// gives the event loop back every `FOLD_YIELD_MS`, so even that one pass never
|
|
25
|
+
// holds the loop for its whole length.
|
|
26
|
+
//
|
|
27
|
+
// WHY THE RESULT IS THE SAME AS A FULL REPLAY. The SDK reader cannot resume
|
|
28
|
+
// inside an open part: the open text, reasoning and tool input ids live in
|
|
29
|
+
// reader state, not in the message. At a `start-step` chunk with nothing open
|
|
30
|
+
// that state is empty in a full replay too, and the reader's "current step"
|
|
31
|
+
// (the parts after the last `step-start` part) begins at that chunk in both.
|
|
32
|
+
// The only other things a chunk can reach in earlier parts are:
|
|
33
|
+
// - a tool part by `toolCallId` (an output for a call from an earlier step),
|
|
34
|
+
// - a tool part by approval id (`tool-approval-response`),
|
|
35
|
+
// - a data part with the same type and id (it is updated in place),
|
|
36
|
+
// - the message metadata (merged).
|
|
37
|
+
// So each read hands the reader the settled metadata plus a copy of exactly
|
|
38
|
+
// the earlier parts the new chunks name, then puts the updated copies back at
|
|
39
|
+
// their places and appends the new parts. A seeded property test
|
|
40
|
+
// (test/message-fold.test.ts) holds it to a full replay after every chunk.
|
|
41
|
+
import { readUIMessageStream, UIMessageStreamError } from "ai";
|
|
42
|
+
/** One of a chunk's string fields, or `undefined`. */
|
|
43
|
+
function stringField(chunk, name) {
|
|
44
|
+
const value = chunk[name];
|
|
45
|
+
return typeof value === "string" ? value : undefined;
|
|
46
|
+
}
|
|
47
|
+
function isToolPart(part) {
|
|
48
|
+
return "toolCallId" in part && typeof part.toolCallId === "string";
|
|
49
|
+
}
|
|
50
|
+
function approvalIdOf(part) {
|
|
51
|
+
if (!("approval" in part))
|
|
52
|
+
return undefined;
|
|
53
|
+
const approval = part.approval;
|
|
54
|
+
if (approval === null || typeof approval !== "object")
|
|
55
|
+
return undefined;
|
|
56
|
+
const id = approval.id;
|
|
57
|
+
return typeof id === "string" ? id : undefined;
|
|
58
|
+
}
|
|
59
|
+
/** A chunk type the reader treats as a data part. */
|
|
60
|
+
function isDataChunk(chunk) {
|
|
61
|
+
return chunk.type.startsWith("data-");
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The indexes of the settled parts that `chunks` may reach, chosen the way the
|
|
65
|
+
* reader looks them up: a tool part by call id takes the LAST match (the reader
|
|
66
|
+
* scans the whole message backwards once the current step has none), a data
|
|
67
|
+
* part the FIRST (`parts.find`), a tool part by approval id the first.
|
|
68
|
+
*/
|
|
69
|
+
function reachedParts(parts, chunks) {
|
|
70
|
+
// EVERY call id the chunks name, including one a `tool-input-start` in these
|
|
71
|
+
// chunks opens again: if that new part is later removed (`reset-step`), the
|
|
72
|
+
// reader's backward scan finds the earlier step's part for the same id.
|
|
73
|
+
const callIds = new Set();
|
|
74
|
+
const approvalIds = new Set();
|
|
75
|
+
const dataKeys = new Set();
|
|
76
|
+
for (const chunk of chunks) {
|
|
77
|
+
const callId = stringField(chunk, "toolCallId");
|
|
78
|
+
if (callId !== undefined)
|
|
79
|
+
callIds.add(callId);
|
|
80
|
+
if (chunk.type === "tool-approval-response") {
|
|
81
|
+
const approvalId = stringField(chunk, "approvalId");
|
|
82
|
+
if (approvalId !== undefined)
|
|
83
|
+
approvalIds.add(approvalId);
|
|
84
|
+
}
|
|
85
|
+
const id = stringField(chunk, "id");
|
|
86
|
+
if (isDataChunk(chunk) && id !== undefined && chunk.transient !== true) {
|
|
87
|
+
dataKeys.add(`${chunk.type}\u0000${id}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (callIds.size === 0 && approvalIds.size === 0 && dataKeys.size === 0)
|
|
91
|
+
return [];
|
|
92
|
+
const reached = new Set();
|
|
93
|
+
const lastByCall = new Map();
|
|
94
|
+
const firstByData = new Map();
|
|
95
|
+
parts.forEach((part, index) => {
|
|
96
|
+
if (isToolPart(part) && callIds.has(part.toolCallId))
|
|
97
|
+
lastByCall.set(part.toolCallId, index);
|
|
98
|
+
const approvalId = approvalIdOf(part);
|
|
99
|
+
if (isToolPart(part) && approvalId !== undefined && approvalIds.has(approvalId))
|
|
100
|
+
reached.add(index);
|
|
101
|
+
if ("id" in part && "data" in part && typeof part.id === "string") {
|
|
102
|
+
const key = `${part.type}\u0000${part.id}`;
|
|
103
|
+
if (dataKeys.has(key) && !firstByData.has(key))
|
|
104
|
+
firstByData.set(key, index);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
lastByCall.forEach((index) => reached.add(index));
|
|
108
|
+
firstByData.forEach((index) => reached.add(index));
|
|
109
|
+
return Array.from(reached).sort((left, right) => left - right);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The last index in `chunks` at which a step starts with no text, reasoning or
|
|
113
|
+
* tool input open, or 0 for none. The chunks before it can be settled.
|
|
114
|
+
*/
|
|
115
|
+
function lastCut(chunks) {
|
|
116
|
+
const open = new Set();
|
|
117
|
+
let cut = 0;
|
|
118
|
+
chunks.forEach((chunk, index) => {
|
|
119
|
+
if (chunk.type === "start-step" && open.size === 0 && index > 0)
|
|
120
|
+
cut = index;
|
|
121
|
+
trackOpenParts(open, chunk);
|
|
122
|
+
});
|
|
123
|
+
return cut;
|
|
124
|
+
}
|
|
125
|
+
/** Updates `open`, the text, reasoning and tool-input parts open, for one chunk. */
|
|
126
|
+
function trackOpenParts(open, chunk) {
|
|
127
|
+
const id = stringField(chunk, "id");
|
|
128
|
+
const callId = stringField(chunk, "toolCallId");
|
|
129
|
+
switch (chunk.type) {
|
|
130
|
+
case "text-start":
|
|
131
|
+
case "reasoning-start":
|
|
132
|
+
if (id !== undefined)
|
|
133
|
+
open.add(`${chunk.type.slice(0, -6)}\u0000${id}`);
|
|
134
|
+
break;
|
|
135
|
+
case "text-end":
|
|
136
|
+
case "reasoning-end":
|
|
137
|
+
if (id !== undefined)
|
|
138
|
+
open.delete(`${chunk.type.slice(0, -4)}\u0000${id}`);
|
|
139
|
+
break;
|
|
140
|
+
case "tool-input-start":
|
|
141
|
+
if (callId !== undefined)
|
|
142
|
+
open.add(`tool\u0000${callId}`);
|
|
143
|
+
break;
|
|
144
|
+
case "tool-input-available":
|
|
145
|
+
case "tool-input-error":
|
|
146
|
+
if (callId !== undefined)
|
|
147
|
+
open.delete(`tool\u0000${callId}`);
|
|
148
|
+
break;
|
|
149
|
+
case "reset-step":
|
|
150
|
+
open.clear();
|
|
151
|
+
break;
|
|
152
|
+
default:
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The chunks as a stream the reader pulls one at a time, giving the event loop
|
|
158
|
+
* back every `FOLD_YIELD_MS` of reading. The reader copies its whole message on
|
|
159
|
+
* every chunk it writes, so a pass over a long unsettled tail (a part left
|
|
160
|
+
* open across steps) is still expensive; this spreads it across turns rather
|
|
161
|
+
* than holding the loop for its whole length.
|
|
162
|
+
*/
|
|
163
|
+
/** How many chunks the reader is handed per pull. */
|
|
164
|
+
const CHUNKS_PER_PULL = 16;
|
|
165
|
+
function chunkStream(chunks) {
|
|
166
|
+
let next = 0;
|
|
167
|
+
let worked = performance.now();
|
|
168
|
+
return new ReadableStream({
|
|
169
|
+
async pull(controller) {
|
|
170
|
+
// Pulled once the reader has taken the previous chunk, so the time
|
|
171
|
+
// since the last yield includes its work on the chunks before.
|
|
172
|
+
if (performance.now() - worked >= FOLD_YIELD_MS) {
|
|
173
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
174
|
+
worked = performance.now();
|
|
175
|
+
}
|
|
176
|
+
// A few at a time: a pull per chunk costs more than a short pass.
|
|
177
|
+
const end = Math.min(next + CHUNKS_PER_PULL, chunks.length);
|
|
178
|
+
for (; next < end; next += 1) {
|
|
179
|
+
const chunk = chunks[next];
|
|
180
|
+
if (chunk !== undefined)
|
|
181
|
+
controller.enqueue(chunk);
|
|
182
|
+
}
|
|
183
|
+
if (next >= chunks.length)
|
|
184
|
+
controller.close();
|
|
185
|
+
},
|
|
186
|
+
}, { highWaterMark: 1 });
|
|
187
|
+
}
|
|
188
|
+
/** `base` with the reached parts replaced by the first of `folded`'s parts, and
|
|
189
|
+
* the rest of `folded`'s parts appended. */
|
|
190
|
+
function merge(base, reached, folded) {
|
|
191
|
+
const parts = base.slice();
|
|
192
|
+
reached.forEach((index, position) => {
|
|
193
|
+
const part = folded.parts[position];
|
|
194
|
+
if (part !== undefined)
|
|
195
|
+
parts[index] = part;
|
|
196
|
+
});
|
|
197
|
+
parts.push(...folded.parts.slice(reached.length));
|
|
198
|
+
return { ...folded, parts };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Folds `chunks` onto `base` with the SDK reader. `base` is never mutated: the
|
|
202
|
+
* reader is given copies of the parts it may reach, and the result shares the
|
|
203
|
+
* other parts with `base`.
|
|
204
|
+
*/
|
|
205
|
+
async function fold(base, chunks) {
|
|
206
|
+
const parts = base?.state.parts ?? [];
|
|
207
|
+
const reached = reachedParts(parts, chunks);
|
|
208
|
+
// The reader keeps THIS object as its message and mutates it in place, so
|
|
209
|
+
// once the stream is read it holds the state after the last chunk.
|
|
210
|
+
// In the reader's own key order (`createStreamingUIMessageState`), so the
|
|
211
|
+
// message serialises byte for byte as a full replay's does.
|
|
212
|
+
const working = {
|
|
213
|
+
id: base?.state.id ?? "",
|
|
214
|
+
metadata: base?.state.metadata === undefined ? undefined : structuredClone(base.state.metadata),
|
|
215
|
+
role: "assistant",
|
|
216
|
+
parts: reached.flatMap((index) => {
|
|
217
|
+
const part = parts[index];
|
|
218
|
+
return part === undefined ? [] : [structuredClone(part)];
|
|
219
|
+
}),
|
|
220
|
+
};
|
|
221
|
+
// A chunk the reader cannot apply ends its stream, and it reports that
|
|
222
|
+
// through `onError`, as it reports an `error` chunk. An `error` chunk comes as
|
|
223
|
+
// a plain `Error`; a chunk it cannot apply as a `UIMessageStreamError` (or,
|
|
224
|
+
// for a fault inside the reader, some other error class). Counting `error`
|
|
225
|
+
// chunks instead would miss a stop followed by an `error` chunk the reader
|
|
226
|
+
// never reached.
|
|
227
|
+
let stopped = false;
|
|
228
|
+
let lastWritten;
|
|
229
|
+
for await (const current of readUIMessageStream({
|
|
230
|
+
message: working,
|
|
231
|
+
stream: chunkStream(chunks),
|
|
232
|
+
terminateOnError: false,
|
|
233
|
+
onError: (error) => {
|
|
234
|
+
if (UIMessageStreamError.isInstance(error) ||
|
|
235
|
+
!(error instanceof Error) ||
|
|
236
|
+
Object.getPrototypeOf(error) !== Error.prototype) {
|
|
237
|
+
stopped = true;
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
})) {
|
|
241
|
+
lastWritten = current;
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
state: merge(parts, reached, working),
|
|
245
|
+
written: lastWritten === undefined ? base?.written : merge(parts, reached, lastWritten),
|
|
246
|
+
stopped: (base?.stopped ?? false) || stopped,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/** How many step starts may pass with a part still open before it is logged. */
|
|
250
|
+
export const UNSETTLED_STEPS_LOGGED = 10;
|
|
251
|
+
/** An unsettled tail this long is re-folded only after it grows by a quarter. */
|
|
252
|
+
export const UNSETTLED_TAIL_THROTTLE = 400;
|
|
253
|
+
export function createRunMessageFold(options = {}) {
|
|
254
|
+
let unsettledLogged = false;
|
|
255
|
+
// The last fold of the unsettled tail, and how long that tail was: reset
|
|
256
|
+
// whenever the settled point moves.
|
|
257
|
+
let tailFold;
|
|
258
|
+
let settled;
|
|
259
|
+
let settledSequence = 0;
|
|
260
|
+
let folded = 0;
|
|
261
|
+
return {
|
|
262
|
+
async read(list, readOptions = {}) {
|
|
263
|
+
const stored = await list(settledSequence);
|
|
264
|
+
// A reader that stopped at a chunk it could not apply ignores every later
|
|
265
|
+
// chunk in a full replay too, so nothing after it changes the message.
|
|
266
|
+
if (settled?.stopped === true)
|
|
267
|
+
return settled.written;
|
|
268
|
+
const chunks = stored.map(({ chunk }) => chunk);
|
|
269
|
+
const cut = lastCut(chunks);
|
|
270
|
+
let base = settled;
|
|
271
|
+
if (cut > 0) {
|
|
272
|
+
base = await fold(settled, chunks.slice(0, cut));
|
|
273
|
+
folded += cut;
|
|
274
|
+
settled = base;
|
|
275
|
+
settledSequence = stored[cut - 1]?.sequence ?? settledSequence;
|
|
276
|
+
tailFold = undefined;
|
|
277
|
+
if (base.stopped)
|
|
278
|
+
return base.written;
|
|
279
|
+
}
|
|
280
|
+
const tail = chunks.slice(cut);
|
|
281
|
+
if (!unsettledLogged && options.onUnsettled !== undefined) {
|
|
282
|
+
const steps = tail.filter((chunk) => chunk.type === "start-step").length;
|
|
283
|
+
if (steps >= UNSETTLED_STEPS_LOGGED) {
|
|
284
|
+
unsettledLogged = true;
|
|
285
|
+
options.onUnsettled({ steps, chunks: tail.length });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (tail.length === 0)
|
|
289
|
+
return base?.written;
|
|
290
|
+
if (readOptions.complete !== true &&
|
|
291
|
+
tailFold !== undefined &&
|
|
292
|
+
tail.length >= UNSETTLED_TAIL_THROTTLE &&
|
|
293
|
+
tail.length < tailFold.length * 1.25) {
|
|
294
|
+
return tailFold.written ?? base?.written;
|
|
295
|
+
}
|
|
296
|
+
const current = await fold(base, tail);
|
|
297
|
+
folded += tail.length;
|
|
298
|
+
tailFold = { length: tail.length, written: current.written };
|
|
299
|
+
return current.written;
|
|
300
|
+
},
|
|
301
|
+
foldedChunks: () => folded,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/** How long `foldAllChunks` works before it lets other callbacks run. */
|
|
305
|
+
export const FOLD_YIELD_MS = 10;
|
|
306
|
+
/**
|
|
307
|
+
* The message a full replay of `chunks` builds (the last message
|
|
308
|
+
* `readUIMessageStream` yields, with `terminateOnError: false`), folded a step
|
|
309
|
+
* at a time and giving the event loop back every `FOLD_YIELD_MS`. The reader
|
|
310
|
+
* only ever sees about one step's chunks and the parts they name; with a part
|
|
311
|
+
* left open across steps, one pass over the rest of the run.
|
|
312
|
+
*/
|
|
313
|
+
export async function foldAllChunks(chunks, options = {}) {
|
|
314
|
+
const yieldMs = options.yieldMs ?? FOLD_YIELD_MS;
|
|
315
|
+
// Sequence n is index n - 1, so "after sequence s" is `slice(s)`.
|
|
316
|
+
const stored = chunks.map((chunk, index) => ({
|
|
317
|
+
sequence: index + 1,
|
|
318
|
+
conversationId: "",
|
|
319
|
+
runId: "",
|
|
320
|
+
createdAt: "",
|
|
321
|
+
chunk,
|
|
322
|
+
}));
|
|
323
|
+
const fold = options.fold ?? createRunMessageFold();
|
|
324
|
+
// Only where a step starts with nothing open does a read settle anything
|
|
325
|
+
// (`lastCut`'s rule); a read anywhere else would fold the whole unsettled
|
|
326
|
+
// tail again. So a part left open makes this one pass over the rest of the
|
|
327
|
+
// run, as a full replay is, and never one pass per step.
|
|
328
|
+
const open = new Set();
|
|
329
|
+
let worked = performance.now();
|
|
330
|
+
for (let index = 0; index < stored.length; index += 1) {
|
|
331
|
+
const chunk = stored[index]?.chunk;
|
|
332
|
+
if (chunk === undefined)
|
|
333
|
+
continue;
|
|
334
|
+
if (chunk.type === "start-step" && index > 0 && open.size === 0) {
|
|
335
|
+
const end = index + 1;
|
|
336
|
+
await fold.read(async (after) => stored.slice(after, end));
|
|
337
|
+
if (performance.now() - worked >= yieldMs) {
|
|
338
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
339
|
+
worked = performance.now();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
trackOpenParts(open, chunk);
|
|
343
|
+
}
|
|
344
|
+
return fold.read(async (after) => stored.slice(after), { complete: true });
|
|
345
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { KakapoSceneCommitError } from "@womp/kakapo-sdk";
|
|
2
|
+
/** Only for the standalone host's direct, atomic native Kakapo connection. */
|
|
3
|
+
export async function initializeStandaloneScene(api, signal) {
|
|
4
|
+
signal.throwIfAborted();
|
|
5
|
+
const response = await api.rpc("scene_state_get");
|
|
6
|
+
signal.throwIfAborted();
|
|
7
|
+
const snapshot = typeof response === "string" ? JSON.parse(response) : response;
|
|
8
|
+
if (snapshot === null || typeof snapshot !== "object" || Array.isArray(snapshot)) {
|
|
9
|
+
throw new Error("Kakapo returned an invalid scene snapshot during scene initialization.");
|
|
10
|
+
}
|
|
11
|
+
const additions = [];
|
|
12
|
+
if (snapshot.ext === undefined)
|
|
13
|
+
additions.push({ op: "add", path: "/ext", value: {} });
|
|
14
|
+
for (const key of ["materials", "shaders"]) {
|
|
15
|
+
if (snapshot.storage[key] === undefined) {
|
|
16
|
+
additions.push({ op: "add", path: `/storage/${key}`, value: {} });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (snapshot.storage.openscad === undefined) {
|
|
20
|
+
additions.push({ op: "add", path: "/storage/openscad", value: { scripts: {} } });
|
|
21
|
+
}
|
|
22
|
+
else if (snapshot.storage.openscad.scripts === undefined) {
|
|
23
|
+
additions.push({ op: "add", path: "/storage/openscad/scripts", value: {} });
|
|
24
|
+
}
|
|
25
|
+
if (additions.length === 0)
|
|
26
|
+
return;
|
|
27
|
+
// The SDK requires /ext; native resource updates require per-ID patch paths.
|
|
28
|
+
// Initialize missing collections so later edits address individual resources.
|
|
29
|
+
// The whole-scene test rejects concurrent changes before editing begins.
|
|
30
|
+
let result;
|
|
31
|
+
try {
|
|
32
|
+
result = await api.rpc("scene_state_patch", [JSON.stringify([
|
|
33
|
+
{ op: "test", path: "", value: snapshot },
|
|
34
|
+
...additions,
|
|
35
|
+
])]);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
throw new KakapoSceneCommitError("unknown", error);
|
|
39
|
+
}
|
|
40
|
+
if (result !== undefined && result !== null && result !== "") {
|
|
41
|
+
throw new KakapoSceneCommitError("unknown", new Error(`Scene initialization was not acknowledged: ${JSON.stringify(result)}`));
|
|
42
|
+
}
|
|
43
|
+
await api.refreshScene();
|
|
44
|
+
}
|
package/dist/server/service.d.ts
CHANGED
|
@@ -56,7 +56,16 @@ export declare class SparkAgentService {
|
|
|
56
56
|
deleteConversation(ownerId: string, id: string): Promise<void>;
|
|
57
57
|
getRun(ownerId: string, id: string): Promise<SparkRun>;
|
|
58
58
|
getActiveRun(ownerId: string, conversationId: string): Promise<SparkRun | undefined>;
|
|
59
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* The host must establish exclusive ownership before recovering abandoned runs.
|
|
61
|
+
*
|
|
62
|
+
* The ending this writes spans event-loop turns: the run's message is folded
|
|
63
|
+
* a step at a time and yields as it goes (`finishRun`). The recovered run is
|
|
64
|
+
* not one this process is running, so nothing here keeps a submit to its
|
|
65
|
+
* conversation out meanwhile; `finishRun` then saves the conversation as it
|
|
66
|
+
* read it, over anything that submit wrote. Recover before accepting
|
|
67
|
+
* requests, as the standalone server does.
|
|
68
|
+
*/
|
|
60
69
|
recoverInterruptedRun(runId: string): Promise<void>;
|
|
61
70
|
close(): Promise<void>;
|
|
62
71
|
submitMessage(input: SubmitSparkMessageInput): Promise<SparkRun>;
|
|
@@ -99,6 +108,14 @@ export declare class SparkAgentService {
|
|
|
99
108
|
* was ended; throws when the store is still failing.
|
|
100
109
|
*/
|
|
101
110
|
private endIfNotRunning;
|
|
111
|
+
/**
|
|
112
|
+
* Writes a run's ending: its message, rebuilt from every stored chunk, onto
|
|
113
|
+
* the conversation, then the run's terminal state. The rebuild is folded a
|
|
114
|
+
* step at a time and gives the event loop back as it goes (./message-fold.ts),
|
|
115
|
+
* so the ending spans event-loop turns; for a run this process is running, it
|
|
116
|
+
* is still in `live` throughout, so a submit to its conversation is refused
|
|
117
|
+
* until it has ended.
|
|
118
|
+
*/
|
|
102
119
|
private finishRun;
|
|
103
120
|
private materializeMessage;
|
|
104
121
|
private appendStatus;
|
package/dist/server/service.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sparkErrorDiagnostics } from "../error-diagnostics.js";
|
|
2
|
-
import {
|
|
2
|
+
import { validateUIMessages } from "ai";
|
|
3
|
+
import { foldAllChunks } from "./message-fold.js";
|
|
3
4
|
import { sparkDataPartSchemas, sparkMessageMetadataSchema, } from "../ui.js";
|
|
4
5
|
const TERMINAL_STATUSES = new Set([
|
|
5
6
|
"completed",
|
|
@@ -48,15 +49,6 @@ function titleFor(message) {
|
|
|
48
49
|
return "New build";
|
|
49
50
|
return text.length > 44 ? `${text.slice(0, 44)}…` : text;
|
|
50
51
|
}
|
|
51
|
-
function chunkStream(chunks) {
|
|
52
|
-
return new ReadableStream({
|
|
53
|
-
start(controller) {
|
|
54
|
-
for (const chunk of chunks)
|
|
55
|
-
controller.enqueue(chunk);
|
|
56
|
-
controller.close();
|
|
57
|
-
},
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
52
|
function normalizeUserMessage(message) {
|
|
61
53
|
if (message.role !== "user") {
|
|
62
54
|
throw new SparkServiceError(400, "A new chat message must have the user role.");
|
|
@@ -150,7 +142,16 @@ export class SparkAgentService {
|
|
|
150
142
|
return undefined;
|
|
151
143
|
return run;
|
|
152
144
|
}
|
|
153
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* The host must establish exclusive ownership before recovering abandoned runs.
|
|
147
|
+
*
|
|
148
|
+
* The ending this writes spans event-loop turns: the run's message is folded
|
|
149
|
+
* a step at a time and yields as it goes (`finishRun`). The recovered run is
|
|
150
|
+
* not one this process is running, so nothing here keeps a submit to its
|
|
151
|
+
* conversation out meanwhile; `finishRun` then saves the conversation as it
|
|
152
|
+
* read it, over anything that submit wrote. Recover before accepting
|
|
153
|
+
* requests, as the standalone server does.
|
|
154
|
+
*/
|
|
154
155
|
async recoverInterruptedRun(runId) {
|
|
155
156
|
if (this.controllers.has(runId))
|
|
156
157
|
throw new Error("Cannot recover a live run");
|
|
@@ -584,6 +585,14 @@ export class SparkAgentService {
|
|
|
584
585
|
await this.storeEnding(run.id, { status: "failed", error: diagnostics.message, errorDiagnostics: diagnostics }, inTurn);
|
|
585
586
|
return true;
|
|
586
587
|
}
|
|
588
|
+
/**
|
|
589
|
+
* Writes a run's ending: its message, rebuilt from every stored chunk, onto
|
|
590
|
+
* the conversation, then the run's terminal state. The rebuild is folded a
|
|
591
|
+
* step at a time and gives the event loop back as it goes (./message-fold.ts),
|
|
592
|
+
* so the ending spans event-loop turns; for a run this process is running, it
|
|
593
|
+
* is still in `live` throughout, so a submit to its conversation is refused
|
|
594
|
+
* until it has ended.
|
|
595
|
+
*/
|
|
587
596
|
async finishRun(run, conversation, status, error, errorDiagnostics) {
|
|
588
597
|
const completedAt = now();
|
|
589
598
|
const storedChunks = await this.store.listRunChunks(run.id, 0);
|
|
@@ -615,14 +624,13 @@ export class SparkAgentService {
|
|
|
615
624
|
this.wakeRun(run.id);
|
|
616
625
|
}
|
|
617
626
|
async materializeMessage(run, chunks, status, completedAt, error, errorDiagnostics) {
|
|
627
|
+
// What a full `readUIMessageStream` replay of the chunks builds, folded a
|
|
628
|
+
// step at a time and yielding (./message-fold.ts): one pass over a long
|
|
629
|
+
// run copies the whole message per chunk and held the event loop for
|
|
630
|
+
// seconds as the run ended.
|
|
618
631
|
let message;
|
|
619
632
|
try {
|
|
620
|
-
|
|
621
|
-
stream: chunkStream(chunks),
|
|
622
|
-
terminateOnError: false,
|
|
623
|
-
})) {
|
|
624
|
-
message = current;
|
|
625
|
-
}
|
|
633
|
+
message = await foldAllChunks(chunks);
|
|
626
634
|
}
|
|
627
635
|
catch {
|
|
628
636
|
return undefined;
|
|
@@ -17,36 +17,36 @@ export class SqliteSparkStore {
|
|
|
17
17
|
this.database.close();
|
|
18
18
|
throw new Error(`Unsupported Spark database version: ${version}`);
|
|
19
19
|
}
|
|
20
|
-
this.database.exec(`
|
|
21
|
-
PRAGMA busy_timeout = 5000;
|
|
22
|
-
PRAGMA journal_mode = WAL;
|
|
23
|
-
PRAGMA synchronous = FULL;
|
|
24
|
-
PRAGMA foreign_keys = ON;
|
|
25
|
-
CREATE TABLE IF NOT EXISTS conversations (
|
|
26
|
-
id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, updated_at TEXT NOT NULL, data TEXT NOT NULL
|
|
27
|
-
);
|
|
28
|
-
CREATE INDEX IF NOT EXISTS conversation_owner ON conversations(owner_id, updated_at);
|
|
29
|
-
CREATE TABLE IF NOT EXISTS runs (
|
|
30
|
-
id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, data TEXT NOT NULL
|
|
31
|
-
);
|
|
32
|
-
CREATE TABLE IF NOT EXISTS chunks (
|
|
33
|
-
run_id TEXT NOT NULL REFERENCES runs(id), sequence INTEGER NOT NULL, data TEXT NOT NULL,
|
|
34
|
-
PRIMARY KEY (run_id, sequence)
|
|
35
|
-
);
|
|
36
|
-
CREATE TABLE IF NOT EXISTS artifacts (
|
|
37
|
-
id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, metadata TEXT NOT NULL, bytes BLOB NOT NULL
|
|
38
|
-
);
|
|
39
|
-
CREATE TABLE IF NOT EXISTS execution_owner (
|
|
40
|
-
singleton INTEGER PRIMARY KEY CHECK (singleton = 1), pid INTEGER NOT NULL, token TEXT NOT NULL
|
|
41
|
-
);
|
|
42
|
-
CREATE TABLE IF NOT EXISTS scene_operations (
|
|
43
|
-
target_key TEXT NOT NULL, id TEXT NOT NULL, revision INTEGER NOT NULL, data TEXT NOT NULL,
|
|
44
|
-
PRIMARY KEY (target_key, id)
|
|
45
|
-
);
|
|
46
|
-
CREATE TABLE IF NOT EXISTS repair_states (
|
|
47
|
-
target_key TEXT PRIMARY KEY, revision INTEGER NOT NULL, data TEXT NOT NULL
|
|
48
|
-
);
|
|
49
|
-
PRAGMA user_version = 4;
|
|
20
|
+
this.database.exec(`
|
|
21
|
+
PRAGMA busy_timeout = 5000;
|
|
22
|
+
PRAGMA journal_mode = WAL;
|
|
23
|
+
PRAGMA synchronous = FULL;
|
|
24
|
+
PRAGMA foreign_keys = ON;
|
|
25
|
+
CREATE TABLE IF NOT EXISTS conversations (
|
|
26
|
+
id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, updated_at TEXT NOT NULL, data TEXT NOT NULL
|
|
27
|
+
);
|
|
28
|
+
CREATE INDEX IF NOT EXISTS conversation_owner ON conversations(owner_id, updated_at);
|
|
29
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
30
|
+
id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, data TEXT NOT NULL
|
|
31
|
+
);
|
|
32
|
+
CREATE TABLE IF NOT EXISTS chunks (
|
|
33
|
+
run_id TEXT NOT NULL REFERENCES runs(id), sequence INTEGER NOT NULL, data TEXT NOT NULL,
|
|
34
|
+
PRIMARY KEY (run_id, sequence)
|
|
35
|
+
);
|
|
36
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
37
|
+
id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, metadata TEXT NOT NULL, bytes BLOB NOT NULL
|
|
38
|
+
);
|
|
39
|
+
CREATE TABLE IF NOT EXISTS execution_owner (
|
|
40
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1), pid INTEGER NOT NULL, token TEXT NOT NULL
|
|
41
|
+
);
|
|
42
|
+
CREATE TABLE IF NOT EXISTS scene_operations (
|
|
43
|
+
target_key TEXT NOT NULL, id TEXT NOT NULL, revision INTEGER NOT NULL, data TEXT NOT NULL,
|
|
44
|
+
PRIMARY KEY (target_key, id)
|
|
45
|
+
);
|
|
46
|
+
CREATE TABLE IF NOT EXISTS repair_states (
|
|
47
|
+
target_key TEXT PRIMARY KEY, revision INTEGER NOT NULL, data TEXT NOT NULL
|
|
48
|
+
);
|
|
49
|
+
PRAGMA user_version = 4;
|
|
50
50
|
`);
|
|
51
51
|
}
|
|
52
52
|
repairStateStore(targetKey) {
|
|
@@ -127,13 +127,13 @@ export class SqliteSparkStore {
|
|
|
127
127
|
if (result.changes !== 1)
|
|
128
128
|
throw new Error("Scene operation changed concurrently or is missing");
|
|
129
129
|
if (next.status === "committed" || next.status === "rolled_back") {
|
|
130
|
-
this.database.prepare(`
|
|
131
|
-
DELETE FROM scene_operations WHERE rowid IN (
|
|
132
|
-
SELECT rowid FROM scene_operations
|
|
133
|
-
WHERE target_key = ? AND json_extract(data, '$.status') IN ('committed', 'rolled_back')
|
|
134
|
-
ORDER BY COALESCE(json_extract(data, '$.completedAt'), '') DESC
|
|
135
|
-
LIMIT -1 OFFSET ?
|
|
136
|
-
)
|
|
130
|
+
this.database.prepare(`
|
|
131
|
+
DELETE FROM scene_operations WHERE rowid IN (
|
|
132
|
+
SELECT rowid FROM scene_operations
|
|
133
|
+
WHERE target_key = ? AND json_extract(data, '$.status') IN ('committed', 'rolled_back')
|
|
134
|
+
ORDER BY COALESCE(json_extract(data, '$.completedAt'), '') DESC
|
|
135
|
+
LIMIT -1 OFFSET ?
|
|
136
|
+
)
|
|
137
137
|
`).run(next.targetKey, completedSceneOperationLimit);
|
|
138
138
|
}
|
|
139
139
|
this.database.exec("COMMIT");
|
|
@@ -21,6 +21,7 @@ import { createSparkAgentRouter } from "./express.js";
|
|
|
21
21
|
import { SparkAgentService } from "./service.js";
|
|
22
22
|
import { SqliteSparkStore } from "./sqlite-store.js";
|
|
23
23
|
import { clearSceneContents } from "./scene-actions.js";
|
|
24
|
+
import { initializeStandaloneScene } from "./scene-initialization.js";
|
|
24
25
|
export async function checkStandaloneServices(api) {
|
|
25
26
|
const [fonts, openScad] = await Promise.allSettled([
|
|
26
27
|
api.listFonts(),
|
|
@@ -66,6 +67,8 @@ export async function createStandaloneSparkAgentApp(options) {
|
|
|
66
67
|
context.signal.throwIfAborted();
|
|
67
68
|
await api.connect();
|
|
68
69
|
context.signal.throwIfAborted();
|
|
70
|
+
await initializeStandaloneScene(api, context.signal);
|
|
71
|
+
context.signal.throwIfAborted();
|
|
69
72
|
const support = await checkStandaloneServices(api);
|
|
70
73
|
const services = {
|
|
71
74
|
fonts: support.fonts,
|