bunnyquery 1.5.7 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/engine.cjs CHANGED
@@ -61,6 +61,9 @@ function chatEngineConfig() {
61
61
  }
62
62
  return _config;
63
63
  }
64
+ function windowedIndexingEnabled() {
65
+ return _config?.windowedIndexing === true;
66
+ }
64
67
  function pollOpt() {
65
68
  const p = _config?.poll;
66
69
  return p === void 0 ? {} : { poll: p };
@@ -155,19 +158,65 @@ function isServerExtractable(name, mime) {
155
158
  return m.includes("officedocument") || m.includes("opendocument") || m.includes("hwp") || m.includes("epub") || m === "application/msword" || m === "application/vnd.ms-excel" || m === "application/vnd.ms-powerpoint";
156
159
  }
157
160
  var isOfficeFile = isServerExtractable;
158
- var PAGED_READ_EXTENSIONS = /* @__PURE__ */ new Set(["xls", "xlsx", "xlsm", "ods", "pdf"]);
161
+ var PAGED_READ_EXTENSIONS = /* @__PURE__ */ new Set([
162
+ // grids
163
+ "xls",
164
+ "xlsx",
165
+ "xlsm",
166
+ "ods",
167
+ // delimited text (row-windowed by the layer)
168
+ "csv",
169
+ "tsv",
170
+ "tab",
171
+ // documents
172
+ "pdf",
173
+ "docx",
174
+ "pptx",
175
+ // plain text / data / markup
176
+ "txt",
177
+ "md",
178
+ "markdown",
179
+ "log",
180
+ "json",
181
+ "jsonl",
182
+ "ndjson",
183
+ "xml",
184
+ "yaml",
185
+ "yml"
186
+ ]);
159
187
  function isPagedReadFile(name, mime) {
160
188
  const ext = (name || "").split(".").pop()?.toLowerCase() || "";
161
189
  if (PAGED_READ_EXTENSIONS.has(ext)) return true;
162
190
  const m = (mime || "").toLowerCase();
163
191
  return m === "application/pdf" || m === "application/vnd.ms-excel" || m.includes("spreadsheetml") || m.includes("opendocument.spreadsheet");
164
192
  }
193
+ function isImageVisionFile(name, mime) {
194
+ const ext = (name || "").split(".").pop()?.toLowerCase() || "";
195
+ return ext === "pdf" || (mime || "").toLowerCase() === "application/pdf";
196
+ }
165
197
  var _extractPlaceholderSeq = 0;
166
198
  function makeExtractPlaceholder(seed) {
167
199
  _extractPlaceholderSeq += 1;
168
200
  const slug = (seed || "file").replace(/[^a-zA-Z0-9]+/g, "_").slice(-48);
169
201
  return `{{SKAPI_FILE_CONTENT::${slug}-${_extractPlaceholderSeq}}}`;
170
202
  }
203
+ var _renderPlaceholderSeq = 0;
204
+ function makeRenderPlaceholder(seed) {
205
+ _renderPlaceholderSeq += 1;
206
+ const slug = (seed || "file").replace(/[^a-zA-Z0-9]+/g, "_").slice(-48);
207
+ return `{{SKAPI_RENDER::${slug}-${_renderPlaceholderSeq}}}`;
208
+ }
209
+ var RENDER_PAGES_PER_WINDOW = 5;
210
+ var _windowPlaceholderSeq = 0;
211
+ function makeWindowPlaceholder(seed) {
212
+ _windowPlaceholderSeq += 1;
213
+ const slug = (seed || "file").replace(/[^a-zA-Z0-9]+/g, "_").slice(-48);
214
+ return `{{SKAPI_WINDOW::${slug}-${_windowPlaceholderSeq}}}`;
215
+ }
216
+ function isWindowedReadFile(name, mime) {
217
+ if (isImageVisionFile(name, mime)) return false;
218
+ return isPagedReadFile(name, mime);
219
+ }
171
220
  function composeUserMessage(text, attachmentUrls) {
172
221
  let composed = text;
173
222
  if (attachmentUrls.length > 0) {
@@ -265,12 +314,14 @@ function buildIndexingSystemPrompt(params) {
265
314
  let systemPrompt = `You are a background indexing agent for project ${service}.
266
315
  - 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.
267
316
  - 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.
268
- - BIG or SCANNED files: the inline content may be only the FIRST part of a large file (it can end with a truncation or "more remains" note), and scanned PDFs / files with embedded photos are not fully captured inline. In those cases READ THE FILE WITH THE readFileContent TOOL: it returns the file ONE WINDOW at a time (spreadsheets as coordinate-tagged grid rows, scanned/large PDFs as rendered PAGE IMAGES, 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.
269
- - VISION: when a readFileContent window (or an inline attachment) includes IMAGES - scanned 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).
317
+ - 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.)
318
+ - 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.
319
+ - 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).
270
320
  - 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.
271
321
  - 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.
272
322
  - 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.
273
- - 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 and note the last cursor/page you reached; a follow-up will continue from there. Never store raw or binary bytes (base64, blobs); describe them in human-readable text instead.
323
+ - 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.
324
+ - 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.
274
325
  - 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>.`;
275
326
  if (serviceDescription) {
276
327
  systemPrompt += `
@@ -313,6 +364,74 @@ Read this file with the readFileContent tool, using the storage path above - do
313
364
  }
314
365
  return head + `- temporary URL (fetch this to read the file contents): ${attachment.url}`;
315
366
  }
367
+ var RENDER_FROM_TOKEN = "{{RENDER_FROM}}";
368
+ var WINDOW_CURSOR_TOKEN = RENDER_FROM_TOKEN;
369
+ function buildIndexingRenderMessage(attachment, placeholder, renderFrom) {
370
+ const from = Math.max(0, renderFrom || 0);
371
+ if (from > 0) return buildIndexingRenderContinueTemplate(attachment, placeholder, String(from + 1));
372
+ return `A new file has just been uploaded. Index it now.
373
+
374
+ ` + buildRenderMeta(attachment) + `
375
+ This is a PDF. Its pages are delivered to you as RENDERED PAGE IMAGES embedded directly in this message (you do NOT need any tool, URL, or web_fetch to see them). You are shown a WINDOW of pages at a time, starting at page ${from + 1}.
376
+ ` + buildRenderDatafy(placeholder);
377
+ }
378
+ function buildIndexingRenderContinueTemplate(attachment, placeholder, pageLabel = RENDER_FROM_TOKEN) {
379
+ const src = `src::${attachment.storagePath}`;
380
+ return `CONTINUE indexing a PDF whose previous pass did not finish.
381
+
382
+ ` + buildRenderMeta(attachment) + `
383
+ Records for the earlier pages are ALREADY saved (they reference "${src}"). The NEXT window of rendered page images (starting at page ${pageLabel}) is embedded in this message. Datafy each page as before and do NOT re-save pages that are already saved.
384
+ ` + buildRenderDatafy(placeholder);
385
+ }
386
+ function buildRenderMeta(attachment) {
387
+ return `File metadata:
388
+ - name: ${attachment.name}
389
+ - storage path: ${attachment.storagePath}
390
+ ` + (attachment.mime ? `- mime type: ${attachment.mime}
391
+ ` : "");
392
+ }
393
+ function buildRenderDatafy(placeholder) {
394
+ return `
395
+ ${placeholder}
396
+
397
+ 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.
398
+
399
+ 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.`;
400
+ }
401
+ function buildIndexingWindowMessage(attachment, placeholder, isContinuation, positionLabel) {
402
+ const src = `src::${attachment.storagePath}`;
403
+ const head = isContinuation ? `CONTINUE indexing a file whose previous pass did not finish.
404
+
405
+ ` : `A new file has just been uploaded. Index it now.
406
+
407
+ `;
408
+ const where = isContinuation ? `
409
+ Records for the earlier windows are ALREADY saved (they reference "${src}"). The NEXT window (starting at ${positionLabel || WINDOW_CURSOR_TOKEN}) is embedded below. Do NOT re-save windows that are already saved.
410
+ ` : `
411
+ This file is delivered to you ONE WINDOW at a time, embedded directly in this message. You do NOT need any tool, URL, or web_fetch to read it.
412
+ `;
413
+ return head + buildRenderMeta(attachment) + where + `
414
+ ${placeholder}
415
+
416
+ 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.
417
+
418
+ 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.`;
419
+ }
420
+ function buildIndexingContinueMessage(attachment) {
421
+ const src = `src::${attachment.storagePath}`;
422
+ return `CONTINUE indexing a file whose previous pass did not finish.
423
+
424
+ File metadata:
425
+ - name: ${attachment.name}
426
+ - storage path: ${attachment.storagePath}
427
+ ` + (attachment.mime ? `- mime type: ${attachment.mime}
428
+ ` : "") + `
429
+ 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:
430
+ - 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.
431
+ - 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".
432
+ - Text: the cursor is the character offset already read.
433
+ 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.`;
434
+ }
316
435
 
317
436
  // src/engine/errors.ts
318
437
  function getErrorMessage(input) {
@@ -569,19 +688,22 @@ var mcpUrl = () => chatEngineConfig().mcpBaseUrl;
569
688
  var clientSecretRequest = (opts) => chatEngineConfig().clientSecretRequest(opts);
570
689
  var getOpenAIImageDetail = (model) => {
571
690
  const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
572
- const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?$/);
691
+ const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
573
692
  if (!match) {
574
693
  return DEFAULT_OPENAI_IMAGE_DETAIL;
575
694
  }
576
695
  const major = Number(match[1]);
577
696
  const minor = match[2] === void 0 ? null : Number(match[2]);
578
- if (major > 5) {
579
- return "original";
580
- }
581
- if (major === 5 && minor !== null && minor >= 4) {
582
- return "original";
697
+ const isVariant = !!match[3];
698
+ const supportsOriginal = major > 5 || major === 5 && minor !== null && minor >= 4;
699
+ if (!supportsOriginal) {
700
+ return DEFAULT_OPENAI_IMAGE_DETAIL;
583
701
  }
584
- return DEFAULT_OPENAI_IMAGE_DETAIL;
702
+ return isVariant ? "high" : "original";
703
+ };
704
+ var getRenderImageDetail = (model) => {
705
+ const detail = getOpenAIImageDetail(model);
706
+ return detail === DEFAULT_OPENAI_IMAGE_DETAIL ? "high" : detail;
585
707
  };
586
708
  var IMAGE_URL_REGEX = /\bhttps?:\/\/[^\s<>"'()\[\]]+?\.(?:jpg|jpeg|png|gif|webp)(?:\?[^\s<>"'()\[\]]*)?/gi;
587
709
  function transformContentWithImages(content) {
@@ -817,14 +939,53 @@ async function callOpenAIWithPublicMcp(prompt, service, owner, messages, system,
817
939
  }
818
940
  });
819
941
  }
942
+ async function notifyAgentContinueIndexing(info) {
943
+ return notifyAgentSaveAttachment({ ...info, continueIndexing: true });
944
+ }
820
945
  async function notifyAgentSaveAttachment(info) {
821
946
  const { platform, service, owner, attachment, parsedContent } = info;
822
- const pagedRead = !parsedContent && isPagedReadFile(attachment.name, attachment.mime);
823
- const serverExtract = !parsedContent && !pagedRead && isServerExtractable(attachment.name, attachment.mime);
947
+ const continuing = !!info.continueIndexing;
948
+ const visionFile = !parsedContent && isImageVisionFile(attachment.name, attachment.mime);
949
+ const renderFrom = Math.max(0, info.renderFrom || 0);
950
+ const renderPlaceholder = visionFile ? makeRenderPlaceholder(attachment.storagePath) : void 0;
951
+ const renderDetail = platform === "openai" ? getRenderImageDetail(info.model || DEFAULT_OPENAI_MODEL) : void 0;
952
+ const skapiRender = visionFile && renderPlaceholder ? {
953
+ _skapi_render: [
954
+ {
955
+ path: attachment.storagePath,
956
+ from: renderFrom,
957
+ count: RENDER_PAGES_PER_WINDOW,
958
+ placeholder: renderPlaceholder,
959
+ name: attachment.name,
960
+ mime: attachment.mime,
961
+ detail: renderDetail,
962
+ auto_continue: true,
963
+ continue_text: buildIndexingRenderContinueTemplate(attachment, renderPlaceholder)
964
+ }
965
+ ]
966
+ } : {};
967
+ const windowedRead = !visionFile && !parsedContent && windowedIndexingEnabled() && isWindowedReadFile(attachment.name, attachment.mime);
968
+ const windowPlaceholder = windowedRead ? makeWindowPlaceholder(attachment.storagePath) : void 0;
969
+ const skapiWindow = windowedRead && windowPlaceholder ? {
970
+ _skapi_window: [
971
+ {
972
+ path: attachment.storagePath,
973
+ cursor: null,
974
+ placeholder: windowPlaceholder,
975
+ name: attachment.name,
976
+ mime: attachment.mime,
977
+ kind: "window",
978
+ auto_continue: true,
979
+ continue_text: buildIndexingWindowMessage(attachment, windowPlaceholder, true)
980
+ }
981
+ ]
982
+ } : {};
983
+ const pagedRead = !visionFile && !windowedRead && (continuing || !parsedContent && isPagedReadFile(attachment.name, attachment.mime));
984
+ const serverExtract = !visionFile && !windowedRead && !continuing && !parsedContent && !pagedRead && isServerExtractable(attachment.name, attachment.mime);
824
985
  const placeholder = serverExtract ? makeExtractPlaceholder(attachment.storagePath) : void 0;
825
986
  const extractContent = serverExtract && placeholder ? [{ path: attachment.storagePath, placeholder, name: attachment.name, mime: attachment.mime }] : void 0;
826
987
  const skapiExtract = extractContent && extractContent.length ? { _skapi_extract: extractContent } : {};
827
- const userMessage = buildIndexingUserMessage(
988
+ const userMessage = visionFile && renderPlaceholder ? buildIndexingRenderMessage(attachment, renderPlaceholder, renderFrom) : windowedRead && windowPlaceholder ? buildIndexingWindowMessage(attachment, windowPlaceholder, false) : continuing ? buildIndexingContinueMessage(attachment) : buildIndexingUserMessage(
828
989
  attachment,
829
990
  parsedContent ? { inlineContent: parsedContent } : placeholder ? { inlineContentPlaceholder: placeholder } : pagedRead ? { pagedRead: true } : void 0
830
991
  );
@@ -852,6 +1013,8 @@ async function notifyAgentSaveAttachment(info) {
852
1013
  model: resolvedModel2,
853
1014
  max_output_tokens: MAX_TOKENS,
854
1015
  ...skapiExtract,
1016
+ ...skapiRender,
1017
+ ...skapiWindow,
855
1018
  input: [
856
1019
  { role: "system", content: systemPrompt },
857
1020
  {
@@ -896,6 +1059,8 @@ async function notifyAgentSaveAttachment(info) {
896
1059
  model: resolvedModel,
897
1060
  max_tokens: MAX_TOKENS,
898
1061
  ...skapiExtract,
1062
+ ...skapiRender,
1063
+ ...skapiWindow,
899
1064
  system: [
900
1065
  {
901
1066
  type: "text",
@@ -992,6 +1157,15 @@ async function listOpenAIModels(service, owner) {
992
1157
  });
993
1158
  }
994
1159
  var BG_INDEXING_QUEUE_SUFFIX = "-bg";
1160
+ function isBgIndexingQueue(queueName) {
1161
+ if (typeof queueName !== "string" || !queueName) return false;
1162
+ const prefix = queueName.split("|")[0];
1163
+ const idx = prefix.lastIndexOf(":");
1164
+ const name = idx === -1 ? prefix : prefix.slice(idx + 1);
1165
+ return name.slice(-BG_INDEXING_QUEUE_SUFFIX.length) === BG_INDEXING_QUEUE_SUFFIX;
1166
+ }
1167
+ var INDEXING_COMPLETE_MARKER = "INDEXING_COMPLETE";
1168
+ var MAX_INDEXING_RESUME_PASSES = 6;
995
1169
  async function getChatHistory(params, fetchOptions) {
996
1170
  const url = params.platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
997
1171
  const p = Object.assign(
@@ -1119,6 +1293,9 @@ function nextFrame(cb) {
1119
1293
  cb(nowMs());
1120
1294
  }, 16);
1121
1295
  }
1296
+ function isPollStopped(res) {
1297
+ return !!res && typeof res === "object" && res.status === "stopped";
1298
+ }
1122
1299
  var ChatSession = class {
1123
1300
  constructor(host) {
1124
1301
  this.typewriterQueue = Promise.resolve();
@@ -1142,8 +1319,83 @@ var ChatSession = class {
1142
1319
  this.pendingAgentRequests = {};
1143
1320
  this.aiChatHistoryCache = {};
1144
1321
  this.historyItemPolls = /* @__PURE__ */ new Map();
1322
+ this._pauseReasons = /* @__PURE__ */ new Set();
1323
+ this._resuming = false;
1145
1324
  this._lidSeq = 0;
1146
1325
  }
1326
+ /**
1327
+ * Register a live poll so (a) a remount dedupes against it instead of stacking a
1328
+ * SECOND poll on the same item, and (b) pausePolling can stop it.
1329
+ *
1330
+ * `stop` comes from the SDK and may be absent on an older skapi-js, in which case the
1331
+ * poll simply cannot be stopped and is left running — see pausePolling.
1332
+ */
1333
+ _trackPoll(id, kind, p) {
1334
+ var stop = p && typeof p.stop === "function" ? p.stop.bind(p) : void 0;
1335
+ if (!stop) {
1336
+ console.debug("[chat-engine] poll has no stop handle", { id, kind });
1337
+ }
1338
+ this.historyItemPolls.set(id, { kind, stop });
1339
+ return p;
1340
+ }
1341
+ /** True while any pause reason is active. */
1342
+ isPollingPaused() {
1343
+ return this._pauseReasons.size > 0;
1344
+ }
1345
+ /**
1346
+ * Stop BACKGROUND polling until resumePolling. Foreground polls (a reply the user is
1347
+ * waiting on) keep running deliberately: their results must still land in the history
1348
+ * cache so resumePendingRequest can render them on return, otherwise a user who sends
1349
+ * a message then navigates away comes back to a permanently stuck "Thinking...".
1350
+ *
1351
+ * Server-side work is untouched; this only stops asking about it. That is safe for
1352
+ * document indexing because the worker drives that loop itself.
1353
+ */
1354
+ pausePolling(reason) {
1355
+ this._pauseReasons.add(reason || "paused");
1356
+ var self = this;
1357
+ var stopped = [];
1358
+ this.historyItemPolls.forEach(function(handle, id) {
1359
+ if (!handle || handle.kind !== "bg") return;
1360
+ if (typeof handle.stop !== "function") return;
1361
+ try {
1362
+ handle.stop();
1363
+ } catch (e) {
1364
+ }
1365
+ stopped.push(id);
1366
+ });
1367
+ stopped.forEach(function(id) {
1368
+ self.historyItemPolls.delete(id);
1369
+ });
1370
+ }
1371
+ /**
1372
+ * Lift a pause reason WITHOUT running the reconcile. For a caller that is about to
1373
+ * reload history anyway (a view remounting), letting resumePolling also reconcile
1374
+ * would race that load and can double-attach.
1375
+ */
1376
+ clearPauseReason(reason) {
1377
+ this._pauseReasons.delete(reason || "paused");
1378
+ }
1379
+ /**
1380
+ * Clear a pause reason and, once none remain, re-attach polling and reconcile.
1381
+ * Deliberately does NOT touch gateRefreshToken: bumping it would silently discard
1382
+ * the results of anything still in flight across the pause.
1383
+ */
1384
+ resumePolling(reason) {
1385
+ this._pauseReasons.delete(reason || "paused");
1386
+ if (this._pauseReasons.size > 0 || this._resuming) return Promise.resolve();
1387
+ if (!this.host.isViewMounted || !this.host.isViewMounted()) return Promise.resolve();
1388
+ var self = this;
1389
+ this._resuming = true;
1390
+ return Promise.resolve().then(function() {
1391
+ self.drainBgTaskQueue();
1392
+ return self.loadHistory(false, self.state.gateRefreshToken);
1393
+ }).catch(function(e) {
1394
+ console.error("[chat-engine] resume polling failed", e);
1395
+ }).then(function() {
1396
+ self._resuming = false;
1397
+ });
1398
+ }
1147
1399
  _newLocalId() {
1148
1400
  this._lidSeq += 1;
1149
1401
  return "lid_" + this._lidSeq;
@@ -1157,29 +1409,86 @@ var ChatSession = class {
1157
1409
  var key = this.getHistoryCacheKey();
1158
1410
  if (!key) return;
1159
1411
  this.aiChatHistoryCache[key] = {
1160
- messages: this.state.messages.slice(),
1412
+ messages: this.state.messages.filter(function(m) {
1413
+ return m._ownerKey === void 0 || m._ownerKey === key;
1414
+ }),
1161
1415
  endOfList: this.state.historyEndOfList,
1162
1416
  startKeyHistory: this.state.historyStartKeyHistory.slice()
1163
1417
  };
1164
1418
  }
1165
- _callProviderFor(platform, prompt, messages, system, model, userId, extractContent, fileUrls) {
1166
- var id = this.host.getIdentity();
1167
- return platform === "openai" ? callOpenAIWithPublicMcp(prompt, id.serviceId, id.owner, messages, system, model, userId, extractContent, fileUrls) : callClaudeWithPublicMcp(prompt, id.serviceId, id.owner, messages, system, model, userId, extractContent, fileUrls);
1419
+ /**
1420
+ * Land a resolved reply in the history cache of a chat that is NOT currently
1421
+ * visible, without touching state.messages. Mirrors the cache-only path in
1422
+ * dispatchAgentRequest: REPLACE the trailing pending "Thinking..." bubble
1423
+ * (append only when there is none), and settle the matching pending user
1424
+ * bubble, so the cached copy never keeps a stuck "Thinking..." that a later
1425
+ * cache-first load would re-render forever.
1426
+ */
1427
+ _applyReplyToCache(key, reply, serverId) {
1428
+ if (!key) return;
1429
+ var existing = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
1430
+ var msgs = existing.messages.slice();
1431
+ var thIdx = -1;
1432
+ for (var i = msgs.length - 1; i >= 0; i--) {
1433
+ var m = msgs[i];
1434
+ if (!m || !m.isPending || m.role !== "assistant" || m.isBackgroundTask) continue;
1435
+ if (serverId && m._serverItemId && m._serverItemId !== serverId) continue;
1436
+ thIdx = i;
1437
+ break;
1438
+ }
1439
+ if (thIdx !== -1) {
1440
+ if (reply._serverItemId === void 0 && msgs[thIdx]._serverItemId !== void 0) reply._serverItemId = msgs[thIdx]._serverItemId;
1441
+ msgs[thIdx] = reply;
1442
+ } else {
1443
+ msgs.push(reply);
1444
+ }
1445
+ for (var j = 0; j < msgs.length; j++) {
1446
+ var u = msgs[j];
1447
+ if (!u || u.role !== "user" || u.isBackgroundTask) continue;
1448
+ if (!(u.isPendingQueued || u.isPendingInProcess || u.isSendingToServer)) continue;
1449
+ if (serverId && u._serverItemId && u._serverItemId !== serverId) continue;
1450
+ var settled = { role: "user", content: u.content };
1451
+ if (u._serverItemId !== void 0) settled._serverItemId = u._serverItemId;
1452
+ if (u._ownerKey !== void 0) settled._ownerKey = u._ownerKey;
1453
+ msgs[j] = settled;
1454
+ break;
1455
+ }
1456
+ this.aiChatHistoryCache[key] = {
1457
+ messages: msgs,
1458
+ endOfList: existing.endOfList,
1459
+ startKeyHistory: existing.startKeyHistory
1460
+ };
1461
+ }
1462
+ /**
1463
+ * serviceId/owner are passed explicitly by every caller: a request can be
1464
+ * dispatched after the user moved to another project, and re-reading the live
1465
+ * identity here would silently send the turn to THAT project instead of the
1466
+ * one it was composed for. Falls back to the live read only when a caller
1467
+ * omits them.
1468
+ */
1469
+ _callProviderFor(platform, prompt, messages, system, model, userId, extractContent, fileUrls, serviceId, owner) {
1470
+ if (serviceId === void 0 || owner === void 0) {
1471
+ var id = this.host.getIdentity();
1472
+ if (serviceId === void 0) serviceId = id.serviceId;
1473
+ if (owner === void 0) owner = id.owner;
1474
+ }
1475
+ return platform === "openai" ? callOpenAIWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls) : callClaudeWithPublicMcp(prompt, serviceId, owner, messages, system, model, userId, extractContent, fileUrls);
1168
1476
  }
1169
1477
  dispatchAgentRequest(params) {
1170
1478
  var self = this;
1171
1479
  var dispatchItemId;
1172
1480
  var sendAndPoll = function() {
1173
1481
  return Promise.resolve(
1174
- self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls)
1482
+ self._callProviderFor(params.aiPlatform, params.text, params.boundedMessages, params.systemPrompt, params.aiModel, params.userId, params.extractContent, params.fileUrls, params.serviceId, params.owner)
1175
1483
  ).then(function(initial) {
1176
1484
  if (initial && initial.poll && (initial.status === "pending" || initial.status === "running")) {
1177
1485
  if (initial.id) {
1178
1486
  if (dispatchItemId && dispatchItemId !== initial.id) self.historyItemPolls.delete(dispatchItemId);
1179
1487
  dispatchItemId = initial.id;
1180
- self.historyItemPolls.set(initial.id, true);
1181
1488
  }
1182
- return initial.poll({ latency: POLL_INTERVAL });
1489
+ var dp = initial.poll({ latency: POLL_INTERVAL });
1490
+ if (initial.id) self._trackPoll(initial.id, "fg", dp);
1491
+ return dp;
1183
1492
  }
1184
1493
  return initial;
1185
1494
  });
@@ -1232,20 +1541,57 @@ var ChatSession = class {
1232
1541
  // composed = clean display text; composedForLlm carries office-extraction
1233
1542
  // placeholders for the provider only. useBgQueue routes a post-attachment turn
1234
1543
  // onto the "-bg" queue so it runs after indexing.
1235
- dispatchComposedMessage(composed, useBgQueue, composedForLlm, extractContent, fileUrls) {
1544
+ dispatchComposedMessage(composed, useBgQueue, composedForLlm, extractContent, fileUrls, pinned) {
1236
1545
  var self = this;
1237
1546
  if (!composed) return;
1238
- var id = this.host.getIdentity();
1547
+ var id = pinned ? pinned.identity : this.host.getIdentity();
1239
1548
  if (id.platform === "none") return;
1240
1549
  var llmComposed = composedForLlm || composed;
1241
- var isQueuedSend = useBgQueue || this.state.sending || this.state.messages.some(function(m) {
1550
+ var key = !id.serviceId ? "" : id.serviceId + "#" + id.platform;
1551
+ var offChat = !!key && key !== this.getHistoryCacheKey();
1552
+ var isQueuedSend = !offChat && (useBgQueue || this.state.sending || this.state.messages.some(function(m) {
1242
1553
  return (m.isPending || m.isPendingQueued) && !m.isBackgroundTask && !m._useBgQueue;
1243
- });
1554
+ }));
1244
1555
  var aiPlatform = id.platform;
1245
1556
  var aiModel = id.model || void 0;
1246
- var systemPrompt = this.host.buildSystemPrompt();
1557
+ var systemPrompt = pinned ? pinned.systemPrompt : this.host.buildSystemPrompt();
1247
1558
  var userId = id.userId || id.serviceId;
1248
1559
  var chatQueue = useBgQueue ? userId + BG_INDEXING_QUEUE_SUFFIX : userId;
1560
+ if (offChat) {
1561
+ var offHistory = (this.aiChatHistoryCache[key] ? this.aiChatHistoryCache[key].messages : []).filter(function(m) {
1562
+ return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask;
1563
+ });
1564
+ var offBounded = buildBoundedChatMessages({
1565
+ platform: aiPlatform,
1566
+ model: aiModel,
1567
+ systemPrompt,
1568
+ serviceId: id.serviceId,
1569
+ history: offHistory.concat([{ role: "user", content: llmComposed }])
1570
+ });
1571
+ var offExisting = this.aiChatHistoryCache[key] || { messages: [], endOfList: false, startKeyHistory: [] };
1572
+ this.aiChatHistoryCache[key] = {
1573
+ messages: offExisting.messages.concat([
1574
+ { role: "user", content: composed, _ownerKey: key },
1575
+ { role: "assistant", content: "", isPending: true, isPendingInProcess: true, _ownerKey: key }
1576
+ ]),
1577
+ endOfList: offExisting.endOfList,
1578
+ startKeyHistory: offExisting.startKeyHistory
1579
+ };
1580
+ this.dispatchAgentRequest({
1581
+ key,
1582
+ serviceId: id.serviceId,
1583
+ owner: id.owner,
1584
+ aiPlatform,
1585
+ aiModel,
1586
+ systemPrompt,
1587
+ text: composed,
1588
+ boundedMessages: offBounded.messages,
1589
+ userId: chatQueue,
1590
+ extractContent,
1591
+ fileUrls
1592
+ });
1593
+ return;
1594
+ }
1249
1595
  if (isQueuedSend) {
1250
1596
  var resolvedHistory = this.state.messages.filter(function(m) {
1251
1597
  return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder && !m.isCancelled && !m.isBackgroundTask;
@@ -1258,15 +1604,16 @@ var ChatSession = class {
1258
1604
  history: resolvedHistory.concat([{ role: "user", content: llmComposed }])
1259
1605
  });
1260
1606
  var queuedBubble = { role: "user", content: composed, isPendingQueued: true, isSendingToServer: true };
1607
+ if (key) queuedBubble._ownerKey = key;
1261
1608
  if (useBgQueue) queuedBubble._useBgQueue = true;
1262
1609
  this.state.messages.push(queuedBubble);
1263
1610
  this.host.notify();
1264
1611
  this.updateHistoryCache();
1265
1612
  this.host.scrollToBottom(true);
1266
- var capturedComposed = composed, capturedPlatform = aiPlatform;
1267
- Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls)).then(function(result) {
1268
- var sendingIdx = self.state.messages.findIndex(function(m) {
1269
- return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === "user";
1613
+ var capturedComposed = composed, capturedPlatform = aiPlatform, capturedKey = key;
1614
+ Promise.resolve(this._callProviderFor(aiPlatform, composed, boundedQ.messages, systemPrompt, aiModel, chatQueue, extractContent, fileUrls, id.serviceId, id.owner)).then(function(result) {
1615
+ var sendingIdx = self.getHistoryCacheKey() !== capturedKey ? -1 : self.state.messages.findIndex(function(m) {
1616
+ return m.isSendingToServer && (m.isPendingQueued || m.isPendingInProcess) && m.role === "user" && (m._ownerKey === void 0 || m._ownerKey === capturedKey);
1270
1617
  });
1271
1618
  var serverId = result && typeof result.id === "string" ? result.id : void 0;
1272
1619
  if (sendingIdx >= 0) {
@@ -1276,26 +1623,27 @@ var ChatSession = class {
1276
1623
  self.host.notify();
1277
1624
  }
1278
1625
  if (result && result.poll && (result.status === "pending" || result.status === "running")) {
1279
- if (serverId) self.historyItemPolls.set(serverId, true);
1280
- return result.poll({ latency: POLL_INTERVAL }).then(function(res) {
1281
- return self.onQueuedSendResponse(capturedComposed, res, capturedPlatform, serverId);
1626
+ var qp = result.poll({ latency: POLL_INTERVAL });
1627
+ if (serverId) self._trackPoll(serverId, "fg", qp);
1628
+ return qp.then(function(res) {
1629
+ if (isPollStopped(res)) return;
1630
+ return self.onQueuedSendResponse(capturedComposed, res, capturedPlatform, serverId, capturedKey);
1282
1631
  }).catch(function(err) {
1283
- return self.onQueuedSendError(capturedComposed, err, serverId);
1632
+ return self.onQueuedSendError(capturedComposed, err, serverId, capturedKey);
1284
1633
  });
1285
1634
  }
1286
- return self.onQueuedSendResponse(capturedComposed, result, capturedPlatform, serverId);
1635
+ return self.onQueuedSendResponse(capturedComposed, result, capturedPlatform, serverId, capturedKey);
1287
1636
  }).catch(function(err) {
1288
- return self.onQueuedSendError(capturedComposed, err, void 0);
1637
+ return self.onQueuedSendError(capturedComposed, err, void 0, capturedKey);
1289
1638
  });
1290
1639
  return;
1291
1640
  }
1292
- this.state.messages.push({ role: "user", content: composed });
1293
- this.state.messages.push({ role: "assistant", content: "", isPending: true, isPendingInProcess: true });
1641
+ this.state.messages.push({ role: "user", content: composed, ...key ? { _ownerKey: key } : {} });
1642
+ this.state.messages.push({ role: "assistant", content: "", isPending: true, isPendingInProcess: true, ...key ? { _ownerKey: key } : {} });
1294
1643
  this.host.notify();
1295
1644
  this.updateHistoryCache();
1296
1645
  this.state.sending = true;
1297
1646
  this.host.scrollToBottom(true);
1298
- var key = this.getHistoryCacheKey();
1299
1647
  var historyForLlm = this.state.messages.filter(function(m) {
1300
1648
  return !m.isCancelled && !m.isBackgroundTask;
1301
1649
  });
@@ -1329,8 +1677,8 @@ var ChatSession = class {
1329
1677
  });
1330
1678
  Promise.resolve(run).catch(function() {
1331
1679
  }).then(function() {
1332
- if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
1333
1680
  self.state.sending = false;
1681
+ if (!(self.host.isViewMounted() && self.getHistoryCacheKey() === key)) return;
1334
1682
  return Promise.resolve(self.typewriteLatestReply(key)).then(function() {
1335
1683
  self.host.scrollToBottom(true);
1336
1684
  });
@@ -1347,9 +1695,11 @@ var ChatSession = class {
1347
1695
  var existing = this.state.messages[nextIdx];
1348
1696
  var promoted = { role: "user", content: existing.content, isPendingInProcess: true, isBackgroundTask: true };
1349
1697
  if (existing._serverItemId !== void 0) promoted._serverItemId = existing._serverItemId;
1698
+ if (existing._ownerKey !== void 0) promoted._ownerKey = existing._ownerKey;
1350
1699
  this.state.messages[nextIdx] = promoted;
1351
1700
  var placeholder = { role: "assistant", content: "", isPending: true, isPendingInProcess: true, isBackgroundTask: true };
1352
1701
  if (existing._serverItemId !== void 0) placeholder._serverItemId = existing._serverItemId;
1702
+ if (existing._ownerKey !== void 0) placeholder._ownerKey = existing._ownerKey;
1353
1703
  this.state.messages.splice(nextIdx + 1, 0, placeholder);
1354
1704
  this.host.notify();
1355
1705
  }
@@ -1365,14 +1715,20 @@ var ChatSession = class {
1365
1715
  var promoted = { role: "user", content: existing.content, isPendingInProcess: true };
1366
1716
  if (existing.isBackgroundTask) promoted.isBackgroundTask = true;
1367
1717
  if (existing._serverItemId !== void 0) promoted._serverItemId = existing._serverItemId;
1718
+ if (existing._ownerKey !== void 0) promoted._ownerKey = existing._ownerKey;
1368
1719
  if (existing.isSendingToServer) promoted.isSendingToServer = true;
1369
1720
  this.state.messages[nextIdx] = promoted;
1370
1721
  var placeholder = { role: "assistant", content: "", isPending: true };
1371
1722
  if (existing._serverItemId !== void 0) placeholder._serverItemId = existing._serverItemId;
1723
+ if (existing._ownerKey !== void 0) placeholder._ownerKey = existing._ownerKey;
1372
1724
  this.state.messages.splice(nextIdx + 1, 0, placeholder);
1373
1725
  this.host.notify();
1374
1726
  }
1375
1727
  resolveQueuedUserBubble(serverId) {
1728
+ var liveKey = this.getHistoryCacheKey();
1729
+ var isLocal = function(m) {
1730
+ return m._ownerKey === void 0 || m._ownerKey === liveKey;
1731
+ };
1376
1732
  var userIdx = -1;
1377
1733
  if (serverId) {
1378
1734
  userIdx = this.state.messages.findIndex(function(m) {
@@ -1381,19 +1737,19 @@ var ChatSession = class {
1381
1737
  }
1382
1738
  if (userIdx === -1) {
1383
1739
  userIdx = this.state.messages.findIndex(function(m) {
1384
- return m.isPendingInProcess && m.role === "user" && !m.isBackgroundTask && !m._useBgQueue;
1740
+ return m.isPendingInProcess && m.role === "user" && !m.isBackgroundTask && !m._useBgQueue && isLocal(m);
1385
1741
  });
1386
1742
  }
1387
1743
  if (userIdx === -1) {
1388
1744
  userIdx = this.state.messages.findIndex(function(m) {
1389
- return m.isPendingQueued && m.role === "user" && !m.isBackgroundTask && !m._useBgQueue;
1745
+ return m.isPendingQueued && m.role === "user" && !m.isBackgroundTask && !m._useBgQueue && isLocal(m);
1390
1746
  });
1391
1747
  }
1392
1748
  if (serverId && this.cancelledServerIds.has(serverId)) {
1393
1749
  this.cancelledServerIds.delete(serverId);
1394
1750
  if (userIdx >= 0) {
1395
1751
  var ex = this.state.messages[userIdx];
1396
- this.state.messages[userIdx] = { role: "user", content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId };
1752
+ this.state.messages[userIdx] = { role: "user", content: ex.content, isCancelled: true, _serverItemId: ex._serverItemId, ...ex._ownerKey !== void 0 ? { _ownerKey: ex._ownerKey } : {} };
1397
1753
  var thIdx = this.state.messages.findIndex(function(m, i) {
1398
1754
  return i > userIdx && m.isPending && m.role === "assistant" && !m.isBackgroundTask;
1399
1755
  });
@@ -1406,6 +1762,7 @@ var ChatSession = class {
1406
1762
  var exist = this.state.messages[userIdx];
1407
1763
  var repl = { role: "user", content: exist.content };
1408
1764
  if (exist._serverItemId !== void 0) repl._serverItemId = exist._serverItemId;
1765
+ if (exist._ownerKey !== void 0) repl._ownerKey = exist._ownerKey;
1409
1766
  this.state.messages[userIdx] = repl;
1410
1767
  }
1411
1768
  var thinkingIdx = userIdx >= 0 ? this.state.messages.findIndex(function(m, i) {
@@ -1418,8 +1775,14 @@ var ChatSession = class {
1418
1775
  else if (targetIdx >= 0) this.state.messages.splice(targetIdx, 0, msg);
1419
1776
  else this.state.messages.push(msg);
1420
1777
  }
1421
- onQueuedSendResponse(_composed, response, platform, serverId) {
1778
+ onQueuedSendResponse(_composed, response, platform, serverId, ownerKey) {
1422
1779
  if (serverId) this.historyItemPolls.delete(serverId);
1780
+ if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
1781
+ var offReply = isErrorResponseBody(response) ? { role: "assistant", content: getErrorMessage(response), isError: true } : { role: "assistant", content: ((platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "").trim() || "No text response received from AI provider." };
1782
+ this._applyReplyToCache(ownerKey, offReply, serverId);
1783
+ if (serverId) this.cancelledServerIds.delete(serverId);
1784
+ return;
1785
+ }
1423
1786
  var targetIdx = this.resolveQueuedUserBubble(serverId);
1424
1787
  if (targetIdx === void 0) {
1425
1788
  this.host.notify();
@@ -1453,8 +1816,14 @@ var ChatSession = class {
1453
1816
  this.host.notify();
1454
1817
  this.host.scrollToBottom(true);
1455
1818
  }
1456
- onQueuedSendError(_composed, err, serverId) {
1819
+ onQueuedSendError(_composed, err, serverId, ownerKey) {
1457
1820
  if (serverId) this.historyItemPolls.delete(serverId);
1821
+ if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
1822
+ var isGone = err && (err.code === "NOT_EXISTS" || err.body && err.body.code === "NOT_EXISTS");
1823
+ this._applyReplyToCache(ownerKey, isGone ? { role: "assistant", content: "Request was cancelled.", isError: true } : { role: "assistant", content: getErrorMessage(err), isError: true }, serverId);
1824
+ if (serverId) this.cancelledServerIds.delete(serverId);
1825
+ return;
1826
+ }
1458
1827
  var isNotExists = err && (err.code === "NOT_EXISTS" || err.body && err.body.code === "NOT_EXISTS");
1459
1828
  if (isNotExists) {
1460
1829
  var userIdx = serverId ? this.state.messages.findIndex(function(m) {
@@ -1800,6 +2169,7 @@ var ChatSession = class {
1800
2169
  this.historyItemPolls.delete(itemId);
1801
2170
  var isErr = isErrorResponseBody(response);
1802
2171
  var answer = isErr ? getErrorMessage(response) : ((platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "").trim();
2172
+ if (!isErr && answer) answer = answer.split(INDEXING_COMPLETE_MARKER).join("").trim();
1803
2173
  var idx = this.state.messages.findIndex(function(m) {
1804
2174
  return m.isPending && m._serverItemId === itemId;
1805
2175
  });
@@ -1857,24 +2227,33 @@ var ChatSession = class {
1857
2227
  }
1858
2228
  this.bgTaskQueue.forEach(function(entry) {
1859
2229
  if (entry.serviceId !== svcId || entry.platform !== plat) return;
1860
- if (presentIds[entry.id]) return;
1861
- var isRunning = entry.status === "running";
1862
- var userBubble = { role: "user", content: self.host.formatIndexingLabel(entry.filename, entry.mime, entry.size, entry.storagePath, entry.isReindex), isBackgroundTask: true, _serverItemId: entry.id };
1863
- if (isRunning) userBubble.isPendingInProcess = true;
1864
- else userBubble.isPendingQueued = true;
1865
- self.state.messages.push(userBubble);
1866
- if (isRunning) {
1867
- self.state.messages.push({ role: "assistant", content: "", isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id });
2230
+ if (!presentIds[entry.id]) {
2231
+ var isRunning = entry.status === "running";
2232
+ var userBubble = { role: "user", content: self.host.formatIndexingLabel(entry.filename, entry.mime, entry.size, entry.storagePath, entry.isReindex), isBackgroundTask: true, _serverItemId: entry.id };
2233
+ if (isRunning) userBubble.isPendingInProcess = true;
2234
+ else userBubble.isPendingQueued = true;
2235
+ self.state.messages.push(userBubble);
2236
+ if (isRunning) {
2237
+ self.state.messages.push({ role: "assistant", content: "", isPending: true, isPendingInProcess: true, isBackgroundTask: true, _serverItemId: entry.id });
2238
+ }
2239
+ presentIds[entry.id] = true;
2240
+ self.host.notify();
2241
+ self.updateHistoryCache();
2242
+ self.host.scrollToBottom(false);
1868
2243
  }
1869
- presentIds[entry.id] = true;
1870
- self.host.notify();
1871
- self.updateHistoryCache();
1872
- self.host.scrollToBottom(false);
1873
- if (!self.historyItemPolls.has(entry.id) && typeof entry.poll === "function") {
1874
- self.historyItemPolls.set(entry.id, true);
2244
+ if (!self.isPollingPaused() && !self.historyItemPolls.has(entry.id) && typeof entry.poll === "function") {
1875
2245
  var capturedId = entry.id, capturedPlat = plat;
1876
- entry.poll({ latency: POLL_INTERVAL }).then(function(response) {
2246
+ var capturedEntry = entry;
2247
+ var wasStopped = false;
2248
+ var bp = entry.poll({ latency: POLL_INTERVAL });
2249
+ self._trackPoll(entry.id, "bg", bp);
2250
+ bp.then(function(response) {
2251
+ if (isPollStopped(response)) {
2252
+ wasStopped = true;
2253
+ return;
2254
+ }
1877
2255
  self.handleHistoryItemResolution(capturedId, response, capturedPlat);
2256
+ self.maybeResumeIndexing(capturedEntry, response, capturedPlat);
1878
2257
  }).catch(function(err) {
1879
2258
  self.historyItemPolls.delete(capturedId);
1880
2259
  var isNotExists = err && (err.code === "NOT_EXISTS" || err.body && err.body.code === "NOT_EXISTS");
@@ -1888,6 +2267,7 @@ var ChatSession = class {
1888
2267
  self.updateHistoryCache();
1889
2268
  }
1890
2269
  }).then(function() {
2270
+ if (wasStopped) return;
1891
2271
  var qi = self.bgTaskQueue.findIndex(function(q) {
1892
2272
  return q.id === capturedId;
1893
2273
  });
@@ -1897,6 +2277,69 @@ var ChatSession = class {
1897
2277
  });
1898
2278
  this.promoteNextBgQueuedToRunning();
1899
2279
  }
2280
+ // Resume-across-passes: if a background INDEXING task for a paged file (spreadsheet or
2281
+ // text) finished WITHOUT the completion marker, the agent ran out of room before reading
2282
+ // the whole file - dispatch a CONTINUE pass (up to a cap) that resumes from where the
2283
+ // already-saved records leave off. Additive + guarded so it never loops forever and
2284
+ // never breaks the resolution path.
2285
+ //
2286
+ // VISION files (PDFs rendered to page images) are NOT resumed here: the proxy worker
2287
+ // advances their page window itself, off the renderer's true page count. Driving them
2288
+ // from the browser is what used to lose pages on long documents - the chain lived in tab
2289
+ // memory (a reload or a closed tab ended it), and it stopped whenever the model claimed
2290
+ // completion, which on an 88-page file happened at page 15. Continuing to dispatch here
2291
+ // as well would now double-index every window.
2292
+ maybeResumeIndexing(entry, response, platform) {
2293
+ var self = this;
2294
+ try {
2295
+ if (!entry || !entry.storagePath) return;
2296
+ if (!isPagedReadFile(entry.filename, entry.mime)) return;
2297
+ if (isImageVisionFile(entry.filename, entry.mime)) return;
2298
+ if (isErrorResponseBody(response)) return;
2299
+ var answer = (platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "";
2300
+ if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return;
2301
+ var pass = (entry.resumePass || 0) + 1;
2302
+ if (pass > MAX_INDEXING_RESUME_PASSES) return;
2303
+ var id = this.host.getIdentity();
2304
+ if (!id || id.platform === "none" || id.serviceId !== entry.serviceId) return;
2305
+ notifyAgentContinueIndexing({
2306
+ platform: id.platform,
2307
+ model: id.model,
2308
+ service: id.serviceId,
2309
+ owner: id.owner,
2310
+ userId: id.userId || id.serviceId,
2311
+ serviceName: id.serviceName,
2312
+ serviceDescription: id.serviceDescription,
2313
+ attachment: {
2314
+ name: entry.filename,
2315
+ storagePath: entry.storagePath,
2316
+ mime: entry.mime,
2317
+ size: entry.size,
2318
+ url: ""
2319
+ }
2320
+ }).then(function(ack) {
2321
+ if (ack && typeof ack.id === "string") {
2322
+ self.bgTaskQueue.push({
2323
+ serviceId: id.serviceId,
2324
+ platform: id.platform,
2325
+ id: ack.id,
2326
+ filename: entry.filename,
2327
+ storagePath: entry.storagePath,
2328
+ isReindex: entry.isReindex,
2329
+ mime: entry.mime,
2330
+ size: entry.size,
2331
+ status: ack.status === "running" ? "running" : "pending",
2332
+ poll: ack.poll,
2333
+ resumePass: pass
2334
+ });
2335
+ self.drainBgTaskQueue();
2336
+ }
2337
+ }, function(e) {
2338
+ console.error("[chat-engine] resume-indexing dispatch failed", e);
2339
+ });
2340
+ } catch (e) {
2341
+ }
2342
+ }
1900
2343
  // --- history fetch + pagination --------------------------------------
1901
2344
  // Initial load (fetchMore=false) replaces the list (with in-flight rescue +
1902
2345
  // cancelled-merge) and attaches polls to running/pending items; pagination
@@ -1906,6 +2349,7 @@ var ChatSession = class {
1906
2349
  loadHistory(fetchMore, token) {
1907
2350
  var self = this;
1908
2351
  var id = this.host.getIdentity();
2352
+ var loadKey = !id.serviceId || id.platform === "none" ? "" : id.serviceId + "#" + id.platform;
1909
2353
  if (token === void 0) token = this.state.gateRefreshToken;
1910
2354
  if (this.state.loadingHistory && this.state.historyRequestToken === token || id.platform === "none" || !id.serviceId) {
1911
2355
  return Promise.resolve();
@@ -1928,9 +2372,9 @@ var ChatSession = class {
1928
2372
  if (token !== self.state.gateRefreshToken) return;
1929
2373
  var chatList = history && Array.isArray(history.list) ? history.list : [];
1930
2374
  chatList.forEach(function(item) {
1931
- if (typeof item.queue_name === "string" && item.queue_name.slice(-BG_INDEXING_QUEUE_SUFFIX.length) === BG_INDEXING_QUEUE_SUFFIX) {
2375
+ if (isBgIndexingQueue(item.queue_name)) {
1932
2376
  var userText = extractLastUserTextFromRequest(item.request_body);
1933
- if (typeof userText === "string" && userText.indexOf("A new file has just been uploaded") === 0) item._isBgTask = true;
2377
+ if (typeof userText === "string" && (userText.indexOf("A new file has just been uploaded") === 0 || userText.indexOf("CONTINUE indexing") === 0)) item._isBgTask = true;
1934
2378
  else item._isOnBgQueue = true;
1935
2379
  }
1936
2380
  });
@@ -1962,6 +2406,7 @@ var ChatSession = class {
1962
2406
  for (var ri = 0; ri < self.state.messages.length; ri++) {
1963
2407
  var mm = self.state.messages[ri];
1964
2408
  if (mm.isBackgroundTask) continue;
2409
+ if (mm._ownerKey !== void 0 && mm._ownerKey !== loadKey) continue;
1965
2410
  if (mm._serverItemId && serverIds[mm._serverItemId]) continue;
1966
2411
  if (!mm._serverItemId) {
1967
2412
  if (mappedHasPendingAssistant) continue;
@@ -2006,11 +2451,12 @@ var ChatSession = class {
2006
2451
  if (!item.poll || !item.id) return;
2007
2452
  if (self.historyItemPolls.has(item.id)) return;
2008
2453
  if (self.pendingAgentRequests[self.getHistoryCacheKey()] && !item._isBgTask && !item._isOnBgQueue) return;
2009
- self.historyItemPolls.set(item.id, true);
2454
+ if ((item._isBgTask || item._isOnBgQueue) && self.isPollingPaused()) return;
2010
2455
  var capturedId = item.id;
2011
2456
  var pp = item.poll({
2012
2457
  latency: POLL_INTERVAL,
2013
2458
  onResponse: function(response) {
2459
+ if (isPollStopped(response)) return;
2014
2460
  self.handleHistoryItemResolution(capturedId, response, platform);
2015
2461
  },
2016
2462
  onError: function(err) {
@@ -2046,6 +2492,7 @@ var ChatSession = class {
2046
2492
  }
2047
2493
  }
2048
2494
  });
2495
+ self._trackPoll(capturedId, item._isBgTask || item._isOnBgQueue ? "bg" : "fg", pp);
2049
2496
  if (pp && pp.catch) pp.catch(function() {
2050
2497
  });
2051
2498
  });
@@ -2275,12 +2722,17 @@ exports.MCP_NAME = MCP_NAME;
2275
2722
  exports.MIN_INPUT_TOKEN_BUDGET = MIN_INPUT_TOKEN_BUDGET;
2276
2723
  exports.OUTPUT_TOKEN_RESERVE = OUTPUT_TOKEN_RESERVE;
2277
2724
  exports.POLL_INTERVAL = POLL_INTERVAL;
2725
+ exports.RENDER_FROM_TOKEN = RENDER_FROM_TOKEN;
2278
2726
  exports.TOOL_AND_RESPONSE_BUFFER = TOOL_AND_RESPONSE_BUFFER;
2279
2727
  exports.buildBoundedChatMessages = buildBoundedChatMessages;
2280
2728
  exports.buildChatSystemPrompt = buildChatSystemPrompt;
2281
2729
  exports.buildDisplayExpiredAttachmentHref = buildDisplayExpiredAttachmentHref;
2730
+ exports.buildIndexingContinueMessage = buildIndexingContinueMessage;
2731
+ exports.buildIndexingRenderContinueTemplate = buildIndexingRenderContinueTemplate;
2732
+ exports.buildIndexingRenderMessage = buildIndexingRenderMessage;
2282
2733
  exports.buildIndexingSystemPrompt = buildIndexingSystemPrompt;
2283
2734
  exports.buildIndexingUserMessage = buildIndexingUserMessage;
2735
+ exports.buildIndexingWindowMessage = buildIndexingWindowMessage;
2284
2736
  exports.callClaudeWithMcp = callClaudeWithMcp;
2285
2737
  exports.callClaudeWithPublicMcp = callClaudeWithPublicMcp;
2286
2738
  exports.callOpenAIWithPublicMcp = callOpenAIWithPublicMcp;
@@ -2305,6 +2757,7 @@ exports.getErrorMessage = getErrorMessage;
2305
2757
  exports.getExpiredAttachmentVisiblePath = getExpiredAttachmentVisiblePath;
2306
2758
  exports.groupAttachmentFailures = groupAttachmentFailures;
2307
2759
  exports.isAuthExpiredError = isAuthExpiredError;
2760
+ exports.isBgIndexingQueue = isBgIndexingQueue;
2308
2761
  exports.isErrorResponseBody = isErrorResponseBody;
2309
2762
  exports.isNonRetryableRequestError = isNonRetryableRequestError;
2310
2763
  exports.isOfficeFile = isOfficeFile;