bunnyquery 1.9.7 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,12 +305,82 @@ 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.
155
380
  *
156
381
  * Office documents (Microsoft .docx/.xlsx/.pptx, Hancom .hwpx, etc.) can't be
157
- * read by web_fetch (binary/zip). The proxy worker downloads them from db
382
+ * read by web_fetch (binary/zip), and neither can an RFC822 email (.eml), whose
383
+ * body and attachments are MIME-encoded. The proxy worker downloads them from db
158
384
  * storage, extracts their text server-side, and substitutes that text for a
159
385
  * placeholder token in the request body (carried under the reserved
160
386
  * `_skapi_extract` key, which the producer strips before the upstream call).
@@ -185,7 +411,28 @@ type FileUrlDirective = {
185
411
  declare function isServerExtractable(name?: string, mime?: string): boolean;
186
412
  /** @deprecated renamed to {@link isServerExtractable} (now also covers text files). */
187
413
  declare const isOfficeFile: typeof isServerExtractable;
414
+ /**
415
+ * True when a file should be indexed by PAGING through readFileContent (spreadsheets and
416
+ * PDFs), rather than inline extraction or a web_fetch URL. This is what lets a huge sheet
417
+ * be read row-window by row-window and a scanned PDF be read page-image by page-image,
418
+ * with embedded photos delivered to the vision model.
419
+ */
420
+ declare function isPagedReadFile(name?: string, mime?: string): boolean;
421
+ /**
422
+ * True for files whose content is VISUAL and must be delivered to the model as IMAGE
423
+ * BLOCKS in the message (rendered pages), because tool-result images render on neither
424
+ * provider. The worker renders a page window to image URLs and injects them (`_skapi_render`
425
+ * directive). Currently PDFs (scanned or not); indexed page-window by page-window with
426
+ * resume advancing the window.
427
+ */
428
+ declare function isImageVisionFile(name?: string, mime?: string): boolean;
188
429
  declare function makeExtractPlaceholder(seed: string): string;
430
+ /**
431
+ * True when a file should be read server-side, one window at a time, by the worker.
432
+ * PDFs are excluded: they go through the VISION path, where pages are rendered to
433
+ * images because their text layer is often absent or unreliable.
434
+ */
435
+ declare function isWindowedReadFile(name?: string, mime?: string): boolean;
189
436
  interface ComposedUserMessage {
190
437
  /** Clean display/history copy (attachment links, NO extraction placeholders). */
191
438
  composed: string;
@@ -280,8 +527,10 @@ type ChatSystemPromptParams = {
280
527
  */
281
528
  client?: 'console' | 'widget';
282
529
  /**
283
- * The access group THIS project's indexer writes its records at, from the
284
- * project's `default_access_group` setting.
530
+ * The access group THIS project's indexer writes its records at, read from
531
+ * the project's BunnyQuery settings record (`bq::settings`, key
532
+ * `upload_access_group`). It used to come from the service record's
533
+ * `default_access_group`, which no longer exists.
285
534
  *
286
535
  * The MCP auto-fills an index/tag query that names a table but no group with
287
536
  * "authorized", which used to be right because every BunnyQuery record was
@@ -289,6 +538,16 @@ type ChatSystemPromptParams = {
289
538
  * visitor can read it) or "private", and on those projects the auto-fill
290
539
  * silently searches a group the data is not in and answers "nothing found".
291
540
  * Defaults to 'authorized', which is what an unset project still uses.
541
+ *
542
+ * A PLAIN table query needs the group just as much, and this is newer: the
543
+ * SDK no longer fills a group in for a table that arrives without one, so the
544
+ * SERVER resolves it, and it resolves it differently per caller. A master
545
+ * (the project's owner) is answered across every access group; a normal
546
+ * signed-in user is answered from access_group 0 alone. So an end user asking
547
+ * about a table indexed at "authorized" would silently search public only,
548
+ * and get "nothing found" over data that is right there. The prompt therefore
549
+ * asks for the group on EVERY query that names a table, not just index/tag
550
+ * ones.
292
551
  */
293
552
  indexAccessGroup?: string;
294
553
  };
@@ -355,9 +614,11 @@ type IndexingAttachmentInfo = {
355
614
  };
356
615
  type BuildIndexingUserMessageOptions = {
357
616
  /**
358
- * For files with no paged reader (.epub/.hwp/.doc/.rtf, source code) the model can't read the binary via
359
- * web_fetch, so the proxy worker extracts the text server-side and replaces
360
- * this exact token with it. When provided, the message embeds the token (and
617
+ * For files the layer parses server-side (office, e-book, email) and for text
618
+ * files, the text is inlined server-side: a binary container cannot be read via
619
+ * web_fetch, and a text file is inlined so providers without a file-fetch tool
620
+ * still see it. The proxy worker extracts the text and replaces this exact
621
+ * token with it. When provided, the message embeds the token (and
361
622
  * drops the temporary-URL line - there is nothing for the model to fetch).
362
623
  */
363
624
  inlineContentPlaceholder?: string;
@@ -468,6 +729,58 @@ declare function buildChatGreeting(params: ChatGreetingParams): ChatGreetingPart
468
729
  * Error detection + message extraction (pure). Moved verbatim from the
469
730
  * agent.vue / bunnyquery chatbox so both consumers share one implementation.
470
731
  */
732
+ /**
733
+ * True when a csr-poll answer is the STATUS ENVELOPE rather than a stored body.
734
+ *
735
+ * Duck-typed, because the engine does not import skapi-js and the SDK does not
736
+ * export its own copy. The rule is the SDK's (isPollEnvelope): a request that has
737
+ * a stored result hands that result back verbatim, every other state hands back
738
+ * `{ id, status, in_queue, ... }`. A finalized body is the caller's own content
739
+ * and can itself carry a `status` key (OpenAI's Responses object does), so the
740
+ * id/in_queue pair is demanded too: a provider body would have to reproduce all
741
+ * three to be mistaken for an envelope.
742
+ *
743
+ * It lives HERE, next to the error readers, rather than in session.ts, because
744
+ * both of the things that have to recognise an envelope (the settle that
745
+ * substitutes an assembled body for one, and the error readers below) must agree
746
+ * on what one is. It was written twice once; that is how the error readers came to
747
+ * look one level too shallow.
748
+ */
749
+ declare function isCsrStatusEnvelope(res: any): boolean;
750
+ /**
751
+ * The real error payload inside a FAILED csr-poll status envelope, or undefined
752
+ * when `input` is not one.
753
+ *
754
+ * THE FAILURE THIS PREVENTS, verbatim from the wire. A buffered turn that fails
755
+ * polls back as the worker's failed payload itself:
756
+ *
757
+ * { status_code: 401, body: { error: { type, message } }, truncated: false }
758
+ *
759
+ * ...which every predicate below reads correctly. A STREAMED turn that fails does
760
+ * not: the poller (client_secret_key_request_polling) cannot return the error
761
+ * early for a streamed row, because the chunks that arrived before the stream died
762
+ * have to come back in the same response, so it falls through and ships
763
+ *
764
+ * { id, status: 'failed', queue_name, in_queue, stream, chunks, last_seq,
765
+ * more, error: <the payload above> }
766
+ *
767
+ * The payload is one level deeper, and every predicate here looked at the top
768
+ * level: `response.error.message` is undefined on it, `response.status_code` is
769
+ * absent, and `response.status` is the string 'failed' rather than a number. So a
770
+ * wrong API key on a streamed turn read as "not an error, and no answer either",
771
+ * which the caller renders as "No text response received from AI provider": the
772
+ * one message that tells the user nothing.
773
+ *
774
+ * Unwrapping HERE, once, is what makes a streamed error and a buffered error take
775
+ * the same path through every reader below. Deliberately not recursive: the value
776
+ * inside an envelope is a provider payload, never another envelope, and a single
777
+ * unwrap cannot loop on a malformed one.
778
+ *
779
+ * A failed envelope with a NULL payload (the worker recorded no detail, or its
780
+ * spill could not be fetched) still yields an object, because the row's status is
781
+ * itself the fact: 'failed' with nothing attached must not read as a clean turn.
782
+ */
783
+ declare function csrEnvelopeError(input: any): any;
471
784
  declare function getErrorMessage(input: any): string;
472
785
  declare function isErrorResponseBody(response: any): boolean;
473
786
  declare function isNonRetryableRequestError(input: any): boolean;
@@ -512,6 +825,38 @@ declare function registerModelContextWindows(models: Array<{
512
825
  declare function setProjectContextWindow(projectId: string, tokens: number | null | undefined): void;
513
826
  declare function getProjectContextWindow(projectId: string): number | null;
514
827
  declare var MAX_OUTPUT_TOKENS: number;
828
+ /**
829
+ * The same ceiling for an INDEXING pass, which is a different job with a different shape.
830
+ *
831
+ * A chat turn has a person waiting, so a long reply is a worse outcome than a truncated
832
+ * one and 25,000 is generous for it. An indexing pass has nobody waiting and one job:
833
+ * emit records for the window it was shown. When it runs out of budget mid-window the
834
+ * worker halves the window and re-sends it (`window_scale 1.0 -> 0.5`), so the file pays
835
+ * roughly twice the passes for the rest of its length.
836
+ *
837
+ * WHY 64,000, measured on a live 465-row spreadsheet (9 passes, project ap21U8y5byIbkGSv):
838
+ * - gpt-5.6-luna allows 128,000 output tokens, so 25,000 was 19.5% of what it permits.
839
+ * - Only 1 of those 9 passes hit the cap; the median used 6,370. A cap is a CEILING, not
840
+ * a target: the model stops when it is done, so raising it costs nothing on the eight
841
+ * passes that never approach it. It only changes the one that would have truncated.
842
+ * - Fitting duration against output tokens across the nine: `55.4s + output / 131 tok/s`
843
+ * (R^2 0.906). The binding constraint is TIME, not the model.
844
+ * - The worker's upstream timeout is 870s and its Lambda is 900s with a 30s settle
845
+ * reserve, which puts the theoretical ceiling near 103,000 tokens. 64,000 lands at
846
+ * about 544s and leaves roughly 300s of margin for a slow provider hour or a pass with
847
+ * more tool round trips. Spending that margin buys nothing: 64,000 is already enough
848
+ * for a full 250-row window to finish in one pass, which is the whole point.
849
+ *
850
+ * It does NOT feed OUTPUT_TOKEN_RESERVE below. That reserve exists to size the INPUT
851
+ * budget for buildBoundedChatMessages, which only the chat path calls; an indexing message
852
+ * is built from a file window, not from bounded history. Wiring this into the reserve would
853
+ * shrink a budget this number has nothing to do with.
854
+ *
855
+ * Re-derive rather than nudge: re-run the fit if the model, the Lambda timeout or
856
+ * REQUEST_TIMEOUT changes, and note that a model whose own ceiling is lower still wins
857
+ * (getMaxOutputTokens clamps, so gpt-4o stays at its 4,000).
858
+ */
859
+ declare var INDEXING_MAX_OUTPUT_TOKENS: number;
515
860
  declare var OUTPUT_TOKEN_RESERVE: number;
516
861
  declare var TOOL_AND_RESPONSE_BUFFER: number;
517
862
  declare var MIN_INPUT_TOKEN_BUDGET: number;
@@ -535,7 +880,10 @@ declare function getModelContextWindow(platform: string, model?: string): number
535
880
  * but a model whose own cap is lower rejects the request outright, so clamp to
536
881
  * whichever is smaller. Models with no known cap keep MAX_OUTPUT_TOKENS.
537
882
  */
538
- declare function getMaxOutputTokens(platform: string, model?: string): number;
883
+ declare function getMaxOutputTokens(platform: string, model?: string,
884
+ /** 'indexing' asks for INDEXING_MAX_OUTPUT_TOKENS instead. Omitted means chat, so every
885
+ * existing caller keeps the number it had. */
886
+ purpose?: 'chat' | 'indexing'): number;
539
887
  /**
540
888
  * The window a request is actually budgeted at: the per-project override when
541
889
  * one is set, otherwise DEFAULT_CONTEXT_WINDOW. Both are clamped to the model's
@@ -1216,9 +1564,275 @@ type ParsedAiAgent = {
1216
1564
  declare function parseAiAgentValue(value: string | null | undefined): ParsedAiAgent;
1217
1565
  declare function buildAiAgentValue(platform: string | null | undefined, model?: string | null, contextWindow?: number | null): string;
1218
1566
 
1567
+ /**
1568
+ * Streamed-turn parser: raw provider SSE bytes in, live answer text + the body a
1569
+ * buffered call would have returned out.
1570
+ *
1571
+ * WHY THIS FILE EXISTS, AND WHY IT IS HERE AND NOT IN SKAPI.
1572
+ * skapi's clientSecretRequest is a byte relay. On a streamed turn the worker reads
1573
+ * the destination's response incrementally and appends the raw bytes to a chunk
1574
+ * table; it settles the polling row with STATUS ONLY, no body, because the content
1575
+ * lives in the chunks. skapi therefore does not know that Anthropic or OpenAI
1576
+ * exist, has no dialect list, and parses nothing. BunnyQuery is the party that
1577
+ * knows which destination it dialled, so BunnyQuery is the party that parses, and
1578
+ * this module is the whole of that knowledge.
1579
+ *
1580
+ * WHAT ARRIVES. csr-poll hands back `chunks: [{seq, txt}]` (ascending, seq starts
1581
+ * at 1) plus `last_seq` to send back as the next `since`. A chunk is whatever the
1582
+ * worker's flush happened to contain: it flushes on a byte cap or a time interval,
1583
+ * so a chunk boundary lands wherever the socket broke, which is routinely in the
1584
+ * MIDDLE of an SSE frame. Half a frame is not data, so every partial is held in a
1585
+ * buffer until the rest arrives, and nothing is ever emitted from an incomplete
1586
+ * frame. That is what makes this a stateful object fed chunks rather than a
1587
+ * function over a whole transcript.
1588
+ *
1589
+ * REPLAY SAFETY. The parser is a pure function of the chunk SEQUENCE, so a reload
1590
+ * or a second tab that starts at seq 1 of a stream it did not initiate rebuilds
1591
+ * the identical state. Nothing here depends on having dispatched the request.
1592
+ *
1593
+ * THE TWO OUTPUTS, AND WHY BOTH ARE NEEDED.
1594
+ * text the assistant's answer ONLY, for rendering as it arrives. Not tool
1595
+ * arguments, not thinking. Concatenating every delta into one string
1596
+ * is how a half serialised tool call ends up in the middle of a
1597
+ * user's sentence.
1598
+ * finalBody() the assembled provider body, byte equivalent to the buffered
1599
+ * response, so extractClaudeText / extractOpenAIText (requests.ts)
1600
+ * produce the identical string whether the turn was read live or
1601
+ * re-read from history later.
1602
+ *
1603
+ * THE BUG THIS IS SHAPED AROUND. extractClaudeText joins TEXT BLOCKS with '\n':
1604
+ *
1605
+ * content.filter(b => b.type === 'text').map(b => b.text).join('\n')
1606
+ *
1607
+ * A server-tool turn has text at content index 0, the tool call at 1, its result
1608
+ * at 2, and text again at 3. Accumulate every text_delta into one string and those
1609
+ * two paragraphs fuse with no separator, so the answer the user watched arrive and
1610
+ * the same turn re-read from history are different strings. Blocks are therefore
1611
+ * keyed BY INDEX and never merged, and `text` is the join of the text blocks in
1612
+ * index order, which is exactly the extractor's rule and not an approximation of
1613
+ * it. See tests/sse-stream.cjs, "four separate blocks".
1614
+ *
1615
+ * NEVER THROWS FROM feed(). Chunks arrive on a poll tick, inside a timer the
1616
+ * consumer cannot reasonably wrap; a parse error there would take the whole poll
1617
+ * down over one malformed frame. Frames that cannot be understood are counted
1618
+ * (`malformedFrames`) and skipped.
1619
+ *
1620
+ * HONEST TERMINATION, AND WHY IT TAKES TWO FLAGS. `complete` means a terminal event
1621
+ * actually arrived. A stream that was cut (deadline, cancelled row, a worker crash
1622
+ * after some chunks landed) reports complete:false, and the caller must not present
1623
+ * it as a finished answer. A partial answer the reader can see beats an empty turn,
1624
+ * but only if it is labelled as partial.
1625
+ *
1626
+ * That flag alone used to be read as "the answer is whole", and it is not the same
1627
+ * claim. An `error` frame IS a terminal event: the provider said the stream is over
1628
+ * and nothing more is coming. But the text in hand is only whatever arrived before
1629
+ * the error, so the answer is TRUNCATED and the stream is FINISHED at the same
1630
+ * time. A caller whose finalize gate read `complete` therefore stored the
1631
+ * truncation as the turn's permanent history and, because finalize is also the only
1632
+ * way to release chunks, deleted the only copy of the bytes in the same call - for
1633
+ * a turn the provider had explicitly told it went wrong. So the two claims are two
1634
+ * fields:
1635
+ *
1636
+ * complete a terminal event arrived. Nothing more is coming; stop waiting.
1637
+ * answerComplete ...and it was a terminal event that means the answer FINISHED
1638
+ * (message_stop, response.completed, response.incomplete), not
1639
+ * one that means it DIED (an `error` frame, response.failed, a
1640
+ * terminal Response carrying an error payload).
1641
+ *
1642
+ * `response.incomplete` is deliberately on the finished side: the model stopped
1643
+ * short at max_output_tokens, but the terminal event carries the complete Response
1644
+ * document, so the chunks hold nothing the body does not. A caller deciding what to
1645
+ * keep reads `answerComplete`; a caller deciding whether to keep waiting reads
1646
+ * `complete`.
1647
+ *
1648
+ * WHEN THE BYTES ARE NOT SSE AT ALL. skapi's `stream: true` tells the RELAY to read
1649
+ * the response incrementally. It does not tell the destination to produce an event
1650
+ * stream: that is the caller's own request body. If the body never asked for one
1651
+ * (or something in front of the destination buffers the stream back into a single
1652
+ * document and drops the framing), the answer arrives as a plain JSON body with not
1653
+ * one `data:` line in it. Every frame test below then matches nothing, and the turn
1654
+ * used to end as an empty answer with malformedFrames 0, complete false and
1655
+ * finalBody() null: the entire reply lost, with nothing in the output saying so, so
1656
+ * a client draws an empty bubble and no error. That state is now reported as
1657
+ * `unframed`, and the bytes are handed back BOTH ways, because the parser cannot
1658
+ * tell an answer from a gateway's error page without knowing the vendor, and it
1659
+ * must not:
1660
+ * unframedText the bytes verbatim, for a body that is not JSON at all (an HTML
1661
+ * 502 page), which finalBody() cannot represent.
1662
+ * finalBody() the parsed document when the bytes ARE JSON, because a buffered
1663
+ * body is exactly what finalBody() promises, so the caller's
1664
+ * existing buffered path (isErrorResponseBody, extractClaudeText,
1665
+ * extractOpenAIText) reads it with no new branch at all.
1666
+ * Noticing that a byte stream carries no SSE framing is framing, not parsing, and
1667
+ * JSON.parse is the same transport-level codec this file already runs on every
1668
+ * frame payload. Nothing about the document is interpreted: it is handed over
1669
+ * whole, and `provider` stays null because no event ever identified one.
1670
+ *
1671
+ * DOM-free and framework-free like the rest of the engine.
1672
+ */
1673
+ /** One row of csr-poll's `chunks`. */
1674
+ interface SseChunk {
1675
+ seq: number;
1676
+ txt: string;
1677
+ }
1678
+ /**
1679
+ * Which grammar the bytes turned out to be in. Detected, never declared: see
1680
+ * detectProvider() below for why the caller is not asked.
1681
+ */
1682
+ type SseProvider = 'claude' | 'openai';
1683
+ /**
1684
+ * A tool the model reached for, in the order it appeared, so a "querying sales
1685
+ * table..." row can be drawn before a single character of answer text exists.
1686
+ * Duplicates are kept: two calls to the same tool are two rows, not one.
1687
+ */
1688
+ interface SseToolCall {
1689
+ /** Anthropic content index, or OpenAI output index. Identifies the block. */
1690
+ index: number;
1691
+ /** The name as the provider wrote it, falling back to the block/item type for
1692
+ * a built-in that carries no name of its own (OpenAI's web_search_call). */
1693
+ name: string;
1694
+ /** The provider's own block/item type: tool_use, server_tool_use,
1695
+ * mcp_tool_use, function_call, mcp_call, web_search_call, ... */
1696
+ type: string;
1697
+ /** Present on Anthropic mcp_tool_use only. */
1698
+ serverName?: string;
1699
+ }
1700
+ interface SseSnapshot {
1701
+ /** null until the first identifying event has been seen. */
1702
+ provider: SseProvider | null;
1703
+ /** The assistant's answer text so far, joined exactly as the extractor joins
1704
+ * it. Never contains tool arguments or thinking. */
1705
+ text: string;
1706
+ /** Extended-thinking text so far, for a "thinking..." affordance. Deliberately
1707
+ * a SEPARATE field: it must never be concatenated into `text`. Populated on
1708
+ * BOTH providers (Anthropic thinking blocks, OpenAI reasoning summary and
1709
+ * reasoning text deltas). It used to be Anthropic-only, which meant the field
1710
+ * read as "the model's thinking" on one provider and as "this model did not
1711
+ * think" on the other, and every consumer that did not branch on `provider`
1712
+ * drew the wrong thing. Absorbing exactly that branch is what this module is
1713
+ * for, so the field is filled rather than renamed. */
1714
+ thinkingText: string;
1715
+ /** Tools reached for, in order of appearance. */
1716
+ toolCalls: SseToolCall[];
1717
+ /** Convenience projection of toolCalls, same order, duplicates kept. */
1718
+ toolNames: string[];
1719
+ /** Anthropic stop_reason ('end_turn' | 'tool_use' | 'max_tokens' | ...), or for
1720
+ * OpenAI the terminal Response's status, or its incomplete_details.reason when
1721
+ * it stopped short ('max_output_tokens'). null until the stream says. */
1722
+ stopReason: string | null;
1723
+ /** A terminal event ARRIVED. False means the stream was cut and whatever is
1724
+ * here is partial: do not present it as a finished answer.
1725
+ *
1726
+ * THIS IS NOT THE FLAG TO STORE BY. It answers "is anything more coming?", not
1727
+ * "is this the whole answer?" - see `answerComplete`. */
1728
+ complete: boolean;
1729
+ /** The terminal event that arrived means the answer FINISHED, not that it DIED.
1730
+ *
1731
+ * True on message_stop, response.completed and response.incomplete; false while
1732
+ * the stream is still running, false when it was cut, and false when it ended
1733
+ * on an `error` frame, on response.failed, or on a terminal Response carrying
1734
+ * an error payload.
1735
+ *
1736
+ * THE FAILURE THIS FIELD EXISTS FOR. An `error` frame sets `terminalEvent`, so
1737
+ * `complete` goes true while the text is only what arrived before the error. A
1738
+ * caller that finalizes on `complete` therefore writes that truncation into the
1739
+ * turn's permanent history AND releases the chunks it was assembled from, which
1740
+ * is the one loss in this feature that cannot be undone. Every keep/store gate
1741
+ * reads THIS field; `complete` is for deciding whether to keep waiting. Bytes
1742
+ * that were never SSE at all reach neither: see `unframed`, where it is the
1743
+ * polling row's status and not the parse that says the response finished. */
1744
+ answerComplete: boolean;
1745
+ /** The exact terminal event: 'message_stop', 'response.completed',
1746
+ * 'response.incomplete', 'response.failed', 'error'. null while running. */
1747
+ terminalEvent: string | null;
1748
+ /** The stream ended in a provider error. */
1749
+ errored: boolean;
1750
+ /** The provider's error payload, in the shape isErrorResponseBody() detects. */
1751
+ error: any;
1752
+ /** Frames that could not be parsed, and tool-argument JSON that would not
1753
+ * parse at content_block_stop. Diagnostics: both are zero on a healthy turn. */
1754
+ malformedFrames: number;
1755
+ malformedToolJson: number;
1756
+ /** Bytes were relayed, end() was called, and NOT ONE of them was SSE framing:
1757
+ * no `data:`, no `event:`, not even a comment. The destination answered with a
1758
+ * plain body instead of an event stream. This is NOT malformedFrames: there
1759
+ * were no frames to mangle. Nothing is lost, `unframedText` is the bytes and
1760
+ * finalBody() is the parsed document when they are JSON, so the caller can
1761
+ * either render them through its buffered path or surface a real error.
1762
+ * `complete` stays false here because no terminal EVENT arrived and none ever
1763
+ * will: on an unframed body it is the polling row's own status, not the bytes,
1764
+ * that says whether the response finished. */
1765
+ unframed: boolean;
1766
+ /** The relayed bytes verbatim when `unframed`, else null. */
1767
+ unframedText: string | null;
1768
+ /** Highest chunk seq accepted, for the caller's `since` cursor. 0 = none. */
1769
+ lastSeq: number;
1770
+ }
1771
+ interface SseParser {
1772
+ /** Feed raw bytes. Any prefix of a frame is held until the rest arrives. */
1773
+ feed(text: string): void;
1774
+ /** Feed csr-poll's `chunks` array. Chunks at or below the highest seq already
1775
+ * accepted are DROPPED, so a re-poll from a stale `since` cannot double-append
1776
+ * the same bytes into the answer. */
1777
+ feedChunks(chunks: SseChunk[] | null | undefined): void;
1778
+ /** No more bytes are coming. Flushes a final frame that arrived without its
1779
+ * terminating blank line. Does NOT mark the stream complete: only a terminal
1780
+ * event does that. It IS what decides `unframed`, because up to this call
1781
+ * "no framing seen yet" and "the first frame has not finished arriving" are
1782
+ * the same state, so a caller that never calls end() never learns the bytes
1783
+ * were not SSE. */
1784
+ end(): void;
1785
+ snapshot(): SseSnapshot;
1786
+ /** The assembled provider body, byte equivalent to a buffered response, or
1787
+ * null when nothing has been assembled. See buildBody() for the two rules:
1788
+ * one about errors, one about bytes that were never SSE. */
1789
+ finalBody(): any;
1790
+ }
1791
+ declare function createSseParser(): SseParser;
1792
+
1219
1793
  declare const MCP_NAME = "BunnyQuery";
1220
1794
  declare const DEFAULT_CLAUDE_MODEL = "claude-sonnet-5";
1221
1795
  declare const DEFAULT_OPENAI_MODEL = "gpt-5.6-luna";
1796
+ /**
1797
+ * THE two `stream` flags of a streamed chat turn, produced together or not at all.
1798
+ *
1799
+ * There are two of them and they are NOT the same flag:
1800
+ *
1801
+ * * `transport.stream` is SKAPI's. It tells the polling worker to read the
1802
+ * destination's response incrementally and append the raw bytes to the chunk
1803
+ * table, and it is never sent on to the destination.
1804
+ * * `body.stream` is the DESTINATION's own field, and BunnyQuery is the party
1805
+ * that may set it: skapi relays bytes and knows no vendor, so it cannot know
1806
+ * that Anthropic Messages and OpenAI Responses both happen to spell it
1807
+ * `stream` at the top level of the body.
1808
+ *
1809
+ * Setting one without the other fails QUIETLY, which is why they are produced by
1810
+ * one function from one boolean and returned as one object:
1811
+ *
1812
+ * * body streams, skapi buffers -> the row stores an SSE TRANSCRIPT where
1813
+ * extractClaudeText / extractOpenAIText expect a parsed document, so the turn
1814
+ * reads back as an empty answer with nothing in the logs to say why.
1815
+ * * skapi streams, the body never asked -> the destination sends one plain
1816
+ * document, the relay chops it into chunks, the frame parser finds no framing
1817
+ * at all, and the row settles with a status and no body.
1818
+ *
1819
+ * Two frozen constants rather than a fresh object per call: the pair is a
1820
+ * CONSTANT, and an object literal built at each call site is exactly the shape
1821
+ * that drifts when someone edits one arm.
1822
+ */
1823
+ type ChatStreamWiring = {
1824
+ /** Spread into the clientSecretRequest OPTIONS (skapi's relay switch). `realtime`
1825
+ * belongs here and never in `body`: it is skapi's, not the destination's. */
1826
+ transport: {
1827
+ stream?: true;
1828
+ realtime?: true;
1829
+ };
1830
+ /** Spread into `data` (the destination's own switch). */
1831
+ body: {
1832
+ stream?: true;
1833
+ };
1834
+ };
1835
+ declare function chatStreamWiring(queue?: string): ChatStreamWiring;
1222
1836
  /** How a given model should be shown a rendered document. */
1223
1837
  type VisionProfile = {
1224
1838
  /** Per-image `detail` (OpenAI only). */
@@ -1284,6 +1898,7 @@ type CallClaudeWithMcpParams = {
1284
1898
  onError?: (err: any) => void;
1285
1899
  };
1286
1900
  declare const POLL_INTERVAL = 3000;
1901
+ declare const STREAM_POLL_INTERVAL = 1000;
1287
1902
  declare const MAX_CONCURRENT_BG_POLLS = 6;
1288
1903
  declare function callClaudeWithMcp({ prompt, messages, service, owner, userId, model, maxTokens, system, mcpServer, extractContent, fileUrls, }: CallClaudeWithMcpParams): Promise<any>;
1289
1904
  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 +2226,91 @@ interface ChatMessage {
1611
2226
  * still dimmed. Cleared (with _dimSending) by markStagedMessageReady the moment
1612
2227
  * the queue drains, which is when the turn genuinely becomes "(In queue)". */
1613
2228
  isAwaitingIndexing?: boolean;
2229
+ /** PROTOCOL + PRESENTATIONAL: this bubble is being painted from a LIVE stream.
2230
+ *
2231
+ * It sits alongside `isPending`, never instead of it. The bubble is still the
2232
+ * turn's "Thinking..." placeholder as far as every queue mechanism is concerned
2233
+ * (_ownThinkingIndex, resolveQueuedUserBubble, typewriteLatestReply and the
2234
+ * stray-pending sweep all find their target by isPending), and clearing that flag
2235
+ * to mean "it has text now" would strand the turn's real answer beside an orphan.
2236
+ * What this adds is the one thing those mechanisms do not care about and the VIEW
2237
+ * does: `content` is already worth rendering, so draw the text instead of the
2238
+ * spinner.
2239
+ *
2240
+ * Cleared the moment the turn settles, BEFORE the authoritative answer replaces
2241
+ * the live text: from that instant the bubble is an ordinary reply being typed.
2242
+ * The partially painted `content` is deliberately left in place across that clear,
2243
+ * because it is what the typewriter resumes from instead of replaying from zero.
2244
+ *
2245
+ * Also read by shouldRescueInFlightMessage: a streaming bubble that has no server
2246
+ * id yet is unrepresentable in a freshly fetched page, so it must survive the
2247
+ * merge or its stream is orphaned with nothing left to paint into. */
2248
+ _streaming?: boolean;
2249
+ /**
2250
+ * This turn's row is TERMINAL but carries no stored answer, because the answer
2251
+ * was streamed and nobody ever finalized it: the bytes are in the chunk store,
2252
+ * not on the row. The bubble's `content` is therefore UNKNOWN, not empty.
2253
+ *
2254
+ * That distinction is the whole of the fix for two bugs that looked unrelated.
2255
+ * A refetch landing in the window between the row going 'resolved' and finalize
2256
+ * storing the body used to ERASE the answer off the screen (the server copy has
2257
+ * no content, so the merge dropped the local bubble that did); and a turn that
2258
+ * settled while no poll was attached (closed tab, slept device) used to be
2259
+ * unrecoverable, because the mapper emitted no assistant bubble at all for a
2260
+ * terminal-but-empty row. Both are the same question, "what should the merge
2261
+ * believe when the server copy is authoritative but empty", and the answer is
2262
+ * this flag: an unknown answer NEVER overwrites a known one, and an unknown one
2263
+ * left over after the merge is resolved by reading the chunks back
2264
+ * (ChatSession.recoverStreamedAnswer).
2265
+ *
2266
+ * For a view it is a rendering hint and nothing more: a bubble carrying it with
2267
+ * empty content is being fetched, so draw whatever this client draws for a
2268
+ * loading answer. A host that ignores it renders an empty bubble for the second
2269
+ * or two the recovery takes, which is what it would have rendered anyway.
2270
+ *
2271
+ * WHEN IT COMES OFF, because that is the half that loses answers. It comes off
2272
+ * for a FACT about the turn and never for an event in the client: an answer was
2273
+ * recovered and written in, or the chunks were read and were genuinely empty (in
2274
+ * which case the empty bubble is removed as well, restoring the list the mapper
2275
+ * used to produce). It stays ON when the read FAILED, when the read was STOPPED,
2276
+ * and when a live turn settled having painted nothing - three states that say
2277
+ * nothing whatever about the turn, and in which the chunks are all still there.
2278
+ * A marker cleared on one of those is an answer nothing will ever go back for,
2279
+ * so a host may see the same bubble marked across several loads while the reads
2280
+ * keep failing; that is the recoverable state, not a stuck one.
2281
+ */
2282
+ _streamPending?: boolean;
2283
+ /**
2284
+ * IS ANYTHING ACTUALLY DRIVING THIS BUBBLE RIGHT NOW? The second half of
2285
+ * `_streamPending`, and the half a view cannot do without.
2286
+ *
2287
+ * `_streamPending` says the answer is elsewhere; it does NOT say somebody is on
2288
+ * their way to fetch it, and the two are different states that used to render
2289
+ * identically. Recovery is capped per history load (STREAM_RECOVERY_PER_LOAD),
2290
+ * so the third and later marked turns on a page are marked and nobody is reading
2291
+ * them; a read that FAILED leaves the marker on with the attempt forgotten, which
2292
+ * is also nobody. Both drew the same loader as a live turn, so a bubble could
2293
+ * spin for the rest of the session with nothing behind it - the one thing a
2294
+ * spinner must never do, because it is a promise that something is coming.
2295
+ *
2296
+ * Three states, and only the first of them may draw a spinner:
2297
+ * 'active' a chunk read is in flight or queued for this turn. Something IS
2298
+ * coming; the loader is honest.
2299
+ * 'failed' the last read failed. Nothing is coming until somebody asks
2300
+ * again, so the view owes the reader a way to ask.
2301
+ * undefined nothing has been tried, or the attempt is over. Same obligation.
2302
+ *
2303
+ * Written by the engine only, and never persisted anywhere: it describes THIS
2304
+ * session's fetching, not the turn. A fresh history page therefore arrives
2305
+ * without it, and _adoptLocalAnswers re-stamps the page's still-marked bubbles
2306
+ * from the session's own bookkeeping, so a reload during a read does not turn a
2307
+ * live loader into a button (and back a second later).
2308
+ *
2309
+ * Read it through streamRecoveryPhase(msg), never directly: the phase folds in
2310
+ * "does this bubble need the affordance at all", and both clients must not
2311
+ * answer that twice.
2312
+ */
2313
+ _streamRecovery?: 'active' | 'failed';
1614
2314
  _serverItemId?: string;
1615
2315
  _localId?: string;
1616
2316
  _cancelling?: boolean;
@@ -1916,7 +2616,28 @@ type MapHistoryOptions = {
1916
2616
  declare function mapHistoryListToMessages(list: any[], platform: 'claude' | 'openai', opts: MapHistoryOptions): {
1917
2617
  messages: any[];
1918
2618
  runningItemIds: string[];
2619
+ streamPendingItemIds: string[];
1919
2620
  };
2621
+ /**
2622
+ * Let a LOCAL copy of a turn survive a page whose copy of it is
2623
+ * AUTHORITATIVE-BUT-EMPTY. Mutates `incoming`; returns true when it took anything.
2624
+ *
2625
+ * THE FAILURE THIS PREVENTS. A streamed turn's row goes 'resolved' the moment the
2626
+ * relay finishes, and its answer reaches the row only when csr-finalize stores it,
2627
+ * one poll interval plus a round trip later. A first-page history refetch landing
2628
+ * inside that window maps the row to a `_streamPending` bubble with no content, and
2629
+ * the merge, which believes the server, throws away the local bubble holding the
2630
+ * answer the reader is looking at. The window opens on EVERY streamed turn, and a
2631
+ * refetch fires from visibilitychange, so it is not a corner case.
2632
+ *
2633
+ * The rule is the same one the recovery reads: an UNKNOWN answer never overwrites a
2634
+ * KNOWN one. Where the local copy is still live (pending, or being painted into),
2635
+ * its live-ness is adopted too: without it the merge would hand back a settled
2636
+ * bubble the painter can no longer find (_liveTargetIndex wants isPending or
2637
+ * _streaming) and that _turnAlreadyRendered would then read as already answered, so
2638
+ * the settle would drop the real answer on the floor.
2639
+ */
2640
+ declare function adoptLocalAnswerIntoPage(incoming: ChatMessage, local: ChatMessage): boolean;
1920
2641
  interface RescueDecisionContext {
1921
2642
  /** Is this `_serverItemId` in the page that was just fetched? */
1922
2643
  hasServerId: (id: string) => boolean;
@@ -2559,6 +3280,166 @@ type PollHandle = {
2559
3280
  /** Absent on an older skapi-js that cannot stop an attached poll. */
2560
3281
  stop?: () => void;
2561
3282
  };
3283
+ /**
3284
+ * The prefix of a still-arriving answer that is safe to render as markdown.
3285
+ *
3286
+ * Four cuts, each taking the earliest position that could still change meaning:
3287
+ * 1. an UNCLOSED ``` fence (odd number of markers) - everything from its opener;
3288
+ * 2. an UNCLOSED inline link on the last line - `[label` with no `]`, or
3289
+ * `[label](url` with no `)`, from its `[`;
3290
+ * 3. a trailing bare url or `src::` token, from its first character, because a
3291
+ * link is minted from whatever is there and a growing url means a chip whose
3292
+ * href changes on every paint;
3293
+ * 4. an unclosed inline-code span on the last line (odd backtick count).
3294
+ *
3295
+ * Deliberately NOT covered: emphasis markers, half-written table rows and list
3296
+ * bullets. Those degrade to a flicker of STYLING, which self-corrects on the next
3297
+ * paint; the four above degrade to a wrong link, a wrong chip, or prose shown where
3298
+ * a fence was meant, none of which the reader can tell from the real thing.
3299
+ */
3300
+ declare function liveSafePrefix(text: string): string;
3301
+ /**
3302
+ * Where the typewriter should START revealing `fullText`, given what a live stream
3303
+ * has already painted into the bubble.
3304
+ *
3305
+ * The point is that the settle must not replay an answer the reader has already
3306
+ * watched arrive: the authoritative text REPLACES the live text (it is the only
3307
+ * source of truth), but the characters the two agree on are already on screen and
3308
+ * retyping them from zero is the one thing that would make streaming look worse
3309
+ * than not streaming.
3310
+ *
3311
+ * `regions` are the typewriter's own atomic regions. A resume index landing inside
3312
+ * one is pushed FORWARD to its end rather than back to its start: forward reveals
3313
+ * the link or fence whole, which is the policy those regions exist to enforce, and
3314
+ * backward would make the bubble shrink at the exact moment the answer settles.
3315
+ *
3316
+ * LEADING WHITESPACE IS NORMALISED FIRST, and that is not a nicety. The two strings
3317
+ * come from two places that disagree about it by design: the painter writes the
3318
+ * parser's `text` UNTRIMMED (currentText says why: trimming a render feed would
3319
+ * remove a leading newline and then hand it back when the next delta lands), while
3320
+ * every settle path trims, exactly as it trims a buffered answer. And the extractor
3321
+ * joins text blocks with '\n', so a model that opens an empty text block before its
3322
+ * first tool call, which Claude routinely does, produces a painted answer starting
3323
+ * with a newline the authoritative one does not have. Compared raw, the two agree on
3324
+ * NOTHING (their first characters differ), the resume index is 0, and the reader
3325
+ * watches the entire answer they just read be retyped from zero. Which is the one
3326
+ * thing streaming was supposed to stop happening.
3327
+ */
3328
+ declare function typewriterResumeIndex(painted: string, fullText: string, regions: Array<{
3329
+ start: number;
3330
+ end: number;
3331
+ }>): number;
3332
+ /**
3333
+ * THE KEEP POLICY, in one place, for every path that can reach csr-finalize.
3334
+ *
3335
+ * WHY IT IS A FUNCTION AND NOT A LINE IN EACH CALLER. Finalizing does two things in
3336
+ * one call: it stores what you hand it as the row's permanent answer, and it
3337
+ * DELETES the chunks it was assembled from. Chunks are the only copy of a streamed
3338
+ * answer until that call, and there is no way to release them without also storing
3339
+ * something, so "may this be kept?" is the single decision that separates a
3340
+ * recoverable turn from a permanently truncated one. It was answered in two places
3341
+ * that then disagreed: the live settle refused to finalize a failed or cancelled
3342
+ * turn (its partial text is the only copy there is, and both ways of releasing it
3343
+ * cost something real), while the recovery path computed the same question from
3344
+ * parse completeness ALONE - so recovering a failed row finalized it and released
3345
+ * exactly the chunks the live policy exists to keep. Two halves of one fix, pulling
3346
+ * opposite ways. One predicate, consulted by both, is the fix for that.
3347
+ *
3348
+ * The three terms, and what each of them is protecting:
3349
+ *
3350
+ * THE ROW'S OWN STATUS wins over anything the bytes say. 'failed' means the
3351
+ * destination's account of the turn is the error, not the text that arrived
3352
+ * before it; 'cancelled' means the user's Stop said to discard the half answer,
3353
+ * so writing it into history as the kept version resurrects exactly what the stop
3354
+ * was for; 'stopped' is a poll that was ended, which says nothing about the turn
3355
+ * at all. Pass undefined when the status is genuinely not known (the caller is
3356
+ * looking only at bytes); pass the status whenever there is one, because a caller
3357
+ * that omits a status it HAS is asking the wrong question.
3358
+ *
3359
+ * `errored` covers the same refusal expressed by the bytes rather than by the
3360
+ * row: an `error` frame, a response.failed, a terminal Response with an error
3361
+ * payload. See sse.ts's answerComplete for why a terminal event is not the same
3362
+ * claim as a finished answer.
3363
+ *
3364
+ * `answerComplete` (NOT `complete`) is the completeness half. A degraded chunk
3365
+ * read - the poller degrades to "no chunks this tick, more=true" on any transient
3366
+ * chunk-table error, and caps one read at 500k characters - hands a settle a
3367
+ * stream that stopped mid-answer while the ROW settles 'resolved' on top of it,
3368
+ * because the row's status describes the destination's request and not our read
3369
+ * of it. Anything short of a finished answer leaves the chunks exactly where they
3370
+ * are, which is what they are for: the turn stays re-readable through
3371
+ * clientSecretRequestStream and a later load recovers it in full.
3372
+ *
3373
+ * `unframed` is the one exception to needing a terminal event, and it is not a
3374
+ * loophole: bytes that were never SSE carry no events at all and none is ever
3375
+ * coming, so there it IS the row's status that says the response finished - which
3376
+ * is why this is only ever reached with a 'resolved' row or with no status to
3377
+ * contradict it.
3378
+ *
3379
+ * Exported so the two clients cannot answer it a third way.
3380
+ */
3381
+ declare function mayKeepStreamedAnswer(snap: any, rowStatus?: string | null): boolean;
3382
+ /**
3383
+ * WHAT A VIEW SHOULD DRAW FOR A TURN WHOSE ANSWER IS STILL IN THE CHUNK STORE.
3384
+ *
3385
+ * One predicate, on the barrel, because the alternative is each client deciding
3386
+ * for itself when a spinner is honest - and the two clients have forked on
3387
+ * smaller things than this. Returns:
3388
+ *
3389
+ * '' not this state at all. Either the bubble is not marked, or it HAS
3390
+ * content (the merge adopted a local answer onto it, or a recovery
3391
+ * wrote a truncated one in), in which case there is text to render
3392
+ * and the recovery, if any, is a background correction the reader
3393
+ * does not need to be told about.
3394
+ * 'active' a chunk read is in flight or queued. Draw the loader: this is the
3395
+ * only phase in which something really is coming.
3396
+ * 'failed' the last read failed. Draw the failure and an ask-again control.
3397
+ * 'idle' marked, and nothing is fetching it. Draw an ask-for-it control.
3398
+ *
3399
+ * THE FAILURE THIS EXISTS TO STOP. Recovery is capped at STREAM_RECOVERY_PER_LOAD
3400
+ * per history load, so on a page holding several unfinalized turns the third and
3401
+ * later ones are marked and queued for nobody; a failed read likewise leaves the
3402
+ * marker on deliberately (it is the only thing keeping the answer reachable) with
3403
+ * no attempt behind it. Both used to take the same branch as a live pending turn,
3404
+ * so those bubbles spun forever with nothing driving them and no way for the
3405
+ * reader to resolve them - while the answer sat in the chunk table the whole time,
3406
+ * one recoverStreamedAnswer() call away.
3407
+ *
3408
+ * A bubble with no `_serverItemId` returns '' on purpose: there is no id to hand
3409
+ * recoverStreamedAnswer, so an affordance would be a button that cannot work.
3410
+ * Unreachable today (the mapper only ever marks a row it has an id for), stated so
3411
+ * that it stays unreachable rather than becoming a dead control.
3412
+ */
3413
+ declare function streamRecoveryPhase(msg: any): '' | 'active' | 'failed' | 'idle';
3414
+ /**
3415
+ * The words for the two phases a reader has to act on. Here rather than in each
3416
+ * client for the same reason as the phase itself: two clients wording the same
3417
+ * state differently is how one of them ends up saying something untrue.
3418
+ *
3419
+ * Neither string claims the answer is lost. It is not: the row is unfinalized, so
3420
+ * the chunks are retained until somebody finalizes them, and that is exactly why
3421
+ * asking again is worth offering.
3422
+ */
3423
+ declare function streamRecoveryLabels(phase: string): {
3424
+ note: string;
3425
+ action: string;
3426
+ };
3427
+ /**
3428
+ * The identity a streamed turn was DISPATCHED under, pinned by the caller.
3429
+ *
3430
+ * Same reason _callProviderFor takes projectId/owner explicitly: a turn can be
3431
+ * acked after the user has moved to another project or platform, and a live
3432
+ * getIdentity() read at that moment describes where the user is now, not where the
3433
+ * turn came from. Every field optional so a caller can pin what it knows and let
3434
+ * the rest fall back to the live read.
3435
+ */
3436
+ type StreamDispatchContext = {
3437
+ platform?: string;
3438
+ projectId?: string;
3439
+ owner?: string;
3440
+ /** History cache key (chatCacheKey) of the chat the turn belongs to. */
3441
+ ownerKey?: string;
3442
+ };
2562
3443
  declare class ChatSession {
2563
3444
  host: ChatHost;
2564
3445
  state: ChatState;
@@ -2803,7 +3684,7 @@ declare class ChatSession {
2803
3684
  * and they are the ones bounded by MAX_CONCURRENT_BG_POLLS, so adding probes there would spend
2804
3685
  * the request budget the cap exists to protect.
2805
3686
  */
2806
- attachForegroundPoll(source: any, itemId: string, opts?: any): any;
3687
+ attachForegroundPoll(source: any, itemId: string, opts?: any, ctx?: StreamDispatchContext): any;
2807
3688
  private _fgPollWithEarlyProbe;
2808
3689
  private _trackPoll;
2809
3690
  /** Background polls currently attached, for the MAX_CONCURRENT_BG_POLLS budget.
@@ -2812,6 +3693,282 @@ declare class ChatSession {
2812
3693
  * entry left behind by pausePolling on an older skapi-js (no stop handle)
2813
3694
  * still counts, which is correct — that poll really is still running. */
2814
3695
  private _countBgPolls;
3696
+ /** Live streams by server item id. One per in-flight streamed turn. */
3697
+ private liveStreams;
3698
+ /**
3699
+ * Open (or re-open) the live stream for `itemId`, or null when this poll must
3700
+ * not carry one.
3701
+ *
3702
+ * Re-entrant on purpose: an auth-refresh retry re-dispatches the SAME turn under
3703
+ * a NEW id, and a re-attach after a tab return replays an existing id from seq 0.
3704
+ * Either way the bytes about to arrive are a whole stream, so an existing entry
3705
+ * is discarded and a fresh parser takes its place - feeding a replay into the old
3706
+ * parser would concatenate the answer with itself.
3707
+ *
3708
+ * `ctx` IS THE TURN'S OWN IDENTITY, and every caller that has one passes it.
3709
+ * This used to read the LIVE getIdentity(), which is a bug of exactly the kind
3710
+ * _callProviderFor documents and threads its own parameters to avoid: the user
3711
+ * hits Send, then switches project or platform inside the ack round trip, and the
3712
+ * stream that opens for the OLD turn is stamped with the NEW identity. What that
3713
+ * costs is not cosmetic - `platform` picks which url csr-finalize is addressed
3714
+ * with and which extractor reads the assembled body, `projectId`/`owner` scope
3715
+ * the finalize itself, and `ownerKey` decides which chat the answer is painted
3716
+ * into. Get them from the live read at the wrong moment and the turn is finalized
3717
+ * against the wrong service (so its answer is never stored), parsed with the
3718
+ * wrong provider's extractor, or painted into a conversation it does not belong
3719
+ * to. The live read stays only as the fallback for a caller with nothing pinned.
3720
+ */
3721
+ private _beginLiveStream;
3722
+ /** The chunk sink handed to skapi's poll. Raw relayed text, in order, never parsed
3723
+ * here: the parser owns the grammar and this owns the pacing. */
3724
+ private _feedLiveStream;
3725
+ /**
3726
+ * Write the safe prefix of the answer so far into the turn's bubble.
3727
+ *
3728
+ * notify() is spent EXACTLY ONCE per turn, on the first paint, because that is a
3729
+ * state change the per-bubble refresh cannot express: the bubble stops being a
3730
+ * "Thinking..." spinner and becomes text. Every paint after it goes through
3731
+ * refreshMessageBubble, which is what keeps a growing answer from rebuilding the
3732
+ * whole display list once a second.
3733
+ */
3734
+ private _paintLiveStream;
3735
+ /** The bubble a live stream paints into: the turn's pending assistant placeholder,
3736
+ * found by server item id. Not by _localId, deliberately - a history refetch
3737
+ * replaces the local copy with the server's, and only the id survives that. */
3738
+ private _liveTargetIndex;
3739
+ /** Hand the host its optional observation update. Guarded: this runs on the paint
3740
+ * path, and a throwing hook must not cost the user the rest of their answer. */
3741
+ private _reportLiveStream;
3742
+ /** Stop painting and (when the turn really ended) assemble the body. `finished`
3743
+ * is false for a stream being discarded rather than settled: a retry replacing
3744
+ * it, or a stop, neither of which has an answer to assemble. */
3745
+ private _closeLiveStream;
3746
+ /**
3747
+ * Settle a streamed turn: end the parse, decide the body the rest of the session
3748
+ * will read, and release the chunks.
3749
+ *
3750
+ * The substitution is one-directional and never a merge. A response that is a
3751
+ * real stored body (a buffered turn, or a streamed one somebody already
3752
+ * finalized) is returned untouched, because that is the destination's own answer
3753
+ * and the stream is not entitled to overwrite it. Only a STATUS ENVELOPE - the
3754
+ * shape a streamed row settles as, having stored nothing - is replaced, and then
3755
+ * by the assembled body, which every caller downstream reads with the same
3756
+ * extractor it uses for a buffered reply. Idempotent, because it is reached both
3757
+ * through the poll's onResponse and through the promise it resolves.
3758
+ */
3759
+ private _settleLiveStream;
3760
+ /**
3761
+ * May this parse be STORED as the turn's permanent answer?
3762
+ *
3763
+ * THE FAILURE THIS PREVENTS. Finalizing does two things at once: it stores what
3764
+ * you give it as the row's result, and it DELETES the chunks it was assembled
3765
+ * from. So finalizing a truncated parse is not a cosmetic loss, it is the
3766
+ * permanent one: the truncation becomes the stored answer and the only copy of
3767
+ * the missing part is deleted in the same call. And a truncated parse is a shape
3768
+ * this repo has already paid for - a degraded chunk read (the poller degrades to
3769
+ * "no chunks this tick, more=true" on any transient chunk-table error, and caps
3770
+ * a long answer at 500k characters per response) can hand the settle a stream
3771
+ * that stopped mid-answer. The row can settle 'resolved' on top of that, because
3772
+ * the ROW's status describes the destination's request, not the client's read of
3773
+ * it.
3774
+ *
3775
+ * THE POLICY ITSELF IS mayKeepStreamedAnswer (top of this file), shared with the
3776
+ * recovery path so the two cannot drift apart again - they did, and the drift was
3777
+ * silent: the live settle refused a failed turn while the recovery finalized one.
3778
+ * What is local to this method is only the two things the free function cannot
3779
+ * know: that there is an assembled body at all, and that this call site is
3780
+ * reached only on a row that settled 'resolved' (the caller returns before it
3781
+ * otherwise), which is the status it therefore states.
3782
+ *
3783
+ * The test the policy applies is deliberately NOT `complete`: a terminal event
3784
+ * arrived and the answer finished are two claims, and an `error` frame satisfies
3785
+ * the first while truncating the second. See sse.ts's answerComplete.
3786
+ */
3787
+ private _mayFinalize;
3788
+ /**
3789
+ * Store the assembled body as the version history keeps, which is also what
3790
+ * releases this request's chunks.
3791
+ *
3792
+ * The ASSEMBLED BODY and not the extracted text, because the row is read back by
3793
+ * mapHistoryListToMessages through extractClaudeText / extractOpenAIText: storing
3794
+ * the provider's own document is what makes a streamed turn indistinguishable
3795
+ * from a buffered one on the next load, with no branch anywhere in the mapper.
3796
+ *
3797
+ * BEST EFFORT, and loudly so: the answer is already on screen and already in the
3798
+ * history cache by the time this fires. A failure costs the chunks (they stay,
3799
+ * and the turn stays re-readable) and a row that reads back empty, never the
3800
+ * user's answer in front of them.
3801
+ *
3802
+ * WHAT IS DELIBERATELY NEVER FINALIZED, because finalize is also the only way to
3803
+ * release chunks and it is tempting to reach for it as a cleanup:
3804
+ *
3805
+ * - an INCOMPLETE parse (see _mayFinalize). Storing a truncation makes it
3806
+ * permanent AND deletes the part that was missing from it. A stream killed by
3807
+ * an `error` frame is one of these however terminal it looks: the frame ends
3808
+ * the stream, so `complete` is true, while the text is only what arrived
3809
+ * before the error. That is why the gate reads answerComplete.
3810
+ * - a FAILED turn. Its chunks hold the part of the answer that did arrive,
3811
+ * which is the only copy of that text there is, and the two ways to release
3812
+ * them both cost something real: storing the partial makes a truncated answer
3813
+ * the turn's permanent history AND masks the failure on read (csr-poll hands
3814
+ * back a finalized body before it ever looks at the row's error, so the turn
3815
+ * would read back as a clean short answer), while storing the error throws
3816
+ * the partial away outright. Keeping them costs storage on rows that produced
3817
+ * bytes and then failed, which is rare - a failure before the first byte (a
3818
+ * wrong API key, the common case) has no chunks to keep - and the poller
3819
+ * hands those chunks back alongside the error on every later read, so nothing
3820
+ * is stranded, only retained. Retention is the honest trade here; deletion is
3821
+ * not reversible.
3822
+ * - a CANCELLED turn, for the same reason plus one: the user's Stop means the
3823
+ * half answer is to be discarded, so writing it into history as the kept
3824
+ * version would resurrect exactly what the stop was for.
3825
+ */
3826
+ private _finalizeStreamedTurn;
3827
+ /** Painted-but-unsettled live text on a bubble, for the typewriter to resume from.
3828
+ * A pending assistant placeholder is created with content '' by every path that
3829
+ * makes one, so non-empty content on one can only have been painted here. */
3830
+ private _paintedTextAt;
3831
+ private _streamRecovery?;
3832
+ /** The recovery bookkeeping, created on first touch.
3833
+ *
3834
+ * LAZY, not constructor-initialised, and for a concrete reason: ChatSession is
3835
+ * also built with Object.create(ChatSession.prototype) by the engine's own test
3836
+ * harnesses, which drive one method against a hand-built state rather than a
3837
+ * whole session. A field only the constructor creates is undefined there, and
3838
+ * the method that reaches for it throws, turning a test of the settle into a
3839
+ * crash about bookkeeping. */
3840
+ private _rec;
3841
+ /**
3842
+ * Put this session's fetching state onto the turn's bubble, so a view can tell a
3843
+ * loader that means something from one that means nothing.
3844
+ *
3845
+ * ONLY EVER ONTO A STILL-MARKED BUBBLE. Once `_streamPending` is off the turn has
3846
+ * an answer (or was proven to have none) and this says nothing about it; writing
3847
+ * it there would leave a stale 'active' on a settled bubble forever.
3848
+ *
3849
+ * host.notify() is what redraws the widget, whose renderer is imperative. It is a
3850
+ * no-op in agent.vue, whose state is a Vue reactive() - the property write above
3851
+ * is what redraws there. Both are covered by doing both, and neither is a
3852
+ * substitute for the other.
3853
+ */
3854
+ private _markRecoveryPhase;
3855
+ /**
3856
+ * Let LOCAL answers survive a freshly-mapped page whose copies of them are
3857
+ * authoritative-but-empty. Call with the page BEFORE it replaces or merges into
3858
+ * state.messages; mutates the page's bubbles in place.
3859
+ *
3860
+ * The adoption itself is history.ts's adoptLocalAnswerIntoPage (shared, so the
3861
+ * clients' own mappers cannot fork it). What lives here is the one thing the
3862
+ * pure function cannot know: whether the local text is the WHOLE answer. Text
3863
+ * left by a stream that ended without a terminal event is not, so that bubble
3864
+ * keeps its marker and gets read back even though it has content - otherwise a
3865
+ * truncated answer would adopt itself over the row and never be corrected.
3866
+ */
3867
+ private _adoptLocalAnswers;
3868
+ /**
3869
+ * This session's fetching state for one turn, from the bookkeeping rather than
3870
+ * from any bubble. A queued entry counts as 'active': it is committed to be read,
3871
+ * serially, and the reader has no way to tell "being read" from "next in line"
3872
+ * apart from the wait.
3873
+ */
3874
+ private _recoveryPhaseFor;
3875
+ /**
3876
+ * PUBLIC DELEGATE, for a client that maps and merges its own history page.
3877
+ *
3878
+ * agent.vue keeps a forked mapper and a forked first-page merge (its mount path
3879
+ * runs them, while resumePolling routes through loadHistory below), so both
3880
+ * paths are live for the SAME row inside one component. Adoption is part of the
3881
+ * merge contract, not an optional extra: without it that fork erases a streamed
3882
+ * answer off the screen on every turn, which is the whole of MAJOR 3.
3883
+ *
3884
+ * Exposed rather than reimplemented because the rule needs the session's own
3885
+ * `incomplete` set, which the pure helper (history.ts adoptLocalAnswerIntoPage)
3886
+ * cannot see. A client that reached for the helper alone would adopt a TRUNCATED
3887
+ * answer over the row and clear the marker that would have gone back for the
3888
+ * rest - a fork that reads as correct and loses text.
3889
+ *
3890
+ * Call it exactly where loadHistory does: on the freshly mapped page, after
3891
+ * applyHydratedBodies and BEFORE the page replaces or merges into state.messages.
3892
+ */
3893
+ adoptLocalAnswers(mapped: ChatMessage[], loadKey?: string): void;
3894
+ /**
3895
+ * Queue the on-screen turns whose answer is only in the chunk store, newest
3896
+ * first, and start draining. Never blocks and never throws.
3897
+ *
3898
+ * `ownerKey` is the chat the queue entries belong to, snapshotted by the caller:
3899
+ * a recovery that lands after the user has moved on writes into that chat's
3900
+ * cache, never into whatever list is on screen by then.
3901
+ */
3902
+ private _scheduleStreamRecovery;
3903
+ /**
3904
+ * PUBLIC DELEGATE, the other half of what a forked history path needs.
3905
+ *
3906
+ * Same reason as adoptLocalAnswers: agent.vue's mount path never calls
3907
+ * loadHistory, so without this its pages would MARK unfinalized streamed turns
3908
+ * and then never read them back - CRITICAL 1 left unfixed on the client's
3909
+ * primary path, with the marker making it look handled.
3910
+ *
3911
+ * Takes the load's SNAPSHOTTED identity rather than reading it live, and that is
3912
+ * the reason this exists instead of the caller looping over recoverStreamedAnswer:
3913
+ * that one reads getIdentity() at call time (right, for an on-demand affordance
3914
+ * the user just clicked), which after a project switch racing the load would
3915
+ * finalize the turn against the project they switched TO. Call it AFTER the page
3916
+ * is rendered and the loading flags are cleared - it must never hold up the
3917
+ * conversation it belongs to.
3918
+ */
3919
+ scheduleStreamRecovery(ownerKey: string, platform: 'claude' | 'openai', projectId: string, owner: string): void;
3920
+ /** Serial drain of the recovery queue. Each entry is one full chunk read. */
3921
+ private _drainStreamRecovery;
3922
+ /**
3923
+ * Read one unfinalized streamed turn back out of the chunk store and put its
3924
+ * answer where the turn's answer belongs.
3925
+ *
3926
+ * Public because the cap above is deliberately small: a host that wants to offer
3927
+ * "load the rest" on an older recoverable turn calls this with its
3928
+ * `_serverItemId`, and gets the same path the automatic recovery uses. Safe to
3929
+ * call for an id that turns out not to be recoverable, and safe to call twice -
3930
+ * a second call while the first is still in flight is a no-op.
3931
+ *
3932
+ * THIS IS THE USER ASKING, and that is why it passes `manual`. The automatic
3933
+ * recovery refuses a row it has already tried, so that a re-render, or the
3934
+ * history load that every visibilitychange fires, cannot loop on the same
3935
+ * chunks. A click is neither of those: it is one bounded request that a person
3936
+ * asked for, and applying the loop guard to it made the affordance a button that
3937
+ * silently did nothing for exactly the rows most likely to have it - every row
3938
+ * an earlier read touched and could not settle.
3939
+ */
3940
+ recoverStreamedAnswer(itemId: string): Promise<void>;
3941
+ private _readBackStreamedTurn;
3942
+ /**
3943
+ * Write a recovered answer into the turn's bubble (or into the owning chat's
3944
+ * cache when the reader has moved on), then store it as the version history
3945
+ * keeps.
3946
+ *
3947
+ * FINALIZING IS WHAT MAKES THIS RUN ONCE. It copies the answer onto the row and
3948
+ * releases the chunks, so the next load reads an ordinary turn and no recovery is
3949
+ * scheduled for it ever again, by anyone, in any tab. `store` is the caller's
3950
+ * decision and carries two gates at once: mayKeepStreamedAnswer, the SAME keep
3951
+ * policy the live settle applies (an incomplete, errored or failed read is shown
3952
+ * but never stored, because storing it would make the truncation permanent and
3953
+ * delete the part that was missing), and whether the body is new at all (one
3954
+ * that came off the row is already stored).
3955
+ */
3956
+ private _applyRecoveredAnswer;
3957
+ /**
3958
+ * Take the "answer is elsewhere" marker off a turn once it is settled one way or
3959
+ * the other. `drop` removes an assistant bubble that turned out to have no answer
3960
+ * at all, which restores exactly the list the mapper used to produce for such a
3961
+ * row (none), rather than leaving a permanently empty bubble behind.
3962
+ *
3963
+ * ONLY EVER CALLED FOR A TURN THAT WAS ACTUALLY READ. The marker is the one thing
3964
+ * that keeps an unrecovered answer reachable, so it comes off only on the strength
3965
+ * of an answer (the recovery wrote one) or of a read that came back empty. A read
3966
+ * that FAILED, or one that was STOPPED, knows neither, and taking the marker off
3967
+ * on either of those is how a bubble ends up empty forever with its answer still
3968
+ * in the chunk table. `drop` is likewise never passed for a bubble that HAS
3969
+ * content: an empty row is an empty turn, a failed read is not.
3970
+ */
3971
+ private _clearStreamPendingMark;
2815
3972
  /**
2816
3973
  * Stop and forget one item's poll. Used after a cancel: the row is either gone
2817
3974
  * (cancelled while queued) or flagged cancelled (cancelled while running), so
@@ -3087,9 +4244,9 @@ declare class ChatSession {
3087
4244
  * work, it does not undo it.
3088
4245
  */
3089
4246
  cancelIndexingGroup(group: IndexingGroup): void;
3090
- typewriteIntoIndex(idx: number, fullText: string, localId?: string): Promise<void>;
4247
+ typewriteIntoIndex(idx: number, fullText: string, localId?: string, paintedText?: string): Promise<void>;
3091
4248
  private typewriterQueue;
3092
- enqueueTypewrite(idx: number, fullText: string, localId?: string): Promise<any>;
4249
+ enqueueTypewrite(idx: number, fullText: string, localId?: string, paintedText?: string): Promise<any>;
3093
4250
  typewriteLatestReply(key: string): Promise<any>;
3094
4251
  _removeStrayPendingAssistants(): void;
3095
4252
  /** Index of the USER bubble the message at `idx` belongs to — the nearest one
@@ -3264,4 +4421,171 @@ declare class ChatSession {
3264
4421
  bumpGate(): void;
3265
4422
  }
3266
4423
 
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 };
4424
+ /**
4425
+ * The project's BunnyQuery settings, held as a record in the project's own
4426
+ * database rather than on the skapi service record.
4427
+ *
4428
+ * WHY A RECORD. The upload access group used to live on the service record as
4429
+ * `default_access_group`, which was ALSO the skapi SDK's project-wide default
4430
+ * for `table.access_group`. One field meant two things: "what BunnyQuery indexes
4431
+ * new files at" and "what every SDK record call on this project defaults to".
4432
+ * That coupling is gone. The SDK no longer has a project default at all, so this
4433
+ * setting needs a home of its own, and a plain public record in the customer's
4434
+ * own project is one every client can already reach with the calls it has.
4435
+ *
4436
+ * SHAPE. One record per project, holding an OBJECT rather than a single value:
4437
+ *
4438
+ * unique_id: 'bq::settings'
4439
+ * table: { name: '__SETTINGS__', access_group: 'public' }
4440
+ * data: { upload_access_group: 'authorized' }
4441
+ *
4442
+ * One record and one fetch covers every present and future project setting. A
4443
+ * second setting is a new key, not a new record, so the "wait for settings
4444
+ * before the first upload" hand-off below never has to become several waits.
4445
+ *
4446
+ * WHY PUBLIC. The widget reads this, and the widget frequently runs before there
4447
+ * is any session. Group 0 is the only group an unauthenticated caller is served
4448
+ * (`check_rec_access` returns immediately for "00" and refuses the rest). Note
4449
+ * this is NOT sufficient on its own: skapi's `require_login` gate refuses ALL
4450
+ * database reads from a signed-out visitor, and it defaults to true, so on most
4451
+ * projects a signed-out widget still cannot read this and falls back to the
4452
+ * default. That is survivable because the only thing a signed-out visitor could
4453
+ * do with the value is upload, which they cannot do either.
4454
+ *
4455
+ * WHY THE VALUE MATTERS. The file BYTES are not what the access group controls.
4456
+ * BunnyQuery uploads to db storage, whose object key carries no access group and
4457
+ * whose read path performs no access check. What carries the group is the
4458
+ * RECORDS: the `src::` file record in `file_summaries`, the `run::`/`done::`
4459
+ * markers in `__INDEXING__`, and every content record the indexing agent
4460
+ * extracts. Those are what a chat answers from, so those are what decide who the
4461
+ * file is visible to. The same value is also handed to the chat system prompt as
4462
+ * `indexAccessGroup`, because a record written under a different group is in a
4463
+ * different table and never comes back with the rest of the file.
4464
+ *
4465
+ * TRANSPORT-FREE, like the rest of the engine. The store never imports a skapi
4466
+ * instance; the consumer injects a reader. See configureProjectSettings.
4467
+ */
4468
+ /** The access groups a BunnyQuery upload may be recorded at. */
4469
+ type UploadAccessGroup = 'public' | 'authorized' | 'private';
4470
+ declare const UPLOAD_ACCESS_GROUPS: UploadAccessGroup[];
4471
+ /**
4472
+ * What the project's upload-access setting may be: one of the three groups the
4473
+ * dashboard offers, or 'ask' to be prompted per upload.
4474
+ *
4475
+ * `'admin'` (99) is deliberately not offered: a file only a master can read is
4476
+ * indistinguishable from one that failed to upload, and no dashboard control
4477
+ * would produce it.
4478
+ */
4479
+ type ProjectAccessSetting = UploadAccessGroup | 'ask';
4480
+ /**
4481
+ * `authorized` is the default because it is what every record written before
4482
+ * this setting existed was hardcoded to. A project that never opens the setting
4483
+ * keeps exactly the visibility it already had. It is also what the abandoned
4484
+ * `default_access_group` service field was seeded to at project creation, so a
4485
+ * project carrying that old value reads the same before and after the move.
4486
+ */
4487
+ declare const DEFAULT_UPLOAD_ACCESS_GROUP: UploadAccessGroup;
4488
+ /** Where the settings record lives. Shared so no client re-derives it. */
4489
+ declare const PROJECT_SETTINGS_TABLE = "__SETTINGS__";
4490
+ declare const PROJECT_SETTINGS_UNIQUE_ID = "bq::settings";
4491
+ declare const PROJECT_SETTINGS_ACCESS_GROUP = "public";
4492
+ declare const UPLOAD_ACCESS_LABELS: Record<UploadAccessGroup, string>;
4493
+ declare const UPLOAD_ACCESS_HINTS: Record<UploadAccessGroup, string>;
4494
+ /** Menu/modal option list, in the order they should be shown. */
4495
+ declare const UPLOAD_ACCESS_OPTIONS: {
4496
+ value: UploadAccessGroup;
4497
+ label: string;
4498
+ hint: string;
4499
+ }[];
4500
+ /** The settings record's `data`. Open-ended: future settings are new keys. */
4501
+ type ProjectSettingsData = {
4502
+ upload_access_group?: unknown;
4503
+ [key: string]: unknown;
4504
+ };
4505
+ /** Narrow an unknown stored value to a usable group, falling back to the default. */
4506
+ declare function normalizeUploadAccessGroup(value: any): UploadAccessGroup;
4507
+ /**
4508
+ * The stored setting as written, or null when the project has never set one.
4509
+ *
4510
+ * Returns null rather than a default so callers can tell "unset" from "set to
4511
+ * authorized". The settings page needs that distinction to decide what the
4512
+ * control shows; upload paths do not and use uploadAccessGroupFrom instead.
4513
+ */
4514
+ declare function normalizeProjectAccessSetting(value: any): ProjectAccessSetting | null;
4515
+ /** The setting held in a settings-record `data`, or null when unset. */
4516
+ declare function accessSettingFrom(data: ProjectSettingsData | null | undefined): ProjectAccessSetting | null;
4517
+ /** The group an upload lands in when the project is NOT set to 'ask'. */
4518
+ declare function uploadAccessGroupFrom(data: ProjectSettingsData | null | undefined): UploadAccessGroup;
4519
+ /** True when the project wants to be asked per upload rather than told once. */
4520
+ declare function asksUploadAccessFrom(data: ProjectSettingsData | null | undefined): boolean;
4521
+ /**
4522
+ * Fetch one project's settings record. Resolves the record's `data`, or null
4523
+ * when there is no record.
4524
+ *
4525
+ * MAY REJECT, and the store treats a rejection as "no record": a signed-out
4526
+ * visitor on a `require_login` project gets REQUIRE_LOGIN here, which is a
4527
+ * normal outcome and not an error the user should ever see.
4528
+ */
4529
+ type ProjectSettingsReader = (service: string) => Promise<ProjectSettingsData | null>;
4530
+ declare function configureProjectSettings(fn: ProjectSettingsReader | null): void;
4531
+ /**
4532
+ * Start the fetch and hand back the promise, deduping concurrent callers.
4533
+ *
4534
+ * Never rejects: a failed read settles as null, which every accessor reads as
4535
+ * "unset" and answers with the default. A settings fetch must not be able to
4536
+ * fail an upload.
4537
+ */
4538
+ declare function loadProjectSettings(service: string): Promise<ProjectSettingsData | null>;
4539
+ /**
4540
+ * Kick the fetch off without waiting for it. Call on chat/page open.
4541
+ *
4542
+ * Fire-and-forget by design: the page paints on the default and the first upload
4543
+ * awaits the real value via readyProjectSettings. Nothing blocks on this.
4544
+ */
4545
+ declare function primeProjectSettings(service: string): void;
4546
+ /**
4547
+ * Await the settings for this project. What the FIRST upload calls.
4548
+ *
4549
+ * Cheap after the first call: a settled entry resolves immediately, and a
4550
+ * primed-but-unsettled one joins the in-flight request rather than starting a
4551
+ * second.
4552
+ */
4553
+ declare function readyProjectSettings(service: string): Promise<ProjectSettingsData | null>;
4554
+ /**
4555
+ * The cached data WITHOUT waiting, or null when nothing has settled yet.
4556
+ *
4557
+ * For synchronous readers (a template, a menu's current value). A caller that is
4558
+ * about to WRITE an access group onto a record must use readyProjectSettings
4559
+ * instead: answering from an unsettled cache is how a file lands in the wrong
4560
+ * group on the first upload after a page load.
4561
+ */
4562
+ declare function cachedProjectSettings(service: string): ProjectSettingsData | null;
4563
+ /** True once this project's settings have been fetched (whether or not one existed). */
4564
+ declare function projectSettingsSettled(service: string): boolean;
4565
+ /** Sync convenience: the project's setting as stored, or null when unset/unsettled. */
4566
+ declare function projectAccessSetting(service: string): ProjectAccessSetting | null;
4567
+ /** Sync convenience: the upload group, falling back to the default. */
4568
+ declare function projectUploadAccessGroup(service: string): UploadAccessGroup;
4569
+ /** Sync convenience: does this project want a per-upload prompt? */
4570
+ declare function projectAsksUploadAccess(service: string): boolean;
4571
+ /**
4572
+ * Adopt a value the caller just WROTE, so the settings page reflects its own
4573
+ * save without a re-fetch.
4574
+ *
4575
+ * Marks the entry settled: the writer knows the stored value better than a
4576
+ * refetch would, and leaving it unsettled would send the next upload back to the
4577
+ * network for a value already in hand.
4578
+ */
4579
+ declare function setProjectSettings(service: string, data: ProjectSettingsData | null): void;
4580
+ /** Merge one key into the cached settings, preserving the rest. */
4581
+ declare function patchProjectSettings(service: string, patch: ProjectSettingsData): void;
4582
+ /**
4583
+ * Drop cached settings. Pass a service to drop one, omit to drop all.
4584
+ *
4585
+ * An in-flight fetch is abandoned rather than cancelled: its `.then` checks that
4586
+ * the entry it is writing into is still its own, so a late response cannot
4587
+ * repopulate a cleared project.
4588
+ */
4589
+ declare function clearProjectSettings(service?: string): void;
4590
+
4591
+ 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, INDEXING_MAX_OUTPUT_TOKENS, 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, isImageVisionFile, isIndexingRequestText, isLinkUnavailable, isNonRetryableRequestError, isOfficeFile, isPagedReadFile, isPreviewableImagePath, isProviderApiKeyError, isServerExtractable, isServiceDbAttachmentHref, isWindowedReadFile, 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 };