@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.
@@ -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,3 @@
1
+ import { type KakapoAPI } from "@womp/kakapo-sdk";
2
+ /** Only for the standalone host's direct, atomic native Kakapo connection. */
3
+ export declare function initializeStandaloneScene(api: Pick<KakapoAPI, "rpc" | "refreshScene">, signal: AbortSignal): Promise<void>;
@@ -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
+ }
@@ -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
- /** The host must establish exclusive ownership before recovering abandoned runs. */
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;
@@ -1,5 +1,6 @@
1
1
  import { sparkErrorDiagnostics } from "../error-diagnostics.js";
2
- import { readUIMessageStream, validateUIMessages } from "ai";
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
- /** The host must establish exclusive ownership before recovering abandoned runs. */
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
- for await (const current of readUIMessageStream({
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,