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,68 @@
1
+ // extension/shared/sanitize.js - makes untrusted text safe to print in the terminal.
2
+ //
3
+ // Several things the companion prints come from outside this process:
4
+ // messages typed into the in-page panel, and every field of a capture. The
5
+ // relay's /capture route is CORS `*` by design (see relay-server/server.js),
6
+ // so ANY website the student visits can post a capture whose problemTitle or
7
+ // trigger carries an escape sequence. Written to a terminal unchanged, that
8
+ // sequence can recolor or clear the screen, retitle the window, or (on some
9
+ // terminals) do worse. The relay strips control characters from panel
10
+ // messages too, but the companion must not depend on that: it is the one
11
+ // writing to the terminal.
12
+ //
13
+ // The rule matches relay-server/panel-broker.js's stripControlChars: every C0
14
+ // control except newline and tab, DEL, and every C1 control (U+0080 to U+009F,
15
+ // which some terminals treat as 8-bit escape introducers, e.g. U+009B as CSI).
16
+ //
17
+ // Invisible formatting characters are dropped too, because they make text
18
+ // read differently from what it is (the "Trojan Source" trick): the bidi
19
+ // embeddings, overrides and isolates (U+202A to U+202E, U+2066 to U+2069) and
20
+ // the bidi marks (U+200E, U+200F, U+061C) reorder what follows them, and the
21
+ // zero-width space, word joiner, invisible operators and BOM (U+200B, U+2060
22
+ // to U+2064, U+FEFF) hide inside a word. The line and paragraph separators
23
+ // (U+2028, U+2029) become a plain newline, which is what they mean.
24
+ // Deliberately kept: the zero-width joiner and non-joiner (U+200D, U+200C).
25
+ // Emoji sequences (a family, a skin tone on a profession, a flag) are glued
26
+ // together by U+200D, and Persian and several Indic scripts need U+200C to
27
+ // spell words correctly; neither reorders text, so neither can disguise it.
28
+
29
+ const CONTROL_CHARS_RE = /[\x00-\x08\x0b-\x1f\x7f-\x9f\u061c\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g;
30
+ const LINE_SEPARATORS_RE = /[\u2028\u2029]/g;
31
+
32
+ // Multi-line text (a typed message, a problem description): keeps newlines
33
+ // and tabs, drops every other control character.
34
+ export function stripControlChars(text) {
35
+ if (text === null || text === undefined) return '';
36
+ return String(text).replace(LINE_SEPARATORS_RE, '\n').replace(CONTROL_CHARS_RE, '');
37
+ }
38
+
39
+ // A value shown inside a one-line label (a problem title, a slug, a trigger):
40
+ // control characters dropped and any run of whitespace, newlines included,
41
+ // collapsed to one space, so it can neither inject escapes nor break the
42
+ // label across lines.
43
+ export function oneLine(text) {
44
+ return stripControlChars(text).replace(/\s+/g, ' ').trim();
45
+ }
46
+
47
+ // Normalizes a parsed capture in place of the raw one: every string field
48
+ // that reaches the terminal, the panel or the model's prompt label is made
49
+ // safe once, here, so no call site has to remember to. The student's code is
50
+ // left exactly as captured (it only goes to the model and is never printed).
51
+ export function sanitizeCapture(capture) {
52
+ if (!capture || typeof capture !== 'object' || Array.isArray(capture)) return {};
53
+ const out = { ...capture };
54
+ for (const field of ['problemTitle', 'problemSlug', 'trigger', 'language']) {
55
+ if (out[field] === undefined || out[field] === null) continue;
56
+ out[field] = oneLine(out[field]) || null;
57
+ }
58
+ if (out.problemDescription !== undefined && out.problemDescription !== null) {
59
+ out.problemDescription = stripControlChars(out.problemDescription);
60
+ }
61
+ if (Array.isArray(out.problemTags)) {
62
+ out.problemTags = out.problemTags.filter((tag) => typeof tag === 'string').map(oneLine).filter(Boolean);
63
+ }
64
+ // The relay assigns attemptSeq, but the log file is just JSON lines: never
65
+ // print a non-integer.
66
+ if (out.attemptSeq !== undefined && !Number.isInteger(out.attemptSeq)) out.attemptSeq = null;
67
+ return out;
68
+ }
@@ -0,0 +1,316 @@
1
+ // extension/shared/solved.js - which problems the student has solved, kept
2
+ // in the extension's own storage and never sent anywhere.
3
+ //
4
+ // Storage (storage.local):
5
+ // solvedProblems: { [slug]: { at: ISO time, count, manual? } }
6
+ // at when it was last accepted (or marked by hand)
7
+ // count how many accepted submissions were seen for it (0 when it was
8
+ // only marked by hand on the Progress tab)
9
+ // starredProblems: { [slug]: true } the Progress tab's stars
10
+ // activeProgressList: list id the list the student works on (see
11
+ // shared/roadmap.js LISTS); missing or unknown means NeetCode 150.
12
+ // Changing it never touches the solved problems.
13
+ //
14
+ // Messages the background worker answers through createSolvedService:
15
+ // from a content script on leetcode.com/problems/<slug>/...:
16
+ // { type: 'lc-tutor:accepted', submissionId, slug } LeetCode accepted a
17
+ // submission for the problem this tab shows (`slug`, read by the
18
+ // content script from the live address, since sender.url does not
19
+ // follow in-app moves; the sender must be a problem page in a tab).
20
+ // Answers { ok, recorded, card }.
21
+ // from the extension's own pages (the Progress tab):
22
+ // { type: 'lc-tutor:solved-set', slug, solved } mark or unmark by hand
23
+ // { type: 'lc-tutor:star-set', slug, starred }
24
+ // from either (the Progress tab's list selector, the card's list menu):
25
+ // { type: 'lc-tutor:set-list', list, slug? } work on another list. From
26
+ // a problem page (with the card's `slug`, as for lc-tutor:accepted)
27
+ // the answer carries that card again, built for the new list:
28
+ // { ok, card }.
29
+ // from a content script on a problem page (the Tutor panel's "Practice a
30
+ // skill" picker, with the page's `slug` as for lc-tutor:accepted):
31
+ // { type: 'lc-tutor:practice', slug, skill?, offset?, skip? } read only. The
32
+ // reference difficulty is that problem's in the bundled index (none
33
+ // for a problem it lacks), and that problem is never suggested.
34
+ // Answers { ok, skills, skill, reference, suggestions, offset, more }
35
+ // (see shared/practice.js); without `skill` only the list of skills
36
+ // matters. Nothing of it reaches the tutor.
37
+ //
38
+ // Pure JavaScript with no Node API (extension/shared.test.js checks that).
39
+
40
+ import { catalogEntry } from './catalog.js';
41
+ import { practiceSuggestions, referenceLevel, skillById, skillChoices } from './practice.js';
42
+ import { LISTS, NEETCODE_150, ROADMAPS, listById, problemInList, suggestAfterSolve } from './roadmap.js';
43
+
44
+ export const SOLVED_KEY = 'solvedProblems';
45
+ export const STARRED_KEY = 'starredProblems';
46
+ export const ACTIVE_LIST_KEY = 'activeProgressList';
47
+ export const SYNCED_AT_KEY = 'solvedSyncedAt';
48
+ // How long an automatic sync waits for the next one; the button ignores it.
49
+ export const SYNC_EVERY_MS = 12 * 60 * 60 * 1000;
50
+ const MAX_SYNC = 6000;
51
+
52
+ export function isListId(value) {
53
+ return typeof value === 'string' && Object.hasOwn(ROADMAPS, value);
54
+ }
55
+
56
+ // LeetCode's own slugs: lowercase letters and digits joined by single
57
+ // hyphens ("two-sum", "3sum", "powx-n").
58
+ const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
59
+ const MAX_SLUG = 120;
60
+
61
+ export function isSlug(value) {
62
+ return typeof value === 'string' && value.length <= MAX_SLUG && SLUG_RE.test(value);
63
+ }
64
+
65
+ // The problem slug of a leetcode.com problem page address, or null.
66
+ export function slugFromUrl(url) {
67
+ let parsed;
68
+ try {
69
+ parsed = new URL(url);
70
+ } catch {
71
+ return null;
72
+ }
73
+ if (parsed.protocol !== 'https:' || parsed.hostname !== 'leetcode.com') return null;
74
+ const match = /^\/problems\/([^/]+)/.exec(parsed.pathname);
75
+ return match && isSlug(match[1]) ? match[1] : null;
76
+ }
77
+
78
+ function validTime(value) {
79
+ return typeof value === 'string' && !Number.isNaN(Date.parse(value));
80
+ }
81
+
82
+ // Whatever storage held, as a clean record: unknown keys and malformed
83
+ // entries are dropped.
84
+ export function normalizeSolved(raw) {
85
+ const out = {};
86
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
87
+ for (const [slug, entry] of Object.entries(raw)) {
88
+ if (!isSlug(slug) || !entry || typeof entry !== 'object') continue;
89
+ const count = Number.isInteger(entry.count) && entry.count >= 0 ? entry.count : 0;
90
+ const clean = { at: validTime(entry.at) ? entry.at : new Date(0).toISOString(), count };
91
+ if (entry.manual === true) clean.manual = true;
92
+ else if (entry.synced === true) clean.synced = true;
93
+ out[slug] = clean;
94
+ }
95
+ return out;
96
+ }
97
+
98
+ export function normalizeStarred(raw) {
99
+ const out = {};
100
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
101
+ for (const [slug, on] of Object.entries(raw)) if (isSlug(slug) && on === true) out[slug] = true;
102
+ return out;
103
+ }
104
+
105
+ // An accepted submission: the count goes up and `at` moves to now. A problem
106
+ // marked by hand before counts as detected from now on.
107
+ export function recordAccepted(solved, slug, at) {
108
+ const before = solved[slug];
109
+ return { ...solved, [slug]: { at, count: (before ? before.count : 0) + 1 } };
110
+ }
111
+
112
+ // The Progress tab's status mark. Marking keeps a detected record as it is;
113
+ // unmarking removes the problem whatever made it solved.
114
+ export function markSolved(solved, slug, on, at) {
115
+ const next = { ...solved };
116
+ if (!on) delete next[slug];
117
+ else if (!next[slug]) next[slug] = { at, count: 0, manual: true };
118
+ return next;
119
+ }
120
+
121
+ // The problems LeetCode says the account solved, added to the list as
122
+ // "synced". Whatever is marked already stays as it is, and nothing is
123
+ // removed. Returns the new record and how many were added.
124
+ export function mergeSynced(solved, slugs, at) {
125
+ const next = { ...solved };
126
+ let added = 0;
127
+ for (const slug of slugs) {
128
+ if (next[slug]) continue;
129
+ next[slug] = { at, count: 0, synced: true };
130
+ added += 1;
131
+ }
132
+ return { solved: next, added };
133
+ }
134
+
135
+ export function solvedSlugs(solved) {
136
+ return Object.keys(solved);
137
+ }
138
+
139
+ function brief(problem) {
140
+ return problem ? { slug: problem.slug, title: problem.title, difficulty: problem.difficulty } : null;
141
+ }
142
+
143
+ // What the Tutor panel's "nice job" card shows after `slug` was accepted,
144
+ // built only from the active list, the catalog and the solved list:
145
+ // { roadmap, lists, solvedTitle, next,
146
+ // more: { topic, label, problem } | null, finished, skills }
147
+ // `lists` is every list the card's menu offers ({ id, title, size }), and
148
+ // `skills` the "Practice a skill" chips ({ id, label }, a fixed list, never
149
+ // the solved problem's own tags).
150
+ // `solvedTitle` is the catalog's title, or null for a problem no bundled
151
+ // list has (the panel then uses the page's own title). `more` is null for a
152
+ // problem the active list does not have (then the card is the agenda only),
153
+ // or when its topic has nothing left that `next` does not already suggest.
154
+ export function congratulation(solved, slug, roadmap = NEETCODE_150) {
155
+ const done = solvedSlugs(solved);
156
+ const suggestion = suggestAfterSolve(roadmap, done, slug, { count: 2 });
157
+ const next = suggestion.agenda[0] || null;
158
+ const sameTopic = suggestion.sameTopic;
159
+ const other = sameTopic.problems.find((p) => !next || p.slug !== next.slug) || null;
160
+ const entry = problemInList(roadmap, slug) || catalogEntry(slug);
161
+ const label = sameTopic.topic && `More ${sameTopic.topic.title} problems`;
162
+ return {
163
+ roadmap: suggestion.roadmap,
164
+ lists: LISTS.map((list) => ({ id: list.id, title: list.title, size: list.size })),
165
+ solvedTitle: entry ? entry.title : null,
166
+ next: brief(next),
167
+ more: sameTopic.topic && other ? { topic: sameTopic.topic, label, problem: brief(other) } : null,
168
+ finished: !next,
169
+ skills: skillChoices(),
170
+ };
171
+ }
172
+
173
+ const MAX_OFFSET = 5000;
174
+
175
+ // The "Practice a skill" answer for the problem page `slug`: the skills,
176
+ // and with a known `skill` the suggestions after `offset`.
177
+ export function practiceAnswer(solved, slug, skill, offset = 0, skip = []) {
178
+ const result = practiceSuggestions({ skill: skillById(skill), reference: referenceLevel(slug), solved, exclude: [slug, ...skip], offset });
179
+ return { ok: true, skills: skillChoices(), ...result };
180
+ }
181
+
182
+ // The background worker's side. `storage` is { get(keys), set(items) };
183
+ // every write goes through one queue so two quick events never lose one
184
+ // another's change.
185
+ export function createSolvedService({ storage, extensionOrigin, now = () => new Date().toISOString(), log = () => {} }) {
186
+ let chain = Promise.resolve();
187
+ function update(fn) {
188
+ const run = chain.then(async () => {
189
+ const stored = (await storage.get([SOLVED_KEY, STARRED_KEY, ACTIVE_LIST_KEY])) || {};
190
+ return fn(normalizeSolved(stored[SOLVED_KEY]), normalizeStarred(stored[STARRED_KEY]), listById(stored[ACTIVE_LIST_KEY]));
191
+ });
192
+ chain = run.catch(() => {});
193
+ return run;
194
+ }
195
+
196
+ // The same submission is reported once: LeetCode may poll its result
197
+ // again, a page may be reloaded mid-poll, and a worker may restart.
198
+ const seen = [];
199
+ const MAX_SEEN = 200;
200
+
201
+ function isExtensionPage(sender) {
202
+ return Boolean(sender && typeof sender.url === 'string' && sender.url.startsWith(`${extensionOrigin}/`));
203
+ }
204
+
205
+ // The problem a message from a problem tab is about. The sender must be a
206
+ // leetcode.com problem page in a tab, but its address (sender.url) is where
207
+ // the document was loaded: LeetCode moves between problems in the page
208
+ // (history.pushState) and sender.url does not follow, so the content
209
+ // script names the problem from the page's live address.
210
+ function tabSlug(message, sender) {
211
+ if (!sender || !sender.tab || !slugFromUrl(sender.url)) return null;
212
+ return isSlug(message.slug) ? message.slug : null;
213
+ }
214
+
215
+ // content.js sends this only when the submission's first check started on
216
+ // the problem the page shows now (see inject.js), so a verdict that arrives
217
+ // after a move never marks the next problem solved.
218
+ async function accepted(message, sender) {
219
+ const slug = tabSlug(message, sender);
220
+ const id = message.submissionId;
221
+ if (!slug || typeof id !== 'string' || !/^\d{1,20}$/.test(id)) return { ok: false };
222
+ const key = `${slug}:${id}`;
223
+ if (seen.includes(key)) return { ok: true, recorded: false, card: null };
224
+ seen.push(key);
225
+ if (seen.length > MAX_SEEN) seen.shift();
226
+ return update(async (solved, _starred, list) => {
227
+ const next = recordAccepted(solved, slug, now());
228
+ await storage.set({ [SOLVED_KEY]: next });
229
+ return { ok: true, recorded: true, card: congratulation(next, slug, list) };
230
+ });
231
+ }
232
+
233
+ // Only the list choice is written; the solved problems stay as they are.
234
+ async function setList(message, sender) {
235
+ const page = isExtensionPage(sender);
236
+ const slug = page ? null : tabSlug(message, sender);
237
+ if (!isListId(message.list) || (!page && !slug)) return { ok: false };
238
+ return update(async (solved) => {
239
+ await storage.set({ [ACTIVE_LIST_KEY]: message.list });
240
+ return slug ? { ok: true, card: congratulation(solved, slug, listById(message.list)) } : { ok: true };
241
+ });
242
+ }
243
+
244
+ // Read only: the solved problems as stored now (after any queued write).
245
+ async function practice(message, sender) {
246
+ const slug = tabSlug(message, sender);
247
+ const offset = message.offset === undefined ? 0 : message.offset;
248
+ const skill = message.skill === undefined || message.skill === null ? null : message.skill;
249
+ if (!slug || !Number.isInteger(offset) || offset < 0 || offset > MAX_OFFSET) return { ok: false };
250
+ if (skill !== null && !skillById(skill)) return { ok: false };
251
+ // Problems the asking card already shows, left out so none appears twice.
252
+ const skip = message.skip === undefined ? [] : message.skip;
253
+ if (!Array.isArray(skip) || skip.length > 4 || !skip.every(isSlug)) return { ok: false };
254
+ return update(async (solved) => practiceAnswer(solved, slug, skill, offset, skip));
255
+ }
256
+
257
+ async function setSolved(message) {
258
+ if (!isSlug(message.slug) || typeof message.solved !== 'boolean') return { ok: false };
259
+ return update(async (solved) => {
260
+ await storage.set({ [SOLVED_KEY]: markSolved(solved, message.slug, message.solved, now()) });
261
+ return { ok: true };
262
+ });
263
+ }
264
+
265
+ // True when an automatic sync is due: never synced, or not for SYNC_EVERY_MS.
266
+ async function syncDue() {
267
+ const stored = (await storage.get([SYNCED_AT_KEY])) || {};
268
+ const last = Date.parse(stored[SYNCED_AT_KEY]);
269
+ return { ok: true, due: Number.isNaN(last) || Date.parse(now()) - last >= SYNC_EVERY_MS };
270
+ }
271
+
272
+ // `slugs` is what a signed-in LeetCode tab reported as accepted.
273
+ async function mergeFromLeetCode(slugs) {
274
+ if (!Array.isArray(slugs) || slugs.length > MAX_SYNC || !slugs.every(isSlug)) return { ok: false };
275
+ return update(async (solved) => {
276
+ const merged = mergeSynced(solved, slugs, now());
277
+ await storage.set({ [SOLVED_KEY]: merged.solved, [SYNCED_AT_KEY]: now() });
278
+ return { ok: true, added: merged.added, total: new Set(slugs).size };
279
+ });
280
+ }
281
+
282
+ async function setStarred(message) {
283
+ if (!isSlug(message.slug) || typeof message.starred !== 'boolean') return { ok: false };
284
+ return update(async (_solved, starred) => {
285
+ const next = { ...starred };
286
+ if (message.starred) next[message.slug] = true;
287
+ else delete next[message.slug];
288
+ await storage.set({ [STARRED_KEY]: next });
289
+ return { ok: true };
290
+ });
291
+ }
292
+
293
+ return {
294
+ // Resolves to the answer, or undefined when the message is not for this
295
+ // service.
296
+ mergeFromLeetCode,
297
+ async handleMessage(message, sender) {
298
+ if (!message || typeof message !== 'object') return undefined;
299
+ try {
300
+ if (message.type === 'lc-tutor:sync-check') return sender && sender.tab && slugFromUrl(sender.url) ? await syncDue() : { ok: false };
301
+ if (message.type === 'lc-tutor:sync-solved') return sender && sender.tab && slugFromUrl(sender.url) ? await mergeFromLeetCode(message.slugs) : { ok: false };
302
+ if (message.type === 'lc-tutor:accepted') return await accepted(message, sender);
303
+ if (message.type === 'lc-tutor:set-list') return await setList(message, sender);
304
+ if (message.type === 'lc-tutor:practice') return await practice(message, sender);
305
+ if (message.type === 'lc-tutor:solved-set' || message.type === 'lc-tutor:star-set') {
306
+ if (!isExtensionPage(sender)) return { ok: false };
307
+ return message.type === 'lc-tutor:solved-set' ? await setSolved(message) : await setStarred(message);
308
+ }
309
+ } catch (err) {
310
+ log(`could not update solved problems: ${err && err.message}`);
311
+ return { ok: false };
312
+ }
313
+ return undefined;
314
+ },
315
+ };
316
+ }