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,337 @@
1
+ // companion/codex-backend.js - tutor backend for a ChatGPT subscription,
2
+ // driven through OpenAI's Codex CLI.
3
+ //
4
+ // OpenAI has no public API for a ChatGPT plan, but the Codex CLI signs in with
5
+ // one ("codex login") and `codex exec` runs a single non-interactive turn that
6
+ // reuses that saved sign-in. This backend shells out to it, one process per
7
+ // turn, the same way ClaudeBackend rides the Claude Code sign-in.
8
+ //
9
+ // Reference (OpenAI's non-interactive mode docs): the prompt is read from stdin
10
+ // when the argument is `-`; `--json` turns stdout into JSON Lines whose
11
+ // `item.completed` events carry `item.type === "agent_message"` with a `text`;
12
+ // `turn.failed` and `error` events carry failures; `--sandbox read-only`,
13
+ // `--skip-git-repo-check`, `--ephemeral` and `-C <dir>` keep it from touching
14
+ // anything. The tutor needs none of Codex's agent tooling, so it runs in an
15
+ // empty scratch directory with a read-only sandbox.
16
+ //
17
+ // Experimental: Codex is a coding agent, so its own instructions sit under the
18
+ // tutor prompt this backend sends; and unlike the other backends there is no
19
+ // token streaming (a reply arrives whole).
20
+ //
21
+ // `codex` is spawned per turn and sees the whole conversation each time
22
+ // (--ephemeral keeps no session), so this backend keeps its own bounded
23
+ // history like LocalBackend does: past `maxHistoryTurns` exchanges, the older
24
+ // ones are condensed into one short summary (context-summary.js) by one extra
25
+ // codex call, or a deterministic digest when that call fails.
26
+
27
+ import { spawn } from 'node:child_process';
28
+ import fs from 'node:fs';
29
+ import os from 'node:os';
30
+ import path from 'node:path';
31
+ import { buildMessages } from '../extension/shared/context-budget.js';
32
+ import { formatPinnedCode } from '../extension/shared/code-ledger.js';
33
+ import { flattenMessages, normalizeConversation } from '../extension/shared/conversation.js';
34
+ import { SUMMARY_MAX_TOKENS, SUMMARY_TIMEOUT_MS, condense, condenseCount, summaryBodyChars } from '../extension/shared/context-summary.js';
35
+
36
+ const DEFAULT_TIMEOUT_MS = 180_000;
37
+ const MAX_STDERR_CHARS = 2000;
38
+
39
+ // Environment variables that would make codex bill an API key instead of the
40
+ // ChatGPT sign-in the user picked this provider for.
41
+ const API_KEY_ENV = ['CODEX_API_KEY', 'OPENAI_API_KEY'];
42
+
43
+ // A helper started by the browser (or a desktop launcher) gets the session's
44
+ // PATH, not the shell profile's, so a Codex installed with `npm install -g`
45
+ // into a user folder (~/.npm-global, nvm, volta) is invisible to it and was
46
+ // reported as "not installed". These are the usual places, appended after the
47
+ // real PATH so a system install still wins.
48
+ export function codexSearchDirs({ env = process.env, home = env.HOME || env.USERPROFILE || os.homedir(), platform = process.platform } = {}) {
49
+ const dirs = [];
50
+ if (platform === 'win32') {
51
+ if (env.APPDATA) dirs.push(path.join(env.APPDATA, 'npm'));
52
+ if (env.LOCALAPPDATA) dirs.push(path.join(env.LOCALAPPDATA, 'Programs', 'codex'));
53
+ return dirs;
54
+ }
55
+ if (env.npm_config_prefix) dirs.push(path.join(env.npm_config_prefix, 'bin'));
56
+ dirs.push(
57
+ path.join(home, '.npm-global', 'bin'),
58
+ path.join(home, '.local', 'bin'),
59
+ path.join(home, '.volta', 'bin'),
60
+ path.join(home, '.bun', 'bin'),
61
+ '/opt/homebrew/bin',
62
+ '/usr/local/bin'
63
+ );
64
+ try {
65
+ const nvm = path.join(home, '.nvm', 'versions', 'node');
66
+ for (const version of fs.readdirSync(nvm)) dirs.push(path.join(nvm, version, 'bin'));
67
+ } catch {
68
+ // no nvm
69
+ }
70
+ return dirs;
71
+ }
72
+
73
+ export function pathWithCodexDirs(env = process.env, options = {}) {
74
+ const platform = options.platform || process.platform;
75
+ const delimiter = platform === 'win32' ? ';' : ':';
76
+ const key = platform === 'win32' ? Object.keys(env).find((k) => k.toLowerCase() === 'path') || 'Path' : 'PATH';
77
+ const have = String(env[key] || '').split(delimiter).filter(Boolean);
78
+ const extra = codexSearchDirs({ env, ...options }).filter((d) => !have.includes(d));
79
+ return { key, value: [...have, ...extra].join(delimiter) };
80
+ }
81
+
82
+ export function codexSubscriptionEnv(env = process.env, options = {}) {
83
+ const out = { ...env };
84
+ for (const name of API_KEY_ENV) delete out[name];
85
+ const { key, value } = pathWithCodexDirs(out, options);
86
+ out[key] = value;
87
+ return out;
88
+ }
89
+
90
+ export function buildCodexArgs({ model, cwd }) {
91
+ const args = ['exec', '--json', '--skip-git-repo-check', '--ephemeral', '--sandbox', 'read-only', '-C', cwd];
92
+ if (model) args.push('-m', model);
93
+ args.push('-'); // read the prompt from stdin
94
+ return args;
95
+ }
96
+
97
+ // Pulls the reply out of codex's JSON Lines output. Returns { text, error }.
98
+ export function parseCodexOutput(stdout) {
99
+ let text = null;
100
+ let error = null;
101
+ for (const line of stdout.split('\n')) {
102
+ const trimmed = line.trim();
103
+ if (!trimmed.startsWith('{')) continue;
104
+ let event;
105
+ try {
106
+ event = JSON.parse(trimmed);
107
+ } catch {
108
+ continue;
109
+ }
110
+ if (event.type === 'item.completed' && event.item && event.item.type === 'agent_message' && typeof event.item.text === 'string') {
111
+ text = event.item.text;
112
+ } else if (event.type === 'turn.failed') {
113
+ error = (event.error && event.error.message) || 'the turn failed';
114
+ } else if (event.type === 'error' && !error) {
115
+ error = event.message || (event.error && event.error.message) || 'codex reported an error';
116
+ }
117
+ }
118
+ return { text, error };
119
+ }
120
+
121
+ export class CodexBackend {
122
+ constructor({
123
+ model = null,
124
+ cwd,
125
+ systemPrompt,
126
+ maxHistoryTurns = 6,
127
+ command = 'codex',
128
+ commandArgs = [],
129
+ timeoutMs = DEFAULT_TIMEOUT_MS,
130
+ summaryTimeoutMs = SUMMARY_TIMEOUT_MS,
131
+ env = process.env,
132
+ }) {
133
+ if (!cwd) throw new Error('CodexBackend needs a scratch directory (cwd)');
134
+ this.model = model;
135
+ this.cwd = cwd;
136
+ this.systemPrompt = systemPrompt;
137
+ this.maxHistoryTurns = maxHistoryTurns;
138
+ this.command = command;
139
+ this.commandArgs = commandArgs;
140
+ this.timeoutMs = timeoutMs;
141
+ this.summaryTimeoutMs = summaryTimeoutMs;
142
+ this.env = env;
143
+ // Whole exchanges, oldest first, in conversation.js's shape:
144
+ // [{ role: 'user' | 'assistant', content, compactContent? }].
145
+ this.history = [];
146
+ this.pinnedProblemContext = null;
147
+ // The student's current full code (see code-ledger.js's
148
+ // formatPinnedCode), pinned like the problem statement on every request.
149
+ // `{ text }` or null.
150
+ this.pinnedCode = null;
151
+ // The "earlier in this session" summary of condensed exchanges, or null.
152
+ this.summary = null;
153
+ }
154
+
155
+ resetContext() {
156
+ this.history = [];
157
+ this.pinnedProblemContext = null;
158
+ this.pinnedCode = null;
159
+ this.summary = null;
160
+ }
161
+
162
+ // The same pinning rules as LocalBackend (context-budget.js's
163
+ // buildMessages): the statement and the current code come first, and an
164
+ // older student turn with a compact form is sent as that.
165
+ buildPrompt(text, { compactText } = {}) {
166
+ const history = [
167
+ { role: 'system', content: this.systemPrompt },
168
+ ...this.history,
169
+ { role: 'user', content: text, ...(typeof compactText === 'string' ? { compactContent: compactText } : {}) },
170
+ ];
171
+ const messages = buildMessages({
172
+ history,
173
+ pinnedProblemContext: this.pinnedProblemContext,
174
+ pinnedCode: this.pinnedCode,
175
+ summary: this.summary,
176
+ });
177
+ const { system, prompt } = flattenMessages(messages, {
178
+ closing: 'Reply now as the tutor, following the rules above. Do not run commands or read files.',
179
+ });
180
+ return `${system}\n\n---\n\n${prompt}`;
181
+ }
182
+
183
+ // `currentCode` is the student's current full code for this request and
184
+ // `compactText` what this turn becomes once it is older (see
185
+ // LocalBackend.sendMessage, which takes the same options).
186
+ async sendMessage(text, { problemContext, currentCode, compactText } = {}) {
187
+ if (problemContext?.description && (!this.pinnedProblemContext || problemContext.problemId !== this.pinnedProblemContext.problemId)) {
188
+ this.pinnedProblemContext = problemContext;
189
+ }
190
+ const pinnedText = formatPinnedCode(currentCode);
191
+ this.pinnedCode = pinnedText ? { text: pinnedText } : null;
192
+ fs.mkdirSync(this.cwd, { recursive: true });
193
+ await this.condenseIfNeeded();
194
+ const prompt = this.buildPrompt(text, { compactText });
195
+ const reply = await this.runCodex(prompt);
196
+ this.history.push(
197
+ { role: 'user', content: text, ...(typeof compactText === 'string' && compactText !== text ? { compactContent: compactText } : {}) },
198
+ { role: 'assistant', content: reply }
199
+ );
200
+ return reply;
201
+ }
202
+
203
+ // The post-check guard's one re-ask (extension/shared/reply-guard.js): the
204
+ // same prompt with the rejected reply and `instruction` after it. History
205
+ // is left as it is; the guard then calls replaceLastReply.
206
+ async reviseLastReply(instruction) {
207
+ const last = this.history[this.history.length - 1];
208
+ if (!last || last.role !== 'assistant') throw new Error('there is no reply to revise');
209
+ return this.runCodex(this.buildPrompt(instruction));
210
+ }
211
+
212
+ // Puts `text` (what the student was shown) in place of the last reply.
213
+ replaceLastReply(text) {
214
+ const last = this.history[this.history.length - 1];
215
+ if (last && last.role === 'assistant' && typeof text === 'string' && text.trim()) last.content = text;
216
+ }
217
+
218
+ // The conversation in the shape every backend shares (conversation.js).
219
+ exportConversation() {
220
+ return normalizeConversation({ turns: this.history, summary: this.summary, problem: this.pinnedProblemContext });
221
+ }
222
+
223
+ importConversation(state) {
224
+ const { turns, summary, problem } = normalizeConversation(state);
225
+ this.resetContext();
226
+ this.history = turns;
227
+ this.summary = summary;
228
+ this.pinnedProblemContext = problem;
229
+ }
230
+
231
+ // Past maxHistoryTurns exchanges, condenses the older ones (at least the
232
+ // older half) into the summary instead of dropping them. Runs at the start
233
+ // of a turn, inside the companion's turn queue, and prints nothing.
234
+ async condenseIfNeeded() {
235
+ const pairs = this.history.length / 2;
236
+ if (!(this.maxHistoryTurns > 0) || pairs <= this.maxHistoryTurns) return;
237
+ const k = condenseCount(pairs, pairs - this.maxHistoryTurns);
238
+ const dropped = [];
239
+ for (let i = 0; i < k; i += 1) {
240
+ const student = this.history[2 * i];
241
+ const tutor = this.history[2 * i + 1];
242
+ const compact = Boolean(this.pinnedCode) && typeof student.compactContent === 'string';
243
+ dropped.push({ student: compact ? student.compactContent : student.content, tutor: tutor.content });
244
+ }
245
+ const { summary } = await condense({
246
+ existing: this.summary,
247
+ dropped,
248
+ maxChars: summaryBodyChars(SUMMARY_MAX_TOKENS),
249
+ summarize: (messages) =>
250
+ this.runCodex(
251
+ `${messages.map((m) => m.content).join('\n\n---\n\n')}\n\nDo not run commands or read files.`,
252
+ { timeoutMs: this.summaryTimeoutMs }
253
+ ),
254
+ timeoutMs: this.summaryTimeoutMs,
255
+ });
256
+ this.summary = summary;
257
+ this.history = this.history.slice(2 * k);
258
+ }
259
+
260
+ runCodex(prompt, { timeoutMs = this.timeoutMs } = {}) {
261
+ return new Promise((resolve, reject) => {
262
+ const args = [...this.commandArgs, ...buildCodexArgs({ model: this.model, cwd: this.cwd })];
263
+ const child = spawn(this.command, args, { env: codexSubscriptionEnv(this.env), stdio: ['pipe', 'pipe', 'pipe'] });
264
+ let stdout = '';
265
+ let stderr = '';
266
+ let settled = false;
267
+ const finish = (fn, value) => {
268
+ if (settled) return;
269
+ settled = true;
270
+ clearTimeout(timer);
271
+ fn(value);
272
+ };
273
+ const timer = setTimeout(() => {
274
+ child.kill('SIGKILL');
275
+ finish(reject, new Error(`codex did not answer within ${Math.round(timeoutMs / 1000)} seconds`));
276
+ }, timeoutMs);
277
+ child.stdout.on('data', (chunk) => (stdout += chunk.toString()));
278
+ child.stderr.on('data', (chunk) => {
279
+ if (stderr.length < MAX_STDERR_CHARS) stderr += chunk.toString();
280
+ });
281
+ child.on('error', (err) => {
282
+ if (err.code === 'ENOENT') {
283
+ finish(
284
+ reject,
285
+ new Error('the Codex CLI was not found. Install it (npm install -g @openai/codex), then run "codex login" and choose ChatGPT')
286
+ );
287
+ } else {
288
+ finish(reject, new Error(`could not start codex: ${err.message}`));
289
+ }
290
+ });
291
+ child.on('close', (code) => {
292
+ const { text, error } = parseCodexOutput(stdout);
293
+ if (text && text.trim()) return finish(resolve, text);
294
+ if (error) return finish(reject, new Error(`codex: ${error}`));
295
+ const tail = stderr.trim().split('\n').slice(-3).join(' ').slice(0, 400);
296
+ return finish(
297
+ reject,
298
+ new Error(`codex exited with code ${code} and no reply${tail ? ` (${tail})` : ''}. Run "codex login" if you have not signed in`)
299
+ );
300
+ });
301
+ child.stdin.on('error', () => {});
302
+ child.stdin.end(prompt);
303
+ });
304
+ }
305
+ }
306
+
307
+ // `codex login status` exits 0 when signed in. Used by the options page's
308
+ // "Test connection" so a missing sign-in is explained before any tutor turn.
309
+ export function checkCodexLogin({ command = 'codex', commandArgs = [], env = process.env, timeoutMs = 15_000 } = {}) {
310
+ return new Promise((resolve) => {
311
+ let output = '';
312
+ let child;
313
+ try {
314
+ child = spawn(command, [...commandArgs, 'login', 'status'], { env: codexSubscriptionEnv(env), stdio: ['ignore', 'pipe', 'pipe'] });
315
+ } catch (err) {
316
+ resolve({ ok: false, message: err.message });
317
+ return;
318
+ }
319
+ const timer = setTimeout(() => {
320
+ child.kill('SIGKILL');
321
+ resolve({ ok: false, message: 'codex login status did not answer' });
322
+ }, timeoutMs);
323
+ child.stdout.on('data', (c) => (output += c.toString()));
324
+ child.stderr.on('data', (c) => (output += c.toString()));
325
+ child.on('error', (err) => {
326
+ clearTimeout(timer);
327
+ resolve({
328
+ ok: false,
329
+ message: err.code === 'ENOENT' ? 'the Codex CLI was not found (npm install -g @openai/codex)' : err.message,
330
+ });
331
+ });
332
+ child.on('close', (code) => {
333
+ clearTimeout(timer);
334
+ resolve({ ok: code === 0, message: output.trim().split('\n')[0] || (code === 0 ? 'signed in' : 'not signed in; run "codex login"') });
335
+ });
336
+ });
337
+ }