@adecore/agents 0.31.8 → 0.31.10

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.
@@ -1,7 +1,8 @@
1
1
  import { createHash, type Hash } from 'node:crypto';
2
2
  import { mkdir, open, readFile, readdir, stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { zstdDecompressSync } from 'node:zlib';
4
+ import { Readable } from 'node:stream';
5
+ import { createZstdDecompress } from 'node:zlib';
5
6
  import type { UsageProvider, UsageRoot } from '@adecore/agent-contracts';
6
7
  import { errorText } from '../error-text.ts';
7
8
  import { isNotFound, writeAtomic } from '../fs.ts';
@@ -14,49 +15,67 @@ import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine
14
15
  import { usageRoots, type UsageRootPath } from './roots.ts';
15
16
 
16
17
  const HASH_CHUNK_BYTES = 65536;
18
+ // How much of a transcript is read at once. Codex writes sessions of more than a gigabyte, which read whole held several times their size.
19
+ const READ_CHUNK_BYTES = 1 << 20;
17
20
  const CODEX_PARSER_VERSION = 2;
21
+ const NEWLINE = 0x0a;
18
22
 
19
- interface HashedRead {
20
- /* Everything from `from` to the end of the file, as it is at the read. */
21
- bytes: Buffer;
22
- /* Over the bytes before `from`, left open so the caller can carry it on over `bytes`. */
23
- hash: Hash;
24
- }
25
-
26
- /*
27
- * Hashes the bytes before `from` and returns the rest, in one pass over one handle, so verifying
28
- * what a transcript committed and parsing what it added never reads a byte twice.
29
- */
30
- async function readHashed(path: string, from: number): Promise<HashedRead> {
23
+ /* Hashes the first `length` bytes of a file into `hash`, a chunk at a time. */
24
+ async function hashPrefix(path: string, length: number, hash: Hash): Promise<Hash> {
31
25
  const handle = await open(path, 'r');
32
26
  try {
33
- const hash = createHash('sha256');
34
- const chunk = Buffer.alloc(Math.min(from, HASH_CHUNK_BYTES));
27
+ const chunk = Buffer.alloc(Math.min(length, HASH_CHUNK_BYTES));
35
28
  let offset = 0;
36
- while (offset < from) {
37
- const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, from - offset), offset);
29
+ while (offset < length) {
30
+ const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, length - offset), offset);
38
31
  if (bytesRead === 0) {
39
32
  break;
40
33
  }
41
34
  hash.update(chunk.subarray(0, bytesRead));
42
35
  offset += bytesRead;
43
36
  }
44
- const { size } = await handle.stat();
45
- const bytes = Buffer.alloc(Math.max(0, size - from));
46
- let filled = 0;
47
- while (filled < bytes.length) {
48
- const { bytesRead } = await handle.read(bytes, filled, bytes.length - filled, from + filled);
49
- if (bytesRead === 0) {
50
- break;
51
- }
52
- filled += bytesRead;
53
- }
54
- return { bytes: bytes.subarray(0, filled), hash };
37
+ return hash;
55
38
  } finally {
56
39
  await handle.close();
57
40
  }
58
41
  }
59
42
 
43
+ export interface LineRead {
44
+ /* The bytes of every whole line, newlines included. */
45
+ consumed: number;
46
+ /* What follows the last newline: a line still being written. */
47
+ rest: string;
48
+ }
49
+
50
+ /*
51
+ * Hands every whole line of a byte stream to `onLine` and hashes it with its newline, holding no more
52
+ * than a chunk and the line it is in. Lines are split on the byte, which never occurs inside a UTF-8 sequence.
53
+ */
54
+ export async function forEachLine(chunks: AsyncIterable<Uint8Array>, hash: Hash, onLine: (line: string) => void): Promise<LineRead> {
55
+ let consumed = 0;
56
+ let pending: Buffer[] = [];
57
+ for await (const piece of chunks) {
58
+ const chunk = Buffer.from(piece.buffer, piece.byteOffset, piece.byteLength);
59
+ let start = 0;
60
+ let newline = chunk.indexOf(NEWLINE, start);
61
+ while (newline !== -1) {
62
+ const end = newline + 1;
63
+ const line = pending.length === 0 ? chunk.subarray(start, end) : Buffer.concat([...pending, chunk.subarray(start, end)]);
64
+ pending = [];
65
+ hash.update(line);
66
+ consumed += line.length;
67
+ onLine(line.toString('utf8', 0, line.length - 1));
68
+ start = end;
69
+ newline = chunk.indexOf(NEWLINE, start);
70
+ }
71
+ if (start < chunk.length) {
72
+ // A copy: the stream may hand the same memory out again for its next chunk.
73
+ pending.push(Buffer.from(chunk.subarray(start)));
74
+ }
75
+ }
76
+ return { consumed, rest: Buffer.concat(pending).toString('utf8') };
77
+ }
78
+
60
79
  /* Covers every committed byte, since a transcript can be rewritten after an unchanged session header. */
61
80
  function headOf(hash: Hash): string {
62
81
  // Older indexes hashed only the first 4 KB; they cannot establish that the rest stayed unchanged.
@@ -106,54 +125,76 @@ async function listUsageFiles(dir: string, provider: UsageProvider, out: string[
106
125
  return out;
107
126
  }
108
127
 
128
+ /* The bytes of a file from `from` to its end, a chunk at a time. */
129
+ async function* fileChunks(path: string, from: number): AsyncGenerator<Uint8Array> {
130
+ const handle = await open(path, 'r');
131
+ try {
132
+ let position = from;
133
+ for (;;) {
134
+ // A buffer per chunk: a decompressor may still hold the one before.
135
+ const chunk = Buffer.alloc(READ_CHUNK_BYTES);
136
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
137
+ if (bytesRead === 0) {
138
+ return;
139
+ }
140
+ position += bytesRead;
141
+ yield chunk.subarray(0, bytesRead);
142
+ }
143
+ } finally {
144
+ await handle.close();
145
+ }
146
+ }
147
+
148
+ /* The bytes of a file from `from` on, decompressed for a `.zst` one; a failure on either side ends the iteration with it. */
149
+ function readBytes(path: string, from: number, compressed: boolean): AsyncIterable<Uint8Array> {
150
+ if (!compressed) {
151
+ return fileChunks(path, from);
152
+ }
153
+ const source = Readable.from(fileChunks(path, from));
154
+ const decompressed = createZstdDecompress();
155
+ source.on('error', (error) => decompressed.destroy(error));
156
+ return source.pipe(decompressed);
157
+ }
158
+
159
+ /* The usage record of one transcript line, when it carries one. */
160
+ function parseLine(raw: string, provider: UsageProvider, codex: CodexParserState | null): UsageRecord | null {
161
+ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
162
+ if (line === '') {
163
+ return null;
164
+ }
165
+ if (provider === 'claude') {
166
+ return claudeMightCarryUsage(line) ? parseClaudeLine(line) : null;
167
+ }
168
+ if (provider === 'codex') {
169
+ return codex !== null && codexMightCarryUsage(line) ? parseCodexLine(line, codex) : null;
170
+ }
171
+ // Claude Code and Codex write transcripts of their own, Grok a file read whole; every other provider is a log its host keeps.
172
+ return hostLogMightCarryUsage(line) ? parseHostUsageLine(line, provider) : null;
173
+ }
174
+
109
175
  /*
110
176
  * Reads what a file added since the last scan. Only whole lines count towards the offset; the piece
111
177
  * after the last newline is a line still being written, so it is parsed for what it is worth and
112
178
  * read again next time. The Codex state is snapshotted before that tail for the same reason.
113
179
  */
114
- function parseChunk(text: string, provider: UsageProvider, from: number, state: CodexParserState | null): ParsedFile {
115
- const end = text.lastIndexOf('\n');
116
- const whole = end === -1 ? '' : text.slice(0, end);
117
- const tail = text.slice(end + 1);
118
- const read = (chunk: string, codex: CodexParserState | null): UsageRecord[] => {
119
- const records: UsageRecord[] = [];
120
- for (const raw of chunk.split('\n')) {
121
- const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
122
- if (line === '') {
123
- continue;
124
- }
125
- if (provider === 'claude') {
126
- if (claudeMightCarryUsage(line)) {
127
- const record = parseClaudeLine(line);
128
- if (record !== null) {
129
- records.push(record);
130
- }
131
- }
132
- continue;
133
- }
134
- if (provider === 'codex') {
135
- if (codex !== null && codexMightCarryUsage(line)) {
136
- const record = parseCodexLine(line, codex);
137
- if (record !== null) {
138
- records.push(record);
139
- }
140
- }
141
- continue;
142
- }
143
- // Claude Code and Codex write transcripts of their own, Grok a file read whole; every other provider is a log its host keeps.
144
- if (hostLogMightCarryUsage(line)) {
145
- const record = parseHostUsageLine(line, provider);
146
- if (record !== null) {
147
- records.push(record);
148
- }
149
- }
150
- }
151
- return records;
152
- };
180
+ async function parseLines(
181
+ chunks: AsyncIterable<Uint8Array>,
182
+ hash: Hash,
183
+ provider: UsageProvider,
184
+ from: number,
185
+ state: CodexParserState | null
186
+ ): Promise<ParsedFile> {
153
187
  const codex = provider === 'codex' ? (state ?? createCodexState()) : null;
154
- const counted = read(whole, codex);
188
+ const records: UsageRecord[] = [];
189
+ const read = await forEachLine(chunks, hash, (line) => {
190
+ const record = parseLine(line, provider, codex);
191
+ if (record !== null) {
192
+ records.push(record);
193
+ }
194
+ });
155
195
  const committed = codex === null ? null : cloneCodexState(codex);
156
- return { records: counted, tail: read(tail, codex), offset: from + (end === -1 ? 0 : Buffer.byteLength(whole, 'utf8') + 1), codex: committed };
196
+ const tail = parseLine(read.rest, provider, codex);
197
+ return { records, tail: tail === null ? [] : [tail], offset: from + read.consumed, codex: committed };
157
198
  }
158
199
 
159
200
  export interface UsageScannerOptions {
@@ -320,18 +361,13 @@ export class UsageScanner {
320
361
  known.provider === provider &&
321
362
  info.size > known.size &&
322
363
  known.head?.startsWith('sha256:') === true;
323
- let read = compressed
324
- ? { bytes: zstdDecompressSync(await readFile(path)), hash: createHash('sha256') }
325
- : await readHashed(path, appended ? known.offset : 0);
326
364
  // A file only counts as grown while it starts the way it did; one rewritten larger is read again from the start.
327
- const grown = appended && headOf(read.hash) === known.head;
328
- if (appended && !grown) {
329
- read = await readHashed(path, 0);
330
- }
331
- const from = grown ? known.offset : 0;
332
- const parsed = parseChunk(read.bytes.toString('utf8'), provider, from, grown ? known.codex : null);
333
- read.hash.update(read.bytes.subarray(0, parsed.offset - from));
334
- const before = grown ? known.records : [];
365
+ const prefix = appended ? await hashPrefix(path, known.offset, createHash('sha256')) : null;
366
+ const grown = prefix !== null && known !== undefined && headOf(prefix) === known.head ? known : null;
367
+ const hash = grown === null || prefix === null ? createHash('sha256') : prefix;
368
+ const from = grown?.offset ?? 0;
369
+ const parsed = await parseLines(readBytes(path, from, compressed), hash, provider, from, grown?.codex ?? null);
370
+ const before = grown?.records ?? [];
335
371
  const entry: IndexedFile = {
336
372
  provider,
337
373
  size: info.size,
@@ -341,7 +377,7 @@ export class UsageScanner {
341
377
  records: foldByKey([...before, ...withAccount(parsed.records, accountOf)]),
342
378
  tail: withAccount(parsed.tail, accountOf),
343
379
  codex: parsed.codex,
344
- head: headOf(read.hash),
380
+ head: headOf(hash),
345
381
  ...(provider === 'codex' ? { parserVersion: CODEX_PARSER_VERSION, compressed } : {})
346
382
  };
347
383
  this.index.set(key, entry);