@vue-skuilder/db 0.2.2 → 0.2.4

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.
Files changed (41) hide show
  1. package/dist/{contentSource-Ht3N2f-y.d.ts → contentSource-Cplhv3bJ.d.ts} +1 -1
  2. package/dist/{contentSource-BMlMwSiG.d.cts → contentSource-kI9_jwTu.d.cts} +1 -1
  3. package/dist/core/index.d.cts +5 -5
  4. package/dist/core/index.d.ts +5 -5
  5. package/dist/core/index.js +2 -1
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +2 -1
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-BEqB8VBR.d.cts → dataLayerProvider-CiA2Rr0v.d.cts} +1 -1
  10. package/dist/{dataLayerProvider-DObSXjnf.d.ts → dataLayerProvider-DrBqOUa3.d.ts} +1 -1
  11. package/dist/impl/couch/index.d.cts +3 -3
  12. package/dist/impl/couch/index.d.ts +3 -3
  13. package/dist/impl/couch/index.js +2 -1
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +2 -1
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +4 -4
  18. package/dist/impl/static/index.d.ts +4 -4
  19. package/dist/impl/static/index.js +2 -1
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +2 -1
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/{index-BWvO-_rJ.d.ts → index-BLLT5BYE.d.ts} +1 -1
  24. package/dist/{index-Ba7hYbHj.d.cts → index-k9NFHpS1.d.cts} +1 -1
  25. package/dist/index.d.cts +209 -10
  26. package/dist/index.d.ts +209 -10
  27. package/dist/index.js +361 -17
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +361 -17
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/{types-W8n-B6HG.d.cts → types-BFUa1pa3.d.cts} +1 -1
  32. package/dist/{types-CJrLM1Ew.d.ts → types-CHgpWQAY.d.ts} +1 -1
  33. package/dist/{types-legacy-JXDxinpU.d.cts → types-legacy-4tlwHnXo.d.cts} +1 -1
  34. package/dist/{types-legacy-JXDxinpU.d.ts → types-legacy-4tlwHnXo.d.ts} +1 -1
  35. package/dist/util/packer/index.d.cts +3 -3
  36. package/dist/util/packer/index.d.ts +3 -3
  37. package/package.json +3 -3
  38. package/src/core/navigators/Pipeline.ts +1 -1
  39. package/src/study/SessionController.ts +347 -22
  40. package/src/study/SessionDebugger.ts +10 -0
  41. package/src/study/SessionOverlay.ts +276 -0
package/dist/index.js CHANGED
@@ -4207,7 +4207,8 @@ var init_orchestration = __esm({
4207
4207
  // src/core/navigators/Pipeline.ts
4208
4208
  var Pipeline_exports = {};
4209
4209
  __export(Pipeline_exports, {
4210
- Pipeline: () => Pipeline
4210
+ Pipeline: () => Pipeline,
4211
+ mergeHints: () => mergeHints2
4211
4212
  });
4212
4213
  function globToRegex(pattern) {
4213
4214
  const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
@@ -11344,6 +11345,7 @@ var ItemQueue = class {
11344
11345
 
11345
11346
  // src/study/SessionController.ts
11346
11347
  init_couch();
11348
+ init_core();
11347
11349
  init_recording();
11348
11350
 
11349
11351
  // src/util/index.ts
@@ -12770,6 +12772,7 @@ init_dataDirectory();
12770
12772
 
12771
12773
  // src/study/SessionController.ts
12772
12774
  init_navigators();
12775
+ init_Pipeline();
12773
12776
 
12774
12777
  // src/study/SourceMixer.ts
12775
12778
  var QuotaRoundRobinMixer = class {
@@ -13153,6 +13156,161 @@ mountMixerDebugger();
13153
13156
  // src/study/SessionDebugger.ts
13154
13157
  init_logger();
13155
13158
  init_PipelineDebugger();
13159
+
13160
+ // src/study/SessionOverlay.ts
13161
+ init_logger();
13162
+ var activeController = null;
13163
+ function registerActiveController(controller) {
13164
+ activeController = controller;
13165
+ }
13166
+ function getActiveController() {
13167
+ return activeController;
13168
+ }
13169
+ var OVERLAY_ID = "skuilder-session-overlay";
13170
+ var POLL_MS = 300;
13171
+ var INLINE_THRESHOLD = 5;
13172
+ var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
13173
+ var spinnerFrame = 0;
13174
+ var overlayEl = null;
13175
+ var pollHandle = null;
13176
+ var expanded = { reviewQ: false, newQ: false, failedQ: false };
13177
+ function toggleSessionOverlay() {
13178
+ if (typeof document === "undefined") {
13179
+ logger.info("[Session Overlay] No DOM available (non-browser host); overlay unavailable.");
13180
+ return;
13181
+ }
13182
+ if (overlayEl) {
13183
+ teardown();
13184
+ logger.info("[Session Overlay] Hidden.");
13185
+ } else {
13186
+ mount();
13187
+ logger.info("[Session Overlay] Shown. Toggle off with window.skuilder.session.dbgOverlay().");
13188
+ }
13189
+ }
13190
+ function mount() {
13191
+ overlayEl = document.createElement("div");
13192
+ overlayEl.id = OVERLAY_ID;
13193
+ Object.assign(overlayEl.style, {
13194
+ position: "fixed",
13195
+ top: "8px",
13196
+ left: "8px",
13197
+ zIndex: "2147483647",
13198
+ maxWidth: "320px",
13199
+ maxHeight: "90vh",
13200
+ overflowY: "auto",
13201
+ padding: "8px 10px",
13202
+ background: "rgba(17, 24, 39, 0.92)",
13203
+ color: "#e5e7eb",
13204
+ font: "11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",
13205
+ borderRadius: "6px",
13206
+ boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
13207
+ pointerEvents: "auto",
13208
+ userSelect: "none"
13209
+ });
13210
+ document.body.appendChild(overlayEl);
13211
+ render();
13212
+ pollHandle = setInterval(render, POLL_MS);
13213
+ }
13214
+ function teardown() {
13215
+ if (pollHandle !== null) {
13216
+ clearInterval(pollHandle);
13217
+ pollHandle = null;
13218
+ }
13219
+ if (overlayEl?.parentNode) {
13220
+ overlayEl.parentNode.removeChild(overlayEl);
13221
+ }
13222
+ overlayEl = null;
13223
+ }
13224
+ function render() {
13225
+ if (!overlayEl) return;
13226
+ spinnerFrame++;
13227
+ const ctrl = getActiveController();
13228
+ if (!ctrl) {
13229
+ overlayEl.innerHTML = headerHtml() + `<div style="opacity:.65">No active session.</div>`;
13230
+ return;
13231
+ }
13232
+ 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);
13234
+ overlayEl.querySelectorAll("[data-q]").forEach((el) => {
13235
+ el.onclick = () => {
13236
+ const key = el.dataset.q;
13237
+ if (!key) return;
13238
+ expanded[key] = !expanded[key];
13239
+ render();
13240
+ };
13241
+ });
13242
+ }
13243
+ function headerHtml() {
13244
+ return `<div style="font-weight:600;color:#93c5fd;margin-bottom:4px">\u2699 SessionController</div>`;
13245
+ }
13246
+ function replanHtml(s) {
13247
+ if (!s.replanActive) {
13248
+ return `<div style="margin-bottom:6px;opacity:.45">\u25CB idle</div>`;
13249
+ }
13250
+ const frame = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length];
13251
+ const reason = esc(s.replanLabel ?? "(auto)");
13252
+ return `<div style="margin-bottom:6px;color:#fde047">${frame} replanning <span style="opacity:.85">[${reason}]</span></div>`;
13253
+ }
13254
+ function metaHtml(s) {
13255
+ const mmss = formatTime(s.secondsRemaining);
13256
+ const guarantee = s.hasCardGuarantee ? ` \xB7 <span style="color:#fbbf24">guarantee ${s.minCardsGuarantee}</span>` : "";
13257
+ const rows = [
13258
+ `time ${mmss}${guarantee}`,
13259
+ `well-indicated left: ${s.wellIndicatedRemaining}`,
13260
+ `current: ${s.currentCard ? esc(s.currentCard) : '<span style="opacity:.6">\u2014</span>'}`
13261
+ ];
13262
+ return `<div style="margin-bottom:6px">${rows.map((r) => `<div>${r}</div>`).join("")}</div>`;
13263
+ }
13264
+ function hintsHtml(h) {
13265
+ const parts = [];
13266
+ if (h) {
13267
+ if (h.boostTags && Object.keys(h.boostTags).length) {
13268
+ parts.push(
13269
+ `boost: ` + Object.entries(h.boostTags).map(([k, v]) => `${esc(k)}<span style="opacity:.6">\xD7${v}</span>`).join(", ")
13270
+ );
13271
+ }
13272
+ if (h.boostCards && Object.keys(h.boostCards).length) {
13273
+ parts.push(
13274
+ `boostCards: ` + Object.entries(h.boostCards).map(([k, v]) => `${esc(k)}<span style="opacity:.6">\xD7${v}</span>`).join(", ")
13275
+ );
13276
+ }
13277
+ if (h.requireCards?.length) parts.push(`require: ${h.requireCards.map(esc).join(", ")}`);
13278
+ if (h.requireTags?.length) parts.push(`requireTags: ${h.requireTags.map(esc).join(", ")}`);
13279
+ if (h.excludeTags?.length) parts.push(`exclude: ${h.excludeTags.map(esc).join(", ")}`);
13280
+ if (h.excludeCards?.length) parts.push(`excludeCards: ${h.excludeCards.map(esc).join(", ")}`);
13281
+ }
13282
+ const body = parts.length ? parts.map((p) => `<div style="margin-left:6px">${p}</div>`).join("") : `<div style="margin-left:6px;opacity:.6">none</div>`;
13283
+ return `<div style="margin-bottom:6px"><div style="color:#86efac">sessionHints</div>${body}</div>`;
13284
+ }
13285
+ function queueHtml(key, label, q) {
13286
+ const collapsible = q.length > INLINE_THRESHOLD;
13287
+ const isOpen = !collapsible || expanded[key];
13288
+ const caret = collapsible ? expanded[key] ? "\u25BE " : "\u25B8 " : "";
13289
+ const drawn = q.dequeueCount ? ` <span style="opacity:.5">drawn ${q.dequeueCount}</span>` : "";
13290
+ const titleStyle = collapsible ? "cursor:pointer;color:#f9a8d4" : "color:#f9a8d4";
13291
+ const titleAttr = collapsible ? ` data-q="${key}"` : "";
13292
+ 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>`;
13300
+ }
13301
+ return title + body;
13302
+ }
13303
+ function formatTime(totalSeconds) {
13304
+ const s = Math.max(0, Math.round(totalSeconds));
13305
+ const m = Math.floor(s / 60);
13306
+ const r = s % 60;
13307
+ return `${m}:${r.toString().padStart(2, "0")}`;
13308
+ }
13309
+ function esc(value) {
13310
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
13311
+ }
13312
+
13313
+ // src/study/SessionDebugger.ts
13156
13314
  var activeSession = null;
13157
13315
  var sessionHistory = [];
13158
13316
  var MAX_HISTORY = 5;
@@ -13333,6 +13491,13 @@ var sessionDebugAPI = {
13333
13491
  showQueue() {
13334
13492
  showCurrentQueue();
13335
13493
  },
13494
+ /**
13495
+ * Toggle the pinned, live-updating DOM overlay for the active controller
13496
+ * (queues, session hints, timer). No-ops in non-browser hosts.
13497
+ */
13498
+ dbgOverlay() {
13499
+ toggleSessionOverlay();
13500
+ },
13336
13501
  /**
13337
13502
  * Show presentation history for current or past session.
13338
13503
  */
@@ -13394,6 +13559,7 @@ var sessionDebugAPI = {
13394
13559
  \u{1F3AF} Session Debug API
13395
13560
 
13396
13561
  Commands:
13562
+ .dbgOverlay() Toggle the pinned live overlay (queues, hints, timer)
13397
13563
  .showQueue() Show current queue state (active session only)
13398
13564
  .showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
13399
13565
  .showInterleaving(index?) Analyze course interleaving pattern
@@ -13462,6 +13628,13 @@ var SessionController = class _SessionController extends Loggable {
13462
13628
  * Used by nextCard() to await completion before drawing from queues.
13463
13629
  */
13464
13630
  _replanPromise = null;
13631
+ /**
13632
+ * Reason for the replan currently executing in `_runReplan`, surfaced by the
13633
+ * debug overlay's spinner. The caller's `opts.label` when present, else
13634
+ * `'(auto)'`. Only meaningful while `_replanPromise` is non-null; cleared
13635
+ * when the in-flight chain settles.
13636
+ */
13637
+ _activeReplanLabel = null;
13465
13638
  /**
13466
13639
  * Number of well-indicated new cards remaining before the queue
13467
13640
  * degrades to poorly-indicated content. Decremented on each newQ
@@ -13481,6 +13654,32 @@ var SessionController = class _SessionController extends Loggable {
13481
13654
  * each nextCard() draw. Set by replans that include `minFollowUpCards`.
13482
13655
  */
13483
13656
  _minCardsGuarantee = 0;
13657
+ /**
13658
+ * Session-durable scoring hints. Re-merged into every pipeline run for
13659
+ * the rest of the session (initial plan + every replan, including bare
13660
+ * auto-replans and the wedge-breaker), via `_applyHintsToSources`.
13661
+ *
13662
+ * Set by `setSessionHints()` (e.g. session-start post-lesson boost) or by
13663
+ * any replan carrying `ReplanOptions.sessionHints` (e.g. a just-failed
13664
+ * concept boost). Replace semantics, no decay — lives until overwritten
13665
+ * or session end. See `ReplanOptions.sessionHints` for rationale.
13666
+ *
13667
+ * Note: the controller-managed auto-excludes (current card, session
13668
+ * record, imminent draw) are intentionally NOT folded in here — those are
13669
+ * recomputed per-run in `_runReplan` and would otherwise go stale.
13670
+ */
13671
+ _sessionHints = null;
13672
+ /**
13673
+ * Consumer-supplied hooks invoked after each question response is processed.
13674
+ * Seeded from constructor options (threaded from
13675
+ * `StudySessionConfig.outcomeObservers`). See {@link OutcomeObserver}.
13676
+ */
13677
+ _outcomeObservers = [];
13678
+ /**
13679
+ * Lazily-built, stable capability object handed to observers. Bound to
13680
+ * `this`; constructed once so observers can rely on referential identity.
13681
+ */
13682
+ _sessionControls = null;
13484
13683
  startTime;
13485
13684
  endTime;
13486
13685
  _secondsRemaining;
@@ -13540,11 +13739,15 @@ var SessionController = class _SessionController extends Loggable {
13540
13739
  if (options?.initialReviewCap !== void 0) {
13541
13740
  this._initialReviewCap = options.initialReviewCap;
13542
13741
  }
13742
+ if (options?.outcomeObservers?.length) {
13743
+ this._outcomeObservers = [...options.outcomeObservers];
13744
+ }
13543
13745
  this.log(`Session constructed:
13544
13746
  startTime: ${this.startTime}
13545
13747
  endTime: ${this.endTime}
13546
13748
  defaultBatchLimit: ${this._defaultBatchLimit}
13547
13749
  initialReviewCap: ${this._initialReviewCap}`);
13750
+ registerActiveController(this);
13548
13751
  }
13549
13752
  tick() {
13550
13753
  this._secondsRemaining = Math.floor((this.endTime.valueOf() - Date.now()) / 1e3);
@@ -13645,15 +13848,23 @@ var SessionController = class _SessionController extends Loggable {
13645
13848
  );
13646
13849
  const inflight = this._replanPromise;
13647
13850
  const queued = inflight.catch(() => void 0).then(() => this._runReplan(opts));
13648
- this._replanPromise = queued.finally(() => {
13649
- if (this._replanPromise === queued) this._replanPromise = null;
13851
+ const tracked2 = queued.finally(() => {
13852
+ if (this._replanPromise === tracked2) {
13853
+ this._replanPromise = null;
13854
+ this._activeReplanLabel = null;
13855
+ }
13650
13856
  });
13857
+ this._replanPromise = tracked2;
13651
13858
  return queued;
13652
13859
  }
13653
13860
  const run = this._runReplan(opts);
13654
- this._replanPromise = run.finally(() => {
13655
- if (this._replanPromise === run) this._replanPromise = null;
13861
+ const tracked = run.finally(() => {
13862
+ if (this._replanPromise === tracked) {
13863
+ this._replanPromise = null;
13864
+ this._activeReplanLabel = null;
13865
+ }
13656
13866
  });
13867
+ this._replanPromise = tracked;
13657
13868
  await run;
13658
13869
  }
13659
13870
  /**
@@ -13662,11 +13873,11 @@ var SessionController = class _SessionController extends Loggable {
13662
13873
  * triggers in nextCard) return false and may coalesce.
13663
13874
  */
13664
13875
  _replanHasIntent(opts) {
13665
- if (opts.label) return true;
13666
13876
  if (opts.limit !== void 0) return true;
13667
13877
  if (opts.minFollowUpCards !== void 0) return true;
13668
13878
  if (opts.mode && opts.mode !== "replace") return true;
13669
13879
  if (opts.hints && Object.keys(opts.hints).length > 0) return true;
13880
+ if (opts.sessionHints !== void 0) return true;
13670
13881
  return false;
13671
13882
  }
13672
13883
  /**
@@ -13682,6 +13893,7 @@ var SessionController = class _SessionController extends Loggable {
13682
13893
  * newQ.peek(0) is the imminent draw we need to exclude.
13683
13894
  */
13684
13895
  async _runReplan(opts) {
13896
+ this._activeReplanLabel = opts.label ?? "(auto)";
13685
13897
  if (!opts.hints) opts.hints = {};
13686
13898
  const hints = opts.hints;
13687
13899
  const excludeSet = new Set(hints.excludeCards ?? []);
@@ -13695,12 +13907,13 @@ var SessionController = class _SessionController extends Loggable {
13695
13907
  excludeSet.add(this.newQ.peek(0).cardID);
13696
13908
  }
13697
13909
  hints.excludeCards = [...excludeSet];
13698
- if (opts.hints) {
13699
- const hintsWithLabel = opts.label ? { ...opts.hints, _label: opts.label } : opts.hints;
13700
- for (const source of this.sources) {
13701
- source.setEphemeralHints?.(hintsWithLabel);
13702
- }
13910
+ if (opts.sessionHints !== void 0) {
13911
+ this._sessionHints = opts.sessionHints;
13912
+ this.log(
13913
+ `[Replan] Session hints ${opts.sessionHints ? "set" : "cleared"}: ${JSON.stringify(opts.sessionHints)}`
13914
+ );
13703
13915
  }
13916
+ this._applyHintsToSources(opts.hints, opts.label);
13704
13917
  const labelTag = opts.label ? ` [${opts.label}]` : "";
13705
13918
  this.log(
13706
13919
  `Mid-session replan requested${labelTag} (limit: ${opts.limit ?? "default"}, mode: ${opts.mode ?? "replace"}${opts.hints ? ", with hints" : ""})`
@@ -13711,6 +13924,128 @@ var SessionController = class _SessionController extends Loggable {
13711
13924
  }
13712
13925
  await this._executeReplan(opts);
13713
13926
  }
13927
+ /**
13928
+ * Set the session-durable scoring hints (replace semantics, no decay).
13929
+ *
13930
+ * Unlike a one-shot replan hint, these are re-merged into every pipeline
13931
+ * run for the rest of the session — including the initial plan when set
13932
+ * before `prepareSession()`, every replan, the bare auto-replans, and the
13933
+ * wedge-breaker. Pass `null` to clear.
13934
+ *
13935
+ * Typical callers:
13936
+ * - `StudySession` at session start, threading `StudySessionConfig.initHints`
13937
+ * (e.g. a post-lesson concept boost) — so the boost outlives the first
13938
+ * queue rebuild instead of being clobbered by the first auto-replan.
13939
+ * - A consumer view on a failure, boosting the just-failed concept tag.
13940
+ *
13941
+ * Does not itself trigger a replan; the next plan/replan picks it up.
13942
+ */
13943
+ setSessionHints(hints) {
13944
+ this._sessionHints = hints;
13945
+ this.log(`Session hints ${hints ? "set" : "cleared"}: ${JSON.stringify(hints)}`);
13946
+ }
13947
+ /**
13948
+ * Read the current session-durable hints (for read-modify-write callers,
13949
+ * e.g. an outcome observer that clamps a compounding boost).
13950
+ */
13951
+ getSessionHints() {
13952
+ return this._sessionHints;
13953
+ }
13954
+ /**
13955
+ * Live state snapshot for the debug overlay (window.skuilder.session
13956
+ * .dbgOverlay()). Reads directly from the private queues and hints, so it
13957
+ * always reflects the current moment — unlike the passive SessionDebugger
13958
+ * snapshots, which only capture what was explicitly pushed to them.
13959
+ */
13960
+ getDebugSnapshot() {
13961
+ const describe = (q) => {
13962
+ const cards = [];
13963
+ for (let i = 0; i < q.length; i++) {
13964
+ cards.push(q.peek(i).cardID);
13965
+ }
13966
+ return { length: q.length, dequeueCount: q.dequeueCount, cards };
13967
+ };
13968
+ return {
13969
+ secondsRemaining: this.secondsRemaining,
13970
+ hasCardGuarantee: this.hasCardGuarantee,
13971
+ minCardsGuarantee: this._minCardsGuarantee,
13972
+ wellIndicatedRemaining: this._wellIndicatedRemaining,
13973
+ currentCard: this._currentCard?.item.cardID ?? null,
13974
+ sessionHints: this._sessionHints,
13975
+ replanActive: this._replanPromise !== null,
13976
+ replanLabel: this._activeReplanLabel,
13977
+ reviewQ: describe(this.reviewQ),
13978
+ newQ: describe(this.newQ),
13979
+ failedQ: describe(this.failedQ)
13980
+ };
13981
+ }
13982
+ /**
13983
+ * Merge `hints` into the durable session hints via the pipeline's
13984
+ * `mergeHints` (boosts multiply, require/exclude lists concat-dedup).
13985
+ * Convenience over get-then-set for the common additive case. Note the
13986
+ * multiplicative, no-decay semantics — clamp boost factors at the call
13987
+ * site if a repeatedly-emphasised tag could compound unboundedly.
13988
+ */
13989
+ mergeSessionHints(hints) {
13990
+ this._sessionHints = mergeHints2([this._sessionHints, hints]) ?? null;
13991
+ this.log(`Session hints merged: ${JSON.stringify(this._sessionHints)}`);
13992
+ }
13993
+ /**
13994
+ * Merge the durable `_sessionHints` with this run's one-shot hints and
13995
+ * push the result to every source for consumption on the next pipeline
13996
+ * run. Centralised so the initial plan and all replan paths apply session
13997
+ * emphasis identically. No-op when there are no hints of either kind.
13998
+ */
13999
+ _applyHintsToSources(oneShot, label) {
14000
+ const oneShotWithLabel = oneShot && label ? { ...oneShot, _label: label } : oneShot;
14001
+ const merged = mergeHints2([this._sessionHints, oneShotWithLabel]);
14002
+ if (!merged) return;
14003
+ for (const source of this.sources) {
14004
+ source.setEphemeralHints?.(merged);
14005
+ }
14006
+ }
14007
+ /**
14008
+ * Build (once) the stable capability object handed to outcome observers.
14009
+ * Methods are bound to `this`; the object identity is stable across calls
14010
+ * so observers may key off it.
14011
+ */
14012
+ _getSessionControls() {
14013
+ if (!this._sessionControls) {
14014
+ this._sessionControls = {
14015
+ getSessionHints: () => this.getSessionHints(),
14016
+ setSessionHints: (h) => this.setSessionHints(h),
14017
+ mergeSessionHints: (h) => this.mergeSessionHints(h),
14018
+ requestReplan: (opts) => this.requestReplan(opts)
14019
+ };
14020
+ }
14021
+ return this._sessionControls;
14022
+ }
14023
+ /**
14024
+ * Notify registered outcome observers about a processed response.
14025
+ *
14026
+ * Only question records are surfaced (non-question dismisses are skipped).
14027
+ * Observers run after ELO/SRS are recorded and before navigation. Each is
14028
+ * awaited but isolated in try/catch — a throwing observer is logged and
14029
+ * skipped, never wedging the session. Keep observers cheap and `void` any
14030
+ * long work (e.g. a triggered replan) to avoid stalling the draw.
14031
+ */
14032
+ async _notifyOutcomeObservers(record, currentCard, result) {
14033
+ if (this._outcomeObservers.length === 0) return;
14034
+ if (!isQuestionRecord(record)) return;
14035
+ const outcome = {
14036
+ record,
14037
+ card: currentCard.card,
14038
+ result
14039
+ };
14040
+ const controls = this._getSessionControls();
14041
+ for (const observer of this._outcomeObservers) {
14042
+ try {
14043
+ await observer(outcome, controls);
14044
+ } catch (e) {
14045
+ this.error("[OutcomeObserver] observer threw; ignoring", e);
14046
+ }
14047
+ }
14048
+ }
13714
14049
  /**
13715
14050
  * Run a replan, bypassing requestReplan()'s coalesce logic.
13716
14051
  *
@@ -13725,9 +14060,13 @@ var SessionController = class _SessionController extends Loggable {
13725
14060
  */
13726
14061
  async _replanUncoalesced(opts) {
13727
14062
  const run = this._runReplan(opts);
13728
- this._replanPromise = run.finally(() => {
13729
- if (this._replanPromise === run) this._replanPromise = null;
14063
+ const tracked = run.finally(() => {
14064
+ if (this._replanPromise === tracked) {
14065
+ this._replanPromise = null;
14066
+ this._activeReplanLabel = null;
14067
+ }
13730
14068
  });
14069
+ this._replanPromise = tracked;
13731
14070
  await run;
13732
14071
  }
13733
14072
  /**
@@ -13738,7 +14077,7 @@ var SessionController = class _SessionController extends Loggable {
13738
14077
  */
13739
14078
  normalizeReplanOptions(input) {
13740
14079
  if (!input) return {};
13741
- const replanKeys = ["hints", "limit", "mode", "label", "minFollowUpCards"];
14080
+ const replanKeys = ["hints", "sessionHints", "limit", "mode", "label", "minFollowUpCards"];
13742
14081
  const inputKeys = Object.keys(input);
13743
14082
  if (inputKeys.some((k) => replanKeys.includes(k))) {
13744
14083
  return input;
@@ -13890,6 +14229,9 @@ var SessionController = class _SessionController extends Loggable {
13890
14229
  const additive = options?.additive ?? false;
13891
14230
  const newLimit = options?.limit ?? this._defaultBatchLimit;
13892
14231
  const fetchLimit = replan ? newLimit : newLimit + this._initialReviewCap;
14232
+ if (!replan) {
14233
+ this._applyHintsToSources();
14234
+ }
13893
14235
  const batches = [];
13894
14236
  for (let i = 0; i < this.sources.length; i++) {
13895
14237
  const source = this.sources[i];
@@ -14080,14 +14422,14 @@ var SessionController = class _SessionController extends Loggable {
14080
14422
  this.log(
14081
14423
  `[AutoReplan:depletion] newQ has ${this.newQ.length} card(s) (${otherContent} in other queues) with ${this._secondsRemaining}s remaining. Triggering background replan.`
14082
14424
  );
14083
- void this.requestReplan();
14425
+ void this.requestReplan({ label: "auto:depletion" });
14084
14426
  }
14085
14427
  const REPLAN_BUFFER = 3;
14086
14428
  if (!this._suppressQualityReplan && this._wellIndicatedRemaining <= REPLAN_BUFFER && this.newQ.length > 0 && !this._replanPromise) {
14087
14429
  this.log(
14088
14430
  `[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining (newQ: ${this.newQ.length}). Triggering background replan.`
14089
14431
  );
14090
- void this.requestReplan();
14432
+ void this.requestReplan({ label: "auto:quality" });
14091
14433
  }
14092
14434
  if (this._secondsRemaining <= 0 && this.failedQ.length === 0 && this._minCardsGuarantee <= 0) {
14093
14435
  this._currentCard = null;
@@ -14170,7 +14512,7 @@ var SessionController = class _SessionController extends Loggable {
14170
14512
  const studySessionItem = {
14171
14513
  ...currentCard.item
14172
14514
  };
14173
- return await this.services.response.processResponse(
14515
+ const result = await this.services.response.processResponse(
14174
14516
  cardRecord,
14175
14517
  cardHistory,
14176
14518
  studySessionItem,
@@ -14182,6 +14524,8 @@ var SessionController = class _SessionController extends Loggable {
14182
14524
  maxSessionViews,
14183
14525
  sessionViews
14184
14526
  );
14527
+ await this._notifyOutcomeObservers(cardRecord, currentCard, result);
14528
+ return result;
14185
14529
  }
14186
14530
  dismissCurrentCard(action = "dismiss-success") {
14187
14531
  if (this._currentCard) {