@vue-skuilder/db 0.1.31 → 0.1.32-b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -307,6 +307,34 @@ declare class QuotaRoundRobinMixer implements SourceMixer {
307
307
  mix(batches: SourceBatch[], limit: number): WeightedCard[];
308
308
  }
309
309
 
310
+ /**
311
+ * Options for requesting a mid-session replan.
312
+ *
313
+ * All fields are optional — callers can pass just the fields they need.
314
+ * When omitted, defaults match the existing behaviour (full 20-card
315
+ * replace with no hints).
316
+ */
317
+ interface ReplanOptions {
318
+ /** Scoring hints forwarded to the pipeline (boost/exclude/require). */
319
+ hints?: Record<string, unknown>;
320
+ /**
321
+ * Maximum number of new cards to return from the pipeline.
322
+ * Default: 20 (the standard session batch size).
323
+ */
324
+ limit?: number;
325
+ /**
326
+ * How to integrate the new cards into the existing newQ.
327
+ * - `'replace'` (default): atomically swap the entire newQ.
328
+ * - `'merge'`: insert new cards at the front, keeping existing cards.
329
+ */
330
+ mode?: 'replace' | 'merge';
331
+ /**
332
+ * Human-readable label for debugging / provenance.
333
+ * Appears in console logs and in card provenance entries created
334
+ * by ephemeral hint application.
335
+ */
336
+ label?: string;
337
+ }
310
338
  interface StudySessionRecord {
311
339
  card: {
312
340
  course_id: string;
@@ -321,6 +349,13 @@ type SessionAction = 'dismiss-success' | 'dismiss-failed' | 'marked-failed' | 'd
321
349
  interface ResponseResult {
322
350
  nextCardAction: Exclude<SessionAction, 'dismiss-error'> | 'none';
323
351
  shouldLoadNextCard: boolean;
352
+ /**
353
+ * When true, the card requested deferred advancement via `deferAdvance`.
354
+ * The record was logged and ELO updated, but navigation was suppressed.
355
+ * StudySession should stash `nextCardAction` and wait for a
356
+ * `ready-to-advance` event from the card before calling `nextCard()`.
357
+ */
358
+ deferred?: boolean;
324
359
  isCorrect: boolean;
325
360
  performanceScore?: number;
326
361
  shouldClearFeedbackShadow: boolean;
@@ -337,6 +372,12 @@ declare class SessionController<TView = unknown> extends Loggable {
337
372
  private mixer;
338
373
  private dataLayer;
339
374
  private courseNameCache;
375
+ /**
376
+ * Default pipeline batch size for new-card planning.
377
+ * Set via constructor options; falls back to 20 when not specified.
378
+ * Individual replans can override via `ReplanOptions.limit`.
379
+ */
380
+ private _defaultBatchLimit;
340
381
  private sources;
341
382
  private _sessionRecord;
342
383
  set sessionRecord(r: StudySessionRecord[]);
@@ -356,6 +397,20 @@ declare class SessionController<TView = unknown> extends Loggable {
356
397
  * (user state has changed from completing good cards).
357
398
  */
358
399
  private _wellIndicatedRemaining;
400
+ /**
401
+ * When true, suppresses the quality-based auto-replan trigger in
402
+ * nextCard(). Set after a burst replan (small limit) to prevent the
403
+ * auto-replan from clobbering the burst cards before they're consumed.
404
+ * Cleared when the depletion-triggered replan fires (newQ exhausted).
405
+ */
406
+ private _suppressQualityReplan;
407
+ /**
408
+ * Guards against infinite depletion-triggered replans. Set to true
409
+ * when a depletion replan fires; cleared when a replan produces
410
+ * content (newQ.length > 0 after replan) or when an explicit
411
+ * (non-auto) replan is requested.
412
+ */
413
+ private _depletionReplanAttempted;
359
414
  private startTime;
360
415
  private endTime;
361
416
  private _secondsRemaining;
@@ -369,8 +424,14 @@ declare class SessionController<TView = unknown> extends Loggable {
369
424
  * @param dataLayer - Data layer provider
370
425
  * @param getViewComponent - Function to resolve view components
371
426
  * @param mixer - Optional source mixer strategy (defaults to QuotaRoundRobinMixer)
372
- */
373
- constructor(sources: StudyContentSource[], time: number, dataLayer: DataLayerProvider, getViewComponent: (viewId: string) => TView, mixer?: SourceMixer);
427
+ * @param options - Optional session-level configuration
428
+ * @param options.defaultBatchLimit - Default pipeline batch size (default: 20).
429
+ * Smaller values for newer users cause more frequent replans, keeping plans
430
+ * aligned with rapidly-changing user state.
431
+ */
432
+ constructor(sources: StudyContentSource[], time: number, dataLayer: DataLayerProvider, getViewComponent: (viewId: string) => TView, mixer?: SourceMixer, options?: {
433
+ defaultBatchLimit?: number;
434
+ });
374
435
  private tick;
375
436
  /**
376
437
  * Returns a rough, erring toward conservative, guess at
@@ -401,7 +462,14 @@ declare class SessionController<TView = unknown> extends Loggable {
401
462
  * Typical trigger: application-level code (e.g. after a GPC intro completion)
402
463
  * calls this to ensure newly-unlocked content appears in the session.
403
464
  */
404
- requestReplan(hints?: Record<string, unknown>): Promise<void>;
465
+ requestReplan(options?: ReplanOptions | Record<string, unknown>): Promise<void>;
466
+ /**
467
+ * Normalise the requestReplan argument. Accepts either a ReplanOptions
468
+ * object (new API) or a plain Record<string, unknown> (legacy callers
469
+ * that passed hints directly). Distinguishes the two by checking for
470
+ * the presence of ReplanOptions-specific keys.
471
+ */
472
+ private normalizeReplanOptions;
405
473
  /** Minimum well-indicated cards before an additive retry is attempted */
406
474
  private static readonly MIN_WELL_INDICATED;
407
475
  /**
@@ -467,6 +535,8 @@ declare class SessionController<TView = unknown> extends Loggable {
467
535
  };
468
536
  replan: {
469
537
  inProgress: boolean;
538
+ suppressQualityReplan: boolean;
539
+ defaultBatchLimit: number;
470
540
  };
471
541
  };
472
542
  /**
@@ -1017,4 +1087,4 @@ interface CouchDbUserDoc extends PouchDB.Authentication.User {
1017
1087
  entitlements: UserEntitlements;
1018
1088
  }
1019
1089
 
1020
- export { type AggregatedDocument, type AttachmentUploadResult, CardHistory, type CardPresentation, CardRecord, type CouchDbUserDoc, CourseDBInterface, CourseLookup, CourseRegistrationDoc, type CustomQuestionsData, DEFAULT_MIGRATION_OPTIONS, type DataLayerConfig, DataLayerProvider, type DocumentCounts, ENV, type Entitlement, FileSystemAdapter, Loggable, type MigrationOptions, type MigrationResult, type MixerCardInfo, type MixerRunReport, NOT_SET, type ProcessedDataShape, type ProcessedQuestionData, type QueueSnapshot, QuotaRoundRobinMixer, type ResponseResult, type RestoreProgress, type SessionAction, SessionController, type SessionRunReport, type SourceBatch, type SourceMixer, type SourceSelectionBreakdown, type SourceSummary, StaticCourseManifest, type StaticCourseValidation, StaticToCouchDBMigrator, StudyContentSource, StudySessionItem, type StudySessionRecord, TagFilteredContentSource, type UserAccountStatus, UserDBInterface, type UserEntitlements, type ValidationIssue, type ValidationResult, WeightedCard, _resetDataLayer, captureMixerRun, endSessionTracking, ensureAppDataDirectory, getAppDataDirectory, getDataLayer, getDbPath, initializeDataDirectory, initializeDataLayer, isDataShapeRegistered, isDataShapeSchemaAvailable, isQuestionTypeRegistered, mixerDebugAPI, mountMixerDebugger, mountSessionDebugger, processCustomQuestionsData, recordCardPresentation, registerBlanksCard, registerCustomQuestionTypes, registerDataShape, registerQuestionType, registerSeedData, removeCustomQuestionTypes, removeDataShape, removeQuestionType, sessionDebugAPI, snapshotQueues, startSessionTracking, validateMigration, validateStaticCourse };
1090
+ export { type AggregatedDocument, type AttachmentUploadResult, CardHistory, type CardPresentation, CardRecord, type CouchDbUserDoc, CourseDBInterface, CourseLookup, CourseRegistrationDoc, type CustomQuestionsData, DEFAULT_MIGRATION_OPTIONS, type DataLayerConfig, DataLayerProvider, type DocumentCounts, ENV, type Entitlement, FileSystemAdapter, Loggable, type MigrationOptions, type MigrationResult, type MixerCardInfo, type MixerRunReport, NOT_SET, type ProcessedDataShape, type ProcessedQuestionData, type QueueSnapshot, QuotaRoundRobinMixer, type ReplanOptions, type ResponseResult, type RestoreProgress, type SessionAction, SessionController, type SessionRunReport, type SourceBatch, type SourceMixer, type SourceSelectionBreakdown, type SourceSummary, StaticCourseManifest, type StaticCourseValidation, StaticToCouchDBMigrator, StudyContentSource, StudySessionItem, type StudySessionRecord, TagFilteredContentSource, type UserAccountStatus, UserDBInterface, type UserEntitlements, type ValidationIssue, type ValidationResult, WeightedCard, _resetDataLayer, captureMixerRun, endSessionTracking, ensureAppDataDirectory, getAppDataDirectory, getDataLayer, getDbPath, initializeDataDirectory, initializeDataLayer, isDataShapeRegistered, isDataShapeSchemaAvailable, isQuestionTypeRegistered, mixerDebugAPI, mountMixerDebugger, mountSessionDebugger, processCustomQuestionsData, recordCardPresentation, registerBlanksCard, registerCustomQuestionTypes, registerDataShape, registerQuestionType, registerSeedData, removeCustomQuestionTypes, removeDataShape, removeQuestionType, sessionDebugAPI, snapshotQueues, startSessionTracking, validateMigration, validateStaticCourse };
package/dist/index.d.ts CHANGED
@@ -307,6 +307,34 @@ declare class QuotaRoundRobinMixer implements SourceMixer {
307
307
  mix(batches: SourceBatch[], limit: number): WeightedCard[];
308
308
  }
309
309
 
310
+ /**
311
+ * Options for requesting a mid-session replan.
312
+ *
313
+ * All fields are optional — callers can pass just the fields they need.
314
+ * When omitted, defaults match the existing behaviour (full 20-card
315
+ * replace with no hints).
316
+ */
317
+ interface ReplanOptions {
318
+ /** Scoring hints forwarded to the pipeline (boost/exclude/require). */
319
+ hints?: Record<string, unknown>;
320
+ /**
321
+ * Maximum number of new cards to return from the pipeline.
322
+ * Default: 20 (the standard session batch size).
323
+ */
324
+ limit?: number;
325
+ /**
326
+ * How to integrate the new cards into the existing newQ.
327
+ * - `'replace'` (default): atomically swap the entire newQ.
328
+ * - `'merge'`: insert new cards at the front, keeping existing cards.
329
+ */
330
+ mode?: 'replace' | 'merge';
331
+ /**
332
+ * Human-readable label for debugging / provenance.
333
+ * Appears in console logs and in card provenance entries created
334
+ * by ephemeral hint application.
335
+ */
336
+ label?: string;
337
+ }
310
338
  interface StudySessionRecord {
311
339
  card: {
312
340
  course_id: string;
@@ -321,6 +349,13 @@ type SessionAction = 'dismiss-success' | 'dismiss-failed' | 'marked-failed' | 'd
321
349
  interface ResponseResult {
322
350
  nextCardAction: Exclude<SessionAction, 'dismiss-error'> | 'none';
323
351
  shouldLoadNextCard: boolean;
352
+ /**
353
+ * When true, the card requested deferred advancement via `deferAdvance`.
354
+ * The record was logged and ELO updated, but navigation was suppressed.
355
+ * StudySession should stash `nextCardAction` and wait for a
356
+ * `ready-to-advance` event from the card before calling `nextCard()`.
357
+ */
358
+ deferred?: boolean;
324
359
  isCorrect: boolean;
325
360
  performanceScore?: number;
326
361
  shouldClearFeedbackShadow: boolean;
@@ -337,6 +372,12 @@ declare class SessionController<TView = unknown> extends Loggable {
337
372
  private mixer;
338
373
  private dataLayer;
339
374
  private courseNameCache;
375
+ /**
376
+ * Default pipeline batch size for new-card planning.
377
+ * Set via constructor options; falls back to 20 when not specified.
378
+ * Individual replans can override via `ReplanOptions.limit`.
379
+ */
380
+ private _defaultBatchLimit;
340
381
  private sources;
341
382
  private _sessionRecord;
342
383
  set sessionRecord(r: StudySessionRecord[]);
@@ -356,6 +397,20 @@ declare class SessionController<TView = unknown> extends Loggable {
356
397
  * (user state has changed from completing good cards).
357
398
  */
358
399
  private _wellIndicatedRemaining;
400
+ /**
401
+ * When true, suppresses the quality-based auto-replan trigger in
402
+ * nextCard(). Set after a burst replan (small limit) to prevent the
403
+ * auto-replan from clobbering the burst cards before they're consumed.
404
+ * Cleared when the depletion-triggered replan fires (newQ exhausted).
405
+ */
406
+ private _suppressQualityReplan;
407
+ /**
408
+ * Guards against infinite depletion-triggered replans. Set to true
409
+ * when a depletion replan fires; cleared when a replan produces
410
+ * content (newQ.length > 0 after replan) or when an explicit
411
+ * (non-auto) replan is requested.
412
+ */
413
+ private _depletionReplanAttempted;
359
414
  private startTime;
360
415
  private endTime;
361
416
  private _secondsRemaining;
@@ -369,8 +424,14 @@ declare class SessionController<TView = unknown> extends Loggable {
369
424
  * @param dataLayer - Data layer provider
370
425
  * @param getViewComponent - Function to resolve view components
371
426
  * @param mixer - Optional source mixer strategy (defaults to QuotaRoundRobinMixer)
372
- */
373
- constructor(sources: StudyContentSource[], time: number, dataLayer: DataLayerProvider, getViewComponent: (viewId: string) => TView, mixer?: SourceMixer);
427
+ * @param options - Optional session-level configuration
428
+ * @param options.defaultBatchLimit - Default pipeline batch size (default: 20).
429
+ * Smaller values for newer users cause more frequent replans, keeping plans
430
+ * aligned with rapidly-changing user state.
431
+ */
432
+ constructor(sources: StudyContentSource[], time: number, dataLayer: DataLayerProvider, getViewComponent: (viewId: string) => TView, mixer?: SourceMixer, options?: {
433
+ defaultBatchLimit?: number;
434
+ });
374
435
  private tick;
375
436
  /**
376
437
  * Returns a rough, erring toward conservative, guess at
@@ -401,7 +462,14 @@ declare class SessionController<TView = unknown> extends Loggable {
401
462
  * Typical trigger: application-level code (e.g. after a GPC intro completion)
402
463
  * calls this to ensure newly-unlocked content appears in the session.
403
464
  */
404
- requestReplan(hints?: Record<string, unknown>): Promise<void>;
465
+ requestReplan(options?: ReplanOptions | Record<string, unknown>): Promise<void>;
466
+ /**
467
+ * Normalise the requestReplan argument. Accepts either a ReplanOptions
468
+ * object (new API) or a plain Record<string, unknown> (legacy callers
469
+ * that passed hints directly). Distinguishes the two by checking for
470
+ * the presence of ReplanOptions-specific keys.
471
+ */
472
+ private normalizeReplanOptions;
405
473
  /** Minimum well-indicated cards before an additive retry is attempted */
406
474
  private static readonly MIN_WELL_INDICATED;
407
475
  /**
@@ -467,6 +535,8 @@ declare class SessionController<TView = unknown> extends Loggable {
467
535
  };
468
536
  replan: {
469
537
  inProgress: boolean;
538
+ suppressQualityReplan: boolean;
539
+ defaultBatchLimit: number;
470
540
  };
471
541
  };
472
542
  /**
@@ -1017,4 +1087,4 @@ interface CouchDbUserDoc extends PouchDB.Authentication.User {
1017
1087
  entitlements: UserEntitlements;
1018
1088
  }
1019
1089
 
1020
- export { type AggregatedDocument, type AttachmentUploadResult, CardHistory, type CardPresentation, CardRecord, type CouchDbUserDoc, CourseDBInterface, CourseLookup, CourseRegistrationDoc, type CustomQuestionsData, DEFAULT_MIGRATION_OPTIONS, type DataLayerConfig, DataLayerProvider, type DocumentCounts, ENV, type Entitlement, FileSystemAdapter, Loggable, type MigrationOptions, type MigrationResult, type MixerCardInfo, type MixerRunReport, NOT_SET, type ProcessedDataShape, type ProcessedQuestionData, type QueueSnapshot, QuotaRoundRobinMixer, type ResponseResult, type RestoreProgress, type SessionAction, SessionController, type SessionRunReport, type SourceBatch, type SourceMixer, type SourceSelectionBreakdown, type SourceSummary, StaticCourseManifest, type StaticCourseValidation, StaticToCouchDBMigrator, StudyContentSource, StudySessionItem, type StudySessionRecord, TagFilteredContentSource, type UserAccountStatus, UserDBInterface, type UserEntitlements, type ValidationIssue, type ValidationResult, WeightedCard, _resetDataLayer, captureMixerRun, endSessionTracking, ensureAppDataDirectory, getAppDataDirectory, getDataLayer, getDbPath, initializeDataDirectory, initializeDataLayer, isDataShapeRegistered, isDataShapeSchemaAvailable, isQuestionTypeRegistered, mixerDebugAPI, mountMixerDebugger, mountSessionDebugger, processCustomQuestionsData, recordCardPresentation, registerBlanksCard, registerCustomQuestionTypes, registerDataShape, registerQuestionType, registerSeedData, removeCustomQuestionTypes, removeDataShape, removeQuestionType, sessionDebugAPI, snapshotQueues, startSessionTracking, validateMigration, validateStaticCourse };
1090
+ export { type AggregatedDocument, type AttachmentUploadResult, CardHistory, type CardPresentation, CardRecord, type CouchDbUserDoc, CourseDBInterface, CourseLookup, CourseRegistrationDoc, type CustomQuestionsData, DEFAULT_MIGRATION_OPTIONS, type DataLayerConfig, DataLayerProvider, type DocumentCounts, ENV, type Entitlement, FileSystemAdapter, Loggable, type MigrationOptions, type MigrationResult, type MixerCardInfo, type MixerRunReport, NOT_SET, type ProcessedDataShape, type ProcessedQuestionData, type QueueSnapshot, QuotaRoundRobinMixer, type ReplanOptions, type ResponseResult, type RestoreProgress, type SessionAction, SessionController, type SessionRunReport, type SourceBatch, type SourceMixer, type SourceSelectionBreakdown, type SourceSummary, StaticCourseManifest, type StaticCourseValidation, StaticToCouchDBMigrator, StudyContentSource, StudySessionItem, type StudySessionRecord, TagFilteredContentSource, type UserAccountStatus, UserDBInterface, type UserEntitlements, type ValidationIssue, type ValidationResult, WeightedCard, _resetDataLayer, captureMixerRun, endSessionTracking, ensureAppDataDirectory, getAppDataDirectory, getDataLayer, getDbPath, initializeDataDirectory, initializeDataLayer, isDataShapeRegistered, isDataShapeSchemaAvailable, isQuestionTypeRegistered, mixerDebugAPI, mountMixerDebugger, mountSessionDebugger, processCustomQuestionsData, recordCardPresentation, registerBlanksCard, registerCustomQuestionTypes, registerDataShape, registerQuestionType, registerSeedData, removeCustomQuestionTypes, removeDataShape, removeQuestionType, sessionDebugAPI, snapshotQueues, startSessionTracking, validateMigration, validateStaticCourse };
package/dist/index.js CHANGED
@@ -1936,6 +1936,7 @@ var init_hierarchyDefinition = __esm({
1936
1936
  "use strict";
1937
1937
  init_navigators();
1938
1938
  import_common6 = require("@vue-skuilder/common");
1939
+ init_logger();
1939
1940
  DEFAULT_MIN_COUNT = 3;
1940
1941
  HierarchyDefinitionNavigator = class extends ContentNavigator {
1941
1942
  config;
@@ -2103,6 +2104,9 @@ var init_hierarchyDefinition = __esm({
2103
2104
  finalScore *= maxBoost;
2104
2105
  action = "boosted";
2105
2106
  finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
2107
+ logger.info(
2108
+ `[HierarchyDefinition] preReqBoost \xD7${maxBoost.toFixed(2)} applied to card ${card.cardId} via tags [${boostedPrereqs.join(", ")}] (score: ${card.score.toFixed(3)} \u2192 ${finalScore.toFixed(3)})`
2109
+ );
2106
2110
  }
2107
2111
  }
2108
2112
  gated.push({
@@ -2612,7 +2616,7 @@ var init_relativePriority = __esm({
2612
2616
  const cardTags = card.tags ?? [];
2613
2617
  const priority = this.computeCardPriority(cardTags);
2614
2618
  const boostFactor = this.computeBoostFactor(priority);
2615
- const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
2619
+ const finalScore = Math.max(0, card.score * boostFactor);
2616
2620
  const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
2617
2621
  const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
2618
2622
  return {
@@ -3377,7 +3381,7 @@ var init_Pipeline = __esm({
3377
3381
  card.provenance.push({
3378
3382
  strategy: "ephemeralHint",
3379
3383
  strategyId: "ephemeral-hint",
3380
- strategyName: "Replan Hint",
3384
+ strategyName: hints._label ? `Replan Hint (${hints._label})` : "Replan Hint",
3381
3385
  action: "boosted",
3382
3386
  score: card.score,
3383
3387
  reason: `boostTag ${pattern} \xD7${factor}`
@@ -3394,7 +3398,7 @@ var init_Pipeline = __esm({
3394
3398
  card.provenance.push({
3395
3399
  strategy: "ephemeralHint",
3396
3400
  strategyId: "ephemeral-hint",
3397
- strategyName: "Replan Hint",
3401
+ strategyName: hints._label ? `Replan Hint (${hints._label})` : "Replan Hint",
3398
3402
  action: "boosted",
3399
3403
  score: card.score,
3400
3404
  reason: `boostCard ${pattern} \xD7${factor}`
@@ -3404,6 +3408,7 @@ var init_Pipeline = __esm({
3404
3408
  }
3405
3409
  }
3406
3410
  const cardIds = new Set(cards.map((c) => c.cardId));
3411
+ const hintLabel = hints._label ? `Replan Hint (${hints._label})` : "Replan Hint";
3407
3412
  const inject = (card, reason) => {
3408
3413
  if (!cardIds.has(card.cardId)) {
3409
3414
  const floorScore = Math.max(card.score, 1);
@@ -3415,7 +3420,7 @@ var init_Pipeline = __esm({
3415
3420
  {
3416
3421
  strategy: "ephemeralHint",
3417
3422
  strategyId: "ephemeral-hint",
3418
- strategyName: "Replan Hint",
3423
+ strategyName: hintLabel,
3419
3424
  action: "boosted",
3420
3425
  score: floorScore,
3421
3426
  reason
@@ -4664,10 +4669,18 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4664
4669
  * @param limit - Maximum number of cards to return
4665
4670
  * @returns Cards sorted by score descending
4666
4671
  */
4672
+ _pendingHints = null;
4673
+ setEphemeralHints(hints) {
4674
+ this._pendingHints = hints;
4675
+ }
4667
4676
  async getWeightedCards(limit) {
4668
4677
  const u = await this._getCurrentUser();
4669
4678
  try {
4670
4679
  const navigator = await this.createNavigator(u);
4680
+ if (this._pendingHints) {
4681
+ navigator.setEphemeralHints(this._pendingHints);
4682
+ this._pendingHints = null;
4683
+ }
4671
4684
  return navigator.getWeightedCards(limit);
4672
4685
  } catch (e) {
4673
4686
  logger.error(`[courseDB] Error getting weighted cards: ${e}`);
@@ -7659,9 +7672,17 @@ var init_courseDB2 = __esm({
7659
7672
  }
7660
7673
  }
7661
7674
  // Study Content Source implementation
7675
+ _pendingHints = null;
7676
+ setEphemeralHints(hints) {
7677
+ this._pendingHints = hints;
7678
+ }
7662
7679
  async getWeightedCards(limit) {
7663
7680
  try {
7664
7681
  const navigator = await this.createNavigator(this.userDB);
7682
+ if (this._pendingHints) {
7683
+ navigator.setEphemeralHints(this._pendingHints);
7684
+ this._pendingHints = null;
7685
+ }
7665
7686
  return navigator.getWeightedCards(limit);
7666
7687
  } catch (e) {
7667
7688
  logger.error(`[static/courseDB] Error getting weighted cards: ${e}`);
@@ -9425,8 +9446,9 @@ var ResponseProcessor = class {
9425
9446
  }
9426
9447
  try {
9427
9448
  const history = await cardHistory;
9449
+ let result;
9428
9450
  if (cardRecord.isCorrect) {
9429
- return this.processCorrectResponse(
9451
+ result = this.processCorrectResponse(
9430
9452
  cardRecord,
9431
9453
  history,
9432
9454
  studySessionItem,
@@ -9436,7 +9458,7 @@ var ResponseProcessor = class {
9436
9458
  cardId
9437
9459
  );
9438
9460
  } else {
9439
- return this.processIncorrectResponse(
9461
+ result = this.processIncorrectResponse(
9440
9462
  cardRecord,
9441
9463
  history,
9442
9464
  courseRegistrationDoc,
@@ -9448,6 +9470,18 @@ var ResponseProcessor = class {
9448
9470
  sessionViews
9449
9471
  );
9450
9472
  }
9473
+ if (cardRecord.deferAdvance && result.shouldLoadNextCard) {
9474
+ logger.info(
9475
+ "[ResponseProcessor] deferAdvance requested \u2014 suppressing navigation, action stashed:",
9476
+ { nextCardAction: result.nextCardAction }
9477
+ );
9478
+ result = {
9479
+ ...result,
9480
+ shouldLoadNextCard: false,
9481
+ deferred: true
9482
+ };
9483
+ }
9484
+ return result;
9451
9485
  } catch (e) {
9452
9486
  logger.error("[ResponseProcessor] Failed to load card history", { e, cardId });
9453
9487
  throw e;
@@ -11938,6 +11972,12 @@ var SessionController = class _SessionController extends Loggable {
11938
11972
  mixer;
11939
11973
  dataLayer;
11940
11974
  courseNameCache = /* @__PURE__ */ new Map();
11975
+ /**
11976
+ * Default pipeline batch size for new-card planning.
11977
+ * Set via constructor options; falls back to 20 when not specified.
11978
+ * Individual replans can override via `ReplanOptions.limit`.
11979
+ */
11980
+ _defaultBatchLimit = 20;
11941
11981
  sources;
11942
11982
  // dataLayer and getViewComponent now injected into CardHydrationService
11943
11983
  _sessionRecord = [];
@@ -11962,6 +12002,20 @@ var SessionController = class _SessionController extends Loggable {
11962
12002
  * (user state has changed from completing good cards).
11963
12003
  */
11964
12004
  _wellIndicatedRemaining = 0;
12005
+ /**
12006
+ * When true, suppresses the quality-based auto-replan trigger in
12007
+ * nextCard(). Set after a burst replan (small limit) to prevent the
12008
+ * auto-replan from clobbering the burst cards before they're consumed.
12009
+ * Cleared when the depletion-triggered replan fires (newQ exhausted).
12010
+ */
12011
+ _suppressQualityReplan = false;
12012
+ /**
12013
+ * Guards against infinite depletion-triggered replans. Set to true
12014
+ * when a depletion replan fires; cleared when a replan produces
12015
+ * content (newQ.length > 0 after replan) or when an explicit
12016
+ * (non-auto) replan is requested.
12017
+ */
12018
+ _depletionReplanAttempted = false;
11965
12019
  startTime;
11966
12020
  endTime;
11967
12021
  _secondsRemaining;
@@ -11986,8 +12040,12 @@ var SessionController = class _SessionController extends Loggable {
11986
12040
  * @param dataLayer - Data layer provider
11987
12041
  * @param getViewComponent - Function to resolve view components
11988
12042
  * @param mixer - Optional source mixer strategy (defaults to QuotaRoundRobinMixer)
12043
+ * @param options - Optional session-level configuration
12044
+ * @param options.defaultBatchLimit - Default pipeline batch size (default: 20).
12045
+ * Smaller values for newer users cause more frequent replans, keeping plans
12046
+ * aligned with rapidly-changing user state.
11989
12047
  */
11990
- constructor(sources, time, dataLayer, getViewComponent, mixer) {
12048
+ constructor(sources, time, dataLayer, getViewComponent, mixer, options) {
11991
12049
  super();
11992
12050
  this.dataLayer = dataLayer;
11993
12051
  this.mixer = mixer || new QuotaRoundRobinMixer();
@@ -12005,9 +12063,13 @@ var SessionController = class _SessionController extends Loggable {
12005
12063
  this.startTime = /* @__PURE__ */ new Date();
12006
12064
  this._secondsRemaining = time;
12007
12065
  this.endTime = new Date(this.startTime.valueOf() + 1e3 * this._secondsRemaining);
12066
+ if (options?.defaultBatchLimit !== void 0) {
12067
+ this._defaultBatchLimit = options.defaultBatchLimit;
12068
+ }
12008
12069
  this.log(`Session constructed:
12009
12070
  startTime: ${this.startTime}
12010
- endTime: ${this.endTime}`);
12071
+ endTime: ${this.endTime}
12072
+ defaultBatchLimit: ${this._defaultBatchLimit}`);
12011
12073
  }
12012
12074
  tick() {
12013
12075
  this._secondsRemaining = Math.floor((this.endTime.valueOf() - Date.now()) / 1e3);
@@ -12083,25 +12145,47 @@ var SessionController = class _SessionController extends Loggable {
12083
12145
  * Typical trigger: application-level code (e.g. after a GPC intro completion)
12084
12146
  * calls this to ensure newly-unlocked content appears in the session.
12085
12147
  */
12086
- async requestReplan(hints) {
12148
+ async requestReplan(options) {
12149
+ const opts = this.normalizeReplanOptions(options);
12150
+ if (opts.hints || opts.label || opts.limit) {
12151
+ this._depletionReplanAttempted = false;
12152
+ }
12087
12153
  if (this._replanPromise) {
12088
12154
  this.log("Replan already in progress, awaiting existing replan");
12089
12155
  return this._replanPromise;
12090
12156
  }
12091
- if (hints) {
12157
+ if (opts.hints) {
12158
+ const hintsWithLabel = opts.label ? { ...opts.hints, _label: opts.label } : opts.hints;
12092
12159
  for (const source of this.sources) {
12093
- this.log(`[Hints] source type=${source.constructor.name}, hasMethod=${typeof source.setEphemeralHints}`);
12094
- source.setEphemeralHints?.(hints);
12160
+ source.setEphemeralHints?.(hintsWithLabel);
12095
12161
  }
12096
12162
  }
12097
- this.log(`Mid-session replan requested${hints ? ` (hints: ${JSON.stringify(hints)})` : ""}`);
12098
- this._replanPromise = this._executeReplan();
12163
+ const labelTag = opts.label ? ` [${opts.label}]` : "";
12164
+ this.log(
12165
+ `Mid-session replan requested${labelTag} (limit: ${opts.limit ?? "default"}, mode: ${opts.mode ?? "replace"}${opts.hints ? ", with hints" : ""})`
12166
+ );
12167
+ this._replanPromise = this._executeReplan(opts);
12099
12168
  try {
12100
12169
  await this._replanPromise;
12101
12170
  } finally {
12102
12171
  this._replanPromise = null;
12103
12172
  }
12104
12173
  }
12174
+ /**
12175
+ * Normalise the requestReplan argument. Accepts either a ReplanOptions
12176
+ * object (new API) or a plain Record<string, unknown> (legacy callers
12177
+ * that passed hints directly). Distinguishes the two by checking for
12178
+ * the presence of ReplanOptions-specific keys.
12179
+ */
12180
+ normalizeReplanOptions(input) {
12181
+ if (!input) return {};
12182
+ const replanKeys = ["hints", "limit", "mode", "label"];
12183
+ const inputKeys = Object.keys(input);
12184
+ if (inputKeys.some((k) => replanKeys.includes(k))) {
12185
+ return input;
12186
+ }
12187
+ return { hints: input };
12188
+ }
12105
12189
  /** Minimum well-indicated cards before an additive retry is attempted */
12106
12190
  static MIN_WELL_INDICATED = 5;
12107
12191
  /**
@@ -12120,16 +12204,32 @@ var SessionController = class _SessionController extends Loggable {
12120
12204
  * pass all hierarchy filters, one additive retry is attempted — merging
12121
12205
  * any new high-quality candidates into the front of the queue.
12122
12206
  */
12123
- async _executeReplan() {
12124
- const wellIndicated = await this.getWeightedContent({ replan: true });
12207
+ async _executeReplan(opts = {}) {
12208
+ const limit = opts.limit;
12209
+ const mode = opts.mode ?? "replace";
12210
+ const wellIndicated = await this.getWeightedContent({
12211
+ replan: true,
12212
+ additive: mode === "merge",
12213
+ limit
12214
+ });
12125
12215
  this._wellIndicatedRemaining = wellIndicated;
12216
+ if (limit !== void 0 && limit < this._defaultBatchLimit) {
12217
+ this._suppressQualityReplan = true;
12218
+ this.log(`[Replan] Burst mode (limit=${limit}): suppressing quality-based auto-replan`);
12219
+ } else {
12220
+ this._suppressQualityReplan = false;
12221
+ }
12126
12222
  if (wellIndicated >= 0 && wellIndicated < _SessionController.MIN_WELL_INDICATED) {
12127
12223
  this.log(
12128
12224
  `[Replan] Only ${wellIndicated}/${_SessionController.MIN_WELL_INDICATED} well-indicated cards after replan`
12129
12225
  );
12130
12226
  }
12227
+ if (this.newQ.length > 0) {
12228
+ this._depletionReplanAttempted = false;
12229
+ }
12131
12230
  await this.hydrationService.ensureHydratedCards();
12132
- this.log(`Replan complete: newQ now has ${this.newQ.length} cards`);
12231
+ const labelTag = opts.label ? ` [${opts.label}]` : "";
12232
+ this.log(`Replan complete${labelTag}: newQ now has ${this.newQ.length} cards (mode=${mode})`);
12133
12233
  snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
12134
12234
  }
12135
12235
  addTime(seconds) {
@@ -12189,7 +12289,9 @@ var SessionController = class _SessionController extends Loggable {
12189
12289
  cardIds: this.hydrationService.getHydratedCardIds()
12190
12290
  },
12191
12291
  replan: {
12192
- inProgress: this._replanPromise !== null
12292
+ inProgress: this._replanPromise !== null,
12293
+ suppressQualityReplan: this._suppressQualityReplan,
12294
+ defaultBatchLimit: this._defaultBatchLimit
12193
12295
  }
12194
12296
  };
12195
12297
  }
@@ -12216,7 +12318,7 @@ var SessionController = class _SessionController extends Loggable {
12216
12318
  async getWeightedContent(options) {
12217
12319
  const replan = options?.replan ?? false;
12218
12320
  const additive = options?.additive ?? false;
12219
- const limit = 20;
12321
+ const limit = options?.limit ?? this._defaultBatchLimit;
12220
12322
  const batches = [];
12221
12323
  for (let i = 0; i < this.sources.length; i++) {
12222
12324
  const source = this.sources[i];
@@ -12397,10 +12499,26 @@ var SessionController = class _SessionController extends Loggable {
12397
12499
  this.log("nextCard: awaiting in-flight replan before drawing");
12398
12500
  await this._replanPromise;
12399
12501
  }
12502
+ if (this.newQ.length <= 1 && this._secondsRemaining > 0 && !this._replanPromise && !this._depletionReplanAttempted) {
12503
+ this._suppressQualityReplan = false;
12504
+ this._depletionReplanAttempted = true;
12505
+ const otherContent = this.reviewQ.length + this.failedQ.length;
12506
+ if (this.newQ.length === 0 && otherContent === 0) {
12507
+ this.log(
12508
+ `[AutoReplan:depletion] All queues empty with ${this._secondsRemaining}s remaining. Awaiting replan.`
12509
+ );
12510
+ await this.requestReplan();
12511
+ } else {
12512
+ this.log(
12513
+ `[AutoReplan:depletion] newQ has ${this.newQ.length} card(s) (${otherContent} in other queues) with ${this._secondsRemaining}s remaining. Triggering background replan.`
12514
+ );
12515
+ void this.requestReplan();
12516
+ }
12517
+ }
12400
12518
  const REPLAN_BUFFER = 3;
12401
- if (this._wellIndicatedRemaining <= REPLAN_BUFFER && this.newQ.length > 0 && !this._replanPromise) {
12519
+ if (!this._suppressQualityReplan && this._wellIndicatedRemaining <= REPLAN_BUFFER && this.newQ.length > 0 && !this._replanPromise) {
12402
12520
  this.log(
12403
- `[AutoReplan] ${this._wellIndicatedRemaining} well-indicated cards remaining (newQ: ${this.newQ.length}). Triggering background replan.`
12521
+ `[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining (newQ: ${this.newQ.length}). Triggering background replan.`
12404
12522
  );
12405
12523
  void this.requestReplan();
12406
12524
  }