@vue-skuilder/db 0.2.4 → 0.2.7

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.
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ItemQueue } from './ItemQueue';
3
+
4
+ type Item = { cardID: string };
5
+ const id = (i: Item) => i.cardID;
6
+ const item = (cardID: string): Item => ({ cardID });
7
+ const ids = (q: ItemQueue<Item>): string[] =>
8
+ Array.from({ length: q.length }, (_, i) => q.peek(i).cardID);
9
+
10
+ describe('ItemQueue.mergeToFront', () => {
11
+ it('adds new items to the front, preserving batch order', () => {
12
+ const q = new ItemQueue<Item>();
13
+ q.addAll([item('a'), item('b')], id);
14
+
15
+ const added = q.mergeToFront([item('x'), item('y')], id);
16
+
17
+ expect(added).toBe(2);
18
+ expect(ids(q)).toEqual(['x', 'y', 'a', 'b']);
19
+ });
20
+
21
+ it('skips an ordinary duplicate, leaving it in place', () => {
22
+ const q = new ItemQueue<Item>();
23
+ q.addAll([item('a'), item('b'), item('c')], id);
24
+
25
+ // 'b' already queued and not mandatory → left where it is; 'x' fronted.
26
+ const added = q.mergeToFront([item('x'), item('b')], id);
27
+
28
+ expect(added).toBe(1);
29
+ expect(ids(q)).toEqual(['x', 'a', 'b', 'c']);
30
+ });
31
+
32
+ it('re-fronts an already-queued mandatory card instead of burying it', () => {
33
+ // Repro of the require-card burial: 'req' was fronted by a prior burst
34
+ // replan, then an additive merge brings fresh non-required cards. Without
35
+ // the mandatory re-front, 'x'/'y' would leapfrog 'req' and sink it.
36
+ const q = new ItemQueue<Item>();
37
+ q.addAll([item('req'), item('a'), item('b')], id);
38
+
39
+ const added = q.mergeToFront(
40
+ [item('req'), item('x'), item('y')],
41
+ id,
42
+ new Set(['req'])
43
+ );
44
+
45
+ // 'req' is not a *new* add, so it isn't counted...
46
+ expect(added).toBe(2);
47
+ // ...but it leads the queue (ahead of the freshly merged 'x'/'y').
48
+ expect(ids(q)).toEqual(['req', 'x', 'y', 'a', 'b']);
49
+ // and isn't duplicated.
50
+ expect(ids(q).filter((c) => c === 'req')).toHaveLength(1);
51
+ });
52
+
53
+ it('keeps a mandatory card already at the front at the front', () => {
54
+ const q = new ItemQueue<Item>();
55
+ q.addAll([item('req'), item('a')], id);
56
+
57
+ q.mergeToFront([item('req'), item('x')], id, new Set(['req']));
58
+
59
+ expect(ids(q)).toEqual(['req', 'x', 'a']);
60
+ });
61
+
62
+ it('without forceFrontIds, preserves the legacy skip-duplicate behavior', () => {
63
+ const q = new ItemQueue<Item>();
64
+ q.addAll([item('req'), item('a')], id);
65
+
66
+ // No mandatory set → 'req' stays put and is buried behind the merged 'x'.
67
+ q.mergeToFront([item('req'), item('x')], id);
68
+
69
+ expect(ids(q)).toEqual(['x', 'req', 'a']);
70
+ });
71
+ });
@@ -73,8 +73,21 @@ export class ItemQueue<T> {
73
73
  * Merge new items into the front of the queue, skipping duplicates.
74
74
  * Used by additive replans to inject high-quality candidates without
75
75
  * discarding the existing queue contents.
76
+ *
77
+ * `forceFrontIds` carries the mandatory (`+INF`) cards in this batch — a
78
+ * durable `requireCard`/`requireTag` re-asserted by every replan. An ordinary
79
+ * duplicate is left in place (skip), but a mandatory one that's *already*
80
+ * queued is pulled out of its current slot so it rejoins at the front in batch
81
+ * order. Without this, an additive merge unshifts fresh non-required cards
82
+ * ahead of an already-present required card, steadily burying it until it never
83
+ * gets drawn — defeating the "must appear" guarantee. Returns the count of
84
+ * genuinely new cards added (re-fronted duplicates are not counted).
76
85
  */
77
- public mergeToFront(items: T[], cardIdExtractor: (item: T) => string): number {
86
+ public mergeToFront(
87
+ items: T[],
88
+ cardIdExtractor: (item: T) => string,
89
+ forceFrontIds?: ReadonlySet<string>
90
+ ): number {
78
91
  let added = 0;
79
92
  const toInsert: T[] = [];
80
93
  for (const item of items) {
@@ -83,6 +96,11 @@ export class ItemQueue<T> {
83
96
  this.seenCardIds.push(cardId);
84
97
  toInsert.push(item);
85
98
  added++;
99
+ } else if (forceFrontIds?.has(cardId)) {
100
+ const idx = this.q.findIndex((qi) => cardIdExtractor(qi) === cardId);
101
+ if (idx >= 0) {
102
+ toInsert.push(...this.q.splice(idx, 1));
103
+ }
86
104
  }
87
105
  }
88
106
  this.q.unshift(...toInsert);
@@ -21,7 +21,12 @@ import { mergeHints } from '@db/core/navigators/Pipeline';
21
21
  import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
22
22
  import { captureMixerRun } from './MixerDebugger';
23
23
  import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking } from './SessionDebugger';
24
- import { registerActiveController, type SessionDebugSnapshot, type SessionQueueDebug } from './SessionOverlay';
24
+ import {
25
+ registerActiveController,
26
+ type SessionDebugSnapshot,
27
+ type SessionDrawnCardDebug,
28
+ type SessionQueueDebug,
29
+ } from './SessionOverlay';
25
30
 
26
31
  // ReplanHints is defined in generators/types to avoid circular dependencies.
27
32
  // Re-exported here for backward compatibility.
@@ -59,6 +64,21 @@ export interface ReplanOptions {
59
64
  * multiply, require/exclude lists concatenate).
60
65
  */
61
66
  sessionHints?: ReplanHints;
67
+ /**
68
+ * Like `sessionHints`, but *merged* into the existing session-durable hints
69
+ * (via `mergeHints`) instead of replacing them. Use when emphasis should
70
+ * *accumulate* across replans rather than clobber — e.g. introducing a second
71
+ * concept mid-session must not wipe the first concept's boost, nor any
72
+ * `difficultyBooster`/`conceptBackoff` state on other concepts.
73
+ *
74
+ * Merge semantics (see `mergeHints`): boosts MULTIPLY, require/exclude lists
75
+ * concat-dedup. Re-emphasising the *same* tag therefore compounds — callers
76
+ * boosting a tag they may have already boosted should clamp at the call site.
77
+ *
78
+ * If both `sessionHints` and `mergeSessionHints` are supplied, the replace is
79
+ * applied first, then the merge — but they are normally mutually exclusive.
80
+ */
81
+ mergeSessionHints?: ReplanHints;
62
82
  /**
63
83
  * Maximum number of new cards to return from the pipeline.
64
84
  * Default: 20 (the standard session batch size).
@@ -283,6 +303,22 @@ export class SessionController<TView = unknown> extends Loggable {
283
303
  */
284
304
  private _sessionHints: ReplanHints | null = null;
285
305
 
306
+ /**
307
+ * Card IDs that have been *served* (drawn/consumed) this session. Populated
308
+ * at the single consumption choke-point (removeItemFromQueue), so it reflects
309
+ * a draw the instant it happens — earlier than `_sessionRecord`, which only
310
+ * lands once the card is *responded to*.
311
+ *
312
+ * Used to keep already-served cards out of newQ on every (re)plan: a `new`
313
+ * card shown once must never re-enter newQ this session. This is the general
314
+ * guard against re-presentation — including the case where a replan in flight
315
+ * captured a now-drawn card (e.g. a +INF require-injected follow-up the
316
+ * depletion prefetch grabbed just before it was drawn). Reviews/failed cards
317
+ * legitimately recur and are tracked by their own queues, so this only gates
318
+ * `new`-origin candidates.
319
+ */
320
+ private _servedCardIds: Set<string> = new Set();
321
+
286
322
  /**
287
323
  * Consumer-supplied hooks invoked after each question response is processed.
288
324
  * Seeded from constructor options (threaded from
@@ -562,6 +598,7 @@ export class SessionController<TView = unknown> extends Loggable {
562
598
  if (opts.mode && opts.mode !== 'replace') return true;
563
599
  if (opts.hints && Object.keys(opts.hints).length > 0) return true;
564
600
  if (opts.sessionHints !== undefined) return true;
601
+ if (opts.mergeSessionHints !== undefined) return true;
565
602
  return false;
566
603
  }
567
604
 
@@ -623,6 +660,14 @@ export class SessionController<TView = unknown> extends Loggable {
623
660
  );
624
661
  }
625
662
 
663
+ // Additive emphasis: merge (don't clobber) into durable hints. Lets a new
664
+ // concept's boost accumulate on top of prior concepts' boosts and any
665
+ // observer-managed boost/decay state. Boosts multiply, lists concat-dedup.
666
+ if (opts.mergeSessionHints !== undefined) {
667
+ this._sessionHints = mergeHints([this._sessionHints, opts.mergeSessionHints]) ?? null;
668
+ this.log(`[Replan] Session hints merged: ${JSON.stringify(this._sessionHints)}`);
669
+ }
670
+
626
671
  // Forward hints to all sources (CourseDB stashes them, Pipeline consumes
627
672
  // them). The one-shot `opts.hints` are merged with the durable
628
673
  // `_sessionHints` so session emphasis survives this and every later run.
@@ -692,6 +737,16 @@ export class SessionController<TView = unknown> extends Loggable {
692
737
  }
693
738
  return { length: q.length, dequeueCount: q.dequeueCount, cards };
694
739
  };
740
+ const drawnCards: SessionDrawnCardDebug[] = this._sessionRecord.map((r) => {
741
+ const last = r.records[r.records.length - 1];
742
+ return {
743
+ cardID: r.item.cardID,
744
+ status: r.item.status,
745
+ attempts: r.records.length,
746
+ correct: last && isQuestionRecord(last) ? last.isCorrect : null,
747
+ timeSpentMs: r.records.reduce((sum, rec) => sum + rec.timeSpent, 0),
748
+ };
749
+ });
695
750
  return {
696
751
  secondsRemaining: this.secondsRemaining,
697
752
  hasCardGuarantee: this.hasCardGuarantee,
@@ -704,6 +759,7 @@ export class SessionController<TView = unknown> extends Loggable {
704
759
  reviewQ: describe(this.reviewQ),
705
760
  newQ: describe(this.newQ),
706
761
  failedQ: describe(this.failedQ),
762
+ drawnCards,
707
763
  };
708
764
  }
709
765
 
@@ -1100,9 +1156,27 @@ export class SessionController<TView = unknown> extends Loggable {
1100
1156
  const reviewWeighted = mixedWeighted
1101
1157
  .filter((w) => getCardOrigin(w) === 'review')
1102
1158
  .slice(0, this._initialReviewCap);
1103
- const newWeighted = mixedWeighted
1104
- .filter((w) => getCardOrigin(w) === 'new')
1105
- .slice(0, newLimit);
1159
+ // Proactive de-dup: a `new` card served earlier this session must never
1160
+ // re-enter newQ whether it slipped back via a +INF require-injection, an
1161
+ // additive merge, or a stale generator candidate. This is the general guard
1162
+ // (see _servedCardIds); it makes re-presentation structurally impossible
1163
+ // rather than relying on each upstream path to exclude correctly.
1164
+ const newCandidates = mixedWeighted.filter(
1165
+ (w) => getCardOrigin(w) === 'new' && !this._servedCardIds.has(w.cardId)
1166
+ );
1167
+ // `+INF` is the hard "include at all costs" sentinel applied by require*
1168
+ // injection (see Pipeline.applyRequirement). Partition these mandatory cards
1169
+ // to the front and exempt them from the newLimit slice, so neither the
1170
+ // mixer's source-shuffle/round-robin nor the cap can bury or drop a required
1171
+ // card before it reaches newQ. The set is also handed to mergeToFront so an
1172
+ // already-queued required card gets re-fronted rather than leapfrogged.
1173
+ const mandatoryWeighted = newCandidates.filter((w) => w.score === Number.POSITIVE_INFINITY);
1174
+ const optionalWeighted = newCandidates.filter((w) => w.score !== Number.POSITIVE_INFINITY);
1175
+ const newWeighted = [
1176
+ ...mandatoryWeighted,
1177
+ ...optionalWeighted.slice(0, Math.max(0, newLimit - mandatoryWeighted.length)),
1178
+ ];
1179
+ const mandatoryIds = new Set(mandatoryWeighted.map((w) => w.cardId));
1106
1180
 
1107
1181
  logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
1108
1182
 
@@ -1145,8 +1219,10 @@ export class SessionController<TView = unknown> extends Loggable {
1145
1219
  }
1146
1220
 
1147
1221
  if (additive) {
1148
- // Additive replan: merge new candidates into front of existing queue
1149
- const added = this.newQ.mergeToFront(newItems, (item) => item.cardID);
1222
+ // Additive replan: merge new candidates into front of existing queue.
1223
+ // Pass mandatory (+INF) ids so an already-queued required card is pulled
1224
+ // back to the front instead of being buried by fresh non-required cards.
1225
+ const added = this.newQ.mergeToFront(newItems, (item) => item.cardID, mandatoryIds);
1150
1226
  report += `Additive merge: ${added} new cards added to front of newQ\n`;
1151
1227
  } else if (replan) {
1152
1228
  // Atomic swap: replace entire newQ contents at once (no empty-queue window)
@@ -1347,7 +1423,10 @@ export class SessionController<TView = unknown> extends Loggable {
1347
1423
  `Triggering background replan.`
1348
1424
  );
1349
1425
  // label is observability-only (overlay/logs); does not affect coalescing.
1350
- void this.requestReplan({ label: 'auto:depletion' });
1426
+ // mode:'merge' preserves any already-queued cards (e.g. an undrawn
1427
+ // prescribed WST whose one-shot requireCards won't be re-asserted by this
1428
+ // bare replan) instead of replaceAll() wiping them. See mergeToFront.
1429
+ void this.requestReplan({ label: 'auto:depletion', mode: 'merge' });
1351
1430
  }
1352
1431
 
1353
1432
  // Opportunistic quality: few well-indicated cards remain.
@@ -1586,6 +1665,21 @@ export class SessionController<TView = unknown> extends Loggable {
1586
1665
  * Remove an item from its source queue after consumption by nextCard().
1587
1666
  */
1588
1667
  private removeItemFromQueue(item: StudySessionItem): void {
1668
+ // Durable-until-drawn requirements: a caller may place a card ID in the
1669
+ // session-durable `requireCards` (via mergeSessionHints) so every later
1670
+ // replan re-asserts it at +INF until it actually surfaces — e.g. a
1671
+ // prescribed intro follow-up that must survive the replace-mode burst/auto
1672
+ // replans that would otherwise clobber a one-shot requirement. The
1673
+ // requirement is satisfied the instant the card is drawn, so clear it here
1674
+ // (the single consumption choke-point) to stop it being re-injected forever
1675
+ // and to bound accumulation. Generic: card-ID only, no domain vocabulary.
1676
+ this._clearDurableRequirement(item.cardID);
1677
+
1678
+ // Record the draw immediately (earlier than _sessionRecord, which waits for
1679
+ // a response) so getWeightedContent can keep this card out of newQ on any
1680
+ // replan that lands after this draw.
1681
+ this._servedCardIds.add(item.cardID);
1682
+
1589
1683
  // Check each queue - item should be at the front of one of them
1590
1684
  if (this.reviewQ.peek(0)?.cardID === item.cardID) {
1591
1685
  this.reviewQ.dequeue((queueItem) => queueItem.cardID);
@@ -1599,6 +1693,28 @@ export class SessionController<TView = unknown> extends Loggable {
1599
1693
  }
1600
1694
  }
1601
1695
 
1696
+ /**
1697
+ * Remove a satisfied card ID from the durable session-hint `requireCards`
1698
+ * list. Called when a card is consumed (see removeItemFromQueue). No-op if
1699
+ * the card was not a durable requirement.
1700
+ *
1701
+ * Matches literal IDs only: a glob/pattern requirement (which may stand for
1702
+ * several cards) is NOT considered satisfied by a single draw and is left in
1703
+ * place — durable patterns are the caller's responsibility, one-shot `hints`
1704
+ * remain the right tool for them.
1705
+ */
1706
+ private _clearDurableRequirement(cardID: string): void {
1707
+ const req = this._sessionHints?.requireCards;
1708
+ if (!req || req.length === 0) return;
1709
+ const next = req.filter((id) => id !== cardID);
1710
+ if (next.length === req.length) return; // not a durable requirement
1711
+ this._sessionHints = {
1712
+ ...this._sessionHints!,
1713
+ requireCards: next.length > 0 ? next : undefined,
1714
+ };
1715
+ this.log(`[Replan] Durable requirement satisfied & cleared on draw: ${cardID}`);
1716
+ }
1717
+
1602
1718
  /**
1603
1719
  * End the session and record learning outcomes.
1604
1720
  *
@@ -25,6 +25,22 @@ export interface SessionQueueDebug {
25
25
  cards: string[];
26
26
  }
27
27
 
28
+ /**
29
+ * A card the learner has interacted with this session (one entry per card in
30
+ * the session record, regardless of which queue — if any — still holds it).
31
+ */
32
+ export interface SessionDrawnCardDebug {
33
+ cardID: string;
34
+ /** Queue status at draw time: 'new' | 'review' | 'failed-new' | 'failed-review'. */
35
+ status: string;
36
+ /** Number of CardRecords logged for this card this session (≥1). */
37
+ attempts: number;
38
+ /** Latest record's correctness; null for non-question (info) records. */
39
+ correct: boolean | null;
40
+ /** Total time spent across all of this card's records, in ms. */
41
+ timeSpentMs: number;
42
+ }
43
+
28
44
  /** Live snapshot of the controller, read fresh on each overlay tick. */
29
45
  export interface SessionDebugSnapshot {
30
46
  secondsRemaining: number;
@@ -42,6 +58,8 @@ export interface SessionDebugSnapshot {
42
58
  reviewQ: SessionQueueDebug;
43
59
  newQ: SessionQueueDebug;
44
60
  failedQ: SessionQueueDebug;
61
+ /** Every card the learner has interacted with this session, draw order. */
62
+ drawnCards: SessionDrawnCardDebug[];
45
63
  }
46
64
 
47
65
  /** The narrow surface the overlay needs from a SessionController. */
@@ -75,7 +93,11 @@ export function getActiveController(): SessionDebugTarget | null {
75
93
 
76
94
  const OVERLAY_ID = 'skuilder-session-overlay';
77
95
  const POLL_MS = 300;
78
- /** Queues with at most this many cards are listed outright; larger ones collapse to a count. */
96
+ /**
97
+ * Cap on how many cards a queue lists by default. Queues at or below this show
98
+ * in full; larger ones show the first INLINE_THRESHOLD then a clickable
99
+ * "… +N more" affordance that expands to the full list (and back).
100
+ */
79
101
  const INLINE_THRESHOLD = 5;
80
102
 
81
103
  /** Braille spinner frames, advanced once per render tick (≈POLL_MS cadence). */
@@ -85,8 +107,29 @@ let spinnerFrame = 0;
85
107
  let overlayEl: HTMLElement | null = null;
86
108
  let pollHandle: ReturnType<typeof setInterval> | null = null;
87
109
 
88
- /** Expansion state for collapsible (large) queues, preserved across re-renders. */
89
- const expanded: Record<string, boolean> = { reviewQ: false, newQ: false, failedQ: false };
110
+ /**
111
+ * Most recent snapshot rendered, retained so the click-to-copy button can
112
+ * serialise exactly what is on screen at click time (decoupled from the poll).
113
+ */
114
+ let lastSnapshot: SessionDebugSnapshot | null = null;
115
+
116
+ /** Epoch ms until which the copy button shows its "copied" confirmation. */
117
+ let copyFlashUntil = 0;
118
+
119
+ /**
120
+ * When minified, the overlay collapses to just its header bar (title + copy +
121
+ * restore button), suppressing all body sections. The poll keeps running so
122
+ * `lastSnapshot` — and therefore the copy button — stays current underneath.
123
+ */
124
+ let minified = false;
125
+
126
+ /** Expansion state for collapsible (large) lists, preserved across re-renders. */
127
+ const expanded: Record<string, boolean> = {
128
+ reviewQ: false,
129
+ newQ: false,
130
+ failedQ: false,
131
+ drawn: false,
132
+ };
90
133
 
91
134
  /**
92
135
  * Toggle the pinned overlay on/off. No-ops (with a console hint) when there is
@@ -107,6 +150,7 @@ export function toggleSessionOverlay(): void {
107
150
  }
108
151
 
109
152
  function mount(): void {
153
+ minified = false;
110
154
  overlayEl = document.createElement('div');
111
155
  overlayEl.id = OVERLAY_ID;
112
156
  Object.assign(overlayEl.style, {
@@ -149,11 +193,22 @@ function render(): void {
149
193
 
150
194
  const ctrl = getActiveController();
151
195
  if (!ctrl) {
152
- overlayEl.innerHTML = headerHtml() + `<div style="opacity:.65">No active session.</div>`;
196
+ lastSnapshot = null;
197
+ overlayEl.innerHTML =
198
+ headerHtml() + (minified ? '' : `<div style="opacity:.65">No active session.</div>`);
199
+ attachHandlers();
153
200
  return;
154
201
  }
155
202
 
156
203
  const s = ctrl.getDebugSnapshot();
204
+ lastSnapshot = s;
205
+
206
+ if (minified) {
207
+ overlayEl.innerHTML = headerHtml();
208
+ attachHandlers();
209
+ return;
210
+ }
211
+
157
212
  overlayEl.innerHTML =
158
213
  headerHtml() +
159
214
  replanHtml(s) +
@@ -161,9 +216,17 @@ function render(): void {
161
216
  hintsHtml(s.sessionHints) +
162
217
  queueHtml('reviewQ', 'reviewQ', s.reviewQ) +
163
218
  queueHtml('newQ', 'newQ', s.newQ) +
164
- queueHtml('failedQ', 'failedQ', s.failedQ);
219
+ queueHtml('failedQ', 'failedQ', s.failedQ) +
220
+ drawnHtml('drawn', s.drawnCards);
221
+
222
+ attachHandlers();
223
+ }
224
+
225
+ /** (Re-)bind click handlers after each innerHTML rewrite. */
226
+ function attachHandlers(): void {
227
+ if (!overlayEl) return;
165
228
 
166
- // Re-attach toggle handlers for collapsible queue headers each render.
229
+ // Toggle handlers for collapsible queue / drawn-list headers and footers.
167
230
  overlayEl.querySelectorAll<HTMLElement>('[data-q]').forEach((el) => {
168
231
  el.onclick = () => {
169
232
  const key = el.dataset.q;
@@ -172,10 +235,61 @@ function render(): void {
172
235
  render();
173
236
  };
174
237
  });
238
+
239
+ // Global click-to-copy: dump the currently-displayed snapshot as plain text.
240
+ const copyBtn = overlayEl.querySelector<HTMLElement>('[data-copy]');
241
+ if (copyBtn) {
242
+ copyBtn.onclick = (ev) => {
243
+ ev.stopPropagation();
244
+ copySnapshot();
245
+ };
246
+ }
247
+
248
+ // Minify / restore: collapse the overlay to its header bar and back.
249
+ const minBtn = overlayEl.querySelector<HTMLElement>('[data-min]');
250
+ if (minBtn) {
251
+ minBtn.onclick = (ev) => {
252
+ ev.stopPropagation();
253
+ minified = !minified;
254
+ render();
255
+ };
256
+ }
257
+ }
258
+
259
+ /** Serialise the on-screen snapshot to the clipboard, with a transient flash. */
260
+ function copySnapshot(): void {
261
+ const text = snapshotToText(lastSnapshot);
262
+ const flash = () => {
263
+ copyFlashUntil = Date.now() + 1200;
264
+ render();
265
+ };
266
+ const clip = typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
267
+ if (clip?.writeText) {
268
+ clip.writeText(text).then(flash, (err) => {
269
+ logger.warn(`[Session Overlay] Clipboard write failed: ${String(err)}`);
270
+ });
271
+ } else {
272
+ logger.info(`[Session Overlay] Clipboard unavailable; snapshot follows:\n${text}`);
273
+ }
175
274
  }
176
275
 
177
276
  function headerHtml(): string {
178
- return `<div style="font-weight:600;color:#93c5fd;margin-bottom:4px">⚙ SessionController</div>`;
277
+ const flashing = Date.now() < copyFlashUntil;
278
+ const btnLabel = flashing ? '✓ copied' : '⎘ copy';
279
+ const btnColor = flashing ? '#86efac' : '#93c5fd';
280
+ const copyBtn =
281
+ `<span data-copy style="cursor:pointer;float:right;font-weight:400;` +
282
+ `color:${btnColor};border:1px solid currentColor;border-radius:4px;` +
283
+ `padding:0 4px;line-height:1.3">${btnLabel}</span>`;
284
+ const minBtn =
285
+ `<span data-min title="${minified ? 'Restore' : 'Minify'}" ` +
286
+ `style="cursor:pointer;float:right;font-weight:400;color:#93c5fd;` +
287
+ `border:1px solid currentColor;border-radius:4px;padding:0 5px;` +
288
+ `margin-right:4px;line-height:1.3">${minified ? '▢' : '—'}</span>`;
289
+ return (
290
+ `<div style="font-weight:600;color:#93c5fd;margin-bottom:${minified ? 0 : 4}px">` +
291
+ `${copyBtn}${minBtn}⚙ SessionController</div>`
292
+ );
179
293
  }
180
294
 
181
295
  function replanHtml(s: SessionDebugSnapshot): string {
@@ -238,29 +352,139 @@ function hintsHtml(h: ReplanHints | null): string {
238
352
 
239
353
  function queueHtml(key: string, label: string, q: SessionQueueDebug): string {
240
354
  const collapsible = q.length > INLINE_THRESHOLD;
241
- const isOpen = !collapsible || expanded[key];
355
+ const isOpen = collapsible && expanded[key];
242
356
  const caret = collapsible ? (expanded[key] ? '▾ ' : '▸ ') : '';
243
357
  const drawn = q.dequeueCount ? ` <span style="opacity:.5">drawn ${q.dequeueCount}</span>` : '';
244
- const titleStyle = collapsible
245
- ? 'cursor:pointer;color:#f9a8d4'
246
- : 'color:#f9a8d4';
358
+ const titleStyle = collapsible ? 'cursor:pointer;color:#f9a8d4' : 'color:#f9a8d4';
247
359
  const titleAttr = collapsible ? ` data-q="${key}"` : '';
248
360
  const title = `<div${titleAttr} style="${titleStyle}">${caret}${label}: ${q.length}${drawn}</div>`;
249
361
 
250
- let body = '';
251
- if (isOpen && q.cards.length) {
252
- body =
253
- `<ol style="margin:2px 0 6px 0;padding-left:20px">` +
254
- q.cards.map((c) => `<li style="white-space:nowrap">${esc(c)}</li>`).join('') +
255
- `</ol>`;
256
- } else if (!q.cards.length) {
257
- body = `<div style="margin:1px 0 6px 6px;opacity:.5">empty</div>`;
258
- } else {
259
- body = `<div style="margin:1px 0 6px 6px;opacity:.55">(${q.length} cards — click to expand)</div>`;
362
+ if (!q.cards.length) {
363
+ return title + `<div style="margin:1px 0 6px 6px;opacity:.5">empty</div>`;
260
364
  }
365
+
366
+ // Always list up to INLINE_THRESHOLD cards; the remainder hides behind an
367
+ // expand toggle so long queues never blow out the overlay but stay inspectable.
368
+ const shown = isOpen ? q.cards : q.cards.slice(0, INLINE_THRESHOLD);
369
+ const hiddenCount = q.length - shown.length;
370
+ const listMarginBottom = collapsible ? 2 : 6;
371
+
372
+ let body =
373
+ `<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">` +
374
+ shown.map((c) => `<li style="white-space:nowrap">${esc(c)}</li>`).join('') +
375
+ `</ol>`;
376
+
377
+ if (collapsible) {
378
+ const footer = isOpen ? '▾ show less' : `… +${hiddenCount} more`;
379
+ body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
380
+ }
381
+
261
382
  return title + body;
262
383
  }
263
384
 
385
+ /** Compact, colour-coded per-card glyph: ✓ correct, ✗ wrong, · info-only. */
386
+ function outcomeGlyph(correct: boolean | null): string {
387
+ if (correct === true) return `<span style="color:#86efac">✓</span>`;
388
+ if (correct === false) return `<span style="color:#fca5a5">✗</span>`;
389
+ return `<span style="opacity:.5">·</span>`;
390
+ }
391
+
392
+ /**
393
+ * Expandable list of every card the learner has interacted with this session.
394
+ * Mirrors `queueHtml`'s collapse behaviour but renders richer per-card detail
395
+ * (status, outcome, attempt count) since this is the audit trail, not a queue.
396
+ */
397
+ function drawnHtml(key: string, drawn: SessionDrawnCardDebug[]): string {
398
+ const collapsible = drawn.length > INLINE_THRESHOLD;
399
+ const isOpen = collapsible && expanded[key];
400
+ const caret = collapsible ? (expanded[key] ? '▾ ' : '▸ ') : '';
401
+ const titleStyle = collapsible ? 'cursor:pointer;color:#c4b5fd' : 'color:#c4b5fd';
402
+ const titleAttr = collapsible ? ` data-q="${key}"` : '';
403
+ const title = `<div${titleAttr} style="${titleStyle}">${caret}drawn: ${drawn.length}</div>`;
404
+
405
+ if (!drawn.length) {
406
+ return title + `<div style="margin:1px 0 6px 6px;opacity:.5">none yet</div>`;
407
+ }
408
+
409
+ const shown = isOpen ? drawn : drawn.slice(0, INLINE_THRESHOLD);
410
+ const hiddenCount = drawn.length - shown.length;
411
+ const listMarginBottom = collapsible ? 2 : 6;
412
+
413
+ const rows = shown
414
+ .map((d) => {
415
+ const retries = d.attempts > 1 ? `<span style="opacity:.5"> ×${d.attempts}</span>` : '';
416
+ const time = `<span style="opacity:.45"> ${Math.round(d.timeSpentMs / 100) / 10}s</span>`;
417
+ return (
418
+ `<li style="white-space:nowrap">${outcomeGlyph(d.correct)} ${esc(d.cardID)}` +
419
+ `<span style="opacity:.5"> [${esc(d.status)}]</span>${retries}${time}</li>`
420
+ );
421
+ })
422
+ .join('');
423
+
424
+ let body = `<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">${rows}</ol>`;
425
+
426
+ if (collapsible) {
427
+ const footer = isOpen ? '▾ show less' : `… +${hiddenCount} more`;
428
+ body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
429
+ }
430
+
431
+ return title + body;
432
+ }
433
+
434
+ /**
435
+ * Plain-text rendering of a snapshot for the clipboard. Mirrors the on-screen
436
+ * sections (without truncation) so a copied dump is a complete, paste-able
437
+ * picture of session state at the moment of the click.
438
+ */
439
+ function snapshotToText(s: SessionDebugSnapshot | null): string {
440
+ if (!s) return 'SessionController — no active session.';
441
+
442
+ const lines: string[] = [];
443
+ lines.push('=== SessionController ===');
444
+ lines.push(`time ${formatTime(s.secondsRemaining)}`);
445
+ if (s.hasCardGuarantee) lines.push(`guarantee: ${s.minCardsGuarantee}`);
446
+ lines.push(`well-indicated left: ${s.wellIndicatedRemaining}`);
447
+ lines.push(`current: ${s.currentCard ?? '—'}`);
448
+ lines.push(
449
+ s.replanActive ? `replan: ACTIVE [${s.replanLabel ?? '(auto)'}]` : 'replan: idle'
450
+ );
451
+
452
+ lines.push('');
453
+ lines.push('sessionHints:');
454
+ const h = s.sessionHints;
455
+ const hintParts: string[] = [];
456
+ if (h) {
457
+ if (h.boostTags && Object.keys(h.boostTags).length)
458
+ hintParts.push(` boost: ${Object.entries(h.boostTags).map(([k, v]) => `${k}×${v}`).join(', ')}`);
459
+ if (h.boostCards && Object.keys(h.boostCards).length)
460
+ hintParts.push(` boostCards: ${Object.entries(h.boostCards).map(([k, v]) => `${k}×${v}`).join(', ')}`);
461
+ if (h.requireCards?.length) hintParts.push(` require: ${h.requireCards.join(', ')}`);
462
+ if (h.requireTags?.length) hintParts.push(` requireTags: ${h.requireTags.join(', ')}`);
463
+ if (h.excludeTags?.length) hintParts.push(` exclude: ${h.excludeTags.join(', ')}`);
464
+ if (h.excludeCards?.length) hintParts.push(` excludeCards: ${h.excludeCards.join(', ')}`);
465
+ }
466
+ lines.push(hintParts.length ? hintParts.join('\n') : ' none');
467
+
468
+ const queueText = (label: string, q: SessionQueueDebug) => {
469
+ lines.push('');
470
+ lines.push(`${label}: ${q.length} (drawn ${q.dequeueCount})`);
471
+ q.cards.forEach((c, i) => lines.push(` ${i + 1}. ${c}`));
472
+ };
473
+ queueText('reviewQ', s.reviewQ);
474
+ queueText('newQ', s.newQ);
475
+ queueText('failedQ', s.failedQ);
476
+
477
+ lines.push('');
478
+ lines.push(`drawn: ${s.drawnCards.length}`);
479
+ s.drawnCards.forEach((d, i) => {
480
+ const mark = d.correct === true ? '✓' : d.correct === false ? '✗' : '·';
481
+ const time = `${Math.round(d.timeSpentMs / 100) / 10}s`;
482
+ lines.push(` ${i + 1}. ${mark} ${d.cardID} [${d.status}] ×${d.attempts} ${time}`);
483
+ });
484
+
485
+ return lines.join('\n');
486
+ }
487
+
264
488
  function formatTime(totalSeconds: number): string {
265
489
  const s = Math.max(0, Math.round(totalSeconds));
266
490
  const m = Math.floor(s / 60);