@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
@@ -1,5 +1,5 @@
1
1
  import { CourseConfig } from '@vue-skuilder/common';
2
- import { D as DocType } from './types-legacy-JXDxinpU.cjs';
2
+ import { D as DocType } from './types-legacy-4tlwHnXo.cjs';
3
3
 
4
4
  interface StaticCourseManifest {
5
5
  version: string;
@@ -1,5 +1,5 @@
1
1
  import { CourseConfig } from '@vue-skuilder/common';
2
- import { D as DocType } from './types-legacy-JXDxinpU.js';
2
+ import { D as DocType } from './types-legacy-4tlwHnXo.js';
3
3
 
4
4
  interface StaticCourseManifest {
5
5
  version: string;
@@ -157,4 +157,4 @@ interface QuestionRecord extends CardRecord, Evaluation {
157
157
  priorAttemps: number;
158
158
  }
159
159
 
160
- export { type CardHistory as C, DocType as D, type Field as F, GuestUsername as G, type QualifiedCardID as Q, type SkuilderCourseData as S, type TagStub as T, type Tag as a, DocTypePrefixes as b, type CardRecord as c, type CardData as d, type CourseListData as e, type DisplayableData as f, type DataShapeData as g, type QuestionData as h, type QuestionRecord as i, log as l };
160
+ export { type CardHistory as C, DocType as D, type Field as F, GuestUsername as G, type QualifiedCardID as Q, type SkuilderCourseData as S, type TagStub as T, type Tag as a, DocTypePrefixes as b, type CardRecord as c, type QuestionRecord as d, type CardData as e, type CourseListData as f, type DisplayableData as g, type DataShapeData as h, type QuestionData as i, log as l };
@@ -157,4 +157,4 @@ interface QuestionRecord extends CardRecord, Evaluation {
157
157
  priorAttemps: number;
158
158
  }
159
159
 
160
- export { type CardHistory as C, DocType as D, type Field as F, GuestUsername as G, type QualifiedCardID as Q, type SkuilderCourseData as S, type TagStub as T, type Tag as a, DocTypePrefixes as b, type CardRecord as c, type CardData as d, type CourseListData as e, type DisplayableData as f, type DataShapeData as g, type QuestionData as h, type QuestionRecord as i, log as l };
160
+ export { type CardHistory as C, DocType as D, type Field as F, GuestUsername as G, type QualifiedCardID as Q, type SkuilderCourseData as S, type TagStub as T, type Tag as a, DocTypePrefixes as b, type CardRecord as c, type QuestionRecord as d, type CardData as e, type CourseListData as f, type DisplayableData as g, type DataShapeData as h, type QuestionData as i, log as l };
@@ -1,5 +1,5 @@
1
- export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-W8n-B6HG.cjs';
2
- export { C as CouchDBToStaticPacker } from '../../index-Ba7hYbHj.cjs';
1
+ export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-BFUa1pa3.cjs';
2
+ export { C as CouchDBToStaticPacker } from '../../index-k9NFHpS1.cjs';
3
3
  import '@vue-skuilder/common';
4
- import '../../types-legacy-JXDxinpU.cjs';
4
+ import '../../types-legacy-4tlwHnXo.cjs';
5
5
  import 'moment';
@@ -1,5 +1,5 @@
1
- export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-CJrLM1Ew.js';
2
- export { C as CouchDBToStaticPacker } from '../../index-BWvO-_rJ.js';
1
+ export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-CHgpWQAY.js';
2
+ export { C as CouchDBToStaticPacker } from '../../index-BLLT5BYE.js';
3
3
  import '@vue-skuilder/common';
4
- import '../../types-legacy-JXDxinpU.js';
4
+ import '../../types-legacy-4tlwHnXo.js';
5
5
  import 'moment';
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.2.2",
7
+ "version": "0.2.4",
8
8
  "description": "Database layer for vue-skuilder",
9
9
  "main": "dist/index.js",
10
10
  "module": "dist/index.mjs",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@nilock2/pouchdb-authentication": "^1.0.2",
51
- "@vue-skuilder/common": "0.2.2",
51
+ "@vue-skuilder/common": "0.2.4",
52
52
  "cross-fetch": "^4.1.0",
53
53
  "moment": "^2.29.4",
54
54
  "pouchdb": "^9.0.0",
@@ -62,5 +62,5 @@
62
62
  "vite": "^8.0.0",
63
63
  "vitest": "^4.1.0"
64
64
  },
65
- "stableVersion": "0.2.2"
65
+ "stableVersion": "0.2.4"
66
66
  }
@@ -48,7 +48,7 @@ function cardMatchesTagPattern(card: WeightedCard, pattern: string): boolean {
48
48
  return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
49
49
  }
50
50
 
51
- function mergeHints(allHints: Array<ReplanHints | null | undefined>): ReplanHints | undefined {
51
+ export function mergeHints(allHints: Array<ReplanHints | null | undefined>): ReplanHints | undefined {
52
52
  const defined = allHints.filter((h): h is ReplanHints => h !== null && h !== undefined);
53
53
  if (defined.length === 0) return undefined;
54
54
 
@@ -12,14 +12,16 @@ import {
12
12
  StudySessionReviewItem,
13
13
  } from '@db/impl/couch';
14
14
 
15
- import { CardRecord, CardHistory, CourseRegistrationDoc, QuestionRecord } from '@db/core';
15
+ import { CardRecord, CardHistory, CourseRegistrationDoc, QuestionRecord, isQuestionRecord } from '@db/core';
16
16
  import { recordUserOutcome } from '@db/core/orchestration/recording';
17
17
  import { Loggable } from '@db/util';
18
18
  import { getCardOrigin } from '@db/core/navigators';
19
19
  import { ReplanHints } from '@db/core/navigators/generators/types';
20
+ import { mergeHints } from '@db/core/navigators/Pipeline';
20
21
  import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
21
22
  import { captureMixerRun } from './MixerDebugger';
22
23
  import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking } from './SessionDebugger';
24
+ import { registerActiveController, type SessionDebugSnapshot, type SessionQueueDebug } from './SessionOverlay';
23
25
 
24
26
  // ReplanHints is defined in generators/types to avoid circular dependencies.
25
27
  // Re-exported here for backward compatibility.
@@ -35,6 +37,28 @@ export type { ReplanHints } from '@db/core/navigators/generators/types';
35
37
  export interface ReplanOptions {
36
38
  /** Scoring hints forwarded to the pipeline (boost/exclude/require). */
37
39
  hints?: ReplanHints;
40
+ /**
41
+ * Session-durable scoring hints. Unlike `hints` (one-shot, applied to
42
+ * exactly the run this replan triggers), `sessionHints` are stashed on
43
+ * the controller and re-merged into *every* subsequent pipeline run for
44
+ * the remainder of the session — including the bare auto-replans
45
+ * (depletion/quality) that carry no caller hints, and the wedge-breaker.
46
+ *
47
+ * Use for "emphasis that should outlive a single queue rebuild" — e.g.
48
+ * boosting a just-failed concept tag, or a post-lesson concept boost set
49
+ * at session start. Without this, a one-shot `hints` boost evaporates on
50
+ * the next replan and the freshly-rebuilt (replace-mode) queue clobbers
51
+ * whatever it surfaced.
52
+ *
53
+ * Semantics (KISS): setting `sessionHints` *replaces* the prior session
54
+ * hints wholesale (caller beware — no accumulation, no decay). They live
55
+ * until session end or until explicitly overwritten. Normal usage applies
56
+ * a fixed boost, so repeated identical requests are no-ops.
57
+ *
58
+ * Merged with per-run `hints` via the pipeline's `mergeHints` (boosts
59
+ * multiply, require/exclude lists concatenate).
60
+ */
61
+ sessionHints?: ReplanHints;
38
62
  /**
39
63
  * Maximum number of new cards to return from the pipeline.
40
64
  * Default: 20 (the standard session batch size).
@@ -102,6 +126,64 @@ export interface ResponseResult {
102
126
  shouldClearFeedbackShadow: boolean;
103
127
  }
104
128
 
129
+ /**
130
+ * Read-only snapshot of a single processed response, handed to every
131
+ * registered {@link OutcomeObserver} after ELO/SRS have been recorded.
132
+ *
133
+ * Only emitted for question records (non-question dismisses are skipped).
134
+ */
135
+ export interface SessionOutcome {
136
+ /** The user's response. Includes `isCorrect`, `performance`, `priorAttemps`. */
137
+ readonly record: QuestionRecord;
138
+ /**
139
+ * The card that was answered, including its `tags` — the primary key an
140
+ * observer matches against (e.g. `gpc:exercise:*`). `card_elo` reflects
141
+ * pre-update state; the ELO write for this response is already in flight.
142
+ */
143
+ readonly card: StudySessionRecord['card'];
144
+ /** The navigation decision produced for this response (read-only). */
145
+ readonly result: Readonly<ResponseResult>;
146
+ }
147
+
148
+ /**
149
+ * The narrow capability surface handed to an {@link OutcomeObserver}. This is
150
+ * the *only* way an observer can affect the session — it cannot touch ELO,
151
+ * the queues, the timer, or mutate the `ResponseResult`. A misbehaving
152
+ * observer degrades to "wrong boost", never "corrupted session".
153
+ */
154
+ export interface SessionControls {
155
+ /** Current session-durable hints, or null. For read-modify-write. */
156
+ getSessionHints(): ReplanHints | null;
157
+ /** Replace the session-durable hints wholesale (no decay). */
158
+ setSessionHints(hints: ReplanHints | null): void;
159
+ /**
160
+ * Merge `hints` into the existing session-durable hints via the pipeline's
161
+ * `mergeHints` (boosts multiply, require/exclude lists concat-dedup).
162
+ * Convenience for the common "add a boost on top of what's there" case.
163
+ * Note: multiplicative + no decay — clamp boost factors yourself if a
164
+ * repeatedly-failed tag could compound unboundedly.
165
+ */
166
+ mergeSessionHints(hints: ReplanHints): void;
167
+ /** Request a replan (e.g. `{ mode: 'merge' }` for immediate visibility). */
168
+ requestReplan(opts?: ReplanOptions): Promise<void>;
169
+ }
170
+
171
+ /**
172
+ * A consumer-supplied hook invoked after each question response is processed.
173
+ *
174
+ * Fires on *every* question response (gate inside on `record.isCorrect` /
175
+ * `result.nextCardAction` as needed). Awaited but isolated: a throwing
176
+ * observer is caught and logged, never wedging the session. Keep the
177
+ * synchronous body cheap and `void` any long work (e.g. a triggered replan)
178
+ * so you don't stall navigation.
179
+ *
180
+ * Registered via `StudySessionConfig.outcomeObservers` → constructor options.
181
+ */
182
+ export type OutcomeObserver = (
183
+ outcome: SessionOutcome,
184
+ controls: SessionControls
185
+ ) => void | Promise<void>;
186
+
105
187
  interface SessionServices {
106
188
  response: ResponseProcessor;
107
189
  }
@@ -155,6 +237,14 @@ export class SessionController<TView = unknown> extends Loggable {
155
237
  */
156
238
  private _replanPromise: Promise<void> | null = null;
157
239
 
240
+ /**
241
+ * Reason for the replan currently executing in `_runReplan`, surfaced by the
242
+ * debug overlay's spinner. The caller's `opts.label` when present, else
243
+ * `'(auto)'`. Only meaningful while `_replanPromise` is non-null; cleared
244
+ * when the in-flight chain settles.
245
+ */
246
+ private _activeReplanLabel: string | null = null;
247
+
158
248
  /**
159
249
  * Number of well-indicated new cards remaining before the queue
160
250
  * degrades to poorly-indicated content. Decremented on each newQ
@@ -177,6 +267,35 @@ export class SessionController<TView = unknown> extends Loggable {
177
267
  */
178
268
  private _minCardsGuarantee: number = 0;
179
269
 
270
+ /**
271
+ * Session-durable scoring hints. Re-merged into every pipeline run for
272
+ * the rest of the session (initial plan + every replan, including bare
273
+ * auto-replans and the wedge-breaker), via `_applyHintsToSources`.
274
+ *
275
+ * Set by `setSessionHints()` (e.g. session-start post-lesson boost) or by
276
+ * any replan carrying `ReplanOptions.sessionHints` (e.g. a just-failed
277
+ * concept boost). Replace semantics, no decay — lives until overwritten
278
+ * or session end. See `ReplanOptions.sessionHints` for rationale.
279
+ *
280
+ * Note: the controller-managed auto-excludes (current card, session
281
+ * record, imminent draw) are intentionally NOT folded in here — those are
282
+ * recomputed per-run in `_runReplan` and would otherwise go stale.
283
+ */
284
+ private _sessionHints: ReplanHints | null = null;
285
+
286
+ /**
287
+ * Consumer-supplied hooks invoked after each question response is processed.
288
+ * Seeded from constructor options (threaded from
289
+ * `StudySessionConfig.outcomeObservers`). See {@link OutcomeObserver}.
290
+ */
291
+ private _outcomeObservers: OutcomeObserver[] = [];
292
+
293
+ /**
294
+ * Lazily-built, stable capability object handed to observers. Bound to
295
+ * `this`; constructed once so observers can rely on referential identity.
296
+ */
297
+ private _sessionControls: SessionControls | null = null;
298
+
180
299
  private startTime: Date;
181
300
  private endTime: Date;
182
301
  private _secondsRemaining: number;
@@ -219,7 +338,11 @@ export class SessionController<TView = unknown> extends Loggable {
219
338
  dataLayer: DataLayerProvider,
220
339
  getViewComponent: (viewId: string) => TView,
221
340
  mixer?: SourceMixer,
222
- options?: { defaultBatchLimit?: number; initialReviewCap?: number }
341
+ options?: {
342
+ defaultBatchLimit?: number;
343
+ initialReviewCap?: number;
344
+ outcomeObservers?: OutcomeObserver[];
345
+ }
223
346
  ) {
224
347
  super();
225
348
 
@@ -249,12 +372,20 @@ export class SessionController<TView = unknown> extends Loggable {
249
372
  if (options?.initialReviewCap !== undefined) {
250
373
  this._initialReviewCap = options.initialReviewCap;
251
374
  }
375
+ if (options?.outcomeObservers?.length) {
376
+ this._outcomeObservers = [...options.outcomeObservers];
377
+ }
252
378
 
253
379
  this.log(`Session constructed:
254
380
  startTime: ${this.startTime}
255
381
  endTime: ${this.endTime}
256
382
  defaultBatchLimit: ${this._defaultBatchLimit}
257
383
  initialReviewCap: ${this._initialReviewCap}`);
384
+
385
+ // Expose this (now the most-recently-constructed) controller to the debug
386
+ // overlay (window.skuilder.session.dbgOverlay()). A new session overwrites
387
+ // the prior handle; no-op overhead when the overlay is never opened.
388
+ registerActiveController(this);
258
389
  }
259
390
 
260
391
  private tick() {
@@ -383,17 +514,33 @@ export class SessionController<TView = unknown> extends Loggable {
383
514
  .catch(() => undefined)
384
515
  .then(() => this._runReplan(opts));
385
516
 
386
- this._replanPromise = queued.finally(() => {
387
- if (this._replanPromise === queued) this._replanPromise = null;
517
+ // Compare against the promise we actually store. `.finally()` returns a
518
+ // NEW promise, so guarding on `=== queued` (the pre-finally promise) never
519
+ // matches and would leak _replanPromise. `tracked` is read only inside the
520
+ // async callback (after init), so the self-reference is safe.
521
+ const tracked: Promise<void> = queued.finally(() => {
522
+ if (this._replanPromise === tracked) {
523
+ this._replanPromise = null;
524
+ this._activeReplanLabel = null;
525
+ }
388
526
  });
527
+ this._replanPromise = tracked;
389
528
 
390
529
  return queued;
391
530
  }
392
531
 
393
532
  const run = this._runReplan(opts);
394
- this._replanPromise = run.finally(() => {
395
- if (this._replanPromise === run) this._replanPromise = null;
533
+ // Compare against the wrapped promise we store, not `run` — `.finally()`
534
+ // returns a new promise, so `=== run` never matches and _replanPromise
535
+ // would never clear (perpetual "replan in progress"). Safe self-reference:
536
+ // `tracked` is read only in the async callback, after initialization.
537
+ const tracked: Promise<void> = run.finally(() => {
538
+ if (this._replanPromise === tracked) {
539
+ this._replanPromise = null;
540
+ this._activeReplanLabel = null;
541
+ }
396
542
  });
543
+ this._replanPromise = tracked;
397
544
 
398
545
  await run;
399
546
  }
@@ -404,11 +551,17 @@ export class SessionController<TView = unknown> extends Loggable {
404
551
  * triggers in nextCard) return false and may coalesce.
405
552
  */
406
553
  private _replanHasIntent(opts: ReplanOptions): boolean {
407
- if (opts.label) return true;
554
+ // NOTE: `label` is intentionally NOT an intent signal. It is observability-
555
+ // only metadata (debug overlay spinner, log tags, Pipeline strategy names),
556
+ // so labelling a replan must never change scheduling. Intent is strictly
557
+ // "does this replan carry scheduling-relevant options". This lets the
558
+ // unlabeled-but-named auto-replans (auto:depletion / auto:quality) keep
559
+ // coalescing while still showing a reason in the overlay.
408
560
  if (opts.limit !== undefined) return true;
409
561
  if (opts.minFollowUpCards !== undefined) return true;
410
562
  if (opts.mode && opts.mode !== 'replace') return true;
411
563
  if (opts.hints && Object.keys(opts.hints).length > 0) return true;
564
+ if (opts.sessionHints !== undefined) return true;
412
565
  return false;
413
566
  }
414
567
 
@@ -425,6 +578,11 @@ export class SessionController<TView = unknown> extends Loggable {
425
578
  * newQ.peek(0) is the imminent draw we need to exclude.
426
579
  */
427
580
  private async _runReplan(opts: ReplanOptions): Promise<void> {
581
+ // Surface the executing replan's reason to the debug overlay spinner.
582
+ // `label` is observability-only (see _replanHasIntent); '(auto)' covers any
583
+ // unlabeled path.
584
+ this._activeReplanLabel = opts.label ?? '(auto)';
585
+
428
586
  // Exclude all cards already presented this session. The pipeline may
429
587
  // not yet see their encounter records (async writes), so without this
430
588
  // they can re-enter newQ via replaceAll and cause duplicates.
@@ -455,17 +613,21 @@ export class SessionController<TView = unknown> extends Loggable {
455
613
 
456
614
  hints.excludeCards = [...excludeSet];
457
615
 
458
- // Forward hints to all sources (CourseDB stashes them, Pipeline consumes them)
459
- if (opts.hints) {
460
- // Thread label into hints so Pipeline can attach it to provenance
461
- const hintsWithLabel = opts.label
462
- ? { ...opts.hints, _label: opts.label }
463
- : opts.hints;
464
- for (const source of this.sources) {
465
- source.setEphemeralHints?.(hintsWithLabel);
466
- }
616
+ // Replace session-durable hints if this replan carries them. KISS:
617
+ // wholesale replace, no accumulation/decay (see ReplanOptions.sessionHints).
618
+ if (opts.sessionHints !== undefined) {
619
+ this._sessionHints = opts.sessionHints;
620
+ this.log(
621
+ `[Replan] Session hints ${opts.sessionHints ? 'set' : 'cleared'}: ` +
622
+ `${JSON.stringify(opts.sessionHints)}`
623
+ );
467
624
  }
468
625
 
626
+ // Forward hints to all sources (CourseDB stashes them, Pipeline consumes
627
+ // them). The one-shot `opts.hints` are merged with the durable
628
+ // `_sessionHints` so session emphasis survives this and every later run.
629
+ this._applyHintsToSources(opts.hints, opts.label);
630
+
469
631
  const labelTag = opts.label ? ` [${opts.label}]` : '';
470
632
  this.log(
471
633
  `Mid-session replan requested${labelTag}` +
@@ -487,6 +649,146 @@ export class SessionController<TView = unknown> extends Loggable {
487
649
  // );
488
650
  }
489
651
 
652
+ /**
653
+ * Set the session-durable scoring hints (replace semantics, no decay).
654
+ *
655
+ * Unlike a one-shot replan hint, these are re-merged into every pipeline
656
+ * run for the rest of the session — including the initial plan when set
657
+ * before `prepareSession()`, every replan, the bare auto-replans, and the
658
+ * wedge-breaker. Pass `null` to clear.
659
+ *
660
+ * Typical callers:
661
+ * - `StudySession` at session start, threading `StudySessionConfig.initHints`
662
+ * (e.g. a post-lesson concept boost) — so the boost outlives the first
663
+ * queue rebuild instead of being clobbered by the first auto-replan.
664
+ * - A consumer view on a failure, boosting the just-failed concept tag.
665
+ *
666
+ * Does not itself trigger a replan; the next plan/replan picks it up.
667
+ */
668
+ public setSessionHints(hints: ReplanHints | null): void {
669
+ this._sessionHints = hints;
670
+ this.log(`Session hints ${hints ? 'set' : 'cleared'}: ${JSON.stringify(hints)}`);
671
+ }
672
+
673
+ /**
674
+ * Read the current session-durable hints (for read-modify-write callers,
675
+ * e.g. an outcome observer that clamps a compounding boost).
676
+ */
677
+ public getSessionHints(): ReplanHints | null {
678
+ return this._sessionHints;
679
+ }
680
+
681
+ /**
682
+ * Live state snapshot for the debug overlay (window.skuilder.session
683
+ * .dbgOverlay()). Reads directly from the private queues and hints, so it
684
+ * always reflects the current moment — unlike the passive SessionDebugger
685
+ * snapshots, which only capture what was explicitly pushed to them.
686
+ */
687
+ public getDebugSnapshot(): SessionDebugSnapshot {
688
+ const describe = <T extends { cardID: string }>(q: ItemQueue<T>): SessionQueueDebug => {
689
+ const cards: string[] = [];
690
+ for (let i = 0; i < q.length; i++) {
691
+ cards.push(q.peek(i).cardID);
692
+ }
693
+ return { length: q.length, dequeueCount: q.dequeueCount, cards };
694
+ };
695
+ return {
696
+ secondsRemaining: this.secondsRemaining,
697
+ hasCardGuarantee: this.hasCardGuarantee,
698
+ minCardsGuarantee: this._minCardsGuarantee,
699
+ wellIndicatedRemaining: this._wellIndicatedRemaining,
700
+ currentCard: this._currentCard?.item.cardID ?? null,
701
+ sessionHints: this._sessionHints,
702
+ replanActive: this._replanPromise !== null,
703
+ replanLabel: this._activeReplanLabel,
704
+ reviewQ: describe(this.reviewQ),
705
+ newQ: describe(this.newQ),
706
+ failedQ: describe(this.failedQ),
707
+ };
708
+ }
709
+
710
+ /**
711
+ * Merge `hints` into the durable session hints via the pipeline's
712
+ * `mergeHints` (boosts multiply, require/exclude lists concat-dedup).
713
+ * Convenience over get-then-set for the common additive case. Note the
714
+ * multiplicative, no-decay semantics — clamp boost factors at the call
715
+ * site if a repeatedly-emphasised tag could compound unboundedly.
716
+ */
717
+ public mergeSessionHints(hints: ReplanHints): void {
718
+ this._sessionHints = mergeHints([this._sessionHints, hints]) ?? null;
719
+ this.log(`Session hints merged: ${JSON.stringify(this._sessionHints)}`);
720
+ }
721
+
722
+ /**
723
+ * Merge the durable `_sessionHints` with this run's one-shot hints and
724
+ * push the result to every source for consumption on the next pipeline
725
+ * run. Centralised so the initial plan and all replan paths apply session
726
+ * emphasis identically. No-op when there are no hints of either kind.
727
+ */
728
+ private _applyHintsToSources(oneShot?: ReplanHints, label?: string): void {
729
+ // Thread the provenance label into the one-shot layer; mergeHints will
730
+ // fold it into the combined `_label`.
731
+ const oneShotWithLabel: ReplanHints | undefined =
732
+ oneShot && label ? { ...oneShot, _label: label } : oneShot;
733
+
734
+ const merged = mergeHints([this._sessionHints, oneShotWithLabel]);
735
+ if (!merged) return;
736
+
737
+ for (const source of this.sources) {
738
+ source.setEphemeralHints?.(merged);
739
+ }
740
+ }
741
+
742
+ /**
743
+ * Build (once) the stable capability object handed to outcome observers.
744
+ * Methods are bound to `this`; the object identity is stable across calls
745
+ * so observers may key off it.
746
+ */
747
+ private _getSessionControls(): SessionControls {
748
+ if (!this._sessionControls) {
749
+ this._sessionControls = {
750
+ getSessionHints: () => this.getSessionHints(),
751
+ setSessionHints: (h) => this.setSessionHints(h),
752
+ mergeSessionHints: (h) => this.mergeSessionHints(h),
753
+ requestReplan: (opts) => this.requestReplan(opts),
754
+ };
755
+ }
756
+ return this._sessionControls;
757
+ }
758
+
759
+ /**
760
+ * Notify registered outcome observers about a processed response.
761
+ *
762
+ * Only question records are surfaced (non-question dismisses are skipped).
763
+ * Observers run after ELO/SRS are recorded and before navigation. Each is
764
+ * awaited but isolated in try/catch — a throwing observer is logged and
765
+ * skipped, never wedging the session. Keep observers cheap and `void` any
766
+ * long work (e.g. a triggered replan) to avoid stalling the draw.
767
+ */
768
+ private async _notifyOutcomeObservers(
769
+ record: CardRecord,
770
+ currentCard: StudySessionRecord,
771
+ result: ResponseResult
772
+ ): Promise<void> {
773
+ if (this._outcomeObservers.length === 0) return;
774
+ if (!isQuestionRecord(record)) return;
775
+
776
+ const outcome: SessionOutcome = {
777
+ record,
778
+ card: currentCard.card,
779
+ result,
780
+ };
781
+ const controls = this._getSessionControls();
782
+
783
+ for (const observer of this._outcomeObservers) {
784
+ try {
785
+ await observer(outcome, controls);
786
+ } catch (e) {
787
+ this.error('[OutcomeObserver] observer threw; ignoring', e);
788
+ }
789
+ }
790
+ }
791
+
490
792
  /**
491
793
  * Run a replan, bypassing requestReplan()'s coalesce logic.
492
794
  *
@@ -501,9 +803,14 @@ export class SessionController<TView = unknown> extends Loggable {
501
803
  */
502
804
  private async _replanUncoalesced(opts: ReplanOptions): Promise<void> {
503
805
  const run = this._runReplan(opts);
504
- this._replanPromise = run.finally(() => {
505
- if (this._replanPromise === run) this._replanPromise = null;
806
+ // See requestReplan: guard against the wrapped promise we store, not `run`.
807
+ const tracked: Promise<void> = run.finally(() => {
808
+ if (this._replanPromise === tracked) {
809
+ this._replanPromise = null;
810
+ this._activeReplanLabel = null;
811
+ }
506
812
  });
813
+ this._replanPromise = tracked;
507
814
  await run;
508
815
  }
509
816
 
@@ -519,7 +826,7 @@ export class SessionController<TView = unknown> extends Loggable {
519
826
  if (!input) return {};
520
827
 
521
828
  // If the input has any ReplanOptions-specific key, treat it as ReplanOptions
522
- const replanKeys = ['hints', 'limit', 'mode', 'label', 'minFollowUpCards'];
829
+ const replanKeys = ['hints', 'sessionHints', 'limit', 'mode', 'label', 'minFollowUpCards'];
523
830
  const inputKeys = Object.keys(input);
524
831
  if (inputKeys.some((k) => replanKeys.includes(k))) {
525
832
  return input as ReplanOptions;
@@ -706,6 +1013,14 @@ export class SessionController<TView = unknown> extends Loggable {
706
1013
  // never touch reviewQ, so the inflation is unnecessary there.
707
1014
  const fetchLimit = replan ? newLimit : newLimit + this._initialReviewCap;
708
1015
 
1016
+ // Initial plan: push session-durable hints to sources so the very first
1017
+ // pipeline run reflects them (e.g. a post-lesson boost). Replans push
1018
+ // their own session+one-shot merge via _runReplan before reaching here,
1019
+ // so we must NOT re-apply here or we'd drop their per-run excludeCards.
1020
+ if (!replan) {
1021
+ this._applyHintsToSources();
1022
+ }
1023
+
709
1024
  // Collect batches from each source
710
1025
  const batches: SourceBatch[] = [];
711
1026
 
@@ -1031,7 +1346,8 @@ export class SessionController<TView = unknown> extends Loggable {
1031
1346
  `(${otherContent} in other queues) with ${this._secondsRemaining}s remaining. ` +
1032
1347
  `Triggering background replan.`
1033
1348
  );
1034
- void this.requestReplan();
1349
+ // label is observability-only (overlay/logs); does not affect coalescing.
1350
+ void this.requestReplan({ label: 'auto:depletion' });
1035
1351
  }
1036
1352
 
1037
1353
  // Opportunistic quality: few well-indicated cards remain.
@@ -1047,7 +1363,8 @@ export class SessionController<TView = unknown> extends Loggable {
1047
1363
  `[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining ` +
1048
1364
  `(newQ: ${this.newQ.length}). Triggering background replan.`
1049
1365
  );
1050
- void this.requestReplan();
1366
+ // label is observability-only (overlay/logs); does not affect coalescing.
1367
+ void this.requestReplan({ label: 'auto:quality' });
1051
1368
  }
1052
1369
 
1053
1370
  if (this._secondsRemaining <= 0 && this.failedQ.length === 0 && this._minCardsGuarantee <= 0) {
@@ -1192,7 +1509,7 @@ export class SessionController<TView = unknown> extends Loggable {
1192
1509
  ...currentCard.item,
1193
1510
  };
1194
1511
 
1195
- return await this.services.response.processResponse(
1512
+ const result = await this.services.response.processResponse(
1196
1513
  cardRecord,
1197
1514
  cardHistory,
1198
1515
  studySessionItem,
@@ -1204,6 +1521,14 @@ export class SessionController<TView = unknown> extends Loggable {
1204
1521
  maxSessionViews,
1205
1522
  sessionViews
1206
1523
  );
1524
+
1525
+ // Surface the processed outcome to any registered observers (e.g. a
1526
+ // difficulty-booster that bumps session hints on a failed exercise tag).
1527
+ // Runs after ELO/SRS recording, before the caller navigates. Isolated so
1528
+ // a faulty observer can't break response handling.
1529
+ await this._notifyOutcomeObservers(cardRecord, currentCard, result);
1530
+
1531
+ return result;
1207
1532
  }
1208
1533
 
1209
1534
  private dismissCurrentCard(action: SessionAction = 'dismiss-success') {
@@ -1,5 +1,6 @@
1
1
  import { logger } from '../util/logger';
2
2
  import { clearRunHistory as clearPipelineRunHistory } from '../core/navigators/PipelineDebugger';
3
+ import { toggleSessionOverlay } from './SessionOverlay';
3
4
 
4
5
  // ============================================================================
5
6
  // SESSION DEBUGGER
@@ -344,6 +345,14 @@ export const sessionDebugAPI = {
344
345
  showCurrentQueue();
345
346
  },
346
347
 
348
+ /**
349
+ * Toggle the pinned, live-updating DOM overlay for the active controller
350
+ * (queues, session hints, timer). No-ops in non-browser hosts.
351
+ */
352
+ dbgOverlay(): void {
353
+ toggleSessionOverlay();
354
+ },
355
+
347
356
  /**
348
357
  * Show presentation history for current or past session.
349
358
  */
@@ -413,6 +422,7 @@ export const sessionDebugAPI = {
413
422
  🎯 Session Debug API
414
423
 
415
424
  Commands:
425
+ .dbgOverlay() Toggle the pinned live overlay (queues, hints, timer)
416
426
  .showQueue() Show current queue state (active session only)
417
427
  .showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
418
428
  .showInterleaving(index?) Analyze course interleaving pattern