bunnyquery 1.8.2 → 1.8.4

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.
@@ -1,12 +1,12 @@
1
1
  /**
2
- * BASE PROMPT Background file-indexing agent (user message)
2
+ * BASE PROMPT - Background file-indexing agent (user message)
3
3
  * ============================================================================
4
4
  * USER-role message paired with the indexing system prompt. Sent by
5
5
  * notifyAgentSaveAttachment() each time a file is uploaded or re-indexed.
6
6
  *
7
7
  * NOTE: the leading line "A new file has just been uploaded. Index it now." and
8
8
  * the "- name: ..." line are also what the chat client parses to build the
9
- * "Indexing: <name>" history bubble keep those fields on their own lines.
9
+ * "Indexing: <name>" history bubble - keep those fields on their own lines.
10
10
  */
11
11
 
12
12
  export type IndexingAttachmentInfo = {
@@ -24,15 +24,15 @@ export type IndexingAttachmentInfo = {
24
24
 
25
25
  export type BuildIndexingUserMessageOptions = {
26
26
  /**
27
- * For office files (.docx/.xlsx/.pptx) the model can't read the binary via
27
+ * For files with no paged reader (.epub/.hwp/.doc/.rtf, source code) the model can't read the binary via
28
28
  * web_fetch, so the proxy worker extracts the text server-side and replaces
29
29
  * this exact token with it. When provided, the message embeds the token (and
30
- * drops the temporary-URL line there is nothing for the model to fetch).
30
+ * drops the temporary-URL line - there is nothing for the model to fetch).
31
31
  */
32
32
  inlineContentPlaceholder?: string;
33
33
  /**
34
34
  * Actual file content parsed CLIENT-SIDE by an attachment-parser plugin (e.g.
35
- * an .hwp parser). Embedded inline verbatim no server extraction and no
35
+ * an .hwp parser). Embedded inline verbatim - no server extraction and no
36
36
  * web_fetch for this file. Takes precedence over `inlineContentPlaceholder`.
37
37
  */
38
38
  inlineContent?: string;
@@ -58,12 +58,12 @@ export function buildIndexingUserMessage(
58
58
 
59
59
  if (options?.inlineContent) {
60
60
  // Parsed client-side (an attachment-parser plugin). The content is already
61
- // inlined below no server extraction, no URL to fetch.
61
+ // inlined below - no server extraction, no URL to fetch.
62
62
  return (
63
63
  head +
64
64
  `\nThe file's content was parsed by the client and is provided inline below. ` +
65
- `Read it directly do NOT fetch any URL for this file. ` +
66
- `Use the storage path above (not this content) for the "src::" unique_id.\n\n` +
65
+ `Read it directly - do NOT fetch any URL for this file. ` +
66
+ `Set every record's reference to exactly "src::" + the storage path above (not this content). That file record already exists, so enrich it with updateRecords rather than posting it.\n\n` +
67
67
  `----- BEGIN FILE CONTENT -----\n` +
68
68
  `${options.inlineContent}\n` +
69
69
  `----- END FILE CONTENT -----`
@@ -76,8 +76,8 @@ export function buildIndexingUserMessage(
76
76
  return (
77
77
  head +
78
78
  `\nThe file's text content was extracted on the server and is provided inline below. ` +
79
- `Read it directly do NOT fetch any URL for this file. ` +
80
- `Use the storage path above (not this content) for the "src::" unique_id.\n\n` +
79
+ `Read it directly - do NOT fetch any URL for this file. ` +
80
+ `Set every record's reference to exactly "src::" + the storage path above (not this content). That file record already exists, so enrich it with updateRecords rather than posting it.\n\n` +
81
81
  `----- BEGIN FILE CONTENT -----\n` +
82
82
  `${options.inlineContentPlaceholder}\n` +
83
83
  `----- END FILE CONTENT -----`
@@ -93,7 +93,7 @@ export function buildIndexingUserMessage(
93
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
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
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.` +
96
+ `Do NOT stop after the first window and do NOT just write a summary. Set every record's reference to exactly "src::" + the storage path above; that file record already exists, so enrich it with updateRecords instead of posting it again.` +
97
97
  (attachment.url ? `\n(A temporary URL is provided ONLY as a fallback if readFileContent fails: ${attachment.url})` : '')
98
98
  );
99
99
  }
@@ -186,7 +186,17 @@ function buildRenderDatafy(placeholder: string): string {
186
186
  `LOOK at each rendered page image in this message and DATAFY what it shows: for EVERY page ` +
187
187
  `call postRecords and save records - one record per row / table entry / line item visible on the page ` +
188
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` +
189
+ `cell by cell, describe any photos/diagrams). Set EVERY record's reference to exactly "src::" + the storage path above. That file record ALREADY EXISTS, so do NOT post it, and do NOT give your page records a "src::" unique_id of their own. A record with no reference back to it is an ORPHAN: re-indexing the file deletes the linked records and leaves the orphan behind forever as stale data.\n\n` +
190
+ `Each image is preceded by a label giving its DOCUMENT PAGE number. That label is the page's identity - ` +
191
+ `use it, and ignore any page number PRINTED on the document itself (a scan often restarts its own ` +
192
+ `numbering per section, so a footer reading "PAGE 4 OF 8" routinely disagrees with the real position). ` +
193
+ `Whether a page is one you have already saved is stated in the note above the images - decide from that, ` +
194
+ `never from a printed page number.\n\n` +
195
+ `Transcribe COMPLETELY, not representatively. A table with twenty rows gets twenty records, not a sample ` +
196
+ `of the first few - if a page has more rows than you can save comfortably, still save them all rather than ` +
197
+ `summarising. Where a page carries an embedded text layer it is quoted above that page's image: it is the ` +
198
+ `exact text and should be preferred over reading the pixels, with the image used for layout, tables, ` +
199
+ `stamps and handwriting.\n\n` +
190
200
  `Save records for THIS window of pages only, then stop and report what you saved. Do NOT try to read ` +
191
201
  `the rest of the file and do NOT worry about the pages after this window: if any remain, the next window ` +
192
202
  `is rendered and sent to you automatically. Report only the pages you were actually shown - never imply ` +
@@ -227,12 +237,12 @@ export function buildIndexingWindowMessage(
227
237
  `\n${placeholder}\n\n` +
228
238
  `DATAFY this window: call postRecords and save records for everything in it - ONE RECORD PER ROW ` +
229
239
  `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, ` +
240
+ `value you can read. The file-level record ALREADY EXISTS with unique_id "src::" + the storage path above: do NOT post it (a duplicate unique_id is rejected), enrich it with updateRecords, ` +
231
241
  `and link every row/section record to it by reference.\n\n` +
232
- `If this window has PHOTOS attached as images, LOOK at each one and datafy what it actually shows ` +
233
- `into the record for the row it is anchored to (a «PHOTO A88» marker in the grid text only says WHERE ` +
234
- `a picture sits - the picture itself is attached to this message). Never report that photo contents ` +
235
- `could not be extracted when images are attached here.\n\n` +
242
+ `If this window has PHOTOS attached as images, LOOK at each one and datafy what it actually shows. ` +
243
+ `A «PHOTO ...» marker in the grid text ties a picture to its row and comes in two forms. ` +
244
+ `«PHOTO A88 -> __MEDIA__/...» means the picture at cell A88 is saved as a permanent file at exactly that storage path, and its record in table "__MEDIA__" has unique_id "src::" + that path: UPDATE that record with updateRecords, adding what the picture SHOWS and TAGS for every identifier visible in it (part numbers, tag ids, item names, serial numbers). Do NOT create a duplicate and do NOT add a second photo record in another table: one file, one record. If that update reports the record does not exist, create it ONCE with that same unique_id, reference "src::" + the storage path above, table "__MEDIA__", access group "authorized", and data carrying the path - the path must never be lost. ` +
245
+ `A bare «PHOTO A88» marker with no arrow is a picture with no stored path of its own in this window: usually a repeat stored under an earlier anchor, or one too small to keep. NEVER construct a storage path or unique_id for it: find its record, if any, with getRecords reference "src::" + the storage path above, matching the cell against data.anchor or tags, and enrich what you find. The row record stays about its row's cells. Never report that photo contents could not be extracted when images are attached here.\n\n` +
236
246
  `Save records for THIS window only, then stop and report what you saved. Do NOT try to read the rest ` +
237
247
  `of the file, and do NOT call readFileContent - if more remains, the next window is read and sent to ` +
238
248
  `you automatically. Report only what you were actually shown, and never imply you have seen the whole ` +
@@ -253,13 +263,13 @@ export function buildIndexingContinueMessage(attachment: IndexingAttachmentInfo)
253
263
  `- storage path: ${attachment.storagePath}\n` +
254
264
  (attachment.mime ? `- mime type: ${attachment.mime}\n` : '') +
255
265
  `\nRecords for the earlier windows/pages of this file are ALREADY saved (they reference "${src}"). ` +
256
- `First call getRecords with reference "${src}" to see how far the previous pass got (the furthest page/row/window already saved). ` +
266
+ `First call getRecords with reference "${src}" to see how far the previous pass got (the furthest row/window already saved). The reference ALONE is the whole query: it returns every record written from this file across ALL tables and ALL access groups, so do NOT add table_name or access_group to narrow it. The response is PAGED, so keep fetching pages until it reports there are no more, and take the furthest point from the WHOLE set, never from the first page. ` +
257
267
  `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` +
258
- ` - 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` +
259
- ` - 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` +
260
- ` - Text: the cursor is the character offset already read.\n` +
268
+ ` - 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` +
269
+ ` - Text: the cursor is the character offset already read.\n` +
261
270
  `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. ` +
271
+ `A «PHOTO <cell>» marker in a window marks an embedded picture whose extracted file already has a record in table "__MEDIA__": find it with getRecords reference "src::" + the storage path above and match the cell against data.anchor or tags (a repeated picture is stored under its first anchor only), then enrich it with updateRecords. Never create a photo record of your own and never construct a path for one. ` +
262
272
  `Do NOT re-save windows that are already saved. ` +
263
- `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.`
273
+ `Set every record's reference to exactly "src::" + the storage path above (no sheet, window or summary suffix added). That file record already exists, so do NOT post it; enrich it with updateRecords. When the ENTIRE file is finally indexed, end your message with the token INDEXING_COMPLETE.`
264
274
  );
265
275
  }
@@ -44,11 +44,99 @@ const clientSecretRequest = (opts: any) => chatEngineConfig().clientSecretReques
44
44
  // model silently fell through to 'auto' — i.e. the cheap tiers that most need
45
45
  // resolution were the ones getting downsampled images.
46
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.
47
+ // Variants used to resolve to 'high' rather than 'original' on the reasoning that
48
+ // 'high' is universally supported and an unsupported value would fail the whole
49
+ // request. The cost of that caution turned out to be real: 'high' downsamples to
50
+ // a 512px grid, so the small tiers that most need resolution were reading dense
51
+ // scans at the lower of the two settings, and gpt-5.4-nano reports it cannot make
52
+ // out the text where gpt-5.4 (on 'original') can. Variants now get 'original' too.
53
+ //
54
+ // If a variant rejects 'original' the failure is loud and immediate (a terminal
55
+ // 400 on the whole request, no retry), so flip VARIANT_IMAGE_DETAIL back to
56
+ // 'high' and it is undone. That is the one word to change.
57
+ const VARIANT_IMAGE_DETAIL = 'original';
58
+
59
+ // Extra Responses-API knobs for NANO models on INDEXING passes only.
60
+ //
61
+ // `detail` (above) governs what the model SEES; these govern how it thinks and how completely it
62
+ // writes. Nano strips vision detail to hit its latency target and compresses layout when
63
+ // transcribing, which is the documented reason to raise verbosity FOR IT SPECIFICALLY. mini and the
64
+ // base/named models do not need it, so they are excluded rather than paying its output cost.
65
+ //
66
+ // Deliberately NOT applied to chat: high verbosity makes ordinary replies longer and worse, and the
67
+ // chat path has no transcription job to justify it.
68
+ //
69
+ // Two things to keep in mind, both of which is why these are separate switches:
70
+ // 1. An UNKNOWN body field is fatal here, not ignored. The engine already documents it for
71
+ // `_skapi_window`: it "reaches the provider as an unknown body field and the call fails
72
+ // terminally with no retry". If a field name is wrong, EVERY indexing pass dies. Set either
73
+ // constant to null to remove the field entirely.
74
+ // 2. Reasoning tokens are billed against max_output_tokens on this API. An indexing pass spends
75
+ // its output budget emitting postRecords calls, so buying reasoning takes budget away from the
76
+ // records themselves and can truncate them.
77
+ //
78
+ // Effort was OFF, and the measurement that turned it on: across one nano run's photo records, only
79
+ // 5 of 28 image text fields carried a concrete identifier read off the tag (a part number, a tag id).
80
+ // The other 23 held a generic scene description. Reading small handwriting off a photographed label
81
+ // is exactly the kind of work a moment of deliberation buys, so it went on at the LOWEST setting.
82
+ //
83
+ // It stays 'low' for a nano that already transcribes correctly (5.6-nano), because effort is
84
+ // billed against the same budget as the records and buying it where it is not needed can only
85
+ // truncate them.
86
+ //
87
+ // OLDEST_NANO_REASONING_EFFORT raises it for gpt-5.4-nano and below ONLY, which is the tier that
88
+ // actually reads dense scans badly. It is 'high', and what makes that affordable is not one change
89
+ // but two:
90
+ // - that tier's render window is SMALL_TIER_PAGES_PER_WINDOW pages, not five, so the same cap is
91
+ // divided between far fewer pages to begin with; and
92
+ // - the worker now DETECTS a pass that ran out of output budget (_output_truncation_reason) and
93
+ // re-runs that window at half the page count instead of advancing past it.
94
+ // Before the detector, over-buying reasoning was unsafe in a way that did not show up: a truncated
95
+ // pass still returned 200, so the page loop moved on and the rest of that window's records were
96
+ // lost silently. With it, spending too much on reasoning costs an extra pass instead of data.
97
+ //
98
+ // Still the first knob to lower if the logs start showing repeated truncation retries, and
99
+ // re-measure that 5-of-28 ratio rather than assuming. null removes the field entirely.
100
+ const VARIANT_TEXT_VERBOSITY: string | null = 'high';
101
+ const VARIANT_REASONING_EFFORT: string | null = 'low';
102
+ const OLDEST_NANO_REASONING_EFFORT: string | null = 'high';
103
+
104
+ /**
105
+ * True only for a NANO model, including a dated nano snapshot. NOT mini, not a base model, and not
106
+ * a named variant like gpt-5.6-luna: those transcribe faithfully on their own and do not need the
107
+ * knob, so paying its output cost on them would be waste.
108
+ */
109
+ const isOpenAINano = (model?: string) => {
110
+ const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
111
+ if (!/(^|-)nano(-|$)/.test(normalized)) return false;
112
+
113
+ // Family floor, same shape as getOpenAIImageDetail's. "Contains nano" alone also matches
114
+ // gpt-4.1-nano and anything else a project might name, and these are body fields an older
115
+ // model REJECTS rather than ignores: one 400 with no retry kills every indexing pass. Only
116
+ // gpt-5.4 and newer are known to take `text.verbosity` and `reasoning.effort`.
117
+ const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
118
+ if (!match) return false;
119
+ const major = Number(match[1]);
120
+ const minor = match[2] === undefined ? null : Number(match[2]);
121
+ return major > 5 || (major === 5 && minor !== null && minor >= 4);
122
+ };
123
+
124
+ /**
125
+ * The indexing-only body knobs, for the ONE tier that needs them: gpt-5.4-nano.
126
+ *
127
+ * Both conditions are load-bearing and mean different things. isOpenAINano is the gpt-5.4
128
+ * FLOOR - below it these fields are rejected rather than ignored, and one 400 kills every
129
+ * indexing pass. isOldestNano is the CEILING - 5.5-nano and 5.6-nano transcribe correctly
130
+ * on their own, so they are left on the provider's defaults exactly like mini and the base
131
+ * models. Every other model gets an empty object, i.e. no `text` and no `reasoning` at all.
132
+ */
133
+ const variantIndexingOptions = (model?: string) => {
134
+ if (!isOpenAINano(model) || !isOldestNano(model)) return {};
135
+ return {
136
+ ...(VARIANT_TEXT_VERBOSITY ? { text: { verbosity: VARIANT_TEXT_VERBOSITY } } : {}),
137
+ ...(OLDEST_NANO_REASONING_EFFORT ? { reasoning: { effort: OLDEST_NANO_REASONING_EFFORT } } : {}),
138
+ };
139
+ };
52
140
  const getOpenAIImageDetail = (model?: string) => {
53
141
  const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
54
142
  const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
@@ -65,7 +153,7 @@ const getOpenAIImageDetail = (model?: string) => {
65
153
  return DEFAULT_OPENAI_IMAGE_DETAIL;
66
154
  }
67
155
 
68
- return isVariant ? 'high' : 'original';
156
+ return isVariant ? VARIANT_IMAGE_DETAIL : 'original';
69
157
  };
70
158
 
71
159
  // Per-image `detail` for WORKER-RENDERED document pages (the `_skapi_render`
@@ -81,6 +169,103 @@ const getRenderImageDetail = (model?: string) => {
81
169
  return detail === DEFAULT_OPENAI_IMAGE_DETAIL ? 'high' : detail;
82
170
  };
83
171
 
172
+ /**
173
+ * A nano at gpt-5.4 or OLDER.
174
+ *
175
+ * This is the observed quality boundary, not a guessed one: gpt-5.4-nano and below
176
+ * transcribe dense scans poorly, while mini, the base models and every gpt-5.6 model
177
+ * (5.6-nano included) read the same documents correctly. So only this tier gets
178
+ * compensated, and everything above it is left exactly as it was — on 'original' detail
179
+ * and a full window, because nothing about it needs fixing and every compensation costs
180
+ * either passes or output budget.
181
+ */
182
+ /** The id parses as a gpt version we can reason about. An id that does NOT (o3,
183
+ * chatgpt-4o-latest, any custom name) is left entirely alone: we cannot tell what it is,
184
+ * and every compensation here is either a body field that can 400 or a change to how many
185
+ * images it receives. Unknown means untouched. */
186
+ const OPENAI_VERSIONED_ID = /^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/;
187
+ const isRecognisedOpenAIVersion = (model?: string) =>
188
+ OPENAI_VERSIONED_ID.test((model || DEFAULT_OPENAI_MODEL).trim().toLowerCase());
189
+
190
+ const isOldestNano = (model?: string) => {
191
+ const normalized = (model || DEFAULT_OPENAI_MODEL).trim().toLowerCase();
192
+ if (!/(^|-)nano(-|$)/.test(normalized)) return false;
193
+ const match = normalized.match(/^gpt-(\d+)(?:\.(\d+))?(-[a-z0-9.\-]+)?$/);
194
+ // Called "nano" but not a naming we recognise: treat it as the weak tier. It also
195
+ // fails the 'original' gate, so it is caught by the downsampled branch first anyway.
196
+ if (!match) return true;
197
+ const major = Number(match[1]);
198
+ const minor = match[2] === undefined ? null : Number(match[2]);
199
+ if (major < 5) return true;
200
+ if (major > 5) return false;
201
+ return minor === null || minor <= 4;
202
+ };
203
+
204
+ // Pages in one render window for a SMALL tier, against RENDER_PAGES_PER_WINDOW (5) for a
205
+ // full one.
206
+ //
207
+ // This is the lever that does not risk a 400. The output budget is one number for the whole
208
+ // pass (MAX_TOKENS, and reasoning is billed against it), so a window of 5 dense pages leaves
209
+ // a small model a couple of thousand tokens per page and it starts sampling rows instead of
210
+ // transcribing them - which is exactly the "saved 5 line items" on a page holding twenty.
211
+ // Halving the window does not raise the cap, it just stops dividing it so many ways, and the
212
+ // worker's page loop already runs as many windows as a file needs.
213
+ const SMALL_TIER_PAGES_PER_WINDOW = 2;
214
+
215
+ // Horizontal bands per page for a tier whose images the API DOWNSAMPLES before the model
216
+ // sees them ('high' resamples onto a 512px tile grid). For those models the render DPI is
217
+ // irrelevant - the pixels are thrown away upstream - so the only way to hand them more
218
+ // readable text is to make each image cover less of the page. Two bands doubles the
219
+ // resolution the model effectively gets, at the cost of one extra image per page.
220
+ //
221
+ // Bands are horizontal so a table row is never cut down its middle, and they overlap
222
+ // slightly so a line landing on the seam appears whole in one of them.
223
+ const DOWNSAMPLED_TIER_TILE = 2;
224
+
225
+ /** How a given model should be shown a rendered document. */
226
+ export type VisionProfile = {
227
+ /** Per-image `detail` (OpenAI only). */
228
+ detail: string;
229
+ /** Pages the worker renders into one window. */
230
+ pagesPerWindow: number;
231
+ /** Horizontal bands per page; 1 renders the whole page as one image. */
232
+ tile: number;
233
+ };
234
+
235
+ /**
236
+ * Resolve the render profile for a model.
237
+ *
238
+ * Three tiers, and they fail for different reasons, which is why one set of knobs cannot
239
+ * serve all of them:
240
+ * - full: everything ABOVE gpt-5.4-nano - the base models, mini, and every gpt-5.6
241
+ * including 5.6-nano. These already transcribe dense scans correctly, so they get
242
+ * 'original' detail and are otherwise untouched.
243
+ * - gpt-5.4-nano: also gets 'original', so it sees exactly the SAME pixels as the full
244
+ * tier. Its gap therefore is not resolution, and tiling would do nothing for it. What
245
+ * it lacks is room: a smaller window leaves the same output budget divided between
246
+ * fewer pages.
247
+ * - downsampled (below the 'original' floor: gpt-5.3-nano, gpt-5-nano, gpt-4.1-nano):
248
+ * capped at 'high', which resamples the page onto a 512px grid no matter what DPI it
249
+ * was rendered at. Render resolution is wasted on these entirely; the only way to give
250
+ * them readable text is to make each image cover less of the page, which is `tile`.
251
+ */
252
+ export function getVisionProfile(model?: string): VisionProfile {
253
+ const detail = getRenderImageDetail(model);
254
+ // An id we cannot parse is left exactly as it was: full window, no tiling. Treating
255
+ // "unknown" as "weak" would quietly change how much work every custom or aliased model
256
+ // does, on no evidence at all.
257
+ if (!isRecognisedOpenAIVersion(model)) {
258
+ return { detail, pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
259
+ }
260
+ if (detail !== 'original') {
261
+ return { detail, pagesPerWindow: SMALL_TIER_PAGES_PER_WINDOW, tile: DOWNSAMPLED_TIER_TILE };
262
+ }
263
+ if (isOldestNano(model)) {
264
+ return { detail, pagesPerWindow: SMALL_TIER_PAGES_PER_WINDOW, tile: 1 };
265
+ }
266
+ return { detail, pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
267
+ }
268
+
84
269
  export type ClaudeRole = 'user' | 'assistant';
85
270
 
86
271
  export type ClaudeMessage = {
@@ -236,6 +421,28 @@ export type CallClaudeWithMcpParams = {
236
421
  // engine's own poll sites and imported by agent.vue; the widget carries its own
237
422
  // copy in src/index.js that must be kept in step.
238
423
  export const POLL_INTERVAL = 3000;
424
+ // Ceiling on how many BACKGROUND indexing polls may be attached at once, across
425
+ // every poll site (the engine's drain, the engine's history load, and each
426
+ // client's own fallback poller).
427
+ //
428
+ // Every unresolved bg item used to get its own poll, and a poll is a
429
+ // POLL_INTERVAL setInterval firing one request per tick. A bulk upload from the
430
+ // db-files page enqueues one indexing pass per FILE, so uploading 10,000 files
431
+ // attached 10,000 concurrent intervals: ~3,300 requests/second against a browser
432
+ // that opens six connections per host. The resulting request backlog starved the
433
+ // uploads themselves, which is the "frozen tab that eventually finishes" users
434
+ // reported.
435
+ //
436
+ // Capping costs nothing, because the server settles one queue's passes in FIFO
437
+ // order (a single SQS MessageGroupId per `<user>-bg` queue). A pass cannot
438
+ // finish before the ones ahead of it, so asking about the newest 9,994 is pure
439
+ // waste. Each resolution frees a slot, which the next-OLDEST unpolled entry
440
+ // takes on the drain that follows. Spending the budget oldest-first is
441
+ // load-bearing: spend it on the newest and the batch wedges, since those cannot
442
+ // settle until the ones ahead of them do and nothing ahead would hold a poll.
443
+ //
444
+ // FOREGROUND polls (a reply the user is actively waiting on) are never capped.
445
+ export const MAX_CONCURRENT_BG_POLLS = 6;
239
446
  export async function callClaudeWithMcp({
240
447
  prompt,
241
448
  messages,
@@ -450,8 +657,20 @@ export type AttachmentSaveInfo = {
450
657
  platform: 'claude' | 'openai';
451
658
  model?: string;
452
659
  service: string;
660
+ /** The PUBLIC project ID (formatted token, skapi.project_id). Shown to the model; falls back to `service`. */
661
+ publicProjectId?: string;
453
662
  owner: string;
454
- userId?: string;
663
+ /**
664
+ * Queue base for this indexing pass: "<userId>-bg". REQUIRED, and it must be
665
+ * the SAME value the chat turn uses (ChatSession.dispatchComposedMessage's
666
+ * `id.userId || id.projectId`) — the backend serialises requests that share a
667
+ * queue name and runs different ones IN PARALLEL, so a pass enqueued under a
668
+ * different base does not hold the chat back at all. It was optional once,
669
+ * defaulting to `service`; the chatbox omitted it, and its files were indexed
670
+ * on "<projectId>-bg" while its question ran on "<userId>-bg" — the question
671
+ * was answered from a file nothing had read yet. Pass `userId || projectId`.
672
+ */
673
+ userId: string;
455
674
  serviceName?: string;
456
675
  serviceDescription?: string;
457
676
  attachment: {
@@ -516,12 +735,19 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
516
735
  // (substituting the window's 1-based start page for RENDER_FROM_TOKEN) and enqueues it
517
736
  // itself. That is what makes a 500-page document index end-to-end — the loop no longer
518
737
  // depends on the tab staying open, nor on the model correctly declaring itself unfinished.
738
+ // Window size and per-page tiling are resolved from the MODEL, not fixed: see
739
+ // getVisionProfile. Claude keeps the full window (renderDetail is OpenAI-only, and its
740
+ // own resizing behaviour is a separate question this does not try to answer).
741
+ const visionProfile: VisionProfile = platform === 'openai'
742
+ ? getVisionProfile(info.model || DEFAULT_OPENAI_MODEL)
743
+ : { detail: '', pagesPerWindow: RENDER_PAGES_PER_WINDOW, tile: 1 };
519
744
  const skapiRender = visionFile && renderPlaceholder
520
745
  ? {
521
746
  _skapi_render: [
522
747
  {
523
- path: attachment.storagePath, from: renderFrom, count: RENDER_PAGES_PER_WINDOW,
748
+ path: attachment.storagePath, from: renderFrom, count: visionProfile.pagesPerWindow,
524
749
  placeholder: renderPlaceholder, name: attachment.name, mime: attachment.mime, detail: renderDetail,
750
+ tile: visionProfile.tile,
525
751
  auto_continue: true,
526
752
  continue_text: buildIndexingRenderContinueTemplate(attachment, renderPlaceholder),
527
753
  },
@@ -550,6 +776,12 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
550
776
  name: attachment.name,
551
777
  mime: attachment.mime,
552
778
  kind: 'window',
779
+ // Same per-image `detail` the render path sends. Without it the worker falls
780
+ // back to its model-blind default of 'high', so a spreadsheet's embedded
781
+ // photos were tiled at lower resolution than the SAME model gets for a PDF
782
+ // page or a chat attachment. That is why a model could describe an attached
783
+ // photo but reported the pictures inside a sheet as only partly legible.
784
+ detail: renderDetail,
553
785
  auto_continue: true,
554
786
  continue_text: buildIndexingWindowMessage(attachment, windowPlaceholder, true),
555
787
  },
@@ -569,7 +801,20 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
569
801
  ? [{ path: attachment.storagePath, placeholder, name: attachment.name, mime: attachment.mime }]
570
802
  : undefined;
571
803
  const skapiExtract =
572
- extractContent && extractContent.length ? { _skapi_extract: extractContent } : {};
804
+ extractContent && extractContent.length
805
+ ? {
806
+ _skapi_extract: extractContent.map((d) => ({
807
+ ...d,
808
+ // FIRST pass of an INDEXING run only: tells the worker to also pull the
809
+ // file's embedded pictures into __MEDIA__ and register their records.
810
+ // Chat-turn extraction (callClaudeWithMcp / callOpenAIWithPublicMcp)
811
+ // never sets this, so merely ATTACHING a file to a chat message cannot
812
+ // write media records; a CONTINUE pass skips it because the first pass
813
+ // already saved (the save is whole-file, not windowed).
814
+ save_media: !continuing,
815
+ })),
816
+ }
817
+ : {};
573
818
 
574
819
  const userMessage = (visionFile && renderPlaceholder)
575
820
  ? buildIndexingRenderMessage(attachment, renderPlaceholder, renderFrom)
@@ -589,7 +834,10 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
589
834
  );
590
835
 
591
836
  const systemPrompt = buildIndexingSystemPrompt({
592
- service,
837
+ // The model copies this id verbatim into project_id tool calls, so it must be
838
+ // the PUBLIC token whenever the host supplied one; the raw code is rejected
839
+ // by the tools' schema pattern.
840
+ projectId: info.publicProjectId || service,
593
841
  serviceName: info.serviceName,
594
842
  serviceDescription: info.serviceDescription,
595
843
  });
@@ -599,7 +847,7 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
599
847
  const imageDetail = getOpenAIImageDetail(resolvedModel);
600
848
  return clientSecretRequest({
601
849
  clientSecretName: 'openai',
602
- queue: (info.userId || service) + BG_INDEXING_QUEUE_SUFFIX,
850
+ queue: bgIndexingQueueName(info.userId, service),
603
851
  service,
604
852
  owner,
605
853
  ...pollOpt(),
@@ -612,6 +860,8 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
612
860
  data: {
613
861
  model: resolvedModel,
614
862
  max_output_tokens: MAX_TOKENS,
863
+ // Nano-only transcription knobs. Indexing only; see variantIndexingOptions.
864
+ ...variantIndexingOptions(resolvedModel),
615
865
  ...skapiExtract,
616
866
  ...skapiRender,
617
867
  ...skapiWindow,
@@ -646,7 +896,7 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
646
896
  const resolvedModel = info.model || DEFAULT_CLAUDE_MODEL;
647
897
  return clientSecretRequest({
648
898
  clientSecretName: 'claude',
649
- queue: (info.userId || service) + BG_INDEXING_QUEUE_SUFFIX,
899
+ queue: bgIndexingQueueName(info.userId, service),
650
900
  service,
651
901
  owner,
652
902
  ...pollOpt(),
@@ -788,6 +1038,17 @@ export async function listOpenAIModels(service: string, owner: string) {
788
1038
  // so the chat-history BETWEEN query never includes bg-queue items. '-' (45) works.
789
1039
  export const BG_INDEXING_QUEUE_SUFFIX = '-bg';
790
1040
 
1041
+ /**
1042
+ * The one place the background-indexing queue name is spelled out. The backend
1043
+ * serialises requests sharing a queue name and runs different names in PARALLEL,
1044
+ * so every indexing pass AND the chat turn that must wait behind them have to
1045
+ * resolve to the identical string — see AttachmentSaveInfo.userId for what
1046
+ * happens when they do not.
1047
+ */
1048
+ export function bgIndexingQueueName(userId?: string, service?: string): string {
1049
+ return (userId || service || '') + BG_INDEXING_QUEUE_SUFFIX;
1050
+ }
1051
+
791
1052
  /**
792
1053
  * True when a request belongs to the background-indexing queue.
793
1054
  *
@@ -809,7 +1070,7 @@ export function isBgIndexingQueue(queueName?: string): boolean {
809
1070
  // (a Vue `reactive([])` in agent.vue, a plain array in bunnyquery) is app-level
810
1071
  // state owned by the consumer — only the TYPE lives in the engine.
811
1072
  export type BgTaskEntry = {
812
- serviceId: string;
1073
+ projectId: string;
813
1074
  platform: 'claude' | 'openai';
814
1075
  id: string;
815
1076
  filename: string;
@@ -821,6 +1082,13 @@ export type BgTaskEntry = {
821
1082
  poll: ((opts: { latency: number }) => Promise<any>) | undefined;
822
1083
  /** How many CONTINUE passes have already run for this file (resume-across-passes). */
823
1084
  resumePass?: number;
1085
+ /** The STAGED chat turn these files were attached to (ChatSession.stageOutgoingMessage).
1086
+ * drainBgTaskQueue inserts this pass's bubble directly ABOVE that turn's bubble, so the
1087
+ * collapsed row sits where the reader expects it — right before the message the files
1088
+ * came with — from the moment it appears, instead of the turn being moved down past it
1089
+ * once everything finishes. Absent for work with no chat turn behind it (the dbfile
1090
+ * page, an attachment-only send, a worker-adopted pass), which appends as before. */
1091
+ stageId?: string;
824
1092
  };
825
1093
 
826
1094
  // Token the indexing agent appends to its final message ONLY when it has fully read and
@@ -830,12 +1098,32 @@ export type BgTaskEntry = {
830
1098
  // asks the model whether it is finished — the worker advances that loop off the renderer's
831
1099
  // page count — so this marker has no say in whether a PDF keeps going.
832
1100
  export const INDEXING_COMPLETE_MARKER = 'INDEXING_COMPLETE';
1101
+ // What an indexing pass's bubble says when its ENTIRE answer was the completion
1102
+ // token and stripping it left nothing. Without a stand-in the history mappers emit
1103
+ // no bubble at all for that pass (their `else if (assistantText)` guard fails on the
1104
+ // empty string) while the live path emits one — so the same run read as finished
1105
+ // live and unfinished after a reload, and the row's loader came back.
1106
+ export const EMPTY_INDEXING_REPLY = 'Finished reading this file.';
833
1107
  // Cap on CONTINUE passes per file, so a file the agent can never mark complete (or a
834
1108
  // pathological loop) stops instead of re-dispatching forever. The text/grid paging path
835
1109
  // reads MANY windows within a single pass (the agent loops readFileContent in one turn), so
836
1110
  // a small cap suffices.
837
1111
  export const MAX_INDEXING_RESUME_PASSES = 6;
838
1112
 
1113
+ // Records per chat-history page. Bigger than skapi's own default so the first load
1114
+ // (and each scroll-up page) covers more of the conversation in one round-trip — a
1115
+ // short page leaves the box unfilled and forces the viewport-fill loop to page
1116
+ // again immediately. Callers that want a narrower page (the queue/status probes in
1117
+ // ChatSession) pass their own `limit`, which wins over this default.
1118
+ // 500, up from 100: an indexing run is dozens of rows that collapse into ONE visible
1119
+ // row, so a screenful of history behind a few indexed files took 5+ sequential
1120
+ // round trips to assemble (cursor paging cannot be parallelised - each page's
1121
+ // startKey comes from the previous response). The number is a CAP, not a payload
1122
+ // size: DynamoDB stops a page at 1MB regardless and hands back a cursor, and the
1123
+ // backend passes the limit through without looping, so heavy indexing rows page at
1124
+ // the same bytes per trip as before while light chat rows now arrive 500 at a time.
1125
+ export const CHAT_HISTORY_PAGE_LIMIT = 500;
1126
+
839
1127
  /**
840
1128
  * `queue` narrows the fetch to one processing chain; `status` narrows it to items
841
1129
  * in one state. Passing both is how the client asks "is there still unresolved
@@ -864,6 +1152,6 @@ export async function getChatHistory(
864
1152
 
865
1153
  return chatEngineConfig().clientSecretRequestHistory(
866
1154
  p as { url: string; method: 'POST'; queue?: string; status?: string },
867
- Object.assign({ ascending: false }, fetchOptions),
1155
+ Object.assign({ ascending: false, limit: CHAT_HISTORY_PAGE_LIMIT }, fetchOptions),
868
1156
  );
869
1157
  }