@shogo-ai/core 1.9.13 → 2.4.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.
@@ -21,15 +21,135 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var stream_buffer_exports = {};
22
22
  __export(stream_buffer_exports, {
23
23
  StreamBufferStore: () => StreamBufferStore,
24
- createBufferingTransform: () => createBufferingTransform
24
+ TURN_COMPLETE_EVENT_TYPE: () => TURN_COMPLETE_EVENT_TYPE,
25
+ TURN_SEQ_EVENT_TYPE: () => TURN_SEQ_EVENT_TYPE,
26
+ compactSseFrames: () => compactSseFrames,
27
+ createBufferingTransform: () => createBufferingTransform,
28
+ encodeTurnCompleteFrame: () => encodeTurnCompleteFrame,
29
+ encodeTurnSeqFrame: () => encodeTurnSeqFrame
25
30
  });
26
31
  module.exports = __toCommonJS(stream_buffer_exports);
32
+
33
+ // src/stream-compaction.ts
34
+ var TURN_SEQ_EVENT_TYPE = "data-turn-seq";
35
+ var DELTA_PART = {
36
+ "text-delta": "text",
37
+ "reasoning-delta": "reasoning"
38
+ };
39
+ var PART_BOUNDARY = {
40
+ "text-end": "text",
41
+ "reasoning-end": "reasoning",
42
+ "text-start": "text",
43
+ "reasoning-start": "reasoning"
44
+ };
45
+ function compactSseFrames(frames) {
46
+ if (frames.length === 0) return new Uint8Array(0);
47
+ const decoder = new TextDecoder();
48
+ let text = "";
49
+ for (const frame of frames) text += decoder.decode(frame, { stream: true });
50
+ text += decoder.decode();
51
+ const events = text.split("\n\n");
52
+ const tail = events.pop() ?? "";
53
+ const out = [];
54
+ const openParts = /* @__PURE__ */ new Map();
55
+ const openToolInputs = /* @__PURE__ */ new Map();
56
+ const preliminaryOutputs = /* @__PURE__ */ new Map();
57
+ for (const event of events) {
58
+ const chunk = parseEvent(event);
59
+ if (!chunk) {
60
+ out.push({ raw: event });
61
+ continue;
62
+ }
63
+ const type = chunk.type;
64
+ if (type === TURN_SEQ_EVENT_TYPE) continue;
65
+ if (type in PART_BOUNDARY) {
66
+ openParts.delete(`${PART_BOUNDARY[type]}:${chunk.id}`);
67
+ out.push({ chunk });
68
+ continue;
69
+ }
70
+ if (type in DELTA_PART && typeof chunk.delta === "string") {
71
+ const key = `${DELTA_PART[type]}:${chunk.id}`;
72
+ const merged = openParts.get(key);
73
+ if (merged) {
74
+ merged.delta += chunk.delta;
75
+ if (chunk.providerMetadata !== void 0) merged.providerMetadata = chunk.providerMetadata;
76
+ continue;
77
+ }
78
+ const copy = { ...chunk };
79
+ openParts.set(key, copy);
80
+ out.push({ chunk: copy });
81
+ continue;
82
+ }
83
+ if (type === "tool-input-delta" && typeof chunk.inputTextDelta === "string") {
84
+ const index = openToolInputs.get(chunk.toolCallId);
85
+ const existing = index !== void 0 ? out[index] : null;
86
+ if (existing && "chunk" in existing) {
87
+ existing.chunk.inputTextDelta += chunk.inputTextDelta;
88
+ continue;
89
+ }
90
+ openToolInputs.set(chunk.toolCallId, out.length);
91
+ out.push({ chunk: { ...chunk } });
92
+ continue;
93
+ }
94
+ if (type === "tool-input-available" || type === "tool-input-error") {
95
+ const index = openToolInputs.get(chunk.toolCallId);
96
+ if (index !== void 0) out[index] = null;
97
+ openToolInputs.delete(chunk.toolCallId);
98
+ } else if (type === "tool-input-start") {
99
+ openToolInputs.delete(chunk.toolCallId);
100
+ } else if (type === "tool-output-available" || type === "tool-output-error") {
101
+ const superseded = preliminaryOutputs.get(chunk.toolCallId);
102
+ if (superseded !== void 0) {
103
+ out[superseded] = null;
104
+ preliminaryOutputs.delete(chunk.toolCallId);
105
+ }
106
+ if (type === "tool-output-available" && chunk.preliminary === true) {
107
+ preliminaryOutputs.set(chunk.toolCallId, out.length);
108
+ }
109
+ }
110
+ out.push({ chunk });
111
+ }
112
+ let result = "";
113
+ for (const entry of out) {
114
+ if (!entry) continue;
115
+ result += "chunk" in entry ? encodeEvent(entry.chunk) : `${entry.raw}
116
+
117
+ `;
118
+ }
119
+ result += tail;
120
+ return new TextEncoder().encode(result);
121
+ }
122
+ function encodeTurnSeqFrame(turnId, seq) {
123
+ return new TextEncoder().encode(
124
+ encodeEvent({ type: TURN_SEQ_EVENT_TYPE, data: { turnId, seq }, transient: true })
125
+ );
126
+ }
127
+ function encodeEvent(chunk) {
128
+ return `data: ${JSON.stringify(chunk)}
129
+
130
+ `;
131
+ }
132
+ function parseEvent(event) {
133
+ if (!event.startsWith("data: ") || event.includes("\n")) return null;
134
+ const payload = event.slice(6);
135
+ if (!payload.startsWith("{")) return null;
136
+ try {
137
+ const chunk = JSON.parse(payload);
138
+ return chunk && typeof chunk.type === "string" ? chunk : null;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ // src/stream-buffer.ts
27
145
  var CLEANUP_INTERVAL_MS = 6e4;
28
146
  var MAX_BUFFER_AGE_MS = 30 * 6e4;
29
147
  var COMPLETED_GRACE_MS = 3e4;
148
+ var TURN_REPLY_TTL_MS = 60 * 6e4;
30
149
  var StreamBufferStore = class {
31
150
  constructor() {
32
151
  this.buffers = /* @__PURE__ */ new Map();
152
+ this.replies = /* @__PURE__ */ new Map();
33
153
  this.cleanupTimer = null;
34
154
  this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
35
155
  const handle = this.cleanupTimer;
@@ -143,6 +263,44 @@ var StreamBufferStore = class {
143
263
  lastEventAt: buf.lastEventAt
144
264
  };
145
265
  }
266
+ /**
267
+ * Keep the reply a turn finished with, so it can still be read after the
268
+ * buffer is evicted (30 s grace) — e.g. by the API's call follower, or by a
269
+ * reconciliation pass long after the stream is gone. Keyed by the same
270
+ * session key as the buffer.
271
+ */
272
+ recordTurnReply(key, reply) {
273
+ const trimmed = reply?.trim();
274
+ if (!trimmed) return;
275
+ const now = Date.now();
276
+ this.pruneReplies(now);
277
+ this.replies.set(key, {
278
+ turnId: this.buffers.get(key)?.turnId ?? "",
279
+ reply,
280
+ completedAt: now
281
+ });
282
+ }
283
+ /**
284
+ * The terminal reply for a session, or null when none was recorded or it
285
+ * expired. A reply belonging to an older turn of a session that has since
286
+ * started a new one is never returned.
287
+ */
288
+ turnReply(key) {
289
+ const entry = this.replies.get(key);
290
+ if (!entry) return null;
291
+ if (Date.now() - entry.completedAt > TURN_REPLY_TTL_MS) {
292
+ this.replies.delete(key);
293
+ return null;
294
+ }
295
+ const current = this.buffers.get(key);
296
+ if (current && entry.turnId && current.turnId !== entry.turnId) return null;
297
+ return entry;
298
+ }
299
+ pruneReplies(now) {
300
+ for (const [key, entry] of this.replies) {
301
+ if (now - entry.completedAt > TURN_REPLY_TTL_MS) this.replies.delete(key);
302
+ }
303
+ }
146
304
  /**
147
305
  * Create a ReadableStream that first replays all buffered chunks past
148
306
  * `opts.fromSeq` (default: 0 → replay everything), then subscribes for
@@ -155,15 +313,29 @@ var StreamBufferStore = class {
155
313
  if (!buf) return null;
156
314
  const fromSeq = Math.max(0, opts.fromSeq ?? 0);
157
315
  let subscribedController = null;
316
+ if (opts.buildReplay) return this.createBuiltReplayStream(buf, fromSeq, opts.buildReplay);
158
317
  return new ReadableStream({
159
318
  start(controller) {
160
- for (const frame of buf.frames) {
161
- if (frame.seq <= fromSeq) continue;
319
+ if (opts.compact) {
320
+ const pending = buf.frames.filter((frame) => frame.seq > fromSeq);
321
+ const compacted = compactSseFrames(pending.map((frame) => frame.chunk));
162
322
  try {
163
- controller.enqueue(frame.chunk);
323
+ if (compacted.byteLength > 0) controller.enqueue(compacted);
324
+ if (pending.length > 0 && buf.status === "active") {
325
+ controller.enqueue(encodeTurnSeqFrame(buf.turnId, buf.nextSeq - 1));
326
+ }
164
327
  } catch {
165
328
  return;
166
329
  }
330
+ } else {
331
+ for (const frame of buf.frames) {
332
+ if (frame.seq <= fromSeq) continue;
333
+ try {
334
+ controller.enqueue(frame.chunk);
335
+ } catch {
336
+ return;
337
+ }
338
+ }
167
339
  }
168
340
  if (buf.status !== "active") {
169
341
  try {
@@ -183,8 +355,59 @@ var StreamBufferStore = class {
183
355
  }
184
356
  });
185
357
  }
358
+ createBuiltReplayStream(buf, fromSeq, buildReplay) {
359
+ const pending = buf.frames.filter((frame) => frame.seq > fromSeq).map((frame) => frame.chunk);
360
+ const ctx = { turnId: buf.turnId, lastSeq: buf.nextSeq - 1 };
361
+ const held = [];
362
+ let ended = false;
363
+ let target = null;
364
+ const proxy = {
365
+ enqueue(chunk) {
366
+ if (target) target.enqueue(chunk);
367
+ else held.push(chunk);
368
+ },
369
+ close() {
370
+ if (target) target.close();
371
+ else ended = true;
372
+ },
373
+ error(reason) {
374
+ if (target) target.error(reason);
375
+ else ended = true;
376
+ }
377
+ };
378
+ const active = buf.status === "active";
379
+ if (active) buf.subscribers.add(proxy);
380
+ return new ReadableStream({
381
+ async start(controller) {
382
+ let replay;
383
+ try {
384
+ replay = await buildReplay(pending, ctx);
385
+ } catch {
386
+ replay = compactSseFrames(pending);
387
+ }
388
+ try {
389
+ if (replay.byteLength > 0) controller.enqueue(replay);
390
+ if (pending.length > 0 && active) controller.enqueue(encodeTurnSeqFrame(ctx.turnId, ctx.lastSeq));
391
+ for (const chunk of held) controller.enqueue(chunk);
392
+ held.length = 0;
393
+ if (!active || ended || buf.status !== "active") {
394
+ controller.close();
395
+ return;
396
+ }
397
+ target = controller;
398
+ } catch {
399
+ buf.subscribers.delete(proxy);
400
+ }
401
+ },
402
+ cancel() {
403
+ buf.subscribers.delete(proxy);
404
+ target = null;
405
+ }
406
+ });
407
+ }
186
408
  cleanup() {
187
409
  const now = Date.now();
410
+ this.pruneReplies(now);
188
411
  for (const [key, buf] of this.buffers) {
189
412
  if (buf.status !== "active" && buf.completedAt && now - buf.completedAt > COMPLETED_GRACE_MS) {
190
413
  this.buffers.delete(key);
@@ -203,6 +426,7 @@ var StreamBufferStore = class {
203
426
  this.completeBuffer(buf, "disposed");
204
427
  }
205
428
  this.buffers.clear();
429
+ this.replies.clear();
206
430
  }
207
431
  completeBuffer(buf, reason) {
208
432
  if (buf.status !== "active") return;
@@ -233,6 +457,14 @@ var StreamBufferStore = class {
233
457
  buf.subscribers.clear();
234
458
  }
235
459
  };
460
+ var TURN_COMPLETE_EVENT_TYPE = "data-turn-complete";
461
+ function encodeTurnCompleteFrame(data) {
462
+ return new TextEncoder().encode(
463
+ `data: ${JSON.stringify({ type: TURN_COMPLETE_EVENT_TYPE, data })}
464
+
465
+ `
466
+ );
467
+ }
236
468
  function generateTurnId() {
237
469
  try {
238
470
  if (typeof globalThis.crypto?.randomUUID === "function") {
@@ -256,6 +488,11 @@ function createBufferingTransform(store, key) {
256
488
  // Annotate the CommonJS export names for ESM import in node:
257
489
  0 && (module.exports = {
258
490
  StreamBufferStore,
259
- createBufferingTransform
491
+ TURN_COMPLETE_EVENT_TYPE,
492
+ TURN_SEQ_EVENT_TYPE,
493
+ compactSseFrames,
494
+ createBufferingTransform,
495
+ encodeTurnCompleteFrame,
496
+ encodeTurnSeqFrame
260
497
  });
261
498
  //# sourceMappingURL=stream-buffer.cjs.map
@@ -1,3 +1,30 @@
1
+ /**
2
+ * Compaction of a buffered AI SDK UI-message stream for replay.
3
+ *
4
+ * A long turn buffers tens of thousands of tiny frames (one per text, reasoning
5
+ * or tool-input delta). Replaying them one by one makes a reconnecting client
6
+ * rebuild the message chunk by chunk, which takes seconds on a phone. The
7
+ * compacted replay produces the same message with a handful of chunks:
8
+ *
9
+ * - all deltas of one text / reasoning part are merged into its first delta
10
+ * (a part runs from its `*-start` to its `*-end`; providers reuse ids
11
+ * across steps, so merging never crosses a start);
12
+ * - tool-input deltas are merged per tool call, and dropped entirely once
13
+ * `tool-input-available` / `tool-input-error` carries the full input;
14
+ * - of a tool call's `preliminary: true` outputs only the latest is kept (each
15
+ * one carries the whole accumulated state, so it replaces the earlier ones),
16
+ * and all of them are dropped once the call's final output or error is in
17
+ * the buffer — a sub-agent streams one of these per update;
18
+ * - `data-turn-seq` heartbeats are dropped; the caller appends one with the
19
+ * exact current seq so `?fromSeq=N` reconnects stay correct.
20
+ *
21
+ * Anything that isn't a parseable `data: {json}` event is kept verbatim, in
22
+ * place, as is a trailing partial event.
23
+ */
24
+ declare const TURN_SEQ_EVENT_TYPE: "data-turn-seq";
25
+ declare function compactSseFrames(frames: Uint8Array[]): Uint8Array;
26
+ declare function encodeTurnSeqFrame(turnId: string, seq: number): Uint8Array;
27
+
1
28
  /**
2
29
  * In-memory stream buffer + turn ledger for SSE reconnect support.
3
30
  *
@@ -16,6 +43,7 @@
16
43
  *
17
44
  * Lifted into the SDK from `@shogo/shared-runtime` (was AGPL) under MIT.
18
45
  */
46
+
19
47
  type TurnStatus = 'active' | 'completed' | 'aborted' | 'failed';
20
48
  interface TurnTerminal {
21
49
  reason?: string;
@@ -41,6 +69,27 @@ interface ReplayOptions {
41
69
  * Skip frames with seq <= fromSeq. Use 0 (or omit) to replay from the start.
42
70
  */
43
71
  fromSeq?: number;
72
+ /**
73
+ * Replay the buffered frames as one compacted chunk (see
74
+ * `compactSseFrames`) followed by a `data-turn-seq` frame with the exact
75
+ * last seq, then the live frames. The rebuilt message is the same; the
76
+ * client processes a handful of chunks instead of one per delta.
77
+ */
78
+ compact?: boolean;
79
+ /**
80
+ * Replace the buffered-frame replay with caller-built bytes. Receives the
81
+ * frames after `fromSeq` and returns what to send before the live frames
82
+ * (a `data-turn-seq` frame with the exact last seq is appended). Takes
83
+ * precedence over `compact`; if it throws, the replay falls back to
84
+ * `compactSseFrames`.
85
+ *
86
+ * The builder may be async: live frames written while it runs are held
87
+ * back and delivered after its output, in order.
88
+ */
89
+ buildReplay?: (frames: Uint8Array[], ctx: {
90
+ turnId: string;
91
+ lastSeq: number;
92
+ }) => Uint8Array | Promise<Uint8Array>;
44
93
  }
45
94
  interface TurnSnapshot {
46
95
  turnId: string;
@@ -51,8 +100,15 @@ interface TurnSnapshot {
51
100
  completedAt: number | null;
52
101
  lastEventAt: number;
53
102
  }
103
+ /** The reply a pipeline turn finished with, kept past its buffer's lifetime. */
104
+ interface TurnReply {
105
+ turnId: string;
106
+ reply: string;
107
+ completedAt: number;
108
+ }
54
109
  declare class StreamBufferStore {
55
110
  private buffers;
111
+ private replies;
56
112
  private cleanupTimer;
57
113
  constructor();
58
114
  /**
@@ -78,6 +134,20 @@ declare class StreamBufferStore {
78
134
  has(key: string): boolean;
79
135
  /** Read-only metadata about a buffered turn. Useful for resume responses. */
80
136
  snapshot(key: string): TurnSnapshot | null;
137
+ /**
138
+ * Keep the reply a turn finished with, so it can still be read after the
139
+ * buffer is evicted (30 s grace) — e.g. by the API's call follower, or by a
140
+ * reconciliation pass long after the stream is gone. Keyed by the same
141
+ * session key as the buffer.
142
+ */
143
+ recordTurnReply(key: string, reply: string): void;
144
+ /**
145
+ * The terminal reply for a session, or null when none was recorded or it
146
+ * expired. A reply belonging to an older turn of a session that has since
147
+ * started a new one is never returned.
148
+ */
149
+ turnReply(key: string): TurnReply | null;
150
+ private pruneReplies;
81
151
  /**
82
152
  * Create a ReadableStream that first replays all buffered chunks past
83
153
  * `opts.fromSeq` (default: 0 → replay everything), then subscribes for
@@ -86,11 +156,37 @@ declare class StreamBufferStore {
86
156
  * Returns null if no buffer exists for the key.
87
157
  */
88
158
  createReplayStream(key: string, opts?: ReplayOptions): ReadableStream<Uint8Array> | null;
159
+ private createBuiltReplayStream;
89
160
  cleanup(): void;
90
161
  dispose(): void;
91
162
  private completeBuffer;
92
163
  private failBuffer;
93
164
  }
165
+ /**
166
+ * The SSE data-part event type the client's auto-resuming fetch treats as a
167
+ * turn's terminal marker. The client keeps reconnecting to `/stream?fromSeq=N`
168
+ * until it parses one of these; a buffer that reaches a terminal `status`
169
+ * WITHOUT such a frame makes the client replay the tail forever and pins
170
+ * `useChat().status` at `streaming` (composer stuck on Stop/Queue).
171
+ */
172
+ declare const TURN_COMPLETE_EVENT_TYPE: "data-turn-complete";
173
+ interface TurnCompleteFrameData {
174
+ turnId: string;
175
+ chatSessionId: string;
176
+ status: TurnStatus;
177
+ error?: string;
178
+ lastSeq: number;
179
+ completedAt?: number;
180
+ }
181
+ /**
182
+ * Encode a `data-turn-complete` SSE frame as raw bytes suitable for
183
+ * {@link StreamBufferWriter.append}. Matches the AI SDK UI-message-stream wire
184
+ * format (`data: <json>\n\n`) so the client's SSE parser recognizes it
185
+ * identically to frames written through the live `writer`. Use this to
186
+ * synthesize a terminal marker when a turn ends abnormally (transport error,
187
+ * abort race) before the normal terminal frame was emitted.
188
+ */
189
+ declare function encodeTurnCompleteFrame(data: TurnCompleteFrameData): Uint8Array;
94
190
  /**
95
191
  * TransformStream that copies every chunk into a StreamBufferStore while
96
192
  * passing it through unchanged. On flush/cancel, completes the buffer.
@@ -100,4 +196,4 @@ declare class StreamBufferStore {
100
196
  */
101
197
  declare function createBufferingTransform(store: StreamBufferStore, key: string): TransformStream<Uint8Array, Uint8Array>;
102
198
 
103
- export { type ReplayOptions, StreamBufferStore, type StreamBufferWriter, type TurnSnapshot, type TurnStatus, type TurnTerminal, createBufferingTransform };
199
+ export { type ReplayOptions, StreamBufferStore, type StreamBufferWriter, TURN_COMPLETE_EVENT_TYPE, TURN_SEQ_EVENT_TYPE, type TurnCompleteFrameData, type TurnReply, type TurnSnapshot, type TurnStatus, type TurnTerminal, compactSseFrames, createBufferingTransform, encodeTurnCompleteFrame, encodeTurnSeqFrame };
@@ -1,3 +1,30 @@
1
+ /**
2
+ * Compaction of a buffered AI SDK UI-message stream for replay.
3
+ *
4
+ * A long turn buffers tens of thousands of tiny frames (one per text, reasoning
5
+ * or tool-input delta). Replaying them one by one makes a reconnecting client
6
+ * rebuild the message chunk by chunk, which takes seconds on a phone. The
7
+ * compacted replay produces the same message with a handful of chunks:
8
+ *
9
+ * - all deltas of one text / reasoning part are merged into its first delta
10
+ * (a part runs from its `*-start` to its `*-end`; providers reuse ids
11
+ * across steps, so merging never crosses a start);
12
+ * - tool-input deltas are merged per tool call, and dropped entirely once
13
+ * `tool-input-available` / `tool-input-error` carries the full input;
14
+ * - of a tool call's `preliminary: true` outputs only the latest is kept (each
15
+ * one carries the whole accumulated state, so it replaces the earlier ones),
16
+ * and all of them are dropped once the call's final output or error is in
17
+ * the buffer — a sub-agent streams one of these per update;
18
+ * - `data-turn-seq` heartbeats are dropped; the caller appends one with the
19
+ * exact current seq so `?fromSeq=N` reconnects stay correct.
20
+ *
21
+ * Anything that isn't a parseable `data: {json}` event is kept verbatim, in
22
+ * place, as is a trailing partial event.
23
+ */
24
+ declare const TURN_SEQ_EVENT_TYPE: "data-turn-seq";
25
+ declare function compactSseFrames(frames: Uint8Array[]): Uint8Array;
26
+ declare function encodeTurnSeqFrame(turnId: string, seq: number): Uint8Array;
27
+
1
28
  /**
2
29
  * In-memory stream buffer + turn ledger for SSE reconnect support.
3
30
  *
@@ -16,6 +43,7 @@
16
43
  *
17
44
  * Lifted into the SDK from `@shogo/shared-runtime` (was AGPL) under MIT.
18
45
  */
46
+
19
47
  type TurnStatus = 'active' | 'completed' | 'aborted' | 'failed';
20
48
  interface TurnTerminal {
21
49
  reason?: string;
@@ -41,6 +69,27 @@ interface ReplayOptions {
41
69
  * Skip frames with seq <= fromSeq. Use 0 (or omit) to replay from the start.
42
70
  */
43
71
  fromSeq?: number;
72
+ /**
73
+ * Replay the buffered frames as one compacted chunk (see
74
+ * `compactSseFrames`) followed by a `data-turn-seq` frame with the exact
75
+ * last seq, then the live frames. The rebuilt message is the same; the
76
+ * client processes a handful of chunks instead of one per delta.
77
+ */
78
+ compact?: boolean;
79
+ /**
80
+ * Replace the buffered-frame replay with caller-built bytes. Receives the
81
+ * frames after `fromSeq` and returns what to send before the live frames
82
+ * (a `data-turn-seq` frame with the exact last seq is appended). Takes
83
+ * precedence over `compact`; if it throws, the replay falls back to
84
+ * `compactSseFrames`.
85
+ *
86
+ * The builder may be async: live frames written while it runs are held
87
+ * back and delivered after its output, in order.
88
+ */
89
+ buildReplay?: (frames: Uint8Array[], ctx: {
90
+ turnId: string;
91
+ lastSeq: number;
92
+ }) => Uint8Array | Promise<Uint8Array>;
44
93
  }
45
94
  interface TurnSnapshot {
46
95
  turnId: string;
@@ -51,8 +100,15 @@ interface TurnSnapshot {
51
100
  completedAt: number | null;
52
101
  lastEventAt: number;
53
102
  }
103
+ /** The reply a pipeline turn finished with, kept past its buffer's lifetime. */
104
+ interface TurnReply {
105
+ turnId: string;
106
+ reply: string;
107
+ completedAt: number;
108
+ }
54
109
  declare class StreamBufferStore {
55
110
  private buffers;
111
+ private replies;
56
112
  private cleanupTimer;
57
113
  constructor();
58
114
  /**
@@ -78,6 +134,20 @@ declare class StreamBufferStore {
78
134
  has(key: string): boolean;
79
135
  /** Read-only metadata about a buffered turn. Useful for resume responses. */
80
136
  snapshot(key: string): TurnSnapshot | null;
137
+ /**
138
+ * Keep the reply a turn finished with, so it can still be read after the
139
+ * buffer is evicted (30 s grace) — e.g. by the API's call follower, or by a
140
+ * reconciliation pass long after the stream is gone. Keyed by the same
141
+ * session key as the buffer.
142
+ */
143
+ recordTurnReply(key: string, reply: string): void;
144
+ /**
145
+ * The terminal reply for a session, or null when none was recorded or it
146
+ * expired. A reply belonging to an older turn of a session that has since
147
+ * started a new one is never returned.
148
+ */
149
+ turnReply(key: string): TurnReply | null;
150
+ private pruneReplies;
81
151
  /**
82
152
  * Create a ReadableStream that first replays all buffered chunks past
83
153
  * `opts.fromSeq` (default: 0 → replay everything), then subscribes for
@@ -86,11 +156,37 @@ declare class StreamBufferStore {
86
156
  * Returns null if no buffer exists for the key.
87
157
  */
88
158
  createReplayStream(key: string, opts?: ReplayOptions): ReadableStream<Uint8Array> | null;
159
+ private createBuiltReplayStream;
89
160
  cleanup(): void;
90
161
  dispose(): void;
91
162
  private completeBuffer;
92
163
  private failBuffer;
93
164
  }
165
+ /**
166
+ * The SSE data-part event type the client's auto-resuming fetch treats as a
167
+ * turn's terminal marker. The client keeps reconnecting to `/stream?fromSeq=N`
168
+ * until it parses one of these; a buffer that reaches a terminal `status`
169
+ * WITHOUT such a frame makes the client replay the tail forever and pins
170
+ * `useChat().status` at `streaming` (composer stuck on Stop/Queue).
171
+ */
172
+ declare const TURN_COMPLETE_EVENT_TYPE: "data-turn-complete";
173
+ interface TurnCompleteFrameData {
174
+ turnId: string;
175
+ chatSessionId: string;
176
+ status: TurnStatus;
177
+ error?: string;
178
+ lastSeq: number;
179
+ completedAt?: number;
180
+ }
181
+ /**
182
+ * Encode a `data-turn-complete` SSE frame as raw bytes suitable for
183
+ * {@link StreamBufferWriter.append}. Matches the AI SDK UI-message-stream wire
184
+ * format (`data: <json>\n\n`) so the client's SSE parser recognizes it
185
+ * identically to frames written through the live `writer`. Use this to
186
+ * synthesize a terminal marker when a turn ends abnormally (transport error,
187
+ * abort race) before the normal terminal frame was emitted.
188
+ */
189
+ declare function encodeTurnCompleteFrame(data: TurnCompleteFrameData): Uint8Array;
94
190
  /**
95
191
  * TransformStream that copies every chunk into a StreamBufferStore while
96
192
  * passing it through unchanged. On flush/cancel, completes the buffer.
@@ -100,4 +196,4 @@ declare class StreamBufferStore {
100
196
  */
101
197
  declare function createBufferingTransform(store: StreamBufferStore, key: string): TransformStream<Uint8Array, Uint8Array>;
102
198
 
103
- export { type ReplayOptions, StreamBufferStore, type StreamBufferWriter, type TurnSnapshot, type TurnStatus, type TurnTerminal, createBufferingTransform };
199
+ export { type ReplayOptions, StreamBufferStore, type StreamBufferWriter, TURN_COMPLETE_EVENT_TYPE, TURN_SEQ_EVENT_TYPE, type TurnCompleteFrameData, type TurnReply, type TurnSnapshot, type TurnStatus, type TurnTerminal, compactSseFrames, createBufferingTransform, encodeTurnCompleteFrame, encodeTurnSeqFrame };
@@ -1,9 +1,19 @@
1
1
  import {
2
2
  StreamBufferStore,
3
- createBufferingTransform
4
- } from "./chunk-ELMG6OXT.js";
3
+ TURN_COMPLETE_EVENT_TYPE,
4
+ TURN_SEQ_EVENT_TYPE,
5
+ compactSseFrames,
6
+ createBufferingTransform,
7
+ encodeTurnCompleteFrame,
8
+ encodeTurnSeqFrame
9
+ } from "./chunk-H625AGFN.js";
5
10
  export {
6
11
  StreamBufferStore,
7
- createBufferingTransform
12
+ TURN_COMPLETE_EVENT_TYPE,
13
+ TURN_SEQ_EVENT_TYPE,
14
+ compactSseFrames,
15
+ createBufferingTransform,
16
+ encodeTurnCompleteFrame,
17
+ encodeTurnSeqFrame
8
18
  };
9
19
  //# sourceMappingURL=stream-buffer.js.map
@@ -21,7 +21,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var tech_stack_registry_exports = {};
22
22
  __export(tech_stack_registry_exports, {
23
23
  TECH_STACK_REGISTRY: () => TECH_STACK_REGISTRY,
24
+ getDeclaredPorts: () => getDeclaredPorts,
25
+ getMinimumInstanceSize: () => getMinimumInstanceSize,
24
26
  getStackEntry: () => getStackEntry,
27
+ isDockerTechStack: () => isDockerTechStack,
25
28
  isMobileTechStack: () => isMobileTechStack,
26
29
  stackSeedsItself: () => stackSeedsItself,
27
30
  usesMetroBundler: () => usesMetroBundler
@@ -32,14 +35,35 @@ var TECH_STACK_REGISTRY = {
32
35
  "react-app": { id: "react-app", target: "web" },
33
36
  "threejs-game": { id: "threejs-game", target: "web" },
34
37
  "phaser-game": { id: "phaser-game", target: "web" },
38
+ // Video (Remotion). `target: 'web'` because the canvas preview is the
39
+ // Remotion Player page the runtime serves from `dist/`; the stack's own
40
+ // `stack.json` declares `runtime.devServer: 'static-build'`, so no new
41
+ // target value (and therefore no new sizing/publish rules) is needed.
42
+ "video-editor": { id: "video-editor", target: "web", seedsOwnTemplate: true },
35
43
  // Mobile (Metro/Expo) — agent-runtime owns the seed.
36
44
  "expo-app": { id: "expo-app", target: "mobile", metro: true, seedsOwnTemplate: true },
37
45
  "expo-three": { id: "expo-three", target: "mobile", metro: true, seedsOwnTemplate: true },
38
46
  "react-native": { id: "react-native", target: "mobile", metro: true, seedsOwnTemplate: true },
39
47
  // Data / scripting
40
48
  "python-data": { id: "python-data", target: "data", seedsOwnTemplate: true },
49
+ // Multi-service backend (dockerd + docker compose). Requires the
50
+ // Docker-capable metal VM class; gated separately by
51
+ // `runtime.docker_class_enabled` (see apps/api/src/lib/runtime-class-setting.ts).
52
+ "docker-compose": {
53
+ id: "docker-compose",
54
+ target: "data",
55
+ seedsOwnTemplate: true,
56
+ vmClass: "docker",
57
+ minimumInstanceSize: "large",
58
+ ports: [
59
+ { port: 8e3, label: "app", protocol: "http", defaultVisibility: "preview" },
60
+ { port: 5432, label: "postgres", protocol: "tcp", defaultVisibility: "tunnel" }
61
+ ]
62
+ },
41
63
  // Native (full game engines)
42
64
  "unity-game": { id: "unity-game", target: "native", seedsOwnTemplate: true },
65
+ // Bring-your-own framework: the preview runs the project's static build.
66
+ custom: { id: "custom", target: "web", seedsOwnTemplate: true },
43
67
  // Bare / no-stack
44
68
  none: { id: "none", target: "none", seedsOwnTemplate: true }
45
69
  };
@@ -56,10 +80,22 @@ function usesMetroBundler(techStackId) {
56
80
  function stackSeedsItself(techStackId) {
57
81
  return getStackEntry(techStackId)?.seedsOwnTemplate === true;
58
82
  }
83
+ function isDockerTechStack(techStackId) {
84
+ return getStackEntry(techStackId)?.vmClass === "docker";
85
+ }
86
+ function getMinimumInstanceSize(techStackId) {
87
+ return getStackEntry(techStackId)?.minimumInstanceSize ?? null;
88
+ }
89
+ function getDeclaredPorts(techStackId) {
90
+ return getStackEntry(techStackId)?.ports ?? [];
91
+ }
59
92
  // Annotate the CommonJS export names for ESM import in node:
60
93
  0 && (module.exports = {
61
94
  TECH_STACK_REGISTRY,
95
+ getDeclaredPorts,
96
+ getMinimumInstanceSize,
62
97
  getStackEntry,
98
+ isDockerTechStack,
63
99
  isMobileTechStack,
64
100
  stackSeedsItself,
65
101
  usesMetroBundler