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,445 @@
1
+ // extension/shared/extension-settings.js - the extension runtime's own
2
+ // settings: which provider the tutor uses and where it runs, the fields of
3
+ // the providers that can run inside the extension, and how a backend is built
4
+ // from them. The background worker keeps them in chrome.storage.local (see
5
+ // background.js); the companion's settings (companion/settings.js) are only
6
+ // used for the helper runtime.
7
+ //
8
+ // Two stored records, so a key can never ride along with anything else:
9
+ // settings: { version, provider, runtimes, providers, options, profiles,
10
+ // activeProfile }, no secrets
11
+ // secrets: { local, anthropic, openai }, the API keys
12
+ // publicView() is all the options page is ever given: `hasKey`, never a key.
13
+ //
14
+ // `profiles` are the saved models the Tutor panel's switcher lists (see
15
+ // profiles.js); `activeProfile` is the one in use. `provider` and the active
16
+ // provider's `model` always match the active profile, so everything that
17
+ // reads them (runtimeFor, buildBackend, statusSummary) needs no change. A
18
+ // record from before profiles becomes one profile (migrateProfiles).
19
+ //
20
+ // Pure JavaScript with no Node API (extension/shared.test.js checks that).
21
+
22
+ import { PROVIDERS, OLLAMA_BASE_URL, OPENAI_BASE_URL, defaultRuntime } from './providers.js';
23
+ import { LocalBackend } from './local-backend.js';
24
+ import { AnthropicBackend, ANTHROPIC_BASE_URL, DEFAULT_ANTHROPIC_MODEL } from './anthropic-backend.js';
25
+ import { ProfileError, cleanActiveProfile, cleanProfiles, findProfile, migrateProfiles, profileName } from './profiles.js';
26
+
27
+ export const EXTENSION_SETTINGS_VERSION = 2;
28
+ // The providers the extension can run itself.
29
+ export const EXTENSION_PROVIDERS = Object.keys(PROVIDERS).filter((id) => PROVIDERS[id].runtimes.includes('extension'));
30
+ export const DEFAULT_LOCAL_CONTEXT_TOKENS = 8192;
31
+ // Same assumption as the companion (companion/settings.js): 128k is the
32
+ // floor for current hosted OpenAI models.
33
+ export const OPENAI_CONTEXT_TOKENS = 128000;
34
+ export const DEFAULT_OPTIONS = { autoClearContext: true, localMaxHistoryTurns: 6 };
35
+ // What the context-window field is called in errors the tutor shows.
36
+ export const WINDOW_SETTING = 'the "Context window (tokens)" setting';
37
+
38
+ const DEFAULT_FIELDS = {
39
+ local: { baseUrl: OLLAMA_BASE_URL, model: null, contextTokens: DEFAULT_LOCAL_CONTEXT_TOKENS },
40
+ anthropic: { model: null },
41
+ openai: { model: null, baseUrl: OPENAI_BASE_URL },
42
+ };
43
+
44
+ export class SettingsError extends Error {
45
+ constructor(message, field) {
46
+ super(message);
47
+ this.name = 'SettingsError';
48
+ this.field = field || null;
49
+ }
50
+ }
51
+
52
+ export function emptySettings() {
53
+ const providers = {};
54
+ const runtimes = {};
55
+ for (const id of EXTENSION_PROVIDERS) {
56
+ providers[id] = { ...DEFAULT_FIELDS[id] };
57
+ runtimes[id] = defaultRuntime(id);
58
+ }
59
+ return {
60
+ version: EXTENSION_SETTINGS_VERSION,
61
+ provider: null,
62
+ runtimes,
63
+ providers,
64
+ options: { ...DEFAULT_OPTIONS },
65
+ profiles: [],
66
+ activeProfile: null,
67
+ };
68
+ }
69
+
70
+ export function emptySecrets() {
71
+ const secrets = {};
72
+ for (const id of EXTENSION_PROVIDERS) secrets[id] = null;
73
+ return secrets;
74
+ }
75
+
76
+ // ---- validation ------------------------------------------------------------
77
+
78
+ function cleanString(value, max, field) {
79
+ if (value === null || value === undefined) return null;
80
+ if (typeof value !== 'string') throw new SettingsError(`${field} must be text`, field);
81
+ const trimmed = value.trim();
82
+ if (!trimmed) return null;
83
+ if (trimmed.length > max) throw new SettingsError(`${field} is too long`, field);
84
+ // eslint-disable-next-line no-control-regex
85
+ if (/[\u0000-\u001f\u007f]/.test(trimmed)) throw new SettingsError(`${field} contains control characters`, field);
86
+ return trimmed;
87
+ }
88
+
89
+ export function cleanUrl(value, field = 'baseUrl') {
90
+ const text = cleanString(value, 500, field);
91
+ if (text === null) return null;
92
+ let url;
93
+ try {
94
+ url = new URL(text);
95
+ } catch {
96
+ throw new SettingsError(`${field} must be a full URL like http://localhost:11434/v1`, field);
97
+ }
98
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
99
+ throw new SettingsError(`${field} must start with http:// or https://`, field);
100
+ }
101
+ if (url.username || url.password) throw new SettingsError(`${field} must not contain a user name or password`, field);
102
+ return text.replace(/\/+$/, '');
103
+ }
104
+
105
+ function cleanApiKey(value) {
106
+ const text = cleanString(value, 400, 'apiKey');
107
+ if (text !== null && /\s/.test(text)) throw new SettingsError('apiKey must not contain spaces', 'apiKey');
108
+ return text;
109
+ }
110
+
111
+ function cleanInt(value, min, max, field) {
112
+ if (value === null || value === undefined || value === '') return null;
113
+ const n = Number(value);
114
+ if (!Number.isInteger(n) || n < min || n > max) {
115
+ throw new SettingsError(`${field} must be a whole number between ${min} and ${max}`, field);
116
+ }
117
+ return n;
118
+ }
119
+
120
+ // Stored values from an older or hand-edited record are taken field by field;
121
+ // anything invalid falls back to the default rather than breaking the page.
122
+ export function normalizeSettings(raw) {
123
+ const out = emptySettings();
124
+ if (!raw || typeof raw !== 'object') return out;
125
+ if (typeof raw.provider === 'string' && PROVIDERS[raw.provider]) out.provider = raw.provider;
126
+ for (const id of EXTENSION_PROVIDERS) {
127
+ const runtime = raw.runtimes && raw.runtimes[id];
128
+ if (PROVIDERS[id].runtimes.includes(runtime)) out.runtimes[id] = runtime;
129
+ const fields = (raw.providers && raw.providers[id]) || {};
130
+ for (const field of Object.keys(DEFAULT_FIELDS[id])) {
131
+ try {
132
+ const value = applyField(field, fields[field]);
133
+ if (value !== undefined && value !== null) out.providers[id][field] = value;
134
+ } catch {
135
+ // keep the default
136
+ }
137
+ }
138
+ }
139
+ const options = raw.options || {};
140
+ if (typeof options.autoClearContext === 'boolean') out.options.autoClearContext = options.autoClearContext;
141
+ try {
142
+ const turns = cleanInt(options.localMaxHistoryTurns, 0, 100, 'localMaxHistoryTurns');
143
+ if (turns !== null) out.options.localMaxHistoryTurns = turns;
144
+ } catch {
145
+ // keep the default
146
+ }
147
+ const providerModel = out.provider && out.providers[out.provider] ? out.providers[out.provider].model : null;
148
+ const migrated = migrateProfiles({
149
+ profiles: cleanProfiles(raw.profiles, PROVIDERS),
150
+ activeProfile: raw.activeProfile,
151
+ provider: out.provider,
152
+ model: providerModel,
153
+ });
154
+ out.profiles = migrated.profiles;
155
+ out.activeProfile = migrated.activeProfile;
156
+ syncActive(out);
157
+ return out;
158
+ }
159
+
160
+ // Makes `provider` and that provider's model match the active profile.
161
+ function syncActive(settings) {
162
+ const profile = findProfile(settings.profiles, settings.activeProfile);
163
+ if (!profile) return;
164
+ settings.provider = profile.provider;
165
+ if (settings.providers[profile.provider]) settings.providers[profile.provider].model = profile.model;
166
+ }
167
+
168
+ // An id for a profile the store makes itself, unused in `profiles`.
169
+ function freeProfileId(profiles, base) {
170
+ const stem = base.replace(/[^a-z0-9-]/g, '').slice(0, 30) || 'model';
171
+ for (let n = 1; ; n += 1) {
172
+ const id = n === 1 ? stem : `${stem}-${n}`;
173
+ if (!findProfile(profiles, id)) return id;
174
+ }
175
+ }
176
+
177
+ // A patch from before profiles (a provider and its fields, no profile list):
178
+ // the active profile follows it, as it would have been migrated.
179
+ function followLegacyPatch(next) {
180
+ if (!next.provider) {
181
+ next.activeProfile = null;
182
+ return;
183
+ }
184
+ const model = next.providers[next.provider] ? next.providers[next.provider].model : null;
185
+ const active = findProfile(next.profiles, next.activeProfile);
186
+ if (active && active.provider === next.provider) {
187
+ if (next.providers[next.provider]) active.model = model;
188
+ return;
189
+ }
190
+ const same = next.profiles.find((p) => p.provider === next.provider && (!next.providers[next.provider] || p.model === model));
191
+ if (same) {
192
+ next.activeProfile = same.id;
193
+ return;
194
+ }
195
+ const profile = { id: freeProfileId(next.profiles, next.profiles.length ? next.provider : 'default'), provider: next.provider, model, label: null };
196
+ next.profiles.push(profile);
197
+ next.activeProfile = profile.id;
198
+ }
199
+
200
+ export function normalizeSecrets(raw) {
201
+ const out = emptySecrets();
202
+ if (!raw || typeof raw !== 'object') return out;
203
+ for (const id of EXTENSION_PROVIDERS) {
204
+ try {
205
+ out[id] = cleanApiKey(raw[id]);
206
+ } catch {
207
+ out[id] = null;
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+
213
+ function applyField(field, value) {
214
+ if (field === 'baseUrl') return cleanUrl(value);
215
+ if (field === 'model') return cleanString(value, 200, 'model');
216
+ if (field === 'contextTokens') return cleanInt(value, 1024, 2_000_000, 'contextTokens');
217
+ return undefined;
218
+ }
219
+
220
+ // Applies an options-page patch:
221
+ // { provider?, runtime?, providers?: { id: { field: value, apiKey?: string|null } }, options? }
222
+ // `runtime` applies to the chosen provider. A provider field set to null goes
223
+ // back to its default; an apiKey of null removes the key, a missing one keeps
224
+ // it. Returns new { settings, secrets }; throws SettingsError naming the field.
225
+ export function applyPatch(settings, secrets, patch) {
226
+ const next = normalizeSettings(settings);
227
+ const nextSecrets = { ...normalizeSecrets(secrets) };
228
+ if (!patch || typeof patch !== 'object') return { settings: next, secrets: nextSecrets };
229
+ if (patch.provider !== undefined) {
230
+ if (patch.provider !== null && !PROVIDERS[patch.provider]) throw new SettingsError('unknown provider', 'provider');
231
+ next.provider = patch.provider;
232
+ }
233
+ if (patch.runtime !== undefined) {
234
+ const id = next.provider;
235
+ if (!id || !PROVIDERS[id].runtimes.includes(patch.runtime)) {
236
+ throw new SettingsError(`${id ? PROVIDERS[id].label : 'This provider'} cannot run there`, 'runtime');
237
+ }
238
+ if (next.runtimes[id] !== undefined) next.runtimes[id] = patch.runtime;
239
+ }
240
+ for (const [id, fields] of Object.entries(patch.providers || {})) {
241
+ if (!EXTENSION_PROVIDERS.includes(id) || !fields || typeof fields !== 'object') continue;
242
+ for (const [field, value] of Object.entries(fields)) {
243
+ if (field === 'apiKey') {
244
+ if (value === undefined) continue;
245
+ nextSecrets[id] = value === null ? null : cleanApiKey(value);
246
+ continue;
247
+ }
248
+ if (!(field in DEFAULT_FIELDS[id])) continue;
249
+ const cleaned = applyField(field, value);
250
+ next.providers[id][field] = cleaned === null ? DEFAULT_FIELDS[id][field] : cleaned;
251
+ }
252
+ }
253
+ if (patch.profiles !== undefined) {
254
+ try {
255
+ next.profiles = cleanProfiles(patch.profiles, PROVIDERS, { strict: true });
256
+ } catch (err) {
257
+ throw new SettingsError(err instanceof ProfileError ? err.message : 'profiles are not valid', 'profiles');
258
+ }
259
+ next.activeProfile = cleanActiveProfile(next.profiles, next.activeProfile);
260
+ }
261
+ if (patch.activeProfile !== undefined) {
262
+ if (patch.activeProfile === null) next.activeProfile = null;
263
+ else if (!findProfile(next.profiles, patch.activeProfile)) throw new SettingsError('That model is not in the saved list.', 'activeProfile');
264
+ else next.activeProfile = patch.activeProfile;
265
+ }
266
+ if (patch.profiles === undefined && patch.activeProfile === undefined && (patch.provider !== undefined || patch.providers !== undefined)) {
267
+ followLegacyPatch(next);
268
+ }
269
+ syncActive(next);
270
+ const options = patch.options || {};
271
+ if (options.autoClearContext !== undefined) {
272
+ if (typeof options.autoClearContext !== 'boolean') throw new SettingsError('autoClearContext must be on or off', 'autoClearContext');
273
+ next.options.autoClearContext = options.autoClearContext;
274
+ }
275
+ if (options.localMaxHistoryTurns !== undefined) {
276
+ const turns = cleanInt(options.localMaxHistoryTurns, 0, 100, 'localMaxHistoryTurns');
277
+ next.options.localMaxHistoryTurns = turns === null ? DEFAULT_OPTIONS.localMaxHistoryTurns : turns;
278
+ }
279
+ return { settings: next, secrets: nextSecrets };
280
+ }
281
+
282
+ // ---- what runs where -------------------------------------------------------
283
+
284
+ // 'extension' or 'helper'. Nothing chosen yet means the helper, which is how
285
+ // the extension behaved before it could run the tutor itself, so an existing
286
+ // companion setup keeps working untouched.
287
+ export function runtimeFor(settings) {
288
+ const id = settings && settings.provider;
289
+ if (!id || !PROVIDERS[id]) return 'helper';
290
+ const runtime = settings.runtimes && settings.runtimes[id];
291
+ return PROVIDERS[id].runtimes.includes(runtime) ? runtime : defaultRuntime(id);
292
+ }
293
+
294
+ // What is missing before the extension can run the chosen provider, as a
295
+ // sentence for the options page and the panel, or null.
296
+ export function missingRequirement(settings, secrets) {
297
+ const id = settings.provider;
298
+ if (!id) return 'Choose a provider in the extension options.';
299
+ const fields = settings.providers[id] || {};
300
+ for (const need of PROVIDERS[id].requires) {
301
+ if (need === 'apiKey' ? !secrets[id] : !fields[need]) {
302
+ return `${PROVIDERS[id].label} needs ${need === 'apiKey' ? 'an API key' : `a ${need === 'model' ? 'model name' : need}`}.`;
303
+ }
304
+ }
305
+ return null;
306
+ }
307
+
308
+ // The server the chosen provider talks to.
309
+ export function serverUrlFor(settings) {
310
+ const id = settings.provider;
311
+ if (id === 'anthropic') return ANTHROPIC_BASE_URL;
312
+ if (id === 'local' || id === 'openai') return settings.providers[id].baseUrl || DEFAULT_FIELDS[id].baseUrl;
313
+ return null;
314
+ }
315
+
316
+ // The host permission (an optional_host_permissions match pattern) the
317
+ // extension needs before it may reach that server.
318
+ export function originPatternFor(url) {
319
+ if (!url) return null;
320
+ try {
321
+ const u = new URL(url);
322
+ return `${u.protocol}//${u.hostname}/*`;
323
+ } catch {
324
+ return null;
325
+ }
326
+ }
327
+
328
+ // The non-secret summary for the panel header ({ provider, label, model }).
329
+ export function statusSummary(settings) {
330
+ const id = settings.provider;
331
+ if (!id) return null;
332
+ const fields = settings.providers[id] || {};
333
+ const model = fields.model || (id === 'anthropic' ? DEFAULT_ANTHROPIC_MODEL : null);
334
+ return { provider: id, label: PROVIDERS[id].label, model };
335
+ }
336
+
337
+ // What a backend is built from; unchanged means the running one is kept.
338
+ export function backendKey(settings, secrets) {
339
+ const id = settings.provider;
340
+ const fields = settings.providers[id] || {};
341
+ return JSON.stringify([id, fields.model, fields.baseUrl, fields.contextTokens, secrets[id] || null, settings.options.localMaxHistoryTurns]);
342
+ }
343
+
344
+ // Builds the backend for the chosen provider. Throws SettingsError when it
345
+ // cannot be built from these settings.
346
+ export function buildBackend(settings, secrets, { probe } = {}) {
347
+ const id = settings.provider;
348
+ const problem = missingRequirement(settings, secrets);
349
+ if (problem) throw new SettingsError(problem, 'provider');
350
+ const fields = settings.providers[id];
351
+ const maxHistoryTurns = settings.options.localMaxHistoryTurns;
352
+ if (id === 'anthropic') {
353
+ return new AnthropicBackend({ apiKey: secrets.anthropic, model: fields.model, maxHistoryTurns });
354
+ }
355
+ if (id === 'local' || id === 'openai') {
356
+ return new LocalBackend({
357
+ baseUrl: fields.baseUrl || DEFAULT_FIELDS[id].baseUrl,
358
+ model: fields.model,
359
+ apiKey: secrets[id],
360
+ maxHistoryTurns,
361
+ // A hosted OpenAI API always speaks the OpenAI shape; a local server
362
+ // is probed (Ollama gets its native API, which honors num_ctx).
363
+ apiStyle: id === 'openai' ? 'openai' : null,
364
+ numCtx: id === 'openai' ? OPENAI_CONTEXT_TOKENS : fields.contextTokens || DEFAULT_LOCAL_CONTEXT_TOKENS,
365
+ windowSetting: WINDOW_SETTING,
366
+ ...(probe ? { probe } : {}),
367
+ });
368
+ }
369
+ throw new SettingsError(`${PROVIDERS[id].label} needs the helper app`, 'runtime');
370
+ }
371
+
372
+ export function publicView(settings, secrets) {
373
+ const s = normalizeSettings(settings);
374
+ const keys = normalizeSecrets(secrets);
375
+ const providers = {};
376
+ for (const id of EXTENSION_PROVIDERS) providers[id] = { ...s.providers[id], hasKey: Boolean(keys[id]) };
377
+ return {
378
+ provider: s.provider,
379
+ runtime: runtimeFor(s),
380
+ runtimes: { ...s.runtimes },
381
+ providers,
382
+ options: { ...s.options },
383
+ profiles: s.profiles.map((p) => ({ ...p })),
384
+ activeProfile: s.activeProfile,
385
+ defaults: { local: { ...DEFAULT_FIELDS.local }, openai: { ...DEFAULT_FIELDS.openai }, anthropic: { model: DEFAULT_ANTHROPIC_MODEL } },
386
+ };
387
+ }
388
+
389
+ // ---- profiles for the panel's switcher -------------------------------------
390
+
391
+ // The settings with `profile` made the active one (nothing is saved).
392
+ export function withProfile(settings, profile) {
393
+ const next = normalizeSettings(settings);
394
+ next.activeProfile = profile.id;
395
+ if (!findProfile(next.profiles, profile.id)) next.profiles.push({ ...profile });
396
+ syncActive(next);
397
+ return next;
398
+ }
399
+
400
+ // Why a profile that runs in the browser cannot be used yet, as a short
401
+ // line for the switcher, or null.
402
+ export function profileProblem(settings, secrets) {
403
+ const id = settings.provider;
404
+ const fields = settings.providers[id] || {};
405
+ for (const need of PROVIDERS[id].requires) {
406
+ if (need === 'apiKey' && !secrets[id]) return 'No API key saved';
407
+ if (need === 'model' && !fields.model) return 'No model chosen';
408
+ }
409
+ return null;
410
+ }
411
+
412
+ // One switcher entry, without availability (the service adds it).
413
+ export function profileEntry(settings, profile) {
414
+ const spec = PROVIDERS[profile.provider];
415
+ const model = profile.model || (profile.provider === 'anthropic' ? DEFAULT_ANTHROPIC_MODEL : null);
416
+ return {
417
+ id: profile.id,
418
+ name: profileName({ ...profile, model }, PROVIDERS),
419
+ provider: profile.provider,
420
+ providerLabel: spec.label,
421
+ model,
422
+ runtime: runtimeFor(withProfile(settings, profile)),
423
+ };
424
+ }
425
+
426
+ // ---- plain error messages -------------------------------------------------
427
+
428
+ // Turns a failed request to the chosen provider into a message that says what
429
+ // to do. Ollama refuses requests from an extension's origin unless
430
+ // OLLAMA_ORIGINS allows it (it answers 403, or the browser reports a network
431
+ // error), which is the most likely first-run failure for a local model.
432
+ export function explainError(message, { provider, serverUrl, extensionOrigin }) {
433
+ const text = String(message || '');
434
+ if (provider !== 'local') return text;
435
+ const forbidden = /\b403\b/.test(text);
436
+ const unreachable = /could not reach|Failed to fetch|NetworkError|network error|fetch failed/i.test(text);
437
+ if (!forbidden && !unreachable) return text;
438
+ const origin = extensionOrigin || 'chrome-extension://<this extension id>';
439
+ const fix =
440
+ `If it is Ollama, it only answers browser extensions it is told to trust: set the environment variable ` +
441
+ `OLLAMA_ORIGINS=${origin} for the Ollama server (for example with "systemctl edit ollama.service" on Linux, ` +
442
+ `or "launchctl setenv OLLAMA_ORIGINS ${origin}" on macOS) and restart Ollama.`;
443
+ if (forbidden) return `The local server at ${serverUrl} refused the request (403). ${fix}`;
444
+ return `Couldn't reach the local server at ${serverUrl}. Check that it is running. ${fix}`;
445
+ }
@@ -0,0 +1,248 @@
1
+ // The background worker's side of the Native Messaging helper (helper/ at the
2
+ // repository root): while the helper runtime is chosen, the nativeMessaging
3
+ // permission is granted and at least one LeetCode problem tab is open, keep a
4
+ // native port to the helper, which runs the companion and relay for as long
5
+ // as that port stays open.
6
+ //
7
+ // Tabs are counted through each tab's long-lived Tutor panel port (a hidden
8
+ // tab drops its event stream but keeps that port). The rules:
9
+ // - The first tab connects the helper and asks it to start.
10
+ // - After the last tab closes, wait GRACE_MS, then disconnect (the helper
11
+ // then stops the companion and relay). A tab opening meanwhile cancels it.
12
+ // - The active problem is the tab the student last sent a message, Run or
13
+ // Submit from. If that tab closes while others stay open, wait GRACE_MS,
14
+ // then ask the helper to reset the tutor's context (the processes keep
15
+ // running). Reloading that tab, or opening the same problem again,
16
+ // meanwhile cancels it.
17
+ // - A ping every PING_MS tells the helper someone is still there; it exits
18
+ // on its own after a few quiet minutes (a crashed browser, say).
19
+ // - A port that drops for another reason is retried with a growing delay.
20
+ // A helper that is not installed is not retried until retry().
21
+ //
22
+ // Pure JavaScript with no Node or browser API: connectNative, the timers and
23
+ // the error lookup are passed in (extension/shared.test.js checks that).
24
+
25
+ export const HELPER_HOST_NAME = 'dev.thomsonklee.leetcode_capture';
26
+ export const GRACE_MS = 30_000;
27
+ export const PING_MS = 60_000;
28
+ const RETRY_MIN_MS = 5000;
29
+ const RETRY_MAX_MS = 60_000;
30
+
31
+ // How Chrome and Firefox say the host is not registered.
32
+ const NOT_INSTALLED_RE = /not found|no such native application|not registered|access to the specified native messaging host is forbidden/i;
33
+
34
+ export function problemSlugFromUrl(url) {
35
+ const m = /^https:\/\/leetcode\.com\/problems\/([^/?#]+)/.exec(String(url || ''));
36
+ return m ? m[1] : null;
37
+ }
38
+
39
+ export function createHelperLauncher({
40
+ connectNative,
41
+ lastError = () => null,
42
+ timers,
43
+ graceMs = GRACE_MS,
44
+ pingMs = PING_MS,
45
+ log = () => {},
46
+ }) {
47
+ const tabs = new Map(); // tabId -> { ports, slug }
48
+ const listeners = new Set();
49
+ let wanted = false;
50
+ let port = null;
51
+ let state = 'idle'; // idle | connecting | running | not-installed | error
52
+ let host = null; // the helper's newest status message
53
+ let error = null;
54
+ let active = null; // { tabId, slug }
55
+ let stopTimer = null;
56
+ let resetTimer = null;
57
+ let resetFor = null; // { tabId, slug } the pending reset is about
58
+ let pingTimer = null;
59
+ let retryTimer = null;
60
+ let retryDelay = RETRY_MIN_MS;
61
+ let nextId = 1;
62
+
63
+ function emit() {
64
+ const snapshot = view();
65
+ for (const listener of listeners) listener(snapshot);
66
+ }
67
+
68
+ function view() {
69
+ return { wanted, state, tabs: tabs.size, host, error };
70
+ }
71
+
72
+ function post(message) {
73
+ if (!port) return false;
74
+ try {
75
+ port.postMessage({ id: nextId++, ...message });
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ function clear(timer) {
83
+ if (timer !== null) timers.clearTimeout(timer);
84
+ return null;
85
+ }
86
+
87
+ function disconnect(nextState = 'idle') {
88
+ pingTimer = pingTimer !== null ? (timers.clearInterval(pingTimer), null) : null;
89
+ retryTimer = clear(retryTimer);
90
+ stopTimer = clear(stopTimer);
91
+ resetTimer = clear(resetTimer);
92
+ resetFor = null;
93
+ const current = port;
94
+ port = null;
95
+ if (current) {
96
+ try {
97
+ current.disconnect();
98
+ } catch {
99
+ // already gone
100
+ }
101
+ }
102
+ host = null;
103
+ if (state !== 'not-installed' || nextState !== 'idle') state = nextState;
104
+ emit();
105
+ }
106
+
107
+ function connect() {
108
+ if (port || state === 'not-installed') return;
109
+ retryTimer = clear(retryTimer);
110
+ let next;
111
+ try {
112
+ next = connectNative(HELPER_HOST_NAME);
113
+ } catch (err) {
114
+ state = 'error';
115
+ error = err && err.message ? err.message : String(err);
116
+ emit();
117
+ return;
118
+ }
119
+ port = next;
120
+ state = 'connecting';
121
+ error = null;
122
+ next.onMessage.addListener((message) => {
123
+ if (port !== next || !message || typeof message !== 'object') return;
124
+ if (message.type === 'status') {
125
+ host = message;
126
+ error = message.error || null;
127
+ state = 'running';
128
+ retryDelay = RETRY_MIN_MS;
129
+ emit();
130
+ }
131
+ });
132
+ next.onDisconnect.addListener(() => {
133
+ if (port !== next) return;
134
+ port = null;
135
+ pingTimer = pingTimer !== null ? (timers.clearInterval(pingTimer), null) : null;
136
+ host = null;
137
+ const reason = lastError(next);
138
+ if (reason && NOT_INSTALLED_RE.test(reason)) {
139
+ state = 'not-installed';
140
+ error = reason;
141
+ } else {
142
+ state = 'error';
143
+ error = reason || 'The helper stopped.';
144
+ log(`helper disconnected: ${error}`);
145
+ if (wanted && tabs.size > 0) {
146
+ retryTimer = timers.setTimeout(() => {
147
+ retryTimer = null;
148
+ if (wanted && tabs.size > 0) connect();
149
+ }, retryDelay);
150
+ retryDelay = Math.min(retryDelay * 2, RETRY_MAX_MS);
151
+ }
152
+ }
153
+ emit();
154
+ });
155
+ post({ type: 'start' });
156
+ pingTimer = timers.setInterval(() => post({ type: 'ping' }), pingMs);
157
+ emit();
158
+ }
159
+
160
+ // Whether the processes should be running right now, applied.
161
+ function update() {
162
+ if (!wanted) {
163
+ if (port || state === 'connecting' || retryTimer !== null) disconnect();
164
+ return;
165
+ }
166
+ if (tabs.size > 0) {
167
+ stopTimer = clear(stopTimer);
168
+ connect();
169
+ } else if (port && stopTimer === null) {
170
+ stopTimer = timers.setTimeout(() => {
171
+ stopTimer = null;
172
+ if (tabs.size === 0) disconnect();
173
+ }, graceMs);
174
+ }
175
+ }
176
+
177
+ return {
178
+ // A Tutor panel port opened in this tab.
179
+ tabOpened(tabId, url) {
180
+ const entry = tabs.get(tabId) || { ports: 0, slug: null };
181
+ entry.ports += 1;
182
+ entry.slug = problemSlugFromUrl(url) || entry.slug;
183
+ tabs.set(tabId, entry);
184
+ if (resetFor && (resetFor.tabId === tabId || (entry.slug && entry.slug === resetFor.slug))) {
185
+ resetTimer = clear(resetTimer);
186
+ resetFor = null;
187
+ active = { tabId, slug: entry.slug };
188
+ }
189
+ update();
190
+ emit();
191
+ },
192
+
193
+ tabClosed(tabId) {
194
+ const entry = tabs.get(tabId);
195
+ if (!entry) return;
196
+ entry.ports -= 1;
197
+ if (entry.ports > 0) return;
198
+ tabs.delete(tabId);
199
+ if (tabs.size > 0 && active && active.tabId === tabId && port) {
200
+ resetTimer = clear(resetTimer);
201
+ resetFor = active;
202
+ active = null;
203
+ resetTimer = timers.setTimeout(() => {
204
+ resetTimer = null;
205
+ resetFor = null;
206
+ post({ type: 'reset' });
207
+ }, graceMs);
208
+ }
209
+ update();
210
+ emit();
211
+ },
212
+
213
+ // The student sent a message, Run or Submit from this tab.
214
+ markActive(tabId) {
215
+ const entry = tabs.get(tabId);
216
+ if (!entry) return;
217
+ active = { tabId, slug: entry.slug };
218
+ // The pending reset was about the old problem; whatever the companion
219
+ // does on a switch of problem now applies instead.
220
+ resetTimer = clear(resetTimer);
221
+ resetFor = null;
222
+ },
223
+
224
+ // Whether the helper runtime is chosen and the permission granted.
225
+ setWanted(next) {
226
+ wanted = Boolean(next);
227
+ if (wanted && state === 'not-installed' && !port) state = 'idle';
228
+ update();
229
+ emit();
230
+ },
231
+
232
+ // Try again after the student installed the helper.
233
+ retry() {
234
+ if (state === 'not-installed' || state === 'error') state = 'idle';
235
+ retryDelay = RETRY_MIN_MS;
236
+ update();
237
+ emit();
238
+ return view();
239
+ },
240
+
241
+ view,
242
+
243
+ onChange(listener) {
244
+ listeners.add(listener);
245
+ return () => listeners.delete(listener);
246
+ },
247
+ };
248
+ }