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
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }