@vue-skuilder/db 0.2.3 → 0.2.5

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.js CHANGED
@@ -1939,13 +1939,14 @@ var elo_exports = {};
1939
1939
  __export(elo_exports, {
1940
1940
  default: () => ELONavigator
1941
1941
  });
1942
- var import_common5, ELONavigator;
1942
+ var import_common5, ELO_RELEVANCE_SIGMA, ELONavigator;
1943
1943
  var init_elo = __esm({
1944
1944
  "src/core/navigators/generators/elo.ts"() {
1945
1945
  "use strict";
1946
1946
  init_navigators();
1947
1947
  import_common5 = require("@vue-skuilder/common");
1948
1948
  init_logger();
1949
+ ELO_RELEVANCE_SIGMA = 300;
1949
1950
  ELONavigator = class extends ContentNavigator {
1950
1951
  /** Human-readable name for CardGenerator interface */
1951
1952
  name;
@@ -1985,8 +1986,8 @@ var init_elo = __esm({
1985
1986
  const scored = newCards.map((c) => {
1986
1987
  const cardElo = c.elo ?? 1e3;
1987
1988
  const distance = Math.abs(cardElo - userGlobalElo);
1988
- const rawScore = Math.max(0, 1 - distance / 500);
1989
- const samplingKey = rawScore > 0 ? Math.random() ** (1 / rawScore) : 0;
1989
+ const relevance = Math.exp(-((distance / ELO_RELEVANCE_SIGMA) ** 2));
1990
+ const samplingKey = relevance * (0.5 + 0.5 * Math.random());
1990
1991
  return {
1991
1992
  cardId: c.cardID,
1992
1993
  courseId: c.courseID,
@@ -1998,7 +1999,7 @@ var init_elo = __esm({
1998
1999
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-ELO-default",
1999
2000
  action: "generated",
2000
2001
  score: samplingKey,
2001
- reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), raw ${rawScore.toFixed(3)}, key ${samplingKey.toFixed(3)}`
2002
+ reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), relevance ${relevance.toFixed(3)}, key ${samplingKey.toFixed(3)}`
2002
2003
  }
2003
2004
  ]
2004
2005
  };
@@ -13156,6 +13157,294 @@ mountMixerDebugger();
13156
13157
  // src/study/SessionDebugger.ts
13157
13158
  init_logger();
13158
13159
  init_PipelineDebugger();
13160
+
13161
+ // src/study/SessionOverlay.ts
13162
+ init_logger();
13163
+ var activeController = null;
13164
+ function registerActiveController(controller) {
13165
+ activeController = controller;
13166
+ }
13167
+ function getActiveController() {
13168
+ return activeController;
13169
+ }
13170
+ var OVERLAY_ID = "skuilder-session-overlay";
13171
+ var POLL_MS = 300;
13172
+ var INLINE_THRESHOLD = 5;
13173
+ var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
13174
+ var spinnerFrame = 0;
13175
+ var overlayEl = null;
13176
+ var pollHandle = null;
13177
+ var lastSnapshot = null;
13178
+ var copyFlashUntil = 0;
13179
+ var minified = false;
13180
+ var expanded = {
13181
+ reviewQ: false,
13182
+ newQ: false,
13183
+ failedQ: false,
13184
+ drawn: false
13185
+ };
13186
+ function toggleSessionOverlay() {
13187
+ if (typeof document === "undefined") {
13188
+ logger.info("[Session Overlay] No DOM available (non-browser host); overlay unavailable.");
13189
+ return;
13190
+ }
13191
+ if (overlayEl) {
13192
+ teardown();
13193
+ logger.info("[Session Overlay] Hidden.");
13194
+ } else {
13195
+ mount();
13196
+ logger.info("[Session Overlay] Shown. Toggle off with window.skuilder.session.dbgOverlay().");
13197
+ }
13198
+ }
13199
+ function mount() {
13200
+ minified = false;
13201
+ overlayEl = document.createElement("div");
13202
+ overlayEl.id = OVERLAY_ID;
13203
+ Object.assign(overlayEl.style, {
13204
+ position: "fixed",
13205
+ top: "8px",
13206
+ left: "8px",
13207
+ zIndex: "2147483647",
13208
+ maxWidth: "320px",
13209
+ maxHeight: "90vh",
13210
+ overflowY: "auto",
13211
+ padding: "8px 10px",
13212
+ background: "rgba(17, 24, 39, 0.92)",
13213
+ color: "#e5e7eb",
13214
+ font: "11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",
13215
+ borderRadius: "6px",
13216
+ boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
13217
+ pointerEvents: "auto",
13218
+ userSelect: "none"
13219
+ });
13220
+ document.body.appendChild(overlayEl);
13221
+ render();
13222
+ pollHandle = setInterval(render, POLL_MS);
13223
+ }
13224
+ function teardown() {
13225
+ if (pollHandle !== null) {
13226
+ clearInterval(pollHandle);
13227
+ pollHandle = null;
13228
+ }
13229
+ if (overlayEl?.parentNode) {
13230
+ overlayEl.parentNode.removeChild(overlayEl);
13231
+ }
13232
+ overlayEl = null;
13233
+ }
13234
+ function render() {
13235
+ if (!overlayEl) return;
13236
+ spinnerFrame++;
13237
+ const ctrl = getActiveController();
13238
+ if (!ctrl) {
13239
+ lastSnapshot = null;
13240
+ overlayEl.innerHTML = headerHtml() + (minified ? "" : `<div style="opacity:.65">No active session.</div>`);
13241
+ attachHandlers();
13242
+ return;
13243
+ }
13244
+ const s = ctrl.getDebugSnapshot();
13245
+ lastSnapshot = s;
13246
+ if (minified) {
13247
+ overlayEl.innerHTML = headerHtml();
13248
+ attachHandlers();
13249
+ return;
13250
+ }
13251
+ overlayEl.innerHTML = headerHtml() + replanHtml(s) + metaHtml(s) + hintsHtml(s.sessionHints) + queueHtml("reviewQ", "reviewQ", s.reviewQ) + queueHtml("newQ", "newQ", s.newQ) + queueHtml("failedQ", "failedQ", s.failedQ) + drawnHtml("drawn", s.drawnCards);
13252
+ attachHandlers();
13253
+ }
13254
+ function attachHandlers() {
13255
+ if (!overlayEl) return;
13256
+ overlayEl.querySelectorAll("[data-q]").forEach((el) => {
13257
+ el.onclick = () => {
13258
+ const key = el.dataset.q;
13259
+ if (!key) return;
13260
+ expanded[key] = !expanded[key];
13261
+ render();
13262
+ };
13263
+ });
13264
+ const copyBtn = overlayEl.querySelector("[data-copy]");
13265
+ if (copyBtn) {
13266
+ copyBtn.onclick = (ev) => {
13267
+ ev.stopPropagation();
13268
+ copySnapshot();
13269
+ };
13270
+ }
13271
+ const minBtn = overlayEl.querySelector("[data-min]");
13272
+ if (minBtn) {
13273
+ minBtn.onclick = (ev) => {
13274
+ ev.stopPropagation();
13275
+ minified = !minified;
13276
+ render();
13277
+ };
13278
+ }
13279
+ }
13280
+ function copySnapshot() {
13281
+ const text = snapshotToText(lastSnapshot);
13282
+ const flash = () => {
13283
+ copyFlashUntil = Date.now() + 1200;
13284
+ render();
13285
+ };
13286
+ const clip = typeof navigator !== "undefined" ? navigator.clipboard : void 0;
13287
+ if (clip?.writeText) {
13288
+ clip.writeText(text).then(flash, (err) => {
13289
+ logger.warn(`[Session Overlay] Clipboard write failed: ${String(err)}`);
13290
+ });
13291
+ } else {
13292
+ logger.info(`[Session Overlay] Clipboard unavailable; snapshot follows:
13293
+ ${text}`);
13294
+ }
13295
+ }
13296
+ function headerHtml() {
13297
+ const flashing = Date.now() < copyFlashUntil;
13298
+ const btnLabel = flashing ? "\u2713 copied" : "\u2398 copy";
13299
+ const btnColor = flashing ? "#86efac" : "#93c5fd";
13300
+ const copyBtn = `<span data-copy style="cursor:pointer;float:right;font-weight:400;color:${btnColor};border:1px solid currentColor;border-radius:4px;padding:0 4px;line-height:1.3">${btnLabel}</span>`;
13301
+ const minBtn = `<span data-min title="${minified ? "Restore" : "Minify"}" style="cursor:pointer;float:right;font-weight:400;color:#93c5fd;border:1px solid currentColor;border-radius:4px;padding:0 5px;margin-right:4px;line-height:1.3">${minified ? "\u25A2" : "\u2014"}</span>`;
13302
+ return `<div style="font-weight:600;color:#93c5fd;margin-bottom:${minified ? 0 : 4}px">${copyBtn}${minBtn}\u2699 SessionController</div>`;
13303
+ }
13304
+ function replanHtml(s) {
13305
+ if (!s.replanActive) {
13306
+ return `<div style="margin-bottom:6px;opacity:.45">\u25CB idle</div>`;
13307
+ }
13308
+ const frame = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length];
13309
+ const reason = esc(s.replanLabel ?? "(auto)");
13310
+ return `<div style="margin-bottom:6px;color:#fde047">${frame} replanning <span style="opacity:.85">[${reason}]</span></div>`;
13311
+ }
13312
+ function metaHtml(s) {
13313
+ const mmss = formatTime(s.secondsRemaining);
13314
+ const guarantee = s.hasCardGuarantee ? ` \xB7 <span style="color:#fbbf24">guarantee ${s.minCardsGuarantee}</span>` : "";
13315
+ const rows = [
13316
+ `time ${mmss}${guarantee}`,
13317
+ `well-indicated left: ${s.wellIndicatedRemaining}`,
13318
+ `current: ${s.currentCard ? esc(s.currentCard) : '<span style="opacity:.6">\u2014</span>'}`
13319
+ ];
13320
+ return `<div style="margin-bottom:6px">${rows.map((r) => `<div>${r}</div>`).join("")}</div>`;
13321
+ }
13322
+ function hintsHtml(h) {
13323
+ const parts = [];
13324
+ if (h) {
13325
+ if (h.boostTags && Object.keys(h.boostTags).length) {
13326
+ parts.push(
13327
+ `boost: ` + Object.entries(h.boostTags).map(([k, v]) => `${esc(k)}<span style="opacity:.6">\xD7${v}</span>`).join(", ")
13328
+ );
13329
+ }
13330
+ if (h.boostCards && Object.keys(h.boostCards).length) {
13331
+ parts.push(
13332
+ `boostCards: ` + Object.entries(h.boostCards).map(([k, v]) => `${esc(k)}<span style="opacity:.6">\xD7${v}</span>`).join(", ")
13333
+ );
13334
+ }
13335
+ if (h.requireCards?.length) parts.push(`require: ${h.requireCards.map(esc).join(", ")}`);
13336
+ if (h.requireTags?.length) parts.push(`requireTags: ${h.requireTags.map(esc).join(", ")}`);
13337
+ if (h.excludeTags?.length) parts.push(`exclude: ${h.excludeTags.map(esc).join(", ")}`);
13338
+ if (h.excludeCards?.length) parts.push(`excludeCards: ${h.excludeCards.map(esc).join(", ")}`);
13339
+ }
13340
+ const body = parts.length ? parts.map((p) => `<div style="margin-left:6px">${p}</div>`).join("") : `<div style="margin-left:6px;opacity:.6">none</div>`;
13341
+ return `<div style="margin-bottom:6px"><div style="color:#86efac">sessionHints</div>${body}</div>`;
13342
+ }
13343
+ function queueHtml(key, label, q) {
13344
+ const collapsible = q.length > INLINE_THRESHOLD;
13345
+ const isOpen = collapsible && expanded[key];
13346
+ const caret = collapsible ? expanded[key] ? "\u25BE " : "\u25B8 " : "";
13347
+ const drawn = q.dequeueCount ? ` <span style="opacity:.5">drawn ${q.dequeueCount}</span>` : "";
13348
+ const titleStyle = collapsible ? "cursor:pointer;color:#f9a8d4" : "color:#f9a8d4";
13349
+ const titleAttr = collapsible ? ` data-q="${key}"` : "";
13350
+ const title = `<div${titleAttr} style="${titleStyle}">${caret}${label}: ${q.length}${drawn}</div>`;
13351
+ if (!q.cards.length) {
13352
+ return title + `<div style="margin:1px 0 6px 6px;opacity:.5">empty</div>`;
13353
+ }
13354
+ const shown = isOpen ? q.cards : q.cards.slice(0, INLINE_THRESHOLD);
13355
+ const hiddenCount = q.length - shown.length;
13356
+ const listMarginBottom = collapsible ? 2 : 6;
13357
+ let body = `<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">` + shown.map((c) => `<li style="white-space:nowrap">${esc(c)}</li>`).join("") + `</ol>`;
13358
+ if (collapsible) {
13359
+ const footer = isOpen ? "\u25BE show less" : `\u2026 +${hiddenCount} more`;
13360
+ body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
13361
+ }
13362
+ return title + body;
13363
+ }
13364
+ function outcomeGlyph(correct) {
13365
+ if (correct === true) return `<span style="color:#86efac">\u2713</span>`;
13366
+ if (correct === false) return `<span style="color:#fca5a5">\u2717</span>`;
13367
+ return `<span style="opacity:.5">\xB7</span>`;
13368
+ }
13369
+ function drawnHtml(key, drawn) {
13370
+ const collapsible = drawn.length > INLINE_THRESHOLD;
13371
+ const isOpen = collapsible && expanded[key];
13372
+ const caret = collapsible ? expanded[key] ? "\u25BE " : "\u25B8 " : "";
13373
+ const titleStyle = collapsible ? "cursor:pointer;color:#c4b5fd" : "color:#c4b5fd";
13374
+ const titleAttr = collapsible ? ` data-q="${key}"` : "";
13375
+ const title = `<div${titleAttr} style="${titleStyle}">${caret}drawn: ${drawn.length}</div>`;
13376
+ if (!drawn.length) {
13377
+ return title + `<div style="margin:1px 0 6px 6px;opacity:.5">none yet</div>`;
13378
+ }
13379
+ const shown = isOpen ? drawn : drawn.slice(0, INLINE_THRESHOLD);
13380
+ const hiddenCount = drawn.length - shown.length;
13381
+ const listMarginBottom = collapsible ? 2 : 6;
13382
+ const rows = shown.map((d) => {
13383
+ const retries = d.attempts > 1 ? `<span style="opacity:.5"> \xD7${d.attempts}</span>` : "";
13384
+ const time = `<span style="opacity:.45"> ${Math.round(d.timeSpentMs / 100) / 10}s</span>`;
13385
+ return `<li style="white-space:nowrap">${outcomeGlyph(d.correct)} ${esc(d.cardID)}<span style="opacity:.5"> [${esc(d.status)}]</span>${retries}${time}</li>`;
13386
+ }).join("");
13387
+ let body = `<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">${rows}</ol>`;
13388
+ if (collapsible) {
13389
+ const footer = isOpen ? "\u25BE show less" : `\u2026 +${hiddenCount} more`;
13390
+ body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
13391
+ }
13392
+ return title + body;
13393
+ }
13394
+ function snapshotToText(s) {
13395
+ if (!s) return "SessionController \u2014 no active session.";
13396
+ const lines = [];
13397
+ lines.push("=== SessionController ===");
13398
+ lines.push(`time ${formatTime(s.secondsRemaining)}`);
13399
+ if (s.hasCardGuarantee) lines.push(`guarantee: ${s.minCardsGuarantee}`);
13400
+ lines.push(`well-indicated left: ${s.wellIndicatedRemaining}`);
13401
+ lines.push(`current: ${s.currentCard ?? "\u2014"}`);
13402
+ lines.push(
13403
+ s.replanActive ? `replan: ACTIVE [${s.replanLabel ?? "(auto)"}]` : "replan: idle"
13404
+ );
13405
+ lines.push("");
13406
+ lines.push("sessionHints:");
13407
+ const h = s.sessionHints;
13408
+ const hintParts = [];
13409
+ if (h) {
13410
+ if (h.boostTags && Object.keys(h.boostTags).length)
13411
+ hintParts.push(` boost: ${Object.entries(h.boostTags).map(([k, v]) => `${k}\xD7${v}`).join(", ")}`);
13412
+ if (h.boostCards && Object.keys(h.boostCards).length)
13413
+ hintParts.push(` boostCards: ${Object.entries(h.boostCards).map(([k, v]) => `${k}\xD7${v}`).join(", ")}`);
13414
+ if (h.requireCards?.length) hintParts.push(` require: ${h.requireCards.join(", ")}`);
13415
+ if (h.requireTags?.length) hintParts.push(` requireTags: ${h.requireTags.join(", ")}`);
13416
+ if (h.excludeTags?.length) hintParts.push(` exclude: ${h.excludeTags.join(", ")}`);
13417
+ if (h.excludeCards?.length) hintParts.push(` excludeCards: ${h.excludeCards.join(", ")}`);
13418
+ }
13419
+ lines.push(hintParts.length ? hintParts.join("\n") : " none");
13420
+ const queueText = (label, q) => {
13421
+ lines.push("");
13422
+ lines.push(`${label}: ${q.length} (drawn ${q.dequeueCount})`);
13423
+ q.cards.forEach((c, i) => lines.push(` ${i + 1}. ${c}`));
13424
+ };
13425
+ queueText("reviewQ", s.reviewQ);
13426
+ queueText("newQ", s.newQ);
13427
+ queueText("failedQ", s.failedQ);
13428
+ lines.push("");
13429
+ lines.push(`drawn: ${s.drawnCards.length}`);
13430
+ s.drawnCards.forEach((d, i) => {
13431
+ const mark = d.correct === true ? "\u2713" : d.correct === false ? "\u2717" : "\xB7";
13432
+ const time = `${Math.round(d.timeSpentMs / 100) / 10}s`;
13433
+ lines.push(` ${i + 1}. ${mark} ${d.cardID} [${d.status}] \xD7${d.attempts} ${time}`);
13434
+ });
13435
+ return lines.join("\n");
13436
+ }
13437
+ function formatTime(totalSeconds) {
13438
+ const s = Math.max(0, Math.round(totalSeconds));
13439
+ const m = Math.floor(s / 60);
13440
+ const r = s % 60;
13441
+ return `${m}:${r.toString().padStart(2, "0")}`;
13442
+ }
13443
+ function esc(value) {
13444
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
13445
+ }
13446
+
13447
+ // src/study/SessionDebugger.ts
13159
13448
  var activeSession = null;
13160
13449
  var sessionHistory = [];
13161
13450
  var MAX_HISTORY = 5;
@@ -13336,6 +13625,13 @@ var sessionDebugAPI = {
13336
13625
  showQueue() {
13337
13626
  showCurrentQueue();
13338
13627
  },
13628
+ /**
13629
+ * Toggle the pinned, live-updating DOM overlay for the active controller
13630
+ * (queues, session hints, timer). No-ops in non-browser hosts.
13631
+ */
13632
+ dbgOverlay() {
13633
+ toggleSessionOverlay();
13634
+ },
13339
13635
  /**
13340
13636
  * Show presentation history for current or past session.
13341
13637
  */
@@ -13397,6 +13693,7 @@ var sessionDebugAPI = {
13397
13693
  \u{1F3AF} Session Debug API
13398
13694
 
13399
13695
  Commands:
13696
+ .dbgOverlay() Toggle the pinned live overlay (queues, hints, timer)
13400
13697
  .showQueue() Show current queue state (active session only)
13401
13698
  .showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
13402
13699
  .showInterleaving(index?) Analyze course interleaving pattern
@@ -13465,6 +13762,13 @@ var SessionController = class _SessionController extends Loggable {
13465
13762
  * Used by nextCard() to await completion before drawing from queues.
13466
13763
  */
13467
13764
  _replanPromise = null;
13765
+ /**
13766
+ * Reason for the replan currently executing in `_runReplan`, surfaced by the
13767
+ * debug overlay's spinner. The caller's `opts.label` when present, else
13768
+ * `'(auto)'`. Only meaningful while `_replanPromise` is non-null; cleared
13769
+ * when the in-flight chain settles.
13770
+ */
13771
+ _activeReplanLabel = null;
13468
13772
  /**
13469
13773
  * Number of well-indicated new cards remaining before the queue
13470
13774
  * degrades to poorly-indicated content. Decremented on each newQ
@@ -13499,6 +13803,21 @@ var SessionController = class _SessionController extends Loggable {
13499
13803
  * recomputed per-run in `_runReplan` and would otherwise go stale.
13500
13804
  */
13501
13805
  _sessionHints = null;
13806
+ /**
13807
+ * Card IDs that have been *served* (drawn/consumed) this session. Populated
13808
+ * at the single consumption choke-point (removeItemFromQueue), so it reflects
13809
+ * a draw the instant it happens — earlier than `_sessionRecord`, which only
13810
+ * lands once the card is *responded to*.
13811
+ *
13812
+ * Used to keep already-served cards out of newQ on every (re)plan: a `new`
13813
+ * card shown once must never re-enter newQ this session. This is the general
13814
+ * guard against re-presentation — including the case where a replan in flight
13815
+ * captured a now-drawn card (e.g. a +INF require-injected follow-up the
13816
+ * depletion prefetch grabbed just before it was drawn). Reviews/failed cards
13817
+ * legitimately recur and are tracked by their own queues, so this only gates
13818
+ * `new`-origin candidates.
13819
+ */
13820
+ _servedCardIds = /* @__PURE__ */ new Set();
13502
13821
  /**
13503
13822
  * Consumer-supplied hooks invoked after each question response is processed.
13504
13823
  * Seeded from constructor options (threaded from
@@ -13577,6 +13896,7 @@ var SessionController = class _SessionController extends Loggable {
13577
13896
  endTime: ${this.endTime}
13578
13897
  defaultBatchLimit: ${this._defaultBatchLimit}
13579
13898
  initialReviewCap: ${this._initialReviewCap}`);
13899
+ registerActiveController(this);
13580
13900
  }
13581
13901
  tick() {
13582
13902
  this._secondsRemaining = Math.floor((this.endTime.valueOf() - Date.now()) / 1e3);
@@ -13677,15 +13997,23 @@ var SessionController = class _SessionController extends Loggable {
13677
13997
  );
13678
13998
  const inflight = this._replanPromise;
13679
13999
  const queued = inflight.catch(() => void 0).then(() => this._runReplan(opts));
13680
- this._replanPromise = queued.finally(() => {
13681
- if (this._replanPromise === queued) this._replanPromise = null;
14000
+ const tracked2 = queued.finally(() => {
14001
+ if (this._replanPromise === tracked2) {
14002
+ this._replanPromise = null;
14003
+ this._activeReplanLabel = null;
14004
+ }
13682
14005
  });
14006
+ this._replanPromise = tracked2;
13683
14007
  return queued;
13684
14008
  }
13685
14009
  const run = this._runReplan(opts);
13686
- this._replanPromise = run.finally(() => {
13687
- if (this._replanPromise === run) this._replanPromise = null;
14010
+ const tracked = run.finally(() => {
14011
+ if (this._replanPromise === tracked) {
14012
+ this._replanPromise = null;
14013
+ this._activeReplanLabel = null;
14014
+ }
13688
14015
  });
14016
+ this._replanPromise = tracked;
13689
14017
  await run;
13690
14018
  }
13691
14019
  /**
@@ -13694,12 +14022,12 @@ var SessionController = class _SessionController extends Loggable {
13694
14022
  * triggers in nextCard) return false and may coalesce.
13695
14023
  */
13696
14024
  _replanHasIntent(opts) {
13697
- if (opts.label) return true;
13698
14025
  if (opts.limit !== void 0) return true;
13699
14026
  if (opts.minFollowUpCards !== void 0) return true;
13700
14027
  if (opts.mode && opts.mode !== "replace") return true;
13701
14028
  if (opts.hints && Object.keys(opts.hints).length > 0) return true;
13702
14029
  if (opts.sessionHints !== void 0) return true;
14030
+ if (opts.mergeSessionHints !== void 0) return true;
13703
14031
  return false;
13704
14032
  }
13705
14033
  /**
@@ -13715,6 +14043,7 @@ var SessionController = class _SessionController extends Loggable {
13715
14043
  * newQ.peek(0) is the imminent draw we need to exclude.
13716
14044
  */
13717
14045
  async _runReplan(opts) {
14046
+ this._activeReplanLabel = opts.label ?? "(auto)";
13718
14047
  if (!opts.hints) opts.hints = {};
13719
14048
  const hints = opts.hints;
13720
14049
  const excludeSet = new Set(hints.excludeCards ?? []);
@@ -13734,6 +14063,10 @@ var SessionController = class _SessionController extends Loggable {
13734
14063
  `[Replan] Session hints ${opts.sessionHints ? "set" : "cleared"}: ${JSON.stringify(opts.sessionHints)}`
13735
14064
  );
13736
14065
  }
14066
+ if (opts.mergeSessionHints !== void 0) {
14067
+ this._sessionHints = mergeHints2([this._sessionHints, opts.mergeSessionHints]) ?? null;
14068
+ this.log(`[Replan] Session hints merged: ${JSON.stringify(this._sessionHints)}`);
14069
+ }
13737
14070
  this._applyHintsToSources(opts.hints, opts.label);
13738
14071
  const labelTag = opts.label ? ` [${opts.label}]` : "";
13739
14072
  this.log(
@@ -13772,6 +14105,45 @@ var SessionController = class _SessionController extends Loggable {
13772
14105
  getSessionHints() {
13773
14106
  return this._sessionHints;
13774
14107
  }
14108
+ /**
14109
+ * Live state snapshot for the debug overlay (window.skuilder.session
14110
+ * .dbgOverlay()). Reads directly from the private queues and hints, so it
14111
+ * always reflects the current moment — unlike the passive SessionDebugger
14112
+ * snapshots, which only capture what was explicitly pushed to them.
14113
+ */
14114
+ getDebugSnapshot() {
14115
+ const describe = (q) => {
14116
+ const cards = [];
14117
+ for (let i = 0; i < q.length; i++) {
14118
+ cards.push(q.peek(i).cardID);
14119
+ }
14120
+ return { length: q.length, dequeueCount: q.dequeueCount, cards };
14121
+ };
14122
+ const drawnCards = this._sessionRecord.map((r) => {
14123
+ const last = r.records[r.records.length - 1];
14124
+ return {
14125
+ cardID: r.item.cardID,
14126
+ status: r.item.status,
14127
+ attempts: r.records.length,
14128
+ correct: last && isQuestionRecord(last) ? last.isCorrect : null,
14129
+ timeSpentMs: r.records.reduce((sum, rec) => sum + rec.timeSpent, 0)
14130
+ };
14131
+ });
14132
+ return {
14133
+ secondsRemaining: this.secondsRemaining,
14134
+ hasCardGuarantee: this.hasCardGuarantee,
14135
+ minCardsGuarantee: this._minCardsGuarantee,
14136
+ wellIndicatedRemaining: this._wellIndicatedRemaining,
14137
+ currentCard: this._currentCard?.item.cardID ?? null,
14138
+ sessionHints: this._sessionHints,
14139
+ replanActive: this._replanPromise !== null,
14140
+ replanLabel: this._activeReplanLabel,
14141
+ reviewQ: describe(this.reviewQ),
14142
+ newQ: describe(this.newQ),
14143
+ failedQ: describe(this.failedQ),
14144
+ drawnCards
14145
+ };
14146
+ }
13775
14147
  /**
13776
14148
  * Merge `hints` into the durable session hints via the pipeline's
13777
14149
  * `mergeHints` (boosts multiply, require/exclude lists concat-dedup).
@@ -13853,9 +14225,13 @@ var SessionController = class _SessionController extends Loggable {
13853
14225
  */
13854
14226
  async _replanUncoalesced(opts) {
13855
14227
  const run = this._runReplan(opts);
13856
- this._replanPromise = run.finally(() => {
13857
- if (this._replanPromise === run) this._replanPromise = null;
14228
+ const tracked = run.finally(() => {
14229
+ if (this._replanPromise === tracked) {
14230
+ this._replanPromise = null;
14231
+ this._activeReplanLabel = null;
14232
+ }
13858
14233
  });
14234
+ this._replanPromise = tracked;
13859
14235
  await run;
13860
14236
  }
13861
14237
  /**
@@ -14074,7 +14450,7 @@ var SessionController = class _SessionController extends Loggable {
14074
14450
  mixedWeighted
14075
14451
  );
14076
14452
  const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review").slice(0, this._initialReviewCap);
14077
- const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new").slice(0, newLimit);
14453
+ const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new" && !this._servedCardIds.has(w.cardId)).slice(0, newLimit);
14078
14454
  logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
14079
14455
  let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
14080
14456
  if (!replan) {
@@ -14211,14 +14587,14 @@ var SessionController = class _SessionController extends Loggable {
14211
14587
  this.log(
14212
14588
  `[AutoReplan:depletion] newQ has ${this.newQ.length} card(s) (${otherContent} in other queues) with ${this._secondsRemaining}s remaining. Triggering background replan.`
14213
14589
  );
14214
- void this.requestReplan();
14590
+ void this.requestReplan({ label: "auto:depletion", mode: "merge" });
14215
14591
  }
14216
14592
  const REPLAN_BUFFER = 3;
14217
14593
  if (!this._suppressQualityReplan && this._wellIndicatedRemaining <= REPLAN_BUFFER && this.newQ.length > 0 && !this._replanPromise) {
14218
14594
  this.log(
14219
14595
  `[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining (newQ: ${this.newQ.length}). Triggering background replan.`
14220
14596
  );
14221
- void this.requestReplan();
14597
+ void this.requestReplan({ label: "auto:quality" });
14222
14598
  }
14223
14599
  if (this._secondsRemaining <= 0 && this.failedQ.length === 0 && this._minCardsGuarantee <= 0) {
14224
14600
  this._currentCard = null;
@@ -14352,6 +14728,8 @@ var SessionController = class _SessionController extends Loggable {
14352
14728
  * Remove an item from its source queue after consumption by nextCard().
14353
14729
  */
14354
14730
  removeItemFromQueue(item) {
14731
+ this._clearDurableRequirement(item.cardID);
14732
+ this._servedCardIds.add(item.cardID);
14355
14733
  if (this.reviewQ.peek(0)?.cardID === item.cardID) {
14356
14734
  this.reviewQ.dequeue((queueItem) => queueItem.cardID);
14357
14735
  } else if (this.newQ.peek(0)?.cardID === item.cardID) {
@@ -14363,6 +14741,27 @@ var SessionController = class _SessionController extends Loggable {
14363
14741
  this.failedQ.dequeue((queueItem) => queueItem.cardID);
14364
14742
  }
14365
14743
  }
14744
+ /**
14745
+ * Remove a satisfied card ID from the durable session-hint `requireCards`
14746
+ * list. Called when a card is consumed (see removeItemFromQueue). No-op if
14747
+ * the card was not a durable requirement.
14748
+ *
14749
+ * Matches literal IDs only: a glob/pattern requirement (which may stand for
14750
+ * several cards) is NOT considered satisfied by a single draw and is left in
14751
+ * place — durable patterns are the caller's responsibility, one-shot `hints`
14752
+ * remain the right tool for them.
14753
+ */
14754
+ _clearDurableRequirement(cardID) {
14755
+ const req = this._sessionHints?.requireCards;
14756
+ if (!req || req.length === 0) return;
14757
+ const next = req.filter((id) => id !== cardID);
14758
+ if (next.length === req.length) return;
14759
+ this._sessionHints = {
14760
+ ...this._sessionHints,
14761
+ requireCards: next.length > 0 ? next : void 0
14762
+ };
14763
+ this.log(`[Replan] Durable requirement satisfied & cleared on draw: ${cardID}`);
14764
+ }
14366
14765
  /**
14367
14766
  * End the session and record learning outcomes.
14368
14767
  *