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,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,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
|
+
}
|