leetcode-capture-helper 0.0.0-stage → 0.1.0

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 +346 -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 +2193 -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 +169 -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 +1344 -0
  37. package/runtime/extension/shared/sanitize.js +68 -0
  38. package/runtime/extension/shared/solved.js +277 -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,706 @@
1
+ // extension/shared/local-backend.js - the tutor backend for any
2
+ // OpenAI-compatible chat server (Ollama, LM Studio, llama.cpp server, vLLM,
3
+ // text-generation-webui, LocalAI, hosted OpenAI) and Ollama's own native chat
4
+ // API. The companion (Node) and the extension's background engine import this
5
+ // one copy.
6
+ //
7
+ // Pure JavaScript with no Node API: only fetch, TextDecoder and
8
+ // AbortController, which a browser's service worker has too
9
+ // (extension/shared.test.js checks that).
10
+
11
+ import { buildMessages as buildLocalMessages, resolveContextWindow, estimateMessagesTokens } from './context-budget.js';
12
+ import { fitWithSummary } from './context-summary.js';
13
+ import { probeServer, serverRoot } from './local-server.js';
14
+ import { formatPinnedCode } from './code-ledger.js';
15
+ import { normalizeConversation } from './conversation.js';
16
+ import { TUTOR_SYSTEM_MESSAGE } from './tutor-prompt.js';
17
+
18
+ // How many tokens of the window are held back for the model's own thinking
19
+ // and answer (see LocalBackend's class comment and companion.js's
20
+ // LOCAL_RESERVE_TOKENS, which can override it).
21
+ export const DEFAULT_RESERVE_TOKENS = 2600;
22
+
23
+ // What the companion calls the context-window setting in its messages.
24
+ export const DEFAULT_WINDOW_SETTING = 'COMPANION_LOCAL_NUM_CTX';
25
+
26
+ // Picks the actual reply text out of an OpenAI-compatible chat-completion
27
+ // response (or accumulated stream), used by both LocalBackend.sendMessage
28
+ // (non-streaming) and streamReply (streaming) so the two paths can't drift.
29
+ //
30
+ // The `reasoning`-when-`content`-is-empty fallback (see sendMessage's own
31
+ // comment) was built for a model that puts its whole, *finished* answer in
32
+ // `reasoning` and leaves `content` empty on an otherwise normal turn
33
+ // (finish_reason "stop"). It does NOT hold when the response was cut off
34
+ // before the model ever got past its own internal thinking
35
+ // (finish_reason "length"): in that case `reasoning` isn't a finished
36
+ // answer at all, just a raw, mid-thought scratchpad - handing it to
37
+ // sendAndPrint would print the model's own thinking process as if it were
38
+ // the tutor's actual reply, indistinguishable from a real one since it's
39
+ // still just text in the terminal.
40
+ //
41
+ // Confirmed live against the real Ollama gemma4:26b model this companion is
42
+ // actually configured for: a normal-length capture completes fine (content
43
+ // non-empty, or a complete reasoning-only answer on finish_reason "stop"),
44
+ // but a big Submit capture - a long solution, a long problem description,
45
+ // and/or several turns of resent history piling up in one session - eats
46
+ // most of Ollama's default 4096-token context window for this model (no
47
+ // num_ctx override; confirmed neither a top-level nor a nested `options`
48
+ // num_ctx field is honored by this Ollama version's /v1/chat/completions
49
+ // endpoint - only its native /api/chat does), leaving too little of the
50
+ // window for a real answer. The model then gets cut off mid-`reasoning`
51
+ // (finish_reason "length", content still empty), and the existing fallback
52
+ // printed that raw, truncated internal monologue as the "reply" - matching
53
+ // what grows more frequent as a session's conversation (and therefore its
54
+ // resent history) grows, not a rare fluke. Rather than ever surface that
55
+ // text, this throws a distinct, clearly-worded error instead - surfaced via
56
+ // the same "companion: error talking to backend" path as any other backend
57
+ // failure - so the failure is visible instead of silently masquerading as a
58
+ // real tutoring reply.
59
+ //
60
+ // This also fires on the very first turn of a brand-new conversation, with
61
+ // no accumulated history at all: one capture's own content (system prompt +
62
+ // problem description + submitted code) can by itself already exceed a
63
+ // small model's context window - a genuinely different failure from the
64
+ // multi-turn growth the pre-flight fit guards against, and confirmed live to
65
+ // happen with a perfectly normal-sized real submission (a description plus a
66
+ // ~150-line solution), not just a synthetic worst case. See LocalBackend's
67
+ // class comment for the actual fix (a bigger real context window via
68
+ // Ollama's native chat API) - `advice` lets the caller phrase this error
69
+ // according to which API path is actually in play, since the fix differs.
70
+ export function resolveReplyText({ content, reasoning, finishReason, advice }) {
71
+ const trimmedContent = content && content.trim();
72
+ const trimmedReasoning = reasoning && reasoning.trim();
73
+ // A cut-off answer is a failed turn, even when visible content exists.
74
+ // Keep that content on the error so callers can mark it as partial.
75
+ if (finishReason === 'length' && (trimmedContent || trimmedReasoning)) {
76
+ const err = new Error(
77
+ 'local backend reply was cut off before finishing; ' +
78
+ (advice ||
79
+ 'try a shorter capture, clearing context, or a model/server configuration with more context')
80
+ );
81
+ err.cutOff = true; // LocalBackend frees room for the next message on this one
82
+ err.partialReply = trimmedContent || '';
83
+ throw err;
84
+ }
85
+ if (trimmedContent) return trimmedContent;
86
+ return trimmedReasoning || '';
87
+ }
88
+
89
+ // Phrases resolveReplyText's cut-off advice and the budget refusal according
90
+ // to which API path LocalBackend actually used, so the message always points
91
+ // at something that can genuinely help: only Ollama's native API can be asked
92
+ // for a bigger window (COMPANION_LOCAL_NUM_CTX, or the runtime's own
93
+ // `windowSetting`); on any OpenAI-compatible
94
+ // server the window is set in the server itself.
95
+ export function contextAdviceFor(apiStyle, numCtx, setting = DEFAULT_WINDOW_SETTING) {
96
+ if (apiStyle === 'native') {
97
+ return (
98
+ `try a shorter capture, clearing context, or raising ${setting} ` +
99
+ `(currently ${numCtx}) if your model/machine has room for a bigger context window`
100
+ );
101
+ }
102
+ return (
103
+ "try a shorter capture, clearing context, or giving the model a bigger context window in the local " +
104
+ `server's own settings and setting ${setting} to match (an OpenAI-compatible API ` +
105
+ 'cannot ask the server for one)'
106
+ );
107
+ }
108
+
109
+ // A reply whose reported prompt size (usage.prompt_tokens, or Ollama's
110
+ // prompt_eval_count) is under this share of what LocalBackend estimates it
111
+ // sent means the server dropped part of the prompt to fit its own, smaller
112
+ // window. The estimate is deliberately on the high side for code (see
113
+ // CHARS_PER_TOKEN_ESTIMATE), so a normal tokenizer lands well above this;
114
+ // TRUNCATION_MIN_GAP keeps a tiny prompt's rounding from ever counting.
115
+ export const TRUNCATION_RATIO = 0.7;
116
+ export const TRUNCATION_MIN_GAP = 256;
117
+
118
+ export class LocalBackend {
119
+ // Talks to either a standard OpenAI-compatible /v1/chat/completions
120
+ // endpoint (`apiStyle: 'openai'`, any such server: LM Studio, llama.cpp
121
+ // server, vLLM, text-generation-webui, LocalAI, Ollama's compat endpoint,
122
+ // ...) or Ollama's own native /api/chat (`apiStyle: 'native'`), the only
123
+ // one of the two that honors a requested context window (`num_ctx`). With
124
+ // `apiStyle` null (COMPANION_LOCAL_API=auto) the server is probed once at
125
+ // startup (local-server.js) and 'native' is used only if it answers like
126
+ // Ollama. The same probes report the model's real window where the server
127
+ // exposes it, and the budget uses the smaller of that and `numCtx`.
128
+ //
129
+ // Why the window matters: this project's own configured model (Ollama,
130
+ // gemma4:26b) runs with a 4096-token window by default, and a perfectly
131
+ // normal single Submit (a real description plus a ~150-line solution) was
132
+ // confirmed live to exhaust it; the same request completed once resent to
133
+ // /api/chat with `num_ctx: 8192`. Worse, past the window a server may
134
+ // silently drop the start of the prompt (the system prompt and problem)
135
+ // with an ordinary "stop", so the tutor answers about the wrong thing with
136
+ // no error. Three things close that, all in sendMessage: the pre-flight
137
+ // fit (every request is fitted to the budget by condensing the oldest
138
+ // whole exchanges into one short summary, see context-summary.js, and
139
+ // refused only if the parts that can never be dropped are over it), the
140
+ // probed window, and the silent-truncation guard (a reply that
141
+ // reports far fewer prompt tokens than were sent lowers the window to what
142
+ // the server really kept, with one visible warning).
143
+ //
144
+ // `windowSetting` names, in error and warning text, what sets `numCtx` in
145
+ // the runtime using this class (the companion's COMPANION_LOCAL_NUM_CTX by
146
+ // default; the extension names its own options-page field).
147
+ constructor({
148
+ baseUrl,
149
+ model,
150
+ apiKey,
151
+ maxHistoryTurns,
152
+ apiStyle,
153
+ numCtx,
154
+ reserveTokens = DEFAULT_RESERVE_TOKENS,
155
+ probe = probeServer,
156
+ windowSetting = DEFAULT_WINDOW_SETTING,
157
+ }) {
158
+ if (!model) {
159
+ throw new Error(
160
+ 'a model name is required for a local or OpenAI-compatible model (set it in the extension\'s Tutor settings, or COMPANION_MODEL, e.g. COMPANION_MODEL=llama3.2)'
161
+ );
162
+ }
163
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
164
+ this.model = model;
165
+ this.apiKey = apiKey;
166
+ this.maxHistoryTurns = maxHistoryTurns;
167
+ // Explicit 'native'/'openai' wins; null means "decide from the probe",
168
+ // and until it answers (or if it fails) the portable 'openai' is used.
169
+ this.apiStyle = apiStyle === 'native' ? 'native' : 'openai';
170
+ this.numCtx = numCtx;
171
+ this.reserveTokens = reserveTokens;
172
+ this.windowSetting = windowSetting;
173
+ // Ollama's native API lives at the server root (e.g.
174
+ // http://localhost:11434/api/chat), not under the OpenAI-compat prefix,
175
+ // so a trailing /v1 is dropped for it.
176
+ this.nativeBaseUrl = serverRoot(this.baseUrl);
177
+ // What the probe found (`{ tokens, source }` or null) and the smallest
178
+ // window a reply showed the server really keeping (or null). See
179
+ // contextWindow().
180
+ this.detectedWindow = null;
181
+ this.observedWindow = null;
182
+ this.truncationWarned = false;
183
+ // Warnings for the caller to print once the turn is over (takeWarnings).
184
+ this.pendingWarnings = [];
185
+ // Ask for usage on a streamed reply (the OpenAI `stream_options`) until a
186
+ // server rejects the field, then never again.
187
+ this.streamUsage = true;
188
+ // Probed once, in the background, so startup never waits on it; the
189
+ // first message waits at most for the probe's own short timeouts. A
190
+ // failed probe leaves the configured window and the 'openai' style.
191
+ this.ready = Promise.resolve()
192
+ .then(() => probe({ baseUrl: this.baseUrl, model, apiKey, apiStyle: apiStyle || null }))
193
+ .then((found) => {
194
+ if (!found) return;
195
+ if (!apiStyle && found.apiStyle === 'native') this.apiStyle = 'native';
196
+ if (found.window) this.detectedWindow = found.window;
197
+ })
198
+ .catch(() => {});
199
+ // Standard OpenAI chat-completions shape: a leading `role: 'system'`
200
+ // message, resent in full on every turn along with the rest of history.
201
+ // Includes the shared style addendum (see STYLE_ADDENDUM) - this backend
202
+ // never inherits it any other way. Kept on its own so resetContext
203
+ // (below) can restore exactly this. Ollama's native /api/chat accepts
204
+ // this exact same messages shape too, so history/resetContext and the fit
205
+ // are all shared unchanged between both API styles.
206
+ this.systemMessage = { role: 'system', content: TUTOR_SYSTEM_MESSAGE };
207
+ this.history = [this.systemMessage];
208
+ // The full problem description for whichever problem is currently being
209
+ // discussed, if one has been seen yet - `{ problemId, title, slug,
210
+ // description }`, or null. Deliberately NOT part of `this.history`: it is
211
+ // reconstructed fresh into every outgoing request (buildMessages), so a
212
+ // long session can never lose it to trimming.
213
+ this.pinnedProblemContext = null;
214
+ // The student's current full code (see code-ledger.js's
215
+ // formatPinnedCode), pinned as a system message like the description on
216
+ // every request, so the tutor always sees the exact code and condensing
217
+ // can never lose it; a turn's message only says what changed. `{ text }`
218
+ // or null.
219
+ this.pinnedCode = null;
220
+ // The "earlier in this session" summary standing in for condensed older
221
+ // exchanges (context-summary.js), or null. Memory only, cleared by
222
+ // resetContext; a backend swap builds a new backend without one.
223
+ this.summary = null;
224
+ // Exchanges that must be condensed on the next request even if it fits
225
+ // (after a reply cut off for room, see sendMessage).
226
+ this.forceCondense = 0;
227
+ }
228
+
229
+ // The context window this backend budgets for, `{ tokens, source }`: the
230
+ // smallest of the configured COMPANION_LOCAL_NUM_CTX, the probed one and
231
+ // the observed one (see resolveContextWindow). The single place the
232
+ // window is decided, so a settings field only needs to feed `numCtx`.
233
+ contextWindow() {
234
+ return resolveContextWindow({
235
+ configured: this.numCtx,
236
+ detected: this.detectedWindow,
237
+ observed: this.observedWindow,
238
+ });
239
+ }
240
+
241
+ // The prompt budget: the window minus the reserve for the model's reply.
242
+ promptBudget() {
243
+ return this.contextWindow().tokens - this.reserveTokens;
244
+ }
245
+
246
+ // Where the window in a budget message came from, in words.
247
+ describeWindow() {
248
+ const window = this.contextWindow();
249
+ if (window.source === 'detected') {
250
+ return `the model's ${window.tokens}-token context window, as reported by the server (${window.from})`;
251
+ }
252
+ if (window.source === 'observed') {
253
+ return `a ${window.tokens}-token context window, the most the server was seen to keep`;
254
+ }
255
+ return /^[A-Z][A-Z0-9_]*$/.test(this.windowSetting)
256
+ ? `${this.windowSetting}=${window.tokens}`
257
+ : `a ${window.tokens}-token context window (${this.windowSetting})`;
258
+ }
259
+
260
+ // Warnings raised during the last turn (the silent-truncation guard), for
261
+ // the caller to print once the reply is on screen. Emptied by the call.
262
+ takeWarnings() {
263
+ const warnings = this.pendingWarnings;
264
+ this.pendingWarnings = [];
265
+ return warnings;
266
+ }
267
+
268
+ // `problemContext` (see companion.js's captureProblemContext) is supplied
269
+ // whenever a problem is known. `currentCode` is the student's current full
270
+ // code for this request (code-ledger.js's currentCodeFor: `{ code,
271
+ // language, stale }` or null), and `compactText` what this turn becomes
272
+ // once it is older (see context-budget.js's buildMessages).
273
+ // `shouldStop(contentSoFar)`, when given (the post-check guard, see
274
+ // reply-guard.js), ends a streamed request early once it returns true; the
275
+ // content so far is then the reply.
276
+ async sendMessage(text, { onChunk, problemContext, currentCode, compactText, shouldStop } = {}) {
277
+ await this.ready;
278
+ // Pin the description the first time we see one for whichever problem
279
+ // is now current - ignored on every later turn for the same problem,
280
+ // which is exactly what keeps it from being resent every turn (see
281
+ // buildMessages, which is what actually re-includes it on each
282
+ // request).
283
+ if (problemContext?.description && problemContext.problemId !== this.pinnedProblemContext?.problemId) {
284
+ this.pinnedProblemContext = problemContext;
285
+ }
286
+ const pinnedText = formatPinnedCode(currentCode);
287
+ this.pinnedCode = pinnedText ? { text: pinnedText } : null;
288
+ this.history.push({
289
+ role: 'user',
290
+ content: text,
291
+ ...(typeof compactText === 'string' && compactText !== text ? { compactContent: compactText } : {}),
292
+ });
293
+
294
+ // Pre-flight fit: condense the oldest whole exchanges into the
295
+ // "earlier in this session" summary until the request is within budget
296
+ // (or past the exchange-count cap), rather than ever sending one
297
+ // estimated to exceed the model's real window (a server may silently
298
+ // discard part of an over-long prompt and review the wrong code with an
299
+ // ordinary "stop", which no finishReason guard can see). Done on every
300
+ // request, so a conversation that grew past the budget (long replies, or
301
+ // a window that turned out smaller) frees itself on the very next
302
+ // message, silently: the summary call (see summarize) streams nothing and
303
+ // prints nothing, and it runs inside this turn, so the turn queue never
304
+ // interleaves it with another. Refused only when the parts that can
305
+ // never be dropped (system prompt, problem statement, code, this
306
+ // message) are over the budget alone.
307
+ const budget = this.promptBudget();
308
+ const fit = await fitWithSummary({
309
+ history: this.history,
310
+ pinnedProblemContext: this.pinnedProblemContext,
311
+ pinnedCode: this.pinnedCode,
312
+ summary: this.summary,
313
+ budget,
314
+ maxPairs: this.maxHistoryTurns > 0 ? this.maxHistoryTurns : 0,
315
+ forcePairs: this.forceCondense,
316
+ summarize: (messages, signal) => this.summarize(messages, signal),
317
+ });
318
+ if (!fit.fits) {
319
+ this.history.pop();
320
+ const { systemPrompt, problem, code, message } = fit.parts;
321
+ throw new Error(
322
+ `this request is estimated at ~${fit.estimatedTokens} prompt tokens, over the ${budget}-token budget ` +
323
+ `(${this.describeWindow()} minus a ${this.reserveTokens}-token reserve for the model's own ` +
324
+ `thinking/answer) even with every earlier exchange left out: the system prompt is ~${systemPrompt} ` +
325
+ `tokens, the problem statement ~${problem}, the code ~${code} and this message ~${message} - ` +
326
+ `refusing to send it rather than risk the local model silently discarding part of the prompt and ` +
327
+ `reviewing the wrong code with no visible sign anything went wrong; ` +
328
+ `${contextAdviceFor(this.apiStyle, this.numCtx, this.windowSetting)}`
329
+ );
330
+ }
331
+ this.history = fit.history.slice();
332
+ this.summary = fit.summary;
333
+ this.forceCondense = 0;
334
+
335
+ // Every failure path below pops the dangling user turn before
336
+ // propagating, rather than leaving history with a user message that was
337
+ // never actually answered.
338
+ let raw;
339
+ try {
340
+ raw = await this.requestStoppable(onChunk, fit.messages, shouldStop);
341
+ } catch (err) {
342
+ this.history.pop();
343
+ throw err;
344
+ }
345
+ this.checkPromptTokens(fit.estimatedTokens, raw.promptTokens);
346
+
347
+ let replyText;
348
+ try {
349
+ replyText = resolveReplyText({
350
+ content: raw.content,
351
+ reasoning: raw.reasoning,
352
+ finishReason: raw.finishReason,
353
+ advice: contextAdviceFor(this.apiStyle, this.numCtx, this.windowSetting),
354
+ });
355
+ } catch (err) {
356
+ this.history.pop();
357
+ // Cut off for room: the same request would only be cut off again, so
358
+ // the oldest exchange is condensed on the next message, giving it more
359
+ // room.
360
+ if (err.cutOff) this.forceCondense = 1;
361
+ throw err;
362
+ }
363
+
364
+ if (!replyText) {
365
+ this.history.pop();
366
+ throw new Error('local backend returned an empty reply (no content or reasoning in the response)');
367
+ }
368
+ this.history.push({ role: 'assistant', content: replyText });
369
+ return replyText;
370
+ }
371
+
372
+ // One request that `shouldStop(contentSoFar)` may end early (it aborts the
373
+ // request, which also stops a local server generating). The content
374
+ // streamed so far then stands as the reply, with finishReason 'stop'.
375
+ // Without `shouldStop` this is just request().
376
+ async requestStoppable(onChunk, messages, shouldStop) {
377
+ if (typeof shouldStop !== 'function') return this.request(onChunk, messages);
378
+ const controller = new AbortController();
379
+ let soFar = '';
380
+ let stopped = false;
381
+ const watch = (chunk) => {
382
+ if (stopped) return;
383
+ soFar += chunk;
384
+ if (onChunk) onChunk(chunk);
385
+ if (shouldStop(soFar)) {
386
+ stopped = true;
387
+ controller.abort();
388
+ }
389
+ };
390
+ try {
391
+ const raw = await this.request(watch, messages, { signal: controller.signal });
392
+ return stopped ? { ...raw, content: soFar, finishReason: 'stop' } : raw;
393
+ } catch (err) {
394
+ if (stopped) return { content: soFar, reasoning: '', finishReason: 'stop', promptTokens: null };
395
+ throw err;
396
+ }
397
+ }
398
+
399
+ // The post-check guard's one re-ask (reply-guard.js): the last request
400
+ // again, with the rejected reply and `instruction` after it. History is
401
+ // left as it is (the guard then calls replaceLastReply), so neither the
402
+ // rejected reply nor the instruction is ever kept. Not streamed.
403
+ async reviseLastReply(instruction, { shouldStop } = {}) {
404
+ const last = this.history[this.history.length - 1];
405
+ if (!last || last.role !== 'assistant' || this.history.length < 3) throw new Error('there is no reply to revise');
406
+ const messages = buildLocalMessages({
407
+ history: [...this.history, { role: 'user', content: instruction }],
408
+ pinnedProblemContext: this.pinnedProblemContext,
409
+ pinnedCode: this.pinnedCode,
410
+ summary: this.summary,
411
+ });
412
+ if (estimateMessagesTokens(messages) > this.promptBudget()) throw new Error('the re-ask would not fit the context window');
413
+ const raw = await this.requestStoppable(null, messages, shouldStop);
414
+ return resolveReplyText({ content: raw.content, reasoning: raw.reasoning, finishReason: raw.finishReason, advice: '' });
415
+ }
416
+
417
+ // Puts `text` in place of the last reply in history (what the student was
418
+ // shown, see reply-guard.js). Does nothing when the last turn is not a
419
+ // reply.
420
+ replaceLastReply(text) {
421
+ const last = this.history[this.history.length - 1];
422
+ if (last && last.role === 'assistant' && typeof text === 'string' && text.trim()) last.content = text;
423
+ }
424
+
425
+ // The summary call (context-summary.js's condense): the same model and
426
+ // API, not streamed, no warnings, no truncation check. Resolves to the
427
+ // text (empty when the model gave none) or throws; any failure means the
428
+ // deterministic digest is used instead.
429
+ async summarize(messages, signal) {
430
+ const raw = await this.request(null, messages, { signal });
431
+ return resolveReplyText({ content: raw.content, reasoning: raw.reasoning, finishReason: raw.finishReason, advice: '' });
432
+ }
433
+
434
+ // One request to the model, streamed when `onChunk` is given. Resolves to
435
+ // the raw reply parts `{ content, reasoning, finishReason, promptTokens }`.
436
+ // A subclass speaking another API (anthropic-backend.js) overrides only
437
+ // this.
438
+ request(onChunk, messages, options = {}) {
439
+ return this.apiStyle === 'native'
440
+ ? this.requestNative(onChunk, messages, options)
441
+ : this.requestOpenAI(onChunk, messages, options);
442
+ }
443
+
444
+ // The silent-truncation guard. `reported` is the prompt size the server
445
+ // says it processed (null if it did not say). Far below what was sent
446
+ // means the server's real window is smaller than budgeted for and it
447
+ // dropped the start of the prompt: warn once per session, and budget for
448
+ // the window it really kept from now on, so the next request is trimmed to
449
+ // fit (the window only ever goes down this way).
450
+ checkPromptTokens(estimated, reported) {
451
+ if (!Number.isFinite(reported) || reported <= 0) return;
452
+ if (reported >= estimated * TRUNCATION_RATIO || estimated - reported < TRUNCATION_MIN_GAP) return;
453
+ const before = this.contextWindow().tokens;
454
+ if (!this.observedWindow || reported < this.observedWindow) this.observedWindow = reported;
455
+ if (this.truncationWarned) return;
456
+ this.truncationWarned = true;
457
+ this.pendingWarnings.push(
458
+ `the local server processed only ~${reported} of the ~${estimated} prompt tokens sent, so it silently ` +
459
+ `dropped part of the conversation (its context window is smaller than the ${before} tokens budgeted ` +
460
+ `for, and that reply may have missed the problem or code); budgeting for a ${this.contextWindow().tokens}-token ` +
461
+ `window from now on so later messages fit. Set a bigger context window in the server, or ` +
462
+ `${this.windowSetting} to what it really has.`
463
+ );
464
+ }
465
+
466
+ // Builds the actual outgoing messages array for a request (context-
467
+ // budget.js's buildMessages, pure and unit-tested directly; this is a thin
468
+ // wrapper passing this instance's own fields through).
469
+ buildMessages() {
470
+ return buildLocalMessages({
471
+ history: this.history,
472
+ pinnedProblemContext: this.pinnedProblemContext,
473
+ pinnedCode: this.pinnedCode,
474
+ summary: this.summary,
475
+ });
476
+ }
477
+
478
+ // Standard OpenAI-compatible /v1/chat/completions request/response shape -
479
+ // portable to any server that speaks it. Resolves to the raw reply parts
480
+ // `{ content, reasoning, finishReason, promptTokens }`; sendMessage turns
481
+ // them into the reply (resolveReplyText) after the truncation guard.
482
+ async requestOpenAI(onChunk, messages, { signal } = {}) {
483
+ const stream = Boolean(onChunk);
484
+ const send = (withUsage) =>
485
+ fetch(`${this.baseUrl}/chat/completions`, {
486
+ method: 'POST',
487
+ signal,
488
+ headers: {
489
+ 'Content-Type': 'application/json',
490
+ ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
491
+ },
492
+ body: JSON.stringify({
493
+ model: this.model,
494
+ messages,
495
+ stream,
496
+ // Without this a streamed reply carries no usage at all, and the
497
+ // truncation guard has nothing to check.
498
+ ...(stream && withUsage ? { stream_options: { include_usage: true } } : {}),
499
+ }),
500
+ });
501
+ let response;
502
+ try {
503
+ response = await send(this.streamUsage);
504
+ if (!response.ok && stream && this.streamUsage && (response.status === 400 || response.status === 422)) {
505
+ // A strict server that does not know `stream_options`: retry once
506
+ // without it, and leave it off from now on.
507
+ const bodyText = await response.text().catch(() => '');
508
+ if (/stream_options/i.test(bodyText)) {
509
+ this.streamUsage = false;
510
+ response = await send(false);
511
+ } else {
512
+ throw new Error(`local backend request failed: ${response.status} ${response.statusText} ${bodyText}`);
513
+ }
514
+ }
515
+ } catch (err) {
516
+ if (err.message.startsWith('local backend request failed')) throw err;
517
+ throw new Error(`could not reach ${this.baseUrl} (${err.message})`);
518
+ }
519
+
520
+ if (!response.ok) {
521
+ const bodyText = await response.text().catch(() => '');
522
+ throw new Error(`local backend request failed: ${response.status} ${response.statusText} ${bodyText}`);
523
+ }
524
+
525
+ // Some locally-hosted "thinking" models (e.g. Ollama's gemma-family
526
+ // thinking variants) put their whole answer in a separate `reasoning`
527
+ // field and leave `content` empty, especially on the longer responses a
528
+ // Submit's full breakdown asks for; resolveReplyText falls back to it.
529
+ if (stream) return this.streamReply(response, onChunk);
530
+ const data = await response.json();
531
+ const choice = data?.choices?.[0];
532
+ return {
533
+ content: choice?.message?.content,
534
+ reasoning: choice?.message?.reasoning,
535
+ finishReason: choice?.finish_reason,
536
+ promptTokens: Number(data?.usage?.prompt_tokens),
537
+ };
538
+ }
539
+
540
+ // Ollama's native /api/chat - same messages shape in, but a different
541
+ // response shape out: a single JSON object (non-streaming) or newline-
542
+ // delimited JSON objects (streaming), `message: { content, thinking }`
543
+ // instead of `choices[0].message`, and `done_reason`/`prompt_eval_count`
544
+ // at the top level. The only reason to use this over requestOpenAI is
545
+ // `options.num_ctx`, below - confirmed live this is honored here and
546
+ // nowhere on the compat endpoint.
547
+ async requestNative(onChunk, messages, { signal } = {}) {
548
+ let response;
549
+ try {
550
+ response = await fetch(`${this.nativeBaseUrl}/api/chat`, {
551
+ method: 'POST',
552
+ signal,
553
+ headers: {
554
+ 'Content-Type': 'application/json',
555
+ ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
556
+ },
557
+ body: JSON.stringify({
558
+ model: this.model,
559
+ messages,
560
+ stream: Boolean(onChunk),
561
+ options: { num_ctx: this.numCtx },
562
+ }),
563
+ });
564
+ } catch (err) {
565
+ throw new Error(`could not reach ${this.nativeBaseUrl} (${err.message})`);
566
+ }
567
+
568
+ if (!response.ok) {
569
+ const bodyText = await response.text().catch(() => '');
570
+ throw new Error(`local backend request failed: ${response.status} ${response.statusText} ${bodyText}`);
571
+ }
572
+
573
+ if (onChunk) return this.streamReplyNative(response, onChunk);
574
+ const data = await response.json();
575
+ return {
576
+ content: data?.message?.content,
577
+ reasoning: data?.message?.thinking,
578
+ finishReason: data?.done_reason,
579
+ promptTokens: Number(data?.prompt_eval_count),
580
+ };
581
+ }
582
+
583
+ // Reads an OpenAI-compatible chat-completions SSE stream (one `data:
584
+ // {...}` event per chunk, terminated by `data: [DONE]`), calling onChunk
585
+ // for each piece of visible `delta.content` as it arrives. `delta.reasoning`
586
+ // is accumulated the same way but never handed to onChunk - a "thinking"
587
+ // model's internal trace isn't what streaming is meant to surface live.
588
+ // `usage` arrives on its own final event when `stream_options` asked for it.
589
+ async streamReply(response, onChunk) {
590
+ const reader = response.body.getReader();
591
+ const decoder = new TextDecoder();
592
+ let buffer = '';
593
+ let content = '';
594
+ let reasoning = '';
595
+ let finishReason = null;
596
+ let promptTokens = null;
597
+ for (;;) {
598
+ // eslint-disable-next-line no-await-in-loop
599
+ const { done, value } = await reader.read();
600
+ if (done) break;
601
+ buffer += decoder.decode(value, { stream: true });
602
+ let newlineIndex;
603
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
604
+ const line = buffer.slice(0, newlineIndex).trim();
605
+ buffer = buffer.slice(newlineIndex + 1);
606
+ if (!line.startsWith('data:')) continue;
607
+ const payload = line.slice(5).trim();
608
+ if (!payload || payload === '[DONE]') continue;
609
+ let event;
610
+ try {
611
+ event = JSON.parse(payload);
612
+ } catch {
613
+ continue; // a malformed/partial SSE line - ignore rather than crash the stream
614
+ }
615
+ if (event?.usage && Number.isFinite(Number(event.usage.prompt_tokens))) {
616
+ promptTokens = Number(event.usage.prompt_tokens);
617
+ }
618
+ const choice = event?.choices?.[0];
619
+ const delta = choice?.delta;
620
+ if (delta?.content) {
621
+ content += delta.content;
622
+ onChunk(delta.content);
623
+ }
624
+ if (delta?.reasoning) reasoning += delta.reasoning;
625
+ if (choice?.finish_reason) finishReason = choice.finish_reason;
626
+ }
627
+ }
628
+ return { content, reasoning, finishReason, promptTokens };
629
+ }
630
+
631
+ // Reads Ollama's native /api/chat streaming shape: newline-delimited JSON
632
+ // objects (no `data:`/SSE framing, no `[DONE]` sentinel), each carrying an
633
+ // incremental `message.content` and/or `message.thinking`, with the final
634
+ // object marked `done: true` and carrying `done_reason` and
635
+ // `prompt_eval_count` - confirmed live against a real Ollama server.
636
+ // `thinking` deltas accumulate like `reasoning` above, never to onChunk.
637
+ async streamReplyNative(response, onChunk) {
638
+ const reader = response.body.getReader();
639
+ const decoder = new TextDecoder();
640
+ let buffer = '';
641
+ let content = '';
642
+ let reasoning = '';
643
+ let doneReason = null;
644
+ let promptTokens = null;
645
+ for (;;) {
646
+ // eslint-disable-next-line no-await-in-loop
647
+ const { done, value } = await reader.read();
648
+ if (done) break;
649
+ buffer += decoder.decode(value, { stream: true });
650
+ let newlineIndex;
651
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
652
+ const line = buffer.slice(0, newlineIndex).trim();
653
+ buffer = buffer.slice(newlineIndex + 1);
654
+ if (!line) continue;
655
+ let event;
656
+ try {
657
+ event = JSON.parse(line);
658
+ } catch {
659
+ continue; // a malformed/partial line - ignore rather than crash the stream
660
+ }
661
+ const delta = event?.message;
662
+ if (delta?.content) {
663
+ content += delta.content;
664
+ onChunk(delta.content);
665
+ }
666
+ if (delta?.thinking) reasoning += delta.thinking;
667
+ if (event?.done) {
668
+ doneReason = event.done_reason || 'stop';
669
+ if (Number.isFinite(Number(event.prompt_eval_count))) promptTokens = Number(event.prompt_eval_count);
670
+ }
671
+ }
672
+ }
673
+ return { content, reasoning, finishReason: doneReason, promptTokens };
674
+ }
675
+
676
+ // Truncates history back to just the leading system message, so the next
677
+ // sendMessage resends none of the prior conversation - and drops the
678
+ // pinned problem description too, so a genuinely new problem's own
679
+ // description gets pinned fresh on its first capture. What was learned
680
+ // about the server (its window) is kept: it is the same server.
681
+ resetContext() {
682
+ this.history = [this.systemMessage];
683
+ this.pinnedProblemContext = null;
684
+ this.pinnedCode = null;
685
+ this.summary = null;
686
+ this.forceCondense = 0;
687
+ }
688
+
689
+ // The conversation in the shape every backend shares (conversation.js), so
690
+ // a swapped-in backend carries on from it.
691
+ exportConversation() {
692
+ return normalizeConversation({
693
+ turns: this.history.slice(1),
694
+ summary: this.summary,
695
+ problem: this.pinnedProblemContext,
696
+ });
697
+ }
698
+
699
+ importConversation(state) {
700
+ const { turns, summary, problem } = normalizeConversation(state);
701
+ this.resetContext();
702
+ this.history = [this.systemMessage, ...turns];
703
+ this.summary = summary;
704
+ this.pinnedProblemContext = problem;
705
+ }
706
+ }