bunnyquery 1.8.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,13 +45,24 @@ export * from './prompts';
45
45
  // normalization, and history mapping — shared so both consumers stay identical.
46
46
  export { getErrorMessage, isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError } from './errors';
47
47
  export * from './budget';
48
+ // Per-format UTF-8 declaration for files offered as a download. Shared so a fenced
49
+ // block and a server-published file open identically in Excel, Word and a browser.
50
+ export * from './download_encoding';
48
51
  export * from './links';
49
52
  export * from './time';
53
+ export * from './ai_agent';
50
54
  export {
51
55
  filterListByClearHorizon,
52
56
  normalizeTextContent,
53
57
  extractLastUserTextFromRequest,
54
58
  mapHistoryListToMessages,
59
+ // The indexing-prompt reader. Exported because the prompt is the only record
60
+ // of what a pass is about, so anything that meets a pass without a bubble
61
+ // (a history rebuild, an adopted worker pass, agent.vue's own mapper) has to
62
+ // read it the same way or the two will not group together.
63
+ isIndexingRequestText,
64
+ parseIndexingRequestText,
65
+ type IndexingRequestRef,
55
66
  type MapHistoryOptions,
56
67
  } from './history';
57
68
 
@@ -232,7 +232,10 @@ export type CallClaudeWithMcpParams = {
232
232
  onResponse?: (res: any) => void;
233
233
  onError?: (err: any) => void;
234
234
  };
235
- export const POLL_INTERVAL = 1500;
235
+ // Poll cadence for every client-secret request the chat waits on. Shared by the
236
+ // engine's own poll sites and imported by agent.vue; the widget carries its own
237
+ // copy in src/index.js that must be kept in step.
238
+ export const POLL_INTERVAL = 3000;
236
239
  export async function callClaudeWithMcp({
237
240
  prompt,
238
241
  messages,
@@ -833,8 +836,16 @@ export const INDEXING_COMPLETE_MARKER = 'INDEXING_COMPLETE';
833
836
  // a small cap suffices.
834
837
  export const MAX_INDEXING_RESUME_PASSES = 6;
835
838
 
839
+ /**
840
+ * `queue` narrows the fetch to one processing chain; `status` narrows it to items
841
+ * in one state. Passing both is how the client asks "is there still unresolved
842
+ * work on the background-indexing queue?" without pulling a page of chat history
843
+ * (see ChatSession._adoptWorkerIndexingPasses) — the server answers that from a
844
+ * status-keyed index, so the reply carries only the live items, not the bodies of
845
+ * everything already finished.
846
+ */
836
847
  export async function getChatHistory(
837
- params: { service?: string; owner?: string; platform: 'claude' | 'openai'; queue?: string },
848
+ params: { service?: string; owner?: string; platform: 'claude' | 'openai'; queue?: string; status?: 'pending' | 'running' | 'resolved' | 'failed' },
838
849
  fetchOptions: Record<string, any>,
839
850
  ) {
840
851
  const url =
@@ -848,10 +859,11 @@ export async function getChatHistory(
848
859
  },
849
860
  { service: params.service, owner: params.owner },
850
861
  params.queue ? { queue: params.queue } : {},
862
+ params.status ? { status: params.status } : {},
851
863
  );
852
864
 
853
865
  return chatEngineConfig().clientSecretRequestHistory(
854
- p as { url: string; method: 'POST'; queue?: string },
866
+ p as { url: string; method: 'POST'; queue?: string; status?: string },
855
867
  Object.assign({ ascending: false }, fetchOptions),
856
868
  );
857
869
  }
@@ -39,7 +39,7 @@ import { windowedIndexingEnabled } from './config';
39
39
  import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage } from './errors';
40
40
  import { buildBoundedChatMessages } from './budget';
41
41
  import { createInlineLinkRegex } from './links';
42
- import { mapHistoryListToMessages, extractLastUserTextFromRequest } from './history';
42
+ import { mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText } from './history';
43
43
  import { wallClockNow } from './time';
44
44
  import { parseAttachmentContent } from './attachment_parsers';
45
45
  import type { ChatHost, ChatState, ChatMessage, PinnedDispatchContext } from './host';
@@ -49,6 +49,16 @@ function sleep(ms: number): Promise<void> {
49
49
  return new Promise(function (r) { setTimeout(r, ms); });
50
50
  }
51
51
 
52
+ // How many live items one look at the background-indexing queue asks for. The
53
+ // queue is FIFO per user, so a healthy chain has one running plus at most a
54
+ // couple queued; the cap only bounds a pathological backlog.
55
+ const WORKER_PASS_ADOPT_LIMIT = 20;
56
+ // Delays before each look (the first is immediate). More than one because the
57
+ // worker writes the next pass's row a few milliseconds AFTER it resolves the
58
+ // current one, so a look that wins that race sees an empty queue for a chain
59
+ // that is still alive. Fixed and finite: this must never become a poll.
60
+ const WORKER_PASS_ADOPT_ATTEMPTS = [0, 2000, 6000];
61
+
52
62
  // requestAnimationFrame / high-res clock, reached through globalThis so the
53
63
  // engine stays DOM-free at the type level (and degrades gracefully in non-DOM
54
64
  // / test environments where these globals are absent).
@@ -469,7 +479,7 @@ export class ChatSession {
469
479
  // the pending bubble in this same cache entry when the reply lands.
470
480
  var offHistory = (this.aiChatHistoryCache[key] ? this.aiChatHistoryCache[key].messages : []).filter(function (m) {
471
481
  return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
472
- !m.isCancelled && !m.isBackgroundTask;
482
+ !m.isCancelled && !m.isBackgroundTask && !m.isError;
473
483
  });
474
484
  var offBounded = buildBoundedChatMessages({
475
485
  platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
@@ -495,7 +505,7 @@ export class ChatSession {
495
505
  if (isQueuedSend) {
496
506
  var resolvedHistory = this.state.messages.filter(function (m) {
497
507
  return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
498
- !m.isCancelled && !m.isBackgroundTask;
508
+ !m.isCancelled && !m.isBackgroundTask && !m.isError;
499
509
  });
500
510
  var boundedQ = buildBoundedChatMessages({
501
511
  platform: aiPlatform, model: aiModel, systemPrompt: systemPrompt, serviceId: id.serviceId,
@@ -547,7 +557,29 @@ export class ChatSession {
547
557
  this.state.messages.push({ role: 'assistant', content: '', isPending: true, isPendingInProcess: true, ...(key ? { _ownerKey: key } : {}) });
548
558
  this.host.notify(); this.updateHistoryCache(); this.state.sending = true; this.host.scrollToBottom(true);
549
559
 
550
- var historyForLlm = this.state.messages.filter(function (m) { return !m.isCancelled && !m.isBackgroundTask; });
560
+ // Same filter as the offChat and isQueuedSend paths above. It must drop the
561
+ // pending flags too: the `isPending` placeholder pushed two lines up is the
562
+ // in-flight "Thinking..." bubble, and leaving it in sent a trailing
563
+ // `{role:'assistant', content:''}` upstream on EVERY immediate send. That is
564
+ // a last-assistant-turn prefill, which the Claude platform rejects outright,
565
+ // and on both platforms it made the history end on an assistant turn, so
566
+ // prepareOpenAIMessages / prepareClaudeMessages bailed at their
567
+ // `last.role !== 'user'` guard and silently stopped converting attached
568
+ // images into image blocks.
569
+ //
570
+ // `isError` is dropped in all three paths for a different reason: those
571
+ // bubbles are written by THIS client, never by the model. Every site that
572
+ // sets `isError: true` fills content from getErrorMessage() or the literal
573
+ // 'Request was cancelled.', so filtering them discards no model-authored
574
+ // text. Keeping them handed the model a turn it never produced ("The AI
575
+ // provider is temporarily unreachable..."), attributed to itself, because the
576
+ // flag is stripped when buildBoundedChatMessages maps down to {role,content}.
577
+ // The cost is that a retry now follows an unanswered question with no stated
578
+ // reason, which is a true account of what happened rather than a false one.
579
+ var historyForLlm = this.state.messages.filter(function (m) {
580
+ return !m.isPending && !m.isPendingQueued && !m.isPendingInProcess && !m.isPendingOlder &&
581
+ !m.isCancelled && !m.isBackgroundTask && !m.isError;
582
+ });
551
583
  if (llmComposed !== composed) {
552
584
  for (var li = historyForLlm.length - 1; li >= 0; li--) {
553
585
  if (historyForLlm[li].role === 'user' && historyForLlm[li].content === composed) {
@@ -1111,8 +1143,29 @@ export class ChatSession {
1111
1143
 
1112
1144
  // --- background-task resolution + drain -------------------------------
1113
1145
  handleHistoryItemResolution(itemId: string, response: any, platform: string): void {
1146
+ // Which file this turn was indexing, read BEFORE the resolution rewrites the
1147
+ // bubbles. Every settling turn funnels through here — the bg drain's own
1148
+ // poll, the history poll, and agent.vue's forked history poll, which calls
1149
+ // straight into this method — so hooking the chain follow-up here is what
1150
+ // makes it work in both clients without either of them forking a call site.
1151
+ var indexRef = this._indexRefOfItem(itemId);
1114
1152
  this.applyHistoryItemResolution(itemId, response, platform);
1115
1153
  this.promoteNextBgQueuedToRunning();
1154
+ // A worker-driven chain has no client-side record of its next pass, so a
1155
+ // settling pass is the only moment there is to go looking for one.
1156
+ if (indexRef) this._followWorkerIndexingChain(indexRef.name, indexRef.mime);
1157
+ }
1158
+
1159
+ /** The file an already-rendered background pass is about, off its request
1160
+ * bubble. Null for an ordinary turn, which is most of them. */
1161
+ private _indexRefOfItem(itemId: string): { name?: string; mime?: string } | null {
1162
+ if (!itemId) return null;
1163
+ for (var i = 0; i < this.state.messages.length; i++) {
1164
+ var m = this.state.messages[i];
1165
+ if (m._serverItemId !== itemId || m.role !== 'user' || !m.isBackgroundTask) continue;
1166
+ return m._indexFile || null;
1167
+ }
1168
+ return null;
1116
1169
  }
1117
1170
 
1118
1171
  applyHistoryItemResolution(itemId: string, response: any, platform: string): void {
@@ -1261,6 +1314,175 @@ export class ChatSession {
1261
1314
  });
1262
1315
  }
1263
1316
 
1317
+ /**
1318
+ * True when the WORKER, not this client, drives the rest of this file's chain.
1319
+ * The mirror image of the early returns in maybeResumeIndexing: whatever that
1320
+ * refuses to continue is exactly what nothing client-side is tracking.
1321
+ */
1322
+ private _isWorkerDrivenIndexing(filename?: string, mime?: string): boolean {
1323
+ if (!isPagedReadFile(filename, mime)) return false;
1324
+ if (isImageVisionFile(filename, mime)) return true;
1325
+ return windowedIndexingEnabled() && isWindowedReadFile(filename, mime);
1326
+ }
1327
+
1328
+ /**
1329
+ * Pick up indexing passes the WORKER minted, which no client ever dispatched.
1330
+ *
1331
+ * For a PDF (and for text/grid when windowed indexing is on) the worker writes
1332
+ * pass N+1's row itself, inside pass N's invocation, right after saving pass
1333
+ * N's result. That row reaches this client through nothing at all: it is not in
1334
+ * bgTaskQueue (the client never asked for it) and it is not in state.messages
1335
+ * (only a first-page history load maps it in, which happens on mount, project
1336
+ * switch or tab return). So between two worker passes every pass the client
1337
+ * knows about is settled, and the collapsed row renders "Indexed N passes"
1338
+ * with no spinner and no Stop, for a file that is still being read. A user who
1339
+ * believes that then asks questions against a half-indexed file — which is what
1340
+ * this exists to prevent.
1341
+ *
1342
+ * So when a background indexing pass settles, ask the bg queue what is still
1343
+ * unresolved on it and adopt anything unknown as an ordinary BgTaskEntry.
1344
+ * drainBgTaskQueue then treats it exactly like a pass this client dispatched:
1345
+ * same bubble, same `_indexFile` (so it joins the file's collapsed row), same
1346
+ * poll — and that poll settling runs this again, so the chain is followed to
1347
+ * its end. `status`-scoped so the reply carries the live items only, never a
1348
+ * page of finished ones with their bodies.
1349
+ *
1350
+ * Termination: the only trigger is a pass SETTLING, which happens once per
1351
+ * pass. An adopted item that is still running is not re-adopted (its id is
1352
+ * already polled), and when the queue holds nothing unknown the chain stops on
1353
+ * its own. Nothing here is periodic — a timer that re-reads history is the
1354
+ * shape that previously looped fetchHistoryPage after an already-DONE index.
1355
+ */
1356
+ private _adoptingWorkerPasses = false;
1357
+ private _adoptWorkerIndexingPasses(attempt: number): void {
1358
+ var self = this;
1359
+ // One in flight at a time. The query is queue-WIDE, so a second file's pass
1360
+ // settling in the same moment is covered by the query already running.
1361
+ if (this._adoptingWorkerPasses) return;
1362
+ var id = this.host.getIdentity();
1363
+ var platform = id.platform;
1364
+ if (!id.serviceId || (platform !== 'claude' && platform !== 'openai')) return;
1365
+ if (this.isPollingPaused() || !this.host.isViewMounted()) return;
1366
+ var svcId = id.serviceId, owner = id.owner;
1367
+ var queue = (id.userId || id.serviceId) + BG_INDEXING_QUEUE_SUFFIX;
1368
+ var ask = function (status: 'pending' | 'running') {
1369
+ return Promise.resolve(getChatHistory(
1370
+ { service: svcId, owner: owner, platform: platform as 'claude' | 'openai', queue: queue, status: status },
1371
+ { limit: WORKER_PASS_ADOPT_LIMIT },
1372
+ )).catch(function () { return null; });
1373
+ };
1374
+ this._adoptingWorkerPasses = true;
1375
+ Promise.all([ask('running'), ask('pending')]).then(function (results: any[]) {
1376
+ self._adoptingWorkerPasses = false;
1377
+ // The chat may have changed under the query; adopting into another
1378
+ // project's session is the cross-project bubble leak all over again.
1379
+ var now = self.host.getIdentity();
1380
+ if (now.serviceId !== svcId || now.platform !== platform) return;
1381
+ if (!self.host.isViewMounted()) return;
1382
+ var adoptedIds: string[] = [];
1383
+ for (var ri = 0; ri < results.length; ri++) {
1384
+ var list = results[ri] && Array.isArray(results[ri].list) ? results[ri].list : [];
1385
+ for (var i = 0; i < list.length; i++) {
1386
+ if (self._adoptWorkerIndexingItem(list[i], svcId, platform as 'claude' | 'openai')) adoptedIds.push(list[i].id);
1387
+ }
1388
+ }
1389
+ if (adoptedIds.length) {
1390
+ self.drainBgTaskQueue();
1391
+ // Only an item the drain KEPT means the chain is still going. One it
1392
+ // threw straight back out is not progress and must not end the ladder:
1393
+ // the status index is eventually consistent, so a look taken the
1394
+ // instant a pass settles can still report that pass as running, and a
1395
+ // pass belonging to a file the user stopped is dropped on sight. Both
1396
+ // used to read as "found the next pass", which ended the search for a
1397
+ // pass that had not been written yet.
1398
+ if (self._isTrackingAny(adoptedIds)) return;
1399
+ }
1400
+ // Nothing yet. The worker writes pass N+1 a few milliseconds AFTER it
1401
+ // flips pass N to resolved, so a poll that lands in that gap sees an
1402
+ // empty queue for a chain that is very much alive — and with no pass
1403
+ // left to settle, nothing would ever ask again. Look once or twice more
1404
+ // before believing the file is finished.
1405
+ if (attempt + 1 >= WORKER_PASS_ADOPT_ATTEMPTS.length) return;
1406
+ setTimeout(function () {
1407
+ var later = self.host.getIdentity();
1408
+ if (later.serviceId !== svcId || later.platform !== platform) return;
1409
+ if (self.isPollingPaused() || !self.host.isViewMounted()) return;
1410
+ self._adoptWorkerIndexingPasses(attempt + 1);
1411
+ }, WORKER_PASS_ADOPT_ATTEMPTS[attempt + 1]);
1412
+ }, function () { self._adoptingWorkerPasses = false; });
1413
+ }
1414
+
1415
+ /** Any of these ids still queued or still polled, i.e. surviving work. */
1416
+ private _isTrackingAny(ids: string[]): boolean {
1417
+ for (var i = 0; i < ids.length; i++) {
1418
+ if (this.historyItemPolls.has(ids[i])) return true;
1419
+ for (var q = 0; q < this.bgTaskQueue.length; q++) {
1420
+ if (this.bgTaskQueue[q].id === ids[i]) return true;
1421
+ }
1422
+ }
1423
+ return false;
1424
+ }
1425
+
1426
+ /** One live bg-queue item -> a BgTaskEntry, if it is an indexing pass this
1427
+ * client is not already tracking. Returns whether it was adopted. */
1428
+ private _adoptWorkerIndexingItem(item: any, svcId: string, platform: 'claude' | 'openai'): boolean {
1429
+ if (!item || typeof item.id !== 'string' || !item.id) return false;
1430
+ if (item.status !== 'pending' && item.status !== 'running') return false;
1431
+ if (typeof item.poll !== 'function') return false;
1432
+ // Already known: a pass this client dispatched, or one adopted earlier that
1433
+ // has not settled yet. Adopting twice would stack a second poll on one item.
1434
+ if (this.historyItemPolls.has(item.id)) return false;
1435
+ for (var q = 0; q < this.bgTaskQueue.length; q++) {
1436
+ if (this.bgTaskQueue[q].id === item.id) return false;
1437
+ }
1438
+ // Already SETTLED here. The status index is eventually consistent, so the
1439
+ // pass that just finished can still come back as running; re-adopting it
1440
+ // would re-poll a dead item and, worse, read as the chain continuing.
1441
+ for (var m = 0; m < this.state.messages.length; m++) {
1442
+ var msg = this.state.messages[m];
1443
+ if (msg._serverItemId !== item.id) continue;
1444
+ if (!(msg.isPending || msg.isPendingInProcess || msg.isPendingQueued)) return false;
1445
+ }
1446
+ // The bg queue also carries ordinary chats routed onto it (_isOnBgQueue), and
1447
+ // both platforms share one queue name, so check the shape as well: a claude
1448
+ // request body has `messages`, an openai one has `input`. Adopting the other
1449
+ // platform's item would poll it against this platform's url.
1450
+ var body = item.request_body;
1451
+ if (!body || typeof body !== 'object') return false;
1452
+ if (platform === 'claude' ? !Array.isArray((body as any).messages) : !Array.isArray((body as any).input)) return false;
1453
+ var userText = extractLastUserTextFromRequest(body);
1454
+ if (!isIndexingRequestText(userText)) return false;
1455
+ var ref = parseIndexingRequestText(userText);
1456
+ if (!ref || !ref.name) return false;
1457
+ // Only chains the worker drives. A client-driven file is already tracked by
1458
+ // the entry that dispatched it, and adopting a copy of it would hand
1459
+ // maybeResumeIndexing an entry whose resumePass no longer counts that
1460
+ // chain's passes — which is the cap that stops it running forever.
1461
+ if (!this._isWorkerDrivenIndexing(ref.name, ref.mime)) return false;
1462
+ this.bgTaskQueue.push({
1463
+ serviceId: svcId,
1464
+ platform: platform,
1465
+ id: item.id,
1466
+ filename: ref.name,
1467
+ storagePath: ref.path,
1468
+ mime: ref.mime,
1469
+ size: ref.size,
1470
+ status: item.status === 'running' ? 'running' : 'pending',
1471
+ poll: item.poll,
1472
+ // Drives the "(continuing)" label and marks this as a continuation for
1473
+ // _applyIndexCancellations, which must not read a CONTINUE pass as the
1474
+ // fresh first pass that lifts a stop.
1475
+ resumePass: ref.continued ? 1 : 0,
1476
+ });
1477
+ return true;
1478
+ }
1479
+
1480
+ /** Follow the chain on from a background indexing pass that just settled. */
1481
+ private _followWorkerIndexingChain(filename?: string, mime?: string): void {
1482
+ if (!this._isWorkerDrivenIndexing(filename, mime)) return;
1483
+ this._adoptWorkerIndexingPasses(0);
1484
+ }
1485
+
1264
1486
  /** Best-effort server-side cancel of a bg-queue item that has no bubble (so
1265
1487
  * cancelQueuedMessage, which drives one, has nothing to act on). */
1266
1488
  private _cancelServerItem(serverId: string): void {
@@ -1485,8 +1707,7 @@ export class ChatSession {
1485
1707
  var chatList = history && Array.isArray(history.list) ? history.list : [];
1486
1708
  chatList.forEach(function (item: any) {
1487
1709
  if (isBgIndexingQueue(item.queue_name)) {
1488
- var userText = extractLastUserTextFromRequest(item.request_body);
1489
- if (typeof userText === 'string' && (userText.indexOf('A new file has just been uploaded') === 0 || userText.indexOf('CONTINUE indexing') === 0)) item._isBgTask = true;
1710
+ if (isIndexingRequestText(extractLastUserTextFromRequest(item.request_body))) item._isBgTask = true;
1490
1711
  else item._isOnBgQueue = true;
1491
1712
  }
1492
1713
  });