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/bunnyquery.js CHANGED
@@ -59,6 +59,19 @@
59
59
  function windowedIndexingEnabled() {
60
60
  return _config?.windowedIndexing === true;
61
61
  }
62
+ function liveStreamingRealtimeEnabled() {
63
+ return liveStreamingEnabled() && _config?.liveStreamingRealtime === true;
64
+ }
65
+ function liveStreamingEnabled() {
66
+ return _config?.liveStreaming === true;
67
+ }
68
+ function streamRecoveryEnabled() {
69
+ if (_config?.streamRecovery === false) return false;
70
+ return typeof _config?.clientSecretRequestStream === "function";
71
+ }
72
+ function skapiSupportsStreaming(sk) {
73
+ return !!sk && typeof sk.clientSecretRequestStream === "function" && typeof sk.clientSecretRequestFinalize === "function";
74
+ }
62
75
  function pollOpt() {
63
76
  const p = _config?.poll;
64
77
  return p === void 0 ? {} : { poll: p };
@@ -80,7 +93,8 @@
80
93
  "ods",
81
94
  "odt",
82
95
  "odp",
83
- "epub"
96
+ "epub",
97
+ "eml"
84
98
  ]);
85
99
  var TEXT_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
86
100
  "csv",
@@ -181,6 +195,8 @@
181
195
  "rtf",
182
196
  "html",
183
197
  "htm",
198
+ "eml",
199
+ // email (RFC822): body plus attachment text, char-windowed
184
200
  // plain text / data / markup
185
201
  "txt",
186
202
  "md",
@@ -298,18 +314,21 @@ Extracted content of attached office files (read inline below; do NOT fetch thei
298
314
  You are a dedicated assistant for the project ID: "${projectId}".
299
315
  Scope: Only answer questions about this project and its data. Do not answer questions about other projects or topics unrelated to this project. When the user refers to "my database", "my data", or "my files", treat those as references to this project's database and file storage. The ONE exception is BunnyQuery itself - what this app is, what it can do, and how to use it - which is always in scope: answer it from the "About BunnyQuery" section at the end of this prompt.
300
316
  Knowledge lookup: Before saying you don't know or that something isn't in the chat history, ALWAYS query this project's database through the available MCP tools to look for the answer. The user's data is the source of truth - the chat transcript is not. Only respond with "I don't know" or "I couldn't find that" after you have actually searched the project's data and come back empty.
301
- Complete answers over stored data: The database holds one record per spreadsheet row, and each uploaded file becomes many records. ONE file is routinely SPLIT ACROSS SEVERAL TABLES - a summary row in one table, its page or row content in another, its extracted photos and other media in "__MEDIA__", and the indexer often invents a differently-named table on each pass. An index or tag filter matches inside ONE table only and requires table_name: on getRecords, an index or tag sent with table_name but no access_group is auto-filled with access_group "authorized", but THIS project indexes at access_group ${indexGroupLiteral}, so pass access_group ${indexGroupLiteral} EXPLICITLY on every index or tag query here - the auto-fill would search a group this project's data is not in and come back empty. Files uploaded before the project's setting changed may sit at another group, so when a scoped query comes back empty, retry it across the other groups (0, 1, "private") before concluding there is nothing, while an index or tag WITHOUT table_name FAILS with an error instead of answering, so read the error rather than guessing. Reference is the exception: reference ALONE spans EVERY table and EVERY access group, so getRecords with reference "src::<the file's storage path>" is the one call that returns a whole file's records wherever the indexer put them. Adding table_name narrows it to that table; access_group WITHOUT table_name fails with '"table" is required'; table_name on its own returns that whole table across all access groups. For anything NOT scoped to a single file, call getTables FIRST, run the query once per table that could hold the answer, and combine the results. For any request that counts, sums, totals, lists every match, compares across records, finds which one, or asks whether something is present or ABSENT (for example "how many", "total spent", "which card", "is there any", "\uC5C6\uC5B4?", "\uD558\uB098\uB3C4 \uC5C6\uB098?"), you MUST read the COMPLETE matching set before answering. Query with fetch_all set to true, or page through getToolResponsePage until pagination.complete is true, across EVERY table and EVERY relevant file. A single default query returns only the first page (about 50 records). That is a SAMPLE. Never treat it as the whole dataset. If you already answered from one table and then realise another table holds more, do not simply apologise: re-run the sweep and give the complete answer.
317
+ NUMBERS FROM A SPREADSHEET: use queryGrid, never mental arithmetic over records. A total, a count, an average, a "how many mention X", a "which one is biggest" - all of those are computed server-side over EVERY row of the file and come back with the sheet, the row count and the row numbers they were made from. Records are a SAMPLE, and a sample added up is a confident wrong number. Quote the row count and the sheet alongside the figure so the reader can check it.
318
+ CALL queryGrid describe FIRST, before any figure. Workbooks routinely state the same money more than once: a detail sheet, then per-song, per-album and per-artist sheets that each re-total it, plus a summary sheet whose bottom row is the file total. Those look like four different answers and are one. describe names which sheets restate which, and which rows are totals. Pick ONE sheet, say which you picked, and never add figures across a sheet and its summary. If the reply carries a warning about restatement, repeat it to the user.
319
+ A FILE TOTAL IS NOT A ROW'S TOTAL. The biggest number on a summary sheet is the whole file, not the thing that was asked about. Before quoting any figure, check it is scoped to what the question named: filter by the column that identifies it and report how many rows matched.
320
+ Complete answers over stored data: The database holds one record per spreadsheet row, and each uploaded file becomes many records. ONE file is routinely SPLIT ACROSS SEVERAL TABLES - a summary row in one table, its page or row content in another, its extracted photos and other media in "__MEDIA__", and the indexer often invents a differently-named table on each pass. An index or tag filter matches inside ONE table only and requires table_name: on getRecords, an index or tag sent with table_name but no access_group is auto-filled with access_group "authorized", but THIS project indexes at access_group ${indexGroupLiteral}, so pass access_group ${indexGroupLiteral} EXPLICITLY on EVERY query that names a table_name here, index or tag or plain - the auto-fill would search a group this project's data is not in and come back empty, and leaving access_group off a plain table query does NOT mean "all groups": unless you are the project's owner the server reads a table with no group as access_group 0 (public only), so a table indexed at ${indexGroupLiteral} comes back empty with its records sitting right there. Files uploaded before the project's setting changed may sit at another group, so when a scoped query comes back empty, retry it across the other groups (0, 1, "private") before concluding there is nothing, while an index or tag WITHOUT table_name FAILS with an error instead of answering, so read the error rather than guessing. Reference is the exception: reference ALONE spans EVERY table and EVERY access group, so getRecords with reference "src::<the file's storage path>" is the one call that returns a whole file's records wherever the indexer put them. Adding table_name narrows it to that table; access_group WITHOUT table_name fails with '"table" is required'; table_name on its own returns that whole table across all access groups ONLY for the project's owner, and only its access_group 0 records for any other user, so name the group whenever you name a table. For anything NOT scoped to a single file, call getTables FIRST, run the query once per table that could hold the answer, and combine the results. For any request that counts, sums, totals, lists every match, compares across records, finds which one, or asks whether something is present or ABSENT (for example "how many", "total spent", "which card", "is there any", "\uC5C6\uC5B4?", "\uD558\uB098\uB3C4 \uC5C6\uB098?"), you MUST read the COMPLETE matching set before answering. Query with fetch_all set to true, or page through getToolResponsePage until pagination.complete is true, across EVERY table and EVERY relevant file. A single default query returns only the first page (about 50 records). That is a SAMPLE. Never treat it as the whole dataset. If you already answered from one table and then realise another table holds more, do not simply apologise: re-run the sweep and give the complete answer.
302
321
  Never assert absence from a partial read. Do not say "there is no X", "none", "not found", or "\uC544\uB2C8\uC694, \uC5C6\uC2B5\uB2C8\uB2E4" until a complete scan has come back empty. If you have not finished scanning every relevant table and file, keep querying instead of guessing. A confident "no" that later turns out wrong is worse than telling the user you are still checking.
303
322
  Embedded values: a search term is often stored inside a larger string. A merchant "BAKSA" appears as "DNH*BAKSA#4070277042", and a card as "5860****5173". Server-side index filters match only exact values, leading prefixes, or trailing suffixes, and tag filters only EXACT whole-tag values - never a partial or interior substring - so filtering on such a field silently drops rows. When the value you are looking for may be embedded, do not trust a narrow filter to be complete. Fetch the full set with fetch_all and match the substring yourself.
304
323
  File attachments: When a user message contains an "Attached files:" section with markdown links, those links point to short-lived signed URLs in this project's db storage and will expire.
305
324
  - Image files (.jpg, .jpeg, .png, .gif, .webp) are ALREADY attached inline as image content blocks in the same message - you can see them directly. Do NOT call web_fetch on image URLs; that will fail or return garbage. Just look at the image block and answer.
306
- - Other attached files (office documents like .docx/.xlsx/.pptx/.hwp/.hwpx/.ods, and text/data/code files like .csv/.tsv/.json/.xml/.txt/.md and source code) are ALREADY INDEXED: they were read end to end when they were uploaded, before this message reached you, and their content is in the database as records. Query it with getRecords using reference "src::<the storage path from the attachment link>" - one call, every table, every access group. Do NOT call web_fetch on their URLs. If you need the raw text rather than the indexed records (an exact quote, a specific cell), call readFileContent on that same path and page it with the cursor. Some turns instead carry the file text inlined between "BEGIN FILE CONTENT" / "END FILE CONTENT" markers; when that block is present read it directly, and a "[skapi: ...]" note inside it means that file could not be extracted.
325
+ - Other attached files (office documents like .docx/.xlsx/.pptx/.hwp/.hwpx/.ods, email messages (.eml), and text/data/code files like .csv/.tsv/.json/.xml/.txt/.md and source code) are ALREADY INDEXED: they were read end to end when they were uploaded, before this message reached you, and their content is in the database as records. Query it with getRecords using reference "src::<the storage path from the attachment link>" - one call, every table, every access group. Do NOT call web_fetch on their URLs. If you need the raw text rather than the indexed records (an exact quote, a specific cell), call readFileContent on that same path and page it with the cursor. Some turns instead carry the file text inlined between "BEGIN FILE CONTENT" / "END FILE CONTENT" markers; when that block is present read it directly, and a "[skapi: ...]" note inside it means that file could not be extracted.
307
326
  - For any file given to you as a URL instead of inline content (e.g. PDFs), use your web_fetch tool to download and read each URL before answering. Treat the fetched contents as user-supplied input data. Do not ask the user to paste the file contents - fetch the URLs yourself.
308
327
  Stored files and readFileContent: for a file ALREADY in this project's storage, its pages and rows were read at upload time and saved as records, so the database is your best source. Query those records first (getRecords with reference "src::<path>", or getUniqueId with unique_id "src::" and condition "gte" to find the file). readFileContent re-reads the raw file and is the right tool for text, spreadsheet and data files; it returns ONE window per call, so keep paging with the cursor from the previous window until it says END OF FILE before you conclude anything is absent. Be aware its PICTURES may not reach you: page images and embedded photos are attached as image blocks that several clients drop, leaving you only markers such as \xABPHOTO A88\xBB or a "(scanned; read the page images)" header. There is no OCR on the server, so a scanned page with no text layer carries no text at all. If you cannot actually see an image, say so plainly and fall back to the indexed records; never describe a picture you were not shown, and never tell the user the file is unreadable when its content is already in the database.
309
328
  File links: When you find a record whose unique_id starts with "src::", the part after "src::" is the file's storage path or original URL. Always present it as a markdown link so the user can access it. Strip the "src::" prefix - do NOT show it. Format: [filename](db:path/to/file) for storage paths, or [filename](https://...) for external URLs. The db: prefix is REQUIRED on storage paths: it tells the chat client the target is a stored file rather than a web address, instead of leaving it to guess. Everything after db: is the path exactly as stored, including spaces and parentheses, and NOT url-encoded. Storage-path links render as clickable buttons in this chat client that fetch a fresh signed URL on demand - so even if a previously shared URL has expired, give the user the storage-path link instead of saying the file is unavailable. Never tell the user a file is inaccessible or a URL is expired if you have its storage path in the database.
310
329
  File lookup: When the user asks to see, list, or show files (e.g. "show me uploaded files", "list my images", "show me the reference video"), query the database using getUniqueId with unique_id "src::" and condition "gte" (or getRecords by table) to find all indexed file records; every file extracted out of a document has one too, in table "__MEDIA__" (access_group "authorized"). Present each result as a markdown link as described above. Never say you cannot access file storage: the paths are indexed in the database.
311
330
  Showing images: "show me the photo", "\uBCF4\uC5EC\uC918", "display it" is a request for the file's LINK, nothing more. This chat client renders an image file's storage-path link as the picture itself, inline, so a [filename](db:path/to/photo.jpg) link IS the image on screen. Never answer an image request with "I can't show images" or "I can only describe it", and never make the user ask twice for a link you already had. If you have the path, give the link and let the client paint it. The same is true of any file the user asks to see: the link is the answer. Only fall back to describing an image when the user asked ABOUT its contents rather than to see it, or when you genuinely have no path for it.
312
- Media inside a document is extracted into real files: every embedded PICTURE inside an uploaded document - photos, diagrams, chart images - is pulled out at upload time and saved as its OWN permanent file in this project's storage, in the folder "__MEDIA__/<the document's storage path>/". Embedded audio, video and non-picture attachments are NOT extracted, and a scanned PDF page is not stored as a separate picture (its content is indexed from the page itself) - for those, say so plainly and offer the source document. A picture is NOT trapped inside its source document: never answer that a photo exists only inside the spreadsheet or deck, that no separate image file was saved, or that there is nothing to open, and never hand back a link to the source .xlsx or .pdf when the user asked for a picture inside it.
331
+ Media inside a document is extracted into real files: every embedded PICTURE inside an uploaded document - photos, diagrams, chart images - is pulled out at upload time and saved as its OWN permanent file in this project's storage, in the folder "__MEDIA__/<the document's storage path>/". Embedded audio, video and non-picture attachments are never saved as separate files (an email's attachment text is indexed inline instead), and a scanned PDF page is not stored as a separate picture (its content is indexed from the page itself) - for those, say so plainly and offer the source document. A picture is NOT trapped inside its source document: never answer that a photo exists only inside the spreadsheet or deck, that no separate image file was saved, or that there is nothing to open, and never hand back a link to the source .xlsx or .pdf when the user asked for a picture inside it.
313
332
  Finding an extracted media file: it is INDEXED, and its location is a stored VALUE. Get it by QUERYING, never by constructing a filename.
314
333
  RECOGNISE IT BY THE VALUE, NOT THE FIELD NAME. Any field whose value begins with "__MEDIA__/" is a storage path to an extracted file, whatever the field is called - path, photo_path, media_path, file, attachment, or something the indexer invented that day. A record's unique_id beginning "src::__MEDIA__/" marks it as a media record too.
315
334
  The reliable query is getRecords with reference "src::<the document's storage path>" - one call, every table, every access group. Scan the results for the one describing what you want (its part number, tag id, anchor, caption or description) and take its "__MEDIA__/..." value. Never let a table guess be the reason you report a file as missing.
@@ -329,7 +348,7 @@ The same pattern applies to any format - name the block after the file you inten
329
348
  systemPrompt += `
330
349
  About BunnyQuery (this app - questions about it are in scope):
331
350
  You are the assistant inside BunnyQuery, an AI assistant for the user's own business data. Instead of digging through folders, dashboards and files, the user uploads their documents, spreadsheets, images, notes and records, BunnyQuery indexes them into this project's database, and you answer questions, write reports and summarize from THAT data rather than from the open internet. Each project has its own data, its own AI platform (ChatGPT or Claude, powered by the project owner's own API key) and its own base prompt. BunnyQuery is built on Skapi (www.skapi.com), so the same project database is also reachable over MCP from any MCP-compatible AI client (mcp.broadwayinc.computer), and this chat can be embedded in a website as a widget with one script tag. Answer product questions from the facts in this section. If you are asked something about BunnyQuery that is NOT stated here - pricing, plan limits, a roadmap, a feature you cannot see - say you are not certain and point the user at the project owner or the BunnyQuery site, rather than inventing it.
332
- How data gets in: ${canUpload === false ? `this user CANNOT upload in this session (they are not signed in, or the project's database is frozen for non-admins), and the attach affordances are hidden from them. Never instruct them to attach, drag in or upload a file, and never blame a missing answer on them not having uploaded it. Answer from what is already indexed, and when something genuinely is not in the project, say so and suggest asking the project's owner to add it.` : `the user attaches files to a chat message with the paperclip button in the composer, or drags and drops them onto the chat (whole folders work; up to 20 files per message). Uploaded files land in this project's file storage and are indexed automatically: read end to end and turned into database records. "Indexed" means exactly that, and it is why you can only answer from a file once its indexing has finished. While a file indexes, the chat shows a status row for it: yellow while it is working, green when it is indexed, red if it failed. A large file is indexed in windows over several passes, which takes longer; indexing runs on the server, so it keeps going if the user closes the page and the row is still there when they come back. The user can also paste plain text straight into the chat and ask you to save it - store it with the postRecords tool. BunnyQuery reads over 50 formats: office documents (.docx, .xlsx, .pptx, .hwp, .hwpx, .odt, .ods, .odp, .epub), PDFs, images, .csv/.tsv, .json, .xml, .html, .txt/.md and source code. Images and scanned PDFs are read with vision at index time.`}
351
+ How data gets in: ${canUpload === false ? `this user CANNOT upload in this session (they are not signed in, or the project's database is frozen for non-admins), and the attach affordances are hidden from them. Never instruct them to attach, drag in or upload a file, and never blame a missing answer on them not having uploaded it. Answer from what is already indexed, and when something genuinely is not in the project, say so and suggest asking the project's owner to add it.` : `the user attaches files to a chat message with the paperclip button in the composer, or drags and drops them onto the chat (whole folders work; up to 20 files per message). Uploaded files land in this project's file storage and are indexed automatically: read end to end and turned into database records. "Indexed" means exactly that, and it is why you can only answer from a file once its indexing has finished. While a file indexes, the chat shows a status row for it: yellow while it is working, green when it is indexed, red if it failed. A large file is indexed in windows over several passes, which takes longer; indexing runs on the server, so it keeps going if the user closes the page and the row is still there when they come back. The user can also paste plain text straight into the chat and ask you to save it - store it with the postRecords tool. BunnyQuery reads over 50 formats: office documents (.docx, .xlsx, .pptx, .hwp, .hwpx, .odt, .ods, .odp, .epub), email (.eml), PDFs, images, .csv/.tsv, .json, .xml, .html, .txt/.md and source code. Images and scanned PDFs are read with vision at index time.`}
333
352
  Getting answers out: the user asks in plain language, in any language, and you answer from this project's data. You can also produce reports and downloadable files (CSV and the rest) as described in the File generation rules above, and any stored file can be handed back as a link, with images rendering inline in the chat.${""}${`
334
353
  This chat is the BunnyQuery widget embedded in a website, so the user may have no access to the project console: keep any instructions to what can be done here in the chat.` }`;
335
354
  if (greeting) {
@@ -350,7 +369,7 @@ Project description: """${serviceDescription}"""`;
350
369
  const accessGroup = params.accessGroup === "public" || params.accessGroup === "private" ? params.accessGroup : "authorized";
351
370
  let systemPrompt = `You are a background indexing agent for project ${projectId}.
352
371
  - Image files (.jpg, .jpeg, .png, .gif, .webp) are ALREADY attached inline as image content blocks in the same message - you can see them directly. Do NOT call web_fetch on image URLs; that will fail or return garbage. Just look at the image block and answer.
353
- - Most files (office documents like .docx/.xlsx/.pptx/.hwp/.hwpx/.ods, and text/data/code files like .csv/.tsv/.json/.xml/.txt/.md and source code) have ALREADY been extracted on the server and included inline in the user message between the "BEGIN FILE CONTENT" / "END FILE CONTENT" markers - read that directly. If the inline content is a "[skapi: ...]" note, the file could not be extracted - index it from its metadata only.
372
+ - Most files (office documents like .docx/.xlsx/.pptx/.hwp/.hwpx/.ods, email messages (.eml), and text/data/code files like .csv/.tsv/.json/.xml/.txt/.md and source code) have ALREADY been extracted on the server and included inline in the user message between the "BEGIN FILE CONTENT" / "END FILE CONTENT" markers - read that directly. If the inline content is a "[skapi: ...]" note, the file could not be extracted - index it from its metadata only.
354
373
  - BIG SPREADSHEETS / TEXT: the inline content may be only the FIRST part of a large file (it can end with a truncation or "more remains" note). UNLESS this message already embeds a window of the file (in which case the message tells you not to call readFileContent, and you must not), read big spreadsheets and big text/data files WITH THE readFileContent TOOL: it returns the file ONE WINDOW at a time (spreadsheets as coordinate-tagged grid rows, text as a range of characters). Pass the file's storage path. After each window: datafy it into records and SAVE them, THEN if the window says MORE REMAINS call readFileContent again with the cursor it gives you. Repeat until it says END OF FILE, so the WHOLE file is indexed - never stop after the first window. (Do NOT call readFileContent on a PDF - see the next line.)
355
374
  - PDFs (scanned or not): you do NOT read a PDF with a tool or a URL. Its pages are RENDERED and embedded directly in the user message as IMAGE blocks, a WINDOW of pages at a time. LOOK at the embedded page images and datafy every one. The note beside them tells you whether MORE pages remain: if so, save this window's records and stop (a follow-up pass shows the next window automatically); only when the note says it was the LAST window is the PDF fully seen. Do NOT call readFileContent or web_fetch for a PDF.
356
375
  - VISION: when the message (a readFileContent window, an embedded PDF page, or an inline attachment) includes IMAGES - scanned/rendered PDF pages, or photos embedded in a spreadsheet next to a row/block - LOOK at them and capture what they show as record data (the reading/values in a scanned table, the part/defect/condition visible in a photo). The image IS part of the data; correlate each photo with its labelled block ("PHOTO A3" markers tie a photo to that grid row).
@@ -359,12 +378,16 @@ Project description: """${serviceDescription}"""`;
359
378
  - Whatever the file type, this file's identity is "src::" + its storage path (the "storage path" metadata line) - never the inline content or a temporary URL. That record ALREADY EXISTS: the upload pipeline creates it in table "file_summaries" (access group "${accessGroup}") before indexing starts, so posting it again is rejected as a duplicate unique_id. Reference it from every record you write, and add what you learn to it with updateRecords. If that update unexpectedly reports the record does not exist, post it yourself ONCE with that exact "src::" unique_id (table "file_summaries", access group "${accessGroup}") and carry on; this is the ONE exception to the do-NOT-post-the-file-record rules elsewhere in these instructions, because the source identity must never be dropped just because an update failed.
360
379
  - ACCESS GROUP (hard rule): every record you write for this file - the file record, per-row records, chapters, summaries, intermediates - MUST be posted with access group "${accessGroup}". Pass it explicitly on every postRecords call; do not leave it out and do not vary it between passes of the same file. An access group is part of a record's table key, so records saved under a different group than the file are in a different table and will not come back with the rest of it: a "public" file whose rows were saved as "authorized" is one an anonymous visitor can see the name of and none of the contents of, and a re-index cannot find the strays to clean them up. The one exception is the EXTRACTED MEDIA records in "__MEDIA__", which the pipeline creates for you - leave their group alone and only enrich them.
361
380
  - REACHABILITY (hard rule): every record you write while indexing this file MUST be reachable from the file's "src::<storage path>" record by following reference - either reference that record directly, or reference something that already reaches it. A record with no reference, or one pointing outside this file's chain, is an ORPHAN: deleting or re-indexing the file removes the reachable records and leaves the orphan behind forever, where it keeps turning up in later answers as stale data. If you create an intermediate record that OTHER records reference (a page record that rows hang off, a sheet or section record), set source.can_remove_referencing_records to true on it; the delete cascade passes a delete through a record only when that record carries the flag OR a unique_id starting "src::" (the file record cascades because its unique_id starts with "src::"; the intermediates you create carry no "src::" id, so they need the flag), and it cascades ONE LEVEL AT A TIME, so EVERY intermediate record in a chain needs its own marker - an unmarked link stops the cascade there and everything below it survives as orphans. When in doubt, reference the file record directly and keep the chain flat.
362
- - TABULAR data (any spreadsheet - .csv/.tsv/.xlsx/.xls/.ods, or sheet-like rows): you MUST save EVERY data row as its own record (ONE record per row) with that row's actual column values in the record's "data", keyed by the header names, in a table named EXACTLY "spreadsheet_rows". Do NOT summarize, sample only a few rows, or save just file metadata - index the whole sheet, window by window, until it ends. Make MULTIPLE postRecords calls in batches (e.g. 30-50 rows per call) rather than one oversized call. This per-row completeness OVERRIDES brevity. The file-level "src::" record ALREADY EXISTS - the upload pipeline creates it before indexing starts - so do NOT create it. Link EVERY per-row record to it via reference (set each row record's reference to exactly "src::" + the storage path, with NO sheet/window/summary suffix added; the row records themselves do NOT carry a src:: unique_id). Enrich that same record with sheet name(s), column headers and total row count via updateRecords rather than posting another one. The per-row records AND this reference linkage are BOTH mandatory: the linkage is what lets the whole sheet be found and cleaned up together when the file is re-indexed. INDEX each row record on the row's most useful NUMERIC column (named by its header) so rows sort and range-query; when the row has no numeric column, index the grid row number instead. TAG each row record with the sheet name, the file name, and the row's categorical values (a status, a category, a type) - tags are how rows are filtered without scanning the table.
381
+ - TABULAR data (any spreadsheet - .csv/.tsv/.xlsx/.xls/.ods, or sheet-like rows): UNLESS the message tells you the server has ALREADY saved this spreadsheet's rows as records (in which case you must NOT write row records and must NOT call readFileContent for it; your only job is the file-level summary it describes), you MUST save EVERY data row as its own record (ONE record per row) with that row's actual column values in the record's "data", keyed by the header names, in a table named EXACTLY "spreadsheet_rows". Do NOT summarize, sample only a few rows, or save just file metadata - index the whole sheet, window by window, until it ends. Make MULTIPLE postRecords calls in batches (e.g. 30-50 rows per call) rather than one oversized call. This per-row completeness OVERRIDES brevity. The file-level "src::" record ALREADY EXISTS - the upload pipeline creates it before indexing starts - so do NOT create it. Link EVERY per-row record to it via reference (set each row record's reference to exactly "src::" + the storage path, with NO sheet/window/summary suffix added; the row records themselves do NOT carry a src:: unique_id). Enrich that same record with sheet name(s), column headers and total row count via updateRecords rather than posting another one. The per-row records AND this reference linkage are BOTH mandatory: the linkage is what lets the whole sheet be found and cleaned up together when the file is re-indexed. INDEX each row record on the row's most useful NUMERIC column (named by its header) so rows sort and range-query; when the row has no numeric column, index the grid row number instead. TAG each row record with the sheet name, the file name, and the row's categorical values (a status, a category, a type) - tags are how rows are filtered without scanning the table.
382
+ - WINDOW TAG. The message that shows you a window of a file names a tag of the form "win::" followed by a short code, and tells you to put it on every record you save from that window. Do it, on EVERY record, alongside the record's other tags. It is how the server removes exactly that window's records if the window ever has to be sent to you again, so that a retry never doubles what is stored. Never invent one, never reuse one from another window, and never leave it off.
363
383
  - ONE RECORD PER GRID ROW, ALWAYS. "Row" means the numbered row of the sheet (R37 is one record), never a visual block, item, section or left/right pair. Sheets that repeat the same columns side by side (an A/B block beside a C/D block, "paired" or "mirrored" layouts) still get ONE record per grid row, holding BOTH sides - suffix the keys to keep them apart (PART_NO_A / PART_NO_B). Collapsing a 16-row window into 2 or 3 "block" records is the single most damaging mistake here: it silently loses most of the cells and makes every later total wrong, because some windows were counted per row and others per block. If a window shows rows R37 to R52, you save records for R37..R52 and the count you report is the number of grid rows you actually wrote.
364
- - FIXED TABLE NAMES. Never invent a table name for one pass, and never vary the name between passes of the SAME file: that scatters one file's data across tables nobody can enumerate later, so the data is effectively lost even though every save succeeded. Use exactly "spreadsheet_rows" for spreadsheet row records, "book_chapters" for a chapter record, and "file_summaries" for the file-level record (which already exists, so update it and never post it). Embedded photos and other embedded files get NO table of your choosing: their records already exist in table "__MEDIA__", see EXTRACTED MEDIA below. For a content type none of those fit, choose ONE plain descriptive name, use that same name for every pass of the file, and never mint variants of it (inspection_items / item_records / sheet_items / inspection_data are four names for what is one table).
365
- - EXTRACTED MEDIA: every PICTURE embedded in an uploaded document (photos, diagrams, chart images) is pulled out and saved as a real permanent file under "__MEDIA__/<the document's storage path>/<name>", and a record for each one ALREADY EXISTS in table "__MEDIA__" with unique_id "src::<that path>", reference "src::<the document>", and its path, anchor and sheet already in data. Do NOT create it - the unique_id is taken and your post is rejected. UPDATE it with updateRecords, addressed by that unique_id, adding what the file actually SHOWS plus TAGS for every identifier visible in it (part numbers, tag ids, item names, serial numbers). An update REPLACES the fields you send, so send the existing tags back with your new ones and keep every field already in data (path, anchor, sheet, source, mime, bytes). ONE FILE, ONE RECORD: never also create a photo record in another table. If the update reports that the record does not exist, create it with that same unique_id, reference and data.path - the path must never be lost. Audio and video clips and non-picture attachments are NOT extracted, so never claim a separate file or a "__MEDIA__" record exists for one of those.
384
+ - THE FILE NAME AND ITS FOLDERS ARE EVIDENCE ABOUT WHAT THE DATA MEANS, and often the only evidence there is. A grid of bare figures filed under "2026/Q2/royalties" is a quarterly royalty settlement; the same grid under "inspections/KCG-B507" is one aircraft's inspection. Nothing inside the sheet says so. Read the trail in the metadata block and use it: name the period, the entity, the counterparty or the subject in the file record's description, and TAG the records with the meaningful parts of it (the client, the aircraft, the quarter, the site), so a later question about that entity finds this file at all. A folder that is only an id or a date is still worth a tag; a folder like "uploads", "new" or "temp" is not.
385
+ - BUT NEVER INSTEAD OF READING. The path tells you what the data is ABOUT; only the content tells you what it SAYS. Never infer a value, a column meaning, a row count or a total from a name, never let a name override what the cells actually contain, and never derive a TABLE name from a folder or a file name - table names are fixed (see below), and a table named after a folder scatters one kind of record across as many tables as the user has folders. Where the name and the content disagree, the content wins and the disagreement is worth recording.
386
+ - FIXED TABLE NAMES. Never invent a table name for one pass, and never vary the name between passes of the SAME file: that scatters one file's data across tables nobody can enumerate later, so the data is effectively lost even though every save succeeded. Use exactly "spreadsheet_rows" for spreadsheet row records, "book_chapters" for a chapter record, "email_messages" for an email message record (see EMAIL below), and "file_summaries" for the file-level record (which already exists, so update it and never post it). Embedded photos and other embedded files get NO table of your choosing: their records already exist in table "__MEDIA__", see EXTRACTED MEDIA below. For a content type none of those fit, choose ONE plain descriptive name, use that same name for every pass of the file, and never mint variants of it (inspection_items / item_records / sheet_items / inspection_data are four names for what is one table).
387
+ - EXTRACTED MEDIA: every PICTURE embedded in an uploaded document (photos, diagrams, chart images) is pulled out and saved as a real permanent file under "__MEDIA__/<the document's storage path>/<name>", and a record for each one ALREADY EXISTS in table "__MEDIA__" with unique_id "src::<that path>", reference "src::<the document>", and its path, anchor and sheet already in data. Do NOT create it - the unique_id is taken and your post is rejected. UPDATE it with updateRecords, addressed by that unique_id, adding what the file actually SHOWS plus TAGS for every identifier visible in it (part numbers, tag ids, item names, serial numbers). An update REPLACES the fields you send, so send the existing tags back with your new ones and keep every field already in data (path, anchor, sheet, source, mime, bytes). ONE FILE, ONE RECORD: never also create a photo record in another table. If the update reports that the record does not exist, create it with that same unique_id, reference and data.path - the path must never be lost. Audio and video clips and non-picture attachments are never saved as separate files (an email's attachment text is read inline instead, see EMAIL below), so never claim a separate file or a "__MEDIA__" record exists for one of those.
366
388
  - AUDIO files: transcribe the speech, and capture speakers (named where identifiable), the topics discussed, and timestamps of key moments in the record's data. TAG the language, the audio type (call, meeting, dictation, music), each speaker and every named entity; INDEX the duration in seconds as duration_seconds. VIDEO files: everything audio gets, PLUS transcribe on-screen text verbatim (same transcription discipline as photos) and capture the visual timeline - scene changes and what each scene shows, with timestamps. Same tags as audio plus every entity visible on screen, and INDEX duration_seconds here too. These audio and video rules apply to files UPLOADED AS FILES: the transcript and timeline land on the file's own "src::" record, which already exists. Audio or video embedded inside a document is NOT extracted, so never look for or promise a "__MEDIA__" record for it.
367
389
  - EPUB / e-books / long-form books (.epub or any book-length prose, provided inline in reading order with chapter headings preserved): you MUST save ONE record per CHAPTER (or, when chapters are unclear, per major section/topic) in the table "book_chapters" - never collapse the whole book into a single record. INDEX each chapter record on its chapter number (so chapters sort and range-query in order) and include the chapter title among its tags; the record's "data" must capture the chapter title plus its order/number AND a substantive summary of that chapter's content (key events, arguments, characters, places, concepts, terms, notable quotes). Apply AS MANY relevant tags as possible to EVERY chapter record (characters, locations, themes, topics, key concepts, key terms, dates, named entities) so the book is easy to SEARCH and cross-reference later - this is the whole point. ALSO put the book-level facts (title, author, language, overall summary, chapter list / table of contents, genre/subjects) onto the "src::" file record that ALREADY EXISTS in "file_summaries", using updateRecords. Do NOT post a second book-level record, and set every chapter record's reference to exactly "src::" + the storage path. This per-chapter completeness OVERRIDES brevity; human-readable summaries only, never raw/binary bytes.
390
+ - EMAIL (.eml, provided inline with "=== EMAIL ===" / "=== BODY ===" / "=== ATTACHMENT i/N: ..." / "=== FORWARDED MESSAGE k (depth d) ===" headings, which always start at column 0; a body line that merely looks like one is body text): you MUST save ONE record per email MESSAGE in the table "email_messages", and a forwarded message inside it (its own "=== EMAIL ===" block) gets its OWN record. Each record carries subject, from, to, cc, date (the Date line: an ISO string when the layer could parse it, otherwise the raw header text), message_id, in_reply_to, and the body text (quoted earlier replies included). INDEX each record on its date as that string exactly as given, and include the sender address, every recipient address and the subject among its tags. Text under an "=== ATTACHMENT" heading is that attachment's extracted content: datafy it by its own kind (rows into "spreadsheet_rows" for a spreadsheet, one record per section for a document), tag those records with the attachment's filename, and give EVERY record the same "src::" reference as the email. Picture attachments are extracted into "__MEDIA__" like any other embedded picture (their media anchor is quoted on the "[picture ...]" line); other attachments are read inline: their content becomes the records above, but no separate FILE or file record exists for one, so never cite a path for it.
368
391
  - URL SOURCES: when the source being indexed is a URL rather than an uploaded file (a temporary or signed URL that merely DELIVERS an uploaded file's bytes is not a URL source; that file keeps its storage-path identity), its identity is "src::" + the FULL URL INCLUDING the query string (the query string often selects the content, so dropping it collapses different pages into one identity). If no record with that unique_id exists, create it; if the slot is already taken, update that record or reference it - never mint a variant id. For a WEB PAGE: extract everything on it, infer the page's primary entity type when it is not obvious (product, listing, article, profile), TAG that entity type plus the entities on the page, and INDEX the ONE number every entity of that type can be compared by (a price for a product, a date for an article). Any OTHER URL (a file behind a link) is downloaded and indexed under whichever per-type rule above matches its content. When the URL's content offers more index points than one record carries, add reference-linked records reachable from its "src::" record.
369
392
  - This is a background indexing task: do ALL the MCP saving FIRST, never reply mid-task, and never ask the user questions. Be exhaustive about meaning (and, for tabular data, about every row). SAVE AS YOU GO: persist each window's records before reading the next, so progress is never lost. If the file is so large you cannot finish in one turn, still save everything you have read so far; a follow-up pass will automatically continue from where you stopped. NEVER store raw or encoded file bytes in ANY field: no base64, no data: URIs, no hex or blob dumps. A long opaque non-human-readable string is not data - replace it with a structured description of what it encodes. If base64 or a data: URI is all you have for something, describe it conceptually and never paste it; if nothing human-readable can be extracted at all, OMIT that record rather than saving noise.
370
393
  - COMPLETION SIGNAL: only when YOU paged the file yourself with readFileContent and it reported "END OF FILE", with every row/item saved, end your final message with the token INDEXING_COMPLETE on its own line. If more rows remain, do NOT write that token - leaving it out is how the system knows to run another pass to continue. When the file arrives INSIDE this message one window at a time (an embedded window of rows/text, or rendered PDF page images), you are NOT the one who decides it is finished: the system advances the window off the real page/row count and sends the next pass automatically, so save this window, report what you saved, and never imply you have seen the whole file.
@@ -382,13 +405,21 @@ Project description: """${serviceDescription}"""`;
382
405
  const g = attachment && attachment.accessGroup;
383
406
  return g === "public" || g === "private" ? g : "authorized";
384
407
  }
408
+ function indexingFolderTrail(storagePath) {
409
+ if (typeof storagePath !== "string" || !storagePath) return "";
410
+ const parts = storagePath.split("/").filter(Boolean);
411
+ parts.pop();
412
+ return parts.join(" / ");
413
+ }
385
414
  function buildIndexingUserMessage(attachment, options) {
386
415
  const head = `A new file has just been uploaded. Index it now.
387
416
 
388
417
  File metadata:
389
418
  - name: ${attachment.name}
390
419
  - storage path: ${attachment.storagePath}
391
- ` + (attachment.mime ? `- mime type: ${attachment.mime}
420
+ ` + // Context, not an address. See indexingFolderTrail.
421
+ (indexingFolderTrail(attachment.storagePath) ? `- folders it was filed under: ${indexingFolderTrail(attachment.storagePath)}
422
+ ` : "") + (attachment.mime ? `- mime type: ${attachment.mime}
392
423
  ` : "") + (typeof attachment.size === "number" ? `- size (bytes): ${attachment.size}
393
424
  ` : "") + // Stated in the metadata block as well as the system prompt because this is
394
425
  // the per-FILE value: one project can hold public and private files at once,
@@ -441,7 +472,9 @@ Records for the earlier pages are ALREADY saved (they reference "${src}"). The N
441
472
  return `File metadata:
442
473
  - name: ${attachment.name}
443
474
  - storage path: ${attachment.storagePath}
444
- ` + (attachment.mime ? `- mime type: ${attachment.mime}
475
+ ` + // Context, not an address. See indexingFolderTrail.
476
+ (indexingFolderTrail(attachment.storagePath) ? `- folders it was filed under: ${indexingFolderTrail(attachment.storagePath)}
477
+ ` : "") + (attachment.mime ? `- mime type: ${attachment.mime}
445
478
  ` : "") + `- access group (use this for EVERY record you write for this file): ${indexingAccessGroup(attachment)}
446
479
  `;
447
480
  }
@@ -485,7 +518,9 @@ Save records for THIS window only, then stop and report what you saved. Do NOT t
485
518
  File metadata:
486
519
  - name: ${attachment.name}
487
520
  - storage path: ${attachment.storagePath}
488
- ` + (attachment.mime ? `- mime type: ${attachment.mime}
521
+ ` + // Context, not an address. See indexingFolderTrail.
522
+ (indexingFolderTrail(attachment.storagePath) ? `- folders it was filed under: ${indexingFolderTrail(attachment.storagePath)}
523
+ ` : "") + (attachment.mime ? `- mime type: ${attachment.mime}
489
524
  ` : "") + `- access group (use this for EVERY record you write for this file): ${indexingAccessGroup(attachment)}
490
525
 
491
526
  Records for the earlier windows/pages of this file are ALREADY saved (they reference "${src}"). First call getRecords with reference "${src}" to see how far the previous pass got (the furthest row/window already saved). The reference ALONE is the whole query: it returns every record written from this file across ALL tables and ALL access groups, so do NOT add table_name or access_group to narrow it. The response is PAGED, so keep fetching pages until it reports there are no more, and take the furthest point from the WHOLE set, never from the first page. Then call readFileContent with the storage path above and a CURSOR that RESUMES just after that point - do NOT start at the beginning. The cursor is derivable from what you already saved:
@@ -516,7 +551,17 @@ Index the REMAINING windows - one record per row/item, looking at any page image
516
551
  function isTransientStatus(status) {
517
552
  return status === 408 || status === 425 || status === 429 || status >= 500;
518
553
  }
554
+ function isCsrStatusEnvelope(res) {
555
+ return !!res && typeof res === "object" && !Array.isArray(res) && typeof res.status === "string" && typeof res.id === "string" && "in_queue" in res;
556
+ }
557
+ function csrEnvelopeError(input) {
558
+ if (!isCsrStatusEnvelope(input)) return void 0;
559
+ if (input.status !== "failed") return void 0;
560
+ return input.error != null ? input.error : { message: "The AI provider request failed." };
561
+ }
519
562
  function getErrorMessage(input) {
563
+ var envErr = csrEnvelopeError(input);
564
+ if (envErr !== void 0) input = envErr;
520
565
  if (!input) return "Something went wrong.";
521
566
  if (typeof input === "string") return input;
522
567
  if (input.error && input.error.message) return input.error.message;
@@ -531,7 +576,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
531
576
  return "Something went wrong.";
532
577
  }
533
578
  function isErrorResponseBody(response) {
534
- if (!response || typeof response !== "object") return false;
579
+ var envErr = csrEnvelopeError(response);
580
+ if (envErr !== void 0) response = envErr;
581
+ if (!response || typeof response !== "object") return envErr !== void 0;
535
582
  if (typeof response.status_code === "number" && response.status_code >= 400) return true;
536
583
  if (response.type === "error") return true;
537
584
  if (response.error && (response.error.message || response.error.type)) return true;
@@ -548,6 +595,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
548
595
  return false;
549
596
  }
550
597
  function isNonRetryableRequestError(input) {
598
+ var envErr = csrEnvelopeError(input);
599
+ if (envErr !== void 0) input = envErr;
551
600
  if (!input || typeof input !== "object") return false;
552
601
  var status = typeof input.status_code === "number" ? input.status_code : typeof input.status === "number" ? input.status : void 0;
553
602
  var param = void 0;
@@ -578,6 +627,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
578
627
  return false;
579
628
  }
580
629
  function isAuthExpiredError(input) {
630
+ var envErr = csrEnvelopeError(input);
631
+ if (envErr !== void 0) input = envErr;
581
632
  if (!input) return false;
582
633
  var blobs = [];
583
634
  var push = function(v) {
@@ -1006,6 +1057,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1006
1057
  return key && projectContextWindows[key] ? projectContextWindows[key] : null;
1007
1058
  }
1008
1059
  var MAX_OUTPUT_TOKENS = 25e3;
1060
+ var INDEXING_MAX_OUTPUT_TOKENS = 64e3;
1009
1061
  var TOOL_AND_RESPONSE_BUFFER = 4e3;
1010
1062
  var MIN_INPUT_TOKEN_BUDGET = 8e3;
1011
1063
  var MIN_PER_REQUEST_INPUT_CAP = 28e3;
@@ -1034,9 +1086,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1034
1086
  function getModelContextWindow(platform, model) {
1035
1087
  return resolveByModelId(apiReportedContextWindows, CONTEXT_WINDOW_BY_MODEL, model) || CONTEXT_WINDOW_DEFAULT[platform];
1036
1088
  }
1037
- function getMaxOutputTokens(platform, model) {
1089
+ function getMaxOutputTokens(platform, model, purpose) {
1090
+ var want = purpose === "indexing" ? INDEXING_MAX_OUTPUT_TOKENS : MAX_OUTPUT_TOKENS;
1038
1091
  var cap = resolveByModelId(apiReportedMaxOutput, MAX_OUTPUT_BY_MODEL, model);
1039
- return cap ? Math.min(MAX_OUTPUT_TOKENS, cap) : MAX_OUTPUT_TOKENS;
1092
+ return cap ? Math.min(want, cap) : want;
1040
1093
  }
1041
1094
  function getContextWindow(platform, model, projectId) {
1042
1095
  var ceiling = getModelContextWindow(platform, model);
@@ -1457,6 +1510,541 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1457
1510
  return { platform, model, contextWindow, hasPlatform: !!platform };
1458
1511
  }
1459
1512
 
1513
+ // src/engine/sse.ts
1514
+ var CLAUDE_EVENTS = {
1515
+ message_start: true,
1516
+ message_delta: true,
1517
+ message_stop: true,
1518
+ content_block_start: true,
1519
+ content_block_delta: true,
1520
+ content_block_stop: true,
1521
+ ping: true
1522
+ };
1523
+ var CLAUDE_TOOL_BLOCKS = {
1524
+ tool_use: true,
1525
+ server_tool_use: true,
1526
+ mcp_tool_use: true,
1527
+ web_search_tool_use: true
1528
+ };
1529
+ var OPENAI_TOOL_ITEMS = {
1530
+ function_call: true,
1531
+ mcp_call: true,
1532
+ web_search_call: true,
1533
+ file_search_call: true,
1534
+ code_interpreter_call: true,
1535
+ computer_call: true,
1536
+ image_generation_call: true
1537
+ };
1538
+ function detectProvider(type) {
1539
+ if (!type) return null;
1540
+ if (type.indexOf("response.") === 0) return "openai";
1541
+ if (CLAUDE_EVENTS[type]) return "claude";
1542
+ return null;
1543
+ }
1544
+ function lineEnd(s, from) {
1545
+ for (var i = from; i < s.length; i++) {
1546
+ var c = s.charCodeAt(i);
1547
+ if (c === 10) return { at: i, len: 1 };
1548
+ if (c === 13) {
1549
+ if (i + 1 >= s.length) return null;
1550
+ return { at: i, len: s.charCodeAt(i + 1) === 10 ? 2 : 1 };
1551
+ }
1552
+ }
1553
+ return null;
1554
+ }
1555
+ function readFrame(lines) {
1556
+ var event = "";
1557
+ var data = [];
1558
+ var framed = false;
1559
+ for (var i = 0; i < lines.length; i++) {
1560
+ var line = lines[i];
1561
+ if (!line.length) continue;
1562
+ if (line.charCodeAt(0) === 58) {
1563
+ framed = true;
1564
+ continue;
1565
+ }
1566
+ var colon = line.indexOf(":");
1567
+ var field = colon === -1 ? line : line.slice(0, colon);
1568
+ var value = colon === -1 ? "" : line.slice(colon + 1);
1569
+ if (value.charCodeAt(0) === 32) value = value.slice(1);
1570
+ if (field === "data") {
1571
+ framed = true;
1572
+ data.push(value);
1573
+ } else if (field === "event") {
1574
+ framed = true;
1575
+ event = value;
1576
+ } else if (field === "id" || field === "retry") {
1577
+ framed = true;
1578
+ }
1579
+ }
1580
+ return { event, data: data.join("\n"), framed };
1581
+ }
1582
+ function createSseParser() {
1583
+ var buf = "";
1584
+ var lines = [];
1585
+ var lastSeq = 0;
1586
+ var sawFraming = false;
1587
+ var raw = "";
1588
+ var rawHasContent = false;
1589
+ var ended = false;
1590
+ var rawParsed = false;
1591
+ var rawBody = null;
1592
+ var provider = null;
1593
+ var terminalEvent = null;
1594
+ var errored = false;
1595
+ var error = null;
1596
+ var stopReason = null;
1597
+ var toolCalls = [];
1598
+ var malformedFrames = 0;
1599
+ var malformedToolJson = 0;
1600
+ var message = null;
1601
+ var blocks = /* @__PURE__ */ new Map();
1602
+ var parts = /* @__PURE__ */ new Map();
1603
+ var reasoning = /* @__PURE__ */ new Map();
1604
+ var response = null;
1605
+ var textCache = null;
1606
+ var thinkingCache = null;
1607
+ function feed(text) {
1608
+ if (typeof text !== "string" || !text.length) return;
1609
+ if (!sawFraming) {
1610
+ raw += text;
1611
+ if (!rawHasContent) rawHasContent = /\S/.test(text);
1612
+ rawParsed = false;
1613
+ rawBody = null;
1614
+ }
1615
+ buf += text;
1616
+ var i = 0;
1617
+ for (; ; ) {
1618
+ var end2 = lineEnd(buf, i);
1619
+ if (!end2) break;
1620
+ var line = buf.slice(i, end2.at);
1621
+ i = end2.at + end2.len;
1622
+ if (line.length === 0) dispatch();
1623
+ else lines.push(line);
1624
+ }
1625
+ if (i > 0) buf = buf.slice(i);
1626
+ }
1627
+ function feedChunks(chunks) {
1628
+ if (!chunks || !chunks.length) return;
1629
+ for (var i = 0; i < chunks.length; i++) {
1630
+ var c = chunks[i];
1631
+ if (!c || typeof c !== "object") continue;
1632
+ var seq = typeof c.seq === "number" ? c.seq : 0;
1633
+ if (seq && seq <= lastSeq) continue;
1634
+ if (seq > lastSeq) lastSeq = seq;
1635
+ feed(typeof c.txt === "string" ? c.txt : "");
1636
+ }
1637
+ }
1638
+ function end() {
1639
+ if (buf.length) {
1640
+ var tail = buf.charCodeAt(buf.length - 1) === 13 ? buf.slice(0, -1) : buf;
1641
+ if (tail.length) lines.push(tail);
1642
+ buf = "";
1643
+ }
1644
+ if (lines.length) dispatch();
1645
+ ended = true;
1646
+ }
1647
+ function isUnframed() {
1648
+ return ended && !sawFraming && rawHasContent;
1649
+ }
1650
+ function dispatch() {
1651
+ var pending = lines;
1652
+ lines = [];
1653
+ if (!pending.length) return;
1654
+ try {
1655
+ var frame = readFrame(pending);
1656
+ if (frame.framed && !sawFraming) {
1657
+ sawFraming = true;
1658
+ raw = "";
1659
+ rawHasContent = false;
1660
+ }
1661
+ if (!frame.data.length) return;
1662
+ if (frame.data === "[DONE]") return;
1663
+ var ev = JSON.parse(frame.data);
1664
+ if (!ev || typeof ev !== "object") {
1665
+ malformedFrames++;
1666
+ return;
1667
+ }
1668
+ var type = typeof ev.type === "string" && ev.type ? ev.type : frame.event;
1669
+ if (!type) {
1670
+ malformedFrames++;
1671
+ return;
1672
+ }
1673
+ if (!provider) provider = detectProvider(type);
1674
+ if (provider === "openai") handleOpenAI(type, ev);
1675
+ else if (provider === "claude") handleClaude(type, ev);
1676
+ else handleUnattributed(type, ev);
1677
+ } catch (e) {
1678
+ malformedFrames++;
1679
+ }
1680
+ }
1681
+ function handleUnattributed(type, ev) {
1682
+ if (type === "error") {
1683
+ takeError(ev && ev.error ? ev : { type: "error", error: ev });
1684
+ return;
1685
+ }
1686
+ malformedFrames++;
1687
+ }
1688
+ function takeError(payload) {
1689
+ errored = true;
1690
+ terminalEvent = "error";
1691
+ error = payload;
1692
+ }
1693
+ function handleClaude(type, ev) {
1694
+ if (type === "ping") return;
1695
+ if (type === "error") {
1696
+ takeError({ type: "error", error: ev && ev.error ? ev.error : ev });
1697
+ return;
1698
+ }
1699
+ if (type === "message_start") {
1700
+ message = ev && ev.message ? shallowClone(ev.message) : { type: "message", role: "assistant" };
1701
+ if (typeof message.stop_reason === "string") stopReason = message.stop_reason;
1702
+ return;
1703
+ }
1704
+ if (type === "content_block_start") {
1705
+ var idx = numberOr(ev.index, -1);
1706
+ if (idx < 0) {
1707
+ malformedFrames++;
1708
+ return;
1709
+ }
1710
+ var block = ev.content_block ? shallowClone(ev.content_block) : {};
1711
+ blocks.set(idx, { block, json: "", sawJson: false });
1712
+ invalidate();
1713
+ if (block && typeof block.type === "string" && CLAUDE_TOOL_BLOCKS[block.type]) {
1714
+ var call = {
1715
+ index: idx,
1716
+ name: typeof block.name === "string" && block.name ? block.name : block.type,
1717
+ type: block.type
1718
+ };
1719
+ if (typeof block.server_name === "string") call.serverName = block.server_name;
1720
+ toolCalls.push(call);
1721
+ }
1722
+ return;
1723
+ }
1724
+ if (type === "content_block_delta") {
1725
+ var i = numberOr(ev.index, -1);
1726
+ var d = ev.delta;
1727
+ if (i < 0 || !d || typeof d !== "object") {
1728
+ malformedFrames++;
1729
+ return;
1730
+ }
1731
+ var st = blocks.get(i);
1732
+ if (!st) {
1733
+ st = { block: { type: deltaBlockType(d.type) }, json: "", sawJson: false };
1734
+ blocks.set(i, st);
1735
+ }
1736
+ applyClaudeDelta(st, d);
1737
+ invalidate();
1738
+ return;
1739
+ }
1740
+ if (type === "content_block_stop") {
1741
+ var j = numberOr(ev.index, -1);
1742
+ var s = j >= 0 ? blocks.get(j) : void 0;
1743
+ if (s && s.sawJson) finishToolJson(s);
1744
+ return;
1745
+ }
1746
+ if (type === "message_delta") {
1747
+ if (!message) message = { type: "message", role: "assistant" };
1748
+ var delta = ev.delta;
1749
+ if (delta && typeof delta === "object") {
1750
+ for (var k in delta) {
1751
+ if (Object.prototype.hasOwnProperty.call(delta, k)) message[k] = delta[k];
1752
+ }
1753
+ if (typeof delta.stop_reason === "string") stopReason = delta.stop_reason;
1754
+ }
1755
+ if (ev.usage && typeof ev.usage === "object") {
1756
+ message.usage = mergeInto(shallowClone(message.usage) || {}, ev.usage);
1757
+ }
1758
+ return;
1759
+ }
1760
+ if (type === "message_stop") {
1761
+ terminalEvent = "message_stop";
1762
+ return;
1763
+ }
1764
+ malformedFrames++;
1765
+ }
1766
+ function applyClaudeDelta(st, d) {
1767
+ var t = d.type;
1768
+ if (t === "text_delta") {
1769
+ st.block.text = (st.block.text || "") + str(d.text);
1770
+ return;
1771
+ }
1772
+ if (t === "thinking_delta") {
1773
+ st.block.thinking = (st.block.thinking || "") + str(d.thinking);
1774
+ return;
1775
+ }
1776
+ if (t === "signature_delta") {
1777
+ st.block.signature = (st.block.signature || "") + str(d.signature);
1778
+ return;
1779
+ }
1780
+ if (t === "input_json_delta") {
1781
+ st.json += str(d.partial_json);
1782
+ st.sawJson = true;
1783
+ return;
1784
+ }
1785
+ if (t === "citations_delta") {
1786
+ if (d.citation) {
1787
+ if (!Array.isArray(st.block.citations)) st.block.citations = [];
1788
+ st.block.citations.push(d.citation);
1789
+ }
1790
+ return;
1791
+ }
1792
+ malformedFrames++;
1793
+ }
1794
+ function finishToolJson(st) {
1795
+ if (!st.json.length) {
1796
+ return;
1797
+ }
1798
+ try {
1799
+ st.block.input = JSON.parse(st.json);
1800
+ } catch (e) {
1801
+ malformedToolJson++;
1802
+ }
1803
+ }
1804
+ function deltaBlockType(deltaType) {
1805
+ if (deltaType === "thinking_delta" || deltaType === "signature_delta") return "thinking";
1806
+ if (deltaType === "input_json_delta") return "tool_use";
1807
+ return "text";
1808
+ }
1809
+ function handleOpenAI(type, ev) {
1810
+ if (type === "response.output_text.delta") {
1811
+ putPart(ev, str(ev.delta), false);
1812
+ invalidate();
1813
+ return;
1814
+ }
1815
+ if (type === "response.output_text.done") {
1816
+ if (typeof ev.text === "string") {
1817
+ putPart(ev, ev.text, true);
1818
+ invalidate();
1819
+ }
1820
+ return;
1821
+ }
1822
+ if (type === "response.reasoning_summary_text.delta" || type === "response.reasoning_text.delta") {
1823
+ putReasoning(type, ev, str(ev.delta), false);
1824
+ invalidate();
1825
+ return;
1826
+ }
1827
+ if (type === "response.reasoning_summary_text.done" || type === "response.reasoning_text.done") {
1828
+ if (typeof ev.text === "string") {
1829
+ putReasoning(type, ev, ev.text, true);
1830
+ invalidate();
1831
+ }
1832
+ return;
1833
+ }
1834
+ if (type === "response.output_item.added") {
1835
+ var item = ev.item;
1836
+ if (item && typeof item.type === "string" && OPENAI_TOOL_ITEMS[item.type]) {
1837
+ toolCalls.push({
1838
+ index: numberOr(ev.output_index, toolCalls.length),
1839
+ // A built-in tool (web_search_call) has no name of its own, so the item
1840
+ // type is the only label there is and a row can still be drawn.
1841
+ name: typeof item.name === "string" && item.name ? item.name : item.type,
1842
+ type: item.type
1843
+ });
1844
+ }
1845
+ return;
1846
+ }
1847
+ if (type === "response.completed" || type === "response.incomplete" || type === "response.failed") {
1848
+ terminalEvent = type;
1849
+ if (ev.response && typeof ev.response === "object") {
1850
+ response = ev.response;
1851
+ var st = response.status;
1852
+ if (st === "incomplete") {
1853
+ var reason = response.incomplete_details && response.incomplete_details.reason;
1854
+ stopReason = typeof reason === "string" && reason ? reason : "incomplete";
1855
+ } else if (typeof st === "string" && st) {
1856
+ stopReason = st;
1857
+ }
1858
+ if (response.error && (response.error.message || response.error.code)) {
1859
+ errored = true;
1860
+ error = response;
1861
+ }
1862
+ }
1863
+ if (type === "response.failed") errored = true;
1864
+ return;
1865
+ }
1866
+ if (type === "response.error" || type === "error") {
1867
+ takeError(ev && ev.error ? ev : { type: "error", error: ev });
1868
+ return;
1869
+ }
1870
+ }
1871
+ function putReasoning(type, ev, text, replace) {
1872
+ var summary = type.indexOf("response.reasoning_summary_text.") === 0;
1873
+ var oi = numberOr(ev.output_index, 0);
1874
+ var idx = numberOr(summary ? ev.summary_index : ev.content_index, 0);
1875
+ var key = oi + ":" + (summary ? "s" : "r") + ":" + idx;
1876
+ var r = reasoning.get(key);
1877
+ if (!r) {
1878
+ r = { oi, idx, kind: summary ? 0 : 1, text: "" };
1879
+ reasoning.set(key, r);
1880
+ }
1881
+ r.text = replace ? text : r.text + text;
1882
+ }
1883
+ function putPart(ev, text, replace) {
1884
+ var oi = numberOr(ev.output_index, 0);
1885
+ var ci = numberOr(ev.content_index, 0);
1886
+ var key = oi + ":" + ci;
1887
+ var p = parts.get(key);
1888
+ if (!p) {
1889
+ p = { oi, ci, text: "" };
1890
+ parts.set(key, p);
1891
+ }
1892
+ p.text = replace ? text : p.text + text;
1893
+ }
1894
+ function invalidate() {
1895
+ textCache = null;
1896
+ thinkingCache = null;
1897
+ }
1898
+ function claudeTextBlocks() {
1899
+ return orderedBlocks().filter(function(b) {
1900
+ return b && b.type === "text";
1901
+ });
1902
+ }
1903
+ function orderedBlocks() {
1904
+ var idx = [];
1905
+ blocks.forEach(function(_v, k) {
1906
+ idx.push(k);
1907
+ });
1908
+ idx.sort(function(a, b) {
1909
+ return a - b;
1910
+ });
1911
+ var out = [];
1912
+ for (var i = 0; i < idx.length; i++) out.push(blocks.get(idx[i]).block);
1913
+ return out;
1914
+ }
1915
+ function orderedParts() {
1916
+ var out = [];
1917
+ parts.forEach(function(p) {
1918
+ out.push(p);
1919
+ });
1920
+ out.sort(function(a, b) {
1921
+ return a.oi !== b.oi ? a.oi - b.oi : a.ci - b.ci;
1922
+ });
1923
+ return out;
1924
+ }
1925
+ function currentText() {
1926
+ if (textCache !== null) return textCache;
1927
+ var out;
1928
+ if (provider === "openai") {
1929
+ out = orderedParts().map(function(p) {
1930
+ return p.text;
1931
+ }).join("\n");
1932
+ } else {
1933
+ out = claudeTextBlocks().map(function(b) {
1934
+ return b.text || "";
1935
+ }).join("\n");
1936
+ }
1937
+ textCache = out;
1938
+ return out;
1939
+ }
1940
+ function currentThinking() {
1941
+ if (thinkingCache !== null) return thinkingCache;
1942
+ var out;
1943
+ if (provider === "openai") {
1944
+ var rs = [];
1945
+ reasoning.forEach(function(r) {
1946
+ rs.push(r);
1947
+ });
1948
+ rs.sort(function(a, b) {
1949
+ if (a.oi !== b.oi) return a.oi - b.oi;
1950
+ if (a.idx !== b.idx) return a.idx - b.idx;
1951
+ return a.kind - b.kind;
1952
+ });
1953
+ out = rs.map(function(r) {
1954
+ return r.text;
1955
+ }).join("\n");
1956
+ } else {
1957
+ out = orderedBlocks().filter(function(b) {
1958
+ return b && b.type === "thinking";
1959
+ }).map(function(b) {
1960
+ return b.thinking || "";
1961
+ }).join("\n");
1962
+ }
1963
+ thinkingCache = out;
1964
+ return out;
1965
+ }
1966
+ function buildBody() {
1967
+ if (provider === "openai") {
1968
+ if (response) return response;
1969
+ } else if (blocks.size || message) {
1970
+ var base = message ? shallowClone(message) : { type: "message", role: "assistant" };
1971
+ base.content = orderedBlocks();
1972
+ return base;
1973
+ }
1974
+ if (errored && error) return error;
1975
+ return unframedBody();
1976
+ }
1977
+ function unframedBody() {
1978
+ if (!isUnframed()) return null;
1979
+ if (rawParsed) return rawBody;
1980
+ rawParsed = true;
1981
+ try {
1982
+ var v = JSON.parse(raw);
1983
+ rawBody = v && typeof v === "object" ? v : null;
1984
+ } catch (e) {
1985
+ rawBody = null;
1986
+ }
1987
+ return rawBody;
1988
+ }
1989
+ function snapshot() {
1990
+ return {
1991
+ provider,
1992
+ text: currentText(),
1993
+ thinkingText: currentThinking(),
1994
+ toolCalls: toolCalls.slice(),
1995
+ toolNames: toolCalls.map(function(t) {
1996
+ return t.name;
1997
+ }),
1998
+ stopReason,
1999
+ complete: terminalEvent !== null,
2000
+ // A terminal event that ENDED the answer rather than KILLED it. The
2001
+ // `errored` term covers all three ways a stream dies with a terminal event
2002
+ // on it: an Anthropic or OpenAI `error` frame (takeError sets both), a
2003
+ // response.failed, and a response.completed/incomplete whose Response
2004
+ // object carries an error payload. See the field's own doc for the loss
2005
+ // this separation prevents.
2006
+ answerComplete: terminalEvent !== null && terminalEvent !== "error" && !errored,
2007
+ terminalEvent,
2008
+ errored,
2009
+ error,
2010
+ malformedFrames,
2011
+ malformedToolJson,
2012
+ unframed: isUnframed(),
2013
+ unframedText: isUnframed() ? raw : null,
2014
+ lastSeq
2015
+ };
2016
+ }
2017
+ return {
2018
+ feed,
2019
+ feedChunks,
2020
+ end,
2021
+ snapshot,
2022
+ finalBody: buildBody
2023
+ };
2024
+ }
2025
+ function str(v) {
2026
+ return typeof v === "string" ? v : "";
2027
+ }
2028
+ function numberOr(v, fallback) {
2029
+ return typeof v === "number" && isFinite(v) ? v : fallback;
2030
+ }
2031
+ function shallowClone(o) {
2032
+ if (!o || typeof o !== "object") return o;
2033
+ var out = Array.isArray(o) ? o.slice() : {};
2034
+ if (!Array.isArray(o)) {
2035
+ for (var k in o) {
2036
+ if (Object.prototype.hasOwnProperty.call(o, k)) out[k] = o[k];
2037
+ }
2038
+ }
2039
+ return out;
2040
+ }
2041
+ function mergeInto(target, src) {
2042
+ for (var k in src) {
2043
+ if (Object.prototype.hasOwnProperty.call(src, k)) target[k] = src[k];
2044
+ }
2045
+ return target;
2046
+ }
2047
+
1460
2048
  // src/engine/requests.ts
1461
2049
  var ANTHROPIC_MESSAGES_API_URL = "https://api.anthropic.com/v1/messages";
1462
2050
  var ANTHROPIC_VERSION = "2023-06-01";
@@ -1473,12 +2061,43 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1473
2061
  var DEFAULT_CLAUDE_MODEL = "claude-sonnet-5";
1474
2062
  var DEFAULT_OPENAI_MODEL = "gpt-5.6-luna";
1475
2063
  var mcpUrl = () => chatEngineConfig().mcpBaseUrl;
2064
+ function withMcpParams(base, params) {
2065
+ if (!base) return base;
2066
+ const pairs = Object.keys(params).filter((k) => params[k] !== void 0 && params[k] !== null && params[k] !== "").map((k) => encodeURIComponent(k) + "=" + encodeURIComponent(String(params[k])));
2067
+ if (!pairs.length) return base;
2068
+ const [addr, existing] = base.split("?");
2069
+ const hasPath = /^[a-z][a-z0-9+.-]*:\/\/[^/]+\/./i.test(addr);
2070
+ const path = hasPath ? addr : addr.replace(/\/+$/, "") + "/";
2071
+ return path + "?" + (existing ? existing + "&" : "") + pairs.join("&");
2072
+ }
2073
+ var mcpContextParam = (platform, model) => getModelContextWindow(platform, model);
2074
+ var mcpIndexingUrl = (platform = "openai", model) => withMcpParams(mcpUrl(), { profile: "index", ctx: mcpContextParam(platform, model) });
1476
2075
  function mcpEndpointFor(anonymous, publicProjectId, service) {
1477
2076
  if (!anonymous) return { url: mcpUrl(), token: "$ACCESS_TOKEN" };
1478
2077
  const project = publicProjectId || service;
1479
2078
  return { url: String(mcpUrl()).replace(/\/+$/, "") + "/p/" + project };
1480
2079
  }
1481
2080
  var clientSecretRequest = (opts) => chatEngineConfig().clientSecretRequest(opts);
2081
+ var CHAT_STREAM_ON = Object.freeze({
2082
+ transport: Object.freeze({ stream: true }),
2083
+ body: Object.freeze({ stream: true })
2084
+ });
2085
+ var CHAT_STREAM_OFF = Object.freeze({
2086
+ transport: Object.freeze({}),
2087
+ body: Object.freeze({})
2088
+ });
2089
+ var CHAT_STREAM_ON_REALTIME = Object.freeze({
2090
+ // `realtime` rides on the TRANSPORT arm only. It is a skapi option, not a field
2091
+ // the destination understands, so it must never reach `data`: the body arm stays
2092
+ // exactly what it is with the socket off.
2093
+ transport: Object.freeze({ stream: true, realtime: true }),
2094
+ body: Object.freeze({ stream: true })
2095
+ });
2096
+ function chatStreamWiring(queue) {
2097
+ if (!liveStreamingEnabled()) return CHAT_STREAM_OFF;
2098
+ if (isBgIndexingQueue(queue)) return CHAT_STREAM_OFF;
2099
+ return liveStreamingRealtimeEnabled() ? CHAT_STREAM_ON_REALTIME : CHAT_STREAM_ON;
2100
+ }
1482
2101
  var VARIANT_IMAGE_DETAIL = "original";
1483
2102
  var VARIANT_TEXT_VERBOSITY = "high";
1484
2103
  var OLDEST_NANO_REASONING_EFFORT = "high";
@@ -1625,6 +2244,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1625
2244
  });
1626
2245
  }
1627
2246
  var POLL_INTERVAL = 3e3;
2247
+ var STREAM_POLL_INTERVAL = 1e3;
1628
2248
  var MAX_CONCURRENT_BG_POLLS = 6;
1629
2249
  async function callClaudeWithMcp({
1630
2250
  prompt,
@@ -1647,12 +2267,14 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1647
2267
  if (mcpServer.authorizationToken) {
1648
2268
  mcpServerDefinition.authorization_token = mcpServer.authorizationToken;
1649
2269
  }
2270
+ const stream = chatStreamWiring(userId || service);
1650
2271
  return clientSecretRequest({
1651
2272
  clientSecretName: "claude",
1652
2273
  queue: userId || service,
1653
2274
  service,
1654
2275
  owner,
1655
2276
  ...pollOpt(),
2277
+ ...stream.transport,
1656
2278
  url: ANTHROPIC_MESSAGES_API_URL,
1657
2279
  method: "POST",
1658
2280
  headers: {
@@ -1664,6 +2286,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1664
2286
  data: {
1665
2287
  model,
1666
2288
  max_tokens: maxTokens,
2289
+ // Top level beside model/messages/mcp_servers, which is where the
2290
+ // Messages API takes it.
2291
+ ...stream.body,
1667
2292
  ...extractContent && extractContent.length ? { _skapi_extract: extractContent } : {},
1668
2293
  ...fileUrls && fileUrls.length ? { _skapi_file_urls: fileUrls } : {},
1669
2294
  ...system ? {
@@ -1718,7 +2343,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1718
2343
  fileUrls,
1719
2344
  mcpServer: {
1720
2345
  name: MCP_NAME,
1721
- url: endpoint.url,
2346
+ url: withMcpParams(endpoint.url, {
2347
+ ctx: mcpContextParam("claude", model || DEFAULT_CLAUDE_MODEL)
2348
+ }),
1722
2349
  // Omitted entirely for an anonymous turn; the `if (mcpServer.authorizationToken)`
1723
2350
  // guard below drops the key rather than sending an empty one.
1724
2351
  authorizationToken: endpoint.token
@@ -1746,12 +2373,14 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1746
2373
  content: m.content
1747
2374
  }))
1748
2375
  ];
2376
+ const stream = chatStreamWiring(userId || service);
1749
2377
  return clientSecretRequest({
1750
2378
  clientSecretName: "openai",
1751
2379
  queue: userId || service,
1752
2380
  service,
1753
2381
  owner,
1754
2382
  ...pollOpt(),
2383
+ ...stream.transport,
1755
2384
  url: OPENAI_RESPONSES_API_URL,
1756
2385
  method: "POST",
1757
2386
  headers: {
@@ -1761,6 +2390,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1761
2390
  data: {
1762
2391
  model: resolvedModel,
1763
2392
  max_output_tokens: getMaxOutputTokens("openai", resolvedModel),
2393
+ // Top level beside model/input/tools, which is where the Responses API
2394
+ // takes it.
2395
+ ...stream.body,
1764
2396
  ...extractContent && extractContent.length ? { _skapi_extract: extractContent } : {},
1765
2397
  ...fileUrls && fileUrls.length ? { _skapi_file_urls: fileUrls } : {},
1766
2398
  input: responseInput,
@@ -1768,7 +2400,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1768
2400
  {
1769
2401
  type: "mcp",
1770
2402
  server_label: MCP_NAME,
1771
- server_url: endpoint.url,
2403
+ server_url: withMcpParams(endpoint.url, { ctx: mcpContextParam("openai", resolvedModel) }),
1772
2404
  require_approval: "never",
1773
2405
  // No `headers` at all for an anonymous turn: `Bearer ` with an
1774
2406
  // empty token is a credential the MCP server rejects, and the
@@ -1906,7 +2538,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1906
2538
  },
1907
2539
  data: {
1908
2540
  model: resolvedModel2,
1909
- max_output_tokens: getMaxOutputTokens("openai", resolvedModel2),
2541
+ max_output_tokens: getMaxOutputTokens("openai", resolvedModel2, "indexing"),
1910
2542
  // Nano-only transcription knobs. Indexing only; see variantIndexingOptions.
1911
2543
  ...variantIndexingOptions(resolvedModel2),
1912
2544
  ...skapiExtract,
@@ -1924,7 +2556,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1924
2556
  {
1925
2557
  type: "mcp",
1926
2558
  server_label: MCP_NAME,
1927
- server_url: mcpUrl(),
2559
+ server_url: mcpIndexingUrl("openai", resolvedModel2),
1928
2560
  require_approval: "never",
1929
2561
  headers: { Authorization: "Bearer $ACCESS_TOKEN" }
1930
2562
  },
@@ -1955,7 +2587,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1955
2587
  },
1956
2588
  data: {
1957
2589
  model: resolvedModel,
1958
- max_tokens: getMaxOutputTokens("claude", resolvedModel),
2590
+ max_tokens: getMaxOutputTokens("claude", resolvedModel, "indexing"),
1959
2591
  ...skapiExtract,
1960
2592
  ...skapiRender,
1961
2593
  ...skapiWindow,
@@ -1977,7 +2609,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1977
2609
  {
1978
2610
  type: "url",
1979
2611
  name: MCP_NAME,
1980
- url: mcpUrl(),
2612
+ url: mcpIndexingUrl("claude", resolvedModel),
1981
2613
  authorization_token: "$ACCESS_TOKEN"
1982
2614
  }
1983
2615
  ],
@@ -2148,9 +2780,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2148
2780
  { service: params.service, owner: params.owner, platform: params.platform, queue: params.queue, status: params.status },
2149
2781
  { limit: params.limit, fetchMore: false }
2150
2782
  )).then(function(result) {
2151
- const entry = { result, at: Date.now() };
2152
- bgProbeCache[key] = entry;
2153
- return entry;
2783
+ const entry2 = { result, at: Date.now() };
2784
+ bgProbeCache[key] = entry2;
2785
+ return entry2;
2154
2786
  });
2155
2787
  bgProbeInflight[key] = p;
2156
2788
  p.then(function() {
@@ -2380,8 +3012,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2380
3012
  return projectId + "#" + platform;
2381
3013
  }
2382
3014
  function mapHistoryListToMessages(list, platform, opts) {
2383
- var mapped = [], runningItemIds = [];
3015
+ var mapped = [], runningItemIds = [], streamPendingItemIds = [];
2384
3016
  var extractAssistantText = platform === "openai" ? extractOpenAIText : extractClaudeText;
3017
+ var canRecoverStreams = streamRecoveryEnabled();
2385
3018
  var filtered = filterListByClearHorizon(list, opts.clearedAt);
2386
3019
  filtered.slice().reverse().forEach(function(item) {
2387
3020
  var requestBody = item && item.request_body;
@@ -2395,6 +3028,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2395
3028
  var userText = isCompact ? typeof item.request_text === "string" ? item.request_text : "" : extractLastUserTextFromRequest(requestBody);
2396
3029
  var assistantText = isPending ? "" : isCompact ? (typeof item.response_text === "string" ? item.response_text : "").trim() : (extractAssistantText(response) || "").trim() || "";
2397
3030
  var isErrorResponse = !isPending && (isFailed || !isCompact && isErrorResponseBody(response));
3031
+ var isStreamPending = canRecoverStreams && !isCompact && !isPending && !isCancelledItem && !isErrorResponse && !item._isBgTask && !item._isOnBgQueue && item.status === "resolved" && item.response_body == null && item.error == null && !assistantText;
2398
3032
  var reportedComplete = !!(item && item._isBgTask) && !isErrorResponse && (isCompact ? item.response_complete_marker === true : !!assistantText && assistantText.indexOf(INDEXING_COMPLETE_MARKER) !== -1);
2399
3033
  if (reportedComplete) assistantText = assistantText.split(INDEXING_COMPLETE_MARKER).join("").trim();
2400
3034
  var serverItemId = item && typeof item.id === "string" && item.id ? item.id : void 0;
@@ -2453,6 +3087,14 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2453
3087
  if (serverItemId !== void 0) em._serverItemId = serverItemId;
2454
3088
  if (replyTs !== void 0) em._ts = replyTs;
2455
3089
  mapped.push(em);
3090
+ } else if (isStreamPending) {
3091
+ var sp = { role: "assistant", content: "", _streamPending: true };
3092
+ if (serverItemId !== void 0) {
3093
+ sp._serverItemId = serverItemId;
3094
+ streamPendingItemIds.push(serverItemId);
3095
+ }
3096
+ if (replyTs !== void 0) sp._ts = replyTs;
3097
+ mapped.push(sp);
2456
3098
  } else if (assistantText || reportedComplete) {
2457
3099
  var okm = { role: "assistant", content: sanitizeAttachmentLinksForHistory(assistantText, opts.projectId, true) || EMPTY_INDEXING_REPLY };
2458
3100
  if (item._fromBgChain) okm._fromBgChain = true;
@@ -2468,7 +3110,23 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2468
3110
  var ownerKey = chatCacheKey(opts.projectId, platform, opts.userId);
2469
3111
  for (var oi = 0; oi < mapped.length; oi++) mapped[oi]._ownerKey = ownerKey;
2470
3112
  }
2471
- return { messages: mapped, runningItemIds };
3113
+ return { messages: mapped, runningItemIds, streamPendingItemIds };
3114
+ }
3115
+ function adoptLocalAnswerIntoPage(incoming, local) {
3116
+ if (!incoming || !local || !incoming._streamPending) return false;
3117
+ if (incoming.role !== "assistant" || local.role !== "assistant") return false;
3118
+ var hasText = typeof local.content === "string" && local.content.length > 0;
3119
+ var isLive = !!(local.isPending || local._streaming);
3120
+ if (!hasText && !isLive) return false;
3121
+ if (hasText) {
3122
+ incoming.content = local.content;
3123
+ incoming._streamPending = false;
3124
+ }
3125
+ if (local._localId !== void 0) incoming._localId = local._localId;
3126
+ if (local.isPending) incoming.isPending = true;
3127
+ if (local.isPendingInProcess) incoming.isPendingInProcess = true;
3128
+ if (local._streaming) incoming._streaming = true;
3129
+ return true;
2472
3130
  }
2473
3131
  function shouldRescueInFlightMessage(m, ctx) {
2474
3132
  if (!m) return false;
@@ -2476,6 +3134,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2476
3134
  if (m._ownerKey !== void 0 && ctx.loadKey !== void 0 && m._ownerKey !== ctx.loadKey) return false;
2477
3135
  if (m._serverItemId && ctx.hasServerId(m._serverItemId)) return false;
2478
3136
  if (m._stageId) return true;
3137
+ if (m._streaming && !m._serverItemId) return true;
2479
3138
  if (!m._serverItemId && ctx.pageHasPendingAssistant) return false;
2480
3139
  if (m.isSendingToServer || m.isPendingQueued || m.isPendingInProcess || m.isPending) return true;
2481
3140
  if (ctx.sending && m.role === "user") {
@@ -2862,8 +3521,143 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2862
3521
  function isPollStopped(res) {
2863
3522
  return !!res && typeof res === "object" && res.status === "stopped";
2864
3523
  }
3524
+ var LIVE_PENDING_LINK_WINDOW = 512;
3525
+ var STREAM_RECOVERY_PER_LOAD = 2;
3526
+ function liveSafePrefix(text) {
3527
+ if (!text) return "";
3528
+ var cut = text.length;
3529
+ var fenceAt = -1, fences = 0, from = 0, hit;
3530
+ for (; ; ) {
3531
+ hit = text.indexOf("```", from);
3532
+ if (hit === -1) break;
3533
+ fences++;
3534
+ fenceAt = hit;
3535
+ from = hit + 3;
3536
+ }
3537
+ if (fences % 2 === 1 && fenceAt !== -1) cut = fenceAt;
3538
+ var head = text.slice(0, cut);
3539
+ var lineStart = head.lastIndexOf("\n") + 1;
3540
+ var line = head.slice(lineStart);
3541
+ var open = line.lastIndexOf("[");
3542
+ if (open !== -1 && line.length - open <= LIVE_PENDING_LINK_WINDOW) {
3543
+ var rest = line.slice(open);
3544
+ var close = rest.indexOf("]");
3545
+ if (close === -1) {
3546
+ cut = lineStart + open;
3547
+ } else if (rest.charAt(close + 1) === "(" && rest.indexOf(")", close + 1) === -1) {
3548
+ cut = lineStart + open;
3549
+ }
3550
+ }
3551
+ var tokStart = line.length;
3552
+ while (tokStart > 0 && !/\s/.test(line.charAt(tokStart - 1))) tokStart--;
3553
+ var tok = line.slice(tokStart);
3554
+ if (tok && /^(?:https?:\/\/|src::)/i.test(tok)) {
3555
+ var tokCut = lineStart + tokStart;
3556
+ if (tokCut < cut) cut = tokCut;
3557
+ }
3558
+ if (line.indexOf("```") === -1) {
3559
+ var ticks = 0, lastTick = -1;
3560
+ for (var i = 0; i < line.length; i++) {
3561
+ if (line.charAt(i) === "`") {
3562
+ ticks++;
3563
+ lastTick = i;
3564
+ }
3565
+ }
3566
+ if (ticks % 2 === 1 && lastTick !== -1) {
3567
+ var tickCut = lineStart + lastTick;
3568
+ if (tickCut < cut) cut = tickCut;
3569
+ }
3570
+ }
3571
+ if (cut >= text.length) return text;
3572
+ if (cut < 0) cut = 0;
3573
+ return text.slice(0, cut);
3574
+ }
3575
+ function commonPrefixLength(a, b) {
3576
+ var n = Math.min(a.length, b.length), i = 0;
3577
+ while (i < n && a.charCodeAt(i) === b.charCodeAt(i)) i++;
3578
+ if (i > 0) {
3579
+ var prev = a.charCodeAt(i - 1);
3580
+ if (prev >= 55296 && prev <= 56319) i--;
3581
+ }
3582
+ return i;
3583
+ }
3584
+ function typewriterResumeIndex(painted, fullText, regions) {
3585
+ if (!painted || !fullText) return 0;
3586
+ if (/^\s/.test(painted) && !/^\s/.test(fullText)) {
3587
+ painted = painted.replace(/^\s+/, "");
3588
+ if (!painted) return 0;
3589
+ }
3590
+ var i = commonPrefixLength(painted, fullText);
3591
+ if (i <= 0) return 0;
3592
+ if (i >= fullText.length) return fullText.length;
3593
+ for (var changed = true; changed; ) {
3594
+ changed = false;
3595
+ for (var k = 0; k < regions.length; k++) {
3596
+ var r = regions[k];
3597
+ if (i > r.start && i < r.end) {
3598
+ i = r.end;
3599
+ changed = true;
3600
+ }
3601
+ }
3602
+ }
3603
+ return i > fullText.length ? fullText.length : i;
3604
+ }
3605
+ function mayKeepStreamedAnswer(snap, rowStatus) {
3606
+ if (rowStatus !== void 0 && rowStatus !== null && rowStatus !== "" && rowStatus !== "resolved") return false;
3607
+ if (!snap || typeof snap !== "object") return false;
3608
+ if (snap.errored) return false;
3609
+ if (snap.answerComplete) return true;
3610
+ if (snap.unframed) return true;
3611
+ return false;
3612
+ }
3613
+ function streamRecoveryPhase(msg) {
3614
+ if (!msg || !msg._streamPending || msg.content || !msg._serverItemId) return "";
3615
+ if (msg._streamRecovery === "active") return "active";
3616
+ if (msg._streamRecovery === "failed") return "failed";
3617
+ return "idle";
3618
+ }
3619
+ function streamRecoveryLabels(phase) {
3620
+ if (phase === "failed") {
3621
+ return { note: "Could not load this answer.", action: "Try again" };
3622
+ }
3623
+ return { note: "This answer was not saved with the conversation.", action: "Load answer" };
3624
+ }
3625
+ var LIVE_PAINT_MIN_MS = 250;
3626
+ var LIVE_TYPE_MAX_STEP = 1200;
2865
3627
  var ChatSession = class {
2866
3628
  constructor(host) {
3629
+ // --- live streaming ----------------------------------------------------
3630
+ //
3631
+ // A streamed turn's answer NEVER reaches the polling row: the relay appends the
3632
+ // destination's raw bytes to a chunk table and the row settles with a status and
3633
+ // nothing else. So for a streamed turn this parser is not a nicety that makes the
3634
+ // wait prettier, it is the only place the answer exists until csr-finalize stores
3635
+ // one. Three things follow, and all three are load-bearing:
3636
+ //
3637
+ // 1. EVERY foreground poll gets a sink while streaming is on, not just the one
3638
+ // the dispatch attaches. A tab return, a reload, a resumePolling all
3639
+ // re-attach a poll to a still-running item, and skapi's reader sends
3640
+ // `since: 0` on its first tick, so a fresh sink REPLAYS the whole stream from
3641
+ // the beginning. Attaching without one settles that turn on an envelope and
3642
+ // the user's answer is gone.
3643
+ // 2. The parser is keyed by SERVER ITEM ID, and so is the bubble it paints into.
3644
+ // A history refetch replaces the local pending bubble with the server's copy
3645
+ // of the same turn; that copy carries the same _serverItemId, so the next
3646
+ // paint finds it and carries on. Nothing has to be rescued and nothing can be
3647
+ // painted twice.
3648
+ // 3. The stream is never the source of truth. At settle the parser's ASSEMBLED
3649
+ // body (byte equivalent to what a buffered call returns) goes through the
3650
+ // same extractClaudeText / extractOpenAIText the buffered path uses, and a
3651
+ // row that does hold a stored body wins outright.
3652
+ //
3653
+ // Background polls never get a sink, and that is safe because nothing on the bg
3654
+ // queue ever streams: an indexing pass must not (the worker READS its reply), and
3655
+ // a chat turn sent with attachments is deliberately left buffered for exactly the
3656
+ // reason point 1 gives, since the re-attach loop would poll it as a background
3657
+ // item and hand it no reader. See chatStreamWiring. A sink there would also spend
3658
+ // the request budget MAX_CONCURRENT_BG_POLLS exists to protect.
3659
+ /** Live streams by server item id. One per in-flight streamed turn. */
3660
+ this.liveStreams = {};
2867
3661
  // ─── compact-stub hydration ─────────────────────────────────────────────
2868
3662
  // Split-fetch bg pages arrive as label stubs (no bodies). When the user
2869
3663
  // expands a row, the real reply text is fetched per item (csr-poll point
@@ -3068,8 +3862,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3068
3862
  return Promise.resolve(probeBgQueue(
3069
3863
  { service: id.projectId, owner: id.owner, platform, queue, status, limit: WORKER_PASS_ADOPT_LIMIT },
3070
3864
  { maxAgeMs: BG_PROBE_TTL_MS }
3071
- )).then(function(entry) {
3072
- return entry.result;
3865
+ )).then(function(entry2) {
3866
+ return entry2.result;
3073
3867
  }).catch(function() {
3074
3868
  return null;
3075
3869
  });
@@ -3256,10 +4050,49 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3256
4050
  * and they are the ones bounded by MAX_CONCURRENT_BG_POLLS, so adding probes there would spend
3257
4051
  * the request budget the cap exists to protect.
3258
4052
  */
3259
- attachForegroundPoll(source, itemId, opts) {
3260
- return this._fgPollWithEarlyProbe(source, itemId, opts);
4053
+ attachForegroundPoll(source, itemId, opts, ctx) {
4054
+ return this._fgPollWithEarlyProbe(source, itemId, opts, ctx);
3261
4055
  }
3262
- _fgPollWithEarlyProbe(source, itemId, opts) {
4056
+ _fgPollWithEarlyProbe(source, itemId, opts, ctx) {
4057
+ var self = this;
4058
+ var live = this._beginLiveStream(itemId, ctx);
4059
+ if (live) {
4060
+ var inner = opts || {};
4061
+ var callerResponse = typeof inner.onResponse === "function" ? inner.onResponse : null;
4062
+ var callerError = typeof inner.onError === "function" ? inner.onError : null;
4063
+ var streamOpts = Object.assign({}, inner, {
4064
+ onStream: function(chunk, _seq, via) {
4065
+ self._feedLiveStream(live, chunk, via);
4066
+ },
4067
+ onResponse: function(res) {
4068
+ var effective = res;
4069
+ if (isPollStopped(res)) self._closeLiveStream(live, false);
4070
+ else effective = self._settleLiveStream(live, res);
4071
+ if (callerResponse) callerResponse(effective);
4072
+ },
4073
+ onError: function(err) {
4074
+ self._closeLiveStream(live, false);
4075
+ if (callerError) callerError(err);
4076
+ }
4077
+ });
4078
+ var lp = source.poll(Object.assign({ latency: STREAM_POLL_INTERVAL }, streamOpts));
4079
+ var stopLp = lp && typeof lp.stop === "function" ? lp.stop.bind(lp) : null;
4080
+ var wrapped = Promise.resolve(lp).then(function(res) {
4081
+ if (isPollStopped(res)) {
4082
+ self._closeLiveStream(live, false);
4083
+ return res;
4084
+ }
4085
+ return self._settleLiveStream(live, res);
4086
+ }, function(err) {
4087
+ self._closeLiveStream(live, false);
4088
+ throw err;
4089
+ });
4090
+ wrapped.stop = function() {
4091
+ self._closeLiveStream(live, false);
4092
+ if (stopLp) stopLp();
4093
+ };
4094
+ return wrapped;
4095
+ }
3263
4096
  var base = source.poll(Object.assign({ latency: POLL_INTERVAL }, opts || {}));
3264
4097
  var lookup = chatEngineConfig().csrHistoryItemLookup;
3265
4098
  var ident = this.host.getIdentity();
@@ -3341,6 +4174,661 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3341
4174
  });
3342
4175
  return n;
3343
4176
  }
4177
+ /**
4178
+ * Open (or re-open) the live stream for `itemId`, or null when this poll must
4179
+ * not carry one.
4180
+ *
4181
+ * Re-entrant on purpose: an auth-refresh retry re-dispatches the SAME turn under
4182
+ * a NEW id, and a re-attach after a tab return replays an existing id from seq 0.
4183
+ * Either way the bytes about to arrive are a whole stream, so an existing entry
4184
+ * is discarded and a fresh parser takes its place - feeding a replay into the old
4185
+ * parser would concatenate the answer with itself.
4186
+ *
4187
+ * `ctx` IS THE TURN'S OWN IDENTITY, and every caller that has one passes it.
4188
+ * This used to read the LIVE getIdentity(), which is a bug of exactly the kind
4189
+ * _callProviderFor documents and threads its own parameters to avoid: the user
4190
+ * hits Send, then switches project or platform inside the ack round trip, and the
4191
+ * stream that opens for the OLD turn is stamped with the NEW identity. What that
4192
+ * costs is not cosmetic - `platform` picks which url csr-finalize is addressed
4193
+ * with and which extractor reads the assembled body, `projectId`/`owner` scope
4194
+ * the finalize itself, and `ownerKey` decides which chat the answer is painted
4195
+ * into. Get them from the live read at the wrong moment and the turn is finalized
4196
+ * against the wrong service (so its answer is never stored), parsed with the
4197
+ * wrong provider's extractor, or painted into a conversation it does not belong
4198
+ * to. The live read stays only as the fallback for a caller with nothing pinned.
4199
+ */
4200
+ _beginLiveStream(itemId, ctx) {
4201
+ if (!liveStreamingEnabled()) return null;
4202
+ if (!itemId) {
4203
+ console.warn("[chat-engine] live streaming is on but the dispatch reported no item id");
4204
+ return null;
4205
+ }
4206
+ var pinnedPlatform = ctx && (ctx.platform === "claude" || ctx.platform === "openai") ? ctx.platform : void 0;
4207
+ var ident = pinnedPlatform && ctx && ctx.projectId !== void 0 && ctx.owner !== void 0 && ctx.ownerKey !== void 0 ? null : this.host.getIdentity();
4208
+ var platform = pinnedPlatform || (ident ? ident.platform : void 0);
4209
+ if (platform !== "claude" && platform !== "openai") return null;
4210
+ var projectId = ctx && ctx.projectId !== void 0 ? ctx.projectId : ident ? ident.projectId : "";
4211
+ var owner = ctx && ctx.owner !== void 0 ? ctx.owner : ident ? ident.owner : "";
4212
+ var ownerKey = ctx && ctx.ownerKey !== void 0 ? ctx.ownerKey : this.getHistoryCacheKey();
4213
+ var prev = this.liveStreams[itemId];
4214
+ if (prev) this._closeLiveStream(prev, false);
4215
+ var st = {
4216
+ id: itemId,
4217
+ ownerKey,
4218
+ platform,
4219
+ projectId,
4220
+ owner,
4221
+ parser: createSseParser(),
4222
+ painted: "",
4223
+ started: false,
4224
+ fed: false,
4225
+ ended: false,
4226
+ timer: null,
4227
+ lastPaintAt: 0,
4228
+ finalBody: null,
4229
+ transport: { socket: 0, poll: 0 }
4230
+ };
4231
+ this.liveStreams[itemId] = st;
4232
+ return st;
4233
+ }
4234
+ /** The chunk sink handed to skapi's poll. Raw relayed text, in order, never parsed
4235
+ * here: the parser owns the grammar and this owns the pacing. */
4236
+ _feedLiveStream(st, chunk, via) {
4237
+ if (st.ended || typeof chunk !== "string" || !chunk) return;
4238
+ st.fed = true;
4239
+ if (via === "socket") st.transport.socket++;
4240
+ else if (via === "poll") st.transport.poll++;
4241
+ st.parser.feed(chunk);
4242
+ if (st.timer) return;
4243
+ var self = this;
4244
+ var wait = st.lastPaintAt ? Math.max(0, LIVE_PAINT_MIN_MS - (nowMs() - st.lastPaintAt)) : 0;
4245
+ st.timer = setTimeout(function() {
4246
+ st.timer = null;
4247
+ self._paintLiveStream(st);
4248
+ }, wait);
4249
+ }
4250
+ /**
4251
+ * Write the safe prefix of the answer so far into the turn's bubble.
4252
+ *
4253
+ * notify() is spent EXACTLY ONCE per turn, on the first paint, because that is a
4254
+ * state change the per-bubble refresh cannot express: the bubble stops being a
4255
+ * "Thinking..." spinner and becomes text. Every paint after it goes through
4256
+ * refreshMessageBubble, which is what keeps a growing answer from rebuilding the
4257
+ * whole display list once a second.
4258
+ */
4259
+ _paintLiveStream(st) {
4260
+ if (st.ended) return;
4261
+ st.lastPaintAt = nowMs();
4262
+ if (this.getHistoryCacheKey() !== st.ownerKey) return;
4263
+ var idx = this._liveTargetIndex(st.id);
4264
+ if (idx === -1) return;
4265
+ var msg = this.state.messages[idx];
4266
+ if (!msg) return;
4267
+ var snap = st.parser.snapshot();
4268
+ var next = liveSafePrefix(snap.text);
4269
+ if (next.length <= st.painted.length) return;
4270
+ var prev = st.painted;
4271
+ st.painted = next;
4272
+ var grew = next.length - prev.length;
4273
+ var animate = grew > 0 && grew <= LIVE_TYPE_MAX_STEP;
4274
+ if (animate) {
4275
+ if (!msg._localId) msg._localId = this._newLocalId();
4276
+ if (!msg._streaming) {
4277
+ msg._streaming = true;
4278
+ this.host.notify();
4279
+ }
4280
+ this.enqueueTypewrite(idx, next, msg._localId, prev);
4281
+ } else {
4282
+ msg.content = next;
4283
+ if (!msg._streaming) {
4284
+ msg._streaming = true;
4285
+ this.host.notify();
4286
+ } else this.host.refreshMessageBubble(idx);
4287
+ }
4288
+ this.host.scrollToBottomIfSticky();
4289
+ this._reportLiveStream(st, st.started ? "update" : "start", snap, next);
4290
+ st.started = true;
4291
+ }
4292
+ /** The bubble a live stream paints into: the turn's pending assistant placeholder,
4293
+ * found by server item id. Not by _localId, deliberately - a history refetch
4294
+ * replaces the local copy with the server's, and only the id survives that. */
4295
+ _liveTargetIndex(itemId) {
4296
+ return this.state.messages.findIndex(function(m) {
4297
+ return !!m && m.role === "assistant" && !m.isBackgroundTask && m._serverItemId === itemId && (!!m.isPending || !!m._streaming);
4298
+ });
4299
+ }
4300
+ /** Hand the host its optional observation update. Guarded: this runs on the paint
4301
+ * path, and a throwing hook must not cost the user the rest of their answer. */
4302
+ _reportLiveStream(st, phase, snap, text) {
4303
+ var hook = chatEngineConfig().onLiveStreamUpdate;
4304
+ if (!hook) return;
4305
+ try {
4306
+ hook({
4307
+ serverItemId: st.id,
4308
+ ownerKey: st.ownerKey,
4309
+ phase,
4310
+ text,
4311
+ thinkingText: snap && snap.thinkingText || "",
4312
+ toolNames: snap && snap.toolNames ? snap.toolNames.slice() : [],
4313
+ complete: !!(snap && snap.complete),
4314
+ // Reported alongside `complete`, never instead of it: a host drawing
4315
+ // "still arriving" wants complete, a host drawing "this answer is
4316
+ // partial" wants this one, and an `error` frame is the case where the
4317
+ // two disagree. See sse.ts answerComplete.
4318
+ answerComplete: !!(snap && snap.answerComplete),
4319
+ errored: !!(snap && snap.errored),
4320
+ transport: { socket: st.transport.socket, poll: st.transport.poll }
4321
+ });
4322
+ } catch (e) {
4323
+ console.warn("[chat-engine] onLiveStreamUpdate threw", e);
4324
+ }
4325
+ }
4326
+ /** Stop painting and (when the turn really ended) assemble the body. `finished`
4327
+ * is false for a stream being discarded rather than settled: a retry replacing
4328
+ * it, or a stop, neither of which has an answer to assemble. */
4329
+ _closeLiveStream(st, finished) {
4330
+ var first = !st.ended;
4331
+ if (st.timer) {
4332
+ clearTimeout(st.timer);
4333
+ st.timer = null;
4334
+ }
4335
+ if (first) {
4336
+ st.ended = true;
4337
+ if (finished && st.fed) {
4338
+ st.parser.end();
4339
+ st.finalBody = st.parser.finalBody();
4340
+ }
4341
+ }
4342
+ if (this.liveStreams[st.id] === st) delete this.liveStreams[st.id];
4343
+ if (first && st.started) this._reportLiveStream(st, "end", st.parser.snapshot(), "");
4344
+ if (this.getHistoryCacheKey() !== st.ownerKey) return;
4345
+ var idx = this._liveTargetIndex(st.id);
4346
+ if (idx !== -1 && this.state.messages[idx] && this.state.messages[idx]._streaming) {
4347
+ this.state.messages[idx]._streaming = false;
4348
+ }
4349
+ }
4350
+ /**
4351
+ * Settle a streamed turn: end the parse, decide the body the rest of the session
4352
+ * will read, and release the chunks.
4353
+ *
4354
+ * The substitution is one-directional and never a merge. A response that is a
4355
+ * real stored body (a buffered turn, or a streamed one somebody already
4356
+ * finalized) is returned untouched, because that is the destination's own answer
4357
+ * and the stream is not entitled to overwrite it. Only a STATUS ENVELOPE - the
4358
+ * shape a streamed row settles as, having stored nothing - is replaced, and then
4359
+ * by the assembled body, which every caller downstream reads with the same
4360
+ * extractor it uses for a buffered reply. Idempotent, because it is reached both
4361
+ * through the poll's onResponse and through the promise it resolves.
4362
+ */
4363
+ _settleLiveStream(st, response) {
4364
+ this._closeLiveStream(st, true);
4365
+ if (!isCsrStatusEnvelope(response)) return response;
4366
+ if (response.status !== "resolved") return response;
4367
+ if (!this._mayFinalize(st)) this._rec().incomplete[st.id] = true;
4368
+ if (st.finalBody == null) return response;
4369
+ this._finalizeStreamedTurn(st);
4370
+ return st.finalBody;
4371
+ }
4372
+ /**
4373
+ * May this parse be STORED as the turn's permanent answer?
4374
+ *
4375
+ * THE FAILURE THIS PREVENTS. Finalizing does two things at once: it stores what
4376
+ * you give it as the row's result, and it DELETES the chunks it was assembled
4377
+ * from. So finalizing a truncated parse is not a cosmetic loss, it is the
4378
+ * permanent one: the truncation becomes the stored answer and the only copy of
4379
+ * the missing part is deleted in the same call. And a truncated parse is a shape
4380
+ * this repo has already paid for - a degraded chunk read (the poller degrades to
4381
+ * "no chunks this tick, more=true" on any transient chunk-table error, and caps
4382
+ * a long answer at 500k characters per response) can hand the settle a stream
4383
+ * that stopped mid-answer. The row can settle 'resolved' on top of that, because
4384
+ * the ROW's status describes the destination's request, not the client's read of
4385
+ * it.
4386
+ *
4387
+ * THE POLICY ITSELF IS mayKeepStreamedAnswer (top of this file), shared with the
4388
+ * recovery path so the two cannot drift apart again - they did, and the drift was
4389
+ * silent: the live settle refused a failed turn while the recovery finalized one.
4390
+ * What is local to this method is only the two things the free function cannot
4391
+ * know: that there is an assembled body at all, and that this call site is
4392
+ * reached only on a row that settled 'resolved' (the caller returns before it
4393
+ * otherwise), which is the status it therefore states.
4394
+ *
4395
+ * The test the policy applies is deliberately NOT `complete`: a terminal event
4396
+ * arrived and the answer finished are two claims, and an `error` frame satisfies
4397
+ * the first while truncating the second. See sse.ts's answerComplete.
4398
+ */
4399
+ _mayFinalize(st) {
4400
+ if (st.finalBody == null) return false;
4401
+ return mayKeepStreamedAnswer(st.parser.snapshot(), "resolved");
4402
+ }
4403
+ /**
4404
+ * Store the assembled body as the version history keeps, which is also what
4405
+ * releases this request's chunks.
4406
+ *
4407
+ * The ASSEMBLED BODY and not the extracted text, because the row is read back by
4408
+ * mapHistoryListToMessages through extractClaudeText / extractOpenAIText: storing
4409
+ * the provider's own document is what makes a streamed turn indistinguishable
4410
+ * from a buffered one on the next load, with no branch anywhere in the mapper.
4411
+ *
4412
+ * BEST EFFORT, and loudly so: the answer is already on screen and already in the
4413
+ * history cache by the time this fires. A failure costs the chunks (they stay,
4414
+ * and the turn stays re-readable) and a row that reads back empty, never the
4415
+ * user's answer in front of them.
4416
+ *
4417
+ * WHAT IS DELIBERATELY NEVER FINALIZED, because finalize is also the only way to
4418
+ * release chunks and it is tempting to reach for it as a cleanup:
4419
+ *
4420
+ * - an INCOMPLETE parse (see _mayFinalize). Storing a truncation makes it
4421
+ * permanent AND deletes the part that was missing from it. A stream killed by
4422
+ * an `error` frame is one of these however terminal it looks: the frame ends
4423
+ * the stream, so `complete` is true, while the text is only what arrived
4424
+ * before the error. That is why the gate reads answerComplete.
4425
+ * - a FAILED turn. Its chunks hold the part of the answer that did arrive,
4426
+ * which is the only copy of that text there is, and the two ways to release
4427
+ * them both cost something real: storing the partial makes a truncated answer
4428
+ * the turn's permanent history AND masks the failure on read (csr-poll hands
4429
+ * back a finalized body before it ever looks at the row's error, so the turn
4430
+ * would read back as a clean short answer), while storing the error throws
4431
+ * the partial away outright. Keeping them costs storage on rows that produced
4432
+ * bytes and then failed, which is rare - a failure before the first byte (a
4433
+ * wrong API key, the common case) has no chunks to keep - and the poller
4434
+ * hands those chunks back alongside the error on every later read, so nothing
4435
+ * is stranded, only retained. Retention is the honest trade here; deletion is
4436
+ * not reversible.
4437
+ * - a CANCELLED turn, for the same reason plus one: the user's Stop means the
4438
+ * half answer is to be discarded, so writing it into history as the kept
4439
+ * version would resurrect exactly what the stop was for.
4440
+ */
4441
+ _finalizeStreamedTurn(st) {
4442
+ if (st.finalized) return;
4443
+ if (!this._mayFinalize(st)) return;
4444
+ var fin = chatEngineConfig().clientSecretRequestFinalize;
4445
+ if (!fin || st.finalBody == null) return;
4446
+ st.finalized = true;
4447
+ var url = st.platform === "openai" ? OPENAI_RESPONSES_API_URL : ANTHROPIC_MESSAGES_API_URL;
4448
+ try {
4449
+ Promise.resolve(fin(st.id, st.finalBody, {
4450
+ url,
4451
+ method: "POST",
4452
+ service: st.projectId,
4453
+ owner: st.owner
4454
+ })).catch(function(err) {
4455
+ console.warn("[chat-engine] clientSecretRequestFinalize failed", err);
4456
+ });
4457
+ } catch (e) {
4458
+ console.warn("[chat-engine] clientSecretRequestFinalize threw", e);
4459
+ }
4460
+ }
4461
+ /** Painted-but-unsettled live text on a bubble, for the typewriter to resume from.
4462
+ * A pending assistant placeholder is created with content '' by every path that
4463
+ * makes one, so non-empty content on one can only have been painted here. */
4464
+ _paintedTextAt(idx) {
4465
+ var m = idx >= 0 ? this.state.messages[idx] : void 0;
4466
+ if (!m || m.role !== "assistant" || typeof m.content !== "string") return "";
4467
+ return m.content;
4468
+ }
4469
+ /** The recovery bookkeeping, created on first touch.
4470
+ *
4471
+ * LAZY, not constructor-initialised, and for a concrete reason: ChatSession is
4472
+ * also built with Object.create(ChatSession.prototype) by the engine's own test
4473
+ * harnesses, which drive one method against a hand-built state rather than a
4474
+ * whole session. A field only the constructor creates is undefined there, and
4475
+ * the method that reaches for it throws, turning a test of the settle into a
4476
+ * crash about bookkeeping. */
4477
+ _rec() {
4478
+ if (!this._streamRecovery) this._streamRecovery = { incomplete: {}, attempted: {}, inflight: {}, failed: {}, queue: [], running: false };
4479
+ return this._streamRecovery;
4480
+ }
4481
+ /**
4482
+ * Put this session's fetching state onto the turn's bubble, so a view can tell a
4483
+ * loader that means something from one that means nothing.
4484
+ *
4485
+ * ONLY EVER ONTO A STILL-MARKED BUBBLE. Once `_streamPending` is off the turn has
4486
+ * an answer (or was proven to have none) and this says nothing about it; writing
4487
+ * it there would leave a stale 'active' on a settled bubble forever.
4488
+ *
4489
+ * host.notify() is what redraws the widget, whose renderer is imperative. It is a
4490
+ * no-op in agent.vue, whose state is a Vue reactive() - the property write above
4491
+ * is what redraws there. Both are covered by doing both, and neither is a
4492
+ * substitute for the other.
4493
+ */
4494
+ _markRecoveryPhase(itemId, phase) {
4495
+ var changed = false;
4496
+ for (var i = 0; i < this.state.messages.length; i++) {
4497
+ var m = this.state.messages[i];
4498
+ if (!m || m.role !== "assistant" || m._serverItemId !== itemId || !m._streamPending) continue;
4499
+ var next = phase === null ? void 0 : phase;
4500
+ if (m._streamRecovery === next) continue;
4501
+ if (next === void 0) delete m._streamRecovery;
4502
+ else m._streamRecovery = next;
4503
+ changed = true;
4504
+ }
4505
+ if (changed) this.host.notify();
4506
+ }
4507
+ /**
4508
+ * Let LOCAL answers survive a freshly-mapped page whose copies of them are
4509
+ * authoritative-but-empty. Call with the page BEFORE it replaces or merges into
4510
+ * state.messages; mutates the page's bubbles in place.
4511
+ *
4512
+ * The adoption itself is history.ts's adoptLocalAnswerIntoPage (shared, so the
4513
+ * clients' own mappers cannot fork it). What lives here is the one thing the
4514
+ * pure function cannot know: whether the local text is the WHOLE answer. Text
4515
+ * left by a stream that ended without a terminal event is not, so that bubble
4516
+ * keeps its marker and gets read back even though it has content - otherwise a
4517
+ * truncated answer would adopt itself over the row and never be corrected.
4518
+ */
4519
+ _adoptLocalAnswers(mapped, loadKey) {
4520
+ if (!mapped || !mapped.length) return;
4521
+ var pendingIncoming = [];
4522
+ for (var i = 0; i < mapped.length; i++) {
4523
+ if (mapped[i] && mapped[i]._streamPending) pendingIncoming.push(mapped[i]);
4524
+ }
4525
+ if (!pendingIncoming.length) return;
4526
+ var locals = {};
4527
+ for (var j = 0; j < this.state.messages.length; j++) {
4528
+ var lm = this.state.messages[j];
4529
+ if (!lm || lm.role !== "assistant" || !lm._serverItemId) continue;
4530
+ if (lm._ownerKey !== void 0 && loadKey !== void 0 && lm._ownerKey !== loadKey) continue;
4531
+ if (locals[lm._serverItemId] === void 0) locals[lm._serverItemId] = lm;
4532
+ }
4533
+ for (var k = 0; k < pendingIncoming.length; k++) {
4534
+ var inc = pendingIncoming[k];
4535
+ var id = inc._serverItemId;
4536
+ if (!id) continue;
4537
+ var local = locals[id];
4538
+ if (!local) continue;
4539
+ if (!adoptLocalAnswerIntoPage(inc, local)) continue;
4540
+ if (this._rec().incomplete[id]) inc._streamPending = true;
4541
+ }
4542
+ for (var p = 0; p < pendingIncoming.length; p++) {
4543
+ var pi = pendingIncoming[p];
4544
+ if (!pi._streamPending || !pi._serverItemId) continue;
4545
+ var phase = this._recoveryPhaseFor(pi._serverItemId);
4546
+ if (phase === null) delete pi._streamRecovery;
4547
+ else pi._streamRecovery = phase;
4548
+ }
4549
+ }
4550
+ /**
4551
+ * This session's fetching state for one turn, from the bookkeeping rather than
4552
+ * from any bubble. A queued entry counts as 'active': it is committed to be read,
4553
+ * serially, and the reader has no way to tell "being read" from "next in line"
4554
+ * apart from the wait.
4555
+ */
4556
+ _recoveryPhaseFor(itemId) {
4557
+ var rec = this._rec();
4558
+ if (rec.inflight[itemId]) return "active";
4559
+ for (var i = 0; i < rec.queue.length; i++) if (rec.queue[i].id === itemId) return "active";
4560
+ if (rec.failed[itemId]) return "failed";
4561
+ return null;
4562
+ }
4563
+ /**
4564
+ * PUBLIC DELEGATE, for a client that maps and merges its own history page.
4565
+ *
4566
+ * agent.vue keeps a forked mapper and a forked first-page merge (its mount path
4567
+ * runs them, while resumePolling routes through loadHistory below), so both
4568
+ * paths are live for the SAME row inside one component. Adoption is part of the
4569
+ * merge contract, not an optional extra: without it that fork erases a streamed
4570
+ * answer off the screen on every turn, which is the whole of MAJOR 3.
4571
+ *
4572
+ * Exposed rather than reimplemented because the rule needs the session's own
4573
+ * `incomplete` set, which the pure helper (history.ts adoptLocalAnswerIntoPage)
4574
+ * cannot see. A client that reached for the helper alone would adopt a TRUNCATED
4575
+ * answer over the row and clear the marker that would have gone back for the
4576
+ * rest - a fork that reads as correct and loses text.
4577
+ *
4578
+ * Call it exactly where loadHistory does: on the freshly mapped page, after
4579
+ * applyHydratedBodies and BEFORE the page replaces or merges into state.messages.
4580
+ */
4581
+ adoptLocalAnswers(mapped, loadKey) {
4582
+ this._adoptLocalAnswers(mapped, loadKey);
4583
+ }
4584
+ /**
4585
+ * Queue the on-screen turns whose answer is only in the chunk store, newest
4586
+ * first, and start draining. Never blocks and never throws.
4587
+ *
4588
+ * `ownerKey` is the chat the queue entries belong to, snapshotted by the caller:
4589
+ * a recovery that lands after the user has moved on writes into that chat's
4590
+ * cache, never into whatever list is on screen by then.
4591
+ */
4592
+ _scheduleStreamRecovery(ownerKey, platform, projectId, owner) {
4593
+ if (!streamRecoveryEnabled()) return;
4594
+ var rec = this._rec();
4595
+ var wanted = [];
4596
+ for (var i = this.state.messages.length - 1; i >= 0; i--) {
4597
+ var m = this.state.messages[i];
4598
+ if (!m || m.role !== "assistant" || !m._streamPending || !m._serverItemId) continue;
4599
+ var id = m._serverItemId;
4600
+ if (rec.attempted[id]) continue;
4601
+ if (this.liveStreams[id]) continue;
4602
+ if (rec.queue.some(function(e) {
4603
+ return e.id === id;
4604
+ })) continue;
4605
+ wanted.push(id);
4606
+ if (wanted.length >= STREAM_RECOVERY_PER_LOAD) break;
4607
+ }
4608
+ if (!wanted.length) return;
4609
+ for (var w = 0; w < wanted.length; w++) {
4610
+ rec.queue.push({ id: wanted[w], ownerKey, platform, projectId, owner });
4611
+ this._markRecoveryPhase(wanted[w], "active");
4612
+ }
4613
+ this._drainStreamRecovery();
4614
+ }
4615
+ /**
4616
+ * PUBLIC DELEGATE, the other half of what a forked history path needs.
4617
+ *
4618
+ * Same reason as adoptLocalAnswers: agent.vue's mount path never calls
4619
+ * loadHistory, so without this its pages would MARK unfinalized streamed turns
4620
+ * and then never read them back - CRITICAL 1 left unfixed on the client's
4621
+ * primary path, with the marker making it look handled.
4622
+ *
4623
+ * Takes the load's SNAPSHOTTED identity rather than reading it live, and that is
4624
+ * the reason this exists instead of the caller looping over recoverStreamedAnswer:
4625
+ * that one reads getIdentity() at call time (right, for an on-demand affordance
4626
+ * the user just clicked), which after a project switch racing the load would
4627
+ * finalize the turn against the project they switched TO. Call it AFTER the page
4628
+ * is rendered and the loading flags are cleared - it must never hold up the
4629
+ * conversation it belongs to.
4630
+ */
4631
+ scheduleStreamRecovery(ownerKey, platform, projectId, owner) {
4632
+ this._scheduleStreamRecovery(ownerKey, platform, projectId, owner);
4633
+ }
4634
+ /** Serial drain of the recovery queue. Each entry is one full chunk read. */
4635
+ _drainStreamRecovery() {
4636
+ var rec = this._rec();
4637
+ if (rec.running) return;
4638
+ var next = rec.queue.shift();
4639
+ if (!next) return;
4640
+ rec.running = true;
4641
+ var self = this;
4642
+ this._readBackStreamedTurn(next.id, next.ownerKey, next.platform, next.projectId, next.owner).catch(function() {
4643
+ }).then(function() {
4644
+ self._rec().running = false;
4645
+ self._drainStreamRecovery();
4646
+ });
4647
+ }
4648
+ /**
4649
+ * Read one unfinalized streamed turn back out of the chunk store and put its
4650
+ * answer where the turn's answer belongs.
4651
+ *
4652
+ * Public because the cap above is deliberately small: a host that wants to offer
4653
+ * "load the rest" on an older recoverable turn calls this with its
4654
+ * `_serverItemId`, and gets the same path the automatic recovery uses. Safe to
4655
+ * call for an id that turns out not to be recoverable, and safe to call twice -
4656
+ * a second call while the first is still in flight is a no-op.
4657
+ *
4658
+ * THIS IS THE USER ASKING, and that is why it passes `manual`. The automatic
4659
+ * recovery refuses a row it has already tried, so that a re-render, or the
4660
+ * history load that every visibilitychange fires, cannot loop on the same
4661
+ * chunks. A click is neither of those: it is one bounded request that a person
4662
+ * asked for, and applying the loop guard to it made the affordance a button that
4663
+ * silently did nothing for exactly the rows most likely to have it - every row
4664
+ * an earlier read touched and could not settle.
4665
+ */
4666
+ recoverStreamedAnswer(itemId) {
4667
+ if (!itemId) return Promise.resolve();
4668
+ var id = this.host.getIdentity();
4669
+ var platform = id && id.platform === "openai" ? "openai" : "claude";
4670
+ return this._readBackStreamedTurn(itemId, this.getHistoryCacheKey(), platform, id ? id.projectId : "", id ? id.owner : "", true);
4671
+ }
4672
+ _readBackStreamedTurn(itemId, ownerKey, platform, projectId, owner, manual) {
4673
+ var cfg = chatEngineConfig();
4674
+ var read = cfg.clientSecretRequestStream;
4675
+ if (!read || !itemId) return Promise.resolve();
4676
+ if (this._rec().inflight[itemId]) return Promise.resolve();
4677
+ if (!manual && this._rec().attempted[itemId]) {
4678
+ this._markRecoveryPhase(itemId, null);
4679
+ return Promise.resolve();
4680
+ }
4681
+ this._rec().attempted[itemId] = true;
4682
+ this._rec().inflight[itemId] = true;
4683
+ delete this._rec().failed[itemId];
4684
+ this._markRecoveryPhase(itemId, "active");
4685
+ var self = this;
4686
+ var url = platform === "openai" ? OPENAI_RESPONSES_API_URL : ANTHROPIC_MESSAGES_API_URL;
4687
+ var parser = createSseParser();
4688
+ var fed = false;
4689
+ return Promise.resolve(read(itemId, {
4690
+ url,
4691
+ method: "POST",
4692
+ service: projectId,
4693
+ owner,
4694
+ since: 0,
4695
+ onStream: function(chunk) {
4696
+ if (typeof chunk !== "string" || !chunk) return;
4697
+ fed = true;
4698
+ parser.feed(chunk);
4699
+ }
4700
+ })).then(function(res) {
4701
+ if (isPollStopped(res)) {
4702
+ delete self._rec().attempted[itemId];
4703
+ delete self._rec().inflight[itemId];
4704
+ self._markRecoveryPhase(itemId, null);
4705
+ return;
4706
+ }
4707
+ var envelope = isCsrStatusEnvelope(res);
4708
+ var body = null;
4709
+ if (res && !envelope) {
4710
+ body = res;
4711
+ } else if (fed) {
4712
+ parser.end();
4713
+ body = parser.finalBody();
4714
+ }
4715
+ var snap = parser.snapshot();
4716
+ var fromRow = !!(res && !envelope);
4717
+ var rowStatus = envelope && typeof res.status === "string" ? res.status : void 0;
4718
+ var degraded = !!(envelope && res && res.more === true);
4719
+ var store = !fromRow && !degraded && body != null && mayKeepStreamedAnswer(snap, rowStatus);
4720
+ delete self._rec().inflight[itemId];
4721
+ delete self._rec().failed[itemId];
4722
+ self._markRecoveryPhase(itemId, null);
4723
+ if (degraded) {
4724
+ delete self._rec().attempted[itemId];
4725
+ }
4726
+ self._applyRecoveredAnswer(itemId, ownerKey, platform, projectId, owner, body, store, degraded);
4727
+ }, function(err) {
4728
+ console.warn("[chat-engine] could not read back a streamed turn", itemId, err);
4729
+ delete self._rec().attempted[itemId];
4730
+ delete self._rec().inflight[itemId];
4731
+ self._rec().failed[itemId] = true;
4732
+ self._markRecoveryPhase(itemId, "failed");
4733
+ });
4734
+ }
4735
+ /**
4736
+ * Write a recovered answer into the turn's bubble (or into the owning chat's
4737
+ * cache when the reader has moved on), then store it as the version history
4738
+ * keeps.
4739
+ *
4740
+ * FINALIZING IS WHAT MAKES THIS RUN ONCE. It copies the answer onto the row and
4741
+ * releases the chunks, so the next load reads an ordinary turn and no recovery is
4742
+ * scheduled for it ever again, by anyone, in any tab. `store` is the caller's
4743
+ * decision and carries two gates at once: mayKeepStreamedAnswer, the SAME keep
4744
+ * policy the live settle applies (an incomplete, errored or failed read is shown
4745
+ * but never stored, because storing it would make the truncation permanent and
4746
+ * delete the part that was missing), and whether the body is new at all (one
4747
+ * that came off the row is already stored).
4748
+ */
4749
+ _applyRecoveredAnswer(itemId, ownerKey, platform, projectId, owner, body, store, degraded) {
4750
+ var text = "";
4751
+ var isErr = isErrorResponseBody(body);
4752
+ if (body != null && !isErr) {
4753
+ text = ((platform === "openai" ? extractOpenAIText(body) : extractClaudeText(body)) || "").trim();
4754
+ }
4755
+ if (!text && !isErr) {
4756
+ if (degraded) {
4757
+ return;
4758
+ }
4759
+ this._clearStreamPendingMark(itemId, ownerKey, true);
4760
+ return;
4761
+ }
4762
+ var reply = isErr ? { role: "assistant", content: getErrorMessage(body), isError: true, _serverItemId: itemId } : { role: "assistant", content: text, _serverItemId: itemId };
4763
+ if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
4764
+ this._applyReplyToCache(ownerKey, reply, itemId);
4765
+ } else {
4766
+ var idx = -1;
4767
+ for (var i = 0; i < this.state.messages.length; i++) {
4768
+ var m = this.state.messages[i];
4769
+ if (m && m.role === "assistant" && m._serverItemId === itemId) {
4770
+ idx = i;
4771
+ break;
4772
+ }
4773
+ }
4774
+ if (idx === -1) {
4775
+ this._applyReplyToCache(ownerKey, reply, itemId);
4776
+ } else {
4777
+ var prev = this.state.messages[idx];
4778
+ if (prev._ts !== void 0) reply._ts = prev._ts;
4779
+ if (prev._ownerKey !== void 0) reply._ownerKey = prev._ownerKey;
4780
+ this.state.messages[idx] = reply;
4781
+ this.updateHistoryCache();
4782
+ this.host.notify();
4783
+ }
4784
+ }
4785
+ if (!degraded) delete this._rec().incomplete[itemId];
4786
+ if (!store || body == null || isErr) return;
4787
+ var fin = chatEngineConfig().clientSecretRequestFinalize;
4788
+ if (!fin) return;
4789
+ var url = platform === "openai" ? OPENAI_RESPONSES_API_URL : ANTHROPIC_MESSAGES_API_URL;
4790
+ try {
4791
+ Promise.resolve(fin(itemId, body, { url, method: "POST", service: projectId, owner })).catch(function(err) {
4792
+ console.warn("[chat-engine] finalize of a recovered turn failed", err);
4793
+ });
4794
+ } catch (e) {
4795
+ console.warn("[chat-engine] finalize of a recovered turn threw", e);
4796
+ }
4797
+ }
4798
+ /**
4799
+ * Take the "answer is elsewhere" marker off a turn once it is settled one way or
4800
+ * the other. `drop` removes an assistant bubble that turned out to have no answer
4801
+ * at all, which restores exactly the list the mapper used to produce for such a
4802
+ * row (none), rather than leaving a permanently empty bubble behind.
4803
+ *
4804
+ * ONLY EVER CALLED FOR A TURN THAT WAS ACTUALLY READ. The marker is the one thing
4805
+ * that keeps an unrecovered answer reachable, so it comes off only on the strength
4806
+ * of an answer (the recovery wrote one) or of a read that came back empty. A read
4807
+ * that FAILED, or one that was STOPPED, knows neither, and taking the marker off
4808
+ * on either of those is how a bubble ends up empty forever with its answer still
4809
+ * in the chunk table. `drop` is likewise never passed for a bubble that HAS
4810
+ * content: an empty row is an empty turn, a failed read is not.
4811
+ */
4812
+ _clearStreamPendingMark(itemId, ownerKey, drop) {
4813
+ if (ownerKey && this.getHistoryCacheKey() !== ownerKey) return;
4814
+ var changed = false;
4815
+ for (var i = this.state.messages.length - 1; i >= 0; i--) {
4816
+ var m = this.state.messages[i];
4817
+ if (!m || m.role !== "assistant" || m._serverItemId !== itemId) continue;
4818
+ if (!m._streamPending) continue;
4819
+ if (drop && !m.content) {
4820
+ this.state.messages.splice(i, 1);
4821
+ changed = true;
4822
+ continue;
4823
+ }
4824
+ m._streamPending = false;
4825
+ changed = true;
4826
+ }
4827
+ if (changed) {
4828
+ this.updateHistoryCache();
4829
+ this.host.notify();
4830
+ }
4831
+ }
3344
4832
  /**
3345
4833
  * Stop and forget one item's poll. Used after a cancel: the row is either gone
3346
4834
  * (cancelled while queued) or flagged cancelled (cancelled while running), so
@@ -3655,7 +5143,12 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3655
5143
  dispatchItemId = initial.id;
3656
5144
  if (typeof params.onItemId === "function") params.onItemId(initial.id);
3657
5145
  }
3658
- var dp = self._fgPollWithEarlyProbe(initial, initial.id);
5146
+ var dp = self._fgPollWithEarlyProbe(initial, initial.id, void 0, {
5147
+ platform: params.aiPlatform,
5148
+ projectId: params.projectId,
5149
+ owner: params.owner,
5150
+ ownerKey: params.key
5151
+ });
3659
5152
  if (initial.id) self._trackPoll(initial.id, "fg", dp);
3660
5153
  return dp;
3661
5154
  }
@@ -3868,8 +5361,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
3868
5361
  Promise.resolve(probeBgQueue(
3869
5362
  { service: svcId, owner, platform, queue, status, limit: WORKER_PASS_ADOPT_LIMIT },
3870
5363
  { maxAgeMs: 0 }
3871
- )).then(function(entry) {
3872
- settle(entry.result);
5364
+ )).then(function(entry2) {
5365
+ settle(entry2.result);
3873
5366
  }, function() {
3874
5367
  settle(null);
3875
5368
  });
@@ -4087,7 +5580,12 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4087
5580
  }
4088
5581
  if (serverId) self._stampTurnWithItemId(capturedKey, capturedQueuedLid, void 0, serverId);
4089
5582
  if (result && result.poll && (result.status === "pending" || result.status === "running")) {
4090
- var qp = self._fgPollWithEarlyProbe(result, serverId);
5583
+ var qp = self._fgPollWithEarlyProbe(result, serverId, void 0, {
5584
+ platform: capturedPlatform,
5585
+ projectId: id.projectId,
5586
+ owner: id.owner,
5587
+ ownerKey: capturedKey
5588
+ });
4091
5589
  if (serverId) self._trackPoll(serverId, "fg", qp);
4092
5590
  return qp.then(function(res) {
4093
5591
  if (isPollStopped(res)) return;
@@ -4358,9 +5856,14 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4358
5856
  answer = (answer || "").trim() || "No text response received from AI provider.";
4359
5857
  var lid = this._newLocalId();
4360
5858
  if (targetIdx >= 0 && this.state.messages[targetIdx] && this.state.messages[targetIdx].isPending) {
4361
- this.state.messages[targetIdx] = { role: "assistant", content: "", _localId: lid };
5859
+ var qPainted = this._paintedTextAt(targetIdx);
5860
+ var prevQ = this.state.messages[targetIdx] || {};
5861
+ var qSettled = { role: "assistant", content: qPainted, _localId: lid };
5862
+ if (prevQ._serverItemId) qSettled._serverItemId = prevQ._serverItemId;
5863
+ if (prevQ._ownerKey) qSettled._ownerKey = prevQ._ownerKey;
5864
+ this.state.messages[targetIdx] = qSettled;
4362
5865
  this.host.notify();
4363
- this.enqueueTypewrite(targetIdx, answer, lid);
5866
+ this.enqueueTypewrite(targetIdx, answer, lid, qPainted);
4364
5867
  } else if (targetIdx >= 0) {
4365
5868
  this.state.messages.splice(targetIdx, 0, { role: "assistant", content: "", _localId: lid });
4366
5869
  this.host.notify();
@@ -4622,7 +6125,14 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4622
6125
  // renders self-throttles to what the machine can actually paint.
4623
6126
  // * rAF paces us to the browser's paint cycle and pauses in background
4624
6127
  // tabs, so we never queue work faster than it can be drawn.
4625
- typewriteIntoIndex(idx, fullText, localId) {
6128
+ //
6129
+ // `paintedText` is what a LIVE STREAM already put in this bubble. The reveal
6130
+ // starts from the point the two texts stop agreeing rather than from zero: the
6131
+ // authoritative answer still replaces the live one character for character (it is
6132
+ // the only source of truth, and this method writes fullText and nothing else), but
6133
+ // retyping a paragraph the reader has just watched arrive is the one thing that
6134
+ // would make a streamed turn look worse than an unstreamed one.
6135
+ typewriteIntoIndex(idx, fullText, localId, paintedText) {
4626
6136
  var self = this;
4627
6137
  if (!fullText) return Promise.resolve();
4628
6138
  var CHARS_PER_SEC = 300;
@@ -4638,7 +6148,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4638
6148
  });
4639
6149
  this.state.typing = true;
4640
6150
  this.state.typingAbort = false;
4641
- var i = 0;
6151
+ var i = paintedText ? typewriterResumeIndex(paintedText, fullText, regions) : 0;
4642
6152
  var last = nowMs();
4643
6153
  return new Promise(function(resolve) {
4644
6154
  var done = false;
@@ -4653,16 +6163,14 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4653
6163
  if (done) return;
4654
6164
  done = true;
4655
6165
  cleanup();
4656
- if (!self.state.typingAbort) {
4657
- var fi = localId ? self.state.messages.findIndex(function(mm) {
4658
- return mm._localId === localId;
4659
- }) : idx;
4660
- if (fi !== -1) {
4661
- var t = self.state.messages[fi];
4662
- if (t) {
4663
- t.content = fullText;
4664
- self.host.refreshMessageBubble(fi);
4665
- }
6166
+ var fi = localId ? self.state.messages.findIndex(function(mm) {
6167
+ return mm._localId === localId;
6168
+ }) : idx;
6169
+ if (fi !== -1) {
6170
+ var t = self.state.messages[fi];
6171
+ if (t) {
6172
+ t.content = fullText;
6173
+ self.host.refreshMessageBubble(fi);
4666
6174
  }
4667
6175
  }
4668
6176
  self.state.typing = false;
@@ -4721,12 +6229,13 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4721
6229
  nextFrame(frame);
4722
6230
  });
4723
6231
  }
4724
- enqueueTypewrite(idx, fullText, localId) {
6232
+ enqueueTypewrite(idx, fullText, localId, paintedText) {
4725
6233
  var self = this;
4726
6234
  var target = this.state.messages[idx];
4727
6235
  if (target && target._ts === void 0) target._ts = wallClockNow();
6236
+ if (!this.typewriterQueue) this.typewriterQueue = Promise.resolve();
4728
6237
  this.typewriterQueue = this.typewriterQueue.then(function() {
4729
- return self.typewriteIntoIndex(idx, fullText, localId);
6238
+ return self.typewriteIntoIndex(idx, fullText, localId, paintedText);
4730
6239
  });
4731
6240
  return this.typewriterQueue;
4732
6241
  }
@@ -4759,12 +6268,17 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4759
6268
  this.promoteNextQueuedToRunning();
4760
6269
  return Promise.resolve();
4761
6270
  }
6271
+ var painted = this._paintedTextAt(pendingIdx);
4762
6272
  var lid = this._newLocalId();
4763
- this.state.messages[pendingIdx] = { role: "assistant", content: "", isPending: false, _localId: lid };
6273
+ var prevSettled = this.state.messages[pendingIdx] || {};
6274
+ var settled = { role: "assistant", content: painted, isPending: false, _localId: lid };
6275
+ if (prevSettled._serverItemId) settled._serverItemId = prevSettled._serverItemId;
6276
+ if (prevSettled._ownerKey) settled._ownerKey = prevSettled._ownerKey;
6277
+ this.state.messages[pendingIdx] = settled;
4764
6278
  this._removeStrayPendingAssistants();
4765
6279
  this.host.notify();
4766
6280
  this.promoteNextQueuedToRunning();
4767
- return this.enqueueTypewrite(pendingIdx, latest.content, lid);
6281
+ return this.enqueueTypewrite(pendingIdx, latest.content, lid, painted);
4768
6282
  }
4769
6283
  // Remove leftover non-background pending ("Thinking…") assistant bubbles: the
4770
6284
  // duplicate that appears when a concurrent history refetch re-maps the still-
@@ -4794,6 +6308,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4794
6308
  for (var k = this.state.messages.length - 1; k >= 0; k--) {
4795
6309
  var m = this.state.messages[k];
4796
6310
  if (!m || !m.isPending || m.role !== "assistant" || m.isBackgroundTask) continue;
6311
+ if (m._streaming) continue;
4797
6312
  if (this._isLiveImmediatePlaceholder(k)) continue;
4798
6313
  this.state.messages.splice(k, 1);
4799
6314
  }
@@ -4971,10 +6486,11 @@ Index the REMAINING windows - one record per row/item, looking at any page image
4971
6486
  this.updateHistoryCache();
4972
6487
  return;
4973
6488
  }
6489
+ var hPainted = this._paintedTextAt(idx);
4974
6490
  var lid = this._newLocalId();
4975
- this.state.messages[idx] = { role: "assistant", content: "", _localId: lid, _serverItemId: itemId };
6491
+ this.state.messages[idx] = { role: "assistant", content: hPainted, _localId: lid, _serverItemId: itemId };
4976
6492
  this.host.notify();
4977
- this.enqueueTypewrite(idx, text, lid);
6493
+ this.enqueueTypewrite(idx, text, lid, hPainted);
4978
6494
  this.updateHistoryCache();
4979
6495
  return;
4980
6496
  }
@@ -5016,11 +6532,11 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5016
6532
  * path is project-relative ("report.xlsx"), and ONE ChatSession serves every
5017
6533
  * project — unscoped, stopping a file in one project would silently suppress
5018
6534
  * the same filename's continuations in another. */
5019
- _indexKeyOf(entry) {
5020
- if (!entry) return "";
5021
- var file = entry.storagePath || entry.filename;
6535
+ _indexKeyOf(entry2) {
6536
+ if (!entry2) return "";
6537
+ var file = entry2.storagePath || entry2.filename;
5022
6538
  if (!file) return "";
5023
- return indexScopeKey(entry.projectId, entry.platform) + "|" + file;
6539
+ return indexScopeKey(entry2.projectId, entry2.platform) + "|" + file;
5024
6540
  }
5025
6541
  /**
5026
6542
  * Reconcile the bg queue with the files the user has stopped.
@@ -5049,17 +6565,17 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5049
6565
  if (m.isPending || m.isPendingQueued || m.isPendingInProcess) surfaced[m._serverItemId] = true;
5050
6566
  });
5051
6567
  for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
5052
- var entry = this.bgTaskQueue[i];
5053
- var key = this._indexKeyOf(entry);
6568
+ var entry2 = this.bgTaskQueue[i];
6569
+ var key = this._indexKeyOf(entry2);
5054
6570
  if (!key || !this.cancelledIndexKeys.has(key)) continue;
5055
- if (!entry.resumePass && !this.state.stoppedIndexIds[entry.id]) {
6571
+ if (!entry2.resumePass && !this.state.stoppedIndexIds[entry2.id]) {
5056
6572
  this.cancelledIndexKeys.delete(key);
5057
6573
  continue;
5058
6574
  }
5059
- if (surfaced[entry.id]) continue;
6575
+ if (surfaced[entry2.id]) continue;
5060
6576
  this.bgTaskQueue.splice(i, 1);
5061
- this._stopPoll(entry.id);
5062
- this._cancelServerItem(entry.id);
6577
+ this._stopPoll(entry2.id);
6578
+ this._cancelServerItem(entry2.id);
5063
6579
  }
5064
6580
  }
5065
6581
  /**
@@ -5117,8 +6633,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5117
6633
  return Promise.resolve(probeBgQueue(
5118
6634
  { service: svcId, owner, platform, queue, status, limit: WORKER_PASS_ADOPT_LIMIT },
5119
6635
  { maxAgeMs: 0 }
5120
- )).then(function(entry) {
5121
- return entry.result;
6636
+ )).then(function(entry2) {
6637
+ return entry2.result;
5122
6638
  }).catch(function() {
5123
6639
  return null;
5124
6640
  });
@@ -5271,30 +6787,30 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5271
6787
  }
5272
6788
  var bgPollBudget = MAX_CONCURRENT_BG_POLLS - this._countBgPolls();
5273
6789
  var injectedAny = false;
5274
- this.bgTaskQueue.forEach(function(entry) {
5275
- if (entry.projectId !== svcId || entry.platform !== plat) return;
5276
- if (!presentIds[entry.id]) {
5277
- var isRunning = entry.status === "running";
6790
+ this.bgTaskQueue.forEach(function(entry2) {
6791
+ if (entry2.projectId !== svcId || entry2.platform !== plat) return;
6792
+ if (!presentIds[entry2.id]) {
6793
+ var isRunning = entry2.status === "running";
5278
6794
  var userBubble = {
5279
6795
  role: "user",
5280
- content: self.host.formatIndexingLabel(entry.filename, entry.mime, entry.size, entry.storagePath, entry.isReindex, !!entry.resumePass),
6796
+ content: self.host.formatIndexingLabel(entry2.filename, entry2.mime, entry2.size, entry2.storagePath, entry2.isReindex, !!entry2.resumePass),
5281
6797
  isBackgroundTask: true,
5282
- _serverItemId: entry.id,
6798
+ _serverItemId: entry2.id,
5283
6799
  // Structured ref so this live pass groups with the same file's passes
5284
6800
  // rebuilt from history (see indexing_groups.buildChatDisplayList).
5285
6801
  _indexFile: {
5286
- name: entry.filename,
5287
- path: entry.storagePath,
5288
- mime: entry.mime,
5289
- size: entry.size,
5290
- isReindex: !!entry.isReindex,
5291
- continued: !!entry.resumePass
6802
+ name: entry2.filename,
6803
+ path: entry2.storagePath,
6804
+ mime: entry2.mime,
6805
+ size: entry2.size,
6806
+ isReindex: !!entry2.isReindex,
6807
+ continued: !!entry2.resumePass
5292
6808
  }
5293
6809
  };
5294
6810
  if (isRunning) userBubble.isPendingInProcess = true;
5295
6811
  else userBubble.isPendingQueued = true;
5296
- var stageAt = self._stageIndex(self.state.messages, entry.stageId);
5297
- var runningBubble = isRunning ? { role: "assistant", content: "", isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id } : null;
6812
+ var stageAt = self._stageIndex(self.state.messages, entry2.stageId);
6813
+ var runningBubble = isRunning ? { role: "assistant", content: "", isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry2.id } : null;
5298
6814
  if (stageAt === -1) {
5299
6815
  self.state.messages.push(userBubble);
5300
6816
  if (runningBubble) self.state.messages.push(runningBubble);
@@ -5303,16 +6819,16 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5303
6819
  } else {
5304
6820
  self.state.messages.splice(stageAt, 0, userBubble);
5305
6821
  }
5306
- presentIds[entry.id] = true;
6822
+ presentIds[entry2.id] = true;
5307
6823
  injectedAny = true;
5308
6824
  }
5309
- if (bgPollBudget > 0 && !self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === "function") {
6825
+ if (bgPollBudget > 0 && !self.isPollingPaused() && !self.historyItemPolls.has(entry2.id) && typeof entry2.poll === "function") {
5310
6826
  bgPollBudget--;
5311
- var capturedId = entry.id, capturedPlat = plat;
5312
- var capturedEntry = entry;
6827
+ var capturedId = entry2.id, capturedPlat = plat;
6828
+ var capturedEntry = entry2;
5313
6829
  var wasStopped = false;
5314
- var bp = entry.poll({ latency: POLL_INTERVAL });
5315
- self._trackPoll(entry.id, "bg", bp);
6830
+ var bp = entry2.poll({ latency: POLL_INTERVAL });
6831
+ self._trackPoll(entry2.id, "bg", bp);
5316
6832
  bp.then(function(response) {
5317
6833
  if (isPollStopped(response)) {
5318
6834
  wasStopped = true;
@@ -5378,13 +6894,13 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5378
6894
  * client knows DETERMINISTICALLY (see the two call sites in
5379
6895
  * maybeResumeIndexing). Best-effort by contract; identity-checked so a
5380
6896
  * project switch mid-settle cannot stamp the wrong service. */
5381
- _mintDoneMarker(entry) {
6897
+ _mintDoneMarker(entry2) {
5382
6898
  try {
5383
6899
  var mint = chatEngineConfig().mintIndexDoneMarker;
5384
- if (!mint || !entry || !entry.storagePath || !entry.projectId) return;
6900
+ if (!mint || !entry2 || !entry2.storagePath || !entry2.projectId) return;
5385
6901
  var id = this.host.getIdentity();
5386
- if (!id || id.projectId !== entry.projectId) return;
5387
- mint({ service: entry.projectId, storagePath: entry.storagePath });
6902
+ if (!id || id.projectId !== entry2.projectId) return;
6903
+ mint({ service: entry2.projectId, storagePath: entry2.storagePath });
5388
6904
  } catch (_e) {
5389
6905
  }
5390
6906
  }
@@ -5405,27 +6921,27 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5405
6921
  * maybeResumeIndexing's single-pass branch); paged files stay with their
5406
6922
  * drivers. Outcome is read from the settled bubbles' own flags, which is
5407
6923
  * all the history mapping left us. Best-effort and idempotent throughout. */
5408
- _flipRunFromSettledEntry(entry) {
6924
+ _flipRunFromSettledEntry(entry2) {
5409
6925
  try {
5410
- if (!entry || !entry.storagePath || !entry.id || !entry.projectId) return;
5411
- if (isPagedReadFile(entry.filename, entry.mime)) return;
5412
- if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
5413
- if (this.state.stoppedIndexIds[entry.id]) return;
6926
+ if (!entry2 || !entry2.storagePath || !entry2.id || !entry2.projectId) return;
6927
+ if (isPagedReadFile(entry2.filename, entry2.mime)) return;
6928
+ if (this.cancelledIndexKeys.has(this._indexKeyOf(entry2))) return;
6929
+ if (this.state.stoppedIndexIds[entry2.id]) return;
5414
6930
  var userMsg = null, replyMsg = null;
5415
6931
  this.state.messages.forEach(function(m) {
5416
- if (m._serverItemId !== entry.id) return;
6932
+ if (m._serverItemId !== entry2.id) return;
5417
6933
  if (m.role === "user") {
5418
6934
  if (!userMsg) userMsg = m;
5419
6935
  } else if (!replyMsg) replyMsg = m;
5420
6936
  });
5421
6937
  if (userMsg && userMsg.isCancelled || replyMsg && replyMsg.isCancelled) {
5422
- this._flipRunRecord(entry, "cancelled");
6938
+ this._flipRunRecord(entry2, "cancelled");
5423
6939
  } else if (replyMsg && replyMsg.isError) {
5424
6940
  var errText = typeof replyMsg.content === "string" ? replyMsg.content.replace(/\s+/g, " ").trim().slice(0, 300) : "";
5425
- this._flipRunRecord(entry, "error", errText || "Indexing failed.");
6941
+ this._flipRunRecord(entry2, "error", errText || "Indexing failed.");
5426
6942
  } else if (replyMsg) {
5427
- this._mintDoneMarker(entry);
5428
- this._flipRunRecord(entry, "done");
6943
+ this._mintDoneMarker(entry2);
6944
+ this._flipRunRecord(entry2, "done");
5429
6945
  }
5430
6946
  } catch (_e) {
5431
6947
  }
@@ -5436,55 +6952,55 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5436
6952
  * mid-settle — otherwise the record lies 'working' forever. Best-effort
5437
6953
  * through upsertIndexRunRecordSafe; the consumer's precedence guard keeps
5438
6954
  * repeats and races harmless. */
5439
- _flipRunRecord(entry, status, error) {
5440
- if (!entry || !entry.storagePath || !entry.projectId) return;
6955
+ _flipRunRecord(entry2, status, error) {
6956
+ if (!entry2 || !entry2.storagePath || !entry2.projectId) return;
5441
6957
  var patch = { status, finished: Date.now() };
5442
6958
  if (error) patch.error = error;
5443
- upsertIndexRunRecordSafe(entry.projectId, entry.storagePath, patch);
6959
+ upsertIndexRunRecordSafe(entry2.projectId, entry2.storagePath, patch);
5444
6960
  }
5445
- maybeResumeIndexing(entry, response, platform) {
6961
+ maybeResumeIndexing(entry2, response, platform) {
5446
6962
  var self = this;
5447
6963
  var endOfClientChain = function() {
5448
6964
  self._nudgeIndexingDrain();
5449
6965
  };
5450
6966
  try {
5451
- if (!entry || !entry.storagePath) return;
5452
- if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
5453
- if (!isPagedReadFile(entry.filename, entry.mime)) {
6967
+ if (!entry2 || !entry2.storagePath) return;
6968
+ if (this.cancelledIndexKeys.has(this._indexKeyOf(entry2))) return;
6969
+ if (!isPagedReadFile(entry2.filename, entry2.mime)) {
5454
6970
  if (!isErrorResponseBody(response) && !this._isCancelledPollResult(response)) {
5455
- this._mintDoneMarker(entry);
5456
- this._flipRunRecord(entry, "done");
6971
+ this._mintDoneMarker(entry2);
6972
+ this._flipRunRecord(entry2, "done");
5457
6973
  } else if (this._isCancelledPollResult(response)) {
5458
- this._flipRunRecord(entry, "cancelled");
6974
+ this._flipRunRecord(entry2, "cancelled");
5459
6975
  } else {
5460
- this._flipRunRecord(entry, "error", this._runErrorText(response));
6976
+ this._flipRunRecord(entry2, "error", this._runErrorText(response));
5461
6977
  }
5462
6978
  endOfClientChain();
5463
6979
  return;
5464
6980
  }
5465
- if (isImageVisionFile(entry.filename, entry.mime)) return;
5466
- if (windowedIndexingEnabled() && isWindowedReadFile(entry.filename, entry.mime)) return;
6981
+ if (isImageVisionFile(entry2.filename, entry2.mime)) return;
6982
+ if (windowedIndexingEnabled() && isWindowedReadFile(entry2.filename, entry2.mime)) return;
5467
6983
  if (isErrorResponseBody(response)) {
5468
- this._flipRunRecord(entry, "error", this._runErrorText(response));
6984
+ this._flipRunRecord(entry2, "error", this._runErrorText(response));
5469
6985
  endOfClientChain();
5470
6986
  return;
5471
6987
  }
5472
6988
  var answer = (platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "";
5473
6989
  if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) {
5474
- this._mintDoneMarker(entry);
5475
- this._flipRunRecord(entry, "done");
6990
+ this._mintDoneMarker(entry2);
6991
+ this._flipRunRecord(entry2, "done");
5476
6992
  endOfClientChain();
5477
6993
  return;
5478
6994
  }
5479
- var pass = (entry.resumePass || 0) + 1;
6995
+ var pass = (entry2.resumePass || 0) + 1;
5480
6996
  if (pass > MAX_INDEXING_RESUME_PASSES) {
5481
- this._flipRunRecord(entry, "error", "Stopped after " + MAX_INDEXING_RESUME_PASSES + " passes without finishing.");
6997
+ this._flipRunRecord(entry2, "error", "Stopped after " + MAX_INDEXING_RESUME_PASSES + " passes without finishing.");
5482
6998
  endOfClientChain();
5483
6999
  return;
5484
7000
  }
5485
7001
  var id = this.host.getIdentity();
5486
- if (!id || id.platform === "none" || id.projectId !== entry.projectId) {
5487
- this._flipRunRecord(entry, "error", "Indexing stopped: the session or project changed before the file finished.");
7002
+ if (!id || id.platform === "none" || id.projectId !== entry2.projectId) {
7003
+ this._flipRunRecord(entry2, "error", "Indexing stopped: the session or project changed before the file finished.");
5488
7004
  endOfClientChain();
5489
7005
  return;
5490
7006
  }
@@ -5502,10 +7018,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5502
7018
  serviceName: id.serviceName,
5503
7019
  serviceDescription: id.serviceDescription,
5504
7020
  attachment: {
5505
- name: entry.filename,
5506
- storagePath: entry.storagePath,
5507
- mime: entry.mime,
5508
- size: entry.size,
7021
+ name: entry2.filename,
7022
+ storagePath: entry2.storagePath,
7023
+ mime: entry2.mime,
7024
+ size: entry2.size,
5509
7025
  url: ""
5510
7026
  }
5511
7027
  }).then(function(ack) {
@@ -5514,11 +7030,11 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5514
7030
  projectId: id.projectId,
5515
7031
  platform: id.platform,
5516
7032
  id: ack.id,
5517
- filename: entry.filename,
5518
- storagePath: entry.storagePath,
5519
- isReindex: entry.isReindex,
5520
- mime: entry.mime,
5521
- size: entry.size,
7033
+ filename: entry2.filename,
7034
+ storagePath: entry2.storagePath,
7035
+ isReindex: entry2.isReindex,
7036
+ mime: entry2.mime,
7037
+ size: entry2.size,
5522
7038
  status: ack.status === "running" ? "running" : "pending",
5523
7039
  poll: ack.poll,
5524
7040
  resumePass: pass
@@ -5605,6 +7121,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5605
7121
  formatIndexingLabel: self.host.formatIndexingLabel
5606
7122
  }).messages;
5607
7123
  self.applyHydratedBodies(mapped);
7124
+ self._adoptLocalAnswers(mapped, loadKey);
5608
7125
  var keptOlderPages = false;
5609
7126
  var keptScreenAwaitingBg = false;
5610
7127
  if (fetchMore) {
@@ -5780,6 +7297,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5780
7297
  }
5781
7298
  self.updateHistoryCache();
5782
7299
  self.host.notify();
7300
+ self._scheduleStreamRecovery(loadKey, platform, projectId, owner);
5783
7301
  var bgPending = !fetchMore && history && history.bgPending;
5784
7302
  if (bgPending) {
5785
7303
  var batchId = ++_bgHistoryBatchSeq;
@@ -5929,7 +7447,12 @@ Index the REMAINING windows - one record per row/item, looking at any page image
5929
7447
  }
5930
7448
  }
5931
7449
  };
5932
- var pp = isBg ? item.poll(Object.assign({ latency: POLL_INTERVAL }, pollOpts)) : self._fgPollWithEarlyProbe(item, capturedId, pollOpts);
7450
+ var pp = isBg ? item.poll(Object.assign({ latency: POLL_INTERVAL }, pollOpts)) : self._fgPollWithEarlyProbe(item, capturedId, pollOpts, {
7451
+ platform,
7452
+ projectId,
7453
+ owner,
7454
+ ownerKey: loadKey
7455
+ });
5933
7456
  self._trackPoll(capturedId, item._isBgTask || item._isOnBgQueue ? "bg" : "fg", pp);
5934
7457
  if (pp && pp.catch) pp.catch(function() {
5935
7458
  });
@@ -6599,11 +8122,90 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6599
8122
  return out;
6600
8123
  }
6601
8124
 
8125
+ // src/engine/project_settings.ts
8126
+ var UPLOAD_ACCESS_GROUPS = ["public", "authorized", "private"];
8127
+ var DEFAULT_UPLOAD_ACCESS_GROUP = "authorized";
8128
+ var PROJECT_SETTINGS_UNIQUE_ID = "bq::settings";
8129
+ var UPLOAD_ACCESS_LABELS = {
8130
+ public: "Public",
8131
+ authorized: "Signed in users",
8132
+ private: "Only me"
8133
+ };
8134
+ var UPLOAD_ACCESS_HINTS = {
8135
+ public: "Anyone can ask about this file, including visitors who are not logged in.",
8136
+ authorized: "Only users signed in to this project can ask about this file.",
8137
+ private: "Only you can ask about this file."
8138
+ };
8139
+ function normalizeUploadAccessGroup(value) {
8140
+ return UPLOAD_ACCESS_GROUPS.indexOf(value) === -1 ? DEFAULT_UPLOAD_ACCESS_GROUP : value;
8141
+ }
8142
+ function normalizeProjectAccessSetting(value) {
8143
+ if (value === "ask") return "ask";
8144
+ return UPLOAD_ACCESS_GROUPS.indexOf(value) === -1 ? null : value;
8145
+ }
8146
+ function accessSettingFrom(data) {
8147
+ return normalizeProjectAccessSetting(data?.upload_access_group);
8148
+ }
8149
+ function uploadAccessGroupFrom(data) {
8150
+ const v = accessSettingFrom(data);
8151
+ return v && v !== "ask" ? v : DEFAULT_UPLOAD_ACCESS_GROUP;
8152
+ }
8153
+ function asksUploadAccessFrom(data) {
8154
+ return accessSettingFrom(data) === "ask";
8155
+ }
8156
+ var reader = null;
8157
+ var cache = /* @__PURE__ */ new Map();
8158
+ function configureProjectSettings(fn) {
8159
+ reader = fn;
8160
+ }
8161
+ function entry(service) {
8162
+ let e = cache.get(service);
8163
+ if (!e) {
8164
+ e = { data: null, settled: false, inflight: null };
8165
+ cache.set(service, e);
8166
+ }
8167
+ return e;
8168
+ }
8169
+ function loadProjectSettings(service) {
8170
+ if (!service) return Promise.resolve(null);
8171
+ const e = entry(service);
8172
+ if (e.settled) return Promise.resolve(e.data);
8173
+ if (e.inflight) return e.inflight;
8174
+ if (!reader) return Promise.resolve(null);
8175
+ const run = reader(service).then((data) => data && typeof data === "object" ? data : null).catch(() => null).then((data) => {
8176
+ const cur = entry(service);
8177
+ if (cur.inflight === run) {
8178
+ cur.data = data;
8179
+ cur.settled = true;
8180
+ cur.inflight = null;
8181
+ }
8182
+ return data;
8183
+ });
8184
+ e.inflight = run;
8185
+ return run;
8186
+ }
8187
+ function primeProjectSettings(service) {
8188
+ void loadProjectSettings(service);
8189
+ }
8190
+ function readyProjectSettings(service) {
8191
+ return loadProjectSettings(service);
8192
+ }
8193
+ function cachedProjectSettings(service) {
8194
+ const e = cache.get(service);
8195
+ return e && e.settled ? e.data : null;
8196
+ }
8197
+ function projectUploadAccessGroup(service) {
8198
+ return uploadAccessGroupFrom(cachedProjectSettings(service));
8199
+ }
8200
+ function projectAsksUploadAccess(service) {
8201
+ return asksUploadAccessFrom(cachedProjectSettings(service));
8202
+ }
8203
+
6602
8204
  // src/index.js
6603
8205
  (function() {
6604
8206
  var MCP_PROD = "https://mcp.broadwayinc.computer";
6605
8207
  var MCP_DEV = "https://mcp-dev.broadwayinc.computer";
6606
- var BQ_VERSION = "1.9.7" ;
8208
+ var BQ_VERSION = "1.10.0" ;
6607
8209
  var ATTACHMENT_URL_EXPIRES_SECONDS = 600;
6608
8210
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
6609
8211
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
@@ -6680,9 +8282,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
6680
8282
  }
6681
8283
  }
6682
8284
  function base64UrlEncode(bytes) {
6683
- var str = "";
6684
- for (var i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]);
6685
- return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
8285
+ var str2 = "";
8286
+ for (var i = 0; i < bytes.length; i++) str2 += String.fromCharCode(bytes[i]);
8287
+ return btoa(str2).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
6686
8288
  }
6687
8289
  function randBytes(n) {
6688
8290
  var b = new Uint8Array(n);
@@ -8474,7 +10076,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
8474
10076
  jpeg: "image/jpeg",
8475
10077
  gif: "image/gif",
8476
10078
  webp: "image/webp",
8477
- svg: "image/svg+xml"
10079
+ svg: "image/svg+xml",
10080
+ eml: "message/rfc822"
8478
10081
  };
8479
10082
  return map[ext] || null;
8480
10083
  }
@@ -8514,10 +10117,15 @@ Index the REMAINING windows - one record per row/item, looking at any page image
8514
10117
  // buildGreetingEl, so the two can never disagree.
8515
10118
  greeting: greetingParts().text,
8516
10119
  canUpload: !uploadsFrozenForUser(),
8517
- // Where THIS project's indexer writes. The MCP's auto-fill assumes
8518
- // "authorized"; on a project set to public or private that would
8519
- // search the wrong group and answer "nothing found".
8520
- indexAccessGroup: projectUploadAccessGroup()});
10120
+ // Where THIS project's indexer writes, from the "bq::settings"
10121
+ // record. The MCP's auto-fill assumes "authorized"; on a project set
10122
+ // to public or private that would search the wrong group and answer
10123
+ // "nothing found". A SYNC CACHE READ, because the engine calls this
10124
+ // hook from paths with nowhere to put an await. Every send that
10125
+ // reaches it has settled the fetch first: sendMessage awaits
10126
+ // readyProjectSettings on the text-only branch, and the attachment
10127
+ // branch resolves the upload group before it dispatches.
10128
+ indexAccessGroup: projectUploadAccessGroup(S.projectId)});
8521
10129
  }
8522
10130
  function refreshSkapiSession() {
8523
10131
  return S.skapi.getProfile({ refreshToken: true }).then(function() {
@@ -8593,7 +10201,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
8593
10201
  CS.drafting = false;
8594
10202
  syncDraftingIndicator();
8595
10203
  if (!hasAttachments) {
8596
- session.dispatchComposedMessage(text, false);
10204
+ readyProjectSettings(S.projectId).then(function() {
10205
+ session.dispatchComposedMessage(text, false);
10206
+ });
8597
10207
  return;
8598
10208
  }
8599
10209
  attachmentBatchSeq += 1;
@@ -9150,32 +10760,32 @@ Index the REMAINING windows - one record per row/item, looking at any page image
9150
10760
  });
9151
10761
  if (objs.length) appendAttachments(objs);
9152
10762
  }
9153
- function readEntry(entry, prefix) {
10763
+ function readEntry(entry2, prefix) {
9154
10764
  prefix = prefix || "";
9155
10765
  return new Promise(function(resolve) {
9156
- if (!entry) {
10766
+ if (!entry2) {
9157
10767
  resolve([]);
9158
10768
  return;
9159
10769
  }
9160
- if (entry.isFile) {
9161
- entry.file(function(file) {
10770
+ if (entry2.isFile) {
10771
+ entry2.file(function(file) {
9162
10772
  resolve([{ file, path: prefix + file.name }]);
9163
10773
  }, function() {
9164
10774
  resolve([]);
9165
10775
  });
9166
10776
  return;
9167
10777
  }
9168
- if (entry.isDirectory) {
9169
- var reader = entry.createReader();
10778
+ if (entry2.isDirectory) {
10779
+ var reader2 = entry2.createReader();
9170
10780
  var all = [];
9171
10781
  var readBatch = function() {
9172
- reader.readEntries(function(entries) {
10782
+ reader2.readEntries(function(entries) {
9173
10783
  if (!entries.length) {
9174
10784
  resolve(all);
9175
10785
  return;
9176
10786
  }
9177
10787
  Promise.all(entries.map(function(e) {
9178
- return readEntry(e, prefix + entry.name + "/");
10788
+ return readEntry(e, prefix + entry2.name + "/");
9179
10789
  })).then(function(groups) {
9180
10790
  groups.forEach(function(g) {
9181
10791
  all.push.apply(all, g);
@@ -9485,20 +11095,20 @@ Index the REMAINING windows - one record per row/item, looking at any page image
9485
11095
  for (var i = 0; i < items.length; i++) {
9486
11096
  var it = items[i];
9487
11097
  if (it.kind !== "file") continue;
9488
- var entry = it.webkitGetAsEntry ? it.webkitGetAsEntry() : null;
9489
- entries.push(entry || it.getAsFile());
9490
- }
9491
- Promise.all(entries.map(function(entry2) {
9492
- if (!entry2) return Promise.resolve(null);
9493
- if (entry2 instanceof File) return Promise.resolve(newAttachment({ kind: "file", name: entry2.name, file: entry2 }));
9494
- if (entry2.isFile) {
9495
- return readEntry(entry2).then(function(files) {
11098
+ var entry2 = it.webkitGetAsEntry ? it.webkitGetAsEntry() : null;
11099
+ entries.push(entry2 || it.getAsFile());
11100
+ }
11101
+ Promise.all(entries.map(function(entry3) {
11102
+ if (!entry3) return Promise.resolve(null);
11103
+ if (entry3 instanceof File) return Promise.resolve(newAttachment({ kind: "file", name: entry3.name, file: entry3 }));
11104
+ if (entry3.isFile) {
11105
+ return readEntry(entry3).then(function(files) {
9496
11106
  return files[0] ? newAttachment({ kind: "file", name: files[0].file.name, file: files[0].file }) : null;
9497
11107
  });
9498
11108
  }
9499
- if (entry2.isDirectory) {
9500
- return readEntry(entry2).then(function(files) {
9501
- return newAttachment({ kind: "folder", name: entry2.name, files });
11109
+ if (entry3.isDirectory) {
11110
+ return readEntry(entry3).then(function(files) {
11111
+ return newAttachment({ kind: "folder", name: entry3.name, files });
9502
11112
  });
9503
11113
  }
9504
11114
  return Promise.resolve(null);
@@ -9831,8 +11441,18 @@ Index the REMAINING windows - one record per row/item, looking at any page image
9831
11441
  if (msg.isPendingQueued || msg.isPendingOlder) cls.push("is-pending-older");
9832
11442
  if (msg._dimSending || msg._cancelling) cls.push("is-sending-to-server");
9833
11443
  var bubble;
9834
- if (msg.isPending) {
11444
+ if (msg.isPending && !msg._streaming || streamRecoveryPhase(msg) === "active") {
9835
11445
  bubble = h("div", { class: "bq-bubble" }, h("span", { class: "bq-loader" }));
11446
+ } else if (streamRecoveryPhase(msg)) {
11447
+ var labels = streamRecoveryLabels(streamRecoveryPhase(msg));
11448
+ bubble = h("div", { class: "bq-bubble is-stream-recover" + (streamRecoveryPhase(msg) === "failed" ? " is-stream-failed" : "") });
11449
+ bubble.appendChild(h("span", { class: "bq-stream-recover-note", text: labels.note }));
11450
+ var recoverBtn = h("button", { class: "bq-stream-recover-btn", type: "button", text: labels.action });
11451
+ recoverBtn.addEventListener("click", function(e) {
11452
+ e.stopPropagation();
11453
+ session.recoverStreamedAnswer(msg._serverItemId);
11454
+ });
11455
+ bubble.appendChild(recoverBtn);
9836
11456
  } else {
9837
11457
  bubble = h("div", { class: "bq-bubble" });
9838
11458
  if (msg.role === "user" && msg.isPendingQueued) {
@@ -9857,7 +11477,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
9857
11477
  else if (msg.isPendingQueued) bubble.appendChild(h("span", { class: "bq-pending-note", text: "(In queue)" }));
9858
11478
  if (msg.isCancelled) bubble.appendChild(h("span", { class: "bq-cancel-error", text: "(cancelled)" }));
9859
11479
  if (msg._cancelError) bubble.appendChild(h("span", { class: "bq-cancel-error", text: msg._cancelError }));
9860
- var ts = formatChatTimestamp(msg._ts);
11480
+ var ts = msg.isPending ? "" : formatChatTimestamp(msg._ts);
9861
11481
  if (ts) bubble.appendChild(h("time", { class: "bq-msg-time", text: ts }));
9862
11482
  }
9863
11483
  return h("div", { class: cls.join(" "), dataset: { msgIndex: String(idx) } }, bubble);
@@ -10329,8 +11949,12 @@ Index the REMAINING windows - one record per row/item, looking at any page image
10329
11949
  return h(
10330
11950
  "div",
10331
11951
  { class: "bq-history-loading" },
10332
- h("span", { text: "Fetching history" }),
10333
- h("span", { class: "bq-loader" })
11952
+ h(
11953
+ "span",
11954
+ { class: "bq-history-loading-inner" },
11955
+ h("span", { text: "Fetching history" }),
11956
+ h("span", { class: "bq-loader" })
11957
+ )
10334
11958
  );
10335
11959
  }
10336
11960
  function rowAnchorKey(msg, index) {
@@ -10395,8 +12019,12 @@ Index the REMAINING windows - one record per row/item, looking at any page image
10395
12019
  CS.messagesBox.appendChild(h(
10396
12020
  "div",
10397
12021
  { class: "bq-history-loading" },
10398
- h("span", { text: "Loading indexing history" }),
10399
- h("span", { class: "bq-loader" })
12022
+ h(
12023
+ "span",
12024
+ { class: "bq-history-loading-inner" },
12025
+ h("span", { text: "Loading indexing history" }),
12026
+ h("span", { class: "bq-loader" })
12027
+ )
10400
12028
  ));
10401
12029
  }
10402
12030
  CS.messagesBox.appendChild(buildGreetingEl());
@@ -10497,6 +12125,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
10497
12125
  });
10498
12126
  }
10499
12127
  function renderChat() {
12128
+ primeProjectSettings(S.projectId);
10500
12129
  clearImagePreviewCache(S.projectId || "default");
10501
12130
  chatScrollAnchor.forget();
10502
12131
  for (var uk in unavailableLinkMap) delete unavailableLinkMap[uk];
@@ -10695,33 +12324,6 @@ Index the REMAINING windows - one record per row/item, looking at any page image
10695
12324
  overwriteState.resolver = null;
10696
12325
  if (r) r(choice);
10697
12326
  }
10698
- var UPLOAD_ACCESS_GROUPS = ["public", "authorized", "private"];
10699
- var UPLOAD_ACCESS_LABELS = {
10700
- public: "Public",
10701
- authorized: "Signed in users",
10702
- private: "Only me"
10703
- };
10704
- var UPLOAD_ACCESS_HINTS = {
10705
- public: "Anyone can ask about this file, including visitors who are not logged in.",
10706
- authorized: "Only users signed in to this project can ask about this file.",
10707
- private: "Only you can ask about this file."
10708
- };
10709
- function normalizeUploadAccessGroup(v) {
10710
- return UPLOAD_ACCESS_GROUPS.indexOf(v) === -1 ? "authorized" : v;
10711
- }
10712
- function projectAccessSetting() {
10713
- var conf = S.service && S.service.conf || {};
10714
- var v = conf.default_access_group;
10715
- if (v === "ask") return "ask";
10716
- return UPLOAD_ACCESS_GROUPS.indexOf(v) === -1 ? null : v;
10717
- }
10718
- function projectUploadAccessGroup() {
10719
- var v = projectAccessSetting();
10720
- return v && v !== "ask" ? v : "authorized";
10721
- }
10722
- function projectAsksUploadAccess() {
10723
- return projectAccessSetting() === "ask";
10724
- }
10725
12327
  var accessGroupState = { resolver: null, sticky: null, handle: null, applyToAll: false, choice: "authorized", perPath: {} };
10726
12328
  function resetAccessGroupBatch() {
10727
12329
  accessGroupState.sticky = null;
@@ -10741,8 +12343,14 @@ Index the REMAINING windows - one record per row/item, looking at any page image
10741
12343
  }
10742
12344
  var accessGroupChain = Promise.resolve();
10743
12345
  function resolveUploadAccessGroup(storagePath) {
10744
- if (!projectAsksUploadAccess()) return Promise.resolve(projectUploadAccessGroup());
10745
- var fallback = projectUploadAccessGroup();
12346
+ return readyProjectSettings(S.projectId).then(function() {
12347
+ return decideUploadAccessGroup(storagePath);
12348
+ });
12349
+ }
12350
+ function decideUploadAccessGroup(storagePath) {
12351
+ var svc = S.projectId;
12352
+ if (!projectAsksUploadAccess(svc)) return Promise.resolve(projectUploadAccessGroup(svc));
12353
+ var fallback = projectUploadAccessGroup(svc);
10746
12354
  if (accessGroupState.sticky) return Promise.resolve(accessGroupState.sticky);
10747
12355
  var pathKey = String(storagePath || "");
10748
12356
  if (pathKey && accessGroupState.perPath[pathKey]) {
@@ -11049,7 +12657,25 @@ Index the REMAINING windows - one record per row/item, looking at any page image
11049
12657
  allowAnonymous: null,
11050
12658
  // Server-driven windowed indexing; read at configureChatEngine time.
11051
12659
  // Listed here so the defaults object is the full opt surface.
11052
- windowedIndexing: true
12660
+ windowedIndexing: true,
12661
+ // Live streaming of chat turns; read at configureChatEngine time.
12662
+ // OFF until the region's polling worker relays the response bytes:
12663
+ // see the configureChatEngine call for what goes wrong without it.
12664
+ // A REQUEST, not a switch: it is also refused (with a warning, falling
12665
+ // back to buffered) when the embedder's own skapi-js is too old to
12666
+ // carry skapi's half of the stream flag. See skapiSupportsStreaming.
12667
+ liveStreaming: false,
12668
+ // Socket delivery for the streamed reply. OFF unless the embedder asks,
12669
+ // and separately from liveStreaming, because this widget runs on someone
12670
+ // else's page with someone else's skapi instance: skapi's joinRealtime
12671
+ // REPLACES the connection's group rather than adding to it, so for the
12672
+ // length of a turn this would take the room out from under whatever the
12673
+ // host app uses realtime for, and the host would see its own messages
12674
+ // simply stop. Only an embedder who knows their app does not use realtime
12675
+ // (or does not mind) can answer that, so only they can turn it on. It is
12676
+ // purely an accelerator: with it off the reply still streams, just on the
12677
+ // poll's cadence rather than as the text is relayed.
12678
+ liveStreamingRealtime: false
11053
12679
  }, opts || {});
11054
12680
  S.mountEl = mountEl;
11055
12681
  clear(mountEl);
@@ -11058,6 +12684,21 @@ Index the REMAINING windows - one record per row/item, looking at any page image
11058
12684
  applyTheme(loadTheme());
11059
12685
  S.booted = true;
11060
12686
  console.log("[bunnyquery] v" + BQ_VERSION);
12687
+ var canStream = skapiSupportsStreaming(S.skapi);
12688
+ var liveStreaming = S.opts.liveStreaming === true;
12689
+ if (liveStreaming && !canStream) {
12690
+ liveStreaming = false;
12691
+ console.warn(
12692
+ "[bunnyquery] liveStreaming was requested but this page's skapi-js has no clientSecretRequestStream/clientSecretRequestFinalize, so skapi's half of the stream flag would be dropped and every reply would read back empty. Falling back to buffered replies - update skapi-js to enable streaming."
12693
+ );
12694
+ }
12695
+ configureProjectSettings(function(service) {
12696
+ if (!S.skapi || typeof S.skapi.getRecords !== "function") return Promise.resolve(null);
12697
+ return Promise.resolve(S.skapi.getRecords({ service, unique_id: PROJECT_SETTINGS_UNIQUE_ID })).then(function(res) {
12698
+ var rec = res && res.list && res.list[0] || null;
12699
+ return rec && rec.data || null;
12700
+ });
12701
+ });
11061
12702
  configureChatEngine({
11062
12703
  clientSecretRequest: function(o) {
11063
12704
  return S.skapi.clientSecretRequest(o);
@@ -11090,7 +12731,49 @@ Index the REMAINING windows - one record per row/item, looking at any page image
11090
12731
  // on a big file. Pass windowedIndexing: false in init opts to opt back out.
11091
12732
  windowedIndexing: S.opts.windowedIndexing !== false,
11092
12733
  // Client-side attachment parsers (e.g. an .hwp parser) passed via init opts.
11093
- attachmentParsers: S.opts.attachmentParsers || void 0
12734
+ attachmentParsers: S.opts.attachmentParsers || void 0,
12735
+ // ---- live streaming (mirrored in agent.vue's ai_agent.ts) --------
12736
+ // Off by default, and for the same shipping-order reason
12737
+ // windowedIndexing had one: THE RELAYING POLLING WORKER MUST SHIP
12738
+ // FIRST. With this on against a region whose worker does not relay,
12739
+ // the request either has its `since` cursor rejected or the row keeps
12740
+ // an SSE transcript where the readers expect a parsed document, and
12741
+ // the turn reads back as an empty answer. A streamed row settles with
12742
+ // a STATUS AND NO BODY on purpose, so there is no fallback to read.
12743
+ // Flip it per environment once the worker is deployed there; the
12744
+ // widget takes it as an init opt because an embed picks its own
12745
+ // region, where agent.vue flips one module constant.
12746
+ // It also needs a skapi-js that supports `stream`/`onStream`, and the
12747
+ // page's pin is the EMBEDDER's, so the request is granted above by
12748
+ // skapiSupportsStreaming rather than taken on trust here.
12749
+ liveStreaming,
12750
+ // Requires liveStreaming, and cannot outlive it: the AND is what stops an
12751
+ // embedder turning on socket delivery for a reply that is not streamed.
12752
+ liveStreamingRealtime: liveStreaming && S.opts.liveStreamingRealtime === true,
12753
+ // What stores the version of a streamed turn that history keeps. The
12754
+ // engine sends the ASSEMBLED provider body, so a streamed turn reads
12755
+ // back through exactly the extractors a buffered one does, with no
12756
+ // branch in the mapper; storing is also what releases the chunks.
12757
+ // Called only for a streamed turn, and best-effort inside the engine.
12758
+ // Handed over only when the SDK actually has it, so the engine's own
12759
+ // "is this host able to?" checks answer honestly instead of a call
12760
+ // reaching an undefined method mid-turn.
12761
+ clientSecretRequestFinalize: canStream ? function(requestId, data, options) {
12762
+ return S.skapi.clientSecretRequestFinalize(requestId, data, options);
12763
+ } : void 0,
12764
+ // THE SECOND HALF OF THE DURABILITY GUARANTEE. A streamed row settles
12765
+ // with a status and NO body: the answer is chunks until finalize copies
12766
+ // a version onto the row. A row that settles while no poll is attached
12767
+ // (the tab was closed, a mobile browser discarded it, the device slept
12768
+ // and the interval stopped) is therefore never finalized, and without
12769
+ // this hook the engine has no way back to it - the answer reads as gone
12770
+ // from the conversation with every byte of it still stored. Given the
12771
+ // request id this drains that turn's chunks in one pass, and the engine
12772
+ // parses them exactly as it parses a live stream, finalizing what it
12773
+ // read so the row becomes ordinary history and is never re-read.
12774
+ clientSecretRequestStream: canStream ? function(requestId, options) {
12775
+ return S.skapi.clientSecretRequestStream(requestId, options);
12776
+ } : void 0
11094
12777
  });
11095
12778
  if (!S._resizeBound && typeof window !== "undefined" && window.addEventListener) {
11096
12779
  S._resizeBound = true;