bunnyquery 1.9.4 → 1.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.9.4",
3
+ "version": "1.9.6",
4
4
  "description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
5
5
  "main": "bunnyquery.js",
6
6
  "exports": {
@@ -323,7 +323,34 @@ export type SplitHistoryResult = {
323
323
  };
324
324
 
325
325
  export async function getSplitChatHistory(
326
- params: { service: string; owner: string; platform: 'claude' | 'openai'; userId?: string },
326
+ params: {
327
+ service: string;
328
+ owner: string;
329
+ platform: 'claude' | 'openai';
330
+ userId?: string;
331
+ /**
332
+ * Scope the SURFACE fetch to this chat's own queue instead of "everything
333
+ * that is not the bg queue".
334
+ *
335
+ * Set for an ANONYMOUS visitor, and only for one. The backend identifies an
336
+ * unauthenticated caller as `ip + "(" + user_agent + ")"`, and the default
337
+ * surface fetch (queue_exclude, no queue) is scoped by exactly that string
338
+ * server side - so two anonymous visitors behind one NAT on the same browser
339
+ * build read each other's transcript, which is the thing per-device history
340
+ * exists to prevent. Reading the device's own queue instead scopes it by a
341
+ * value the client controls and the other device does not share.
342
+ *
343
+ * NOT used for a signed-in caller. Their turns are already scoped by their
344
+ * `sub`, and queue_exact would additionally hide any history sent under a
345
+ * different queue name than the current userId (an older fallback, a
346
+ * pre-rename row), which queue_exclude still returns.
347
+ *
348
+ * The queue name is unguessable but NOT secret: it travels on every request
349
+ * and queue listings are not user-scoped server side. Anonymous transcripts
350
+ * are non-confidential by construction.
351
+ */
352
+ scopeSurfaceToQueue?: boolean;
353
+ },
327
354
  fetchOptions: Record<string, any>,
328
355
  /** Test seam: replaces getChatHistory. Not for production callers. */
329
356
  _fetchImpl?: typeof getChatHistory,
@@ -345,7 +372,34 @@ export async function getSplitChatHistory(
345
372
 
346
373
  async function _getSplitChatHistoryLocked(
347
374
  key: string,
348
- params: { service: string; owner: string; platform: 'claude' | 'openai'; userId?: string },
375
+ params: {
376
+ service: string;
377
+ owner: string;
378
+ platform: 'claude' | 'openai';
379
+ userId?: string;
380
+ /**
381
+ * Scope the SURFACE fetch to this chat's own queue instead of "everything
382
+ * that is not the bg queue".
383
+ *
384
+ * Set for an ANONYMOUS visitor, and only for one. The backend identifies an
385
+ * unauthenticated caller as `ip + "(" + user_agent + ")"`, and the default
386
+ * surface fetch (queue_exclude, no queue) is scoped by exactly that string
387
+ * server side - so two anonymous visitors behind one NAT on the same browser
388
+ * build read each other's transcript, which is the thing per-device history
389
+ * exists to prevent. Reading the device's own queue instead scopes it by a
390
+ * value the client controls and the other device does not share.
391
+ *
392
+ * NOT used for a signed-in caller. Their turns are already scoped by their
393
+ * `sub`, and queue_exact would additionally hide any history sent under a
394
+ * different queue name than the current userId (an older fallback, a
395
+ * pre-rename row), which queue_exclude still returns.
396
+ *
397
+ * The queue name is unguessable but NOT secret: it travels on every request
398
+ * and queue listings are not user-scoped server side. Anonymous transcripts
399
+ * are non-confidential by construction.
400
+ */
401
+ scopeSurfaceToQueue?: boolean;
402
+ },
349
403
  fetchOptions: Record<string, any>,
350
404
  releaseLock: () => void,
351
405
  _fetchImpl?: typeof getChatHistory,
@@ -353,6 +407,14 @@ async function _getSplitChatHistoryLocked(
353
407
  const fetch = _fetchImpl || getChatHistory;
354
408
  const bgQueue = bgIndexingQueueName(params.userId, params.service);
355
409
  const base = { service: params.service, owner: params.owner, platform: params.platform };
410
+ // What the SURFACE (non-background) fetch filters on. `queue_exclude` is the
411
+ // default and returns every row that is not on the bg chain; the anonymous
412
+ // path narrows to this chat's OWN queue instead. Same queue name the chat
413
+ // turns are dispatched under (requests.ts `queue: userId || service`), so the
414
+ // two cannot drift.
415
+ const surfaceScope: Record<string, any> = params.scopeSurfaceToQueue && params.userId
416
+ ? { queue: params.userId, queue_exact: true }
417
+ : { queue_exclude: bgQueue };
356
418
  const fetchMore = !!(fetchOptions && fetchOptions.fetchMore);
357
419
  const limit = fetchOptions && fetchOptions.limit;
358
420
 
@@ -404,14 +466,14 @@ async function _getSplitChatHistoryLocked(
404
466
  } else {
405
467
  const sOpts: any = { fetchMore };
406
468
  if (limit) sOpts.limit = limit;
407
- let s = await fetch({ ...base, queue_exclude: bgQueue }, sOpts);
469
+ let s = await fetch({ ...base, ...surfaceScope }, sOpts);
408
470
  // Loop past empty-but-not-end pages (see SURFACE_EMPTY_MAX_PAGES).
409
471
  let hops = 0;
410
472
  while (s && !s.endOfList && !((s.list || []).length) && hops < SURFACE_EMPTY_MAX_PAGES) {
411
473
  hops++;
412
474
  const nOpts: any = { fetchMore: true };
413
475
  if (limit) nOpts.limit = limit;
414
- s = await fetch({ ...base, queue_exclude: bgQueue }, nOpts);
476
+ s = await fetch({ ...base, ...surfaceScope }, nOpts);
415
477
  }
416
478
  state.pendingSurface = {
417
479
  list: (s && Array.isArray(s.list)) ? s.list : [],
@@ -588,9 +650,53 @@ async function _getSplitChatHistoryLocked(
588
650
  }
589
651
 
590
652
 
653
+ /**
654
+ * THE chat key. Every cache, every ownership stamp and every "is this turn for
655
+ * the chat on screen?" comparison is built here and nowhere else.
656
+ *
657
+ * It used to be written out by hand in four places. When a third segment was
658
+ * added for the chat identity - a browser can hold an anonymous conversation and
659
+ * a signed-in one on the SAME project, and the two must not share a cache - only
660
+ * one of those four was updated. The rest kept producing the two-segment form,
661
+ * so `key !== getHistoryCacheKey()` became permanently true: every send was
662
+ * treated as belonging to another project, the optimistic bubble and the
663
+ * "Thinking..." placeholder were never pushed, and nothing appeared until the
664
+ * server history caught up. One function, so a twin cannot drift again.
665
+ */
666
+ export function chatCacheKey(
667
+ projectId: string | undefined,
668
+ platform: string | undefined,
669
+ userId?: string,
670
+ ): string {
671
+ if (!projectId || platform === 'none') return '';
672
+ return projectId + '#' + platform + '#' + (userId || '');
673
+ }
674
+
675
+ /**
676
+ * The INDEXING scope key: project + platform, deliberately WITHOUT the identity.
677
+ *
678
+ * Claiming, stopping and cancelling a file's indexing are scoped per project and
679
+ * platform because a storage path is project-relative and one ChatSession serves
680
+ * every project. They are NOT per user: an anonymous visitor cannot upload or
681
+ * index at all, so there is no second identity to separate, and folding the
682
+ * identity in here would only have to be threaded through BgTaskEntry to no end.
683
+ *
684
+ * Kept separate from chatCacheKey ON PURPOSE. These two were the same string
685
+ * once, which is exactly how adding a segment to one silently broke the other.
686
+ */
687
+ export function indexScopeKey(
688
+ projectId: string | undefined,
689
+ platform: string | undefined,
690
+ ): string {
691
+ if (!projectId || platform === 'none') return '';
692
+ return projectId + '#' + platform;
693
+ }
694
+
591
695
  export type MapHistoryOptions = {
592
696
  clearedAt: number;
593
697
  projectId: string;
698
+ /** Chat identity, so the `_ownerKey` stamp matches chatCacheKey(). */
699
+ userId?: string;
594
700
  /** View-side display formatter for "Indexing:/Reindexing: …" bubbles. */
595
701
  formatIndexingLabel: (name: string, mime?: string, size?: number | null, storagePath?: string, reindex?: boolean, continued?: boolean) => string;
596
702
  };
@@ -728,7 +834,7 @@ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'open
728
834
  // unchallenged. That is what let one project's transcript survive on screen
729
835
  // into another project and be persisted under its key.
730
836
  if (opts.projectId) {
731
- var ownerKey = opts.projectId + '#' + platform;
837
+ var ownerKey = chatCacheKey(opts.projectId, platform, opts.userId);
732
838
  for (var oi = 0; oi < mapped.length; oi++) mapped[oi]._ownerKey = ownerKey;
733
839
  }
734
840
  return { messages: mapped, runningItemIds: runningItemIds };
@@ -81,6 +81,11 @@ export {
81
81
  __resetSplitHistoryState,
82
82
  type IndexingRequestRef,
83
83
  type MapHistoryOptions,
84
+ // THE two key builders. Exported so a consumer (and a test) can assert the
85
+ // shape rather than rebuild it: a hand-built twin drifting out of step with
86
+ // getHistoryCacheKey is what stopped the chat rendering sent messages.
87
+ chatCacheKey,
88
+ indexScopeKey,
84
89
  } from './history';
85
90
 
86
91
  // Older history is reachable only by scrolling to the top of the message box, so
@@ -46,7 +46,7 @@ import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, ge
46
46
  import { buildBoundedChatMessages } from './budget';
47
47
  import { createInlineLinkRegex, sanitizeAttachmentLinksForHistory } from './links';
48
48
  import { markImagePreviewStale } from './image_preview';
49
- import { mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText, probeBgQueue, BG_PROBE_TTL_MS, getSplitChatHistory, shouldRescueInFlightMessage } from './history';
49
+ import { chatCacheKey, indexScopeKey, mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText, probeBgQueue, BG_PROBE_TTL_MS, getSplitChatHistory, shouldRescueInFlightMessage } from './history';
50
50
  import { wallClockNow } from './time';
51
51
  import { parseAttachmentContent } from './attachment_parsers';
52
52
  import type { ChatHost, ChatState, ChatMessage, ChatIdentity, PinnedDispatchContext } from './host';
@@ -325,7 +325,8 @@ export class ChatSession {
325
325
  /** Storage paths are project-relative, and one ChatSession serves every
326
326
  * project, so a claim has to be scoped the way a stop is (_indexKeyOf). */
327
327
  private _indexClaimKey(storagePath: string): string {
328
- return this.getHistoryCacheKey() + '|' + storagePath;
328
+ var id = this.host.getIdentity();
329
+ return indexScopeKey(id.projectId, id.platform) + '|' + storagePath;
329
330
  }
330
331
 
331
332
  /**
@@ -813,8 +814,7 @@ export class ChatSession {
813
814
  */
814
815
  getHistoryCacheKey(): string {
815
816
  var id = this.host.getIdentity();
816
- if (!id.projectId || id.platform === 'none') return '';
817
- return id.projectId + '#' + id.platform + '#' + (id.userId || '');
817
+ return chatCacheKey(id.projectId, id.platform, id.userId);
818
818
  }
819
819
 
820
820
  // ─── compact-stub hydration ─────────────────────────────────────────────
@@ -1482,7 +1482,7 @@ export class ChatSession {
1482
1482
  // getIdentity()/getHistoryCacheKey() to the new project) can't
1483
1483
  // misattribute this turn's bubbles to that project.
1484
1484
  // (platform === 'none' already returned above, so projectId is the only gate)
1485
- var key = !id.projectId ? '' : id.projectId + '#' + id.platform;
1485
+ var key = chatCacheKey(id.projectId, id.platform, id.userId);
1486
1486
  // True when the pinned chat is NOT the one currently on screen. Then
1487
1487
  // state.messages belongs to a different project and MUST NOT be touched:
1488
1488
  // the turn is staged in the pinned chat's cache instead and shows up when
@@ -2159,7 +2159,8 @@ export class ChatSession {
2159
2159
  if (!group || !group.key) return;
2160
2160
  // The group belongs to the chat on screen, so scope its key the same way
2161
2161
  // _indexKeyOf scopes a queued task's.
2162
- var scoped = this.getHistoryCacheKey() + '|' + group.key;
2162
+ var idn = this.host.getIdentity();
2163
+ var scoped = indexScopeKey(idn.projectId, idn.platform) + '|' + group.key;
2163
2164
  this.cancelledIndexKeys.add(scoped);
2164
2165
  // Remember WHICH RUN was stopped, by the ids of the passes it is made of.
2165
2166
  // Everything else here stops the work without leaving any mark on the
@@ -2709,7 +2710,7 @@ export class ChatSession {
2709
2710
  if (!entry) return '';
2710
2711
  var file = entry.storagePath || entry.filename;
2711
2712
  if (!file) return '';
2712
- return entry.projectId + '#' + entry.platform + '|' + file;
2713
+ return indexScopeKey(entry.projectId, entry.platform) + '|' + file;
2713
2714
  }
2714
2715
 
2715
2716
  /**
@@ -3435,7 +3436,7 @@ export class ChatSession {
3435
3436
  // request is built from. The rescue below compares against this rather
3436
3437
  // than a live getHistoryCacheKey(), so a project switch mid-fetch can't
3437
3438
  // make another chat's in-flight bubbles look local.
3438
- var loadKey = (!id.projectId || id.platform === 'none') ? '' : id.projectId + '#' + id.platform;
3439
+ var loadKey = chatCacheKey(id.projectId, id.platform, id.userId);
3439
3440
  if (token === undefined) token = this.state.gateRefreshToken;
3440
3441
  if ((this.state.loadingHistory && this.state.historyRequestToken === token) || id.platform === 'none' || !id.projectId) {
3441
3442
  return Promise.resolve();
@@ -3464,7 +3465,16 @@ export class ChatSession {
3464
3465
  // stubs, tiled to the surface page's time window with module-level
3465
3466
  // state (buffering + retry safety) — see getSplitChatHistory. Returns
3466
3467
  // the exact single-fetch shape; startKeyHistory is bookkeeping only.
3467
- var fetchHistory = function () { return getSplitChatHistory({ service: projectId, owner: owner, platform: platform, userId: id.userId }, options); };
3468
+ var fetchHistory = function () {
3469
+ return getSplitChatHistory({
3470
+ service: projectId, owner: owner, platform: platform, userId: id.userId,
3471
+ // An anonymous visitor's history is scoped server side by
3472
+ // ip + "(" + user_agent + ")", which two devices behind one NAT
3473
+ // share. Read this device's own queue instead. See
3474
+ // scopeSurfaceToQueue.
3475
+ scopeSurfaceToQueue: !!id.anonymous,
3476
+ }, options);
3477
+ };
3468
3478
 
3469
3479
  return Promise.resolve().then(fetchHistory).catch(function (err: any) {
3470
3480
  if (isAuthExpiredError(err) && !isNonRetryableRequestError(err)) return self.host.refreshSession().then(fetchHistory);
@@ -3489,6 +3499,10 @@ export class ChatSession {
3489
3499
  var mapped = mapHistoryListToMessages(list, platform, {
3490
3500
  clearedAt: self.host.getClearedAt(),
3491
3501
  projectId: id.projectId,
3502
+ // So the `_ownerKey` stamped on server history matches loadKey and
3503
+ // the cache key. Without it every mapped bubble carries a
3504
+ // two-segment stamp that no comparison can ever match.
3505
+ userId: id.userId,
3492
3506
  formatIndexingLabel: self.host.formatIndexingLabel,
3493
3507
  }).messages;
3494
3508
  // Re-apply any previously-hydrated compact bodies: a refresh maps
@@ -3782,6 +3796,7 @@ export class ChatSession {
3782
3796
  var m2 = mapHistoryListToMessages(sorted, platform, {
3783
3797
  clearedAt: self.host.getClearedAt(),
3784
3798
  projectId: id.projectId,
3799
+ userId: id.userId,
3785
3800
  formatIndexingLabel: self.host.formatIndexingLabel,
3786
3801
  }).messages;
3787
3802
  self.applyHydratedBodies(m2);