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.
- package/cli.js +92 -0
- package/config.js +25 -0
- package/framing.js +57 -0
- package/host.js +346 -0
- package/install.js +290 -0
- package/package.json +31 -4
- package/runtime/bundle.json +42 -0
- package/runtime/companion/capture-log.js +83 -0
- package/runtime/companion/codex-backend.js +337 -0
- package/runtime/companion/companion.js +2193 -0
- package/runtime/companion/mouse-input.js +154 -0
- package/runtime/companion/package.json +4 -0
- package/runtime/companion/panel-bridge.js +273 -0
- package/runtime/companion/settings-rpc.js +228 -0
- package/runtime/companion/settings.js +464 -0
- package/runtime/companion/terminal-format.js +330 -0
- package/runtime/companion/vault-summary.js +169 -0
- package/runtime/companion/virtual-terminal.js +138 -0
- package/runtime/extension/shared/anthropic-backend.js +193 -0
- package/runtime/extension/shared/catalog.js +415 -0
- package/runtime/extension/shared/code-ledger.js +333 -0
- package/runtime/extension/shared/context-budget.js +259 -0
- package/runtime/extension/shared/context-summary.js +271 -0
- package/runtime/extension/shared/conversation.js +81 -0
- package/runtime/extension/shared/event-hub.js +126 -0
- package/runtime/extension/shared/extension-settings.js +445 -0
- package/runtime/extension/shared/helper-launcher.js +248 -0
- package/runtime/extension/shared/local-backend.js +706 -0
- package/runtime/extension/shared/local-server.js +149 -0
- package/runtime/extension/shared/package.json +4 -0
- package/runtime/extension/shared/practice.js +228 -0
- package/runtime/extension/shared/problem-index.js +4128 -0
- package/runtime/extension/shared/profiles.js +106 -0
- package/runtime/extension/shared/providers.js +58 -0
- package/runtime/extension/shared/reply-guard.js +864 -0
- package/runtime/extension/shared/roadmap.js +1344 -0
- package/runtime/extension/shared/sanitize.js +68 -0
- package/runtime/extension/shared/solved.js +277 -0
- package/runtime/extension/shared/tutor-engine.js +369 -0
- package/runtime/extension/shared/tutor-prompt.js +108 -0
- package/runtime/extension/shared/tutor-service.js +520 -0
- package/runtime/relay-server/package.json +4 -0
- package/runtime/relay-server/panel-broker.js +797 -0
- package/runtime/relay-server/server.js +241 -0
- package/runtime/vault-tool/log-session.js +230 -0
- package/runtime/vault-tool/package.json +4 -0
- package/runtime/vault-tool/vault-notes.js +206 -0
- 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
|
+
}
|