@vue-skuilder/db 0.1.34 → 0.1.36

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/dist/index.d.cts CHANGED
@@ -478,8 +478,19 @@ declare class SessionController<TView = unknown> extends Loggable {
478
478
  /**
479
479
  * Request a mid-session replan. Re-runs the pipeline with current user state
480
480
  * and atomically replaces the newQ contents. Safe to call at any time during
481
- * a session — if called while a replan is already in progress, returns the
482
- * existing replan promise (no duplicate work).
481
+ * a session.
482
+ *
483
+ * Concurrency policy:
484
+ * - Two unhinted auto-replans never run in parallel; the second coalesces
485
+ * into the first (returns the same promise).
486
+ * - A hint-bearing replan that arrives while another replan is in flight
487
+ * is queued to run **after** the in-flight one rather than dropped.
488
+ * This preserves caller intent (label, requireCards, excludeTags,
489
+ * limit, minFollowUpCards) instead of silently discarding it. Without
490
+ * queueing, a background auto-replan that started just before a
491
+ * completion-triggered replan would clobber the queue with unhinted
492
+ * results (e.g. surfacing another gpc-intro card right after one
493
+ * completed, skipping the prescribed `c-wst-*` follow-up).
483
494
  *
484
495
  * Does NOT affect reviewQ or failedQ.
485
496
  *
@@ -491,6 +502,25 @@ declare class SessionController<TView = unknown> extends Loggable {
491
502
  * calls this to ensure newly-unlocked content appears in the session.
492
503
  */
493
504
  requestReplan(options?: ReplanOptions | ReplanHints): Promise<void>;
505
+ /**
506
+ * True when a requestReplan call carries caller intent that must not be
507
+ * silently dropped. Bare unhinted auto-replans (depletion / quality
508
+ * triggers in nextCard) return false and may coalesce.
509
+ */
510
+ private _replanHasIntent;
511
+ /**
512
+ * Body of a single replan: populate auto-excludes, stash hints on
513
+ * sources, log, then run the pipeline. Extracted so it can be invoked
514
+ * either immediately (no in-flight replan) or queued (chained after
515
+ * the in-flight one resolves).
516
+ *
517
+ * IMPORTANT: hint stash and the queue-state snapshot used to build
518
+ * excludeCards happen at *invocation* time, not at *queue* time. For a
519
+ * queued replan that means excludes reflect the state after the prior
520
+ * replan landed — which is what we want, since the prior replan's
521
+ * newQ.peek(0) is the imminent draw we need to exclude.
522
+ */
523
+ private _runReplan;
494
524
  /**
495
525
  * Normalise the requestReplan argument. Accepts either a ReplanOptions
496
526
  * object (new API) or a plain Record<string, unknown> (legacy callers
package/dist/index.d.ts CHANGED
@@ -478,8 +478,19 @@ declare class SessionController<TView = unknown> extends Loggable {
478
478
  /**
479
479
  * Request a mid-session replan. Re-runs the pipeline with current user state
480
480
  * and atomically replaces the newQ contents. Safe to call at any time during
481
- * a session — if called while a replan is already in progress, returns the
482
- * existing replan promise (no duplicate work).
481
+ * a session.
482
+ *
483
+ * Concurrency policy:
484
+ * - Two unhinted auto-replans never run in parallel; the second coalesces
485
+ * into the first (returns the same promise).
486
+ * - A hint-bearing replan that arrives while another replan is in flight
487
+ * is queued to run **after** the in-flight one rather than dropped.
488
+ * This preserves caller intent (label, requireCards, excludeTags,
489
+ * limit, minFollowUpCards) instead of silently discarding it. Without
490
+ * queueing, a background auto-replan that started just before a
491
+ * completion-triggered replan would clobber the queue with unhinted
492
+ * results (e.g. surfacing another gpc-intro card right after one
493
+ * completed, skipping the prescribed `c-wst-*` follow-up).
483
494
  *
484
495
  * Does NOT affect reviewQ or failedQ.
485
496
  *
@@ -491,6 +502,25 @@ declare class SessionController<TView = unknown> extends Loggable {
491
502
  * calls this to ensure newly-unlocked content appears in the session.
492
503
  */
493
504
  requestReplan(options?: ReplanOptions | ReplanHints): Promise<void>;
505
+ /**
506
+ * True when a requestReplan call carries caller intent that must not be
507
+ * silently dropped. Bare unhinted auto-replans (depletion / quality
508
+ * triggers in nextCard) return false and may coalesce.
509
+ */
510
+ private _replanHasIntent;
511
+ /**
512
+ * Body of a single replan: populate auto-excludes, stash hints on
513
+ * sources, log, then run the pipeline. Extracted so it can be invoked
514
+ * either immediately (no in-flight replan) or queued (chained after
515
+ * the in-flight one resolves).
516
+ *
517
+ * IMPORTANT: hint stash and the queue-state snapshot used to build
518
+ * excludeCards happen at *invocation* time, not at *queue* time. For a
519
+ * queued replan that means excludes reflect the state after the prior
520
+ * replan landed — which is what we want, since the prior replan's
521
+ * newQ.peek(0) is the imminent draw we need to exclude.
522
+ */
523
+ private _runReplan;
494
524
  /**
495
525
  * Normalise the requestReplan argument. Accepts either a ReplanOptions
496
526
  * object (new API) or a plain Record<string, unknown> (legacy callers
package/dist/index.js CHANGED
@@ -877,6 +877,7 @@ var PipelineDebugger_exports = {};
877
877
  __export(PipelineDebugger_exports, {
878
878
  buildRunReport: () => buildRunReport,
879
879
  captureRun: () => captureRun,
880
+ clearRunHistory: () => clearRunHistory,
880
881
  mountPipelineDebugger: () => mountPipelineDebugger,
881
882
  pipelineDebugAPI: () => pipelineDebugAPI,
882
883
  registerPipelineForDebug: () => registerPipelineForDebug
@@ -884,6 +885,9 @@ __export(PipelineDebugger_exports, {
884
885
  function registerPipelineForDebug(pipeline) {
885
886
  _activePipeline = pipeline;
886
887
  }
888
+ function clearRunHistory() {
889
+ runHistory.length = 0;
890
+ }
887
891
  function getOrigin(card) {
888
892
  const firstEntry = card.provenance[0];
889
893
  if (!firstEntry) return "unknown";
@@ -912,7 +916,7 @@ function parseCardElo(provenance) {
912
916
  }
913
917
  function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards, userElo, hints) {
914
918
  const selectedIds = new Set(selectedCards.map((c) => c.cardId));
915
- const cards = allCards.map((card) => ({
919
+ const toReport = (card) => ({
916
920
  cardId: card.cardId,
917
921
  courseId: card.courseId,
918
922
  origin: getOrigin(card),
@@ -922,7 +926,47 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
922
926
  provenance: card.provenance,
923
927
  tags: card.tags,
924
928
  selected: selectedIds.has(card.cardId)
925
- }));
929
+ });
930
+ const selectedReported = [];
931
+ const nearMissReported = [];
932
+ const discardedTailCards = [];
933
+ let nonSelectedSeen = 0;
934
+ for (const card of allCards) {
935
+ if (selectedIds.has(card.cardId)) {
936
+ selectedReported.push(toReport(card));
937
+ } else if (nonSelectedSeen < DISCARDED_KEEP_TOP) {
938
+ nearMissReported.push(toReport(card));
939
+ nonSelectedSeen++;
940
+ } else {
941
+ discardedTailCards.push(card);
942
+ }
943
+ }
944
+ const cards = [...selectedReported, ...nearMissReported];
945
+ let discardedTail;
946
+ if (discardedTailCards.length > 0) {
947
+ let scoreMin = Infinity;
948
+ let scoreMax = -Infinity;
949
+ let eloMin = Infinity;
950
+ let eloMax = -Infinity;
951
+ let eloSeen = false;
952
+ for (const c of discardedTailCards) {
953
+ if (c.score < scoreMin) scoreMin = c.score;
954
+ if (c.score > scoreMax) scoreMax = c.score;
955
+ const elo = parseCardElo(c.provenance);
956
+ if (elo !== void 0) {
957
+ eloSeen = true;
958
+ if (elo < eloMin) eloMin = elo;
959
+ if (elo > eloMax) eloMax = elo;
960
+ }
961
+ }
962
+ const eloFragment = eloSeen ? `, ELO ${eloMin}\u2013${eloMax}` : "";
963
+ discardedTail = {
964
+ count: discardedTailCards.length,
965
+ scoreRange: [scoreMin, scoreMax],
966
+ eloRange: eloSeen ? [eloMin, eloMax] : void 0,
967
+ note: `${discardedTailCards.length} additional candidate(s) scored below the top ${DISCARDED_KEEP_TOP} near-misses and were not retained (score ${scoreMin.toExponential(2)}\u2013${scoreMax.toExponential(2)}${eloFragment}). Likely ELO-window pull remnants filtered out by hierarchy/lesson/priority gates.`
968
+ };
969
+ }
926
970
  const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
927
971
  const newSelected = selectedCards.filter((c) => getOrigin(c) === "new").length;
928
972
  return {
@@ -937,7 +981,8 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
937
981
  finalCount: selectedCards.length,
938
982
  reviewsSelected,
939
983
  newSelected,
940
- cards
984
+ cards,
985
+ discardedTail
941
986
  };
942
987
  }
943
988
  function formatProvenance(provenance) {
@@ -973,6 +1018,44 @@ function printRunSummary(run) {
973
1018
  );
974
1019
  console.groupEnd();
975
1020
  }
1021
+ function escapeHtml(s) {
1022
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1023
+ }
1024
+ function escapeAttr(s) {
1025
+ return escapeHtml(s).replace(/"/g, "&quot;");
1026
+ }
1027
+ function copyTextToClipboard(text, btn) {
1028
+ const done = () => {
1029
+ if (!btn) return;
1030
+ const orig = btn.textContent ?? "Copy";
1031
+ btn.textContent = "Copied!";
1032
+ btn.classList.add("copied");
1033
+ setTimeout(() => {
1034
+ btn.textContent = orig;
1035
+ btn.classList.remove("copied");
1036
+ }, 1200);
1037
+ };
1038
+ const fallback = () => {
1039
+ const ta = document.createElement("textarea");
1040
+ ta.value = text;
1041
+ ta.style.position = "fixed";
1042
+ ta.style.opacity = "0";
1043
+ document.body.appendChild(ta);
1044
+ ta.select();
1045
+ try {
1046
+ document.execCommand("copy");
1047
+ } catch (e) {
1048
+ logger.warn(`[Pipeline Debug] Copy failed: ${e}`);
1049
+ }
1050
+ document.body.removeChild(ta);
1051
+ done();
1052
+ };
1053
+ if (navigator.clipboard?.writeText) {
1054
+ navigator.clipboard.writeText(text).then(done).catch(fallback);
1055
+ } else {
1056
+ fallback();
1057
+ }
1058
+ }
976
1059
  function renderUI() {
977
1060
  if (!_uiContainer) return;
978
1061
  const runs = runHistory;
@@ -1031,6 +1114,13 @@ function renderUI() {
1031
1114
  #sk-pipeline-debugger .close-btn { background: #dc3545; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; }
1032
1115
  #sk-pipeline-debugger .search-box { margin-bottom: 1rem; width: 100%; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 4px; }
1033
1116
  #sk-pipeline-debugger .provenance { font-size: 12px; color: #666; margin-top: 0.25rem; white-space: pre-wrap; font-family: monospace; background: #f8f9fa; padding: 0.5rem; border-radius: 4px; }
1117
+ #sk-pipeline-debugger .run-label { display: inline-block; margin-top: 0.25rem; padding: 0.1rem 0.4rem; background: #fff3cd; color: #664d03; border-radius: 3px; font-family: monospace; font-size: 11px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
1118
+ #sk-pipeline-debugger .label-banner { display: inline-block; padding: 0.25rem 0.6rem; background: #fff3cd; color: #664d03; border-radius: 4px; font-family: monospace; font-size: 13px; margin: 0 0 0.75rem 0; }
1119
+ #sk-pipeline-debugger .copy-btn { background: #0d6efd; color: white; border: none; padding: 0.25rem 0.6rem; border-radius: 3px; cursor: pointer; font-size: 12px; margin-left: 0.5rem; }
1120
+ #sk-pipeline-debugger .copy-btn:hover { background: #0b5ed7; }
1121
+ #sk-pipeline-debugger .copy-btn.copied { background: #198754; }
1122
+ #sk-pipeline-debugger .section-head { display: flex; align-items: center; justify-content: space-between; margin-top: 1rem; }
1123
+ #sk-pipeline-debugger .section-head h3 { margin: 0; }
1034
1124
  `;
1035
1125
  const runListHtml = runs.length === 0 ? '<div style="padding: 1rem;">No runs captured yet.</div>' : runs.map(
1036
1126
  (r, i) => `
@@ -1038,6 +1128,7 @@ function renderUI() {
1038
1128
  <strong>${r.timestamp.toLocaleTimeString()}</strong><br/>
1039
1129
  <small>${r.courseName || r.courseId.slice(0, 8)}</small><br/>
1040
1130
  <small>${r.finalCount} cards selected</small>
1131
+ ${r.hints?._label ? `<br/><span class="run-label" title="${escapeAttr(r.hints._label)}">${escapeHtml(r.hints._label)}</span>` : ""}
1041
1132
  </div>
1042
1133
  `
1043
1134
  ).join("");
@@ -1046,11 +1137,13 @@ function renderUI() {
1046
1137
  const filteredCards = selectedRun.cards.filter(
1047
1138
  (c) => !_cardSearchQuery || c.cardId.toLowerCase().includes(_cardSearchQuery.toLowerCase())
1048
1139
  );
1140
+ const labelText = selectedRun.hints?._label ?? "(no label)";
1049
1141
  detailsHtml = `
1050
1142
  <h2>Run: ${selectedRun.runId}</h2>
1143
+ <div class="label-banner" title="${escapeAttr(labelText)}">${escapeHtml(labelText)}</div>
1051
1144
  <p>
1052
- <strong>Time:</strong> ${selectedRun.timestamp.toLocaleString()} |
1053
- <strong>Course:</strong> ${selectedRun.courseName || selectedRun.courseId} |
1145
+ <strong>Time:</strong> ${selectedRun.timestamp.toLocaleString()} |
1146
+ <strong>Course:</strong> ${selectedRun.courseName || selectedRun.courseId} |
1054
1147
  <strong>User ELO:</strong> ${selectedRun.userElo ?? "unknown"}
1055
1148
  </p>
1056
1149
 
@@ -1065,7 +1158,10 @@ function renderUI() {
1065
1158
  </table>
1066
1159
 
1067
1160
  ${selectedRun.hints ? `
1068
- <h3>Ephemeral Hints</h3>
1161
+ <div class="section-head">
1162
+ <h3>Ephemeral Hints</h3>
1163
+ <button class="copy-btn" onclick="window.skuilder.pipeline._copyConfig('${selectedRun.runId}', this)">Copy config</button>
1164
+ </div>
1069
1165
  <table>
1070
1166
  ${selectedRun.hints._label ? `<tr><th>Label</th><td>${selectedRun.hints._label}</td></tr>` : ""}
1071
1167
  ${selectedRun.hints.boostTags ? `<tr><th>Boost Tags</th><td><pre style="margin:0">${JSON.stringify(selectedRun.hints.boostTags, null, 2)}</pre></td></tr>` : ""}
@@ -1089,7 +1185,10 @@ function renderUI() {
1089
1185
  </tbody>
1090
1186
  </table>
1091
1187
 
1092
- <h3>Cards (${selectedRun.finalCount} selected / ${selectedRun.cards.length} total)</h3>
1188
+ <div class="section-head">
1189
+ <h3>Cards (${selectedRun.finalCount} selected / ${selectedRun.cards.length} total)</h3>
1190
+ <button class="copy-btn" onclick="window.skuilder.pipeline._copyResults('${selectedRun.runId}', this)">Copy results</button>
1191
+ </div>
1093
1192
  <input type="text" class="search-box" placeholder="Search Card ID..." value="${_cardSearchQuery}" oninput="window.skuilder.pipeline._setSearch(this.value)">
1094
1193
 
1095
1194
  <table>
@@ -1135,7 +1234,7 @@ function mountPipelineDebugger() {
1135
1234
  win.skuilder = win.skuilder || {};
1136
1235
  win.skuilder.pipeline = pipelineDebugAPI;
1137
1236
  }
1138
- var _activePipeline, MAX_RUNS, runHistory, _uiContainer, _selectedRunIndex, _cardSearchQuery, pipelineDebugAPI;
1237
+ var _activePipeline, MAX_RUNS, runHistory, DISCARDED_KEEP_TOP, _uiContainer, _selectedRunIndex, _cardSearchQuery, pipelineDebugAPI;
1139
1238
  var init_PipelineDebugger = __esm({
1140
1239
  "src/core/navigators/PipelineDebugger.ts"() {
1141
1240
  "use strict";
@@ -1144,6 +1243,7 @@ var init_PipelineDebugger = __esm({
1144
1243
  _activePipeline = null;
1145
1244
  MAX_RUNS = 10;
1146
1245
  runHistory = [];
1246
+ DISCARDED_KEEP_TOP = 25;
1147
1247
  _uiContainer = null;
1148
1248
  _selectedRunIndex = null;
1149
1249
  _cardSearchQuery = "";
@@ -1208,7 +1308,14 @@ var init_PipelineDebugger = __esm({
1208
1308
  return;
1209
1309
  }
1210
1310
  }
1211
- logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
1311
+ const runsWithTails = runHistory.filter((r) => r.discardedTail && r.discardedTail.count > 0);
1312
+ if (runsWithTails.length > 0) {
1313
+ logger.info(
1314
+ `[Pipeline Debug] Card '${cardId}' not found in retained cards. ${runsWithTails.length} run(s) have discarded tails that were not retained \u2014 the card may have been a low-score candidate. See run.discardedTail for ranges.`
1315
+ );
1316
+ } else {
1317
+ logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
1318
+ }
1212
1319
  },
1213
1320
  /**
1214
1321
  * Explain why reviews may or may not have been selected.
@@ -1513,6 +1620,50 @@ var init_PipelineDebugger = __esm({
1513
1620
  _cardSearchQuery = query;
1514
1621
  renderUI();
1515
1622
  },
1623
+ /**
1624
+ * Internal UI helpers
1625
+ * @internal
1626
+ */
1627
+ _copyConfig(runId, btn) {
1628
+ const run = runHistory.find((r) => r.runId === runId);
1629
+ if (!run) return;
1630
+ const payload = {
1631
+ runId: run.runId,
1632
+ timestamp: run.timestamp.toISOString(),
1633
+ courseId: run.courseId,
1634
+ courseName: run.courseName,
1635
+ hints: run.hints ?? null
1636
+ };
1637
+ copyTextToClipboard(JSON.stringify(payload, null, 2), btn);
1638
+ },
1639
+ /**
1640
+ * Internal UI helpers
1641
+ * @internal
1642
+ *
1643
+ * Copies an "abridged" view of results: just the selected cards with their
1644
+ * generator, origin, final score, and the top provenance reason. Designed
1645
+ * for pasting into bug reports without flooding with full provenance.
1646
+ */
1647
+ _copyResults(runId, btn) {
1648
+ const run = runHistory.find((r) => r.runId === runId);
1649
+ if (!run) return;
1650
+ const selected = run.cards.filter((c) => c.selected).sort((a, b) => b.finalScore - a.finalScore).map((c) => ({
1651
+ cardId: c.cardId,
1652
+ generator: c.generator,
1653
+ origin: c.origin,
1654
+ score: Number(c.finalScore.toFixed(3)),
1655
+ topReason: c.provenance[0]?.reason ?? ""
1656
+ }));
1657
+ const payload = {
1658
+ runId: run.runId,
1659
+ label: run.hints?._label ?? null,
1660
+ finalCount: run.finalCount,
1661
+ newSelected: run.newSelected,
1662
+ reviewsSelected: run.reviewsSelected,
1663
+ selected
1664
+ };
1665
+ copyTextToClipboard(JSON.stringify(payload, null, 2), btn);
1666
+ },
1516
1667
  /**
1517
1668
  * Show help.
1518
1669
  */
@@ -5822,12 +5973,12 @@ ${e.stack}` : JSON.stringify(e);
5822
5973
  async getWeightedCards(limit) {
5823
5974
  const u = await this._getCurrentUser();
5824
5975
  try {
5825
- const navigator = await this.createNavigator(u);
5976
+ const navigator2 = await this.createNavigator(u);
5826
5977
  if (this._pendingHints) {
5827
- navigator.setEphemeralHints(this._pendingHints);
5978
+ navigator2.setEphemeralHints(this._pendingHints);
5828
5979
  this._pendingHints = null;
5829
5980
  }
5830
- return navigator.getWeightedCards(limit);
5981
+ return navigator2.getWeightedCards(limit);
5831
5982
  } catch (e) {
5832
5983
  logger.error(`[courseDB] Error getting weighted cards: ${e}`);
5833
5984
  throw e;
@@ -8872,12 +9023,12 @@ var init_courseDB2 = __esm({
8872
9023
  }
8873
9024
  async getWeightedCards(limit) {
8874
9025
  try {
8875
- const navigator = await this.createNavigator(this.userDB);
9026
+ const navigator2 = await this.createNavigator(this.userDB);
8876
9027
  if (this._pendingHints) {
8877
- navigator.setEphemeralHints(this._pendingHints);
9028
+ navigator2.setEphemeralHints(this._pendingHints);
8878
9029
  this._pendingHints = null;
8879
9030
  }
8880
- return navigator.getWeightedCards(limit);
9031
+ return navigator2.getWeightedCards(limit);
8881
9032
  } catch (e) {
8882
9033
  logger.error(`[static/courseDB] Error getting weighted cards: ${e}`);
8883
9034
  throw e;
@@ -12912,10 +13063,12 @@ mountMixerDebugger();
12912
13063
 
12913
13064
  // src/study/SessionDebugger.ts
12914
13065
  init_logger();
13066
+ init_PipelineDebugger();
12915
13067
  var activeSession = null;
12916
13068
  var sessionHistory = [];
12917
13069
  var MAX_HISTORY = 5;
12918
13070
  function startSessionTracking(reviewQLength, newQLength, failedQLength) {
13071
+ clearRunHistory();
12919
13072
  const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
12920
13073
  activeSession = {
12921
13074
  sessionId,
@@ -13373,8 +13526,19 @@ var SessionController = class _SessionController extends Loggable {
13373
13526
  /**
13374
13527
  * Request a mid-session replan. Re-runs the pipeline with current user state
13375
13528
  * and atomically replaces the newQ contents. Safe to call at any time during
13376
- * a session — if called while a replan is already in progress, returns the
13377
- * existing replan promise (no duplicate work).
13529
+ * a session.
13530
+ *
13531
+ * Concurrency policy:
13532
+ * - Two unhinted auto-replans never run in parallel; the second coalesces
13533
+ * into the first (returns the same promise).
13534
+ * - A hint-bearing replan that arrives while another replan is in flight
13535
+ * is queued to run **after** the in-flight one rather than dropped.
13536
+ * This preserves caller intent (label, requireCards, excludeTags,
13537
+ * limit, minFollowUpCards) instead of silently discarding it. Without
13538
+ * queueing, a background auto-replan that started just before a
13539
+ * completion-triggered replan would clobber the queue with unhinted
13540
+ * results (e.g. surfacing another gpc-intro card right after one
13541
+ * completed, skipping the prescribed `c-wst-*` follow-up).
13378
13542
  *
13379
13543
  * Does NOT affect reviewQ or failedQ.
13380
13544
  *
@@ -13390,10 +13554,55 @@ var SessionController = class _SessionController extends Loggable {
13390
13554
  if (opts.hints || opts.label || opts.limit) {
13391
13555
  this._depletionReplanAttempted = false;
13392
13556
  }
13557
+ const hasIntent = this._replanHasIntent(opts);
13393
13558
  if (this._replanPromise) {
13394
- this.log("Replan already in progress, awaiting existing replan");
13395
- return this._replanPromise;
13559
+ if (!hasIntent) {
13560
+ this.log("Replan already in progress, coalescing unhinted auto-replan");
13561
+ return this._replanPromise;
13562
+ }
13563
+ const labelTag = opts.label ? ` [${opts.label}]` : "";
13564
+ this.log(
13565
+ `Replan in progress; queueing hint-bearing replan${labelTag} behind in-flight run`
13566
+ );
13567
+ const inflight = this._replanPromise;
13568
+ const queued = inflight.catch(() => void 0).then(() => this._runReplan(opts));
13569
+ this._replanPromise = queued.finally(() => {
13570
+ if (this._replanPromise === queued) this._replanPromise = null;
13571
+ });
13572
+ return queued;
13396
13573
  }
13574
+ const run = this._runReplan(opts);
13575
+ this._replanPromise = run.finally(() => {
13576
+ if (this._replanPromise === run) this._replanPromise = null;
13577
+ });
13578
+ await run;
13579
+ }
13580
+ /**
13581
+ * True when a requestReplan call carries caller intent that must not be
13582
+ * silently dropped. Bare unhinted auto-replans (depletion / quality
13583
+ * triggers in nextCard) return false and may coalesce.
13584
+ */
13585
+ _replanHasIntent(opts) {
13586
+ if (opts.label) return true;
13587
+ if (opts.limit !== void 0) return true;
13588
+ if (opts.minFollowUpCards !== void 0) return true;
13589
+ if (opts.mode && opts.mode !== "replace") return true;
13590
+ if (opts.hints && Object.keys(opts.hints).length > 0) return true;
13591
+ return false;
13592
+ }
13593
+ /**
13594
+ * Body of a single replan: populate auto-excludes, stash hints on
13595
+ * sources, log, then run the pipeline. Extracted so it can be invoked
13596
+ * either immediately (no in-flight replan) or queued (chained after
13597
+ * the in-flight one resolves).
13598
+ *
13599
+ * IMPORTANT: hint stash and the queue-state snapshot used to build
13600
+ * excludeCards happen at *invocation* time, not at *queue* time. For a
13601
+ * queued replan that means excludes reflect the state after the prior
13602
+ * replan landed — which is what we want, since the prior replan's
13603
+ * newQ.peek(0) is the imminent draw we need to exclude.
13604
+ */
13605
+ async _runReplan(opts) {
13397
13606
  if (!opts.hints) opts.hints = {};
13398
13607
  const hints = opts.hints;
13399
13608
  const excludeSet = new Set(hints.excludeCards ?? []);
@@ -13403,6 +13612,9 @@ var SessionController = class _SessionController extends Loggable {
13403
13612
  for (const rec of this._sessionRecord) {
13404
13613
  excludeSet.add(rec.card.card_id);
13405
13614
  }
13615
+ if (this.newQ.length > 0) {
13616
+ excludeSet.add(this.newQ.peek(0).cardID);
13617
+ }
13406
13618
  hints.excludeCards = [...excludeSet];
13407
13619
  if (opts.hints) {
13408
13620
  const hintsWithLabel = opts.label ? { ...opts.hints, _label: opts.label } : opts.hints;
@@ -13418,12 +13630,7 @@ var SessionController = class _SessionController extends Loggable {
13418
13630
  this._minCardsGuarantee = Math.max(this._minCardsGuarantee, opts.minFollowUpCards);
13419
13631
  this.log(`[Replan] Card guarantee set to ${this._minCardsGuarantee}`);
13420
13632
  }
13421
- this._replanPromise = this._executeReplan(opts);
13422
- try {
13423
- await this._replanPromise;
13424
- } finally {
13425
- this._replanPromise = null;
13426
- }
13633
+ await this._executeReplan(opts);
13427
13634
  }
13428
13635
  /**
13429
13636
  * Normalise the requestReplan argument. Accepts either a ReplanOptions