@vue-skuilder/db 0.1.31-b → 0.1.31

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 (49) hide show
  1. package/dist/{contentSource-ygoFw9oV.d.ts → contentSource-Bdwkvqa8.d.ts} +16 -0
  2. package/dist/{contentSource-B7nXusjk.d.cts → contentSource-DF1nUbPQ.d.cts} +16 -0
  3. package/dist/core/index.d.cts +34 -3
  4. package/dist/core/index.d.ts +34 -3
  5. package/dist/core/index.js +510 -50
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +510 -50
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-BW7HvkMt.d.cts → dataLayerProvider-BKmVoyJR.d.ts} +20 -1
  10. package/dist/{dataLayerProvider-BfXUVDuG.d.ts → dataLayerProvider-BQdfJuBN.d.cts} +20 -1
  11. package/dist/impl/couch/index.d.cts +156 -4
  12. package/dist/impl/couch/index.d.ts +156 -4
  13. package/dist/impl/couch/index.js +730 -41
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +729 -41
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +3 -2
  18. package/dist/impl/static/index.d.ts +3 -2
  19. package/dist/impl/static/index.js +467 -31
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +467 -31
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/index.d.cts +64 -3
  24. package/dist/index.d.ts +64 -3
  25. package/dist/index.js +948 -72
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +948 -72
  28. package/dist/index.mjs.map +1 -1
  29. package/package.json +3 -3
  30. package/src/core/interfaces/contentSource.ts +6 -0
  31. package/src/core/interfaces/courseDB.ts +6 -0
  32. package/src/core/interfaces/dataLayerProvider.ts +20 -0
  33. package/src/core/navigators/Pipeline.ts +414 -9
  34. package/src/core/navigators/PipelineAssembler.ts +23 -18
  35. package/src/core/navigators/PipelineDebugger.ts +35 -1
  36. package/src/core/navigators/filters/hierarchyDefinition.ts +78 -8
  37. package/src/core/navigators/generators/prescribed.ts +95 -0
  38. package/src/core/navigators/index.ts +12 -0
  39. package/src/impl/common/BaseUserDB.ts +4 -1
  40. package/src/impl/couch/CourseSyncService.ts +356 -0
  41. package/src/impl/couch/PouchDataLayerProvider.ts +21 -1
  42. package/src/impl/couch/courseDB.ts +60 -13
  43. package/src/impl/couch/index.ts +1 -0
  44. package/src/impl/static/courseDB.ts +5 -0
  45. package/src/study/ItemQueue.ts +42 -0
  46. package/src/study/SessionController.ts +195 -22
  47. package/src/study/SpacedRepetition.ts +3 -1
  48. package/tests/core/navigators/Pipeline.test.ts +1 -1
  49. package/tests/core/navigators/PipelineAssembler.test.ts +15 -14
package/dist/index.js CHANGED
@@ -878,8 +878,12 @@ __export(PipelineDebugger_exports, {
878
878
  buildRunReport: () => buildRunReport,
879
879
  captureRun: () => captureRun,
880
880
  mountPipelineDebugger: () => mountPipelineDebugger,
881
- pipelineDebugAPI: () => pipelineDebugAPI
881
+ pipelineDebugAPI: () => pipelineDebugAPI,
882
+ registerPipelineForDebug: () => registerPipelineForDebug
882
883
  });
884
+ function registerPipelineForDebug(pipeline) {
885
+ _activePipeline = pipeline;
886
+ }
883
887
  function getOrigin(card) {
884
888
  const firstEntry = card.provenance[0];
885
889
  if (!firstEntry) return "unknown";
@@ -907,6 +911,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
907
911
  origin: getOrigin(card),
908
912
  finalScore: card.score,
909
913
  provenance: card.provenance,
914
+ tags: card.tags,
910
915
  selected: selectedIds.has(card.cardId)
911
916
  }));
912
917
  const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
@@ -962,12 +967,13 @@ function mountPipelineDebugger() {
962
967
  win.skuilder = win.skuilder || {};
963
968
  win.skuilder.pipeline = pipelineDebugAPI;
964
969
  }
965
- var MAX_RUNS, runHistory, pipelineDebugAPI;
970
+ var _activePipeline, MAX_RUNS, runHistory, pipelineDebugAPI;
966
971
  var init_PipelineDebugger = __esm({
967
972
  "src/core/navigators/PipelineDebugger.ts"() {
968
973
  "use strict";
969
974
  init_navigators();
970
975
  init_logger();
976
+ _activePipeline = null;
971
977
  MAX_RUNS = 10;
972
978
  runHistory = [];
973
979
  pipelineDebugAPI = {
@@ -1169,6 +1175,21 @@ var init_PipelineDebugger = __esm({
1169
1175
  }
1170
1176
  console.groupEnd();
1171
1177
  },
1178
+ /**
1179
+ * Scan the full card space through the filter chain for the current user.
1180
+ *
1181
+ * Reports how many cards are well-indicated and how many are new.
1182
+ * Use this to understand how the search space grows during onboarding.
1183
+ *
1184
+ * @param threshold - Score threshold for "well indicated" (default 0.10)
1185
+ */
1186
+ async diagnoseCardSpace(threshold) {
1187
+ if (!_activePipeline) {
1188
+ logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
1189
+ return null;
1190
+ }
1191
+ return _activePipeline.diagnoseCardSpace({ threshold });
1192
+ },
1172
1193
  /**
1173
1194
  * Show help.
1174
1195
  */
@@ -1181,6 +1202,7 @@ Commands:
1181
1202
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
1182
1203
  .showCard(cardId) Show provenance trail for a specific card
1183
1204
  .explainReviews() Analyze why reviews were/weren't selected
1205
+ .diagnoseCardSpace() Scan full card space through filters (async)
1184
1206
  .showRegistry() Show navigator registry (classes + roles)
1185
1207
  .showStrategies() Show registry + strategy mapping from last run
1186
1208
  .listRuns() List all captured runs in table format
@@ -1192,7 +1214,7 @@ Commands:
1192
1214
  Example:
1193
1215
  window.skuilder.pipeline.showLastRun()
1194
1216
  window.skuilder.pipeline.showRun(1)
1195
- window.skuilder.pipeline.showCard('abc123')
1217
+ await window.skuilder.pipeline.diagnoseCardSpace()
1196
1218
  `);
1197
1219
  }
1198
1220
  };
@@ -1487,6 +1509,69 @@ var init_generators = __esm({
1487
1509
  }
1488
1510
  });
1489
1511
 
1512
+ // src/core/navigators/generators/prescribed.ts
1513
+ var prescribed_exports = {};
1514
+ __export(prescribed_exports, {
1515
+ default: () => PrescribedCardsGenerator
1516
+ });
1517
+ var PrescribedCardsGenerator;
1518
+ var init_prescribed = __esm({
1519
+ "src/core/navigators/generators/prescribed.ts"() {
1520
+ "use strict";
1521
+ init_navigators();
1522
+ init_logger();
1523
+ PrescribedCardsGenerator = class extends ContentNavigator {
1524
+ name;
1525
+ config;
1526
+ constructor(user, course, strategyData) {
1527
+ super(user, course, strategyData);
1528
+ this.name = strategyData.name || "Prescribed Cards";
1529
+ try {
1530
+ const parsed = JSON.parse(strategyData.serializedData);
1531
+ this.config = { cardIds: parsed.cardIds || [] };
1532
+ } catch {
1533
+ this.config = { cardIds: [] };
1534
+ }
1535
+ logger.debug(
1536
+ `[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
1537
+ );
1538
+ }
1539
+ async getWeightedCards(limit, _context) {
1540
+ if (this.config.cardIds.length === 0) {
1541
+ return [];
1542
+ }
1543
+ const courseId = this.course.getCourseID();
1544
+ const activeCards = await this.user.getActiveCards();
1545
+ const activeIds = new Set(activeCards.map((ac) => ac.cardID));
1546
+ const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
1547
+ if (eligibleIds.length === 0) {
1548
+ logger.debug("[Prescribed] All prescribed cards already active, returning empty");
1549
+ return [];
1550
+ }
1551
+ const cards = eligibleIds.slice(0, limit).map((cardId) => ({
1552
+ cardId,
1553
+ courseId,
1554
+ score: 1,
1555
+ provenance: [
1556
+ {
1557
+ strategy: "prescribed",
1558
+ strategyName: this.strategyName || this.name,
1559
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1560
+ action: "generated",
1561
+ score: 1,
1562
+ reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
1563
+ }
1564
+ ]
1565
+ }));
1566
+ logger.info(
1567
+ `[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
1568
+ );
1569
+ return cards;
1570
+ }
1571
+ };
1572
+ }
1573
+ });
1574
+
1490
1575
  // src/core/navigators/generators/srs.ts
1491
1576
  var srs_exports = {};
1492
1577
  __export(srs_exports, {
@@ -1681,6 +1766,7 @@ var init_ = __esm({
1681
1766
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
1682
1767
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1683
1768
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
1769
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
1684
1770
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
1685
1771
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
1686
1772
  });
@@ -1881,6 +1967,8 @@ var init_hierarchyDefinition = __esm({
1881
1967
  if (userTagElo.count < minCount) return false;
1882
1968
  if (prereq.masteryThreshold?.minElo !== void 0) {
1883
1969
  return userTagElo.score >= prereq.masteryThreshold.minElo;
1970
+ } else if (prereq.masteryThreshold?.minCount !== void 0) {
1971
+ return true;
1884
1972
  } else {
1885
1973
  return userTagElo.score >= userGlobalElo;
1886
1974
  }
@@ -1956,14 +2044,38 @@ var init_hierarchyDefinition = __esm({
1956
2044
  };
1957
2045
  }
1958
2046
  }
2047
+ /**
2048
+ * Build a map of prereq tag → max configured boost for all *closed* gates.
2049
+ *
2050
+ * When a gate is closed (prereqs unmet), cards carrying that gate's prereq
2051
+ * tags get boosted — steering the pipeline toward content that helps unlock
2052
+ * the gated material. Once the gate opens, the boost disappears.
2053
+ */
2054
+ getPreReqBoosts(unlockedTags, masteredTags) {
2055
+ const boosts = /* @__PURE__ */ new Map();
2056
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
2057
+ if (unlockedTags.has(tagId)) continue;
2058
+ for (const prereq of prereqs) {
2059
+ if (!prereq.preReqBoost || prereq.preReqBoost <= 1) continue;
2060
+ if (masteredTags.has(prereq.tag)) continue;
2061
+ const existing = boosts.get(prereq.tag) ?? 1;
2062
+ boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
2063
+ }
2064
+ }
2065
+ return boosts;
2066
+ }
1959
2067
  /**
1960
2068
  * CardFilter.transform implementation.
1961
2069
  *
1962
- * Apply prerequisite gating to cards. Cards with locked tags receive score * 0.01.
2070
+ * Two effects:
2071
+ * 1. Cards with locked tags receive score * 0.05 (gating penalty)
2072
+ * 2. Cards carrying prereq tags of closed gates receive a configured
2073
+ * boost (preReqBoost), steering toward content that unlocks gates
1963
2074
  */
1964
2075
  async transform(cards, context) {
1965
2076
  const masteredTags = await this.getMasteredTags(context);
1966
2077
  const unlockedTags = this.getUnlockedTags(masteredTags);
2078
+ const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
1967
2079
  const gated = [];
1968
2080
  for (const card of cards) {
1969
2081
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1972,9 +2084,27 @@ var init_hierarchyDefinition = __esm({
1972
2084
  unlockedTags,
1973
2085
  masteredTags
1974
2086
  );
1975
- const LOCKED_PENALTY = 0.01;
1976
- const finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1977
- const action = isUnlocked ? "passed" : "penalized";
2087
+ const LOCKED_PENALTY = 0.02;
2088
+ let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
2089
+ let action = isUnlocked ? "passed" : "penalized";
2090
+ let finalReason = reason;
2091
+ if (isUnlocked && preReqBoosts.size > 0) {
2092
+ const cardTags = card.tags ?? [];
2093
+ let maxBoost = 1;
2094
+ const boostedPrereqs = [];
2095
+ for (const tag of cardTags) {
2096
+ const boost = preReqBoosts.get(tag);
2097
+ if (boost && boost > maxBoost) {
2098
+ maxBoost = boost;
2099
+ boostedPrereqs.push(tag);
2100
+ }
2101
+ }
2102
+ if (maxBoost > 1) {
2103
+ finalScore *= maxBoost;
2104
+ action = "boosted";
2105
+ finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
2106
+ }
2107
+ }
1978
2108
  gated.push({
1979
2109
  ...card,
1980
2110
  score: finalScore,
@@ -1986,7 +2116,7 @@ var init_hierarchyDefinition = __esm({
1986
2116
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
1987
2117
  action,
1988
2118
  score: finalScore,
1989
- reason
2119
+ reason: finalReason
1990
2120
  }
1991
2121
  ]
1992
2122
  });
@@ -2920,6 +3050,18 @@ var Pipeline_exports = {};
2920
3050
  __export(Pipeline_exports, {
2921
3051
  Pipeline: () => Pipeline
2922
3052
  });
3053
+ function globToRegex(pattern) {
3054
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
3055
+ const withWildcards = escaped.replace(/\*/g, ".*");
3056
+ return new RegExp(`^${withWildcards}$`);
3057
+ }
3058
+ function globMatch(value, pattern) {
3059
+ if (!pattern.includes("*")) return value === pattern;
3060
+ return globToRegex(pattern).test(value);
3061
+ }
3062
+ function cardMatchesTagPattern(card, pattern) {
3063
+ return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
3064
+ }
2923
3065
  function logPipelineConfig(generator, filters) {
2924
3066
  const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
2925
3067
  logger.info(
@@ -2954,6 +3096,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
2954
3096
  \u{1F4A1} Inspect: window.skuilder.pipeline`
2955
3097
  );
2956
3098
  }
3099
+ function logResultCards(cards) {
3100
+ if (!VERBOSE_RESULTS || cards.length === 0) return;
3101
+ logger.info(`[Pipeline] Results (${cards.length} cards):`);
3102
+ for (let i = 0; i < cards.length; i++) {
3103
+ const c = cards[i];
3104
+ const tags = c.tags?.slice(0, 3).join(", ") || "";
3105
+ const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
3106
+ const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
3107
+ return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
3108
+ }).join(" | ");
3109
+ logger.info(
3110
+ `[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ""}`
3111
+ );
3112
+ }
3113
+ }
2957
3114
  function logCardProvenance(cards, maxCards = 3) {
2958
3115
  const cardsToLog = cards.slice(0, maxCards);
2959
3116
  logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
@@ -2968,7 +3125,7 @@ function logCardProvenance(cards, maxCards = 3) {
2968
3125
  }
2969
3126
  }
2970
3127
  }
2971
- var import_common8, Pipeline;
3128
+ var import_common8, VERBOSE_RESULTS, Pipeline;
2972
3129
  var init_Pipeline = __esm({
2973
3130
  "src/core/navigators/Pipeline.ts"() {
2974
3131
  "use strict";
@@ -2977,9 +3134,31 @@ var init_Pipeline = __esm({
2977
3134
  init_logger();
2978
3135
  init_orchestration();
2979
3136
  init_PipelineDebugger();
3137
+ VERBOSE_RESULTS = true;
2980
3138
  Pipeline = class extends ContentNavigator {
2981
3139
  generator;
2982
3140
  filters;
3141
+ /**
3142
+ * Cached orchestration context. Course config and salt don't change within
3143
+ * a page load, so we build the orchestration context once and reuse it on
3144
+ * subsequent getWeightedCards() calls (e.g. mid-session replans).
3145
+ *
3146
+ * This eliminates a remote getCourseConfig() round trip per pipeline run.
3147
+ */
3148
+ _cachedOrchestration = null;
3149
+ /**
3150
+ * Persistent tag cache. Maps cardId → tag names.
3151
+ *
3152
+ * Tags are static within a session (they're set at card generation time),
3153
+ * so we cache them across pipeline runs. On replans, many of the same cards
3154
+ * reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
3155
+ */
3156
+ _tagCache = /* @__PURE__ */ new Map();
3157
+ /**
3158
+ * One-shot replan hints. Applied after the filter chain on the next
3159
+ * getWeightedCards() call, then cleared.
3160
+ */
3161
+ _ephemeralHints = null;
2983
3162
  /**
2984
3163
  * Create a new pipeline.
2985
3164
  *
@@ -3000,6 +3179,17 @@ var init_Pipeline = __esm({
3000
3179
  logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
3001
3180
  });
3002
3181
  logPipelineConfig(generator, filters);
3182
+ registerPipelineForDebug(this);
3183
+ }
3184
+ /**
3185
+ * Set one-shot hints for the next pipeline run.
3186
+ * Consumed after one getWeightedCards() call, then cleared.
3187
+ *
3188
+ * Overrides ContentNavigator.setEphemeralHints() no-op.
3189
+ */
3190
+ setEphemeralHints(hints) {
3191
+ this._ephemeralHints = hints;
3192
+ logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
3003
3193
  }
3004
3194
  /**
3005
3195
  * Get weighted cards by running generator and applying filters.
@@ -3016,13 +3206,15 @@ var init_Pipeline = __esm({
3016
3206
  * @returns Cards sorted by score descending
3017
3207
  */
3018
3208
  async getWeightedCards(limit) {
3209
+ const t0 = performance.now();
3019
3210
  const context = await this.buildContext();
3020
- const overFetchMultiplier = 2 + this.filters.length * 0.5;
3021
- const fetchLimit = Math.ceil(limit * overFetchMultiplier);
3211
+ const tContext = performance.now();
3212
+ const fetchLimit = 500;
3022
3213
  logger.debug(
3023
3214
  `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
3024
3215
  );
3025
3216
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
3217
+ const tGenerate = performance.now();
3026
3218
  const generatedCount = cards.length;
3027
3219
  let generatorSummaries;
3028
3220
  if (this.generator.generators) {
@@ -3051,6 +3243,7 @@ var init_Pipeline = __esm({
3051
3243
  }
3052
3244
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
3053
3245
  cards = await this.hydrateTags(cards);
3246
+ const tHydrate = performance.now();
3054
3247
  const allCardsBeforeFiltering = [...cards];
3055
3248
  const filterImpacts = [];
3056
3249
  for (const filter of this.filters) {
@@ -3069,8 +3262,17 @@ var init_Pipeline = __esm({
3069
3262
  logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
3070
3263
  }
3071
3264
  cards = cards.filter((c) => c.score > 0);
3265
+ const hints = this._ephemeralHints;
3266
+ if (hints) {
3267
+ this._ephemeralHints = null;
3268
+ cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
3269
+ }
3072
3270
  cards.sort((a, b) => b.score - a.score);
3271
+ const tFilter = performance.now();
3073
3272
  const result = cards.slice(0, limit);
3273
+ logger.info(
3274
+ `[Pipeline:timing] total=${(tFilter - t0).toFixed(0)}ms (context=${(tContext - t0).toFixed(0)} generate=${(tGenerate - tContext).toFixed(0)} hydrate=${(tHydrate - tGenerate).toFixed(0)} filter=${(tFilter - tHydrate).toFixed(0)})`
3275
+ );
3074
3276
  const topScores = result.slice(0, 3).map((c) => c.score);
3075
3277
  logExecutionSummary(
3076
3278
  this.generator.name,
@@ -3080,6 +3282,7 @@ var init_Pipeline = __esm({
3080
3282
  topScores,
3081
3283
  filterImpacts
3082
3284
  );
3285
+ logResultCards(result);
3083
3286
  logCardProvenance(result, 3);
3084
3287
  try {
3085
3288
  const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
@@ -3106,6 +3309,10 @@ var init_Pipeline = __esm({
3106
3309
  * to the WeightedCard objects. Filters can then use card.tags instead of
3107
3310
  * making individual getAppliedTags() calls.
3108
3311
  *
3312
+ * Uses a persistent tag cache across pipeline runs — tags are static within
3313
+ * a session, so cards seen in a prior run (e.g. before a replan) don't
3314
+ * require a second DB query.
3315
+ *
3109
3316
  * @param cards - Cards to hydrate
3110
3317
  * @returns Cards with tags populated
3111
3318
  */
@@ -3113,14 +3320,128 @@ var init_Pipeline = __esm({
3113
3320
  if (cards.length === 0) {
3114
3321
  return cards;
3115
3322
  }
3116
- const cardIds = cards.map((c) => c.cardId);
3117
- const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
3323
+ const uncachedIds = [];
3324
+ for (const card of cards) {
3325
+ if (!this._tagCache.has(card.cardId)) {
3326
+ uncachedIds.push(card.cardId);
3327
+ }
3328
+ }
3329
+ if (uncachedIds.length > 0) {
3330
+ const freshTags = await this.course.getAppliedTagsBatch(uncachedIds);
3331
+ for (const [cardId, tags] of freshTags) {
3332
+ this._tagCache.set(cardId, tags);
3333
+ }
3334
+ }
3335
+ const tagsByCard = /* @__PURE__ */ new Map();
3336
+ for (const card of cards) {
3337
+ tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
3338
+ }
3118
3339
  logTagHydration(cards, tagsByCard);
3119
3340
  return cards.map((card) => ({
3120
3341
  ...card,
3121
- tags: tagsByCard.get(card.cardId) ?? []
3342
+ tags: this._tagCache.get(card.cardId) ?? []
3122
3343
  }));
3123
3344
  }
3345
+ // ---------------------------------------------------------------------------
3346
+ // Ephemeral hints application
3347
+ // ---------------------------------------------------------------------------
3348
+ /**
3349
+ * Apply one-shot replan hints to the post-filter card set.
3350
+ *
3351
+ * Order of operations:
3352
+ * 1. Exclude (remove unwanted cards)
3353
+ * 2. Boost (multiply scores)
3354
+ * 3. Require (inject must-have cards from the full pre-filter pool)
3355
+ *
3356
+ * @param cards - Post-filter cards (score > 0)
3357
+ * @param hints - The ephemeral hints to apply
3358
+ * @param allCards - Full pre-filter card pool (for require injection)
3359
+ */
3360
+ applyHints(cards, hints, allCards) {
3361
+ const beforeCount = cards.length;
3362
+ if (hints.excludeCards?.length) {
3363
+ cards = cards.filter(
3364
+ (c) => !hints.excludeCards.some((pat) => globMatch(c.cardId, pat))
3365
+ );
3366
+ }
3367
+ if (hints.excludeTags?.length) {
3368
+ cards = cards.filter(
3369
+ (c) => !hints.excludeTags.some((pat) => cardMatchesTagPattern(c, pat))
3370
+ );
3371
+ }
3372
+ if (hints.boostTags) {
3373
+ for (const [pattern, factor] of Object.entries(hints.boostTags)) {
3374
+ for (const card of cards) {
3375
+ if (cardMatchesTagPattern(card, pattern)) {
3376
+ card.score *= factor;
3377
+ card.provenance.push({
3378
+ strategy: "ephemeralHint",
3379
+ strategyId: "ephemeral-hint",
3380
+ strategyName: "Replan Hint",
3381
+ action: "boosted",
3382
+ score: card.score,
3383
+ reason: `boostTag ${pattern} \xD7${factor}`
3384
+ });
3385
+ }
3386
+ }
3387
+ }
3388
+ }
3389
+ if (hints.boostCards) {
3390
+ for (const [pattern, factor] of Object.entries(hints.boostCards)) {
3391
+ for (const card of cards) {
3392
+ if (globMatch(card.cardId, pattern)) {
3393
+ card.score *= factor;
3394
+ card.provenance.push({
3395
+ strategy: "ephemeralHint",
3396
+ strategyId: "ephemeral-hint",
3397
+ strategyName: "Replan Hint",
3398
+ action: "boosted",
3399
+ score: card.score,
3400
+ reason: `boostCard ${pattern} \xD7${factor}`
3401
+ });
3402
+ }
3403
+ }
3404
+ }
3405
+ }
3406
+ const cardIds = new Set(cards.map((c) => c.cardId));
3407
+ const inject = (card, reason) => {
3408
+ if (!cardIds.has(card.cardId)) {
3409
+ const floorScore = Math.max(card.score, 1);
3410
+ cards.push({
3411
+ ...card,
3412
+ score: floorScore,
3413
+ provenance: [
3414
+ ...card.provenance,
3415
+ {
3416
+ strategy: "ephemeralHint",
3417
+ strategyId: "ephemeral-hint",
3418
+ strategyName: "Replan Hint",
3419
+ action: "boosted",
3420
+ score: floorScore,
3421
+ reason
3422
+ }
3423
+ ]
3424
+ });
3425
+ cardIds.add(card.cardId);
3426
+ }
3427
+ };
3428
+ if (hints.requireCards?.length) {
3429
+ for (const pattern of hints.requireCards) {
3430
+ for (const card of allCards) {
3431
+ if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
3432
+ }
3433
+ }
3434
+ }
3435
+ if (hints.requireTags?.length) {
3436
+ for (const pattern of hints.requireTags) {
3437
+ for (const card of allCards) {
3438
+ if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
3439
+ }
3440
+ }
3441
+ }
3442
+ logger.info(`[Pipeline] Hints applied: ${beforeCount} \u2192 ${cards.length} cards`);
3443
+ return cards;
3444
+ }
3124
3445
  /**
3125
3446
  * Build shared context for generator and filters.
3126
3447
  *
@@ -3138,7 +3459,10 @@ var init_Pipeline = __esm({
3138
3459
  } catch (e) {
3139
3460
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
3140
3461
  }
3141
- const orchestration = await createOrchestrationContext(this.user, this.course);
3462
+ if (!this._cachedOrchestration) {
3463
+ this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
3464
+ }
3465
+ const orchestration = this._cachedOrchestration;
3142
3466
  return {
3143
3467
  user: this.user,
3144
3468
  course: this.course,
@@ -3182,6 +3506,87 @@ var init_Pipeline = __esm({
3182
3506
  }
3183
3507
  return [...new Set(ids)];
3184
3508
  }
3509
+ // ---------------------------------------------------------------------------
3510
+ // Card-space diagnostic
3511
+ // ---------------------------------------------------------------------------
3512
+ /**
3513
+ * Scan every card in the course through the filter chain and report
3514
+ * how many are "well indicated" (score >= threshold) for the current user.
3515
+ *
3516
+ * Also reports how many well-indicated cards the user has NOT yet encountered.
3517
+ *
3518
+ * Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
3519
+ */
3520
+ async diagnoseCardSpace(opts) {
3521
+ const THRESHOLD = opts?.threshold ?? 0.1;
3522
+ const t0 = performance.now();
3523
+ const allCardIds = await this.course.getAllCardIds();
3524
+ let cards = allCardIds.map((cardId) => ({
3525
+ cardId,
3526
+ courseId: this.course.getCourseID(),
3527
+ score: 1,
3528
+ provenance: []
3529
+ }));
3530
+ cards = await this.hydrateTags(cards);
3531
+ const context = await this.buildContext();
3532
+ const filterBreakdown = [];
3533
+ for (const filter of this.filters) {
3534
+ cards = await filter.transform(cards, context);
3535
+ const wi = cards.filter((c) => c.score >= THRESHOLD).length;
3536
+ filterBreakdown.push({ name: filter.name, wellIndicated: wi });
3537
+ }
3538
+ const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
3539
+ const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
3540
+ let encounteredIds;
3541
+ try {
3542
+ const courseId = this.course.getCourseID();
3543
+ const seenCards = await this.user.getSeenCards(courseId);
3544
+ encounteredIds = new Set(seenCards);
3545
+ } catch {
3546
+ encounteredIds = /* @__PURE__ */ new Set();
3547
+ }
3548
+ const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
3549
+ const byType = /* @__PURE__ */ new Map();
3550
+ for (const card of cards) {
3551
+ const type = card.cardId.split("-")[1] || "unknown";
3552
+ if (!byType.has(type)) {
3553
+ byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
3554
+ }
3555
+ const entry = byType.get(type);
3556
+ entry.total++;
3557
+ if (card.score >= THRESHOLD) {
3558
+ entry.wellIndicated++;
3559
+ if (!encounteredIds.has(card.cardId)) entry.new++;
3560
+ }
3561
+ }
3562
+ const elapsed = performance.now() - t0;
3563
+ const result = {
3564
+ totalCards: allCardIds.length,
3565
+ threshold: THRESHOLD,
3566
+ wellIndicated: wellIndicatedIds.size,
3567
+ encountered: encounteredIds.size,
3568
+ wellIndicatedNew: wellIndicatedNew.length,
3569
+ byType: Object.fromEntries(byType),
3570
+ filterBreakdown,
3571
+ elapsedMs: Math.round(elapsed)
3572
+ };
3573
+ logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
3574
+ logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
3575
+ logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
3576
+ logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
3577
+ logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
3578
+ logger.info(`[Pipeline:diagnose] By type:`);
3579
+ for (const [type, counts] of byType) {
3580
+ logger.info(
3581
+ `[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
3582
+ );
3583
+ }
3584
+ logger.info(`[Pipeline:diagnose] After each filter:`);
3585
+ for (const fb of filterBreakdown) {
3586
+ logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
3587
+ }
3588
+ return result;
3589
+ }
3185
3590
  };
3186
3591
  }
3187
3592
  });
@@ -3286,23 +3691,25 @@ var init_PipelineAssembler = __esm({
3286
3691
  warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
3287
3692
  }
3288
3693
  }
3694
+ const courseId = course.getCourseID();
3695
+ const hasElo = generatorStrategies.some((s) => s.implementingClass === "elo" /* ELO */);
3696
+ const hasSrs = generatorStrategies.some((s) => s.implementingClass === "srs" /* SRS */);
3697
+ if (!hasElo) {
3698
+ logger.debug("[PipelineAssembler] No ELO generator configured, adding default");
3699
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
3700
+ }
3701
+ if (!hasSrs) {
3702
+ logger.debug("[PipelineAssembler] No SRS generator configured, adding default");
3703
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
3704
+ }
3289
3705
  if (generatorStrategies.length === 0) {
3290
- if (filterStrategies.length > 0) {
3291
- logger.debug(
3292
- "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
3293
- );
3294
- const courseId = course.getCourseID();
3295
- generatorStrategies.push(createDefaultEloStrategy(courseId));
3296
- generatorStrategies.push(createDefaultSrsStrategy(courseId));
3297
- } else {
3298
- warnings.push("No generator strategy found");
3299
- return {
3300
- pipeline: null,
3301
- generatorStrategies: [],
3302
- filterStrategies: [],
3303
- warnings
3304
- };
3305
- }
3706
+ warnings.push("No generator strategy found");
3707
+ return {
3708
+ pipeline: null,
3709
+ generatorStrategies: [],
3710
+ filterStrategies: [],
3711
+ warnings
3712
+ };
3306
3713
  }
3307
3714
  let generator;
3308
3715
  if (generatorStrategies.length === 1) {
@@ -3380,6 +3787,7 @@ var init_3 = __esm({
3380
3787
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
3381
3788
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
3382
3789
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
3790
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
3383
3791
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
3384
3792
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
3385
3793
  "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
@@ -3428,8 +3836,10 @@ async function initializeNavigatorRegistry() {
3428
3836
  Promise.resolve().then(() => (init_elo(), elo_exports)),
3429
3837
  Promise.resolve().then(() => (init_srs(), srs_exports))
3430
3838
  ]);
3839
+ const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
3431
3840
  registerNavigator("elo", eloModule.default);
3432
3841
  registerNavigator("srs", srsModule.default);
3842
+ registerNavigator("prescribed", prescribedModule.default);
3433
3843
  const [
3434
3844
  hierarchyModule,
3435
3845
  interferenceModule,
@@ -3484,6 +3894,7 @@ var init_navigators = __esm({
3484
3894
  Navigators = /* @__PURE__ */ ((Navigators2) => {
3485
3895
  Navigators2["ELO"] = "elo";
3486
3896
  Navigators2["SRS"] = "srs";
3897
+ Navigators2["PRESCRIBED"] = "prescribed";
3487
3898
  Navigators2["HIERARCHY"] = "hierarchyDefinition";
3488
3899
  Navigators2["INTERFERENCE"] = "interferenceMitigator";
3489
3900
  Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
@@ -3498,6 +3909,7 @@ var init_navigators = __esm({
3498
3909
  NavigatorRoles = {
3499
3910
  ["elo" /* ELO */]: "generator" /* GENERATOR */,
3500
3911
  ["srs" /* SRS */]: "generator" /* GENERATOR */,
3912
+ ["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
3501
3913
  ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
3502
3914
  ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
3503
3915
  ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
@@ -3662,6 +4074,12 @@ var init_navigators = __esm({
3662
4074
  async getWeightedCards(_limit) {
3663
4075
  throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
3664
4076
  }
4077
+ /**
4078
+ * Set ephemeral hints for the next pipeline run.
4079
+ * No-op for non-Pipeline navigators. Pipeline overrides this.
4080
+ */
4081
+ setEphemeralHints(_hints) {
4082
+ }
3665
4083
  };
3666
4084
  }
3667
4085
  });
@@ -3806,15 +4224,42 @@ var init_courseDB = __esm({
3806
4224
  // private log(msg: string): void {
3807
4225
  // log(`CourseLog: ${this.id}\n ${msg}`);
3808
4226
  // }
4227
+ /**
4228
+ * Primary database handle used for all **read** operations (queries, gets).
4229
+ *
4230
+ * When local sync is active, this points to the local PouchDB replica for
4231
+ * fast, network-free reads. Otherwise it points to the remote CouchDB.
4232
+ */
3809
4233
  db;
4234
+ /**
4235
+ * Remote database handle used for all **write** operations.
4236
+ *
4237
+ * Always points to the remote CouchDB so that writes (ELO updates, tag
4238
+ * mutations, admin operations) aggregate on the server. The local replica
4239
+ * is a read-only snapshot that refreshes on the next page load.
4240
+ *
4241
+ * When local sync is NOT active, this is the same instance as `this.db`.
4242
+ */
4243
+ remoteDB;
3810
4244
  id;
3811
4245
  _getCurrentUser;
3812
4246
  updateQueue;
3813
- constructor(id, userLookup) {
4247
+ /**
4248
+ * @param id - Course ID
4249
+ * @param userLookup - Async function returning the current user DB
4250
+ * @param localDB - Optional local PouchDB replica for reads. When provided,
4251
+ * `this.db` uses the local replica and `this.remoteDB` stays remote.
4252
+ * The UpdateQueue reads from remote and writes to remote (local `_rev`
4253
+ * values may be stale, so read-modify-write cycles must go through
4254
+ * the remote DB to avoid conflicts).
4255
+ */
4256
+ constructor(id, userLookup, localDB) {
3814
4257
  this.id = id;
3815
- this.db = getCourseDB2(this.id);
4258
+ const remote = getCourseDB2(this.id);
4259
+ this.remoteDB = remote;
4260
+ this.db = localDB ?? remote;
3816
4261
  this._getCurrentUser = userLookup;
3817
- this.updateQueue = new UpdateQueue(this.db);
4262
+ this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
3818
4263
  }
3819
4264
  getCourseID() {
3820
4265
  return this.id;
@@ -3902,7 +4347,7 @@ var init_courseDB = __esm({
3902
4347
  };
3903
4348
  }
3904
4349
  async removeCard(id) {
3905
- const doc = await this.db.get(id);
4350
+ const doc = await this.remoteDB.get(id);
3906
4351
  if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
3907
4352
  throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
3908
4353
  }
@@ -3923,7 +4368,7 @@ var init_courseDB = __esm({
3923
4368
  } catch (error) {
3924
4369
  logger.error(`Error removing card ${id} from tags: ${error}`);
3925
4370
  }
3926
- return this.db.remove(doc);
4371
+ return this.remoteDB.remove(doc);
3927
4372
  }
3928
4373
  async getCardDisplayableDataIDs(id) {
3929
4374
  logger.debug(id.join(", "));
@@ -4025,8 +4470,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4025
4470
  if (cardIds.length === 0) {
4026
4471
  return /* @__PURE__ */ new Map();
4027
4472
  }
4028
- const db = getCourseDB2(this.id);
4029
- const result = await db.query("getTags", {
4473
+ const result = await this.db.query("getTags", {
4030
4474
  keys: cardIds,
4031
4475
  include_docs: false
4032
4476
  });
@@ -4043,6 +4487,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4043
4487
  }
4044
4488
  return tagsByCard;
4045
4489
  }
4490
+ async getAllCardIds() {
4491
+ const result = await this.db.allDocs({
4492
+ startkey: "CARD-",
4493
+ endkey: "CARD-\uFFF0",
4494
+ include_docs: false
4495
+ });
4496
+ return result.rows.map((row) => row.id);
4497
+ }
4046
4498
  async addTagToCard(cardId, tagId, updateELO) {
4047
4499
  return await addTagToCard(
4048
4500
  this.id,
@@ -4109,10 +4561,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4109
4561
  }
4110
4562
  }
4111
4563
  async getCourseDoc(id, options) {
4112
- return await getCourseDoc(this.id, id, options);
4564
+ return await this.db.get(id, options);
4113
4565
  }
4114
4566
  async getCourseDocs(ids, options = {}) {
4115
- return await getCourseDocs(this.id, ids, options);
4567
+ return await this.db.allDocs({
4568
+ ...options,
4569
+ keys: ids
4570
+ });
4116
4571
  }
4117
4572
  ////////////////////////////////////
4118
4573
  // NavigationStrategyManager implementation
@@ -4146,7 +4601,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4146
4601
  }
4147
4602
  async addNavigationStrategy(data) {
4148
4603
  logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
4149
- return this.db.put(data).then(() => {
4604
+ return this.remoteDB.put(data).then(() => {
4150
4605
  });
4151
4606
  }
4152
4607
  updateNavigationStrategy(id, data) {
@@ -4703,6 +5158,234 @@ var init_adminDB2 = __esm({
4703
5158
  }
4704
5159
  });
4705
5160
 
5161
+ // src/impl/couch/CourseSyncService.ts
5162
+ var CourseSyncService;
5163
+ var init_CourseSyncService = __esm({
5164
+ "src/impl/couch/CourseSyncService.ts"() {
5165
+ "use strict";
5166
+ init_pouchdb_setup();
5167
+ init_couch();
5168
+ init_logger();
5169
+ CourseSyncService = class _CourseSyncService {
5170
+ static instance = null;
5171
+ entries = /* @__PURE__ */ new Map();
5172
+ constructor() {
5173
+ }
5174
+ static getInstance() {
5175
+ if (!_CourseSyncService.instance) {
5176
+ _CourseSyncService.instance = new _CourseSyncService();
5177
+ }
5178
+ return _CourseSyncService.instance;
5179
+ }
5180
+ /**
5181
+ * Reset the singleton (for testing).
5182
+ */
5183
+ static resetInstance() {
5184
+ if (_CourseSyncService.instance) {
5185
+ for (const [, entry] of _CourseSyncService.instance.entries) {
5186
+ if (entry.localDB) {
5187
+ entry.localDB.close().catch(() => {
5188
+ });
5189
+ }
5190
+ }
5191
+ _CourseSyncService.instance.entries.clear();
5192
+ }
5193
+ _CourseSyncService.instance = null;
5194
+ }
5195
+ // --------------------------------------------------------------------------
5196
+ // Public API
5197
+ // --------------------------------------------------------------------------
5198
+ /**
5199
+ * Ensure a course's local replica is synced.
5200
+ *
5201
+ * On first call for a course:
5202
+ * 1. Fetches CourseConfig from remote to check localSync.enabled
5203
+ * 2. If enabled, performs one-shot replication remote → local
5204
+ * 3. Pre-warms PouchDB view indices (elo, getTags)
5205
+ *
5206
+ * On subsequent calls: returns immediately if already synced, or awaits
5207
+ * the in-flight sync if one is in progress.
5208
+ *
5209
+ * Safe to call multiple times — concurrent calls coalesce to one sync.
5210
+ *
5211
+ * @param courseId - The course to sync
5212
+ * @param forceEnabled - Skip the CourseConfig check and sync regardless.
5213
+ * Useful when the caller already knows local sync is desired (e.g.,
5214
+ * LettersPractice hardcodes this).
5215
+ */
5216
+ async ensureSynced(courseId, forceEnabled) {
5217
+ const existing = this.entries.get(courseId);
5218
+ if (existing?.status.state === "ready") {
5219
+ return;
5220
+ }
5221
+ if (existing?.status.state === "disabled") {
5222
+ return;
5223
+ }
5224
+ if (existing?.readyPromise) {
5225
+ return existing.readyPromise;
5226
+ }
5227
+ const entry = {
5228
+ localDB: null,
5229
+ status: { state: "not-started" },
5230
+ readyPromise: null
5231
+ };
5232
+ this.entries.set(courseId, entry);
5233
+ entry.readyPromise = this.performSync(courseId, entry, forceEnabled);
5234
+ return entry.readyPromise;
5235
+ }
5236
+ /**
5237
+ * Get the local PouchDB for a course, or null if not available.
5238
+ *
5239
+ * Returns null when:
5240
+ * - Local sync is not enabled for this course
5241
+ * - Sync has not been triggered yet
5242
+ * - Sync is still in progress
5243
+ * - Sync failed
5244
+ */
5245
+ getLocalDB(courseId) {
5246
+ const entry = this.entries.get(courseId);
5247
+ if (entry?.status.state === "ready" && entry.localDB) {
5248
+ return entry.localDB;
5249
+ }
5250
+ return null;
5251
+ }
5252
+ /**
5253
+ * Check whether a course has a ready local replica.
5254
+ */
5255
+ isReady(courseId) {
5256
+ return this.entries.get(courseId)?.status.state === "ready";
5257
+ }
5258
+ /**
5259
+ * Get detailed sync status for a course.
5260
+ */
5261
+ getStatus(courseId) {
5262
+ return this.entries.get(courseId)?.status ?? { state: "not-started" };
5263
+ }
5264
+ // --------------------------------------------------------------------------
5265
+ // Internal
5266
+ // --------------------------------------------------------------------------
5267
+ async performSync(courseId, entry, forceEnabled) {
5268
+ try {
5269
+ if (!forceEnabled) {
5270
+ entry.status = { state: "checking-config" };
5271
+ const enabled = await this.checkLocalSyncEnabled(courseId);
5272
+ if (!enabled) {
5273
+ entry.status = { state: "disabled" };
5274
+ entry.readyPromise = null;
5275
+ logger.debug(
5276
+ `[CourseSyncService] Local sync disabled for course ${courseId}`
5277
+ );
5278
+ return;
5279
+ }
5280
+ }
5281
+ entry.status = { state: "syncing" };
5282
+ const localDBName = this.localDBName(courseId);
5283
+ const localDB = new pouchdb_setup_default(localDBName);
5284
+ entry.localDB = localDB;
5285
+ const remoteDB = this.getRemoteDB(courseId);
5286
+ const syncStart = Date.now();
5287
+ logger.info(
5288
+ `[CourseSyncService] Starting one-shot replication for course ${courseId}`
5289
+ );
5290
+ const result = await this.replicate(remoteDB, localDB);
5291
+ const syncTimeMs = Date.now() - syncStart;
5292
+ logger.info(
5293
+ `[CourseSyncService] Replication complete for course ${courseId}: ${result.docs_written} docs in ${syncTimeMs}ms`
5294
+ );
5295
+ entry.status = { state: "warming-views" };
5296
+ const warmStart = Date.now();
5297
+ await this.warmViewIndices(localDB);
5298
+ const viewWarmTimeMs = Date.now() - warmStart;
5299
+ logger.info(
5300
+ `[CourseSyncService] View indices warmed for course ${courseId} in ${viewWarmTimeMs}ms`
5301
+ );
5302
+ entry.status = {
5303
+ state: "ready",
5304
+ docsReplicated: result.docs_written,
5305
+ syncTimeMs,
5306
+ viewWarmTimeMs
5307
+ };
5308
+ } catch (e) {
5309
+ const errorMsg = e instanceof Error ? e.message : String(e);
5310
+ logger.error(
5311
+ `[CourseSyncService] Sync failed for course ${courseId}: ${errorMsg}`
5312
+ );
5313
+ entry.status = { state: "error", error: errorMsg };
5314
+ entry.readyPromise = null;
5315
+ if (entry.localDB) {
5316
+ try {
5317
+ await entry.localDB.destroy();
5318
+ } catch {
5319
+ }
5320
+ entry.localDB = null;
5321
+ }
5322
+ }
5323
+ }
5324
+ /**
5325
+ * Check CourseConfig.localSync.enabled on the remote DB.
5326
+ */
5327
+ async checkLocalSyncEnabled(courseId) {
5328
+ try {
5329
+ const remoteDB = this.getRemoteDB(courseId);
5330
+ const config = await remoteDB.get("CourseConfig");
5331
+ return config.localSync?.enabled === true;
5332
+ } catch (e) {
5333
+ logger.warn(
5334
+ `[CourseSyncService] Could not read CourseConfig for ${courseId}, assuming local sync disabled: ${e}`
5335
+ );
5336
+ return false;
5337
+ }
5338
+ }
5339
+ /**
5340
+ * One-shot replication from remote to local.
5341
+ */
5342
+ replicate(source, target) {
5343
+ return new Promise((resolve, reject) => {
5344
+ void pouchdb_setup_default.replicate(source, target, {
5345
+ // One-shot, not live. Local is a read-only snapshot.
5346
+ }).on("complete", (info) => {
5347
+ resolve(info);
5348
+ }).on("error", (err) => {
5349
+ reject(err);
5350
+ });
5351
+ });
5352
+ }
5353
+ /**
5354
+ * Pre-warm PouchDB view indices by running a minimal query against each
5355
+ * design doc. This forces PouchDB to build the MapReduce index now
5356
+ * (during a loading phase) rather than on first pipeline query.
5357
+ */
5358
+ async warmViewIndices(localDB) {
5359
+ const viewsToWarm = ["elo", "getTags"];
5360
+ for (const viewName of viewsToWarm) {
5361
+ try {
5362
+ await localDB.query(viewName, { limit: 1 });
5363
+ logger.debug(
5364
+ `[CourseSyncService] Warmed view index: ${viewName}`
5365
+ );
5366
+ } catch (e) {
5367
+ logger.debug(
5368
+ `[CourseSyncService] Could not warm view ${viewName}: ${e}`
5369
+ );
5370
+ }
5371
+ }
5372
+ }
5373
+ /**
5374
+ * Get a remote PouchDB handle for a course.
5375
+ */
5376
+ getRemoteDB(courseId) {
5377
+ return getCourseDB2(courseId);
5378
+ }
5379
+ /**
5380
+ * Local DB naming convention.
5381
+ */
5382
+ localDBName(courseId) {
5383
+ return `coursedb-local-${courseId}`;
5384
+ }
5385
+ };
5386
+ }
5387
+ });
5388
+
4706
5389
  // src/impl/couch/auth.ts
4707
5390
  async function getCurrentSession() {
4708
5391
  try {
@@ -5001,15 +5684,6 @@ function getCourseDB2(courseID) {
5001
5684
  createPouchDBConfig()
5002
5685
  );
5003
5686
  }
5004
- function getCourseDocs(courseID, docIDs, options = {}) {
5005
- return getCourseDB2(courseID).allDocs({
5006
- ...options,
5007
- keys: docIDs
5008
- });
5009
- }
5010
- function getCourseDoc(courseID, docID, options = {}) {
5011
- return getCourseDB2(courseID).get(docID, options);
5012
- }
5013
5687
  function filterAllDocsByPrefix2(db, prefix, opts) {
5014
5688
  const options = {
5015
5689
  startkey: prefix,
@@ -5043,6 +5717,7 @@ var init_couch = __esm({
5043
5717
  init_classroomDB2();
5044
5718
  init_courseAPI();
5045
5719
  init_courseDB();
5720
+ init_CourseSyncService();
5046
5721
  init_CouchDBSyncStrategy();
5047
5722
  isBrowser = typeof window !== "undefined";
5048
5723
  if (isBrowser) {
@@ -5343,6 +6018,9 @@ Currently logged-in as ${this._username}.`
5343
6018
  const id = row.id;
5344
6019
  return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
5345
6020
  id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */]) || // Scheduled reviews
6021
+ id.startsWith(DocTypePrefixes["STRATEGY_STATE" /* STRATEGY_STATE */]) || // Strategy state (user prefs, progression)
6022
+ id.startsWith(DocTypePrefixes["USER_OUTCOME" /* USER_OUTCOME */]) || // Evolutionary orchestration outcomes
6023
+ id.startsWith(DocTypePrefixes["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]) || // Strategy learning state
5346
6024
  id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
5347
6025
  id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
5348
6026
  id === _BaseUser.DOC_IDS.CONFIG;
@@ -6139,6 +6817,7 @@ var init_PouchDataLayerProvider = __esm({
6139
6817
  init_adminDB2();
6140
6818
  init_classroomDB2();
6141
6819
  init_courseDB();
6820
+ init_CourseSyncService();
6142
6821
  init_common();
6143
6822
  init_CouchDBSyncStrategy();
6144
6823
  CouchDataLayerProvider = class {
@@ -6178,7 +6857,22 @@ var init_PouchDataLayerProvider = __esm({
6178
6857
  return this.userDB;
6179
6858
  }
6180
6859
  getCourseDB(courseId) {
6181
- return new CourseDB(courseId, async () => this.getUserDB());
6860
+ const localDB = CourseSyncService.getInstance().getLocalDB(courseId);
6861
+ return new CourseDB(courseId, async () => this.getUserDB(), localDB ?? void 0);
6862
+ }
6863
+ /**
6864
+ * Trigger local sync for a course. Call during app initialization or
6865
+ * pre-session loading for courses that opt in via CourseConfig.localSync.
6866
+ *
6867
+ * Safe to call multiple times — concurrent calls coalesce. Returns when
6868
+ * sync is complete (or immediately if already synced / disabled).
6869
+ *
6870
+ * @param courseId - The course to sync locally
6871
+ * @param forceEnabled - Skip CourseConfig check and sync regardless.
6872
+ * Use when the caller already knows local sync is desired.
6873
+ */
6874
+ async ensureCourseSynced(courseId, forceEnabled) {
6875
+ return CourseSyncService.getInstance().ensureSynced(courseId, forceEnabled);
6182
6876
  }
6183
6877
  getCoursesDB() {
6184
6878
  return new CoursesDB(this._courseIDs);
@@ -6806,6 +7500,10 @@ var init_courseDB2 = __esm({
6806
7500
  }
6807
7501
  return tagsByCard;
6808
7502
  }
7503
+ async getAllCardIds() {
7504
+ const tagsIndex = await this.unpacker.getTagsIndex();
7505
+ return Object.keys(tagsIndex.byCard);
7506
+ }
6809
7507
  async addTagToCard(_cardId, _tagId) {
6810
7508
  throw new Error("Cannot modify tags in static mode");
6811
7509
  }
@@ -8447,8 +9145,10 @@ function newQuestionInterval(user, cardHistory) {
8447
9145
  const lastInterval = lastSuccessfulInterval(records);
8448
9146
  if (lastInterval > cardHistory.bestInterval) {
8449
9147
  cardHistory.bestInterval = lastInterval;
8450
- void user.update(cardHistory._id, {
9148
+ user.update(cardHistory._id, {
8451
9149
  bestInterval: lastInterval
9150
+ }).catch((e) => {
9151
+ logger.warn(`[SpacedRepetition] Failed to update bestInterval for ${cardHistory._id}: ${e?.message ?? e}`);
8452
9152
  });
8453
9153
  }
8454
9154
  if (currentAttempt.isCorrect) {
@@ -9106,6 +9806,46 @@ var ItemQueue = class {
9106
9806
  return null;
9107
9807
  }
9108
9808
  }
9809
+ /**
9810
+ * Atomically replace all queue contents with new items.
9811
+ *
9812
+ * Used by mid-session replanning to swap the queue without a window where
9813
+ * it's empty (avoiding dead-air if nextCard() is called concurrently).
9814
+ *
9815
+ * Preserves dequeueCount (cumulative across the session).
9816
+ * Resets seenCardIds to match the new contents — cards from the old queue
9817
+ * that don't appear in the new set can be re-added in future replans.
9818
+ */
9819
+ replaceAll(items, cardIdExtractor) {
9820
+ this.q = [];
9821
+ this.seenCardIds = [];
9822
+ for (const item of items) {
9823
+ const cardId = cardIdExtractor(item);
9824
+ if (!this.seenCardIds.includes(cardId)) {
9825
+ this.seenCardIds.push(cardId);
9826
+ this.q.push(item);
9827
+ }
9828
+ }
9829
+ }
9830
+ /**
9831
+ * Merge new items into the front of the queue, skipping duplicates.
9832
+ * Used by additive replans to inject high-quality candidates without
9833
+ * discarding the existing queue contents.
9834
+ */
9835
+ mergeToFront(items, cardIdExtractor) {
9836
+ let added = 0;
9837
+ const toInsert = [];
9838
+ for (const item of items) {
9839
+ const cardId = cardIdExtractor(item);
9840
+ if (!this.seenCardIds.includes(cardId)) {
9841
+ this.seenCardIds.push(cardId);
9842
+ toInsert.push(item);
9843
+ added++;
9844
+ }
9845
+ }
9846
+ this.q.unshift(...toInsert);
9847
+ return added;
9848
+ }
9109
9849
  get toString() {
9110
9850
  return `${typeof this.q[0]}:
9111
9851
  ` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
@@ -11189,7 +11929,7 @@ mountSessionDebugger();
11189
11929
 
11190
11930
  // src/study/SessionController.ts
11191
11931
  init_logger();
11192
- var SessionController = class extends Loggable {
11932
+ var SessionController = class _SessionController extends Loggable {
11193
11933
  _className = "SessionController";
11194
11934
  services;
11195
11935
  srsService;
@@ -11210,6 +11950,18 @@ var SessionController = class extends Loggable {
11210
11950
  newQ = new ItemQueue();
11211
11951
  failedQ = new ItemQueue();
11212
11952
  // END Session card stores
11953
+ /**
11954
+ * Promise tracking a currently in-progress replan, or null if idle.
11955
+ * Used by nextCard() to await completion before drawing from queues.
11956
+ */
11957
+ _replanPromise = null;
11958
+ /**
11959
+ * Number of well-indicated new cards remaining before the queue
11960
+ * degrades to poorly-indicated content. Decremented on each newQ
11961
+ * draw; when it hits 0, a replan is triggered automatically
11962
+ * (user state has changed from completing good cards).
11963
+ */
11964
+ _wellIndicatedRemaining = 0;
11213
11965
  startTime;
11214
11966
  endTime;
11215
11967
  _secondsRemaining;
@@ -11303,13 +12055,83 @@ var SessionController = class extends Loggable {
11303
12055
  "[SessionController] All content sources must implement getWeightedCards()."
11304
12056
  );
11305
12057
  }
11306
- await this.getWeightedContent();
12058
+ const wellIndicated = await this.getWeightedContent();
12059
+ this._wellIndicatedRemaining = wellIndicated;
12060
+ if (wellIndicated >= 0 && wellIndicated < _SessionController.MIN_WELL_INDICATED) {
12061
+ this.log(
12062
+ `[Init] Only ${wellIndicated}/${_SessionController.MIN_WELL_INDICATED} well-indicated cards in initial load`
12063
+ );
12064
+ }
11307
12065
  await this.hydrationService.ensureHydratedCards();
11308
12066
  startSessionTracking(this.reviewQ.length, this.newQ.length, this.failedQ.length);
11309
12067
  this._intervalHandle = setInterval(() => {
11310
12068
  this.tick();
11311
12069
  }, 1e3);
11312
12070
  }
12071
+ /**
12072
+ * Request a mid-session replan. Re-runs the pipeline with current user state
12073
+ * and atomically replaces the newQ contents. Safe to call at any time during
12074
+ * a session — if called while a replan is already in progress, returns the
12075
+ * existing replan promise (no duplicate work).
12076
+ *
12077
+ * Does NOT affect reviewQ or failedQ.
12078
+ *
12079
+ * If nextCard() is called while a replan is in flight, it will automatically
12080
+ * await the replan before drawing from queues, ensuring the user always sees
12081
+ * cards scored against their latest state.
12082
+ *
12083
+ * Typical trigger: application-level code (e.g. after a GPC intro completion)
12084
+ * calls this to ensure newly-unlocked content appears in the session.
12085
+ */
12086
+ async requestReplan(hints) {
12087
+ if (this._replanPromise) {
12088
+ this.log("Replan already in progress, awaiting existing replan");
12089
+ return this._replanPromise;
12090
+ }
12091
+ if (hints) {
12092
+ for (const source of this.sources) {
12093
+ this.log(`[Hints] source type=${source.constructor.name}, hasMethod=${typeof source.setEphemeralHints}`);
12094
+ source.setEphemeralHints?.(hints);
12095
+ }
12096
+ }
12097
+ this.log(`Mid-session replan requested${hints ? ` (hints: ${JSON.stringify(hints)})` : ""}`);
12098
+ this._replanPromise = this._executeReplan();
12099
+ try {
12100
+ await this._replanPromise;
12101
+ } finally {
12102
+ this._replanPromise = null;
12103
+ }
12104
+ }
12105
+ /** Minimum well-indicated cards before an additive retry is attempted */
12106
+ static MIN_WELL_INDICATED = 5;
12107
+ /**
12108
+ * Score threshold for considering a card "well-indicated."
12109
+ * Cards below this score are treated as fallback filler — present only
12110
+ * because no strategy hard-removed them, but likely penalized by one
12111
+ * or more filters. Strategy-agnostic: the SessionController doesn't
12112
+ * know or care which strategy assigned the score.
12113
+ */
12114
+ static WELL_INDICATED_SCORE = 0.1;
12115
+ /**
12116
+ * Internal replan execution. Runs the pipeline, builds a new newQ,
12117
+ * atomically swaps it in, and triggers hydration for the new contents.
12118
+ *
12119
+ * If the initial replan produces fewer than MIN_WELL_INDICATED cards that
12120
+ * pass all hierarchy filters, one additive retry is attempted — merging
12121
+ * any new high-quality candidates into the front of the queue.
12122
+ */
12123
+ async _executeReplan() {
12124
+ const wellIndicated = await this.getWeightedContent({ replan: true });
12125
+ this._wellIndicatedRemaining = wellIndicated;
12126
+ if (wellIndicated >= 0 && wellIndicated < _SessionController.MIN_WELL_INDICATED) {
12127
+ this.log(
12128
+ `[Replan] Only ${wellIndicated}/${_SessionController.MIN_WELL_INDICATED} well-indicated cards after replan`
12129
+ );
12130
+ }
12131
+ await this.hydrationService.ensureHydratedCards();
12132
+ this.log(`Replan complete: newQ now has ${this.newQ.length} cards`);
12133
+ snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
12134
+ }
11313
12135
  addTime(seconds) {
11314
12136
  this.endTime = new Date(this.endTime.valueOf() + 1e3 * seconds);
11315
12137
  }
@@ -11365,6 +12187,9 @@ var SessionController = class extends Loggable {
11365
12187
  hydratedCache: {
11366
12188
  count: this.hydrationService.hydratedCount,
11367
12189
  cardIds: this.hydrationService.getHydratedCardIds()
12190
+ },
12191
+ replan: {
12192
+ inProgress: this._replanPromise !== null
11368
12193
  }
11369
12194
  };
11370
12195
  }
@@ -11377,7 +12202,20 @@ var SessionController = class extends Loggable {
11377
12202
  * 3. Uses SourceMixer to balance content across sources
11378
12203
  * 4. Populates review and new card queues with mixed results
11379
12204
  */
11380
- async getWeightedContent() {
12205
+ /**
12206
+ * Fetch weighted content from all sources and populate session queues.
12207
+ *
12208
+ * @param options.replan - If true, this is a mid-session replan rather than
12209
+ * initial session setup. Skips review queue population (avoiding duplicates),
12210
+ * atomically replaces newQ contents, and treats empty results as non-fatal.
12211
+ * @param options.additive - If true (replan only), merge new high-quality
12212
+ * candidates into the front of the existing newQ instead of replacing it.
12213
+ * @returns Number of "well-indicated" cards (passed all hierarchy filters)
12214
+ * in the new content. Returns -1 if no content was loaded.
12215
+ */
12216
+ async getWeightedContent(options) {
12217
+ const replan = options?.replan ?? false;
12218
+ const additive = options?.additive ?? false;
11381
12219
  const limit = 20;
11382
12220
  const batches = [];
11383
12221
  for (let i = 0; i < this.sources.length; i++) {
@@ -11396,6 +12234,10 @@ var SessionController = class extends Loggable {
11396
12234
  }
11397
12235
  }
11398
12236
  if (batches.length === 0) {
12237
+ if (replan) {
12238
+ this.log("Replan: no content from any source, keeping existing newQ");
12239
+ return -1;
12240
+ }
11399
12241
  throw new Error(
11400
12242
  `Cannot start session: failed to load content from all ${this.sources.length} source(s). Check logs for details.`
11401
12243
  );
@@ -11407,10 +12249,12 @@ var SessionController = class extends Loggable {
11407
12249
  });
11408
12250
  await Promise.all(
11409
12251
  sourceIds.map(async (id) => {
11410
- try {
11411
- const config = await this.dataLayer.getCoursesDB().getCourseConfig(id);
11412
- this.courseNameCache.set(id, config.name);
11413
- } catch {
12252
+ if (!this.courseNameCache.has(id)) {
12253
+ try {
12254
+ const config = await this.dataLayer.getCoursesDB().getCourseConfig(id);
12255
+ this.courseNameCache.set(id, config.name);
12256
+ } catch {
12257
+ }
11414
12258
  }
11415
12259
  })
11416
12260
  );
@@ -11428,20 +12272,26 @@ var SessionController = class extends Loggable {
11428
12272
  const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review");
11429
12273
  const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new");
11430
12274
  logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
11431
- let report = "Mixed content session created with:\n";
11432
- for (const w of reviewWeighted) {
11433
- const reviewItem = {
11434
- cardID: w.cardId,
11435
- courseID: w.courseId,
11436
- contentSourceType: "course",
11437
- contentSourceID: w.courseId,
11438
- reviewID: w.reviewID,
11439
- status: "review"
11440
- };
11441
- this.reviewQ.add(reviewItem, reviewItem.cardID);
11442
- report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
12275
+ let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
12276
+ if (!replan) {
12277
+ for (const w of reviewWeighted) {
12278
+ const reviewItem = {
12279
+ cardID: w.cardId,
12280
+ courseID: w.courseId,
12281
+ contentSourceType: "course",
12282
+ contentSourceID: w.courseId,
12283
+ reviewID: w.reviewID,
12284
+ status: "review"
12285
+ };
12286
+ this.reviewQ.add(reviewItem, reviewItem.cardID);
12287
+ report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
11443
12288
  `;
12289
+ }
11444
12290
  }
12291
+ const wellIndicated = newWeighted.filter(
12292
+ (w) => w.score >= _SessionController.WELL_INDICATED_SCORE
12293
+ ).length;
12294
+ const newItems = [];
11445
12295
  for (const w of newWeighted) {
11446
12296
  const newItem = {
11447
12297
  cardID: w.cardId,
@@ -11450,11 +12300,23 @@ var SessionController = class extends Loggable {
11450
12300
  contentSourceID: w.courseId,
11451
12301
  status: "new"
11452
12302
  };
11453
- this.newQ.add(newItem, newItem.cardID);
12303
+ newItems.push(newItem);
11454
12304
  report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
11455
12305
  `;
11456
12306
  }
12307
+ if (additive) {
12308
+ const added = this.newQ.mergeToFront(newItems, (item) => item.cardID);
12309
+ report += `Additive merge: ${added} new cards added to front of newQ
12310
+ `;
12311
+ } else if (replan) {
12312
+ this.newQ.replaceAll(newItems, (item) => item.cardID);
12313
+ } else {
12314
+ for (const item of newItems) {
12315
+ this.newQ.add(item, item.cardID);
12316
+ }
12317
+ }
11457
12318
  this.log(report);
12319
+ return wellIndicated;
11458
12320
  }
11459
12321
  /**
11460
12322
  * Returns items that should be pre-hydrated.
@@ -11531,6 +12393,17 @@ var SessionController = class extends Loggable {
11531
12393
  }
11532
12394
  async nextCard(action = "dismiss-success") {
11533
12395
  this.dismissCurrentCard(action);
12396
+ if (this._replanPromise) {
12397
+ this.log("nextCard: awaiting in-flight replan before drawing");
12398
+ await this._replanPromise;
12399
+ }
12400
+ const REPLAN_BUFFER = 3;
12401
+ if (this._wellIndicatedRemaining <= REPLAN_BUFFER && this.newQ.length > 0 && !this._replanPromise) {
12402
+ this.log(
12403
+ `[AutoReplan] ${this._wellIndicatedRemaining} well-indicated cards remaining (newQ: ${this.newQ.length}). Triggering background replan.`
12404
+ );
12405
+ void this.requestReplan();
12406
+ }
11534
12407
  if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
11535
12408
  this._currentCard = null;
11536
12409
  endSessionTracking();
@@ -11644,6 +12517,9 @@ var SessionController = class extends Loggable {
11644
12517
  this.reviewQ.dequeue((queueItem) => queueItem.cardID);
11645
12518
  } else if (this.newQ.peek(0)?.cardID === item.cardID) {
11646
12519
  this.newQ.dequeue((queueItem) => queueItem.cardID);
12520
+ if (this._wellIndicatedRemaining > 0) {
12521
+ this._wellIndicatedRemaining--;
12522
+ }
11647
12523
  } else if (this.failedQ.peek(0)?.cardID === item.cardID) {
11648
12524
  this.failedQ.dequeue((queueItem) => queueItem.cardID);
11649
12525
  }