bunnyquery 1.12.1 → 1.14.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.
@@ -48,6 +48,7 @@ import { windowedIndexingEnabled, liveStreamingEnabled, streamRecoveryEnabled, c
48
48
  import { createSseParser, type SseParser } from './sse';
49
49
  import { isErrorResponseBody, isAuthExpiredError, isNonRetryableRequestError, getErrorMessage, isCsrStatusEnvelope } from './errors';
50
50
  import { buildBoundedChatMessages } from './budget';
51
+ import { extractRetrievalNote } from './retrieval_memory';
51
52
  import { createInlineLinkRegex, sanitizeAttachmentLinksForHistory } from './links';
52
53
  import { markImagePreviewStale } from './image_preview';
53
54
  import { chatCacheKey, indexScopeKey, mapHistoryListToMessages, extractLastUserTextFromRequest, isIndexingRequestText, parseIndexingRequestText, probeBgQueue, BG_PROBE_TTL_MS, getSplitChatHistory, shouldRescueInFlightMessage, adoptLocalAnswerIntoPage } from './history';
@@ -1220,6 +1221,35 @@ export class ChatSession {
1220
1221
  /** Live streams by server item id. One per in-flight streamed turn. */
1221
1222
  private liveStreams: { [itemId: string]: LiveStreamState } = {};
1222
1223
 
1224
+ /** When skapi recorded a turn's response as having finished arriving, by server
1225
+ * item id. Read off the terminal envelope of a STREAMED turn (the only settle
1226
+ * that carries it, see _settleLiveStream) and used as the answer bubble's time
1227
+ * in place of this browser's clock. Entries are never removed: a settle reaches
1228
+ * here twice (the poll's onResponse, then the promise), and the value for an id
1229
+ * cannot change. */
1230
+ private _respondedAt: { [itemId: string]: number } = {};
1231
+
1232
+ private _noteRespondedAt(itemId: string, response: any): void {
1233
+ if (!itemId || !isCsrStatusEnvelope(response)) return;
1234
+ var at = Number(response.responded);
1235
+ if (!isFinite(at) || at <= 0) return;
1236
+ // Self-healing for a session built without the constructor (the engine tests
1237
+ // drive Object.create(ChatSession.prototype)), like typewriterQueue below.
1238
+ if (!this._respondedAt) this._respondedAt = {};
1239
+ this._respondedAt[itemId] = at;
1240
+ }
1241
+
1242
+ /** The time a live answer bubble is stamped with: when skapi received the
1243
+ * response, if the settle told us, else now. "Now" is what every bubble got
1244
+ * before, and it is still right for a buffered turn, whose settle hands back the
1245
+ * destination's body and nothing of skapi's; a history load then replaces it
1246
+ * with the server's value. */
1247
+ private _replyStampFor(msg: ChatMessage | undefined): number {
1248
+ var id = msg ? msg._serverItemId : undefined;
1249
+ var at = (typeof id === 'string' && id && this._respondedAt) ? this._respondedAt[id] : undefined;
1250
+ return typeof at === 'number' ? at : wallClockNow();
1251
+ }
1252
+
1223
1253
  /**
1224
1254
  * Open (or re-open) the live stream for `itemId`, or null when this poll must
1225
1255
  * not carry one.
@@ -1472,6 +1502,10 @@ export class ChatSession {
1472
1502
  private _settleLiveStream(st: LiveStreamState, response: any): any {
1473
1503
  this._closeLiveStream(st, true);
1474
1504
  if (!isCsrStatusEnvelope(response)) return response;
1505
+ // Before any return below, and for every terminal status: the bubble that
1506
+ // settles this turn is stamped from it whether it ends as an answer, an error
1507
+ // or a truncation.
1508
+ this._noteRespondedAt(st.id, response);
1475
1509
  // RESOLVED only, and this is not caution, it is correctness. 'cancelled' is the
1476
1510
  // envelope the user's own Stop produces, and _isCancelledPollResult downstream
1477
1511
  // is what settles the turn as stopped: hand it an assembled body instead and a
@@ -2076,6 +2110,10 @@ export class ChatSession {
2076
2110
  var reply: ChatMessage = isErr
2077
2111
  ? { role: 'assistant', content: getErrorMessage(body), isError: true, _serverItemId: itemId }
2078
2112
  : { role: 'assistant', content: text, _serverItemId: itemId };
2113
+ if (!isErr) {
2114
+ var rNote = extractRetrievalNote(body, text);
2115
+ if (rNote) reply._retrieval = rNote;
2116
+ }
2079
2117
  if (ownerKey && this.getHistoryCacheKey() !== ownerKey) {
2080
2118
  // The reader is in another chat. Settle it where it belongs and touch
2081
2119
  // nothing on screen - state.messages is a different conversation's list.
@@ -2583,7 +2621,9 @@ export class ChatSession {
2583
2621
  if (isErrorResponseBody(response)) return { content: getErrorMessage(response), isError: true };
2584
2622
  var answer = (params.aiPlatform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response));
2585
2623
  answer = (answer || '').trim();
2586
- return { content: answer || 'No text response received from AI provider.', isError: false };
2624
+ // Retrieval memory: what this turn read, off the body's tool blocks (a
2625
+ // streamed turn's `response` is already the assembled body here).
2626
+ return { content: answer || 'No text response received from AI provider.', isError: false, retrieval: extractRetrievalNote(response, answer) };
2587
2627
  })
2588
2628
  .catch(function (err: any) { return { content: getErrorMessage(err), isError: true }; })
2589
2629
  .then(function (result: any) {
@@ -2602,6 +2642,7 @@ export class ChatSession {
2602
2642
  }
2603
2643
  var reply: ChatMessage = { role: 'assistant', content: result.content, isError: result.isError };
2604
2644
  if (dispatchItemId) reply._serverItemId = dispatchItemId;
2645
+ if (result.retrieval) reply._retrieval = result.retrieval;
2605
2646
 
2606
2647
  // REPLACE the trailing pending "Thinking..." bubble in the cache with
2607
2648
  // the answer, regardless of whether this chat is the currently-visible
@@ -3377,7 +3418,7 @@ export class ChatSession {
3377
3418
  insertAtTarget(msg: ChatMessage, targetIdx: number): void {
3378
3419
  // Error/direct replies land here rather than through the typewriter, so this
3379
3420
  // is where they pick up their display time.
3380
- if (msg && msg.role === 'assistant' && msg._ts === undefined) msg._ts = wallClockNow();
3421
+ if (msg && msg.role === 'assistant' && msg._ts === undefined) msg._ts = this._replyStampFor(msg);
3381
3422
  // Overwrite only a placeholder that genuinely belongs to the turn above it.
3382
3423
  // Two things now sit at this index that must never be replaced by a chat
3383
3424
  // answer: a background "Indexing:" placeholder (a file's rows are inserted
@@ -3437,6 +3478,10 @@ export class ChatSession {
3437
3478
  var offReply: ChatMessage = isErrorResponseBody(response)
3438
3479
  ? { role: 'assistant', content: getErrorMessage(response), isError: true }
3439
3480
  : { role: 'assistant', content: ((platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response)) || '').trim() || 'No text response received from AI provider.' };
3481
+ if (!offReply.isError) {
3482
+ var offNote = extractRetrievalNote(response, offReply.content);
3483
+ if (offNote) offReply._retrieval = offNote;
3484
+ }
3440
3485
  this._applyReplyToCache(ownerKey, offReply, serverId);
3441
3486
  if (serverId) this.cancelledServerIds.delete(serverId);
3442
3487
  return;
@@ -3452,6 +3497,9 @@ export class ChatSession {
3452
3497
  } else {
3453
3498
  var answer = (platform === 'openai' ? extractOpenAIText(response) : extractClaudeText(response));
3454
3499
  answer = (answer || '').trim() || 'No text response received from AI provider.';
3500
+ // Retrieval memory for this turn, attached to whichever bubble below ends up
3501
+ // holding the answer.
3502
+ var qNote = extractRetrievalNote(response, answer);
3455
3503
  var lid = this._newLocalId();
3456
3504
  if (targetIdx >= 0 && this.state.messages[targetIdx] && this.state.messages[targetIdx].isPending) {
3457
3505
  // Same resume as the immediate-send path: keep what the stream painted
@@ -3465,14 +3513,19 @@ export class ChatSession {
3465
3513
  var qSettled: any = { role: 'assistant', content: qPainted, _localId: lid };
3466
3514
  if (prevQ._serverItemId) qSettled._serverItemId = prevQ._serverItemId;
3467
3515
  if (prevQ._ownerKey) qSettled._ownerKey = prevQ._ownerKey;
3516
+ if (qNote) qSettled._retrieval = qNote;
3468
3517
  this.state.messages[targetIdx] = qSettled;
3469
3518
  this.host.notify(); this.enqueueTypewrite(targetIdx, answer, lid, qPainted);
3470
3519
  } else if (targetIdx >= 0) {
3471
- this.state.messages.splice(targetIdx, 0, { role: 'assistant', content: '', _localId: lid });
3520
+ var qInsert: ChatMessage = { role: 'assistant', content: '', _localId: lid };
3521
+ if (qNote) qInsert._retrieval = qNote;
3522
+ this.state.messages.splice(targetIdx, 0, qInsert);
3472
3523
  this.host.notify(); this.enqueueTypewrite(targetIdx, answer, lid);
3473
3524
  } else {
3474
3525
  var aiIdx = this.state.messages.length;
3475
- this.state.messages.push({ role: 'assistant', content: '', _localId: lid });
3526
+ var qAppend: ChatMessage = { role: 'assistant', content: '', _localId: lid };
3527
+ if (qNote) qAppend._retrieval = qNote;
3528
+ this.state.messages.push(qAppend);
3476
3529
  this.host.notify(); this.enqueueTypewrite(aiIdx, answer, lid);
3477
3530
  }
3478
3531
  }
@@ -4069,9 +4122,11 @@ export class ChatSession {
4069
4122
  // Stamp the reply bubble's display time as it starts revealing. This is the
4070
4123
  // single chokepoint every typed reply (immediate, queued, and bg-resolution)
4071
4124
  // passes through, so it is where a live reply gets its "when it arrived"
4072
- // timestamp; a history reload later replaces it with the server `updated`.
4125
+ // timestamp; a history reload later replaces it with the server's own.
4126
+ // _replyStampFor prefers the time skapi received the response when the settle
4127
+ // reported one (a streamed turn), over this browser's clock.
4073
4128
  var target = this.state.messages[idx];
4074
- if (target && target._ts === undefined) target._ts = wallClockNow();
4129
+ if (target && target._ts === undefined) target._ts = this._replyStampFor(target);
4075
4130
  // SELF-HEALING, because this is now reached from the live paint path too. The
4076
4131
  // queue is a class field, so it is only initialised by the constructor; any
4077
4132
  // caller holding a ChatSession built another way (the engine tests drive
@@ -4130,6 +4185,9 @@ export class ChatSession {
4130
4185
  var settled: any = { role: 'assistant', content: painted, isPending: false, _localId: lid };
4131
4186
  if (prevSettled._serverItemId) settled._serverItemId = prevSettled._serverItemId;
4132
4187
  if (prevSettled._ownerKey) settled._ownerKey = prevSettled._ownerKey;
4188
+ // The retrieval note rides with the cached reply; the typewriter only
4189
+ // writes `content`, so it survives the reveal.
4190
+ if (latest._retrieval) settled._retrieval = latest._retrieval;
4133
4191
  this.state.messages[pendingIdx] = settled;
4134
4192
  this._removeStrayPendingAssistants();
4135
4193
  this.host.notify();
@@ -4423,6 +4481,9 @@ export class ChatSession {
4423
4481
  // before and after a reload. Only an INDEXING pass has a protocol token to
4424
4482
  // hide.
4425
4483
  var reportedComplete = !isErr && !!answer && answer.indexOf(INDEXING_COMPLETE_MARKER) !== -1;
4484
+ // Retrieval memory for a CHAT turn that settled through a re-attached poll.
4485
+ // Read once here; the background branches below never use it.
4486
+ var hNote = isErr ? null : extractRetrievalNote(response, answer);
4426
4487
  var stripMarker = function (t: string): string {
4427
4488
  return reportedComplete ? t.split(INDEXING_COMPLETE_MARKER).join('').trim() : t;
4428
4489
  };
@@ -4464,7 +4525,9 @@ export class ChatSession {
4464
4525
  // the reader watched arrive.
4465
4526
  var hPainted = this._paintedTextAt(idx);
4466
4527
  var lid = this._newLocalId();
4467
- this.state.messages[idx] = { role: 'assistant', content: hPainted, _localId: lid, _serverItemId: itemId };
4528
+ var hReply: ChatMessage = { role: 'assistant', content: hPainted, _localId: lid, _serverItemId: itemId };
4529
+ if (hNote) hReply._retrieval = hNote;
4530
+ this.state.messages[idx] = hReply;
4468
4531
  this.host.notify(); this.enqueueTypewrite(idx, text, lid, hPainted);
4469
4532
  this.updateHistoryCache(); return;
4470
4533
  }
@@ -4497,6 +4560,7 @@ export class ChatSession {
4497
4560
  }
4498
4561
  var lid2 = this._newLocalId();
4499
4562
  var reply: ChatMessage = { role: 'assistant', content: '', _localId: lid2, _serverItemId: itemId };
4563
+ if (hNote) reply._retrieval = hNote;
4500
4564
  this.state.messages.splice(userIdx + 1, 0, reply);
4501
4565
  this.host.notify(); this.enqueueTypewrite(userIdx + 1, text2, lid2);
4502
4566
  this.updateHistoryCache();
package/styles/chat.css CHANGED
@@ -149,6 +149,37 @@
149
149
  font-style: italic;
150
150
  clear: both;
151
151
  }
152
+ /* ---- citations + the no-source mark -------------------------------------*/
153
+ /* Under an answer that read something: one chip per cited file (and page, in
154
+ the label only). The chips are the ordinary stored-file link chips
155
+ (.bq-link-button, emitted by renderInlineLinkHtml), so they inherit the
156
+ expired / refreshing / unavailable states and the hover rule; this only lays
157
+ them out as a row under a hairline. Absent entirely when there is nothing to
158
+ cite, so a plain bubble is laid out exactly as before. */
159
+ .bq-citations {
160
+ display: flex;
161
+ flex-wrap: wrap;
162
+ align-items: baseline;
163
+ gap: 0.25rem 0.6rem;
164
+ margin-top: 0.45rem;
165
+ padding-top: 0.35rem;
166
+ border-top: 1px solid var(--bq-line);
167
+ font-size: 0.72rem;
168
+ line-height: 1.35;
169
+ white-space: normal;
170
+ clear: both;
171
+ }
172
+ .bq-citations .bq-link-button { max-width: 16rem; }
173
+ /* The answer used no data tool: a small muted line, never a colour that reads
174
+ as an error (the answer may still be right; what it was not is READ). */
175
+ .bq-no-source-note {
176
+ display: block;
177
+ margin-top: 0.35rem;
178
+ font-size: 0.68rem;
179
+ font-style: italic;
180
+ color: var(--bq-muted);
181
+ clear: both;
182
+ }
152
183
  /* Timestamp under a settled bubble (created for a user turn, response time for an
153
184
  assistant turn). Muted and small so it never competes with the message. */
154
185
  .bq-msg-time {