@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.mjs CHANGED
@@ -4184,7 +4184,8 @@ var init_orchestration = __esm({
4184
4184
  // src/core/navigators/Pipeline.ts
4185
4185
  var Pipeline_exports = {};
4186
4186
  __export(Pipeline_exports, {
4187
- Pipeline: () => Pipeline
4187
+ Pipeline: () => Pipeline,
4188
+ mergeHints: () => mergeHints2
4188
4189
  });
4189
4190
  import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
4190
4191
  function globToRegex(pattern) {
@@ -11242,6 +11243,7 @@ var ItemQueue = class {
11242
11243
 
11243
11244
  // src/study/SessionController.ts
11244
11245
  init_couch();
11246
+ init_core();
11245
11247
  init_recording();
11246
11248
 
11247
11249
  // src/util/index.ts
@@ -12668,6 +12670,7 @@ init_dataDirectory();
12668
12670
 
12669
12671
  // src/study/SessionController.ts
12670
12672
  init_navigators();
12673
+ init_Pipeline();
12671
12674
 
12672
12675
  // src/study/SourceMixer.ts
12673
12676
  var QuotaRoundRobinMixer = class {
@@ -13051,6 +13054,161 @@ mountMixerDebugger();
13051
13054
  // src/study/SessionDebugger.ts
13052
13055
  init_logger();
13053
13056
  init_PipelineDebugger();
13057
+
13058
+ // src/study/SessionOverlay.ts
13059
+ init_logger();
13060
+ var activeController = null;
13061
+ function registerActiveController(controller) {
13062
+ activeController = controller;
13063
+ }
13064
+ function getActiveController() {
13065
+ return activeController;
13066
+ }
13067
+ var OVERLAY_ID = "skuilder-session-overlay";
13068
+ var POLL_MS = 300;
13069
+ var INLINE_THRESHOLD = 5;
13070
+ var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
13071
+ var spinnerFrame = 0;
13072
+ var overlayEl = null;
13073
+ var pollHandle = null;
13074
+ var expanded = { reviewQ: false, newQ: false, failedQ: false };
13075
+ function toggleSessionOverlay() {
13076
+ if (typeof document === "undefined") {
13077
+ logger.info("[Session Overlay] No DOM available (non-browser host); overlay unavailable.");
13078
+ return;
13079
+ }
13080
+ if (overlayEl) {
13081
+ teardown();
13082
+ logger.info("[Session Overlay] Hidden.");
13083
+ } else {
13084
+ mount();
13085
+ logger.info("[Session Overlay] Shown. Toggle off with window.skuilder.session.dbgOverlay().");
13086
+ }
13087
+ }
13088
+ function mount() {
13089
+ overlayEl = document.createElement("div");
13090
+ overlayEl.id = OVERLAY_ID;
13091
+ Object.assign(overlayEl.style, {
13092
+ position: "fixed",
13093
+ top: "8px",
13094
+ left: "8px",
13095
+ zIndex: "2147483647",
13096
+ maxWidth: "320px",
13097
+ maxHeight: "90vh",
13098
+ overflowY: "auto",
13099
+ padding: "8px 10px",
13100
+ background: "rgba(17, 24, 39, 0.92)",
13101
+ color: "#e5e7eb",
13102
+ font: "11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",
13103
+ borderRadius: "6px",
13104
+ boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
13105
+ pointerEvents: "auto",
13106
+ userSelect: "none"
13107
+ });
13108
+ document.body.appendChild(overlayEl);
13109
+ render();
13110
+ pollHandle = setInterval(render, POLL_MS);
13111
+ }
13112
+ function teardown() {
13113
+ if (pollHandle !== null) {
13114
+ clearInterval(pollHandle);
13115
+ pollHandle = null;
13116
+ }
13117
+ if (overlayEl?.parentNode) {
13118
+ overlayEl.parentNode.removeChild(overlayEl);
13119
+ }
13120
+ overlayEl = null;
13121
+ }
13122
+ function render() {
13123
+ if (!overlayEl) return;
13124
+ spinnerFrame++;
13125
+ const ctrl = getActiveController();
13126
+ if (!ctrl) {
13127
+ overlayEl.innerHTML = headerHtml() + `<div style="opacity:.65">No active session.</div>`;
13128
+ return;
13129
+ }
13130
+ const s = ctrl.getDebugSnapshot();
13131
+ 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);
13132
+ overlayEl.querySelectorAll("[data-q]").forEach((el) => {
13133
+ el.onclick = () => {
13134
+ const key = el.dataset.q;
13135
+ if (!key) return;
13136
+ expanded[key] = !expanded[key];
13137
+ render();
13138
+ };
13139
+ });
13140
+ }
13141
+ function headerHtml() {
13142
+ return `<div style="font-weight:600;color:#93c5fd;margin-bottom:4px">\u2699 SessionController</div>`;
13143
+ }
13144
+ function replanHtml(s) {
13145
+ if (!s.replanActive) {
13146
+ return `<div style="margin-bottom:6px;opacity:.45">\u25CB idle</div>`;
13147
+ }
13148
+ const frame = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length];
13149
+ const reason = esc(s.replanLabel ?? "(auto)");
13150
+ return `<div style="margin-bottom:6px;color:#fde047">${frame} replanning <span style="opacity:.85">[${reason}]</span></div>`;
13151
+ }
13152
+ function metaHtml(s) {
13153
+ const mmss = formatTime(s.secondsRemaining);
13154
+ const guarantee = s.hasCardGuarantee ? ` \xB7 <span style="color:#fbbf24">guarantee ${s.minCardsGuarantee}</span>` : "";
13155
+ const rows = [
13156
+ `time ${mmss}${guarantee}`,
13157
+ `well-indicated left: ${s.wellIndicatedRemaining}`,
13158
+ `current: ${s.currentCard ? esc(s.currentCard) : '<span style="opacity:.6">\u2014</span>'}`
13159
+ ];
13160
+ return `<div style="margin-bottom:6px">${rows.map((r) => `<div>${r}</div>`).join("")}</div>`;
13161
+ }
13162
+ function hintsHtml(h) {
13163
+ const parts = [];
13164
+ if (h) {
13165
+ if (h.boostTags && Object.keys(h.boostTags).length) {
13166
+ parts.push(
13167
+ `boost: ` + Object.entries(h.boostTags).map(([k, v]) => `${esc(k)}<span style="opacity:.6">\xD7${v}</span>`).join(", ")
13168
+ );
13169
+ }
13170
+ if (h.boostCards && Object.keys(h.boostCards).length) {
13171
+ parts.push(
13172
+ `boostCards: ` + Object.entries(h.boostCards).map(([k, v]) => `${esc(k)}<span style="opacity:.6">\xD7${v}</span>`).join(", ")
13173
+ );
13174
+ }
13175
+ if (h.requireCards?.length) parts.push(`require: ${h.requireCards.map(esc).join(", ")}`);
13176
+ if (h.requireTags?.length) parts.push(`requireTags: ${h.requireTags.map(esc).join(", ")}`);
13177
+ if (h.excludeTags?.length) parts.push(`exclude: ${h.excludeTags.map(esc).join(", ")}`);
13178
+ if (h.excludeCards?.length) parts.push(`excludeCards: ${h.excludeCards.map(esc).join(", ")}`);
13179
+ }
13180
+ const body = parts.length ? parts.map((p) => `<div style="margin-left:6px">${p}</div>`).join("") : `<div style="margin-left:6px;opacity:.6">none</div>`;
13181
+ return `<div style="margin-bottom:6px"><div style="color:#86efac">sessionHints</div>${body}</div>`;
13182
+ }
13183
+ function queueHtml(key, label, q) {
13184
+ const collapsible = q.length > INLINE_THRESHOLD;
13185
+ const isOpen = !collapsible || expanded[key];
13186
+ const caret = collapsible ? expanded[key] ? "\u25BE " : "\u25B8 " : "";
13187
+ const drawn = q.dequeueCount ? ` <span style="opacity:.5">drawn ${q.dequeueCount}</span>` : "";
13188
+ const titleStyle = collapsible ? "cursor:pointer;color:#f9a8d4" : "color:#f9a8d4";
13189
+ const titleAttr = collapsible ? ` data-q="${key}"` : "";
13190
+ const title = `<div${titleAttr} style="${titleStyle}">${caret}${label}: ${q.length}${drawn}</div>`;
13191
+ let body = "";
13192
+ if (isOpen && q.cards.length) {
13193
+ 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>`;
13194
+ } else if (!q.cards.length) {
13195
+ body = `<div style="margin:1px 0 6px 6px;opacity:.5">empty</div>`;
13196
+ } else {
13197
+ body = `<div style="margin:1px 0 6px 6px;opacity:.55">(${q.length} cards \u2014 click to expand)</div>`;
13198
+ }
13199
+ return title + body;
13200
+ }
13201
+ function formatTime(totalSeconds) {
13202
+ const s = Math.max(0, Math.round(totalSeconds));
13203
+ const m = Math.floor(s / 60);
13204
+ const r = s % 60;
13205
+ return `${m}:${r.toString().padStart(2, "0")}`;
13206
+ }
13207
+ function esc(value) {
13208
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
13209
+ }
13210
+
13211
+ // src/study/SessionDebugger.ts
13054
13212
  var activeSession = null;
13055
13213
  var sessionHistory = [];
13056
13214
  var MAX_HISTORY = 5;
@@ -13231,6 +13389,13 @@ var sessionDebugAPI = {
13231
13389
  showQueue() {
13232
13390
  showCurrentQueue();
13233
13391
  },
13392
+ /**
13393
+ * Toggle the pinned, live-updating DOM overlay for the active controller
13394
+ * (queues, session hints, timer). No-ops in non-browser hosts.
13395
+ */
13396
+ dbgOverlay() {
13397
+ toggleSessionOverlay();
13398
+ },
13234
13399
  /**
13235
13400
  * Show presentation history for current or past session.
13236
13401
  */
@@ -13292,6 +13457,7 @@ var sessionDebugAPI = {
13292
13457
  \u{1F3AF} Session Debug API
13293
13458
 
13294
13459
  Commands:
13460
+ .dbgOverlay() Toggle the pinned live overlay (queues, hints, timer)
13295
13461
  .showQueue() Show current queue state (active session only)
13296
13462
  .showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
13297
13463
  .showInterleaving(index?) Analyze course interleaving pattern
@@ -13360,6 +13526,13 @@ var SessionController = class _SessionController extends Loggable {
13360
13526
  * Used by nextCard() to await completion before drawing from queues.
13361
13527
  */
13362
13528
  _replanPromise = null;
13529
+ /**
13530
+ * Reason for the replan currently executing in `_runReplan`, surfaced by the
13531
+ * debug overlay's spinner. The caller's `opts.label` when present, else
13532
+ * `'(auto)'`. Only meaningful while `_replanPromise` is non-null; cleared
13533
+ * when the in-flight chain settles.
13534
+ */
13535
+ _activeReplanLabel = null;
13363
13536
  /**
13364
13537
  * Number of well-indicated new cards remaining before the queue
13365
13538
  * degrades to poorly-indicated content. Decremented on each newQ
@@ -13379,6 +13552,32 @@ var SessionController = class _SessionController extends Loggable {
13379
13552
  * each nextCard() draw. Set by replans that include `minFollowUpCards`.
13380
13553
  */
13381
13554
  _minCardsGuarantee = 0;
13555
+ /**
13556
+ * Session-durable scoring hints. Re-merged into every pipeline run for
13557
+ * the rest of the session (initial plan + every replan, including bare
13558
+ * auto-replans and the wedge-breaker), via `_applyHintsToSources`.
13559
+ *
13560
+ * Set by `setSessionHints()` (e.g. session-start post-lesson boost) or by
13561
+ * any replan carrying `ReplanOptions.sessionHints` (e.g. a just-failed
13562
+ * concept boost). Replace semantics, no decay — lives until overwritten
13563
+ * or session end. See `ReplanOptions.sessionHints` for rationale.
13564
+ *
13565
+ * Note: the controller-managed auto-excludes (current card, session
13566
+ * record, imminent draw) are intentionally NOT folded in here — those are
13567
+ * recomputed per-run in `_runReplan` and would otherwise go stale.
13568
+ */
13569
+ _sessionHints = null;
13570
+ /**
13571
+ * Consumer-supplied hooks invoked after each question response is processed.
13572
+ * Seeded from constructor options (threaded from
13573
+ * `StudySessionConfig.outcomeObservers`). See {@link OutcomeObserver}.
13574
+ */
13575
+ _outcomeObservers = [];
13576
+ /**
13577
+ * Lazily-built, stable capability object handed to observers. Bound to
13578
+ * `this`; constructed once so observers can rely on referential identity.
13579
+ */
13580
+ _sessionControls = null;
13382
13581
  startTime;
13383
13582
  endTime;
13384
13583
  _secondsRemaining;
@@ -13438,11 +13637,15 @@ var SessionController = class _SessionController extends Loggable {
13438
13637
  if (options?.initialReviewCap !== void 0) {
13439
13638
  this._initialReviewCap = options.initialReviewCap;
13440
13639
  }
13640
+ if (options?.outcomeObservers?.length) {
13641
+ this._outcomeObservers = [...options.outcomeObservers];
13642
+ }
13441
13643
  this.log(`Session constructed:
13442
13644
  startTime: ${this.startTime}
13443
13645
  endTime: ${this.endTime}
13444
13646
  defaultBatchLimit: ${this._defaultBatchLimit}
13445
13647
  initialReviewCap: ${this._initialReviewCap}`);
13648
+ registerActiveController(this);
13446
13649
  }
13447
13650
  tick() {
13448
13651
  this._secondsRemaining = Math.floor((this.endTime.valueOf() - Date.now()) / 1e3);
@@ -13543,15 +13746,23 @@ var SessionController = class _SessionController extends Loggable {
13543
13746
  );
13544
13747
  const inflight = this._replanPromise;
13545
13748
  const queued = inflight.catch(() => void 0).then(() => this._runReplan(opts));
13546
- this._replanPromise = queued.finally(() => {
13547
- if (this._replanPromise === queued) this._replanPromise = null;
13749
+ const tracked2 = queued.finally(() => {
13750
+ if (this._replanPromise === tracked2) {
13751
+ this._replanPromise = null;
13752
+ this._activeReplanLabel = null;
13753
+ }
13548
13754
  });
13755
+ this._replanPromise = tracked2;
13549
13756
  return queued;
13550
13757
  }
13551
13758
  const run = this._runReplan(opts);
13552
- this._replanPromise = run.finally(() => {
13553
- if (this._replanPromise === run) this._replanPromise = null;
13759
+ const tracked = run.finally(() => {
13760
+ if (this._replanPromise === tracked) {
13761
+ this._replanPromise = null;
13762
+ this._activeReplanLabel = null;
13763
+ }
13554
13764
  });
13765
+ this._replanPromise = tracked;
13555
13766
  await run;
13556
13767
  }
13557
13768
  /**
@@ -13560,11 +13771,11 @@ var SessionController = class _SessionController extends Loggable {
13560
13771
  * triggers in nextCard) return false and may coalesce.
13561
13772
  */
13562
13773
  _replanHasIntent(opts) {
13563
- if (opts.label) return true;
13564
13774
  if (opts.limit !== void 0) return true;
13565
13775
  if (opts.minFollowUpCards !== void 0) return true;
13566
13776
  if (opts.mode && opts.mode !== "replace") return true;
13567
13777
  if (opts.hints && Object.keys(opts.hints).length > 0) return true;
13778
+ if (opts.sessionHints !== void 0) return true;
13568
13779
  return false;
13569
13780
  }
13570
13781
  /**
@@ -13580,6 +13791,7 @@ var SessionController = class _SessionController extends Loggable {
13580
13791
  * newQ.peek(0) is the imminent draw we need to exclude.
13581
13792
  */
13582
13793
  async _runReplan(opts) {
13794
+ this._activeReplanLabel = opts.label ?? "(auto)";
13583
13795
  if (!opts.hints) opts.hints = {};
13584
13796
  const hints = opts.hints;
13585
13797
  const excludeSet = new Set(hints.excludeCards ?? []);
@@ -13593,12 +13805,13 @@ var SessionController = class _SessionController extends Loggable {
13593
13805
  excludeSet.add(this.newQ.peek(0).cardID);
13594
13806
  }
13595
13807
  hints.excludeCards = [...excludeSet];
13596
- if (opts.hints) {
13597
- const hintsWithLabel = opts.label ? { ...opts.hints, _label: opts.label } : opts.hints;
13598
- for (const source of this.sources) {
13599
- source.setEphemeralHints?.(hintsWithLabel);
13600
- }
13808
+ if (opts.sessionHints !== void 0) {
13809
+ this._sessionHints = opts.sessionHints;
13810
+ this.log(
13811
+ `[Replan] Session hints ${opts.sessionHints ? "set" : "cleared"}: ${JSON.stringify(opts.sessionHints)}`
13812
+ );
13601
13813
  }
13814
+ this._applyHintsToSources(opts.hints, opts.label);
13602
13815
  const labelTag = opts.label ? ` [${opts.label}]` : "";
13603
13816
  this.log(
13604
13817
  `Mid-session replan requested${labelTag} (limit: ${opts.limit ?? "default"}, mode: ${opts.mode ?? "replace"}${opts.hints ? ", with hints" : ""})`
@@ -13609,6 +13822,128 @@ var SessionController = class _SessionController extends Loggable {
13609
13822
  }
13610
13823
  await this._executeReplan(opts);
13611
13824
  }
13825
+ /**
13826
+ * Set the session-durable scoring hints (replace semantics, no decay).
13827
+ *
13828
+ * Unlike a one-shot replan hint, these are re-merged into every pipeline
13829
+ * run for the rest of the session — including the initial plan when set
13830
+ * before `prepareSession()`, every replan, the bare auto-replans, and the
13831
+ * wedge-breaker. Pass `null` to clear.
13832
+ *
13833
+ * Typical callers:
13834
+ * - `StudySession` at session start, threading `StudySessionConfig.initHints`
13835
+ * (e.g. a post-lesson concept boost) — so the boost outlives the first
13836
+ * queue rebuild instead of being clobbered by the first auto-replan.
13837
+ * - A consumer view on a failure, boosting the just-failed concept tag.
13838
+ *
13839
+ * Does not itself trigger a replan; the next plan/replan picks it up.
13840
+ */
13841
+ setSessionHints(hints) {
13842
+ this._sessionHints = hints;
13843
+ this.log(`Session hints ${hints ? "set" : "cleared"}: ${JSON.stringify(hints)}`);
13844
+ }
13845
+ /**
13846
+ * Read the current session-durable hints (for read-modify-write callers,
13847
+ * e.g. an outcome observer that clamps a compounding boost).
13848
+ */
13849
+ getSessionHints() {
13850
+ return this._sessionHints;
13851
+ }
13852
+ /**
13853
+ * Live state snapshot for the debug overlay (window.skuilder.session
13854
+ * .dbgOverlay()). Reads directly from the private queues and hints, so it
13855
+ * always reflects the current moment — unlike the passive SessionDebugger
13856
+ * snapshots, which only capture what was explicitly pushed to them.
13857
+ */
13858
+ getDebugSnapshot() {
13859
+ const describe = (q) => {
13860
+ const cards = [];
13861
+ for (let i = 0; i < q.length; i++) {
13862
+ cards.push(q.peek(i).cardID);
13863
+ }
13864
+ return { length: q.length, dequeueCount: q.dequeueCount, cards };
13865
+ };
13866
+ return {
13867
+ secondsRemaining: this.secondsRemaining,
13868
+ hasCardGuarantee: this.hasCardGuarantee,
13869
+ minCardsGuarantee: this._minCardsGuarantee,
13870
+ wellIndicatedRemaining: this._wellIndicatedRemaining,
13871
+ currentCard: this._currentCard?.item.cardID ?? null,
13872
+ sessionHints: this._sessionHints,
13873
+ replanActive: this._replanPromise !== null,
13874
+ replanLabel: this._activeReplanLabel,
13875
+ reviewQ: describe(this.reviewQ),
13876
+ newQ: describe(this.newQ),
13877
+ failedQ: describe(this.failedQ)
13878
+ };
13879
+ }
13880
+ /**
13881
+ * Merge `hints` into the durable session hints via the pipeline's
13882
+ * `mergeHints` (boosts multiply, require/exclude lists concat-dedup).
13883
+ * Convenience over get-then-set for the common additive case. Note the
13884
+ * multiplicative, no-decay semantics — clamp boost factors at the call
13885
+ * site if a repeatedly-emphasised tag could compound unboundedly.
13886
+ */
13887
+ mergeSessionHints(hints) {
13888
+ this._sessionHints = mergeHints2([this._sessionHints, hints]) ?? null;
13889
+ this.log(`Session hints merged: ${JSON.stringify(this._sessionHints)}`);
13890
+ }
13891
+ /**
13892
+ * Merge the durable `_sessionHints` with this run's one-shot hints and
13893
+ * push the result to every source for consumption on the next pipeline
13894
+ * run. Centralised so the initial plan and all replan paths apply session
13895
+ * emphasis identically. No-op when there are no hints of either kind.
13896
+ */
13897
+ _applyHintsToSources(oneShot, label) {
13898
+ const oneShotWithLabel = oneShot && label ? { ...oneShot, _label: label } : oneShot;
13899
+ const merged = mergeHints2([this._sessionHints, oneShotWithLabel]);
13900
+ if (!merged) return;
13901
+ for (const source of this.sources) {
13902
+ source.setEphemeralHints?.(merged);
13903
+ }
13904
+ }
13905
+ /**
13906
+ * Build (once) the stable capability object handed to outcome observers.
13907
+ * Methods are bound to `this`; the object identity is stable across calls
13908
+ * so observers may key off it.
13909
+ */
13910
+ _getSessionControls() {
13911
+ if (!this._sessionControls) {
13912
+ this._sessionControls = {
13913
+ getSessionHints: () => this.getSessionHints(),
13914
+ setSessionHints: (h) => this.setSessionHints(h),
13915
+ mergeSessionHints: (h) => this.mergeSessionHints(h),
13916
+ requestReplan: (opts) => this.requestReplan(opts)
13917
+ };
13918
+ }
13919
+ return this._sessionControls;
13920
+ }
13921
+ /**
13922
+ * Notify registered outcome observers about a processed response.
13923
+ *
13924
+ * Only question records are surfaced (non-question dismisses are skipped).
13925
+ * Observers run after ELO/SRS are recorded and before navigation. Each is
13926
+ * awaited but isolated in try/catch — a throwing observer is logged and
13927
+ * skipped, never wedging the session. Keep observers cheap and `void` any
13928
+ * long work (e.g. a triggered replan) to avoid stalling the draw.
13929
+ */
13930
+ async _notifyOutcomeObservers(record, currentCard, result) {
13931
+ if (this._outcomeObservers.length === 0) return;
13932
+ if (!isQuestionRecord(record)) return;
13933
+ const outcome = {
13934
+ record,
13935
+ card: currentCard.card,
13936
+ result
13937
+ };
13938
+ const controls = this._getSessionControls();
13939
+ for (const observer of this._outcomeObservers) {
13940
+ try {
13941
+ await observer(outcome, controls);
13942
+ } catch (e) {
13943
+ this.error("[OutcomeObserver] observer threw; ignoring", e);
13944
+ }
13945
+ }
13946
+ }
13612
13947
  /**
13613
13948
  * Run a replan, bypassing requestReplan()'s coalesce logic.
13614
13949
  *
@@ -13623,9 +13958,13 @@ var SessionController = class _SessionController extends Loggable {
13623
13958
  */
13624
13959
  async _replanUncoalesced(opts) {
13625
13960
  const run = this._runReplan(opts);
13626
- this._replanPromise = run.finally(() => {
13627
- if (this._replanPromise === run) this._replanPromise = null;
13961
+ const tracked = run.finally(() => {
13962
+ if (this._replanPromise === tracked) {
13963
+ this._replanPromise = null;
13964
+ this._activeReplanLabel = null;
13965
+ }
13628
13966
  });
13967
+ this._replanPromise = tracked;
13629
13968
  await run;
13630
13969
  }
13631
13970
  /**
@@ -13636,7 +13975,7 @@ var SessionController = class _SessionController extends Loggable {
13636
13975
  */
13637
13976
  normalizeReplanOptions(input) {
13638
13977
  if (!input) return {};
13639
- const replanKeys = ["hints", "limit", "mode", "label", "minFollowUpCards"];
13978
+ const replanKeys = ["hints", "sessionHints", "limit", "mode", "label", "minFollowUpCards"];
13640
13979
  const inputKeys = Object.keys(input);
13641
13980
  if (inputKeys.some((k) => replanKeys.includes(k))) {
13642
13981
  return input;
@@ -13788,6 +14127,9 @@ var SessionController = class _SessionController extends Loggable {
13788
14127
  const additive = options?.additive ?? false;
13789
14128
  const newLimit = options?.limit ?? this._defaultBatchLimit;
13790
14129
  const fetchLimit = replan ? newLimit : newLimit + this._initialReviewCap;
14130
+ if (!replan) {
14131
+ this._applyHintsToSources();
14132
+ }
13791
14133
  const batches = [];
13792
14134
  for (let i = 0; i < this.sources.length; i++) {
13793
14135
  const source = this.sources[i];
@@ -13978,14 +14320,14 @@ var SessionController = class _SessionController extends Loggable {
13978
14320
  this.log(
13979
14321
  `[AutoReplan:depletion] newQ has ${this.newQ.length} card(s) (${otherContent} in other queues) with ${this._secondsRemaining}s remaining. Triggering background replan.`
13980
14322
  );
13981
- void this.requestReplan();
14323
+ void this.requestReplan({ label: "auto:depletion" });
13982
14324
  }
13983
14325
  const REPLAN_BUFFER = 3;
13984
14326
  if (!this._suppressQualityReplan && this._wellIndicatedRemaining <= REPLAN_BUFFER && this.newQ.length > 0 && !this._replanPromise) {
13985
14327
  this.log(
13986
14328
  `[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining (newQ: ${this.newQ.length}). Triggering background replan.`
13987
14329
  );
13988
- void this.requestReplan();
14330
+ void this.requestReplan({ label: "auto:quality" });
13989
14331
  }
13990
14332
  if (this._secondsRemaining <= 0 && this.failedQ.length === 0 && this._minCardsGuarantee <= 0) {
13991
14333
  this._currentCard = null;
@@ -14068,7 +14410,7 @@ var SessionController = class _SessionController extends Loggable {
14068
14410
  const studySessionItem = {
14069
14411
  ...currentCard.item
14070
14412
  };
14071
- return await this.services.response.processResponse(
14413
+ const result = await this.services.response.processResponse(
14072
14414
  cardRecord,
14073
14415
  cardHistory,
14074
14416
  studySessionItem,
@@ -14080,6 +14422,8 @@ var SessionController = class _SessionController extends Loggable {
14080
14422
  maxSessionViews,
14081
14423
  sessionViews
14082
14424
  );
14425
+ await this._notifyOutcomeObservers(cardRecord, currentCard, result);
14426
+ return result;
14083
14427
  }
14084
14428
  dismissCurrentCard(action = "dismiss-success") {
14085
14429
  if (this._currentCard) {