bunnyquery 1.6.0 → 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.6.0",
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
@@ -160,6 +181,33 @@ export function makeRenderPlaceholder(seed: string): string {
160
181
  // resume window (from = pass * PAGES) lines up with what the worker renders.
161
182
  export const RENDER_PAGES_PER_WINDOW = 5;
162
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
+
163
211
  export interface ComposedUserMessage {
164
212
  /** Clean display/history copy (attachment links, NO extraction placeholders). */
165
213
  composed: string;
@@ -11,6 +11,9 @@ export {
11
11
  buildIndexingUserMessage,
12
12
  buildIndexingContinueMessage,
13
13
  buildIndexingRenderMessage,
14
+ buildIndexingRenderContinueTemplate,
15
+ buildIndexingWindowMessage,
16
+ RENDER_FROM_TOKEN,
14
17
  type IndexingAttachmentInfo,
15
18
  type BuildIndexingUserMessageOptions,
16
19
  } from './indexing_user_message';
@@ -101,16 +101,29 @@ export function buildIndexingUserMessage(
101
101
  return head + `- temporary URL (fetch this to read the file contents): ${attachment.url}`;
102
102
  }
103
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
+
104
112
  /**
105
113
  * User message for a VISION file (PDF): its pages are delivered as RENDERED PAGE IMAGES that
106
114
  * the proxy worker injects into THIS message at the `placeholder` token (tool-result images
107
115
  * 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.
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.
110
122
  *
111
123
  * 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).
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.
114
127
  */
115
128
  export function buildIndexingRenderMessage(
116
129
  attachment: IndexingAttachmentInfo,
@@ -118,44 +131,108 @@ export function buildIndexingRenderMessage(
118
131
  renderFrom: number,
119
132
  ): string {
120
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 {
121
156
  const src = `src::${attachment.storagePath}`;
122
- const meta =
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 (
123
169
  `File metadata:\n` +
124
170
  `- name: ${attachment.name}\n` +
125
171
  `- storage path: ${attachment.storagePath}\n` +
126
- (attachment.mime ? `- mime type: ${attachment.mime}\n` : '');
172
+ (attachment.mime ? `- mime type: ${attachment.mime}\n` : '')
173
+ );
174
+ }
127
175
 
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 =
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 (
132
185
  `\n${placeholder}\n\n` +
133
186
  `LOOK at each rendered page image in this message and DATAFY what it shows: for EVERY page ` +
134
187
  `call postRecords and save records - one record per row / table entry / line item visible on the page ` +
135
188
  `(or one record for the page if it is prose), capturing every value you can read (OCR the text, read tables ` +
136
189
  `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.`;
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
+ }
140
196
 
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
- }
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`;
151
222
 
152
223
  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
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.`
159
236
  );
160
237
  }
161
238
 
@@ -10,9 +10,9 @@
10
10
  * state that stays in the consumer, not here;
11
11
  * only the BgTaskEntry TYPE lives here)
12
12
  */
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
- import { chatEngineConfig, pollOpt } from './config';
13
+ import { buildIndexingSystemPrompt, buildIndexingUserMessage, buildIndexingContinueMessage, buildIndexingRenderMessage, buildIndexingRenderContinueTemplate, buildIndexingWindowMessage } from './prompts';
14
+ import { isServerExtractable, isPagedReadFile, isImageVisionFile, isWindowedReadFile, makeExtractPlaceholder, makeRenderPlaceholder, makeWindowPlaceholder, RENDER_PAGES_PER_WINDOW, type ExtractDirective, type FileUrlDirective } from './office';
15
+ import { chatEngineConfig, pollOpt, windowedIndexingEnabled } from './config';
16
16
 
17
17
  export const ANTHROPIC_MESSAGES_API_URL = 'https://api.anthropic.com/v1/messages';
18
18
  const ANTHROPIC_MODELS_API_URL = 'https://api.anthropic.com/v1/models';
@@ -38,25 +38,47 @@ export const DEFAULT_OPENAI_MODEL = 'gpt-5.4';
38
38
  const mcpUrl = () => chatEngineConfig().mcpBaseUrl;
39
39
  const clientSecretRequest = (opts: any) => chatEngineConfig().clientSecretRequest(opts);
40
40
 
41
+ // Resolve the per-image `detail` for OpenAI. The version match tolerates a
42
+ // trailing variant/date suffix (`gpt-5.4-nano`, `-mini`, `-2026-01-01`, …):
43
+ // previously the pattern was anchored with no suffix allowed, so EVERY suffixed
44
+ // model silently fell through to 'auto' — i.e. the cheap tiers that most need
45
+ // resolution were the ones getting downsampled images.
46
+ //
47
+ // Base models keep their exact previous behavior ('original'). A suffixed
48
+ // variant resolves to 'high' rather than 'original': 'high' is the universally
49
+ // supported value, and we have no way to confirm a given variant accepts
50
+ // 'original' — sending an unsupported value would fail the whole request, which
51
+ // is far worse than a slightly less detailed image.
41
52
  const getOpenAIImageDetail = (model?: string) => {
42
53
  const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
43
- const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?$/);
54
+ const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
44
55
  if (!match) {
45
56
  return DEFAULT_OPENAI_IMAGE_DETAIL;
46
57
  }
47
58
 
48
59
  const major = Number(match[1]);
49
60
  const minor = match[2] === undefined ? null : Number(match[2]);
61
+ const isVariant = !!match[3];
50
62
 
51
- if (major > 5) {
52
- return 'original';
63
+ const supportsOriginal = major > 5 || (major === 5 && minor !== null && minor >= 4);
64
+ if (!supportsOriginal) {
65
+ return DEFAULT_OPENAI_IMAGE_DETAIL;
53
66
  }
54
67
 
55
- if (major === 5 && minor !== null && minor >= 4) {
56
- return 'original';
57
- }
68
+ return isVariant ? 'high' : 'original';
69
+ };
58
70
 
59
- return DEFAULT_OPENAI_IMAGE_DETAIL;
71
+ // Per-image `detail` for WORKER-RENDERED document pages (the `_skapi_render`
72
+ // path). Same resolution as above with one difference: these are dense scans
73
+ // whose entire purpose is to be read, so 'auto' is never acceptable — it lets
74
+ // the API downsample exactly the pixels the model needs to OCR. Floor it at
75
+ // 'high'; models that support full-resolution 'original' still get it.
76
+ //
77
+ // Without this the worker falls back to its own model-blind default ('high'),
78
+ // which silently denies the strongest models the 'original' detail they support.
79
+ const getRenderImageDetail = (model?: string) => {
80
+ const detail = getOpenAIImageDetail(model);
81
+ return detail === DEFAULT_OPENAI_IMAGE_DETAIL ? 'high' : detail;
60
82
  };
61
83
 
62
84
  export type ClaudeRole = 'user' | 'assistant';
@@ -478,20 +500,66 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
478
500
  const visionFile = !parsedContent && isImageVisionFile(attachment.name, attachment.mime);
479
501
  const renderFrom = Math.max(0, info.renderFrom || 0);
480
502
  const renderPlaceholder = visionFile ? makeRenderPlaceholder(attachment.storagePath) : undefined;
503
+ // Tell the worker what image `detail` to stamp on the injected page blocks.
504
+ // The worker is model-blind, so without this it applies a one-size default and
505
+ // the strongest models never get the 'original' detail they support. Only
506
+ // meaningful for OpenAI — Claude has no per-image detail knob and the worker
507
+ // ignores the field for it.
508
+ const renderDetail = platform === 'openai'
509
+ ? getRenderImageDetail(info.model || DEFAULT_OPENAI_MODEL)
510
+ : undefined;
511
+ // `auto_continue` + `continue_text` hand the page loop to the WORKER: when its renderer
512
+ // reports pages left after this window, it builds the next pass from this template
513
+ // (substituting the window's 1-based start page for RENDER_FROM_TOKEN) and enqueues it
514
+ // itself. That is what makes a 500-page document index end-to-end — the loop no longer
515
+ // depends on the tab staying open, nor on the model correctly declaring itself unfinished.
481
516
  const skapiRender = visionFile && renderPlaceholder
482
517
  ? {
483
518
  _skapi_render: [
484
- { path: attachment.storagePath, from: renderFrom, count: RENDER_PAGES_PER_WINDOW, placeholder: renderPlaceholder, name: attachment.name, mime: attachment.mime },
519
+ {
520
+ path: attachment.storagePath, from: renderFrom, count: RENDER_PAGES_PER_WINDOW,
521
+ placeholder: renderPlaceholder, name: attachment.name, mime: attachment.mime, detail: renderDetail,
522
+ auto_continue: true,
523
+ continue_text: buildIndexingRenderContinueTemplate(attachment, renderPlaceholder),
524
+ },
525
+ ],
526
+ }
527
+ : {};
528
+
529
+ // SERVER-DRIVEN windowed read. When enabled, the worker reads one window of the file
530
+ // per request and continues from the reader's own cursor until the file is exhausted,
531
+ // so the traversal no longer lives inside the model's turn budget.
532
+ //
533
+ // Flag-gated because the BACKEND MUST SHIP FIRST: against a worker that does not strip
534
+ // `_skapi_window`, the directive reaches the provider as an unknown body field and the
535
+ // call fails terminally with no retry.
536
+ const windowedRead =
537
+ !visionFile && !parsedContent && windowedIndexingEnabled() &&
538
+ isWindowedReadFile(attachment.name, attachment.mime);
539
+ const windowPlaceholder = windowedRead ? makeWindowPlaceholder(attachment.storagePath) : undefined;
540
+ const skapiWindow = windowedRead && windowPlaceholder
541
+ ? {
542
+ _skapi_window: [
543
+ {
544
+ path: attachment.storagePath,
545
+ cursor: null,
546
+ placeholder: windowPlaceholder,
547
+ name: attachment.name,
548
+ mime: attachment.mime,
549
+ kind: 'window',
550
+ auto_continue: true,
551
+ continue_text: buildIndexingWindowMessage(attachment, windowPlaceholder, true),
552
+ },
485
553
  ],
486
554
  }
487
555
  : {};
488
556
 
489
557
  // Spreadsheets are read by PAGING through readFileContent (grid rows), NOT inlined - so
490
558
  // 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)));
559
+ const pagedRead = !visionFile && !windowedRead && (continuing || (!parsedContent && isPagedReadFile(attachment.name, attachment.mime)));
492
560
 
493
561
  // Client-parsed content wins over server-side extraction.
494
- const serverExtract = !visionFile && !continuing && !parsedContent && !pagedRead && isServerExtractable(attachment.name, attachment.mime);
562
+ const serverExtract = !visionFile && !windowedRead && !continuing && !parsedContent && !pagedRead && isServerExtractable(attachment.name, attachment.mime);
495
563
  const placeholder = serverExtract ? makeExtractPlaceholder(attachment.storagePath) : undefined;
496
564
  const extractContent: ExtractDirective[] | undefined =
497
565
  serverExtract && placeholder
@@ -502,6 +570,8 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
502
570
 
503
571
  const userMessage = (visionFile && renderPlaceholder)
504
572
  ? buildIndexingRenderMessage(attachment, renderPlaceholder, renderFrom)
573
+ : (windowedRead && windowPlaceholder)
574
+ ? buildIndexingWindowMessage(attachment, windowPlaceholder, false)
505
575
  : continuing
506
576
  ? buildIndexingContinueMessage(attachment)
507
577
  : buildIndexingUserMessage(
@@ -541,6 +611,7 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
541
611
  max_output_tokens: MAX_TOKENS,
542
612
  ...skapiExtract,
543
613
  ...skapiRender,
614
+ ...skapiWindow,
544
615
  input: [
545
616
  { role: 'system', content: systemPrompt },
546
617
  {
@@ -589,6 +660,7 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
589
660
  max_tokens: MAX_TOKENS,
590
661
  ...skapiExtract,
591
662
  ...skapiRender,
663
+ ...skapiWindow,
592
664
  system: [
593
665
  {
594
666
  type: 'text',
@@ -713,6 +785,23 @@ export async function listOpenAIModels(service: string, owner: string) {
713
785
  // so the chat-history BETWEEN query never includes bg-queue items. '-' (45) works.
714
786
  export const BG_INDEXING_QUEUE_SUFFIX = '-bg';
715
787
 
788
+ /**
789
+ * True when a request belongs to the background-indexing queue.
790
+ *
791
+ * Accepts BOTH shapes this value arrives in: the bare queue name the client sends
792
+ * ("<userId>-bg"), and the server qid that comes back on history/poll responses
793
+ * ("<service>:<queue>|<seq>"). Testing the tail of the raw value only works for the
794
+ * first: a qid ends in "|<seq>", so `endsWith('-bg')` is always false for it — which
795
+ * silently meant history items were NEVER recognised as background tasks.
796
+ */
797
+ export function isBgIndexingQueue(queueName?: string): boolean {
798
+ if (typeof queueName !== 'string' || !queueName) return false;
799
+ const prefix = queueName.split('|')[0];
800
+ const idx = prefix.lastIndexOf(':');
801
+ const name = idx === -1 ? prefix : prefix.slice(idx + 1);
802
+ return name.slice(-BG_INDEXING_QUEUE_SUFFIX.length) === BG_INDEXING_QUEUE_SUFFIX;
803
+ }
804
+
716
805
  // Pending background-indexing task descriptor. NOTE: the live mutable queue
717
806
  // (a Vue `reactive([])` in agent.vue, a plain array in bunnyquery) is app-level
718
807
  // state owned by the consumer — only the TYPE lives in the engine.
@@ -733,18 +822,16 @@ export type BgTaskEntry = {
733
822
 
734
823
  // Token the indexing agent appends to its final message ONLY when it has fully read and
735
824
  // saved the whole file. Its ABSENCE is what tells the client to run another CONTINUE pass.
825
+ //
826
+ // Applies to the TEXT/GRID paging path only. The vision path (rendered PDF pages) no longer
827
+ // asks the model whether it is finished — the worker advances that loop off the renderer's
828
+ // page count — so this marker has no say in whether a PDF keeps going.
736
829
  export const INDEXING_COMPLETE_MARKER = 'INDEXING_COMPLETE';
737
830
  // Cap on CONTINUE passes per file, so a file the agent can never mark complete (or a
738
831
  // pathological loop) stops instead of re-dispatching forever. The text/grid paging path
739
832
  // reads MANY windows within a single pass (the agent loops readFileContent in one turn), so
740
833
  // a small cap suffices.
741
834
  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
835
 
749
836
  export async function getChatHistory(
750
837
  params: { service?: string; owner?: string; platform: 'claude' | 'openai'; queue?: string },