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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.5.7",
3
+ "version": "1.6.2",
4
4
  "description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
5
5
  "main": "bunnyquery.js",
6
6
  "exports": {
@@ -33,6 +33,20 @@ export interface ChatEngineConfig {
33
33
  * `registerAttachmentParser()`. See attachment_parsers.ts.
34
34
  */
35
35
  attachmentParsers?: AttachmentParser[];
36
+ /**
37
+ * Opt in to SERVER-DRIVEN windowed indexing for text/grid files.
38
+ *
39
+ * Off by default, and deliberately so. When on, the client emits a
40
+ * `_skapi_window` directive and the WORKER reads the file one window at a time,
41
+ * continuing until the reader says it is exhausted. When off, the agent pages the
42
+ * file itself with readFileContent, exactly as before.
43
+ *
44
+ * The flag exists because the backend must ship FIRST: a client emitting the
45
+ * directive against a worker that does not strip it leaves an unknown field in the
46
+ * request body, and the provider rejects the whole call with no retry. Keep it off
47
+ * until the worker is deployed, then flip it per environment.
48
+ */
49
+ windowedIndexing?: boolean;
36
50
  }
37
51
 
38
52
  let _config: ChatEngineConfig | null = null;
@@ -53,6 +67,11 @@ export function chatEngineConfig(): ChatEngineConfig {
53
67
  return _config;
54
68
  }
55
69
 
70
+ /** True when the consumer has opted in to server-driven windowed indexing. */
71
+ export function windowedIndexingEnabled(): boolean {
72
+ return _config?.windowedIndexing === true;
73
+ }
74
+
56
75
  /** Spread helper: `{ ...pollOpt() }` adds `poll` only when configured. */
57
76
  export function pollOpt(): { poll?: number } {
58
77
  const p = _config?.poll;
@@ -18,6 +18,18 @@ export interface ChatIdentity {
18
18
  serviceDescription?: string;
19
19
  }
20
20
 
21
+ /**
22
+ * Project context captured at the moment the user hit Send, so a turn whose
23
+ * dispatch is delayed (attachment uploads are awaited first) still reaches the
24
+ * project the question was asked of rather than whichever project the user has
25
+ * navigated to by then. Both fields are identity-derived and must be snapshotted
26
+ * together — the system prompt embeds the service name/description/id.
27
+ */
28
+ export interface PinnedDispatchContext {
29
+ identity: ChatIdentity;
30
+ systemPrompt: string;
31
+ }
32
+
21
33
  export interface ChatMessage {
22
34
  role: 'user' | 'assistant';
23
35
  content: string; // raw markdown — never HTML (the view parses for display)
@@ -34,6 +46,13 @@ export interface ChatMessage {
34
46
  _localId?: string;
35
47
  _cancelling?: boolean;
36
48
  _cancelError?: string;
49
+ // History cache key (`serviceId#platform`) this bubble was created under.
50
+ // Stamped on LOCALLY-created bubbles only (the optimistic user message and
51
+ // its "Thinking..." placeholder); server-mapped bubbles are identified by
52
+ // _serverItemId instead. The dashboard renders every project through ONE
53
+ // ChatSession singleton, so without this a bubble is unattributable and an
54
+ // in-flight turn from project A gets rescued/cached into project B.
55
+ _ownerKey?: string;
37
56
  }
38
57
 
39
58
  export interface ChatState {
@@ -57,12 +57,13 @@ export {
57
57
  // Tier-2: the stateful chat orchestration (queue/poll/cancel, typewriter,
58
58
  // bg-task drain, resolution). DOM-free; the consumer implements ChatHost.
59
59
  export { ChatSession } from './session';
60
- export type { ChatHost, ChatIdentity, ChatState, ChatMessage } from './host';
60
+ export type { ChatHost, ChatIdentity, ChatState, ChatMessage, PinnedDispatchContext } from './host';
61
61
 
62
62
  export {
63
63
  // constants
64
64
  POLL_INTERVAL,
65
65
  BG_INDEXING_QUEUE_SUFFIX,
66
+ isBgIndexingQueue,
66
67
  MCP_NAME,
67
68
  DEFAULT_CLAUDE_MODEL,
68
69
  DEFAULT_OPENAI_MODEL,
@@ -101,11 +101,32 @@ 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']);
104
+ // Extensions read WINDOW-BY-WINDOW via the readFileContent tool instead of inlined once.
105
+ //
106
+ // Anything NOT in this set falls back to one-shot server extraction, which is capped at
107
+ // MAX_EXTRACTED_CHARS (200k). Measured against real files, that cap was discarding the
108
+ // overwhelming majority of every large non-spreadsheet upload:
109
+ // 5MB .txt -> 4.0% indexed (4.8M characters silently dropped)
110
+ // 4.8MB .json-> 4.2%
111
+ // 1.9M-char Korean .txt -> 10.5%
112
+ // .docx -> 70.6%
113
+ // The truncation was invisible: the agent received a plausible-looking document and had
114
+ // no way to know most of it was missing. Windowing these types is what makes "index the
115
+ // whole file" true rather than aspirational.
116
+ //
117
+ // CSV/TSV specifically must be here rather than in the inline path: the layer now gives
118
+ // them ROW-bounded windows with absolute row numbers, where the character windower used
119
+ // to split rows across boundaries and emit no row numbers at all.
120
+ const PAGED_READ_EXTENSIONS = new Set([
121
+ // grids
122
+ 'xls', 'xlsx', 'xlsm', 'ods',
123
+ // delimited text (row-windowed by the layer)
124
+ 'csv', 'tsv', 'tab',
125
+ // documents
126
+ 'pdf', 'docx', 'pptx',
127
+ // plain text / data / markup
128
+ 'txt', 'md', 'markdown', 'log', 'json', 'jsonl', 'ndjson', 'xml', 'yaml', 'yml',
129
+ ]);
109
130
 
110
131
  /**
111
132
  * True when a file should be indexed by PAGING through readFileContent (spreadsheets and
@@ -125,6 +146,18 @@ export function isPagedReadFile(name?: string, mime?: string): boolean {
125
146
  );
126
147
  }
127
148
 
149
+ /**
150
+ * True for files whose content is VISUAL and must be delivered to the model as IMAGE
151
+ * BLOCKS in the message (rendered pages), because tool-result images render on neither
152
+ * provider. The worker renders a page window to image URLs and injects them (`_skapi_render`
153
+ * directive). Currently PDFs (scanned or not); indexed page-window by page-window with
154
+ * resume advancing the window.
155
+ */
156
+ export function isImageVisionFile(name?: string, mime?: string): boolean {
157
+ const ext = (name || '').split('.').pop()?.toLowerCase() || '';
158
+ return ext === 'pdf' || (mime || '').toLowerCase() === 'application/pdf';
159
+ }
160
+
128
161
  // Monotonic counter so placeholders are unique even for same-named files in one
129
162
  // request. Token shape must match the worker's _EXTRACT_PLACEHOLDER_RE:
130
163
  // {{SKAPI_FILE_CONTENT::<id>}}.
@@ -135,6 +168,46 @@ export function makeExtractPlaceholder(seed: string): string {
135
168
  return `{{SKAPI_FILE_CONTENT::${slug}-${_extractPlaceholderSeq}}}`;
136
169
  }
137
170
 
171
+ // Placeholder marking WHERE the worker injects a window of rendered page/photo IMAGE blocks
172
+ // (the `_skapi_render` directive). Distinct token from the text-extraction placeholder.
173
+ let _renderPlaceholderSeq = 0;
174
+ export function makeRenderPlaceholder(seed: string): string {
175
+ _renderPlaceholderSeq += 1;
176
+ const slug = (seed || 'file').replace(/[^a-zA-Z0-9]+/g, '_').slice(-48);
177
+ return `{{SKAPI_RENDER::${slug}-${_renderPlaceholderSeq}}}`;
178
+ }
179
+
180
+ // Page/photo images per render window. Must match the server default so the client's
181
+ // resume window (from = pass * PAGES) lines up with what the worker renders.
182
+ export const RENDER_PAGES_PER_WINDOW = 5;
183
+
184
+ // Token the WORKER substitutes with a human description of the next window's position.
185
+ // Shared with the render loop so one substitution path serves both.
186
+ export const WINDOW_CURSOR_TOKEN = '{{RENDER_FROM}}';
187
+
188
+ let _windowPlaceholderSeq = 0;
189
+
190
+ /**
191
+ * Placeholder the worker replaces with ONE window of a file's text/grid content.
192
+ * Distinct token from the render (page-image) and extract (whole-file) placeholders so
193
+ * a stale token from either can never be mistaken for this one.
194
+ */
195
+ export function makeWindowPlaceholder(seed: string): string {
196
+ _windowPlaceholderSeq += 1;
197
+ const slug = (seed || 'file').replace(/[^a-zA-Z0-9]+/g, '_').slice(-48);
198
+ return `{{SKAPI_WINDOW::${slug}-${_windowPlaceholderSeq}}}`;
199
+ }
200
+
201
+ /**
202
+ * True when a file should be read server-side, one window at a time, by the worker.
203
+ * PDFs are excluded: they go through the VISION path, where pages are rendered to
204
+ * images because their text layer is often absent or unreliable.
205
+ */
206
+ export function isWindowedReadFile(name?: string, mime?: string): boolean {
207
+ if (isImageVisionFile(name, mime)) return false;
208
+ return isPagedReadFile(name, mime);
209
+ }
210
+
138
211
  export interface ComposedUserMessage {
139
212
  /** Clean display/history copy (attachment links, NO extraction placeholders). */
140
213
  composed: string;
@@ -9,6 +9,11 @@ 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,
14
+ buildIndexingRenderContinueTemplate,
15
+ buildIndexingWindowMessage,
16
+ RENDER_FROM_TOKEN,
12
17
  type IndexingAttachmentInfo,
13
18
  type BuildIndexingUserMessageOptions,
14
19
  } 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) {
@@ -100,3 +100,162 @@ export function buildIndexingUserMessage(
100
100
 
101
101
  return head + `- temporary URL (fetch this to read the file contents): ${attachment.url}`;
102
102
  }
103
+
104
+ /**
105
+ * Token the WORKER substitutes with the 1-based first page of the window it is about to
106
+ * render, when it builds the next pass of a document from `RENDER_CONTINUE_TEMPLATE`.
107
+ * Must match the worker's RENDER_FROM_TOKEN.
108
+ */
109
+ export const RENDER_FROM_TOKEN = '{{RENDER_FROM}}';
110
+ const WINDOW_CURSOR_TOKEN = RENDER_FROM_TOKEN;
111
+
112
+ /**
113
+ * User message for a VISION file (PDF): its pages are delivered as RENDERED PAGE IMAGES that
114
+ * the proxy worker injects into THIS message at the `placeholder` token (tool-result images
115
+ * render on neither provider, so the pages must be image blocks in the message itself). Each
116
+ * pass shows one WINDOW of pages starting at `renderFrom` (0-based).
117
+ *
118
+ * The WORKER advances the window: when its renderer reports pages remaining it enqueues the
119
+ * next pass itself, off the true page count, so a document indexes end-to-end with no browser
120
+ * involved. This message therefore only ever describes ONE window, and the model is never
121
+ * asked to decide whether the document is finished.
122
+ *
123
+ * renderFrom === 0 is the FIRST pass (leads with "A new file has just been uploaded." so the
124
+ * client builds the "Indexing: <name>" bubble); a continue pass (built by the worker from
125
+ * buildIndexingRenderContinueTemplate) leads with "CONTINUE indexing" so it is not a duplicate
126
+ * primary bubble.
127
+ */
128
+ export function buildIndexingRenderMessage(
129
+ attachment: IndexingAttachmentInfo,
130
+ placeholder: string,
131
+ renderFrom: number,
132
+ ): string {
133
+ const from = Math.max(0, renderFrom || 0);
134
+ if (from > 0) return buildIndexingRenderContinueTemplate(attachment, placeholder, String(from + 1));
135
+
136
+ return (
137
+ `A new file has just been uploaded. Index it now.\n\n` +
138
+ buildRenderMeta(attachment) +
139
+ `\nThis is a PDF. Its pages are delivered to you as RENDERED PAGE IMAGES embedded directly in this ` +
140
+ `message (you do NOT need any tool, URL, or web_fetch to see them). You are shown a WINDOW of pages ` +
141
+ `at a time, starting at page ${from + 1}.\n` +
142
+ buildRenderDatafy(placeholder)
143
+ );
144
+ }
145
+
146
+ /**
147
+ * The CONTINUE pass, as a template the worker fills in. `pageLabel` defaults to the
148
+ * RENDER_FROM_TOKEN placeholder, which the worker replaces with the real 1-based start page
149
+ * of the window it is rendering; passing an explicit label produces a ready-to-send message.
150
+ */
151
+ export function buildIndexingRenderContinueTemplate(
152
+ attachment: IndexingAttachmentInfo,
153
+ placeholder: string,
154
+ pageLabel: string = RENDER_FROM_TOKEN,
155
+ ): string {
156
+ const src = `src::${attachment.storagePath}`;
157
+ return (
158
+ `CONTINUE indexing a PDF whose previous pass did not finish.\n\n` +
159
+ buildRenderMeta(attachment) +
160
+ `\nRecords for the earlier pages are ALREADY saved (they reference "${src}"). The NEXT window of ` +
161
+ `rendered page images (starting at page ${pageLabel}) is embedded in this message. Datafy each page as ` +
162
+ `before and do NOT re-save pages that are already saved.\n` +
163
+ buildRenderDatafy(placeholder)
164
+ );
165
+ }
166
+
167
+ function buildRenderMeta(attachment: IndexingAttachmentInfo): string {
168
+ return (
169
+ `File metadata:\n` +
170
+ `- name: ${attachment.name}\n` +
171
+ `- storage path: ${attachment.storagePath}\n` +
172
+ (attachment.mime ? `- mime type: ${attachment.mime}\n` : '')
173
+ );
174
+ }
175
+
176
+ // Shared datafy guidance. The placeholder is where the worker splices the note + rendered
177
+ // page images; instructions reference "the page images in this message" so they read
178
+ // correctly whether the images land before or after this text.
179
+ //
180
+ // Deliberately says nothing about INDEXING_COMPLETE or about whether the document is
181
+ // finished: the worker decides that from the renderer's page count. Asking the model was
182
+ // what used to end an 88-page file at page 15.
183
+ function buildRenderDatafy(placeholder: string): string {
184
+ return (
185
+ `\n${placeholder}\n\n` +
186
+ `LOOK at each rendered page image in this message and DATAFY what it shows: for EVERY page ` +
187
+ `call postRecords and save records - one record per row / table entry / line item visible on the page ` +
188
+ `(or one record for the page if it is prose), capturing every value you can read (OCR the text, read tables ` +
189
+ `cell by cell, describe any photos/diagrams). Use the storage path above for the "src::" unique_id.\n\n` +
190
+ `Save records for THIS window of pages only, then stop and report what you saved. Do NOT try to read ` +
191
+ `the rest of the file and do NOT worry about the pages after this window: if any remain, the next window ` +
192
+ `is rendered and sent to you automatically. Report only the pages you were actually shown - never imply ` +
193
+ `you have seen the whole document.`
194
+ );
195
+ }
196
+
197
+ /**
198
+ * User message for a WINDOWED file: the worker splices ONE window of the file's rows or
199
+ * text into this message at `placeholder`, then continues from the reader's own cursor
200
+ * until the file is exhausted.
201
+ *
202
+ * The agent is deliberately NOT asked to page the file itself, and is NOT asked to judge
203
+ * whether it is finished. Both used to be its job, and both failed the same way: the
204
+ * traversal lived inside a single turn's budget, so a large file simply stopped partway
205
+ * with a confident summary of the part it had seen.
206
+ */
207
+ export function buildIndexingWindowMessage(
208
+ attachment: IndexingAttachmentInfo,
209
+ placeholder: string,
210
+ isContinuation: boolean,
211
+ positionLabel?: string,
212
+ ): string {
213
+ const src = `src::${attachment.storagePath}`;
214
+ const head = isContinuation
215
+ ? `CONTINUE indexing a file whose previous pass did not finish.\n\n`
216
+ : `A new file has just been uploaded. Index it now.\n\n`;
217
+ const where = isContinuation
218
+ ? `\nRecords for the earlier windows are ALREADY saved (they reference "${src}"). The NEXT window ` +
219
+ `(starting at ${positionLabel || WINDOW_CURSOR_TOKEN}) is embedded below. Do NOT re-save windows that are already saved.\n`
220
+ : `\nThis file is delivered to you ONE WINDOW at a time, embedded directly in this message. ` +
221
+ `You do NOT need any tool, URL, or web_fetch to read it.\n`;
222
+
223
+ return (
224
+ head +
225
+ buildRenderMeta(attachment) +
226
+ where +
227
+ `\n${placeholder}\n\n` +
228
+ `DATAFY this window: call postRecords and save records for everything in it - ONE RECORD PER ROW ` +
229
+ `for tabular data (keyed by the column headers), or one record per section for prose. Capture every ` +
230
+ `value you can read. Use the storage path above for the "src::" unique_id on the file-level record, ` +
231
+ `and link every row/section record to it by reference.\n\n` +
232
+ `Save records for THIS window only, then stop and report what you saved. Do NOT try to read the rest ` +
233
+ `of the file, and do NOT call readFileContent - if more remains, the next window is read and sent to ` +
234
+ `you automatically. Report only what you were actually shown, and never imply you have seen the whole ` +
235
+ `file when the note beside the window says more remains.`
236
+ );
237
+ }
238
+
239
+ /**
240
+ * User message for a RESUME pass: a previous indexing pass could not finish this large
241
+ * file, so continue it from where the already-saved records leave off (never restart).
242
+ */
243
+ export function buildIndexingContinueMessage(attachment: IndexingAttachmentInfo): string {
244
+ const src = `src::${attachment.storagePath}`;
245
+ return (
246
+ `CONTINUE indexing a file whose previous pass did not finish.\n\n` +
247
+ `File metadata:\n` +
248
+ `- name: ${attachment.name}\n` +
249
+ `- storage path: ${attachment.storagePath}\n` +
250
+ (attachment.mime ? `- mime type: ${attachment.mime}\n` : '') +
251
+ `\nRecords for the earlier windows/pages of this file are ALREADY saved (they reference "${src}"). ` +
252
+ `First call getRecords with reference "${src}" to see how far the previous pass got (the furthest page/row/window already saved). ` +
253
+ `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` +
254
+ ` - 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` +
255
+ ` - 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` +
256
+ ` - Text: the cursor is the character offset already read.\n` +
257
+ `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. ` +
258
+ `Do NOT re-save windows that are already saved. ` +
259
+ `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.`
260
+ );
261
+ }