@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.
@@ -0,0 +1,459 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants, copyFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
3
+ import { copyFile, mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { gunzip, gzip, gzipSync } from 'node:zlib';
7
+ import { CHAT_PAYLOAD_INLINE_BYTES, ChatCheckpointDiffSchema, type ChatCheckpointDiff, type ChatItem, type ChatPayloadRef } from '@adecore/agent-contracts';
8
+ import { isNotFound, writeAtomic, writeAtomicSync } from '../fs.ts';
9
+ import { ChatError } from './errors.ts';
10
+
11
+ /* How much of a value stored apart the item keeps: what a timeline shows before anyone asks for the rest. */
12
+ export const PAYLOAD_PREVIEW_BYTES = 8 * 1024;
13
+
14
+ const FOLDER_SUFFIX = '.payloads';
15
+ const ID_PATTERN = /^[0-9a-f]{64}$/;
16
+ const CONTINUATION_MASK = 0xc0;
17
+ const CONTINUATION = 0x80;
18
+
19
+ const gzipAsync = promisify(gzip);
20
+ const gunzipAsync = promisify(gunzip);
21
+
22
+ /* What a thread needs on disk beside its record: each payload by id, with its text when this process still holds it. */
23
+ export type PayloadSources = Map<string, (() => string) | null>;
24
+
25
+ export interface CompactedItems {
26
+ items: ChatItem[];
27
+ payloads: PayloadSources;
28
+ }
29
+
30
+ interface Compacted {
31
+ item: ChatItem;
32
+ payloads: [string, (() => string) | null][];
33
+ }
34
+
35
+ // Items never change in place, so an item compacted once answers the same for every write of every chat after it.
36
+ const compactedItems = new WeakMap<ChatItem, Compacted>();
37
+
38
+ /* The folder of a chat's payloads beside its record, which `ChatStore.list` passes over since it is no JSON. */
39
+ export function payloadFolderName(chatId: string): string {
40
+ return `${encodeURIComponent(chatId)}${FOLDER_SUFFIX}`;
41
+ }
42
+
43
+ function payloadId(text: string): string {
44
+ return createHash('sha256').update(text).digest('hex');
45
+ }
46
+
47
+ function byteLength(text: string): number {
48
+ return Buffer.byteLength(text, 'utf8');
49
+ }
50
+
51
+ /* Whether a text is past the inline limit, without encoding the short ones: a UTF-16 unit is at most three bytes. */
52
+ function pastInline(text: string): boolean {
53
+ return text.length * 3 > CHAT_PAYLOAD_INLINE_BYTES && byteLength(text) > CHAT_PAYLOAD_INLINE_BYTES;
54
+ }
55
+
56
+ /*
57
+ * The beginning of a text in at most `maxBytes` of UTF-8, never cut inside a character, and cut after
58
+ * the last line break when that keeps at least half of it, so a preview ends on a whole line.
59
+ */
60
+ export function previewOf(text: string, maxBytes: number): string {
61
+ let units = Math.min(text.length, maxBytes);
62
+
63
+ // Half a surrogate pair would encode as a replacement character.
64
+ if (units < text.length && isHighSurrogate(text.charCodeAt(units - 1))) {
65
+ units -= 1;
66
+ }
67
+
68
+ const head = Buffer.from(text.slice(0, units), 'utf8');
69
+ let end = Math.min(head.length, maxBytes);
70
+
71
+ while (end > 0 && end < head.length && (head[end]! & CONTINUATION_MASK) === CONTINUATION) {
72
+ end -= 1;
73
+ }
74
+
75
+ const prefix = head.subarray(0, end).toString('utf8');
76
+
77
+ if (prefix.length === text.length) {
78
+ return text;
79
+ }
80
+
81
+ const lineEnd = prefix.lastIndexOf('\n');
82
+
83
+ return lineEnd >= prefix.length / 2 ? prefix.slice(0, lineEnd + 1) : prefix;
84
+ }
85
+
86
+ function isHighSurrogate(code: number): boolean {
87
+ return code >= 0xd800 && code <= 0xdbff;
88
+ }
89
+
90
+ /* Every file of a diff, with the diffs that fit the preview in order and the rest held back as empty. */
91
+ function diffPreview(diff: ChatCheckpointDiff): ChatCheckpointDiff {
92
+ let budget = PAYLOAD_PREVIEW_BYTES;
93
+
94
+ const files = diff.files.map((file) => {
95
+ const size = byteLength(file.diff);
96
+
97
+ if (size <= budget) {
98
+ budget -= size;
99
+
100
+ return file;
101
+ }
102
+
103
+ budget = 0;
104
+
105
+ return { ...file, diff: '' };
106
+ });
107
+
108
+ return { ...diff, files };
109
+ }
110
+
111
+ function compactOne(item: ChatItem): Compacted {
112
+ if (item.kind === 'tool') {
113
+ if (item.outputRef) {
114
+ return { item, payloads: [[item.outputRef.id, null]] };
115
+ }
116
+
117
+ const output = item.output;
118
+
119
+ if (output === null || !pastInline(output)) {
120
+ return { item, payloads: [] };
121
+ }
122
+
123
+ const ref: ChatPayloadRef = { id: payloadId(output), bytes: byteLength(output) };
124
+
125
+ return { item: { ...item, output: previewOf(output, PAYLOAD_PREVIEW_BYTES), outputRef: ref }, payloads: [[ref.id, () => output]] };
126
+ }
127
+
128
+ if (item.kind === 'turn' && item.checkpointDiff) {
129
+ if (item.checkpointDiffRef) {
130
+ return { item, payloads: [[item.checkpointDiffRef.id, null]] };
131
+ }
132
+
133
+ const json = JSON.stringify(item.checkpointDiff);
134
+
135
+ if (!pastInline(json)) {
136
+ return { item, payloads: [] };
137
+ }
138
+
139
+ const ref: ChatPayloadRef = { id: payloadId(json), bytes: byteLength(json) };
140
+ const diff = item.checkpointDiff;
141
+
142
+ return { item: { ...item, checkpointDiff: diffPreview(diff), checkpointDiffRef: ref }, payloads: [[ref.id, () => JSON.stringify(diff)]] };
143
+ }
144
+
145
+ return { item, payloads: [] };
146
+ }
147
+
148
+ /*
149
+ * A thread with every output and diff past the inline limit cut to its beginning, and what has to be
150
+ * on disk for the rest. A payload is named by the hash of its text, so the same text written twice, or
151
+ * copied in from another chat, is one file.
152
+ */
153
+ export function compactItems(items: readonly ChatItem[]): CompactedItems {
154
+ const payloads: PayloadSources = new Map();
155
+
156
+ const compacted = items.map((item) => {
157
+ let known = compactedItems.get(item);
158
+
159
+ if (known === undefined) {
160
+ known = compactOne(item);
161
+ compactedItems.set(item, known);
162
+ }
163
+
164
+ for (const [id, source] of known.payloads) {
165
+ if (source !== null || !payloads.has(id)) {
166
+ payloads.set(id, source);
167
+ }
168
+ }
169
+
170
+ return known.item;
171
+ });
172
+
173
+ return { items: compacted, payloads };
174
+ }
175
+
176
+ export function payloadRefsOf(items: readonly ChatItem[]): Set<string> {
177
+ const ids = new Set<string>();
178
+
179
+ for (const item of items) {
180
+ if (item.kind === 'tool' && item.outputRef) {
181
+ ids.add(item.outputRef.id);
182
+ } else if (item.kind === 'turn' && item.checkpointDiffRef) {
183
+ ids.add(item.checkpointDiffRef.id);
184
+ }
185
+ }
186
+
187
+ return ids;
188
+ }
189
+
190
+ /*
191
+ * The outputs and diffs of every chat that are too large to keep in its record, one gzip file per
192
+ * payload under `<chats>/<chat id>.payloads/<sha-256>.gz`. Each chat has its own folder, so removing
193
+ * a chat never reaches into a fork that copied from it.
194
+ */
195
+ export class PayloadStore {
196
+ private readonly dir: string;
197
+ // What this process knows is on disk per chat, so a write checks a payload once and not every time.
198
+ private readonly present = new Map<string, Set<string>>();
199
+ // What was written while a prune ran, which that prune must not take away again.
200
+ private readonly writtenDuringPrune = new Map<string, Set<string>>();
201
+
202
+ constructor(dir: string) {
203
+ this.dir = dir;
204
+ }
205
+
206
+ folder(chatId: string): string {
207
+ return join(this.dir, payloadFolderName(chatId));
208
+ }
209
+
210
+ /* Puts every payload on disk that is not there yet: from its text, or copied from another chat that has it. */
211
+ async ensure(chatId: string, payloads: PayloadSources): Promise<void> {
212
+ const present = this.presentIn(chatId);
213
+ const folder = this.folder(chatId);
214
+ let others: string[] | null = null;
215
+
216
+ for (const [id, source] of payloads) {
217
+ if (present.has(id)) {
218
+ continue;
219
+ }
220
+
221
+ const path = join(folder, `${id}.gz`);
222
+
223
+ if (await existsAsync(path)) {
224
+ this.mark(chatId, id);
225
+
226
+ continue;
227
+ }
228
+
229
+ await mkdir(folder, { recursive: true, mode: 0o700 });
230
+
231
+ if (source !== null) {
232
+ await writeAtomic(path, await gzipAsync(source()));
233
+ this.mark(chatId, id);
234
+
235
+ continue;
236
+ }
237
+
238
+ others ??= await this.otherFolders(chatId);
239
+
240
+ if (await copyFromAny(others, id, path)) {
241
+ this.mark(chatId, id);
242
+ }
243
+ }
244
+ }
245
+
246
+ /* `ensure` without a turn of the event loop, for a write on the way out. */
247
+ ensureSync(chatId: string, payloads: PayloadSources): void {
248
+ const present = this.presentIn(chatId);
249
+ const folder = this.folder(chatId);
250
+ let others: string[] | null = null;
251
+
252
+ for (const [id, source] of payloads) {
253
+ if (present.has(id)) {
254
+ continue;
255
+ }
256
+
257
+ const path = join(folder, `${id}.gz`);
258
+
259
+ if (existsSync(path)) {
260
+ this.mark(chatId, id);
261
+
262
+ continue;
263
+ }
264
+
265
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
266
+
267
+ if (source !== null) {
268
+ writeAtomicSync(path, gzipSync(source()));
269
+ this.mark(chatId, id);
270
+
271
+ continue;
272
+ }
273
+
274
+ others ??= this.otherFoldersSync(chatId);
275
+
276
+ if (copyFromAnySync(others, id, path)) {
277
+ this.mark(chatId, id);
278
+ }
279
+ }
280
+ }
281
+
282
+ async read(chatId: string, id: string): Promise<string> {
283
+ if (!ID_PATTERN.test(id)) {
284
+ throw new ChatError('payload-missing', `No stored payload ${id} in chat ${chatId}`);
285
+ }
286
+
287
+ let packed: Buffer;
288
+
289
+ try {
290
+ packed = await readFile(join(this.folder(chatId), `${id}.gz`));
291
+ } catch (e) {
292
+ if (isNotFound(e)) {
293
+ throw new ChatError('payload-missing', `The stored payload ${id} of chat ${chatId} is gone`);
294
+ }
295
+
296
+ throw e;
297
+ }
298
+
299
+ return (await gunzipAsync(packed)).toString('utf8');
300
+ }
301
+
302
+ async readDiff(chatId: string, id: string): Promise<ChatCheckpointDiff> {
303
+ const parsed = ChatCheckpointDiffSchema.safeParse(JSON.parse(await this.read(chatId, id)));
304
+
305
+ if (!parsed.success) {
306
+ throw new ChatError('payload-missing', `The stored diff ${id} of chat ${chatId} does not read`);
307
+ }
308
+
309
+ return parsed.data;
310
+ }
311
+
312
+ /* Removes the payloads of a chat its thread no longer refers to, such as everything before a clear. */
313
+ async prune(chatId: string, keep: ReadonlySet<string>): Promise<void> {
314
+ const folder = this.folder(chatId);
315
+ let names: string[];
316
+
317
+ try {
318
+ names = await readdir(folder);
319
+ } catch (e) {
320
+ if (isNotFound(e)) {
321
+ return;
322
+ }
323
+
324
+ throw e;
325
+ }
326
+
327
+ const written = new Set<string>();
328
+ this.writtenDuringPrune.set(chatId, written);
329
+
330
+ try {
331
+ for (const name of names) {
332
+ const id = name.replace(/\.gz$/, '');
333
+
334
+ if (keep.has(id) || written.has(id)) {
335
+ continue;
336
+ }
337
+
338
+ this.present.get(chatId)?.delete(id);
339
+ await rm(join(folder, name), { force: true });
340
+ }
341
+ } finally {
342
+ this.writtenDuringPrune.delete(chatId);
343
+ }
344
+ }
345
+
346
+ async remove(chatId: string): Promise<void> {
347
+ this.present.delete(chatId);
348
+ await rm(this.folder(chatId), { recursive: true, force: true });
349
+ }
350
+
351
+ private presentIn(chatId: string): Set<string> {
352
+ let present = this.present.get(chatId);
353
+
354
+ if (!present) {
355
+ present = new Set();
356
+ this.present.set(chatId, present);
357
+ }
358
+
359
+ return present;
360
+ }
361
+
362
+ private mark(chatId: string, id: string): void {
363
+ this.presentIn(chatId).add(id);
364
+ this.writtenDuringPrune.get(chatId)?.add(id);
365
+ }
366
+
367
+ private async otherFolders(chatId: string): Promise<string[]> {
368
+ try {
369
+ return foldersOf(this.dir, await readdir(this.dir), chatId);
370
+ } catch (e) {
371
+ if (isNotFound(e)) {
372
+ return [];
373
+ }
374
+
375
+ throw e;
376
+ }
377
+ }
378
+
379
+ private otherFoldersSync(chatId: string): string[] {
380
+ try {
381
+ return foldersOf(this.dir, readdirSync(this.dir), chatId);
382
+ } catch (e) {
383
+ if (isNotFound(e)) {
384
+ return [];
385
+ }
386
+
387
+ throw e;
388
+ }
389
+ }
390
+ }
391
+
392
+ function foldersOf(dir: string, names: readonly string[], chatId: string): string[] {
393
+ const own = payloadFolderName(chatId);
394
+
395
+ return names.filter((name) => name.endsWith(FOLDER_SUFFIX) && name !== own).map((name) => join(dir, name));
396
+ }
397
+
398
+ // A clone where the file system has one, so a fork of a large chat takes no space until either side changes.
399
+ const COPY_MODE = constants.COPYFILE_FICLONE;
400
+
401
+ async function copyFromAny(folders: readonly string[], id: string, target: string): Promise<boolean> {
402
+ for (const folder of folders) {
403
+ try {
404
+ await copyFile(join(folder, `${id}.gz`), target, COPY_MODE);
405
+
406
+ return true;
407
+ } catch (e) {
408
+ if (!isNotFound(e)) {
409
+ throw e;
410
+ }
411
+ }
412
+ }
413
+
414
+ return false;
415
+ }
416
+
417
+ function copyFromAnySync(folders: readonly string[], id: string, target: string): boolean {
418
+ for (const folder of folders) {
419
+ try {
420
+ copyFileSync(join(folder, `${id}.gz`), target, COPY_MODE);
421
+
422
+ return true;
423
+ } catch (e) {
424
+ if (!isNotFound(e)) {
425
+ throw e;
426
+ }
427
+ }
428
+ }
429
+
430
+ return false;
431
+ }
432
+
433
+ async function existsAsync(path: string): Promise<boolean> {
434
+ try {
435
+ await stat(path);
436
+
437
+ return true;
438
+ } catch (e) {
439
+ if (isNotFound(e)) {
440
+ return false;
441
+ }
442
+
443
+ throw e;
444
+ }
445
+ }
446
+
447
+ function existsSync(path: string): boolean {
448
+ try {
449
+ statSync(path);
450
+
451
+ return true;
452
+ } catch (e) {
453
+ if (isNotFound(e)) {
454
+ return false;
455
+ }
456
+
457
+ throw e;
458
+ }
459
+ }
@@ -293,7 +293,8 @@ export class ThreadProjector {
293
293
  if (target?.kind === 'tool' && target.name === 'Workflow' && event.state === 'done' && event.output?.startsWith(WORKFLOW_LAUNCHED)) {
294
294
  // The call only launched the workflow; the row runs on past the turn until the workflow's task ends.
295
295
  this.backgroundCalls.add(target.id);
296
- events.push(this.thread.upsert({ ...target, output: event.output }));
296
+ const { outputRef: _earlier, ...launched } = target;
297
+ events.push(this.thread.upsert({ ...launched, output: event.output }));
297
298
 
298
299
  break;
299
300
  }
@@ -667,6 +668,10 @@ export class ThreadProjector {
667
668
  item.progress = previous.progress;
668
669
  }
669
670
 
671
+ if (previous?.outputRef) {
672
+ item.outputRef = previous.outputRef;
673
+ }
674
+
670
675
  const changes = event.changes ?? previous?.changes;
671
676
 
672
677
  if (changes) {
@@ -697,6 +702,11 @@ export class ThreadProjector {
697
702
  const settled: ChatToolItem = { ...tool, output: event.output ?? tool.output, state: event.state };
698
703
  delete settled.progress;
699
704
 
705
+ // A stored whole of an earlier output would answer for this one.
706
+ if (event.output != null) {
707
+ delete settled.outputRef;
708
+ }
709
+
700
710
  if (event.changes) {
701
711
  settled.changes = event.changes;
702
712
  }
@@ -103,6 +103,7 @@ export function chatHandlers(chats: ChatCore, providers: ProviderRegistry): { [T
103
103
  return {};
104
104
  },
105
105
  'chat.turnDiff': async (payload) => ({ diff: await chats.turnDiff(payload.chatId, payload.turnId) }),
106
+ 'chat.toolOutput': async (payload) => ({ output: await chats.toolOutput(payload.chatId, payload.itemId) }),
106
107
  'chat.fork': unsupported('fork chats'),
107
108
  'chat.forkInfo': unsupported('fork chats'),
108
109
  'chat.summarize': unsupported('fork chats'),
@@ -25,7 +25,7 @@ import { cliEnvironment, type CliEnvironmentPolicy } from './environment.ts';
25
25
  import { accountHandlers, chatHandlers, usageHandlers, type AgentHandlers } from './handlers.ts';
26
26
 
27
27
  export interface AgentWiringOptions<Core extends ChatCore = ChatCore> {
28
- // Where the chats, their attachments, bookmarks and visuals, the accounts and the usage index are kept.
28
+ // Where the chats, their attachments, bookmarks and visuals, the accounts and the usage history are kept.
29
29
  dataDir: string;
30
30
  // The environment the CLIs start in; absent, this process's own through `cliEnvironment`.
31
31
  env?: Record<string, string | undefined>;
@@ -0,0 +1,140 @@
1
+ import { UsageProviderSchema, type UsageProvider } from '@adecore/agent-contracts';
2
+ import { foldByKey, type UsageRecord } from './record.ts';
3
+ import { CURRENT_ROW, decodeRows, RowWriter, type RecordRow } from './rows.ts';
4
+
5
+ const HISTORY_VERSION = 1;
6
+ const NEWLINE = 0x0a;
7
+
8
+ /* What the transcript at one path has counted, kept after the transcript itself is gone. */
9
+ export interface FileUsage {
10
+ provider: UsageProvider;
11
+ records: UsageRecord[];
12
+ /* What the line still being written at the end of the transcript says; replaced on every read of it. */
13
+ tail: UsageRecord[];
14
+ }
15
+
16
+ /*
17
+ * One line of the history. `set` replaces what a path counted, since its transcript was read from the
18
+ * start; `add` folds its records into what the path had, since they came from an append.
19
+ */
20
+ export interface HistoryEntry {
21
+ path: string;
22
+ op: 'set' | 'add';
23
+ usage: FileUsage;
24
+ }
25
+
26
+ /*
27
+ * The first line. `gen` is new on every rewrite of the file, so scan state saved against another
28
+ * rewrite is never trusted; `base` is the size of the lines it was rewritten with, which says when to
29
+ * rewrite it again.
30
+ */
31
+ export interface HistoryHeader {
32
+ gen: string;
33
+ base: number;
34
+ }
35
+
36
+ /* Each line names its own models, sessions and folders, so it reads back without the lines before it. */
37
+ interface EntryLine {
38
+ p: string;
39
+ k: UsageProvider;
40
+ op: 'set' | 'add';
41
+ m: string[];
42
+ s: string[];
43
+ f: string[];
44
+ a: string[];
45
+ r: RecordRow[];
46
+ t: RecordRow[];
47
+ }
48
+
49
+ export function encodeHeader(header: HistoryHeader): string {
50
+ return `${JSON.stringify({ v: HISTORY_VERSION, gen: header.gen, base: header.base })}\n`;
51
+ }
52
+
53
+ export function encodeEntry(entry: HistoryEntry): string {
54
+ const writer = new RowWriter();
55
+ const rows = entry.usage.records.map((record) => writer.row(record));
56
+ const tail = entry.usage.tail.map((record) => writer.row(record));
57
+ const { models, sessions, folders, accounts } = writer.tables;
58
+ const line: EntryLine = { p: entry.path, k: entry.usage.provider, op: entry.op, m: models, s: sessions, f: folders, a: accounts, r: rows, t: tail };
59
+
60
+ return `${JSON.stringify(line)}\n`;
61
+ }
62
+
63
+ function parseLine(text: string): unknown {
64
+ try {
65
+ return JSON.parse(text);
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ function decodeHeader(text: string): HistoryHeader | null {
72
+ const header = parseLine(text) as Partial<HistoryHeader & { v: number }> | null;
73
+
74
+ if (header?.v !== HISTORY_VERSION || typeof header.gen !== 'string' || typeof header.base !== 'number') {
75
+ return null;
76
+ }
77
+
78
+ return { gen: header.gen, base: header.base };
79
+ }
80
+
81
+ function decodeEntry(text: string): HistoryEntry | null {
82
+ const line = parseLine(text) as Partial<EntryLine> | null;
83
+
84
+ if (typeof line?.p !== 'string' || (line.op !== 'set' && line.op !== 'add') || !UsageProviderSchema.safeParse(line.k).success) {
85
+ return null;
86
+ }
87
+
88
+ const provider = line.k as UsageProvider;
89
+ const tables = { models: line.m, sessions: line.s, folders: line.f, accounts: line.a };
90
+ const records = decodeRows(line.r, tables, provider, CURRENT_ROW);
91
+ const tail = decodeRows(line.t, tables, provider, CURRENT_ROW);
92
+
93
+ if (records === null || tail === null) {
94
+ return null;
95
+ }
96
+
97
+ return { path: line.p, op: line.op, usage: { provider, records, tail } };
98
+ }
99
+
100
+ /* Folds one line into what every path counted. */
101
+ export function applyEntry(usage: Map<string, FileUsage>, entry: HistoryEntry): void {
102
+ const known = usage.get(entry.path);
103
+
104
+ if (entry.op === 'set' || known === undefined || known.provider !== entry.usage.provider) {
105
+ usage.set(entry.path, entry.usage);
106
+
107
+ return;
108
+ }
109
+
110
+ usage.set(entry.path, { provider: known.provider, records: foldByKey([...known.records, ...entry.usage.records]), tail: entry.usage.tail });
111
+ }
112
+
113
+ export interface DecodedHistory {
114
+ header: HistoryHeader | null;
115
+ usage: Map<string, FileUsage>;
116
+ /* The bytes up to the last newline; past it is a line an append left half-written. */
117
+ complete: number;
118
+ }
119
+
120
+ /*
121
+ * Every line stands alone, so a line that does not read back costs only that line. Only the end can
122
+ * be half-written, since lines are only ever appended.
123
+ */
124
+ export function decodeHistory(bytes: Uint8Array): DecodedHistory {
125
+ const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
126
+ const complete = buffer.lastIndexOf(NEWLINE) + 1;
127
+ const lines = buffer.toString('utf8', 0, complete).split('\n');
128
+ const usage = new Map<string, FileUsage>();
129
+ const header = complete === 0 ? null : decodeHeader(lines[0]!);
130
+
131
+ for (let i = header === null ? 0 : 1; i < lines.length; i++) {
132
+ const entry = lines[i] === '' ? null : decodeEntry(lines[i]!);
133
+
134
+ if (entry !== null) {
135
+ applyEntry(usage, entry);
136
+ }
137
+ }
138
+
139
+ return { header, usage, complete };
140
+ }