bunnyquery 1.9.0 → 1.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
5
5
  "main": "bunnyquery.js",
6
6
  "exports": {
@@ -289,6 +289,23 @@ export type RunStubInfo = {
289
289
  platform?: 'claude' | 'openai';
290
290
  };
291
291
 
292
+ /** Canonical form of an indexing key for COMPARISON only (never for storage).
293
+ *
294
+ * The two sides of every stub-vs-group comparison come from one string but by two
295
+ * routes: the run:: record id keeps `attachment.storagePath` verbatim
296
+ * (requests.ts runIndexUniqueId), while the group's key is recovered from the pass
297
+ * prompt and trimmed on the way (history.ts parseIndexingRequestText). A path whose
298
+ * first or last character is whitespace therefore produced two keys for one file, the
299
+ * stub was never suppressed, and the file drew two rows.
300
+ *
301
+ * Trim only. NOT NFC: both keys derive from the same string with no normalization on
302
+ * either side, so no NFC/NFD divergence is reachable here, and normalizing a key that
303
+ * is also an S3 object key elsewhere is how storage paths get silently renamed.
304
+ */
305
+ export function canonIndexKey(s?: string): string {
306
+ return typeof s === 'string' && s ? s.trim() : '';
307
+ }
308
+
292
309
  /** A 'working' run record older than this with no live-queue confirmation is
293
310
  * treated as unknown rather than live: a chain that died without reaching any
294
311
  * error path leaves 'working' dangling, and a row must not spin forever on a
@@ -413,6 +430,10 @@ export function buildChatDisplayList(
413
430
  var openRunOfKey: { [key: string]: string } = {};
414
431
  var runsOfKey: { [key: string]: string[] } = {};
415
432
  var keyOfRun: { [runId: string]: string } = {};
433
+ // Newest pass timestamp seen for each open run, so a FIRST pass can be told from a
434
+ // re-index (it arrives AFTER the run it follows) apart from a misordered older pass
435
+ // (it arrives before). Only the second must not re-open.
436
+ var newestTsOfRun: { [runId: string]: number } = {};
416
437
  var runSeq = 0;
417
438
 
418
439
  for (var i = 0; i < list.length; i++) {
@@ -421,7 +442,18 @@ export function buildChatDisplayList(
421
442
 
422
443
  var runId: string | undefined;
423
444
  var ref = msg.role === 'user' ? readFileRef(msg) : null;
424
- if (ref) {
445
+ // A pass already attributed to a run stays with it, whatever its label says.
446
+ // Reading the label FIRST made attribution depend on walk order: a first pass
447
+ // ("A new file has just been uploaded") re-opened its file's run at the line
448
+ // below, so if history delivered a run's continuations before its first pass -
449
+ // which paging and the bg merge can do - the continuations opened a run, the
450
+ // late first pass deleted it and opened a second, and the abandoned one was
451
+ // force-finished. One run then rendered as TWO rows for the same file: a green
452
+ // "complete" and a yellow "in progress". The same applies to a duplicate copy
453
+ // of a pass. The id is the stable identity here; the label is a description.
454
+ if (msg._serverItemId && runByItemId[msg._serverItemId]) {
455
+ runId = runByItemId[msg._serverItemId];
456
+ } else if (ref) {
425
457
  // Path first — a file can be re-uploaded under a name that already
426
458
  // exists elsewhere. But a pass that supplied no path (a compact
427
459
  // continuation label recovered from an old history cache) is still the
@@ -431,7 +463,24 @@ export function buildChatDisplayList(
431
463
  // A FIRST pass ("A new file has just been uploaded") when this file
432
464
  // already has a run open starts a new one: it is a re-index, or a
433
465
  // re-upload over the same storage path. Continuations join the run.
434
- if (!ref.continued && openRunOfKey[key]) delete openRunOfKey[key];
466
+ //
467
+ // ...but only when this pass is genuinely NEW. A first pass whose id is
468
+ // already attributed is the same pass reached twice (a duplicate bubble, or
469
+ // a walk that reaches it after the continuations it opened), and re-opening
470
+ // on it splits one run in half.
471
+ var alreadySeen = !!(msg._serverItemId && runByItemId[msg._serverItemId]);
472
+ // ...and only when it is actually LATER than the run it would replace. A
473
+ // re-index's first pass follows the previous run; a misordered one precedes
474
+ // the continuations already walked, and re-opening on it abandons a live run
475
+ // (rendered green "complete") beside the fragment (yellow "in progress").
476
+ // Timestamps decide it when both are known; without them the old
477
+ // label-only behaviour stands, so the re-index case is unchanged.
478
+ var openId = openRunOfKey[key];
479
+ var notLater = false;
480
+ if (openId && typeof msg._ts === 'number' && typeof newestTsOfRun[openId] === 'number') {
481
+ notLater = (msg._ts as number) <= newestTsOfRun[openId];
482
+ }
483
+ if (!ref.continued && !alreadySeen && !notLater && openRunOfKey[key]) delete openRunOfKey[key];
435
484
  runId = openRunOfKey[key];
436
485
  if (!runId) {
437
486
  runId = 'run' + (runSeq++);
@@ -439,8 +488,6 @@ export function buildChatDisplayList(
439
488
  keyOfRun[runId] = key;
440
489
  (runsOfKey[key] || (runsOfKey[key] = [])).push(runId);
441
490
  }
442
- } else if (msg._serverItemId && runByItemId[msg._serverItemId]) {
443
- runId = runByItemId[msg._serverItemId];
444
491
  } else if (msg.role !== 'user') {
445
492
  // ADJACENT only. Both paths that create a pass emit its request and
446
493
  // response bubbles together, so an id-less response belongs to the
@@ -455,6 +502,10 @@ export function buildChatDisplayList(
455
502
  // or a response whose request is not loaded) stays an ordinary message
456
503
  // rather than being folded into whichever group happens to be nearest.
457
504
  if (!runId) continue;
505
+ if (typeof msg._ts === 'number') {
506
+ var prevTs = newestTsOfRun[runId];
507
+ if (typeof prevTs !== 'number' || (msg._ts as number) > prevTs) newestTsOfRun[runId] = msg._ts as number;
508
+ }
458
509
 
459
510
  var g = groups[runId];
460
511
  if (!g) {
@@ -780,16 +831,18 @@ export function buildChatDisplayList(
780
831
  var coveredPathlessNames: { [k: string]: boolean } = {};
781
832
  for (var ci = 0; ci < order.length; ci++) {
782
833
  var cg = groups[order[ci]];
783
- if (cg.path) { coveredPaths[cg.path] = true; if (cg.key) coveredPaths[cg.key] = true; }
784
- else if (cg.name) coveredPathlessNames[cg.name] = true;
785
- else if (cg.key) coveredPaths[cg.key] = true;
834
+ if (cg.path) {
835
+ coveredPaths[canonIndexKey(cg.path)] = true;
836
+ if (cg.key) coveredPaths[canonIndexKey(cg.key)] = true;
837
+ } else if (cg.name) coveredPathlessNames[canonIndexKey(cg.name)] = true;
838
+ else if (cg.key) coveredPaths[canonIndexKey(cg.key)] = true;
786
839
  }
787
840
  var now = opts && typeof opts.now === 'number' ? opts.now : Date.now();
788
841
  var stubClearedAt = (opts && typeof opts.stubClearedAt === 'number' && opts.stubClearedAt > 0)
789
842
  ? opts.stubClearedAt : 0;
790
843
  for (var sp in runStubs) {
791
844
  var rec = runStubs[sp];
792
- if (!sp || !rec || !rec.status || coveredPaths[sp]) continue;
845
+ if (!sp || !rec || !rec.status || coveredPaths[canonIndexKey(sp)]) continue;
793
846
  var fname = rec.filename || sp.split('/').pop() || sp;
794
847
  // A PATHLESS group (legacy compact label) can only be matched by
795
848
  // name, and that is still the right match: the file already has a
@@ -797,7 +850,7 @@ export function buildChatDisplayList(
797
850
  // void. What is gone is the reverse — a group WITH a path no longer
798
851
  // writes into the name namespace, so it can no longer delete a
799
852
  // same-basename stub from another folder.
800
- if (coveredPathlessNames[fname]) continue;
853
+ if (coveredPathlessNames[canonIndexKey(fname)]) continue;
801
854
  // A run recorded under the OTHER platform's chat belongs to that
802
855
  // conversation: its passes live in that history (so no real group
803
856
  // can ever cover this stub) and the queue probe is platform-scoped
@@ -808,7 +861,26 @@ export function buildChatDisplayList(
808
861
  // whatever the record says (a lagged update must not paint a verdict over
809
862
  // visible work), then the record's own terminal statuses, then the
810
863
  // queue's authoritative ABSENCE, and only then the stated wait.
811
- var live = !!liveIndexKeys[sp] || !!liveIndexKeys[fname];
864
+ // The bare-NAME disjunct is load-bearing: a legacy pass prompt may carry no
865
+ // storage path at all, so the name is the only key its run can appear under.
866
+ // But it also matched ANY live file sharing this basename, painting a
867
+ // finished run's stub yellow because a same-named file in another folder was
868
+ // indexing. Take the name hit only when it cannot belong to a different
869
+ // file - i.e. no OTHER live key is a path ending in this basename.
870
+ var live = !!liveIndexKeys[sp] || !!liveIndexKeys[canonIndexKey(sp)];
871
+ if (!live && (liveIndexKeys[fname] || liveIndexKeys[canonIndexKey(fname)])) {
872
+ var claimedByOther = false;
873
+ for (var lk in liveIndexKeys) {
874
+ if (!liveIndexKeys[lk]) continue;
875
+ var lkc = canonIndexKey(lk);
876
+ if (lkc === canonIndexKey(sp)) continue;
877
+ if (lkc.length > fname.length && lkc.slice(-(fname.length + 1)) === '/' + fname) {
878
+ claimedByOther = true;
879
+ break;
880
+ }
881
+ }
882
+ live = !claimedByOther;
883
+ }
812
884
  // Cleared-history horizon: a run that ended at or before the clear is
813
885
  // part of what the user asked to forget. A live queue hit survives it
814
886
  // (see BuildDisplayListOptions.stubClearedAt). A record carrying NO
@@ -825,7 +897,8 @@ export function buildChatDisplayList(
825
897
  // A done:: marker from the same sweep is as terminal as the record's
826
898
  // own 'done' — it settles a dangling 'working' the chain never
827
899
  // flipped (mirrors the real-group doneKeys disjunct).
828
- if (rec.status === 'done' || doneKeys[sp] || doneKeys[fname]) { st = 'done'; fin = true; }
900
+ if (rec.status === 'done' || doneKeys[sp] || doneKeys[canonIndexKey(sp)]
901
+ || doneKeys[fname] || doneKeys[canonIndexKey(fname)]) { st = 'done'; fin = true; }
829
902
  else if (rec.status === 'error') { st = 'error'; fin = true; }
830
903
  else if (rec.status === 'cancelled') { st = 'cancelled'; fin = true; }
831
904
  else if (liveIndexChecked) {
@@ -903,11 +976,14 @@ export function buildChatDisplayList(
903
976
  // the first pass paged in, and again when paging ended. A record with a
904
977
  // `started` places the run at ONE spot for as long as the record exists.
905
978
  var suppressAnchor: { [runId: string]: boolean } = {};
979
+ var stubByCanon: { [k: string]: RunStubInfo } = {};
980
+ if (runStubs) for (var ck in runStubs) stubByCanon[canonIndexKey(ck)] = runStubs[ck];
906
981
  if (runStubs) {
907
982
  for (var ti2 = 0; ti2 < order.length; ti2++) {
908
983
  var tg = groups[order[ti2]];
909
984
  if (!newestRunOfKey[order[ti2]]) continue;
910
- var trec = (tg.path && runStubs[tg.path]) || runStubs[tg.key];
985
+ var trec = (tg.path && (runStubs[tg.path] || stubByCanon[canonIndexKey(tg.path)]))
986
+ || runStubs[tg.key] || stubByCanon[canonIndexKey(tg.key)];
911
987
  if (!trec || typeof trec.started !== 'number') continue;
912
988
  if (stubPlatform && trec.platform && trec.platform !== stubPlatform) continue;
913
989
  suppressAnchor[order[ti2]] = true;