bunnyquery 1.15.0 → 1.15.1

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 CHANGED
@@ -140,7 +140,8 @@ Mounts the widget. Returns the `BunnyQuery` object.
140
140
  | `attachmentParsers` | `array` | `null` | Client-side attachment parsers. See [Attachment parser plugins](#attachment-parser-plugins). |
141
141
  | `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. |
142
142
  | `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. |
143
- | `webAccess` | `boolean` | `null` | Whether the chat's answers may reach the open web (Claude `web_fetch`, OpenAI `web_search`). `null` follows the project's own **Web access for answers** switch on its Project Settings page (the `web_access` key of the project's `bq::settings` record), which is **off** unless the owner turned it on: answers then come only from the project's files and database. `true`/`false` pins it for this page, both ways. Resolution order: this option, then the project setting, then off. Indexing is not affected. |
143
+ | `webAccess` | `boolean` | `null` | Whether the chat's answers may reach the open web (Claude `web_fetch`, OpenAI `web_search`). The project's own **Web access for answers** switch on its Project Settings page (the `web_access` key of the project's `bq::settings` record, **off** unless the owner turned it on) is the **ceiling**, and this option may only restrict below it: `effective = projectSetting && webAccess !== false`. `null` or `true` follows the project; `false` turns the web off for this page whatever the project says; a value above the project setting has no effect. With the web off, answers come only from the project's files and database. Indexing is not affected. |
144
+ | `skipDuplicateFiles` | `boolean` | `null` | Whether a chat attachment the signed-in user **already uploaded** to this project (same stored file name, same size in bytes, same uploader, found from the file's `src::` record) is skipped: it is neither uploaded nor indexed again, the chip reads "already uploaded", and the message uses the stored copy's url. The project's **Skip duplicate files** setting (the `skip_duplicate_files` key of `bq::settings`, **on** unless the owner turned it off) is the **ceiling**, and this option may only restrict below it: `effective = projectSetting && skipDuplicateFiles !== false`. `null` or `true` follows the project; `false` lets this page re-upload duplicates; a value above the project setting has no effect. The check never blocks an upload: a failed lookup uploads as usual. |
144
145
  | `liveStreaming` | `boolean` | `true` | Paint a chat answer into its bubble as it arrives, instead of at the end. On by default; pass `false` for buffered replies. Still 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 `forwardRequestStream` and `forwardRequestFinalize`, or their older names `clientSecretRequestStream` and `clientSecretRequestFinalize` on an earlier `skapi-js`), 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 also needs a polling worker that relays the response bytes, which every deployed region now has; against a self-hosted worker that does not, pass `false`. |
145
146
  | `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. |
146
147
 
@@ -492,7 +493,8 @@ helpers. See the `.d.ts` shipped with `bunnyquery/engine`.
492
493
  | `attachmentParsers` | `array?` | Client-side attachment parsers, registered at configure time. More can be added later with `registerAttachmentParser()`. See [Attachment parser plugins](#attachment-parser-plugins). |
493
494
  | `windowedIndexing` | `boolean?` | Opt in to **server-driven** windowed indexing for text and grid files (see [file types](#supported-file-types)). Off by default in the engine; the widget passes it as `true`. The deployed skapi workers support it; only leave it off against a self-hosted worker that does not yet strip the `_skapi_window` directive, where it would reach the provider as an unknown body field and fail the call terminally with no retry. |
494
495
  | `liveStreaming` | `boolean?` | Opt in to **live streaming** of chat turns. Off by default in the engine (the widget passes it as `true`), and the backend ships first: a streamed row settles with a status and NO body (the answer was the stream), so against a worker that does not relay, the turn reads back empty. Pair it with `forwardRequestFinalize` and `forwardRequestStream`, and gate it on `skapiSupportsStreaming(skapi)`. |
495
- | `webAccess` | `boolean?` | Whether a chat turn may reach the open web: Claude's `web_fetch` and OpenAI's `web_search` are attached to a chat request only when this resolves to `true`. Leave it out to follow the project's own `web_access` setting (the `bq::settings` record, read through `configureProjectSettings`), which is **off** unless the project owner turned it on; a boolean here overrides the project for your host, both ways. `webAccessEnabled(serviceId)` and `resolveWebAccess(serviceId)` answer the resolved switch (sync from the cache, or awaiting the settings fetch). Indexing passes keep their tools regardless. Pass the same value to `buildChatSystemPrompt({ webAccess })` so the prompt matches the tool list. |
496
+ | `webAccess` | `boolean?` | Whether a chat turn may reach the open web: Claude's `web_fetch` and OpenAI's `web_search` are attached to a chat request only when this resolves to `true`. The project's own `web_access` setting (the `bq::settings` record, read through `configureProjectSettings`; **off** unless the project owner turned it on) is the **ceiling** and this option may only restrict below it: `effective = projectSetting && webAccess !== false`. Leave it out or pass `true` to follow the project; `false` turns the web off for your host; a value above the project setting has no effect. `webAccessEnabled(serviceId)` and `resolveWebAccess(serviceId, override?)` answer the resolved switch (sync from the cache, or awaiting the settings fetch; `override` is a per-call host value that restricts the same way). Indexing passes keep their tools regardless. Pass the same value to `buildChatSystemPrompt({ webAccess })` so the prompt matches the tool list. |
497
+ | `skipDuplicateFiles` | `boolean?` | Whether an attachment the signed-in user already uploaded to the project (same stored name, same size, same uploader) is skipped by `ChatSession`'s upload path: not uploaded, not indexed, the turn using the stored copy's url. The project's `skip_duplicate_files` setting (**on** unless the owner turned it off) is the **ceiling** and this option may only restrict: `effective = projectSetting && skipDuplicateFiles !== false`. The check runs only when the host implements the optional `findDuplicateUpload(file)` hook; `findDuplicateUpload(skapi, { name, size }, userId, { service? })` from the barrel is the ready-made implementation (an ends-with `getUniqueId` lookup narrowed to `src::<name>` and `src::.../<name>`, then the first record whose `data.size_bytes` and `user_id` match; it never throws, a failed lookup answers `null` and the file uploads as usual). `skipDuplicateFilesEnabled(serviceId)` and `resolveSkipDuplicateFiles(serviceId)` answer the resolved switch. |
496
498
  | `forwardRequestFinalize` | `function?` | `skapi.forwardRequestFinalize`, 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. |
497
499
  | `forwardRequestStream` | `function?` | `skapi.forwardRequestStream`, 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. |
498
500
  | `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. |
package/bunnyquery.js CHANGED
@@ -61,6 +61,13 @@
61
61
  function webAccessFrom(data) {
62
62
  return data ? normalizeWebAccess(data.web_access) : DEFAULT_WEB_ACCESS;
63
63
  }
64
+ var DEFAULT_SKIP_DUPLICATE_FILES = true;
65
+ function normalizeSkipDuplicateFiles(value) {
66
+ return value !== false;
67
+ }
68
+ function skipDuplicateFilesFrom(data) {
69
+ return data ? normalizeSkipDuplicateFiles(data.skip_duplicate_files) : DEFAULT_SKIP_DUPLICATE_FILES;
70
+ }
64
71
  var CHAT_GREETING_MAX_LENGTH = 400;
65
72
  function normalizeChatGreeting(value) {
66
73
  if (typeof value !== "string") return "";
@@ -203,16 +210,25 @@
203
210
  function liveStreamingEnabled() {
204
211
  return _config?.liveStreaming === true;
205
212
  }
213
+ function underCeiling(projectSetting, option, override) {
214
+ if (!projectSetting) return false;
215
+ if (option === false) return false;
216
+ if (override === false) return false;
217
+ return true;
218
+ }
206
219
  function webAccessEnabled(service) {
207
220
  const opt = _config ? _config.webAccess : void 0;
208
- if (typeof opt === "boolean") return opt;
209
- return webAccessFrom(service ? cachedProjectSettings(service) : null);
221
+ return underCeiling(webAccessFrom(service ? cachedProjectSettings(service) : null), opt);
210
222
  }
211
- function resolveWebAccess(service) {
223
+ function resolveWebAccess(service, override) {
212
224
  const opt = _config ? _config.webAccess : void 0;
213
- if (typeof opt === "boolean") return Promise.resolve(opt);
214
- if (!service) return Promise.resolve(false);
215
- return loadProjectSettings(service).then(webAccessFrom, () => false);
225
+ if (!service) return Promise.resolve(underCeiling(webAccessFrom(null), opt, override));
226
+ return loadProjectSettings(service).then(webAccessFrom, () => false).then((setting) => underCeiling(setting, opt, override));
227
+ }
228
+ function resolveSkipDuplicateFiles(service) {
229
+ const opt = _config ? _config.skipDuplicateFiles : void 0;
230
+ if (!service) return Promise.resolve(underCeiling(skipDuplicateFilesFrom(null), opt));
231
+ return loadProjectSettings(service).then(skipDuplicateFilesFrom, () => skipDuplicateFilesFrom(null)).then((setting) => underCeiling(setting, opt));
216
232
  }
217
233
  function streamRecoveryEnabled() {
218
234
  if (!_config) return false;
@@ -228,6 +244,54 @@
228
244
  return p === void 0 ? {} : { poll: p };
229
245
  }
230
246
 
247
+ // src/engine/duplicates.ts
248
+ var DUPLICATE_LOOKUP_MAX_CANDIDATES = 25;
249
+ var SRC_PREFIX = "src::";
250
+ function isDuplicateCandidateId(uniqueId, name) {
251
+ if (typeof uniqueId !== "string" || !name) return false;
252
+ if (uniqueId.indexOf(SRC_PREFIX) !== 0) return false;
253
+ if (uniqueId === SRC_PREFIX + name) return true;
254
+ const tail = "/" + name;
255
+ return uniqueId.length > SRC_PREFIX.length + tail.length && uniqueId.slice(-tail.length) === tail;
256
+ }
257
+ function listOf(res) {
258
+ if (!res) return [];
259
+ if (Array.isArray(res)) return res;
260
+ return Array.isArray(res.list) ? res.list : [];
261
+ }
262
+ async function findDuplicateUpload(skapi, file, userId, opts) {
263
+ try {
264
+ if (!skapi || typeof skapi.getUniqueId !== "function" || typeof skapi.getRecords !== "function") return null;
265
+ const name = file && typeof file.name === "string" ? file.name : "";
266
+ const size = file && typeof file.size === "number" ? file.size : NaN;
267
+ if (!name || !(size >= 0) || !userId) return null;
268
+ const service = opts && opts.service ? { service: opts.service } : {};
269
+ const found = listOf(await skapi.getUniqueId(Object.assign({ unique_id: name, condition: "<=" }, service)));
270
+ const ids = [];
271
+ for (const item of found) {
272
+ const id = item && typeof item === "object" ? item.unique_id : item;
273
+ if (isDuplicateCandidateId(id, name) && ids.indexOf(id) === -1) ids.push(id);
274
+ if (ids.length >= DUPLICATE_LOOKUP_MAX_CANDIDATES) break;
275
+ }
276
+ for (const uniqueId of ids) {
277
+ let record = null;
278
+ try {
279
+ record = listOf(await skapi.getRecords(Object.assign({ unique_id: uniqueId }, service)))[0] || null;
280
+ } catch {
281
+ continue;
282
+ }
283
+ if (!record || typeof record !== "object") continue;
284
+ const data = record.data && typeof record.data === "object" ? record.data : null;
285
+ if (!data || data.size_bytes !== size) continue;
286
+ if (record.user_id !== userId) continue;
287
+ return { storagePath: uniqueId.slice(SRC_PREFIX.length), uniqueId, record };
288
+ }
289
+ return null;
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+
231
295
  // src/engine/office.ts
232
296
  var OFFICE_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
233
297
  "doc",
@@ -637,6 +701,10 @@ Records for the earlier pages are ALREADY saved (they reference "${src}"). The N
637
701
  ` : "") + `- access group (use this for EVERY record you write for this file): ${indexingAccessGroup(attachment)}
638
702
  `;
639
703
  }
704
+ var PAGE_TEXT_TABLE = "page_texts";
705
+ var PAGE_TEXT_RULE = `PAGE TEXT for pages WITHOUT a text layer: for EVERY page whose label (its first label, when the page is split into strips) carries NO "Embedded text layer" block, ALSO post exactly ONE record in table "${PAGE_TEXT_TABLE}" with data {"page": <its document page number from the label, 1-based>, "page_text": "<the page's complete text exactly as printed, verbatim, in reading order, nothing summarised or left out>"}, reference "src::" + the storage path above, in addition to the records above. A page WITH an embedded text layer gets NO such record: its text is already stored.
706
+
707
+ `;
640
708
  function buildRenderDatafy(placeholder) {
641
709
  return `
642
710
  ${placeholder}
@@ -647,7 +715,7 @@ Each image is preceded by a label giving its DOCUMENT PAGE number. That label is
647
715
 
648
716
  Transcribe COMPLETELY, not representatively. A table with twenty rows gets twenty records, not a sample of the first few - if a page has more rows than you can save comfortably, still save them all rather than summarising. Where a page carries an embedded text layer it is quoted above that page's image: it is the exact text and should be preferred over reading the pixels, with the image used for layout, tables, stamps and handwriting.
649
717
 
650
- Save records for THIS window of pages only, then stop and report what you saved. Do NOT try to read the rest of the file and do NOT worry about the pages after this window: if any remain, the next window is rendered and sent to you automatically. Report only the pages you were actually shown - never imply you have seen the whole document.`;
718
+ ` + PAGE_TEXT_RULE + `Save records for THIS window of pages only, then stop and report what you saved. Do NOT try to read the rest of the file and do NOT worry about the pages after this window: if any remain, the next window is rendered and sent to you automatically. Report only the pages you were actually shown - never imply you have seen the whole document.`;
651
719
  }
652
720
  function buildIndexingWindowMessage(attachment, placeholder, isContinuation, positionLabel) {
653
721
  const src = `src::${attachment.storagePath}`;
@@ -1151,7 +1219,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1151
1219
  var FILE_KEYS = ["path", "storage_path", "storagePath", "file", "file_path", "filePath"];
1152
1220
  var TABLE_KEYS = ["table_name", "tableName"];
1153
1221
  var GROUP_KEYS = ["access_group", "accessGroup"];
1154
- var SRC_PREFIX = "src::";
1222
+ var SRC_PREFIX2 = "src::";
1155
1223
  var RESULT_WALK_MAX_NODES = 400;
1156
1224
  var RESULT_WALK_MAX_DEPTH = 5;
1157
1225
  function isWebToolName(name) {
@@ -1180,8 +1248,8 @@ Index the REMAINING windows - one record per row/item, looking at any page image
1180
1248
  }
1181
1249
  function pathFromSrcId(value) {
1182
1250
  if (typeof value !== "string") return null;
1183
- if (value.indexOf(SRC_PREFIX) !== 0) return null;
1184
- var rest = value.slice(SRC_PREFIX.length).trim();
1251
+ if (value.indexOf(SRC_PREFIX2) !== 0) return null;
1252
+ var rest = value.slice(SRC_PREFIX2.length).trim();
1185
1253
  return rest ? rest : null;
1186
1254
  }
1187
1255
  function parseJsonLoose(value) {
@@ -2811,7 +2879,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
2811
2879
  fileUrls,
2812
2880
  webAccess
2813
2881
  }) {
2814
- const web = typeof webAccess === "boolean" ? webAccess : await resolveWebAccess(service);
2882
+ const web = await resolveWebAccess(service, typeof webAccess === "boolean" ? webAccess : void 0);
2815
2883
  const mcpServerDefinition = {
2816
2884
  type: "url",
2817
2885
  name: mcpServer.name,
@@ -8341,11 +8409,26 @@ Index the REMAINING windows - one record per row/item, looking at any page image
8341
8409
  if (!total) return Promise.reject(new Error("Empty attachment"));
8342
8410
  var urls = [];
8343
8411
  var anyIndexFailed = false;
8412
+ var reusedCount = 0;
8413
+ att.reused = false;
8344
8414
  var chain = Promise.resolve();
8415
+ var findDuplicate = function(member) {
8416
+ if (typeof self.host.findDuplicateUpload !== "function") return Promise.resolve(null);
8417
+ var storedName = String(member.storagePath || "").split("/").filter(Boolean).pop() || member.file.name;
8418
+ return resolveSkipDuplicateFiles(id.projectId).then(function(on) {
8419
+ if (!on) return null;
8420
+ return Promise.resolve(self.host.findDuplicateUpload({ name: storedName, size: member.file.size })).then(function(hit) {
8421
+ return hit && typeof hit.storagePath === "string" && hit.storagePath ? hit.storagePath : null;
8422
+ });
8423
+ }).catch(function() {
8424
+ return null;
8425
+ });
8426
+ };
8345
8427
  members.forEach(function(member, idx) {
8346
8428
  chain = chain.then(function() {
8347
8429
  var hadExists = false;
8348
8430
  var skipped = false;
8431
+ var duplicateOf = null;
8349
8432
  var existedBefore = false;
8350
8433
  var onProg = function(p) {
8351
8434
  if (p && p.total) {
@@ -8364,7 +8447,14 @@ Index the REMAINING windows - one record per row/item, looking at any page image
8364
8447
  }
8365
8448
  });
8366
8449
  };
8367
- return doMemberUpload(true).catch(function(err) {
8450
+ return findDuplicate(member).then(function(dup) {
8451
+ if (dup) {
8452
+ duplicateOf = dup;
8453
+ console.log("[chat-engine] skipping an upload the user already has:", member.storagePath, "->", dup);
8454
+ return;
8455
+ }
8456
+ return doMemberUpload(true);
8457
+ }).catch(function(err) {
8368
8458
  var code = err && (err.code || err.body && err.body.code);
8369
8459
  var msg = err && (err.message || err.body && err.body.message || (typeof err === "string" ? err : ""));
8370
8460
  var isExists = code === "EXISTS" || msg && /exist/i.test(msg);
@@ -8384,9 +8474,20 @@ Index the REMAINING windows - one record per row/item, looking at any page image
8384
8474
  });
8385
8475
  }).then(function() {
8386
8476
  if (skipped) return;
8387
- return self.host.getTemporaryUrl(member.storagePath);
8477
+ return self.host.getTemporaryUrl(duplicateOf || member.storagePath);
8388
8478
  }).then(function(url) {
8389
8479
  if (skipped) return;
8480
+ if (duplicateOf) {
8481
+ urls.push({ name: member.relPath, url, storagePath: duplicateOf });
8482
+ reusedCount += 1;
8483
+ if (att.kind !== "folder") {
8484
+ att.uploadedUrl = url;
8485
+ att.storagePath = duplicateOf;
8486
+ att.reused = true;
8487
+ } else att.reusedCount = reusedCount;
8488
+ self.host.renderAttachmentChips();
8489
+ return;
8490
+ }
8390
8491
  urls.push({ name: member.relPath, url, storagePath: member.storagePath });
8391
8492
  if (att.kind !== "folder") {
8392
8493
  att.uploadedUrl = url;
@@ -9018,7 +9119,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
9018
9119
  (function() {
9019
9120
  var MCP_PROD = "https://mcp.broadwayinc.computer";
9020
9121
  var MCP_DEV = "https://mcp-dev.broadwayinc.computer";
9021
- var BQ_VERSION = "1.15.0" ;
9122
+ var BQ_VERSION = "1.15.1" ;
9022
9123
  var ATTACHMENT_URL_EXPIRES_SECONDS = 600;
9023
9124
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
9024
9125
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
@@ -10858,6 +10959,13 @@ Index the REMAINING windows - one record per row/item, looking at any page image
10858
10959
  uploadAccessGroup: function(path) {
10859
10960
  return resolveUploadAccessGroup(path);
10860
10961
  },
10962
+ // "Already uploaded by this user?" from the src:: file records. The engine
10963
+ // asks only while the skip-duplicates switch is on; null means upload.
10964
+ findDuplicateUpload: function(file) {
10965
+ var uid = S.user && S.user.user_id;
10966
+ if (!uid) return Promise.resolve(null);
10967
+ return findDuplicateUpload(S.skapi, file, uid, { service: S.projectId });
10968
+ },
10861
10969
  storagePathFor: function(relPath) {
10862
10970
  return attachmentStoragePath(relPath);
10863
10971
  },
@@ -11839,7 +11947,7 @@ Index the REMAINING windows - one record per row/item, looking at any page image
11839
11947
  });
11840
11948
  chip.appendChild(h("span", { class: "bq-attachment-icon", html: isFolder ? FOLDER_ICON_SVG : FILE_ICON_SVG }));
11841
11949
  chip.appendChild(h("span", { class: "bq-attachment-name", text: att.name, title: att.name }));
11842
- var meta = att.status === "error" ? "(Failed)" : att.status === "indexError" ? "(Error)" : preparing ? "Preparing" : finalizing ? "Finalizing" : att.status === "uploading" ? att.progress + "%" : isFolder ? "(" + (att.files ? att.files.length : 0) + ")" : formatBytes(att.file ? att.file.size : att.size);
11950
+ var meta = att.status === "error" ? "(Failed)" : att.status === "indexError" ? "(Error)" : preparing ? "Preparing" : finalizing ? "Finalizing" : att.status === "uploading" ? att.progress + "%" : isFolder ? "(" + (att.files ? att.files.length : 0) + ")" : att.reused ? "already uploaded" : formatBytes(att.file ? att.file.size : att.size);
11843
11951
  chip.appendChild(h("span", { class: "bq-attachment-meta", text: meta }));
11844
11952
  if (clickable) chip.appendChild(h("span", { class: "bq-attachment-arrow", text: "\u2197" }));
11845
11953
  if (att.status !== "uploading" && att.status !== "done") {
@@ -13663,6 +13771,15 @@ Index the REMAINING windows - one record per row/item, looking at any page image
13663
13771
  // true/false pins it for this page, both ways. Read at
13664
13772
  // configureChatEngine time and when each chat request is built.
13665
13773
  webAccess: null,
13774
+ // Whether a chat attachment the signed-in user ALREADY uploaded to this
13775
+ // project (same file name, same size, same uploader) is skipped: not
13776
+ // uploaded again, not indexed again, the message using the stored
13777
+ // copy. null = follow the project's own "Skip duplicate files" setting
13778
+ // (`skip_duplicate_files` in the bq::settings record), which is ON
13779
+ // unless the owner turned it off. The project setting is the ceiling:
13780
+ // false here restricts below it for this page, true has no effect on
13781
+ // a project that turned it off.
13782
+ skipDuplicateFiles: null,
13666
13783
  // Server-driven windowed indexing; read at configureChatEngine time.
13667
13784
  // Listed here so the defaults object is the full opt surface.
13668
13785
  windowedIndexing: true,
@@ -13744,6 +13861,9 @@ Index the REMAINING windows - one record per row/item, looking at any page image
13744
13861
  // chat request (off unless the owner turned it on). Passing the null
13745
13862
  // default through would read as a decision.
13746
13863
  webAccess: typeof S.opts.webAccess === "boolean" ? S.opts.webAccess : void 0,
13864
+ // Skip duplicate uploads. Same hand-over rule: only a pinned boolean is
13865
+ // passed, and the project's setting is the ceiling the engine applies.
13866
+ skipDuplicateFiles: typeof S.opts.skipDuplicateFiles === "boolean" ? S.opts.skipDuplicateFiles : void 0,
13747
13867
  // ---- live streaming (mirrored in agent.vue's ai_agent.ts) --------
13748
13868
  // On by default since every region's polling worker relays the
13749
13869
  // response bytes. It used to be off for the shipping-order reason
package/dist/engine.cjs CHANGED
@@ -73,6 +73,13 @@ function normalizeWebAccess(value) {
73
73
  function webAccessFrom(data) {
74
74
  return data ? normalizeWebAccess(data.web_access) : DEFAULT_WEB_ACCESS;
75
75
  }
76
+ var DEFAULT_SKIP_DUPLICATE_FILES = true;
77
+ function normalizeSkipDuplicateFiles(value) {
78
+ return value !== false;
79
+ }
80
+ function skipDuplicateFilesFrom(data) {
81
+ return data ? normalizeSkipDuplicateFiles(data.skip_duplicate_files) : DEFAULT_SKIP_DUPLICATE_FILES;
82
+ }
76
83
  var CHAT_GREETING_MAX_LENGTH = 400;
77
84
  function normalizeChatGreeting(value) {
78
85
  if (typeof value !== "string") return "";
@@ -159,6 +166,9 @@ function projectChatGreeting(service) {
159
166
  function projectWebAccess(service) {
160
167
  return webAccessFrom(cachedProjectSettings(service));
161
168
  }
169
+ function projectSkipDuplicateFiles(service) {
170
+ return skipDuplicateFilesFrom(cachedProjectSettings(service));
171
+ }
162
172
  function setProjectSettings(service, data) {
163
173
  if (!service) return;
164
174
  cache.set(service, { data: data || null, settled: true, inflight: null });
@@ -238,16 +248,29 @@ function liveStreamingRealtimeEnabled() {
238
248
  function liveStreamingEnabled() {
239
249
  return _config?.liveStreaming === true;
240
250
  }
251
+ function underCeiling(projectSetting, option, override) {
252
+ if (!projectSetting) return false;
253
+ if (option === false) return false;
254
+ if (override === false) return false;
255
+ return true;
256
+ }
241
257
  function webAccessEnabled(service) {
242
258
  const opt = _config ? _config.webAccess : void 0;
243
- if (typeof opt === "boolean") return opt;
244
- return webAccessFrom(service ? cachedProjectSettings(service) : null);
259
+ return underCeiling(webAccessFrom(service ? cachedProjectSettings(service) : null), opt);
245
260
  }
246
- function resolveWebAccess(service) {
261
+ function resolveWebAccess(service, override) {
247
262
  const opt = _config ? _config.webAccess : void 0;
248
- if (typeof opt === "boolean") return Promise.resolve(opt);
249
- if (!service) return Promise.resolve(false);
250
- return loadProjectSettings(service).then(webAccessFrom, () => false);
263
+ if (!service) return Promise.resolve(underCeiling(webAccessFrom(null), opt, override));
264
+ return loadProjectSettings(service).then(webAccessFrom, () => false).then((setting) => underCeiling(setting, opt, override));
265
+ }
266
+ function skipDuplicateFilesEnabled(service) {
267
+ const opt = _config ? _config.skipDuplicateFiles : void 0;
268
+ return underCeiling(skipDuplicateFilesFrom(service ? cachedProjectSettings(service) : null), opt);
269
+ }
270
+ function resolveSkipDuplicateFiles(service) {
271
+ const opt = _config ? _config.skipDuplicateFiles : void 0;
272
+ if (!service) return Promise.resolve(underCeiling(skipDuplicateFilesFrom(null), opt));
273
+ return loadProjectSettings(service).then(skipDuplicateFilesFrom, () => skipDuplicateFilesFrom(null)).then((setting) => underCeiling(setting, opt));
251
274
  }
252
275
  function streamRecoveryEnabled() {
253
276
  if (!_config) return false;
@@ -263,6 +286,54 @@ function pollOpt() {
263
286
  return p === void 0 ? {} : { poll: p };
264
287
  }
265
288
 
289
+ // src/engine/duplicates.ts
290
+ var DUPLICATE_LOOKUP_MAX_CANDIDATES = 25;
291
+ var SRC_PREFIX = "src::";
292
+ function isDuplicateCandidateId(uniqueId, name) {
293
+ if (typeof uniqueId !== "string" || !name) return false;
294
+ if (uniqueId.indexOf(SRC_PREFIX) !== 0) return false;
295
+ if (uniqueId === SRC_PREFIX + name) return true;
296
+ const tail = "/" + name;
297
+ return uniqueId.length > SRC_PREFIX.length + tail.length && uniqueId.slice(-tail.length) === tail;
298
+ }
299
+ function listOf(res) {
300
+ if (!res) return [];
301
+ if (Array.isArray(res)) return res;
302
+ return Array.isArray(res.list) ? res.list : [];
303
+ }
304
+ async function findDuplicateUpload(skapi, file, userId, opts) {
305
+ try {
306
+ if (!skapi || typeof skapi.getUniqueId !== "function" || typeof skapi.getRecords !== "function") return null;
307
+ const name = file && typeof file.name === "string" ? file.name : "";
308
+ const size = file && typeof file.size === "number" ? file.size : NaN;
309
+ if (!name || !(size >= 0) || !userId) return null;
310
+ const service = opts && opts.service ? { service: opts.service } : {};
311
+ const found = listOf(await skapi.getUniqueId(Object.assign({ unique_id: name, condition: "<=" }, service)));
312
+ const ids = [];
313
+ for (const item of found) {
314
+ const id = item && typeof item === "object" ? item.unique_id : item;
315
+ if (isDuplicateCandidateId(id, name) && ids.indexOf(id) === -1) ids.push(id);
316
+ if (ids.length >= DUPLICATE_LOOKUP_MAX_CANDIDATES) break;
317
+ }
318
+ for (const uniqueId of ids) {
319
+ let record = null;
320
+ try {
321
+ record = listOf(await skapi.getRecords(Object.assign({ unique_id: uniqueId }, service)))[0] || null;
322
+ } catch {
323
+ continue;
324
+ }
325
+ if (!record || typeof record !== "object") continue;
326
+ const data = record.data && typeof record.data === "object" ? record.data : null;
327
+ if (!data || data.size_bytes !== size) continue;
328
+ if (record.user_id !== userId) continue;
329
+ return { storagePath: uniqueId.slice(SRC_PREFIX.length), uniqueId, record };
330
+ }
331
+ return null;
332
+ } catch {
333
+ return null;
334
+ }
335
+ }
336
+
266
337
  // src/engine/office.ts
267
338
  var OFFICE_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
268
339
  "doc",
@@ -675,6 +746,10 @@ function buildRenderMeta(attachment) {
675
746
  ` : "") + `- access group (use this for EVERY record you write for this file): ${indexingAccessGroup(attachment)}
676
747
  `;
677
748
  }
749
+ var PAGE_TEXT_TABLE = "page_texts";
750
+ var PAGE_TEXT_RULE = `PAGE TEXT for pages WITHOUT a text layer: for EVERY page whose label (its first label, when the page is split into strips) carries NO "Embedded text layer" block, ALSO post exactly ONE record in table "${PAGE_TEXT_TABLE}" with data {"page": <its document page number from the label, 1-based>, "page_text": "<the page's complete text exactly as printed, verbatim, in reading order, nothing summarised or left out>"}, reference "src::" + the storage path above, in addition to the records above. A page WITH an embedded text layer gets NO such record: its text is already stored.
751
+
752
+ `;
678
753
  function buildRenderDatafy(placeholder) {
679
754
  return `
680
755
  ${placeholder}
@@ -685,7 +760,7 @@ Each image is preceded by a label giving its DOCUMENT PAGE number. That label is
685
760
 
686
761
  Transcribe COMPLETELY, not representatively. A table with twenty rows gets twenty records, not a sample of the first few - if a page has more rows than you can save comfortably, still save them all rather than summarising. Where a page carries an embedded text layer it is quoted above that page's image: it is the exact text and should be preferred over reading the pixels, with the image used for layout, tables, stamps and handwriting.
687
762
 
688
- Save records for THIS window of pages only, then stop and report what you saved. Do NOT try to read the rest of the file and do NOT worry about the pages after this window: if any remain, the next window is rendered and sent to you automatically. Report only the pages you were actually shown - never imply you have seen the whole document.`;
763
+ ` + PAGE_TEXT_RULE + `Save records for THIS window of pages only, then stop and report what you saved. Do NOT try to read the rest of the file and do NOT worry about the pages after this window: if any remain, the next window is rendered and sent to you automatically. Report only the pages you were actually shown - never imply you have seen the whole document.`;
689
764
  }
690
765
  function buildIndexingWindowMessage(attachment, placeholder, isContinuation, positionLabel) {
691
766
  const src = `src::${attachment.storagePath}`;
@@ -1227,7 +1302,7 @@ var RETRIEVAL_NOTE_PREFIX = "[Retrieval note, not shown to the user";
1227
1302
  var FILE_KEYS = ["path", "storage_path", "storagePath", "file", "file_path", "filePath"];
1228
1303
  var TABLE_KEYS = ["table_name", "tableName"];
1229
1304
  var GROUP_KEYS = ["access_group", "accessGroup"];
1230
- var SRC_PREFIX = "src::";
1305
+ var SRC_PREFIX2 = "src::";
1231
1306
  var RESULT_WALK_MAX_NODES = 400;
1232
1307
  var RESULT_WALK_MAX_DEPTH = 5;
1233
1308
  function isWebToolName(name) {
@@ -1256,8 +1331,8 @@ function pushHit(list, path, page) {
1256
1331
  }
1257
1332
  function pathFromSrcId(value) {
1258
1333
  if (typeof value !== "string") return null;
1259
- if (value.indexOf(SRC_PREFIX) !== 0) return null;
1260
- var rest = value.slice(SRC_PREFIX.length).trim();
1334
+ if (value.indexOf(SRC_PREFIX2) !== 0) return null;
1335
+ var rest = value.slice(SRC_PREFIX2.length).trim();
1261
1336
  return rest ? rest : null;
1262
1337
  }
1263
1338
  function parseJsonLoose(value) {
@@ -2926,7 +3001,7 @@ async function callClaudeWithMcp({
2926
3001
  fileUrls,
2927
3002
  webAccess
2928
3003
  }) {
2929
- const web = typeof webAccess === "boolean" ? webAccess : await resolveWebAccess(service);
3004
+ const web = await resolveWebAccess(service, typeof webAccess === "boolean" ? webAccess : void 0);
2930
3005
  const mcpServerDefinition = {
2931
3006
  type: "url",
2932
3007
  name: mcpServer.name,
@@ -8515,11 +8590,26 @@ var ChatSession = class {
8515
8590
  if (!total) return Promise.reject(new Error("Empty attachment"));
8516
8591
  var urls = [];
8517
8592
  var anyIndexFailed = false;
8593
+ var reusedCount = 0;
8594
+ att.reused = false;
8518
8595
  var chain = Promise.resolve();
8596
+ var findDuplicate = function(member) {
8597
+ if (typeof self.host.findDuplicateUpload !== "function") return Promise.resolve(null);
8598
+ var storedName = String(member.storagePath || "").split("/").filter(Boolean).pop() || member.file.name;
8599
+ return resolveSkipDuplicateFiles(id.projectId).then(function(on) {
8600
+ if (!on) return null;
8601
+ return Promise.resolve(self.host.findDuplicateUpload({ name: storedName, size: member.file.size })).then(function(hit) {
8602
+ return hit && typeof hit.storagePath === "string" && hit.storagePath ? hit.storagePath : null;
8603
+ });
8604
+ }).catch(function() {
8605
+ return null;
8606
+ });
8607
+ };
8519
8608
  members.forEach(function(member, idx) {
8520
8609
  chain = chain.then(function() {
8521
8610
  var hadExists = false;
8522
8611
  var skipped = false;
8612
+ var duplicateOf = null;
8523
8613
  var existedBefore = false;
8524
8614
  var onProg = function(p) {
8525
8615
  if (p && p.total) {
@@ -8538,7 +8628,14 @@ var ChatSession = class {
8538
8628
  }
8539
8629
  });
8540
8630
  };
8541
- return doMemberUpload(true).catch(function(err) {
8631
+ return findDuplicate(member).then(function(dup) {
8632
+ if (dup) {
8633
+ duplicateOf = dup;
8634
+ console.log("[chat-engine] skipping an upload the user already has:", member.storagePath, "->", dup);
8635
+ return;
8636
+ }
8637
+ return doMemberUpload(true);
8638
+ }).catch(function(err) {
8542
8639
  var code = err && (err.code || err.body && err.body.code);
8543
8640
  var msg = err && (err.message || err.body && err.body.message || (typeof err === "string" ? err : ""));
8544
8641
  var isExists = code === "EXISTS" || msg && /exist/i.test(msg);
@@ -8558,9 +8655,20 @@ var ChatSession = class {
8558
8655
  });
8559
8656
  }).then(function() {
8560
8657
  if (skipped) return;
8561
- return self.host.getTemporaryUrl(member.storagePath);
8658
+ return self.host.getTemporaryUrl(duplicateOf || member.storagePath);
8562
8659
  }).then(function(url) {
8563
8660
  if (skipped) return;
8661
+ if (duplicateOf) {
8662
+ urls.push({ name: member.relPath, url, storagePath: duplicateOf });
8663
+ reusedCount += 1;
8664
+ if (att.kind !== "folder") {
8665
+ att.uploadedUrl = url;
8666
+ att.storagePath = duplicateOf;
8667
+ att.reused = true;
8668
+ } else att.reusedCount = reusedCount;
8669
+ self.host.renderAttachmentChips();
8670
+ return;
8671
+ }
8564
8672
  urls.push({ name: member.relPath, url, storagePath: member.storagePath });
8565
8673
  if (att.kind !== "folder") {
8566
8674
  att.uploadedUrl = url;
@@ -9201,8 +9309,10 @@ exports.DATA_TOOL_NAMES = DATA_TOOL_NAMES;
9201
9309
  exports.DEFAULT_CLAUDE_MODEL = DEFAULT_CLAUDE_MODEL;
9202
9310
  exports.DEFAULT_CONTEXT_WINDOW = DEFAULT_CONTEXT_WINDOW;
9203
9311
  exports.DEFAULT_OPENAI_MODEL = DEFAULT_OPENAI_MODEL;
9312
+ exports.DEFAULT_SKIP_DUPLICATE_FILES = DEFAULT_SKIP_DUPLICATE_FILES;
9204
9313
  exports.DEFAULT_UPLOAD_ACCESS_GROUP = DEFAULT_UPLOAD_ACCESS_GROUP;
9205
9314
  exports.DEFAULT_WEB_ACCESS = DEFAULT_WEB_ACCESS;
9315
+ exports.DUPLICATE_LOOKUP_MAX_CANDIDATES = DUPLICATE_LOOKUP_MAX_CANDIDATES;
9206
9316
  exports.EMPTY_INDEXING_REPLY = EMPTY_INDEXING_REPLY;
9207
9317
  exports.EXPIRED_ATTACHMENT_URL_HOST = EXPIRED_ATTACHMENT_URL_HOST;
9208
9318
  exports.EXPIRED_ATTACHMENT_URL_ORIGIN = EXPIRED_ATTACHMENT_URL_ORIGIN;
@@ -9234,6 +9344,7 @@ exports.MIN_INPUT_TOKEN_BUDGET = MIN_INPUT_TOKEN_BUDGET;
9234
9344
  exports.MIN_PER_REQUEST_INPUT_CAP = MIN_PER_REQUEST_INPUT_CAP;
9235
9345
  exports.NO_DATA_TOOL_MARK = NO_DATA_TOOL_MARK;
9236
9346
  exports.OUTPUT_TOKEN_RESERVE = OUTPUT_TOKEN_RESERVE;
9347
+ exports.PAGE_TEXT_TABLE = PAGE_TEXT_TABLE;
9237
9348
  exports.POLL_INTERVAL = POLL_INTERVAL;
9238
9349
  exports.PRESIGN_SAFETY_MARGIN_MS = PRESIGN_SAFETY_MARGIN_MS;
9239
9350
  exports.PREVIEWABLE_IMAGE_CONTENT_TYPES = PREVIEWABLE_IMAGE_CONTENT_TYPES;
@@ -9322,6 +9433,7 @@ exports.fetchLiveIndexingKeys = fetchLiveIndexingKeys;
9322
9433
  exports.fillHistoryViewport = fillHistoryViewport;
9323
9434
  exports.filterListByClearHorizon = filterListByClearHorizon;
9324
9435
  exports.findAttachmentParser = findAttachmentParser;
9436
+ exports.findDuplicateUpload = findDuplicateUpload;
9325
9437
  exports.formatChatTimestamp = formatChatTimestamp;
9326
9438
  exports.formatDuration = formatDuration;
9327
9439
  exports.formatRetrievalNote = formatRetrievalNote;
@@ -9346,6 +9458,7 @@ exports.isAuthExpiredError = isAuthExpiredError;
9346
9458
  exports.isBgIndexingQueue = isBgIndexingQueue;
9347
9459
  exports.isCsrStatusEnvelope = isCsrStatusEnvelope;
9348
9460
  exports.isDataToolName = isDataToolName;
9461
+ exports.isDuplicateCandidateId = isDuplicateCandidateId;
9349
9462
  exports.isErrorResponseBody = isErrorResponseBody;
9350
9463
  exports.isHttpUrlLike = isHttpUrlLike;
9351
9464
  exports.isImageVisionFile = isImageVisionFile;
@@ -9379,6 +9492,7 @@ exports.normalizeAttachmentPathCandidate = normalizeAttachmentPathCandidate;
9379
9492
  exports.normalizeChatGreeting = normalizeChatGreeting;
9380
9493
  exports.normalizeExt = normalizeExt;
9381
9494
  exports.normalizeProjectAccessSetting = normalizeProjectAccessSetting;
9495
+ exports.normalizeSkipDuplicateFiles = normalizeSkipDuplicateFiles;
9382
9496
  exports.normalizeTextContent = normalizeTextContent;
9383
9497
  exports.normalizeTrailingInlineToken = normalizeTrailingInlineToken;
9384
9498
  exports.normalizeUploadAccessGroup = normalizeUploadAccessGroup;
@@ -9401,6 +9515,7 @@ exports.projectAccessSetting = projectAccessSetting;
9401
9515
  exports.projectAsksUploadAccess = projectAsksUploadAccess;
9402
9516
  exports.projectChatGreeting = projectChatGreeting;
9403
9517
  exports.projectSettingsSettled = projectSettingsSettled;
9518
+ exports.projectSkipDuplicateFiles = projectSkipDuplicateFiles;
9404
9519
  exports.projectUploadAccessGroup = projectUploadAccessGroup;
9405
9520
  exports.projectWebAccess = projectWebAccess;
9406
9521
  exports.providerBodyWasRead = providerBodyWasRead;
@@ -9412,6 +9527,7 @@ exports.renderInlineLinkHtml = renderInlineLinkHtml;
9412
9527
  exports.repairUrlEntities = repairUrlEntities;
9413
9528
  exports.repairUrlWhitespace = repairUrlWhitespace;
9414
9529
  exports.resolveImagePreviewUrl = resolveImagePreviewUrl;
9530
+ exports.resolveSkipDuplicateFiles = resolveSkipDuplicateFiles;
9415
9531
  exports.resolveWebAccess = resolveWebAccess;
9416
9532
  exports.retrievalNoteFromText = retrievalNoteFromText;
9417
9533
  exports.retrievalNoteOfAnswer = retrievalNoteOfAnswer;
@@ -9422,6 +9538,8 @@ exports.setProjectContextWindow = setProjectContextWindow;
9422
9538
  exports.setProjectSettings = setProjectSettings;
9423
9539
  exports.shouldRescueInFlightMessage = shouldRescueInFlightMessage;
9424
9540
  exports.skapiSupportsStreaming = skapiSupportsStreaming;
9541
+ exports.skipDuplicateFilesEnabled = skipDuplicateFilesEnabled;
9542
+ exports.skipDuplicateFilesFrom = skipDuplicateFilesFrom;
9425
9543
  exports.streamRecoveryEnabled = streamRecoveryEnabled;
9426
9544
  exports.streamRecoveryLabels = streamRecoveryLabels;
9427
9545
  exports.streamRecoveryPhase = streamRecoveryPhase;