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/README.md +60 -22
- package/bunnyquery.css +53 -2
- package/bunnyquery.js +1880 -197
- package/dist/engine.cjs +1806 -133
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +1335 -11
- package/dist/engine.d.ts +1335 -11
- package/dist/engine.mjs +1765 -134
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/budget.ts +46 -2
- package/src/engine/config.ts +243 -0
- package/src/engine/errors.ts +87 -1
- package/src/engine/history.ts +113 -2
- package/src/engine/host.ts +85 -0
- package/src/engine/index.ts +111 -2
- package/src/engine/office.ts +16 -4
- package/src/engine/project_settings.ts +303 -0
- package/src/engine/prompts/chat_system_prompt.ts +21 -6
- package/src/engine/prompts/indexing_system_prompt.ts +8 -4
- package/src/engine/prompts/indexing_user_message.ts +39 -3
- package/src/engine/requests.ts +203 -8
- package/src/engine/session.ts +1553 -29
- package/src/engine/sse.ts +1054 -0
- package/src/widget.css +21 -2
- package/styles/chat.css +32 -0
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)
|
|
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
|
|
284
|
-
* project's `
|
|
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
|
|
359
|
-
*
|
|
360
|
-
*
|
|
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
|
|
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
|
-
|
|
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 };
|