@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.
- package/dist/chat/chat-core.d.ts +1 -0
- package/dist/chat/chat-core.js +21 -3
- package/dist/chat/chat-store.d.ts +7 -1
- package/dist/chat/chat-store.js +68 -7
- package/dist/chat/errors.d.ts +1 -1
- package/dist/chat/payload-store.d.ts +28 -0
- package/dist/chat/payload-store.js +350 -0
- package/dist/chat/projector.js +9 -1
- package/dist/host/handlers.js +1 -0
- package/dist/usage/history-file.d.ts +25 -0
- package/dist/usage/history-file.js +72 -0
- package/dist/usage/index-file.d.ts +1 -1
- package/dist/usage/index-file.js +33 -114
- package/dist/usage/rows.d.ts +23 -0
- package/dist/usage/rows.js +94 -0
- package/dist/usage/scan-state.d.ts +21 -0
- package/dist/usage/scan-state.js +53 -0
- package/dist/usage/scanner.d.ts +1 -4
- package/dist/usage/scanner.js +37 -50
- package/dist/usage/usage-store.d.ts +26 -0
- package/dist/usage/usage-store.js +197 -0
- package/package.json +3 -3
- package/src/chat/chat-core.ts +27 -3
- package/src/chat/chat-store.ts +83 -8
- package/src/chat/errors.ts +2 -0
- package/src/chat/payload-store.ts +459 -0
- package/src/chat/projector.ts +11 -1
- package/src/host/handlers.ts +1 -0
- package/src/host/wiring.ts +1 -1
- package/src/usage/history-file.ts +140 -0
- package/src/usage/index-file.ts +35 -143
- package/src/usage/rows.ts +136 -0
- package/src/usage/scan-state.ts +105 -0
- package/src/usage/scanner.ts +39 -54
- package/src/usage/usage-store.ts +237 -0
package/src/usage/index-file.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { UsageProviderSchema, type UsageProvider } from '@adecore/agent-contracts';
|
|
2
2
|
import type { UsageRecord } from './record.ts';
|
|
3
3
|
import type { CodexParserState } from './readers/codex.ts';
|
|
4
|
+
import { CURRENT_ROW, decodeRows, RowWriter, type RecordRow } from './rows.ts';
|
|
4
5
|
|
|
5
|
-
/*
|
|
6
|
+
/*
|
|
7
|
+
* The single index usage was kept in before history and scan state went apart. The store reads it
|
|
8
|
+
* once to migrate it; nothing writes it any more but the tests that need one to migrate.
|
|
9
|
+
*/
|
|
6
10
|
const INDEX_VERSION = 3;
|
|
7
11
|
/* Before accounts: every record was the default account's, so it reads as that without a scan. */
|
|
8
12
|
const ACCOUNTLESS_VERSION = 1;
|
|
@@ -11,15 +15,10 @@ export interface IndexedFile {
|
|
|
11
15
|
provider: UsageProvider;
|
|
12
16
|
size: number;
|
|
13
17
|
mtimeMs: number;
|
|
14
|
-
/* Just past the last newline that was read, so a file that grew is read from there. */
|
|
15
18
|
offset: number;
|
|
16
19
|
records: UsageRecord[];
|
|
17
|
-
/* What the line still being written at the end of the file says; read again on the next scan,
|
|
18
|
-
so it is kept apart from the records the offset has already accounted for. */
|
|
19
20
|
tail: UsageRecord[];
|
|
20
|
-
/* What the reader has to remember to carry on halfway through a Codex file. */
|
|
21
21
|
codex: CodexParserState | null;
|
|
22
|
-
/* A versioned hash of all bytes before the offset; absent in indexes that cannot verify an append. */
|
|
23
22
|
head: string | null;
|
|
24
23
|
parserVersion?: number;
|
|
25
24
|
compressed?: boolean;
|
|
@@ -27,9 +26,6 @@ export interface IndexedFile {
|
|
|
27
26
|
|
|
28
27
|
export type UsageIndex = Map<string, IndexedFile>;
|
|
29
28
|
|
|
30
|
-
/* Interned names keep the index compact; optional cost and tier fields preserve per-call pricing. */
|
|
31
|
-
type RecordRow = [number, number, number, number, number, number, number, number, number, number, number, string | 0, number?, number?, string?];
|
|
32
|
-
|
|
33
29
|
interface FileRow {
|
|
34
30
|
p: UsageProvider;
|
|
35
31
|
s: number;
|
|
@@ -52,60 +48,10 @@ interface IndexFile {
|
|
|
52
48
|
files: Record<string, FileRow>;
|
|
53
49
|
}
|
|
54
50
|
|
|
55
|
-
class Interner {
|
|
56
|
-
readonly values: string[] = [];
|
|
57
|
-
private readonly at = new Map<string, number>();
|
|
58
|
-
|
|
59
|
-
index(value: string): number {
|
|
60
|
-
const seen = this.at.get(value);
|
|
61
|
-
|
|
62
|
-
if (seen !== undefined) {
|
|
63
|
-
return seen;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const next = this.values.length;
|
|
67
|
-
this.values.push(value);
|
|
68
|
-
this.at.set(value, next);
|
|
69
|
-
|
|
70
|
-
return next;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
51
|
export function encodeIndex(index: UsageIndex): string {
|
|
75
|
-
const
|
|
76
|
-
const sessions = new Interner();
|
|
77
|
-
const folders = new Interner();
|
|
78
|
-
const accounts = new Interner();
|
|
52
|
+
const writer = new RowWriter();
|
|
79
53
|
const files: Record<string, FileRow> = {};
|
|
80
54
|
|
|
81
|
-
const row = (record: UsageRecord): RecordRow => {
|
|
82
|
-
const counted: RecordRow = [
|
|
83
|
-
record.timestampMs,
|
|
84
|
-
models.index(record.model),
|
|
85
|
-
sessions.index(record.sessionId),
|
|
86
|
-
folders.index(record.cwd),
|
|
87
|
-
record.totals.calls,
|
|
88
|
-
record.totals.input,
|
|
89
|
-
record.totals.cacheRead,
|
|
90
|
-
record.totals.cacheWrite,
|
|
91
|
-
record.totals.cacheWrite1h,
|
|
92
|
-
record.totals.output,
|
|
93
|
-
record.totals.reasoning,
|
|
94
|
-
record.dedupeKey ?? 0,
|
|
95
|
-
record.account === undefined ? 0 : accounts.index(record.account) + 1
|
|
96
|
-
];
|
|
97
|
-
|
|
98
|
-
if (record.costUsd !== undefined || record.serviceTier !== undefined) {
|
|
99
|
-
counted[13] = record.costUsd;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (record.serviceTier !== undefined) {
|
|
103
|
-
counted[14] = record.serviceTier;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return counted;
|
|
107
|
-
};
|
|
108
|
-
|
|
109
55
|
for (const [path, file] of index) {
|
|
110
56
|
files[path] = {
|
|
111
57
|
p: file.provider,
|
|
@@ -113,50 +59,30 @@ export function encodeIndex(index: UsageIndex): string {
|
|
|
113
59
|
m: file.mtimeMs,
|
|
114
60
|
o: file.offset,
|
|
115
61
|
c: file.codex,
|
|
116
|
-
r: file.records.map(row),
|
|
117
|
-
t: file.tail.map(row),
|
|
62
|
+
r: file.records.map((record) => writer.row(record)),
|
|
63
|
+
t: file.tail.map((record) => writer.row(record)),
|
|
118
64
|
...(file.head === null ? {} : { h: file.head }),
|
|
119
65
|
...(file.parserVersion === undefined ? {} : { v: file.parserVersion }),
|
|
120
66
|
...(file.compressed === undefined ? {} : { z: file.compressed })
|
|
121
67
|
};
|
|
122
68
|
}
|
|
123
69
|
|
|
124
|
-
const document: IndexFile = {
|
|
125
|
-
version: INDEX_VERSION,
|
|
126
|
-
models: models.values,
|
|
127
|
-
sessions: sessions.values,
|
|
128
|
-
folders: folders.values,
|
|
129
|
-
accounts: accounts.values,
|
|
130
|
-
files
|
|
131
|
-
};
|
|
70
|
+
const document: IndexFile = { version: INDEX_VERSION, ...writer.tables, files };
|
|
132
71
|
|
|
133
72
|
return JSON.stringify(document);
|
|
134
73
|
}
|
|
135
74
|
|
|
136
|
-
function isRow(row: unknown, version: number): row is RecordRow {
|
|
137
|
-
return (
|
|
138
|
-
Array.isArray(row) &&
|
|
139
|
-
(version === ACCOUNTLESS_VERSION ? row.length === 12 : row.length >= 13 && row.length <= (version === INDEX_VERSION ? 15 : 14)) &&
|
|
140
|
-
row.slice(0, 11).every((value) => typeof value === 'number') &&
|
|
141
|
-
(typeof row[11] === 'string' || row[11] === 0) &&
|
|
142
|
-
(version === ACCOUNTLESS_VERSION || typeof row[12] === 'number') &&
|
|
143
|
-
(row.length < 14 || row[13] === null || row[13] === undefined || typeof row[13] === 'number') &&
|
|
144
|
-
(row.length < 15 || typeof row[14] === 'string')
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
75
|
/*
|
|
149
|
-
*
|
|
150
|
-
* so
|
|
76
|
+
* Null for text that is no index of a version this reads. A file whose rows do not read back is left
|
|
77
|
+
* out, so only that file's usage is missing from what migrates, never a wrong total.
|
|
151
78
|
*/
|
|
152
|
-
export function decodeIndex(text: string): UsageIndex {
|
|
153
|
-
const index: UsageIndex = new Map();
|
|
79
|
+
export function decodeIndex(text: string): UsageIndex | null {
|
|
154
80
|
let document: IndexFile;
|
|
155
81
|
|
|
156
82
|
try {
|
|
157
83
|
document = JSON.parse(text) as IndexFile;
|
|
158
84
|
} catch {
|
|
159
|
-
return
|
|
85
|
+
return null;
|
|
160
86
|
}
|
|
161
87
|
|
|
162
88
|
const version = document?.version;
|
|
@@ -167,12 +93,12 @@ export function decodeIndex(text: string): UsageIndex {
|
|
|
167
93
|
!Array.isArray(document.sessions) ||
|
|
168
94
|
!Array.isArray(document.folders)
|
|
169
95
|
) {
|
|
170
|
-
return
|
|
96
|
+
return null;
|
|
171
97
|
}
|
|
172
98
|
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
const
|
|
99
|
+
const index: UsageIndex = new Map();
|
|
100
|
+
const tables = { ...document, accounts: Array.isArray(document.accounts) ? document.accounts : [] };
|
|
101
|
+
const shape = { accountless: version === ACCOUNTLESS_VERSION, maxLength: version === INDEX_VERSION ? CURRENT_ROW.maxLength : 14 };
|
|
176
102
|
|
|
177
103
|
for (const [path, file] of Object.entries(document.files ?? {})) {
|
|
178
104
|
// Every provider the schema knows, so a new one is never dropped from the index it was saved in.
|
|
@@ -180,63 +106,29 @@ export function decodeIndex(text: string): UsageIndex {
|
|
|
180
106
|
continue;
|
|
181
107
|
}
|
|
182
108
|
|
|
183
|
-
if (typeof file.s !== 'number' || typeof file.m !== 'number' || typeof file.o !== 'number' || file.o < 0
|
|
109
|
+
if (typeof file.s !== 'number' || typeof file.m !== 'number' || typeof file.o !== 'number' || file.o < 0) {
|
|
184
110
|
continue;
|
|
185
111
|
}
|
|
186
112
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const decode = (rows: RecordRow[]): UsageRecord[] => {
|
|
190
|
-
const records: UsageRecord[] = [];
|
|
191
|
-
|
|
192
|
-
for (const row of rows) {
|
|
193
|
-
const valid = isRow(row, version);
|
|
194
|
-
const model = valid ? name(document.models, row[1]) : null;
|
|
195
|
-
const sessionId = valid ? name(document.sessions, row[2]) : null;
|
|
196
|
-
const cwd = valid ? name(document.folders, row[3]) : null;
|
|
197
|
-
const accountAt = valid ? (row[12] ?? 0) : 0;
|
|
198
|
-
const account = accountAt === 0 ? undefined : name(accounts, accountAt - 1);
|
|
199
|
-
|
|
200
|
-
if (!valid || model === null || sessionId === null || cwd === null || account === null) {
|
|
201
|
-
broken = true;
|
|
202
|
-
|
|
203
|
-
return records;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
records.push({
|
|
207
|
-
provider: file.p,
|
|
208
|
-
timestampMs: row[0],
|
|
209
|
-
model,
|
|
210
|
-
sessionId,
|
|
211
|
-
cwd,
|
|
212
|
-
...(account === undefined ? {} : { account }),
|
|
213
|
-
totals: { calls: row[4], input: row[5], cacheRead: row[6], cacheWrite: row[7], cacheWrite1h: row[8], output: row[9], reasoning: row[10] },
|
|
214
|
-
...(typeof row[13] === 'number' ? { costUsd: row[13] } : {}),
|
|
215
|
-
...(typeof row[14] === 'string' ? { serviceTier: row[14] } : {}),
|
|
216
|
-
dedupeKey: row[11] === 0 ? null : row[11]
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
return records;
|
|
221
|
-
};
|
|
113
|
+
const records = decodeRows(file.r, tables, file.p, shape);
|
|
114
|
+
const tail = decodeRows(Array.isArray(file.t) ? file.t : [], tables, file.p, shape);
|
|
222
115
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (!broken) {
|
|
227
|
-
index.set(path, {
|
|
228
|
-
provider: file.p,
|
|
229
|
-
size: file.s,
|
|
230
|
-
mtimeMs: file.m,
|
|
231
|
-
offset: file.o,
|
|
232
|
-
records,
|
|
233
|
-
tail,
|
|
234
|
-
codex: file.c ?? null,
|
|
235
|
-
head: typeof file.h === 'string' ? file.h : null,
|
|
236
|
-
...(typeof file.v === 'number' ? { parserVersion: file.v } : {}),
|
|
237
|
-
...(typeof file.z === 'boolean' ? { compressed: file.z } : {})
|
|
238
|
-
});
|
|
116
|
+
if (records === null || tail === null) {
|
|
117
|
+
continue;
|
|
239
118
|
}
|
|
119
|
+
|
|
120
|
+
index.set(path, {
|
|
121
|
+
provider: file.p,
|
|
122
|
+
size: file.s,
|
|
123
|
+
mtimeMs: file.m,
|
|
124
|
+
offset: file.o,
|
|
125
|
+
records,
|
|
126
|
+
tail,
|
|
127
|
+
codex: file.c ?? null,
|
|
128
|
+
head: typeof file.h === 'string' ? file.h : null,
|
|
129
|
+
...(typeof file.v === 'number' ? { parserVersion: file.v } : {}),
|
|
130
|
+
...(typeof file.z === 'boolean' ? { compressed: file.z } : {})
|
|
131
|
+
});
|
|
240
132
|
}
|
|
241
133
|
|
|
242
134
|
return index;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { UsageProvider } from '@adecore/agent-contracts';
|
|
2
|
+
import type { UsageRecord } from './record.ts';
|
|
3
|
+
|
|
4
|
+
/* Interned names keep the files compact; optional cost and tier fields preserve per-call pricing. */
|
|
5
|
+
export type RecordRow = [number, number, number, number, number, number, number, number, number, number, number, string | 0, number?, number?, string?];
|
|
6
|
+
|
|
7
|
+
/* The names the rows of one document point into. */
|
|
8
|
+
export interface RowTables {
|
|
9
|
+
models: string[];
|
|
10
|
+
sessions: string[];
|
|
11
|
+
folders: string[];
|
|
12
|
+
accounts: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
class Interner {
|
|
16
|
+
readonly values: string[] = [];
|
|
17
|
+
private readonly at = new Map<string, number>();
|
|
18
|
+
|
|
19
|
+
index(value: string): number {
|
|
20
|
+
const seen = this.at.get(value);
|
|
21
|
+
|
|
22
|
+
if (seen !== undefined) {
|
|
23
|
+
return seen;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const next = this.values.length;
|
|
27
|
+
this.values.push(value);
|
|
28
|
+
this.at.set(value, next);
|
|
29
|
+
|
|
30
|
+
return next;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Turns records into rows, collecting the names they share into one set of tables. */
|
|
35
|
+
export class RowWriter {
|
|
36
|
+
private readonly models = new Interner();
|
|
37
|
+
private readonly sessions = new Interner();
|
|
38
|
+
private readonly folders = new Interner();
|
|
39
|
+
private readonly accounts = new Interner();
|
|
40
|
+
|
|
41
|
+
get tables(): RowTables {
|
|
42
|
+
return { models: this.models.values, sessions: this.sessions.values, folders: this.folders.values, accounts: this.accounts.values };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
row(record: UsageRecord): RecordRow {
|
|
46
|
+
const counted: RecordRow = [
|
|
47
|
+
record.timestampMs,
|
|
48
|
+
this.models.index(record.model),
|
|
49
|
+
this.sessions.index(record.sessionId),
|
|
50
|
+
this.folders.index(record.cwd),
|
|
51
|
+
record.totals.calls,
|
|
52
|
+
record.totals.input,
|
|
53
|
+
record.totals.cacheRead,
|
|
54
|
+
record.totals.cacheWrite,
|
|
55
|
+
record.totals.cacheWrite1h,
|
|
56
|
+
record.totals.output,
|
|
57
|
+
record.totals.reasoning,
|
|
58
|
+
record.dedupeKey ?? 0,
|
|
59
|
+
record.account === undefined ? 0 : this.accounts.index(record.account) + 1
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
if (record.costUsd !== undefined || record.serviceTier !== undefined) {
|
|
63
|
+
counted[13] = record.costUsd;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (record.serviceTier !== undefined) {
|
|
67
|
+
counted[14] = record.serviceTier;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return counted;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* Rows written before accounts carry twelve fields, and before per-call pricing at most fourteen. */
|
|
75
|
+
export interface RowShape {
|
|
76
|
+
accountless: boolean;
|
|
77
|
+
maxLength: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const CURRENT_ROW: RowShape = { accountless: false, maxLength: 15 };
|
|
81
|
+
|
|
82
|
+
function isRow(row: unknown, shape: RowShape): row is RecordRow {
|
|
83
|
+
return (
|
|
84
|
+
Array.isArray(row) &&
|
|
85
|
+
(shape.accountless ? row.length === 12 : row.length >= 13 && row.length <= shape.maxLength) &&
|
|
86
|
+
row.slice(0, 11).every((value) => typeof value === 'number') &&
|
|
87
|
+
(typeof row[11] === 'string' || row[11] === 0) &&
|
|
88
|
+
(shape.accountless || typeof row[12] === 'number') &&
|
|
89
|
+
(row.length < 14 || row[13] === null || row[13] === undefined || typeof row[13] === 'number') &&
|
|
90
|
+
(row.length < 15 || typeof row[14] === 'string')
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function nameAt(table: unknown, at: number): string | null {
|
|
95
|
+
return Array.isArray(table) && typeof table[at] === 'string' ? table[at] : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* The records of `rows`, or null when any row does not read back against its tables. */
|
|
99
|
+
export function decodeRows(rows: unknown, tables: Partial<Record<keyof RowTables, unknown>>, provider: UsageProvider, shape: RowShape): UsageRecord[] | null {
|
|
100
|
+
if (!Array.isArray(rows)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const records: UsageRecord[] = [];
|
|
105
|
+
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
if (!isRow(row, shape)) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const model = nameAt(tables.models, row[1]);
|
|
112
|
+
const sessionId = nameAt(tables.sessions, row[2]);
|
|
113
|
+
const cwd = nameAt(tables.folders, row[3]);
|
|
114
|
+
const accountAt = row[12] ?? 0;
|
|
115
|
+
const account = accountAt === 0 ? undefined : nameAt(tables.accounts, accountAt - 1);
|
|
116
|
+
|
|
117
|
+
if (model === null || sessionId === null || cwd === null || account === null) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
records.push({
|
|
122
|
+
provider,
|
|
123
|
+
timestampMs: row[0],
|
|
124
|
+
model,
|
|
125
|
+
sessionId,
|
|
126
|
+
cwd,
|
|
127
|
+
...(account === undefined ? {} : { account }),
|
|
128
|
+
totals: { calls: row[4], input: row[5], cacheRead: row[6], cacheWrite: row[7], cacheWrite1h: row[8], output: row[9], reasoning: row[10] },
|
|
129
|
+
...(typeof row[13] === 'number' ? { costUsd: row[13] } : {}),
|
|
130
|
+
...(typeof row[14] === 'string' ? { serviceTier: row[14] } : {}),
|
|
131
|
+
dedupeKey: row[11] === 0 ? null : row[11]
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return records;
|
|
136
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { UsageProviderSchema, type UsageProvider } from '@adecore/agent-contracts';
|
|
2
|
+
import type { CodexParserState } from './readers/codex.ts';
|
|
3
|
+
|
|
4
|
+
const STATE_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
/* Where the scanner left a transcript. All of it can be rebuilt by reading the transcript again. */
|
|
7
|
+
export interface ScanState {
|
|
8
|
+
provider: UsageProvider;
|
|
9
|
+
size: number;
|
|
10
|
+
mtimeMs: number;
|
|
11
|
+
/* Just past the last newline that was read, so a file that grew is read from there. */
|
|
12
|
+
offset: number;
|
|
13
|
+
/* What the reader has to remember to carry on halfway through a Codex file. */
|
|
14
|
+
codex: CodexParserState | null;
|
|
15
|
+
/* A versioned hash of all bytes before the offset; absent where an append cannot be verified. */
|
|
16
|
+
head: string | null;
|
|
17
|
+
parserVersion?: number;
|
|
18
|
+
compressed?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* The history this state was saved against: the rewrite it belongs to and how far into it. */
|
|
22
|
+
export interface HistoryMark {
|
|
23
|
+
gen: string;
|
|
24
|
+
bytes: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface StateRow {
|
|
28
|
+
p: UsageProvider;
|
|
29
|
+
s: number;
|
|
30
|
+
m: number;
|
|
31
|
+
o: number;
|
|
32
|
+
c: CodexParserState | null;
|
|
33
|
+
h?: string;
|
|
34
|
+
v?: number;
|
|
35
|
+
z?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface StateFile {
|
|
39
|
+
version: number;
|
|
40
|
+
history: HistoryMark;
|
|
41
|
+
files: Record<string, StateRow>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function encodeState(state: ReadonlyMap<string, ScanState>, history: HistoryMark): string {
|
|
45
|
+
const files: Record<string, StateRow> = {};
|
|
46
|
+
|
|
47
|
+
for (const [path, file] of state) {
|
|
48
|
+
files[path] = {
|
|
49
|
+
p: file.provider,
|
|
50
|
+
s: file.size,
|
|
51
|
+
m: file.mtimeMs,
|
|
52
|
+
o: file.offset,
|
|
53
|
+
c: file.codex,
|
|
54
|
+
...(file.head === null ? {} : { h: file.head }),
|
|
55
|
+
...(file.parserVersion === undefined ? {} : { v: file.parserVersion }),
|
|
56
|
+
...(file.compressed === undefined ? {} : { z: file.compressed })
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const document: StateFile = { version: STATE_VERSION, history, files };
|
|
61
|
+
|
|
62
|
+
return JSON.stringify(document);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* Null for text that is no state of this version; an entry that does not read only costs a read of its transcript. */
|
|
66
|
+
export function decodeState(text: string): { history: HistoryMark; files: Map<string, ScanState> } | null {
|
|
67
|
+
let document: Partial<StateFile> | null;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
document = JSON.parse(text) as Partial<StateFile> | null;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const history = document?.history;
|
|
76
|
+
|
|
77
|
+
if (document?.version !== STATE_VERSION || typeof history?.gen !== 'string' || typeof history.bytes !== 'number') {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const files = new Map<string, ScanState>();
|
|
82
|
+
|
|
83
|
+
for (const [path, file] of Object.entries(document.files ?? {})) {
|
|
84
|
+
if (!UsageProviderSchema.safeParse(file.p).success) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (typeof file.s !== 'number' || typeof file.m !== 'number' || typeof file.o !== 'number' || file.o < 0) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
files.set(path, {
|
|
93
|
+
provider: file.p,
|
|
94
|
+
size: file.s,
|
|
95
|
+
mtimeMs: file.m,
|
|
96
|
+
offset: file.o,
|
|
97
|
+
codex: file.c ?? null,
|
|
98
|
+
head: typeof file.h === 'string' ? file.h : null,
|
|
99
|
+
...(typeof file.v === 'number' ? { parserVersion: file.v } : {}),
|
|
100
|
+
...(typeof file.z === 'boolean' ? { compressed: file.z } : {})
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { history: { gen: history.gen, bytes: history.bytes }, files };
|
|
105
|
+
}
|