bunnyquery 1.8.3 → 1.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/engine.cjs CHANGED
@@ -282,29 +282,39 @@ function groupAttachmentFailures(attachments) {
282
282
 
283
283
  // src/engine/prompts/chat_system_prompt.ts
284
284
  function buildChatSystemPrompt(params) {
285
- const { formattedServiceId, serviceName, serviceDescription } = params;
285
+ const { projectId, serviceName, serviceDescription } = params;
286
286
  let systemPrompt = `
287
- You are a dedicated assistant for the project ID: "${formattedServiceId}".
287
+ You are a dedicated assistant for the project ID: "${projectId}".
288
288
  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.
289
289
  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.
290
- Complete answers over stored data: The database holds one record per spreadsheet row, and each uploaded file becomes many records that usually share a table. So a question about the data almost always spans many records across several files. 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 relevant 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.
290
+ 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" (where the indexer writes; pass access_group explicitly, including 0, to search another group), 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.
291
291
  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.
292
- Embedded values: a search term is often stored inside a larger string. A merchant "GODADDY" appears as "DNH*GODADDY#4070277042", and a card as "4140****2941". Server-side index and tag filters match only exact values or leading prefixes, not substrings, 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.
292
+ Embedded values: a search term is often stored inside a larger string. A merchant "GODADDY" appears as "DNH*GODADDY#4070277042", and a card as "4140****2941". 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.
293
293
  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.
294
294
  - 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.
295
295
  - Most 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) have ALREADY had their text extracted on the server and inlined in the same message between the "BEGIN FILE CONTENT" / "END FILE CONTENT" markers - read it directly there and do NOT call web_fetch for those files. A "[skapi: ...]" note in that block means the file could not be extracted.
296
296
  - 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.
297
- 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, but 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.
298
- 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 \u2014 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 \u2014 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.
299
- 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. Present each result as a markdown link as described above. Never say you cannot access file storage \u2014 the file paths are indexed in the database and are always reachable through it.
300
- File generation: When the user asks you to generate a file \u2014 or to produce specifically-formatted text such as HTML, CSV, JSON, or Markdown \u2014 put the file's full contents inside a fenced code block whose info string is the intended filename WITH its extension (e.g. report.csv), NOT a language name like "csv". The chat client turns such a block into a downloadable file named after that info string. Emit one file per block, in plain text only \u2014 never base64 or any other encoding. Example for CSV:
297
+ 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.
298
+ 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.
299
+ 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.
300
+ 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.
301
+ 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.
302
+ Finding an extracted media file: it is INDEXED, and its location is a stored VALUE. Get it by QUERYING, never by constructing a filename.
303
+ 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.
304
+ 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.
305
+ Link it VERBATIM as [caption](db:<the path>). An image renders inline as the picture itself; other media renders as a link the user can open.
306
+ So "show me the photo of part X" is: find the record for that part, take its "__MEDIA__/..." value, link it.
307
+ IF THAT RECORD HAS NO PATH, JOIN ON LOCATION - this needs nothing to have been enriched. Every media record carries data.anchor (the cell or page it was embedded at), plus data.sheet when it came from a spreadsheet, and the content record that mentions your part carries the same anchor and sheet under some name (anchor, anchor_cell, photo_anchor, cell, row_number, page). So: read the anchor and sheet off the content record, query getRecords with reference "src::<the document>", and take the media record whose data.anchor, data.also_at or tags match the anchor, using data.sheet too when both records carry one. Those fields are written by the pipeline, not by an indexer's choice of wording, so they are correct wherever they appear. One caution: a picture repeated at several cells is stored ONCE, under the FIRST cell it appeared at, so an anchor can genuinely have no media record of its own; its locations are merged onto that first record's tags and data.also_at. Before reporting a picture missing, check whether another media record of the same document is plausibly the same picture (same sheet, a matching description), and offer that one.
308
+ THIS IS NOT ONLY ABOUT SPREADSHEET PHOTOS. Treat "show me the diagram in that deck" or "the picture in that PDF" exactly like a photo request: query for the media record, never reconstruct a filename. For embedded video, audio or a non-picture attachment there is no extracted file: say so plainly and offer the source document.
309
+ A document may still have no media record: it was indexed before the "__MEDIA__" table existed, or its format is one whose embedded files are not extracted. Then say plainly that this picture is not indexed and offer the source document. One missing record is never evidence that media is not stored.
310
+ File generation: When the user asks for DATABASE records as a file (CSV, spreadsheet, export, download), call exportRecordsToFile: it writes the rows on the server, keeps them out of your context, and returns a download_url you paste as the link. Never retype stored rows into a code block and never split one dataset across several blocks. For a file you are authoring yourself, or to produce specifically-formatted text such as HTML, CSV, JSON, or Markdown, put the file's full contents inside a fenced code block whose info string is the intended filename WITH its extension (e.g. report.csv), NOT a language name like "csv". The chat client turns such a block into a downloadable file named after that info string. Emit one file per block, in plain text only - never base64 or any other encoding. Example for CSV:
301
311
  \`\`\`filename.csv
302
312
  item,qty,total
303
313
  Carrots,55,$38.50
304
314
  Mushrooms,41,$73.80
305
315
  Zucchini,29,$43.50
306
316
  \`\`\`
307
- The same pattern applies to any format \u2014 name the block after the file you intend: \`\`\`my-data.json, \`\`\`index.html, \`\`\`sample.txt, and so on.`;
317
+ The same pattern applies to any format - name the block after the file you intend: \`\`\`my-data.json, \`\`\`index.html, \`\`\`sample.txt, and so on.`;
308
318
  if (serviceDescription) {
309
319
  systemPrompt += `
310
320
  Project name: "${serviceName ?? ""}"
@@ -315,18 +325,26 @@ Project description: """${serviceDescription}"""`;
315
325
 
316
326
  // src/engine/prompts/indexing_system_prompt.ts
317
327
  function buildIndexingSystemPrompt(params) {
318
- const { service, serviceName, serviceDescription } = params;
319
- let systemPrompt = `You are a background indexing agent for project ${service}.
328
+ const { projectId, serviceName, serviceDescription } = params;
329
+ let systemPrompt = `You are a background indexing agent for project ${projectId}.
320
330
  - 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.
321
331
  - 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.
322
- - 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). For big spreadsheets and big text/data files READ THE FILE 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.)
332
+ - 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.)
323
333
  - 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.
324
334
  - 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).
325
- - Whatever the file type, use the file's storage path (the "storage path" metadata line) as the "src::" unique_id - never the inline content or a temporary URL.
326
- - 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 dedicated table (e.g. "spreadsheet_rows"). Do NOT summarize, sample only a few rows, or save just file metadata - index the whole sheet, paging through it with readFileContent when it is large. Make MULTIPLE postRecords calls in batches (e.g. 30-50 rows per call) rather than one oversized call. This per-row completeness OVERRIDES brevity. ALSO save one file-level summary record (file name, sheet name(s), column headers, total row count, overall summary) - this is the record that carries the file's "src::" unique_id - and link EVERY per-row record to it via reference (set each row record's reference to that src:: file record; the row records themselves do NOT carry a src:: unique_id). 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.
327
- - 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 a dedicated table (e.g. "book_chapters") - never collapse the whole book into a single record. Each chapter 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 save one book-level record (title, author, language, overall summary, chapter list / table of contents, genre/subjects) and link each chapter record to it via reference. This per-chapter completeness OVERRIDES brevity; human-readable summaries only, never raw/binary bytes.
328
- - This is a background indexing task: do ALL the MCP saving FIRST, never reply mid-task, and never ask the user questions. Always use the MCP tools to save what you learn - 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 binary bytes (base64, blobs); describe them in human-readable text instead.
329
- - COMPLETION SIGNAL: only when you have fully read and saved the ENTIRE file (for readFileContent files: reached "END OF FILE"; for PDFs: the embedded page-image note said it was the LAST window - with all rows/pages/items saved), end your final message with the token INDEXING_COMPLETE on its own line. If you did NOT finish the whole file (more rows/pages remain), do NOT write that token - leaving it out is how the system knows to run another pass to continue.
335
+ - TRANSCRIBE, DO NOT DESCRIBE. When an image contains ANY text - a label, tag, stamp, form field, serial/part number, handwriting - your FIRST job is to read the characters out and store them VERBATIM, not to describe the scene. A record saying "a red inspection tag with handwritten markings" is worthless: it is unsearchable and every such photo produces the same sentence. Put the characters you can actually read into these EXACT fields, not variations of them: "printed_text" (the pre-printed wording), "handwritten_text" (what a person wrote by hand), and, when you can resolve one, "part_no", "tag_id" and "date". Same reason as the fixed table names: a field called photo_text in one pass and visible_text_notes in the next cannot be queried together. Read PARTIAL values rather than skipping: "500.7402.52__" beats nothing. Only when a character is genuinely unreadable, leave that field null or mark the unreadable span - do NOT invent it, and do NOT replace the whole transcription with a description of what the object looks like. A scene description is a nice extra AFTER the text, never instead of it.
336
+ - IMAGE FILES uploaded as the file itself: if ANY readable character appears ANYWHERE in the image (a label, a stamp, a sign in the background) it counts as an image WITH text - transcribe it per the rule above, and also capture the layout (what appears where) and every entity named. Only a truly text-free image gets description first: a one-line caption, then the objects present with their attributes (type, color, count, condition, position). Either way, save what you extract onto the file's "src::" record with updateRecords, TAG every entity and identifier visible, and INDEX the one number the image offers (a measured value, an amount, a count).
337
+ - 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 "authorized") 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 "authorized") 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.
338
+ - 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.
339
+ - 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.
340
+ - 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.
341
+ - 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).
342
+ - 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.
343
+ - 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.
344
+ - 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.
345
+ - 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.
346
+ - 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.
347
+ - 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.
330
348
  - Only AFTER every save is done, send exactly ONE final message summarizing what you indexed - never just "Indexing complete", and never a raw/base64/binary value or a large pasted dump. Keep it to a few factual sentences or a short markdown bullet list covering: the file name, its content type, each table you wrote to with its record/row count and the key columns/fields or topics captured, and anything that could not be extracted. Follow this shape - Indexed <file name> (<content type>): saved <N> records to <table(s)> capturing <key columns/fields or topics>; could not extract: <gaps, or none>.`;
331
349
  if (serviceDescription) {
332
350
  systemPrompt += `
@@ -348,7 +366,7 @@ File metadata:
348
366
  ` : "");
349
367
  if (options?.inlineContent) {
350
368
  return head + `
351
- The file's content was parsed by the client and is provided inline below. Read it directly \u2014 do NOT fetch any URL for this file. Use the storage path above (not this content) for the "src::" unique_id.
369
+ The file's content was parsed by the client and is provided inline below. Read it directly - do NOT fetch any URL for this file. Set every record's reference to exactly "src::" + the storage path above (not this content). That file record already exists, so enrich it with updateRecords rather than posting it.
352
370
 
353
371
  ----- BEGIN FILE CONTENT -----
354
372
  ${options.inlineContent}
@@ -356,7 +374,7 @@ ${options.inlineContent}
356
374
  }
357
375
  if (options?.inlineContentPlaceholder) {
358
376
  return head + `
359
- The file's text content was extracted on the server and is provided inline below. Read it directly \u2014 do NOT fetch any URL for this file. Use the storage path above (not this content) for the "src::" unique_id.
377
+ The file's text content was extracted on the server and is provided inline below. Read it directly - do NOT fetch any URL for this file. Set every record's reference to exactly "src::" + the storage path above (not this content). That file record already exists, so enrich it with updateRecords rather than posting it.
360
378
 
361
379
  ----- BEGIN FILE CONTENT -----
362
380
  ${options.inlineContentPlaceholder}
@@ -364,7 +382,7 @@ ${options.inlineContentPlaceholder}
364
382
  }
365
383
  if (options?.pagedRead) {
366
384
  return head + `
367
- Read this file with the readFileContent tool, using the storage path above - do NOT fetch a URL and do NOT rely on a single sample. readFileContent returns the file ONE WINDOW at a time: spreadsheets as coordinate-tagged grid rows (e.g. 'R4 A:E&I NUMBER | B:E1007'), scanned/large PDFs as rendered PAGE IMAGES, and windows may include embedded photos - LOOK at any images and datafy what they show. Page through EVERY window: for each window SAVE records for its rows/items/pages (postRecords, one record per row/item), 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. Do NOT stop after the first window and do NOT just write a summary. Use the storage path above for the "src::" unique_id.` + (attachment.url ? `
385
+ Read this file with the readFileContent tool, using the storage path above - do NOT fetch a URL and do NOT rely on a single sample. readFileContent returns the file ONE WINDOW at a time: spreadsheets as coordinate-tagged grid rows (e.g. 'R4 A:E&I NUMBER | B:E1007'), scanned/large PDFs as rendered PAGE IMAGES, and windows may include embedded photos - LOOK at any images and datafy what they show. Page through EVERY window: for each window SAVE records for its rows/items/pages (postRecords, one record per row/item), 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. Do NOT stop after the first window and do NOT just write a summary. Set every record's reference to exactly "src::" + the storage path above; that file record already exists, so enrich it with updateRecords instead of posting it again.` + (attachment.url ? `
368
386
  (A temporary URL is provided ONLY as a fallback if readFileContent fails: ${attachment.url})` : "");
369
387
  }
370
388
  return head + `- temporary URL (fetch this to read the file contents): ${attachment.url}`;
@@ -399,7 +417,11 @@ function buildRenderDatafy(placeholder) {
399
417
  return `
400
418
  ${placeholder}
401
419
 
402
- LOOK at each rendered page image in this message and DATAFY what it shows: for EVERY page call postRecords and save records - one record per row / table entry / line item visible on the page (or one record for the page if it is prose), capturing every value you can read (OCR the text, read tables cell by cell, describe any photos/diagrams). Use the storage path above for the "src::" unique_id.
420
+ LOOK at each rendered page image in this message and DATAFY what it shows: for EVERY page call postRecords and save records - one record per row / table entry / line item visible on the page (or one record for the page if it is prose), capturing every value you can read (OCR the text, read tables cell by cell, describe any photos/diagrams). Set EVERY record's reference to exactly "src::" + the storage path above. That file record ALREADY EXISTS, so do NOT post it, and do NOT give your page records a "src::" unique_id of their own. A record with no reference back to it is an ORPHAN: re-indexing the file deletes the linked records and leaves the orphan behind forever as stale data.
421
+
422
+ Each image is preceded by a label giving its DOCUMENT PAGE number. That label is the page's identity - use it, and ignore any page number PRINTED on the document itself (a scan often restarts its own numbering per section, so a footer reading "PAGE 4 OF 8" routinely disagrees with the real position). Whether a page is one you have already saved is stated in the note above the images - decide from that, never from a printed page number.
423
+
424
+ Transcribe COMPLETELY, not representatively. A table with twenty rows gets twenty records, not a sample of the first few - if a page has more rows than you can save comfortably, still save them all rather than summarising. Where a page carries an embedded text layer it is quoted above that page's image: it is the exact text and should be preferred over reading the pixels, with the image used for layout, tables, stamps and handwriting.
403
425
 
404
426
  Save records for THIS window of pages only, then stop and report what you saved. Do NOT try to read the rest of the file and do NOT worry about the pages after this window: if any remain, the next window is rendered and sent to you automatically. Report only the pages you were actually shown - never imply you have seen the whole document.`;
405
427
  }
@@ -418,9 +440,9 @@ This file is delivered to you ONE WINDOW at a time, embedded directly in this me
418
440
  return head + buildRenderMeta(attachment) + where + `
419
441
  ${placeholder}
420
442
 
421
- DATAFY this window: call postRecords and save records for everything in it - ONE RECORD PER ROW for tabular data (keyed by the column headers), or one record per section for prose. Capture every value you can read. Use the storage path above for the "src::" unique_id on the file-level record, and link every row/section record to it by reference.
443
+ DATAFY this window: call postRecords and save records for everything in it - ONE RECORD PER ROW for tabular data (keyed by the column headers), or one record per section for prose. Capture every value you can read. The file-level record ALREADY EXISTS with unique_id "src::" + the storage path above: do NOT post it (a duplicate unique_id is rejected), enrich it with updateRecords, and link every row/section record to it by reference.
422
444
 
423
- If this window has PHOTOS attached as images, LOOK at each one and datafy what it actually shows into the record for the row it is anchored to (a \xABPHOTO A88\xBB marker in the grid text only says WHERE a picture sits - the picture itself is attached to this message). Never report that photo contents could not be extracted when images are attached here.
445
+ If this window has PHOTOS attached as images, LOOK at each one and datafy what it actually shows. A \xABPHOTO ...\xBB marker in the grid text ties a picture to its row and comes in two forms. \xABPHOTO A88 -> __MEDIA__/...\xBB means the picture at cell A88 is saved as a permanent file at exactly that storage path, and its record in table "__MEDIA__" has unique_id "src::" + that path: UPDATE that record with updateRecords, adding what the picture SHOWS and TAGS for every identifier visible in it (part numbers, tag ids, item names, serial numbers). Do NOT create a duplicate and do NOT add a second photo record in another table: one file, one record. If that update reports the record does not exist, create it ONCE with that same unique_id, reference "src::" + the storage path above, table "__MEDIA__", access group "authorized", and data carrying the path - the path must never be lost. A bare \xABPHOTO A88\xBB marker with no arrow is a picture with no stored path of its own in this window: usually a repeat stored under an earlier anchor, or one too small to keep. NEVER construct a storage path or unique_id for it: find its record, if any, with getRecords reference "src::" + the storage path above, matching the cell against data.anchor or tags, and enrich what you find. The row record stays about its row's cells. Never report that photo contents could not be extracted when images are attached here.
424
446
 
425
447
  Save records for THIS window only, then stop and report what you saved. Do NOT try to read the rest of the file, and do NOT call readFileContent - if more remains, the next window is read and sent to you automatically. Report only what you were actually shown, and never imply you have seen the whole file when the note beside the window says more remains.`;
426
448
  }
@@ -433,11 +455,10 @@ File metadata:
433
455
  - storage path: ${attachment.storagePath}
434
456
  ` + (attachment.mime ? `- mime type: ${attachment.mime}
435
457
  ` : "") + `
436
- 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 page/row/window already saved). 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:
437
- - PDF: the cursor is the NUMBER OF PAGES already read (0-based next page). If you saved up to page N, call readFileContent with cursor="N" to get page N+1 onward.
438
- - Spreadsheet: the cursor is "<sheetIndex>:<nextRow>" (0-based sheet index, 1-based row). If you saved up to row R of sheet S, use cursor="S:R+1".
439
- - Text: the cursor is the character offset already read.
440
- Index the REMAINING windows - one record per row/item, looking at any page images or embedded photos - saving as you go until readFileContent reports END OF FILE. Do NOT re-save windows that are already saved. Use the storage path above for the "src::" unique_id. When the ENTIRE file is finally indexed, end your message with the token INDEXING_COMPLETE.`;
458
+ 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:
459
+ - Spreadsheet: the cursor is "<sheetIndex>:<nextRow>" (0-based sheet index, 1-based row). If you saved up to row R of sheet S, use cursor="S:R+1".
460
+ - Text: the cursor is the character offset already read.
461
+ Index the REMAINING windows - one record per row/item, looking at any page images or embedded photos - saving as you go until readFileContent reports END OF FILE. A \xABPHOTO <cell>\xBB marker in a window marks an embedded picture whose extracted file already has a record in table "__MEDIA__": find it with getRecords reference "src::" + the storage path above and match the cell against data.anchor or tags (a repeated picture is stored under its first anchor only), then enrich it with updateRecords. Never create a photo record of your own and never construct a path for one. Do NOT re-save windows that are already saved. Set every record's reference to exactly "src::" + the storage path above (no sheet, window or summary suffix added). That file record already exists, so do NOT post it; enrich it with updateRecords. When the ENTIRE file is finally indexed, end your message with the token INDEXING_COMPLETE.`;
441
462
  }
442
463
 
443
464
  // src/engine/errors.ts
@@ -551,6 +572,7 @@ var EXPIRED_ATTACHMENT_URL_HOST = "_expired_.url";
551
572
  var EXPIRED_ATTACHMENT_URL_ORIGIN = "https://" + EXPIRED_ATTACHMENT_URL_HOST;
552
573
  var LINK_LABEL_MAX_DISPLAY_CHARS = 32;
553
574
  var EXPIRED_LINK_REFRESH_EXPIRES_SECONDS = 20 * 60;
575
+ var PREVIEW_BROWSER_CACHE_SECONDS = 7 * 24 * 60 * 60;
554
576
  var LINK_REFRESH_WINDOW_MS = (EXPIRED_LINK_REFRESH_EXPIRES_SECONDS - 5 * 60) * 1e3;
555
577
  function createInlineLinkRegex() {
556
578
  return /src::(\S+)|\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]|\([^\s()]*\))+)\)|\[([^\]\n]+)\]\(((?:[^()\n]|\([^()\n]*\))+)\)|(https?:\/\/[^\s<>"']+)/g;
@@ -570,7 +592,7 @@ function encodePathSegments(path) {
570
592
  function normalizeAttachmentPathCandidate(value) {
571
593
  return safeDecodeURIComponent((value || "").trim()).replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/");
572
594
  }
573
- function extractRemotePathFromAttachmentHref(href, serviceId) {
595
+ function extractRemotePathFromAttachmentHref(href, projectId) {
574
596
  try {
575
597
  var parsed = new URL(href);
576
598
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
@@ -578,7 +600,7 @@ function extractRemotePathFromAttachmentHref(href, serviceId) {
578
600
  var segs = path.split("/").filter(Boolean);
579
601
  if (!segs.length) return null;
580
602
  var HEX = /^[a-f0-9]{32,}$/i;
581
- var sid = serviceId || "";
603
+ var sid = projectId || "";
582
604
  var start = 0;
583
605
  while (start < segs.length) {
584
606
  var seg = segs[start];
@@ -602,13 +624,13 @@ function getExpiredAttachmentVisiblePath(remotePath, fallback) {
602
624
  function buildDisplayExpiredAttachmentHref(remotePath, fallback) {
603
625
  return EXPIRED_ATTACHMENT_URL_ORIGIN + "/" + encodePathSegments(getExpiredAttachmentVisiblePath(remotePath, fallback));
604
626
  }
605
- function isServiceDbAttachmentHref(href, serviceId) {
606
- if (!serviceId) return false;
627
+ function isServiceDbAttachmentHref(href, projectId) {
628
+ if (!projectId) return false;
607
629
  try {
608
630
  var parsed = new URL(href);
609
631
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
610
632
  var segs = normalizeAttachmentPathCandidate(parsed.pathname || "").split("/").filter(Boolean);
611
- return segs.length > 0 && segs[0] === serviceId;
633
+ return segs.length > 0 && segs[0] === projectId;
612
634
  } catch (e) {
613
635
  return false;
614
636
  }
@@ -623,12 +645,12 @@ function readExpiredAttachmentHref(href) {
623
645
  return null;
624
646
  }
625
647
  }
626
- function sanitizeAttachmentLinksForHistory(content, serviceId, forAssistant) {
648
+ function sanitizeAttachmentLinksForHistory(content, projectId, forAssistant) {
627
649
  if (!content) return content;
628
650
  if (!forAssistant && content.indexOf("Attached files:") === -1) return content;
629
651
  return content.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, function(_m, label, href) {
630
- if (!isServiceDbAttachmentHref(href, serviceId)) return _m;
631
- var remotePath = extractRemotePathFromAttachmentHref(href, serviceId);
652
+ if (!isServiceDbAttachmentHref(href, projectId)) return _m;
653
+ var remotePath = extractRemotePathFromAttachmentHref(href, projectId);
632
654
  var fullPath = remotePath || normalizeAttachmentPathCandidate(label);
633
655
  if (!fullPath) return _m;
634
656
  return "[" + label + "](" + buildDisplayExpiredAttachmentHref(fullPath, label) + ")";
@@ -669,6 +691,31 @@ function normalizeTrailingInlineToken(value) {
669
691
  out = out.replace(/[`'"*>]+$/, "");
670
692
  return out;
671
693
  }
694
+ var PREVIEWABLE_IMAGE_CONTENT_TYPES = {
695
+ png: "image/png",
696
+ jpg: "image/jpeg",
697
+ jpeg: "image/jpeg",
698
+ gif: "image/gif",
699
+ webp: "image/webp",
700
+ avif: "image/avif",
701
+ bmp: "image/bmp"
702
+ };
703
+ function previewableExtOf(nameOrPath) {
704
+ var v = String(nameOrPath || "");
705
+ var cut = v.search(/[?#]/);
706
+ if (cut !== -1) v = v.slice(0, cut);
707
+ v = v.replace(/[\\/]+$/, "");
708
+ var dot = v.lastIndexOf(".");
709
+ if (dot <= 0) return "";
710
+ var ext = v.slice(dot + 1).trim().toLowerCase();
711
+ return /^[a-z0-9]+$/.test(ext) ? ext : "";
712
+ }
713
+ function isPreviewableImagePath(nameOrPath) {
714
+ return !!PREVIEWABLE_IMAGE_CONTENT_TYPES[previewableExtOf(nameOrPath)];
715
+ }
716
+ function previewImageContentType(nameOrPath) {
717
+ return PREVIEWABLE_IMAGE_CONTENT_TYPES[previewableExtOf(nameOrPath)] || null;
718
+ }
672
719
  function classifyInlineLink(full, groups, ctx) {
673
720
  var g1 = groups[0], g2 = groups[1], g3 = groups[2], g4 = groups[3], g5 = groups[4], g6 = groups[5];
674
721
  var dbHostPrefix = (ctx.dbHostPrefix || "").toLowerCase();
@@ -682,17 +729,19 @@ function classifyInlineLink(full, groups, ctx) {
682
729
  if (!remotePath2) return null;
683
730
  var expiredHref = buildDisplayExpiredAttachmentHref(remotePath2, label);
684
731
  var cached = fresh(expiredHref);
685
- return {
686
- part: {
687
- type: "link",
688
- label: truncateLabelForDisplay(label),
689
- fullLabel: label,
690
- href: cached || expiredHref,
691
- expired: !cached,
692
- expiredHref,
693
- remotePath: remotePath2
694
- }
732
+ var part = {
733
+ type: "link",
734
+ label: truncateLabelForDisplay(label),
735
+ fullLabel: label,
736
+ href: cached || expiredHref,
737
+ expired: !cached,
738
+ expiredHref,
739
+ remotePath: remotePath2
695
740
  };
741
+ var ext = previewableExtOf(remotePath2);
742
+ var ct = PREVIEWABLE_IMAGE_CONTENT_TYPES[ext];
743
+ if (ct) part.image = { ext, contentType: ct };
744
+ return { part };
696
745
  };
697
746
  if (g1) {
698
747
  var rawPath = normalizeTrailingInlineToken(g1);
@@ -705,14 +754,15 @@ function classifyInlineLink(full, groups, ctx) {
705
754
  tail
706
755
  };
707
756
  }
708
- var srcPath = readExpiredAttachmentHref(rawPath) || (srcIsUrl ? extractRemotePathFromAttachmentHref(rawPath, ctx.serviceId) || normalizeAttachmentPathCandidate(rawPath) : normalizeAttachmentPathCandidate(rawPath));
757
+ var srcPath = readExpiredAttachmentHref(rawPath) || (srcIsUrl ? extractRemotePathFromAttachmentHref(rawPath, ctx.projectId) || normalizeAttachmentPathCandidate(rawPath) : rawPath.trim().replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/"));
709
758
  var srcBuilt = asStoredFile(srcPath, srcPath);
710
759
  return srcBuilt ? { part: srcBuilt.part, tail } : null;
711
760
  }
712
761
  if (g4 && g5) {
713
762
  var dbTarget = /^db:(.+)$/i.exec(g5.trim());
714
763
  if (dbTarget) {
715
- var declared = asStoredFile(normalizeAttachmentPathCandidate(dbTarget[1]), g4);
764
+ var rawDbPath = dbTarget[1].trim().replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/");
765
+ var declared = asStoredFile(rawDbPath, g4);
716
766
  if (!declared) return null;
717
767
  declared.part.label = truncateLabelForDisplay(g4);
718
768
  declared.part.fullLabel = g4;
@@ -757,8 +807,8 @@ function classifyInlineLink(full, groups, ctx) {
757
807
  return withTail(carriedBuilt);
758
808
  }
759
809
  }
760
- if (isServiceDbAttachmentHref(originalHref, ctx.serviceId)) {
761
- var remotePath = extractRemotePathFromAttachmentHref(originalHref, ctx.serviceId);
810
+ if (isServiceDbAttachmentHref(originalHref, ctx.projectId)) {
811
+ var remotePath = extractRemotePathFromAttachmentHref(originalHref, ctx.projectId);
762
812
  if (remotePath) {
763
813
  var dbBuilt = asStoredFile(remotePath, getExpiredAttachmentVisiblePath(remotePath, urlLabel));
764
814
  if (dbBuilt) return withTail(dbBuilt);
@@ -768,6 +818,19 @@ function classifyInlineLink(full, groups, ctx) {
768
818
  part: { type: "link", label: truncateLabelForDisplay(urlLabel), fullLabel: urlLabel, href: originalHref, expired: false }
769
819
  });
770
820
  }
821
+ function linkUnavailableKeyForPath(remotePath) {
822
+ return "path:" + (remotePath || "");
823
+ }
824
+ function linkUnavailableKeyForHref(href) {
825
+ return "href:" + (href || "");
826
+ }
827
+ function isLinkUnavailable(link, map) {
828
+ if (!link || !map) return false;
829
+ if (link.remotePath && map[linkUnavailableKeyForPath(link.remotePath)]) return true;
830
+ if (link.expiredHref && map[linkUnavailableKeyForHref(link.expiredHref)]) return true;
831
+ if (link.href && map[linkUnavailableKeyForHref(link.href)]) return true;
832
+ return false;
833
+ }
771
834
  function truncateLabelForDisplay(label) {
772
835
  if (!label) return label;
773
836
  if (label.length <= LINK_LABEL_MAX_DISPLAY_CHARS) return label;
@@ -807,15 +870,15 @@ function registerModelContextWindows(models) {
807
870
  }
808
871
  }
809
872
  var projectContextWindows = {};
810
- function setProjectContextWindow(serviceId, tokens) {
811
- var key = (serviceId || "").trim();
873
+ function setProjectContextWindow(projectId, tokens) {
874
+ var key = (projectId || "").trim();
812
875
  if (!key) return;
813
876
  var n = Number(tokens);
814
877
  if (Number.isFinite(n) && n > 0) projectContextWindows[key] = Math.floor(n);
815
878
  else delete projectContextWindows[key];
816
879
  }
817
- function getProjectContextWindow(serviceId) {
818
- var key = (serviceId || "").trim();
880
+ function getProjectContextWindow(projectId) {
881
+ var key = (projectId || "").trim();
819
882
  return key && projectContextWindows[key] ? projectContextWindows[key] : null;
820
883
  }
821
884
  var OUTPUT_TOKEN_RESERVE = 22e3;
@@ -832,8 +895,8 @@ function estimateTextTokens(text) {
832
895
  function estimateMessageTokens(msg) {
833
896
  return estimateTextTokens(msg.content) + estimateTextTokens(msg.role) + 6;
834
897
  }
835
- function getContextWindow(platform, model, serviceId) {
836
- var override = serviceId ? getProjectContextWindow(serviceId) : null;
898
+ function getContextWindow(platform, model, projectId) {
899
+ var override = projectId ? getProjectContextWindow(projectId) : null;
837
900
  if (override) return override;
838
901
  var normalized = (model || "").trim().toLowerCase();
839
902
  if (normalized) {
@@ -852,12 +915,12 @@ function stripFileBlocksFromHistory(content) {
852
915
  return content.replace(/```([^\n`]+?\.[^\s.`]+)\n[\s\S]*?```/g, "[file previously attached: $1]");
853
916
  }
854
917
  function buildBoundedChatMessages(options) {
855
- var contextWindow = getContextWindow(options.platform, options.model, options.serviceId);
918
+ var contextWindow = getContextWindow(options.platform, options.model, options.projectId);
856
919
  var contextBasedBudget = Math.max(
857
920
  MIN_INPUT_TOKEN_BUDGET,
858
921
  contextWindow - OUTPUT_TOKEN_RESERVE - TOOL_AND_RESPONSE_BUFFER
859
922
  );
860
- var scaled = !!(options.serviceId && getProjectContextWindow(options.serviceId));
923
+ var scaled = !!(options.projectId && getProjectContextWindow(options.projectId));
861
924
  var claudeInputCap = scaled ? Math.max(CLAUDE_PER_REQUEST_INPUT_CAP, Math.round(contextBasedBudget * CLAUDE_INPUT_CAP_RATIO)) : CLAUDE_PER_REQUEST_INPUT_CAP;
862
925
  var availableInputBudget = options.platform === "claude" ? Math.min(contextBasedBudget, claudeInputCap) : contextBasedBudget;
863
926
  var systemCost = estimateTextTokens(options.systemPrompt) + 12;
@@ -869,7 +932,7 @@ function buildBoundedChatMessages(options) {
869
932
  var trimmed = windowed.map(function(m, i2) {
870
933
  if (i2 === latestIndex) return m;
871
934
  var stripped = stripFileBlocksFromHistory(m.content);
872
- var sanitized = sanitizeAttachmentLinksForHistory(stripped, options.serviceId, m.role !== "user");
935
+ var sanitized = sanitizeAttachmentLinksForHistory(stripped, options.projectId, m.role !== "user");
873
936
  return Object.assign({}, m, { content: sanitized });
874
937
  });
875
938
  var bounded = [], used = 0;
@@ -1049,6 +1112,147 @@ function prepareDownloadText(filename, body) {
1049
1112
  };
1050
1113
  }
1051
1114
 
1115
+ // src/engine/link_markup.ts
1116
+ function escapeInlineHtml(v) {
1117
+ return String(v == null ? "" : v).replace(/[&<>"']/g, function(ch) {
1118
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch];
1119
+ });
1120
+ }
1121
+ var IMAGE_PREVIEWS_PER_MESSAGE = 8;
1122
+ var INLINE_LINK_GLYPH = "\u2197";
1123
+ var INLINE_LINK_UNAVAILABLE_GLYPH = "\u2715";
1124
+ var INLINE_LINK_UNAVAILABLE_SUFFIX = " (unavailable)";
1125
+ function renderInlineLinkHtml(link, opts) {
1126
+ var o = opts || {};
1127
+ var unavailable = !!o.unavailable;
1128
+ var refreshing = !unavailable && !!o.refreshing;
1129
+ var full = link.fullLabel || link.label;
1130
+ var preview = !!link.image && !!link.remotePath && o.allowImagePreview !== false && !unavailable;
1131
+ var cls = ["bq-link-button"];
1132
+ if (link.expired) cls.push("is-expired");
1133
+ if (refreshing) cls.push("is-refreshing");
1134
+ if (unavailable) cls.push("is-unavailable");
1135
+ if (preview) cls.push("is-image-preview");
1136
+ var labelText = (unavailable ? INLINE_LINK_UNAVAILABLE_GLYPH : INLINE_LINK_GLYPH) + " " + link.label + (unavailable ? INLINE_LINK_UNAVAILABLE_SUFFIX : refreshing ? " (fetching...)" : "");
1137
+ var attrs = ['class="' + cls.join(" ") + '"'];
1138
+ if (unavailable) attrs.push('aria-disabled="true"', 'data-bq-unavailable="1"');
1139
+ else attrs.push('href="' + escapeInlineHtml(link.href) + '"', 'target="_blank"', 'rel="noopener noreferrer"');
1140
+ attrs.push('title="' + escapeInlineHtml(unavailable ? full + INLINE_LINK_UNAVAILABLE_SUFFIX : full) + '"');
1141
+ if (!preview && !unavailable) attrs.push('download="' + escapeInlineHtml(full) + '"');
1142
+ attrs.push('data-bq-link="1"');
1143
+ if (link.expired && !unavailable) attrs.push('data-bq-expired="1"');
1144
+ if (link.expiredHref) attrs.push('data-bq-expired-href="' + escapeInlineHtml(link.expiredHref) + '"');
1145
+ if (link.remotePath) attrs.push('data-bq-remote-path="' + escapeInlineHtml(link.remotePath) + '"');
1146
+ if (link.fullLabel) attrs.push('data-bq-full-label="' + escapeInlineHtml(link.fullLabel) + '"');
1147
+ if (!preview) return "<a " + attrs.join(" ") + ">" + escapeInlineHtml(labelText) + "</a>";
1148
+ return "<a " + attrs.join(" ") + '><img class="bq-img-preview" alt="' + escapeInlineHtml(full) + '" data-bq-img-path="' + escapeInlineHtml(link.remotePath || "") + '" data-bq-img-type="' + escapeInlineHtml(link.image ? link.image.contentType : "") + '" loading="lazy" decoding="async"><span class="bq-loader" data-bq-img-loader="1"></span><span class="bq-img-preview-caption" translate="no">' + escapeInlineHtml(labelText) + "</span></a>";
1149
+ }
1150
+
1151
+ // src/engine/image_preview.ts
1152
+ var previewUrlCache = /* @__PURE__ */ Object.create(null);
1153
+ var previewInFlight = /* @__PURE__ */ Object.create(null);
1154
+ function cacheKey(scope, path) {
1155
+ return scope + "\0" + path;
1156
+ }
1157
+ function clearImagePreviewCache(scope) {
1158
+ if (!scope) {
1159
+ previewUrlCache = /* @__PURE__ */ Object.create(null);
1160
+ previewInFlight = /* @__PURE__ */ Object.create(null);
1161
+ staleImagePreviews = /* @__PURE__ */ Object.create(null);
1162
+ return;
1163
+ }
1164
+ var prefix = scope + "\0";
1165
+ for (var k in previewUrlCache) if (k.indexOf(prefix) === 0) delete previewUrlCache[k];
1166
+ for (var f in previewInFlight) if (f.indexOf(prefix) === 0) delete previewInFlight[f];
1167
+ for (var s in staleImagePreviews) if (s.indexOf(prefix) === 0) delete staleImagePreviews[s];
1168
+ }
1169
+ function peekImagePreviewUrl(ctx, remotePath) {
1170
+ var hit = previewUrlCache[cacheKey(ctx.scope, remotePath)];
1171
+ if (hit && Date.now() - hit.at < LINK_REFRESH_WINDOW_MS) return hit.url;
1172
+ return null;
1173
+ }
1174
+ function resolveImagePreviewUrl(ctx, remotePath, contentType, refresh) {
1175
+ var key = cacheKey(ctx.scope, remotePath);
1176
+ if (staleImagePreviews[key]) {
1177
+ delete staleImagePreviews[key];
1178
+ refresh = true;
1179
+ }
1180
+ if (refresh) {
1181
+ delete previewUrlCache[key];
1182
+ delete previewInFlight[key];
1183
+ } else {
1184
+ var warm = peekImagePreviewUrl(ctx, remotePath);
1185
+ if (warm) return Promise.resolve(warm);
1186
+ var flight = previewInFlight[key];
1187
+ if (flight) return flight;
1188
+ }
1189
+ var run = ctx.mint(remotePath, contentType, refresh).then(function(url) {
1190
+ if (previewInFlight[key] === run) {
1191
+ previewUrlCache[key] = { url, at: Date.now() };
1192
+ delete previewInFlight[key];
1193
+ }
1194
+ return url;
1195
+ }, function(e) {
1196
+ if (previewInFlight[key] === run) delete previewInFlight[key];
1197
+ throw e;
1198
+ });
1199
+ previewInFlight[key] = run;
1200
+ return run;
1201
+ }
1202
+ function markImagePreviewStale(scope, remotePath) {
1203
+ if (!scope || !remotePath) return;
1204
+ staleImagePreviews[cacheKey(scope, remotePath)] = true;
1205
+ delete previewUrlCache[cacheKey(scope, remotePath)];
1206
+ }
1207
+ var staleImagePreviews = /* @__PURE__ */ Object.create(null);
1208
+ function hydrateImagePreviews(imgs, ctx) {
1209
+ for (var i = 0; i < imgs.length; i++) hydrateOne(imgs[i], ctx);
1210
+ }
1211
+ function hydrateOne(img, ctx) {
1212
+ if (img.getAttribute("data-bq-img-state")) return;
1213
+ var path = img.getAttribute("data-bq-img-path");
1214
+ var type = img.getAttribute("data-bq-img-type") || "";
1215
+ if (!path) {
1216
+ img.setAttribute("data-bq-img-state", "error");
1217
+ return;
1218
+ }
1219
+ img.setAttribute("data-bq-img-state", "loading");
1220
+ img.addEventListener("load", function() {
1221
+ img.setAttribute("data-bq-img-state", "ready");
1222
+ if (ctx.onLoad) ctx.onLoad(path);
1223
+ });
1224
+ img.addEventListener("error", function() {
1225
+ onImageError(img, ctx, path, type);
1226
+ });
1227
+ var warm = peekImagePreviewUrl(ctx, path);
1228
+ if (warm) {
1229
+ img.setAttribute("src", warm);
1230
+ return;
1231
+ }
1232
+ resolveImagePreviewUrl(ctx, path, type).then(function(url) {
1233
+ if (img.getAttribute("data-bq-img-state") !== "loading") return;
1234
+ img.setAttribute("src", url);
1235
+ }, function(e) {
1236
+ img.setAttribute("data-bq-img-state", "error");
1237
+ if (ctx.onError) ctx.onError(path, e);
1238
+ });
1239
+ }
1240
+ function onImageError(img, ctx, path, type) {
1241
+ if (img.getAttribute("data-bq-img-retry") === "1") {
1242
+ img.setAttribute("data-bq-img-state", "error");
1243
+ if (ctx.onError) ctx.onError(path, new Error("image preview failed to load"));
1244
+ return;
1245
+ }
1246
+ img.setAttribute("data-bq-img-retry", "1");
1247
+ img.removeAttribute("src");
1248
+ resolveImagePreviewUrl(ctx, path, type, true).then(function(url) {
1249
+ img.setAttribute("src", url);
1250
+ }, function(e) {
1251
+ img.setAttribute("data-bq-img-state", "error");
1252
+ if (ctx.onError) ctx.onError(path, e);
1253
+ });
1254
+ }
1255
+
1052
1256
  // src/engine/time.ts
1053
1257
  function wallClockNow() {
1054
1258
  return Date.now();
@@ -1123,6 +1327,25 @@ var DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6";
1123
1327
  var DEFAULT_OPENAI_MODEL = "gpt-5.6-luna";
1124
1328
  var mcpUrl = () => chatEngineConfig().mcpBaseUrl;
1125
1329
  var clientSecretRequest = (opts) => chatEngineConfig().clientSecretRequest(opts);
1330
+ var VARIANT_IMAGE_DETAIL = "original";
1331
+ var VARIANT_TEXT_VERBOSITY = "high";
1332
+ var OLDEST_NANO_REASONING_EFFORT = "high";
1333
+ var isOpenAINano = (model) => {
1334
+ const normalized = (model).trim().toLowerCase();
1335
+ if (!/(^|-)nano(-|$)/.test(normalized)) return false;
1336
+ const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
1337
+ if (!match) return false;
1338
+ const major = Number(match[1]);
1339
+ const minor = match[2] === void 0 ? null : Number(match[2]);
1340
+ return major > 5 || major === 5 && minor !== null && minor >= 4;
1341
+ };
1342
+ var variantIndexingOptions = (model) => {
1343
+ if (!isOpenAINano(model) || !isOldestNano(model)) return {};
1344
+ return {
1345
+ ...{ text: { verbosity: VARIANT_TEXT_VERBOSITY } } ,
1346
+ ...{ reasoning: { effort: OLDEST_NANO_REASONING_EFFORT } }
1347
+ };
1348
+ };
1126
1349
  var getOpenAIImageDetail = (model) => {
1127
1350
  const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
1128
1351
  const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
@@ -1136,12 +1359,40 @@ var getOpenAIImageDetail = (model) => {
1136
1359
  if (!supportsOriginal) {
1137
1360
  return DEFAULT_OPENAI_IMAGE_DETAIL;
1138
1361
  }
1139
- return isVariant ? "high" : "original";
1362
+ return isVariant ? VARIANT_IMAGE_DETAIL : "original";
1140
1363
  };
1141
1364
  var getRenderImageDetail = (model) => {
1142
1365
  const detail = getOpenAIImageDetail(model);
1143
1366
  return detail === DEFAULT_OPENAI_IMAGE_DETAIL ? "high" : detail;
1144
1367
  };
1368
+ var OPENAI_VERSIONED_ID = /^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/;
1369
+ var isRecognisedOpenAIVersion = (model) => OPENAI_VERSIONED_ID.test((model || DEFAULT_OPENAI_MODEL).trim().toLowerCase());
1370
+ var isOldestNano = (model) => {
1371
+ const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
1372
+ if (!/(^|-)nano(-|$)/.test(normalized)) return false;
1373
+ const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
1374
+ if (!match) return true;
1375
+ const major = Number(match[1]);
1376
+ const minor = match[2] === void 0 ? null : Number(match[2]);
1377
+ if (major < 5) return true;
1378
+ if (major > 5) return false;
1379
+ return minor === null || minor <= 4;
1380
+ };
1381
+ var SMALL_TIER_PAGES_PER_WINDOW = 2;
1382
+ var DOWNSAMPLED_TIER_TILE = 2;
1383
+ function getVisionProfile(model) {
1384
+ const detail = getRenderImageDetail(model);
1385
+ if (!isRecognisedOpenAIVersion(model)) {
1386
+ return { detail, pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
1387
+ }
1388
+ if (detail !== "original") {
1389
+ return { detail, pagesPerWindow: SMALL_TIER_PAGES_PER_WINDOW, tile: DOWNSAMPLED_TIER_TILE };
1390
+ }
1391
+ if (isOldestNano(model)) {
1392
+ return { detail, pagesPerWindow: SMALL_TIER_PAGES_PER_WINDOW, tile: 1 };
1393
+ }
1394
+ return { detail, pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
1395
+ }
1145
1396
  var IMAGE_URL_REGEX = /\bhttps?:\/\/[^\s<>"'()\[\]]+?\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s<>"'()\[\]]*)?/gi;
1146
1397
  function transformContentWithImages(content) {
1147
1398
  if (typeof content !== "string" || !content) {
@@ -1222,6 +1473,7 @@ function applyHistoryCacheBreakpoint(messages) {
1222
1473
  });
1223
1474
  }
1224
1475
  var POLL_INTERVAL = 3e3;
1476
+ var MAX_CONCURRENT_BG_POLLS = 6;
1225
1477
  async function callClaudeWithMcp({
1226
1478
  prompt,
1227
1479
  messages,
@@ -1386,16 +1638,18 @@ async function notifyAgentSaveAttachment(info) {
1386
1638
  const renderFrom = Math.max(0, info.renderFrom || 0);
1387
1639
  const renderPlaceholder = visionFile ? makeRenderPlaceholder(attachment.storagePath) : void 0;
1388
1640
  const renderDetail = platform === "openai" ? getRenderImageDetail(info.model || DEFAULT_OPENAI_MODEL) : void 0;
1641
+ const visionProfile = platform === "openai" ? getVisionProfile(info.model || DEFAULT_OPENAI_MODEL) : { pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
1389
1642
  const skapiRender = visionFile && renderPlaceholder ? {
1390
1643
  _skapi_render: [
1391
1644
  {
1392
1645
  path: attachment.storagePath,
1393
1646
  from: renderFrom,
1394
- count: RENDER_PAGES_PER_WINDOW,
1647
+ count: visionProfile.pagesPerWindow,
1395
1648
  placeholder: renderPlaceholder,
1396
1649
  name: attachment.name,
1397
1650
  mime: attachment.mime,
1398
1651
  detail: renderDetail,
1652
+ tile: visionProfile.tile,
1399
1653
  auto_continue: true,
1400
1654
  continue_text: buildIndexingRenderContinueTemplate(attachment, renderPlaceholder)
1401
1655
  }
@@ -1412,6 +1666,12 @@ async function notifyAgentSaveAttachment(info) {
1412
1666
  name: attachment.name,
1413
1667
  mime: attachment.mime,
1414
1668
  kind: "window",
1669
+ // Same per-image `detail` the render path sends. Without it the worker falls
1670
+ // back to its model-blind default of 'high', so a spreadsheet's embedded
1671
+ // photos were tiled at lower resolution than the SAME model gets for a PDF
1672
+ // page or a chat attachment. That is why a model could describe an attached
1673
+ // photo but reported the pictures inside a sheet as only partly legible.
1674
+ detail: renderDetail,
1415
1675
  auto_continue: true,
1416
1676
  continue_text: buildIndexingWindowMessage(attachment, windowPlaceholder, true)
1417
1677
  }
@@ -1421,13 +1681,27 @@ async function notifyAgentSaveAttachment(info) {
1421
1681
  const serverExtract = !visionFile && !windowedRead && !continuing && !parsedContent && !pagedRead && isServerExtractable(attachment.name, attachment.mime);
1422
1682
  const placeholder = serverExtract ? makeExtractPlaceholder(attachment.storagePath) : void 0;
1423
1683
  const extractContent = serverExtract && placeholder ? [{ path: attachment.storagePath, placeholder, name: attachment.name, mime: attachment.mime }] : void 0;
1424
- const skapiExtract = extractContent && extractContent.length ? { _skapi_extract: extractContent } : {};
1684
+ const skapiExtract = extractContent && extractContent.length ? {
1685
+ _skapi_extract: extractContent.map((d) => ({
1686
+ ...d,
1687
+ // FIRST pass of an INDEXING run only: tells the worker to also pull the
1688
+ // file's embedded pictures into __MEDIA__ and register their records.
1689
+ // Chat-turn extraction (callClaudeWithMcp / callOpenAIWithPublicMcp)
1690
+ // never sets this, so merely ATTACHING a file to a chat message cannot
1691
+ // write media records; a CONTINUE pass skips it because the first pass
1692
+ // already saved (the save is whole-file, not windowed).
1693
+ save_media: !continuing
1694
+ }))
1695
+ } : {};
1425
1696
  const userMessage = visionFile && renderPlaceholder ? buildIndexingRenderMessage(attachment, renderPlaceholder, renderFrom) : windowedRead && windowPlaceholder ? buildIndexingWindowMessage(attachment, windowPlaceholder, false) : continuing ? buildIndexingContinueMessage(attachment) : buildIndexingUserMessage(
1426
1697
  attachment,
1427
1698
  parsedContent ? { inlineContent: parsedContent } : placeholder ? { inlineContentPlaceholder: placeholder } : pagedRead ? { pagedRead: true } : void 0
1428
1699
  );
1429
1700
  const systemPrompt = buildIndexingSystemPrompt({
1430
- service,
1701
+ // The model copies this id verbatim into project_id tool calls, so it must be
1702
+ // the PUBLIC token whenever the host supplied one; the raw code is rejected
1703
+ // by the tools' schema pattern.
1704
+ projectId: info.publicProjectId || service,
1431
1705
  serviceName: info.serviceName,
1432
1706
  serviceDescription: info.serviceDescription
1433
1707
  });
@@ -1449,6 +1723,8 @@ async function notifyAgentSaveAttachment(info) {
1449
1723
  data: {
1450
1724
  model: resolvedModel2,
1451
1725
  max_output_tokens: MAX_TOKENS,
1726
+ // Nano-only transcription knobs. Indexing only; see variantIndexingOptions.
1727
+ ...variantIndexingOptions(resolvedModel2),
1452
1728
  ...skapiExtract,
1453
1729
  ...skapiRender,
1454
1730
  ...skapiWindow,
@@ -1605,7 +1881,9 @@ function isBgIndexingQueue(queueName) {
1605
1881
  return name.slice(-BG_INDEXING_QUEUE_SUFFIX.length) === BG_INDEXING_QUEUE_SUFFIX;
1606
1882
  }
1607
1883
  var INDEXING_COMPLETE_MARKER = "INDEXING_COMPLETE";
1884
+ var EMPTY_INDEXING_REPLY = "Finished reading this file.";
1608
1885
  var MAX_INDEXING_RESUME_PASSES = 6;
1886
+ var CHAT_HISTORY_PAGE_LIMIT = 500;
1609
1887
  async function getChatHistory(params, fetchOptions) {
1610
1888
  const url = params.platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
1611
1889
  const p = Object.assign(
@@ -1619,7 +1897,7 @@ async function getChatHistory(params, fetchOptions) {
1619
1897
  );
1620
1898
  return chatEngineConfig().clientSecretRequestHistory(
1621
1899
  p,
1622
- Object.assign({ ascending: false }, fetchOptions)
1900
+ Object.assign({ ascending: false, limit: CHAT_HISTORY_PAGE_LIMIT }, fetchOptions)
1623
1901
  );
1624
1902
  }
1625
1903
 
@@ -1686,6 +1964,8 @@ function mapHistoryListToMessages(list, platform, opts) {
1686
1964
  var userText = extractLastUserTextFromRequest(requestBody);
1687
1965
  var assistantText = isPending ? "" : (extractAssistantText(response) || "").trim() || "";
1688
1966
  var isErrorResponse = !isPending && (isFailed || isErrorResponseBody(response));
1967
+ var reportedComplete = !!(item && item._isBgTask) && !isErrorResponse && !!assistantText && assistantText.indexOf(INDEXING_COMPLETE_MARKER) !== -1;
1968
+ if (reportedComplete) assistantText = assistantText.split(INDEXING_COMPLETE_MARKER).join("").trim();
1689
1969
  var serverItemId = item && typeof item.id === "string" && item.id ? item.id : void 0;
1690
1970
  var createdTs = Number(item && item.created);
1691
1971
  var updatedTs = Number(item && item.updated);
@@ -1710,7 +1990,7 @@ function mapHistoryListToMessages(list, platform, opts) {
1710
1990
  displayContent = userText;
1711
1991
  }
1712
1992
  } else {
1713
- displayContent = sanitizeAttachmentLinksForHistory(userText, opts.serviceId);
1993
+ displayContent = sanitizeAttachmentLinksForHistory(userText, opts.projectId);
1714
1994
  }
1715
1995
  var userMsg = { role: "user", content: displayContent };
1716
1996
  if (isInProcess) userMsg.isPendingInProcess = true;
@@ -1737,11 +2017,12 @@ function mapHistoryListToMessages(list, platform, opts) {
1737
2017
  if (serverItemId !== void 0) em._serverItemId = serverItemId;
1738
2018
  if (replyTs !== void 0) em._ts = replyTs;
1739
2019
  mapped.push(em);
1740
- } else if (assistantText) {
1741
- var okm = { role: "assistant", content: sanitizeAttachmentLinksForHistory(assistantText, opts.serviceId, true) };
2020
+ } else if (assistantText || reportedComplete) {
2021
+ var okm = { role: "assistant", content: sanitizeAttachmentLinksForHistory(assistantText, opts.projectId, true) || EMPTY_INDEXING_REPLY };
1742
2022
  if (item._isBgTask) okm.isBackgroundTask = true;
1743
2023
  if (serverItemId !== void 0) okm._serverItemId = serverItemId;
1744
2024
  if (replyTs !== void 0) okm._ts = replyTs;
2025
+ if (reportedComplete) okm._indexComplete = true;
1745
2026
  mapped.push(okm);
1746
2027
  }
1747
2028
  });
@@ -1800,6 +2081,16 @@ async function fillHistoryViewport(opts) {
1800
2081
  function createHistoryFiller(base) {
1801
2082
  var pending = [];
1802
2083
  var running = false;
2084
+ var fetching = false;
2085
+ function announce(next) {
2086
+ if (fetching === next) return;
2087
+ fetching = next;
2088
+ if (!base.onRunningChange) return;
2089
+ try {
2090
+ base.onRunningChange(next);
2091
+ } catch (e) {
2092
+ }
2093
+ }
1803
2094
  async function allSatisfied() {
1804
2095
  var next = [];
1805
2096
  for (var i = 0; i < pending.length; i++) {
@@ -1809,23 +2100,33 @@ function createHistoryFiller(base) {
1809
2100
  return pending.length === 0;
1810
2101
  }
1811
2102
  return {
2103
+ // The published fact, so a view and `isRunning()` can never disagree about
2104
+ // what they are showing. A fill that never fetches is not something anyone
2105
+ // outside this module has any use for knowing about.
1812
2106
  isRunning: function() {
1813
- return running;
2107
+ return fetching;
1814
2108
  },
1815
2109
  fill: function(isSatisfied) {
1816
2110
  pending.push(isSatisfied);
1817
2111
  if (running) return Promise.resolve();
1818
2112
  running = true;
1819
2113
  var done = function() {
1820
- running = false;
1821
2114
  pending = [];
2115
+ running = false;
2116
+ announce(false);
1822
2117
  };
1823
2118
  return fillHistoryViewport({
1824
2119
  isSatisfied: allSatisfied,
1825
2120
  isEndOfList: base.isEndOfList,
1826
2121
  isLoading: base.isLoading,
1827
2122
  messageCount: base.messageCount,
1828
- fetchOlder: base.fetchOlder,
2123
+ // The span opens HERE, at the first real page request: past
2124
+ // isEndOfList, past isStale, past isSatisfied. Everything before this
2125
+ // point is a fill that concluded there was nothing to do.
2126
+ fetchOlder: function() {
2127
+ announce(true);
2128
+ return base.fetchOlder();
2129
+ },
1829
2130
  isStale: base.isStale,
1830
2131
  maxPages: base.maxPages
1831
2132
  }).then(done, done);
@@ -1835,12 +2136,16 @@ function createHistoryFiller(base) {
1835
2136
 
1836
2137
  // src/engine/session.ts
1837
2138
  var WORKER_PASS_ADOPT_LIMIT = 20;
2139
+ var LIVE_INDEX_SNAPSHOT_MAX_AGE_MS = 5e3;
2140
+ var INDEX_DISPATCH_CLAIM_MS = 2 * 60 * 1e3;
1838
2141
  var WORKER_PASS_ADOPT_ATTEMPTS = [0, 2e3, 6e3];
1839
2142
  var INDEXING_DRAIN_BUSY_POLL_MS = 8e3;
1840
2143
  var INDEXING_DRAIN_CONFIRM_POLL_MS = 3e3;
1841
2144
  var INDEXING_DRAIN_IDLE_LOOKS = 2;
1842
2145
  var INDEXING_DRAIN_MIN_MS = 8e3;
1843
2146
  var INDEXING_DRAIN_TIMEOUT_MS = 15 * 60 * 1e3;
2147
+ var INDEXING_DRAIN_LOOK_TIMEOUT_MS = 45e3;
2148
+ var INDEXING_DRAIN_NUDGE_MIN_GAP_MS = 1500;
1844
2149
  var _g = typeof globalThis !== "undefined" ? globalThis : {};
1845
2150
  function nowMs() {
1846
2151
  return _g.performance && typeof _g.performance.now === "function" ? _g.performance.now() : Date.now();
@@ -1902,7 +2207,10 @@ var ChatSession = class {
1902
2207
  historyEndOfList: false,
1903
2208
  historyStartKeyHistory: [],
1904
2209
  historyRequestToken: 0,
1905
- gateRefreshToken: 0
2210
+ gateRefreshToken: 0,
2211
+ liveIndexKeys: {},
2212
+ liveIndexChecked: false,
2213
+ stoppedIndexIds: {}
1906
2214
  };
1907
2215
  this.bgTaskQueue = [];
1908
2216
  this.cancelledServerIds = /* @__PURE__ */ new Set();
@@ -1916,6 +2224,254 @@ var ChatSession = class {
1916
2224
  this._stageSeq = 0;
1917
2225
  this._uploadBatches = 0;
1918
2226
  this._indexDispatchesInFlight = 0;
2227
+ this._drainNudges = [];
2228
+ this._liveStages = {};
2229
+ this._liveIndexKey = "";
2230
+ this._liveIndexAt = 0;
2231
+ this._indexClaims = {};
2232
+ }
2233
+ /** What the display layer needs to decide whether a run is finished. `keys` holds
2234
+ * every file the server still has indexing work for; `checked` is false until the
2235
+ * first answer for this chat, and false means "we do not know yet". */
2236
+ getLiveIndexState() {
2237
+ return { keys: this.state.liveIndexKeys, checked: this.state.liveIndexChecked };
2238
+ }
2239
+ /** Passes that were on a row when the user stopped it, so the display layer can
2240
+ * still tell that this run was stopped once the stop has left no other trace.
2241
+ * See cancelIndexingGroup, which fills it, and buildChatDisplayList, which is
2242
+ * the only reader. */
2243
+ getStoppedIndexIds() {
2244
+ return this.state.stoppedIndexIds;
2245
+ }
2246
+ /**
2247
+ * Is this file ALREADY being indexed by this client?
2248
+ *
2249
+ * One live run per file, and the reason is what a second one looks like: the
2250
+ * conversation grows a SECOND collapsed row for the same file (a run is opened
2251
+ * by every FIRST pass, so two of them are two rows), the same document is read
2252
+ * twice at full provider cost, and the two chains fight over the same records —
2253
+ * the delete-then-repost that starts run 2 wipes what run 1 has saved so far.
2254
+ *
2255
+ * Asked of this client's own live work, so it cannot be wrong in the dangerous
2256
+ * direction: a queued/running pass keeps its bgTaskQueue entry until its bubble
2257
+ * settles, and a settled run answers false, which is what a genuine later
2258
+ * re-index needs.
2259
+ *
2260
+ * The retry that made this necessary: a chip whose INDEX request failed is
2261
+ * handed back to the composer to be retried on the next send, and an index
2262
+ * request can fail from the client's side (a lost ack, an expired token on the
2263
+ * response) while the server has already queued the pass. The retry then indexes
2264
+ * a file that was never not being indexed.
2265
+ */
2266
+ hasLiveIndexRun(storagePath) {
2267
+ if (!storagePath) return false;
2268
+ var claimed = this._indexClaims[this._indexClaimKey(storagePath)];
2269
+ if (claimed && nowMs() - claimed < INDEX_DISPATCH_CLAIM_MS) return true;
2270
+ var id = this.host.getIdentity();
2271
+ for (var i = 0; i < this.bgTaskQueue.length; i++) {
2272
+ var e = this.bgTaskQueue[i];
2273
+ if (e && e.storagePath === storagePath && e.projectId === id.projectId && e.platform === id.platform) return true;
2274
+ }
2275
+ return this.state.messages.some(function(m) {
2276
+ if (!m.isBackgroundTask || m.role !== "user" || m.isCancelled) return false;
2277
+ if (!(m.isPendingQueued || m.isPendingInProcess || m.isSendingToServer)) return false;
2278
+ return !!m._indexFile && m._indexFile.path === storagePath;
2279
+ });
2280
+ }
2281
+ /** Storage paths are project-relative, and one ChatSession serves every
2282
+ * project, so a claim has to be scoped the way a stop is (_indexKeyOf). */
2283
+ _indexClaimKey(storagePath) {
2284
+ return this.getHistoryCacheKey() + "|" + storagePath;
2285
+ }
2286
+ /**
2287
+ * Take this file's indexing slot, or report that someone already has it.
2288
+ *
2289
+ * The check-and-CLAIM is what makes it safe against a second caller arriving
2290
+ * mid-flight: the claim is written SYNCHRONOUSLY, before the first await, so a
2291
+ * concurrent caller sees it even though no request has completed and no queue
2292
+ * has admitted anything. Ask-then-dispatch could not do that — every source it
2293
+ * consults only learns about a dispatch after the ack.
2294
+ *
2295
+ * Returns true when the caller owns the slot and should dispatch. A caller that
2296
+ * then fails to dispatch MUST releaseIndexRun, or the file waits out the claim
2297
+ * (a few minutes) before it can be retried.
2298
+ */
2299
+ claimIndexRun(storagePath) {
2300
+ var self = this;
2301
+ if (!storagePath) return Promise.resolve(true);
2302
+ if (this.hasLiveIndexRun(storagePath)) return Promise.resolve(false);
2303
+ this._indexClaims[this._indexClaimKey(storagePath)] = nowMs();
2304
+ return this._refreshLiveIndexKeys(LIVE_INDEX_SNAPSHOT_MAX_AGE_MS).then(function() {
2305
+ if (!self.state.liveIndexKeys[storagePath]) return true;
2306
+ self.releaseIndexRun(storagePath);
2307
+ return false;
2308
+ }).catch(function() {
2309
+ return true;
2310
+ });
2311
+ }
2312
+ /** Give the slot back — the dispatch failed, or was abandoned. */
2313
+ releaseIndexRun(storagePath) {
2314
+ if (storagePath) delete this._indexClaims[this._indexClaimKey(storagePath)];
2315
+ }
2316
+ /**
2317
+ * The same question, asked of the SERVER when this page cannot answer it.
2318
+ *
2319
+ * hasLiveIndexRun only knows what this page did. That is not enough for the
2320
+ * case duplicates actually come from: the first run was started before a
2321
+ * reload, or in another tab, or its bubble has since been paged out of the
2322
+ * loaded window — and then the retry finds nothing locally and starts a second
2323
+ * run of a file that is still being indexed. The queue is the one place that
2324
+ * knows, and it is already asked for exactly this list.
2325
+ *
2326
+ * Only a POSITIVE answer is used. Absence proves nothing here (the query is
2327
+ * capped, and `liveIndexChecked` records that), so an unanswerable question
2328
+ * falls back to dispatching — the cost of a wrong "no" is the duplicate this
2329
+ * exists to prevent, and the cost of a wrong "yes" is a file that never gets
2330
+ * indexed at all. Only one of those is recoverable by the user.
2331
+ */
2332
+ isIndexRunLive(storagePath) {
2333
+ var self = this;
2334
+ if (!storagePath) return Promise.resolve(false);
2335
+ if (this.hasLiveIndexRun(storagePath)) return Promise.resolve(true);
2336
+ return this._refreshLiveIndexKeys(LIVE_INDEX_SNAPSHOT_MAX_AGE_MS).then(function() {
2337
+ return !!self.state.liveIndexKeys[storagePath];
2338
+ }).catch(function() {
2339
+ return false;
2340
+ });
2341
+ }
2342
+ /** Re-ask the queue which files are still being indexed, unless the answer we
2343
+ * have is younger than `maxAgeMs`. Shared by every caller that needs a current
2344
+ * one; the display layer's own refresh path is the adopt ladder. */
2345
+ _refreshLiveIndexKeys(maxAgeMs) {
2346
+ var self = this;
2347
+ var id = this.host.getIdentity();
2348
+ var platform = id.platform;
2349
+ if (!id.projectId || platform !== "claude" && platform !== "openai") return Promise.resolve();
2350
+ var askedKey = this.getHistoryCacheKey();
2351
+ if (this._liveIndexKey === askedKey && nowMs() - this._liveIndexAt < maxAgeMs) {
2352
+ return Promise.resolve();
2353
+ }
2354
+ var queue = bgIndexingQueueName(id.userId, id.projectId);
2355
+ var ask = function(status) {
2356
+ return Promise.resolve(getChatHistory(
2357
+ { service: id.projectId, owner: id.owner, platform, queue, status },
2358
+ { limit: WORKER_PASS_ADOPT_LIMIT }
2359
+ )).catch(function() {
2360
+ return null;
2361
+ });
2362
+ };
2363
+ return Promise.all([ask("pending"), ask("running")]).then(function(results) {
2364
+ if (results[0] === null || results[1] === null) return;
2365
+ if (self.getHistoryCacheKey() !== askedKey) return;
2366
+ self._liveIndexKey = askedKey;
2367
+ self._recordLiveIndexKeys(results);
2368
+ });
2369
+ }
2370
+ /**
2371
+ * Replace the live-index snapshot from a queue query's raw items.
2372
+ *
2373
+ * Whole-snapshot, never incremental: the query returns everything unresolved on
2374
+ * the queue, so a file MISSING from it is precisely the fact we are after. Merging
2375
+ * would make a finished file impossible to observe.
2376
+ */
2377
+ _recordLiveIndexKeys(lists) {
2378
+ var next = {};
2379
+ var truncated = false;
2380
+ var settledIds = {};
2381
+ this.state.messages.forEach(function(m) {
2382
+ if (!m._serverItemId) return;
2383
+ if (m.isPending || m.isPendingInProcess || m.isPendingQueued) return;
2384
+ settledIds[m._serverItemId] = true;
2385
+ });
2386
+ for (var li = 0; li < lists.length; li++) {
2387
+ var list = lists[li] && Array.isArray(lists[li].list) ? lists[li].list : [];
2388
+ if (list.length >= WORKER_PASS_ADOPT_LIMIT) truncated = true;
2389
+ for (var i = 0; i < list.length; i++) {
2390
+ var item = list[i];
2391
+ if (!item || item.status !== "pending" && item.status !== "running") continue;
2392
+ if (item.id && settledIds[item.id]) continue;
2393
+ var text = extractLastUserTextFromRequest(item.request_body);
2394
+ if (!isIndexingRequestText(text)) continue;
2395
+ var ref = parseIndexingRequestText(text);
2396
+ if (!ref) continue;
2397
+ if (ref.path) next[ref.path] = true;
2398
+ if (ref.name) next[ref.name] = true;
2399
+ }
2400
+ }
2401
+ var nowChecked = !truncated;
2402
+ var was = this.state.liveIndexKeys, changed = this.state.liveIndexChecked !== nowChecked;
2403
+ if (!changed) {
2404
+ for (var k in next) if (!was[k]) {
2405
+ changed = true;
2406
+ break;
2407
+ }
2408
+ if (!changed) {
2409
+ for (var k2 in was) if (!next[k2]) {
2410
+ changed = true;
2411
+ break;
2412
+ }
2413
+ }
2414
+ }
2415
+ this.state.liveIndexKeys = next;
2416
+ this.state.liveIndexChecked = nowChecked;
2417
+ this._liveIndexAt = nowMs();
2418
+ this._liveIndexKey = this.getHistoryCacheKey();
2419
+ if (changed) this.host.notify();
2420
+ }
2421
+ /** Forget the snapshot: it describes ONE chat's queue, and the answer for the
2422
+ * project the user just switched to is unknown until it is asked for again. */
2423
+ _resetLiveIndexKeys() {
2424
+ this.state.liveIndexKeys = {};
2425
+ this.state.liveIndexChecked = false;
2426
+ this._liveIndexAt = 0;
2427
+ }
2428
+ /**
2429
+ * Ask the queue what is still indexing, once, for the chat that is on screen.
2430
+ *
2431
+ * Seeds the snapshot on a history load. Without it a reloaded chat has no way to
2432
+ * learn that a run it can see is over: the adopt ladder that normally answers this
2433
+ * only fires when a pass SETTLES, and after a reload there is no pass left to
2434
+ * settle — so every finished worker-driven row would spin forever.
2435
+ *
2436
+ * Best-effort: a failure leaves `checked` false, which reads as "still working"
2437
+ * rather than as a false all-clear.
2438
+ *
2439
+ * Delegates to the adopt ladder rather than asking once. A single empty look is
2440
+ * exactly what that ladder exists to distrust — the worker writes pass N+1 a few
2441
+ * milliseconds AFTER flipping pass N to resolved, so a query landing in that gap
2442
+ * sees an empty queue for a chain that is very much alive. One look would turn
2443
+ * that into a confident "Indexed" with a green check, on the one scenario this
2444
+ * whole feature is for, and nothing would ever re-ask: the ladder is normally
2445
+ * triggered by a pass SETTLING, and after a reload there is no pass left to
2446
+ * settle. The ladder re-asks at 0/2s/6s, records each answer, and as a bonus
2447
+ * adopts and polls any live pass it finds, which makes the row genuinely active
2448
+ * instead of merely unconfirmed.
2449
+ */
2450
+ refreshLiveIndexState() {
2451
+ this._adoptWorkerIndexingPasses(0);
2452
+ }
2453
+ /** Forget what we know about which files are indexing — but ONLY when the
2454
+ * snapshot was taken for a different chat than the one on screen now. For a
2455
+ * consumer whose history loading is its own fork and so never reaches
2456
+ * loadHistory's reset — a snapshot describes ONE chat's queue, and carrying it
2457
+ * into another project would let a row there claim to be finished on someone
2458
+ * else's evidence.
2459
+ *
2460
+ * Conditional for the same reason loadHistory's own reset is (the
2461
+ * `loadKey !== _liveIndexKey` gate): the view calls this on every mount, and
2462
+ * an unconditional wipe turned every re-entry to the chat into a grey
2463
+ * "Checking status:" sweep across rows whose state was already known. A
2464
+ * RE-entry keeps showing the last answer (green/yellow) while the first-page
2465
+ * refresh re-asks quietly; only a genuine project/platform switch starts from
2466
+ * "not known yet". Claiming `_liveIndexKey` here (before any answer) is the
2467
+ * same fudge loadHistory makes: it marks WHOSE chat the empty snapshot is
2468
+ * for, so repeated calls do not re-wipe, and _recordLiveIndexKeys re-claims
2469
+ * it when the real answer lands. */
2470
+ resetLiveIndexState() {
2471
+ var key = this.getHistoryCacheKey();
2472
+ if (key === this._liveIndexKey) return;
2473
+ this._liveIndexKey = key;
2474
+ this._resetLiveIndexKeys();
1919
2475
  }
1920
2476
  /** Wrap an indexing-request dispatch so awaitIndexingDrained counts it as
1921
2477
  * live work from the moment it is sent, not from the moment it is acked. */
@@ -1933,6 +2489,31 @@ var ChatSession = class {
1933
2489
  throw e;
1934
2490
  });
1935
2491
  }
2492
+ /**
2493
+ * Something just happened that plausibly ENDED indexing work, so let any waiting
2494
+ * turn look now instead of sitting out the rest of its busy interval.
2495
+ *
2496
+ * A nudge changes only WHEN a look happens, never what it concludes: the two
2497
+ * agreeing idle looks, the confirm gap between them, "a failed look counts as
2498
+ * busy" and the minimum wait are all untouched. That is why it is safe to fire
2499
+ * from places that are merely good guesses.
2500
+ *
2501
+ * Fired from end-of-chain points ONLY: the adopt ladder giving up, a resume
2502
+ * declining to continue, a pass failing. Not from every settling pass (one nudge
2503
+ * per pass per file for the whole run), and not from an indexing request being
2504
+ * accepted — see the note in trackIndexDispatch for why that one is actively
2505
+ * harmful rather than merely wasteful.
2506
+ */
2507
+ _nudgeIndexingDrain() {
2508
+ if (!this._drainNudges.length) return;
2509
+ var list = this._drainNudges.slice();
2510
+ for (var i = 0; i < list.length; i++) {
2511
+ try {
2512
+ list[i]();
2513
+ } catch (e) {
2514
+ }
2515
+ }
2516
+ }
1936
2517
  /**
1937
2518
  * Register a live poll so (a) a remount dedupes against it instead of stacking a
1938
2519
  * SECOND poll on the same item, and (b) pausePolling can stop it.
@@ -1948,6 +2529,18 @@ var ChatSession = class {
1948
2529
  this.historyItemPolls.set(id, { kind, stop });
1949
2530
  return p;
1950
2531
  }
2532
+ /** Background polls currently attached, for the MAX_CONCURRENT_BG_POLLS budget.
2533
+ * Counts the registry rather than a separate tally so it cannot drift: every
2534
+ * attach goes through _trackPoll and every detach deletes the entry. Note an
2535
+ * entry left behind by pausePolling on an older skapi-js (no stop handle)
2536
+ * still counts, which is correct — that poll really is still running. */
2537
+ _countBgPolls() {
2538
+ var n = 0;
2539
+ this.historyItemPolls.forEach(function(handle) {
2540
+ if (handle && handle.kind === "bg") n++;
2541
+ });
2542
+ return n;
2543
+ }
1951
2544
  /**
1952
2545
  * Stop and forget one item's poll. Used after a cancel: the row is either gone
1953
2546
  * (cancelled while queued) or flagged cancelled (cancelled while running), so
@@ -2029,15 +2622,14 @@ var ChatSession = class {
2029
2622
  }
2030
2623
  getHistoryCacheKey() {
2031
2624
  var id = this.host.getIdentity();
2032
- if (!id.serviceId || id.platform === "none") return "";
2033
- return id.serviceId + "#" + id.platform;
2625
+ if (!id.projectId || id.platform === "none") return "";
2626
+ return id.projectId + "#" + id.platform;
2034
2627
  }
2035
2628
  updateHistoryCache() {
2036
2629
  var key = this.getHistoryCacheKey();
2037
2630
  if (!key) return;
2038
2631
  this.aiChatHistoryCache[key] = {
2039
2632
  messages: this.state.messages.filter(function(m) {
2040
- if (m._stageId) return false;
2041
2633
  return m._ownerKey === void 0 || m._ownerKey === key;
2042
2634
  }),
2043
2635
  endOfList: this.state.historyEndOfList,
@@ -2084,6 +2676,7 @@ var ChatSession = class {
2084
2676
  for (var j = 0; j < msgs.length; j++) {
2085
2677
  var u = msgs[j];
2086
2678
  if (!u || u.role !== "user" || u.isBackgroundTask) continue;
2679
+ if (u._stageId) continue;
2087
2680
  if (!(u.isPendingQueued || u.isPendingInProcess || u.isSendingToServer)) continue;
2088
2681
  if (serverId && u._serverItemId && u._serverItemId !== serverId) continue;
2089
2682
  var settled = { role: "user", content: u.content };
@@ -2099,26 +2692,26 @@ var ChatSession = class {
2099
2692
  };
2100
2693
  }
2101
2694
  /**
2102
- * serviceId/owner are passed explicitly by every caller: a request can be
2695
+ * projectId/owner are passed explicitly by every caller: a request can be
2103
2696
  * dispatched after the user moved to another project, and re-reading the live
2104
2697
  * identity here would silently send the turn to THAT project instead of the
2105
2698
  * one it was composed for. Falls back to the live read only when a caller
2106
2699
  * omits them.
2107
2700
  */
2108
- _callProviderFor(platform, prompt, messages, system, model, userId, extractContent, fileUrls, serviceId, owner) {
2109
- if (serviceId === void 0 || owner === void 0) {
2701
+ _callProviderFor(platform, prompt, messages, system, model, userId, extractContent, fileUrls, projectId, owner) {
2702
+ if (projectId === void 0 || owner === void 0) {
2110
2703
  var id = this.host.getIdentity();
2111
- if (serviceId === void 0) serviceId = id.serviceId;
2704
+ if (projectId === void 0) projectId = id.projectId;
2112
2705
  if (owner === void 0) owner = id.owner;
2113
2706
  }
2114
- return platform === "openai" ? callOpenAIWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls) : callClaudeWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls);
2707
+ return platform === "openai" ? callOpenAIWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls) : callClaudeWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls);
2115
2708
  }
2116
2709
  dispatchAgentRequest(params) {
2117
2710
  var self = this;
2118
2711
  var dispatchItemId;
2119
2712
  var sendAndPoll = function() {
2120
2713
  return Promise.resolve(
2121
- self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.serviceId, params.owner)
2714
+ self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.projectId, params.owner)
2122
2715
  ).then(function(initial) {
2123
2716
  if (initial && initial.poll && (initial.status === "pending" || initial.status === "running")) {
2124
2717
  if (initial.id) {
@@ -2202,16 +2795,60 @@ var ChatSession = class {
2202
2795
  isPendingQueued: true,
2203
2796
  isUploadingAttachments: true,
2204
2797
  isSendingToServer: true,
2798
+ _dimSending: true,
2799
+ // A staged bubble has no server id for minutes, and its indexing rows are
2800
+ // now inserted ABOVE it — so its array index moves. Both views fall back to
2801
+ // the index when a bubble has no id, which would re-key (and in Vue, remount)
2802
+ // this bubble on every file, restarting its transition and losing it as a
2803
+ // scroll anchor. A local id it keeps for its whole life fixes both.
2804
+ _localId: this._newLocalId(),
2205
2805
  _useBgQueue: true,
2206
2806
  _stageId: stageId,
2207
2807
  _ts: wallClockNow()
2208
2808
  };
2209
2809
  if (key) staged._ownerKey = key;
2810
+ this._liveStages[stageId] = true;
2210
2811
  this.state.messages.push(staged);
2211
2812
  this.host.notify();
2212
2813
  this.host.scrollToBottom(true);
2213
2814
  return stageId;
2214
2815
  }
2816
+ /** Is anything in this page still uploading/dispatching for this stage? */
2817
+ isLiveStage(stageId) {
2818
+ return !!stageId && !!this._liveStages[stageId];
2819
+ }
2820
+ /**
2821
+ * Settle any staged bubble in `list` whose chain no longer exists, and return the
2822
+ * list (a new array only if something changed).
2823
+ *
2824
+ * The caller is a cache restore. A staged bubble is the one kind of message whose
2825
+ * resolution lives entirely in page memory — no server request stands behind it
2826
+ * yet — so a copy that outlives its upload would render "(Uploading files...)"
2827
+ * forever with nothing left to finish it. Today nothing can: this cache dies with
2828
+ * the page, so every restored stage is still live and this is a no-op. It exists
2829
+ * so that stops being a silent assumption.
2830
+ */
2831
+ settleDeadStagedMessages(list) {
2832
+ if (!Array.isArray(list)) return list;
2833
+ var self = this;
2834
+ var dead = false;
2835
+ for (var i = 0; i < list.length; i++) {
2836
+ var m = list[i];
2837
+ if (m && m._stageId && !self._liveStages[m._stageId]) {
2838
+ dead = true;
2839
+ break;
2840
+ }
2841
+ }
2842
+ if (!dead) return list;
2843
+ return list.map(function(m2) {
2844
+ if (!m2 || !m2._stageId || self._liveStages[m2._stageId]) return m2;
2845
+ var settled = { role: "user", content: m2.content };
2846
+ if (m2._ownerKey !== void 0) settled._ownerKey = m2._ownerKey;
2847
+ if (m2._ts !== void 0) settled._ts = m2._ts;
2848
+ if (m2._localId !== void 0) settled._localId = m2._localId;
2849
+ return settled;
2850
+ });
2851
+ }
2215
2852
  _stageIndex(list, stageId) {
2216
2853
  if (!stageId) return -1;
2217
2854
  for (var i = 0; i < list.length; i++) {
@@ -2220,32 +2857,46 @@ var ChatSession = class {
2220
2857
  return -1;
2221
2858
  }
2222
2859
  /**
2223
- * Where a staged turn belongs once it is finally sent: BELOW every indexing
2224
- * row, because that is the order it ran in and the order the server history
2225
- * will report on the next load (its request id is newer than every pass it
2226
- * waited for). While its files were uploading it sat above them — the rows
2227
- * are injected as each file's pass starts, after the bubble was staged.
2860
+ * Staged turn, phase 2: its files are up and it is now waiting for the whole
2861
+ * indexing chain behind them. Swaps "(Uploading files...)" for
2862
+ * "(Indexing files...)"; the bubble stays dimmed, because from the user's side
2863
+ * nothing has been handed over yet.
2228
2864
  *
2229
- * Returns the index to insert at, or -1 to leave the turn where it is. Never
2230
- * moves a turn UP: a bubble that already sits below the indexing rows (or a
2231
- * chat with no indexing at all) must not jump backwards over anything.
2865
+ * It deliberately does NOT say "(In queue)" here. The turn is not queued behind
2866
+ * anything the server knows about yet it is waiting on work that can run for
2867
+ * minutes and claiming otherwise is what made the wait look like a stall.
2232
2868
  */
2233
- _settledStagePosition(fromIdx) {
2234
- var lastBg = -1;
2235
- for (var i = 0; i < this.state.messages.length; i++) {
2236
- if (this.state.messages[i] && this.state.messages[i].isBackgroundTask) lastBg = i;
2237
- }
2238
- if (lastBg <= fromIdx) return -1;
2239
- return lastBg;
2240
- }
2241
- /** The staged turn's files are up; it is now just waiting its place in the
2242
- * queue. Drops the "(Uploading files...)" note back to "(In queue)". */
2243
- markStagedMessageQueued(stageId) {
2869
+ markStagedMessageIndexing(stageId) {
2244
2870
  var idx = this._stageIndex(this.state.messages, stageId);
2245
2871
  if (idx === -1) return;
2246
2872
  var ex = this.state.messages[idx];
2247
2873
  if (!ex.isUploadingAttachments) return;
2248
- this.state.messages[idx] = Object.assign({}, ex, { isUploadingAttachments: false });
2874
+ this.state.messages[idx] = Object.assign({}, ex, {
2875
+ isUploadingAttachments: false,
2876
+ isAwaitingIndexing: true
2877
+ });
2878
+ this.host.notify();
2879
+ }
2880
+ /**
2881
+ * Staged turn, phase 3: the last of its files has finished indexing, so the turn
2882
+ * is genuinely just queued now. Full opacity + "(In queue)".
2883
+ *
2884
+ * Clears the PRESENTATIONAL _dimSending only; isSendingToServer stays set until
2885
+ * the server actually acks (it is the token that ack matches on). Called by the
2886
+ * clients the instant awaitIndexingDrained resolves, i.e. immediately before the
2887
+ * dispatch that replaces this bubble — dispatchComposedMessage carries the
2888
+ * cleared flag onto the replacement so the turn does not blink back to dimmed.
2889
+ */
2890
+ markStagedMessageReady(stageId) {
2891
+ var idx = this._stageIndex(this.state.messages, stageId);
2892
+ if (idx === -1) return;
2893
+ var ex = this.state.messages[idx];
2894
+ if (!ex.isAwaitingIndexing && !ex._dimSending && !ex.isUploadingAttachments) return;
2895
+ this.state.messages[idx] = Object.assign({}, ex, {
2896
+ isUploadingAttachments: false,
2897
+ isAwaitingIndexing: false,
2898
+ _dimSending: false
2899
+ });
2249
2900
  this.host.notify();
2250
2901
  }
2251
2902
  /**
@@ -2272,7 +2923,7 @@ var ChatSession = class {
2272
2923
  */
2273
2924
  awaitIndexingDrained(identity) {
2274
2925
  var self = this;
2275
- var svcId = identity && identity.serviceId;
2926
+ var svcId = identity && identity.projectId;
2276
2927
  var platform = identity && identity.platform;
2277
2928
  if (!svcId || platform !== "claude" && platform !== "openai") return Promise.resolve("skipped");
2278
2929
  var owner = identity.owner;
@@ -2281,11 +2932,29 @@ var ChatSession = class {
2281
2932
  var deadline = startedAt + INDEXING_DRAIN_TIMEOUT_MS;
2282
2933
  var idleLooks = 0;
2283
2934
  var ask = function(status) {
2284
- return Promise.resolve(getChatHistory(
2285
- { service: svcId, owner, platform, queue, status },
2286
- { limit: WORKER_PASS_ADOPT_LIMIT }
2287
- )).catch(function() {
2288
- return null;
2935
+ var answered = false;
2936
+ return new Promise(function(res) {
2937
+ var bail = null;
2938
+ var settle = function(v) {
2939
+ if (answered) return;
2940
+ answered = true;
2941
+ if (bail) {
2942
+ clearTimeout(bail);
2943
+ bail = null;
2944
+ }
2945
+ res(v);
2946
+ };
2947
+ bail = setTimeout(function() {
2948
+ settle(null);
2949
+ }, INDEXING_DRAIN_LOOK_TIMEOUT_MS);
2950
+ Promise.resolve(getChatHistory(
2951
+ { service: svcId, owner, platform, queue, status },
2952
+ { limit: WORKER_PASS_ADOPT_LIMIT }
2953
+ )).then(function(r) {
2954
+ settle(r);
2955
+ }, function() {
2956
+ settle(null);
2957
+ });
2289
2958
  });
2290
2959
  };
2291
2960
  var hasLiveIndexing = function(res) {
@@ -2298,12 +2967,42 @@ var ChatSession = class {
2298
2967
  return false;
2299
2968
  };
2300
2969
  return new Promise(function(resolve) {
2301
- var again = function() {
2302
- setTimeout(look, idleLooks > 0 ? INDEXING_DRAIN_CONFIRM_POLL_MS : INDEXING_DRAIN_BUSY_POLL_MS);
2970
+ var timer = null;
2971
+ var lastLookAt = -Infinity;
2972
+ var nudgedThisInterval = false;
2973
+ var inFlight = false;
2974
+ var finish = function(v) {
2975
+ if (timer) {
2976
+ clearTimeout(timer);
2977
+ timer = null;
2978
+ }
2979
+ var ni = self._drainNudges.indexOf(nudge);
2980
+ if (ni !== -1) self._drainNudges.splice(ni, 1);
2981
+ resolve(v);
2982
+ };
2983
+ var again = function(ms) {
2984
+ if (timer) {
2985
+ clearTimeout(timer);
2986
+ timer = null;
2987
+ }
2988
+ var wait = ms == null ? idleLooks > 0 ? INDEXING_DRAIN_CONFIRM_POLL_MS : INDEXING_DRAIN_BUSY_POLL_MS : ms;
2989
+ timer = setTimeout(look, wait);
2990
+ };
2991
+ var nudge = function() {
2992
+ if (idleLooks > 0) return;
2993
+ if (inFlight) return;
2994
+ if (nudgedThisInterval) return;
2995
+ if (self._indexDispatchesInFlight > 0) return;
2996
+ nudgedThisInterval = true;
2997
+ again(Math.max(0, INDEXING_DRAIN_NUDGE_MIN_GAP_MS - (nowMs() - lastLookAt)));
2303
2998
  };
2304
2999
  var look = function() {
3000
+ timer = null;
3001
+ if (inFlight) return;
3002
+ lastLookAt = nowMs();
3003
+ nudgedThisInterval = false;
2305
3004
  if (nowMs() >= deadline) {
2306
- resolve("timedout");
3005
+ finish("timedout");
2307
3006
  return;
2308
3007
  }
2309
3008
  if (self._indexDispatchesInFlight > 0) {
@@ -2311,20 +3010,24 @@ var ChatSession = class {
2311
3010
  again();
2312
3011
  return;
2313
3012
  }
3013
+ inFlight = true;
2314
3014
  Promise.all([ask("running"), ask("pending")]).then(function(res) {
3015
+ inFlight = false;
2315
3016
  var unknown = res[0] === null || res[1] === null;
2316
3017
  if (unknown || hasLiveIndexing(res[0]) || hasLiveIndexing(res[1])) idleLooks = 0;
2317
3018
  else idleLooks += 1;
2318
3019
  if (idleLooks >= INDEXING_DRAIN_IDLE_LOOKS && nowMs() - startedAt >= INDEXING_DRAIN_MIN_MS) {
2319
- resolve("drained");
3020
+ finish("drained");
2320
3021
  return;
2321
3022
  }
2322
3023
  again();
2323
3024
  }, function() {
3025
+ inFlight = false;
2324
3026
  idleLooks = 0;
2325
3027
  again();
2326
3028
  });
2327
3029
  };
3030
+ self._drainNudges.push(nudge);
2328
3031
  look();
2329
3032
  });
2330
3033
  }
@@ -2335,12 +3038,14 @@ var ChatSession = class {
2335
3038
  * failure separately.
2336
3039
  */
2337
3040
  settleStagedMessage(stageId) {
3041
+ delete this._liveStages[stageId];
2338
3042
  var idx = this._stageIndex(this.state.messages, stageId);
2339
3043
  if (idx === -1) return;
2340
3044
  var ex = this.state.messages[idx];
2341
3045
  var settled = { role: "user", content: ex.content };
2342
3046
  if (ex._ownerKey !== void 0) settled._ownerKey = ex._ownerKey;
2343
3047
  if (ex._ts !== void 0) settled._ts = ex._ts;
3048
+ if (ex._localId !== void 0) settled._localId = ex._localId;
2344
3049
  this.state.messages[idx] = settled;
2345
3050
  this.host.notify();
2346
3051
  this.updateHistoryCache();
@@ -2360,8 +3065,9 @@ var ChatSession = class {
2360
3065
  if (stageId) this.settleStagedMessage(stageId);
2361
3066
  return;
2362
3067
  }
3068
+ if (stageId) delete this._liveStages[stageId];
2363
3069
  var llmComposed = composedForLlm || composed;
2364
- var key = !id.serviceId ? "" : id.serviceId + "#" + id.platform;
3070
+ var key = !id.projectId ? "" : id.projectId + "#" + id.platform;
2365
3071
  var offChat = !!key && key !== this.getHistoryCacheKey();
2366
3072
  var isQueuedSend = !offChat && (useBgQueue || this.state.sending || this.state.messages.some(function(m) {
2367
3073
  return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue;
@@ -2369,7 +3075,7 @@ var ChatSession = class {
2369
3075
  var aiPlatform = id.platform;
2370
3076
  var aiModel = id.model || void 0;
2371
3077
  var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
2372
- var userId = id.userId || id.serviceId;
3078
+ var userId = id.userId || id.projectId;
2373
3079
  var chatQueue = useBgQueue ? bgIndexingQueueName(userId) : userId;
2374
3080
  if (offChat) {
2375
3081
  var offHistory = (this.aiChatHistoryCache[key] ? this.aiChatHistoryCache[key].messages : []).filter(function(m) {
@@ -2379,7 +3085,7 @@ var ChatSession = class {
2379
3085
  platform: aiPlatform,
2380
3086
  model: aiModel,
2381
3087
  systemPrompt,
2382
- serviceId: id.serviceId,
3088
+ projectId: id.projectId,
2383
3089
  history: offHistory.concat([{ role: "user", content: llmComposed }])
2384
3090
  });
2385
3091
  var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
@@ -2390,8 +3096,16 @@ var ChatSession = class {
2390
3096
  this.state.messages.splice(offStage, 1);
2391
3097
  this.host.notify();
2392
3098
  }
3099
+ var offCached = offExisting.messages;
3100
+ if (stageId) {
3101
+ offCached = offCached.filter(function(m) {
3102
+ if (m._stageId !== stageId) return true;
3103
+ if (offStage === -1 && m._ts !== void 0) offUser._ts = m._ts;
3104
+ return false;
3105
+ });
3106
+ }
2393
3107
  this.aiChatHistoryCache[key] = {
2394
- messages: offExisting.messages.concat([
3108
+ messages: offCached.concat([
2395
3109
  offUser,
2396
3110
  { role: "assistant", content: "", isPending: true, isPendingInProcess: true, _ownerKey: key }
2397
3111
  ]),
@@ -2400,7 +3114,7 @@ var ChatSession = class {
2400
3114
  };
2401
3115
  this.dispatchAgentRequest({
2402
3116
  key,
2403
- serviceId: id.serviceId,
3117
+ projectId: id.projectId,
2404
3118
  owner: id.owner,
2405
3119
  aiPlatform,
2406
3120
  aiModel,
@@ -2421,22 +3135,19 @@ var ChatSession = class {
2421
3135
  platform: aiPlatform,
2422
3136
  model: aiModel,
2423
3137
  systemPrompt,
2424
- serviceId: id.serviceId,
3138
+ projectId: id.projectId,
2425
3139
  history: resolvedHistory.concat([{ role: "user", content: llmComposed }])
2426
3140
  });
2427
- var queuedBubble = { role: "user", content: composed, isPendingQueued: true, isSendingToServer: true, _ts: wallClockNow() };
3141
+ var queuedBubble = { role: "user", content: composed, isPendingQueued: true, isSendingToServer: true, _dimSending: true, _localId: this._newLocalId(), _ts: wallClockNow() };
2428
3142
  if (key) queuedBubble._ownerKey = key;
2429
3143
  if (useBgQueue) queuedBubble._useBgQueue = true;
2430
3144
  var qStage = this._stageIndex(this.state.messages, stageId);
2431
3145
  if (qStage !== -1) {
2432
- if (this.state.messages[qStage]._ts !== void 0) queuedBubble._ts = this.state.messages[qStage]._ts;
2433
- var qTarget = this._settledStagePosition(qStage);
2434
- if (qTarget === -1) {
2435
- this.state.messages.splice(qStage, 1, queuedBubble);
2436
- } else {
2437
- this.state.messages.splice(qStage, 1);
2438
- this.state.messages.splice(qTarget, 0, queuedBubble);
2439
- }
3146
+ var qEx = this.state.messages[qStage];
3147
+ if (qEx._ts !== void 0) queuedBubble._ts = qEx._ts;
3148
+ if (qEx._dimSending === false) queuedBubble._dimSending = false;
3149
+ if (qEx._localId) queuedBubble._localId = qEx._localId;
3150
+ this.state.messages.splice(qStage, 1, queuedBubble);
2440
3151
  } else {
2441
3152
  this.state.messages.push(queuedBubble);
2442
3153
  }
@@ -2444,13 +3155,13 @@ var ChatSession = class {
2444
3155
  this.updateHistoryCache();
2445
3156
  this.host.scrollToBottom(true);
2446
3157
  var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
2447
- Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.serviceId, id.owner)).then(function(result) {
3158
+ Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.projectId, id.owner)).then(function(result) {
2448
3159
  var sendingIdx = self.getHistoryCacheKey() !== capturedKey ? -1 : self.state.messages.findIndex(function(m) {
2449
3160
  return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === "user" && !m._stageId && (m._ownerKey === void 0 || m._ownerKey === capturedKey);
2450
3161
  });
2451
3162
  var serverId = result && typeof result.id === "string" ? result.id : void 0;
2452
3163
  if (sendingIdx >= 0) {
2453
- var upd = Object.assign({}, self.state.messages[sendingIdx], { isSendingToServer: false });
3164
+ var upd = Object.assign({}, self.state.messages[sendingIdx], { isSendingToServer: false, _dimSending: false });
2454
3165
  if (serverId) upd._serverItemId = serverId;
2455
3166
  self.state.messages[sendingIdx] = upd;
2456
3167
  self.host.notify();
@@ -2471,18 +3182,14 @@ var ChatSession = class {
2471
3182
  });
2472
3183
  return;
2473
3184
  }
2474
- var immediateUser = { role: "user", content: composed, _ts: wallClockNow(), ...key ? { _ownerKey: key } : {} };
3185
+ var immediateUser = { role: "user", content: composed, _localId: this._newLocalId(), _ts: wallClockNow(), ...key ? { _ownerKey: key } : {} };
2475
3186
  var immediatePlaceholder = { role: "assistant", content: "", isPending: true, isPendingInProcess: true, ...key ? { _ownerKey: key } : {} };
2476
3187
  var iStage = this._stageIndex(this.state.messages, stageId);
2477
3188
  if (iStage !== -1) {
2478
- if (this.state.messages[iStage]._ts !== void 0) immediateUser._ts = this.state.messages[iStage]._ts;
2479
- var iTarget = this._settledStagePosition(iStage);
2480
- if (iTarget === -1) {
2481
- this.state.messages.splice(iStage, 1, immediateUser, immediatePlaceholder);
2482
- } else {
2483
- this.state.messages.splice(iStage, 1);
2484
- this.state.messages.splice(iTarget, 0, immediateUser, immediatePlaceholder);
2485
- }
3189
+ var iEx = this.state.messages[iStage];
3190
+ if (iEx._ts !== void 0) immediateUser._ts = iEx._ts;
3191
+ if (iEx._localId) immediateUser._localId = iEx._localId;
3192
+ this.state.messages.splice(iStage, 1, immediateUser, immediatePlaceholder);
2486
3193
  } else {
2487
3194
  this.state.messages.push(immediateUser);
2488
3195
  this.state.messages.push(immediatePlaceholder);
@@ -2500,12 +3207,12 @@ var ChatSession = class {
2500
3207
  platform: aiPlatform,
2501
3208
  model: aiModel,
2502
3209
  systemPrompt,
2503
- serviceId: id.serviceId,
3210
+ projectId: id.projectId,
2504
3211
  history: historyForLlm
2505
3212
  });
2506
3213
  var run = this.dispatchAgentRequest({
2507
3214
  key,
2508
- serviceId: id.serviceId,
3215
+ projectId: id.projectId,
2509
3216
  owner: id.owner,
2510
3217
  aiPlatform,
2511
3218
  aiModel,
@@ -2562,6 +3269,8 @@ var ChatSession = class {
2562
3269
  if (existing._serverItemId !== void 0) promoted._serverItemId = existing._serverItemId;
2563
3270
  if (existing._ownerKey !== void 0) promoted._ownerKey = existing._ownerKey;
2564
3271
  if (existing.isSendingToServer) promoted.isSendingToServer = true;
3272
+ if (existing._dimSending) promoted._dimSending = true;
3273
+ if (existing._localId !== void 0) promoted._localId = existing._localId;
2565
3274
  this.state.messages[nextIdx] = promoted;
2566
3275
  var placeholder = { role: "assistant", content: "", isPending: true };
2567
3276
  if (existing._serverItemId !== void 0) placeholder._serverItemId = existing._serverItemId;
@@ -2569,6 +3278,33 @@ var ChatSession = class {
2569
3278
  this.state.messages.splice(nextIdx + 1, 0, placeholder);
2570
3279
  this.host.notify();
2571
3280
  }
3281
+ /**
3282
+ * The "Thinking..." placeholder belonging to the user bubble at `userIdx`, or -1.
3283
+ *
3284
+ * Every path that creates one puts it IMMEDIATELY after its user bubble
3285
+ * (promoteNextQueuedToRunning, the immediate-send pair, applyHistoryItemResolution),
3286
+ * so ownership is adjacency — modulo background bubbles, which get spliced in
3287
+ * around them. Taking the first pending assistant ANYWHERE below instead was a
3288
+ * hijack: a turn sent with attachments never gets a placeholder of its own
3289
+ * (promoteNextQueuedToRunning skips _useBgQueue turns) and now keeps the position
3290
+ * it was sent in, so an ordinary turn sent while its files indexed sits BELOW it
3291
+ * with a placeholder of its own — and the attachment turn's answer was rendered
3292
+ * as the answer to that unrelated question.
3293
+ */
3294
+ _ownThinkingIndex(userIdx, serverId) {
3295
+ if (userIdx < 0) return -1;
3296
+ for (var i = userIdx + 1; i < this.state.messages.length; i++) {
3297
+ var m = this.state.messages[i];
3298
+ if (!m) return -1;
3299
+ if (m.isBackgroundTask) continue;
3300
+ if (m.isPending && m.role === "assistant") {
3301
+ if (serverId && m._serverItemId && m._serverItemId !== serverId) return -1;
3302
+ return i;
3303
+ }
3304
+ return -1;
3305
+ }
3306
+ return -1;
3307
+ }
2572
3308
  resolveQueuedUserBubble(serverId) {
2573
3309
  var liveKey = this.getHistoryCacheKey();
2574
3310
  var isLocal = function(m) {
@@ -2595,9 +3331,7 @@ var ChatSession = class {
2595
3331
  if (userIdx >= 0) {
2596
3332
  var ex = this.state.messages[userIdx];
2597
3333
  this.state.messages[userIdx] = { role: "user", content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId, ...ex._ownerKey !== void 0 ? { _ownerKey: ex._ownerKey } : {} };
2598
- var thIdx = this.state.messages.findIndex(function(m, i) {
2599
- return i > userIdx && m.isPending && m.role === "assistant" && !m.isBackgroundTask;
2600
- });
3334
+ var thIdx = this._ownThinkingIndex(userIdx, serverId);
2601
3335
  if (thIdx !== -1) this.state.messages.splice(thIdx, 1);
2602
3336
  }
2603
3337
  this.promoteNextQueuedToRunning();
@@ -2609,16 +3343,17 @@ var ChatSession = class {
2609
3343
  if (exist._serverItemId !== void 0) repl._serverItemId = exist._serverItemId;
2610
3344
  if (exist._ownerKey !== void 0) repl._ownerKey = exist._ownerKey;
2611
3345
  if (exist._ts !== void 0) repl._ts = exist._ts;
3346
+ if (exist._localId !== void 0) repl._localId = exist._localId;
2612
3347
  this.state.messages[userIdx] = repl;
2613
3348
  }
2614
- var thinkingIdx = userIdx >= 0 ? this.state.messages.findIndex(function(m, i) {
2615
- return i > userIdx && m.isPending && m.role === "assistant" && !m.isBackgroundTask;
2616
- }) : -1;
3349
+ var thinkingIdx = this._ownThinkingIndex(userIdx, serverId);
2617
3350
  return thinkingIdx !== -1 ? thinkingIdx : userIdx >= 0 ? userIdx + 1 : -1;
2618
3351
  }
2619
3352
  insertAtTarget(msg, targetIdx) {
2620
3353
  if (msg && msg.role === "assistant" && msg._ts === void 0) msg._ts = wallClockNow();
2621
- if (targetIdx >= 0 && this.state.messages[targetIdx] && this.state.messages[targetIdx].isPending) this.state.messages[targetIdx] = msg;
3354
+ var tgt = targetIdx >= 0 ? this.state.messages[targetIdx] : void 0;
3355
+ var replaceable = !!tgt && !!tgt.isPending && !tgt.isBackgroundTask && this._isOwnPlaceholderOf(targetIdx, this._owningUserIndex(targetIdx));
3356
+ if (replaceable) this.state.messages[targetIdx] = msg;
2622
3357
  else if (targetIdx >= 0) this.state.messages.splice(targetIdx, 0, msg);
2623
3358
  else this.state.messages.push(msg);
2624
3359
  }
@@ -2735,16 +3470,21 @@ var ChatSession = class {
2735
3470
  var platform = id.platform;
2736
3471
  if (platform !== "claude" && platform !== "openai") return;
2737
3472
  var url = platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
2738
- var queueBase = id.userId || id.serviceId;
3473
+ var queueBase = id.userId || id.projectId;
2739
3474
  var queue = msg.isBackgroundTask || msg._useBgQueue ? bgIndexingQueueName(queueBase) : queueBase;
2740
- this.state.messages[idx] = Object.assign({}, msg, { _cancelling: true, _cancelError: void 0 });
3475
+ var at = this.state.messages[idx] && this.state.messages[idx]._serverItemId === serverId && this.state.messages[idx].role === msg.role ? idx : this.state.messages.findIndex(function(m) {
3476
+ return m._serverItemId === serverId && m.role === msg.role;
3477
+ });
3478
+ if (at !== -1) {
3479
+ this.state.messages[at] = Object.assign({}, this.state.messages[at], { _cancelling: true, _cancelError: void 0 });
3480
+ }
2741
3481
  this.host.notify();
2742
3482
  Promise.resolve(this.host.cancelRequest({
2743
3483
  url,
2744
3484
  method: "POST",
2745
3485
  id: serverId,
2746
3486
  queue,
2747
- service: id.serviceId,
3487
+ service: id.projectId,
2748
3488
  owner: id.owner
2749
3489
  })).then(function(result) {
2750
3490
  if (result && result.removed) {
@@ -2814,7 +3554,10 @@ var ChatSession = class {
2814
3554
  * 2. the file is remembered in cancelledIndexKeys, so the client-driven
2815
3555
  * resume (maybeResumeIndexing) stops dispatching CONTINUE passes; and
2816
3556
  * 3. any of its passes still sitting in bgTaskQueue is dropped by the next
2817
- * drain rather than surfacing a fresh "Indexing…" bubble.
3557
+ * drain rather than surfacing a fresh "Indexing…" bubble; and
3558
+ * 4. the RUN is remembered (state.stoppedIndexIds), because none of the above
3559
+ * necessarily leaves a mark on the conversation — see below — and without
3560
+ * it the collapsed row reported the stopped file as finished.
2818
3561
  *
2819
3562
  * Records already written by the passes that DID run are kept — this stops the
2820
3563
  * work, it does not undo it.
@@ -2824,6 +3567,19 @@ var ChatSession = class {
2824
3567
  if (!group || !group.key) return;
2825
3568
  var scoped = this.getHistoryCacheKey() + "|" + group.key;
2826
3569
  this.cancelledIndexKeys.add(scoped);
3570
+ if (!group.finished) {
3571
+ var stoppedIds = {};
3572
+ for (var sk in this.state.stoppedIndexIds) stoppedIds[sk] = true;
3573
+ (group.members || []).forEach(function(m) {
3574
+ var sid = m && m.msg && m.msg._serverItemId;
3575
+ if (sid) stoppedIds[sid] = true;
3576
+ });
3577
+ this.bgTaskQueue.forEach(function(e) {
3578
+ if (e && e.id && self._indexKeyOf(e) === scoped) stoppedIds[e.id] = true;
3579
+ });
3580
+ this.state.stoppedIndexIds = stoppedIds;
3581
+ }
3582
+ this._adoptWorkerIndexingPasses(0);
2827
3583
  var ids = group.cancellableIds || [];
2828
3584
  if (!ids.length) {
2829
3585
  this.host.notify();
@@ -2997,22 +3753,70 @@ var ChatSession = class {
2997
3753
  this.promoteNextQueuedToRunning();
2998
3754
  return this.enqueueTypewrite(pendingIdx, latest.content, lid);
2999
3755
  }
3000
- // Remove any leftover non-background pending ("Thinking…") assistant bubbles.
3001
- // There is normally at most ONE such bubble at a time (promoteNext* refuses to
3002
- // add a second), so any extra is a duplicate it appears when a concurrent
3003
- // history refetch re-maps the still-"running" turn into a pending placeholder
3004
- // (with a real _serverItemId) while the local pending bubble (no _serverItemId)
3005
- // is rescued and re-appended (see loadHistory rescue below). Each resolve path
3006
- // only replaces the FIRST pending bubble, so without this a stray "Thinking…"
3007
- // survives next to the reply/error. MUST run AFTER the resolved bubble has been
3008
- // made non-pending and BEFORE promoteNext*() (so a freshly-promoted Thinking,
3009
- // which is added only once no pending assistant remains, is preserved).
3756
+ // Remove leftover non-background pending ("Thinking…") assistant bubbles: the
3757
+ // duplicate that appears when a concurrent history refetch re-maps the still-
3758
+ // "running" turn into a pending placeholder (with a real _serverItemId) while the
3759
+ // local pending bubble (no _serverItemId) is rescued and re-appended (see the
3760
+ // loadHistory rescue below), and the orphan a resolve leaves when it splices its
3761
+ // reply beside a placeholder instead of into it. Each resolve path only replaces
3762
+ // ONE pending bubble, so without this a stray "Thinking…" survives forever next to
3763
+ // the reply. MUST run AFTER the resolved bubble has been made non-pending and
3764
+ // BEFORE promoteNext*() (which only adds a Thinking once none remains).
3765
+ //
3766
+ // It used to take EVERY one, on the premise that there is at most one at a time
3767
+ // because promoteNext* refuses to add a second. That premise never covered the
3768
+ // immediate-send path, which creates its pair directly — and a turn sent with
3769
+ // attachments does not block the composer and resolves on its own queue, so an
3770
+ // ordinary question asked while files index is in flight, with a placeholder of
3771
+ // its own, exactly when the attachment turn resolves. Sweeping it left that
3772
+ // question with no spinner and, worse, nowhere for its answer to land:
3773
+ // typewriteLatestReply bails when there is no pending assistant, so the reply
3774
+ // reached the cache and never the screen.
3775
+ //
3776
+ // The discriminator is the owning USER bubble. A live immediate send's user bubble
3777
+ // carries NO pending flags (its in-flight-ness lives in state.sending), while every
3778
+ // duplicate this sweep is for belongs to a user bubble that is still pending — and
3779
+ // an orphan has no user bubble above it at all.
3010
3780
  _removeStrayPendingAssistants() {
3011
3781
  for (var k = this.state.messages.length - 1; k >= 0; k--) {
3012
3782
  var m = this.state.messages[k];
3013
- if (m.isPending && m.role === "assistant" && !m.isBackgroundTask) this.state.messages.splice(k, 1);
3783
+ if (!m || !m.isPending || m.role !== "assistant" || m.isBackgroundTask) continue;
3784
+ if (this._isLiveImmediatePlaceholder(k)) continue;
3785
+ this.state.messages.splice(k, 1);
3014
3786
  }
3015
3787
  }
3788
+ /** Index of the USER bubble the message at `idx` belongs to — the nearest one
3789
+ * above it, stepping over background bubbles (a file's indexing rows are
3790
+ * inserted between turns). -1 when the nearest thing above is not a user turn,
3791
+ * which for a placeholder means it is an orphan. */
3792
+ _owningUserIndex(idx) {
3793
+ for (var j = idx - 1; j >= 0; j--) {
3794
+ var p = this.state.messages[j];
3795
+ if (!p) return -1;
3796
+ if (p.isBackgroundTask) continue;
3797
+ return p.role === "user" ? j : -1;
3798
+ }
3799
+ return -1;
3800
+ }
3801
+ /** The bubble at `idx` is the "Thinking…" of a DIFFERENT turn that is still
3802
+ * waiting for its answer, so the sweep above must leave it alone. */
3803
+ _isLiveImmediatePlaceholder(idx) {
3804
+ var ui = this._owningUserIndex(idx);
3805
+ if (ui === -1) return false;
3806
+ var p = this.state.messages[ui];
3807
+ return !p.isPending && !p.isPendingQueued && !p.isPendingInProcess && !p.isPendingOlder && !p.isSendingToServer && !p.isCancelled;
3808
+ }
3809
+ /** A pending assistant at `idx` is the placeholder OF the turn above it, so a
3810
+ * reply may take its slot. Every path that makes one copies the parent's
3811
+ * _serverItemId (or neither has one yet), so a mismatch means the slot belongs to
3812
+ * some other request and the reply must be spliced in beside it, not on top. */
3813
+ _isOwnPlaceholderOf(idx, userIdx) {
3814
+ if (userIdx === -1) return false;
3815
+ var ph = this.state.messages[idx], u = this.state.messages[userIdx];
3816
+ if (!ph || !u) return false;
3817
+ if (ph._serverItemId === void 0 || u._serverItemId === void 0) return true;
3818
+ return ph._serverItemId === u._serverItemId;
3819
+ }
3016
3820
  // Drop the pending flags on the resolved turn's USER bubble (preserving its
3017
3821
  // content + background-task marker). Needed because a bg "Indexing:" turn's user
3018
3822
  // bubble carries isPendingInProcess; leaving it set keeps the bubble visually
@@ -3059,6 +3863,7 @@ var ChatSession = class {
3059
3863
  var indexRef = this._indexRefOfItem(itemId);
3060
3864
  this.applyHistoryItemResolution(itemId, response, platform);
3061
3865
  this.promoteNextBgQueuedToRunning();
3866
+ this.drainBgTaskQueue();
3062
3867
  if (indexRef) this._followWorkerIndexingChain(indexRef.name, indexRef.mime);
3063
3868
  }
3064
3869
  /** The file an already-rendered background pass is about, off its request
@@ -3072,11 +3877,67 @@ var ChatSession = class {
3072
3877
  }
3073
3878
  return null;
3074
3879
  }
3880
+ /**
3881
+ * Settle a turn the server reports as cancelled: the request bubble goes to its
3882
+ * cancelled form and the "Thinking..." placeholder goes away. The same shape
3883
+ * cancelQueuedMessage produces locally, so a cancel this client made and one it
3884
+ * merely found out about render identically — and an indexing pass keeps the
3885
+ * markers that hold it in its file's collapsed row.
3886
+ */
3887
+ _settleCancelledItem(itemId) {
3888
+ var uIdx = this.state.messages.findIndex(function(m) {
3889
+ return m.role === "user" && m._serverItemId === itemId && !m.isCancelled;
3890
+ });
3891
+ if (uIdx !== -1) {
3892
+ var u = this.state.messages[uIdx];
3893
+ var cancelled = { role: "user", content: u.content, isCancelled: true, _serverItemId: itemId };
3894
+ if (u.isBackgroundTask) cancelled.isBackgroundTask = true;
3895
+ if (u._indexFile) cancelled._indexFile = u._indexFile;
3896
+ if (u._useBgQueue) cancelled._useBgQueue = true;
3897
+ if (u._ownerKey !== void 0) cancelled._ownerKey = u._ownerKey;
3898
+ if (u._ts !== void 0) cancelled._ts = u._ts;
3899
+ this.state.messages[uIdx] = cancelled;
3900
+ }
3901
+ var pIdx = this.state.messages.findIndex(function(m) {
3902
+ return m.isPending && m.role === "assistant" && m._serverItemId === itemId;
3903
+ });
3904
+ if (pIdx !== -1) this.state.messages.splice(pIdx, 1);
3905
+ this.cancelledServerIds.delete(itemId);
3906
+ this._removeStrayPendingAssistants();
3907
+ this.host.notify();
3908
+ this.updateHistoryCache();
3909
+ }
3910
+ /**
3911
+ * A poll that came back saying the request was CANCELLED, rather than with an
3912
+ * answer.
3913
+ *
3914
+ * The server keeps a cancelled request as a terminal row instead of deleting it
3915
+ * (that row is the durable record of the stop, and the chat history it belongs
3916
+ * to), so a poll still running when the cancel lands now RESOLVES on it. It used
3917
+ * to reject with NOT_EXISTS, and the resolution path below reads a status object
3918
+ * as an answer with no text — which would stamp "No text response received from
3919
+ * AI provider" over a turn the user had just stopped.
3920
+ *
3921
+ * Reachable whenever the poll was not stopped by whoever cancelled: another tab,
3922
+ * another device, or the row being cancelled server-side by the file's own stop.
3923
+ */
3924
+ _isCancelledPollResult(response) {
3925
+ if (!response || typeof response !== "object" || response.status !== "cancelled") return false;
3926
+ if (response.content !== void 0 || response.output !== void 0) return false;
3927
+ return response.queue_name !== void 0 || response.in_queue !== void 0;
3928
+ }
3075
3929
  applyHistoryItemResolution(itemId, response, platform) {
3076
3930
  this.historyItemPolls.delete(itemId);
3931
+ if (this._isCancelledPollResult(response)) {
3932
+ this._settleCancelledItem(itemId);
3933
+ return;
3934
+ }
3077
3935
  var isErr = isErrorResponseBody(response);
3078
3936
  var answer = isErr ? getErrorMessage(response) : ((platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "").trim();
3079
- if (!isErr && answer) answer = answer.split(INDEXING_COMPLETE_MARKER).join("").trim();
3937
+ var reportedComplete = !isErr && !!answer && answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1;
3938
+ var stripMarker = function(t) {
3939
+ return reportedComplete ? t.split(INDEXING_COMPLETE_MARKER).join("").trim() : t;
3940
+ };
3080
3941
  var idx = this.state.messages.findIndex(function(m) {
3081
3942
  return m.isPending && m._serverItemId === itemId;
3082
3943
  });
@@ -3092,7 +3953,7 @@ var ChatSession = class {
3092
3953
  }
3093
3954
  var text = answer || "No text response received from AI provider.";
3094
3955
  if (wasBgTask) {
3095
- this.state.messages[idx] = { role: "assistant", content: text, isBackgroundTask: true, _serverItemId: itemId };
3956
+ this.state.messages[idx] = { role: "assistant", content: stripMarker(text) || EMPTY_INDEXING_REPLY, isBackgroundTask: true, _serverItemId: itemId, ...reportedComplete ? { _indexComplete: true } : {} };
3096
3957
  this.host.notify();
3097
3958
  this.updateHistoryCache();
3098
3959
  return;
@@ -3125,7 +3986,7 @@ var ChatSession = class {
3125
3986
  }
3126
3987
  var text2 = answer || "No text response received from AI provider.";
3127
3988
  if (ex.isBackgroundTask) {
3128
- this.state.messages.splice(userIdx + 1, 0, { role: "assistant", content: text2, isBackgroundTask: true, _serverItemId: itemId });
3989
+ this.state.messages.splice(userIdx + 1, 0, { role: "assistant", content: stripMarker(text2) || EMPTY_INDEXING_REPLY, isBackgroundTask: true, _serverItemId: itemId, ...reportedComplete ? { _indexComplete: true } : {} });
3129
3990
  this.host.notify();
3130
3991
  this.updateHistoryCache();
3131
3992
  return;
@@ -3146,7 +4007,7 @@ var ChatSession = class {
3146
4007
  if (!entry) return "";
3147
4008
  var file = entry.storagePath || entry.filename;
3148
4009
  if (!file) return "";
3149
- return entry.serviceId + "#" + entry.platform + "|" + file;
4010
+ return entry.projectId + "#" + entry.platform + "|" + file;
3150
4011
  }
3151
4012
  /**
3152
4013
  * Reconcile the bg queue with the files the user has stopped.
@@ -3156,17 +4017,33 @@ var ChatSession = class {
3156
4017
  * path, and without this an earlier cancel would silently kill every future
3157
4018
  * index of the same path. A continuation of a stopped file is dropped instead,
3158
4019
  * covering the pass that was dispatched in the moment before the cancel landed.
4020
+ *
4021
+ * "Fresh" is the load-bearing word, and it used to be missing. A run's OWN first
4022
+ * pass sits in this queue for as long as it runs (entries are only dropped once
4023
+ * their bubble settles), so stopping a file during its first pass — which is
4024
+ * exactly when a user who has just uploaded it does — met that first-pass entry
4025
+ * on the very next drain and lifted the stop the user had just asked for. The
4026
+ * chain then carried on, one worker-minted window after another, with nothing
4027
+ * client-side left to suppress it. The ids recorded at stop time are what tells
4028
+ * the two apart: a pass that was already there when the user hit Stop cannot be
4029
+ * the new request that lifts it.
3159
4030
  */
3160
4031
  _applyIndexCancellations() {
3161
4032
  if (!this.cancelledIndexKeys.size) return;
4033
+ var surfaced = {};
4034
+ this.state.messages.forEach(function(m) {
4035
+ if (!m._serverItemId) return;
4036
+ if (m.isPending || m.isPendingQueued || m.isPendingInProcess) surfaced[m._serverItemId] = true;
4037
+ });
3162
4038
  for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
3163
4039
  var entry = this.bgTaskQueue[i];
3164
4040
  var key = this._indexKeyOf(entry);
3165
4041
  if (!key || !this.cancelledIndexKeys.has(key)) continue;
3166
- if (!entry.resumePass) {
4042
+ if (!entry.resumePass && !this.state.stoppedIndexIds[entry.id]) {
3167
4043
  this.cancelledIndexKeys.delete(key);
3168
4044
  continue;
3169
4045
  }
4046
+ if (surfaced[entry.id]) continue;
3170
4047
  this.bgTaskQueue.splice(i, 1);
3171
4048
  this._stopPoll(entry.id);
3172
4049
  this._cancelServerItem(entry.id);
@@ -3219,10 +4096,10 @@ var ChatSession = class {
3219
4096
  if (this._adoptingWorkerPasses) return;
3220
4097
  var id = this.host.getIdentity();
3221
4098
  var platform = id.platform;
3222
- if (!id.serviceId || platform !== "claude" && platform !== "openai") return;
4099
+ if (!id.projectId || platform !== "claude" && platform !== "openai") return;
3223
4100
  if (this.isPollingPaused() || !this.host.isViewMounted()) return;
3224
- var svcId = id.serviceId, owner = id.owner;
3225
- var queue = bgIndexingQueueName(id.userId, id.serviceId);
4101
+ var svcId = id.projectId, owner = id.owner;
4102
+ var queue = bgIndexingQueueName(id.userId, id.projectId);
3226
4103
  var ask = function(status) {
3227
4104
  return Promise.resolve(getChatHistory(
3228
4105
  { service: svcId, owner, platform, queue, status },
@@ -3235,8 +4112,9 @@ var ChatSession = class {
3235
4112
  Promise.all([ask("running"), ask("pending")]).then(function(results) {
3236
4113
  self._adoptingWorkerPasses = false;
3237
4114
  var now = self.host.getIdentity();
3238
- if (now.serviceId !== svcId || now.platform !== platform) return;
4115
+ if (now.projectId !== svcId || now.platform !== platform) return;
3239
4116
  if (!self.host.isViewMounted()) return;
4117
+ if (results[0] !== null && results[1] !== null) self._recordLiveIndexKeys(results);
3240
4118
  var adoptedIds = [];
3241
4119
  for (var ri = 0; ri < results.length; ri++) {
3242
4120
  var list = results[ri] && Array.isArray(results[ri].list) ? results[ri].list : [];
@@ -3248,10 +4126,13 @@ var ChatSession = class {
3248
4126
  self.drainBgTaskQueue();
3249
4127
  if (self._isTrackingAny(adoptedIds)) return;
3250
4128
  }
3251
- if (attempt + 1 >= WORKER_PASS_ADOPT_ATTEMPTS.length) return;
4129
+ if (attempt + 1 >= WORKER_PASS_ADOPT_ATTEMPTS.length) {
4130
+ self._nudgeIndexingDrain();
4131
+ return;
4132
+ }
3252
4133
  setTimeout(function() {
3253
4134
  var later = self.host.getIdentity();
3254
- if (later.serviceId !== svcId || later.platform !== platform) return;
4135
+ if (later.projectId !== svcId || later.platform !== platform) return;
3255
4136
  if (self.isPollingPaused() || !self.host.isViewMounted()) return;
3256
4137
  self._adoptWorkerIndexingPasses(attempt + 1);
3257
4138
  }, WORKER_PASS_ADOPT_ATTEMPTS[attempt + 1]);
@@ -3293,7 +4174,7 @@ var ChatSession = class {
3293
4174
  if (!ref || !ref.name) return false;
3294
4175
  if (!this._isWorkerDrivenIndexing(ref.name, ref.mime)) return false;
3295
4176
  this.bgTaskQueue.push({
3296
- serviceId: svcId,
4177
+ projectId: svcId,
3297
4178
  platform,
3298
4179
  id: item.id,
3299
4180
  filename: ref.name,
@@ -3324,8 +4205,8 @@ var ChatSession = class {
3324
4205
  url,
3325
4206
  method: "POST",
3326
4207
  id: serverId,
3327
- queue: bgIndexingQueueName(id.userId, id.serviceId),
3328
- service: id.serviceId,
4208
+ queue: bgIndexingQueueName(id.userId, id.projectId),
4209
+ service: id.projectId,
3329
4210
  owner: id.owner
3330
4211
  })).catch(function() {
3331
4212
  });
@@ -3334,7 +4215,7 @@ var ChatSession = class {
3334
4215
  drainBgTaskQueue() {
3335
4216
  var self = this;
3336
4217
  var id = this.host.getIdentity();
3337
- var svcId = id.serviceId, plat = id.platform;
4218
+ var svcId = id.projectId, plat = id.platform;
3338
4219
  if (!svcId || plat === "none" || !this.host.isViewMounted()) return;
3339
4220
  this._applyIndexCancellations();
3340
4221
  this._sweepCancelledIndexing();
@@ -3348,11 +4229,13 @@ var ChatSession = class {
3348
4229
  });
3349
4230
  for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
3350
4231
  var e = this.bgTaskQueue[i];
3351
- if (e.serviceId !== svcId || e.platform !== plat) continue;
4232
+ if (e.projectId !== svcId || e.platform !== plat) continue;
3352
4233
  if (presentIds[e.id] && !pendingIds[e.id]) this.bgTaskQueue.splice(i, 1);
3353
4234
  }
4235
+ var bgPollBudget = MAX_CONCURRENT_BG_POLLS - this._countBgPolls();
4236
+ var injectedAny = false;
3354
4237
  this.bgTaskQueue.forEach(function(entry) {
3355
- if (entry.serviceId !== svcId || entry.platform !== plat) return;
4238
+ if (entry.projectId !== svcId || entry.platform !== plat) return;
3356
4239
  if (!presentIds[entry.id]) {
3357
4240
  var isRunning = entry.status === "running";
3358
4241
  var userBubble = {
@@ -3373,16 +4256,21 @@ var ChatSession = class {
3373
4256
  };
3374
4257
  if (isRunning) userBubble.isPendingInProcess = true;
3375
4258
  else userBubble.isPendingQueued = true;
3376
- self.state.messages.push(userBubble);
3377
- if (isRunning) {
3378
- self.state.messages.push({ role: "assistant", content: "", isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id });
4259
+ var stageAt = self._stageIndex(self.state.messages, entry.stageId);
4260
+ var runningBubble = isRunning ? { role: "assistant", content: "", isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id } : null;
4261
+ if (stageAt === -1) {
4262
+ self.state.messages.push(userBubble);
4263
+ if (runningBubble) self.state.messages.push(runningBubble);
4264
+ } else if (runningBubble) {
4265
+ self.state.messages.splice(stageAt, 0, userBubble, runningBubble);
4266
+ } else {
4267
+ self.state.messages.splice(stageAt, 0, userBubble);
3379
4268
  }
3380
4269
  presentIds[entry.id] = true;
3381
- self.host.notify();
3382
- self.updateHistoryCache();
3383
- self.host.scrollToBottomIfSticky(false);
4270
+ injectedAny = true;
3384
4271
  }
3385
- if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === "function") {
4272
+ if (bgPollBudget > 0 && !self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === "function") {
4273
+ bgPollBudget--;
3386
4274
  var capturedId = entry.id, capturedPlat = plat;
3387
4275
  var capturedEntry = entry;
3388
4276
  var wasStopped = false;
@@ -3415,15 +4303,24 @@ var ChatSession = class {
3415
4303
  }
3416
4304
  self.host.notify();
3417
4305
  self.updateHistoryCache();
4306
+ if (!self._isWorkerDrivenIndexing(capturedEntry.filename, capturedEntry.mime)) {
4307
+ self._nudgeIndexingDrain();
4308
+ }
3418
4309
  }).then(function() {
3419
4310
  if (wasStopped) return;
3420
4311
  var qi = self.bgTaskQueue.findIndex(function(q) {
3421
4312
  return q.id === capturedId;
3422
4313
  });
3423
4314
  if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
4315
+ self.drainBgTaskQueue();
3424
4316
  });
3425
4317
  }
3426
4318
  });
4319
+ if (injectedAny) {
4320
+ this.host.notify();
4321
+ this.updateHistoryCache();
4322
+ this.host.scrollToBottomIfSticky(false);
4323
+ }
3427
4324
  this.promoteNextBgQueuedToRunning();
3428
4325
  }
3429
4326
  // Resume-across-passes: if a background INDEXING task for a paged file (spreadsheet or
@@ -3440,25 +4337,45 @@ var ChatSession = class {
3440
4337
  // as well would now double-index every window.
3441
4338
  maybeResumeIndexing(entry, response, platform) {
3442
4339
  var self = this;
4340
+ var endOfClientChain = function() {
4341
+ self._nudgeIndexingDrain();
4342
+ };
3443
4343
  try {
3444
4344
  if (!entry || !entry.storagePath) return;
3445
4345
  if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
3446
- if (!isPagedReadFile(entry.filename, entry.mime)) return;
4346
+ if (!isPagedReadFile(entry.filename, entry.mime)) {
4347
+ endOfClientChain();
4348
+ return;
4349
+ }
3447
4350
  if (isImageVisionFile(entry.filename, entry.mime)) return;
3448
4351
  if (windowedIndexingEnabled() && isWindowedReadFile(entry.filename, entry.mime)) return;
3449
- if (isErrorResponseBody(response)) return;
4352
+ if (isErrorResponseBody(response)) {
4353
+ endOfClientChain();
4354
+ return;
4355
+ }
3450
4356
  var answer = (platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "";
3451
- if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return;
4357
+ if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) {
4358
+ endOfClientChain();
4359
+ return;
4360
+ }
3452
4361
  var pass = (entry.resumePass || 0) + 1;
3453
- if (pass > MAX_INDEXING_RESUME_PASSES) return;
4362
+ if (pass > MAX_INDEXING_RESUME_PASSES) {
4363
+ endOfClientChain();
4364
+ return;
4365
+ }
3454
4366
  var id = this.host.getIdentity();
3455
- if (!id || id.platform === "none" || id.serviceId !== entry.serviceId) return;
4367
+ if (!id || id.platform === "none" || id.projectId !== entry.projectId) return;
3456
4368
  this.trackIndexDispatch(notifyAgentContinueIndexing({
3457
4369
  platform: id.platform,
3458
4370
  model: id.model,
3459
- service: id.serviceId,
4371
+ service: id.projectId,
4372
+ // Without this the resume pass rebuilds its system prompt from the RAW
4373
+ // regional id (requests.ts falls back to `service`), and the model copies
4374
+ // that id verbatim into project_id tool calls, which the MCP schema
4375
+ // pattern rejects - the whole continue pass saves nothing.
4376
+ publicProjectId: id.publicProjectId,
3460
4377
  owner: id.owner,
3461
- userId: id.userId || id.serviceId,
4378
+ userId: id.userId || id.projectId,
3462
4379
  serviceName: id.serviceName,
3463
4380
  serviceDescription: id.serviceDescription,
3464
4381
  attachment: {
@@ -3471,7 +4388,7 @@ var ChatSession = class {
3471
4388
  }).then(function(ack) {
3472
4389
  if (ack && typeof ack.id === "string") {
3473
4390
  self.bgTaskQueue.push({
3474
- serviceId: id.serviceId,
4391
+ projectId: id.projectId,
3475
4392
  platform: id.platform,
3476
4393
  id: ack.id,
3477
4394
  filename: entry.filename,
@@ -3482,6 +4399,13 @@ var ChatSession = class {
3482
4399
  status: ack.status === "running" ? "running" : "pending",
3483
4400
  poll: ack.poll,
3484
4401
  resumePass: pass
4402
+ // Deliberately NOT stamped with entry.stageId. Only a batch's FIRST
4403
+ // pass anchors to the turn; a continuation appends, which is the
4404
+ // order the server queued it in and therefore the order
4405
+ // promoteNextBgQueuedToRunning should spin it in. It costs nothing
4406
+ // on screen: a continuation is folded into the run whose row
4407
+ // already sits above the turn, and renders nothing at its own
4408
+ // index (indexing_groups anchors a run at its FIRST loaded pass).
3485
4409
  });
3486
4410
  self.drainBgTaskQueue();
3487
4411
  }
@@ -3500,21 +4424,25 @@ var ChatSession = class {
3500
4424
  loadHistory(fetchMore, token) {
3501
4425
  var self = this;
3502
4426
  var id = this.host.getIdentity();
3503
- var loadKey = !id.serviceId || id.platform === "none" ? "" : id.serviceId + "#" + id.platform;
4427
+ var loadKey = !id.projectId || id.platform === "none" ? "" : id.projectId + "#" + id.platform;
3504
4428
  if (token === void 0) token = this.state.gateRefreshToken;
3505
- if (this.state.loadingHistory && this.state.historyRequestToken === token || id.platform === "none" || !id.serviceId) {
4429
+ if (this.state.loadingHistory && this.state.historyRequestToken === token || id.platform === "none" || !id.projectId) {
3506
4430
  return Promise.resolve();
3507
4431
  }
3508
4432
  this.state.historyRequestToken = token;
3509
4433
  this.state.loadingHistory = true;
4434
+ if (!fetchMore && loadKey !== this._liveIndexKey) {
4435
+ this._liveIndexKey = loadKey;
4436
+ this._resetLiveIndexKeys();
4437
+ }
3510
4438
  if (fetchMore) this.state.loadingOlderHistory = true;
3511
4439
  this.host.notify();
3512
4440
  var platform = id.platform;
3513
- var serviceId = id.serviceId, owner = id.owner;
4441
+ var projectId = id.projectId, owner = id.owner;
3514
4442
  var options = { fetchMore };
3515
4443
  if (fetchMore && this.state.historyStartKeyHistory.length) options.startKeyHistory = this.state.historyStartKeyHistory.slice();
3516
4444
  var fetchHistory = function() {
3517
- return getChatHistory({ service: serviceId, owner, platform }, options);
4445
+ return getChatHistory({ service: projectId, owner, platform }, options);
3518
4446
  };
3519
4447
  return Promise.resolve().then(fetchHistory).catch(function(err) {
3520
4448
  if (isAuthExpiredError(err) && !isNonRetryableRequestError(err)) return self.host.refreshSession().then(fetchHistory);
@@ -3534,7 +4462,7 @@ var ChatSession = class {
3534
4462
  });
3535
4463
  var mapped = mapHistoryListToMessages(list, platform, {
3536
4464
  clearedAt: self.host.getClearedAt(),
3537
- serviceId: id.serviceId,
4465
+ projectId: id.projectId,
3538
4466
  formatIndexingLabel: self.host.formatIndexingLabel
3539
4467
  }).messages;
3540
4468
  var keptOlderPages = false;
@@ -3640,12 +4568,26 @@ var ChatSession = class {
3640
4568
  self.updateHistoryCache();
3641
4569
  self.host.notify();
3642
4570
  if (!fetchMore) {
4571
+ var bgAllow = {};
4572
+ var bgHistBudget = MAX_CONCURRENT_BG_POLLS - self._countBgPolls();
4573
+ if (bgHistBudget > 0) {
4574
+ var bgIds = chatList.filter(function(it) {
4575
+ if (it.status !== "running" && it.status !== "pending") return false;
4576
+ if (!it.poll || !it.id) return false;
4577
+ if (!(it._isBgTask || it._isOnBgQueue)) return false;
4578
+ return !self.historyItemPolls.has(it.id);
4579
+ }).map(function(it) {
4580
+ return it.id;
4581
+ }).sort();
4582
+ for (var ba = 0; ba < bgIds.length && ba < bgHistBudget; ba++) bgAllow[bgIds[ba]] = true;
4583
+ }
3643
4584
  chatList.forEach(function(item) {
3644
4585
  if (item.status !== "running" && item.status !== "pending") return;
3645
4586
  if (!item.poll || !item.id) return;
3646
4587
  if (self.historyItemPolls.has(item.id)) return;
3647
4588
  if (self.pendingAgentRequests[self.getHistoryCacheKey()] && !item._isBgTask && !item._isOnBgQueue) return;
3648
4589
  if ((item._isBgTask || item._isOnBgQueue) && self.isPollingPaused()) return;
4590
+ if ((item._isBgTask || item._isOnBgQueue) && !bgAllow[item.id]) return;
3649
4591
  var capturedId = item.id;
3650
4592
  var pp = item.poll({
3651
4593
  latency: POLL_INTERVAL,
@@ -3699,6 +4641,7 @@ var ChatSession = class {
3699
4641
  });
3700
4642
  self.drainBgTaskQueue();
3701
4643
  }
4644
+ if (!fetchMore) self.refreshLiveIndexState();
3702
4645
  if (!fetchMore) return self.host.scrollToBottomIfSticky();
3703
4646
  }).catch(function(err) {
3704
4647
  console.warn("[chat-engine] getChatHistory failed", err);
@@ -3716,7 +4659,7 @@ var ChatSession = class {
3716
4659
  // Upload one attachment (a file = 1 member, a folder = N) to db storage and
3717
4660
  // queue indexing per member. The bytes I/O + chip rendering go through host
3718
4661
  // hooks; the overwrite/reindex flow, status lifecycle, and indexing live here.
3719
- uploadSingleAttachment(att) {
4662
+ uploadSingleAttachment(att, stageId) {
3720
4663
  var self = this;
3721
4664
  var id = this.host.getIdentity();
3722
4665
  att.status = "uploading";
@@ -3763,6 +4706,7 @@ var ChatSession = class {
3763
4706
  return self.host.promptOverwrite(member.file.name).then(function(choice) {
3764
4707
  if (choice === "overwrite") {
3765
4708
  existedBefore = true;
4709
+ markImagePreviewStale(self.host.getIdentity().projectId || "default", member.storagePath);
3766
4710
  return doMemberUpload(false);
3767
4711
  }
3768
4712
  if (choice === "skip") {
@@ -3783,17 +4727,39 @@ var ChatSession = class {
3783
4727
  att.storagePath = member.storagePath;
3784
4728
  }
3785
4729
  var mime = member.file.type || self.host.getMimeType(member.file.name);
3786
- var preIndex = existedBefore && typeof self.host.deleteExistingFileRecord === "function" ? Promise.resolve(self.host.deleteExistingFileRecord(member.storagePath)).catch(function() {
3787
- }) : Promise.resolve();
4730
+ var alreadyIndexing = false;
4731
+ var preIndex = self.claimIndexRun(member.storagePath).then(function(claimed) {
4732
+ alreadyIndexing = !claimed;
4733
+ if (alreadyIndexing) {
4734
+ console.log("[chat-engine] skipping a duplicate index request for", member.storagePath);
4735
+ return;
4736
+ }
4737
+ if (existedBefore && typeof self.host.deleteExistingFileRecord === "function") {
4738
+ return Promise.resolve(self.host.deleteExistingFileRecord(member.storagePath)).catch(function() {
4739
+ });
4740
+ }
4741
+ });
4742
+ preIndex = preIndex.then(function() {
4743
+ if (alreadyIndexing) return;
4744
+ if (typeof self.host.ensureFileIndexRecord !== "function") return;
4745
+ return Promise.resolve(self.host.ensureFileIndexRecord(member.storagePath, {
4746
+ name: member.file.name,
4747
+ mime: mime || void 0,
4748
+ size: member.file.size
4749
+ })).catch(function() {
4750
+ });
4751
+ });
3788
4752
  return preIndex.then(function() {
3789
4753
  return parseAttachmentContent(member.file, member.file.name, mime || void 0);
3790
4754
  }).then(function(parsedContent) {
4755
+ if (alreadyIndexing) return;
3791
4756
  return self.trackIndexDispatch(notifyAgentSaveAttachment({
3792
4757
  platform: id.platform,
3793
4758
  model: id.model,
3794
- service: id.serviceId,
4759
+ service: id.projectId,
4760
+ publicProjectId: id.publicProjectId,
3795
4761
  owner: id.owner,
3796
- userId: id.userId || id.serviceId,
4762
+ userId: id.userId || id.projectId,
3797
4763
  serviceName: id.serviceName,
3798
4764
  serviceDescription: id.serviceDescription,
3799
4765
  attachment: {
@@ -3807,7 +4773,7 @@ var ChatSession = class {
3807
4773
  }).then(function(ack) {
3808
4774
  if (ack && typeof ack.id === "string") {
3809
4775
  self.bgTaskQueue.push({
3810
- serviceId: id.serviceId,
4776
+ projectId: id.projectId,
3811
4777
  platform: id.platform,
3812
4778
  id: ack.id,
3813
4779
  filename: member.file.name,
@@ -3816,12 +4782,17 @@ var ChatSession = class {
3816
4782
  mime: mime || void 0,
3817
4783
  size: member.file.size,
3818
4784
  status: ack.status === "running" ? "running" : "pending",
3819
- poll: ack.poll
4785
+ poll: ack.poll,
4786
+ // Puts this file's row directly above the chat turn it was
4787
+ // attached to (drainBgTaskQueue). Undefined for an
4788
+ // attachment-only send, which appends.
4789
+ stageId
3820
4790
  });
3821
4791
  self.drainBgTaskQueue();
3822
4792
  }
3823
4793
  }, function(e) {
3824
4794
  console.error("[chat-engine] indexing request failed", e);
4795
+ self.releaseIndexRun(member.storagePath);
3825
4796
  anyIndexFailed = true;
3826
4797
  if (!att.errorCode && !att.errorDetail) {
3827
4798
  att.errorCode = e && (e.code || e.body && e.body.code) || "";
@@ -3853,7 +4824,11 @@ var ChatSession = class {
3853
4824
  // message — uploading those here would attach them to the wrong turn, and
3854
4825
  // collecting the previous batch's finished urls would attach files the user
3855
4826
  // already sent. Omitted (no batch) means every chip, the old behavior.
3856
- uploadPendingAttachments(batchId) {
4827
+ //
4828
+ // `stageId` is the turn these chips were attached to, carried onto every indexing
4829
+ // task so its collapsed row renders directly ABOVE that turn's bubble (see
4830
+ // BgTaskEntry.stageId). Omitted for an attachment-only send, which has no turn.
4831
+ uploadPendingAttachments(batchId, stageId) {
3857
4832
  var self = this;
3858
4833
  this.host.resetOverwriteBatch();
3859
4834
  this._uploadBatches += 1;
@@ -3882,7 +4857,7 @@ var ChatSession = class {
3882
4857
  return;
3883
4858
  }
3884
4859
  }
3885
- return self.uploadSingleAttachment(att).then(function(us) {
4860
+ return self.uploadSingleAttachment(att, stageId).then(function(us) {
3886
4861
  collected.push.apply(collected, us);
3887
4862
  }).catch(function(err) {
3888
4863
  var removed = !self.state.attachments.some(function(a) {
@@ -3961,9 +4936,22 @@ function readFileRef(msg) {
3961
4936
  function isPendingMsg(m) {
3962
4937
  return !!(m.isPending || m.isPendingInProcess || m.isPendingQueued || m.isSendingToServer);
3963
4938
  }
4939
+ function isHiddenPass(m) {
4940
+ if (m.role === "user") {
4941
+ if (m.isCancelled) return false;
4942
+ var ref = readFileRef(m);
4943
+ return !!(ref && ref.continued);
4944
+ }
4945
+ return !!m.isPending;
4946
+ }
3964
4947
  function buildChatDisplayList(messages, opts) {
3965
4948
  var list = Array.isArray(messages) ? messages : [];
4949
+ var liveIndexKeys = opts && opts.liveIndexKeys || {};
4950
+ var liveIndexChecked = !!(opts && opts.liveIndexChecked);
4951
+ var stoppedIndexIds = opts && opts.stoppedIndexIds || {};
4952
+ var windowedIndexing = opts && opts.windowedIndexing !== void 0 ? !!opts.windowedIndexing : windowedIndexingEnabled();
3966
4953
  var hasMoreHistory = !!(opts && opts.hasMoreHistory);
4954
+ var loadingOlderHistory = !!(opts && opts.loadingOlderHistory);
3967
4955
  var groups = {};
3968
4956
  var order = [];
3969
4957
  var runOfIndex = new Array(list.length);
@@ -4011,11 +4999,17 @@ function buildChatDisplayList(messages, opts) {
4011
4999
  status: "done",
4012
5000
  cancellableIds: [],
4013
5001
  cancelling: false,
5002
+ stopped: false,
4014
5003
  mayHaveOlder: false,
4015
5004
  // The run's first loaded pass, and never re-stamped: see the file
4016
5005
  // docstring. `anchorId` is filled in once every member is known.
4017
5006
  anchorIndex: i,
4018
- anchorId: ""
5007
+ anchorId: "",
5008
+ // All five are derived once every member is known, below.
5009
+ visibleMembers: [],
5010
+ driver: "single",
5011
+ finished: false,
5012
+ resolving: false
4019
5013
  };
4020
5014
  order.push(runId);
4021
5015
  }
@@ -4033,6 +5027,11 @@ function buildChatDisplayList(messages, opts) {
4033
5027
  if (msg._serverItemId) runByItemId[msg._serverItemId] = runId;
4034
5028
  if (ref && ref.name) keyByName[ref.name] = g.key;
4035
5029
  }
5030
+ var newestRunOfKey = {};
5031
+ for (var nk in runsOfKey) {
5032
+ var nrs = runsOfKey[nk];
5033
+ if (nrs.length) newestRunOfKey[nrs[nrs.length - 1]] = true;
5034
+ }
4036
5035
  for (var rk in runsOfKey) {
4037
5036
  var runIds = runsOfKey[rk];
4038
5037
  for (var ri = 0; ri < runIds.length; ri++) {
@@ -4056,6 +5055,19 @@ function buildChatDisplayList(messages, opts) {
4056
5055
  break;
4057
5056
  }
4058
5057
  }
5058
+ var stopped = false;
5059
+ for (var ki = 0; ki < grp.members.length; ki++) {
5060
+ var km = grp.members[ki].msg;
5061
+ if (km.isCancelled) {
5062
+ stopped = true;
5063
+ break;
5064
+ }
5065
+ if (km._serverItemId && stoppedIndexIds[km._serverItemId]) {
5066
+ stopped = true;
5067
+ break;
5068
+ }
5069
+ }
5070
+ grp.stopped = stopped;
4059
5071
  for (var xi = 0; xi < grp.members.length; xi++) {
4060
5072
  if (grp.members[xi].msg._cancelling) {
4061
5073
  grp.cancelling = true;
@@ -4065,7 +5077,7 @@ function buildChatDisplayList(messages, opts) {
4065
5077
  var seenIds = {};
4066
5078
  for (var ci = 0; ci < grp.members.length; ci++) {
4067
5079
  var cm = grp.members[ci].msg;
4068
- if (cm._cancelError && (active || grp.cancelling)) grp.cancelError = cm._cancelError;
5080
+ if (cm._cancelError && !stopped && (active || grp.cancelling)) grp.cancelError = cm._cancelError;
4069
5081
  if (cm.role !== "user" || !cm._serverItemId || cm._cancelling || cm.isSendingToServer) continue;
4070
5082
  if (!(cm.isPendingQueued || cm.isPendingInProcess)) continue;
4071
5083
  if (ci < lastSettled) continue;
@@ -4075,9 +5087,12 @@ function buildChatDisplayList(messages, opts) {
4075
5087
  }
4076
5088
  if (active) {
4077
5089
  grp.status = "active";
5090
+ if (stopped) grp.cancelling = true;
5091
+ } else if (stopped) {
5092
+ grp.status = "cancelled";
4078
5093
  } else {
4079
5094
  var last = grp.members[grp.members.length - 1].msg;
4080
- grp.status = last.isError ? "error" : last.isCancelled ? "cancelled" : "done";
5095
+ grp.status = last.isError ? "error" : "done";
4081
5096
  }
4082
5097
  var sawFirstPass = false;
4083
5098
  for (var pi = 0; pi < grp.members.length; pi++) {
@@ -4093,6 +5108,35 @@ function buildChatDisplayList(messages, opts) {
4093
5108
  var anchor = grp.members[0];
4094
5109
  grp.anchorIndex = anchor.index;
4095
5110
  grp.anchorId = anchor.msg._serverItemId || anchor.msg._localId || "";
5111
+ var sawComplete = false;
5112
+ for (var vi = 0; vi < grp.members.length; vi++) {
5113
+ var vm = grp.members[vi];
5114
+ if (vm.msg._indexComplete) sawComplete = true;
5115
+ if (!isHiddenPass(vm.msg)) grp.visibleMembers.push(vm);
5116
+ }
5117
+ grp.driver = !isPagedReadFile(grp.name, grp.mime) ? "single" : isImageVisionFile(grp.name, grp.mime) ? "worker" : windowedIndexing ? "worker" : "client";
5118
+ if (grp.status === "active") {
5119
+ grp.finished = false;
5120
+ } else if (grp.status === "cancelled") {
5121
+ grp.finished = true;
5122
+ } else if (grp.driver === "single") {
5123
+ grp.finished = true;
5124
+ } else if (grp.driver === "client") {
5125
+ grp.finished = sawComplete || grp.status === "error" || grp.passCount >= MAX_INDEXING_RESUME_PASSES;
5126
+ } else {
5127
+ grp.finished = !newestRunOfKey[order[oi]] || liveIndexChecked && !liveIndexKeys[grp.key];
5128
+ }
5129
+ if (grp.status !== "done") {
5130
+ grp.resolving = false;
5131
+ } else if (grp.mayHaveOlder && loadingOlderHistory && !liveIndexKeys[grp.key] && newestRunOfKey[order[oi]]) {
5132
+ grp.resolving = true;
5133
+ grp.resolvingReason = "history";
5134
+ } else if (!grp.finished && grp.driver === "worker" && !liveIndexChecked && !liveIndexKeys[grp.key]) {
5135
+ grp.resolving = true;
5136
+ grp.resolvingReason = "status";
5137
+ } else {
5138
+ grp.resolving = false;
5139
+ }
4096
5140
  }
4097
5141
  var out = [];
4098
5142
  for (var j = 0; j < list.length; j++) {
@@ -4116,6 +5160,7 @@ exports.CONTEXT_WINDOW_DEFAULT = CONTEXT_WINDOW_DEFAULT;
4116
5160
  exports.ChatSession = ChatSession;
4117
5161
  exports.DEFAULT_CLAUDE_MODEL = DEFAULT_CLAUDE_MODEL;
4118
5162
  exports.DEFAULT_OPENAI_MODEL = DEFAULT_OPENAI_MODEL;
5163
+ exports.EMPTY_INDEXING_REPLY = EMPTY_INDEXING_REPLY;
4119
5164
  exports.EXPIRED_ATTACHMENT_URL_HOST = EXPIRED_ATTACHMENT_URL_HOST;
4120
5165
  exports.EXPIRED_ATTACHMENT_URL_ORIGIN = EXPIRED_ATTACHMENT_URL_ORIGIN;
4121
5166
  exports.EXPIRED_LINK_REFRESH_EXPIRES_SECONDS = EXPIRED_LINK_REFRESH_EXPIRES_SECONDS;
@@ -4125,8 +5170,14 @@ exports.HISTORY_FILL_SLACK_PX = HISTORY_FILL_SLACK_PX;
4125
5170
  exports.HISTORY_TOKEN_BUDGET = HISTORY_TOKEN_BUDGET;
4126
5171
  exports.HTML_EXTS = HTML_EXTS;
4127
5172
  exports.HTML_HEAD_WINDOW = HTML_HEAD_WINDOW;
5173
+ exports.IMAGE_PREVIEWS_PER_MESSAGE = IMAGE_PREVIEWS_PER_MESSAGE;
5174
+ exports.INDEXING_COMPLETE_MARKER = INDEXING_COMPLETE_MARKER;
5175
+ exports.INLINE_LINK_GLYPH = INLINE_LINK_GLYPH;
5176
+ exports.INLINE_LINK_UNAVAILABLE_GLYPH = INLINE_LINK_UNAVAILABLE_GLYPH;
5177
+ exports.INLINE_LINK_UNAVAILABLE_SUFFIX = INLINE_LINK_UNAVAILABLE_SUFFIX;
4128
5178
  exports.LINK_LABEL_MAX_DISPLAY_CHARS = LINK_LABEL_MAX_DISPLAY_CHARS;
4129
5179
  exports.LINK_REFRESH_WINDOW_MS = LINK_REFRESH_WINDOW_MS;
5180
+ exports.MAX_CONCURRENT_BG_POLLS = MAX_CONCURRENT_BG_POLLS;
4130
5181
  exports.MAX_HISTORY_FILL_PAGES = MAX_HISTORY_FILL_PAGES;
4131
5182
  exports.MAX_HISTORY_MESSAGES = MAX_HISTORY_MESSAGES;
4132
5183
  exports.MAX_PARSED_CONTENT_CHARS = MAX_PARSED_CONTENT_CHARS;
@@ -4134,6 +5185,8 @@ exports.MCP_NAME = MCP_NAME;
4134
5185
  exports.MIN_INPUT_TOKEN_BUDGET = MIN_INPUT_TOKEN_BUDGET;
4135
5186
  exports.OUTPUT_TOKEN_RESERVE = OUTPUT_TOKEN_RESERVE;
4136
5187
  exports.POLL_INTERVAL = POLL_INTERVAL;
5188
+ exports.PREVIEWABLE_IMAGE_CONTENT_TYPES = PREVIEWABLE_IMAGE_CONTENT_TYPES;
5189
+ exports.PREVIEW_BROWSER_CACHE_SECONDS = PREVIEW_BROWSER_CACHE_SECONDS;
4137
5190
  exports.RENDER_FROM_TOKEN = RENDER_FROM_TOKEN;
4138
5191
  exports.RTF_EXTS = RTF_EXTS;
4139
5192
  exports.TOOL_AND_RESPONSE_BUFFER = TOOL_AND_RESPONSE_BUFFER;
@@ -4157,6 +5210,7 @@ exports.callOpenAIWithPublicMcp = callOpenAIWithPublicMcp;
4157
5210
  exports.chatEngineConfig = chatEngineConfig;
4158
5211
  exports.classifyInlineLink = classifyInlineLink;
4159
5212
  exports.clearAttachmentParsers = clearAttachmentParsers;
5213
+ exports.clearImagePreviewCache = clearImagePreviewCache;
4160
5214
  exports.composeUserMessage = composeUserMessage;
4161
5215
  exports.configureChatEngine = configureChatEngine;
4162
5216
  exports.contentTypeForExt = contentTypeForExt;
@@ -4166,6 +5220,7 @@ exports.encodePathSegments = encodePathSegments;
4166
5220
  exports.encodingClassForExt = encodingClassForExt;
4167
5221
  exports.ensureHtmlCharset = ensureHtmlCharset;
4168
5222
  exports.ensureXmlEncoding = ensureXmlEncoding;
5223
+ exports.escapeInlineHtml = escapeInlineHtml;
4169
5224
  exports.escapeRtfNonAscii = escapeRtfNonAscii;
4170
5225
  exports.estimateMessageTokens = estimateMessageTokens;
4171
5226
  exports.estimateTextTokens = estimateTextTokens;
@@ -4184,22 +5239,29 @@ exports.getContextWindow = getContextWindow;
4184
5239
  exports.getErrorMessage = getErrorMessage;
4185
5240
  exports.getExpiredAttachmentVisiblePath = getExpiredAttachmentVisiblePath;
4186
5241
  exports.getProjectContextWindow = getProjectContextWindow;
5242
+ exports.getVisionProfile = getVisionProfile;
4187
5243
  exports.groupAttachmentFailures = groupAttachmentFailures;
4188
5244
  exports.hasBom = hasBom;
5245
+ exports.hydrateImagePreviews = hydrateImagePreviews;
4189
5246
  exports.isAuthExpiredError = isAuthExpiredError;
4190
5247
  exports.isBgIndexingQueue = isBgIndexingQueue;
4191
5248
  exports.isErrorResponseBody = isErrorResponseBody;
4192
5249
  exports.isHttpUrlLike = isHttpUrlLike;
4193
5250
  exports.isIndexingRequestText = isIndexingRequestText;
5251
+ exports.isLinkUnavailable = isLinkUnavailable;
4194
5252
  exports.isNonRetryableRequestError = isNonRetryableRequestError;
4195
5253
  exports.isOfficeFile = isOfficeFile;
5254
+ exports.isPreviewableImagePath = isPreviewableImagePath;
4196
5255
  exports.isServerExtractable = isServerExtractable;
4197
5256
  exports.isServiceDbAttachmentHref = isServiceDbAttachmentHref;
5257
+ exports.linkUnavailableKeyForHref = linkUnavailableKeyForHref;
5258
+ exports.linkUnavailableKeyForPath = linkUnavailableKeyForPath;
4198
5259
  exports.listClaudeModels = listClaudeModels;
4199
5260
  exports.listOpenAIModels = listOpenAIModels;
4200
5261
  exports.looksLikeRtf = looksLikeRtf;
4201
5262
  exports.makeExtractPlaceholder = makeExtractPlaceholder;
4202
5263
  exports.mapHistoryListToMessages = mapHistoryListToMessages;
5264
+ exports.markImagePreviewStale = markImagePreviewStale;
4203
5265
  exports.needsBomForExt = needsBomForExt;
4204
5266
  exports.normalizeAttachmentPathCandidate = normalizeAttachmentPathCandidate;
4205
5267
  exports.normalizeExt = normalizeExt;
@@ -4210,12 +5272,17 @@ exports.parseAiAgentValue = parseAiAgentValue;
4210
5272
  exports.parseAttachmentContent = parseAttachmentContent;
4211
5273
  exports.parseIndexingLabel = parseIndexingLabel;
4212
5274
  exports.parseIndexingRequestText = parseIndexingRequestText;
5275
+ exports.peekImagePreviewUrl = peekImagePreviewUrl;
4213
5276
  exports.prepareDownloadText = prepareDownloadText;
5277
+ exports.previewImageContentType = previewImageContentType;
5278
+ exports.previewableExtOf = previewableExtOf;
4214
5279
  exports.readExpiredAttachmentHref = readExpiredAttachmentHref;
4215
5280
  exports.registerAttachmentParser = registerAttachmentParser;
4216
5281
  exports.registerModelContextWindows = registerModelContextWindows;
5282
+ exports.renderInlineLinkHtml = renderInlineLinkHtml;
4217
5283
  exports.repairUrlEntities = repairUrlEntities;
4218
5284
  exports.repairUrlWhitespace = repairUrlWhitespace;
5285
+ exports.resolveImagePreviewUrl = resolveImagePreviewUrl;
4219
5286
  exports.safeDecodeURIComponent = safeDecodeURIComponent;
4220
5287
  exports.sanitizeAttachmentLinksForHistory = sanitizeAttachmentLinksForHistory;
4221
5288
  exports.setProjectContextWindow = setProjectContextWindow;