leetcode-capture-helper 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/cli.js +92 -0
  2. package/config.js +25 -0
  3. package/framing.js +57 -0
  4. package/host.js +420 -0
  5. package/install.js +290 -0
  6. package/package.json +31 -4
  7. package/runtime/bundle.json +42 -0
  8. package/runtime/companion/capture-log.js +83 -0
  9. package/runtime/companion/codex-backend.js +337 -0
  10. package/runtime/companion/companion.js +2190 -0
  11. package/runtime/companion/mouse-input.js +154 -0
  12. package/runtime/companion/package.json +4 -0
  13. package/runtime/companion/panel-bridge.js +273 -0
  14. package/runtime/companion/settings-rpc.js +228 -0
  15. package/runtime/companion/settings.js +464 -0
  16. package/runtime/companion/terminal-format.js +330 -0
  17. package/runtime/companion/vault-summary.js +168 -0
  18. package/runtime/companion/virtual-terminal.js +138 -0
  19. package/runtime/extension/shared/anthropic-backend.js +193 -0
  20. package/runtime/extension/shared/catalog.js +415 -0
  21. package/runtime/extension/shared/code-ledger.js +333 -0
  22. package/runtime/extension/shared/context-budget.js +259 -0
  23. package/runtime/extension/shared/context-summary.js +271 -0
  24. package/runtime/extension/shared/conversation.js +81 -0
  25. package/runtime/extension/shared/event-hub.js +126 -0
  26. package/runtime/extension/shared/extension-settings.js +445 -0
  27. package/runtime/extension/shared/helper-launcher.js +248 -0
  28. package/runtime/extension/shared/local-backend.js +706 -0
  29. package/runtime/extension/shared/local-server.js +149 -0
  30. package/runtime/extension/shared/package.json +4 -0
  31. package/runtime/extension/shared/practice.js +228 -0
  32. package/runtime/extension/shared/problem-index.js +4128 -0
  33. package/runtime/extension/shared/profiles.js +106 -0
  34. package/runtime/extension/shared/providers.js +58 -0
  35. package/runtime/extension/shared/reply-guard.js +864 -0
  36. package/runtime/extension/shared/roadmap.js +1337 -0
  37. package/runtime/extension/shared/sanitize.js +68 -0
  38. package/runtime/extension/shared/solved.js +316 -0
  39. package/runtime/extension/shared/tutor-engine.js +369 -0
  40. package/runtime/extension/shared/tutor-prompt.js +108 -0
  41. package/runtime/extension/shared/tutor-service.js +520 -0
  42. package/runtime/relay-server/package.json +4 -0
  43. package/runtime/relay-server/panel-broker.js +797 -0
  44. package/runtime/relay-server/server.js +241 -0
  45. package/runtime/vault-tool/log-session.js +230 -0
  46. package/runtime/vault-tool/package.json +4 -0
  47. package/runtime/vault-tool/vault-notes.js +206 -0
  48. package/README.md +0 -3
@@ -0,0 +1,154 @@
1
+ // companion/mouse-input.js - parses xterm SGR mouse-report escape sequences
2
+ // (`\x1b[<Cb;Cx;Cy;M` for press/wheel, `...m` for release) out of a raw
3
+ // input stream, so wheel-up/down events can drive companion.js's own
4
+ // scrollback viewport instead of ever reaching readline.
5
+ //
6
+ // Why this has to happen before readline sees the bytes at all: readline's
7
+ // own keypress decoder has no notion of the SGR mouse protocol (confirmed
8
+ // live - see the PR that introduced this file). Fed a raw wheel-report
9
+ // sequence, it doesn't ignore it or emit one clean "mouse" event; it shreds
10
+ // the sequence into a run of ordinary-looking keypresses, one per
11
+ // character (`6`, `4`, `;`, `1`, `0`, ...), each of which readline's own
12
+ // line editor then inserts into whatever's currently typed - a wheel
13
+ // notch would corrupt the input line with literal digits and semicolons.
14
+ // Legacy X10 mouse reports (`\x1b[M` + three raw bytes, mode 1000 without
15
+ // 1006) happen to be silently swallowed by Node's decoder already, but
16
+ // that mode can't report coordinates past 223 and isn't what this program
17
+ // requests - SGR (1006) is, and SGR gets no such special handling.
18
+ //
19
+ // Kept separate from companion.js's stream wiring (which stream gets raw
20
+ // mode, which one readline reads from) so the byte-level parsing itself is
21
+ // unit-testable without a real TTY - mirrors this project's existing split
22
+ // between terminal-format.js's pure rendering helpers and companion.js's
23
+ // orchestration of when/where they're used.
24
+
25
+ // Only button-event tracking (1000) + SGR extended coordinates (1006) are
26
+ // requested, not full motion tracking (1002/1003): wheel notches are
27
+ // already reported as "buttons" 64-67 under plain 1000, so drag/motion
28
+ // tracking would only add noise this program has no use for. Disable in
29
+ // the reverse order, matching how the alternate-screen buffer's own
30
+ // enable/disable pair is ordered elsewhere in companion.js.
31
+ export const ENABLE_MOUSE_TRACKING = '\x1b[?1000h\x1b[?1006h';
32
+ export const DISABLE_MOUSE_TRACKING = '\x1b[?1006l\x1b[?1000l';
33
+
34
+ // A real terminal delivers one escape sequence in a single write in the
35
+ // overwhelming common case, but nothing guarantees it - this bounds how
36
+ // long a possible-in-progress `\x1b[<...` prefix is held waiting for the
37
+ // rest before it's abandoned and let through byte by byte, so a malformed
38
+ // or truncated sequence can't buffer forever.
39
+ const MAX_PENDING = 32;
40
+
41
+ // A short idle window covers the rare case where a sequence really is
42
+ // split across two reads (e.g. a slow/laggy pty) without leaving a lone,
43
+ // unrelated Escape keypress (which also starts with `\x1b[<`'s first byte)
44
+ // stuck waiting indefinitely just because no more bytes ever follow it.
45
+ const IDLE_FLUSH_MS = 50;
46
+
47
+ const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/;
48
+
49
+ // The wheel bit (bit 6, value 64) distinguishes a wheel report from an
50
+ // ordinary button (0-3) regardless of any modifier bits (4=shift, 8=meta,
51
+ // 16=ctrl) added on top; the low bit (1) then distinguishes up (even) from
52
+ // down (odd). Standard xterm SGR encoding - matches xterm.js/blessed/
53
+ // neovim's own mouse-report handling. Returns null for anything that isn't
54
+ // a wheel report (plain clicks, releases - these are passed through
55
+ // unchanged by createMouseFilter below, not decoded here).
56
+ export function decodeWheelDirection(cb) {
57
+ if ((cb & 64) !== 64) return null;
58
+ return (cb & 1) === 1 ? 'down' : 'up';
59
+ }
60
+
61
+ // Creates a filter: feed it raw bytes as they arrive from the real
62
+ // terminal via push(chunk), and it calls onWheel('up' | 'down') for each
63
+ // complete SGR wheel report found, while writing every other byte -
64
+ // unchanged and in order, including non-wheel mouse reports (clicks,
65
+ // releases) this program doesn't act on - to `output` (expected to be a
66
+ // plain Writable, e.g. a PassThrough handed to readline as its own
67
+ // `input`).
68
+ //
69
+ // `chunk` may be a Buffer or a string; bytes are held internally as a
70
+ // binary/latin1 string (a straight 1:1 byte-to-char-code mapping) rather
71
+ // than decoded as UTF-8, specifically so a multi-byte UTF-8 character that
72
+ // happens to be split across two chunks is never corrupted by a decode
73
+ // happening mid-character - every byte, mouse-protocol or not, round-trips
74
+ // through unchanged.
75
+ export function createMouseFilter({ output, onWheel }) {
76
+ let pending = '';
77
+ let idleTimer = null;
78
+
79
+ function clearIdleTimer() {
80
+ if (idleTimer) {
81
+ clearTimeout(idleTimer);
82
+ idleTimer = null;
83
+ }
84
+ }
85
+
86
+ function flushPending() {
87
+ idleTimer = null;
88
+ if (pending) output.write(Buffer.from(pending, 'binary'));
89
+ pending = '';
90
+ }
91
+
92
+ function armIdleTimer() {
93
+ idleTimer = setTimeout(flushPending, IDLE_FLUSH_MS);
94
+ // Never let a lingering timer keep the process alive on its own - it's
95
+ // purely a cleanup safety net, not something companion.js should have
96
+ // to wait on to exit.
97
+ if (idleTimer.unref) idleTimer.unref();
98
+ }
99
+
100
+ function push(chunk) {
101
+ clearIdleTimer();
102
+ pending += Buffer.isBuffer(chunk) ? chunk.toString('binary') : String(chunk);
103
+ for (;;) {
104
+ const escIndex = pending.indexOf('\x1b');
105
+ if (escIndex === -1) {
106
+ if (pending) output.write(Buffer.from(pending, 'binary'));
107
+ pending = '';
108
+ return;
109
+ }
110
+ if (escIndex > 0) {
111
+ output.write(Buffer.from(pending.slice(0, escIndex), 'binary'));
112
+ pending = pending.slice(escIndex);
113
+ }
114
+ // pending now starts at an ESC byte - not yet enough of it buffered
115
+ // to tell whether it's the start of a mouse report at all.
116
+ if (pending.length < 3) {
117
+ armIdleTimer();
118
+ return;
119
+ }
120
+ if (!pending.startsWith('\x1b[<')) {
121
+ // Not a mouse report - forward just the ESC byte and keep
122
+ // scanning from the next one; whatever follows (arrow keys, other
123
+ // CSI sequences, a lone Escape) is ordinary input for readline to
124
+ // decode exactly as it always has.
125
+ output.write(Buffer.from(pending[0], 'binary'));
126
+ pending = pending.slice(1);
127
+ continue;
128
+ }
129
+ const match = SGR_MOUSE_RE.exec(pending);
130
+ if (!match) {
131
+ if (pending.length > MAX_PENDING) {
132
+ // Long past any real mouse report's length with no terminator -
133
+ // stop waiting and let it through byte by byte rather than
134
+ // buffering forever.
135
+ output.write(Buffer.from(pending[0], 'binary'));
136
+ pending = pending.slice(1);
137
+ continue;
138
+ }
139
+ armIdleTimer();
140
+ return;
141
+ }
142
+ const direction = decodeWheelDirection(Number(match[1]));
143
+ if (direction) onWheel(direction);
144
+ // Any other SGR mouse report (a click, a release, a modifier-only
145
+ // variant) is deliberately swallowed too, not forwarded - this
146
+ // program never enabled motion tracking, so the only reports it can
147
+ // receive at all are clicks/releases/wheel, none of which readline
148
+ // has any use for either.
149
+ pending = pending.slice(match[0].length);
150
+ }
151
+ }
152
+
153
+ return { push };
154
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "private": true,
3
+ "type": "module"
4
+ }
@@ -0,0 +1,273 @@
1
+ // companion/panel-bridge.js - mirrors the tutor conversation to the in-page
2
+ // panel (the extension's panel.js on leetcode.com) through the relay server.
3
+ //
4
+ // Two directions, both over the relay (see relay-server/panel-broker.js):
5
+ // - publish(event): tutor/user/capture events go out in batches to
6
+ // POST /panel/publish. Best effort: the terminal chat is the source of
7
+ // truth, so a relay hiccup drops panel events rather than disturbing it.
8
+ // - inbox: a long-lived GET /panel/inbox stream delivers messages the
9
+ // student typed into the panel, handed to onMessage. It reconnects with
10
+ // backoff for as long as the bridge runs, which is also how the panel
11
+ // learns whether a companion is online at all.
12
+ // - setBusy(busy): tells the relay whether the tutor is working on a turn
13
+ // (POST /panel/activity), for the panel's "Processing" light.
14
+ // - dropProblem(slug): asks the relay to forget one problem's buffered
15
+ // events (POST /panel/drop-problem) once the tutor has moved on from it.
16
+ // It goes out after everything published before it, so the old
17
+ // problem's last events cannot land after the drop.
18
+ // - setConfig(config): publishes the non-secret `config` event (which
19
+ // provider and model the tutor uses) and publishes it again whenever the
20
+ // inbox reconnects, since a restarted relay has none to replay.
21
+ // - rpc: an inbox message `{ type: 'rpc', id, method, params }` is a call
22
+ // from the extension's options page (relay POST /settings/rpc). It goes to
23
+ // onRpc(method, params), and whatever that resolves to is posted back to
24
+ // POST /panel/rpc-result as `{ id, response }`. Calls run concurrently with
25
+ // the inbox, so a slow connection test never holds up a typed message.
26
+ //
27
+ // Kept free of any terminal/readline code so it can be tested against a stub
28
+ // relay without booting companion.js.
29
+
30
+ const FLUSH_MS = 40;
31
+ // A relay that accepts a publish but never answers must not stall the flush
32
+ // chain (and with it every later publish) forever.
33
+ const PUBLISH_TIMEOUT_MS = 5000;
34
+ // While a publish is slow the queue keeps growing; past this many events the
35
+ // oldest are dropped. Losing them is fine (the panel tolerates a missing
36
+ // turn-start, and a turn-end carries the full text), running out of memory is
37
+ // not.
38
+ const MAX_QUEUED_EVENTS = 1000;
39
+ // The relay's companion-only routes require this header; a browser page cannot
40
+ // send it without a CORS preflight, which the relay refuses (see
41
+ // relay-server/panel-broker.js, companionOnlyRejection).
42
+ const COMPANION_HEADERS = { 'X-LC-Companion': '1' };
43
+ const RECONNECT_MIN_MS = 1000;
44
+ const RECONNECT_MAX_MS = 8000;
45
+
46
+ // Parses one SSE block ("id: 3\ndata: {...}") into its JSON payload, or null
47
+ // for a comment/keep-alive/malformed block.
48
+ export function parseSseBlock(block) {
49
+ const data = block
50
+ .split('\n')
51
+ .filter((line) => line.startsWith('data:'))
52
+ .map((line) => line.slice(5).replace(/^ /, ''))
53
+ .join('\n');
54
+ if (!data) return null;
55
+ try {
56
+ return JSON.parse(data);
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ export function createPanelBridge({
63
+ baseUrl,
64
+ onMessage,
65
+ onState,
66
+ onRpc = null,
67
+ fetchImpl = fetch,
68
+ flushMs = FLUSH_MS,
69
+ publishTimeoutMs = PUBLISH_TIMEOUT_MS,
70
+ }) {
71
+ let stopped = false;
72
+ let queue = [];
73
+ let flushTimer = null;
74
+ let flushing = Promise.resolve();
75
+ let inboxAbort = null;
76
+ let reconnectDelay = RECONNECT_MIN_MS;
77
+ let state = 'connecting';
78
+ let config = null;
79
+
80
+ function setState(next) {
81
+ if (state === next) return;
82
+ state = next;
83
+ if (onState) onState(next);
84
+ }
85
+
86
+ function publish(event) {
87
+ if (stopped) return;
88
+ const last = queue[queue.length - 1];
89
+ // Consecutive chunks of one turn travel as one: the panel only ever
90
+ // concatenates them, and a long reply would otherwise cost one HTTP
91
+ // request per streamed token.
92
+ if (last && event.type === 'chunk' && last.type === 'chunk' && last.turnId === event.turnId) {
93
+ last.text += event.text;
94
+ } else {
95
+ queue.push({ ...event });
96
+ if (queue.length > MAX_QUEUED_EVENTS) queue.splice(0, queue.length - MAX_QUEUED_EVENTS);
97
+ }
98
+ if (!flushTimer) {
99
+ flushTimer = setTimeout(() => {
100
+ flushTimer = null;
101
+ flushing = flushing.then(flushQueue);
102
+ }, flushMs);
103
+ flushTimer.unref();
104
+ }
105
+ }
106
+
107
+ async function flushQueue() {
108
+ if (queue.length === 0) return;
109
+ const events = queue;
110
+ queue = [];
111
+ try {
112
+ await fetchImpl(`${baseUrl}/panel/publish`, {
113
+ method: 'POST',
114
+ headers: { 'Content-Type': 'application/json', ...COMPANION_HEADERS },
115
+ body: JSON.stringify({ events }),
116
+ signal: AbortSignal.timeout(publishTimeoutMs),
117
+ });
118
+ } catch {
119
+ // Best effort - see the file header.
120
+ }
121
+ }
122
+
123
+ async function runInbox() {
124
+ while (!stopped) {
125
+ inboxAbort = new AbortController();
126
+ try {
127
+ // eslint-disable-next-line no-await-in-loop
128
+ const res = await fetchImpl(`${baseUrl}/panel/inbox`, {
129
+ headers: COMPANION_HEADERS,
130
+ signal: inboxAbort.signal,
131
+ });
132
+ if (res.status === 404) {
133
+ // A relay server started before this feature existed.
134
+ setState('unsupported');
135
+ } else if (res.ok && res.body) {
136
+ reconnectDelay = RECONNECT_MIN_MS;
137
+ setState('connected');
138
+ if (busy) flushing = flushing.then(postActivity);
139
+ if (config) publish({ type: 'config', ...config });
140
+ const decoder = new TextDecoder();
141
+ let buffer = '';
142
+ // eslint-disable-next-line no-restricted-syntax
143
+ for await (const chunk of res.body) {
144
+ buffer += decoder.decode(chunk, { stream: true });
145
+ let idx;
146
+ // eslint-disable-next-line no-cond-assign
147
+ while ((idx = buffer.indexOf('\n\n')) !== -1) {
148
+ const message = parseSseBlock(buffer.slice(0, idx));
149
+ buffer = buffer.slice(idx + 2);
150
+ if (message && message.type === 'rpc') handleRpc(message);
151
+ else if (message && typeof message.text === 'string') onMessage(message);
152
+ }
153
+ }
154
+ }
155
+ } catch {
156
+ // Relay not up (yet) or the stream dropped - fall through to retry.
157
+ }
158
+ if (stopped) return;
159
+ if (state === 'connected') setState('disconnected');
160
+ // eslint-disable-next-line no-await-in-loop
161
+ await new Promise((resolve) => {
162
+ const t = setTimeout(resolve, reconnectDelay);
163
+ t.unref();
164
+ });
165
+ reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
166
+ }
167
+ }
168
+
169
+ async function handleRpc(message) {
170
+ if (typeof message.id !== 'string' || !message.id) return;
171
+ let response;
172
+ try {
173
+ response = onRpc
174
+ ? await onRpc(message.method, message.params)
175
+ : { ok: false, error: { message: 'this companion does not take settings calls' } };
176
+ } catch (err) {
177
+ response = { ok: false, error: { message: (err && err.message) || 'the settings call failed' } };
178
+ }
179
+ try {
180
+ await fetchImpl(`${baseUrl}/panel/rpc-result`, {
181
+ method: 'POST',
182
+ headers: { 'Content-Type': 'application/json', ...COMPANION_HEADERS },
183
+ body: JSON.stringify({ id: message.id, response }),
184
+ signal: AbortSignal.timeout(publishTimeoutMs),
185
+ });
186
+ } catch {
187
+ // The options page times out on its own and says so.
188
+ }
189
+ }
190
+
191
+ function setConfig(next) {
192
+ if (stopped || !next || typeof next !== 'object') return;
193
+ config = { ...next };
194
+ publish({ type: 'config', ...config });
195
+ }
196
+
197
+ async function postDrop(problemSlug) {
198
+ try {
199
+ await fetchImpl(`${baseUrl}/panel/drop-problem`, {
200
+ method: 'POST',
201
+ headers: { 'Content-Type': 'application/json', ...COMPANION_HEADERS },
202
+ body: JSON.stringify({ problemSlug }),
203
+ signal: AbortSignal.timeout(publishTimeoutMs),
204
+ });
205
+ } catch {
206
+ // Best effort - see the file header.
207
+ }
208
+ }
209
+
210
+ // Tells the relay whether the tutor is working on (or has queued) a turn,
211
+ // for the panel's status light. Sent in order after everything published
212
+ // before it, and again whenever the inbox (re)connects, since a restarted
213
+ // relay starts out idle.
214
+ let busy = false;
215
+ async function postActivity() {
216
+ try {
217
+ await fetchImpl(`${baseUrl}/panel/activity`, {
218
+ method: 'POST',
219
+ headers: { 'Content-Type': 'application/json', ...COMPANION_HEADERS },
220
+ body: JSON.stringify({ busy }),
221
+ signal: AbortSignal.timeout(publishTimeoutMs),
222
+ });
223
+ } catch {
224
+ // Best effort - see the file header.
225
+ }
226
+ }
227
+
228
+ function setBusy(next) {
229
+ if (stopped || busy === Boolean(next)) return;
230
+ busy = Boolean(next);
231
+ if (flushTimer) {
232
+ clearTimeout(flushTimer);
233
+ flushTimer = null;
234
+ }
235
+ flushing = flushing.then(flushQueue).then(postActivity);
236
+ }
237
+
238
+ function dropProblem(problemSlug) {
239
+ if (stopped || typeof problemSlug !== 'string' || !problemSlug) return;
240
+ if (flushTimer) {
241
+ clearTimeout(flushTimer);
242
+ flushTimer = null;
243
+ }
244
+ flushing = flushing.then(flushQueue).then(() => postDrop(problemSlug));
245
+ }
246
+
247
+ return {
248
+ publish,
249
+ dropProblem,
250
+ setBusy,
251
+ setConfig,
252
+ start() {
253
+ runInbox();
254
+ },
255
+ stop() {
256
+ stopped = true;
257
+ if (flushTimer) clearTimeout(flushTimer);
258
+ if (inboxAbort) inboxAbort.abort();
259
+ },
260
+ // Resolves once everything queued so far has been sent (for tests).
261
+ async drain() {
262
+ if (flushTimer) {
263
+ clearTimeout(flushTimer);
264
+ flushTimer = null;
265
+ flushing = flushing.then(flushQueue);
266
+ }
267
+ await flushing;
268
+ },
269
+ get state() {
270
+ return state;
271
+ },
272
+ };
273
+ }
@@ -0,0 +1,228 @@
1
+ // companion/settings-rpc.js - what the extension's options page can ask the
2
+ // companion to do: read the settings, save a change, test a connection, list a
3
+ // server's models, keep its list of saved models (profiles) up to date; and
4
+ // what the Tutor panel's model switcher can ask: switch to one of those saved
5
+ // models (switchProfile, the only method the relay's panel-origin
6
+ // POST /panel/switch-profile forwards).
7
+ //
8
+ // The options page talks to the relay, which forwards each call here over the
9
+ // companion's existing inbox stream (see panel-bridge.js). All the decisions
10
+ // live in this module so they can be tested without a browser, a relay, or a
11
+ // running companion:
12
+ // - a save is validated, a backend is built from it (so a config that cannot
13
+ // work is refused up front), and only then is it written and applied;
14
+ // - a test builds a throwaway backend from an unsaved draft and never touches
15
+ // the saved file or the live conversation;
16
+ // - nothing returned to the options page ever contains an API key, and error
17
+ // text is scrubbed of any key it might echo.
18
+
19
+ import {
20
+ PROVIDERS,
21
+ SettingsError,
22
+ applyPatch,
23
+ missingRequirement,
24
+ publicView,
25
+ resolveEffective,
26
+ saveSettings,
27
+ statusSummary,
28
+ } from './settings.js';
29
+ import { checkCodexLogin } from './codex-backend.js';
30
+ import { PROFILE_ID_RE, findProfile } from '../extension/shared/profiles.js';
31
+ import { CONNECTION_TEST_PROMPT as TEST_PROMPT } from '../extension/shared/tutor-prompt.js';
32
+
33
+ const TEST_TIMEOUT_MS = 90_000;
34
+ const MODELS_TIMEOUT_MS = 6000;
35
+ const MAX_MODELS = 200;
36
+
37
+ function scrub(message, secrets) {
38
+ let out = String(message);
39
+ for (const secret of secrets) {
40
+ if (secret && secret.length >= 6) out = out.split(secret).join('***');
41
+ }
42
+ return out;
43
+ }
44
+
45
+ function withTimeout(promise, ms, what) {
46
+ let timer;
47
+ const timeout = new Promise((_, reject) => {
48
+ timer = setTimeout(() => reject(new Error(`${what} did not answer within ${Math.round(ms / 1000)} seconds`)), ms);
49
+ });
50
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
51
+ }
52
+
53
+ // deps:
54
+ // file, env where settings live and the environment to resolve against
55
+ // getStored() the saved settings object currently in memory
56
+ // setStored(next) replace it after a successful save
57
+ // buildBackend(effective) throws if a backend can't be built (async allowed)
58
+ // apply(effective, backend, why) swap the live backend (companion.js queues it behind the
59
+ // current turn); may return a promise of the swap having happened
60
+ // validateVault(effective) throws if the vault auto-summary can't run (optional)
61
+ // fetchImpl, codexCommand injectable for tests
62
+ export function createSettingsRpc(deps) {
63
+ const fetchImpl = deps.fetchImpl || fetch;
64
+
65
+ function view(extra = {}) {
66
+ const stored = deps.getStored();
67
+ const effective = resolveEffective(stored, deps.env);
68
+ return { settings: publicView(stored, deps.env, deps.file), active: statusSummary(effective), ...extra };
69
+ }
70
+
71
+ async function get() {
72
+ return view();
73
+ }
74
+
75
+ async function save({ patch }) {
76
+ const stored = deps.getStored();
77
+ const next = applyPatch(stored, patch);
78
+ const effective = resolveEffective(next, deps.env);
79
+ const problem = missingRequirement(effective);
80
+ if (problem) throw new SettingsError(problem, 'provider');
81
+ if (effective.options.vaultAutoSummary && deps.validateVault) {
82
+ try {
83
+ deps.validateVault(effective);
84
+ } catch (err) {
85
+ throw new SettingsError(err.message, 'vaultAutoSummary');
86
+ }
87
+ }
88
+ // Build before persisting: a configuration that cannot produce a backend
89
+ // must not replace one that works.
90
+ let backend;
91
+ try {
92
+ backend = await deps.buildBackend(effective);
93
+ } catch (err) {
94
+ throw new SettingsError(scrub(err.message, [effective.apiKey]), 'provider');
95
+ }
96
+ saveSettings(next, deps.file);
97
+ deps.setStored(next);
98
+ // Not waited for: the page hears "saved" even while a reply still runs.
99
+ Promise.resolve(deps.apply(effective, backend, 'save')).catch(() => {});
100
+ return view({ saved: true });
101
+ }
102
+
103
+ // The options page's list of saved models that run through the helper.
104
+ // Changes nothing about the running backend; an active profile that is no
105
+ // longer listed is simply no longer one.
106
+ async function setProfiles({ profiles }) {
107
+ const next = applyPatch(deps.getStored(), { profiles: Array.isArray(profiles) ? profiles : [] });
108
+ saveSettings(next, deps.file);
109
+ deps.setStored(next);
110
+ return view({ saved: true });
111
+ }
112
+
113
+ // The model switcher: makes a saved profile the active one. It can change
114
+ // nothing but which saved profile is active (never a key, a URL or any
115
+ // other setting), so whoever may call it can at most flip between the
116
+ // models the user saved. Resolves once the new backend is in place, after
117
+ // any turn already queued, with only the non-secret summary.
118
+ async function switchProfile({ id }) {
119
+ const stored = deps.getStored();
120
+ const profile = typeof id === 'string' && PROFILE_ID_RE.test(id) ? findProfile(stored.profiles, id) : null;
121
+ if (!profile) {
122
+ throw Object.assign(new SettingsError('The helper app does not have that model saved. Open Manage models and press Save.', 'profile'), { code: 'unknown' });
123
+ }
124
+ const env = deps.env || {};
125
+ if ((env.COMPANION_BACKEND && env.COMPANION_BACKEND !== profile.provider) || (env.COMPANION_MODEL && env.COMPANION_MODEL !== profile.model)) {
126
+ const name = env.COMPANION_BACKEND && env.COMPANION_BACKEND !== profile.provider ? 'COMPANION_BACKEND' : 'COMPANION_MODEL';
127
+ throw Object.assign(new SettingsError(`The model is set by ${name} in the helper's environment, so it can't be switched here.`, 'profile'), { code: 'locked' });
128
+ }
129
+ const current = resolveEffective(stored, deps.env);
130
+ if (stored.activeProfile === id && current.provider === profile.provider && (current.model || null) === (profile.model || null)) {
131
+ return { active: statusSummary(current), profile: id };
132
+ }
133
+ const next = applyPatch(stored, { activeProfile: id });
134
+ const effective = resolveEffective(next, deps.env);
135
+ const problem = missingRequirement(effective);
136
+ if (problem) throw Object.assign(new SettingsError(`${problem}. Open Manage models to finish setting it up.`, 'profile'), { code: 'unavailable' });
137
+ let backend;
138
+ try {
139
+ backend = await deps.buildBackend(effective);
140
+ } catch (err) {
141
+ throw Object.assign(new SettingsError(scrub(err.message, [effective.apiKey]), 'profile'), { code: 'unavailable' });
142
+ }
143
+ saveSettings(next, deps.file);
144
+ deps.setStored(next);
145
+ await deps.apply(effective, backend, 'switch');
146
+ return { active: statusSummary(effective), profile: id };
147
+ }
148
+
149
+ // Tries a draft (the saved settings with the page's unsaved edits on top).
150
+ async function test({ patch }) {
151
+ const draft = applyPatch(deps.getStored(), patch || {});
152
+ const effective = resolveEffective(draft, deps.env);
153
+ const secrets = [effective.apiKey];
154
+ const problem = missingRequirement(effective);
155
+ if (problem) return { ok: false, message: problem };
156
+ const started = Date.now();
157
+ try {
158
+ if (effective.provider === 'chatgpt') {
159
+ const login = await checkCodexLogin({ ...(deps.codexCommand || {}), env: deps.env });
160
+ if (!login.ok) return { ok: false, message: `ChatGPT sign-in: ${login.message}. Run "codex login" and choose ChatGPT.` };
161
+ }
162
+ const backend = await deps.buildBackend(effective);
163
+ const reply = await withTimeout(backend.sendMessage(TEST_PROMPT, {}), TEST_TIMEOUT_MS, PROVIDERS[effective.provider].label);
164
+ if (!reply || !String(reply).trim()) return { ok: false, message: 'The model answered with nothing.' };
165
+ return { ok: true, message: `Connected in ${((Date.now() - started) / 1000).toFixed(1)}s`, reply: String(reply).trim().slice(0, 120) };
166
+ } catch (err) {
167
+ return { ok: false, message: scrub(err.message, secrets) };
168
+ }
169
+ }
170
+
171
+ // Lists model names a server offers, for the model field's suggestions.
172
+ async function models({ provider, baseUrl, apiKey }) {
173
+ const stored = deps.getStored();
174
+ const id = PROVIDERS[provider] ? provider : 'local';
175
+ const draft = applyPatch(stored, {
176
+ providers: { [id]: { ...(baseUrl ? { baseUrl } : {}), ...(typeof apiKey === 'string' && apiKey ? { apiKey } : {}) } },
177
+ });
178
+ const effective = resolveEffective({ ...draft, provider: id }, deps.env);
179
+ if (!effective.baseUrl) return { models: [] };
180
+ const base = effective.baseUrl.replace(/\/+$/, '');
181
+ const headers = effective.apiKey ? { Authorization: `Bearer ${effective.apiKey}` } : {};
182
+ const secrets = [effective.apiKey];
183
+ const ask = async (url) => {
184
+ const res = await withTimeout(fetchImpl(url, { headers }), MODELS_TIMEOUT_MS, url);
185
+ if (!res.ok) throw new Error(`${url} answered ${res.status}`);
186
+ return res.json();
187
+ };
188
+ try {
189
+ let names = [];
190
+ // Ollama's own listing names models exactly as `ollama list` does and is
191
+ // the one most local users want; fall back to the OpenAI-style list.
192
+ if (id === 'local' && /\/v1$/.test(base)) {
193
+ try {
194
+ const tags = await ask(`${base.replace(/\/v1$/, '')}/api/tags`);
195
+ names = (tags.models || []).map((m) => m.name);
196
+ } catch {
197
+ names = [];
198
+ }
199
+ }
200
+ if (names.length === 0) {
201
+ const list = await ask(`${base}/models`);
202
+ names = (list.data || []).map((m) => m.id);
203
+ }
204
+ return { models: [...new Set(names.filter((n) => typeof n === 'string' && n))].sort().slice(0, MAX_MODELS) };
205
+ } catch (err) {
206
+ return { models: [], message: scrub(err.message, secrets) };
207
+ }
208
+ }
209
+
210
+ const METHODS = { get, save, test, models, setProfiles, switchProfile };
211
+
212
+ // Single entry point used by the bridge. Never throws: the result is always
213
+ // { ok: true, result } or { ok: false, error: { message, field } }.
214
+ async function handle(method, params) {
215
+ const fn = Object.prototype.hasOwnProperty.call(METHODS, method) ? METHODS[method] : null;
216
+ if (!fn) return { ok: false, error: { message: `unknown settings method "${method}"` } };
217
+ try {
218
+ return { ok: true, result: await fn(params && typeof params === 'object' ? params : {}) };
219
+ } catch (err) {
220
+ if (err instanceof SettingsError) {
221
+ return { ok: false, error: { message: err.message, field: err.field, ...(err.code ? { code: err.code } : {}) } };
222
+ }
223
+ return { ok: false, error: { message: scrub(err.message, [deps.env.COMPANION_API_KEY]) } };
224
+ }
225
+ }
226
+
227
+ return { handle };
228
+ }