bunnyquery 1.8.2 → 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.mjs CHANGED
@@ -217,14 +217,15 @@ function isWindowedReadFile(name, mime) {
217
217
  }
218
218
  function composeUserMessage(text, attachmentUrls) {
219
219
  let composed = text;
220
+ let composedForLlm = composed;
220
221
  if (attachmentUrls.length > 0) {
221
222
  const lines = attachmentUrls.map((u) => `- [${u.name}](${u.url})`);
222
223
  composed = `${text}
223
224
 
224
225
  Attached files:
225
226
  ${lines.join("\n")}`;
227
+ composedForLlm = composed;
226
228
  }
227
- let composedForLlm = composed;
228
229
  let extractContent;
229
230
  let fileUrls;
230
231
  if (attachmentUrls.length > 0) {
@@ -241,13 +242,13 @@ ${placeholder}
241
242
  ----- END FILE CONTENT -----`;
242
243
  });
243
244
  extractContent = directives;
244
- composedForLlm = `${composed}
245
+ composedForLlm = `${composedForLlm}
245
246
 
246
247
  Extracted content of attached office files (read inline below; do NOT fetch their URLs):
247
248
 
248
249
  ` + sections.join("\n\n");
249
250
  }
250
- const urlFiles = attachmentUrls.filter((u) => u.url && !isServerExtractable(u.name));
251
+ const urlFiles = [];
251
252
  if (urlFiles.length > 0) {
252
253
  fileUrls = urlFiles.map((u) => ({ path: u.storagePath || u.name, url: u.url }));
253
254
  }
@@ -279,28 +280,39 @@ function groupAttachmentFailures(attachments) {
279
280
 
280
281
  // src/engine/prompts/chat_system_prompt.ts
281
282
  function buildChatSystemPrompt(params) {
282
- const { formattedServiceId, serviceName, serviceDescription } = params;
283
+ const { projectId, serviceName, serviceDescription } = params;
283
284
  let systemPrompt = `
284
- You are a dedicated assistant for the project ID: "${formattedServiceId}".
285
+ You are a dedicated assistant for the project ID: "${projectId}".
285
286
  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.
286
287
  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.
287
- 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.
288
+ 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.
288
289
  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.
289
- 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.
290
+ 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.
290
291
  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.
291
292
  - 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.
292
293
  - 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.
293
294
  - 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.
294
- 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.
295
- 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.
296
- 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:
295
+ 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.
296
+ 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.
297
+ 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.
298
+ 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.
299
+ 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.
300
+ Finding an extracted media file: it is INDEXED, and its location is a stored VALUE. Get it by QUERYING, never by constructing a filename.
301
+ 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.
302
+ 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.
303
+ 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.
304
+ So "show me the photo of part X" is: find the record for that part, take its "__MEDIA__/..." value, link it.
305
+ 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.
306
+ 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.
307
+ 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.
308
+ 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:
297
309
  \`\`\`filename.csv
298
310
  item,qty,total
299
311
  Carrots,55,$38.50
300
312
  Mushrooms,41,$73.80
301
313
  Zucchini,29,$43.50
302
314
  \`\`\`
303
- 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.`;
315
+ 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.`;
304
316
  if (serviceDescription) {
305
317
  systemPrompt += `
306
318
  Project name: "${serviceName ?? ""}"
@@ -311,18 +323,26 @@ Project description: """${serviceDescription}"""`;
311
323
 
312
324
  // src/engine/prompts/indexing_system_prompt.ts
313
325
  function buildIndexingSystemPrompt(params) {
314
- const { service, serviceName, serviceDescription } = params;
315
- let systemPrompt = `You are a background indexing agent for project ${service}.
326
+ const { projectId, serviceName, serviceDescription } = params;
327
+ let systemPrompt = `You are a background indexing agent for project ${projectId}.
316
328
  - 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.
317
329
  - 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.
318
- - 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.)
330
+ - 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.)
319
331
  - 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.
320
332
  - 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).
321
- - 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.
322
- - 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.
323
- - 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.
324
- - 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.
325
- - 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.
333
+ - 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.
334
+ - 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).
335
+ - 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.
336
+ - 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.
337
+ - 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.
338
+ - 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.
339
+ - 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).
340
+ - 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.
341
+ - 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.
342
+ - 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.
343
+ - 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.
344
+ - 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.
345
+ - 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.
326
346
  - 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>.`;
327
347
  if (serviceDescription) {
328
348
  systemPrompt += `
@@ -344,7 +364,7 @@ File metadata:
344
364
  ` : "");
345
365
  if (options?.inlineContent) {
346
366
  return head + `
347
- 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.
367
+ 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.
348
368
 
349
369
  ----- BEGIN FILE CONTENT -----
350
370
  ${options.inlineContent}
@@ -352,7 +372,7 @@ ${options.inlineContent}
352
372
  }
353
373
  if (options?.inlineContentPlaceholder) {
354
374
  return head + `
355
- 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.
375
+ 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.
356
376
 
357
377
  ----- BEGIN FILE CONTENT -----
358
378
  ${options.inlineContentPlaceholder}
@@ -360,7 +380,7 @@ ${options.inlineContentPlaceholder}
360
380
  }
361
381
  if (options?.pagedRead) {
362
382
  return head + `
363
- 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 ? `
383
+ 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 ? `
364
384
  (A temporary URL is provided ONLY as a fallback if readFileContent fails: ${attachment.url})` : "");
365
385
  }
366
386
  return head + `- temporary URL (fetch this to read the file contents): ${attachment.url}`;
@@ -395,7 +415,11 @@ function buildRenderDatafy(placeholder) {
395
415
  return `
396
416
  ${placeholder}
397
417
 
398
- 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.
418
+ 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.
419
+
420
+ 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.
421
+
422
+ 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.
399
423
 
400
424
  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.`;
401
425
  }
@@ -414,9 +438,9 @@ This file is delivered to you ONE WINDOW at a time, embedded directly in this me
414
438
  return head + buildRenderMeta(attachment) + where + `
415
439
  ${placeholder}
416
440
 
417
- 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.
441
+ 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.
418
442
 
419
- 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.
443
+ 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.
420
444
 
421
445
  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.`;
422
446
  }
@@ -429,11 +453,10 @@ File metadata:
429
453
  - storage path: ${attachment.storagePath}
430
454
  ` + (attachment.mime ? `- mime type: ${attachment.mime}
431
455
  ` : "") + `
432
- 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:
433
- - 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.
434
- - 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".
435
- - Text: the cursor is the character offset already read.
436
- 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.`;
456
+ 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:
457
+ - 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".
458
+ - Text: the cursor is the character offset already read.
459
+ 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.`;
437
460
  }
438
461
 
439
462
  // src/engine/errors.ts
@@ -547,6 +570,7 @@ var EXPIRED_ATTACHMENT_URL_HOST = "_expired_.url";
547
570
  var EXPIRED_ATTACHMENT_URL_ORIGIN = "https://" + EXPIRED_ATTACHMENT_URL_HOST;
548
571
  var LINK_LABEL_MAX_DISPLAY_CHARS = 32;
549
572
  var EXPIRED_LINK_REFRESH_EXPIRES_SECONDS = 20 * 60;
573
+ var PREVIEW_BROWSER_CACHE_SECONDS = 7 * 24 * 60 * 60;
550
574
  var LINK_REFRESH_WINDOW_MS = (EXPIRED_LINK_REFRESH_EXPIRES_SECONDS - 5 * 60) * 1e3;
551
575
  function createInlineLinkRegex() {
552
576
  return /src::(\S+)|\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]|\([^\s()]*\))+)\)|\[([^\]\n]+)\]\(((?:[^()\n]|\([^()\n]*\))+)\)|(https?:\/\/[^\s<>"']+)/g;
@@ -566,7 +590,7 @@ function encodePathSegments(path) {
566
590
  function normalizeAttachmentPathCandidate(value) {
567
591
  return safeDecodeURIComponent((value || "").trim()).replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/");
568
592
  }
569
- function extractRemotePathFromAttachmentHref(href, serviceId) {
593
+ function extractRemotePathFromAttachmentHref(href, projectId) {
570
594
  try {
571
595
  var parsed = new URL(href);
572
596
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
@@ -574,7 +598,7 @@ function extractRemotePathFromAttachmentHref(href, serviceId) {
574
598
  var segs = path.split("/").filter(Boolean);
575
599
  if (!segs.length) return null;
576
600
  var HEX = /^[a-f0-9]{32,}$/i;
577
- var sid = serviceId || "";
601
+ var sid = projectId || "";
578
602
  var start = 0;
579
603
  while (start < segs.length) {
580
604
  var seg = segs[start];
@@ -598,13 +622,13 @@ function getExpiredAttachmentVisiblePath(remotePath, fallback) {
598
622
  function buildDisplayExpiredAttachmentHref(remotePath, fallback) {
599
623
  return EXPIRED_ATTACHMENT_URL_ORIGIN + "/" + encodePathSegments(getExpiredAttachmentVisiblePath(remotePath, fallback));
600
624
  }
601
- function isServiceDbAttachmentHref(href, serviceId) {
602
- if (!serviceId) return false;
625
+ function isServiceDbAttachmentHref(href, projectId) {
626
+ if (!projectId) return false;
603
627
  try {
604
628
  var parsed = new URL(href);
605
629
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
606
630
  var segs = normalizeAttachmentPathCandidate(parsed.pathname || "").split("/").filter(Boolean);
607
- return segs.length > 0 && segs[0] === serviceId;
631
+ return segs.length > 0 && segs[0] === projectId;
608
632
  } catch (e) {
609
633
  return false;
610
634
  }
@@ -619,12 +643,12 @@ function readExpiredAttachmentHref(href) {
619
643
  return null;
620
644
  }
621
645
  }
622
- function sanitizeAttachmentLinksForHistory(content, serviceId, forAssistant) {
646
+ function sanitizeAttachmentLinksForHistory(content, projectId, forAssistant) {
623
647
  if (!content) return content;
624
648
  if (!forAssistant && content.indexOf("Attached files:") === -1) return content;
625
649
  return content.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, function(_m, label, href) {
626
- if (!isServiceDbAttachmentHref(href, serviceId)) return _m;
627
- var remotePath = extractRemotePathFromAttachmentHref(href, serviceId);
650
+ if (!isServiceDbAttachmentHref(href, projectId)) return _m;
651
+ var remotePath = extractRemotePathFromAttachmentHref(href, projectId);
628
652
  var fullPath = remotePath || normalizeAttachmentPathCandidate(label);
629
653
  if (!fullPath) return _m;
630
654
  return "[" + label + "](" + buildDisplayExpiredAttachmentHref(fullPath, label) + ")";
@@ -665,6 +689,31 @@ function normalizeTrailingInlineToken(value) {
665
689
  out = out.replace(/[`'"*>]+$/, "");
666
690
  return out;
667
691
  }
692
+ var PREVIEWABLE_IMAGE_CONTENT_TYPES = {
693
+ png: "image/png",
694
+ jpg: "image/jpeg",
695
+ jpeg: "image/jpeg",
696
+ gif: "image/gif",
697
+ webp: "image/webp",
698
+ avif: "image/avif",
699
+ bmp: "image/bmp"
700
+ };
701
+ function previewableExtOf(nameOrPath) {
702
+ var v = String(nameOrPath || "");
703
+ var cut = v.search(/[?#]/);
704
+ if (cut !== -1) v = v.slice(0, cut);
705
+ v = v.replace(/[\\/]+$/, "");
706
+ var dot = v.lastIndexOf(".");
707
+ if (dot <= 0) return "";
708
+ var ext = v.slice(dot + 1).trim().toLowerCase();
709
+ return /^[a-z0-9]+$/.test(ext) ? ext : "";
710
+ }
711
+ function isPreviewableImagePath(nameOrPath) {
712
+ return !!PREVIEWABLE_IMAGE_CONTENT_TYPES[previewableExtOf(nameOrPath)];
713
+ }
714
+ function previewImageContentType(nameOrPath) {
715
+ return PREVIEWABLE_IMAGE_CONTENT_TYPES[previewableExtOf(nameOrPath)] || null;
716
+ }
668
717
  function classifyInlineLink(full, groups, ctx) {
669
718
  var g1 = groups[0], g2 = groups[1], g3 = groups[2], g4 = groups[3], g5 = groups[4], g6 = groups[5];
670
719
  var dbHostPrefix = (ctx.dbHostPrefix || "").toLowerCase();
@@ -678,17 +727,19 @@ function classifyInlineLink(full, groups, ctx) {
678
727
  if (!remotePath2) return null;
679
728
  var expiredHref = buildDisplayExpiredAttachmentHref(remotePath2, label);
680
729
  var cached = fresh(expiredHref);
681
- return {
682
- part: {
683
- type: "link",
684
- label: truncateLabelForDisplay(label),
685
- fullLabel: label,
686
- href: cached || expiredHref,
687
- expired: !cached,
688
- expiredHref,
689
- remotePath: remotePath2
690
- }
730
+ var part = {
731
+ type: "link",
732
+ label: truncateLabelForDisplay(label),
733
+ fullLabel: label,
734
+ href: cached || expiredHref,
735
+ expired: !cached,
736
+ expiredHref,
737
+ remotePath: remotePath2
691
738
  };
739
+ var ext = previewableExtOf(remotePath2);
740
+ var ct = PREVIEWABLE_IMAGE_CONTENT_TYPES[ext];
741
+ if (ct) part.image = { ext, contentType: ct };
742
+ return { part };
692
743
  };
693
744
  if (g1) {
694
745
  var rawPath = normalizeTrailingInlineToken(g1);
@@ -701,14 +752,15 @@ function classifyInlineLink(full, groups, ctx) {
701
752
  tail
702
753
  };
703
754
  }
704
- var srcPath = readExpiredAttachmentHref(rawPath) || (srcIsUrl ? extractRemotePathFromAttachmentHref(rawPath, ctx.serviceId) || normalizeAttachmentPathCandidate(rawPath) : normalizeAttachmentPathCandidate(rawPath));
755
+ var srcPath = readExpiredAttachmentHref(rawPath) || (srcIsUrl ? extractRemotePathFromAttachmentHref(rawPath, ctx.projectId) || normalizeAttachmentPathCandidate(rawPath) : rawPath.trim().replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/"));
705
756
  var srcBuilt = asStoredFile(srcPath, srcPath);
706
757
  return srcBuilt ? { part: srcBuilt.part, tail } : null;
707
758
  }
708
759
  if (g4 && g5) {
709
760
  var dbTarget = /^db:(.+)$/i.exec(g5.trim());
710
761
  if (dbTarget) {
711
- var declared = asStoredFile(normalizeAttachmentPathCandidate(dbTarget[1]), g4);
762
+ var rawDbPath = dbTarget[1].trim().replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/");
763
+ var declared = asStoredFile(rawDbPath, g4);
712
764
  if (!declared) return null;
713
765
  declared.part.label = truncateLabelForDisplay(g4);
714
766
  declared.part.fullLabel = g4;
@@ -753,8 +805,8 @@ function classifyInlineLink(full, groups, ctx) {
753
805
  return withTail(carriedBuilt);
754
806
  }
755
807
  }
756
- if (isServiceDbAttachmentHref(originalHref, ctx.serviceId)) {
757
- var remotePath = extractRemotePathFromAttachmentHref(originalHref, ctx.serviceId);
808
+ if (isServiceDbAttachmentHref(originalHref, ctx.projectId)) {
809
+ var remotePath = extractRemotePathFromAttachmentHref(originalHref, ctx.projectId);
758
810
  if (remotePath) {
759
811
  var dbBuilt = asStoredFile(remotePath, getExpiredAttachmentVisiblePath(remotePath, urlLabel));
760
812
  if (dbBuilt) return withTail(dbBuilt);
@@ -764,6 +816,19 @@ function classifyInlineLink(full, groups, ctx) {
764
816
  part: { type: "link", label: truncateLabelForDisplay(urlLabel), fullLabel: urlLabel, href: originalHref, expired: false }
765
817
  });
766
818
  }
819
+ function linkUnavailableKeyForPath(remotePath) {
820
+ return "path:" + (remotePath || "");
821
+ }
822
+ function linkUnavailableKeyForHref(href) {
823
+ return "href:" + (href || "");
824
+ }
825
+ function isLinkUnavailable(link, map) {
826
+ if (!link || !map) return false;
827
+ if (link.remotePath && map[linkUnavailableKeyForPath(link.remotePath)]) return true;
828
+ if (link.expiredHref && map[linkUnavailableKeyForHref(link.expiredHref)]) return true;
829
+ if (link.href && map[linkUnavailableKeyForHref(link.href)]) return true;
830
+ return false;
831
+ }
767
832
  function truncateLabelForDisplay(label) {
768
833
  if (!label) return label;
769
834
  if (label.length <= LINK_LABEL_MAX_DISPLAY_CHARS) return label;
@@ -803,15 +868,15 @@ function registerModelContextWindows(models) {
803
868
  }
804
869
  }
805
870
  var projectContextWindows = {};
806
- function setProjectContextWindow(serviceId, tokens) {
807
- var key = (serviceId || "").trim();
871
+ function setProjectContextWindow(projectId, tokens) {
872
+ var key = (projectId || "").trim();
808
873
  if (!key) return;
809
874
  var n = Number(tokens);
810
875
  if (Number.isFinite(n) && n > 0) projectContextWindows[key] = Math.floor(n);
811
876
  else delete projectContextWindows[key];
812
877
  }
813
- function getProjectContextWindow(serviceId) {
814
- var key = (serviceId || "").trim();
878
+ function getProjectContextWindow(projectId) {
879
+ var key = (projectId || "").trim();
815
880
  return key && projectContextWindows[key] ? projectContextWindows[key] : null;
816
881
  }
817
882
  var OUTPUT_TOKEN_RESERVE = 22e3;
@@ -828,8 +893,8 @@ function estimateTextTokens(text) {
828
893
  function estimateMessageTokens(msg) {
829
894
  return estimateTextTokens(msg.content) + estimateTextTokens(msg.role) + 6;
830
895
  }
831
- function getContextWindow(platform, model, serviceId) {
832
- var override = serviceId ? getProjectContextWindow(serviceId) : null;
896
+ function getContextWindow(platform, model, projectId) {
897
+ var override = projectId ? getProjectContextWindow(projectId) : null;
833
898
  if (override) return override;
834
899
  var normalized = (model || "").trim().toLowerCase();
835
900
  if (normalized) {
@@ -848,12 +913,12 @@ function stripFileBlocksFromHistory(content) {
848
913
  return content.replace(/```([^\n`]+?\.[^\s.`]+)\n[\s\S]*?```/g, "[file previously attached: $1]");
849
914
  }
850
915
  function buildBoundedChatMessages(options) {
851
- var contextWindow = getContextWindow(options.platform, options.model, options.serviceId);
916
+ var contextWindow = getContextWindow(options.platform, options.model, options.projectId);
852
917
  var contextBasedBudget = Math.max(
853
918
  MIN_INPUT_TOKEN_BUDGET,
854
919
  contextWindow - OUTPUT_TOKEN_RESERVE - TOOL_AND_RESPONSE_BUFFER
855
920
  );
856
- var scaled = !!(options.serviceId && getProjectContextWindow(options.serviceId));
921
+ var scaled = !!(options.projectId && getProjectContextWindow(options.projectId));
857
922
  var claudeInputCap = scaled ? Math.max(CLAUDE_PER_REQUEST_INPUT_CAP, Math.round(contextBasedBudget * CLAUDE_INPUT_CAP_RATIO)) : CLAUDE_PER_REQUEST_INPUT_CAP;
858
923
  var availableInputBudget = options.platform === "claude" ? Math.min(contextBasedBudget, claudeInputCap) : contextBasedBudget;
859
924
  var systemCost = estimateTextTokens(options.systemPrompt) + 12;
@@ -865,7 +930,7 @@ function buildBoundedChatMessages(options) {
865
930
  var trimmed = windowed.map(function(m, i2) {
866
931
  if (i2 === latestIndex) return m;
867
932
  var stripped = stripFileBlocksFromHistory(m.content);
868
- var sanitized = sanitizeAttachmentLinksForHistory(stripped, options.serviceId, m.role !== "user");
933
+ var sanitized = sanitizeAttachmentLinksForHistory(stripped, options.projectId, m.role !== "user");
869
934
  return Object.assign({}, m, { content: sanitized });
870
935
  });
871
936
  var bounded = [], used = 0;
@@ -1045,6 +1110,147 @@ function prepareDownloadText(filename, body) {
1045
1110
  };
1046
1111
  }
1047
1112
 
1113
+ // src/engine/link_markup.ts
1114
+ function escapeInlineHtml(v) {
1115
+ return String(v == null ? "" : v).replace(/[&<>"']/g, function(ch) {
1116
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch];
1117
+ });
1118
+ }
1119
+ var IMAGE_PREVIEWS_PER_MESSAGE = 8;
1120
+ var INLINE_LINK_GLYPH = "\u2197";
1121
+ var INLINE_LINK_UNAVAILABLE_GLYPH = "\u2715";
1122
+ var INLINE_LINK_UNAVAILABLE_SUFFIX = " (unavailable)";
1123
+ function renderInlineLinkHtml(link, opts) {
1124
+ var o = opts || {};
1125
+ var unavailable = !!o.unavailable;
1126
+ var refreshing = !unavailable && !!o.refreshing;
1127
+ var full = link.fullLabel || link.label;
1128
+ var preview = !!link.image && !!link.remotePath && o.allowImagePreview !== false && !unavailable;
1129
+ var cls = ["bq-link-button"];
1130
+ if (link.expired) cls.push("is-expired");
1131
+ if (refreshing) cls.push("is-refreshing");
1132
+ if (unavailable) cls.push("is-unavailable");
1133
+ if (preview) cls.push("is-image-preview");
1134
+ var labelText = (unavailable ? INLINE_LINK_UNAVAILABLE_GLYPH : INLINE_LINK_GLYPH) + " " + link.label + (unavailable ? INLINE_LINK_UNAVAILABLE_SUFFIX : refreshing ? " (fetching...)" : "");
1135
+ var attrs = ['class="' + cls.join(" ") + '"'];
1136
+ if (unavailable) attrs.push('aria-disabled="true"', 'data-bq-unavailable="1"');
1137
+ else attrs.push('href="' + escapeInlineHtml(link.href) + '"', 'target="_blank"', 'rel="noopener noreferrer"');
1138
+ attrs.push('title="' + escapeInlineHtml(unavailable ? full + INLINE_LINK_UNAVAILABLE_SUFFIX : full) + '"');
1139
+ if (!preview && !unavailable) attrs.push('download="' + escapeInlineHtml(full) + '"');
1140
+ attrs.push('data-bq-link="1"');
1141
+ if (link.expired && !unavailable) attrs.push('data-bq-expired="1"');
1142
+ if (link.expiredHref) attrs.push('data-bq-expired-href="' + escapeInlineHtml(link.expiredHref) + '"');
1143
+ if (link.remotePath) attrs.push('data-bq-remote-path="' + escapeInlineHtml(link.remotePath) + '"');
1144
+ if (link.fullLabel) attrs.push('data-bq-full-label="' + escapeInlineHtml(link.fullLabel) + '"');
1145
+ if (!preview) return "<a " + attrs.join(" ") + ">" + escapeInlineHtml(labelText) + "</a>";
1146
+ 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>";
1147
+ }
1148
+
1149
+ // src/engine/image_preview.ts
1150
+ var previewUrlCache = /* @__PURE__ */ Object.create(null);
1151
+ var previewInFlight = /* @__PURE__ */ Object.create(null);
1152
+ function cacheKey(scope, path) {
1153
+ return scope + "\0" + path;
1154
+ }
1155
+ function clearImagePreviewCache(scope) {
1156
+ if (!scope) {
1157
+ previewUrlCache = /* @__PURE__ */ Object.create(null);
1158
+ previewInFlight = /* @__PURE__ */ Object.create(null);
1159
+ staleImagePreviews = /* @__PURE__ */ Object.create(null);
1160
+ return;
1161
+ }
1162
+ var prefix = scope + "\0";
1163
+ for (var k in previewUrlCache) if (k.indexOf(prefix) === 0) delete previewUrlCache[k];
1164
+ for (var f in previewInFlight) if (f.indexOf(prefix) === 0) delete previewInFlight[f];
1165
+ for (var s in staleImagePreviews) if (s.indexOf(prefix) === 0) delete staleImagePreviews[s];
1166
+ }
1167
+ function peekImagePreviewUrl(ctx, remotePath) {
1168
+ var hit = previewUrlCache[cacheKey(ctx.scope, remotePath)];
1169
+ if (hit && Date.now() - hit.at < LINK_REFRESH_WINDOW_MS) return hit.url;
1170
+ return null;
1171
+ }
1172
+ function resolveImagePreviewUrl(ctx, remotePath, contentType, refresh) {
1173
+ var key = cacheKey(ctx.scope, remotePath);
1174
+ if (staleImagePreviews[key]) {
1175
+ delete staleImagePreviews[key];
1176
+ refresh = true;
1177
+ }
1178
+ if (refresh) {
1179
+ delete previewUrlCache[key];
1180
+ delete previewInFlight[key];
1181
+ } else {
1182
+ var warm = peekImagePreviewUrl(ctx, remotePath);
1183
+ if (warm) return Promise.resolve(warm);
1184
+ var flight = previewInFlight[key];
1185
+ if (flight) return flight;
1186
+ }
1187
+ var run = ctx.mint(remotePath, contentType, refresh).then(function(url) {
1188
+ if (previewInFlight[key] === run) {
1189
+ previewUrlCache[key] = { url, at: Date.now() };
1190
+ delete previewInFlight[key];
1191
+ }
1192
+ return url;
1193
+ }, function(e) {
1194
+ if (previewInFlight[key] === run) delete previewInFlight[key];
1195
+ throw e;
1196
+ });
1197
+ previewInFlight[key] = run;
1198
+ return run;
1199
+ }
1200
+ function markImagePreviewStale(scope, remotePath) {
1201
+ if (!scope || !remotePath) return;
1202
+ staleImagePreviews[cacheKey(scope, remotePath)] = true;
1203
+ delete previewUrlCache[cacheKey(scope, remotePath)];
1204
+ }
1205
+ var staleImagePreviews = /* @__PURE__ */ Object.create(null);
1206
+ function hydrateImagePreviews(imgs, ctx) {
1207
+ for (var i = 0; i < imgs.length; i++) hydrateOne(imgs[i], ctx);
1208
+ }
1209
+ function hydrateOne(img, ctx) {
1210
+ if (img.getAttribute("data-bq-img-state")) return;
1211
+ var path = img.getAttribute("data-bq-img-path");
1212
+ var type = img.getAttribute("data-bq-img-type") || "";
1213
+ if (!path) {
1214
+ img.setAttribute("data-bq-img-state", "error");
1215
+ return;
1216
+ }
1217
+ img.setAttribute("data-bq-img-state", "loading");
1218
+ img.addEventListener("load", function() {
1219
+ img.setAttribute("data-bq-img-state", "ready");
1220
+ if (ctx.onLoad) ctx.onLoad(path);
1221
+ });
1222
+ img.addEventListener("error", function() {
1223
+ onImageError(img, ctx, path, type);
1224
+ });
1225
+ var warm = peekImagePreviewUrl(ctx, path);
1226
+ if (warm) {
1227
+ img.setAttribute("src", warm);
1228
+ return;
1229
+ }
1230
+ resolveImagePreviewUrl(ctx, path, type).then(function(url) {
1231
+ if (img.getAttribute("data-bq-img-state") !== "loading") return;
1232
+ img.setAttribute("src", url);
1233
+ }, function(e) {
1234
+ img.setAttribute("data-bq-img-state", "error");
1235
+ if (ctx.onError) ctx.onError(path, e);
1236
+ });
1237
+ }
1238
+ function onImageError(img, ctx, path, type) {
1239
+ if (img.getAttribute("data-bq-img-retry") === "1") {
1240
+ img.setAttribute("data-bq-img-state", "error");
1241
+ if (ctx.onError) ctx.onError(path, new Error("image preview failed to load"));
1242
+ return;
1243
+ }
1244
+ img.setAttribute("data-bq-img-retry", "1");
1245
+ img.removeAttribute("src");
1246
+ resolveImagePreviewUrl(ctx, path, type, true).then(function(url) {
1247
+ img.setAttribute("src", url);
1248
+ }, function(e) {
1249
+ img.setAttribute("data-bq-img-state", "error");
1250
+ if (ctx.onError) ctx.onError(path, e);
1251
+ });
1252
+ }
1253
+
1048
1254
  // src/engine/time.ts
1049
1255
  function wallClockNow() {
1050
1256
  return Date.now();
@@ -1119,6 +1325,25 @@ var DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6";
1119
1325
  var DEFAULT_OPENAI_MODEL = "gpt-5.6-luna";
1120
1326
  var mcpUrl = () => chatEngineConfig().mcpBaseUrl;
1121
1327
  var clientSecretRequest = (opts) => chatEngineConfig().clientSecretRequest(opts);
1328
+ var VARIANT_IMAGE_DETAIL = "original";
1329
+ var VARIANT_TEXT_VERBOSITY = "high";
1330
+ var OLDEST_NANO_REASONING_EFFORT = "high";
1331
+ var isOpenAINano = (model) => {
1332
+ const normalized = (model).trim().toLowerCase();
1333
+ if (!/(^|-)nano(-|$)/.test(normalized)) return false;
1334
+ const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
1335
+ if (!match) return false;
1336
+ const major = Number(match[1]);
1337
+ const minor = match[2] === void 0 ? null : Number(match[2]);
1338
+ return major > 5 || major === 5 && minor !== null && minor >= 4;
1339
+ };
1340
+ var variantIndexingOptions = (model) => {
1341
+ if (!isOpenAINano(model) || !isOldestNano(model)) return {};
1342
+ return {
1343
+ ...{ text: { verbosity: VARIANT_TEXT_VERBOSITY } } ,
1344
+ ...{ reasoning: { effort: OLDEST_NANO_REASONING_EFFORT } }
1345
+ };
1346
+ };
1122
1347
  var getOpenAIImageDetail = (model) => {
1123
1348
  const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
1124
1349
  const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
@@ -1132,12 +1357,40 @@ var getOpenAIImageDetail = (model) => {
1132
1357
  if (!supportsOriginal) {
1133
1358
  return DEFAULT_OPENAI_IMAGE_DETAIL;
1134
1359
  }
1135
- return isVariant ? "high" : "original";
1360
+ return isVariant ? VARIANT_IMAGE_DETAIL : "original";
1136
1361
  };
1137
1362
  var getRenderImageDetail = (model) => {
1138
1363
  const detail = getOpenAIImageDetail(model);
1139
1364
  return detail === DEFAULT_OPENAI_IMAGE_DETAIL ? "high" : detail;
1140
1365
  };
1366
+ var OPENAI_VERSIONED_ID = /^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/;
1367
+ var isRecognisedOpenAIVersion = (model) => OPENAI_VERSIONED_ID.test((model || DEFAULT_OPENAI_MODEL).trim().toLowerCase());
1368
+ var isOldestNano = (model) => {
1369
+ const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
1370
+ if (!/(^|-)nano(-|$)/.test(normalized)) return false;
1371
+ const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
1372
+ if (!match) return true;
1373
+ const major = Number(match[1]);
1374
+ const minor = match[2] === void 0 ? null : Number(match[2]);
1375
+ if (major < 5) return true;
1376
+ if (major > 5) return false;
1377
+ return minor === null || minor <= 4;
1378
+ };
1379
+ var SMALL_TIER_PAGES_PER_WINDOW = 2;
1380
+ var DOWNSAMPLED_TIER_TILE = 2;
1381
+ function getVisionProfile(model) {
1382
+ const detail = getRenderImageDetail(model);
1383
+ if (!isRecognisedOpenAIVersion(model)) {
1384
+ return { detail, pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
1385
+ }
1386
+ if (detail !== "original") {
1387
+ return { detail, pagesPerWindow: SMALL_TIER_PAGES_PER_WINDOW, tile: DOWNSAMPLED_TIER_TILE };
1388
+ }
1389
+ if (isOldestNano(model)) {
1390
+ return { detail, pagesPerWindow: SMALL_TIER_PAGES_PER_WINDOW, tile: 1 };
1391
+ }
1392
+ return { detail, pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
1393
+ }
1141
1394
  var IMAGE_URL_REGEX = /\bhttps?:\/\/[^\s<>"'()\[\]]+?\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s<>"'()\[\]]*)?/gi;
1142
1395
  function transformContentWithImages(content) {
1143
1396
  if (typeof content !== "string" || !content) {
@@ -1218,6 +1471,7 @@ function applyHistoryCacheBreakpoint(messages) {
1218
1471
  });
1219
1472
  }
1220
1473
  var POLL_INTERVAL = 3e3;
1474
+ var MAX_CONCURRENT_BG_POLLS = 6;
1221
1475
  async function callClaudeWithMcp({
1222
1476
  prompt,
1223
1477
  messages,
@@ -1382,16 +1636,18 @@ async function notifyAgentSaveAttachment(info) {
1382
1636
  const renderFrom = Math.max(0, info.renderFrom || 0);
1383
1637
  const renderPlaceholder = visionFile ? makeRenderPlaceholder(attachment.storagePath) : void 0;
1384
1638
  const renderDetail = platform === "openai" ? getRenderImageDetail(info.model || DEFAULT_OPENAI_MODEL) : void 0;
1639
+ const visionProfile = platform === "openai" ? getVisionProfile(info.model || DEFAULT_OPENAI_MODEL) : { pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
1385
1640
  const skapiRender = visionFile && renderPlaceholder ? {
1386
1641
  _skapi_render: [
1387
1642
  {
1388
1643
  path: attachment.storagePath,
1389
1644
  from: renderFrom,
1390
- count: RENDER_PAGES_PER_WINDOW,
1645
+ count: visionProfile.pagesPerWindow,
1391
1646
  placeholder: renderPlaceholder,
1392
1647
  name: attachment.name,
1393
1648
  mime: attachment.mime,
1394
1649
  detail: renderDetail,
1650
+ tile: visionProfile.tile,
1395
1651
  auto_continue: true,
1396
1652
  continue_text: buildIndexingRenderContinueTemplate(attachment, renderPlaceholder)
1397
1653
  }
@@ -1408,6 +1664,12 @@ async function notifyAgentSaveAttachment(info) {
1408
1664
  name: attachment.name,
1409
1665
  mime: attachment.mime,
1410
1666
  kind: "window",
1667
+ // Same per-image `detail` the render path sends. Without it the worker falls
1668
+ // back to its model-blind default of 'high', so a spreadsheet's embedded
1669
+ // photos were tiled at lower resolution than the SAME model gets for a PDF
1670
+ // page or a chat attachment. That is why a model could describe an attached
1671
+ // photo but reported the pictures inside a sheet as only partly legible.
1672
+ detail: renderDetail,
1411
1673
  auto_continue: true,
1412
1674
  continue_text: buildIndexingWindowMessage(attachment, windowPlaceholder, true)
1413
1675
  }
@@ -1417,13 +1679,27 @@ async function notifyAgentSaveAttachment(info) {
1417
1679
  const serverExtract = !visionFile && !windowedRead && !continuing && !parsedContent && !pagedRead && isServerExtractable(attachment.name, attachment.mime);
1418
1680
  const placeholder = serverExtract ? makeExtractPlaceholder(attachment.storagePath) : void 0;
1419
1681
  const extractContent = serverExtract && placeholder ? [{ path: attachment.storagePath, placeholder, name: attachment.name, mime: attachment.mime }] : void 0;
1420
- const skapiExtract = extractContent && extractContent.length ? { _skapi_extract: extractContent } : {};
1682
+ const skapiExtract = extractContent && extractContent.length ? {
1683
+ _skapi_extract: extractContent.map((d) => ({
1684
+ ...d,
1685
+ // FIRST pass of an INDEXING run only: tells the worker to also pull the
1686
+ // file's embedded pictures into __MEDIA__ and register their records.
1687
+ // Chat-turn extraction (callClaudeWithMcp / callOpenAIWithPublicMcp)
1688
+ // never sets this, so merely ATTACHING a file to a chat message cannot
1689
+ // write media records; a CONTINUE pass skips it because the first pass
1690
+ // already saved (the save is whole-file, not windowed).
1691
+ save_media: !continuing
1692
+ }))
1693
+ } : {};
1421
1694
  const userMessage = visionFile && renderPlaceholder ? buildIndexingRenderMessage(attachment, renderPlaceholder, renderFrom) : windowedRead && windowPlaceholder ? buildIndexingWindowMessage(attachment, windowPlaceholder, false) : continuing ? buildIndexingContinueMessage(attachment) : buildIndexingUserMessage(
1422
1695
  attachment,
1423
1696
  parsedContent ? { inlineContent: parsedContent } : placeholder ? { inlineContentPlaceholder: placeholder } : pagedRead ? { pagedRead: true } : void 0
1424
1697
  );
1425
1698
  const systemPrompt = buildIndexingSystemPrompt({
1426
- service,
1699
+ // The model copies this id verbatim into project_id tool calls, so it must be
1700
+ // the PUBLIC token whenever the host supplied one; the raw code is rejected
1701
+ // by the tools' schema pattern.
1702
+ projectId: info.publicProjectId || service,
1427
1703
  serviceName: info.serviceName,
1428
1704
  serviceDescription: info.serviceDescription
1429
1705
  });
@@ -1432,7 +1708,7 @@ async function notifyAgentSaveAttachment(info) {
1432
1708
  const imageDetail = getOpenAIImageDetail(resolvedModel2);
1433
1709
  return clientSecretRequest({
1434
1710
  clientSecretName: "openai",
1435
- queue: (info.userId || service) + BG_INDEXING_QUEUE_SUFFIX,
1711
+ queue: bgIndexingQueueName(info.userId, service),
1436
1712
  service,
1437
1713
  owner,
1438
1714
  ...pollOpt(),
@@ -1445,6 +1721,8 @@ async function notifyAgentSaveAttachment(info) {
1445
1721
  data: {
1446
1722
  model: resolvedModel2,
1447
1723
  max_output_tokens: MAX_TOKENS,
1724
+ // Nano-only transcription knobs. Indexing only; see variantIndexingOptions.
1725
+ ...variantIndexingOptions(resolvedModel2),
1448
1726
  ...skapiExtract,
1449
1727
  ...skapiRender,
1450
1728
  ...skapiWindow,
@@ -1476,7 +1754,7 @@ async function notifyAgentSaveAttachment(info) {
1476
1754
  const resolvedModel = info.model || DEFAULT_CLAUDE_MODEL;
1477
1755
  return clientSecretRequest({
1478
1756
  clientSecretName: "claude",
1479
- queue: (info.userId || service) + BG_INDEXING_QUEUE_SUFFIX,
1757
+ queue: bgIndexingQueueName(info.userId, service),
1480
1758
  service,
1481
1759
  owner,
1482
1760
  ...pollOpt(),
@@ -1590,6 +1868,9 @@ async function listOpenAIModels(service, owner) {
1590
1868
  });
1591
1869
  }
1592
1870
  var BG_INDEXING_QUEUE_SUFFIX = "-bg";
1871
+ function bgIndexingQueueName(userId, service) {
1872
+ return (userId || service || "") + BG_INDEXING_QUEUE_SUFFIX;
1873
+ }
1593
1874
  function isBgIndexingQueue(queueName) {
1594
1875
  if (typeof queueName !== "string" || !queueName) return false;
1595
1876
  const prefix = queueName.split("|")[0];
@@ -1598,7 +1879,9 @@ function isBgIndexingQueue(queueName) {
1598
1879
  return name.slice(-BG_INDEXING_QUEUE_SUFFIX.length) === BG_INDEXING_QUEUE_SUFFIX;
1599
1880
  }
1600
1881
  var INDEXING_COMPLETE_MARKER = "INDEXING_COMPLETE";
1882
+ var EMPTY_INDEXING_REPLY = "Finished reading this file.";
1601
1883
  var MAX_INDEXING_RESUME_PASSES = 6;
1884
+ var CHAT_HISTORY_PAGE_LIMIT = 500;
1602
1885
  async function getChatHistory(params, fetchOptions) {
1603
1886
  const url = params.platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
1604
1887
  const p = Object.assign(
@@ -1612,7 +1895,7 @@ async function getChatHistory(params, fetchOptions) {
1612
1895
  );
1613
1896
  return chatEngineConfig().clientSecretRequestHistory(
1614
1897
  p,
1615
- Object.assign({ ascending: false }, fetchOptions)
1898
+ Object.assign({ ascending: false, limit: CHAT_HISTORY_PAGE_LIMIT }, fetchOptions)
1616
1899
  );
1617
1900
  }
1618
1901
 
@@ -1679,6 +1962,8 @@ function mapHistoryListToMessages(list, platform, opts) {
1679
1962
  var userText = extractLastUserTextFromRequest(requestBody);
1680
1963
  var assistantText = isPending ? "" : (extractAssistantText(response) || "").trim() || "";
1681
1964
  var isErrorResponse = !isPending && (isFailed || isErrorResponseBody(response));
1965
+ var reportedComplete = !!(item && item._isBgTask) && !isErrorResponse && !!assistantText && assistantText.indexOf(INDEXING_COMPLETE_MARKER) !== -1;
1966
+ if (reportedComplete) assistantText = assistantText.split(INDEXING_COMPLETE_MARKER).join("").trim();
1682
1967
  var serverItemId = item && typeof item.id === "string" && item.id ? item.id : void 0;
1683
1968
  var createdTs = Number(item && item.created);
1684
1969
  var updatedTs = Number(item && item.updated);
@@ -1703,7 +1988,7 @@ function mapHistoryListToMessages(list, platform, opts) {
1703
1988
  displayContent = userText;
1704
1989
  }
1705
1990
  } else {
1706
- displayContent = sanitizeAttachmentLinksForHistory(userText, opts.serviceId);
1991
+ displayContent = sanitizeAttachmentLinksForHistory(userText, opts.projectId);
1707
1992
  }
1708
1993
  var userMsg = { role: "user", content: displayContent };
1709
1994
  if (isInProcess) userMsg.isPendingInProcess = true;
@@ -1730,11 +2015,12 @@ function mapHistoryListToMessages(list, platform, opts) {
1730
2015
  if (serverItemId !== void 0) em._serverItemId = serverItemId;
1731
2016
  if (replyTs !== void 0) em._ts = replyTs;
1732
2017
  mapped.push(em);
1733
- } else if (assistantText) {
1734
- var okm = { role: "assistant", content: sanitizeAttachmentLinksForHistory(assistantText, opts.serviceId, true) };
2018
+ } else if (assistantText || reportedComplete) {
2019
+ var okm = { role: "assistant", content: sanitizeAttachmentLinksForHistory(assistantText, opts.projectId, true) || EMPTY_INDEXING_REPLY };
1735
2020
  if (item._isBgTask) okm.isBackgroundTask = true;
1736
2021
  if (serverItemId !== void 0) okm._serverItemId = serverItemId;
1737
2022
  if (replyTs !== void 0) okm._ts = replyTs;
2023
+ if (reportedComplete) okm._indexComplete = true;
1738
2024
  mapped.push(okm);
1739
2025
  }
1740
2026
  });
@@ -1793,6 +2079,16 @@ async function fillHistoryViewport(opts) {
1793
2079
  function createHistoryFiller(base) {
1794
2080
  var pending = [];
1795
2081
  var running = false;
2082
+ var fetching = false;
2083
+ function announce(next) {
2084
+ if (fetching === next) return;
2085
+ fetching = next;
2086
+ if (!base.onRunningChange) return;
2087
+ try {
2088
+ base.onRunningChange(next);
2089
+ } catch (e) {
2090
+ }
2091
+ }
1796
2092
  async function allSatisfied() {
1797
2093
  var next = [];
1798
2094
  for (var i = 0; i < pending.length; i++) {
@@ -1802,23 +2098,33 @@ function createHistoryFiller(base) {
1802
2098
  return pending.length === 0;
1803
2099
  }
1804
2100
  return {
2101
+ // The published fact, so a view and `isRunning()` can never disagree about
2102
+ // what they are showing. A fill that never fetches is not something anyone
2103
+ // outside this module has any use for knowing about.
1805
2104
  isRunning: function() {
1806
- return running;
2105
+ return fetching;
1807
2106
  },
1808
2107
  fill: function(isSatisfied) {
1809
2108
  pending.push(isSatisfied);
1810
2109
  if (running) return Promise.resolve();
1811
2110
  running = true;
1812
2111
  var done = function() {
1813
- running = false;
1814
2112
  pending = [];
2113
+ running = false;
2114
+ announce(false);
1815
2115
  };
1816
2116
  return fillHistoryViewport({
1817
2117
  isSatisfied: allSatisfied,
1818
2118
  isEndOfList: base.isEndOfList,
1819
2119
  isLoading: base.isLoading,
1820
2120
  messageCount: base.messageCount,
1821
- fetchOlder: base.fetchOlder,
2121
+ // The span opens HERE, at the first real page request: past
2122
+ // isEndOfList, past isStale, past isSatisfied. Everything before this
2123
+ // point is a fill that concluded there was nothing to do.
2124
+ fetchOlder: function() {
2125
+ announce(true);
2126
+ return base.fetchOlder();
2127
+ },
1822
2128
  isStale: base.isStale,
1823
2129
  maxPages: base.maxPages
1824
2130
  }).then(done, done);
@@ -1828,7 +2134,16 @@ function createHistoryFiller(base) {
1828
2134
 
1829
2135
  // src/engine/session.ts
1830
2136
  var WORKER_PASS_ADOPT_LIMIT = 20;
2137
+ var LIVE_INDEX_SNAPSHOT_MAX_AGE_MS = 5e3;
2138
+ var INDEX_DISPATCH_CLAIM_MS = 2 * 60 * 1e3;
1831
2139
  var WORKER_PASS_ADOPT_ATTEMPTS = [0, 2e3, 6e3];
2140
+ var INDEXING_DRAIN_BUSY_POLL_MS = 8e3;
2141
+ var INDEXING_DRAIN_CONFIRM_POLL_MS = 3e3;
2142
+ var INDEXING_DRAIN_IDLE_LOOKS = 2;
2143
+ var INDEXING_DRAIN_MIN_MS = 8e3;
2144
+ var INDEXING_DRAIN_TIMEOUT_MS = 15 * 60 * 1e3;
2145
+ var INDEXING_DRAIN_LOOK_TIMEOUT_MS = 45e3;
2146
+ var INDEXING_DRAIN_NUDGE_MIN_GAP_MS = 1500;
1832
2147
  var _g = typeof globalThis !== "undefined" ? globalThis : {};
1833
2148
  function nowMs() {
1834
2149
  return _g.performance && typeof _g.performance.now === "function" ? _g.performance.now() : Date.now();
@@ -1890,7 +2205,10 @@ var ChatSession = class {
1890
2205
  historyEndOfList: false,
1891
2206
  historyStartKeyHistory: [],
1892
2207
  historyRequestToken: 0,
1893
- gateRefreshToken: 0
2208
+ gateRefreshToken: 0,
2209
+ liveIndexKeys: {},
2210
+ liveIndexChecked: false,
2211
+ stoppedIndexIds: {}
1894
2212
  };
1895
2213
  this.bgTaskQueue = [];
1896
2214
  this.cancelledServerIds = /* @__PURE__ */ new Set();
@@ -1901,6 +2219,298 @@ var ChatSession = class {
1901
2219
  this._pauseReasons = /* @__PURE__ */ new Set();
1902
2220
  this._resuming = false;
1903
2221
  this._lidSeq = 0;
2222
+ this._stageSeq = 0;
2223
+ this._uploadBatches = 0;
2224
+ this._indexDispatchesInFlight = 0;
2225
+ this._drainNudges = [];
2226
+ this._liveStages = {};
2227
+ this._liveIndexKey = "";
2228
+ this._liveIndexAt = 0;
2229
+ this._indexClaims = {};
2230
+ }
2231
+ /** What the display layer needs to decide whether a run is finished. `keys` holds
2232
+ * every file the server still has indexing work for; `checked` is false until the
2233
+ * first answer for this chat, and false means "we do not know yet". */
2234
+ getLiveIndexState() {
2235
+ return { keys: this.state.liveIndexKeys, checked: this.state.liveIndexChecked };
2236
+ }
2237
+ /** Passes that were on a row when the user stopped it, so the display layer can
2238
+ * still tell that this run was stopped once the stop has left no other trace.
2239
+ * See cancelIndexingGroup, which fills it, and buildChatDisplayList, which is
2240
+ * the only reader. */
2241
+ getStoppedIndexIds() {
2242
+ return this.state.stoppedIndexIds;
2243
+ }
2244
+ /**
2245
+ * Is this file ALREADY being indexed by this client?
2246
+ *
2247
+ * One live run per file, and the reason is what a second one looks like: the
2248
+ * conversation grows a SECOND collapsed row for the same file (a run is opened
2249
+ * by every FIRST pass, so two of them are two rows), the same document is read
2250
+ * twice at full provider cost, and the two chains fight over the same records —
2251
+ * the delete-then-repost that starts run 2 wipes what run 1 has saved so far.
2252
+ *
2253
+ * Asked of this client's own live work, so it cannot be wrong in the dangerous
2254
+ * direction: a queued/running pass keeps its bgTaskQueue entry until its bubble
2255
+ * settles, and a settled run answers false, which is what a genuine later
2256
+ * re-index needs.
2257
+ *
2258
+ * The retry that made this necessary: a chip whose INDEX request failed is
2259
+ * handed back to the composer to be retried on the next send, and an index
2260
+ * request can fail from the client's side (a lost ack, an expired token on the
2261
+ * response) while the server has already queued the pass. The retry then indexes
2262
+ * a file that was never not being indexed.
2263
+ */
2264
+ hasLiveIndexRun(storagePath) {
2265
+ if (!storagePath) return false;
2266
+ var claimed = this._indexClaims[this._indexClaimKey(storagePath)];
2267
+ if (claimed && nowMs() - claimed < INDEX_DISPATCH_CLAIM_MS) return true;
2268
+ var id = this.host.getIdentity();
2269
+ for (var i = 0; i < this.bgTaskQueue.length; i++) {
2270
+ var e = this.bgTaskQueue[i];
2271
+ if (e && e.storagePath === storagePath && e.projectId === id.projectId && e.platform === id.platform) return true;
2272
+ }
2273
+ return this.state.messages.some(function(m) {
2274
+ if (!m.isBackgroundTask || m.role !== "user" || m.isCancelled) return false;
2275
+ if (!(m.isPendingQueued || m.isPendingInProcess || m.isSendingToServer)) return false;
2276
+ return !!m._indexFile && m._indexFile.path === storagePath;
2277
+ });
2278
+ }
2279
+ /** Storage paths are project-relative, and one ChatSession serves every
2280
+ * project, so a claim has to be scoped the way a stop is (_indexKeyOf). */
2281
+ _indexClaimKey(storagePath) {
2282
+ return this.getHistoryCacheKey() + "|" + storagePath;
2283
+ }
2284
+ /**
2285
+ * Take this file's indexing slot, or report that someone already has it.
2286
+ *
2287
+ * The check-and-CLAIM is what makes it safe against a second caller arriving
2288
+ * mid-flight: the claim is written SYNCHRONOUSLY, before the first await, so a
2289
+ * concurrent caller sees it even though no request has completed and no queue
2290
+ * has admitted anything. Ask-then-dispatch could not do that — every source it
2291
+ * consults only learns about a dispatch after the ack.
2292
+ *
2293
+ * Returns true when the caller owns the slot and should dispatch. A caller that
2294
+ * then fails to dispatch MUST releaseIndexRun, or the file waits out the claim
2295
+ * (a few minutes) before it can be retried.
2296
+ */
2297
+ claimIndexRun(storagePath) {
2298
+ var self = this;
2299
+ if (!storagePath) return Promise.resolve(true);
2300
+ if (this.hasLiveIndexRun(storagePath)) return Promise.resolve(false);
2301
+ this._indexClaims[this._indexClaimKey(storagePath)] = nowMs();
2302
+ return this._refreshLiveIndexKeys(LIVE_INDEX_SNAPSHOT_MAX_AGE_MS).then(function() {
2303
+ if (!self.state.liveIndexKeys[storagePath]) return true;
2304
+ self.releaseIndexRun(storagePath);
2305
+ return false;
2306
+ }).catch(function() {
2307
+ return true;
2308
+ });
2309
+ }
2310
+ /** Give the slot back — the dispatch failed, or was abandoned. */
2311
+ releaseIndexRun(storagePath) {
2312
+ if (storagePath) delete this._indexClaims[this._indexClaimKey(storagePath)];
2313
+ }
2314
+ /**
2315
+ * The same question, asked of the SERVER when this page cannot answer it.
2316
+ *
2317
+ * hasLiveIndexRun only knows what this page did. That is not enough for the
2318
+ * case duplicates actually come from: the first run was started before a
2319
+ * reload, or in another tab, or its bubble has since been paged out of the
2320
+ * loaded window — and then the retry finds nothing locally and starts a second
2321
+ * run of a file that is still being indexed. The queue is the one place that
2322
+ * knows, and it is already asked for exactly this list.
2323
+ *
2324
+ * Only a POSITIVE answer is used. Absence proves nothing here (the query is
2325
+ * capped, and `liveIndexChecked` records that), so an unanswerable question
2326
+ * falls back to dispatching — the cost of a wrong "no" is the duplicate this
2327
+ * exists to prevent, and the cost of a wrong "yes" is a file that never gets
2328
+ * indexed at all. Only one of those is recoverable by the user.
2329
+ */
2330
+ isIndexRunLive(storagePath) {
2331
+ var self = this;
2332
+ if (!storagePath) return Promise.resolve(false);
2333
+ if (this.hasLiveIndexRun(storagePath)) return Promise.resolve(true);
2334
+ return this._refreshLiveIndexKeys(LIVE_INDEX_SNAPSHOT_MAX_AGE_MS).then(function() {
2335
+ return !!self.state.liveIndexKeys[storagePath];
2336
+ }).catch(function() {
2337
+ return false;
2338
+ });
2339
+ }
2340
+ /** Re-ask the queue which files are still being indexed, unless the answer we
2341
+ * have is younger than `maxAgeMs`. Shared by every caller that needs a current
2342
+ * one; the display layer's own refresh path is the adopt ladder. */
2343
+ _refreshLiveIndexKeys(maxAgeMs) {
2344
+ var self = this;
2345
+ var id = this.host.getIdentity();
2346
+ var platform = id.platform;
2347
+ if (!id.projectId || platform !== "claude" && platform !== "openai") return Promise.resolve();
2348
+ var askedKey = this.getHistoryCacheKey();
2349
+ if (this._liveIndexKey === askedKey && nowMs() - this._liveIndexAt < maxAgeMs) {
2350
+ return Promise.resolve();
2351
+ }
2352
+ var queue = bgIndexingQueueName(id.userId, id.projectId);
2353
+ var ask = function(status) {
2354
+ return Promise.resolve(getChatHistory(
2355
+ { service: id.projectId, owner: id.owner, platform, queue, status },
2356
+ { limit: WORKER_PASS_ADOPT_LIMIT }
2357
+ )).catch(function() {
2358
+ return null;
2359
+ });
2360
+ };
2361
+ return Promise.all([ask("pending"), ask("running")]).then(function(results) {
2362
+ if (results[0] === null || results[1] === null) return;
2363
+ if (self.getHistoryCacheKey() !== askedKey) return;
2364
+ self._liveIndexKey = askedKey;
2365
+ self._recordLiveIndexKeys(results);
2366
+ });
2367
+ }
2368
+ /**
2369
+ * Replace the live-index snapshot from a queue query's raw items.
2370
+ *
2371
+ * Whole-snapshot, never incremental: the query returns everything unresolved on
2372
+ * the queue, so a file MISSING from it is precisely the fact we are after. Merging
2373
+ * would make a finished file impossible to observe.
2374
+ */
2375
+ _recordLiveIndexKeys(lists) {
2376
+ var next = {};
2377
+ var truncated = false;
2378
+ var settledIds = {};
2379
+ this.state.messages.forEach(function(m) {
2380
+ if (!m._serverItemId) return;
2381
+ if (m.isPending || m.isPendingInProcess || m.isPendingQueued) return;
2382
+ settledIds[m._serverItemId] = true;
2383
+ });
2384
+ for (var li = 0; li < lists.length; li++) {
2385
+ var list = lists[li] && Array.isArray(lists[li].list) ? lists[li].list : [];
2386
+ if (list.length >= WORKER_PASS_ADOPT_LIMIT) truncated = true;
2387
+ for (var i = 0; i < list.length; i++) {
2388
+ var item = list[i];
2389
+ if (!item || item.status !== "pending" && item.status !== "running") continue;
2390
+ if (item.id && settledIds[item.id]) continue;
2391
+ var text = extractLastUserTextFromRequest(item.request_body);
2392
+ if (!isIndexingRequestText(text)) continue;
2393
+ var ref = parseIndexingRequestText(text);
2394
+ if (!ref) continue;
2395
+ if (ref.path) next[ref.path] = true;
2396
+ if (ref.name) next[ref.name] = true;
2397
+ }
2398
+ }
2399
+ var nowChecked = !truncated;
2400
+ var was = this.state.liveIndexKeys, changed = this.state.liveIndexChecked !== nowChecked;
2401
+ if (!changed) {
2402
+ for (var k in next) if (!was[k]) {
2403
+ changed = true;
2404
+ break;
2405
+ }
2406
+ if (!changed) {
2407
+ for (var k2 in was) if (!next[k2]) {
2408
+ changed = true;
2409
+ break;
2410
+ }
2411
+ }
2412
+ }
2413
+ this.state.liveIndexKeys = next;
2414
+ this.state.liveIndexChecked = nowChecked;
2415
+ this._liveIndexAt = nowMs();
2416
+ this._liveIndexKey = this.getHistoryCacheKey();
2417
+ if (changed) this.host.notify();
2418
+ }
2419
+ /** Forget the snapshot: it describes ONE chat's queue, and the answer for the
2420
+ * project the user just switched to is unknown until it is asked for again. */
2421
+ _resetLiveIndexKeys() {
2422
+ this.state.liveIndexKeys = {};
2423
+ this.state.liveIndexChecked = false;
2424
+ this._liveIndexAt = 0;
2425
+ }
2426
+ /**
2427
+ * Ask the queue what is still indexing, once, for the chat that is on screen.
2428
+ *
2429
+ * Seeds the snapshot on a history load. Without it a reloaded chat has no way to
2430
+ * learn that a run it can see is over: the adopt ladder that normally answers this
2431
+ * only fires when a pass SETTLES, and after a reload there is no pass left to
2432
+ * settle — so every finished worker-driven row would spin forever.
2433
+ *
2434
+ * Best-effort: a failure leaves `checked` false, which reads as "still working"
2435
+ * rather than as a false all-clear.
2436
+ *
2437
+ * Delegates to the adopt ladder rather than asking once. A single empty look is
2438
+ * exactly what that ladder exists to distrust — the worker writes pass N+1 a few
2439
+ * milliseconds AFTER flipping pass N to resolved, so a query landing in that gap
2440
+ * sees an empty queue for a chain that is very much alive. One look would turn
2441
+ * that into a confident "Indexed" with a green check, on the one scenario this
2442
+ * whole feature is for, and nothing would ever re-ask: the ladder is normally
2443
+ * triggered by a pass SETTLING, and after a reload there is no pass left to
2444
+ * settle. The ladder re-asks at 0/2s/6s, records each answer, and as a bonus
2445
+ * adopts and polls any live pass it finds, which makes the row genuinely active
2446
+ * instead of merely unconfirmed.
2447
+ */
2448
+ refreshLiveIndexState() {
2449
+ this._adoptWorkerIndexingPasses(0);
2450
+ }
2451
+ /** Forget what we know about which files are indexing — but ONLY when the
2452
+ * snapshot was taken for a different chat than the one on screen now. For a
2453
+ * consumer whose history loading is its own fork and so never reaches
2454
+ * loadHistory's reset — a snapshot describes ONE chat's queue, and carrying it
2455
+ * into another project would let a row there claim to be finished on someone
2456
+ * else's evidence.
2457
+ *
2458
+ * Conditional for the same reason loadHistory's own reset is (the
2459
+ * `loadKey !== _liveIndexKey` gate): the view calls this on every mount, and
2460
+ * an unconditional wipe turned every re-entry to the chat into a grey
2461
+ * "Checking status:" sweep across rows whose state was already known. A
2462
+ * RE-entry keeps showing the last answer (green/yellow) while the first-page
2463
+ * refresh re-asks quietly; only a genuine project/platform switch starts from
2464
+ * "not known yet". Claiming `_liveIndexKey` here (before any answer) is the
2465
+ * same fudge loadHistory makes: it marks WHOSE chat the empty snapshot is
2466
+ * for, so repeated calls do not re-wipe, and _recordLiveIndexKeys re-claims
2467
+ * it when the real answer lands. */
2468
+ resetLiveIndexState() {
2469
+ var key = this.getHistoryCacheKey();
2470
+ if (key === this._liveIndexKey) return;
2471
+ this._liveIndexKey = key;
2472
+ this._resetLiveIndexKeys();
2473
+ }
2474
+ /** Wrap an indexing-request dispatch so awaitIndexingDrained counts it as
2475
+ * live work from the moment it is sent, not from the moment it is acked. */
2476
+ trackIndexDispatch(p) {
2477
+ var self = this;
2478
+ this._indexDispatchesInFlight += 1;
2479
+ var release = function() {
2480
+ self._indexDispatchesInFlight = Math.max(0, self._indexDispatchesInFlight - 1);
2481
+ };
2482
+ return p.then(function(v) {
2483
+ release();
2484
+ return v;
2485
+ }, function(e) {
2486
+ release();
2487
+ throw e;
2488
+ });
2489
+ }
2490
+ /**
2491
+ * Something just happened that plausibly ENDED indexing work, so let any waiting
2492
+ * turn look now instead of sitting out the rest of its busy interval.
2493
+ *
2494
+ * A nudge changes only WHEN a look happens, never what it concludes: the two
2495
+ * agreeing idle looks, the confirm gap between them, "a failed look counts as
2496
+ * busy" and the minimum wait are all untouched. That is why it is safe to fire
2497
+ * from places that are merely good guesses.
2498
+ *
2499
+ * Fired from end-of-chain points ONLY: the adopt ladder giving up, a resume
2500
+ * declining to continue, a pass failing. Not from every settling pass (one nudge
2501
+ * per pass per file for the whole run), and not from an indexing request being
2502
+ * accepted — see the note in trackIndexDispatch for why that one is actively
2503
+ * harmful rather than merely wasteful.
2504
+ */
2505
+ _nudgeIndexingDrain() {
2506
+ if (!this._drainNudges.length) return;
2507
+ var list = this._drainNudges.slice();
2508
+ for (var i = 0; i < list.length; i++) {
2509
+ try {
2510
+ list[i]();
2511
+ } catch (e) {
2512
+ }
2513
+ }
1904
2514
  }
1905
2515
  /**
1906
2516
  * Register a live poll so (a) a remount dedupes against it instead of stacking a
@@ -1917,6 +2527,18 @@ var ChatSession = class {
1917
2527
  this.historyItemPolls.set(id, { kind, stop });
1918
2528
  return p;
1919
2529
  }
2530
+ /** Background polls currently attached, for the MAX_CONCURRENT_BG_POLLS budget.
2531
+ * Counts the registry rather than a separate tally so it cannot drift: every
2532
+ * attach goes through _trackPoll and every detach deletes the entry. Note an
2533
+ * entry left behind by pausePolling on an older skapi-js (no stop handle)
2534
+ * still counts, which is correct — that poll really is still running. */
2535
+ _countBgPolls() {
2536
+ var n = 0;
2537
+ this.historyItemPolls.forEach(function(handle) {
2538
+ if (handle && handle.kind === "bg") n++;
2539
+ });
2540
+ return n;
2541
+ }
1920
2542
  /**
1921
2543
  * Stop and forget one item's poll. Used after a cancel: the row is either gone
1922
2544
  * (cancelled while queued) or flagged cancelled (cancelled while running), so
@@ -1998,8 +2620,8 @@ var ChatSession = class {
1998
2620
  }
1999
2621
  getHistoryCacheKey() {
2000
2622
  var id = this.host.getIdentity();
2001
- if (!id.serviceId || id.platform === "none") return "";
2002
- return id.serviceId + "#" + id.platform;
2623
+ if (!id.projectId || id.platform === "none") return "";
2624
+ return id.projectId + "#" + id.platform;
2003
2625
  }
2004
2626
  updateHistoryCache() {
2005
2627
  var key = this.getHistoryCacheKey();
@@ -2052,6 +2674,7 @@ var ChatSession = class {
2052
2674
  for (var j = 0; j < msgs.length; j++) {
2053
2675
  var u = msgs[j];
2054
2676
  if (!u || u.role !== "user" || u.isBackgroundTask) continue;
2677
+ if (u._stageId) continue;
2055
2678
  if (!(u.isPendingQueued || u.isPendingInProcess || u.isSendingToServer)) continue;
2056
2679
  if (serverId && u._serverItemId && u._serverItemId !== serverId) continue;
2057
2680
  var settled = { role: "user", content: u.content };
@@ -2067,26 +2690,26 @@ var ChatSession = class {
2067
2690
  };
2068
2691
  }
2069
2692
  /**
2070
- * serviceId/owner are passed explicitly by every caller: a request can be
2693
+ * projectId/owner are passed explicitly by every caller: a request can be
2071
2694
  * dispatched after the user moved to another project, and re-reading the live
2072
2695
  * identity here would silently send the turn to THAT project instead of the
2073
2696
  * one it was composed for. Falls back to the live read only when a caller
2074
2697
  * omits them.
2075
2698
  */
2076
- _callProviderFor(platform, prompt, messages, system, model, userId, extractContent, fileUrls, serviceId, owner) {
2077
- if (serviceId === void 0 || owner === void 0) {
2699
+ _callProviderFor(platform, prompt, messages, system, model, userId, extractContent, fileUrls, projectId, owner) {
2700
+ if (projectId === void 0 || owner === void 0) {
2078
2701
  var id = this.host.getIdentity();
2079
- if (serviceId === void 0) serviceId = id.serviceId;
2702
+ if (projectId === void 0) projectId = id.projectId;
2080
2703
  if (owner === void 0) owner = id.owner;
2081
2704
  }
2082
- return platform === "openai" ? callOpenAIWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls) : callClaudeWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls);
2705
+ return platform === "openai" ? callOpenAIWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls) : callClaudeWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls);
2083
2706
  }
2084
2707
  dispatchAgentRequest(params) {
2085
2708
  var self = this;
2086
2709
  var dispatchItemId;
2087
2710
  var sendAndPoll = function() {
2088
2711
  return Promise.resolve(
2089
- self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.serviceId, params.owner)
2712
+ self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.projectId, params.owner)
2090
2713
  ).then(function(initial) {
2091
2714
  if (initial && initial.poll && (initial.status === "pending" || initial.status === "running")) {
2092
2715
  if (initial.id) {
@@ -2145,16 +2768,304 @@ var ChatSession = class {
2145
2768
  this.pendingAgentRequests[params.key] = run;
2146
2769
  return run;
2147
2770
  }
2771
+ /**
2772
+ * Put a turn on screen the INSTANT the user hits Send, before its attachments
2773
+ * have finished uploading. Uploads run in the background now (the composer is
2774
+ * cleared and stays usable), so without a staged bubble the message would
2775
+ * appear only once its files were up — below anything the user sent in the
2776
+ * meantime, in an order that never matches what they typed.
2777
+ *
2778
+ * Staged bubbles carry _useBgQueue because that is where a turn with
2779
+ * attachments ultimately dispatches (behind its own indexing tasks). That flag
2780
+ * is also what keeps promoteNextQueuedToRunning / resolveQueuedUserBubble off
2781
+ * them: those advance the SERVER queue, and a staged turn has no server
2782
+ * request behind it yet.
2783
+ *
2784
+ * Returns the id to hand back as PinnedDispatchContext.stageId at dispatch.
2785
+ */
2786
+ stageOutgoingMessage(displayText) {
2787
+ this._stageSeq += 1;
2788
+ var stageId = "stg_" + this._stageSeq;
2789
+ var key = this.getHistoryCacheKey();
2790
+ var staged = {
2791
+ role: "user",
2792
+ content: displayText,
2793
+ isPendingQueued: true,
2794
+ isUploadingAttachments: true,
2795
+ isSendingToServer: true,
2796
+ _dimSending: true,
2797
+ // A staged bubble has no server id for minutes, and its indexing rows are
2798
+ // now inserted ABOVE it — so its array index moves. Both views fall back to
2799
+ // the index when a bubble has no id, which would re-key (and in Vue, remount)
2800
+ // this bubble on every file, restarting its transition and losing it as a
2801
+ // scroll anchor. A local id it keeps for its whole life fixes both.
2802
+ _localId: this._newLocalId(),
2803
+ _useBgQueue: true,
2804
+ _stageId: stageId,
2805
+ _ts: wallClockNow()
2806
+ };
2807
+ if (key) staged._ownerKey = key;
2808
+ this._liveStages[stageId] = true;
2809
+ this.state.messages.push(staged);
2810
+ this.host.notify();
2811
+ this.host.scrollToBottom(true);
2812
+ return stageId;
2813
+ }
2814
+ /** Is anything in this page still uploading/dispatching for this stage? */
2815
+ isLiveStage(stageId) {
2816
+ return !!stageId && !!this._liveStages[stageId];
2817
+ }
2818
+ /**
2819
+ * Settle any staged bubble in `list` whose chain no longer exists, and return the
2820
+ * list (a new array only if something changed).
2821
+ *
2822
+ * The caller is a cache restore. A staged bubble is the one kind of message whose
2823
+ * resolution lives entirely in page memory — no server request stands behind it
2824
+ * yet — so a copy that outlives its upload would render "(Uploading files...)"
2825
+ * forever with nothing left to finish it. Today nothing can: this cache dies with
2826
+ * the page, so every restored stage is still live and this is a no-op. It exists
2827
+ * so that stops being a silent assumption.
2828
+ */
2829
+ settleDeadStagedMessages(list) {
2830
+ if (!Array.isArray(list)) return list;
2831
+ var self = this;
2832
+ var dead = false;
2833
+ for (var i = 0; i < list.length; i++) {
2834
+ var m = list[i];
2835
+ if (m && m._stageId && !self._liveStages[m._stageId]) {
2836
+ dead = true;
2837
+ break;
2838
+ }
2839
+ }
2840
+ if (!dead) return list;
2841
+ return list.map(function(m2) {
2842
+ if (!m2 || !m2._stageId || self._liveStages[m2._stageId]) return m2;
2843
+ var settled = { role: "user", content: m2.content };
2844
+ if (m2._ownerKey !== void 0) settled._ownerKey = m2._ownerKey;
2845
+ if (m2._ts !== void 0) settled._ts = m2._ts;
2846
+ if (m2._localId !== void 0) settled._localId = m2._localId;
2847
+ return settled;
2848
+ });
2849
+ }
2850
+ _stageIndex(list, stageId) {
2851
+ if (!stageId) return -1;
2852
+ for (var i = 0; i < list.length; i++) {
2853
+ if (list[i] && list[i]._stageId === stageId) return i;
2854
+ }
2855
+ return -1;
2856
+ }
2857
+ /**
2858
+ * Staged turn, phase 2: its files are up and it is now waiting for the whole
2859
+ * indexing chain behind them. Swaps "(Uploading files...)" for
2860
+ * "(Indexing files...)"; the bubble stays dimmed, because from the user's side
2861
+ * nothing has been handed over yet.
2862
+ *
2863
+ * It deliberately does NOT say "(In queue)" here. The turn is not queued behind
2864
+ * anything the server knows about yet — it is waiting on work that can run for
2865
+ * minutes — and claiming otherwise is what made the wait look like a stall.
2866
+ */
2867
+ markStagedMessageIndexing(stageId) {
2868
+ var idx = this._stageIndex(this.state.messages, stageId);
2869
+ if (idx === -1) return;
2870
+ var ex = this.state.messages[idx];
2871
+ if (!ex.isUploadingAttachments) return;
2872
+ this.state.messages[idx] = Object.assign({}, ex, {
2873
+ isUploadingAttachments: false,
2874
+ isAwaitingIndexing: true
2875
+ });
2876
+ this.host.notify();
2877
+ }
2878
+ /**
2879
+ * Staged turn, phase 3: the last of its files has finished indexing, so the turn
2880
+ * is genuinely just queued now. Full opacity + "(In queue)".
2881
+ *
2882
+ * Clears the PRESENTATIONAL _dimSending only; isSendingToServer stays set until
2883
+ * the server actually acks (it is the token that ack matches on). Called by the
2884
+ * clients the instant awaitIndexingDrained resolves, i.e. immediately before the
2885
+ * dispatch that replaces this bubble — dispatchComposedMessage carries the
2886
+ * cleared flag onto the replacement so the turn does not blink back to dimmed.
2887
+ */
2888
+ markStagedMessageReady(stageId) {
2889
+ var idx = this._stageIndex(this.state.messages, stageId);
2890
+ if (idx === -1) return;
2891
+ var ex = this.state.messages[idx];
2892
+ if (!ex.isAwaitingIndexing && !ex._dimSending && !ex.isUploadingAttachments) return;
2893
+ this.state.messages[idx] = Object.assign({}, ex, {
2894
+ isUploadingAttachments: false,
2895
+ isAwaitingIndexing: false,
2896
+ _dimSending: false
2897
+ });
2898
+ this.host.notify();
2899
+ }
2900
+ /**
2901
+ * Resolves once this project's background-indexing queue has nothing left to
2902
+ * run, so a chat enqueued right after it is genuinely last.
2903
+ *
2904
+ * Sending the chat as soon as the uploads finish is not enough, which is the
2905
+ * whole reason this exists: indexing a file is a CHAIN, and each pass is only
2906
+ * enqueued once the previous one lands (the client mints CONTINUE passes for
2907
+ * text/grid files, the worker mints them for PDFs and windowed reads). Every
2908
+ * one of those passes therefore queues up BEHIND a chat sent at upload time,
2909
+ * and the model answers from a file it has only partly read.
2910
+ *
2911
+ * The queue is read from the server's status index rather than from
2912
+ * bgTaskQueue: that mirror holds only what this client dispatched or adopted,
2913
+ * and it stops being maintained once the view unmounts. An empty answer has to
2914
+ * repeat before it is believed — see INDEXING_DRAIN_IDLE_LOOKS — and a look
2915
+ * that fails counts as busy, so a dropped request delays the turn instead of
2916
+ * releasing it early.
2917
+ *
2918
+ * Reads the identity PINNED at Send time, never a live one: the user may be in
2919
+ * another project by now, and this must keep asking about the one they sent
2920
+ * from.
2921
+ */
2922
+ awaitIndexingDrained(identity) {
2923
+ var self = this;
2924
+ var svcId = identity && identity.projectId;
2925
+ var platform = identity && identity.platform;
2926
+ if (!svcId || platform !== "claude" && platform !== "openai") return Promise.resolve("skipped");
2927
+ var owner = identity.owner;
2928
+ var queue = bgIndexingQueueName(identity.userId, svcId);
2929
+ var startedAt = nowMs();
2930
+ var deadline = startedAt + INDEXING_DRAIN_TIMEOUT_MS;
2931
+ var idleLooks = 0;
2932
+ var ask = function(status) {
2933
+ var answered = false;
2934
+ return new Promise(function(res) {
2935
+ var bail = null;
2936
+ var settle = function(v) {
2937
+ if (answered) return;
2938
+ answered = true;
2939
+ if (bail) {
2940
+ clearTimeout(bail);
2941
+ bail = null;
2942
+ }
2943
+ res(v);
2944
+ };
2945
+ bail = setTimeout(function() {
2946
+ settle(null);
2947
+ }, INDEXING_DRAIN_LOOK_TIMEOUT_MS);
2948
+ Promise.resolve(getChatHistory(
2949
+ { service: svcId, owner, platform, queue, status },
2950
+ { limit: WORKER_PASS_ADOPT_LIMIT }
2951
+ )).then(function(r) {
2952
+ settle(r);
2953
+ }, function() {
2954
+ settle(null);
2955
+ });
2956
+ });
2957
+ };
2958
+ var hasLiveIndexing = function(res) {
2959
+ var list = res && Array.isArray(res.list) ? res.list : [];
2960
+ for (var i = 0; i < list.length; i++) {
2961
+ var item = list[i];
2962
+ if (!item || item.status !== "pending" && item.status !== "running") continue;
2963
+ if (isIndexingRequestText(extractLastUserTextFromRequest(item.request_body))) return true;
2964
+ }
2965
+ return false;
2966
+ };
2967
+ return new Promise(function(resolve) {
2968
+ var timer = null;
2969
+ var lastLookAt = -Infinity;
2970
+ var nudgedThisInterval = false;
2971
+ var inFlight = false;
2972
+ var finish = function(v) {
2973
+ if (timer) {
2974
+ clearTimeout(timer);
2975
+ timer = null;
2976
+ }
2977
+ var ni = self._drainNudges.indexOf(nudge);
2978
+ if (ni !== -1) self._drainNudges.splice(ni, 1);
2979
+ resolve(v);
2980
+ };
2981
+ var again = function(ms) {
2982
+ if (timer) {
2983
+ clearTimeout(timer);
2984
+ timer = null;
2985
+ }
2986
+ var wait = ms == null ? idleLooks > 0 ? INDEXING_DRAIN_CONFIRM_POLL_MS : INDEXING_DRAIN_BUSY_POLL_MS : ms;
2987
+ timer = setTimeout(look, wait);
2988
+ };
2989
+ var nudge = function() {
2990
+ if (idleLooks > 0) return;
2991
+ if (inFlight) return;
2992
+ if (nudgedThisInterval) return;
2993
+ if (self._indexDispatchesInFlight > 0) return;
2994
+ nudgedThisInterval = true;
2995
+ again(Math.max(0, INDEXING_DRAIN_NUDGE_MIN_GAP_MS - (nowMs() - lastLookAt)));
2996
+ };
2997
+ var look = function() {
2998
+ timer = null;
2999
+ if (inFlight) return;
3000
+ lastLookAt = nowMs();
3001
+ nudgedThisInterval = false;
3002
+ if (nowMs() >= deadline) {
3003
+ finish("timedout");
3004
+ return;
3005
+ }
3006
+ if (self._indexDispatchesInFlight > 0) {
3007
+ idleLooks = 0;
3008
+ again();
3009
+ return;
3010
+ }
3011
+ inFlight = true;
3012
+ Promise.all([ask("running"), ask("pending")]).then(function(res) {
3013
+ inFlight = false;
3014
+ var unknown = res[0] === null || res[1] === null;
3015
+ if (unknown || hasLiveIndexing(res[0]) || hasLiveIndexing(res[1])) idleLooks = 0;
3016
+ else idleLooks += 1;
3017
+ if (idleLooks >= INDEXING_DRAIN_IDLE_LOOKS && nowMs() - startedAt >= INDEXING_DRAIN_MIN_MS) {
3018
+ finish("drained");
3019
+ return;
3020
+ }
3021
+ again();
3022
+ }, function() {
3023
+ inFlight = false;
3024
+ idleLooks = 0;
3025
+ again();
3026
+ });
3027
+ };
3028
+ self._drainNudges.push(nudge);
3029
+ look();
3030
+ });
3031
+ }
3032
+ /**
3033
+ * Abandon a staged turn — its uploads failed outright, so nothing will be
3034
+ * dispatched. The bubble stays (the user's text is not silently thrown away)
3035
+ * but settles into a plain, non-pending message; the caller reports the
3036
+ * failure separately.
3037
+ */
3038
+ settleStagedMessage(stageId) {
3039
+ delete this._liveStages[stageId];
3040
+ var idx = this._stageIndex(this.state.messages, stageId);
3041
+ if (idx === -1) return;
3042
+ var ex = this.state.messages[idx];
3043
+ var settled = { role: "user", content: ex.content };
3044
+ if (ex._ownerKey !== void 0) settled._ownerKey = ex._ownerKey;
3045
+ if (ex._ts !== void 0) settled._ts = ex._ts;
3046
+ if (ex._localId !== void 0) settled._localId = ex._localId;
3047
+ this.state.messages[idx] = settled;
3048
+ this.host.notify();
3049
+ this.updateHistoryCache();
3050
+ }
2148
3051
  // composed = clean display text; composedForLlm carries office-extraction
2149
3052
  // placeholders for the provider only. useBgQueue routes a post-attachment turn
2150
3053
  // onto the "-bg" queue so it runs after indexing.
2151
3054
  dispatchComposedMessage(composed, useBgQueue, composedForLlm, extractContent, fileUrls, pinned) {
2152
3055
  var self = this;
2153
- if (!composed) return;
3056
+ var stageId = pinned ? pinned.stageId : void 0;
3057
+ if (!composed) {
3058
+ if (stageId) this.settleStagedMessage(stageId);
3059
+ return;
3060
+ }
2154
3061
  var id = pinned ? pinned.identity : this.host.getIdentity();
2155
- if (id.platform === "none") return;
3062
+ if (id.platform === "none") {
3063
+ if (stageId) this.settleStagedMessage(stageId);
3064
+ return;
3065
+ }
3066
+ if (stageId) delete this._liveStages[stageId];
2156
3067
  var llmComposed = composedForLlm || composed;
2157
- var key = !id.serviceId ? "" : id.serviceId + "#" + id.platform;
3068
+ var key = !id.projectId ? "" : id.projectId + "#" + id.platform;
2158
3069
  var offChat = !!key && key !== this.getHistoryCacheKey();
2159
3070
  var isQueuedSend = !offChat && (useBgQueue || this.state.sending || this.state.messages.some(function(m) {
2160
3071
  return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue;
@@ -2162,8 +3073,8 @@ var ChatSession = class {
2162
3073
  var aiPlatform = id.platform;
2163
3074
  var aiModel = id.model || void 0;
2164
3075
  var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
2165
- var userId = id.userId || id.serviceId;
2166
- var chatQueue = useBgQueue ? userId + BG_INDEXING_QUEUE_SUFFIX : userId;
3076
+ var userId = id.userId || id.projectId;
3077
+ var chatQueue = useBgQueue ? bgIndexingQueueName(userId) : userId;
2167
3078
  if (offChat) {
2168
3079
  var offHistory = (this.aiChatHistoryCache[key] ? this.aiChatHistoryCache[key].messages : []).filter(function(m) {
2169
3080
  return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask && !m.isError;
@@ -2172,13 +3083,28 @@ var ChatSession = class {
2172
3083
  platform: aiPlatform,
2173
3084
  model: aiModel,
2174
3085
  systemPrompt,
2175
- serviceId: id.serviceId,
3086
+ projectId: id.projectId,
2176
3087
  history: offHistory.concat([{ role: "user", content: llmComposed }])
2177
3088
  });
2178
3089
  var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
3090
+ var offUser = { role: "user", content: composed, _ownerKey: key, _ts: wallClockNow() };
3091
+ var offStage = this._stageIndex(this.state.messages, stageId);
3092
+ if (offStage !== -1) {
3093
+ if (this.state.messages[offStage]._ts !== void 0) offUser._ts = this.state.messages[offStage]._ts;
3094
+ this.state.messages.splice(offStage, 1);
3095
+ this.host.notify();
3096
+ }
3097
+ var offCached = offExisting.messages;
3098
+ if (stageId) {
3099
+ offCached = offCached.filter(function(m) {
3100
+ if (m._stageId !== stageId) return true;
3101
+ if (offStage === -1 && m._ts !== void 0) offUser._ts = m._ts;
3102
+ return false;
3103
+ });
3104
+ }
2179
3105
  this.aiChatHistoryCache[key] = {
2180
- messages: offExisting.messages.concat([
2181
- { role: "user", content: composed, _ownerKey: key, _ts: wallClockNow() },
3106
+ messages: offCached.concat([
3107
+ offUser,
2182
3108
  { role: "assistant", content: "", isPending: true, isPendingInProcess: true, _ownerKey: key }
2183
3109
  ]),
2184
3110
  endOfList: offExisting.endOfList,
@@ -2186,7 +3112,7 @@ var ChatSession = class {
2186
3112
  };
2187
3113
  this.dispatchAgentRequest({
2188
3114
  key,
2189
- serviceId: id.serviceId,
3115
+ projectId: id.projectId,
2190
3116
  owner: id.owner,
2191
3117
  aiPlatform,
2192
3118
  aiModel,
@@ -2207,24 +3133,33 @@ var ChatSession = class {
2207
3133
  platform: aiPlatform,
2208
3134
  model: aiModel,
2209
3135
  systemPrompt,
2210
- serviceId: id.serviceId,
3136
+ projectId: id.projectId,
2211
3137
  history: resolvedHistory.concat([{ role: "user", content: llmComposed }])
2212
3138
  });
2213
- var queuedBubble = { role: "user", content: composed, isPendingQueued: true, isSendingToServer: true, _ts: wallClockNow() };
3139
+ var queuedBubble = { role: "user", content: composed, isPendingQueued: true, isSendingToServer: true, _dimSending: true, _localId: this._newLocalId(), _ts: wallClockNow() };
2214
3140
  if (key) queuedBubble._ownerKey = key;
2215
3141
  if (useBgQueue) queuedBubble._useBgQueue = true;
2216
- this.state.messages.push(queuedBubble);
3142
+ var qStage = this._stageIndex(this.state.messages, stageId);
3143
+ if (qStage !== -1) {
3144
+ var qEx = this.state.messages[qStage];
3145
+ if (qEx._ts !== void 0) queuedBubble._ts = qEx._ts;
3146
+ if (qEx._dimSending === false) queuedBubble._dimSending = false;
3147
+ if (qEx._localId) queuedBubble._localId = qEx._localId;
3148
+ this.state.messages.splice(qStage, 1, queuedBubble);
3149
+ } else {
3150
+ this.state.messages.push(queuedBubble);
3151
+ }
2217
3152
  this.host.notify();
2218
3153
  this.updateHistoryCache();
2219
3154
  this.host.scrollToBottom(true);
2220
3155
  var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
2221
- Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.serviceId, id.owner)).then(function(result) {
3156
+ Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.projectId, id.owner)).then(function(result) {
2222
3157
  var sendingIdx = self.getHistoryCacheKey() !== capturedKey ? -1 : self.state.messages.findIndex(function(m) {
2223
- return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === "user" && (m._ownerKey === void 0 || m._ownerKey === capturedKey);
3158
+ return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === "user" && !m._stageId && (m._ownerKey === void 0 || m._ownerKey === capturedKey);
2224
3159
  });
2225
3160
  var serverId = result && typeof result.id === "string" ? result.id : void 0;
2226
3161
  if (sendingIdx >= 0) {
2227
- var upd = Object.assign({}, self.state.messages[sendingIdx], { isSendingToServer: false });
3162
+ var upd = Object.assign({}, self.state.messages[sendingIdx], { isSendingToServer: false, _dimSending: false });
2228
3163
  if (serverId) upd._serverItemId = serverId;
2229
3164
  self.state.messages[sendingIdx] = upd;
2230
3165
  self.host.notify();
@@ -2245,33 +3180,37 @@ var ChatSession = class {
2245
3180
  });
2246
3181
  return;
2247
3182
  }
2248
- this.state.messages.push({ role: "user", content: composed, _ts: wallClockNow(), ...key ? { _ownerKey: key } : {} });
2249
- this.state.messages.push({ role: "assistant", content: "", isPending: true, isPendingInProcess: true, ...key ? { _ownerKey: key } : {} });
3183
+ var immediateUser = { role: "user", content: composed, _localId: this._newLocalId(), _ts: wallClockNow(), ...key ? { _ownerKey: key } : {} };
3184
+ var immediatePlaceholder = { role: "assistant", content: "", isPending: true, isPendingInProcess: true, ...key ? { _ownerKey: key } : {} };
3185
+ var iStage = this._stageIndex(this.state.messages, stageId);
3186
+ if (iStage !== -1) {
3187
+ var iEx = this.state.messages[iStage];
3188
+ if (iEx._ts !== void 0) immediateUser._ts = iEx._ts;
3189
+ if (iEx._localId) immediateUser._localId = iEx._localId;
3190
+ this.state.messages.splice(iStage, 1, immediateUser, immediatePlaceholder);
3191
+ } else {
3192
+ this.state.messages.push(immediateUser);
3193
+ this.state.messages.push(immediatePlaceholder);
3194
+ }
2250
3195
  this.host.notify();
2251
3196
  this.updateHistoryCache();
2252
3197
  this.state.sending = true;
2253
3198
  this.host.scrollToBottom(true);
2254
3199
  var historyForLlm = this.state.messages.filter(function(m) {
3200
+ if (m === immediateUser) return false;
2255
3201
  return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask && !m.isError;
2256
3202
  });
2257
- if (llmComposed !== composed) {
2258
- for (var li = historyForLlm.length - 1; li >= 0; li--) {
2259
- if (historyForLlm[li].role === "user" && historyForLlm[li].content === composed) {
2260
- historyForLlm[li] = Object.assign({}, historyForLlm[li], { content: llmComposed });
2261
- break;
2262
- }
2263
- }
2264
- }
3203
+ historyForLlm.push({ role: "user", content: llmComposed });
2265
3204
  var bounded = buildBoundedChatMessages({
2266
3205
  platform: aiPlatform,
2267
3206
  model: aiModel,
2268
3207
  systemPrompt,
2269
- serviceId: id.serviceId,
3208
+ projectId: id.projectId,
2270
3209
  history: historyForLlm
2271
3210
  });
2272
3211
  var run = this.dispatchAgentRequest({
2273
3212
  key,
2274
- serviceId: id.serviceId,
3213
+ projectId: id.projectId,
2275
3214
  owner: id.owner,
2276
3215
  aiPlatform,
2277
3216
  aiModel,
@@ -2328,6 +3267,8 @@ var ChatSession = class {
2328
3267
  if (existing._serverItemId !== void 0) promoted._serverItemId = existing._serverItemId;
2329
3268
  if (existing._ownerKey !== void 0) promoted._ownerKey = existing._ownerKey;
2330
3269
  if (existing.isSendingToServer) promoted.isSendingToServer = true;
3270
+ if (existing._dimSending) promoted._dimSending = true;
3271
+ if (existing._localId !== void 0) promoted._localId = existing._localId;
2331
3272
  this.state.messages[nextIdx] = promoted;
2332
3273
  var placeholder = { role: "assistant", content: "", isPending: true };
2333
3274
  if (existing._serverItemId !== void 0) placeholder._serverItemId = existing._serverItemId;
@@ -2335,6 +3276,33 @@ var ChatSession = class {
2335
3276
  this.state.messages.splice(nextIdx + 1, 0, placeholder);
2336
3277
  this.host.notify();
2337
3278
  }
3279
+ /**
3280
+ * The "Thinking..." placeholder belonging to the user bubble at `userIdx`, or -1.
3281
+ *
3282
+ * Every path that creates one puts it IMMEDIATELY after its user bubble
3283
+ * (promoteNextQueuedToRunning, the immediate-send pair, applyHistoryItemResolution),
3284
+ * so ownership is adjacency — modulo background bubbles, which get spliced in
3285
+ * around them. Taking the first pending assistant ANYWHERE below instead was a
3286
+ * hijack: a turn sent with attachments never gets a placeholder of its own
3287
+ * (promoteNextQueuedToRunning skips _useBgQueue turns) and now keeps the position
3288
+ * it was sent in, so an ordinary turn sent while its files indexed sits BELOW it
3289
+ * with a placeholder of its own — and the attachment turn's answer was rendered
3290
+ * as the answer to that unrelated question.
3291
+ */
3292
+ _ownThinkingIndex(userIdx, serverId) {
3293
+ if (userIdx < 0) return -1;
3294
+ for (var i = userIdx + 1; i < this.state.messages.length; i++) {
3295
+ var m = this.state.messages[i];
3296
+ if (!m) return -1;
3297
+ if (m.isBackgroundTask) continue;
3298
+ if (m.isPending && m.role === "assistant") {
3299
+ if (serverId && m._serverItemId && m._serverItemId !== serverId) return -1;
3300
+ return i;
3301
+ }
3302
+ return -1;
3303
+ }
3304
+ return -1;
3305
+ }
2338
3306
  resolveQueuedUserBubble(serverId) {
2339
3307
  var liveKey = this.getHistoryCacheKey();
2340
3308
  var isLocal = function(m) {
@@ -2361,9 +3329,7 @@ var ChatSession = class {
2361
3329
  if (userIdx >= 0) {
2362
3330
  var ex = this.state.messages[userIdx];
2363
3331
  this.state.messages[userIdx] = { role: "user", content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId, ...ex._ownerKey !== void 0 ? { _ownerKey: ex._ownerKey } : {} };
2364
- var thIdx = this.state.messages.findIndex(function(m, i) {
2365
- return i > userIdx && m.isPending && m.role === "assistant" && !m.isBackgroundTask;
2366
- });
3332
+ var thIdx = this._ownThinkingIndex(userIdx, serverId);
2367
3333
  if (thIdx !== -1) this.state.messages.splice(thIdx, 1);
2368
3334
  }
2369
3335
  this.promoteNextQueuedToRunning();
@@ -2375,16 +3341,17 @@ var ChatSession = class {
2375
3341
  if (exist._serverItemId !== void 0) repl._serverItemId = exist._serverItemId;
2376
3342
  if (exist._ownerKey !== void 0) repl._ownerKey = exist._ownerKey;
2377
3343
  if (exist._ts !== void 0) repl._ts = exist._ts;
3344
+ if (exist._localId !== void 0) repl._localId = exist._localId;
2378
3345
  this.state.messages[userIdx] = repl;
2379
3346
  }
2380
- var thinkingIdx = userIdx >= 0 ? this.state.messages.findIndex(function(m, i) {
2381
- return i > userIdx && m.isPending && m.role === "assistant" && !m.isBackgroundTask;
2382
- }) : -1;
3347
+ var thinkingIdx = this._ownThinkingIndex(userIdx, serverId);
2383
3348
  return thinkingIdx !== -1 ? thinkingIdx : userIdx >= 0 ? userIdx + 1 : -1;
2384
3349
  }
2385
3350
  insertAtTarget(msg, targetIdx) {
2386
3351
  if (msg && msg.role === "assistant" && msg._ts === void 0) msg._ts = wallClockNow();
2387
- if (targetIdx >= 0 && this.state.messages[targetIdx] && this.state.messages[targetIdx].isPending) this.state.messages[targetIdx] = msg;
3352
+ var tgt = targetIdx >= 0 ? this.state.messages[targetIdx] : void 0;
3353
+ var replaceable = !!tgt && !!tgt.isPending && !tgt.isBackgroundTask && this._isOwnPlaceholderOf(targetIdx, this._owningUserIndex(targetIdx));
3354
+ if (replaceable) this.state.messages[targetIdx] = msg;
2388
3355
  else if (targetIdx >= 0) this.state.messages.splice(targetIdx, 0, msg);
2389
3356
  else this.state.messages.push(msg);
2390
3357
  }
@@ -2501,16 +3468,21 @@ var ChatSession = class {
2501
3468
  var platform = id.platform;
2502
3469
  if (platform !== "claude" && platform !== "openai") return;
2503
3470
  var url = platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
2504
- var queueBase = id.userId || id.serviceId;
2505
- var queue = msg.isBackgroundTask || msg._useBgQueue ? queueBase + BG_INDEXING_QUEUE_SUFFIX : queueBase;
2506
- this.state.messages[idx] = Object.assign({}, msg, { _cancelling: true, _cancelError: void 0 });
3471
+ var queueBase = id.userId || id.projectId;
3472
+ var queue = msg.isBackgroundTask || msg._useBgQueue ? bgIndexingQueueName(queueBase) : queueBase;
3473
+ 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) {
3474
+ return m._serverItemId === serverId && m.role === msg.role;
3475
+ });
3476
+ if (at !== -1) {
3477
+ this.state.messages[at] = Object.assign({}, this.state.messages[at], { _cancelling: true, _cancelError: void 0 });
3478
+ }
2507
3479
  this.host.notify();
2508
3480
  Promise.resolve(this.host.cancelRequest({
2509
3481
  url,
2510
3482
  method: "POST",
2511
3483
  id: serverId,
2512
3484
  queue,
2513
- service: id.serviceId,
3485
+ service: id.projectId,
2514
3486
  owner: id.owner
2515
3487
  })).then(function(result) {
2516
3488
  if (result && result.removed) {
@@ -2580,7 +3552,10 @@ var ChatSession = class {
2580
3552
  * 2. the file is remembered in cancelledIndexKeys, so the client-driven
2581
3553
  * resume (maybeResumeIndexing) stops dispatching CONTINUE passes; and
2582
3554
  * 3. any of its passes still sitting in bgTaskQueue is dropped by the next
2583
- * drain rather than surfacing a fresh "Indexing…" bubble.
3555
+ * drain rather than surfacing a fresh "Indexing…" bubble; and
3556
+ * 4. the RUN is remembered (state.stoppedIndexIds), because none of the above
3557
+ * necessarily leaves a mark on the conversation — see below — and without
3558
+ * it the collapsed row reported the stopped file as finished.
2584
3559
  *
2585
3560
  * Records already written by the passes that DID run are kept — this stops the
2586
3561
  * work, it does not undo it.
@@ -2590,6 +3565,19 @@ var ChatSession = class {
2590
3565
  if (!group || !group.key) return;
2591
3566
  var scoped = this.getHistoryCacheKey() + "|" + group.key;
2592
3567
  this.cancelledIndexKeys.add(scoped);
3568
+ if (!group.finished) {
3569
+ var stoppedIds = {};
3570
+ for (var sk in this.state.stoppedIndexIds) stoppedIds[sk] = true;
3571
+ (group.members || []).forEach(function(m) {
3572
+ var sid = m && m.msg && m.msg._serverItemId;
3573
+ if (sid) stoppedIds[sid] = true;
3574
+ });
3575
+ this.bgTaskQueue.forEach(function(e) {
3576
+ if (e && e.id && self._indexKeyOf(e) === scoped) stoppedIds[e.id] = true;
3577
+ });
3578
+ this.state.stoppedIndexIds = stoppedIds;
3579
+ }
3580
+ this._adoptWorkerIndexingPasses(0);
2593
3581
  var ids = group.cancellableIds || [];
2594
3582
  if (!ids.length) {
2595
3583
  this.host.notify();
@@ -2763,22 +3751,70 @@ var ChatSession = class {
2763
3751
  this.promoteNextQueuedToRunning();
2764
3752
  return this.enqueueTypewrite(pendingIdx, latest.content, lid);
2765
3753
  }
2766
- // Remove any leftover non-background pending ("Thinking…") assistant bubbles.
2767
- // There is normally at most ONE such bubble at a time (promoteNext* refuses to
2768
- // add a second), so any extra is a duplicate it appears when a concurrent
2769
- // history refetch re-maps the still-"running" turn into a pending placeholder
2770
- // (with a real _serverItemId) while the local pending bubble (no _serverItemId)
2771
- // is rescued and re-appended (see loadHistory rescue below). Each resolve path
2772
- // only replaces the FIRST pending bubble, so without this a stray "Thinking…"
2773
- // survives next to the reply/error. MUST run AFTER the resolved bubble has been
2774
- // made non-pending and BEFORE promoteNext*() (so a freshly-promoted Thinking,
2775
- // which is added only once no pending assistant remains, is preserved).
3754
+ // Remove leftover non-background pending ("Thinking…") assistant bubbles: the
3755
+ // duplicate that appears when a concurrent history refetch re-maps the still-
3756
+ // "running" turn into a pending placeholder (with a real _serverItemId) while the
3757
+ // local pending bubble (no _serverItemId) is rescued and re-appended (see the
3758
+ // loadHistory rescue below), and the orphan a resolve leaves when it splices its
3759
+ // reply beside a placeholder instead of into it. Each resolve path only replaces
3760
+ // ONE pending bubble, so without this a stray "Thinking…" survives forever next to
3761
+ // the reply. MUST run AFTER the resolved bubble has been made non-pending and
3762
+ // BEFORE promoteNext*() (which only adds a Thinking once none remains).
3763
+ //
3764
+ // It used to take EVERY one, on the premise that there is at most one at a time
3765
+ // because promoteNext* refuses to add a second. That premise never covered the
3766
+ // immediate-send path, which creates its pair directly — and a turn sent with
3767
+ // attachments does not block the composer and resolves on its own queue, so an
3768
+ // ordinary question asked while files index is in flight, with a placeholder of
3769
+ // its own, exactly when the attachment turn resolves. Sweeping it left that
3770
+ // question with no spinner and, worse, nowhere for its answer to land:
3771
+ // typewriteLatestReply bails when there is no pending assistant, so the reply
3772
+ // reached the cache and never the screen.
3773
+ //
3774
+ // The discriminator is the owning USER bubble. A live immediate send's user bubble
3775
+ // carries NO pending flags (its in-flight-ness lives in state.sending), while every
3776
+ // duplicate this sweep is for belongs to a user bubble that is still pending — and
3777
+ // an orphan has no user bubble above it at all.
2776
3778
  _removeStrayPendingAssistants() {
2777
3779
  for (var k = this.state.messages.length - 1; k >= 0; k--) {
2778
3780
  var m = this.state.messages[k];
2779
- if (m.isPending && m.role === "assistant" && !m.isBackgroundTask) this.state.messages.splice(k, 1);
3781
+ if (!m || !m.isPending || m.role !== "assistant" || m.isBackgroundTask) continue;
3782
+ if (this._isLiveImmediatePlaceholder(k)) continue;
3783
+ this.state.messages.splice(k, 1);
2780
3784
  }
2781
3785
  }
3786
+ /** Index of the USER bubble the message at `idx` belongs to — the nearest one
3787
+ * above it, stepping over background bubbles (a file's indexing rows are
3788
+ * inserted between turns). -1 when the nearest thing above is not a user turn,
3789
+ * which for a placeholder means it is an orphan. */
3790
+ _owningUserIndex(idx) {
3791
+ for (var j = idx - 1; j >= 0; j--) {
3792
+ var p = this.state.messages[j];
3793
+ if (!p) return -1;
3794
+ if (p.isBackgroundTask) continue;
3795
+ return p.role === "user" ? j : -1;
3796
+ }
3797
+ return -1;
3798
+ }
3799
+ /** The bubble at `idx` is the "Thinking…" of a DIFFERENT turn that is still
3800
+ * waiting for its answer, so the sweep above must leave it alone. */
3801
+ _isLiveImmediatePlaceholder(idx) {
3802
+ var ui = this._owningUserIndex(idx);
3803
+ if (ui === -1) return false;
3804
+ var p = this.state.messages[ui];
3805
+ return !p.isPending && !p.isPendingQueued && !p.isPendingInProcess && !p.isPendingOlder && !p.isSendingToServer && !p.isCancelled;
3806
+ }
3807
+ /** A pending assistant at `idx` is the placeholder OF the turn above it, so a
3808
+ * reply may take its slot. Every path that makes one copies the parent's
3809
+ * _serverItemId (or neither has one yet), so a mismatch means the slot belongs to
3810
+ * some other request and the reply must be spliced in beside it, not on top. */
3811
+ _isOwnPlaceholderOf(idx, userIdx) {
3812
+ if (userIdx === -1) return false;
3813
+ var ph = this.state.messages[idx], u = this.state.messages[userIdx];
3814
+ if (!ph || !u) return false;
3815
+ if (ph._serverItemId === void 0 || u._serverItemId === void 0) return true;
3816
+ return ph._serverItemId === u._serverItemId;
3817
+ }
2782
3818
  // Drop the pending flags on the resolved turn's USER bubble (preserving its
2783
3819
  // content + background-task marker). Needed because a bg "Indexing:" turn's user
2784
3820
  // bubble carries isPendingInProcess; leaving it set keeps the bubble visually
@@ -2825,6 +3861,7 @@ var ChatSession = class {
2825
3861
  var indexRef = this._indexRefOfItem(itemId);
2826
3862
  this.applyHistoryItemResolution(itemId, response, platform);
2827
3863
  this.promoteNextBgQueuedToRunning();
3864
+ this.drainBgTaskQueue();
2828
3865
  if (indexRef) this._followWorkerIndexingChain(indexRef.name, indexRef.mime);
2829
3866
  }
2830
3867
  /** The file an already-rendered background pass is about, off its request
@@ -2838,11 +3875,67 @@ var ChatSession = class {
2838
3875
  }
2839
3876
  return null;
2840
3877
  }
3878
+ /**
3879
+ * Settle a turn the server reports as cancelled: the request bubble goes to its
3880
+ * cancelled form and the "Thinking..." placeholder goes away. The same shape
3881
+ * cancelQueuedMessage produces locally, so a cancel this client made and one it
3882
+ * merely found out about render identically — and an indexing pass keeps the
3883
+ * markers that hold it in its file's collapsed row.
3884
+ */
3885
+ _settleCancelledItem(itemId) {
3886
+ var uIdx = this.state.messages.findIndex(function(m) {
3887
+ return m.role === "user" && m._serverItemId === itemId && !m.isCancelled;
3888
+ });
3889
+ if (uIdx !== -1) {
3890
+ var u = this.state.messages[uIdx];
3891
+ var cancelled = { role: "user", content: u.content, isCancelled: true, _serverItemId: itemId };
3892
+ if (u.isBackgroundTask) cancelled.isBackgroundTask = true;
3893
+ if (u._indexFile) cancelled._indexFile = u._indexFile;
3894
+ if (u._useBgQueue) cancelled._useBgQueue = true;
3895
+ if (u._ownerKey !== void 0) cancelled._ownerKey = u._ownerKey;
3896
+ if (u._ts !== void 0) cancelled._ts = u._ts;
3897
+ this.state.messages[uIdx] = cancelled;
3898
+ }
3899
+ var pIdx = this.state.messages.findIndex(function(m) {
3900
+ return m.isPending && m.role === "assistant" && m._serverItemId === itemId;
3901
+ });
3902
+ if (pIdx !== -1) this.state.messages.splice(pIdx, 1);
3903
+ this.cancelledServerIds.delete(itemId);
3904
+ this._removeStrayPendingAssistants();
3905
+ this.host.notify();
3906
+ this.updateHistoryCache();
3907
+ }
3908
+ /**
3909
+ * A poll that came back saying the request was CANCELLED, rather than with an
3910
+ * answer.
3911
+ *
3912
+ * The server keeps a cancelled request as a terminal row instead of deleting it
3913
+ * (that row is the durable record of the stop, and the chat history it belongs
3914
+ * to), so a poll still running when the cancel lands now RESOLVES on it. It used
3915
+ * to reject with NOT_EXISTS, and the resolution path below reads a status object
3916
+ * as an answer with no text — which would stamp "No text response received from
3917
+ * AI provider" over a turn the user had just stopped.
3918
+ *
3919
+ * Reachable whenever the poll was not stopped by whoever cancelled: another tab,
3920
+ * another device, or the row being cancelled server-side by the file's own stop.
3921
+ */
3922
+ _isCancelledPollResult(response) {
3923
+ if (!response || typeof response !== "object" || response.status !== "cancelled") return false;
3924
+ if (response.content !== void 0 || response.output !== void 0) return false;
3925
+ return response.queue_name !== void 0 || response.in_queue !== void 0;
3926
+ }
2841
3927
  applyHistoryItemResolution(itemId, response, platform) {
2842
3928
  this.historyItemPolls.delete(itemId);
3929
+ if (this._isCancelledPollResult(response)) {
3930
+ this._settleCancelledItem(itemId);
3931
+ return;
3932
+ }
2843
3933
  var isErr = isErrorResponseBody(response);
2844
3934
  var answer = isErr ? getErrorMessage(response) : ((platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "").trim();
2845
- if (!isErr && answer) answer = answer.split(INDEXING_COMPLETE_MARKER).join("").trim();
3935
+ var reportedComplete = !isErr && !!answer && answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1;
3936
+ var stripMarker = function(t) {
3937
+ return reportedComplete ? t.split(INDEXING_COMPLETE_MARKER).join("").trim() : t;
3938
+ };
2846
3939
  var idx = this.state.messages.findIndex(function(m) {
2847
3940
  return m.isPending && m._serverItemId === itemId;
2848
3941
  });
@@ -2858,7 +3951,7 @@ var ChatSession = class {
2858
3951
  }
2859
3952
  var text = answer || "No text response received from AI provider.";
2860
3953
  if (wasBgTask) {
2861
- this.state.messages[idx] = { role: "assistant", content: text, isBackgroundTask: true, _serverItemId: itemId };
3954
+ this.state.messages[idx] = { role: "assistant", content: stripMarker(text) || EMPTY_INDEXING_REPLY, isBackgroundTask: true, _serverItemId: itemId, ...reportedComplete ? { _indexComplete: true } : {} };
2862
3955
  this.host.notify();
2863
3956
  this.updateHistoryCache();
2864
3957
  return;
@@ -2891,7 +3984,7 @@ var ChatSession = class {
2891
3984
  }
2892
3985
  var text2 = answer || "No text response received from AI provider.";
2893
3986
  if (ex.isBackgroundTask) {
2894
- this.state.messages.splice(userIdx + 1, 0, { role: "assistant", content: text2, isBackgroundTask: true, _serverItemId: itemId });
3987
+ this.state.messages.splice(userIdx + 1, 0, { role: "assistant", content: stripMarker(text2) || EMPTY_INDEXING_REPLY, isBackgroundTask: true, _serverItemId: itemId, ...reportedComplete ? { _indexComplete: true } : {} });
2895
3988
  this.host.notify();
2896
3989
  this.updateHistoryCache();
2897
3990
  return;
@@ -2912,7 +4005,7 @@ var ChatSession = class {
2912
4005
  if (!entry) return "";
2913
4006
  var file = entry.storagePath || entry.filename;
2914
4007
  if (!file) return "";
2915
- return entry.serviceId + "#" + entry.platform + "|" + file;
4008
+ return entry.projectId + "#" + entry.platform + "|" + file;
2916
4009
  }
2917
4010
  /**
2918
4011
  * Reconcile the bg queue with the files the user has stopped.
@@ -2922,17 +4015,33 @@ var ChatSession = class {
2922
4015
  * path, and without this an earlier cancel would silently kill every future
2923
4016
  * index of the same path. A continuation of a stopped file is dropped instead,
2924
4017
  * covering the pass that was dispatched in the moment before the cancel landed.
4018
+ *
4019
+ * "Fresh" is the load-bearing word, and it used to be missing. A run's OWN first
4020
+ * pass sits in this queue for as long as it runs (entries are only dropped once
4021
+ * their bubble settles), so stopping a file during its first pass — which is
4022
+ * exactly when a user who has just uploaded it does — met that first-pass entry
4023
+ * on the very next drain and lifted the stop the user had just asked for. The
4024
+ * chain then carried on, one worker-minted window after another, with nothing
4025
+ * client-side left to suppress it. The ids recorded at stop time are what tells
4026
+ * the two apart: a pass that was already there when the user hit Stop cannot be
4027
+ * the new request that lifts it.
2925
4028
  */
2926
4029
  _applyIndexCancellations() {
2927
4030
  if (!this.cancelledIndexKeys.size) return;
4031
+ var surfaced = {};
4032
+ this.state.messages.forEach(function(m) {
4033
+ if (!m._serverItemId) return;
4034
+ if (m.isPending || m.isPendingQueued || m.isPendingInProcess) surfaced[m._serverItemId] = true;
4035
+ });
2928
4036
  for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
2929
4037
  var entry = this.bgTaskQueue[i];
2930
4038
  var key = this._indexKeyOf(entry);
2931
4039
  if (!key || !this.cancelledIndexKeys.has(key)) continue;
2932
- if (!entry.resumePass) {
4040
+ if (!entry.resumePass && !this.state.stoppedIndexIds[entry.id]) {
2933
4041
  this.cancelledIndexKeys.delete(key);
2934
4042
  continue;
2935
4043
  }
4044
+ if (surfaced[entry.id]) continue;
2936
4045
  this.bgTaskQueue.splice(i, 1);
2937
4046
  this._stopPoll(entry.id);
2938
4047
  this._cancelServerItem(entry.id);
@@ -2985,10 +4094,10 @@ var ChatSession = class {
2985
4094
  if (this._adoptingWorkerPasses) return;
2986
4095
  var id = this.host.getIdentity();
2987
4096
  var platform = id.platform;
2988
- if (!id.serviceId || platform !== "claude" && platform !== "openai") return;
4097
+ if (!id.projectId || platform !== "claude" && platform !== "openai") return;
2989
4098
  if (this.isPollingPaused() || !this.host.isViewMounted()) return;
2990
- var svcId = id.serviceId, owner = id.owner;
2991
- var queue = (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX;
4099
+ var svcId = id.projectId, owner = id.owner;
4100
+ var queue = bgIndexingQueueName(id.userId, id.projectId);
2992
4101
  var ask = function(status) {
2993
4102
  return Promise.resolve(getChatHistory(
2994
4103
  { service: svcId, owner, platform, queue, status },
@@ -3001,8 +4110,9 @@ var ChatSession = class {
3001
4110
  Promise.all([ask("running"), ask("pending")]).then(function(results) {
3002
4111
  self._adoptingWorkerPasses = false;
3003
4112
  var now = self.host.getIdentity();
3004
- if (now.serviceId !== svcId || now.platform !== platform) return;
4113
+ if (now.projectId !== svcId || now.platform !== platform) return;
3005
4114
  if (!self.host.isViewMounted()) return;
4115
+ if (results[0] !== null && results[1] !== null) self._recordLiveIndexKeys(results);
3006
4116
  var adoptedIds = [];
3007
4117
  for (var ri = 0; ri < results.length; ri++) {
3008
4118
  var list = results[ri] && Array.isArray(results[ri].list) ? results[ri].list : [];
@@ -3014,10 +4124,13 @@ var ChatSession = class {
3014
4124
  self.drainBgTaskQueue();
3015
4125
  if (self._isTrackingAny(adoptedIds)) return;
3016
4126
  }
3017
- if (attempt + 1 >= WORKER_PASS_ADOPT_ATTEMPTS.length) return;
4127
+ if (attempt + 1 >= WORKER_PASS_ADOPT_ATTEMPTS.length) {
4128
+ self._nudgeIndexingDrain();
4129
+ return;
4130
+ }
3018
4131
  setTimeout(function() {
3019
4132
  var later = self.host.getIdentity();
3020
- if (later.serviceId !== svcId || later.platform !== platform) return;
4133
+ if (later.projectId !== svcId || later.platform !== platform) return;
3021
4134
  if (self.isPollingPaused() || !self.host.isViewMounted()) return;
3022
4135
  self._adoptWorkerIndexingPasses(attempt + 1);
3023
4136
  }, WORKER_PASS_ADOPT_ATTEMPTS[attempt + 1]);
@@ -3059,7 +4172,7 @@ var ChatSession = class {
3059
4172
  if (!ref || !ref.name) return false;
3060
4173
  if (!this._isWorkerDrivenIndexing(ref.name, ref.mime)) return false;
3061
4174
  this.bgTaskQueue.push({
3062
- serviceId: svcId,
4175
+ projectId: svcId,
3063
4176
  platform,
3064
4177
  id: item.id,
3065
4178
  filename: ref.name,
@@ -3090,8 +4203,8 @@ var ChatSession = class {
3090
4203
  url,
3091
4204
  method: "POST",
3092
4205
  id: serverId,
3093
- queue: (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX,
3094
- service: id.serviceId,
4206
+ queue: bgIndexingQueueName(id.userId, id.projectId),
4207
+ service: id.projectId,
3095
4208
  owner: id.owner
3096
4209
  })).catch(function() {
3097
4210
  });
@@ -3100,7 +4213,7 @@ var ChatSession = class {
3100
4213
  drainBgTaskQueue() {
3101
4214
  var self = this;
3102
4215
  var id = this.host.getIdentity();
3103
- var svcId = id.serviceId, plat = id.platform;
4216
+ var svcId = id.projectId, plat = id.platform;
3104
4217
  if (!svcId || plat === "none" || !this.host.isViewMounted()) return;
3105
4218
  this._applyIndexCancellations();
3106
4219
  this._sweepCancelledIndexing();
@@ -3114,11 +4227,13 @@ var ChatSession = class {
3114
4227
  });
3115
4228
  for (var i = this.bgTaskQueue.length - 1; i >= 0; i--) {
3116
4229
  var e = this.bgTaskQueue[i];
3117
- if (e.serviceId !== svcId || e.platform !== plat) continue;
4230
+ if (e.projectId !== svcId || e.platform !== plat) continue;
3118
4231
  if (presentIds[e.id] && !pendingIds[e.id]) this.bgTaskQueue.splice(i, 1);
3119
4232
  }
4233
+ var bgPollBudget = MAX_CONCURRENT_BG_POLLS - this._countBgPolls();
4234
+ var injectedAny = false;
3120
4235
  this.bgTaskQueue.forEach(function(entry) {
3121
- if (entry.serviceId !== svcId || entry.platform !== plat) return;
4236
+ if (entry.projectId !== svcId || entry.platform !== plat) return;
3122
4237
  if (!presentIds[entry.id]) {
3123
4238
  var isRunning = entry.status === "running";
3124
4239
  var userBubble = {
@@ -3139,16 +4254,21 @@ var ChatSession = class {
3139
4254
  };
3140
4255
  if (isRunning) userBubble.isPendingInProcess = true;
3141
4256
  else userBubble.isPendingQueued = true;
3142
- self.state.messages.push(userBubble);
3143
- if (isRunning) {
3144
- self.state.messages.push({ role: "assistant", content: "", isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id });
4257
+ var stageAt = self._stageIndex(self.state.messages, entry.stageId);
4258
+ var runningBubble = isRunning ? { role: "assistant", content: "", isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id } : null;
4259
+ if (stageAt === -1) {
4260
+ self.state.messages.push(userBubble);
4261
+ if (runningBubble) self.state.messages.push(runningBubble);
4262
+ } else if (runningBubble) {
4263
+ self.state.messages.splice(stageAt, 0, userBubble, runningBubble);
4264
+ } else {
4265
+ self.state.messages.splice(stageAt, 0, userBubble);
3145
4266
  }
3146
4267
  presentIds[entry.id] = true;
3147
- self.host.notify();
3148
- self.updateHistoryCache();
3149
- self.host.scrollToBottomIfSticky(false);
4268
+ injectedAny = true;
3150
4269
  }
3151
- if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === "function") {
4270
+ if (bgPollBudget > 0 && !self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === "function") {
4271
+ bgPollBudget--;
3152
4272
  var capturedId = entry.id, capturedPlat = plat;
3153
4273
  var capturedEntry = entry;
3154
4274
  var wasStopped = false;
@@ -3181,15 +4301,24 @@ var ChatSession = class {
3181
4301
  }
3182
4302
  self.host.notify();
3183
4303
  self.updateHistoryCache();
4304
+ if (!self._isWorkerDrivenIndexing(capturedEntry.filename, capturedEntry.mime)) {
4305
+ self._nudgeIndexingDrain();
4306
+ }
3184
4307
  }).then(function() {
3185
4308
  if (wasStopped) return;
3186
4309
  var qi = self.bgTaskQueue.findIndex(function(q) {
3187
4310
  return q.id === capturedId;
3188
4311
  });
3189
4312
  if (qi !== -1) self.bgTaskQueue.splice(qi, 1);
4313
+ self.drainBgTaskQueue();
3190
4314
  });
3191
4315
  }
3192
4316
  });
4317
+ if (injectedAny) {
4318
+ this.host.notify();
4319
+ this.updateHistoryCache();
4320
+ this.host.scrollToBottomIfSticky(false);
4321
+ }
3193
4322
  this.promoteNextBgQueuedToRunning();
3194
4323
  }
3195
4324
  // Resume-across-passes: if a background INDEXING task for a paged file (spreadsheet or
@@ -3206,25 +4335,45 @@ var ChatSession = class {
3206
4335
  // as well would now double-index every window.
3207
4336
  maybeResumeIndexing(entry, response, platform) {
3208
4337
  var self = this;
4338
+ var endOfClientChain = function() {
4339
+ self._nudgeIndexingDrain();
4340
+ };
3209
4341
  try {
3210
4342
  if (!entry || !entry.storagePath) return;
3211
4343
  if (this.cancelledIndexKeys.has(this._indexKeyOf(entry))) return;
3212
- if (!isPagedReadFile(entry.filename, entry.mime)) return;
4344
+ if (!isPagedReadFile(entry.filename, entry.mime)) {
4345
+ endOfClientChain();
4346
+ return;
4347
+ }
3213
4348
  if (isImageVisionFile(entry.filename, entry.mime)) return;
3214
4349
  if (windowedIndexingEnabled() && isWindowedReadFile(entry.filename, entry.mime)) return;
3215
- if (isErrorResponseBody(response)) return;
4350
+ if (isErrorResponseBody(response)) {
4351
+ endOfClientChain();
4352
+ return;
4353
+ }
3216
4354
  var answer = (platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "";
3217
- if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return;
4355
+ if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) {
4356
+ endOfClientChain();
4357
+ return;
4358
+ }
3218
4359
  var pass = (entry.resumePass || 0) + 1;
3219
- if (pass > MAX_INDEXING_RESUME_PASSES) return;
4360
+ if (pass > MAX_INDEXING_RESUME_PASSES) {
4361
+ endOfClientChain();
4362
+ return;
4363
+ }
3220
4364
  var id = this.host.getIdentity();
3221
- if (!id || id.platform === "none" || id.serviceId !== entry.serviceId) return;
3222
- notifyAgentContinueIndexing({
4365
+ if (!id || id.platform === "none" || id.projectId !== entry.projectId) return;
4366
+ this.trackIndexDispatch(notifyAgentContinueIndexing({
3223
4367
  platform: id.platform,
3224
4368
  model: id.model,
3225
- service: id.serviceId,
4369
+ service: id.projectId,
4370
+ // Without this the resume pass rebuilds its system prompt from the RAW
4371
+ // regional id (requests.ts falls back to `service`), and the model copies
4372
+ // that id verbatim into project_id tool calls, which the MCP schema
4373
+ // pattern rejects - the whole continue pass saves nothing.
4374
+ publicProjectId: id.publicProjectId,
3226
4375
  owner: id.owner,
3227
- userId: id.userId || id.serviceId,
4376
+ userId: id.userId || id.projectId,
3228
4377
  serviceName: id.serviceName,
3229
4378
  serviceDescription: id.serviceDescription,
3230
4379
  attachment: {
@@ -3237,7 +4386,7 @@ var ChatSession = class {
3237
4386
  }).then(function(ack) {
3238
4387
  if (ack && typeof ack.id === "string") {
3239
4388
  self.bgTaskQueue.push({
3240
- serviceId: id.serviceId,
4389
+ projectId: id.projectId,
3241
4390
  platform: id.platform,
3242
4391
  id: ack.id,
3243
4392
  filename: entry.filename,
@@ -3248,12 +4397,19 @@ var ChatSession = class {
3248
4397
  status: ack.status === "running" ? "running" : "pending",
3249
4398
  poll: ack.poll,
3250
4399
  resumePass: pass
4400
+ // Deliberately NOT stamped with entry.stageId. Only a batch's FIRST
4401
+ // pass anchors to the turn; a continuation appends, which is the
4402
+ // order the server queued it in and therefore the order
4403
+ // promoteNextBgQueuedToRunning should spin it in. It costs nothing
4404
+ // on screen: a continuation is folded into the run whose row
4405
+ // already sits above the turn, and renders nothing at its own
4406
+ // index (indexing_groups anchors a run at its FIRST loaded pass).
3251
4407
  });
3252
4408
  self.drainBgTaskQueue();
3253
4409
  }
3254
4410
  }, function(e) {
3255
4411
  console.error("[chat-engine] resume-indexing dispatch failed", e);
3256
- });
4412
+ }));
3257
4413
  } catch (e) {
3258
4414
  }
3259
4415
  }
@@ -3266,21 +4422,25 @@ var ChatSession = class {
3266
4422
  loadHistory(fetchMore, token) {
3267
4423
  var self = this;
3268
4424
  var id = this.host.getIdentity();
3269
- var loadKey = !id.serviceId || id.platform === "none" ? "" : id.serviceId + "#" + id.platform;
4425
+ var loadKey = !id.projectId || id.platform === "none" ? "" : id.projectId + "#" + id.platform;
3270
4426
  if (token === void 0) token = this.state.gateRefreshToken;
3271
- if (this.state.loadingHistory && this.state.historyRequestToken === token || id.platform === "none" || !id.serviceId) {
4427
+ if (this.state.loadingHistory && this.state.historyRequestToken === token || id.platform === "none" || !id.projectId) {
3272
4428
  return Promise.resolve();
3273
4429
  }
3274
4430
  this.state.historyRequestToken = token;
3275
4431
  this.state.loadingHistory = true;
4432
+ if (!fetchMore && loadKey !== this._liveIndexKey) {
4433
+ this._liveIndexKey = loadKey;
4434
+ this._resetLiveIndexKeys();
4435
+ }
3276
4436
  if (fetchMore) this.state.loadingOlderHistory = true;
3277
4437
  this.host.notify();
3278
4438
  var platform = id.platform;
3279
- var serviceId = id.serviceId, owner = id.owner;
4439
+ var projectId = id.projectId, owner = id.owner;
3280
4440
  var options = { fetchMore };
3281
4441
  if (fetchMore && this.state.historyStartKeyHistory.length) options.startKeyHistory = this.state.historyStartKeyHistory.slice();
3282
4442
  var fetchHistory = function() {
3283
- return getChatHistory({ service: serviceId, owner, platform }, options);
4443
+ return getChatHistory({ service: projectId, owner, platform }, options);
3284
4444
  };
3285
4445
  return Promise.resolve().then(fetchHistory).catch(function(err) {
3286
4446
  if (isAuthExpiredError(err) && !isNonRetryableRequestError(err)) return self.host.refreshSession().then(fetchHistory);
@@ -3300,7 +4460,7 @@ var ChatSession = class {
3300
4460
  });
3301
4461
  var mapped = mapHistoryListToMessages(list, platform, {
3302
4462
  clearedAt: self.host.getClearedAt(),
3303
- serviceId: id.serviceId,
4463
+ projectId: id.projectId,
3304
4464
  formatIndexingLabel: self.host.formatIndexingLabel
3305
4465
  }).messages;
3306
4466
  var keptOlderPages = false;
@@ -3331,6 +4491,10 @@ var ChatSession = class {
3331
4491
  if (mm._ownerKey !== void 0 && mm._ownerKey !== loadKey) continue;
3332
4492
  if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
3333
4493
  if (!mm._serverItemId) {
4494
+ if (mm._stageId) {
4495
+ rescued.push(mm);
4496
+ continue;
4497
+ }
3334
4498
  if (mappedHasPendingAssistant) continue;
3335
4499
  if (mm.isSendingToServer || mm.isPendingQueued || mm.isPendingInProcess || mm.isPending) rescued.push(mm);
3336
4500
  else if (self.state.sending && mm.role === "user") {
@@ -3402,12 +4566,26 @@ var ChatSession = class {
3402
4566
  self.updateHistoryCache();
3403
4567
  self.host.notify();
3404
4568
  if (!fetchMore) {
4569
+ var bgAllow = {};
4570
+ var bgHistBudget = MAX_CONCURRENT_BG_POLLS - self._countBgPolls();
4571
+ if (bgHistBudget > 0) {
4572
+ var bgIds = chatList.filter(function(it) {
4573
+ if (it.status !== "running" && it.status !== "pending") return false;
4574
+ if (!it.poll || !it.id) return false;
4575
+ if (!(it._isBgTask || it._isOnBgQueue)) return false;
4576
+ return !self.historyItemPolls.has(it.id);
4577
+ }).map(function(it) {
4578
+ return it.id;
4579
+ }).sort();
4580
+ for (var ba = 0; ba < bgIds.length && ba < bgHistBudget; ba++) bgAllow[bgIds[ba]] = true;
4581
+ }
3405
4582
  chatList.forEach(function(item) {
3406
4583
  if (item.status !== "running" && item.status !== "pending") return;
3407
4584
  if (!item.poll || !item.id) return;
3408
4585
  if (self.historyItemPolls.has(item.id)) return;
3409
4586
  if (self.pendingAgentRequests[self.getHistoryCacheKey()] && !item._isBgTask && !item._isOnBgQueue) return;
3410
4587
  if ((item._isBgTask || item._isOnBgQueue) && self.isPollingPaused()) return;
4588
+ if ((item._isBgTask || item._isOnBgQueue) && !bgAllow[item.id]) return;
3411
4589
  var capturedId = item.id;
3412
4590
  var pp = item.poll({
3413
4591
  latency: POLL_INTERVAL,
@@ -3461,6 +4639,7 @@ var ChatSession = class {
3461
4639
  });
3462
4640
  self.drainBgTaskQueue();
3463
4641
  }
4642
+ if (!fetchMore) self.refreshLiveIndexState();
3464
4643
  if (!fetchMore) return self.host.scrollToBottomIfSticky();
3465
4644
  }).catch(function(err) {
3466
4645
  console.warn("[chat-engine] getChatHistory failed", err);
@@ -3478,7 +4657,7 @@ var ChatSession = class {
3478
4657
  // Upload one attachment (a file = 1 member, a folder = N) to db storage and
3479
4658
  // queue indexing per member. The bytes I/O + chip rendering go through host
3480
4659
  // hooks; the overwrite/reindex flow, status lifecycle, and indexing live here.
3481
- uploadSingleAttachment(att) {
4660
+ uploadSingleAttachment(att, stageId) {
3482
4661
  var self = this;
3483
4662
  var id = this.host.getIdentity();
3484
4663
  att.status = "uploading";
@@ -3525,6 +4704,7 @@ var ChatSession = class {
3525
4704
  return self.host.promptOverwrite(member.file.name).then(function(choice) {
3526
4705
  if (choice === "overwrite") {
3527
4706
  existedBefore = true;
4707
+ markImagePreviewStale(self.host.getIdentity().projectId || "default", member.storagePath);
3528
4708
  return doMemberUpload(false);
3529
4709
  }
3530
4710
  if (choice === "skip") {
@@ -3545,17 +4725,39 @@ var ChatSession = class {
3545
4725
  att.storagePath = member.storagePath;
3546
4726
  }
3547
4727
  var mime = member.file.type || self.host.getMimeType(member.file.name);
3548
- var preIndex = existedBefore && typeof self.host.deleteExistingFileRecord === "function" ? Promise.resolve(self.host.deleteExistingFileRecord(member.storagePath)).catch(function() {
3549
- }) : Promise.resolve();
4728
+ var alreadyIndexing = false;
4729
+ var preIndex = self.claimIndexRun(member.storagePath).then(function(claimed) {
4730
+ alreadyIndexing = !claimed;
4731
+ if (alreadyIndexing) {
4732
+ console.log("[chat-engine] skipping a duplicate index request for", member.storagePath);
4733
+ return;
4734
+ }
4735
+ if (existedBefore && typeof self.host.deleteExistingFileRecord === "function") {
4736
+ return Promise.resolve(self.host.deleteExistingFileRecord(member.storagePath)).catch(function() {
4737
+ });
4738
+ }
4739
+ });
4740
+ preIndex = preIndex.then(function() {
4741
+ if (alreadyIndexing) return;
4742
+ if (typeof self.host.ensureFileIndexRecord !== "function") return;
4743
+ return Promise.resolve(self.host.ensureFileIndexRecord(member.storagePath, {
4744
+ name: member.file.name,
4745
+ mime: mime || void 0,
4746
+ size: member.file.size
4747
+ })).catch(function() {
4748
+ });
4749
+ });
3550
4750
  return preIndex.then(function() {
3551
4751
  return parseAttachmentContent(member.file, member.file.name, mime || void 0);
3552
4752
  }).then(function(parsedContent) {
3553
- return notifyAgentSaveAttachment({
4753
+ if (alreadyIndexing) return;
4754
+ return self.trackIndexDispatch(notifyAgentSaveAttachment({
3554
4755
  platform: id.platform,
3555
4756
  model: id.model,
3556
- service: id.serviceId,
4757
+ service: id.projectId,
4758
+ publicProjectId: id.publicProjectId,
3557
4759
  owner: id.owner,
3558
- userId: id.userId || id.serviceId,
4760
+ userId: id.userId || id.projectId,
3559
4761
  serviceName: id.serviceName,
3560
4762
  serviceDescription: id.serviceDescription,
3561
4763
  attachment: {
@@ -3569,7 +4771,7 @@ var ChatSession = class {
3569
4771
  }).then(function(ack) {
3570
4772
  if (ack && typeof ack.id === "string") {
3571
4773
  self.bgTaskQueue.push({
3572
- serviceId: id.serviceId,
4774
+ projectId: id.projectId,
3573
4775
  platform: id.platform,
3574
4776
  id: ack.id,
3575
4777
  filename: member.file.name,
@@ -3578,18 +4780,23 @@ var ChatSession = class {
3578
4780
  mime: mime || void 0,
3579
4781
  size: member.file.size,
3580
4782
  status: ack.status === "running" ? "running" : "pending",
3581
- poll: ack.poll
4783
+ poll: ack.poll,
4784
+ // Puts this file's row directly above the chat turn it was
4785
+ // attached to (drainBgTaskQueue). Undefined for an
4786
+ // attachment-only send, which appends.
4787
+ stageId
3582
4788
  });
3583
4789
  self.drainBgTaskQueue();
3584
4790
  }
3585
4791
  }, function(e) {
3586
4792
  console.error("[chat-engine] indexing request failed", e);
4793
+ self.releaseIndexRun(member.storagePath);
3587
4794
  anyIndexFailed = true;
3588
4795
  if (!att.errorCode && !att.errorDetail) {
3589
4796
  att.errorCode = e && (e.code || e.body && e.body.code) || "";
3590
4797
  att.errorDetail = e && (e.message || e.body && e.body.message) || (typeof e === "string" ? e : "");
3591
4798
  }
3592
- });
4799
+ }));
3593
4800
  });
3594
4801
  });
3595
4802
  });
@@ -3608,14 +4815,28 @@ var ChatSession = class {
3608
4815
  }
3609
4816
  // Upload all not-yet-done attachments sequentially. Resolves to the full
3610
4817
  // list of { name, url, storagePath } for composing the chat message.
3611
- uploadPendingAttachments() {
4818
+ //
4819
+ // `batchId` scopes the run to the chips stamped with it at Send time. The
4820
+ // composer stays live during an upload, so by the time this runs the
4821
+ // attachment list can already hold chips the user picked for the NEXT
4822
+ // message — uploading those here would attach them to the wrong turn, and
4823
+ // collecting the previous batch's finished urls would attach files the user
4824
+ // already sent. Omitted (no batch) means every chip, the old behavior.
4825
+ //
4826
+ // `stageId` is the turn these chips were attached to, carried onto every indexing
4827
+ // task so its collapsed row renders directly ABOVE that turn's bubble (see
4828
+ // BgTaskEntry.stageId). Omitted for an attachment-only send, which has no turn.
4829
+ uploadPendingAttachments(batchId, stageId) {
3612
4830
  var self = this;
3613
4831
  this.host.resetOverwriteBatch();
4832
+ this._uploadBatches += 1;
3614
4833
  this.state.uploadingAttachments = true;
3615
4834
  this.host.updateComposerControls();
3616
4835
  this.host.renderAttachmentChips();
3617
4836
  var collected = [];
3618
- var snapshot = this.state.attachments.slice();
4837
+ var snapshot = this.state.attachments.filter(function(a) {
4838
+ return batchId ? a._batchId === batchId : true;
4839
+ });
3619
4840
  var chain = Promise.resolve();
3620
4841
  snapshot.forEach(function(att) {
3621
4842
  chain = chain.then(function() {
@@ -3634,7 +4855,7 @@ var ChatSession = class {
3634
4855
  return;
3635
4856
  }
3636
4857
  }
3637
- return self.uploadSingleAttachment(att).then(function(us) {
4858
+ return self.uploadSingleAttachment(att, stageId).then(function(us) {
3638
4859
  collected.push.apply(collected, us);
3639
4860
  }).catch(function(err) {
3640
4861
  var removed = !self.state.attachments.some(function(a) {
@@ -3651,7 +4872,8 @@ var ChatSession = class {
3651
4872
  });
3652
4873
  });
3653
4874
  var done = function() {
3654
- self.state.uploadingAttachments = false;
4875
+ self._uploadBatches = Math.max(0, self._uploadBatches - 1);
4876
+ self.state.uploadingAttachments = self._uploadBatches > 0;
3655
4877
  self.host.updateComposerControls();
3656
4878
  self.host.renderAttachmentChips();
3657
4879
  return collected;
@@ -3712,9 +4934,22 @@ function readFileRef(msg) {
3712
4934
  function isPendingMsg(m) {
3713
4935
  return !!(m.isPending || m.isPendingInProcess || m.isPendingQueued || m.isSendingToServer);
3714
4936
  }
4937
+ function isHiddenPass(m) {
4938
+ if (m.role === "user") {
4939
+ if (m.isCancelled) return false;
4940
+ var ref = readFileRef(m);
4941
+ return !!(ref && ref.continued);
4942
+ }
4943
+ return !!m.isPending;
4944
+ }
3715
4945
  function buildChatDisplayList(messages, opts) {
3716
4946
  var list = Array.isArray(messages) ? messages : [];
4947
+ var liveIndexKeys = opts && opts.liveIndexKeys || {};
4948
+ var liveIndexChecked = !!(opts && opts.liveIndexChecked);
4949
+ var stoppedIndexIds = opts && opts.stoppedIndexIds || {};
4950
+ var windowedIndexing = opts && opts.windowedIndexing !== void 0 ? !!opts.windowedIndexing : windowedIndexingEnabled();
3717
4951
  var hasMoreHistory = !!(opts && opts.hasMoreHistory);
4952
+ var loadingOlderHistory = !!(opts && opts.loadingOlderHistory);
3718
4953
  var groups = {};
3719
4954
  var order = [];
3720
4955
  var runOfIndex = new Array(list.length);
@@ -3762,11 +4997,17 @@ function buildChatDisplayList(messages, opts) {
3762
4997
  status: "done",
3763
4998
  cancellableIds: [],
3764
4999
  cancelling: false,
5000
+ stopped: false,
3765
5001
  mayHaveOlder: false,
3766
5002
  // The run's first loaded pass, and never re-stamped: see the file
3767
5003
  // docstring. `anchorId` is filled in once every member is known.
3768
5004
  anchorIndex: i,
3769
- anchorId: ""
5005
+ anchorId: "",
5006
+ // All five are derived once every member is known, below.
5007
+ visibleMembers: [],
5008
+ driver: "single",
5009
+ finished: false,
5010
+ resolving: false
3770
5011
  };
3771
5012
  order.push(runId);
3772
5013
  }
@@ -3784,6 +5025,11 @@ function buildChatDisplayList(messages, opts) {
3784
5025
  if (msg._serverItemId) runByItemId[msg._serverItemId] = runId;
3785
5026
  if (ref && ref.name) keyByName[ref.name] = g.key;
3786
5027
  }
5028
+ var newestRunOfKey = {};
5029
+ for (var nk in runsOfKey) {
5030
+ var nrs = runsOfKey[nk];
5031
+ if (nrs.length) newestRunOfKey[nrs[nrs.length - 1]] = true;
5032
+ }
3787
5033
  for (var rk in runsOfKey) {
3788
5034
  var runIds = runsOfKey[rk];
3789
5035
  for (var ri = 0; ri < runIds.length; ri++) {
@@ -3807,6 +5053,19 @@ function buildChatDisplayList(messages, opts) {
3807
5053
  break;
3808
5054
  }
3809
5055
  }
5056
+ var stopped = false;
5057
+ for (var ki = 0; ki < grp.members.length; ki++) {
5058
+ var km = grp.members[ki].msg;
5059
+ if (km.isCancelled) {
5060
+ stopped = true;
5061
+ break;
5062
+ }
5063
+ if (km._serverItemId && stoppedIndexIds[km._serverItemId]) {
5064
+ stopped = true;
5065
+ break;
5066
+ }
5067
+ }
5068
+ grp.stopped = stopped;
3810
5069
  for (var xi = 0; xi < grp.members.length; xi++) {
3811
5070
  if (grp.members[xi].msg._cancelling) {
3812
5071
  grp.cancelling = true;
@@ -3816,7 +5075,7 @@ function buildChatDisplayList(messages, opts) {
3816
5075
  var seenIds = {};
3817
5076
  for (var ci = 0; ci < grp.members.length; ci++) {
3818
5077
  var cm = grp.members[ci].msg;
3819
- if (cm._cancelError && (active || grp.cancelling)) grp.cancelError = cm._cancelError;
5078
+ if (cm._cancelError && !stopped && (active || grp.cancelling)) grp.cancelError = cm._cancelError;
3820
5079
  if (cm.role !== "user" || !cm._serverItemId || cm._cancelling || cm.isSendingToServer) continue;
3821
5080
  if (!(cm.isPendingQueued || cm.isPendingInProcess)) continue;
3822
5081
  if (ci < lastSettled) continue;
@@ -3826,9 +5085,12 @@ function buildChatDisplayList(messages, opts) {
3826
5085
  }
3827
5086
  if (active) {
3828
5087
  grp.status = "active";
5088
+ if (stopped) grp.cancelling = true;
5089
+ } else if (stopped) {
5090
+ grp.status = "cancelled";
3829
5091
  } else {
3830
5092
  var last = grp.members[grp.members.length - 1].msg;
3831
- grp.status = last.isError ? "error" : last.isCancelled ? "cancelled" : "done";
5093
+ grp.status = last.isError ? "error" : "done";
3832
5094
  }
3833
5095
  var sawFirstPass = false;
3834
5096
  for (var pi = 0; pi < grp.members.length; pi++) {
@@ -3844,6 +5106,35 @@ function buildChatDisplayList(messages, opts) {
3844
5106
  var anchor = grp.members[0];
3845
5107
  grp.anchorIndex = anchor.index;
3846
5108
  grp.anchorId = anchor.msg._serverItemId || anchor.msg._localId || "";
5109
+ var sawComplete = false;
5110
+ for (var vi = 0; vi < grp.members.length; vi++) {
5111
+ var vm = grp.members[vi];
5112
+ if (vm.msg._indexComplete) sawComplete = true;
5113
+ if (!isHiddenPass(vm.msg)) grp.visibleMembers.push(vm);
5114
+ }
5115
+ grp.driver = !isPagedReadFile(grp.name, grp.mime) ? "single" : isImageVisionFile(grp.name, grp.mime) ? "worker" : windowedIndexing ? "worker" : "client";
5116
+ if (grp.status === "active") {
5117
+ grp.finished = false;
5118
+ } else if (grp.status === "cancelled") {
5119
+ grp.finished = true;
5120
+ } else if (grp.driver === "single") {
5121
+ grp.finished = true;
5122
+ } else if (grp.driver === "client") {
5123
+ grp.finished = sawComplete || grp.status === "error" || grp.passCount >= MAX_INDEXING_RESUME_PASSES;
5124
+ } else {
5125
+ grp.finished = !newestRunOfKey[order[oi]] || liveIndexChecked && !liveIndexKeys[grp.key];
5126
+ }
5127
+ if (grp.status !== "done") {
5128
+ grp.resolving = false;
5129
+ } else if (grp.mayHaveOlder && loadingOlderHistory && !liveIndexKeys[grp.key] && newestRunOfKey[order[oi]]) {
5130
+ grp.resolving = true;
5131
+ grp.resolvingReason = "history";
5132
+ } else if (!grp.finished && grp.driver === "worker" && !liveIndexChecked && !liveIndexKeys[grp.key]) {
5133
+ grp.resolving = true;
5134
+ grp.resolvingReason = "status";
5135
+ } else {
5136
+ grp.resolving = false;
5137
+ }
3847
5138
  }
3848
5139
  var out = [];
3849
5140
  for (var j = 0; j < list.length; j++) {
@@ -3857,6 +5148,6 @@ function buildChatDisplayList(messages, opts) {
3857
5148
  return out;
3858
5149
  }
3859
5150
 
3860
- export { BG_INDEXING_QUEUE_SUFFIX, BOM, BOM_EXTS, CLAUDE_INPUT_CAP_RATIO, CLAUDE_PER_REQUEST_INPUT_CAP, CONTEXT_WINDOW_BY_MODEL, CONTEXT_WINDOW_DEFAULT, ChatSession, DEFAULT_CLAUDE_MODEL, DEFAULT_OPENAI_MODEL, EXPIRED_ATTACHMENT_URL_HOST, EXPIRED_ATTACHMENT_URL_ORIGIN, EXPIRED_LINK_REFRESH_EXPIRES_SECONDS, EXT_CONTENT_TYPES, HISTORY_BUDGET_RATIO, HISTORY_FILL_SLACK_PX, HISTORY_TOKEN_BUDGET, HTML_EXTS, HTML_HEAD_WINDOW, LINK_LABEL_MAX_DISPLAY_CHARS, LINK_REFRESH_WINDOW_MS, MAX_HISTORY_FILL_PAGES, MAX_HISTORY_MESSAGES, MAX_PARSED_CONTENT_CHARS, MCP_NAME, MIN_INPUT_TOKEN_BUDGET, OUTPUT_TOKEN_RESERVE, POLL_INTERVAL, RENDER_FROM_TOKEN, RTF_EXTS, TOOL_AND_RESPONSE_BUFFER, XML_EXTS, applyEncodingDeclaration, buildAiAgentValue, buildBoundedChatMessages, buildChatDisplayList, buildChatSystemPrompt, buildDisplayExpiredAttachmentHref, buildIndexingContinueMessage, buildIndexingRenderContinueTemplate, buildIndexingRenderMessage, buildIndexingSystemPrompt, buildIndexingUserMessage, buildIndexingWindowMessage, callClaudeWithMcp, callClaudeWithPublicMcp, callOpenAIWithPublicMcp, chatEngineConfig, classifyInlineLink, clearAttachmentParsers, composeUserMessage, configureChatEngine, contentTypeForExt, createHistoryFiller, createInlineLinkRegex, encodePathSegments, encodingClassForExt, ensureHtmlCharset, ensureXmlEncoding, escapeRtfNonAscii, estimateMessageTokens, estimateTextTokens, extOf, extractClaudeText, extractLastUserTextFromRequest, extractOpenAIText, extractRemotePathFromAttachmentHref, fillHistoryViewport, filterListByClearHorizon, findAttachmentParser, formatChatTimestamp, getAttachmentParsers, getChatHistory, getContextWindow, getErrorMessage, getExpiredAttachmentVisiblePath, getProjectContextWindow, groupAttachmentFailures, hasBom, isAuthExpiredError, isBgIndexingQueue, isErrorResponseBody, isHttpUrlLike, isIndexingRequestText, isNonRetryableRequestError, isOfficeFile, isServerExtractable, isServiceDbAttachmentHref, listClaudeModels, listOpenAIModels, looksLikeRtf, makeExtractPlaceholder, mapHistoryListToMessages, needsBomForExt, normalizeAttachmentPathCandidate, normalizeExt, normalizeTextContent, normalizeTrailingInlineToken, notifyAgentSaveAttachment, parseAiAgentValue, parseAttachmentContent, parseIndexingLabel, parseIndexingRequestText, prepareDownloadText, readExpiredAttachmentHref, registerAttachmentParser, registerModelContextWindows, repairUrlEntities, repairUrlWhitespace, safeDecodeURIComponent, sanitizeAttachmentLinksForHistory, setProjectContextWindow, stripFileBlocksFromHistory, transformContentWithImages, transformContentWithOpenAIImages, truncateLabelForDisplay, wallClockNow };
5151
+ export { BG_INDEXING_QUEUE_SUFFIX, BOM, BOM_EXTS, CLAUDE_INPUT_CAP_RATIO, CLAUDE_PER_REQUEST_INPUT_CAP, CONTEXT_WINDOW_BY_MODEL, CONTEXT_WINDOW_DEFAULT, ChatSession, DEFAULT_CLAUDE_MODEL, DEFAULT_OPENAI_MODEL, EMPTY_INDEXING_REPLY, EXPIRED_ATTACHMENT_URL_HOST, EXPIRED_ATTACHMENT_URL_ORIGIN, EXPIRED_LINK_REFRESH_EXPIRES_SECONDS, EXT_CONTENT_TYPES, HISTORY_BUDGET_RATIO, HISTORY_FILL_SLACK_PX, HISTORY_TOKEN_BUDGET, HTML_EXTS, HTML_HEAD_WINDOW, IMAGE_PREVIEWS_PER_MESSAGE, INDEXING_COMPLETE_MARKER, INLINE_LINK_GLYPH, INLINE_LINK_UNAVAILABLE_GLYPH, INLINE_LINK_UNAVAILABLE_SUFFIX, LINK_LABEL_MAX_DISPLAY_CHARS, LINK_REFRESH_WINDOW_MS, MAX_CONCURRENT_BG_POLLS, MAX_HISTORY_FILL_PAGES, MAX_HISTORY_MESSAGES, MAX_PARSED_CONTENT_CHARS, MCP_NAME, MIN_INPUT_TOKEN_BUDGET, OUTPUT_TOKEN_RESERVE, POLL_INTERVAL, PREVIEWABLE_IMAGE_CONTENT_TYPES, PREVIEW_BROWSER_CACHE_SECONDS, RENDER_FROM_TOKEN, RTF_EXTS, TOOL_AND_RESPONSE_BUFFER, XML_EXTS, applyEncodingDeclaration, bgIndexingQueueName, buildAiAgentValue, buildBoundedChatMessages, buildChatDisplayList, buildChatSystemPrompt, buildDisplayExpiredAttachmentHref, buildIndexingContinueMessage, buildIndexingRenderContinueTemplate, buildIndexingRenderMessage, buildIndexingSystemPrompt, buildIndexingUserMessage, buildIndexingWindowMessage, callClaudeWithMcp, callClaudeWithPublicMcp, callOpenAIWithPublicMcp, chatEngineConfig, classifyInlineLink, clearAttachmentParsers, clearImagePreviewCache, composeUserMessage, configureChatEngine, contentTypeForExt, createHistoryFiller, createInlineLinkRegex, encodePathSegments, encodingClassForExt, ensureHtmlCharset, ensureXmlEncoding, escapeInlineHtml, escapeRtfNonAscii, estimateMessageTokens, estimateTextTokens, extOf, extractClaudeText, extractLastUserTextFromRequest, extractOpenAIText, extractRemotePathFromAttachmentHref, fillHistoryViewport, filterListByClearHorizon, findAttachmentParser, formatChatTimestamp, getAttachmentParsers, getChatHistory, getContextWindow, getErrorMessage, getExpiredAttachmentVisiblePath, getProjectContextWindow, getVisionProfile, groupAttachmentFailures, hasBom, hydrateImagePreviews, isAuthExpiredError, isBgIndexingQueue, isErrorResponseBody, isHttpUrlLike, isIndexingRequestText, isLinkUnavailable, isNonRetryableRequestError, isOfficeFile, isPreviewableImagePath, isServerExtractable, isServiceDbAttachmentHref, linkUnavailableKeyForHref, linkUnavailableKeyForPath, listClaudeModels, listOpenAIModels, looksLikeRtf, makeExtractPlaceholder, mapHistoryListToMessages, markImagePreviewStale, needsBomForExt, normalizeAttachmentPathCandidate, normalizeExt, normalizeTextContent, normalizeTrailingInlineToken, notifyAgentSaveAttachment, parseAiAgentValue, parseAttachmentContent, parseIndexingLabel, parseIndexingRequestText, peekImagePreviewUrl, prepareDownloadText, previewImageContentType, previewableExtOf, readExpiredAttachmentHref, registerAttachmentParser, registerModelContextWindows, renderInlineLinkHtml, repairUrlEntities, repairUrlWhitespace, resolveImagePreviewUrl, safeDecodeURIComponent, sanitizeAttachmentLinksForHistory, setProjectContextWindow, stripFileBlocksFromHistory, transformContentWithImages, transformContentWithOpenAIImages, truncateLabelForDisplay, wallClockNow };
3861
5152
  //# sourceMappingURL=engine.mjs.map
3862
5153
  //# sourceMappingURL=engine.mjs.map