@botbuddy/cli 1.33.11 → 1.34.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/package.json +1 -1
- package/src/api.mjs +194 -22
- package/src/commands.mjs +63 -5
- package/src/stack.mjs +4 -1
- package/src/usage/claude.mjs +157 -0
- package/src/usage/codex.mjs +236 -0
- package/src/usage/cursor.mjs +151 -0
- package/src/usage/envelope.mjs +313 -0
- package/src/usage/launchd.mjs +170 -0
- package/src/usage/sync.mjs +725 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// BOT-803 — Codex rollout adapter (`~/.codex/sessions/**/*.jsonl`, or
|
|
2
|
+
// `$CODEX_HOME/sessions`).
|
|
3
|
+
//
|
|
4
|
+
// VERIFIED SHAPE (parser_version 'codex-2'): checked on 2026-09-28 against real
|
|
5
|
+
// Codex Desktop rollouts (0.117 → 0.155.0-alpha.9.2, ~/.codex/sessions). The
|
|
6
|
+
// fixtures in cli/test-fixtures/usage/ are synthetic copies of that shape.
|
|
7
|
+
// Every record is {"timestamp","type","ordinal"?,"payload"}:
|
|
8
|
+
// session_meta {id, session_id, cli_version, thread_source, parent_thread_id?, cwd,
|
|
9
|
+
// git:{commit_hash, branch, repository_url}, …}
|
|
10
|
+
// A subagent rollout is its own file: id = the subagent thread,
|
|
11
|
+
// session_id = the ROOT session, thread_source 'subagent'.
|
|
12
|
+
// turn_context {model, cwd, turn_id, …}
|
|
13
|
+
// response_item {type:'function_call'|'custom_tool_call'|'local_shell_call'|…} —
|
|
14
|
+
// written BEFORE the usage record of the response that made it
|
|
15
|
+
// token_usage_record (≥0.151) {response_id, session_id, thread_id, turn_id,
|
|
16
|
+
// usage:{input_tokens, cached_input_tokens, cache_write_input_tokens,
|
|
17
|
+
// output_tokens, reasoning_output_tokens, total_tokens},
|
|
18
|
+
// turn_token_usage, thread_token_usage} — ONE per model response,
|
|
19
|
+
// including a compaction's own response (which has no token_count)
|
|
20
|
+
// event_msg/token_count {info:{total_token_usage, last_token_usage, model_context_window}|null,
|
|
21
|
+
// rate_limits} — follows each usage record with the same numbers,
|
|
22
|
+
// is re-emitted with unchanged totals, and is null-info for a
|
|
23
|
+
// rate-limit-only update
|
|
24
|
+
// Identity: a usage record keys on its provider `response_id`. Rollouts written
|
|
25
|
+
// before token_usage_record existed (and the pre-upgrade head of a session
|
|
26
|
+
// resumed on a newer Codex) fall back to token_count keyed on the cumulative
|
|
27
|
+
// total; once a file shows a usage record, token_count is ignored (it would
|
|
28
|
+
// double count). `cache_write_input_tokens` has no BOT-1019 column and was 0 in
|
|
29
|
+
// every real rollout checked; it is not sent.
|
|
30
|
+
// Codex classes stay provider-faithful (codex_* columns); Claude's four classes
|
|
31
|
+
// are null on Codex rows. Codex `input_tokens` INCLUDES `cached_input_tokens`.
|
|
32
|
+
import {
|
|
33
|
+
commitSha, finalizeEnvelope, inferTicketKey, modelLabel, normalizeRepo, safeLabel, str, tok, usageQuality, uuid, withRoute,
|
|
34
|
+
} from "./envelope.mjs";
|
|
35
|
+
|
|
36
|
+
export const CODEX_PROVIDER = "codex";
|
|
37
|
+
export const CODEX_PARSER_VERSION = "codex-2";
|
|
38
|
+
|
|
39
|
+
// Mirrors the server's PROVIDER_ID_RE (usageEnvelope.ts): resp_… ids, no '/' or '@'.
|
|
40
|
+
const RESPONSE_ID_RE = /^[A-Za-z0-9_.:-]{1,200}$/;
|
|
41
|
+
const responseId = (value) => (typeof value === "string" && RESPONSE_ID_RE.test(value) ? value : null);
|
|
42
|
+
|
|
43
|
+
function totalOf(usage) {
|
|
44
|
+
if (!usage || typeof usage !== "object") return null;
|
|
45
|
+
const explicit = tok(usage.total_tokens);
|
|
46
|
+
if (explicit !== null) return explicit;
|
|
47
|
+
const input = tok(usage.input_tokens);
|
|
48
|
+
const output = tok(usage.output_tokens);
|
|
49
|
+
return input === null && output === null ? null : (input ?? 0) + (output ?? 0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function deltaUsage(total, previous) {
|
|
53
|
+
const keys = ["input_tokens", "cached_input_tokens", "output_tokens", "reasoning_output_tokens"];
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const key of keys) {
|
|
56
|
+
const now = tok(total?.[key]);
|
|
57
|
+
const before = tok(previous?.[key]) ?? 0;
|
|
58
|
+
out[key] = now === null ? null : Math.max(0, now - before);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Only derived values are kept in ctx (it is persisted in the cursor file):
|
|
64
|
+
// the cwd's HMAC and the ticket key its last segment names — never the path.
|
|
65
|
+
function setCwd(ctx, cwd, hmac, tenantFor, repoFor = () => null) {
|
|
66
|
+
const value = str(cwd);
|
|
67
|
+
if (!value) return;
|
|
68
|
+
const cwdHmac = hmac(value);
|
|
69
|
+
// A later turn in ANOTHER checkout: the session_meta git metadata described
|
|
70
|
+
// the old one. Drop it (the ticket then falls back to the new cwd) and
|
|
71
|
+
// re-derive the repo from the new checkout.
|
|
72
|
+
if (ctx.cwd_hmac && ctx.cwd_hmac !== cwdHmac) {
|
|
73
|
+
ctx.git_branch = null;
|
|
74
|
+
ctx.commit_sha = null;
|
|
75
|
+
ctx.repo = repoFor(value) ?? null;
|
|
76
|
+
}
|
|
77
|
+
ctx.cwd_hmac = cwdHmac;
|
|
78
|
+
ctx.cwd_ticket = inferTicketKey(null, value);
|
|
79
|
+
ctx.cwd_tenant = tenantFor(value); // a tenant slug (or null), never the path
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const NONE = Object.freeze({ envelopes: [], refused: 0 });
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {object} opts
|
|
86
|
+
* @param {(value: string) => string} opts.hmac
|
|
87
|
+
* @param {string} opts.environment
|
|
88
|
+
*/
|
|
89
|
+
export function createCodexAdapter({ hmac, environment, tenantFor = () => null, repoFor = () => null }) {
|
|
90
|
+
function parseCodexRecord(record, { offset, sourceHmac, ctx }) {
|
|
91
|
+
if (!record || typeof record !== "object") return NONE;
|
|
92
|
+
const payload = record.payload && typeof record.payload === "object" ? record.payload : null;
|
|
93
|
+
if (!payload) return NONE;
|
|
94
|
+
|
|
95
|
+
if (record.type === "session_meta") {
|
|
96
|
+
// The harness session is the ROOT thread (`session_id`); a subagent's own
|
|
97
|
+
// thread id (`id`) only keeps its legacy token_count keys apart.
|
|
98
|
+
const root = uuid(payload.session_id) ?? uuid(payload.id);
|
|
99
|
+
if (root) ctx.session_id = root;
|
|
100
|
+
const thread = uuid(payload.id);
|
|
101
|
+
ctx.subagent_thread = thread && root && thread !== root ? thread
|
|
102
|
+
: payload.thread_source === "subagent" && thread ? thread : null;
|
|
103
|
+
if (safeLabel(payload.cli_version)) ctx.cli_version = safeLabel(payload.cli_version);
|
|
104
|
+
setCwd(ctx, payload.cwd, hmac, tenantFor, repoFor);
|
|
105
|
+
const git = payload.git && typeof payload.git === "object" ? payload.git : {};
|
|
106
|
+
ctx.git_branch = safeLabel(git.branch) ?? ctx.git_branch ?? null;
|
|
107
|
+
ctx.commit_sha = commitSha(git.commit_hash) ?? ctx.commit_sha ?? null;
|
|
108
|
+
ctx.repo = normalizeRepo(git.repository_url) ?? ctx.repo ?? null;
|
|
109
|
+
return NONE;
|
|
110
|
+
}
|
|
111
|
+
if (record.type === "turn_context") {
|
|
112
|
+
if (modelLabel(payload.model)) ctx.model = modelLabel(payload.model);
|
|
113
|
+
setCwd(ctx, payload.cwd, hmac, tenantFor, repoFor);
|
|
114
|
+
return NONE;
|
|
115
|
+
}
|
|
116
|
+
if (record.type === "response_item") {
|
|
117
|
+
if (["function_call", "local_shell_call", "custom_tool_call"].includes(payload.type)) {
|
|
118
|
+
ctx.pending_tool_calls = (ctx.pending_tool_calls ?? 0) + 1;
|
|
119
|
+
}
|
|
120
|
+
return NONE;
|
|
121
|
+
}
|
|
122
|
+
if (record.type === "token_usage_record") {
|
|
123
|
+
ctx.has_usage_records = true;
|
|
124
|
+
const id = responseId(payload.response_id);
|
|
125
|
+
const session = uuid(payload.session_id) ?? ctx.session_id ?? null;
|
|
126
|
+
const usage = payload.usage && typeof payload.usage === "object" ? payload.usage : null;
|
|
127
|
+
if (!id || !session || !usage) return { envelopes: [], refused: 1 };
|
|
128
|
+
const toolCalls = ctx.pending_tool_calls ?? 0;
|
|
129
|
+
ctx.pending_tool_calls = 0;
|
|
130
|
+
return { envelopes: [envelopeFor({ record, usage, usageFinal: true, messageId: id, session, toolCalls, offset, sourceHmac, ctx })], refused: 0 };
|
|
131
|
+
}
|
|
132
|
+
if (record.type !== "event_msg" || payload.type !== "token_count") return NONE;
|
|
133
|
+
// Each usage record is followed by a token_count carrying the same numbers.
|
|
134
|
+
if (ctx.has_usage_records) return NONE;
|
|
135
|
+
|
|
136
|
+
const info = payload.info && typeof payload.info === "object" ? payload.info : null;
|
|
137
|
+
const total = info?.total_token_usage;
|
|
138
|
+
const cumulative = totalOf(total);
|
|
139
|
+
// A token_count without cumulative usage (e.g. a rate-limit-only update)
|
|
140
|
+
// has no stable identity: refuse it rather than invent one.
|
|
141
|
+
if (!info || cumulative === null || !ctx.session_id) return info ? { envelopes: [], refused: 1 } : NONE;
|
|
142
|
+
// Codex re-emits token_count with unchanged totals; within one accounting
|
|
143
|
+
// epoch the cumulative total strictly increases per real response, so it is
|
|
144
|
+
// the message identity. Older rollouts reset the total (after compaction):
|
|
145
|
+
// a drop starts a new epoch so a later equal total is never read as a repeat.
|
|
146
|
+
if (ctx.last_total_tokens === cumulative) return NONE;
|
|
147
|
+
if (typeof ctx.last_total_tokens === "number" && cumulative < ctx.last_total_tokens) {
|
|
148
|
+
ctx.total_epoch = (ctx.total_epoch ?? 0) + 1;
|
|
149
|
+
ctx.last_total_usage = null; // a delta across the reset would be meaningless
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let usage = info.last_token_usage && typeof info.last_token_usage === "object" ? info.last_token_usage : null;
|
|
153
|
+
let usageFinal = true;
|
|
154
|
+
if (!usage) {
|
|
155
|
+
// No per-response usage: derive it from the previous cumulative total;
|
|
156
|
+
// without one the row cannot be split from earlier turns → not final.
|
|
157
|
+
usage = deltaUsage(total, ctx.last_total_usage);
|
|
158
|
+
usageFinal = Boolean(ctx.last_total_usage);
|
|
159
|
+
}
|
|
160
|
+
ctx.last_total_tokens = cumulative;
|
|
161
|
+
ctx.last_total_usage = {
|
|
162
|
+
input_tokens: tok(total.input_tokens),
|
|
163
|
+
cached_input_tokens: tok(total.cached_input_tokens),
|
|
164
|
+
output_tokens: tok(total.output_tokens),
|
|
165
|
+
reasoning_output_tokens: tok(total.reasoning_output_tokens),
|
|
166
|
+
};
|
|
167
|
+
const toolCalls = ctx.pending_tool_calls ?? 0;
|
|
168
|
+
ctx.pending_tool_calls = 0;
|
|
169
|
+
|
|
170
|
+
// A legacy subagent's cumulative totals restart at zero: keep its keys
|
|
171
|
+
// apart from the parent's under the shared root session.
|
|
172
|
+
const epoch = ctx.total_epoch ? `r${ctx.total_epoch}:` : "";
|
|
173
|
+
const messageId = ctx.subagent_thread
|
|
174
|
+
? `token_count:${ctx.subagent_thread}:${epoch}${cumulative}`
|
|
175
|
+
: `token_count:${epoch}${cumulative}`;
|
|
176
|
+
return {
|
|
177
|
+
envelopes: [envelopeFor({ record, usage, usageFinal, messageId, session: ctx.session_id, toolCalls, offset, sourceHmac, ctx })],
|
|
178
|
+
refused: 0,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function envelopeFor({ record, usage, usageFinal, messageId, session, toolCalls, offset, sourceHmac, ctx }) {
|
|
183
|
+
const classes = {
|
|
184
|
+
codex_input_tokens: tok(usage.input_tokens),
|
|
185
|
+
codex_cached_input_tokens: tok(usage.cached_input_tokens),
|
|
186
|
+
codex_output_tokens: tok(usage.output_tokens),
|
|
187
|
+
codex_reasoning_output_tokens: tok(usage.reasoning_output_tokens),
|
|
188
|
+
};
|
|
189
|
+
const ticket = inferTicketKey(ctx.git_branch, null) ?? ctx.cwd_ticket ?? null;
|
|
190
|
+
return withRoute(finalizeEnvelope({
|
|
191
|
+
provider: CODEX_PROVIDER,
|
|
192
|
+
provider_schema_version: ctx.cli_version ?? null,
|
|
193
|
+
harness_session_id: session,
|
|
194
|
+
message_id: messageId,
|
|
195
|
+
occurred_at: str(record.timestamp),
|
|
196
|
+
model: ctx.model ?? null,
|
|
197
|
+
...classes,
|
|
198
|
+
tool_call_count: toolCalls,
|
|
199
|
+
is_sidechain: Boolean(ctx.subagent_thread),
|
|
200
|
+
usage_final: usageFinal,
|
|
201
|
+
// The server's rule: Codex is 'measured' when input/cached/output are present.
|
|
202
|
+
usage_quality: usageQuality({
|
|
203
|
+
usageFinal, classes: [classes.codex_input_tokens, classes.codex_cached_input_tokens, classes.codex_output_tokens],
|
|
204
|
+
}),
|
|
205
|
+
git_branch: ctx.git_branch ?? null,
|
|
206
|
+
repo: ctx.repo ?? null,
|
|
207
|
+
commit_sha: ctx.commit_sha ?? null,
|
|
208
|
+
ticket_key_inferred: ticket,
|
|
209
|
+
attribution_confidence: ticket ? "inferred" : null,
|
|
210
|
+
environment,
|
|
211
|
+
cwd_hmac: ctx.cwd_hmac ?? null,
|
|
212
|
+
source_hmac: sourceHmac,
|
|
213
|
+
source_offset: offset,
|
|
214
|
+
parser_version: CODEX_PARSER_VERSION,
|
|
215
|
+
}), ctx.cwd_tenant ?? null);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
provider: CODEX_PROVIDER,
|
|
220
|
+
parserVersion: CODEX_PARSER_VERSION,
|
|
221
|
+
/** One file's parser; token_count rows are complete when written, so nothing is ever held back. */
|
|
222
|
+
beginFile({ sourceHmac, ctx }) {
|
|
223
|
+
const envelopes = [];
|
|
224
|
+
const stats = { refused: 0, withheld: 0 };
|
|
225
|
+
return {
|
|
226
|
+
stats,
|
|
227
|
+
parseRecord(record, offset) {
|
|
228
|
+
const result = parseCodexRecord(record, { offset, sourceHmac, ctx });
|
|
229
|
+
stats.refused += result.refused;
|
|
230
|
+
envelopes.push(...result.envelopes);
|
|
231
|
+
},
|
|
232
|
+
finish() { return { envelopes, holdOffset: null }; },
|
|
233
|
+
};
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// BOT-803 — per-file byte-offset cursors for usage sync.
|
|
2
|
+
//
|
|
3
|
+
// `~/.botbuddy/usage-cursors.json` (0600) maps each transcript's source HMAC
|
|
4
|
+
// (never its path) to the byte offset just past the last COMPLETE line that was
|
|
5
|
+
// successfully delivered, plus the adapter's derived per-file context. A live
|
|
6
|
+
// writer's half-written last line is never consumed, so the next run resumes on
|
|
7
|
+
// it. Writes are tmp-file + rename and merge with whatever is on disk at write
|
|
8
|
+
// time — no lock, so a pre-push run can never wait on the daily sweep (BOT-1023
|
|
9
|
+
// AC4). A lost race only means a later run re-reads a few lines; the server's
|
|
10
|
+
// idempotency key makes that a no-op.
|
|
11
|
+
import { open, readFile, rename, mkdir, writeFile, unlink } from "node:fs/promises";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
export const CURSOR_FILE = "usage-cursors.json";
|
|
17
|
+
export const MAX_RUNS_KEPT = 20;
|
|
18
|
+
const CHUNK = 1 << 20;
|
|
19
|
+
const NEWLINE = 0x0a;
|
|
20
|
+
|
|
21
|
+
export function cursorPath(home = homedir()) {
|
|
22
|
+
return join(home, ".botbuddy", CURSOR_FILE);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const emptyState = () => ({ schema_version: 1, files: {}, runs: [] });
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Load the cursor state. A MISSING file is an empty state; a corrupt or
|
|
29
|
+
* unknown-schema file fails closed with `cursor_state_invalid` (Codex #982 R7):
|
|
30
|
+
* reading it as empty would drop the offsets that keep partly read files in
|
|
31
|
+
* scope and then overwrite them. The file is left untouched for the user —
|
|
32
|
+
* delete it to start over (every transcript in --since is re-read once and the
|
|
33
|
+
* server dedupes).
|
|
34
|
+
*/
|
|
35
|
+
export async function loadCursorState(path) {
|
|
36
|
+
let text;
|
|
37
|
+
try {
|
|
38
|
+
text = await readFile(path, "utf8");
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error?.code === "ENOENT") return emptyState();
|
|
41
|
+
throw Object.assign(new Error("usage cursor state is unreadable"), { code: "cursor_state_invalid" });
|
|
42
|
+
}
|
|
43
|
+
let parsed;
|
|
44
|
+
try { parsed = JSON.parse(text); } catch { parsed = null; }
|
|
45
|
+
if (!parsed || typeof parsed !== "object" || parsed.schema_version !== 1
|
|
46
|
+
|| (parsed.files !== undefined && (typeof parsed.files !== "object" || parsed.files === null || Array.isArray(parsed.files)))) {
|
|
47
|
+
throw Object.assign(new Error("usage cursor state is corrupt or from another version"), { code: "cursor_state_invalid" });
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
schema_version: 1,
|
|
51
|
+
files: parsed.files ?? {},
|
|
52
|
+
runs: Array.isArray(parsed.runs) ? parsed.runs : [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Merge `updates` (source_hmac → entry) and `runs` into the on-disk state and
|
|
58
|
+
* write it atomically. For a file both sides know, the entry with the larger
|
|
59
|
+
* offset wins — two overlapping triggers can only move a cursor forward.
|
|
60
|
+
*/
|
|
61
|
+
export async function saveCursorState(path, { files = {}, runs = [] } = {}) {
|
|
62
|
+
const current = await loadCursorState(path);
|
|
63
|
+
for (const [key, entry] of Object.entries(files)) {
|
|
64
|
+
const existing = current.files[key];
|
|
65
|
+
if (!existing || !(existing.offset > entry.offset) || entry.reset === true) {
|
|
66
|
+
const { reset: _reset, ...clean } = entry;
|
|
67
|
+
current.files[key] = clean;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
current.runs = [...current.runs, ...runs].slice(-MAX_RUNS_KEPT);
|
|
71
|
+
await mkdir(join(path, ".."), { recursive: true, mode: 0o700 });
|
|
72
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
73
|
+
try {
|
|
74
|
+
await writeFile(tmp, `${JSON.stringify(current)}\n`, { mode: 0o600 });
|
|
75
|
+
await rename(tmp, path);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
await unlink(tmp).catch(() => {});
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
return current;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A transcript line longer than this is hostile or corrupt: skipped, never buffered. */
|
|
84
|
+
export const MAX_LINE_BYTES = 16 * 1024 * 1024;
|
|
85
|
+
const STOP_CHECK_EVERY = 256;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Stream the complete lines of `path` starting at byte `fromOffset`.
|
|
89
|
+
* `onLine(text, lineStartOffset)` is called per newline-terminated line (a
|
|
90
|
+
* trailing CR is stripped). Returns the offset just past the last line handed
|
|
91
|
+
* out, whether an unterminated tail (a live writer mid-line) was left unread,
|
|
92
|
+
* whether the budget stopped the read, and how many oversized lines were
|
|
93
|
+
* skipped. `shouldStop()` is polled between chunks AND every 256 lines, so one
|
|
94
|
+
* 1 MiB chunk of tiny lines cannot outlive the budget; a line longer than
|
|
95
|
+
* `maxLineBytes` is dropped (counted) instead of growing the buffer unbounded.
|
|
96
|
+
*/
|
|
97
|
+
export async function readCompleteLines(path, fromOffset, onLine, { shouldStop = () => false, maxLineBytes = MAX_LINE_BYTES } = {}) {
|
|
98
|
+
const handle = await open(path, "r");
|
|
99
|
+
try {
|
|
100
|
+
let position = fromOffset;
|
|
101
|
+
let consumed = fromOffset;
|
|
102
|
+
let carry = Buffer.alloc(0);
|
|
103
|
+
const buffer = Buffer.alloc(CHUNK);
|
|
104
|
+
let stopped = false;
|
|
105
|
+
let skipping = false;
|
|
106
|
+
let oversized = 0;
|
|
107
|
+
let lines = 0;
|
|
108
|
+
outer: for (;;) {
|
|
109
|
+
if (shouldStop()) { stopped = true; break; }
|
|
110
|
+
const { bytesRead } = await handle.read(buffer, 0, CHUNK, position);
|
|
111
|
+
if (bytesRead === 0) break;
|
|
112
|
+
position += bytesRead;
|
|
113
|
+
let data = carry.length ? Buffer.concat([carry, buffer.subarray(0, bytesRead)]) : Buffer.from(buffer.subarray(0, bytesRead));
|
|
114
|
+
let start = 0;
|
|
115
|
+
if (skipping) {
|
|
116
|
+
const nl = data.indexOf(NEWLINE);
|
|
117
|
+
if (nl === -1) { consumed += data.length; carry = Buffer.alloc(0); continue; }
|
|
118
|
+
start = nl + 1;
|
|
119
|
+
skipping = false;
|
|
120
|
+
}
|
|
121
|
+
for (;;) {
|
|
122
|
+
const nl = data.indexOf(NEWLINE, start);
|
|
123
|
+
if (nl === -1) break;
|
|
124
|
+
let end = nl;
|
|
125
|
+
if (end > start && data[end - 1] === 0x0d) end -= 1;
|
|
126
|
+
if (nl - start > maxLineBytes) oversized += 1;
|
|
127
|
+
else onLine(data.toString("utf8", start, end), consumed + start);
|
|
128
|
+
start = nl + 1;
|
|
129
|
+
lines += 1;
|
|
130
|
+
if (lines % STOP_CHECK_EVERY === 0 && shouldStop()) {
|
|
131
|
+
stopped = true;
|
|
132
|
+
consumed += start;
|
|
133
|
+
carry = Buffer.alloc(0);
|
|
134
|
+
break outer;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
consumed += start;
|
|
138
|
+
carry = data.subarray(start);
|
|
139
|
+
if (carry.length > maxLineBytes) {
|
|
140
|
+
oversized += 1;
|
|
141
|
+
consumed += carry.length;
|
|
142
|
+
carry = Buffer.alloc(0);
|
|
143
|
+
skipping = true;
|
|
144
|
+
}
|
|
145
|
+
data = null;
|
|
146
|
+
}
|
|
147
|
+
return { endOffset: consumed, partialTail: carry.length > 0, stopped, oversized };
|
|
148
|
+
} finally {
|
|
149
|
+
await handle.close();
|
|
150
|
+
}
|
|
151
|
+
}
|