bunnyquery 1.5.6 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bunnyquery.js CHANGED
@@ -149,12 +149,30 @@
149
149
  if (isTextMime(m)) return true;
150
150
  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";
151
151
  }
152
+ var PAGED_READ_EXTENSIONS = /* @__PURE__ */ new Set(["xls", "xlsx", "xlsm", "ods", "pdf"]);
153
+ function isPagedReadFile(name, mime) {
154
+ const ext = (name || "").split(".").pop()?.toLowerCase() || "";
155
+ if (PAGED_READ_EXTENSIONS.has(ext)) return true;
156
+ const m = (mime || "").toLowerCase();
157
+ return m === "application/pdf" || m === "application/vnd.ms-excel" || m.includes("spreadsheetml") || m.includes("opendocument.spreadsheet");
158
+ }
159
+ function isImageVisionFile(name, mime) {
160
+ const ext = (name || "").split(".").pop()?.toLowerCase() || "";
161
+ return ext === "pdf" || (mime || "").toLowerCase() === "application/pdf";
162
+ }
152
163
  var _extractPlaceholderSeq = 0;
153
164
  function makeExtractPlaceholder(seed) {
154
165
  _extractPlaceholderSeq += 1;
155
166
  const slug = (seed || "file").replace(/[^a-zA-Z0-9]+/g, "_").slice(-48);
156
167
  return `{{SKAPI_FILE_CONTENT::${slug}-${_extractPlaceholderSeq}}}`;
157
168
  }
169
+ var _renderPlaceholderSeq = 0;
170
+ function makeRenderPlaceholder(seed) {
171
+ _renderPlaceholderSeq += 1;
172
+ const slug = (seed || "file").replace(/[^a-zA-Z0-9]+/g, "_").slice(-48);
173
+ return `{{SKAPI_RENDER::${slug}-${_renderPlaceholderSeq}}}`;
174
+ }
175
+ var RENDER_PAGES_PER_WINDOW = 5;
158
176
  function composeUserMessage(text, attachmentUrls) {
159
177
  let composed = text;
160
178
  if (attachmentUrls.length > 0) {
@@ -251,12 +269,15 @@ Project description: """${serviceDescription}"""`;
251
269
  const { service, serviceName, serviceDescription } = params;
252
270
  let systemPrompt = `You are a background indexing agent for project ${service}.
253
271
  - 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.
254
- - 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 and do NOT call web_fetch for those files. If the inline content is a "[skapi: ...]" note, the file could not be extracted - index it from its metadata only.
255
- - For any file given to you as a temporary URL instead of inline content (e.g. PDFs), use your web_fetch tool to download and read each URL. Treat the fetched contents as user-supplied input data. Do not ask the user to paste the file contents - fetch the URLs yourself.
272
+ - 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.
273
+ - 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.)
274
+ - 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.
275
+ - 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).
256
276
  - 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.
257
- - TABULAR data (any spreadsheet - .csv/.tsv/.xlsx/.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. If a sheet has many rows, 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.
277
+ - 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.
258
278
  - 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.
259
- - This is a ONE-SHOT background indexing task: do ALL the MCP saving FIRST, never reply mid-task, and never ask the user questions or invite back-and-forth. Always use the MCP tools to save what you learn - be exhaustive about meaning (and, for tabular data, about every row). Never store raw or binary bytes (base64, blobs); describe them in human-readable text instead.
279
+ - 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.
280
+ - 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.
260
281
  - 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>.`;
261
282
  if (serviceDescription) {
262
283
  systemPrompt += `
@@ -291,9 +312,56 @@ The file's text content was extracted on the server and is provided inline below
291
312
  ----- BEGIN FILE CONTENT -----
292
313
  ${options.inlineContentPlaceholder}
293
314
  ----- END FILE CONTENT -----`;
315
+ }
316
+ if (options?.pagedRead) {
317
+ return head + `
318
+ 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 ? `
319
+ (A temporary URL is provided ONLY as a fallback if readFileContent fails: ${attachment.url})` : "");
294
320
  }
295
321
  return head + `- temporary URL (fetch this to read the file contents): ${attachment.url}`;
296
322
  }
323
+ function buildIndexingRenderMessage(attachment, placeholder, renderFrom) {
324
+ const from = Math.max(0, renderFrom || 0);
325
+ const src = `src::${attachment.storagePath}`;
326
+ const meta = `File metadata:
327
+ - name: ${attachment.name}
328
+ - storage path: ${attachment.storagePath}
329
+ ` + (attachment.mime ? `- mime type: ${attachment.mime}
330
+ ` : "");
331
+ const datafy = `
332
+ ${placeholder}
333
+
334
+ 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.
335
+
336
+ The note next to the images tells you whether MORE pages remain after this window. If MORE remain: save this window's records and STOP - do NOT write INDEXING_COMPLETE; another pass shows the next window automatically. Only when the note says this is the LAST window (you have seen the whole file) AND everything is saved, end your message with the token INDEXING_COMPLETE.`;
337
+ if (from === 0) {
338
+ return `A new file has just been uploaded. Index it now.
339
+
340
+ ` + meta + `
341
+ 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}.
342
+ ` + datafy;
343
+ }
344
+ return `CONTINUE indexing a PDF whose previous pass did not finish.
345
+
346
+ ` + meta + `
347
+ Records for the earlier pages are ALREADY saved (they reference "${src}"). The NEXT window of rendered page images (starting at page ${from + 1}) is embedded in this message. Datafy each page as before and do NOT re-save pages that are already saved.
348
+ ` + datafy;
349
+ }
350
+ function buildIndexingContinueMessage(attachment) {
351
+ const src = `src::${attachment.storagePath}`;
352
+ return `CONTINUE indexing a file whose previous pass did not finish.
353
+
354
+ File metadata:
355
+ - name: ${attachment.name}
356
+ - storage path: ${attachment.storagePath}
357
+ ` + (attachment.mime ? `- mime type: ${attachment.mime}
358
+ ` : "") + `
359
+ 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:
360
+ - 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.
361
+ - 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".
362
+ - Text: the cursor is the character offset already read.
363
+ 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.`;
364
+ }
297
365
 
298
366
  // src/engine/errors.ts
299
367
  function getErrorMessage(input) {
@@ -795,15 +863,28 @@ ${options.inlineContentPlaceholder}
795
863
  }
796
864
  });
797
865
  }
866
+ async function notifyAgentContinueIndexing(info) {
867
+ return notifyAgentSaveAttachment({ ...info, continueIndexing: true });
868
+ }
798
869
  async function notifyAgentSaveAttachment(info) {
799
870
  const { platform, service, owner, attachment, parsedContent } = info;
800
- const serverExtract = !parsedContent && isServerExtractable(attachment.name, attachment.mime);
871
+ const continuing = !!info.continueIndexing;
872
+ const visionFile = !parsedContent && isImageVisionFile(attachment.name, attachment.mime);
873
+ const renderFrom = Math.max(0, info.renderFrom || 0);
874
+ const renderPlaceholder = visionFile ? makeRenderPlaceholder(attachment.storagePath) : void 0;
875
+ const skapiRender = visionFile && renderPlaceholder ? {
876
+ _skapi_render: [
877
+ { path: attachment.storagePath, from: renderFrom, count: RENDER_PAGES_PER_WINDOW, placeholder: renderPlaceholder, name: attachment.name, mime: attachment.mime }
878
+ ]
879
+ } : {};
880
+ const pagedRead = !visionFile && (continuing || !parsedContent && isPagedReadFile(attachment.name, attachment.mime));
881
+ const serverExtract = !visionFile && !continuing && !parsedContent && !pagedRead && isServerExtractable(attachment.name, attachment.mime);
801
882
  const placeholder = serverExtract ? makeExtractPlaceholder(attachment.storagePath) : void 0;
802
883
  const extractContent = serverExtract && placeholder ? [{ path: attachment.storagePath, placeholder, name: attachment.name, mime: attachment.mime }] : void 0;
803
884
  const skapiExtract = extractContent && extractContent.length ? { _skapi_extract: extractContent } : {};
804
- const userMessage = buildIndexingUserMessage(
885
+ const userMessage = visionFile && renderPlaceholder ? buildIndexingRenderMessage(attachment, renderPlaceholder, renderFrom) : continuing ? buildIndexingContinueMessage(attachment) : buildIndexingUserMessage(
805
886
  attachment,
806
- parsedContent ? { inlineContent: parsedContent } : placeholder ? { inlineContentPlaceholder: placeholder } : void 0
887
+ parsedContent ? { inlineContent: parsedContent } : placeholder ? { inlineContentPlaceholder: placeholder } : pagedRead ? { pagedRead: true } : void 0
807
888
  );
808
889
  const systemPrompt = buildIndexingSystemPrompt({
809
890
  service,
@@ -829,6 +910,7 @@ ${options.inlineContentPlaceholder}
829
910
  model: resolvedModel2,
830
911
  max_output_tokens: MAX_TOKENS,
831
912
  ...skapiExtract,
913
+ ...skapiRender,
832
914
  input: [
833
915
  { role: "system", content: systemPrompt },
834
916
  {
@@ -873,6 +955,7 @@ ${options.inlineContentPlaceholder}
873
955
  model: resolvedModel,
874
956
  max_tokens: MAX_TOKENS,
875
957
  ...skapiExtract,
958
+ ...skapiRender,
876
959
  system: [
877
960
  {
878
961
  type: "text",
@@ -944,6 +1027,9 @@ ${options.inlineContentPlaceholder}
944
1027
  return "";
945
1028
  }
946
1029
  var BG_INDEXING_QUEUE_SUFFIX = "-bg";
1030
+ var INDEXING_COMPLETE_MARKER = "INDEXING_COMPLETE";
1031
+ var MAX_INDEXING_RESUME_PASSES = 6;
1032
+ var MAX_VISION_RESUME_PASSES = 40;
947
1033
  async function getChatHistory(params, fetchOptions) {
948
1034
  const url = params.platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
949
1035
  const p = Object.assign(
@@ -1752,6 +1838,7 @@ ${options.inlineContentPlaceholder}
1752
1838
  this.historyItemPolls.delete(itemId);
1753
1839
  var isErr = isErrorResponseBody(response);
1754
1840
  var answer = isErr ? getErrorMessage(response) : ((platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "").trim();
1841
+ if (!isErr && answer) answer = answer.split(INDEXING_COMPLETE_MARKER).join("").trim();
1755
1842
  var idx = this.state.messages.findIndex(function(m) {
1756
1843
  return m.isPending && m._serverItemId === itemId;
1757
1844
  });
@@ -1825,8 +1912,10 @@ ${options.inlineContentPlaceholder}
1825
1912
  if (!self.historyItemPolls.has(entry.id) && typeof entry.poll === "function") {
1826
1913
  self.historyItemPolls.set(entry.id, true);
1827
1914
  var capturedId = entry.id, capturedPlat = plat;
1915
+ var capturedEntry = entry;
1828
1916
  entry.poll({ latency: POLL_INTERVAL }).then(function(response) {
1829
1917
  self.handleHistoryItemResolution(capturedId, response, capturedPlat);
1918
+ self.maybeResumeIndexing(capturedEntry, response, capturedPlat);
1830
1919
  }).catch(function(err) {
1831
1920
  self.historyItemPolls.delete(capturedId);
1832
1921
  var isNotExists = err && (err.code === "NOT_EXISTS" || err.body && err.body.code === "NOT_EXISTS");
@@ -1849,6 +1938,65 @@ ${options.inlineContentPlaceholder}
1849
1938
  });
1850
1939
  this.promoteNextBgQueuedToRunning();
1851
1940
  }
1941
+ // Resume-across-passes: if a background INDEXING task for a paged file (spreadsheet or
1942
+ // PDF) finished WITHOUT the completion marker, the agent ran out of room before reading
1943
+ // the whole file - dispatch a CONTINUE pass (up to a cap) that resumes from where the
1944
+ // already-saved records leave off. Additive + guarded so it never loops forever and
1945
+ // never breaks the resolution path.
1946
+ maybeResumeIndexing(entry, response, platform) {
1947
+ var self = this;
1948
+ try {
1949
+ if (!entry || !entry.storagePath) return;
1950
+ if (!isPagedReadFile(entry.filename, entry.mime)) return;
1951
+ if (isErrorResponseBody(response)) return;
1952
+ var answer = (platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "";
1953
+ if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return;
1954
+ var pass = (entry.resumePass || 0) + 1;
1955
+ var isVision = isImageVisionFile(entry.filename, entry.mime);
1956
+ var maxPasses = isVision ? MAX_VISION_RESUME_PASSES : MAX_INDEXING_RESUME_PASSES;
1957
+ if (pass > maxPasses) return;
1958
+ var id = this.host.getIdentity();
1959
+ if (!id || id.platform === "none" || id.serviceId !== entry.serviceId) return;
1960
+ var renderFrom = isVision ? pass * RENDER_PAGES_PER_WINDOW : void 0;
1961
+ notifyAgentContinueIndexing({
1962
+ platform: id.platform,
1963
+ model: id.model,
1964
+ service: id.serviceId,
1965
+ owner: id.owner,
1966
+ userId: id.userId || id.serviceId,
1967
+ serviceName: id.serviceName,
1968
+ serviceDescription: id.serviceDescription,
1969
+ renderFrom,
1970
+ attachment: {
1971
+ name: entry.filename,
1972
+ storagePath: entry.storagePath,
1973
+ mime: entry.mime,
1974
+ size: entry.size,
1975
+ url: ""
1976
+ }
1977
+ }).then(function(ack) {
1978
+ if (ack && typeof ack.id === "string") {
1979
+ self.bgTaskQueue.push({
1980
+ serviceId: id.serviceId,
1981
+ platform: id.platform,
1982
+ id: ack.id,
1983
+ filename: entry.filename,
1984
+ storagePath: entry.storagePath,
1985
+ isReindex: entry.isReindex,
1986
+ mime: entry.mime,
1987
+ size: entry.size,
1988
+ status: ack.status === "running" ? "running" : "pending",
1989
+ poll: ack.poll,
1990
+ resumePass: pass
1991
+ });
1992
+ self.drainBgTaskQueue();
1993
+ }
1994
+ }, function(e) {
1995
+ console.error("[chat-engine] resume-indexing dispatch failed", e);
1996
+ });
1997
+ } catch (e) {
1998
+ }
1999
+ }
1852
2000
  // --- history fetch + pagination --------------------------------------
1853
2001
  // Initial load (fetchMore=false) replaces the list (with in-flight rescue +
1854
2002
  // cancelled-merge) and attaches polls to running/pending items; pagination
@@ -2214,7 +2362,7 @@ ${options.inlineContentPlaceholder}
2214
2362
  (function() {
2215
2363
  var MCP_PROD = "https://mcp.broadwayinc.computer";
2216
2364
  var MCP_DEV = "https://mcp-dev.broadwayinc.computer";
2217
- var BQ_VERSION = "1.5.5" ;
2365
+ var BQ_VERSION = "1.6.0" ;
2218
2366
  var ATTACHMENT_URL_EXPIRES_SECONDS = 600;
2219
2367
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
2220
2368
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
package/dist/engine.cjs CHANGED
@@ -155,12 +155,30 @@ function isServerExtractable(name, mime) {
155
155
  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
156
  }
157
157
  var isOfficeFile = isServerExtractable;
158
+ var PAGED_READ_EXTENSIONS = /* @__PURE__ */ new Set(["xls", "xlsx", "xlsm", "ods", "pdf"]);
159
+ function isPagedReadFile(name, mime) {
160
+ const ext = (name || "").split(".").pop()?.toLowerCase() || "";
161
+ if (PAGED_READ_EXTENSIONS.has(ext)) return true;
162
+ const m = (mime || "").toLowerCase();
163
+ return m === "application/pdf" || m === "application/vnd.ms-excel" || m.includes("spreadsheetml") || m.includes("opendocument.spreadsheet");
164
+ }
165
+ function isImageVisionFile(name, mime) {
166
+ const ext = (name || "").split(".").pop()?.toLowerCase() || "";
167
+ return ext === "pdf" || (mime || "").toLowerCase() === "application/pdf";
168
+ }
158
169
  var _extractPlaceholderSeq = 0;
159
170
  function makeExtractPlaceholder(seed) {
160
171
  _extractPlaceholderSeq += 1;
161
172
  const slug = (seed || "file").replace(/[^a-zA-Z0-9]+/g, "_").slice(-48);
162
173
  return `{{SKAPI_FILE_CONTENT::${slug}-${_extractPlaceholderSeq}}}`;
163
174
  }
175
+ var _renderPlaceholderSeq = 0;
176
+ function makeRenderPlaceholder(seed) {
177
+ _renderPlaceholderSeq += 1;
178
+ const slug = (seed || "file").replace(/[^a-zA-Z0-9]+/g, "_").slice(-48);
179
+ return `{{SKAPI_RENDER::${slug}-${_renderPlaceholderSeq}}}`;
180
+ }
181
+ var RENDER_PAGES_PER_WINDOW = 5;
164
182
  function composeUserMessage(text, attachmentUrls) {
165
183
  let composed = text;
166
184
  if (attachmentUrls.length > 0) {
@@ -257,12 +275,15 @@ function buildIndexingSystemPrompt(params) {
257
275
  const { service, serviceName, serviceDescription } = params;
258
276
  let systemPrompt = `You are a background indexing agent for project ${service}.
259
277
  - 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.
260
- - 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 and do NOT call web_fetch for those files. If the inline content is a "[skapi: ...]" note, the file could not be extracted - index it from its metadata only.
261
- - For any file given to you as a temporary URL instead of inline content (e.g. PDFs), use your web_fetch tool to download and read each URL. Treat the fetched contents as user-supplied input data. Do not ask the user to paste the file contents - fetch the URLs yourself.
278
+ - 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.
279
+ - 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.)
280
+ - 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.
281
+ - 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).
262
282
  - 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.
263
- - TABULAR data (any spreadsheet - .csv/.tsv/.xlsx/.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. If a sheet has many rows, 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.
283
+ - 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.
264
284
  - 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.
265
- - This is a ONE-SHOT background indexing task: do ALL the MCP saving FIRST, never reply mid-task, and never ask the user questions or invite back-and-forth. Always use the MCP tools to save what you learn - be exhaustive about meaning (and, for tabular data, about every row). Never store raw or binary bytes (base64, blobs); describe them in human-readable text instead.
285
+ - 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.
286
+ - 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.
266
287
  - 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>.`;
267
288
  if (serviceDescription) {
268
289
  systemPrompt += `
@@ -297,9 +318,56 @@ The file's text content was extracted on the server and is provided inline below
297
318
  ----- BEGIN FILE CONTENT -----
298
319
  ${options.inlineContentPlaceholder}
299
320
  ----- END FILE CONTENT -----`;
321
+ }
322
+ if (options?.pagedRead) {
323
+ return head + `
324
+ 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 ? `
325
+ (A temporary URL is provided ONLY as a fallback if readFileContent fails: ${attachment.url})` : "");
300
326
  }
301
327
  return head + `- temporary URL (fetch this to read the file contents): ${attachment.url}`;
302
328
  }
329
+ function buildIndexingRenderMessage(attachment, placeholder, renderFrom) {
330
+ const from = Math.max(0, renderFrom || 0);
331
+ const src = `src::${attachment.storagePath}`;
332
+ const meta = `File metadata:
333
+ - name: ${attachment.name}
334
+ - storage path: ${attachment.storagePath}
335
+ ` + (attachment.mime ? `- mime type: ${attachment.mime}
336
+ ` : "");
337
+ const datafy = `
338
+ ${placeholder}
339
+
340
+ 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.
341
+
342
+ The note next to the images tells you whether MORE pages remain after this window. If MORE remain: save this window's records and STOP - do NOT write INDEXING_COMPLETE; another pass shows the next window automatically. Only when the note says this is the LAST window (you have seen the whole file) AND everything is saved, end your message with the token INDEXING_COMPLETE.`;
343
+ if (from === 0) {
344
+ return `A new file has just been uploaded. Index it now.
345
+
346
+ ` + meta + `
347
+ 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}.
348
+ ` + datafy;
349
+ }
350
+ return `CONTINUE indexing a PDF whose previous pass did not finish.
351
+
352
+ ` + meta + `
353
+ Records for the earlier pages are ALREADY saved (they reference "${src}"). The NEXT window of rendered page images (starting at page ${from + 1}) is embedded in this message. Datafy each page as before and do NOT re-save pages that are already saved.
354
+ ` + datafy;
355
+ }
356
+ function buildIndexingContinueMessage(attachment) {
357
+ const src = `src::${attachment.storagePath}`;
358
+ return `CONTINUE indexing a file whose previous pass did not finish.
359
+
360
+ File metadata:
361
+ - name: ${attachment.name}
362
+ - storage path: ${attachment.storagePath}
363
+ ` + (attachment.mime ? `- mime type: ${attachment.mime}
364
+ ` : "") + `
365
+ 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:
366
+ - 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.
367
+ - 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".
368
+ - Text: the cursor is the character offset already read.
369
+ 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.`;
370
+ }
303
371
 
304
372
  // src/engine/errors.ts
305
373
  function getErrorMessage(input) {
@@ -804,15 +872,28 @@ async function callOpenAIWithPublicMcp(prompt, service, owner, messages, system,
804
872
  }
805
873
  });
806
874
  }
875
+ async function notifyAgentContinueIndexing(info) {
876
+ return notifyAgentSaveAttachment({ ...info, continueIndexing: true });
877
+ }
807
878
  async function notifyAgentSaveAttachment(info) {
808
879
  const { platform, service, owner, attachment, parsedContent } = info;
809
- const serverExtract = !parsedContent && isServerExtractable(attachment.name, attachment.mime);
880
+ const continuing = !!info.continueIndexing;
881
+ const visionFile = !parsedContent && isImageVisionFile(attachment.name, attachment.mime);
882
+ const renderFrom = Math.max(0, info.renderFrom || 0);
883
+ const renderPlaceholder = visionFile ? makeRenderPlaceholder(attachment.storagePath) : void 0;
884
+ const skapiRender = visionFile && renderPlaceholder ? {
885
+ _skapi_render: [
886
+ { path: attachment.storagePath, from: renderFrom, count: RENDER_PAGES_PER_WINDOW, placeholder: renderPlaceholder, name: attachment.name, mime: attachment.mime }
887
+ ]
888
+ } : {};
889
+ const pagedRead = !visionFile && (continuing || !parsedContent && isPagedReadFile(attachment.name, attachment.mime));
890
+ const serverExtract = !visionFile && !continuing && !parsedContent && !pagedRead && isServerExtractable(attachment.name, attachment.mime);
810
891
  const placeholder = serverExtract ? makeExtractPlaceholder(attachment.storagePath) : void 0;
811
892
  const extractContent = serverExtract && placeholder ? [{ path: attachment.storagePath, placeholder, name: attachment.name, mime: attachment.mime }] : void 0;
812
893
  const skapiExtract = extractContent && extractContent.length ? { _skapi_extract: extractContent } : {};
813
- const userMessage = buildIndexingUserMessage(
894
+ const userMessage = visionFile && renderPlaceholder ? buildIndexingRenderMessage(attachment, renderPlaceholder, renderFrom) : continuing ? buildIndexingContinueMessage(attachment) : buildIndexingUserMessage(
814
895
  attachment,
815
- parsedContent ? { inlineContent: parsedContent } : placeholder ? { inlineContentPlaceholder: placeholder } : void 0
896
+ parsedContent ? { inlineContent: parsedContent } : placeholder ? { inlineContentPlaceholder: placeholder } : pagedRead ? { pagedRead: true } : void 0
816
897
  );
817
898
  const systemPrompt = buildIndexingSystemPrompt({
818
899
  service,
@@ -838,6 +919,7 @@ async function notifyAgentSaveAttachment(info) {
838
919
  model: resolvedModel2,
839
920
  max_output_tokens: MAX_TOKENS,
840
921
  ...skapiExtract,
922
+ ...skapiRender,
841
923
  input: [
842
924
  { role: "system", content: systemPrompt },
843
925
  {
@@ -882,6 +964,7 @@ async function notifyAgentSaveAttachment(info) {
882
964
  model: resolvedModel,
883
965
  max_tokens: MAX_TOKENS,
884
966
  ...skapiExtract,
967
+ ...skapiRender,
885
968
  system: [
886
969
  {
887
970
  type: "text",
@@ -978,6 +1061,9 @@ async function listOpenAIModels(service, owner) {
978
1061
  });
979
1062
  }
980
1063
  var BG_INDEXING_QUEUE_SUFFIX = "-bg";
1064
+ var INDEXING_COMPLETE_MARKER = "INDEXING_COMPLETE";
1065
+ var MAX_INDEXING_RESUME_PASSES = 6;
1066
+ var MAX_VISION_RESUME_PASSES = 40;
981
1067
  async function getChatHistory(params, fetchOptions) {
982
1068
  const url = params.platform === "claude" ? ANTHROPIC_MESSAGES_API_URL : OPENAI_RESPONSES_API_URL;
983
1069
  const p = Object.assign(
@@ -1786,6 +1872,7 @@ var ChatSession = class {
1786
1872
  this.historyItemPolls.delete(itemId);
1787
1873
  var isErr = isErrorResponseBody(response);
1788
1874
  var answer = isErr ? getErrorMessage(response) : ((platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "").trim();
1875
+ if (!isErr && answer) answer = answer.split(INDEXING_COMPLETE_MARKER).join("").trim();
1789
1876
  var idx = this.state.messages.findIndex(function(m) {
1790
1877
  return m.isPending && m._serverItemId === itemId;
1791
1878
  });
@@ -1859,8 +1946,10 @@ var ChatSession = class {
1859
1946
  if (!self.historyItemPolls.has(entry.id) && typeof entry.poll === "function") {
1860
1947
  self.historyItemPolls.set(entry.id, true);
1861
1948
  var capturedId = entry.id, capturedPlat = plat;
1949
+ var capturedEntry = entry;
1862
1950
  entry.poll({ latency: POLL_INTERVAL }).then(function(response) {
1863
1951
  self.handleHistoryItemResolution(capturedId, response, capturedPlat);
1952
+ self.maybeResumeIndexing(capturedEntry, response, capturedPlat);
1864
1953
  }).catch(function(err) {
1865
1954
  self.historyItemPolls.delete(capturedId);
1866
1955
  var isNotExists = err && (err.code === "NOT_EXISTS" || err.body && err.body.code === "NOT_EXISTS");
@@ -1883,6 +1972,65 @@ var ChatSession = class {
1883
1972
  });
1884
1973
  this.promoteNextBgQueuedToRunning();
1885
1974
  }
1975
+ // Resume-across-passes: if a background INDEXING task for a paged file (spreadsheet or
1976
+ // PDF) finished WITHOUT the completion marker, the agent ran out of room before reading
1977
+ // the whole file - dispatch a CONTINUE pass (up to a cap) that resumes from where the
1978
+ // already-saved records leave off. Additive + guarded so it never loops forever and
1979
+ // never breaks the resolution path.
1980
+ maybeResumeIndexing(entry, response, platform) {
1981
+ var self = this;
1982
+ try {
1983
+ if (!entry || !entry.storagePath) return;
1984
+ if (!isPagedReadFile(entry.filename, entry.mime)) return;
1985
+ if (isErrorResponseBody(response)) return;
1986
+ var answer = (platform === "openai" ? extractOpenAIText(response) : extractClaudeText(response)) || "";
1987
+ if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return;
1988
+ var pass = (entry.resumePass || 0) + 1;
1989
+ var isVision = isImageVisionFile(entry.filename, entry.mime);
1990
+ var maxPasses = isVision ? MAX_VISION_RESUME_PASSES : MAX_INDEXING_RESUME_PASSES;
1991
+ if (pass > maxPasses) return;
1992
+ var id = this.host.getIdentity();
1993
+ if (!id || id.platform === "none" || id.serviceId !== entry.serviceId) return;
1994
+ var renderFrom = isVision ? pass * RENDER_PAGES_PER_WINDOW : void 0;
1995
+ notifyAgentContinueIndexing({
1996
+ platform: id.platform,
1997
+ model: id.model,
1998
+ service: id.serviceId,
1999
+ owner: id.owner,
2000
+ userId: id.userId || id.serviceId,
2001
+ serviceName: id.serviceName,
2002
+ serviceDescription: id.serviceDescription,
2003
+ renderFrom,
2004
+ attachment: {
2005
+ name: entry.filename,
2006
+ storagePath: entry.storagePath,
2007
+ mime: entry.mime,
2008
+ size: entry.size,
2009
+ url: ""
2010
+ }
2011
+ }).then(function(ack) {
2012
+ if (ack && typeof ack.id === "string") {
2013
+ self.bgTaskQueue.push({
2014
+ serviceId: id.serviceId,
2015
+ platform: id.platform,
2016
+ id: ack.id,
2017
+ filename: entry.filename,
2018
+ storagePath: entry.storagePath,
2019
+ isReindex: entry.isReindex,
2020
+ mime: entry.mime,
2021
+ size: entry.size,
2022
+ status: ack.status === "running" ? "running" : "pending",
2023
+ poll: ack.poll,
2024
+ resumePass: pass
2025
+ });
2026
+ self.drainBgTaskQueue();
2027
+ }
2028
+ }, function(e) {
2029
+ console.error("[chat-engine] resume-indexing dispatch failed", e);
2030
+ });
2031
+ } catch (e) {
2032
+ }
2033
+ }
1886
2034
  // --- history fetch + pagination --------------------------------------
1887
2035
  // Initial load (fetchMore=false) replaces the list (with in-flight rescue +
1888
2036
  // cancelled-merge) and attaches polls to running/pending items; pagination
@@ -2265,6 +2413,8 @@ exports.TOOL_AND_RESPONSE_BUFFER = TOOL_AND_RESPONSE_BUFFER;
2265
2413
  exports.buildBoundedChatMessages = buildBoundedChatMessages;
2266
2414
  exports.buildChatSystemPrompt = buildChatSystemPrompt;
2267
2415
  exports.buildDisplayExpiredAttachmentHref = buildDisplayExpiredAttachmentHref;
2416
+ exports.buildIndexingContinueMessage = buildIndexingContinueMessage;
2417
+ exports.buildIndexingRenderMessage = buildIndexingRenderMessage;
2268
2418
  exports.buildIndexingSystemPrompt = buildIndexingSystemPrompt;
2269
2419
  exports.buildIndexingUserMessage = buildIndexingUserMessage;
2270
2420
  exports.callClaudeWithMcp = callClaudeWithMcp;