@vue-skuilder/db 0.2.4 → 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.d.cts CHANGED
@@ -315,6 +315,21 @@ interface SessionQueueDebug {
315
315
  /** cardIDs in queue order, head (next draw) first. */
316
316
  cards: string[];
317
317
  }
318
+ /**
319
+ * A card the learner has interacted with this session (one entry per card in
320
+ * the session record, regardless of which queue — if any — still holds it).
321
+ */
322
+ interface SessionDrawnCardDebug {
323
+ cardID: string;
324
+ /** Queue status at draw time: 'new' | 'review' | 'failed-new' | 'failed-review'. */
325
+ status: string;
326
+ /** Number of CardRecords logged for this card this session (≥1). */
327
+ attempts: number;
328
+ /** Latest record's correctness; null for non-question (info) records. */
329
+ correct: boolean | null;
330
+ /** Total time spent across all of this card's records, in ms. */
331
+ timeSpentMs: number;
332
+ }
318
333
  /** Live snapshot of the controller, read fresh on each overlay tick. */
319
334
  interface SessionDebugSnapshot {
320
335
  secondsRemaining: number;
@@ -332,6 +347,8 @@ interface SessionDebugSnapshot {
332
347
  reviewQ: SessionQueueDebug;
333
348
  newQ: SessionQueueDebug;
334
349
  failedQ: SessionQueueDebug;
350
+ /** Every card the learner has interacted with this session, draw order. */
351
+ drawnCards: SessionDrawnCardDebug[];
335
352
  }
336
353
 
337
354
  /**
@@ -366,6 +383,21 @@ interface ReplanOptions {
366
383
  * multiply, require/exclude lists concatenate).
367
384
  */
368
385
  sessionHints?: ReplanHints;
386
+ /**
387
+ * Like `sessionHints`, but *merged* into the existing session-durable hints
388
+ * (via `mergeHints`) instead of replacing them. Use when emphasis should
389
+ * *accumulate* across replans rather than clobber — e.g. introducing a second
390
+ * concept mid-session must not wipe the first concept's boost, nor any
391
+ * `difficultyBooster`/`conceptBackoff` state on other concepts.
392
+ *
393
+ * Merge semantics (see `mergeHints`): boosts MULTIPLY, require/exclude lists
394
+ * concat-dedup. Re-emphasising the *same* tag therefore compounds — callers
395
+ * boosting a tag they may have already boosted should clamp at the call site.
396
+ *
397
+ * If both `sessionHints` and `mergeSessionHints` are supplied, the replace is
398
+ * applied first, then the merge — but they are normally mutually exclusive.
399
+ */
400
+ mergeSessionHints?: ReplanHints;
369
401
  /**
370
402
  * Maximum number of new cards to return from the pipeline.
371
403
  * Default: 20 (the standard session batch size).
@@ -549,6 +581,21 @@ declare class SessionController<TView = unknown> extends Loggable {
549
581
  * recomputed per-run in `_runReplan` and would otherwise go stale.
550
582
  */
551
583
  private _sessionHints;
584
+ /**
585
+ * Card IDs that have been *served* (drawn/consumed) this session. Populated
586
+ * at the single consumption choke-point (removeItemFromQueue), so it reflects
587
+ * a draw the instant it happens — earlier than `_sessionRecord`, which only
588
+ * lands once the card is *responded to*.
589
+ *
590
+ * Used to keep already-served cards out of newQ on every (re)plan: a `new`
591
+ * card shown once must never re-enter newQ this session. This is the general
592
+ * guard against re-presentation — including the case where a replan in flight
593
+ * captured a now-drawn card (e.g. a +INF require-injected follow-up the
594
+ * depletion prefetch grabbed just before it was drawn). Reviews/failed cards
595
+ * legitimately recur and are tracked by their own queues, so this only gates
596
+ * `new`-origin candidates.
597
+ */
598
+ private _servedCardIds;
552
599
  /**
553
600
  * Consumer-supplied hooks invoked after each question response is processed.
554
601
  * Seeded from constructor options (threaded from
@@ -863,6 +910,17 @@ declare class SessionController<TView = unknown> extends Loggable {
863
910
  * Remove an item from its source queue after consumption by nextCard().
864
911
  */
865
912
  private removeItemFromQueue;
913
+ /**
914
+ * Remove a satisfied card ID from the durable session-hint `requireCards`
915
+ * list. Called when a card is consumed (see removeItemFromQueue). No-op if
916
+ * the card was not a durable requirement.
917
+ *
918
+ * Matches literal IDs only: a glob/pattern requirement (which may stand for
919
+ * several cards) is NOT considered satisfied by a single draw and is left in
920
+ * place — durable patterns are the caller's responsibility, one-shot `hints`
921
+ * remain the right tool for them.
922
+ */
923
+ private _clearDurableRequirement;
866
924
  /**
867
925
  * End the session and record learning outcomes.
868
926
  *
package/dist/index.d.ts CHANGED
@@ -315,6 +315,21 @@ interface SessionQueueDebug {
315
315
  /** cardIDs in queue order, head (next draw) first. */
316
316
  cards: string[];
317
317
  }
318
+ /**
319
+ * A card the learner has interacted with this session (one entry per card in
320
+ * the session record, regardless of which queue — if any — still holds it).
321
+ */
322
+ interface SessionDrawnCardDebug {
323
+ cardID: string;
324
+ /** Queue status at draw time: 'new' | 'review' | 'failed-new' | 'failed-review'. */
325
+ status: string;
326
+ /** Number of CardRecords logged for this card this session (≥1). */
327
+ attempts: number;
328
+ /** Latest record's correctness; null for non-question (info) records. */
329
+ correct: boolean | null;
330
+ /** Total time spent across all of this card's records, in ms. */
331
+ timeSpentMs: number;
332
+ }
318
333
  /** Live snapshot of the controller, read fresh on each overlay tick. */
319
334
  interface SessionDebugSnapshot {
320
335
  secondsRemaining: number;
@@ -332,6 +347,8 @@ interface SessionDebugSnapshot {
332
347
  reviewQ: SessionQueueDebug;
333
348
  newQ: SessionQueueDebug;
334
349
  failedQ: SessionQueueDebug;
350
+ /** Every card the learner has interacted with this session, draw order. */
351
+ drawnCards: SessionDrawnCardDebug[];
335
352
  }
336
353
 
337
354
  /**
@@ -366,6 +383,21 @@ interface ReplanOptions {
366
383
  * multiply, require/exclude lists concatenate).
367
384
  */
368
385
  sessionHints?: ReplanHints;
386
+ /**
387
+ * Like `sessionHints`, but *merged* into the existing session-durable hints
388
+ * (via `mergeHints`) instead of replacing them. Use when emphasis should
389
+ * *accumulate* across replans rather than clobber — e.g. introducing a second
390
+ * concept mid-session must not wipe the first concept's boost, nor any
391
+ * `difficultyBooster`/`conceptBackoff` state on other concepts.
392
+ *
393
+ * Merge semantics (see `mergeHints`): boosts MULTIPLY, require/exclude lists
394
+ * concat-dedup. Re-emphasising the *same* tag therefore compounds — callers
395
+ * boosting a tag they may have already boosted should clamp at the call site.
396
+ *
397
+ * If both `sessionHints` and `mergeSessionHints` are supplied, the replace is
398
+ * applied first, then the merge — but they are normally mutually exclusive.
399
+ */
400
+ mergeSessionHints?: ReplanHints;
369
401
  /**
370
402
  * Maximum number of new cards to return from the pipeline.
371
403
  * Default: 20 (the standard session batch size).
@@ -549,6 +581,21 @@ declare class SessionController<TView = unknown> extends Loggable {
549
581
  * recomputed per-run in `_runReplan` and would otherwise go stale.
550
582
  */
551
583
  private _sessionHints;
584
+ /**
585
+ * Card IDs that have been *served* (drawn/consumed) this session. Populated
586
+ * at the single consumption choke-point (removeItemFromQueue), so it reflects
587
+ * a draw the instant it happens — earlier than `_sessionRecord`, which only
588
+ * lands once the card is *responded to*.
589
+ *
590
+ * Used to keep already-served cards out of newQ on every (re)plan: a `new`
591
+ * card shown once must never re-enter newQ this session. This is the general
592
+ * guard against re-presentation — including the case where a replan in flight
593
+ * captured a now-drawn card (e.g. a +INF require-injected follow-up the
594
+ * depletion prefetch grabbed just before it was drawn). Reviews/failed cards
595
+ * legitimately recur and are tracked by their own queues, so this only gates
596
+ * `new`-origin candidates.
597
+ */
598
+ private _servedCardIds;
552
599
  /**
553
600
  * Consumer-supplied hooks invoked after each question response is processed.
554
601
  * Seeded from constructor options (threaded from
@@ -863,6 +910,17 @@ declare class SessionController<TView = unknown> extends Loggable {
863
910
  * Remove an item from its source queue after consumption by nextCard().
864
911
  */
865
912
  private removeItemFromQueue;
913
+ /**
914
+ * Remove a satisfied card ID from the durable session-hint `requireCards`
915
+ * list. Called when a card is consumed (see removeItemFromQueue). No-op if
916
+ * the card was not a durable requirement.
917
+ *
918
+ * Matches literal IDs only: a glob/pattern requirement (which may stand for
919
+ * several cards) is NOT considered satisfied by a single draw and is left in
920
+ * place — durable patterns are the caller's responsibility, one-shot `hints`
921
+ * remain the right tool for them.
922
+ */
923
+ private _clearDurableRequirement;
866
924
  /**
867
925
  * End the session and record learning outcomes.
868
926
  *
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
  };
@@ -13173,7 +13174,15 @@ var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834"
13173
13174
  var spinnerFrame = 0;
13174
13175
  var overlayEl = null;
13175
13176
  var pollHandle = null;
13176
- var expanded = { reviewQ: false, newQ: false, failedQ: false };
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
+ };
13177
13186
  function toggleSessionOverlay() {
13178
13187
  if (typeof document === "undefined") {
13179
13188
  logger.info("[Session Overlay] No DOM available (non-browser host); overlay unavailable.");
@@ -13188,6 +13197,7 @@ function toggleSessionOverlay() {
13188
13197
  }
13189
13198
  }
13190
13199
  function mount() {
13200
+ minified = false;
13191
13201
  overlayEl = document.createElement("div");
13192
13202
  overlayEl.id = OVERLAY_ID;
13193
13203
  Object.assign(overlayEl.style, {
@@ -13226,11 +13236,23 @@ function render() {
13226
13236
  spinnerFrame++;
13227
13237
  const ctrl = getActiveController();
13228
13238
  if (!ctrl) {
13229
- overlayEl.innerHTML = headerHtml() + `<div style="opacity:.65">No active session.</div>`;
13239
+ lastSnapshot = null;
13240
+ overlayEl.innerHTML = headerHtml() + (minified ? "" : `<div style="opacity:.65">No active session.</div>`);
13241
+ attachHandlers();
13230
13242
  return;
13231
13243
  }
13232
13244
  const s = ctrl.getDebugSnapshot();
13233
- 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);
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;
13234
13256
  overlayEl.querySelectorAll("[data-q]").forEach((el) => {
13235
13257
  el.onclick = () => {
13236
13258
  const key = el.dataset.q;
@@ -13239,9 +13261,45 @@ function render() {
13239
13261
  render();
13240
13262
  };
13241
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
+ }
13242
13295
  }
13243
13296
  function headerHtml() {
13244
- return `<div style="font-weight:600;color:#93c5fd;margin-bottom:4px">\u2699 SessionController</div>`;
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>`;
13245
13303
  }
13246
13304
  function replanHtml(s) {
13247
13305
  if (!s.replanActive) {
@@ -13284,22 +13342,98 @@ function hintsHtml(h) {
13284
13342
  }
13285
13343
  function queueHtml(key, label, q) {
13286
13344
  const collapsible = q.length > INLINE_THRESHOLD;
13287
- const isOpen = !collapsible || expanded[key];
13345
+ const isOpen = collapsible && expanded[key];
13288
13346
  const caret = collapsible ? expanded[key] ? "\u25BE " : "\u25B8 " : "";
13289
13347
  const drawn = q.dequeueCount ? ` <span style="opacity:.5">drawn ${q.dequeueCount}</span>` : "";
13290
13348
  const titleStyle = collapsible ? "cursor:pointer;color:#f9a8d4" : "color:#f9a8d4";
13291
13349
  const titleAttr = collapsible ? ` data-q="${key}"` : "";
13292
13350
  const title = `<div${titleAttr} style="${titleStyle}">${caret}${label}: ${q.length}${drawn}</div>`;
13293
- let body = "";
13294
- if (isOpen && q.cards.length) {
13295
- body = `<ol style="margin:2px 0 6px 0;padding-left:20px">` + q.cards.map((c) => `<li style="white-space:nowrap">${esc(c)}</li>`).join("") + `</ol>`;
13296
- } else if (!q.cards.length) {
13297
- body = `<div style="margin:1px 0 6px 6px;opacity:.5">empty</div>`;
13298
- } else {
13299
- body = `<div style="margin:1px 0 6px 6px;opacity:.55">(${q.length} cards \u2014 click to expand)</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>`;
13300
13391
  }
13301
13392
  return title + body;
13302
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
+ }
13303
13437
  function formatTime(totalSeconds) {
13304
13438
  const s = Math.max(0, Math.round(totalSeconds));
13305
13439
  const m = Math.floor(s / 60);
@@ -13669,6 +13803,21 @@ var SessionController = class _SessionController extends Loggable {
13669
13803
  * recomputed per-run in `_runReplan` and would otherwise go stale.
13670
13804
  */
13671
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();
13672
13821
  /**
13673
13822
  * Consumer-supplied hooks invoked after each question response is processed.
13674
13823
  * Seeded from constructor options (threaded from
@@ -13878,6 +14027,7 @@ var SessionController = class _SessionController extends Loggable {
13878
14027
  if (opts.mode && opts.mode !== "replace") return true;
13879
14028
  if (opts.hints && Object.keys(opts.hints).length > 0) return true;
13880
14029
  if (opts.sessionHints !== void 0) return true;
14030
+ if (opts.mergeSessionHints !== void 0) return true;
13881
14031
  return false;
13882
14032
  }
13883
14033
  /**
@@ -13913,6 +14063,10 @@ var SessionController = class _SessionController extends Loggable {
13913
14063
  `[Replan] Session hints ${opts.sessionHints ? "set" : "cleared"}: ${JSON.stringify(opts.sessionHints)}`
13914
14064
  );
13915
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
+ }
13916
14070
  this._applyHintsToSources(opts.hints, opts.label);
13917
14071
  const labelTag = opts.label ? ` [${opts.label}]` : "";
13918
14072
  this.log(
@@ -13965,6 +14119,16 @@ var SessionController = class _SessionController extends Loggable {
13965
14119
  }
13966
14120
  return { length: q.length, dequeueCount: q.dequeueCount, cards };
13967
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
+ });
13968
14132
  return {
13969
14133
  secondsRemaining: this.secondsRemaining,
13970
14134
  hasCardGuarantee: this.hasCardGuarantee,
@@ -13976,7 +14140,8 @@ var SessionController = class _SessionController extends Loggable {
13976
14140
  replanLabel: this._activeReplanLabel,
13977
14141
  reviewQ: describe(this.reviewQ),
13978
14142
  newQ: describe(this.newQ),
13979
- failedQ: describe(this.failedQ)
14143
+ failedQ: describe(this.failedQ),
14144
+ drawnCards
13980
14145
  };
13981
14146
  }
13982
14147
  /**
@@ -14285,7 +14450,7 @@ var SessionController = class _SessionController extends Loggable {
14285
14450
  mixedWeighted
14286
14451
  );
14287
14452
  const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review").slice(0, this._initialReviewCap);
14288
- 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);
14289
14454
  logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
14290
14455
  let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
14291
14456
  if (!replan) {
@@ -14422,7 +14587,7 @@ var SessionController = class _SessionController extends Loggable {
14422
14587
  this.log(
14423
14588
  `[AutoReplan:depletion] newQ has ${this.newQ.length} card(s) (${otherContent} in other queues) with ${this._secondsRemaining}s remaining. Triggering background replan.`
14424
14589
  );
14425
- void this.requestReplan({ label: "auto:depletion" });
14590
+ void this.requestReplan({ label: "auto:depletion", mode: "merge" });
14426
14591
  }
14427
14592
  const REPLAN_BUFFER = 3;
14428
14593
  if (!this._suppressQualityReplan && this._wellIndicatedRemaining <= REPLAN_BUFFER && this.newQ.length > 0 && !this._replanPromise) {
@@ -14563,6 +14728,8 @@ var SessionController = class _SessionController extends Loggable {
14563
14728
  * Remove an item from its source queue after consumption by nextCard().
14564
14729
  */
14565
14730
  removeItemFromQueue(item) {
14731
+ this._clearDurableRequirement(item.cardID);
14732
+ this._servedCardIds.add(item.cardID);
14566
14733
  if (this.reviewQ.peek(0)?.cardID === item.cardID) {
14567
14734
  this.reviewQ.dequeue((queueItem) => queueItem.cardID);
14568
14735
  } else if (this.newQ.peek(0)?.cardID === item.cardID) {
@@ -14574,6 +14741,27 @@ var SessionController = class _SessionController extends Loggable {
14574
14741
  this.failedQ.dequeue((queueItem) => queueItem.cardID);
14575
14742
  }
14576
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
+ }
14577
14765
  /**
14578
14766
  * End the session and record learning outcomes.
14579
14767
  *