@adecore/agents 0.41.1 → 0.43.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.
@@ -1,18 +1,19 @@
1
1
  import { createHash, type Hash } from 'node:crypto';
2
- import { mkdir, open, readFile, readdir, stat } from 'node:fs/promises';
2
+ import { open, readFile, readdir, stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { Readable } from 'node:stream';
5
5
  import { createZstdDecompress } from 'node:zlib';
6
6
  import type { UsageProvider, UsageRoot } from '@adecore/agent-contracts';
7
7
  import { errorText } from '../error-text.ts';
8
- import { isNotFound, writeAtomic } from '../fs.ts';
9
- import { decodeIndex, encodeIndex, type IndexedFile, type UsageIndex } from './index-file.ts';
8
+ import { isNotFound } from '../fs.ts';
10
9
  import { foldByKey, type UsageRecord } from './record.ts';
11
10
  import { claudeMightCarryUsage, parseClaudeLine } from './readers/claude.ts';
12
11
  import { GROK_USAGE_FILE, parseGrokUsage } from './readers/grok.ts';
13
12
  import { hostLogMightCarryUsage, parseHostUsageLine } from './readers/host-log.ts';
14
13
  import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine, type CodexParserState } from './readers/codex.ts';
15
14
  import { usageRoots, type UsageRootPath } from './roots.ts';
15
+ import type { ScanState } from './scan-state.ts';
16
+ import { UsageStore } from './usage-store.ts';
16
17
 
17
18
  const HASH_CHUNK_BYTES = 65536;
18
19
  // 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.
@@ -115,6 +116,11 @@ function isUsageFile(provider: UsageProvider, name: string): boolean {
115
116
  return readsWhole(provider) ? name === GROK_USAGE_FILE : name.endsWith('.jsonl') || (provider === 'codex' && name.endsWith('.jsonl.zst'));
116
117
  }
117
118
 
119
+ /* A compressed Codex session is the same transcript as the plain one it was, so both count under one path. */
120
+ function keyOf(path: string, provider: UsageProvider): string {
121
+ return provider === 'codex' && path.endsWith('.jsonl.zst') ? path.slice(0, -4) : path;
122
+ }
123
+
118
124
  async function listUsageFiles(dir: string, provider: UsageProvider, out: string[] = []): Promise<string[]> {
119
125
  let entries;
120
126
 
@@ -266,47 +272,37 @@ function withAccount(records: UsageRecord[], accountOf: AccountOf): UsageRecord[
266
272
 
267
273
  /*
268
274
  * Parses only what a transcript appended since the last scan, and starts a file over once it was
269
- * rewritten or cut. A deleted transcript keeps its totals, since its usage still happened.
275
+ * rewritten or cut. A deleted transcript keeps its totals, since its usage still happened; only
276
+ * where the scan left it is forgotten.
270
277
  */
271
278
  export class UsageScanner {
272
- private readonly home: string;
273
279
  private readonly roots: () => UsageRootPath[];
274
280
  private readonly sessionAccounts: () => ReadonlyMap<string, string>;
275
- private index: UsageIndex = new Map();
281
+ private readonly store: UsageStore;
276
282
  private loaded = false;
277
- private dirty = false;
278
283
 
279
284
  /* `roots` as a function is asked again on every scan, so an account added since is walked too. */
280
285
  constructor(home: string, roots: UsageRootPath[] | (() => UsageRootPath[]) = usageRoots(), options: UsageScannerOptions = {}) {
281
- this.home = home;
286
+ this.store = new UsageStore(join(home, 'usage'));
282
287
  this.roots = typeof roots === 'function' ? roots : () => roots;
283
288
  this.sessionAccounts = options.sessionAccounts ?? (() => new Map());
284
289
  }
285
290
 
286
- private get file(): string {
287
- return join(this.home, 'usage', 'index.json');
288
- }
289
-
291
+ /* A history that does not read fails the scan rather than being written over, which would lose what only it holds. */
290
292
  async load(): Promise<void> {
291
293
  if (this.loaded) {
292
294
  return;
293
295
  }
294
296
 
297
+ await this.store.load();
295
298
  this.loaded = true;
296
-
297
- try {
298
- this.index = decodeIndex(await readFile(this.file, 'utf8'));
299
- } catch (e) {
300
- if (!isNotFound(e)) {
301
- console.warn('The usage index would not read; starting from a cold scan:', errorText(e));
302
- }
303
- }
304
299
  }
305
300
 
306
301
  async scan(): Promise<ScanReport> {
307
302
  const started = Date.now();
308
303
  await this.load();
309
304
  const roots: UsageRoot[] = [];
305
+ const seen = new Set<string>();
310
306
  let files = 0;
311
307
  let changedFiles = 0;
312
308
  let sessions: ReadonlyMap<string, string> | null = null;
@@ -350,6 +346,7 @@ export class UsageScanner {
350
346
  }
351
347
 
352
348
  files += 1;
349
+ seen.add(keyOf(path, root.provider));
353
350
 
354
351
  try {
355
352
  if (await this.readFileInto(path, root.provider, accountOf)) {
@@ -364,31 +361,28 @@ export class UsageScanner {
364
361
  roots.push({ provider: root.provider, path: root.path, status: failure === null ? 'ok' : 'failed', message: failure });
365
362
  }
366
363
 
367
- if (this.dirty) {
368
- this.dirty = false;
369
- await mkdir(join(this.home, 'usage'), { recursive: true, mode: 0o700 });
370
- await writeAtomic(this.file, encodeIndex(this.index));
364
+ // A root that is gone for now costs a read from the start once it is back, which replaces and never adds.
365
+ for (const path of [...this.store.state.keys()]) {
366
+ if (!seen.has(path)) {
367
+ this.store.forget(path);
368
+ }
371
369
  }
372
370
 
371
+ await this.store.commit();
372
+
373
373
  return { at: Date.now(), files, changedFiles, durationMs: Date.now() - started, roots };
374
374
  }
375
375
 
376
- /* Every record the index holds, with the copies one message left in several transcripts folded. */
376
+ /* Every record the history holds, with the copies one message left in several transcripts folded. */
377
377
  records(): UsageRecord[] {
378
- const all: UsageRecord[] = [];
379
-
380
- for (const file of this.index.values()) {
381
- all.push(...file.records, ...file.tail);
382
- }
383
-
384
- return foldByKey(all);
378
+ return this.store.records();
385
379
  }
386
380
 
387
381
  private async readFileInto(path: string, provider: UsageProvider, accountOf: AccountOf): Promise<boolean> {
388
382
  const info = await stat(path);
389
383
  const compressed = provider === 'codex' && path.endsWith('.jsonl.zst');
390
- const key = compressed ? path.slice(0, -4) : path;
391
- const known = this.index.get(key);
384
+ const key = keyOf(path, provider);
385
+ const known = this.store.state.get(key);
392
386
  const currentParser = provider !== 'codex' || known?.parserVersion === CODEX_PARSER_VERSION;
393
387
 
394
388
  if (
@@ -421,29 +415,30 @@ export class UsageScanner {
421
415
  const hash = grown === null || prefix === null ? createHash('sha256') : prefix;
422
416
  const from = grown?.offset ?? 0;
423
417
  const parsed = await parseLines(readBytes(path, from, compressed), hash, provider, from, grown?.codex ?? null);
424
- const before = grown?.records ?? [];
418
+ const usage = { provider, records: withAccount(parsed.records, accountOf), tail: withAccount(parsed.tail, accountOf) };
425
419
 
426
- const entry: IndexedFile = {
420
+ const state: ScanState = {
427
421
  provider,
428
422
  size: info.size,
429
423
  mtimeMs: info.mtimeMs,
430
424
  offset: parsed.offset,
431
- // Folding here keeps the streaming copies of one message out of the index entirely.
432
- records: foldByKey([...before, ...withAccount(parsed.records, accountOf)]),
433
- tail: withAccount(parsed.tail, accountOf),
434
425
  codex: parsed.codex,
435
426
  head: headOf(hash),
436
427
  ...(provider === 'codex' ? { parserVersion: CODEX_PARSER_VERSION, compressed } : {})
437
428
  };
438
429
 
439
- this.index.set(key, entry);
440
- this.dirty = true;
430
+ if (grown === null) {
431
+ // Folding here keeps the streaming copies of one message out of the history entirely.
432
+ this.store.replace(key, { ...usage, records: foldByKey(usage.records) }, state);
433
+ } else {
434
+ this.store.append(key, usage, state);
435
+ }
441
436
 
442
437
  return true;
443
438
  }
444
439
 
445
440
  /*
446
- * A file rewritten as a whole replaces what the index held of it, so a turn it still lists is never
441
+ * A file rewritten as a whole replaces what the history held of it, so a turn it still lists is never
447
442
  * counted twice. One that does not parse, such as one caught halfway through a write, keeps the
448
443
  * records it had and is read again on the next scan.
449
444
  */
@@ -454,18 +449,8 @@ export class UsageScanner {
454
449
  return false;
455
450
  }
456
451
 
457
- this.index.set(path, {
458
- provider,
459
- size: info.size,
460
- mtimeMs: info.mtimeMs,
461
- offset: info.size,
462
- records: foldByKey(withAccount(records, accountOf)),
463
- tail: [],
464
- codex: null,
465
- head: null
466
- });
467
-
468
- this.dirty = true;
452
+ const state: ScanState = { provider, size: info.size, mtimeMs: info.mtimeMs, offset: info.size, codex: null, head: null };
453
+ this.store.replace(path, { provider, records: foldByKey(withAccount(records, accountOf)), tail: [] }, state);
469
454
 
470
455
  return true;
471
456
  }
@@ -0,0 +1,237 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import { appendFile, mkdir, readFile, rename, truncate } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { errorText } from '../error-text.ts';
6
+ import { isNotFound, removeFile, writeAtomic } from '../fs.ts';
7
+ import { applyEntry, decodeHistory, encodeEntry, encodeHeader, type FileUsage, type HistoryEntry } from './history-file.ts';
8
+ import { decodeIndex } from './index-file.ts';
9
+ import { foldByKey, type UsageRecord } from './record.ts';
10
+ import { decodeState, encodeState, type ScanState } from './scan-state.ts';
11
+
12
+ const HISTORY_FILE = 'history.jsonl';
13
+ const STATE_FILE = 'scan-state.json';
14
+ const LEGACY_INDEX_FILE = 'index.json';
15
+ // Below this, appended lines are not worth a rewrite of the whole history.
16
+ const COMPACT_FLOOR_BYTES = 1 << 20;
17
+
18
+ async function readOptional(path: string): Promise<Buffer | null> {
19
+ try {
20
+ return await readFile(path);
21
+ } catch (e) {
22
+ if (isNotFound(e)) {
23
+ return null;
24
+ }
25
+
26
+ throw e;
27
+ }
28
+ }
29
+
30
+ /*
31
+ * The usage of every transcript ever read, kept apart from where the scanner left each one. The state
32
+ * is saved after the lines it accounts for, against the history's size, so after a crash in between it
33
+ * does not match: it is dropped and every transcript is read from the start again, which replaces what
34
+ * a path counted instead of adding to it.
35
+ */
36
+ export class UsageStore {
37
+ readonly usage = new Map<string, FileUsage>();
38
+ readonly state = new Map<string, ScanState>();
39
+ private readonly dir: string;
40
+ private pending: HistoryEntry[] = [];
41
+ private stateDirty = false;
42
+ private gen: string | null = null;
43
+ private base = 0;
44
+ private bytes = 0;
45
+ // Set when the history on disk may not end where this store thinks it does.
46
+ private rewriteNext = false;
47
+
48
+ constructor(dir: string) {
49
+ this.dir = dir;
50
+ }
51
+
52
+ async load(): Promise<void> {
53
+ this.usage.clear();
54
+ this.state.clear();
55
+ const history = await readOptional(join(this.dir, HISTORY_FILE));
56
+
57
+ if (history === null) {
58
+ await this.migrate();
59
+
60
+ return;
61
+ }
62
+
63
+ const decoded = decodeHistory(history);
64
+
65
+ for (const [path, usage] of decoded.usage) {
66
+ this.usage.set(path, usage);
67
+ }
68
+
69
+ if (decoded.complete < history.length) {
70
+ await truncate(join(this.dir, HISTORY_FILE), decoded.complete);
71
+ }
72
+
73
+ this.gen = decoded.header?.gen ?? null;
74
+ this.base = decoded.header?.base ?? 0;
75
+ this.bytes = decoded.complete;
76
+ this.rewriteNext = this.gen === null;
77
+
78
+ const saved = await readOptional(join(this.dir, STATE_FILE));
79
+ const state = saved === null ? null : decodeState(saved.toString('utf8'));
80
+
81
+ if (state !== null && state.history.gen === this.gen && state.history.bytes === this.bytes) {
82
+ for (const [path, file] of state.files) {
83
+ this.state.set(path, file);
84
+ }
85
+ }
86
+
87
+ // A migration that stopped after writing the history; the index has nothing the history lacks.
88
+ await removeFile(join(this.dir, LEGACY_INDEX_FILE));
89
+ }
90
+
91
+ records(): UsageRecord[] {
92
+ const all: UsageRecord[] = [];
93
+
94
+ for (const file of this.usage.values()) {
95
+ all.push(...file.records, ...file.tail);
96
+ }
97
+
98
+ return foldByKey(all);
99
+ }
100
+
101
+ /* A transcript read from the start: what it counts now replaces what it counted. */
102
+ replace(path: string, usage: FileUsage, state: ScanState): void {
103
+ this.setState(path, state);
104
+
105
+ if (isDeepStrictEqual(this.usage.get(path), usage)) {
106
+ return;
107
+ }
108
+
109
+ this.usage.set(path, usage);
110
+ this.pending.push({ path, op: 'set', usage });
111
+ }
112
+
113
+ /* A transcript read from where it was left: `added` counts on top of what it had. */
114
+ append(path: string, added: FileUsage, state: ScanState): void {
115
+ this.setState(path, state);
116
+ const known = this.usage.get(path);
117
+
118
+ if (added.records.length === 0 && known?.provider === added.provider && isDeepStrictEqual(known.tail, added.tail)) {
119
+ return;
120
+ }
121
+
122
+ const entry: HistoryEntry = { path, op: 'add', usage: { ...added, records: foldByKey(added.records) } };
123
+ applyEntry(this.usage, entry);
124
+ this.pending.push(entry);
125
+ }
126
+
127
+ /* Where the scanner left a transcript that is gone; what it counted stays. */
128
+ forget(path: string): void {
129
+ if (this.state.delete(path)) {
130
+ this.stateDirty = true;
131
+ }
132
+ }
133
+
134
+ async commit(): Promise<void> {
135
+ if (this.pending.length === 0 && !this.stateDirty) {
136
+ return;
137
+ }
138
+
139
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
140
+
141
+ try {
142
+ if (this.rewriteNext || (this.pending.length > 0 && this.gen === null)) {
143
+ await this.rewrite();
144
+ } else if (this.pending.length > 0) {
145
+ await this.appendPending();
146
+ }
147
+
148
+ await this.saveState();
149
+ } catch (e) {
150
+ // What failed to land is in memory, and a rewrite puts all of it on disk.
151
+ this.pending = [];
152
+ this.rewriteNext = true;
153
+
154
+ throw e;
155
+ }
156
+ }
157
+
158
+ private setState(path: string, state: ScanState): void {
159
+ this.state.set(path, state);
160
+ this.stateDirty = true;
161
+ }
162
+
163
+ private async appendPending(): Promise<void> {
164
+ const text = this.pending.map(encodeEntry).join('');
165
+ this.pending = [];
166
+ await appendFile(join(this.dir, HISTORY_FILE), text, { mode: 0o600 });
167
+ this.bytes += Buffer.byteLength(text);
168
+
169
+ if (this.bytes > 2 * this.base && this.bytes - this.base > COMPACT_FLOOR_BYTES) {
170
+ await this.rewrite();
171
+ }
172
+ }
173
+
174
+ /* The history as one `set` line per path, which drops every line a later one superseded. */
175
+ private async rewrite(): Promise<void> {
176
+ const lines = [...this.usage].map(([path, usage]) => encodeEntry({ path, op: 'set', usage }));
177
+ const gen = randomUUID();
178
+ const body = lines.join('');
179
+ const base = Buffer.byteLength(body);
180
+ const header = encodeHeader({ gen, base });
181
+
182
+ this.pending = [];
183
+ await writeAtomic(join(this.dir, HISTORY_FILE), header + body);
184
+ this.gen = gen;
185
+ this.base = base;
186
+ this.bytes = Buffer.byteLength(header + body);
187
+ this.rewriteNext = false;
188
+ }
189
+
190
+ private async saveState(): Promise<void> {
191
+ if (this.gen === null) {
192
+ return;
193
+ }
194
+
195
+ await writeAtomic(join(this.dir, STATE_FILE), encodeState(this.state, { gen: this.gen, bytes: this.bytes }));
196
+ this.stateDirty = false;
197
+ }
198
+
199
+ /*
200
+ * Moves an index of the single-file format into history and scan state. The history goes first:
201
+ * once it is there, a load treats the index as moved, so an interrupted migration finishes on the next load.
202
+ */
203
+ private async migrate(): Promise<void> {
204
+ const legacy = join(this.dir, LEGACY_INDEX_FILE);
205
+ const text = await readOptional(legacy);
206
+
207
+ if (text === null) {
208
+ return;
209
+ }
210
+
211
+ const index = decodeIndex(text.toString('utf8'));
212
+
213
+ if (index === null) {
214
+ console.warn('The usage index would not read; it is kept beside a fresh history.');
215
+ await rename(legacy, `${legacy}.unreadable`);
216
+
217
+ return;
218
+ }
219
+
220
+ for (const [path, file] of index) {
221
+ const { records, tail, provider, ...state } = file;
222
+ this.usage.set(path, { provider, records, tail });
223
+ this.state.set(path, { provider, ...state });
224
+ }
225
+
226
+ try {
227
+ await this.rewrite();
228
+ await this.saveState();
229
+ await removeFile(legacy);
230
+ } catch (e) {
231
+ // The index stays and is moved again on the next load; this run counts from memory.
232
+ this.rewriteNext = true;
233
+ this.stateDirty = true;
234
+ console.warn('The usage index could not be moved to the history yet:', errorText(e));
235
+ }
236
+ }
237
+ }