leetcode-capture-helper 0.0.0-stage → 0.1.1

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 (48) hide show
  1. package/cli.js +92 -0
  2. package/config.js +25 -0
  3. package/framing.js +57 -0
  4. package/host.js +420 -0
  5. package/install.js +290 -0
  6. package/package.json +31 -4
  7. package/runtime/bundle.json +42 -0
  8. package/runtime/companion/capture-log.js +83 -0
  9. package/runtime/companion/codex-backend.js +337 -0
  10. package/runtime/companion/companion.js +2190 -0
  11. package/runtime/companion/mouse-input.js +154 -0
  12. package/runtime/companion/package.json +4 -0
  13. package/runtime/companion/panel-bridge.js +273 -0
  14. package/runtime/companion/settings-rpc.js +228 -0
  15. package/runtime/companion/settings.js +464 -0
  16. package/runtime/companion/terminal-format.js +330 -0
  17. package/runtime/companion/vault-summary.js +168 -0
  18. package/runtime/companion/virtual-terminal.js +138 -0
  19. package/runtime/extension/shared/anthropic-backend.js +193 -0
  20. package/runtime/extension/shared/catalog.js +415 -0
  21. package/runtime/extension/shared/code-ledger.js +333 -0
  22. package/runtime/extension/shared/context-budget.js +259 -0
  23. package/runtime/extension/shared/context-summary.js +271 -0
  24. package/runtime/extension/shared/conversation.js +81 -0
  25. package/runtime/extension/shared/event-hub.js +126 -0
  26. package/runtime/extension/shared/extension-settings.js +445 -0
  27. package/runtime/extension/shared/helper-launcher.js +248 -0
  28. package/runtime/extension/shared/local-backend.js +706 -0
  29. package/runtime/extension/shared/local-server.js +149 -0
  30. package/runtime/extension/shared/package.json +4 -0
  31. package/runtime/extension/shared/practice.js +228 -0
  32. package/runtime/extension/shared/problem-index.js +4128 -0
  33. package/runtime/extension/shared/profiles.js +106 -0
  34. package/runtime/extension/shared/providers.js +58 -0
  35. package/runtime/extension/shared/reply-guard.js +864 -0
  36. package/runtime/extension/shared/roadmap.js +1337 -0
  37. package/runtime/extension/shared/sanitize.js +68 -0
  38. package/runtime/extension/shared/solved.js +316 -0
  39. package/runtime/extension/shared/tutor-engine.js +369 -0
  40. package/runtime/extension/shared/tutor-prompt.js +108 -0
  41. package/runtime/extension/shared/tutor-service.js +520 -0
  42. package/runtime/relay-server/package.json +4 -0
  43. package/runtime/relay-server/panel-broker.js +797 -0
  44. package/runtime/relay-server/server.js +241 -0
  45. package/runtime/vault-tool/log-session.js +230 -0
  46. package/runtime/vault-tool/package.json +4 -0
  47. package/runtime/vault-tool/vault-notes.js +206 -0
  48. package/README.md +0 -3
@@ -0,0 +1,271 @@
1
+ // extension/shared/context-summary.js - condenses the oldest exchanges of a tutor
2
+ // conversation into one short "earlier in this session" summary instead of
3
+ // dropping them, like Claude Code's /compact.
4
+ //
5
+ // LocalBackend (companion.js) fits every request to the model's prompt
6
+ // budget, and CodexBackend (codex-backend.js) keeps a bounded number of
7
+ // exchanges. Both used to drop the oldest whole exchanges outright, so the
8
+ // tutor forgot what the student had already tried and which hints it had
9
+ // given. Now those exchanges are folded into one summary (at most
10
+ // SUMMARY_MAX_TOKENS by the companion's own estimate), sent as a system
11
+ // message after the pinned problem and code and before the live history
12
+ // (context-budget.js's buildMessages). A later overflow merges the newly
13
+ // dropped exchanges into it, so it stays bounded. The pinned problem, the
14
+ // pinned code and the current message are never summarized.
15
+ //
16
+ // The summary comes from one model call on the same backend (a short
17
+ // instruction plus only the exchanges being dropped, which already fit
18
+ // before). When that call fails, times out, returns nothing or would not fit
19
+ // the window itself, a deterministic digest is used instead: one line per
20
+ // dropped exchange from the student's first sentence and the tutor's first
21
+ // sentence. When not even a summary fits, the oldest exchanges are dropped
22
+ // as before. None of this prints anything: a fixable overflow is silent.
23
+ //
24
+ // Pure apart from the injected `summarize` call: no Node API, no I/O, so it
25
+ // is unit-tested directly (context-summary.test.js) and could run in the
26
+ // browser extension.
27
+
28
+ import {
29
+ CHARS_PER_TOKEN_ESTIMATE,
30
+ SUMMARY_HEADER,
31
+ buildMessages,
32
+ estimateMessagesTokens,
33
+ estimateTokens,
34
+ fitToBudget,
35
+ summaryContent,
36
+ } from './context-budget.js';
37
+
38
+ export { SUMMARY_HEADER, summaryContent };
39
+
40
+ // The summary's own size cap, by the companion's estimate.
41
+ export const SUMMARY_MAX_TOKENS = 300;
42
+ // Below this much room a summary is not worth keeping: plain dropping wins.
43
+ export const SUMMARY_MIN_TOKENS = 60;
44
+ // How long the summary call may take before the digest is used instead.
45
+ export const SUMMARY_TIMEOUT_MS = 60_000;
46
+ // One digest line's cap, in characters, for each side of an exchange.
47
+ const DIGEST_STUDENT_CHARS = 120;
48
+ const DIGEST_TUTOR_CHARS = 160;
49
+
50
+ // Marks the summary call's own system message, so a log or a test stub can
51
+ // tell it apart from a tutor turn.
52
+ export const SUMMARIZER_SYSTEM =
53
+ 'You condense the earlier part of a programming tutoring session into a short summary for the tutor to keep. ' +
54
+ 'Write plain sentences or short bullet lines, no headings, and never use the em dash.';
55
+
56
+ // The most characters a summary body may have so the whole summary message
57
+ // stays within `tokens` by the estimate.
58
+ export function summaryBodyChars(tokens) {
59
+ return Math.max(0, Math.floor(tokens * CHARS_PER_TOKEN_ESTIMATE) - SUMMARY_HEADER.length - 1);
60
+ }
61
+
62
+ // Whole exchanges at the start of `history` ([system, (user, assistant)*,
63
+ // current]): how many there are.
64
+ export function countPairs(history) {
65
+ let pairs = 0;
66
+ for (let i = 1; i + 2 < history.length && history[i]?.role === 'user' && history[i + 1]?.role === 'assistant'; i += 2) {
67
+ pairs += 1;
68
+ }
69
+ return pairs;
70
+ }
71
+
72
+ function withoutPairs(history, k) {
73
+ return [history[0], ...history.slice(1 + 2 * k)];
74
+ }
75
+
76
+ // What a dropped exchange looked like on the wire: an older user turn whose
77
+ // code is pinned goes as its compact form, the same rule as buildMessages.
78
+ function renderedPairs(history, k, pinnedCode) {
79
+ const out = [];
80
+ for (let i = 0; i < k; i += 1) {
81
+ const user = history[1 + 2 * i];
82
+ const assistant = history[2 + 2 * i];
83
+ const compact = Boolean(pinnedCode) && typeof user.compactContent === 'string';
84
+ out.push({ student: compact ? user.compactContent : user.content, tutor: assistant.content });
85
+ }
86
+ return out;
87
+ }
88
+
89
+ // Cuts `text` to at most `max` characters at a word boundary.
90
+ export function clampText(text, max) {
91
+ const clean = String(text || '').trim();
92
+ if (clean.length <= max) return clean;
93
+ if (max <= 3) return '';
94
+ const cut = clean.slice(0, max - 3);
95
+ const space = cut.lastIndexOf(' ');
96
+ return `${(space > max / 2 ? cut.slice(0, space) : cut).trimEnd()}...`;
97
+ }
98
+
99
+ // The first sentence of a turn, without fenced code, diff lines or the
100
+ // companion's own framing, collapsed to one line.
101
+ export function firstSentence(text) {
102
+ let body = String(text || '');
103
+ const marker = body.lastIndexOf("The student's message:\n");
104
+ if (marker !== -1) body = body.slice(marker + "The student's message:\n".length);
105
+ body = body.replace(/```[\s\S]*?(```|$)/g, ' ');
106
+ body = body
107
+ .split('\n')
108
+ .filter((line) => !/^\s*[+-]\s*\d*\s*\|/.test(line) && !/^\s*\.\.\.\s*$/.test(line))
109
+ .join(' ');
110
+ body = body.replace(/\s+/g, ' ').trim();
111
+ const match = body.match(/^(.+?[.!?])(\s|$)/);
112
+ return match ? match[1] : body;
113
+ }
114
+
115
+ // The deterministic fallback: the earlier summary (cut to make room) plus
116
+ // one line per dropped exchange, newest kept when it is all too long.
117
+ export function digestSummary({ existing = null, dropped, maxChars }) {
118
+ const lines = dropped.map(
119
+ ({ student, tutor }) =>
120
+ `- Student: ${clampText(firstSentence(student), DIGEST_STUDENT_CHARS) || '(code only)'} ` +
121
+ `Tutor: ${clampText(firstSentence(tutor), DIGEST_TUTOR_CHARS) || '(no text)'}`
122
+ );
123
+ while (lines.length > 1 && lines.join('\n').length > maxChars) lines.shift();
124
+ let text = clampText(lines.join('\n'), maxChars);
125
+ if (existing) {
126
+ const room = maxChars - text.length - 1;
127
+ if (room >= 40) text = `${clampText(existing, room)}\n${text}`;
128
+ }
129
+ return text;
130
+ }
131
+
132
+ // The summary call's messages: the instruction, the earlier summary if any,
133
+ // and only the exchanges being dropped.
134
+ export function buildSummaryRequest({ existing = null, dropped }) {
135
+ const exchanges = dropped.map(({ student, tutor }) => `Student: ${student}\n\nTutor: ${tutor}`).join('\n\n---\n\n');
136
+ const content =
137
+ 'Summarize these earlier exchanges in under 250 words, keeping what the student tried, mistakes found, and ' +
138
+ 'hints already given. ' +
139
+ (existing ? 'Merge in the earlier summary below so one summary covers everything. ' : '') +
140
+ 'Reply with the summary only.\n\n' +
141
+ (existing ? `Earlier summary:\n${existing}\n\n` : '') +
142
+ `Exchanges:\n\n${exchanges}`;
143
+ return [
144
+ { role: 'system', content: SUMMARIZER_SYSTEM },
145
+ { role: 'user', content },
146
+ ];
147
+ }
148
+
149
+ function withTimeout(promiseFactory, timeoutMs) {
150
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
151
+ let timer;
152
+ const timeout = new Promise((_, reject) => {
153
+ timer = setTimeout(() => {
154
+ controller?.abort();
155
+ reject(new Error('the summary call timed out'));
156
+ }, timeoutMs);
157
+ });
158
+ return Promise.race([Promise.resolve().then(() => promiseFactory(controller?.signal)), timeout]).finally(() =>
159
+ clearTimeout(timer)
160
+ );
161
+ }
162
+
163
+ // Produces the merged summary of `dropped` (and `existing`), at most
164
+ // `maxChars`. Tries `summarize(messages, signal)` once when given and when
165
+ // `fits(messages)` says the call itself fits; otherwise, or when it throws,
166
+ // times out or returns nothing, uses the digest. Returns { summary, source }
167
+ // with source 'model' or 'digest'. Never throws.
168
+ export async function condense({ existing = null, dropped, maxChars, summarize = null, fits = () => true, timeoutMs = SUMMARY_TIMEOUT_MS }) {
169
+ if (summarize) {
170
+ const messages = buildSummaryRequest({ existing, dropped });
171
+ if (fits(messages)) {
172
+ try {
173
+ const reply = await withTimeout((signal) => summarize(messages, signal), timeoutMs);
174
+ const text = clampText(typeof reply === 'string' ? reply : '', maxChars);
175
+ if (text) return { summary: text, source: 'model' };
176
+ } catch {
177
+ // the digest below
178
+ }
179
+ }
180
+ }
181
+ return { summary: digestSummary({ existing, dropped, maxChars }), source: 'digest' };
182
+ }
183
+
184
+ // How many of `pairs` exchanges to condense once some must go: at least
185
+ // `needed`, and at least the older half, so the summary call is not paid
186
+ // again on the very next message.
187
+ export function condenseCount(pairs, needed) {
188
+ return Math.min(pairs, Math.max(needed, Math.ceil(pairs / 2)));
189
+ }
190
+
191
+ // Fits one LocalBackend request to `budget`, condensing instead of dropping.
192
+ // `history` is [system, (user, assistant)*, current]; `summary` the current
193
+ // summary or null; `maxPairs` the exchange-count cap (0 for none) and
194
+ // `forcePairs` exchanges that must go regardless (after a reply cut off for
195
+ // room). Returns { history, summary, messages, estimatedTokens, fits,
196
+ // droppedPairs, summarized ('model' | 'digest' | null), parts? } in the
197
+ // shape of fitToBudget. Never mutates `history`.
198
+ export async function fitWithSummary({
199
+ history,
200
+ pinnedProblemContext = null,
201
+ pinnedCode = null,
202
+ summary = null,
203
+ budget,
204
+ maxPairs = 0,
205
+ forcePairs = 0,
206
+ summarize = null,
207
+ timeoutMs = SUMMARY_TIMEOUT_MS,
208
+ }) {
209
+ const build = (h, s) => buildMessages({ history: h, pinnedProblemContext, pinnedCode, summary: s });
210
+ const pairs = countPairs(history);
211
+ const countExcess = maxPairs > 0 ? Math.max(0, pairs - maxPairs) : 0;
212
+ const mustDrop = Math.min(pairs, Math.max(countExcess, forcePairs));
213
+ const asIs = build(history, summary);
214
+ const asIsTokens = estimateMessagesTokens(asIs);
215
+ if (mustDrop === 0 && asIsTokens <= budget) {
216
+ return { history, summary, messages: asIs, estimatedTokens: asIsTokens, fits: true, droppedPairs: 0, summarized: null };
217
+ }
218
+
219
+ // The fewest exchanges to condense so the rest plus a full-size summary
220
+ // fit; failing that, all of them with whatever room is left.
221
+ const reserve = estimateTokens(summaryContent('x'.repeat(summaryBodyChars(SUMMARY_MAX_TOKENS))));
222
+ let needed = null;
223
+ for (let k = Math.max(mustDrop, 1); k <= pairs; k += 1) {
224
+ if (estimateMessagesTokens(build(withoutPairs(history, k), null)) + reserve <= budget) {
225
+ needed = k;
226
+ break;
227
+ }
228
+ }
229
+ let k;
230
+ let maxChars;
231
+ if (needed !== null) {
232
+ k = condenseCount(pairs, needed);
233
+ maxChars = summaryBodyChars(SUMMARY_MAX_TOKENS);
234
+ } else {
235
+ k = pairs;
236
+ const room = budget - estimateMessagesTokens(build(withoutPairs(history, pairs), null));
237
+ maxChars = summaryBodyChars(Math.min(SUMMARY_MAX_TOKENS, room));
238
+ }
239
+
240
+ if ((k > 0 || summary) && maxChars >= summaryBodyChars(SUMMARY_MIN_TOKENS)) {
241
+ const kept = withoutPairs(history, k);
242
+ const dropped = renderedPairs(history, k, pinnedCode);
243
+ const condensed = await condense({
244
+ existing: summary,
245
+ dropped,
246
+ maxChars,
247
+ summarize,
248
+ fits: (messages) => estimateMessagesTokens(messages) <= budget,
249
+ timeoutMs,
250
+ });
251
+ const messages = build(kept, condensed.summary);
252
+ const estimatedTokens = estimateMessagesTokens(messages);
253
+ if (estimatedTokens <= budget) {
254
+ return {
255
+ history: kept,
256
+ summary: condensed.summary,
257
+ messages,
258
+ estimatedTokens,
259
+ fits: true,
260
+ droppedPairs: k,
261
+ summarized: condensed.source,
262
+ };
263
+ }
264
+ }
265
+
266
+ // No room for any summary: drop the oldest exchanges as before (and the
267
+ // summary with them), or refuse when the pins and this message alone are
268
+ // over the budget.
269
+ const plain = fitToBudget({ history: withoutPairs(history, mustDrop), pinnedProblemContext, pinnedCode, budget });
270
+ return { ...plain, droppedPairs: plain.droppedPairs + mustDrop, summary: null, summarized: null };
271
+ }
@@ -0,0 +1,81 @@
1
+ // extension/shared/conversation.js - the tutor conversation as data every
2
+ // backend can hand to another, and the flattening the prompt-only backends
3
+ // (CodexBackend, ClaudeBackend's rebuild) share.
4
+ //
5
+ // Every backend keeps the same three things about the chat: the exchanges
6
+ // still shown in full, the "earlier in this session" summary of condensed
7
+ // ones (context-summary.js) and the pinned problem statement. The student's
8
+ // code is not part of it: the runtime's code ledger (code-ledger.js) owns it
9
+ // and hands every request the current full code. So a backend swap (another
10
+ // model on the options page, or the model switcher) moves this state to the
11
+ // new backend and the tutor forgets nothing: `transferConversation`.
12
+ //
13
+ // The exported shape, the same from every backend:
14
+ // { turns: [{ role: 'user' | 'assistant', content, compactContent? }],
15
+ // summary: string | null,
16
+ // problem: { problemId, title, slug, description } | null }
17
+ // `turns` always holds whole exchanges, oldest first.
18
+ //
19
+ // Pure JavaScript with no Node API (extension/shared.test.js checks that).
20
+
21
+ // A copy of `state` in the exported shape, with anything malformed left out:
22
+ // a turn that is not a string, a trailing unanswered user turn, a role out of
23
+ // order. Never throws.
24
+ export function normalizeConversation(state) {
25
+ const turns = [];
26
+ const raw = Array.isArray(state?.turns) ? state.turns : [];
27
+ for (const turn of raw) {
28
+ if (!turn || typeof turn.content !== 'string') continue;
29
+ const role = turn.role === 'assistant' ? 'assistant' : turn.role === 'user' ? 'user' : null;
30
+ const expected = turns.length % 2 === 0 ? 'user' : 'assistant';
31
+ if (role !== expected) continue;
32
+ turns.push({
33
+ role,
34
+ content: turn.content,
35
+ ...(typeof turn.compactContent === 'string' && turn.compactContent !== turn.content
36
+ ? { compactContent: turn.compactContent }
37
+ : {}),
38
+ });
39
+ }
40
+ if (turns.length % 2 === 1) turns.pop();
41
+ const summary = typeof state?.summary === 'string' && state.summary ? state.summary : null;
42
+ const problem = state?.problem && typeof state.problem === 'object' ? { ...state.problem } : null;
43
+ return { turns, summary, problem };
44
+ }
45
+
46
+ // Moves the conversation from one backend to another. Returns true when it
47
+ // did (both sides support it and they differ), so the caller knows the new
48
+ // backend has seen everything the old one had.
49
+ export function transferConversation(from, to) {
50
+ if (!from || !to || from === to) return false;
51
+ if (typeof from.exportConversation !== 'function' || typeof to.importConversation !== 'function') return false;
52
+ to.importConversation(normalizeConversation(from.exportConversation()));
53
+ return true;
54
+ }
55
+
56
+ // Splits buildMessages' list (context-budget.js) for a backend that takes one
57
+ // system text and one prompt string: the first `systemCount` leading system
58
+ // messages become the system text (keep it to what never changes within a
59
+ // problem, so the provider can cache it), and everything else becomes the
60
+ // prompt, in order: any further system messages (the pinned code, the
61
+ // summary), the earlier exchanges as a transcript, then the current message
62
+ // and `closing`.
63
+ export function flattenMessages(messages, { systemCount = Infinity, closing = '' } = {}) {
64
+ const leading = [];
65
+ let i = 0;
66
+ while (i < messages.length && messages[i].role === 'system') {
67
+ leading.push(messages[i].content);
68
+ i += 1;
69
+ }
70
+ const turns = messages.slice(i);
71
+ const current = turns.pop();
72
+ const system = leading.slice(0, systemCount);
73
+ const parts = leading.slice(systemCount);
74
+ if (turns.length) {
75
+ parts.push(
76
+ `Conversation so far:\n\n${turns.map((m) => `${m.role === 'user' ? 'Student' : 'Tutor'}: ${m.content}`).join('\n\n')}`
77
+ );
78
+ }
79
+ parts.push(`Student: ${current ? current.content : ''}${closing ? `\n\n${closing}` : ''}`);
80
+ return { system: system.join('\n\n---\n\n'), prompt: parts.join('\n\n---\n\n') };
81
+ }
@@ -0,0 +1,126 @@
1
+ // extension/shared/event-hub.js - the in-extension stand-in for the relay's
2
+ // panel broker (relay-server/panel-broker.js): a bounded, numbered buffer of
3
+ // tutor events that every Tutor panel replays on connect and then follows
4
+ // live. The background worker owns one; tutor-engine.js publishes into it.
5
+ //
6
+ // It keeps the relay's event protocol exactly, so the panel applies these
7
+ // events with the same code either way (see extension/panel.js):
8
+ // - every event carries `seq` and the hub's random `epoch`; a panel that
9
+ // reconnects with `after` and the same epoch gets only what it missed,
10
+ // and one from another epoch (the worker restarted) gets everything;
11
+ // - a turn-end drops that turn's buffered chunks, since it carries the
12
+ // whole text;
13
+ // - after the replay a subscriber gets the newest `config` event, then a
14
+ // `status` event ({ companionOnline, busy, epoch, openTurns });
15
+ // - dropProblem forgets a problem's events (the reset on a problem switch).
16
+ //
17
+ // Pure JavaScript with no Node API (extension/shared.test.js checks that).
18
+
19
+ export const MAX_BUFFERED_EVENTS = 2000;
20
+ export const MAX_OPEN_TURNS = 100;
21
+
22
+ function randomEpoch() {
23
+ const bytes = new Uint8Array(8);
24
+ crypto.getRandomValues(bytes);
25
+ return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
26
+ }
27
+
28
+ export function createEventHub({ epoch = randomEpoch(), maxEvents = MAX_BUFFERED_EVENTS } = {}) {
29
+ let nextSeq = 1;
30
+ let events = [];
31
+ const subscribers = new Set();
32
+ const openTurns = new Map(); // turnId -> problemSlug
33
+ let busy = false;
34
+ let config = null;
35
+
36
+ function send(subscriber, event) {
37
+ try {
38
+ subscriber(event);
39
+ } catch {
40
+ // A subscriber whose port went away; its own disconnect handler
41
+ // unsubscribes it.
42
+ }
43
+ }
44
+
45
+ function statusEvent() {
46
+ return { type: 'status', companionOnline: true, busy, epoch, openTurns: [...openTurns.keys()] };
47
+ }
48
+
49
+ function broadcast(event) {
50
+ for (const subscriber of subscribers) send(subscriber, event);
51
+ }
52
+
53
+ function track(event) {
54
+ if (!event.turnId) return;
55
+ if (event.type === 'turn-start') {
56
+ openTurns.delete(event.turnId);
57
+ openTurns.set(event.turnId, event.problemSlug || null);
58
+ if (openTurns.size > MAX_OPEN_TURNS) openTurns.delete(openTurns.keys().next().value);
59
+ } else if (event.type === 'turn-end') {
60
+ openTurns.delete(event.turnId);
61
+ }
62
+ }
63
+
64
+ return {
65
+ epoch,
66
+
67
+ // Adds one event ({ type, problemSlug?, turnId?, text?, ... }) and sends
68
+ // it to every subscriber. Returns the stored event.
69
+ publish(event) {
70
+ track(event);
71
+ const stored = { ...event, seq: nextSeq++, epoch };
72
+ if (stored.type === 'turn-end' && stored.turnId) {
73
+ events = events.filter((e) => !(e.type === 'chunk' && e.turnId === stored.turnId));
74
+ }
75
+ events.push(stored);
76
+ if (events.length > maxEvents) events = events.slice(events.length - maxEvents);
77
+ broadcast(stored);
78
+ return stored;
79
+ },
80
+
81
+ // Forgets every buffered event of one problem, chunks of its turns too
82
+ // (they carry only a turn id).
83
+ dropProblem(slug) {
84
+ if (!slug) return 0;
85
+ const before = events.length;
86
+ const turnIds = new Set(events.filter((e) => e.problemSlug === slug && e.turnId).map((e) => e.turnId));
87
+ events = events.filter((e) => e.problemSlug !== slug && !(e.turnId && turnIds.has(e.turnId)));
88
+ for (const [turnId, problemSlug] of openTurns) if (problemSlug === slug) openTurns.delete(turnId);
89
+ return before - events.length;
90
+ },
91
+
92
+ setBusy(next) {
93
+ if (busy === Boolean(next)) return;
94
+ busy = Boolean(next);
95
+ broadcast(statusEvent());
96
+ },
97
+
98
+ // The non-secret summary of what the tutor runs on ({ provider, label,
99
+ // model }), for the panel header.
100
+ setConfig(next) {
101
+ config = next ? { type: 'config', provider: next.provider, label: next.label, model: next.model ?? null } : null;
102
+ if (config) broadcast(config);
103
+ },
104
+
105
+ // Replays the buffer after `after` (everything when `clientEpoch` is from
106
+ // another run), then config and status, then follows live. Returns the
107
+ // unsubscribe function.
108
+ subscribe(subscriber, { after = 0, epoch: clientEpoch = null } = {}) {
109
+ const from = clientEpoch && clientEpoch !== epoch ? 0 : Number.isFinite(after) ? after : 0;
110
+ for (const event of events) if (event.seq > from) send(subscriber, event);
111
+ if (config) send(subscriber, config);
112
+ send(subscriber, statusEvent());
113
+ subscribers.add(subscriber);
114
+ return () => subscribers.delete(subscriber);
115
+ },
116
+
117
+ subscriberCount() {
118
+ return subscribers.size;
119
+ },
120
+
121
+ // For tests.
122
+ events() {
123
+ return events.slice();
124
+ },
125
+ };
126
+ }