bunnyquery 1.9.7 → 1.9.10

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/dist/engine.d.mts CHANGED
@@ -63,6 +63,54 @@ declare function parseAttachmentContent(file: File, name: string, mime?: string)
63
63
  * send cancel). So the request builders include `poll` only when it is set.
64
64
  */
65
65
 
66
+ /**
67
+ * One report about a turn that is streaming, as handed to `onLiveStreamUpdate`.
68
+ *
69
+ * Deliberately a flat snapshot rather than the SseParser itself: the hook is a
70
+ * VIEW seam, and handing a client the parser would invite it to drive the stream
71
+ * (feed it, end it, read the assembled body) behind the session's back.
72
+ */
73
+ interface LiveStreamUpdate {
74
+ /** Server item id of the turn, the same id its bubbles carry as _serverItemId. */
75
+ serverItemId: string;
76
+ /** History cache key (`projectId#platform`) the turn belongs to. A host that
77
+ * renders several projects must ignore an update for a chat it is not showing. */
78
+ ownerKey: string;
79
+ /** 'start' on the first paint, 'update' on every later one, 'end' once the
80
+ * stream is over and nothing more will be painted - whether because the turn
81
+ * settled (its authoritative answer is about to replace the live text) or
82
+ * because it was stopped. 'end' is only sent to a host that was told 'start'. */
83
+ phase: 'start' | 'update' | 'end';
84
+ /** Answer text so far, already trimmed to a safe reveal boundary: it never
85
+ * ends inside a half-arrived link, fence or url. Empty on 'end'. */
86
+ text: string;
87
+ /** Extended-thinking text so far. Separate from `text` and never part of it. */
88
+ thinkingText: string;
89
+ /** Tools reached for, in order of appearance, duplicates kept. */
90
+ toolNames: string[];
91
+ /** A terminal event arrived. False on 'end' means the stream was cut. */
92
+ complete: boolean;
93
+ /** The terminal event that arrived meant the answer FINISHED rather than DIED:
94
+ * false while running, false on a cut stream, and false when the stream ended
95
+ * on a provider error. `complete` answers "is anything more coming?"; this one
96
+ * answers "is this the whole answer?", and they differ on exactly the case
97
+ * that costs text - an `error` frame is terminal and truncating at once. A host
98
+ * drawing a "partial answer" affordance wants THIS one. Added after `complete`
99
+ * and always present: a host that ignores it reads as it did before. */
100
+ answerComplete: boolean;
101
+ /** The stream ended in a provider error. */
102
+ errored: boolean;
103
+ /** How many chunks of this turn each of skapi's two transports carried FIRST:
104
+ * `socket` for the websocket relay, `poll` for the chunk-table read. Both feed
105
+ * the same sink by design, so a turn that streamed perfectly over the socket
106
+ * and one that was polled the whole way are otherwise indistinguishable. A
107
+ * host that does not care can ignore it; a host showing a live/degraded
108
+ * indicator, or just logging which path it got, reads this. */
109
+ transport: {
110
+ socket: number;
111
+ poll: number;
112
+ };
113
+ }
66
114
  interface ChatEngineConfig {
67
115
  /** skapi.clientSecretRequest, bound to the consumer's skapi instance. */
68
116
  clientSecretRequest: (opts: any) => Promise<any>;
@@ -137,6 +185,114 @@ interface ChatEngineConfig {
137
185
  queue?: string;
138
186
  };
139
187
  }) => void;
188
+ /**
189
+ * Opt in to LIVE STREAMING of chat turns.
190
+ *
191
+ * Off by default, and for the same shipping-order reason `windowedIndexing`
192
+ * is: THE BACKEND MUST SHIP FIRST. When on, every chat turn carries two
193
+ * `stream` flags (see requests.ts chatStreamWiring) and the polling row
194
+ * settles with a STATUS AND NO BODY, because the answer was the stream. On a
195
+ * region whose polling worker does not relay, that same request either has
196
+ * its unknown `since` cursor rejected or stores an SSE transcript where the
197
+ * readers expect a parsed document, and the turn reads back as an empty
198
+ * answer. So it stays off until the worker is deployed, then flips per
199
+ * environment.
200
+ *
201
+ * It also needs `clientSecretRequestFinalize` below: without it a streamed
202
+ * turn is never finalized, so its row keeps a status and no body forever and
203
+ * a later history load shows the question with an empty answer.
204
+ */
205
+ liveStreaming?: boolean;
206
+ /**
207
+ * Also push each relayed chunk over skapi's websocket, so text lands as it is
208
+ * relayed instead of on the next poll tick. Requires `liveStreaming`.
209
+ *
210
+ * SEPARATE FROM `liveStreaming` ON PURPOSE, and off unless a host asks. It is a
211
+ * pure accelerator with a safe fallback, so the reason is not risk to the chat, it
212
+ * is what it does to the HOST'S OWN realtime: skapi's joinRealtime REPLACES the
213
+ * connection's group rather than adding to it, so for the length of a turn this
214
+ * takes the room. The dashboard owns its skapi instance and uses realtime for
215
+ * nothing else, so it opts in. The embeddable widget is handed the EMBEDDER'S
216
+ * instance and cannot know what their app does with it, so it stays off there
217
+ * unless the embedder turns it on.
218
+ */
219
+ liveStreamingRealtime?: boolean;
220
+ /**
221
+ * skapi.clientSecretRequestFinalize, bound to the consumer's skapi instance.
222
+ * Stores the version of a streamed turn that history should keep (the engine
223
+ * sends the ASSEMBLED provider body, so history reads it exactly as it reads
224
+ * a buffered turn) and releases that request's chunks. Optional: a host
225
+ * without it can still stream, it just leaves the chunks and an empty row.
226
+ */
227
+ clientSecretRequestFinalize?: (requestId: string, data: any, options: {
228
+ url: string;
229
+ method: string;
230
+ service?: string;
231
+ owner?: string;
232
+ }) => Promise<any>;
233
+ /**
234
+ * skapi.clientSecretRequestStream, bound to the consumer's skapi instance.
235
+ *
236
+ * THE SECOND HALF OF THE DURABILITY GUARANTEE, and without it a streamed turn
237
+ * is only as durable as the tab that started it. A streamed row settles with a
238
+ * status and NO body; the answer is stored as chunks until
239
+ * clientSecretRequestFinalize says what to keep. A row that settles while no
240
+ * poll is attached (the user closed the tab, a mobile browser discarded it,
241
+ * the device slept and the interval stopped) is therefore never finalized, and
242
+ * a later history load sees a terminal row with no body and used to emit no
243
+ * assistant bubble at all: the answer simply gone from the conversation, with
244
+ * every byte of it still sitting in the chunk table.
245
+ *
246
+ * This is the documented way back to it. Given the request id it fetches every
247
+ * chunk of an already-finished turn in one pass (paging internally on `more`)
248
+ * and delivers them in order through `onStream`, then resolves. The engine
249
+ * feeds those into a fresh SSE parser and treats the assembled body exactly as
250
+ * it treats a live one, including finalizing it, which stores the answer as
251
+ * ordinary history and releases the chunks, so each row is recovered at most
252
+ * once ever.
253
+ *
254
+ * Optional. Without it the engine still marks such turns (`_streamPending` on
255
+ * the bubble) but has no way to read them back, so a host that ignores this
256
+ * behaves as it does today.
257
+ *
258
+ * NOTE THAT THIS HOOK, NOT `liveStreaming`, IS WHAT ARMS RECOVERY. See
259
+ * streamRecoveryEnabled() below for why the two decisions are separate.
260
+ */
261
+ clientSecretRequestStream?: (requestId: string, options: {
262
+ url: string;
263
+ method: string;
264
+ onStream?: (chunk: string, seq: number) => void;
265
+ since?: number;
266
+ poll?: number;
267
+ service?: string;
268
+ owner?: string;
269
+ }) => Promise<any>;
270
+ /**
271
+ * Observation hook for a live-streaming turn, called at most once per paint
272
+ * (about once a second) plus once when the turn settles.
273
+ *
274
+ * The engine already paints the answer text into the pending bubble itself,
275
+ * so a host needs this ONLY for the affordances the engine deliberately does
276
+ * not decide the presentation of: a "thinking..." line, or a "querying sales
277
+ * table..." row drawn from the tools the model reached for before any answer
278
+ * text exists. Optional, and a host without it behaves exactly as today.
279
+ *
280
+ * Never throw from it: it is called on the paint path and a throw would cost
281
+ * the user the rest of their answer. The engine guards it anyway.
282
+ */
283
+ onLiveStreamUpdate?: (update: LiveStreamUpdate) => void;
284
+ /**
285
+ * Force the read-back of already-streamed turns OFF, even though the chunk
286
+ * reader is injected.
287
+ *
288
+ * There is no need to set it to turn recovery ON: injecting
289
+ * `clientSecretRequestStream` is what arms it (see streamRecoveryEnabled).
290
+ * This exists only as the way back out for a host that wants byte-for-byte the
291
+ * pre-recovery rendering of a terminal-but-empty row - no bubble, no marker, no
292
+ * chunk read - while keeping the reader available for its own use. Omit it and
293
+ * nothing changes.
294
+ */
295
+ streamRecovery?: boolean;
140
296
  /**
141
297
  * Single-item csr-poll point lookup (skapi.util.request('csr-poll', {id,
142
298
  * service, owner}, {auth:true})). For a RESOLVED item the backend returns
@@ -149,6 +305,75 @@ interface ChatEngineConfig {
149
305
  }
150
306
  declare function configureChatEngine(config: ChatEngineConfig): void;
151
307
  declare function chatEngineConfig(): ChatEngineConfig;
308
+ /**
309
+ * True when a streamed turn whose answer never reached its row can be READ BACK.
310
+ *
311
+ * IT ASKS FOR THE READER AND NOT FOR `liveStreaming`, AND THAT SPLIT IS THE WHOLE
312
+ * POINT. "Should NEW turns stream?" and "can an ALREADY streamed row be recovered?"
313
+ * are two different questions about two different sets of rows, and answering both
314
+ * with one flag strands the second set the moment the first answer changes.
315
+ *
316
+ * The failure, and it is not hypothetical - it is what turning the feature off
317
+ * does. A row streamed yesterday holds its answer in the chunk table and a status
318
+ * and no body on the row; only csr-finalize ever copies one onto it. Flip
319
+ * `liveStreaming` off today (an embedder drops the option, a dev rolls the flag
320
+ * back after a bad deploy, a client's skapiSupportsStreaming probe degrades the
321
+ * instance to buffered) and every one of those rows instantly becomes unmarked,
322
+ * unrecoverable and unreadable: the mapper emits no bubble for it, the recovery
323
+ * never looks at it, and its answer is unreachable with every byte of it still
324
+ * stored. Rolling a rendering flag back must not delete anybody's history.
325
+ *
326
+ * The reader is the honest test because it is the CAPABILITY the recovery needs.
327
+ * Without it the engine could mark such a turn and never fill it in, trading a
328
+ * missing bubble for a permanently empty one, which is strictly worse than the
329
+ * bug - so the marker is still only ever minted when something can act on it.
330
+ *
331
+ * The cost of asking the wider question is one wasted read, once, on a row that
332
+ * was terminal and empty for some reason other than streaming (a buffered turn
333
+ * whose body the worker never managed to spill). That read finds no chunks, the
334
+ * bubble is dropped, and the list looks exactly as it did before. Set
335
+ * `streamRecovery: false` to opt out of even that.
336
+ */
337
+ declare function streamRecoveryEnabled(): boolean;
338
+ /**
339
+ * Does a given skapi INSTANCE support the streaming half of the protocol? Ask this
340
+ * before honouring a `liveStreaming: true` opt-in, and degrade to buffered when the
341
+ * answer is no.
342
+ *
343
+ * THE FAILURE THIS PREVENTS, and it is the one the SDK's own docs call quiet. A
344
+ * streamed turn carries TWO `stream` flags: skapi's (relay the destination's bytes
345
+ * into the chunk table) and the DESTINATION's own field inside `data`, which
346
+ * BunnyQuery is the party that sets, because skapi relays bytes and knows no
347
+ * vendor. clientSecretRequest validates its params against a schema and KEEPS ONLY
348
+ * THE KEYS IN THAT SCHEMA, so an skapi-js predating the feature does not reject
349
+ * `stream` - it silently DROPS it. What ships is then the exact split
350
+ * chatStreamWiring exists to make impossible: the destination is asked to answer in
351
+ * SSE frames, skapi waits and stores the whole transcript on the row, and
352
+ * extractClaudeText / extractOpenAIText read a wall of `data: {...}` lines where a
353
+ * document should be and find no answer at all. Nothing throws and nothing logs;
354
+ * the user gets an empty reply on every single turn.
355
+ *
356
+ * It lives in the ENGINE rather than in each client because the two clients are
357
+ * diffed against each other and this is precisely the kind of predicate that forks:
358
+ * the widget must ask it (init() takes the EMBEDDER's instance, and an embed page
359
+ * pins its own skapi-js version, so `liveStreaming: true` is a REQUEST and this is
360
+ * what grants it) and agent.vue must ask it too (its instance is the repo's own, so
361
+ * only a stale node_modules or an unbuilt skapi-js can fail it - which is exactly
362
+ * the state a dev flipping the constant is most likely to be in, and a silently
363
+ * empty chat is the worst possible way to find out).
364
+ *
365
+ * Probed by the two public METHODS rather than by a version string, for two
366
+ * reasons. They ship in the same change as the `stream` key (one feature: the
367
+ * relay, the finalize that stores what to keep, and the read-back), so an SDK
368
+ * missing them is exactly the SDK that would drop the flag. And they are not merely
369
+ * a proxy for the capability, they ARE half of it - the engine needs finalize to
370
+ * store a streamed answer onto its row and stream to read an unfinalized one back,
371
+ * and streaming without either leaves every answer in the chunk table with nothing
372
+ * able to fetch it. There is no cheap DIRECT probe of the schema: the only way to
373
+ * learn that `stream` was dropped is to send a real request and read an empty
374
+ * answer, which is the bug itself.
375
+ */
376
+ declare function skapiSupportsStreaming(sk: any): boolean;
152
377
 
153
378
  /**
154
379
  * Office-file server-side extraction helpers.
@@ -280,8 +505,10 @@ type ChatSystemPromptParams = {
280
505
  */
281
506
  client?: 'console' | 'widget';
282
507
  /**
283
- * The access group THIS project's indexer writes its records at, from the
284
- * project's `default_access_group` setting.
508
+ * The access group THIS project's indexer writes its records at, read from
509
+ * the project's BunnyQuery settings record (`bq::settings`, key
510
+ * `upload_access_group`). It used to come from the service record's
511
+ * `default_access_group`, which no longer exists.
285
512
  *
286
513
  * The MCP auto-fills an index/tag query that names a table but no group with
287
514
  * "authorized", which used to be right because every BunnyQuery record was
@@ -289,6 +516,16 @@ type ChatSystemPromptParams = {
289
516
  * visitor can read it) or "private", and on those projects the auto-fill
290
517
  * silently searches a group the data is not in and answers "nothing found".
291
518
  * Defaults to 'authorized', which is what an unset project still uses.
519
+ *
520
+ * A PLAIN table query needs the group just as much, and this is newer: the
521
+ * SDK no longer fills a group in for a table that arrives without one, so the
522
+ * SERVER resolves it, and it resolves it differently per caller. A master
523
+ * (the project's owner) is answered across every access group; a normal
524
+ * signed-in user is answered from access_group 0 alone. So an end user asking
525
+ * about a table indexed at "authorized" would silently search public only,
526
+ * and get "nothing found" over data that is right there. The prompt therefore
527
+ * asks for the group on EVERY query that names a table, not just index/tag
528
+ * ones.
292
529
  */
293
530
  indexAccessGroup?: string;
294
531
  };
@@ -468,6 +705,58 @@ declare function buildChatGreeting(params: ChatGreetingParams): ChatGreetingPart
468
705
  * Error detection + message extraction (pure). Moved verbatim from the
469
706
  * agent.vue / bunnyquery chatbox so both consumers share one implementation.
470
707
  */
708
+ /**
709
+ * True when a csr-poll answer is the STATUS ENVELOPE rather than a stored body.
710
+ *
711
+ * Duck-typed, because the engine does not import skapi-js and the SDK does not
712
+ * export its own copy. The rule is the SDK's (isPollEnvelope): a request that has
713
+ * a stored result hands that result back verbatim, every other state hands back
714
+ * `{ id, status, in_queue, ... }`. A finalized body is the caller's own content
715
+ * and can itself carry a `status` key (OpenAI's Responses object does), so the
716
+ * id/in_queue pair is demanded too: a provider body would have to reproduce all
717
+ * three to be mistaken for an envelope.
718
+ *
719
+ * It lives HERE, next to the error readers, rather than in session.ts, because
720
+ * both of the things that have to recognise an envelope (the settle that
721
+ * substitutes an assembled body for one, and the error readers below) must agree
722
+ * on what one is. It was written twice once; that is how the error readers came to
723
+ * look one level too shallow.
724
+ */
725
+ declare function isCsrStatusEnvelope(res: any): boolean;
726
+ /**
727
+ * The real error payload inside a FAILED csr-poll status envelope, or undefined
728
+ * when `input` is not one.
729
+ *
730
+ * THE FAILURE THIS PREVENTS, verbatim from the wire. A buffered turn that fails
731
+ * polls back as the worker's failed payload itself:
732
+ *
733
+ * { status_code: 401, body: { error: { type, message } }, truncated: false }
734
+ *
735
+ * ...which every predicate below reads correctly. A STREAMED turn that fails does
736
+ * not: the poller (client_secret_key_request_polling) cannot return the error
737
+ * early for a streamed row, because the chunks that arrived before the stream died
738
+ * have to come back in the same response, so it falls through and ships
739
+ *
740
+ * { id, status: 'failed', queue_name, in_queue, stream, chunks, last_seq,
741
+ * more, error: <the payload above> }
742
+ *
743
+ * The payload is one level deeper, and every predicate here looked at the top
744
+ * level: `response.error.message` is undefined on it, `response.status_code` is
745
+ * absent, and `response.status` is the string 'failed' rather than a number. So a
746
+ * wrong API key on a streamed turn read as "not an error, and no answer either",
747
+ * which the caller renders as "No text response received from AI provider": the
748
+ * one message that tells the user nothing.
749
+ *
750
+ * Unwrapping HERE, once, is what makes a streamed error and a buffered error take
751
+ * the same path through every reader below. Deliberately not recursive: the value
752
+ * inside an envelope is a provider payload, never another envelope, and a single
753
+ * unwrap cannot loop on a malformed one.
754
+ *
755
+ * A failed envelope with a NULL payload (the worker recorded no detail, or its
756
+ * spill could not be fetched) still yields an object, because the row's status is
757
+ * itself the fact: 'failed' with nothing attached must not read as a clean turn.
758
+ */
759
+ declare function csrEnvelopeError(input: any): any;
471
760
  declare function getErrorMessage(input: any): string;
472
761
  declare function isErrorResponseBody(response: any): boolean;
473
762
  declare function isNonRetryableRequestError(input: any): boolean;
@@ -1216,9 +1505,275 @@ type ParsedAiAgent = {
1216
1505
  declare function parseAiAgentValue(value: string | null | undefined): ParsedAiAgent;
1217
1506
  declare function buildAiAgentValue(platform: string | null | undefined, model?: string | null, contextWindow?: number | null): string;
1218
1507
 
1508
+ /**
1509
+ * Streamed-turn parser: raw provider SSE bytes in, live answer text + the body a
1510
+ * buffered call would have returned out.
1511
+ *
1512
+ * WHY THIS FILE EXISTS, AND WHY IT IS HERE AND NOT IN SKAPI.
1513
+ * skapi's clientSecretRequest is a byte relay. On a streamed turn the worker reads
1514
+ * the destination's response incrementally and appends the raw bytes to a chunk
1515
+ * table; it settles the polling row with STATUS ONLY, no body, because the content
1516
+ * lives in the chunks. skapi therefore does not know that Anthropic or OpenAI
1517
+ * exist, has no dialect list, and parses nothing. BunnyQuery is the party that
1518
+ * knows which destination it dialled, so BunnyQuery is the party that parses, and
1519
+ * this module is the whole of that knowledge.
1520
+ *
1521
+ * WHAT ARRIVES. csr-poll hands back `chunks: [{seq, txt}]` (ascending, seq starts
1522
+ * at 1) plus `last_seq` to send back as the next `since`. A chunk is whatever the
1523
+ * worker's flush happened to contain: it flushes on a byte cap or a time interval,
1524
+ * so a chunk boundary lands wherever the socket broke, which is routinely in the
1525
+ * MIDDLE of an SSE frame. Half a frame is not data, so every partial is held in a
1526
+ * buffer until the rest arrives, and nothing is ever emitted from an incomplete
1527
+ * frame. That is what makes this a stateful object fed chunks rather than a
1528
+ * function over a whole transcript.
1529
+ *
1530
+ * REPLAY SAFETY. The parser is a pure function of the chunk SEQUENCE, so a reload
1531
+ * or a second tab that starts at seq 1 of a stream it did not initiate rebuilds
1532
+ * the identical state. Nothing here depends on having dispatched the request.
1533
+ *
1534
+ * THE TWO OUTPUTS, AND WHY BOTH ARE NEEDED.
1535
+ * text the assistant's answer ONLY, for rendering as it arrives. Not tool
1536
+ * arguments, not thinking. Concatenating every delta into one string
1537
+ * is how a half serialised tool call ends up in the middle of a
1538
+ * user's sentence.
1539
+ * finalBody() the assembled provider body, byte equivalent to the buffered
1540
+ * response, so extractClaudeText / extractOpenAIText (requests.ts)
1541
+ * produce the identical string whether the turn was read live or
1542
+ * re-read from history later.
1543
+ *
1544
+ * THE BUG THIS IS SHAPED AROUND. extractClaudeText joins TEXT BLOCKS with '\n':
1545
+ *
1546
+ * content.filter(b => b.type === 'text').map(b => b.text).join('\n')
1547
+ *
1548
+ * A server-tool turn has text at content index 0, the tool call at 1, its result
1549
+ * at 2, and text again at 3. Accumulate every text_delta into one string and those
1550
+ * two paragraphs fuse with no separator, so the answer the user watched arrive and
1551
+ * the same turn re-read from history are different strings. Blocks are therefore
1552
+ * keyed BY INDEX and never merged, and `text` is the join of the text blocks in
1553
+ * index order, which is exactly the extractor's rule and not an approximation of
1554
+ * it. See tests/sse-stream.cjs, "four separate blocks".
1555
+ *
1556
+ * NEVER THROWS FROM feed(). Chunks arrive on a poll tick, inside a timer the
1557
+ * consumer cannot reasonably wrap; a parse error there would take the whole poll
1558
+ * down over one malformed frame. Frames that cannot be understood are counted
1559
+ * (`malformedFrames`) and skipped.
1560
+ *
1561
+ * HONEST TERMINATION, AND WHY IT TAKES TWO FLAGS. `complete` means a terminal event
1562
+ * actually arrived. A stream that was cut (deadline, cancelled row, a worker crash
1563
+ * after some chunks landed) reports complete:false, and the caller must not present
1564
+ * it as a finished answer. A partial answer the reader can see beats an empty turn,
1565
+ * but only if it is labelled as partial.
1566
+ *
1567
+ * That flag alone used to be read as "the answer is whole", and it is not the same
1568
+ * claim. An `error` frame IS a terminal event: the provider said the stream is over
1569
+ * and nothing more is coming. But the text in hand is only whatever arrived before
1570
+ * the error, so the answer is TRUNCATED and the stream is FINISHED at the same
1571
+ * time. A caller whose finalize gate read `complete` therefore stored the
1572
+ * truncation as the turn's permanent history and, because finalize is also the only
1573
+ * way to release chunks, deleted the only copy of the bytes in the same call - for
1574
+ * a turn the provider had explicitly told it went wrong. So the two claims are two
1575
+ * fields:
1576
+ *
1577
+ * complete a terminal event arrived. Nothing more is coming; stop waiting.
1578
+ * answerComplete ...and it was a terminal event that means the answer FINISHED
1579
+ * (message_stop, response.completed, response.incomplete), not
1580
+ * one that means it DIED (an `error` frame, response.failed, a
1581
+ * terminal Response carrying an error payload).
1582
+ *
1583
+ * `response.incomplete` is deliberately on the finished side: the model stopped
1584
+ * short at max_output_tokens, but the terminal event carries the complete Response
1585
+ * document, so the chunks hold nothing the body does not. A caller deciding what to
1586
+ * keep reads `answerComplete`; a caller deciding whether to keep waiting reads
1587
+ * `complete`.
1588
+ *
1589
+ * WHEN THE BYTES ARE NOT SSE AT ALL. skapi's `stream: true` tells the RELAY to read
1590
+ * the response incrementally. It does not tell the destination to produce an event
1591
+ * stream: that is the caller's own request body. If the body never asked for one
1592
+ * (or something in front of the destination buffers the stream back into a single
1593
+ * document and drops the framing), the answer arrives as a plain JSON body with not
1594
+ * one `data:` line in it. Every frame test below then matches nothing, and the turn
1595
+ * used to end as an empty answer with malformedFrames 0, complete false and
1596
+ * finalBody() null: the entire reply lost, with nothing in the output saying so, so
1597
+ * a client draws an empty bubble and no error. That state is now reported as
1598
+ * `unframed`, and the bytes are handed back BOTH ways, because the parser cannot
1599
+ * tell an answer from a gateway's error page without knowing the vendor, and it
1600
+ * must not:
1601
+ * unframedText the bytes verbatim, for a body that is not JSON at all (an HTML
1602
+ * 502 page), which finalBody() cannot represent.
1603
+ * finalBody() the parsed document when the bytes ARE JSON, because a buffered
1604
+ * body is exactly what finalBody() promises, so the caller's
1605
+ * existing buffered path (isErrorResponseBody, extractClaudeText,
1606
+ * extractOpenAIText) reads it with no new branch at all.
1607
+ * Noticing that a byte stream carries no SSE framing is framing, not parsing, and
1608
+ * JSON.parse is the same transport-level codec this file already runs on every
1609
+ * frame payload. Nothing about the document is interpreted: it is handed over
1610
+ * whole, and `provider` stays null because no event ever identified one.
1611
+ *
1612
+ * DOM-free and framework-free like the rest of the engine.
1613
+ */
1614
+ /** One row of csr-poll's `chunks`. */
1615
+ interface SseChunk {
1616
+ seq: number;
1617
+ txt: string;
1618
+ }
1619
+ /**
1620
+ * Which grammar the bytes turned out to be in. Detected, never declared: see
1621
+ * detectProvider() below for why the caller is not asked.
1622
+ */
1623
+ type SseProvider = 'claude' | 'openai';
1624
+ /**
1625
+ * A tool the model reached for, in the order it appeared, so a "querying sales
1626
+ * table..." row can be drawn before a single character of answer text exists.
1627
+ * Duplicates are kept: two calls to the same tool are two rows, not one.
1628
+ */
1629
+ interface SseToolCall {
1630
+ /** Anthropic content index, or OpenAI output index. Identifies the block. */
1631
+ index: number;
1632
+ /** The name as the provider wrote it, falling back to the block/item type for
1633
+ * a built-in that carries no name of its own (OpenAI's web_search_call). */
1634
+ name: string;
1635
+ /** The provider's own block/item type: tool_use, server_tool_use,
1636
+ * mcp_tool_use, function_call, mcp_call, web_search_call, ... */
1637
+ type: string;
1638
+ /** Present on Anthropic mcp_tool_use only. */
1639
+ serverName?: string;
1640
+ }
1641
+ interface SseSnapshot {
1642
+ /** null until the first identifying event has been seen. */
1643
+ provider: SseProvider | null;
1644
+ /** The assistant's answer text so far, joined exactly as the extractor joins
1645
+ * it. Never contains tool arguments or thinking. */
1646
+ text: string;
1647
+ /** Extended-thinking text so far, for a "thinking..." affordance. Deliberately
1648
+ * a SEPARATE field: it must never be concatenated into `text`. Populated on
1649
+ * BOTH providers (Anthropic thinking blocks, OpenAI reasoning summary and
1650
+ * reasoning text deltas). It used to be Anthropic-only, which meant the field
1651
+ * read as "the model's thinking" on one provider and as "this model did not
1652
+ * think" on the other, and every consumer that did not branch on `provider`
1653
+ * drew the wrong thing. Absorbing exactly that branch is what this module is
1654
+ * for, so the field is filled rather than renamed. */
1655
+ thinkingText: string;
1656
+ /** Tools reached for, in order of appearance. */
1657
+ toolCalls: SseToolCall[];
1658
+ /** Convenience projection of toolCalls, same order, duplicates kept. */
1659
+ toolNames: string[];
1660
+ /** Anthropic stop_reason ('end_turn' | 'tool_use' | 'max_tokens' | ...), or for
1661
+ * OpenAI the terminal Response's status, or its incomplete_details.reason when
1662
+ * it stopped short ('max_output_tokens'). null until the stream says. */
1663
+ stopReason: string | null;
1664
+ /** A terminal event ARRIVED. False means the stream was cut and whatever is
1665
+ * here is partial: do not present it as a finished answer.
1666
+ *
1667
+ * THIS IS NOT THE FLAG TO STORE BY. It answers "is anything more coming?", not
1668
+ * "is this the whole answer?" - see `answerComplete`. */
1669
+ complete: boolean;
1670
+ /** The terminal event that arrived means the answer FINISHED, not that it DIED.
1671
+ *
1672
+ * True on message_stop, response.completed and response.incomplete; false while
1673
+ * the stream is still running, false when it was cut, and false when it ended
1674
+ * on an `error` frame, on response.failed, or on a terminal Response carrying
1675
+ * an error payload.
1676
+ *
1677
+ * THE FAILURE THIS FIELD EXISTS FOR. An `error` frame sets `terminalEvent`, so
1678
+ * `complete` goes true while the text is only what arrived before the error. A
1679
+ * caller that finalizes on `complete` therefore writes that truncation into the
1680
+ * turn's permanent history AND releases the chunks it was assembled from, which
1681
+ * is the one loss in this feature that cannot be undone. Every keep/store gate
1682
+ * reads THIS field; `complete` is for deciding whether to keep waiting. Bytes
1683
+ * that were never SSE at all reach neither: see `unframed`, where it is the
1684
+ * polling row's status and not the parse that says the response finished. */
1685
+ answerComplete: boolean;
1686
+ /** The exact terminal event: 'message_stop', 'response.completed',
1687
+ * 'response.incomplete', 'response.failed', 'error'. null while running. */
1688
+ terminalEvent: string | null;
1689
+ /** The stream ended in a provider error. */
1690
+ errored: boolean;
1691
+ /** The provider's error payload, in the shape isErrorResponseBody() detects. */
1692
+ error: any;
1693
+ /** Frames that could not be parsed, and tool-argument JSON that would not
1694
+ * parse at content_block_stop. Diagnostics: both are zero on a healthy turn. */
1695
+ malformedFrames: number;
1696
+ malformedToolJson: number;
1697
+ /** Bytes were relayed, end() was called, and NOT ONE of them was SSE framing:
1698
+ * no `data:`, no `event:`, not even a comment. The destination answered with a
1699
+ * plain body instead of an event stream. This is NOT malformedFrames: there
1700
+ * were no frames to mangle. Nothing is lost, `unframedText` is the bytes and
1701
+ * finalBody() is the parsed document when they are JSON, so the caller can
1702
+ * either render them through its buffered path or surface a real error.
1703
+ * `complete` stays false here because no terminal EVENT arrived and none ever
1704
+ * will: on an unframed body it is the polling row's own status, not the bytes,
1705
+ * that says whether the response finished. */
1706
+ unframed: boolean;
1707
+ /** The relayed bytes verbatim when `unframed`, else null. */
1708
+ unframedText: string | null;
1709
+ /** Highest chunk seq accepted, for the caller's `since` cursor. 0 = none. */
1710
+ lastSeq: number;
1711
+ }
1712
+ interface SseParser {
1713
+ /** Feed raw bytes. Any prefix of a frame is held until the rest arrives. */
1714
+ feed(text: string): void;
1715
+ /** Feed csr-poll's `chunks` array. Chunks at or below the highest seq already
1716
+ * accepted are DROPPED, so a re-poll from a stale `since` cannot double-append
1717
+ * the same bytes into the answer. */
1718
+ feedChunks(chunks: SseChunk[] | null | undefined): void;
1719
+ /** No more bytes are coming. Flushes a final frame that arrived without its
1720
+ * terminating blank line. Does NOT mark the stream complete: only a terminal
1721
+ * event does that. It IS what decides `unframed`, because up to this call
1722
+ * "no framing seen yet" and "the first frame has not finished arriving" are
1723
+ * the same state, so a caller that never calls end() never learns the bytes
1724
+ * were not SSE. */
1725
+ end(): void;
1726
+ snapshot(): SseSnapshot;
1727
+ /** The assembled provider body, byte equivalent to a buffered response, or
1728
+ * null when nothing has been assembled. See buildBody() for the two rules:
1729
+ * one about errors, one about bytes that were never SSE. */
1730
+ finalBody(): any;
1731
+ }
1732
+ declare function createSseParser(): SseParser;
1733
+
1219
1734
  declare const MCP_NAME = "BunnyQuery";
1220
1735
  declare const DEFAULT_CLAUDE_MODEL = "claude-sonnet-5";
1221
1736
  declare const DEFAULT_OPENAI_MODEL = "gpt-5.6-luna";
1737
+ /**
1738
+ * THE two `stream` flags of a streamed chat turn, produced together or not at all.
1739
+ *
1740
+ * There are two of them and they are NOT the same flag:
1741
+ *
1742
+ * * `transport.stream` is SKAPI's. It tells the polling worker to read the
1743
+ * destination's response incrementally and append the raw bytes to the chunk
1744
+ * table, and it is never sent on to the destination.
1745
+ * * `body.stream` is the DESTINATION's own field, and BunnyQuery is the party
1746
+ * that may set it: skapi relays bytes and knows no vendor, so it cannot know
1747
+ * that Anthropic Messages and OpenAI Responses both happen to spell it
1748
+ * `stream` at the top level of the body.
1749
+ *
1750
+ * Setting one without the other fails QUIETLY, which is why they are produced by
1751
+ * one function from one boolean and returned as one object:
1752
+ *
1753
+ * * body streams, skapi buffers -> the row stores an SSE TRANSCRIPT where
1754
+ * extractClaudeText / extractOpenAIText expect a parsed document, so the turn
1755
+ * reads back as an empty answer with nothing in the logs to say why.
1756
+ * * skapi streams, the body never asked -> the destination sends one plain
1757
+ * document, the relay chops it into chunks, the frame parser finds no framing
1758
+ * at all, and the row settles with a status and no body.
1759
+ *
1760
+ * Two frozen constants rather than a fresh object per call: the pair is a
1761
+ * CONSTANT, and an object literal built at each call site is exactly the shape
1762
+ * that drifts when someone edits one arm.
1763
+ */
1764
+ type ChatStreamWiring = {
1765
+ /** Spread into the clientSecretRequest OPTIONS (skapi's relay switch). `realtime`
1766
+ * belongs here and never in `body`: it is skapi's, not the destination's. */
1767
+ transport: {
1768
+ stream?: true;
1769
+ realtime?: true;
1770
+ };
1771
+ /** Spread into `data` (the destination's own switch). */
1772
+ body: {
1773
+ stream?: true;
1774
+ };
1775
+ };
1776
+ declare function chatStreamWiring(queue?: string): ChatStreamWiring;
1222
1777
  /** How a given model should be shown a rendered document. */
1223
1778
  type VisionProfile = {
1224
1779
  /** Per-image `detail` (OpenAI only). */
@@ -1284,6 +1839,7 @@ type CallClaudeWithMcpParams = {
1284
1839
  onError?: (err: any) => void;
1285
1840
  };
1286
1841
  declare const POLL_INTERVAL = 3000;
1842
+ declare const STREAM_POLL_INTERVAL = 1000;
1287
1843
  declare const MAX_CONCURRENT_BG_POLLS = 6;
1288
1844
  declare function callClaudeWithMcp({ prompt, messages, service, owner, userId, model, maxTokens, system, mcpServer, extractContent, fileUrls, }: CallClaudeWithMcpParams): Promise<any>;
1289
1845
  declare function callClaudeWithPublicMcp(prompt: string, service: string, owner: string, messages?: ClaudeMessage[], system?: string, model?: string, userId?: string, extractContent?: ExtractDirective[], fileUrls?: FileUrlDirective[], onResponse?: (res: any) => void, onError?: (err: any) => void, mcpScope?: {
@@ -1611,6 +2167,91 @@ interface ChatMessage {
1611
2167
  * still dimmed. Cleared (with _dimSending) by markStagedMessageReady the moment
1612
2168
  * the queue drains, which is when the turn genuinely becomes "(In queue)". */
1613
2169
  isAwaitingIndexing?: boolean;
2170
+ /** PROTOCOL + PRESENTATIONAL: this bubble is being painted from a LIVE stream.
2171
+ *
2172
+ * It sits alongside `isPending`, never instead of it. The bubble is still the
2173
+ * turn's "Thinking..." placeholder as far as every queue mechanism is concerned
2174
+ * (_ownThinkingIndex, resolveQueuedUserBubble, typewriteLatestReply and the
2175
+ * stray-pending sweep all find their target by isPending), and clearing that flag
2176
+ * to mean "it has text now" would strand the turn's real answer beside an orphan.
2177
+ * What this adds is the one thing those mechanisms do not care about and the VIEW
2178
+ * does: `content` is already worth rendering, so draw the text instead of the
2179
+ * spinner.
2180
+ *
2181
+ * Cleared the moment the turn settles, BEFORE the authoritative answer replaces
2182
+ * the live text: from that instant the bubble is an ordinary reply being typed.
2183
+ * The partially painted `content` is deliberately left in place across that clear,
2184
+ * because it is what the typewriter resumes from instead of replaying from zero.
2185
+ *
2186
+ * Also read by shouldRescueInFlightMessage: a streaming bubble that has no server
2187
+ * id yet is unrepresentable in a freshly fetched page, so it must survive the
2188
+ * merge or its stream is orphaned with nothing left to paint into. */
2189
+ _streaming?: boolean;
2190
+ /**
2191
+ * This turn's row is TERMINAL but carries no stored answer, because the answer
2192
+ * was streamed and nobody ever finalized it: the bytes are in the chunk store,
2193
+ * not on the row. The bubble's `content` is therefore UNKNOWN, not empty.
2194
+ *
2195
+ * That distinction is the whole of the fix for two bugs that looked unrelated.
2196
+ * A refetch landing in the window between the row going 'resolved' and finalize
2197
+ * storing the body used to ERASE the answer off the screen (the server copy has
2198
+ * no content, so the merge dropped the local bubble that did); and a turn that
2199
+ * settled while no poll was attached (closed tab, slept device) used to be
2200
+ * unrecoverable, because the mapper emitted no assistant bubble at all for a
2201
+ * terminal-but-empty row. Both are the same question, "what should the merge
2202
+ * believe when the server copy is authoritative but empty", and the answer is
2203
+ * this flag: an unknown answer NEVER overwrites a known one, and an unknown one
2204
+ * left over after the merge is resolved by reading the chunks back
2205
+ * (ChatSession.recoverStreamedAnswer).
2206
+ *
2207
+ * For a view it is a rendering hint and nothing more: a bubble carrying it with
2208
+ * empty content is being fetched, so draw whatever this client draws for a
2209
+ * loading answer. A host that ignores it renders an empty bubble for the second
2210
+ * or two the recovery takes, which is what it would have rendered anyway.
2211
+ *
2212
+ * WHEN IT COMES OFF, because that is the half that loses answers. It comes off
2213
+ * for a FACT about the turn and never for an event in the client: an answer was
2214
+ * recovered and written in, or the chunks were read and were genuinely empty (in
2215
+ * which case the empty bubble is removed as well, restoring the list the mapper
2216
+ * used to produce). It stays ON when the read FAILED, when the read was STOPPED,
2217
+ * and when a live turn settled having painted nothing - three states that say
2218
+ * nothing whatever about the turn, and in which the chunks are all still there.
2219
+ * A marker cleared on one of those is an answer nothing will ever go back for,
2220
+ * so a host may see the same bubble marked across several loads while the reads
2221
+ * keep failing; that is the recoverable state, not a stuck one.
2222
+ */
2223
+ _streamPending?: boolean;
2224
+ /**
2225
+ * IS ANYTHING ACTUALLY DRIVING THIS BUBBLE RIGHT NOW? The second half of
2226
+ * `_streamPending`, and the half a view cannot do without.
2227
+ *
2228
+ * `_streamPending` says the answer is elsewhere; it does NOT say somebody is on
2229
+ * their way to fetch it, and the two are different states that used to render
2230
+ * identically. Recovery is capped per history load (STREAM_RECOVERY_PER_LOAD),
2231
+ * so the third and later marked turns on a page are marked and nobody is reading
2232
+ * them; a read that FAILED leaves the marker on with the attempt forgotten, which
2233
+ * is also nobody. Both drew the same loader as a live turn, so a bubble could
2234
+ * spin for the rest of the session with nothing behind it - the one thing a
2235
+ * spinner must never do, because it is a promise that something is coming.
2236
+ *
2237
+ * Three states, and only the first of them may draw a spinner:
2238
+ * 'active' a chunk read is in flight or queued for this turn. Something IS
2239
+ * coming; the loader is honest.
2240
+ * 'failed' the last read failed. Nothing is coming until somebody asks
2241
+ * again, so the view owes the reader a way to ask.
2242
+ * undefined nothing has been tried, or the attempt is over. Same obligation.
2243
+ *
2244
+ * Written by the engine only, and never persisted anywhere: it describes THIS
2245
+ * session's fetching, not the turn. A fresh history page therefore arrives
2246
+ * without it, and _adoptLocalAnswers re-stamps the page's still-marked bubbles
2247
+ * from the session's own bookkeeping, so a reload during a read does not turn a
2248
+ * live loader into a button (and back a second later).
2249
+ *
2250
+ * Read it through streamRecoveryPhase(msg), never directly: the phase folds in
2251
+ * "does this bubble need the affordance at all", and both clients must not
2252
+ * answer that twice.
2253
+ */
2254
+ _streamRecovery?: 'active' | 'failed';
1614
2255
  _serverItemId?: string;
1615
2256
  _localId?: string;
1616
2257
  _cancelling?: boolean;
@@ -1916,7 +2557,28 @@ type MapHistoryOptions = {
1916
2557
  declare function mapHistoryListToMessages(list: any[], platform: 'claude' | 'openai', opts: MapHistoryOptions): {
1917
2558
  messages: any[];
1918
2559
  runningItemIds: string[];
2560
+ streamPendingItemIds: string[];
1919
2561
  };
2562
+ /**
2563
+ * Let a LOCAL copy of a turn survive a page whose copy of it is
2564
+ * AUTHORITATIVE-BUT-EMPTY. Mutates `incoming`; returns true when it took anything.
2565
+ *
2566
+ * THE FAILURE THIS PREVENTS. A streamed turn's row goes 'resolved' the moment the
2567
+ * relay finishes, and its answer reaches the row only when csr-finalize stores it,
2568
+ * one poll interval plus a round trip later. A first-page history refetch landing
2569
+ * inside that window maps the row to a `_streamPending` bubble with no content, and
2570
+ * the merge, which believes the server, throws away the local bubble holding the
2571
+ * answer the reader is looking at. The window opens on EVERY streamed turn, and a
2572
+ * refetch fires from visibilitychange, so it is not a corner case.
2573
+ *
2574
+ * The rule is the same one the recovery reads: an UNKNOWN answer never overwrites a
2575
+ * KNOWN one. Where the local copy is still live (pending, or being painted into),
2576
+ * its live-ness is adopted too: without it the merge would hand back a settled
2577
+ * bubble the painter can no longer find (_liveTargetIndex wants isPending or
2578
+ * _streaming) and that _turnAlreadyRendered would then read as already answered, so
2579
+ * the settle would drop the real answer on the floor.
2580
+ */
2581
+ declare function adoptLocalAnswerIntoPage(incoming: ChatMessage, local: ChatMessage): boolean;
1920
2582
  interface RescueDecisionContext {
1921
2583
  /** Is this `_serverItemId` in the page that was just fetched? */
1922
2584
  hasServerId: (id: string) => boolean;
@@ -2559,6 +3221,166 @@ type PollHandle = {
2559
3221
  /** Absent on an older skapi-js that cannot stop an attached poll. */
2560
3222
  stop?: () => void;
2561
3223
  };
3224
+ /**
3225
+ * The prefix of a still-arriving answer that is safe to render as markdown.
3226
+ *
3227
+ * Four cuts, each taking the earliest position that could still change meaning:
3228
+ * 1. an UNCLOSED ``` fence (odd number of markers) - everything from its opener;
3229
+ * 2. an UNCLOSED inline link on the last line - `[label` with no `]`, or
3230
+ * `[label](url` with no `)`, from its `[`;
3231
+ * 3. a trailing bare url or `src::` token, from its first character, because a
3232
+ * link is minted from whatever is there and a growing url means a chip whose
3233
+ * href changes on every paint;
3234
+ * 4. an unclosed inline-code span on the last line (odd backtick count).
3235
+ *
3236
+ * Deliberately NOT covered: emphasis markers, half-written table rows and list
3237
+ * bullets. Those degrade to a flicker of STYLING, which self-corrects on the next
3238
+ * paint; the four above degrade to a wrong link, a wrong chip, or prose shown where
3239
+ * a fence was meant, none of which the reader can tell from the real thing.
3240
+ */
3241
+ declare function liveSafePrefix(text: string): string;
3242
+ /**
3243
+ * Where the typewriter should START revealing `fullText`, given what a live stream
3244
+ * has already painted into the bubble.
3245
+ *
3246
+ * The point is that the settle must not replay an answer the reader has already
3247
+ * watched arrive: the authoritative text REPLACES the live text (it is the only
3248
+ * source of truth), but the characters the two agree on are already on screen and
3249
+ * retyping them from zero is the one thing that would make streaming look worse
3250
+ * than not streaming.
3251
+ *
3252
+ * `regions` are the typewriter's own atomic regions. A resume index landing inside
3253
+ * one is pushed FORWARD to its end rather than back to its start: forward reveals
3254
+ * the link or fence whole, which is the policy those regions exist to enforce, and
3255
+ * backward would make the bubble shrink at the exact moment the answer settles.
3256
+ *
3257
+ * LEADING WHITESPACE IS NORMALISED FIRST, and that is not a nicety. The two strings
3258
+ * come from two places that disagree about it by design: the painter writes the
3259
+ * parser's `text` UNTRIMMED (currentText says why: trimming a render feed would
3260
+ * remove a leading newline and then hand it back when the next delta lands), while
3261
+ * every settle path trims, exactly as it trims a buffered answer. And the extractor
3262
+ * joins text blocks with '\n', so a model that opens an empty text block before its
3263
+ * first tool call, which Claude routinely does, produces a painted answer starting
3264
+ * with a newline the authoritative one does not have. Compared raw, the two agree on
3265
+ * NOTHING (their first characters differ), the resume index is 0, and the reader
3266
+ * watches the entire answer they just read be retyped from zero. Which is the one
3267
+ * thing streaming was supposed to stop happening.
3268
+ */
3269
+ declare function typewriterResumeIndex(painted: string, fullText: string, regions: Array<{
3270
+ start: number;
3271
+ end: number;
3272
+ }>): number;
3273
+ /**
3274
+ * THE KEEP POLICY, in one place, for every path that can reach csr-finalize.
3275
+ *
3276
+ * WHY IT IS A FUNCTION AND NOT A LINE IN EACH CALLER. Finalizing does two things in
3277
+ * one call: it stores what you hand it as the row's permanent answer, and it
3278
+ * DELETES the chunks it was assembled from. Chunks are the only copy of a streamed
3279
+ * answer until that call, and there is no way to release them without also storing
3280
+ * something, so "may this be kept?" is the single decision that separates a
3281
+ * recoverable turn from a permanently truncated one. It was answered in two places
3282
+ * that then disagreed: the live settle refused to finalize a failed or cancelled
3283
+ * turn (its partial text is the only copy there is, and both ways of releasing it
3284
+ * cost something real), while the recovery path computed the same question from
3285
+ * parse completeness ALONE - so recovering a failed row finalized it and released
3286
+ * exactly the chunks the live policy exists to keep. Two halves of one fix, pulling
3287
+ * opposite ways. One predicate, consulted by both, is the fix for that.
3288
+ *
3289
+ * The three terms, and what each of them is protecting:
3290
+ *
3291
+ * THE ROW'S OWN STATUS wins over anything the bytes say. 'failed' means the
3292
+ * destination's account of the turn is the error, not the text that arrived
3293
+ * before it; 'cancelled' means the user's Stop said to discard the half answer,
3294
+ * so writing it into history as the kept version resurrects exactly what the stop
3295
+ * was for; 'stopped' is a poll that was ended, which says nothing about the turn
3296
+ * at all. Pass undefined when the status is genuinely not known (the caller is
3297
+ * looking only at bytes); pass the status whenever there is one, because a caller
3298
+ * that omits a status it HAS is asking the wrong question.
3299
+ *
3300
+ * `errored` covers the same refusal expressed by the bytes rather than by the
3301
+ * row: an `error` frame, a response.failed, a terminal Response with an error
3302
+ * payload. See sse.ts's answerComplete for why a terminal event is not the same
3303
+ * claim as a finished answer.
3304
+ *
3305
+ * `answerComplete` (NOT `complete`) is the completeness half. A degraded chunk
3306
+ * read - the poller degrades to "no chunks this tick, more=true" on any transient
3307
+ * chunk-table error, and caps one read at 500k characters - hands a settle a
3308
+ * stream that stopped mid-answer while the ROW settles 'resolved' on top of it,
3309
+ * because the row's status describes the destination's request and not our read
3310
+ * of it. Anything short of a finished answer leaves the chunks exactly where they
3311
+ * are, which is what they are for: the turn stays re-readable through
3312
+ * clientSecretRequestStream and a later load recovers it in full.
3313
+ *
3314
+ * `unframed` is the one exception to needing a terminal event, and it is not a
3315
+ * loophole: bytes that were never SSE carry no events at all and none is ever
3316
+ * coming, so there it IS the row's status that says the response finished - which
3317
+ * is why this is only ever reached with a 'resolved' row or with no status to
3318
+ * contradict it.
3319
+ *
3320
+ * Exported so the two clients cannot answer it a third way.
3321
+ */
3322
+ declare function mayKeepStreamedAnswer(snap: any, rowStatus?: string | null): boolean;
3323
+ /**
3324
+ * WHAT A VIEW SHOULD DRAW FOR A TURN WHOSE ANSWER IS STILL IN THE CHUNK STORE.
3325
+ *
3326
+ * One predicate, on the barrel, because the alternative is each client deciding
3327
+ * for itself when a spinner is honest - and the two clients have forked on
3328
+ * smaller things than this. Returns:
3329
+ *
3330
+ * '' not this state at all. Either the bubble is not marked, or it HAS
3331
+ * content (the merge adopted a local answer onto it, or a recovery
3332
+ * wrote a truncated one in), in which case there is text to render
3333
+ * and the recovery, if any, is a background correction the reader
3334
+ * does not need to be told about.
3335
+ * 'active' a chunk read is in flight or queued. Draw the loader: this is the
3336
+ * only phase in which something really is coming.
3337
+ * 'failed' the last read failed. Draw the failure and an ask-again control.
3338
+ * 'idle' marked, and nothing is fetching it. Draw an ask-for-it control.
3339
+ *
3340
+ * THE FAILURE THIS EXISTS TO STOP. Recovery is capped at STREAM_RECOVERY_PER_LOAD
3341
+ * per history load, so on a page holding several unfinalized turns the third and
3342
+ * later ones are marked and queued for nobody; a failed read likewise leaves the
3343
+ * marker on deliberately (it is the only thing keeping the answer reachable) with
3344
+ * no attempt behind it. Both used to take the same branch as a live pending turn,
3345
+ * so those bubbles spun forever with nothing driving them and no way for the
3346
+ * reader to resolve them - while the answer sat in the chunk table the whole time,
3347
+ * one recoverStreamedAnswer() call away.
3348
+ *
3349
+ * A bubble with no `_serverItemId` returns '' on purpose: there is no id to hand
3350
+ * recoverStreamedAnswer, so an affordance would be a button that cannot work.
3351
+ * Unreachable today (the mapper only ever marks a row it has an id for), stated so
3352
+ * that it stays unreachable rather than becoming a dead control.
3353
+ */
3354
+ declare function streamRecoveryPhase(msg: any): '' | 'active' | 'failed' | 'idle';
3355
+ /**
3356
+ * The words for the two phases a reader has to act on. Here rather than in each
3357
+ * client for the same reason as the phase itself: two clients wording the same
3358
+ * state differently is how one of them ends up saying something untrue.
3359
+ *
3360
+ * Neither string claims the answer is lost. It is not: the row is unfinalized, so
3361
+ * the chunks are retained until somebody finalizes them, and that is exactly why
3362
+ * asking again is worth offering.
3363
+ */
3364
+ declare function streamRecoveryLabels(phase: string): {
3365
+ note: string;
3366
+ action: string;
3367
+ };
3368
+ /**
3369
+ * The identity a streamed turn was DISPATCHED under, pinned by the caller.
3370
+ *
3371
+ * Same reason _callProviderFor takes projectId/owner explicitly: a turn can be
3372
+ * acked after the user has moved to another project or platform, and a live
3373
+ * getIdentity() read at that moment describes where the user is now, not where the
3374
+ * turn came from. Every field optional so a caller can pin what it knows and let
3375
+ * the rest fall back to the live read.
3376
+ */
3377
+ type StreamDispatchContext = {
3378
+ platform?: string;
3379
+ projectId?: string;
3380
+ owner?: string;
3381
+ /** History cache key (chatCacheKey) of the chat the turn belongs to. */
3382
+ ownerKey?: string;
3383
+ };
2562
3384
  declare class ChatSession {
2563
3385
  host: ChatHost;
2564
3386
  state: ChatState;
@@ -2803,7 +3625,7 @@ declare class ChatSession {
2803
3625
  * and they are the ones bounded by MAX_CONCURRENT_BG_POLLS, so adding probes there would spend
2804
3626
  * the request budget the cap exists to protect.
2805
3627
  */
2806
- attachForegroundPoll(source: any, itemId: string, opts?: any): any;
3628
+ attachForegroundPoll(source: any, itemId: string, opts?: any, ctx?: StreamDispatchContext): any;
2807
3629
  private _fgPollWithEarlyProbe;
2808
3630
  private _trackPoll;
2809
3631
  /** Background polls currently attached, for the MAX_CONCURRENT_BG_POLLS budget.
@@ -2812,6 +3634,282 @@ declare class ChatSession {
2812
3634
  * entry left behind by pausePolling on an older skapi-js (no stop handle)
2813
3635
  * still counts, which is correct — that poll really is still running. */
2814
3636
  private _countBgPolls;
3637
+ /** Live streams by server item id. One per in-flight streamed turn. */
3638
+ private liveStreams;
3639
+ /**
3640
+ * Open (or re-open) the live stream for `itemId`, or null when this poll must
3641
+ * not carry one.
3642
+ *
3643
+ * Re-entrant on purpose: an auth-refresh retry re-dispatches the SAME turn under
3644
+ * a NEW id, and a re-attach after a tab return replays an existing id from seq 0.
3645
+ * Either way the bytes about to arrive are a whole stream, so an existing entry
3646
+ * is discarded and a fresh parser takes its place - feeding a replay into the old
3647
+ * parser would concatenate the answer with itself.
3648
+ *
3649
+ * `ctx` IS THE TURN'S OWN IDENTITY, and every caller that has one passes it.
3650
+ * This used to read the LIVE getIdentity(), which is a bug of exactly the kind
3651
+ * _callProviderFor documents and threads its own parameters to avoid: the user
3652
+ * hits Send, then switches project or platform inside the ack round trip, and the
3653
+ * stream that opens for the OLD turn is stamped with the NEW identity. What that
3654
+ * costs is not cosmetic - `platform` picks which url csr-finalize is addressed
3655
+ * with and which extractor reads the assembled body, `projectId`/`owner` scope
3656
+ * the finalize itself, and `ownerKey` decides which chat the answer is painted
3657
+ * into. Get them from the live read at the wrong moment and the turn is finalized
3658
+ * against the wrong service (so its answer is never stored), parsed with the
3659
+ * wrong provider's extractor, or painted into a conversation it does not belong
3660
+ * to. The live read stays only as the fallback for a caller with nothing pinned.
3661
+ */
3662
+ private _beginLiveStream;
3663
+ /** The chunk sink handed to skapi's poll. Raw relayed text, in order, never parsed
3664
+ * here: the parser owns the grammar and this owns the pacing. */
3665
+ private _feedLiveStream;
3666
+ /**
3667
+ * Write the safe prefix of the answer so far into the turn's bubble.
3668
+ *
3669
+ * notify() is spent EXACTLY ONCE per turn, on the first paint, because that is a
3670
+ * state change the per-bubble refresh cannot express: the bubble stops being a
3671
+ * "Thinking..." spinner and becomes text. Every paint after it goes through
3672
+ * refreshMessageBubble, which is what keeps a growing answer from rebuilding the
3673
+ * whole display list once a second.
3674
+ */
3675
+ private _paintLiveStream;
3676
+ /** The bubble a live stream paints into: the turn's pending assistant placeholder,
3677
+ * found by server item id. Not by _localId, deliberately - a history refetch
3678
+ * replaces the local copy with the server's, and only the id survives that. */
3679
+ private _liveTargetIndex;
3680
+ /** Hand the host its optional observation update. Guarded: this runs on the paint
3681
+ * path, and a throwing hook must not cost the user the rest of their answer. */
3682
+ private _reportLiveStream;
3683
+ /** Stop painting and (when the turn really ended) assemble the body. `finished`
3684
+ * is false for a stream being discarded rather than settled: a retry replacing
3685
+ * it, or a stop, neither of which has an answer to assemble. */
3686
+ private _closeLiveStream;
3687
+ /**
3688
+ * Settle a streamed turn: end the parse, decide the body the rest of the session
3689
+ * will read, and release the chunks.
3690
+ *
3691
+ * The substitution is one-directional and never a merge. A response that is a
3692
+ * real stored body (a buffered turn, or a streamed one somebody already
3693
+ * finalized) is returned untouched, because that is the destination's own answer
3694
+ * and the stream is not entitled to overwrite it. Only a STATUS ENVELOPE - the
3695
+ * shape a streamed row settles as, having stored nothing - is replaced, and then
3696
+ * by the assembled body, which every caller downstream reads with the same
3697
+ * extractor it uses for a buffered reply. Idempotent, because it is reached both
3698
+ * through the poll's onResponse and through the promise it resolves.
3699
+ */
3700
+ private _settleLiveStream;
3701
+ /**
3702
+ * May this parse be STORED as the turn's permanent answer?
3703
+ *
3704
+ * THE FAILURE THIS PREVENTS. Finalizing does two things at once: it stores what
3705
+ * you give it as the row's result, and it DELETES the chunks it was assembled
3706
+ * from. So finalizing a truncated parse is not a cosmetic loss, it is the
3707
+ * permanent one: the truncation becomes the stored answer and the only copy of
3708
+ * the missing part is deleted in the same call. And a truncated parse is a shape
3709
+ * this repo has already paid for - a degraded chunk read (the poller degrades to
3710
+ * "no chunks this tick, more=true" on any transient chunk-table error, and caps
3711
+ * a long answer at 500k characters per response) can hand the settle a stream
3712
+ * that stopped mid-answer. The row can settle 'resolved' on top of that, because
3713
+ * the ROW's status describes the destination's request, not the client's read of
3714
+ * it.
3715
+ *
3716
+ * THE POLICY ITSELF IS mayKeepStreamedAnswer (top of this file), shared with the
3717
+ * recovery path so the two cannot drift apart again - they did, and the drift was
3718
+ * silent: the live settle refused a failed turn while the recovery finalized one.
3719
+ * What is local to this method is only the two things the free function cannot
3720
+ * know: that there is an assembled body at all, and that this call site is
3721
+ * reached only on a row that settled 'resolved' (the caller returns before it
3722
+ * otherwise), which is the status it therefore states.
3723
+ *
3724
+ * The test the policy applies is deliberately NOT `complete`: a terminal event
3725
+ * arrived and the answer finished are two claims, and an `error` frame satisfies
3726
+ * the first while truncating the second. See sse.ts's answerComplete.
3727
+ */
3728
+ private _mayFinalize;
3729
+ /**
3730
+ * Store the assembled body as the version history keeps, which is also what
3731
+ * releases this request's chunks.
3732
+ *
3733
+ * The ASSEMBLED BODY and not the extracted text, because the row is read back by
3734
+ * mapHistoryListToMessages through extractClaudeText / extractOpenAIText: storing
3735
+ * the provider's own document is what makes a streamed turn indistinguishable
3736
+ * from a buffered one on the next load, with no branch anywhere in the mapper.
3737
+ *
3738
+ * BEST EFFORT, and loudly so: the answer is already on screen and already in the
3739
+ * history cache by the time this fires. A failure costs the chunks (they stay,
3740
+ * and the turn stays re-readable) and a row that reads back empty, never the
3741
+ * user's answer in front of them.
3742
+ *
3743
+ * WHAT IS DELIBERATELY NEVER FINALIZED, because finalize is also the only way to
3744
+ * release chunks and it is tempting to reach for it as a cleanup:
3745
+ *
3746
+ * - an INCOMPLETE parse (see _mayFinalize). Storing a truncation makes it
3747
+ * permanent AND deletes the part that was missing from it. A stream killed by
3748
+ * an `error` frame is one of these however terminal it looks: the frame ends
3749
+ * the stream, so `complete` is true, while the text is only what arrived
3750
+ * before the error. That is why the gate reads answerComplete.
3751
+ * - a FAILED turn. Its chunks hold the part of the answer that did arrive,
3752
+ * which is the only copy of that text there is, and the two ways to release
3753
+ * them both cost something real: storing the partial makes a truncated answer
3754
+ * the turn's permanent history AND masks the failure on read (csr-poll hands
3755
+ * back a finalized body before it ever looks at the row's error, so the turn
3756
+ * would read back as a clean short answer), while storing the error throws
3757
+ * the partial away outright. Keeping them costs storage on rows that produced
3758
+ * bytes and then failed, which is rare - a failure before the first byte (a
3759
+ * wrong API key, the common case) has no chunks to keep - and the poller
3760
+ * hands those chunks back alongside the error on every later read, so nothing
3761
+ * is stranded, only retained. Retention is the honest trade here; deletion is
3762
+ * not reversible.
3763
+ * - a CANCELLED turn, for the same reason plus one: the user's Stop means the
3764
+ * half answer is to be discarded, so writing it into history as the kept
3765
+ * version would resurrect exactly what the stop was for.
3766
+ */
3767
+ private _finalizeStreamedTurn;
3768
+ /** Painted-but-unsettled live text on a bubble, for the typewriter to resume from.
3769
+ * A pending assistant placeholder is created with content '' by every path that
3770
+ * makes one, so non-empty content on one can only have been painted here. */
3771
+ private _paintedTextAt;
3772
+ private _streamRecovery?;
3773
+ /** The recovery bookkeeping, created on first touch.
3774
+ *
3775
+ * LAZY, not constructor-initialised, and for a concrete reason: ChatSession is
3776
+ * also built with Object.create(ChatSession.prototype) by the engine's own test
3777
+ * harnesses, which drive one method against a hand-built state rather than a
3778
+ * whole session. A field only the constructor creates is undefined there, and
3779
+ * the method that reaches for it throws, turning a test of the settle into a
3780
+ * crash about bookkeeping. */
3781
+ private _rec;
3782
+ /**
3783
+ * Put this session's fetching state onto the turn's bubble, so a view can tell a
3784
+ * loader that means something from one that means nothing.
3785
+ *
3786
+ * ONLY EVER ONTO A STILL-MARKED BUBBLE. Once `_streamPending` is off the turn has
3787
+ * an answer (or was proven to have none) and this says nothing about it; writing
3788
+ * it there would leave a stale 'active' on a settled bubble forever.
3789
+ *
3790
+ * host.notify() is what redraws the widget, whose renderer is imperative. It is a
3791
+ * no-op in agent.vue, whose state is a Vue reactive() - the property write above
3792
+ * is what redraws there. Both are covered by doing both, and neither is a
3793
+ * substitute for the other.
3794
+ */
3795
+ private _markRecoveryPhase;
3796
+ /**
3797
+ * Let LOCAL answers survive a freshly-mapped page whose copies of them are
3798
+ * authoritative-but-empty. Call with the page BEFORE it replaces or merges into
3799
+ * state.messages; mutates the page's bubbles in place.
3800
+ *
3801
+ * The adoption itself is history.ts's adoptLocalAnswerIntoPage (shared, so the
3802
+ * clients' own mappers cannot fork it). What lives here is the one thing the
3803
+ * pure function cannot know: whether the local text is the WHOLE answer. Text
3804
+ * left by a stream that ended without a terminal event is not, so that bubble
3805
+ * keeps its marker and gets read back even though it has content - otherwise a
3806
+ * truncated answer would adopt itself over the row and never be corrected.
3807
+ */
3808
+ private _adoptLocalAnswers;
3809
+ /**
3810
+ * This session's fetching state for one turn, from the bookkeeping rather than
3811
+ * from any bubble. A queued entry counts as 'active': it is committed to be read,
3812
+ * serially, and the reader has no way to tell "being read" from "next in line"
3813
+ * apart from the wait.
3814
+ */
3815
+ private _recoveryPhaseFor;
3816
+ /**
3817
+ * PUBLIC DELEGATE, for a client that maps and merges its own history page.
3818
+ *
3819
+ * agent.vue keeps a forked mapper and a forked first-page merge (its mount path
3820
+ * runs them, while resumePolling routes through loadHistory below), so both
3821
+ * paths are live for the SAME row inside one component. Adoption is part of the
3822
+ * merge contract, not an optional extra: without it that fork erases a streamed
3823
+ * answer off the screen on every turn, which is the whole of MAJOR 3.
3824
+ *
3825
+ * Exposed rather than reimplemented because the rule needs the session's own
3826
+ * `incomplete` set, which the pure helper (history.ts adoptLocalAnswerIntoPage)
3827
+ * cannot see. A client that reached for the helper alone would adopt a TRUNCATED
3828
+ * answer over the row and clear the marker that would have gone back for the
3829
+ * rest - a fork that reads as correct and loses text.
3830
+ *
3831
+ * Call it exactly where loadHistory does: on the freshly mapped page, after
3832
+ * applyHydratedBodies and BEFORE the page replaces or merges into state.messages.
3833
+ */
3834
+ adoptLocalAnswers(mapped: ChatMessage[], loadKey?: string): void;
3835
+ /**
3836
+ * Queue the on-screen turns whose answer is only in the chunk store, newest
3837
+ * first, and start draining. Never blocks and never throws.
3838
+ *
3839
+ * `ownerKey` is the chat the queue entries belong to, snapshotted by the caller:
3840
+ * a recovery that lands after the user has moved on writes into that chat's
3841
+ * cache, never into whatever list is on screen by then.
3842
+ */
3843
+ private _scheduleStreamRecovery;
3844
+ /**
3845
+ * PUBLIC DELEGATE, the other half of what a forked history path needs.
3846
+ *
3847
+ * Same reason as adoptLocalAnswers: agent.vue's mount path never calls
3848
+ * loadHistory, so without this its pages would MARK unfinalized streamed turns
3849
+ * and then never read them back - CRITICAL 1 left unfixed on the client's
3850
+ * primary path, with the marker making it look handled.
3851
+ *
3852
+ * Takes the load's SNAPSHOTTED identity rather than reading it live, and that is
3853
+ * the reason this exists instead of the caller looping over recoverStreamedAnswer:
3854
+ * that one reads getIdentity() at call time (right, for an on-demand affordance
3855
+ * the user just clicked), which after a project switch racing the load would
3856
+ * finalize the turn against the project they switched TO. Call it AFTER the page
3857
+ * is rendered and the loading flags are cleared - it must never hold up the
3858
+ * conversation it belongs to.
3859
+ */
3860
+ scheduleStreamRecovery(ownerKey: string, platform: 'claude' | 'openai', projectId: string, owner: string): void;
3861
+ /** Serial drain of the recovery queue. Each entry is one full chunk read. */
3862
+ private _drainStreamRecovery;
3863
+ /**
3864
+ * Read one unfinalized streamed turn back out of the chunk store and put its
3865
+ * answer where the turn's answer belongs.
3866
+ *
3867
+ * Public because the cap above is deliberately small: a host that wants to offer
3868
+ * "load the rest" on an older recoverable turn calls this with its
3869
+ * `_serverItemId`, and gets the same path the automatic recovery uses. Safe to
3870
+ * call for an id that turns out not to be recoverable, and safe to call twice -
3871
+ * a second call while the first is still in flight is a no-op.
3872
+ *
3873
+ * THIS IS THE USER ASKING, and that is why it passes `manual`. The automatic
3874
+ * recovery refuses a row it has already tried, so that a re-render, or the
3875
+ * history load that every visibilitychange fires, cannot loop on the same
3876
+ * chunks. A click is neither of those: it is one bounded request that a person
3877
+ * asked for, and applying the loop guard to it made the affordance a button that
3878
+ * silently did nothing for exactly the rows most likely to have it - every row
3879
+ * an earlier read touched and could not settle.
3880
+ */
3881
+ recoverStreamedAnswer(itemId: string): Promise<void>;
3882
+ private _readBackStreamedTurn;
3883
+ /**
3884
+ * Write a recovered answer into the turn's bubble (or into the owning chat's
3885
+ * cache when the reader has moved on), then store it as the version history
3886
+ * keeps.
3887
+ *
3888
+ * FINALIZING IS WHAT MAKES THIS RUN ONCE. It copies the answer onto the row and
3889
+ * releases the chunks, so the next load reads an ordinary turn and no recovery is
3890
+ * scheduled for it ever again, by anyone, in any tab. `store` is the caller's
3891
+ * decision and carries two gates at once: mayKeepStreamedAnswer, the SAME keep
3892
+ * policy the live settle applies (an incomplete, errored or failed read is shown
3893
+ * but never stored, because storing it would make the truncation permanent and
3894
+ * delete the part that was missing), and whether the body is new at all (one
3895
+ * that came off the row is already stored).
3896
+ */
3897
+ private _applyRecoveredAnswer;
3898
+ /**
3899
+ * Take the "answer is elsewhere" marker off a turn once it is settled one way or
3900
+ * the other. `drop` removes an assistant bubble that turned out to have no answer
3901
+ * at all, which restores exactly the list the mapper used to produce for such a
3902
+ * row (none), rather than leaving a permanently empty bubble behind.
3903
+ *
3904
+ * ONLY EVER CALLED FOR A TURN THAT WAS ACTUALLY READ. The marker is the one thing
3905
+ * that keeps an unrecovered answer reachable, so it comes off only on the strength
3906
+ * of an answer (the recovery wrote one) or of a read that came back empty. A read
3907
+ * that FAILED, or one that was STOPPED, knows neither, and taking the marker off
3908
+ * on either of those is how a bubble ends up empty forever with its answer still
3909
+ * in the chunk table. `drop` is likewise never passed for a bubble that HAS
3910
+ * content: an empty row is an empty turn, a failed read is not.
3911
+ */
3912
+ private _clearStreamPendingMark;
2815
3913
  /**
2816
3914
  * Stop and forget one item's poll. Used after a cancel: the row is either gone
2817
3915
  * (cancelled while queued) or flagged cancelled (cancelled while running), so
@@ -3087,9 +4185,9 @@ declare class ChatSession {
3087
4185
  * work, it does not undo it.
3088
4186
  */
3089
4187
  cancelIndexingGroup(group: IndexingGroup): void;
3090
- typewriteIntoIndex(idx: number, fullText: string, localId?: string): Promise<void>;
4188
+ typewriteIntoIndex(idx: number, fullText: string, localId?: string, paintedText?: string): Promise<void>;
3091
4189
  private typewriterQueue;
3092
- enqueueTypewrite(idx: number, fullText: string, localId?: string): Promise<any>;
4190
+ enqueueTypewrite(idx: number, fullText: string, localId?: string, paintedText?: string): Promise<any>;
3093
4191
  typewriteLatestReply(key: string): Promise<any>;
3094
4192
  _removeStrayPendingAssistants(): void;
3095
4193
  /** Index of the USER bubble the message at `idx` belongs to — the nearest one
@@ -3264,4 +4362,171 @@ declare class ChatSession {
3264
4362
  bumpGate(): void;
3265
4363
  }
3266
4364
 
3267
- export { type AiAgentPlatform, type AnchorBoxEl, type AnchorRowEl, type AttachmentFailureGroup, type AttachmentParser, type AttachmentSaveInfo, BG_INDEXING_QUEUE_SUFFIX, BOM, BOM_EXTS, type BgTaskEntry, type BoundedChatOptions, type BuildDisplayListOptions, type BuildIndexingUserMessageOptions, CLAUDE_INPUT_CAP_RATIO, CLAUDE_PER_REQUEST_INPUT_CAP, CONTEXT_WINDOW_BY_MODEL, CONTEXT_WINDOW_DEFAULT, type CallClaudeWithMcpParams, type ChatEngineConfig, type ChatGreetingParams, type ChatGreetingParts, type ChatHost, type ChatIdentity, type ChatMessage, ChatSession, type ChatState, type ChatSystemPromptParams, type ClaudeMcpServerRequest, type ClaudeMcpToolConfig, type ClaudeMessage, type ClaudeRole, type ComposedUserMessage, DEFAULT_CLAUDE_MODEL, DEFAULT_CONTEXT_WINDOW, DEFAULT_OPENAI_MODEL, type DisplayEntry, EMPTY_INDEXING_REPLY, EXPIRED_ATTACHMENT_URL_HOST, EXPIRED_ATTACHMENT_URL_ORIGIN, EXPIRED_LINK_REFRESH_EXPIRES_SECONDS, EXT_CONTENT_TYPES, type EncodingClass, type ExtractDirective, type FillHistoryViewportOptions, HISTORY_BUDGET_RATIO, HISTORY_FILL_SLACK_PX, HISTORY_TOKEN_BUDGET, HTML_EXTS, HTML_HEAD_WINDOW, IMAGE_PREVIEWS_PER_MESSAGE, INDEXING_COMPLETE_MARKER, INLINE_LINK_GLYPH, INLINE_LINK_UNAVAILABLE_GLYPH, INLINE_LINK_UNAVAILABLE_SUFFIX, INPUT_CAP_RATIO, type ImagePreviewContext, type IndexRunPatch, type IndexRunStatus, type IndexingAttachmentInfo, type IndexingFileRef, type IndexingGroup, type IndexingGroupStatus, type IndexingRequestRef, type IndexingSystemPromptParams, type InlineLinkContext, type InlineLinkMarkupOptions, type InlineLinkPart, LINK_LABEL_MAX_DISPLAY_CHARS, LINK_REFRESH_WINDOW_MS, MAX_CONCURRENT_BG_POLLS, MAX_HISTORY_FILL_PAGES, MAX_HISTORY_MESSAGES, MAX_OUTPUT_BY_MODEL, MAX_OUTPUT_TOKENS, MAX_PARSED_CONTENT_CHARS, MCP_NAME, MINT_CACHE_GENERATION, MIN_INPUT_TOKEN_BUDGET, MIN_PER_REQUEST_INPUT_CAP, type MapHistoryOptions, OUTPUT_TOKEN_RESERVE, type OpenAIMessage, POLL_INTERVAL, PRESIGN_SAFETY_MARGIN_MS, PREVIEWABLE_IMAGE_CONTENT_TYPES, PREVIEW_BROWSER_CACHE_SECONDS, PREVIEW_LAYOUT_BOX_SELECTOR, PREVIEW_URL_EXPIRES_SECONDS, type ParsedAiAgent, type PinnedDispatchContext, type PreviewImageEl, RENDER_FROM_TOKEN, RTF_EXTS, RUN_RECORD_WORKING_STALE_MS, type RenderableInlineLink, type RescueDecisionContext, type RowAnchor, type RunStubInfo, type ScrollAnchor, type ScrollAnchorOptions, TOOL_AND_RESPONSE_BUFFER, type VisionProfile, XML_EXTS, __resetSplitHistoryState, applyEncodingDeclaration, bgIndexingQueueName, buildAiAgentValue, buildBoundedChatMessages, buildChatDisplayList, buildChatGreeting, buildChatSystemPrompt, buildDisplayExpiredAttachmentHref, buildHistoryItemFullId, buildIndexingContinueMessage, buildIndexingRenderContinueTemplate, buildIndexingRenderMessage, buildIndexingSystemPrompt, buildIndexingUserMessage, buildIndexingWindowMessage, callClaudeWithMcp, callClaudeWithPublicMcp, callOpenAIWithPublicMcp, canonicalizePathForm, chatCacheKey, chatEngineConfig, classifyInlineLink, clearAttachmentParsers, clearImagePreviewCache, composeUserMessage, configureChatEngine, contentTypeForExt, createHistoryFiller, createInlineLinkRegex, createScrollAnchor, encodePathSegments, encodingClassForExt, ensureHtmlCharset, ensureXmlEncoding, escapeInlineHtml, escapeRtfNonAscii, estimateMessageTokens, estimateTextTokens, extOf, extractClaudeText, extractLastUserTextFromRequest, extractOpenAIText, extractRemotePathFromAttachmentHref, fetchLiveIndexingKeys, fillHistoryViewport, filterListByClearHorizon, findAttachmentParser, formatChatTimestamp, getAttachmentParsers, getChatHistory, getContextWindow, getErrorMessage, getExpiredAttachmentVisiblePath, getInputTokenBudget, getMaxOutputTokens, getModelContextWindow, getProjectContextWindow, getSplitChatHistory, getVisionProfile, groupAttachmentFailures, hasBom, hydrateImagePreviews, indexDoneUniqueId, indexScopeKey, indexingAccessGroup, isAuthExpiredError, isBgIndexingQueue, isErrorResponseBody, isHttpUrlLike, isIndexingRequestText, isLinkUnavailable, isNonRetryableRequestError, isOfficeFile, isPreviewableImagePath, isProviderApiKeyError, isServerExtractable, isServiceDbAttachmentHref, linkUnavailableKeyForHref, linkUnavailableKeyForPath, linkUnavailableKeysForPath, listClaudeModels, listOpenAIModels, looksLikeRtf, makeExtractPlaceholder, mapHistoryListToMessages, markImagePreviewStale, mintCacheBustStamp, needsBomForExt, normalizeAttachmentPathCandidate, normalizeExt, normalizeTextContent, normalizeTrailingInlineToken, notifyAgentSaveAttachment, parseAiAgentValue, parseAttachmentContent, parseIndexingLabel, parseIndexingRequestText, peekImagePreviewUrl, prepareDownloadText, presignExpiryEpochMs, previewImageContentType, previewLayoutBox, previewMintCacheToken, previewableExtOf, readExpiredAttachmentHref, registerAttachmentParser, registerModelContextWindows, renderInlineLinkHtml, repairUrlEntities, repairUrlWhitespace, resolveImagePreviewUrl, runIndexUniqueId, safeDecodeURIComponent, sanitizeAttachmentLinksForHistory, setProjectContextWindow, shouldRescueInFlightMessage, stripFileBlocksFromHistory, transformContentWithImages, transformContentWithOpenAIImages, truncateLabelForDisplay, upsertIndexRunRecordSafe, wallClockNow };
4365
+ /**
4366
+ * The project's BunnyQuery settings, held as a record in the project's own
4367
+ * database rather than on the skapi service record.
4368
+ *
4369
+ * WHY A RECORD. The upload access group used to live on the service record as
4370
+ * `default_access_group`, which was ALSO the skapi SDK's project-wide default
4371
+ * for `table.access_group`. One field meant two things: "what BunnyQuery indexes
4372
+ * new files at" and "what every SDK record call on this project defaults to".
4373
+ * That coupling is gone. The SDK no longer has a project default at all, so this
4374
+ * setting needs a home of its own, and a plain public record in the customer's
4375
+ * own project is one every client can already reach with the calls it has.
4376
+ *
4377
+ * SHAPE. One record per project, holding an OBJECT rather than a single value:
4378
+ *
4379
+ * unique_id: 'bq::settings'
4380
+ * table: { name: '__SETTINGS__', access_group: 'public' }
4381
+ * data: { upload_access_group: 'authorized' }
4382
+ *
4383
+ * One record and one fetch covers every present and future project setting. A
4384
+ * second setting is a new key, not a new record, so the "wait for settings
4385
+ * before the first upload" hand-off below never has to become several waits.
4386
+ *
4387
+ * WHY PUBLIC. The widget reads this, and the widget frequently runs before there
4388
+ * is any session. Group 0 is the only group an unauthenticated caller is served
4389
+ * (`check_rec_access` returns immediately for "00" and refuses the rest). Note
4390
+ * this is NOT sufficient on its own: skapi's `require_login` gate refuses ALL
4391
+ * database reads from a signed-out visitor, and it defaults to true, so on most
4392
+ * projects a signed-out widget still cannot read this and falls back to the
4393
+ * default. That is survivable because the only thing a signed-out visitor could
4394
+ * do with the value is upload, which they cannot do either.
4395
+ *
4396
+ * WHY THE VALUE MATTERS. The file BYTES are not what the access group controls.
4397
+ * BunnyQuery uploads to db storage, whose object key carries no access group and
4398
+ * whose read path performs no access check. What carries the group is the
4399
+ * RECORDS: the `src::` file record in `file_summaries`, the `run::`/`done::`
4400
+ * markers in `__INDEXING__`, and every content record the indexing agent
4401
+ * extracts. Those are what a chat answers from, so those are what decide who the
4402
+ * file is visible to. The same value is also handed to the chat system prompt as
4403
+ * `indexAccessGroup`, because a record written under a different group is in a
4404
+ * different table and never comes back with the rest of the file.
4405
+ *
4406
+ * TRANSPORT-FREE, like the rest of the engine. The store never imports a skapi
4407
+ * instance; the consumer injects a reader. See configureProjectSettings.
4408
+ */
4409
+ /** The access groups a BunnyQuery upload may be recorded at. */
4410
+ type UploadAccessGroup = 'public' | 'authorized' | 'private';
4411
+ declare const UPLOAD_ACCESS_GROUPS: UploadAccessGroup[];
4412
+ /**
4413
+ * What the project's upload-access setting may be: one of the three groups the
4414
+ * dashboard offers, or 'ask' to be prompted per upload.
4415
+ *
4416
+ * `'admin'` (99) is deliberately not offered: a file only a master can read is
4417
+ * indistinguishable from one that failed to upload, and no dashboard control
4418
+ * would produce it.
4419
+ */
4420
+ type ProjectAccessSetting = UploadAccessGroup | 'ask';
4421
+ /**
4422
+ * `authorized` is the default because it is what every record written before
4423
+ * this setting existed was hardcoded to. A project that never opens the setting
4424
+ * keeps exactly the visibility it already had. It is also what the abandoned
4425
+ * `default_access_group` service field was seeded to at project creation, so a
4426
+ * project carrying that old value reads the same before and after the move.
4427
+ */
4428
+ declare const DEFAULT_UPLOAD_ACCESS_GROUP: UploadAccessGroup;
4429
+ /** Where the settings record lives. Shared so no client re-derives it. */
4430
+ declare const PROJECT_SETTINGS_TABLE = "__SETTINGS__";
4431
+ declare const PROJECT_SETTINGS_UNIQUE_ID = "bq::settings";
4432
+ declare const PROJECT_SETTINGS_ACCESS_GROUP = "public";
4433
+ declare const UPLOAD_ACCESS_LABELS: Record<UploadAccessGroup, string>;
4434
+ declare const UPLOAD_ACCESS_HINTS: Record<UploadAccessGroup, string>;
4435
+ /** Menu/modal option list, in the order they should be shown. */
4436
+ declare const UPLOAD_ACCESS_OPTIONS: {
4437
+ value: UploadAccessGroup;
4438
+ label: string;
4439
+ hint: string;
4440
+ }[];
4441
+ /** The settings record's `data`. Open-ended: future settings are new keys. */
4442
+ type ProjectSettingsData = {
4443
+ upload_access_group?: unknown;
4444
+ [key: string]: unknown;
4445
+ };
4446
+ /** Narrow an unknown stored value to a usable group, falling back to the default. */
4447
+ declare function normalizeUploadAccessGroup(value: any): UploadAccessGroup;
4448
+ /**
4449
+ * The stored setting as written, or null when the project has never set one.
4450
+ *
4451
+ * Returns null rather than a default so callers can tell "unset" from "set to
4452
+ * authorized". The settings page needs that distinction to decide what the
4453
+ * control shows; upload paths do not and use uploadAccessGroupFrom instead.
4454
+ */
4455
+ declare function normalizeProjectAccessSetting(value: any): ProjectAccessSetting | null;
4456
+ /** The setting held in a settings-record `data`, or null when unset. */
4457
+ declare function accessSettingFrom(data: ProjectSettingsData | null | undefined): ProjectAccessSetting | null;
4458
+ /** The group an upload lands in when the project is NOT set to 'ask'. */
4459
+ declare function uploadAccessGroupFrom(data: ProjectSettingsData | null | undefined): UploadAccessGroup;
4460
+ /** True when the project wants to be asked per upload rather than told once. */
4461
+ declare function asksUploadAccessFrom(data: ProjectSettingsData | null | undefined): boolean;
4462
+ /**
4463
+ * Fetch one project's settings record. Resolves the record's `data`, or null
4464
+ * when there is no record.
4465
+ *
4466
+ * MAY REJECT, and the store treats a rejection as "no record": a signed-out
4467
+ * visitor on a `require_login` project gets REQUIRE_LOGIN here, which is a
4468
+ * normal outcome and not an error the user should ever see.
4469
+ */
4470
+ type ProjectSettingsReader = (service: string) => Promise<ProjectSettingsData | null>;
4471
+ declare function configureProjectSettings(fn: ProjectSettingsReader | null): void;
4472
+ /**
4473
+ * Start the fetch and hand back the promise, deduping concurrent callers.
4474
+ *
4475
+ * Never rejects: a failed read settles as null, which every accessor reads as
4476
+ * "unset" and answers with the default. A settings fetch must not be able to
4477
+ * fail an upload.
4478
+ */
4479
+ declare function loadProjectSettings(service: string): Promise<ProjectSettingsData | null>;
4480
+ /**
4481
+ * Kick the fetch off without waiting for it. Call on chat/page open.
4482
+ *
4483
+ * Fire-and-forget by design: the page paints on the default and the first upload
4484
+ * awaits the real value via readyProjectSettings. Nothing blocks on this.
4485
+ */
4486
+ declare function primeProjectSettings(service: string): void;
4487
+ /**
4488
+ * Await the settings for this project. What the FIRST upload calls.
4489
+ *
4490
+ * Cheap after the first call: a settled entry resolves immediately, and a
4491
+ * primed-but-unsettled one joins the in-flight request rather than starting a
4492
+ * second.
4493
+ */
4494
+ declare function readyProjectSettings(service: string): Promise<ProjectSettingsData | null>;
4495
+ /**
4496
+ * The cached data WITHOUT waiting, or null when nothing has settled yet.
4497
+ *
4498
+ * For synchronous readers (a template, a menu's current value). A caller that is
4499
+ * about to WRITE an access group onto a record must use readyProjectSettings
4500
+ * instead: answering from an unsettled cache is how a file lands in the wrong
4501
+ * group on the first upload after a page load.
4502
+ */
4503
+ declare function cachedProjectSettings(service: string): ProjectSettingsData | null;
4504
+ /** True once this project's settings have been fetched (whether or not one existed). */
4505
+ declare function projectSettingsSettled(service: string): boolean;
4506
+ /** Sync convenience: the project's setting as stored, or null when unset/unsettled. */
4507
+ declare function projectAccessSetting(service: string): ProjectAccessSetting | null;
4508
+ /** Sync convenience: the upload group, falling back to the default. */
4509
+ declare function projectUploadAccessGroup(service: string): UploadAccessGroup;
4510
+ /** Sync convenience: does this project want a per-upload prompt? */
4511
+ declare function projectAsksUploadAccess(service: string): boolean;
4512
+ /**
4513
+ * Adopt a value the caller just WROTE, so the settings page reflects its own
4514
+ * save without a re-fetch.
4515
+ *
4516
+ * Marks the entry settled: the writer knows the stored value better than a
4517
+ * refetch would, and leaving it unsettled would send the next upload back to the
4518
+ * network for a value already in hand.
4519
+ */
4520
+ declare function setProjectSettings(service: string, data: ProjectSettingsData | null): void;
4521
+ /** Merge one key into the cached settings, preserving the rest. */
4522
+ declare function patchProjectSettings(service: string, patch: ProjectSettingsData): void;
4523
+ /**
4524
+ * Drop cached settings. Pass a service to drop one, omit to drop all.
4525
+ *
4526
+ * An in-flight fetch is abandoned rather than cancelled: its `.then` checks that
4527
+ * the entry it is writing into is still its own, so a late response cannot
4528
+ * repopulate a cleared project.
4529
+ */
4530
+ declare function clearProjectSettings(service?: string): void;
4531
+
4532
+ export { type AiAgentPlatform, type AnchorBoxEl, type AnchorRowEl, type AttachmentFailureGroup, type AttachmentParser, type AttachmentSaveInfo, BG_INDEXING_QUEUE_SUFFIX, BOM, BOM_EXTS, type BgTaskEntry, type BoundedChatOptions, type BuildDisplayListOptions, type BuildIndexingUserMessageOptions, CLAUDE_INPUT_CAP_RATIO, CLAUDE_PER_REQUEST_INPUT_CAP, CONTEXT_WINDOW_BY_MODEL, CONTEXT_WINDOW_DEFAULT, type CallClaudeWithMcpParams, type ChatEngineConfig, type ChatGreetingParams, type ChatGreetingParts, type ChatHost, type ChatIdentity, type ChatMessage, ChatSession, type ChatState, type ChatStreamWiring, type ChatSystemPromptParams, type ClaudeMcpServerRequest, type ClaudeMcpToolConfig, type ClaudeMessage, type ClaudeRole, type ComposedUserMessage, DEFAULT_CLAUDE_MODEL, DEFAULT_CONTEXT_WINDOW, DEFAULT_OPENAI_MODEL, DEFAULT_UPLOAD_ACCESS_GROUP, type DisplayEntry, EMPTY_INDEXING_REPLY, EXPIRED_ATTACHMENT_URL_HOST, EXPIRED_ATTACHMENT_URL_ORIGIN, EXPIRED_LINK_REFRESH_EXPIRES_SECONDS, EXT_CONTENT_TYPES, type EncodingClass, type ExtractDirective, type FillHistoryViewportOptions, HISTORY_BUDGET_RATIO, HISTORY_FILL_SLACK_PX, HISTORY_TOKEN_BUDGET, HTML_EXTS, HTML_HEAD_WINDOW, IMAGE_PREVIEWS_PER_MESSAGE, INDEXING_COMPLETE_MARKER, INLINE_LINK_GLYPH, INLINE_LINK_UNAVAILABLE_GLYPH, INLINE_LINK_UNAVAILABLE_SUFFIX, INPUT_CAP_RATIO, type ImagePreviewContext, type IndexRunPatch, type IndexRunStatus, type IndexingAttachmentInfo, type IndexingFileRef, type IndexingGroup, type IndexingGroupStatus, type IndexingRequestRef, type IndexingSystemPromptParams, type InlineLinkContext, type InlineLinkMarkupOptions, type InlineLinkPart, LINK_LABEL_MAX_DISPLAY_CHARS, LINK_REFRESH_WINDOW_MS, type LiveStreamUpdate, MAX_CONCURRENT_BG_POLLS, MAX_HISTORY_FILL_PAGES, MAX_HISTORY_MESSAGES, MAX_OUTPUT_BY_MODEL, MAX_OUTPUT_TOKENS, MAX_PARSED_CONTENT_CHARS, MCP_NAME, MINT_CACHE_GENERATION, MIN_INPUT_TOKEN_BUDGET, MIN_PER_REQUEST_INPUT_CAP, type MapHistoryOptions, OUTPUT_TOKEN_RESERVE, type OpenAIMessage, POLL_INTERVAL, PRESIGN_SAFETY_MARGIN_MS, PREVIEWABLE_IMAGE_CONTENT_TYPES, PREVIEW_BROWSER_CACHE_SECONDS, PREVIEW_LAYOUT_BOX_SELECTOR, PREVIEW_URL_EXPIRES_SECONDS, PROJECT_SETTINGS_ACCESS_GROUP, PROJECT_SETTINGS_TABLE, PROJECT_SETTINGS_UNIQUE_ID, type ParsedAiAgent, type PinnedDispatchContext, type PreviewImageEl, type ProjectAccessSetting, type ProjectSettingsData, type ProjectSettingsReader, RENDER_FROM_TOKEN, RTF_EXTS, RUN_RECORD_WORKING_STALE_MS, type RenderableInlineLink, type RescueDecisionContext, type RowAnchor, type RunStubInfo, STREAM_POLL_INTERVAL, type ScrollAnchor, type ScrollAnchorOptions, type SseChunk, type SseParser, type SseProvider, type SseSnapshot, type SseToolCall, type StreamDispatchContext, TOOL_AND_RESPONSE_BUFFER, UPLOAD_ACCESS_GROUPS, UPLOAD_ACCESS_HINTS, UPLOAD_ACCESS_LABELS, UPLOAD_ACCESS_OPTIONS, type UploadAccessGroup, type VisionProfile, XML_EXTS, __resetSplitHistoryState, accessSettingFrom, adoptLocalAnswerIntoPage, applyEncodingDeclaration, asksUploadAccessFrom, bgIndexingQueueName, buildAiAgentValue, buildBoundedChatMessages, buildChatDisplayList, buildChatGreeting, buildChatSystemPrompt, buildDisplayExpiredAttachmentHref, buildHistoryItemFullId, buildIndexingContinueMessage, buildIndexingRenderContinueTemplate, buildIndexingRenderMessage, buildIndexingSystemPrompt, buildIndexingUserMessage, buildIndexingWindowMessage, cachedProjectSettings, callClaudeWithMcp, callClaudeWithPublicMcp, callOpenAIWithPublicMcp, canonicalizePathForm, chatCacheKey, chatEngineConfig, chatStreamWiring, classifyInlineLink, clearAttachmentParsers, clearImagePreviewCache, clearProjectSettings, composeUserMessage, configureChatEngine, configureProjectSettings, contentTypeForExt, createHistoryFiller, createInlineLinkRegex, createScrollAnchor, createSseParser, csrEnvelopeError, encodePathSegments, encodingClassForExt, ensureHtmlCharset, ensureXmlEncoding, escapeInlineHtml, escapeRtfNonAscii, estimateMessageTokens, estimateTextTokens, extOf, extractClaudeText, extractLastUserTextFromRequest, extractOpenAIText, extractRemotePathFromAttachmentHref, fetchLiveIndexingKeys, fillHistoryViewport, filterListByClearHorizon, findAttachmentParser, formatChatTimestamp, getAttachmentParsers, getChatHistory, getContextWindow, getErrorMessage, getExpiredAttachmentVisiblePath, getInputTokenBudget, getMaxOutputTokens, getModelContextWindow, getProjectContextWindow, getSplitChatHistory, getVisionProfile, groupAttachmentFailures, hasBom, hydrateImagePreviews, indexDoneUniqueId, indexScopeKey, indexingAccessGroup, isAuthExpiredError, isBgIndexingQueue, isCsrStatusEnvelope, isErrorResponseBody, isHttpUrlLike, isIndexingRequestText, isLinkUnavailable, isNonRetryableRequestError, isOfficeFile, isPreviewableImagePath, isProviderApiKeyError, isServerExtractable, isServiceDbAttachmentHref, linkUnavailableKeyForHref, linkUnavailableKeyForPath, linkUnavailableKeysForPath, listClaudeModels, listOpenAIModels, liveSafePrefix, loadProjectSettings, looksLikeRtf, makeExtractPlaceholder, mapHistoryListToMessages, markImagePreviewStale, mayKeepStreamedAnswer, mintCacheBustStamp, needsBomForExt, normalizeAttachmentPathCandidate, normalizeExt, normalizeProjectAccessSetting, normalizeTextContent, normalizeTrailingInlineToken, normalizeUploadAccessGroup, notifyAgentSaveAttachment, parseAiAgentValue, parseAttachmentContent, parseIndexingLabel, parseIndexingRequestText, patchProjectSettings, peekImagePreviewUrl, prepareDownloadText, presignExpiryEpochMs, previewImageContentType, previewLayoutBox, previewMintCacheToken, previewableExtOf, primeProjectSettings, projectAccessSetting, projectAsksUploadAccess, projectSettingsSettled, projectUploadAccessGroup, readExpiredAttachmentHref, readyProjectSettings, registerAttachmentParser, registerModelContextWindows, renderInlineLinkHtml, repairUrlEntities, repairUrlWhitespace, resolveImagePreviewUrl, runIndexUniqueId, safeDecodeURIComponent, sanitizeAttachmentLinksForHistory, setProjectContextWindow, setProjectSettings, shouldRescueInFlightMessage, skapiSupportsStreaming, streamRecoveryEnabled, streamRecoveryLabels, streamRecoveryPhase, stripFileBlocksFromHistory, transformContentWithImages, transformContentWithOpenAIImages, truncateLabelForDisplay, typewriterResumeIndex, uploadAccessGroupFrom, upsertIndexRunRecordSafe, wallClockNow };