leetcode-capture-helper 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +92 -0
- package/config.js +25 -0
- package/framing.js +57 -0
- package/host.js +346 -0
- package/install.js +290 -0
- package/package.json +31 -4
- package/runtime/bundle.json +42 -0
- package/runtime/companion/capture-log.js +83 -0
- package/runtime/companion/codex-backend.js +337 -0
- package/runtime/companion/companion.js +2193 -0
- package/runtime/companion/mouse-input.js +154 -0
- package/runtime/companion/package.json +4 -0
- package/runtime/companion/panel-bridge.js +273 -0
- package/runtime/companion/settings-rpc.js +228 -0
- package/runtime/companion/settings.js +464 -0
- package/runtime/companion/terminal-format.js +330 -0
- package/runtime/companion/vault-summary.js +169 -0
- package/runtime/companion/virtual-terminal.js +138 -0
- package/runtime/extension/shared/anthropic-backend.js +193 -0
- package/runtime/extension/shared/catalog.js +415 -0
- package/runtime/extension/shared/code-ledger.js +333 -0
- package/runtime/extension/shared/context-budget.js +259 -0
- package/runtime/extension/shared/context-summary.js +271 -0
- package/runtime/extension/shared/conversation.js +81 -0
- package/runtime/extension/shared/event-hub.js +126 -0
- package/runtime/extension/shared/extension-settings.js +445 -0
- package/runtime/extension/shared/helper-launcher.js +248 -0
- package/runtime/extension/shared/local-backend.js +706 -0
- package/runtime/extension/shared/local-server.js +149 -0
- package/runtime/extension/shared/package.json +4 -0
- package/runtime/extension/shared/practice.js +228 -0
- package/runtime/extension/shared/problem-index.js +4128 -0
- package/runtime/extension/shared/profiles.js +106 -0
- package/runtime/extension/shared/providers.js +58 -0
- package/runtime/extension/shared/reply-guard.js +864 -0
- package/runtime/extension/shared/roadmap.js +1344 -0
- package/runtime/extension/shared/sanitize.js +68 -0
- package/runtime/extension/shared/solved.js +277 -0
- package/runtime/extension/shared/tutor-engine.js +369 -0
- package/runtime/extension/shared/tutor-prompt.js +108 -0
- package/runtime/extension/shared/tutor-service.js +520 -0
- package/runtime/relay-server/package.json +4 -0
- package/runtime/relay-server/panel-broker.js +797 -0
- package/runtime/relay-server/server.js +241 -0
- package/runtime/vault-tool/log-session.js +230 -0
- package/runtime/vault-tool/package.json +4 -0
- package/runtime/vault-tool/vault-notes.js +206 -0
- package/README.md +0 -3
|
@@ -0,0 +1,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. See README.md.
|
|
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,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