leetcode-capture-helper 0.1.0 → 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.
package/config.js CHANGED
@@ -22,4 +22,4 @@ export const CHROME_EXTENSION_IDS = [];
22
22
 
23
23
  export const CHROME_ID_RE = /^[a-p]{32}$/;
24
24
 
25
- export const VERSION = '0.1.0';
25
+ export const VERSION = '0.1.1';
package/host.js CHANGED
@@ -45,6 +45,7 @@ export const DEFAULTS = {
45
45
  relayIdleExitMs: 5 * 60 * 1000,
46
46
  stopTimeoutMs: 3000,
47
47
  startTimeoutMs: 5000,
48
+ reviveMs: 10 * 1000,
48
49
  };
49
50
 
50
51
  // Where the companion and relay live: LEETCODE_CAPTURE_ROOT, else the `root`
@@ -107,6 +108,7 @@ export function createHost({
107
108
  relayIdleExitMs = DEFAULTS.relayIdleExitMs,
108
109
  stopTimeoutMs = DEFAULTS.stopTimeoutMs,
109
110
  startTimeoutMs = DEFAULTS.startTimeoutMs,
111
+ reviveMs = DEFAULTS.reviveMs,
110
112
  rootError = null,
111
113
  }) {
112
114
  const port = env.CAPTURE_PORT ? Number(env.CAPTURE_PORT) : 8135;
@@ -118,6 +120,18 @@ export function createHost({
118
120
  let starting = null;
119
121
  let shuttingDown = null;
120
122
  let lastHeard = now();
123
+ let wantRunning = false;
124
+ let lastRevive = 0;
125
+
126
+ // One line in helper.log for each thing that matters when a start goes wrong.
127
+ function note(text) {
128
+ if (typeof logFd !== 'number') return;
129
+ try {
130
+ fs.writeSync(logFd, `[host ${new Date(now()).toISOString()}] ${text}\n`);
131
+ } catch {
132
+ // no log then
133
+ }
134
+ }
121
135
 
122
136
  function send(message) {
123
137
  try {
@@ -154,6 +168,36 @@ export function createHost({
154
168
  }
155
169
  }
156
170
 
171
+ // Whether a tutor (the companion) is attached to the relay: true, false, or
172
+ // null when that cannot be told. The relay's panel stream opens with a
173
+ // status event that says so.
174
+ async function companionOnline() {
175
+ const controller = new AbortController();
176
+ const timer = timers.setTimeout(() => controller.abort(), 1500);
177
+ try {
178
+ const response = await fetchImpl(`${base}/panel/events?after=0`, { signal: controller.signal });
179
+ if (!response || !response.body || typeof response.body.getReader !== 'function') return null;
180
+ const reader = response.body.getReader();
181
+ const decoder = new TextDecoder();
182
+ let text = '';
183
+ for (;;) {
184
+ // eslint-disable-next-line no-await-in-loop
185
+ const { done, value } = await reader.read();
186
+ if (done) return null;
187
+ text += decoder.decode(value, { stream: true });
188
+ const match = /"companionOnline":(true|false)/.exec(text);
189
+ if (match) {
190
+ controller.abort();
191
+ return match[1] === 'true';
192
+ }
193
+ }
194
+ } catch {
195
+ return null;
196
+ } finally {
197
+ timers.clearTimeout(timer);
198
+ }
199
+ }
200
+
157
201
  function startChild(script, extraEnv) {
158
202
  const child = spawn(execPath, [path.basename(script)], {
159
203
  cwd: path.join(root, path.dirname(script)),
@@ -167,8 +211,10 @@ export function createHost({
167
211
  if (child.stdin) child.stdin.on('error', () => {});
168
212
  child.on('error', (err) => {
169
213
  lastError = `could not start ${script}: ${err.message}`;
214
+ note(lastError);
170
215
  });
171
- child.on('exit', () => {
216
+ child.on('exit', (code, signal) => {
217
+ note(`${script} exited (code ${code}, signal ${signal})`);
172
218
  if (!shuttingDown) send(status());
173
219
  });
174
220
  return child;
@@ -180,6 +226,15 @@ export function createHost({
180
226
  relayExternal = false;
181
227
  if (await relayUp()) {
182
228
  relayExternal = true;
229
+ note('a relay already answers on this port; leaving it alone');
230
+ // A relay somebody else started may have no tutor attached: then the
231
+ // extension's Settings page stays locked. Start one unless it is there.
232
+ if (!alive(companion) && (await companionOnline()) === false) {
233
+ note('no tutor is attached to that relay; starting the companion');
234
+ companion = startChild('companion/companion.js', { COMPANION_HEADLESS: '1' });
235
+ }
236
+ lastError = null;
237
+ send(status());
183
238
  return;
184
239
  }
185
240
  relay = startChild('relay-server/server.js', {
@@ -196,12 +251,14 @@ export function createHost({
196
251
  }
197
252
  if (!up) {
198
253
  lastError = `the relay did not start on ${base}`;
254
+ note(lastError);
199
255
  return;
200
256
  }
201
257
  }
202
258
  if (!alive(companion)) {
203
259
  companion = startChild('companion/companion.js', { COMPANION_HEADLESS: '1' });
204
260
  }
261
+ note(`started: relay pid ${relay && relay.pid}, companion pid ${companion && companion.pid}`);
205
262
  lastError = null;
206
263
  send(status());
207
264
  }
@@ -265,6 +322,8 @@ export function createHost({
265
322
  send(status(id));
266
323
  return;
267
324
  case 'start':
325
+ wantRunning = true;
326
+ note('start requested');
268
327
  await ensureStarted();
269
328
  send(status(id));
270
329
  return;
@@ -294,8 +353,23 @@ export function createHost({
294
353
  if (alive(relay)) {
295
354
  fetchImpl(`${base}/helper/keepalive`, { method: 'POST', headers: { 'x-lc-companion': '1' } }).catch(() => {});
296
355
  }
356
+ revive();
297
357
  }, Math.min(keepaliveMs, idleMs));
298
358
 
359
+ // Once asked to start, notice a relay or tutor that has gone away (a relay
360
+ // that was somebody else's and stopped, a child that crashed) and bring it
361
+ // back, at most every reviveMs.
362
+ async function revive() {
363
+ if (!wantRunning || starting || shuttingDown || rootError || now() - lastRevive < reviveMs) return;
364
+ const relayGone = relayExternal ? !(await relayUp()) : !alive(relay);
365
+ const companionGone = !relayExternal && !alive(companion);
366
+ if (relayGone || companionGone) {
367
+ lastRevive = now();
368
+ note('something went away; starting it again');
369
+ await ensureStarted().catch(() => {});
370
+ }
371
+ }
372
+
299
373
  const decoder = createDecoder({
300
374
  onMessage: (message) => {
301
375
  handle(message).catch((err) => send({ type: 'error', id: message && message.id, error: err.message }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leetcode-capture-helper",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Native Messaging helper for LeetCode Capture: lets the browser extension start and stop the local companion and relay for the Claude and ChatGPT subscriptions. The package carries its own copy of the companion and relay.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0",
2
+ "version": "0.1.1",
3
3
  "note": "Built by scripts/pack.mjs from the LeetCode Capture repository. Do not edit.",
4
4
  "files": [
5
5
  "companion/capture-log.js",
@@ -24,8 +24,7 @@
24
24
  // built-in fetch. "openai" is the same backend against OpenAI's API.
25
25
  // - "chatgpt": the Codex CLI on a ChatGPT sign-in (codex-backend.js).
26
26
  //
27
- // See the "Companion" section of the top-level README.md for setup,
28
- // environment variables, and exactly what credentials each backend needs.
27
+ // Setup: the environment variables, and exactly what credentials each backend needs.
29
28
 
30
29
  import fs from 'node:fs';
31
30
  import os from 'node:os';
@@ -202,7 +201,7 @@ const LOCAL_RESERVE_TOKENS = Number(process.env.COMPANION_LOCAL_RESERVE_TOKENS |
202
201
  // The other options also come from settings.js (`effective.options`):
203
202
  // - vaultAutoSummary (VAULT_AUTO_SUMMARY), off by default: a portable
204
203
  // checkout won't have this vault's Study/Algorithms structure, or even
205
- // use Obsidian. See "Vault auto-summary" below and README.md.
204
+ // use Obsidian.
206
205
  // - autoClearContext (COMPANION_AUTO_CLEAR_CONTEXT), on by default: see the
207
206
  // "automatic context reset on problem switch" section below for why
208
207
  // leaving it on is the safer default (a stale problem's code and
@@ -467,9 +466,8 @@ function backendLabel() {
467
466
  // it exits - one command instead of two. If a server was already running
468
467
  // (started separately, or left over from another companion instance), this
469
468
  // instance leaves it alone on both ends: it doesn't spawn a second one, and
470
- // it doesn't stop it on exit. See the "Companion" section of the top-level
471
- // README.md for the durability tradeoff this implies (captures during a gap
472
- // where companion.js isn't running are no longer durably logged).
469
+ // it doesn't stop it on exit. The tradeoff: captures during a gap
470
+ // where companion.js isn't running are no longer durably logged.
473
471
 
474
472
  // Set only if *this* process spawned the relay server; stays null if an
475
473
  // already-running server was found instead, so the exit handler below knows
@@ -678,8 +676,7 @@ function resetTutorContext() {
678
676
  // session progresses. The actual logic (building the addendum, parsing the
679
677
  // model's reply, writing the notes) lives in vault-summary.js, split out so
680
678
  // it can be unit-tested without booting this file's readline loop / relay
681
- // server lifecycle - see that file's header for the full design, and
682
- // README.md → "Vault auto-summary" for user-facing docs.
679
+ // server lifecycle - see that file's header for the full design.
683
680
 
684
681
  // --- terminal chat loop ------------------------------------------------------
685
682
  //
@@ -3,9 +3,8 @@
3
3
  // Split out from companion.js so this feature's actual logic (building the
4
4
  // addendum, parsing the model's reply, writing the notes) can be exercised
5
5
  // directly by tests without booting companion.js's interactive readline
6
- // loop or relay-server lifecycle. See README.md → "Vault auto-summary" for
7
- // the full design and companion.js for how this gets wired into the capture
8
- // handling path.
6
+ // loop or relay-server lifecycle. See companion.js for how this gets wired
7
+ // into the capture handling path.
9
8
  //
10
9
  // The core constraint shaping this file: reuse the same backend response
11
10
  // already being shown in the terminal for a Submit, and never make a
@@ -1,7 +1,7 @@
1
1
  // extension/shared/code-ledger.js - what code the tutor has already been shown, and
2
2
  // what to tell it next.
3
3
  //
4
- // The rules (Thomson's request, see README.md, "What the tutor is sent"):
4
+ // The rules (Thomson's request):
5
5
  // - A Run (the button or Ctrl+') is never sent to the tutor on its own. It
6
6
  // is only recorded, as the newest code for its problem.
7
7
  // - A Submit is sent automatically, but only when its code differs from
@@ -2,7 +2,7 @@
2
2
  // plus the pure logic they share. No Node or browser APIs, so the options
3
3
  // page, the background worker and the companion can all import it.
4
4
  //
5
- // A list is { id, title, short, description, kind, topics, edges, subjects }:
5
+ // A list is { id, title, short, kind, topics, edges, subjects }:
6
6
  // topics its groups in the publisher's order, each { id, title, problems }
7
7
  // where a problem is { slug, title, difficulty, topic } (title and
8
8
  // difficulty come from the one shared catalog, shared/catalog.js)
@@ -24,7 +24,7 @@ import { CATALOG } from './catalog.js';
24
24
  const words = (slugs) => slugs.split(/\s+/).filter(Boolean);
25
25
 
26
26
  // Each group: [id, title, slugs], the slugs one per line in the list's order.
27
- function defineList({ id, title, short, description, groupNoun, groups, edges = [], subjects = null }) {
27
+ function defineList({ id, title, short, groupNoun, groups, edges = [], subjects = null }) {
28
28
  const topics = groups.map(([topicId, topicTitle, slugs]) => ({
29
29
  id: topicId,
30
30
  title: topicTitle,
@@ -35,7 +35,7 @@ function defineList({ id, title, short, description, groupNoun, groups, edges =
35
35
  }),
36
36
  }));
37
37
  const size = topics.reduce((n, t) => n + t.problems.length, 0);
38
- const list = { id, title, short: short || title, description, groupNoun, kind: edges.length ? 'graph' : 'groups', size, topics, edges, subjects: null };
38
+ const list = { id, title, short: short || title, groupNoun, kind: edges.length ? 'graph' : 'groups', size, topics, edges, subjects: null };
39
39
  if (subjects) list.subjects = defineSubjects(list, subjects);
40
40
  return list;
41
41
  }
@@ -94,7 +94,6 @@ const EDGES = [
94
94
  export const NEETCODE_150 = defineList({
95
95
  id: 'neetcode-150',
96
96
  title: 'NeetCode 150',
97
- description: 'NeetCode\'s expanded Blind 75, by topic on a roadmap.',
98
97
  groupNoun: 'topic',
99
98
  edges: EDGES,
100
99
  groups: [
@@ -279,7 +278,6 @@ export const NEETCODE_150 = defineList({
279
278
  export const NEETCODE_250 = defineList({
280
279
  id: 'neetcode-250',
281
280
  title: 'NeetCode 250',
282
- description: 'NeetCode 150 and 100 more, by topic on the same roadmap.',
283
281
  groupNoun: 'topic',
284
282
  edges: EDGES,
285
283
  groups: [
@@ -566,7 +564,6 @@ export const NEETCODE_250 = defineList({
566
564
  export const BLIND_75 = defineList({
567
565
  id: 'blind-75',
568
566
  title: 'Blind 75',
569
- description: 'The original 75 from Blind, by category. NeetCode 150 extends it.',
570
567
  groupNoun: 'category',
571
568
  groups: [
572
569
  ['array', 'Array', `
@@ -672,7 +669,6 @@ export const BLIND_75 = defineList({
672
669
  export const GRIND_75 = defineList({
673
670
  id: 'grind-75',
674
671
  title: 'Grind 75',
675
- description: 'By the Blind 75 author: the site\'s default eight weeks, or by subject.',
676
672
  groupNoun: 'week',
677
673
  groups: [
678
674
  ['week-1', 'Week 1', `
@@ -872,8 +868,7 @@ export const GRIND_75 = defineList({
872
868
  // against e2e/leetcode-study-plans.txt by roadmap.test.mjs.
873
869
  export const TOP_100_LIKED = defineList({
874
870
  id: 'top-100-liked',
875
- title: 'Top 100 Liked',
876
- description: 'LeetCode\'s 100 most liked problems, by topic.',
871
+ title: 'Top 100 Liked by LeetCode',
877
872
  groupNoun: 'topic',
878
873
  groups: [
879
874
  ['backtracking', 'Backtracking', `
@@ -999,8 +994,7 @@ export const TOP_100_LIKED = defineList({
999
994
  // 2026-11-04: e2e/leetcode-study-plans.txt, which roadmap.test.mjs compares.
1000
995
  export const TOP_INTERVIEW_150 = defineList({
1001
996
  id: 'top-interview-150',
1002
- title: 'Top Interview 150',
1003
- description: 'LeetCode\'s own interview study plan, section by section.',
997
+ title: 'Top Interview 150 by LeetCode',
1004
998
  groupNoun: 'section',
1005
999
  groups: [
1006
1000
  ['array-string', 'Array / String', `
@@ -1184,7 +1178,6 @@ export const TOP_INTERVIEW_150 = defineList({
1184
1178
  export const SQL_50 = defineList({
1185
1179
  id: 'sql-50',
1186
1180
  title: 'SQL 50',
1187
- description: 'LeetCode\'s study plan for database problems.',
1188
1181
  groupNoun: 'section',
1189
1182
  groups: [
1190
1183
  ['select', 'Select', `
@@ -44,6 +44,10 @@ import { LISTS, NEETCODE_150, ROADMAPS, listById, problemInList, suggestAfterSol
44
44
  export const SOLVED_KEY = 'solvedProblems';
45
45
  export const STARRED_KEY = 'starredProblems';
46
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;
47
51
 
48
52
  export function isListId(value) {
49
53
  return typeof value === 'string' && Object.hasOwn(ROADMAPS, value);
@@ -85,6 +89,7 @@ export function normalizeSolved(raw) {
85
89
  const count = Number.isInteger(entry.count) && entry.count >= 0 ? entry.count : 0;
86
90
  const clean = { at: validTime(entry.at) ? entry.at : new Date(0).toISOString(), count };
87
91
  if (entry.manual === true) clean.manual = true;
92
+ else if (entry.synced === true) clean.synced = true;
88
93
  out[slug] = clean;
89
94
  }
90
95
  return out;
@@ -113,6 +118,20 @@ export function markSolved(solved, slug, on, at) {
113
118
  return next;
114
119
  }
115
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
+
116
135
  export function solvedSlugs(solved) {
117
136
  return Object.keys(solved);
118
137
  }
@@ -243,6 +262,23 @@ export function createSolvedService({ storage, extensionOrigin, now = () => new
243
262
  });
244
263
  }
245
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
+
246
282
  async function setStarred(message) {
247
283
  if (!isSlug(message.slug) || typeof message.starred !== 'boolean') return { ok: false };
248
284
  return update(async (_solved, starred) => {
@@ -257,9 +293,12 @@ export function createSolvedService({ storage, extensionOrigin, now = () => new
257
293
  return {
258
294
  // Resolves to the answer, or undefined when the message is not for this
259
295
  // service.
296
+ mergeFromLeetCode,
260
297
  async handleMessage(message, sender) {
261
298
  if (!message || typeof message !== 'object') return undefined;
262
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 };
263
302
  if (message.type === 'lc-tutor:accepted') return await accepted(message, sender);
264
303
  if (message.type === 'lc-tutor:set-list') return await setList(message, sender);
265
304
  if (message.type === 'lc-tutor:practice') return await practice(message, sender);
@@ -5,7 +5,7 @@
5
5
  // a per-problem monotonic attempt sequence number.
6
6
  //
7
7
  // Log file location: ./data/captures.jsonl (relative to this file), unless
8
- // overridden with the CAPTURE_LOG_PATH environment variable. See README.md.
8
+ // overridden with the CAPTURE_LOG_PATH environment variable.
9
9
  // The log is cleared as the companion consumes it (see trimCaptureLog).
10
10
 
11
11
  const http = require("http");