@vue-skuilder/db 0.2.2 → 0.2.3

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 (39) 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 +164 -10
  26. package/dist/index.d.ts +164 -10
  27. package/dist/index.js +141 -8
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +141 -8
  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 +262 -13
@@ -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.3",
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.3",
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.3"
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,11 +12,12 @@ 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';
@@ -35,6 +36,28 @@ export type { ReplanHints } from '@db/core/navigators/generators/types';
35
36
  export interface ReplanOptions {
36
37
  /** Scoring hints forwarded to the pipeline (boost/exclude/require). */
37
38
  hints?: ReplanHints;
39
+ /**
40
+ * Session-durable scoring hints. Unlike `hints` (one-shot, applied to
41
+ * exactly the run this replan triggers), `sessionHints` are stashed on
42
+ * the controller and re-merged into *every* subsequent pipeline run for
43
+ * the remainder of the session — including the bare auto-replans
44
+ * (depletion/quality) that carry no caller hints, and the wedge-breaker.
45
+ *
46
+ * Use for "emphasis that should outlive a single queue rebuild" — e.g.
47
+ * boosting a just-failed concept tag, or a post-lesson concept boost set
48
+ * at session start. Without this, a one-shot `hints` boost evaporates on
49
+ * the next replan and the freshly-rebuilt (replace-mode) queue clobbers
50
+ * whatever it surfaced.
51
+ *
52
+ * Semantics (KISS): setting `sessionHints` *replaces* the prior session
53
+ * hints wholesale (caller beware — no accumulation, no decay). They live
54
+ * until session end or until explicitly overwritten. Normal usage applies
55
+ * a fixed boost, so repeated identical requests are no-ops.
56
+ *
57
+ * Merged with per-run `hints` via the pipeline's `mergeHints` (boosts
58
+ * multiply, require/exclude lists concatenate).
59
+ */
60
+ sessionHints?: ReplanHints;
38
61
  /**
39
62
  * Maximum number of new cards to return from the pipeline.
40
63
  * Default: 20 (the standard session batch size).
@@ -102,6 +125,64 @@ export interface ResponseResult {
102
125
  shouldClearFeedbackShadow: boolean;
103
126
  }
104
127
 
128
+ /**
129
+ * Read-only snapshot of a single processed response, handed to every
130
+ * registered {@link OutcomeObserver} after ELO/SRS have been recorded.
131
+ *
132
+ * Only emitted for question records (non-question dismisses are skipped).
133
+ */
134
+ export interface SessionOutcome {
135
+ /** The user's response. Includes `isCorrect`, `performance`, `priorAttemps`. */
136
+ readonly record: QuestionRecord;
137
+ /**
138
+ * The card that was answered, including its `tags` — the primary key an
139
+ * observer matches against (e.g. `gpc:exercise:*`). `card_elo` reflects
140
+ * pre-update state; the ELO write for this response is already in flight.
141
+ */
142
+ readonly card: StudySessionRecord['card'];
143
+ /** The navigation decision produced for this response (read-only). */
144
+ readonly result: Readonly<ResponseResult>;
145
+ }
146
+
147
+ /**
148
+ * The narrow capability surface handed to an {@link OutcomeObserver}. This is
149
+ * the *only* way an observer can affect the session — it cannot touch ELO,
150
+ * the queues, the timer, or mutate the `ResponseResult`. A misbehaving
151
+ * observer degrades to "wrong boost", never "corrupted session".
152
+ */
153
+ export interface SessionControls {
154
+ /** Current session-durable hints, or null. For read-modify-write. */
155
+ getSessionHints(): ReplanHints | null;
156
+ /** Replace the session-durable hints wholesale (no decay). */
157
+ setSessionHints(hints: ReplanHints | null): void;
158
+ /**
159
+ * Merge `hints` into the existing session-durable hints via the pipeline's
160
+ * `mergeHints` (boosts multiply, require/exclude lists concat-dedup).
161
+ * Convenience for the common "add a boost on top of what's there" case.
162
+ * Note: multiplicative + no decay — clamp boost factors yourself if a
163
+ * repeatedly-failed tag could compound unboundedly.
164
+ */
165
+ mergeSessionHints(hints: ReplanHints): void;
166
+ /** Request a replan (e.g. `{ mode: 'merge' }` for immediate visibility). */
167
+ requestReplan(opts?: ReplanOptions): Promise<void>;
168
+ }
169
+
170
+ /**
171
+ * A consumer-supplied hook invoked after each question response is processed.
172
+ *
173
+ * Fires on *every* question response (gate inside on `record.isCorrect` /
174
+ * `result.nextCardAction` as needed). Awaited but isolated: a throwing
175
+ * observer is caught and logged, never wedging the session. Keep the
176
+ * synchronous body cheap and `void` any long work (e.g. a triggered replan)
177
+ * so you don't stall navigation.
178
+ *
179
+ * Registered via `StudySessionConfig.outcomeObservers` → constructor options.
180
+ */
181
+ export type OutcomeObserver = (
182
+ outcome: SessionOutcome,
183
+ controls: SessionControls
184
+ ) => void | Promise<void>;
185
+
105
186
  interface SessionServices {
106
187
  response: ResponseProcessor;
107
188
  }
@@ -177,6 +258,35 @@ export class SessionController<TView = unknown> extends Loggable {
177
258
  */
178
259
  private _minCardsGuarantee: number = 0;
179
260
 
261
+ /**
262
+ * Session-durable scoring hints. Re-merged into every pipeline run for
263
+ * the rest of the session (initial plan + every replan, including bare
264
+ * auto-replans and the wedge-breaker), via `_applyHintsToSources`.
265
+ *
266
+ * Set by `setSessionHints()` (e.g. session-start post-lesson boost) or by
267
+ * any replan carrying `ReplanOptions.sessionHints` (e.g. a just-failed
268
+ * concept boost). Replace semantics, no decay — lives until overwritten
269
+ * or session end. See `ReplanOptions.sessionHints` for rationale.
270
+ *
271
+ * Note: the controller-managed auto-excludes (current card, session
272
+ * record, imminent draw) are intentionally NOT folded in here — those are
273
+ * recomputed per-run in `_runReplan` and would otherwise go stale.
274
+ */
275
+ private _sessionHints: ReplanHints | null = null;
276
+
277
+ /**
278
+ * Consumer-supplied hooks invoked after each question response is processed.
279
+ * Seeded from constructor options (threaded from
280
+ * `StudySessionConfig.outcomeObservers`). See {@link OutcomeObserver}.
281
+ */
282
+ private _outcomeObservers: OutcomeObserver[] = [];
283
+
284
+ /**
285
+ * Lazily-built, stable capability object handed to observers. Bound to
286
+ * `this`; constructed once so observers can rely on referential identity.
287
+ */
288
+ private _sessionControls: SessionControls | null = null;
289
+
180
290
  private startTime: Date;
181
291
  private endTime: Date;
182
292
  private _secondsRemaining: number;
@@ -219,7 +329,11 @@ export class SessionController<TView = unknown> extends Loggable {
219
329
  dataLayer: DataLayerProvider,
220
330
  getViewComponent: (viewId: string) => TView,
221
331
  mixer?: SourceMixer,
222
- options?: { defaultBatchLimit?: number; initialReviewCap?: number }
332
+ options?: {
333
+ defaultBatchLimit?: number;
334
+ initialReviewCap?: number;
335
+ outcomeObservers?: OutcomeObserver[];
336
+ }
223
337
  ) {
224
338
  super();
225
339
 
@@ -249,6 +363,9 @@ export class SessionController<TView = unknown> extends Loggable {
249
363
  if (options?.initialReviewCap !== undefined) {
250
364
  this._initialReviewCap = options.initialReviewCap;
251
365
  }
366
+ if (options?.outcomeObservers?.length) {
367
+ this._outcomeObservers = [...options.outcomeObservers];
368
+ }
252
369
 
253
370
  this.log(`Session constructed:
254
371
  startTime: ${this.startTime}
@@ -409,6 +526,7 @@ export class SessionController<TView = unknown> extends Loggable {
409
526
  if (opts.minFollowUpCards !== undefined) return true;
410
527
  if (opts.mode && opts.mode !== 'replace') return true;
411
528
  if (opts.hints && Object.keys(opts.hints).length > 0) return true;
529
+ if (opts.sessionHints !== undefined) return true;
412
530
  return false;
413
531
  }
414
532
 
@@ -455,17 +573,21 @@ export class SessionController<TView = unknown> extends Loggable {
455
573
 
456
574
  hints.excludeCards = [...excludeSet];
457
575
 
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
- }
576
+ // Replace session-durable hints if this replan carries them. KISS:
577
+ // wholesale replace, no accumulation/decay (see ReplanOptions.sessionHints).
578
+ if (opts.sessionHints !== undefined) {
579
+ this._sessionHints = opts.sessionHints;
580
+ this.log(
581
+ `[Replan] Session hints ${opts.sessionHints ? 'set' : 'cleared'}: ` +
582
+ `${JSON.stringify(opts.sessionHints)}`
583
+ );
467
584
  }
468
585
 
586
+ // Forward hints to all sources (CourseDB stashes them, Pipeline consumes
587
+ // them). The one-shot `opts.hints` are merged with the durable
588
+ // `_sessionHints` so session emphasis survives this and every later run.
589
+ this._applyHintsToSources(opts.hints, opts.label);
590
+
469
591
  const labelTag = opts.label ? ` [${opts.label}]` : '';
470
592
  this.log(
471
593
  `Mid-session replan requested${labelTag}` +
@@ -487,6 +609,117 @@ export class SessionController<TView = unknown> extends Loggable {
487
609
  // );
488
610
  }
489
611
 
612
+ /**
613
+ * Set the session-durable scoring hints (replace semantics, no decay).
614
+ *
615
+ * Unlike a one-shot replan hint, these are re-merged into every pipeline
616
+ * run for the rest of the session — including the initial plan when set
617
+ * before `prepareSession()`, every replan, the bare auto-replans, and the
618
+ * wedge-breaker. Pass `null` to clear.
619
+ *
620
+ * Typical callers:
621
+ * - `StudySession` at session start, threading `StudySessionConfig.initHints`
622
+ * (e.g. a post-lesson concept boost) — so the boost outlives the first
623
+ * queue rebuild instead of being clobbered by the first auto-replan.
624
+ * - A consumer view on a failure, boosting the just-failed concept tag.
625
+ *
626
+ * Does not itself trigger a replan; the next plan/replan picks it up.
627
+ */
628
+ public setSessionHints(hints: ReplanHints | null): void {
629
+ this._sessionHints = hints;
630
+ this.log(`Session hints ${hints ? 'set' : 'cleared'}: ${JSON.stringify(hints)}`);
631
+ }
632
+
633
+ /**
634
+ * Read the current session-durable hints (for read-modify-write callers,
635
+ * e.g. an outcome observer that clamps a compounding boost).
636
+ */
637
+ public getSessionHints(): ReplanHints | null {
638
+ return this._sessionHints;
639
+ }
640
+
641
+ /**
642
+ * Merge `hints` into the durable session hints via the pipeline's
643
+ * `mergeHints` (boosts multiply, require/exclude lists concat-dedup).
644
+ * Convenience over get-then-set for the common additive case. Note the
645
+ * multiplicative, no-decay semantics — clamp boost factors at the call
646
+ * site if a repeatedly-emphasised tag could compound unboundedly.
647
+ */
648
+ public mergeSessionHints(hints: ReplanHints): void {
649
+ this._sessionHints = mergeHints([this._sessionHints, hints]) ?? null;
650
+ this.log(`Session hints merged: ${JSON.stringify(this._sessionHints)}`);
651
+ }
652
+
653
+ /**
654
+ * Merge the durable `_sessionHints` with this run's one-shot hints and
655
+ * push the result to every source for consumption on the next pipeline
656
+ * run. Centralised so the initial plan and all replan paths apply session
657
+ * emphasis identically. No-op when there are no hints of either kind.
658
+ */
659
+ private _applyHintsToSources(oneShot?: ReplanHints, label?: string): void {
660
+ // Thread the provenance label into the one-shot layer; mergeHints will
661
+ // fold it into the combined `_label`.
662
+ const oneShotWithLabel: ReplanHints | undefined =
663
+ oneShot && label ? { ...oneShot, _label: label } : oneShot;
664
+
665
+ const merged = mergeHints([this._sessionHints, oneShotWithLabel]);
666
+ if (!merged) return;
667
+
668
+ for (const source of this.sources) {
669
+ source.setEphemeralHints?.(merged);
670
+ }
671
+ }
672
+
673
+ /**
674
+ * Build (once) the stable capability object handed to outcome observers.
675
+ * Methods are bound to `this`; the object identity is stable across calls
676
+ * so observers may key off it.
677
+ */
678
+ private _getSessionControls(): SessionControls {
679
+ if (!this._sessionControls) {
680
+ this._sessionControls = {
681
+ getSessionHints: () => this.getSessionHints(),
682
+ setSessionHints: (h) => this.setSessionHints(h),
683
+ mergeSessionHints: (h) => this.mergeSessionHints(h),
684
+ requestReplan: (opts) => this.requestReplan(opts),
685
+ };
686
+ }
687
+ return this._sessionControls;
688
+ }
689
+
690
+ /**
691
+ * Notify registered outcome observers about a processed response.
692
+ *
693
+ * Only question records are surfaced (non-question dismisses are skipped).
694
+ * Observers run after ELO/SRS are recorded and before navigation. Each is
695
+ * awaited but isolated in try/catch — a throwing observer is logged and
696
+ * skipped, never wedging the session. Keep observers cheap and `void` any
697
+ * long work (e.g. a triggered replan) to avoid stalling the draw.
698
+ */
699
+ private async _notifyOutcomeObservers(
700
+ record: CardRecord,
701
+ currentCard: StudySessionRecord,
702
+ result: ResponseResult
703
+ ): Promise<void> {
704
+ if (this._outcomeObservers.length === 0) return;
705
+ if (!isQuestionRecord(record)) return;
706
+
707
+ const outcome: SessionOutcome = {
708
+ record,
709
+ card: currentCard.card,
710
+ result,
711
+ };
712
+ const controls = this._getSessionControls();
713
+
714
+ for (const observer of this._outcomeObservers) {
715
+ try {
716
+ await observer(outcome, controls);
717
+ } catch (e) {
718
+ this.error('[OutcomeObserver] observer threw; ignoring', e);
719
+ }
720
+ }
721
+ }
722
+
490
723
  /**
491
724
  * Run a replan, bypassing requestReplan()'s coalesce logic.
492
725
  *
@@ -519,7 +752,7 @@ export class SessionController<TView = unknown> extends Loggable {
519
752
  if (!input) return {};
520
753
 
521
754
  // If the input has any ReplanOptions-specific key, treat it as ReplanOptions
522
- const replanKeys = ['hints', 'limit', 'mode', 'label', 'minFollowUpCards'];
755
+ const replanKeys = ['hints', 'sessionHints', 'limit', 'mode', 'label', 'minFollowUpCards'];
523
756
  const inputKeys = Object.keys(input);
524
757
  if (inputKeys.some((k) => replanKeys.includes(k))) {
525
758
  return input as ReplanOptions;
@@ -706,6 +939,14 @@ export class SessionController<TView = unknown> extends Loggable {
706
939
  // never touch reviewQ, so the inflation is unnecessary there.
707
940
  const fetchLimit = replan ? newLimit : newLimit + this._initialReviewCap;
708
941
 
942
+ // Initial plan: push session-durable hints to sources so the very first
943
+ // pipeline run reflects them (e.g. a post-lesson boost). Replans push
944
+ // their own session+one-shot merge via _runReplan before reaching here,
945
+ // so we must NOT re-apply here or we'd drop their per-run excludeCards.
946
+ if (!replan) {
947
+ this._applyHintsToSources();
948
+ }
949
+
709
950
  // Collect batches from each source
710
951
  const batches: SourceBatch[] = [];
711
952
 
@@ -1192,7 +1433,7 @@ export class SessionController<TView = unknown> extends Loggable {
1192
1433
  ...currentCard.item,
1193
1434
  };
1194
1435
 
1195
- return await this.services.response.processResponse(
1436
+ const result = await this.services.response.processResponse(
1196
1437
  cardRecord,
1197
1438
  cardHistory,
1198
1439
  studySessionItem,
@@ -1204,6 +1445,14 @@ export class SessionController<TView = unknown> extends Loggable {
1204
1445
  maxSessionViews,
1205
1446
  sessionViews
1206
1447
  );
1448
+
1449
+ // Surface the processed outcome to any registered observers (e.g. a
1450
+ // difficulty-booster that bumps session hints on a failed exercise tag).
1451
+ // Runs after ELO/SRS recording, before the caller navigates. Isolated so
1452
+ // a faulty observer can't break response handling.
1453
+ await this._notifyOutcomeObservers(cardRecord, currentCard, result);
1454
+
1455
+ return result;
1207
1456
  }
1208
1457
 
1209
1458
  private dismissCurrentCard(action: SessionAction = 'dismiss-success') {