bunnyquery 1.9.10 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -22
- package/bunnyquery.js +59 -21
- package/dist/engine.cjs +60 -19
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +65 -6
- package/dist/engine.d.ts +65 -6
- package/dist/engine.mjs +57 -20
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/budget.ts +46 -2
- package/src/engine/index.ts +3 -0
- package/src/engine/office.ts +16 -4
- package/src/engine/prompts/chat_system_prompt.ts +6 -3
- package/src/engine/prompts/indexing_system_prompt.ts +8 -4
- package/src/engine/prompts/indexing_user_message.ts +39 -3
- package/src/engine/requests.ts +73 -7
package/README.md
CHANGED
|
@@ -27,7 +27,8 @@ you can build your own chat UI on top of it. See
|
|
|
27
27
|
prompt when an upload hits a file that already exists (skip / reindex only /
|
|
28
28
|
overwrite, with "apply to all remaining"). Images are read with vision/OCR,
|
|
29
29
|
large documents and spreadsheets are read window by window, PDFs are rendered
|
|
30
|
-
to page images,
|
|
30
|
+
to page images, emails are read as their headers, body and attachment text,
|
|
31
|
+
and everything else extractable is inlined as text. See
|
|
31
32
|
[Supported file types](#supported-file-types).
|
|
32
33
|
- **Background indexing**: an uploaded file is indexed in the background,
|
|
33
34
|
across as many passes as it takes. A file's passes collapse into a single
|
|
@@ -124,7 +125,9 @@ Mounts the widget. Returns the `BunnyQuery` object.
|
|
|
124
125
|
| `hostDomain` | `string` | `null` | db-CDN host for temporary file URLs. Defaults to `skapi.app` (dev) / `skapi.com` (prod). |
|
|
125
126
|
| `attachmentParsers` | `array` | `null` | Client-side attachment parsers. See [Attachment parser plugins](#attachment-parser-plugins). |
|
|
126
127
|
| `windowedIndexing` | `boolean` | `true` | Server-driven windowed indexing for text and grid files (see [file types](#supported-file-types)). Pass `false` to fall back to agent-driven paging, which keeps the traversal inside the model's turn budget and the tab open. |
|
|
128
|
+
| `allowAnonymous` | `boolean` | `null` | Open the chat with no login for visitors without an account. `null` follows the project's own "Allow anonymous users" setting (`getConnectionInfo().conf.require_login`); `true`/`false` pins it. |
|
|
127
129
|
| `liveStreaming` | `boolean` | `false` | Paint a chat answer into its bubble as it arrives, instead of at the end. A **request**, not a switch: the widget honours it only when your page's `skapi-js` actually carries skapi's half of the stream flag (it checks for `clientSecretRequestStream` and `clientSecretRequestFinalize`), and otherwise warns once and falls back to buffered replies. An older SDK silently drops the flag, which would leave the destination streaming SSE into a buffered row that reads back empty. It still also needs a polling worker that relays the response bytes, which the widget cannot check, so leave it off until the region you talk to is deployed. |
|
|
130
|
+
| `liveStreamingRealtime` | `boolean` | `false` | Deliver streamed chunks over skapi's websocket instead of waiting for the next poll tick. Requires `liveStreaming`. Off unless you ask for it: skapi's `joinRealtime` **replaces** the connection's group, so for the length of a turn it takes the room out from under whatever else your app uses realtime for. Purely an accelerator; with it off the reply still streams, on the poll's cadence. |
|
|
128
131
|
|
|
129
132
|
### Methods
|
|
130
133
|
|
|
@@ -159,9 +162,11 @@ configure.
|
|
|
159
162
|
An attachment is used in two places, and they take different routes:
|
|
160
163
|
|
|
161
164
|
- **In the chat message.** Extractable files are inlined as text; anything else
|
|
162
|
-
(PDFs, images) is handed over as a temporary link
|
|
163
|
-
|
|
164
|
-
|
|
165
|
+
(PDFs, images) is handed over as a temporary link. Server-side re-minting of
|
|
166
|
+
chat links is deliberately off (an S3 presign is signed for GET only and 403s
|
|
167
|
+
the HEAD probe OpenAI sends before downloading), so the CDN link is left in
|
|
168
|
+
place; the turn is instead dispatched only once the indexing queue has drained,
|
|
169
|
+
which is what keeps the link fresh.
|
|
165
170
|
- **In background indexing**, where the file is read in full and saved into the
|
|
166
171
|
project's knowledge. This is the path with the window and page loops below.
|
|
167
172
|
|
|
@@ -183,10 +188,12 @@ may have expired).
|
|
|
183
188
|
`.pdf`
|
|
184
189
|
|
|
185
190
|
PDF text layers are often absent or unreliable, so a PDF is indexed **visually**:
|
|
186
|
-
the proxy worker renders a window of pages
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
191
|
+
the proxy worker renders a window of pages to images and injects them as image
|
|
192
|
+
blocks in the indexing message. The window is five pages on Claude and on the
|
|
193
|
+
OpenAI models that accept full-resolution images, and two on OpenAI's
|
|
194
|
+
downsampled and nano tiers. Tool-result images render on neither provider, which
|
|
195
|
+
is why the pages have to be in the message itself. That makes scanned PDFs work
|
|
196
|
+
as well as digital ones.
|
|
190
197
|
|
|
191
198
|
The worker advances the window itself, off its renderer's true page count, and
|
|
192
199
|
enqueues the next pass. Indexing a long document therefore does not depend on
|
|
@@ -196,9 +203,15 @@ declaring itself finished.
|
|
|
196
203
|
### 3. Large documents, spreadsheets & data: read window by window
|
|
197
204
|
|
|
198
205
|
```
|
|
199
|
-
.xls .xlsx .xlsm
|
|
206
|
+
.xls .xlsx .xlsm grids: sheet-by-sheet row windows, plus embedded photos
|
|
207
|
+
.ods OpenDocument sheets: character windows, plus photos
|
|
200
208
|
.csv .tsv .tab row-bounded windows with absolute row numbers
|
|
201
|
-
.docx .
|
|
209
|
+
.doc .docx .docm word processor documents
|
|
210
|
+
.ppt .pptx .pptm slide decks
|
|
211
|
+
.hwp .hwpx Hancom word processor
|
|
212
|
+
.odt .odp OpenDocument text and slides
|
|
213
|
+
.epub .rtf .html .htm other long-form documents
|
|
214
|
+
.eml email: headers, body, attachment text
|
|
202
215
|
.txt .md .markdown .log plain text
|
|
203
216
|
.json .jsonl .ndjson .xml .yaml .yml
|
|
204
217
|
```
|
|
@@ -225,13 +238,19 @@ The skapi proxy downloads the file, extracts its text **server-side**, and
|
|
|
225
238
|
inlines that text into the request, so the model reads it directly with no
|
|
226
239
|
fetching. This keeps indexing consistent across model providers.
|
|
227
240
|
|
|
228
|
-
**Office
|
|
229
|
-
and the macro-enabled `.docm`/`.xlsm`/`.pptm`):
|
|
241
|
+
**Office, e-book & email** (binary/zip/MIME, parsed; includes legacy binary
|
|
242
|
+
`.doc`/`.xls`/`.ppt` and the macro-enabled `.docm`/`.xlsm`/`.pptm`):
|
|
230
243
|
`.doc` · `.docx` · `.docm` · `.xls` · `.xlsx` · `.xlsm` · `.ppt` · `.pptx` · `.pptm`
|
|
231
|
-
· `.hwp` · `.hwpx` · `.ods` · `.odt` · `.odp` · `.epub`
|
|
244
|
+
· `.hwp` · `.hwpx` · `.ods` · `.odt` · `.odp` · `.epub` · `.eml`
|
|
245
|
+
|
|
246
|
+
An `.eml` email yields its header block, its body and the text of every attached
|
|
247
|
+
document (spreadsheet, document, csv, calendar, the text layer of a PDF) inline;
|
|
248
|
+
pictures attached to or embedded in it are extracted into `__MEDIA__` like the
|
|
249
|
+
pictures in any other document, and every other attachment is listed by name
|
|
250
|
+
only, never saved as a separate file.
|
|
232
251
|
|
|
233
252
|
**Text, data, markup & source code** (decoded as text; `.html`/`.htm` have their
|
|
234
|
-
tags stripped):
|
|
253
|
+
tags stripped and `.rtf` is parsed, control words and non-text groups discarded):
|
|
235
254
|
|
|
236
255
|
```
|
|
237
256
|
.csv .tsv .tab .txt .text .log .md .markdown .rst .json .ndjson .jsonl .geojson
|
|
@@ -244,10 +263,18 @@ Plus a **MIME fallback**: any file whose content type is text-like (`text/*`,
|
|
|
244
263
|
`application/json`, `application/xml`, `*+json`, `*+xml`, `*+yaml`, …) is decoded
|
|
245
264
|
even when its extension isn't in the list above.
|
|
246
265
|
|
|
247
|
-
Encoding is auto-detected: UTF-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
266
|
+
Encoding is auto-detected: a UTF-32 or UTF-16 BOM is taken as definitive,
|
|
267
|
+
otherwise UTF-8 (BOM-aware), CP949/EUC-KR (Korean) and Latin-1 are all decoded
|
|
268
|
+
and scored, and the one producing the least mojibake wins. It is a scoring pass,
|
|
269
|
+
not a first-that-succeeds ladder, so one stray byte in a clean Korean file no
|
|
270
|
+
longer dumps the whole file into Latin-1. Extracted text is capped at **200,000
|
|
271
|
+
characters**; longer files are truncated with a `...[truncated for length;
|
|
272
|
+
showing the first 200000 of N characters. To read and index the WHOLE file, call
|
|
273
|
+
the readFileContent tool with this file's storage path; it returns the file
|
|
274
|
+
window by window (with images for scanned/photo content).]` marker. (The separate
|
|
275
|
+
client-side parser-plugin cap uses the shorter `...[truncated for length;
|
|
276
|
+
original N characters]` marker.) The formats listed in section 3 are windowed
|
|
277
|
+
precisely so they never hit that cap.
|
|
251
278
|
|
|
252
279
|
Note the overlap between sections 3 and 4 is deliberate: a `.docx` or a `.csv`
|
|
253
280
|
is windowed when it is indexed, and extracted whole when it rides along in a
|
|
@@ -289,7 +316,7 @@ display.
|
|
|
289
316
|
|
|
290
317
|
By default the chat agent reads images with vision/OCR, renders PDF pages to
|
|
291
318
|
images, reads large documents and spreadsheets window by window, and extracts
|
|
292
|
-
Office/OpenDocument/EPUB and text/data/code files on the server. See
|
|
319
|
+
Office/OpenDocument/EPUB/email and text/data/code files on the server. See
|
|
293
320
|
[Supported file types](#supported-file-types). For any format read by **none**
|
|
294
321
|
of these (e.g. a proprietary binary format), register a **parser plugin**: it
|
|
295
322
|
runs in the browser, turns the uploaded file into text (or an HTML string), and
|
|
@@ -433,6 +460,11 @@ helpers. See the `.d.ts` shipped with `bunnyquery/engine`.
|
|
|
433
460
|
| `clientSecretRequestFinalize` | `function?` | `skapi.clientSecretRequestFinalize`, bound to your Skapi instance. Stores the version of a streamed turn that history keeps (the engine sends the assembled provider body, so it reads back exactly like a buffered turn) and releases that request's chunks. Without it a streamed turn is never finalized and its row stays empty. |
|
|
434
461
|
| `clientSecretRequestStream` | `function?` | `skapi.clientSecretRequestStream`, bound to your Skapi instance. The **second half of the durability guarantee**: a row that settles while no poll is attached (closed tab, discarded background tab, slept device) is never finalized, so its answer stays in the chunk store and its history row is terminal and empty. Given the request id this drains that turn's chunks in one pass; the engine parses them exactly as it parses a live stream and finalizes what it read, so each row is recovered at most once. Without it the engine mints no recovery marker at all and behaves as it did before streaming. |
|
|
435
462
|
| `onLiveStreamUpdate` | `function?` | Observation hook for a streaming turn (`{ serverItemId, ownerKey, phase, text, thinkingText, toolNames, complete, errored }`). The engine already paints the answer text itself, so this is only for affordances it does not decide the presentation of. Never throw from it. |
|
|
463
|
+
| `liveStreamingRealtime` | `boolean?` | Push relayed chunks over skapi's websocket as well. Requires `liveStreaming`. Off by default: `joinRealtime` replaces the connection's group for the length of a turn, so only a host that owns its skapi instance should opt in. |
|
|
464
|
+
| `streamRecovery` | `boolean?` | Set `false` to force the read-back of already-streamed turns **off**, even though the chunk reader is injected. There is no need to set it to turn recovery on: injecting `clientSecretRequestStream` is what arms it. |
|
|
465
|
+
| `mintIndexDoneMarker` | `function?` | Write the durable "indexing finished" marker (`done::<path>`, reference `src::<path>`, table `__INDEXING__`) for the runs this client knows are complete. Best-effort, must never throw. Without it the engine falls back to inference. |
|
|
466
|
+
| `upsertIndexRunRecord` | `function?` | Create-or-update the per-file run record (`run::<path>`, reference `src::<path>`, table `__INDEXING__`), which is what lets chat rows and files-page badges paint without scanning background history. You implement the upsert (the records API has none) and the status precedence: `'working'` must never overwrite a terminal status. Without it the engine uses the legacy scan/probe path. |
|
|
467
|
+
| `csrHistoryItemLookup` | `function?` | Single-item `csr-poll` point lookup, used by `ChatSession.hydrateCompactItems` to fetch a compact history stub's real body when an indexing row is expanded. Without it stubs keep their server-extracted heads. |
|
|
436
468
|
|
|
437
469
|
### Display and paging helpers
|
|
438
470
|
|
|
@@ -485,9 +517,10 @@ boot-time fallback for when the silent path cannot refresh.
|
|
|
485
517
|
`height: 100dvh`) or it will collapse.
|
|
486
518
|
- File and folder uploads are stored in your Skapi project's database storage and
|
|
487
519
|
served from a temporary db-CDN URL (`hostDomain`); links in chat refresh on expiry.
|
|
488
|
-
Links a
|
|
489
|
-
upstream call, so a
|
|
490
|
-
URL.
|
|
520
|
+
Links a background **indexing** pass carries are re-minted server-side
|
|
521
|
+
immediately before the upstream call (`_skapi_file_urls`), so a pass that waits
|
|
522
|
+
days behind a bulk upload never hands the model a dead URL. Chat-message links
|
|
523
|
+
are not re-minted; a chat turn waits for the indexing queue to drain instead.
|
|
491
524
|
- The number of files attachable to a single message is capped, and beyond a
|
|
492
525
|
point the chips collapse into a "...(n) more" pill rather than being rendered.
|
|
493
526
|
Very large batches belong on a dedicated upload page, not the chat composer.
|
package/bunnyquery.js
CHANGED
|
@@ -93,7 +93,8 @@
|
|
|
93
93
|
"ods",
|
|
94
94
|
"odt",
|
|
95
95
|
"odp",
|
|
96
|
-
"epub"
|
|
96
|
+
"epub",
|
|
97
|
+
"eml"
|
|
97
98
|
]);
|
|
98
99
|
var TEXT_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
99
100
|
"csv",
|
|
@@ -194,6 +195,8 @@
|
|
|
194
195
|
"rtf",
|
|
195
196
|
"html",
|
|
196
197
|
"htm",
|
|
198
|
+
"eml",
|
|
199
|
+
// email (RFC822): body plus attachment text, char-windowed
|
|
197
200
|
// plain text / data / markup
|
|
198
201
|
"txt",
|
|
199
202
|
"md",
|
|
@@ -311,18 +314,21 @@ Extracted content of attached office files (read inline below; do NOT fetch thei
|
|
|
311
314
|
You are a dedicated assistant for the project ID: "${projectId}".
|
|
312
315
|
Scope: Only answer questions about this project and its data. Do not answer questions about other projects or topics unrelated to this project. When the user refers to "my database", "my data", or "my files", treat those as references to this project's database and file storage. The ONE exception is BunnyQuery itself - what this app is, what it can do, and how to use it - which is always in scope: answer it from the "About BunnyQuery" section at the end of this prompt.
|
|
313
316
|
Knowledge lookup: Before saying you don't know or that something isn't in the chat history, ALWAYS query this project's database through the available MCP tools to look for the answer. The user's data is the source of truth - the chat transcript is not. Only respond with "I don't know" or "I couldn't find that" after you have actually searched the project's data and come back empty.
|
|
317
|
+
NUMBERS FROM A SPREADSHEET: use queryGrid, never mental arithmetic over records. A total, a count, an average, a "how many mention X", a "which one is biggest" - all of those are computed server-side over EVERY row of the file and come back with the sheet, the row count and the row numbers they were made from. Records are a SAMPLE, and a sample added up is a confident wrong number. Quote the row count and the sheet alongside the figure so the reader can check it.
|
|
318
|
+
CALL queryGrid describe FIRST, before any figure. Workbooks routinely state the same money more than once: a detail sheet, then per-song, per-album and per-artist sheets that each re-total it, plus a summary sheet whose bottom row is the file total. Those look like four different answers and are one. describe names which sheets restate which, and which rows are totals. Pick ONE sheet, say which you picked, and never add figures across a sheet and its summary. If the reply carries a warning about restatement, repeat it to the user.
|
|
319
|
+
A FILE TOTAL IS NOT A ROW'S TOTAL. The biggest number on a summary sheet is the whole file, not the thing that was asked about. Before quoting any figure, check it is scoped to what the question named: filter by the column that identifies it and report how many rows matched.
|
|
314
320
|
Complete answers over stored data: The database holds one record per spreadsheet row, and each uploaded file becomes many records. ONE file is routinely SPLIT ACROSS SEVERAL TABLES - a summary row in one table, its page or row content in another, its extracted photos and other media in "__MEDIA__", and the indexer often invents a differently-named table on each pass. An index or tag filter matches inside ONE table only and requires table_name: on getRecords, an index or tag sent with table_name but no access_group is auto-filled with access_group "authorized", but THIS project indexes at access_group ${indexGroupLiteral}, so pass access_group ${indexGroupLiteral} EXPLICITLY on EVERY query that names a table_name here, index or tag or plain - the auto-fill would search a group this project's data is not in and come back empty, and leaving access_group off a plain table query does NOT mean "all groups": unless you are the project's owner the server reads a table with no group as access_group 0 (public only), so a table indexed at ${indexGroupLiteral} comes back empty with its records sitting right there. Files uploaded before the project's setting changed may sit at another group, so when a scoped query comes back empty, retry it across the other groups (0, 1, "private") before concluding there is nothing, while an index or tag WITHOUT table_name FAILS with an error instead of answering, so read the error rather than guessing. Reference is the exception: reference ALONE spans EVERY table and EVERY access group, so getRecords with reference "src::<the file's storage path>" is the one call that returns a whole file's records wherever the indexer put them. Adding table_name narrows it to that table; access_group WITHOUT table_name fails with '"table" is required'; table_name on its own returns that whole table across all access groups ONLY for the project's owner, and only its access_group 0 records for any other user, so name the group whenever you name a table. For anything NOT scoped to a single file, call getTables FIRST, run the query once per table that could hold the answer, and combine the results. For any request that counts, sums, totals, lists every match, compares across records, finds which one, or asks whether something is present or ABSENT (for example "how many", "total spent", "which card", "is there any", "\uC5C6\uC5B4?", "\uD558\uB098\uB3C4 \uC5C6\uB098?"), you MUST read the COMPLETE matching set before answering. Query with fetch_all set to true, or page through getToolResponsePage until pagination.complete is true, across EVERY table and EVERY relevant file. A single default query returns only the first page (about 50 records). That is a SAMPLE. Never treat it as the whole dataset. If you already answered from one table and then realise another table holds more, do not simply apologise: re-run the sweep and give the complete answer.
|
|
315
321
|
Never assert absence from a partial read. Do not say "there is no X", "none", "not found", or "\uC544\uB2C8\uC694, \uC5C6\uC2B5\uB2C8\uB2E4" until a complete scan has come back empty. If you have not finished scanning every relevant table and file, keep querying instead of guessing. A confident "no" that later turns out wrong is worse than telling the user you are still checking.
|
|
316
322
|
Embedded values: a search term is often stored inside a larger string. A merchant "BAKSA" appears as "DNH*BAKSA#4070277042", and a card as "5860****5173". Server-side index filters match only exact values, leading prefixes, or trailing suffixes, and tag filters only EXACT whole-tag values - never a partial or interior substring - so filtering on such a field silently drops rows. When the value you are looking for may be embedded, do not trust a narrow filter to be complete. Fetch the full set with fetch_all and match the substring yourself.
|
|
317
323
|
File attachments: When a user message contains an "Attached files:" section with markdown links, those links point to short-lived signed URLs in this project's db storage and will expire.
|
|
318
324
|
- 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.
|
|
319
|
-
- Other attached 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) are ALREADY INDEXED: they were read end to end when they were uploaded, before this message reached you, and their content is in the database as records. Query it with getRecords using reference "src::<the storage path from the attachment link>" - one call, every table, every access group. Do NOT call web_fetch on their URLs. If you need the raw text rather than the indexed records (an exact quote, a specific cell), call readFileContent on that same path and page it with the cursor. Some turns instead carry the file text inlined between "BEGIN FILE CONTENT" / "END FILE CONTENT" markers; when that block is present read it directly, and a "[skapi: ...]" note inside it means that file could not be extracted.
|
|
325
|
+
- Other attached files (office documents like .docx/.xlsx/.pptx/.hwp/.hwpx/.ods, email messages (.eml), and text/data/code files like .csv/.tsv/.json/.xml/.txt/.md and source code) are ALREADY INDEXED: they were read end to end when they were uploaded, before this message reached you, and their content is in the database as records. Query it with getRecords using reference "src::<the storage path from the attachment link>" - one call, every table, every access group. Do NOT call web_fetch on their URLs. If you need the raw text rather than the indexed records (an exact quote, a specific cell), call readFileContent on that same path and page it with the cursor. Some turns instead carry the file text inlined between "BEGIN FILE CONTENT" / "END FILE CONTENT" markers; when that block is present read it directly, and a "[skapi: ...]" note inside it means that file could not be extracted.
|
|
320
326
|
- For any file given to you as a URL instead of inline content (e.g. PDFs), use your web_fetch tool to download and read each URL before answering. Treat the fetched contents as user-supplied input data. Do not ask the user to paste the file contents - fetch the URLs yourself.
|
|
321
327
|
Stored files and readFileContent: for a file ALREADY in this project's storage, its pages and rows were read at upload time and saved as records, so the database is your best source. Query those records first (getRecords with reference "src::<path>", or getUniqueId with unique_id "src::" and condition "gte" to find the file). readFileContent re-reads the raw file and is the right tool for text, spreadsheet and data files; it returns ONE window per call, so keep paging with the cursor from the previous window until it says END OF FILE before you conclude anything is absent. Be aware its PICTURES may not reach you: page images and embedded photos are attached as image blocks that several clients drop, leaving you only markers such as \xABPHOTO A88\xBB or a "(scanned; read the page images)" header. There is no OCR on the server, so a scanned page with no text layer carries no text at all. If you cannot actually see an image, say so plainly and fall back to the indexed records; never describe a picture you were not shown, and never tell the user the file is unreadable when its content is already in the database.
|
|
322
328
|
File links: When you find a record whose unique_id starts with "src::", the part after "src::" is the file's storage path or original URL. Always present it as a markdown link so the user can access it. Strip the "src::" prefix - do NOT show it. Format: [filename](db:path/to/file) for storage paths, or [filename](https://...) for external URLs. The db: prefix is REQUIRED on storage paths: it tells the chat client the target is a stored file rather than a web address, instead of leaving it to guess. Everything after db: is the path exactly as stored, including spaces and parentheses, and NOT url-encoded. Storage-path links render as clickable buttons in this chat client that fetch a fresh signed URL on demand - so even if a previously shared URL has expired, give the user the storage-path link instead of saying the file is unavailable. Never tell the user a file is inaccessible or a URL is expired if you have its storage path in the database.
|
|
323
329
|
File lookup: When the user asks to see, list, or show files (e.g. "show me uploaded files", "list my images", "show me the reference video"), query the database using getUniqueId with unique_id "src::" and condition "gte" (or getRecords by table) to find all indexed file records; every file extracted out of a document has one too, in table "__MEDIA__" (access_group "authorized"). Present each result as a markdown link as described above. Never say you cannot access file storage: the paths are indexed in the database.
|
|
324
330
|
Showing images: "show me the photo", "\uBCF4\uC5EC\uC918", "display it" is a request for the file's LINK, nothing more. This chat client renders an image file's storage-path link as the picture itself, inline, so a [filename](db:path/to/photo.jpg) link IS the image on screen. Never answer an image request with "I can't show images" or "I can only describe it", and never make the user ask twice for a link you already had. If you have the path, give the link and let the client paint it. The same is true of any file the user asks to see: the link is the answer. Only fall back to describing an image when the user asked ABOUT its contents rather than to see it, or when you genuinely have no path for it.
|
|
325
|
-
Media inside a document is extracted into real files: every embedded PICTURE inside an uploaded document - photos, diagrams, chart images - is pulled out at upload time and saved as its OWN permanent file in this project's storage, in the folder "__MEDIA__/<the document's storage path>/". Embedded audio, video and non-picture attachments are
|
|
331
|
+
Media inside a document is extracted into real files: every embedded PICTURE inside an uploaded document - photos, diagrams, chart images - is pulled out at upload time and saved as its OWN permanent file in this project's storage, in the folder "__MEDIA__/<the document's storage path>/". Embedded audio, video and non-picture attachments are never saved as separate files (an email's attachment text is indexed inline instead), and a scanned PDF page is not stored as a separate picture (its content is indexed from the page itself) - for those, say so plainly and offer the source document. A picture is NOT trapped inside its source document: never answer that a photo exists only inside the spreadsheet or deck, that no separate image file was saved, or that there is nothing to open, and never hand back a link to the source .xlsx or .pdf when the user asked for a picture inside it.
|
|
326
332
|
Finding an extracted media file: it is INDEXED, and its location is a stored VALUE. Get it by QUERYING, never by constructing a filename.
|
|
327
333
|
RECOGNISE IT BY THE VALUE, NOT THE FIELD NAME. Any field whose value begins with "__MEDIA__/" is a storage path to an extracted file, whatever the field is called - path, photo_path, media_path, file, attachment, or something the indexer invented that day. A record's unique_id beginning "src::__MEDIA__/" marks it as a media record too.
|
|
328
334
|
The reliable query is getRecords with reference "src::<the document's storage path>" - one call, every table, every access group. Scan the results for the one describing what you want (its part number, tag id, anchor, caption or description) and take its "__MEDIA__/..." value. Never let a table guess be the reason you report a file as missing.
|
|
@@ -342,7 +348,7 @@ The same pattern applies to any format - name the block after the file you inten
|
|
|
342
348
|
systemPrompt += `
|
|
343
349
|
About BunnyQuery (this app - questions about it are in scope):
|
|
344
350
|
You are the assistant inside BunnyQuery, an AI assistant for the user's own business data. Instead of digging through folders, dashboards and files, the user uploads their documents, spreadsheets, images, notes and records, BunnyQuery indexes them into this project's database, and you answer questions, write reports and summarize from THAT data rather than from the open internet. Each project has its own data, its own AI platform (ChatGPT or Claude, powered by the project owner's own API key) and its own base prompt. BunnyQuery is built on Skapi (www.skapi.com), so the same project database is also reachable over MCP from any MCP-compatible AI client (mcp.broadwayinc.computer), and this chat can be embedded in a website as a widget with one script tag. Answer product questions from the facts in this section. If you are asked something about BunnyQuery that is NOT stated here - pricing, plan limits, a roadmap, a feature you cannot see - say you are not certain and point the user at the project owner or the BunnyQuery site, rather than inventing it.
|
|
345
|
-
How data gets in: ${canUpload === false ? `this user CANNOT upload in this session (they are not signed in, or the project's database is frozen for non-admins), and the attach affordances are hidden from them. Never instruct them to attach, drag in or upload a file, and never blame a missing answer on them not having uploaded it. Answer from what is already indexed, and when something genuinely is not in the project, say so and suggest asking the project's owner to add it.` : `the user attaches files to a chat message with the paperclip button in the composer, or drags and drops them onto the chat (whole folders work; up to 20 files per message). Uploaded files land in this project's file storage and are indexed automatically: read end to end and turned into database records. "Indexed" means exactly that, and it is why you can only answer from a file once its indexing has finished. While a file indexes, the chat shows a status row for it: yellow while it is working, green when it is indexed, red if it failed. A large file is indexed in windows over several passes, which takes longer; indexing runs on the server, so it keeps going if the user closes the page and the row is still there when they come back. The user can also paste plain text straight into the chat and ask you to save it - store it with the postRecords tool. BunnyQuery reads over 50 formats: office documents (.docx, .xlsx, .pptx, .hwp, .hwpx, .odt, .ods, .odp, .epub), PDFs, images, .csv/.tsv, .json, .xml, .html, .txt/.md and source code. Images and scanned PDFs are read with vision at index time.`}
|
|
351
|
+
How data gets in: ${canUpload === false ? `this user CANNOT upload in this session (they are not signed in, or the project's database is frozen for non-admins), and the attach affordances are hidden from them. Never instruct them to attach, drag in or upload a file, and never blame a missing answer on them not having uploaded it. Answer from what is already indexed, and when something genuinely is not in the project, say so and suggest asking the project's owner to add it.` : `the user attaches files to a chat message with the paperclip button in the composer, or drags and drops them onto the chat (whole folders work; up to 20 files per message). Uploaded files land in this project's file storage and are indexed automatically: read end to end and turned into database records. "Indexed" means exactly that, and it is why you can only answer from a file once its indexing has finished. While a file indexes, the chat shows a status row for it: yellow while it is working, green when it is indexed, red if it failed. A large file is indexed in windows over several passes, which takes longer; indexing runs on the server, so it keeps going if the user closes the page and the row is still there when they come back. The user can also paste plain text straight into the chat and ask you to save it - store it with the postRecords tool. BunnyQuery reads over 50 formats: office documents (.docx, .xlsx, .pptx, .hwp, .hwpx, .odt, .ods, .odp, .epub), email (.eml), PDFs, images, .csv/.tsv, .json, .xml, .html, .txt/.md and source code. Images and scanned PDFs are read with vision at index time.`}
|
|
346
352
|
Getting answers out: the user asks in plain language, in any language, and you answer from this project's data. You can also produce reports and downloadable files (CSV and the rest) as described in the File generation rules above, and any stored file can be handed back as a link, with images rendering inline in the chat.${""}${`
|
|
347
353
|
This chat is the BunnyQuery widget embedded in a website, so the user may have no access to the project console: keep any instructions to what can be done here in the chat.` }`;
|
|
348
354
|
if (greeting) {
|
|
@@ -363,7 +369,7 @@ Project description: """${serviceDescription}"""`;
|
|
|
363
369
|
const accessGroup = params.accessGroup === "public" || params.accessGroup === "private" ? params.accessGroup : "authorized";
|
|
364
370
|
let systemPrompt = `You are a background indexing agent for project ${projectId}.
|
|
365
371
|
- 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.
|
|
366
|
-
- 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.
|
|
372
|
+
- Most files (office documents like .docx/.xlsx/.pptx/.hwp/.hwpx/.ods, email messages (.eml), 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.
|
|
367
373
|
- 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). UNLESS this message already embeds a window of the file (in which case the message tells you not to call readFileContent, and you must not), read big spreadsheets and big text/data files 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.)
|
|
368
374
|
- 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.
|
|
369
375
|
- 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).
|
|
@@ -372,12 +378,16 @@ Project description: """${serviceDescription}"""`;
|
|
|
372
378
|
- Whatever the file type, this file's identity is "src::" + its storage path (the "storage path" metadata line) - never the inline content or a temporary URL. That record ALREADY EXISTS: the upload pipeline creates it in table "file_summaries" (access group "${accessGroup}") before indexing starts, so posting it again is rejected as a duplicate unique_id. Reference it from every record you write, and add what you learn to it with updateRecords. If that update unexpectedly reports the record does not exist, post it yourself ONCE with that exact "src::" unique_id (table "file_summaries", access group "${accessGroup}") and carry on; this is the ONE exception to the do-NOT-post-the-file-record rules elsewhere in these instructions, because the source identity must never be dropped just because an update failed.
|
|
373
379
|
- ACCESS GROUP (hard rule): every record you write for this file - the file record, per-row records, chapters, summaries, intermediates - MUST be posted with access group "${accessGroup}". Pass it explicitly on every postRecords call; do not leave it out and do not vary it between passes of the same file. An access group is part of a record's table key, so records saved under a different group than the file are in a different table and will not come back with the rest of it: a "public" file whose rows were saved as "authorized" is one an anonymous visitor can see the name of and none of the contents of, and a re-index cannot find the strays to clean them up. The one exception is the EXTRACTED MEDIA records in "__MEDIA__", which the pipeline creates for you - leave their group alone and only enrich them.
|
|
374
380
|
- REACHABILITY (hard rule): every record you write while indexing this file MUST be reachable from the file's "src::<storage path>" record by following reference - either reference that record directly, or reference something that already reaches it. A record with no reference, or one pointing outside this file's chain, is an ORPHAN: deleting or re-indexing the file removes the reachable records and leaves the orphan behind forever, where it keeps turning up in later answers as stale data. If you create an intermediate record that OTHER records reference (a page record that rows hang off, a sheet or section record), set source.can_remove_referencing_records to true on it; the delete cascade passes a delete through a record only when that record carries the flag OR a unique_id starting "src::" (the file record cascades because its unique_id starts with "src::"; the intermediates you create carry no "src::" id, so they need the flag), and it cascades ONE LEVEL AT A TIME, so EVERY intermediate record in a chain needs its own marker - an unmarked link stops the cascade there and everything below it survives as orphans. When in doubt, reference the file record directly and keep the chain flat.
|
|
375
|
-
- 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 table named EXACTLY "spreadsheet_rows". Do NOT summarize, sample only a few rows, or save just file metadata - index the whole sheet, window by window, until it ends. Make MULTIPLE postRecords calls in batches (e.g. 30-50 rows per call) rather than one oversized call. This per-row completeness OVERRIDES brevity. The file-level "src::" record ALREADY EXISTS - the upload pipeline creates it before indexing starts - so do NOT create it. Link EVERY per-row record to it via reference (set each row record's reference to exactly "src::" + the storage path, with NO sheet/window/summary suffix added; the row records themselves do NOT carry a src:: unique_id). Enrich that same record with sheet name(s), column headers and total row count via updateRecords rather than posting another one. 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. INDEX each row record on the row's most useful NUMERIC column (named by its header) so rows sort and range-query; when the row has no numeric column, index the grid row number instead. TAG each row record with the sheet name, the file name, and the row's categorical values (a status, a category, a type) - tags are how rows are filtered without scanning the table.
|
|
381
|
+
- TABULAR data (any spreadsheet - .csv/.tsv/.xlsx/.xls/.ods, or sheet-like rows): UNLESS the message tells you the server has ALREADY saved this spreadsheet's rows as records (in which case you must NOT write row records and must NOT call readFileContent for it; your only job is the file-level summary it describes), 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 table named EXACTLY "spreadsheet_rows". Do NOT summarize, sample only a few rows, or save just file metadata - index the whole sheet, window by window, until it ends. Make MULTIPLE postRecords calls in batches (e.g. 30-50 rows per call) rather than one oversized call. This per-row completeness OVERRIDES brevity. The file-level "src::" record ALREADY EXISTS - the upload pipeline creates it before indexing starts - so do NOT create it. Link EVERY per-row record to it via reference (set each row record's reference to exactly "src::" + the storage path, with NO sheet/window/summary suffix added; the row records themselves do NOT carry a src:: unique_id). Enrich that same record with sheet name(s), column headers and total row count via updateRecords rather than posting another one. 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. INDEX each row record on the row's most useful NUMERIC column (named by its header) so rows sort and range-query; when the row has no numeric column, index the grid row number instead. TAG each row record with the sheet name, the file name, and the row's categorical values (a status, a category, a type) - tags are how rows are filtered without scanning the table.
|
|
382
|
+
- WINDOW TAG. The message that shows you a window of a file names a tag of the form "win::" followed by a short code, and tells you to put it on every record you save from that window. Do it, on EVERY record, alongside the record's other tags. It is how the server removes exactly that window's records if the window ever has to be sent to you again, so that a retry never doubles what is stored. Never invent one, never reuse one from another window, and never leave it off.
|
|
376
383
|
- ONE RECORD PER GRID ROW, ALWAYS. "Row" means the numbered row of the sheet (R37 is one record), never a visual block, item, section or left/right pair. Sheets that repeat the same columns side by side (an A/B block beside a C/D block, "paired" or "mirrored" layouts) still get ONE record per grid row, holding BOTH sides - suffix the keys to keep them apart (PART_NO_A / PART_NO_B). Collapsing a 16-row window into 2 or 3 "block" records is the single most damaging mistake here: it silently loses most of the cells and makes every later total wrong, because some windows were counted per row and others per block. If a window shows rows R37 to R52, you save records for R37..R52 and the count you report is the number of grid rows you actually wrote.
|
|
377
|
-
-
|
|
378
|
-
-
|
|
384
|
+
- THE FILE NAME AND ITS FOLDERS ARE EVIDENCE ABOUT WHAT THE DATA MEANS, and often the only evidence there is. A grid of bare figures filed under "2026/Q2/royalties" is a quarterly royalty settlement; the same grid under "inspections/KCG-B507" is one aircraft's inspection. Nothing inside the sheet says so. Read the trail in the metadata block and use it: name the period, the entity, the counterparty or the subject in the file record's description, and TAG the records with the meaningful parts of it (the client, the aircraft, the quarter, the site), so a later question about that entity finds this file at all. A folder that is only an id or a date is still worth a tag; a folder like "uploads", "new" or "temp" is not.
|
|
385
|
+
- BUT NEVER INSTEAD OF READING. The path tells you what the data is ABOUT; only the content tells you what it SAYS. Never infer a value, a column meaning, a row count or a total from a name, never let a name override what the cells actually contain, and never derive a TABLE name from a folder or a file name - table names are fixed (see below), and a table named after a folder scatters one kind of record across as many tables as the user has folders. Where the name and the content disagree, the content wins and the disagreement is worth recording.
|
|
386
|
+
- FIXED TABLE NAMES. Never invent a table name for one pass, and never vary the name between passes of the SAME file: that scatters one file's data across tables nobody can enumerate later, so the data is effectively lost even though every save succeeded. Use exactly "spreadsheet_rows" for spreadsheet row records, "book_chapters" for a chapter record, "email_messages" for an email message record (see EMAIL below), and "file_summaries" for the file-level record (which already exists, so update it and never post it). Embedded photos and other embedded files get NO table of your choosing: their records already exist in table "__MEDIA__", see EXTRACTED MEDIA below. For a content type none of those fit, choose ONE plain descriptive name, use that same name for every pass of the file, and never mint variants of it (inspection_items / item_records / sheet_items / inspection_data are four names for what is one table).
|
|
387
|
+
- EXTRACTED MEDIA: every PICTURE embedded in an uploaded document (photos, diagrams, chart images) is pulled out and saved as a real permanent file under "__MEDIA__/<the document's storage path>/<name>", and a record for each one ALREADY EXISTS in table "__MEDIA__" with unique_id "src::<that path>", reference "src::<the document>", and its path, anchor and sheet already in data. Do NOT create it - the unique_id is taken and your post is rejected. UPDATE it with updateRecords, addressed by that unique_id, adding what the file actually SHOWS plus TAGS for every identifier visible in it (part numbers, tag ids, item names, serial numbers). An update REPLACES the fields you send, so send the existing tags back with your new ones and keep every field already in data (path, anchor, sheet, source, mime, bytes). ONE FILE, ONE RECORD: never also create a photo record in another table. If the update reports that the record does not exist, create it with that same unique_id, reference and data.path - the path must never be lost. Audio and video clips and non-picture attachments are never saved as separate files (an email's attachment text is read inline instead, see EMAIL below), so never claim a separate file or a "__MEDIA__" record exists for one of those.
|
|
379
388
|
- AUDIO files: transcribe the speech, and capture speakers (named where identifiable), the topics discussed, and timestamps of key moments in the record's data. TAG the language, the audio type (call, meeting, dictation, music), each speaker and every named entity; INDEX the duration in seconds as duration_seconds. VIDEO files: everything audio gets, PLUS transcribe on-screen text verbatim (same transcription discipline as photos) and capture the visual timeline - scene changes and what each scene shows, with timestamps. Same tags as audio plus every entity visible on screen, and INDEX duration_seconds here too. These audio and video rules apply to files UPLOADED AS FILES: the transcript and timeline land on the file's own "src::" record, which already exists. Audio or video embedded inside a document is NOT extracted, so never look for or promise a "__MEDIA__" record for it.
|
|
380
389
|
- 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 the table "book_chapters" - never collapse the whole book into a single record. INDEX each chapter record on its chapter number (so chapters sort and range-query in order) and include the chapter title among its tags; the 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 put the book-level facts (title, author, language, overall summary, chapter list / table of contents, genre/subjects) onto the "src::" file record that ALREADY EXISTS in "file_summaries", using updateRecords. Do NOT post a second book-level record, and set every chapter record's reference to exactly "src::" + the storage path. This per-chapter completeness OVERRIDES brevity; human-readable summaries only, never raw/binary bytes.
|
|
390
|
+
- EMAIL (.eml, provided inline with "=== EMAIL ===" / "=== BODY ===" / "=== ATTACHMENT i/N: ..." / "=== FORWARDED MESSAGE k (depth d) ===" headings, which always start at column 0; a body line that merely looks like one is body text): you MUST save ONE record per email MESSAGE in the table "email_messages", and a forwarded message inside it (its own "=== EMAIL ===" block) gets its OWN record. Each record carries subject, from, to, cc, date (the Date line: an ISO string when the layer could parse it, otherwise the raw header text), message_id, in_reply_to, and the body text (quoted earlier replies included). INDEX each record on its date as that string exactly as given, and include the sender address, every recipient address and the subject among its tags. Text under an "=== ATTACHMENT" heading is that attachment's extracted content: datafy it by its own kind (rows into "spreadsheet_rows" for a spreadsheet, one record per section for a document), tag those records with the attachment's filename, and give EVERY record the same "src::" reference as the email. Picture attachments are extracted into "__MEDIA__" like any other embedded picture (their media anchor is quoted on the "[picture ...]" line); other attachments are read inline: their content becomes the records above, but no separate FILE or file record exists for one, so never cite a path for it.
|
|
381
391
|
- URL SOURCES: when the source being indexed is a URL rather than an uploaded file (a temporary or signed URL that merely DELIVERS an uploaded file's bytes is not a URL source; that file keeps its storage-path identity), its identity is "src::" + the FULL URL INCLUDING the query string (the query string often selects the content, so dropping it collapses different pages into one identity). If no record with that unique_id exists, create it; if the slot is already taken, update that record or reference it - never mint a variant id. For a WEB PAGE: extract everything on it, infer the page's primary entity type when it is not obvious (product, listing, article, profile), TAG that entity type plus the entities on the page, and INDEX the ONE number every entity of that type can be compared by (a price for a product, a date for an article). Any OTHER URL (a file behind a link) is downloaded and indexed under whichever per-type rule above matches its content. When the URL's content offers more index points than one record carries, add reference-linked records reachable from its "src::" record.
|
|
382
392
|
- This is a background indexing task: do ALL the MCP saving FIRST, never reply mid-task, and never ask the user questions. 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 encoded file bytes in ANY field: no base64, no data: URIs, no hex or blob dumps. A long opaque non-human-readable string is not data - replace it with a structured description of what it encodes. If base64 or a data: URI is all you have for something, describe it conceptually and never paste it; if nothing human-readable can be extracted at all, OMIT that record rather than saving noise.
|
|
383
393
|
- COMPLETION SIGNAL: only when YOU paged the file yourself with readFileContent and it reported "END OF FILE", with every row/item saved, end your final message with the token INDEXING_COMPLETE on its own line. If more rows remain, do NOT write that token - leaving it out is how the system knows to run another pass to continue. When the file arrives INSIDE this message one window at a time (an embedded window of rows/text, or rendered PDF page images), you are NOT the one who decides it is finished: the system advances the window off the real page/row count and sends the next pass automatically, so save this window, report what you saved, and never imply you have seen the whole file.
|
|
@@ -395,13 +405,21 @@ Project description: """${serviceDescription}"""`;
|
|
|
395
405
|
const g = attachment && attachment.accessGroup;
|
|
396
406
|
return g === "public" || g === "private" ? g : "authorized";
|
|
397
407
|
}
|
|
408
|
+
function indexingFolderTrail(storagePath) {
|
|
409
|
+
if (typeof storagePath !== "string" || !storagePath) return "";
|
|
410
|
+
const parts = storagePath.split("/").filter(Boolean);
|
|
411
|
+
parts.pop();
|
|
412
|
+
return parts.join(" / ");
|
|
413
|
+
}
|
|
398
414
|
function buildIndexingUserMessage(attachment, options) {
|
|
399
415
|
const head = `A new file has just been uploaded. Index it now.
|
|
400
416
|
|
|
401
417
|
File metadata:
|
|
402
418
|
- name: ${attachment.name}
|
|
403
419
|
- storage path: ${attachment.storagePath}
|
|
404
|
-
` +
|
|
420
|
+
` + // Context, not an address. See indexingFolderTrail.
|
|
421
|
+
(indexingFolderTrail(attachment.storagePath) ? `- folders it was filed under: ${indexingFolderTrail(attachment.storagePath)}
|
|
422
|
+
` : "") + (attachment.mime ? `- mime type: ${attachment.mime}
|
|
405
423
|
` : "") + (typeof attachment.size === "number" ? `- size (bytes): ${attachment.size}
|
|
406
424
|
` : "") + // Stated in the metadata block as well as the system prompt because this is
|
|
407
425
|
// the per-FILE value: one project can hold public and private files at once,
|
|
@@ -454,7 +472,9 @@ Records for the earlier pages are ALREADY saved (they reference "${src}"). The N
|
|
|
454
472
|
return `File metadata:
|
|
455
473
|
- name: ${attachment.name}
|
|
456
474
|
- storage path: ${attachment.storagePath}
|
|
457
|
-
` +
|
|
475
|
+
` + // Context, not an address. See indexingFolderTrail.
|
|
476
|
+
(indexingFolderTrail(attachment.storagePath) ? `- folders it was filed under: ${indexingFolderTrail(attachment.storagePath)}
|
|
477
|
+
` : "") + (attachment.mime ? `- mime type: ${attachment.mime}
|
|
458
478
|
` : "") + `- access group (use this for EVERY record you write for this file): ${indexingAccessGroup(attachment)}
|
|
459
479
|
`;
|
|
460
480
|
}
|
|
@@ -498,7 +518,9 @@ Save records for THIS window only, then stop and report what you saved. Do NOT t
|
|
|
498
518
|
File metadata:
|
|
499
519
|
- name: ${attachment.name}
|
|
500
520
|
- storage path: ${attachment.storagePath}
|
|
501
|
-
` +
|
|
521
|
+
` + // Context, not an address. See indexingFolderTrail.
|
|
522
|
+
(indexingFolderTrail(attachment.storagePath) ? `- folders it was filed under: ${indexingFolderTrail(attachment.storagePath)}
|
|
523
|
+
` : "") + (attachment.mime ? `- mime type: ${attachment.mime}
|
|
502
524
|
` : "") + `- access group (use this for EVERY record you write for this file): ${indexingAccessGroup(attachment)}
|
|
503
525
|
|
|
504
526
|
Records for the earlier windows/pages of this file are ALREADY saved (they reference "${src}"). First call getRecords with reference "${src}" to see how far the previous pass got (the furthest 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. 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:
|
|
@@ -1035,6 +1057,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
1035
1057
|
return key && projectContextWindows[key] ? projectContextWindows[key] : null;
|
|
1036
1058
|
}
|
|
1037
1059
|
var MAX_OUTPUT_TOKENS = 25e3;
|
|
1060
|
+
var INDEXING_MAX_OUTPUT_TOKENS = 64e3;
|
|
1038
1061
|
var TOOL_AND_RESPONSE_BUFFER = 4e3;
|
|
1039
1062
|
var MIN_INPUT_TOKEN_BUDGET = 8e3;
|
|
1040
1063
|
var MIN_PER_REQUEST_INPUT_CAP = 28e3;
|
|
@@ -1063,9 +1086,10 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
1063
1086
|
function getModelContextWindow(platform, model) {
|
|
1064
1087
|
return resolveByModelId(apiReportedContextWindows, CONTEXT_WINDOW_BY_MODEL, model) || CONTEXT_WINDOW_DEFAULT[platform];
|
|
1065
1088
|
}
|
|
1066
|
-
function getMaxOutputTokens(platform, model) {
|
|
1089
|
+
function getMaxOutputTokens(platform, model, purpose) {
|
|
1090
|
+
var want = purpose === "indexing" ? INDEXING_MAX_OUTPUT_TOKENS : MAX_OUTPUT_TOKENS;
|
|
1067
1091
|
var cap = resolveByModelId(apiReportedMaxOutput, MAX_OUTPUT_BY_MODEL, model);
|
|
1068
|
-
return cap ? Math.min(
|
|
1092
|
+
return cap ? Math.min(want, cap) : want;
|
|
1069
1093
|
}
|
|
1070
1094
|
function getContextWindow(platform, model, projectId) {
|
|
1071
1095
|
var ceiling = getModelContextWindow(platform, model);
|
|
@@ -2037,6 +2061,17 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
2037
2061
|
var DEFAULT_CLAUDE_MODEL = "claude-sonnet-5";
|
|
2038
2062
|
var DEFAULT_OPENAI_MODEL = "gpt-5.6-luna";
|
|
2039
2063
|
var mcpUrl = () => chatEngineConfig().mcpBaseUrl;
|
|
2064
|
+
function withMcpParams(base, params) {
|
|
2065
|
+
if (!base) return base;
|
|
2066
|
+
const pairs = Object.keys(params).filter((k) => params[k] !== void 0 && params[k] !== null && params[k] !== "").map((k) => encodeURIComponent(k) + "=" + encodeURIComponent(String(params[k])));
|
|
2067
|
+
if (!pairs.length) return base;
|
|
2068
|
+
const [addr, existing] = base.split("?");
|
|
2069
|
+
const hasPath = /^[a-z][a-z0-9+.-]*:\/\/[^/]+\/./i.test(addr);
|
|
2070
|
+
const path = hasPath ? addr : addr.replace(/\/+$/, "") + "/";
|
|
2071
|
+
return path + "?" + (existing ? existing + "&" : "") + pairs.join("&");
|
|
2072
|
+
}
|
|
2073
|
+
var mcpContextParam = (platform, model) => getModelContextWindow(platform, model);
|
|
2074
|
+
var mcpIndexingUrl = (platform = "openai", model) => withMcpParams(mcpUrl(), { profile: "index", ctx: mcpContextParam(platform, model) });
|
|
2040
2075
|
function mcpEndpointFor(anonymous, publicProjectId, service) {
|
|
2041
2076
|
if (!anonymous) return { url: mcpUrl(), token: "$ACCESS_TOKEN" };
|
|
2042
2077
|
const project = publicProjectId || service;
|
|
@@ -2308,7 +2343,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
2308
2343
|
fileUrls,
|
|
2309
2344
|
mcpServer: {
|
|
2310
2345
|
name: MCP_NAME,
|
|
2311
|
-
url: endpoint.url,
|
|
2346
|
+
url: withMcpParams(endpoint.url, {
|
|
2347
|
+
ctx: mcpContextParam("claude", model || DEFAULT_CLAUDE_MODEL)
|
|
2348
|
+
}),
|
|
2312
2349
|
// Omitted entirely for an anonymous turn; the `if (mcpServer.authorizationToken)`
|
|
2313
2350
|
// guard below drops the key rather than sending an empty one.
|
|
2314
2351
|
authorizationToken: endpoint.token
|
|
@@ -2363,7 +2400,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
2363
2400
|
{
|
|
2364
2401
|
type: "mcp",
|
|
2365
2402
|
server_label: MCP_NAME,
|
|
2366
|
-
server_url: endpoint.url,
|
|
2403
|
+
server_url: withMcpParams(endpoint.url, { ctx: mcpContextParam("openai", resolvedModel) }),
|
|
2367
2404
|
require_approval: "never",
|
|
2368
2405
|
// No `headers` at all for an anonymous turn: `Bearer ` with an
|
|
2369
2406
|
// empty token is a credential the MCP server rejects, and the
|
|
@@ -2501,7 +2538,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
2501
2538
|
},
|
|
2502
2539
|
data: {
|
|
2503
2540
|
model: resolvedModel2,
|
|
2504
|
-
max_output_tokens: getMaxOutputTokens("openai", resolvedModel2),
|
|
2541
|
+
max_output_tokens: getMaxOutputTokens("openai", resolvedModel2, "indexing"),
|
|
2505
2542
|
// Nano-only transcription knobs. Indexing only; see variantIndexingOptions.
|
|
2506
2543
|
...variantIndexingOptions(resolvedModel2),
|
|
2507
2544
|
...skapiExtract,
|
|
@@ -2519,7 +2556,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
2519
2556
|
{
|
|
2520
2557
|
type: "mcp",
|
|
2521
2558
|
server_label: MCP_NAME,
|
|
2522
|
-
server_url:
|
|
2559
|
+
server_url: mcpIndexingUrl("openai", resolvedModel2),
|
|
2523
2560
|
require_approval: "never",
|
|
2524
2561
|
headers: { Authorization: "Bearer $ACCESS_TOKEN" }
|
|
2525
2562
|
},
|
|
@@ -2550,7 +2587,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
2550
2587
|
},
|
|
2551
2588
|
data: {
|
|
2552
2589
|
model: resolvedModel,
|
|
2553
|
-
max_tokens: getMaxOutputTokens("claude", resolvedModel),
|
|
2590
|
+
max_tokens: getMaxOutputTokens("claude", resolvedModel, "indexing"),
|
|
2554
2591
|
...skapiExtract,
|
|
2555
2592
|
...skapiRender,
|
|
2556
2593
|
...skapiWindow,
|
|
@@ -2572,7 +2609,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
2572
2609
|
{
|
|
2573
2610
|
type: "url",
|
|
2574
2611
|
name: MCP_NAME,
|
|
2575
|
-
url:
|
|
2612
|
+
url: mcpIndexingUrl("claude", resolvedModel),
|
|
2576
2613
|
authorization_token: "$ACCESS_TOKEN"
|
|
2577
2614
|
}
|
|
2578
2615
|
],
|
|
@@ -8168,7 +8205,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
8168
8205
|
(function() {
|
|
8169
8206
|
var MCP_PROD = "https://mcp.broadwayinc.computer";
|
|
8170
8207
|
var MCP_DEV = "https://mcp-dev.broadwayinc.computer";
|
|
8171
|
-
var BQ_VERSION = "1.
|
|
8208
|
+
var BQ_VERSION = "1.10.0" ;
|
|
8172
8209
|
var ATTACHMENT_URL_EXPIRES_SECONDS = 600;
|
|
8173
8210
|
var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
8174
8211
|
var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
@@ -10039,7 +10076,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
|
|
|
10039
10076
|
jpeg: "image/jpeg",
|
|
10040
10077
|
gif: "image/gif",
|
|
10041
10078
|
webp: "image/webp",
|
|
10042
|
-
svg: "image/svg+xml"
|
|
10079
|
+
svg: "image/svg+xml",
|
|
10080
|
+
eml: "message/rfc822"
|
|
10043
10081
|
};
|
|
10044
10082
|
return map[ext] || null;
|
|
10045
10083
|
}
|