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,241 @@
1
+ // LeetCode Capture - local relay server
2
+ //
3
+ // Listens on localhost only. Accepts POSTs from the browser extension and
4
+ // durably appends each capture as one JSON line to a local log file, adding
5
+ // a per-problem monotonic attempt sequence number.
6
+ //
7
+ // Log file location: ./data/captures.jsonl (relative to this file), unless
8
+ // overridden with the CAPTURE_LOG_PATH environment variable.
9
+ // The log is cleared as the companion consumes it (see trimCaptureLog).
10
+
11
+ const http = require("http");
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+ const { createPanelBroker, companionOnlyRejection } = require("./panel-broker");
15
+
16
+ const HOST = "127.0.0.1";
17
+ const PORT = process.env.CAPTURE_PORT ? Number(process.env.CAPTURE_PORT) : 8135;
18
+ const LOG_PATH = process.env.CAPTURE_LOG_PATH ||
19
+ path.join(__dirname, "data", "captures.jsonl");
20
+
21
+ const MAX_BODY_BYTES = 5 * 1024 * 1024; // 5MB, generous for a single file's code
22
+
23
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
24
+
25
+ // Per-problem attempt sequence numbers, rebuilt from the existing log on
26
+ // startup so restarts don't reset the counter.
27
+ const attemptSeq = new Map();
28
+
29
+ function loadExistingSequences() {
30
+ if (!fs.existsSync(LOG_PATH)) return;
31
+ const lines = fs.readFileSync(LOG_PATH, "utf8").split("\n").filter(Boolean);
32
+ for (const line of lines) {
33
+ try {
34
+ const entry = JSON.parse(line);
35
+ const key = entry.problemSlug || "unknown";
36
+ const current = attemptSeq.get(key) || 0;
37
+ if (entry.attemptSeq > current) {
38
+ attemptSeq.set(key, entry.attemptSeq);
39
+ }
40
+ } catch {
41
+ // Skip malformed lines rather than fail startup.
42
+ }
43
+ }
44
+ }
45
+
46
+ function nextAttemptSeq(problemSlug) {
47
+ const key = problemSlug || "unknown";
48
+ const next = (attemptSeq.get(key) || 0) + 1;
49
+ attemptSeq.set(key, next);
50
+ return next;
51
+ }
52
+
53
+ function isValidCapture(body) {
54
+ return (
55
+ body &&
56
+ typeof body === "object" &&
57
+ typeof body.problemSlug === "string" &&
58
+ typeof body.language === "string" &&
59
+ typeof body.code === "string" &&
60
+ (body.trigger === "run" || body.trigger === "submit") &&
61
+ typeof body.timestamp === "string"
62
+ );
63
+ }
64
+
65
+ function appendCapture(body) {
66
+ const entry = {
67
+ receivedAt: new Date().toISOString(),
68
+ problemSlug: body.problemSlug,
69
+ problemTitle: body.problemTitle || null,
70
+ problemDescription: body.problemDescription || null,
71
+ problemTags: Array.isArray(body.problemTags) ? body.problemTags : [],
72
+ language: body.language,
73
+ trigger: body.trigger,
74
+ timestamp: body.timestamp,
75
+ url: body.url || null,
76
+ code: body.code,
77
+ attemptSeq: nextAttemptSeq(body.problemSlug),
78
+ };
79
+ fs.appendFileSync(LOG_PATH, JSON.stringify(entry) + "\n");
80
+ return entry;
81
+ }
82
+
83
+ // The log is only a short-lived delivery queue from this relay to the
84
+ // companion, which tails it by byte offset. Once the companion has read a
85
+ // prefix of it (and moved on from that problem, or just started), it asks for
86
+ // that prefix to be dropped (the companion-only POST /panel/trim-captures,
87
+ // see panel-broker.js). `before` is the companion's own read offset, so only
88
+ // lines it already handled are dropped. Everything here is synchronous, like
89
+ // appendCapture, so a capture that arrives meanwhile is appended wholly
90
+ // before or wholly after the trim and is never lost or doubled; the rest of
91
+ // the file is written to a temporary file and renamed over the log, so a
92
+ // crash leaves either the old log or the new one. A future setting that
93
+ // keeps old captures (for studying later) would copy the dropped prefix
94
+ // somewhere first, here.
95
+ // Returns { removed, size }, or { error } for a request that cannot be met.
96
+ function trimCaptureLog(before) {
97
+ let size;
98
+ try {
99
+ size = fs.statSync(LOG_PATH).size;
100
+ } catch (err) {
101
+ if (err.code !== "ENOENT") throw err;
102
+ size = 0;
103
+ }
104
+ if (before > size) return { error: `the log is only ${size} bytes` };
105
+ if (before === 0) return { removed: 0, size };
106
+ const fd = fs.openSync(LOG_PATH, "r");
107
+ let rest;
108
+ try {
109
+ const last = Buffer.alloc(1);
110
+ fs.readSync(fd, last, 0, 1, before - 1);
111
+ if (last[0] !== 0x0a) return { error: "that offset is not at the end of a line" };
112
+ rest = Buffer.alloc(size - before);
113
+ fs.readSync(fd, rest, 0, rest.length, before);
114
+ } finally {
115
+ fs.closeSync(fd);
116
+ }
117
+ const tmp = `${LOG_PATH}.trim-tmp`;
118
+ fs.writeFileSync(tmp, rest);
119
+ fs.renameSync(tmp, LOG_PATH);
120
+ return { removed: before, size: rest.length };
121
+ }
122
+
123
+ // CORS headers, sent on every response to /capture (including the OPTIONS
124
+ // preflight). Chrome's content-script fetch() inherits the extension's
125
+ // host_permissions grant and skips CORS entirely, but Firefox/Zen run
126
+ // content-script fetch() as the page's own origin (https://leetcode.com),
127
+ // so a cross-origin POST with a JSON body triggers a real preflight. This
128
+ // server only binds to 127.0.0.1 and only ingests capture data (no
129
+ // secrets, no destructive actions), so a permissive `*` origin is fine -
130
+ // no need for an origin allowlist on a localhost-only dev tool.
131
+ const CORS_HEADERS = {
132
+ "Access-Control-Allow-Origin": "*",
133
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
134
+ "Access-Control-Allow-Headers": "Content-Type",
135
+ };
136
+
137
+ // The in-page tutor panel's broker (see panel-broker.js). Its routes do their
138
+ // own, much stricter origin checks, so they run before the permissive
139
+ // /capture handling below.
140
+ const panelBroker = createPanelBroker({ port: PORT, captureLog: { path: LOG_PATH, trim: trimCaptureLog } });
141
+
142
+ // Run by the Native Messaging helper (helper/host.js), the relay quits on its
143
+ // own when nothing has been heard for RELAY_IDLE_EXIT_MS, so a browser or
144
+ // helper that vanished cannot leave it holding the port. Heard means a
145
+ // request from anything but the companion (a panel, a capture, the options
146
+ // page), an open panel stream, or the helper's POST /helper/keepalive, which
147
+ // the helper sends while the extension keeps talking to it. Off (0) unless
148
+ // set, so a relay started by hand runs until stopped.
149
+ const IDLE_EXIT_MS = Number(process.env.RELAY_IDLE_EXIT_MS) || 0;
150
+ let lastHeard = Date.now();
151
+
152
+ const server = http.createServer((req, res) => {
153
+ if (req.headers["x-lc-companion"] === undefined) lastHeard = Date.now();
154
+ if (req.method === "POST" && req.url === "/helper/keepalive") {
155
+ const rejection = companionOnlyRejection(req);
156
+ res.writeHead(rejection ? 403 : 204, { "Content-Type": "application/json" });
157
+ if (!rejection) lastHeard = Date.now();
158
+ res.end(rejection ? JSON.stringify({ error: rejection }) : undefined);
159
+ req.resume();
160
+ return;
161
+ }
162
+ if (panelBroker.handle(req, res)) return;
163
+
164
+ if (req.method === "OPTIONS" && req.url === "/capture") {
165
+ res.writeHead(204, CORS_HEADERS);
166
+ res.end();
167
+ return;
168
+ }
169
+
170
+ if (req.method !== "POST" || req.url !== "/capture") {
171
+ res.writeHead(404, { "Content-Type": "application/json", ...CORS_HEADERS });
172
+ res.end(JSON.stringify({ error: "not found" }));
173
+ return;
174
+ }
175
+
176
+ let received = 0;
177
+ const chunks = [];
178
+ let aborted = false;
179
+
180
+ req.on("data", (chunk) => {
181
+ received += chunk.length;
182
+ if (received > MAX_BODY_BYTES) {
183
+ aborted = true;
184
+ res.writeHead(413, { "Content-Type": "application/json", ...CORS_HEADERS });
185
+ res.end(JSON.stringify({ error: "payload too large" }));
186
+ req.destroy();
187
+ return;
188
+ }
189
+ chunks.push(chunk);
190
+ });
191
+
192
+ req.on("end", () => {
193
+ if (aborted) return;
194
+ let body;
195
+ try {
196
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
197
+ } catch {
198
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS_HEADERS });
199
+ res.end(JSON.stringify({ error: "invalid JSON" }));
200
+ return;
201
+ }
202
+
203
+ if (!isValidCapture(body)) {
204
+ res.writeHead(400, { "Content-Type": "application/json", ...CORS_HEADERS });
205
+ res.end(JSON.stringify({ error: "missing or invalid required fields" }));
206
+ return;
207
+ }
208
+
209
+ const entry = appendCapture(body);
210
+ res.writeHead(200, { "Content-Type": "application/json", ...CORS_HEADERS });
211
+ res.end(JSON.stringify({ ok: true, attemptSeq: entry.attemptSeq }));
212
+ });
213
+
214
+ req.on("error", () => {
215
+ // Client disconnected mid-request; nothing to do.
216
+ });
217
+ });
218
+
219
+ loadExistingSequences();
220
+
221
+ if (IDLE_EXIT_MS > 0) {
222
+ setInterval(() => {
223
+ if (panelBroker.openPanelStreams() > 0) lastHeard = Date.now();
224
+ if (Date.now() - lastHeard < IDLE_EXIT_MS) return;
225
+ console.log(`nothing heard for ${Math.round(IDLE_EXIT_MS / 1000)} s, exiting`);
226
+ process.exit(0);
227
+ }, Math.min(IDLE_EXIT_MS, 10000)).unref();
228
+ }
229
+
230
+ // Started by the helper with a pipe for stdin: the helper closing it (or
231
+ // dying, which closes it too) means stop.
232
+ if (/^(1|true|yes)$/i.test(process.env.RELAY_EXIT_WITH_STDIN || "")) {
233
+ process.stdin.on("end", () => process.exit(0));
234
+ process.stdin.on("error", () => process.exit(0));
235
+ process.stdin.resume();
236
+ }
237
+
238
+ server.listen(PORT, HOST, () => {
239
+ console.log(`leetcode-capture relay server listening on http://${HOST}:${PORT}`);
240
+ console.log(`logging captures to ${LOG_PATH}`);
241
+ });
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env node
2
+ // LeetCode Capture - vault-logging tool
3
+ //
4
+ // Manual, explicit v1: given a captured problem's log entries (from the
5
+ // relay server's captures.jsonl), produces a short struggle/proficiency
6
+ // note and appends it into Thomson's Obsidian vault.
7
+ //
8
+ // Usage:
9
+ // node log-session.js --slug <problem-slug> [options]
10
+ //
11
+ // Options:
12
+ // --log <path> Path to the captures.jsonl log file
13
+ // (default: ../relay-server/data/captures.jsonl)
14
+ // --vault <path> Path to the Obsidian vault root
15
+ // (default: VAULT_PATH env var, then vault.config.json's
16
+ // "vaultPath", then ~/Documents/My Brain - see
17
+ // vault-notes.js's resolveVaultConfig)
18
+ // --note <text> Freeform note text to include (optional)
19
+ // --accepted Mark the session as accepted (last Submit passed)
20
+ // --dry-run Print the note instead of writing it
21
+ //
22
+ // Matching: searches Study/Algorithms/*.md in the vault for a markdown
23
+ // link containing /problems/<slug>/. If found, the note is appended under
24
+ // that file's "## LeetCode Problems" section. Otherwise, a new per-problem
25
+ // note is created under Study/Algorithms/Problems/<Title>.md following the
26
+ // vault's existing note conventions (frontmatter tags, etc).
27
+
28
+ const fs = require("fs");
29
+ const path = require("path");
30
+ const {
31
+ safeFileName,
32
+ findTopicNoteByProblemLink,
33
+ resolveVaultConfig,
34
+ validateVaultPath,
35
+ } = require("./vault-notes");
36
+
37
+ function parseArgs(argv) {
38
+ const args = { dryRun: false, accepted: false };
39
+ for (let i = 0; i < argv.length; i++) {
40
+ const arg = argv[i];
41
+ if (arg === "--slug") args.slug = argv[++i];
42
+ else if (arg === "--log") args.log = argv[++i];
43
+ else if (arg === "--vault") args.vault = argv[++i];
44
+ else if (arg === "--note") args.note = argv[++i];
45
+ else if (arg === "--accepted") args.accepted = true;
46
+ else if (arg === "--dry-run") args.dryRun = true;
47
+ else {
48
+ console.error(`Unknown argument: ${arg}`);
49
+ process.exit(1);
50
+ }
51
+ }
52
+ return args;
53
+ }
54
+
55
+ function loadCaptures(logPath, slug) {
56
+ if (!fs.existsSync(logPath)) {
57
+ throw new Error(`log file not found: ${logPath}`);
58
+ }
59
+ const lines = fs.readFileSync(logPath, "utf8").split("\n").filter(Boolean);
60
+ const entries = [];
61
+ for (const line of lines) {
62
+ let entry;
63
+ try {
64
+ entry = JSON.parse(line);
65
+ } catch {
66
+ continue; // skip malformed lines
67
+ }
68
+ if (entry.problemSlug === slug) entries.push(entry);
69
+ }
70
+ entries.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
71
+ return entries;
72
+ }
73
+
74
+ function formatDuration(ms) {
75
+ if (ms < 0) ms = 0;
76
+ const totalSeconds = Math.round(ms / 1000);
77
+ const h = Math.floor(totalSeconds / 3600);
78
+ const m = Math.floor((totalSeconds % 3600) / 60);
79
+ const s = totalSeconds % 60;
80
+ const parts = [];
81
+ if (h > 0) parts.push(`${h}h`);
82
+ if (m > 0 || h > 0) parts.push(`${m}m`);
83
+ parts.push(`${s}s`);
84
+ return parts.join(" ");
85
+ }
86
+
87
+ function summarize(entries, accepted) {
88
+ if (entries.length === 0) {
89
+ throw new Error("no captures found for this problem slug");
90
+ }
91
+
92
+ const runs = entries.filter((e) => e.trigger === "run");
93
+ const submits = entries.filter((e) => e.trigger === "submit");
94
+ const firstRun = runs[0] || entries[0];
95
+ const lastSubmit = submits[submits.length - 1];
96
+
97
+ const elapsedMs = lastSubmit
98
+ ? new Date(lastSubmit.timestamp) - new Date(firstRun.timestamp)
99
+ : null;
100
+
101
+ // Not every capture necessarily carries a description (older captures
102
+ // predate this field, or the DOM panel wasn't found at capture time), so
103
+ // take the first one that has it.
104
+ const withDescription = entries.find((e) => e.problemDescription);
105
+
106
+ return {
107
+ problemSlug: entries[0].problemSlug,
108
+ problemTitle: entries[0].problemTitle || entries[0].problemSlug,
109
+ problemDescription: withDescription ? withDescription.problemDescription : null,
110
+ language: entries[entries.length - 1].language,
111
+ attemptCount: entries.length,
112
+ runCount: runs.length,
113
+ submitCount: submits.length,
114
+ elapsedMs,
115
+ accepted,
116
+ sessionDate: firstRun.timestamp.slice(0, 10),
117
+ };
118
+ }
119
+
120
+ function buildNoteBlock(summary, freeformNote) {
121
+ const elapsed = summary.elapsedMs !== null
122
+ ? formatDuration(summary.elapsedMs)
123
+ : "n/a (no Submit captured)";
124
+ const status = summary.submitCount === 0
125
+ ? "no submit captured"
126
+ : summary.accepted
127
+ ? "accepted"
128
+ : "not accepted";
129
+
130
+ const lines = [
131
+ `### Capture session: ${summary.sessionDate}`,
132
+ `- Problem: ${summary.problemTitle} (${summary.problemSlug})`,
133
+ `- Language: ${summary.language}`,
134
+ `- Attempts: ${summary.attemptCount} (${summary.runCount} run, ${summary.submitCount} submit)`,
135
+ `- Elapsed (first Run -> ${summary.submitCount ? "last Submit" : "n/a"}): ${elapsed}`,
136
+ `- Result: ${status}`,
137
+ ];
138
+ if (freeformNote) {
139
+ lines.push(`- Note: ${freeformNote}`);
140
+ }
141
+ let block = lines.join("\n") + "\n";
142
+ if (summary.problemDescription) {
143
+ block += `\n**Problem statement:**\n\n${summary.problemDescription}\n`;
144
+ }
145
+ return block;
146
+ }
147
+
148
+ function appendToTopicNote(filePath, noteBlock) {
149
+ let content = fs.readFileSync(filePath, "utf8");
150
+ const sectionHeader = "## LeetCode Problems";
151
+ const sectionIdx = content.indexOf(sectionHeader);
152
+
153
+ if (sectionIdx === -1) {
154
+ // No existing problems section; append at end of file.
155
+ content = content.replace(/\n*$/, "\n\n") + noteBlock;
156
+ } else {
157
+ // Insert a "Capture Sessions" subsection right after the
158
+ // "## LeetCode Problems" section, before the next "## " heading (or EOF).
159
+ const afterHeader = sectionIdx + sectionHeader.length;
160
+ const nextSectionMatch = content.slice(afterHeader).match(/\n## /);
161
+ const insertAt = nextSectionMatch
162
+ ? afterHeader + nextSectionMatch.index
163
+ : content.length;
164
+
165
+ const before = content.slice(0, insertAt).replace(/\n*$/, "\n");
166
+ const after = content.slice(insertAt);
167
+ content = `${before}\n${noteBlock}${after}`;
168
+ }
169
+
170
+ fs.writeFileSync(filePath, content);
171
+ }
172
+
173
+ function createPerProblemNote(vaultPath, summary, noteBlock, algorithmsSubfolder) {
174
+ const problemsDir = path.join(vaultPath, algorithmsSubfolder, "Problems");
175
+ fs.mkdirSync(problemsDir, { recursive: true });
176
+
177
+ const filePath = path.join(problemsDir, `${safeFileName(summary.problemTitle)}.md`);
178
+
179
+ const frontmatter = "---\ntags:\n - study/algorithms\n---\n\n";
180
+ const heading = `[${summary.problemTitle}](https://leetcode.com/problems/${summary.problemSlug}/)\n\n`;
181
+ const sectionHeader = "## LeetCode Problems\n\n";
182
+
183
+ let content;
184
+ if (fs.existsSync(filePath)) {
185
+ content = fs.readFileSync(filePath, "utf8");
186
+ content = content.replace(/\n*$/, "\n\n") + noteBlock;
187
+ } else {
188
+ content = frontmatter + heading + sectionHeader + noteBlock;
189
+ }
190
+
191
+ fs.writeFileSync(filePath, content);
192
+ return filePath;
193
+ }
194
+
195
+ function main() {
196
+ const args = parseArgs(process.argv.slice(2));
197
+ if (!args.slug) {
198
+ console.error("Usage: node log-session.js --slug <problem-slug> [--log <path>] [--vault <path>] [--note <text>] [--accepted] [--dry-run]");
199
+ process.exit(1);
200
+ }
201
+
202
+ const logPath = args.log || path.join(__dirname, "..", "relay-server", "data", "captures.jsonl");
203
+ const vaultConfig = resolveVaultConfig();
204
+ // --vault is an explicit per-invocation override, so it outranks both the
205
+ // env var and the config file resolveVaultConfig() already applied.
206
+ const vaultPath = args.vault || vaultConfig.vaultPath;
207
+ const algorithmsSubfolder = vaultConfig.algorithmsSubfolder;
208
+
209
+ const entries = loadCaptures(logPath, args.slug);
210
+ const summary = summarize(entries, args.accepted);
211
+ const noteBlock = buildNoteBlock(summary, args.note);
212
+
213
+ if (args.dryRun) {
214
+ console.log(noteBlock);
215
+ return;
216
+ }
217
+
218
+ validateVaultPath(vaultPath);
219
+
220
+ const topicNote = findTopicNoteByProblemLink(vaultPath, args.slug, algorithmsSubfolder);
221
+ if (topicNote) {
222
+ appendToTopicNote(topicNote, noteBlock);
223
+ console.log(`Appended capture session to ${topicNote}`);
224
+ } else {
225
+ const created = createPerProblemNote(vaultPath, summary, noteBlock, algorithmsSubfolder);
226
+ console.log(`No matching topic note found; wrote per-problem note to ${created}`);
227
+ }
228
+ }
229
+
230
+ main();
@@ -0,0 +1,4 @@
1
+ {
2
+ "private": true,
3
+ "type": "commonjs"
4
+ }
@@ -0,0 +1,206 @@
1
+ // vault-tool/vault-notes.js - shared vault note helpers
2
+ //
3
+ // Extracted so both the manual CLI (log-session.js) and the companion's
4
+ // automatic vault-summary feature (companion/companion.js) build on the same
5
+ // per-problem/per-topic note-linking logic instead of two parallel
6
+ // implementations. Node CommonJS, no deps, same as the rest of vault-tool.
7
+
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const os = require("os");
11
+
12
+ function defaultVaultPath() {
13
+ return path.join(os.homedir(), "Documents", "My Brain");
14
+ }
15
+
16
+ const DEFAULT_ALGORITHMS_SUBFOLDER = path.join("Study", "Algorithms");
17
+
18
+ // Optional, gitignored config file at the repo root letting a user other
19
+ // than Thomson point this tool at their own vault without touching code or
20
+ // setting env vars every session. Plain JSON, parsed with JSON.parse, per
21
+ // this project's no-external-deps convention (see CLAUDE.md). Both keys are
22
+ // optional; a missing file is not an error - it just means "use env vars
23
+ // and/or the built-in defaults", which is what Thomson's own setup already
24
+ // relies on.
25
+ function vaultConfigFilePath() {
26
+ return path.join(__dirname, "..", "vault.config.json");
27
+ }
28
+
29
+ function loadVaultConfigFile() {
30
+ const configPath = vaultConfigFilePath();
31
+ if (!fs.existsSync(configPath)) return {};
32
+
33
+ let raw;
34
+ try {
35
+ raw = fs.readFileSync(configPath, "utf8");
36
+ } catch (err) {
37
+ throw new Error(`Could not read vault config file at ${configPath}: ${err.message}`);
38
+ }
39
+ try {
40
+ return JSON.parse(raw);
41
+ } catch (err) {
42
+ throw new Error(`Vault config file at ${configPath} is not valid JSON: ${err.message}`);
43
+ }
44
+ }
45
+
46
+ // Single place both log-session.js and companion/vault-summary.js resolve
47
+ // the vault path and the algorithm-notes subfolder through, so the two
48
+ // consumers can never disagree on precedence. Precedence, highest first:
49
+ // explicit env var, then the config file, then the built-in default - so a
50
+ // vault.config.json with no VAULT_PATH/VAULT_ALGORITHMS_SUBFOLDER set still
51
+ // lets a stray env var win, and no config file at all reproduces Thomson's
52
+ // existing behavior exactly.
53
+ function resolveVaultConfig() {
54
+ const fileConfig = loadVaultConfigFile();
55
+ const vaultPath = process.env.VAULT_PATH || fileConfig.vaultPath || defaultVaultPath();
56
+ const algorithmsSubfolder =
57
+ process.env.VAULT_ALGORITHMS_SUBFOLDER || fileConfig.algorithmsSubfolder || DEFAULT_ALGORITHMS_SUBFOLDER;
58
+ return { vaultPath, algorithmsSubfolder };
59
+ }
60
+
61
+ // Fails loudly, naming the exact path and how to fix it, rather than letting
62
+ // a wrong or unset vault path silently fabricate a brand-new folder tree
63
+ // (the old behavior - ensurePerProblemNoteFile used to mkdirSync the whole
64
+ // path unconditionally). A missing .obsidian folder only warns, since a
65
+ // legitimate vault path passed via config/env could plausibly not carry one
66
+ // (e.g. .obsidian excluded from a synced copy) - that's a much lower-risk
67
+ // false negative than treating a bare "does this directory exist" check as
68
+ // fatal would be.
69
+ function validateVaultPath(vaultPath) {
70
+ if (!fs.existsSync(vaultPath) || !fs.statSync(vaultPath).isDirectory()) {
71
+ throw new Error(
72
+ `Vault path does not exist: ${vaultPath}\n` +
73
+ "Set VAULT_PATH, or \"vaultPath\" in vault.config.json (see README.md), to your Obsidian vault's absolute path."
74
+ );
75
+ }
76
+ if (!fs.existsSync(path.join(vaultPath, ".obsidian"))) {
77
+ console.warn(`Warning: ${vaultPath} exists but has no .obsidian folder - is this really an Obsidian vault?`);
78
+ }
79
+ }
80
+
81
+ // Shared with log-session.js's per-problem note filenames, so the manual CLI
82
+ // and the automatic feature always agree on where a given problem's note
83
+ // lives.
84
+ function safeFileName(title) {
85
+ return title.replace(/[\\/:*?"<>|]/g, "-");
86
+ }
87
+
88
+ // Searches Study/Algorithms/*.md for a note whose content already links to
89
+ // this problem (https://leetcode.com/problems/<slug>/...). This is the
90
+ // original log-session.js matching strategy: it finds a topic note Thomson
91
+ // has already curated to include this specific problem, regardless of the
92
+ // note's filename.
93
+ function findTopicNoteByProblemLink(vaultPath, slug, algorithmsSubfolder = DEFAULT_ALGORITHMS_SUBFOLDER) {
94
+ const algDir = path.join(vaultPath, algorithmsSubfolder);
95
+ if (!fs.existsSync(algDir)) return null;
96
+
97
+ const files = fs.readdirSync(algDir).filter((f) => f.endsWith(".md"));
98
+ const needle = `/problems/${slug}/`;
99
+ for (const file of files) {
100
+ const fullPath = path.join(algDir, file);
101
+ const content = fs.readFileSync(fullPath, "utf8");
102
+ if (content.includes(needle)) {
103
+ return fullPath;
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ // Matches a LeetCode topic tag (e.g. "Dynamic Programming") to an existing
110
+ // Study/Algorithms/<Tag>.md note by filename, case-insensitively. Used by
111
+ // the automatic vault-summary feature: LeetCode's own tags are the source of
112
+ // truth for what topic(s) a problem belongs to (see README.md), but only
113
+ // tags that already have a curated topic note in the vault get linked -
114
+ // this deliberately does not auto-create a new topic note for every raw
115
+ // LeetCode tag (e.g. "Array", "Hash Table" have no note of their own yet),
116
+ // since those are curated concept notes with real prose, not stubs to
117
+ // generate. Returns the absolute path, or null if no matching note exists.
118
+ function findTopicNoteByTagName(vaultPath, tagName, algorithmsSubfolder = DEFAULT_ALGORITHMS_SUBFOLDER) {
119
+ const algDir = path.join(vaultPath, algorithmsSubfolder);
120
+ if (!fs.existsSync(algDir)) return null;
121
+
122
+ const target = `${tagName}.md`.toLowerCase();
123
+ const files = fs.readdirSync(algDir).filter((f) => f.endsWith(".md"));
124
+ const match = files.find((f) => f.toLowerCase() === target);
125
+ return match ? path.join(algDir, match) : null;
126
+ }
127
+
128
+ // Finds (or creates, with minimal frontmatter + a heading link) the
129
+ // per-problem note under Study/Algorithms/Problems/. Mirrors
130
+ // log-session.js's createPerProblemNote frontmatter/heading shape exactly,
131
+ // so a note this creates and one log-session.js creates are
132
+ // indistinguishable, and either tool can safely extend a note the other one
133
+ // started.
134
+ function ensurePerProblemNoteFile(
135
+ vaultPath,
136
+ { problemSlug, problemTitle },
137
+ algorithmsSubfolder = DEFAULT_ALGORITHMS_SUBFOLDER
138
+ ) {
139
+ validateVaultPath(vaultPath);
140
+ const problemsDir = path.join(vaultPath, algorithmsSubfolder, "Problems");
141
+ fs.mkdirSync(problemsDir, { recursive: true });
142
+
143
+ const filePath = path.join(problemsDir, `${safeFileName(problemTitle)}.md`);
144
+ if (!fs.existsSync(filePath)) {
145
+ const frontmatter = "---\ntags:\n - study/algorithms\n---\n\n";
146
+ const heading = `[${problemTitle}](https://leetcode.com/problems/${problemSlug}/)\n`;
147
+ fs.writeFileSync(filePath, frontmatter + heading);
148
+ }
149
+ return filePath;
150
+ }
151
+
152
+ // Returns the current body text of a "## <Header>" section (everything up
153
+ // to the next "## " heading or EOF), trimmed - or null if the section
154
+ // doesn't exist yet. Used to read what's already there before rewriting it,
155
+ // so a rewrite can factor in prior content instead of blindly discarding it.
156
+ function readSection(content, header) {
157
+ const idx = content.indexOf(header);
158
+ if (idx === -1) return null;
159
+ const rest = content.slice(idx + header.length);
160
+ const nextMatch = rest.match(/\n##[ \t]/);
161
+ const body = nextMatch ? rest.slice(0, nextMatch.index) : rest;
162
+ const trimmed = body.trim();
163
+ return trimmed || null;
164
+ }
165
+
166
+ // Replaces a "## <Header>" section's body in place with bodyText, or
167
+ // appends a new "## <Header>" section at the end of the file if it doesn't
168
+ // exist yet. Leaves everything else in the file untouched, including
169
+ // whatever section follows. Idempotent: calling this twice with the same
170
+ // bodyText produces the same content both times.
171
+ function upsertSection(content, header, bodyText) {
172
+ const newSection = `${header}\n\n${bodyText.trim()}\n`;
173
+ const idx = content.indexOf(header);
174
+
175
+ if (idx === -1) {
176
+ const base = content.replace(/\n*$/, "\n\n");
177
+ return `${base}${newSection}`;
178
+ }
179
+
180
+ const rest = content.slice(idx + header.length);
181
+ const nextMatch = rest.match(/\n##[ \t]/);
182
+ const sectionEndInRest = nextMatch ? nextMatch.index + 1 : rest.length; // +1 keeps the \n that starts the next heading
183
+ const before = content.slice(0, idx);
184
+ const after = rest.slice(sectionEndInRest);
185
+
186
+ // newSection already ends in \n; only add a separating blank line when
187
+ // there's a following section to separate from. Without this check,
188
+ // upserting the last section in a file grows it by one blank line on
189
+ // every call - breaking the idempotency callers rely on for "repeated
190
+ // submits on the same problem update the same section in place".
191
+ return after ? `${before}${newSection}\n${after}` : `${before}${newSection}`;
192
+ }
193
+
194
+ module.exports = {
195
+ defaultVaultPath,
196
+ DEFAULT_ALGORITHMS_SUBFOLDER,
197
+ vaultConfigFilePath,
198
+ resolveVaultConfig,
199
+ validateVaultPath,
200
+ safeFileName,
201
+ findTopicNoteByProblemLink,
202
+ findTopicNoteByTagName,
203
+ ensurePerProblemNoteFile,
204
+ readSection,
205
+ upsertSection,
206
+ };
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.