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.
- package/cli.js +92 -0
- package/config.js +25 -0
- package/framing.js +57 -0
- package/host.js +420 -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 +2190 -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 +168 -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 +1337 -0
- package/runtime/extension/shared/sanitize.js +68 -0
- package/runtime/extension/shared/solved.js +316 -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
package/install.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Registers (and unregisters) the helper with the browsers, for the current
|
|
2
|
+
// user only, with no admin rights.
|
|
3
|
+
//
|
|
4
|
+
// What install writes, all of it listed in a receipt so uninstall removes
|
|
5
|
+
// exactly that and nothing else:
|
|
6
|
+
// - a private copy of the host (host.js, framing.js, config.js) in the data
|
|
7
|
+
// folder, so clearing the npx cache does not break the browser's launch,
|
|
8
|
+
// plus helper-config.json recording where the companion and relay live;
|
|
9
|
+
// - a wrapper script that runs that copy with the node that ran install;
|
|
10
|
+
// - one registration file per browser (Native Messaging "host manifest"),
|
|
11
|
+
// and on Windows one registry value per browser pointing at it.
|
|
12
|
+
//
|
|
13
|
+
// Every location comes from layout(), which takes the platform, home folder
|
|
14
|
+
// and environment as arguments, and the registry is reached only through the
|
|
15
|
+
// `reg` function passed in, so tests run everything in a temporary folder
|
|
16
|
+
// with a fake registry.
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { CHROME_EXTENSION_IDS, CHROME_ID_RE, GECKO_ID, HOST_DESCRIPTION, HOST_NAME, VERSION } from './config.js';
|
|
23
|
+
import { checkRoot } from './host.js';
|
|
24
|
+
|
|
25
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const APP_FILES = ['host.js', 'framing.js', 'config.js'];
|
|
27
|
+
const RECEIPT = 'install-receipt.json';
|
|
28
|
+
|
|
29
|
+
// Each browser: where its per-user registrations live. `profile` is the
|
|
30
|
+
// folder that shows the browser is installed (Linux and macOS); Windows
|
|
31
|
+
// registers in the registry for every browser, since the key alone is cheap
|
|
32
|
+
// and uninstall removes it again.
|
|
33
|
+
const BROWSERS = [
|
|
34
|
+
{
|
|
35
|
+
id: 'chrome',
|
|
36
|
+
label: 'Google Chrome',
|
|
37
|
+
kind: 'chromium',
|
|
38
|
+
linux: '.config/google-chrome',
|
|
39
|
+
darwin: 'Library/Application Support/Google/Chrome',
|
|
40
|
+
win32: 'Software\\Google\\Chrome\\NativeMessagingHosts',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'chromium',
|
|
44
|
+
label: 'Chromium',
|
|
45
|
+
kind: 'chromium',
|
|
46
|
+
linux: '.config/chromium',
|
|
47
|
+
darwin: 'Library/Application Support/Chromium',
|
|
48
|
+
win32: 'Software\\Chromium\\NativeMessagingHosts',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'edge',
|
|
52
|
+
label: 'Microsoft Edge',
|
|
53
|
+
kind: 'chromium',
|
|
54
|
+
linux: '.config/microsoft-edge',
|
|
55
|
+
darwin: 'Library/Application Support/Microsoft Edge',
|
|
56
|
+
win32: 'Software\\Microsoft\\Edge\\NativeMessagingHosts',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'brave',
|
|
60
|
+
label: 'Brave',
|
|
61
|
+
kind: 'chromium',
|
|
62
|
+
linux: '.config/BraveSoftware/Brave-Browser',
|
|
63
|
+
darwin: 'Library/Application Support/BraveSoftware/Brave-Browser',
|
|
64
|
+
win32: 'Software\\BraveSoftware\\Brave-Browser\\NativeMessagingHosts',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'firefox',
|
|
68
|
+
label: 'Firefox',
|
|
69
|
+
kind: 'firefox',
|
|
70
|
+
linux: '.mozilla',
|
|
71
|
+
darwin: 'Library/Application Support/Mozilla',
|
|
72
|
+
win32: 'Software\\Mozilla\\NativeMessagingHosts',
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
export const BROWSER_IDS = BROWSERS.map((b) => b.id);
|
|
77
|
+
|
|
78
|
+
export function layout({ platform = process.platform, home, env = process.env }) {
|
|
79
|
+
if (!home) throw new Error('layout needs a home folder');
|
|
80
|
+
let dataDir;
|
|
81
|
+
if (platform === 'win32') dataDir = path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'leetcode-capture-helper');
|
|
82
|
+
else if (platform === 'darwin') dataDir = path.join(home, 'Library', 'Application Support', 'leetcode-capture-helper');
|
|
83
|
+
else if (platform === 'linux') dataDir = path.join(env.XDG_DATA_HOME || path.join(home, '.local', 'share'), 'leetcode-capture-helper');
|
|
84
|
+
else throw new Error(`unsupported platform ${platform}`);
|
|
85
|
+
const targets = BROWSERS.map((b) => {
|
|
86
|
+
if (platform === 'win32') {
|
|
87
|
+
return { ...b, regKey: `HKCU\\${b.win32}\\${HOST_NAME}`, manifestPath: path.join(dataDir, `${HOST_NAME}.${b.kind}.json`) };
|
|
88
|
+
}
|
|
89
|
+
const profile = path.join(home, platform === 'darwin' ? b.darwin : b.linux);
|
|
90
|
+
// Firefox keeps registrations beside the profile folder, not inside it,
|
|
91
|
+
// and calls the folder differently on each OS.
|
|
92
|
+
const dir =
|
|
93
|
+
b.kind === 'firefox'
|
|
94
|
+
? path.join(profile, platform === 'darwin' ? 'NativeMessagingHosts' : 'native-messaging-hosts')
|
|
95
|
+
: path.join(profile, 'NativeMessagingHosts');
|
|
96
|
+
return { ...b, profile, manifestDir: dir, manifestPath: path.join(dir, `${HOST_NAME}.json`) };
|
|
97
|
+
});
|
|
98
|
+
const wrapper = path.join(dataDir, platform === 'win32' ? 'leetcode-capture-helper.cmd' : 'leetcode-capture-helper.sh');
|
|
99
|
+
return { platform, dataDir, appDir: path.join(dataDir, 'app'), wrapper, receipt: path.join(dataDir, RECEIPT), targets };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function defaultReg(args) {
|
|
103
|
+
const result = spawnSync('reg', args, { encoding: 'utf8', windowsHide: true });
|
|
104
|
+
return { status: result.status, stdout: result.stdout || '', stderr: result.stderr || (result.error ? result.error.message : '') };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function manifestFor(target, wrapper, chromeIds) {
|
|
108
|
+
const base = { name: HOST_NAME, description: HOST_DESCRIPTION, path: wrapper, type: 'stdio' };
|
|
109
|
+
return target.kind === 'firefox'
|
|
110
|
+
? { ...base, allowed_extensions: [GECKO_ID] }
|
|
111
|
+
: { ...base, allowed_origins: chromeIds.map((id) => `chrome-extension://${id}/`) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Single quotes for sh: only a single quote itself needs care.
|
|
115
|
+
const shQuote = (text) => `'${String(text).replace(/'/g, `'\\''`)}'`;
|
|
116
|
+
|
|
117
|
+
function wrapperText(platform, nodePath, hostPath) {
|
|
118
|
+
if (platform === 'win32') return `@echo off\r\n"${nodePath}" "${hostPath}" %*\r\n`;
|
|
119
|
+
return `#!/bin/sh\n# LeetCode Capture helper: written by \`leetcode-capture-helper install\`.\nexec ${shQuote(nodePath)} ${shQuote(hostPath)} "$@"\n`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readReceipt(where) {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(fs.readFileSync(where.receipt, 'utf8'));
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Creates `dir` and its missing parents, and returns the ones it created
|
|
131
|
+
// (outermost first) so uninstall can remove exactly those.
|
|
132
|
+
function mkdirTracked(dir) {
|
|
133
|
+
const created = [];
|
|
134
|
+
let current = path.resolve(dir);
|
|
135
|
+
while (!fs.existsSync(current)) {
|
|
136
|
+
created.unshift(current);
|
|
137
|
+
current = path.dirname(current);
|
|
138
|
+
}
|
|
139
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
140
|
+
return created;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function normalizeChromeIds(ids) {
|
|
144
|
+
const all = [...CHROME_EXTENSION_IDS, ...(ids || [])].map((id) => String(id).trim());
|
|
145
|
+
for (const id of all) {
|
|
146
|
+
if (!CHROME_ID_RE.test(id)) throw new Error(`"${id}" is not a Chrome extension id (32 letters from a to p)`);
|
|
147
|
+
}
|
|
148
|
+
return [...new Set(all)];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function install({
|
|
152
|
+
platform = process.platform,
|
|
153
|
+
home,
|
|
154
|
+
env = process.env,
|
|
155
|
+
nodePath = process.execPath,
|
|
156
|
+
root,
|
|
157
|
+
chromeIds = [],
|
|
158
|
+
browsers = null,
|
|
159
|
+
reg = defaultReg,
|
|
160
|
+
sourceDir = HERE,
|
|
161
|
+
}) {
|
|
162
|
+
const where = layout({ platform, home, env });
|
|
163
|
+
const rootError = checkRoot(root);
|
|
164
|
+
if (rootError) throw new Error(rootError);
|
|
165
|
+
const ids = normalizeChromeIds(chromeIds);
|
|
166
|
+
const wanted = browsers ? new Set(browsers) : null;
|
|
167
|
+
for (const id of wanted || []) if (!BROWSER_IDS.includes(id)) throw new Error(`unknown browser "${id}" (one of ${BROWSER_IDS.join(', ')})`);
|
|
168
|
+
|
|
169
|
+
// A reinstall starts from a clean slate.
|
|
170
|
+
const previous = readReceipt(where);
|
|
171
|
+
if (previous) uninstall({ platform, home, env, reg });
|
|
172
|
+
|
|
173
|
+
const receipt = { version: VERSION, platform, files: [], dirs: [], registry: [] };
|
|
174
|
+
const notes = [];
|
|
175
|
+
const writeFile = (file, text, mode) => {
|
|
176
|
+
receipt.dirs.push(...mkdirTracked(path.dirname(file)));
|
|
177
|
+
fs.writeFileSync(file, text, mode ? { mode } : undefined);
|
|
178
|
+
if (mode) fs.chmodSync(file, mode);
|
|
179
|
+
receipt.files.push(file);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
for (const name of APP_FILES) writeFile(path.join(where.appDir, name), fs.readFileSync(path.join(sourceDir, name)));
|
|
184
|
+
writeFile(path.join(where.appDir, 'package.json'), `${JSON.stringify({ type: 'module', private: true }, null, 2)}\n`);
|
|
185
|
+
writeFile(path.join(where.appDir, 'helper-config.json'), `${JSON.stringify({ root: path.resolve(root) }, null, 2)}\n`);
|
|
186
|
+
writeFile(where.wrapper, wrapperText(platform, nodePath, path.join(where.appDir, 'host.js')), platform === 'win32' ? undefined : 0o755);
|
|
187
|
+
|
|
188
|
+
const registered = [];
|
|
189
|
+
for (const target of where.targets) {
|
|
190
|
+
if (wanted && !wanted.has(target.id)) continue;
|
|
191
|
+
if (target.kind === 'chromium' && ids.length === 0) {
|
|
192
|
+
if (wanted) notes.push(`${target.label}: skipped, no extension id (pass --chrome-id <id>, shown in the extension's settings)`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (platform !== 'win32' && !wanted && !fs.existsSync(target.profile)) continue;
|
|
196
|
+
writeFile(target.manifestPath, `${JSON.stringify(manifestFor(target, where.wrapper, ids), null, 2)}\n`);
|
|
197
|
+
if (platform === 'win32') {
|
|
198
|
+
const result = reg(['add', target.regKey, '/ve', '/t', 'REG_SZ', '/d', target.manifestPath, '/f']);
|
|
199
|
+
if (result.status !== 0) throw new Error(`could not register with ${target.label}: ${result.stderr.trim() || 'reg add failed'}`);
|
|
200
|
+
receipt.registry.push(target.regKey);
|
|
201
|
+
}
|
|
202
|
+
registered.push(target.label);
|
|
203
|
+
}
|
|
204
|
+
if (ids.length === 0) {
|
|
205
|
+
notes.push('Chrome, Chromium, Edge and Brave were not registered: pass --chrome-id <id> (the options page shows the command with it)');
|
|
206
|
+
}
|
|
207
|
+
if (registered.length === 0) notes.push('No browser was registered.');
|
|
208
|
+
if (path.resolve(root).split(path.sep).includes('_npx')) {
|
|
209
|
+
notes.push(
|
|
210
|
+
`The companion and relay run from npx's cache (${path.resolve(root)}). If that cache is cleared, run this install command again; for a lasting copy, run \`npm install -g leetcode-capture-helper\` and then \`leetcode-capture-helper install\`.`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
// Uninstall must also be able to remove the receipt and the folders.
|
|
214
|
+
receipt.dirs.push(...mkdirTracked(where.dataDir));
|
|
215
|
+
fs.writeFileSync(where.receipt, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
216
|
+
return { registered, notes, dataDir: where.dataDir, wrapper: where.wrapper };
|
|
217
|
+
} catch (err) {
|
|
218
|
+
// Leave nothing half-written behind.
|
|
219
|
+
receipt.dirs.push(...mkdirTracked(where.dataDir));
|
|
220
|
+
fs.writeFileSync(where.receipt, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
221
|
+
uninstall({ platform, home, env, reg });
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function uninstall({ platform = process.platform, home, env = process.env, reg = defaultReg }) {
|
|
227
|
+
const where = layout({ platform, home, env });
|
|
228
|
+
const receipt = readReceipt(where);
|
|
229
|
+
if (!receipt) return { removed: [], nothing: true };
|
|
230
|
+
const removed = [];
|
|
231
|
+
for (const key of receipt.registry || []) {
|
|
232
|
+
reg(['delete', key, '/f']);
|
|
233
|
+
removed.push(key);
|
|
234
|
+
}
|
|
235
|
+
for (const file of receipt.files || []) {
|
|
236
|
+
try {
|
|
237
|
+
fs.unlinkSync(file);
|
|
238
|
+
removed.push(file);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (err.code !== 'ENOENT') throw err;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
fs.rmSync(where.receipt, { force: true });
|
|
244
|
+
// Folders install created, innermost first, and only while empty (a folder
|
|
245
|
+
// something else has since put files in stays).
|
|
246
|
+
const dirs = [...new Set(receipt.dirs || [])].sort((a, b) => b.length - a.length);
|
|
247
|
+
for (const dir of dirs) {
|
|
248
|
+
try {
|
|
249
|
+
fs.rmdirSync(dir);
|
|
250
|
+
removed.push(dir);
|
|
251
|
+
} catch {
|
|
252
|
+
// not empty, or already gone
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { removed, nothing: false };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function status({ platform = process.platform, home, env = process.env, reg = defaultReg }) {
|
|
259
|
+
const where = layout({ platform, home, env });
|
|
260
|
+
const receipt = readReceipt(where);
|
|
261
|
+
const browsers = where.targets.map((target) => {
|
|
262
|
+
let manifest = null;
|
|
263
|
+
try {
|
|
264
|
+
manifest = JSON.parse(fs.readFileSync(target.manifestPath, 'utf8'));
|
|
265
|
+
} catch {
|
|
266
|
+
// not registered
|
|
267
|
+
}
|
|
268
|
+
let registered = Boolean(manifest) && manifest.path === where.wrapper;
|
|
269
|
+
if (registered && platform === 'win32') {
|
|
270
|
+
const result = reg(['query', target.regKey, '/ve']);
|
|
271
|
+
registered = result.status === 0 && result.stdout.includes(target.manifestPath);
|
|
272
|
+
}
|
|
273
|
+
const allowed = manifest ? manifest.allowed_origins || manifest.allowed_extensions || [] : [];
|
|
274
|
+
return { id: target.id, label: target.label, registered, allowed: registered ? allowed : [] };
|
|
275
|
+
});
|
|
276
|
+
let root = null;
|
|
277
|
+
try {
|
|
278
|
+
root = JSON.parse(fs.readFileSync(path.join(where.appDir, 'helper-config.json'), 'utf8')).root;
|
|
279
|
+
} catch {
|
|
280
|
+
// not installed
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
installed: Boolean(receipt) && fs.existsSync(where.wrapper),
|
|
284
|
+
version: receipt ? receipt.version : null,
|
|
285
|
+
dataDir: where.dataDir,
|
|
286
|
+
root,
|
|
287
|
+
rootError: root ? checkRoot(root) : null,
|
|
288
|
+
browsers,
|
|
289
|
+
};
|
|
290
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leetcode-capture-helper",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"leetcode-capture-helper": "cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"cli.js",
|
|
11
|
+
"config.js",
|
|
12
|
+
"framing.js",
|
|
13
|
+
"host.js",
|
|
14
|
+
"install.js",
|
|
15
|
+
"runtime/"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test framing.test.js host.test.js install.test.js pack.test.js",
|
|
22
|
+
"prepack": "node scripts/pack.mjs",
|
|
23
|
+
"postpack": "node scripts/pack.mjs --clean",
|
|
24
|
+
"e2e:packed": "node e2e/packed.e2e.mjs"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.233",
|
|
28
|
+
"chalk": "^5.6.2",
|
|
29
|
+
"marked": "^15.0.12",
|
|
30
|
+
"marked-terminal": "^7.3.0"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT"
|
|
33
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.1.1",
|
|
3
|
+
"note": "Built by scripts/pack.mjs from the LeetCode Capture repository. Do not edit.",
|
|
4
|
+
"files": [
|
|
5
|
+
"companion/capture-log.js",
|
|
6
|
+
"companion/codex-backend.js",
|
|
7
|
+
"companion/companion.js",
|
|
8
|
+
"companion/mouse-input.js",
|
|
9
|
+
"companion/panel-bridge.js",
|
|
10
|
+
"companion/settings-rpc.js",
|
|
11
|
+
"companion/settings.js",
|
|
12
|
+
"companion/terminal-format.js",
|
|
13
|
+
"companion/vault-summary.js",
|
|
14
|
+
"companion/virtual-terminal.js",
|
|
15
|
+
"relay-server/panel-broker.js",
|
|
16
|
+
"relay-server/server.js",
|
|
17
|
+
"extension/shared/anthropic-backend.js",
|
|
18
|
+
"extension/shared/catalog.js",
|
|
19
|
+
"extension/shared/code-ledger.js",
|
|
20
|
+
"extension/shared/context-budget.js",
|
|
21
|
+
"extension/shared/context-summary.js",
|
|
22
|
+
"extension/shared/conversation.js",
|
|
23
|
+
"extension/shared/event-hub.js",
|
|
24
|
+
"extension/shared/extension-settings.js",
|
|
25
|
+
"extension/shared/helper-launcher.js",
|
|
26
|
+
"extension/shared/local-backend.js",
|
|
27
|
+
"extension/shared/local-server.js",
|
|
28
|
+
"extension/shared/practice.js",
|
|
29
|
+
"extension/shared/problem-index.js",
|
|
30
|
+
"extension/shared/profiles.js",
|
|
31
|
+
"extension/shared/providers.js",
|
|
32
|
+
"extension/shared/reply-guard.js",
|
|
33
|
+
"extension/shared/roadmap.js",
|
|
34
|
+
"extension/shared/sanitize.js",
|
|
35
|
+
"extension/shared/solved.js",
|
|
36
|
+
"extension/shared/tutor-engine.js",
|
|
37
|
+
"extension/shared/tutor-prompt.js",
|
|
38
|
+
"extension/shared/tutor-service.js",
|
|
39
|
+
"vault-tool/log-session.js",
|
|
40
|
+
"vault-tool/vault-notes.js"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// companion/capture-log.js - clearing the capture log as it is consumed.
|
|
2
|
+
//
|
|
3
|
+
// captures.jsonl is only a short-lived delivery queue: the relay appends each
|
|
4
|
+
// Run and Submit, and the companion tails it by byte offset. Old captures are
|
|
5
|
+
// worth nothing once the student has moved on, so the companion asks the
|
|
6
|
+
// relay to drop the prefix it has already read and handled (the relay owns
|
|
7
|
+
// appends, so only it may cut the file; see relay-server/server.js,
|
|
8
|
+
// trimCaptureLog). The companion asks with its own read offset, between
|
|
9
|
+
// polls, so every byte before it belongs to a line already handled and
|
|
10
|
+
// everything after it (the line that triggered a problem switch included,
|
|
11
|
+
// if it has not been read yet) stays. It moves its offset back by the
|
|
12
|
+
// removed byte count only once the relay confirms.
|
|
13
|
+
//
|
|
14
|
+
// One window is left: the relay may have trimmed and the companion died (or
|
|
15
|
+
// lost the answer) before it saved its new offset. So before asking, the
|
|
16
|
+
// companion saves a `pendingTrim` in its state file with the last bytes before
|
|
17
|
+
// the cut; afterwards those bytes are either still at the same place (no trim
|
|
18
|
+
// happened) or not (it did). Each capture line carries its own receive time,
|
|
19
|
+
// so a different line landing on exactly the same bytes is not a real risk.
|
|
20
|
+
|
|
21
|
+
const COMPANION_HEADERS = { 'Content-Type': 'application/json', 'X-LC-Companion': '1' };
|
|
22
|
+
const TRIM_TIMEOUT_MS = 5000;
|
|
23
|
+
// How many bytes before the cut a pendingTrim remembers.
|
|
24
|
+
export const GUARD_BYTES = 256;
|
|
25
|
+
|
|
26
|
+
// Asks the relay to drop the first `before` bytes of the log at `logPath`.
|
|
27
|
+
// Resolves to one of:
|
|
28
|
+
// { outcome: 'trimmed', removed } the relay cut the file
|
|
29
|
+
// { outcome: 'refused', status, error } it answered and did not cut
|
|
30
|
+
// { outcome: 'unsupported' } an older relay without the route
|
|
31
|
+
// { outcome: 'unknown', error } no answer: it may or may not have cut
|
|
32
|
+
export async function requestCaptureTrim({ baseUrl, before, logPath, fetchImpl = fetch, timeoutMs = TRIM_TIMEOUT_MS }) {
|
|
33
|
+
let res;
|
|
34
|
+
try {
|
|
35
|
+
res = await fetchImpl(`${baseUrl}/panel/trim-captures`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: COMPANION_HEADERS,
|
|
38
|
+
body: JSON.stringify({ before, path: logPath }),
|
|
39
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
40
|
+
});
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return { outcome: 'unknown', error: err.message };
|
|
43
|
+
}
|
|
44
|
+
let body = null;
|
|
45
|
+
try {
|
|
46
|
+
body = await res.json();
|
|
47
|
+
} catch {
|
|
48
|
+
body = null;
|
|
49
|
+
}
|
|
50
|
+
if (res.status === 200 && body && Number.isSafeInteger(body.removed) && body.removed === before) {
|
|
51
|
+
return { outcome: 'trimmed', removed: body.removed };
|
|
52
|
+
}
|
|
53
|
+
if (res.status === 404) return { outcome: 'unsupported' };
|
|
54
|
+
if (res.status === 200) return { outcome: 'unknown', error: 'the relay gave an unexpected answer' };
|
|
55
|
+
return { outcome: 'refused', status: res.status, error: (body && body.error) || `HTTP ${res.status}` };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The pendingTrim to save before asking: the cut position and the bytes just
|
|
59
|
+
// before it, base64. `readAt(position, length)` returns a Buffer-like of the
|
|
60
|
+
// log's bytes there (shorter at the end of the file).
|
|
61
|
+
export function pendingTrimFor(before, readAt) {
|
|
62
|
+
const length = Math.min(before, GUARD_BYTES);
|
|
63
|
+
const tail = readAt(before - length, length);
|
|
64
|
+
return { before, tail: Buffer.from(tail).toString('base64') };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Whether a saved pendingTrim was carried out: the bytes it remembered are no
|
|
68
|
+
// longer right before its cut position.
|
|
69
|
+
export function pendingTrimHappened(pending, readAt) {
|
|
70
|
+
const expected = Buffer.from(pending.tail, 'base64');
|
|
71
|
+
const actual = Buffer.from(readAt(pending.before - expected.length, expected.length));
|
|
72
|
+
return !actual.equals(expected);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isPendingTrim(value) {
|
|
76
|
+
return (
|
|
77
|
+
!!value &&
|
|
78
|
+
typeof value === 'object' &&
|
|
79
|
+
Number.isSafeInteger(value.before) &&
|
|
80
|
+
value.before > 0 &&
|
|
81
|
+
typeof value.tail === 'string'
|
|
82
|
+
);
|
|
83
|
+
}
|