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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.5.6",
3
+ "version": "1.6.0",
4
4
  "description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
5
5
  "main": "bunnyquery.js",
6
6
  "exports": {
@@ -101,6 +101,42 @@ export function isServerExtractable(name?: string, mime?: string): boolean {
101
101
  /** @deprecated renamed to {@link isServerExtractable} (now also covers text files). */
102
102
  export const isOfficeFile = isServerExtractable;
103
103
 
104
+ // Extensions that are best read WINDOW-BY-WINDOW via the readFileContent tool instead of
105
+ // inlined once: spreadsheets (grid rows + embedded photos, and can be huge) and PDFs
106
+ // (scanned page images). For these the indexing agent is told to page through the whole
107
+ // file with readFileContent rather than reading a capped inline dump or web_fetching a URL.
108
+ const PAGED_READ_EXTENSIONS = new Set(['xls', 'xlsx', 'xlsm', 'ods', 'pdf']);
109
+
110
+ /**
111
+ * True when a file should be indexed by PAGING through readFileContent (spreadsheets and
112
+ * PDFs), rather than inline extraction or a web_fetch URL. This is what lets a huge sheet
113
+ * be read row-window by row-window and a scanned PDF be read page-image by page-image,
114
+ * with embedded photos delivered to the vision model.
115
+ */
116
+ export function isPagedReadFile(name?: string, mime?: string): boolean {
117
+ const ext = (name || '').split('.').pop()?.toLowerCase() || '';
118
+ if (PAGED_READ_EXTENSIONS.has(ext)) return true;
119
+ const m = (mime || '').toLowerCase();
120
+ return (
121
+ m === 'application/pdf' ||
122
+ m === 'application/vnd.ms-excel' ||
123
+ m.includes('spreadsheetml') ||
124
+ m.includes('opendocument.spreadsheet')
125
+ );
126
+ }
127
+
128
+ /**
129
+ * True for files whose content is VISUAL and must be delivered to the model as IMAGE
130
+ * BLOCKS in the message (rendered pages), because tool-result images render on neither
131
+ * provider. The worker renders a page window to image URLs and injects them (`_skapi_render`
132
+ * directive). Currently PDFs (scanned or not); indexed page-window by page-window with
133
+ * resume advancing the window.
134
+ */
135
+ export function isImageVisionFile(name?: string, mime?: string): boolean {
136
+ const ext = (name || '').split('.').pop()?.toLowerCase() || '';
137
+ return ext === 'pdf' || (mime || '').toLowerCase() === 'application/pdf';
138
+ }
139
+
104
140
  // Monotonic counter so placeholders are unique even for same-named files in one
105
141
  // request. Token shape must match the worker's _EXTRACT_PLACEHOLDER_RE:
106
142
  // {{SKAPI_FILE_CONTENT::<id>}}.
@@ -111,6 +147,19 @@ export function makeExtractPlaceholder(seed: string): string {
111
147
  return `{{SKAPI_FILE_CONTENT::${slug}-${_extractPlaceholderSeq}}}`;
112
148
  }
113
149
 
150
+ // Placeholder marking WHERE the worker injects a window of rendered page/photo IMAGE blocks
151
+ // (the `_skapi_render` directive). Distinct token from the text-extraction placeholder.
152
+ let _renderPlaceholderSeq = 0;
153
+ export function makeRenderPlaceholder(seed: string): string {
154
+ _renderPlaceholderSeq += 1;
155
+ const slug = (seed || 'file').replace(/[^a-zA-Z0-9]+/g, '_').slice(-48);
156
+ return `{{SKAPI_RENDER::${slug}-${_renderPlaceholderSeq}}}`;
157
+ }
158
+
159
+ // Page/photo images per render window. Must match the server default so the client's
160
+ // resume window (from = pass * PAGES) lines up with what the worker renders.
161
+ export const RENDER_PAGES_PER_WINDOW = 5;
162
+
114
163
  export interface ComposedUserMessage {
115
164
  /** Clean display/history copy (attachment links, NO extraction placeholders). */
116
165
  composed: string;
@@ -9,6 +9,8 @@ export { buildChatSystemPrompt, type ChatSystemPromptParams } from './chat_syste
9
9
  export { buildIndexingSystemPrompt, type IndexingSystemPromptParams } from './indexing_system_prompt';
10
10
  export {
11
11
  buildIndexingUserMessage,
12
+ buildIndexingContinueMessage,
13
+ buildIndexingRenderMessage,
12
14
  type IndexingAttachmentInfo,
13
15
  type BuildIndexingUserMessageOptions,
14
16
  } from './indexing_user_message';
@@ -23,12 +23,14 @@ export function buildIndexingSystemPrompt(params: IndexingSystemPromptParams): s
23
23
  `You are a background indexing agent for project ${service}.
24
24
  - 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.
25
25
  - 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.
26
- - 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.
27
- - 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).
26
+ - 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.)
27
+ - 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.
28
+ - 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).
28
29
  - 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.
29
30
  - 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.
30
31
  - 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.
31
- - 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.
32
+ - 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.
33
+ - 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.
32
34
  - 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>.`;
33
35
 
34
36
  if (serviceDescription) {
@@ -36,6 +36,12 @@ export type BuildIndexingUserMessageOptions = {
36
36
  * web_fetch for this file. Takes precedence over `inlineContentPlaceholder`.
37
37
  */
38
38
  inlineContent?: string;
39
+ /**
40
+ * Spreadsheet or PDF: read by PAGING through the readFileContent tool (grid rows +
41
+ * embedded photos / rendered scanned pages), not inline and not by web_fetch. The
42
+ * message instructs the agent to page through EVERY window and datafy each.
43
+ */
44
+ pagedRead?: boolean;
39
45
  };
40
46
 
41
47
  export function buildIndexingUserMessage(
@@ -78,5 +84,101 @@ export function buildIndexingUserMessage(
78
84
  );
79
85
  }
80
86
 
87
+ if (options?.pagedRead) {
88
+ // Spreadsheet / PDF: force the paging path. The agent MUST read this with the
89
+ // readFileContent tool (which returns the file window by window, with grid rows,
90
+ // embedded photos, and rendered scanned pages), NOT by fetching the URL.
91
+ return (
92
+ head +
93
+ `\nRead this file with the readFileContent tool, using the storage path above - do NOT fetch a URL and do NOT rely on a single sample. ` +
94
+ `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. ` +
95
+ `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. ` +
96
+ `Do NOT stop after the first window and do NOT just write a summary. Use the storage path above for the "src::" unique_id.` +
97
+ (attachment.url ? `\n(A temporary URL is provided ONLY as a fallback if readFileContent fails: ${attachment.url})` : '')
98
+ );
99
+ }
100
+
81
101
  return head + `- temporary URL (fetch this to read the file contents): ${attachment.url}`;
82
102
  }
103
+
104
+ /**
105
+ * User message for a VISION file (PDF): its pages are delivered as RENDERED PAGE IMAGES that
106
+ * the proxy worker injects into THIS message at the `placeholder` token (tool-result images
107
+ * render on neither provider, so the pages must be image blocks in the message itself). Each
108
+ * pass shows one WINDOW of pages starting at `renderFrom` (0-based); the resume loop advances
109
+ * the window a pass at a time until the injected note says the last window was reached.
110
+ *
111
+ * renderFrom === 0 is the FIRST pass (leads with "A new file has just been uploaded." so the
112
+ * client builds the "Indexing: <name>" bubble); renderFrom > 0 is a RESUME pass (leads with
113
+ * "CONTINUE indexing" like the paged continue message, so it is not a duplicate primary bubble).
114
+ */
115
+ export function buildIndexingRenderMessage(
116
+ attachment: IndexingAttachmentInfo,
117
+ placeholder: string,
118
+ renderFrom: number,
119
+ ): string {
120
+ const from = Math.max(0, renderFrom || 0);
121
+ const src = `src::${attachment.storagePath}`;
122
+ const meta =
123
+ `File metadata:\n` +
124
+ `- name: ${attachment.name}\n` +
125
+ `- storage path: ${attachment.storagePath}\n` +
126
+ (attachment.mime ? `- mime type: ${attachment.mime}\n` : '');
127
+
128
+ // Shared datafy + completion guidance. The placeholder is where the worker splices the
129
+ // note + rendered page images; instructions reference "the page images in this message"
130
+ // so they read correctly whether the images land before or after this text.
131
+ const datafy =
132
+ `\n${placeholder}\n\n` +
133
+ `LOOK at each rendered page image in this message and DATAFY what it shows: for EVERY page ` +
134
+ `call postRecords and save records - one record per row / table entry / line item visible on the page ` +
135
+ `(or one record for the page if it is prose), capturing every value you can read (OCR the text, read tables ` +
136
+ `cell by cell, describe any photos/diagrams). Use the storage path above for the "src::" unique_id.\n\n` +
137
+ `The note next to the images tells you whether MORE pages remain after this window. ` +
138
+ `If MORE remain: save this window's records and STOP - do NOT write INDEXING_COMPLETE; another pass shows the next window automatically. ` +
139
+ `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.`;
140
+
141
+ if (from === 0) {
142
+ return (
143
+ `A new file has just been uploaded. Index it now.\n\n` +
144
+ meta +
145
+ `\nThis is a PDF. Its pages are delivered to you as RENDERED PAGE IMAGES embedded directly in this ` +
146
+ `message (you do NOT need any tool, URL, or web_fetch to see them). You are shown a WINDOW of pages ` +
147
+ `at a time, starting at page ${from + 1}.\n` +
148
+ datafy
149
+ );
150
+ }
151
+
152
+ return (
153
+ `CONTINUE indexing a PDF whose previous pass did not finish.\n\n` +
154
+ meta +
155
+ `\nRecords for the earlier pages are ALREADY saved (they reference "${src}"). The NEXT window of ` +
156
+ `rendered page images (starting at page ${from + 1}) is embedded in this message. Datafy each page as ` +
157
+ `before and do NOT re-save pages that are already saved.\n` +
158
+ datafy
159
+ );
160
+ }
161
+
162
+ /**
163
+ * User message for a RESUME pass: a previous indexing pass could not finish this large
164
+ * file, so continue it from where the already-saved records leave off (never restart).
165
+ */
166
+ export function buildIndexingContinueMessage(attachment: IndexingAttachmentInfo): string {
167
+ const src = `src::${attachment.storagePath}`;
168
+ return (
169
+ `CONTINUE indexing a file whose previous pass did not finish.\n\n` +
170
+ `File metadata:\n` +
171
+ `- name: ${attachment.name}\n` +
172
+ `- storage path: ${attachment.storagePath}\n` +
173
+ (attachment.mime ? `- mime type: ${attachment.mime}\n` : '') +
174
+ `\nRecords for the earlier windows/pages of this file are ALREADY saved (they reference "${src}"). ` +
175
+ `First call getRecords with reference "${src}" to see how far the previous pass got (the furthest page/row/window already saved). ` +
176
+ `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:\n` +
177
+ ` - 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.\n` +
178
+ ` - 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".\n` +
179
+ ` - Text: the cursor is the character offset already read.\n` +
180
+ `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. ` +
181
+ `Do NOT re-save windows that are already saved. ` +
182
+ `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.`
183
+ );
184
+ }
@@ -10,8 +10,8 @@
10
10
  * state that stays in the consumer, not here;
11
11
  * only the BgTaskEntry TYPE lives here)
12
12
  */
13
- import { buildIndexingSystemPrompt, buildIndexingUserMessage } from './prompts';
14
- import { isServerExtractable, makeExtractPlaceholder, type ExtractDirective, type FileUrlDirective } from './office';
13
+ import { buildIndexingSystemPrompt, buildIndexingUserMessage, buildIndexingContinueMessage, buildIndexingRenderMessage } from './prompts';
14
+ import { isServerExtractable, isPagedReadFile, isImageVisionFile, makeExtractPlaceholder, makeRenderPlaceholder, RENDER_PAGES_PER_WINDOW, type ExtractDirective, type FileUrlDirective } from './office';
15
15
  import { chatEngineConfig, pollOpt } from './config';
16
16
 
17
17
  export const ANTHROPIC_MESSAGES_API_URL = 'https://api.anthropic.com/v1/messages';
@@ -442,16 +442,56 @@ export type AttachmentSaveInfo = {
442
442
  * takes precedence over server-side office extraction / web_fetch.
443
443
  */
444
444
  parsedContent?: string;
445
+ /**
446
+ * True for a RESUME pass: a previous indexing pass could not finish this (large)
447
+ * file, so continue it - always via readFileContent paging, with a "continue"
448
+ * message telling the agent to resume from where the saved records leave off.
449
+ */
450
+ continueIndexing?: boolean;
451
+ /**
452
+ * For an image-vision file (PDF), the 0-based PAGE the render window should start at.
453
+ * The worker renders [renderFrom, renderFrom+RENDER_PAGES_PER_WINDOW) and injects them
454
+ * as image blocks; the resume loop advances this by a window each pass.
455
+ */
456
+ renderFrom?: number;
445
457
  };
446
458
 
459
+ // RESUME pass: continue indexing a large file a previous pass could not finish. Same
460
+ // dispatch as notifyAgentSaveAttachment, but forced onto the paging path with a
461
+ // "continue from where the saved records leave off" message.
462
+ export async function notifyAgentContinueIndexing(info: AttachmentSaveInfo) {
463
+ return notifyAgentSaveAttachment({ ...info, continueIndexing: true });
464
+ }
465
+
447
466
  // Background "save into knowledge" call (not a chat turn). A client-parsed file
448
467
  // (parser plugin) is inlined directly; otherwise office files get the
449
468
  // _skapi_extract directive + a placeholder, and everything else gets a URL.
450
469
  export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
451
470
  const { platform, service, owner, attachment, parsedContent } = info;
452
471
 
472
+ // A CONTINUE pass resumes a large file that a previous pass could not finish.
473
+ const continuing = !!info.continueIndexing;
474
+
475
+ // VISION files (PDFs) are delivered as rendered page IMAGES injected into the message by
476
+ // the worker (`_skapi_render`), because tool-result images render on neither provider.
477
+ // Both the first pass and every resume pass use this; renderFrom advances the page window.
478
+ const visionFile = !parsedContent && isImageVisionFile(attachment.name, attachment.mime);
479
+ const renderFrom = Math.max(0, info.renderFrom || 0);
480
+ const renderPlaceholder = visionFile ? makeRenderPlaceholder(attachment.storagePath) : undefined;
481
+ const skapiRender = visionFile && renderPlaceholder
482
+ ? {
483
+ _skapi_render: [
484
+ { path: attachment.storagePath, from: renderFrom, count: RENDER_PAGES_PER_WINDOW, placeholder: renderPlaceholder, name: attachment.name, mime: attachment.mime },
485
+ ],
486
+ }
487
+ : {};
488
+
489
+ // Spreadsheets are read by PAGING through readFileContent (grid rows), NOT inlined - so
490
+ // they skip the inline server-extract and the agent is told to page the whole file.
491
+ const pagedRead = !visionFile && (continuing || (!parsedContent && isPagedReadFile(attachment.name, attachment.mime)));
492
+
453
493
  // Client-parsed content wins over server-side extraction.
454
- const serverExtract = !parsedContent && isServerExtractable(attachment.name, attachment.mime);
494
+ const serverExtract = !visionFile && !continuing && !parsedContent && !pagedRead && isServerExtractable(attachment.name, attachment.mime);
455
495
  const placeholder = serverExtract ? makeExtractPlaceholder(attachment.storagePath) : undefined;
456
496
  const extractContent: ExtractDirective[] | undefined =
457
497
  serverExtract && placeholder
@@ -460,14 +500,20 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
460
500
  const skapiExtract =
461
501
  extractContent && extractContent.length ? { _skapi_extract: extractContent } : {};
462
502
 
463
- const userMessage = buildIndexingUserMessage(
464
- attachment,
465
- parsedContent
466
- ? { inlineContent: parsedContent }
467
- : placeholder
468
- ? { inlineContentPlaceholder: placeholder }
469
- : undefined,
470
- );
503
+ const userMessage = (visionFile && renderPlaceholder)
504
+ ? buildIndexingRenderMessage(attachment, renderPlaceholder, renderFrom)
505
+ : continuing
506
+ ? buildIndexingContinueMessage(attachment)
507
+ : buildIndexingUserMessage(
508
+ attachment,
509
+ parsedContent
510
+ ? { inlineContent: parsedContent }
511
+ : placeholder
512
+ ? { inlineContentPlaceholder: placeholder }
513
+ : pagedRead
514
+ ? { pagedRead: true }
515
+ : undefined,
516
+ );
471
517
 
472
518
  const systemPrompt = buildIndexingSystemPrompt({
473
519
  service,
@@ -494,6 +540,7 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
494
540
  model: resolvedModel,
495
541
  max_output_tokens: MAX_TOKENS,
496
542
  ...skapiExtract,
543
+ ...skapiRender,
497
544
  input: [
498
545
  { role: 'system', content: systemPrompt },
499
546
  {
@@ -541,6 +588,7 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
541
588
  model: resolvedModel,
542
589
  max_tokens: MAX_TOKENS,
543
590
  ...skapiExtract,
591
+ ...skapiRender,
544
592
  system: [
545
593
  {
546
594
  type: 'text',
@@ -679,8 +727,25 @@ export type BgTaskEntry = {
679
727
  size?: number;
680
728
  status: 'running' | 'pending';
681
729
  poll: ((opts: { latency: number }) => Promise<any>) | undefined;
730
+ /** How many CONTINUE passes have already run for this file (resume-across-passes). */
731
+ resumePass?: number;
682
732
  };
683
733
 
734
+ // Token the indexing agent appends to its final message ONLY when it has fully read and
735
+ // saved the whole file. Its ABSENCE is what tells the client to run another CONTINUE pass.
736
+ export const INDEXING_COMPLETE_MARKER = 'INDEXING_COMPLETE';
737
+ // Cap on CONTINUE passes per file, so a file the agent can never mark complete (or a
738
+ // pathological loop) stops instead of re-dispatching forever. The text/grid paging path
739
+ // reads MANY windows within a single pass (the agent loops readFileContent in one turn), so
740
+ // a small cap suffices.
741
+ export const MAX_INDEXING_RESUME_PASSES = 6;
742
+ // The VISION path (rendered PDF pages) can only advance ONE page-window per pass, because the
743
+ // worker injects a single window of images into each request. A big scanned PDF therefore
744
+ // needs one pass PER window (pages / RENDER_PAGES_PER_WINDOW), so it gets a much higher cap.
745
+ // The loop still terminates naturally when the agent reaches the last window (it appends
746
+ // INDEXING_COMPLETE); this cap is only the backstop for a doc larger than it covers.
747
+ export const MAX_VISION_RESUME_PASSES = 40;
748
+
684
749
  export async function getChatHistory(
685
750
  params: { service?: string; owner?: string; platform: 'claude' | 'openai'; queue?: string },
686
751
  fetchOptions: Record<string, any>,
@@ -21,6 +21,10 @@ import {
21
21
  callClaudeWithPublicMcp,
22
22
  callOpenAIWithPublicMcp,
23
23
  notifyAgentSaveAttachment,
24
+ notifyAgentContinueIndexing,
25
+ INDEXING_COMPLETE_MARKER,
26
+ MAX_INDEXING_RESUME_PASSES,
27
+ MAX_VISION_RESUME_PASSES,
24
28
  extractClaudeText,
25
29
  extractOpenAIText,
26
30
  getChatHistory,
@@ -30,6 +34,7 @@ import {
30
34
  OPENAI_RESPONSES_API_URL,
31
35
  type BgTaskEntry,
32
36
  } from './requests';
37
+ import { isPagedReadFile, isImageVisionFile, RENDER_PAGES_PER_WINDOW } from './office';
33
38
  import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage } from './errors';
34
39
  import { buildBoundedChatMessages } from './budget';
35
40
  import { createInlineLinkRegex } from './links';
@@ -736,6 +741,8 @@ export class ChatSession {
736
741
  var isErr = isErrorResponseBody(response);
737
742
  var answer = isErr ? getErrorMessage(response)
738
743
  : ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim();
744
+ // Hide the internal indexing-completion marker from the displayed summary.
745
+ if (!isErr && answer) answer = answer.split(INDEXING_COMPLETE_MARKER).join('').trim();
739
746
  var idx = this.state.messages.findIndex(function (m) { return m.isPending && m._serverItemId === itemId; });
740
747
  if (idx !== -1) {
741
748
  // A bg "Indexing:" turn pushes a user bubble (isPendingInProcess) ALONGSIDE
@@ -806,8 +813,10 @@ export class ChatSession {
806
813
  if (!self.historyItemPolls.has(entry.id) && typeof entry.poll === 'function') {
807
814
  self.historyItemPolls.set(entry.id, true);
808
815
  var capturedId = entry.id, capturedPlat = plat;
816
+ var capturedEntry = entry;
809
817
  entry.poll({ latency: POLL_INTERVAL }).then(function (response: any) {
810
818
  self.handleHistoryItemResolution(capturedId, response, capturedPlat);
819
+ self.maybeResumeIndexing(capturedEntry, response, capturedPlat);
811
820
  }).catch(function (err: any) {
812
821
  self.historyItemPolls.delete(capturedId);
813
822
  var isNotExists = err && (err.code === 'NOT_EXISTS' || (err.body && err.body.code === 'NOT_EXISTS'));
@@ -826,6 +835,62 @@ export class ChatSession {
826
835
  this.promoteNextBgQueuedToRunning();
827
836
  }
828
837
 
838
+ // Resume-across-passes: if a background INDEXING task for a paged file (spreadsheet or
839
+ // PDF) finished WITHOUT the completion marker, the agent ran out of room before reading
840
+ // the whole file - dispatch a CONTINUE pass (up to a cap) that resumes from where the
841
+ // already-saved records leave off. Additive + guarded so it never loops forever and
842
+ // never breaks the resolution path.
843
+ maybeResumeIndexing(entry: BgTaskEntry, response: any, platform: string): void {
844
+ var self = this;
845
+ try {
846
+ if (!entry || !entry.storagePath) return;
847
+ if (!isPagedReadFile(entry.filename, entry.mime)) return;
848
+ if (isErrorResponseBody(response)) return; // a failed pass is not "incomplete"
849
+ var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '';
850
+ if (answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1) return; // fully indexed
851
+ var pass = (entry.resumePass || 0) + 1;
852
+ // Vision (rendered PDF pages) advances one window per pass, so it needs a much
853
+ // higher cap than the text/grid path (which reads many windows per pass).
854
+ var isVision = isImageVisionFile(entry.filename, entry.mime);
855
+ var maxPasses = isVision ? MAX_VISION_RESUME_PASSES : MAX_INDEXING_RESUME_PASSES;
856
+ if (pass > maxPasses) return; // give up after the cap
857
+ var id = this.host.getIdentity();
858
+ if (!id || id.platform === 'none' || id.serviceId !== entry.serviceId) return;
859
+ // VISION files (PDFs) resume by ADVANCING the rendered page window: pass N reads
860
+ // pages [N*WINDOW, (N+1)*WINDOW). Other paged files resume via a derived cursor
861
+ // (the continue message tells the agent to resume from the furthest saved record).
862
+ var renderFrom = isVision ? pass * RENDER_PAGES_PER_WINDOW : undefined;
863
+ notifyAgentContinueIndexing({
864
+ platform: id.platform as 'claude' | 'openai',
865
+ model: id.model,
866
+ service: id.serviceId,
867
+ owner: id.owner,
868
+ userId: id.userId || id.serviceId,
869
+ serviceName: id.serviceName,
870
+ serviceDescription: id.serviceDescription,
871
+ renderFrom: renderFrom,
872
+ attachment: {
873
+ name: entry.filename,
874
+ storagePath: entry.storagePath,
875
+ mime: entry.mime,
876
+ size: entry.size,
877
+ url: '',
878
+ },
879
+ }).then(function (ack: any) {
880
+ if (ack && typeof ack.id === 'string') {
881
+ self.bgTaskQueue.push({
882
+ serviceId: id.serviceId, platform: id.platform as 'claude' | 'openai', id: ack.id,
883
+ filename: entry.filename, storagePath: entry.storagePath,
884
+ isReindex: entry.isReindex, mime: entry.mime, size: entry.size,
885
+ status: ack.status === 'running' ? 'running' : 'pending',
886
+ poll: ack.poll, resumePass: pass,
887
+ });
888
+ self.drainBgTaskQueue();
889
+ }
890
+ }, function (e: any) { console.error('[chat-engine] resume-indexing dispatch failed', e); });
891
+ } catch (e) { /* best-effort: resume must never break bg-task resolution */ }
892
+ }
893
+
829
894
  // --- history fetch + pagination --------------------------------------
830
895
  // Initial load (fetchMore=false) replaces the list (with in-flight rescue +
831
896
  // cancelled-merge) and attaches polls to running/pending items; pagination