@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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { UsageProviderSchema } from '@adecore/agent-contracts';
|
|
2
|
+
import { foldByKey } from './record.js';
|
|
3
|
+
import { CURRENT_ROW, decodeRows, RowWriter } from './rows.js';
|
|
4
|
+
const HISTORY_VERSION = 1;
|
|
5
|
+
const NEWLINE = 0x0a;
|
|
6
|
+
export function encodeHeader(header) {
|
|
7
|
+
return `${JSON.stringify({ v: HISTORY_VERSION, gen: header.gen, base: header.base })}\n`;
|
|
8
|
+
}
|
|
9
|
+
export function encodeEntry(entry) {
|
|
10
|
+
const writer = new RowWriter();
|
|
11
|
+
const rows = entry.usage.records.map((record) => writer.row(record));
|
|
12
|
+
const tail = entry.usage.tail.map((record) => writer.row(record));
|
|
13
|
+
const { models, sessions, folders, accounts } = writer.tables;
|
|
14
|
+
const line = { p: entry.path, k: entry.usage.provider, op: entry.op, m: models, s: sessions, f: folders, a: accounts, r: rows, t: tail };
|
|
15
|
+
return `${JSON.stringify(line)}\n`;
|
|
16
|
+
}
|
|
17
|
+
function parseLine(text) {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(text);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function decodeHeader(text) {
|
|
26
|
+
const header = parseLine(text);
|
|
27
|
+
if (header?.v !== HISTORY_VERSION || typeof header.gen !== 'string' || typeof header.base !== 'number') {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return { gen: header.gen, base: header.base };
|
|
31
|
+
}
|
|
32
|
+
function decodeEntry(text) {
|
|
33
|
+
const line = parseLine(text);
|
|
34
|
+
if (typeof line?.p !== 'string' || (line.op !== 'set' && line.op !== 'add') || !UsageProviderSchema.safeParse(line.k).success) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const provider = line.k;
|
|
38
|
+
const tables = { models: line.m, sessions: line.s, folders: line.f, accounts: line.a };
|
|
39
|
+
const records = decodeRows(line.r, tables, provider, CURRENT_ROW);
|
|
40
|
+
const tail = decodeRows(line.t, tables, provider, CURRENT_ROW);
|
|
41
|
+
if (records === null || tail === null) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return { path: line.p, op: line.op, usage: { provider, records, tail } };
|
|
45
|
+
}
|
|
46
|
+
/* Folds one line into what every path counted. */
|
|
47
|
+
export function applyEntry(usage, entry) {
|
|
48
|
+
const known = usage.get(entry.path);
|
|
49
|
+
if (entry.op === 'set' || known === undefined || known.provider !== entry.usage.provider) {
|
|
50
|
+
usage.set(entry.path, entry.usage);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
usage.set(entry.path, { provider: known.provider, records: foldByKey([...known.records, ...entry.usage.records]), tail: entry.usage.tail });
|
|
54
|
+
}
|
|
55
|
+
/*
|
|
56
|
+
* Every line stands alone, so a line that does not read back costs only that line. Only the end can
|
|
57
|
+
* be half-written, since lines are only ever appended.
|
|
58
|
+
*/
|
|
59
|
+
export function decodeHistory(bytes) {
|
|
60
|
+
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
61
|
+
const complete = buffer.lastIndexOf(NEWLINE) + 1;
|
|
62
|
+
const lines = buffer.toString('utf8', 0, complete).split('\n');
|
|
63
|
+
const usage = new Map();
|
|
64
|
+
const header = complete === 0 ? null : decodeHeader(lines[0]);
|
|
65
|
+
for (let i = header === null ? 0 : 1; i < lines.length; i++) {
|
|
66
|
+
const entry = lines[i] === '' ? null : decodeEntry(lines[i]);
|
|
67
|
+
if (entry !== null) {
|
|
68
|
+
applyEntry(usage, entry);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { header, usage, complete };
|
|
72
|
+
}
|
|
@@ -15,4 +15,4 @@ export interface IndexedFile {
|
|
|
15
15
|
}
|
|
16
16
|
export type UsageIndex = Map<string, IndexedFile>;
|
|
17
17
|
export declare function encodeIndex(index: UsageIndex): string;
|
|
18
|
-
export declare function decodeIndex(text: string): UsageIndex;
|
|
18
|
+
export declare function decodeIndex(text: string): UsageIndex | null;
|
package/dist/usage/index-file.js
CHANGED
|
@@ -1,52 +1,15 @@
|
|
|
1
1
|
import { UsageProviderSchema } from '@adecore/agent-contracts';
|
|
2
|
-
|
|
2
|
+
import { CURRENT_ROW, decodeRows, RowWriter } from './rows.js';
|
|
3
|
+
/*
|
|
4
|
+
* The single index usage was kept in before history and scan state went apart. The store reads it
|
|
5
|
+
* once to migrate it; nothing writes it any more but the tests that need one to migrate.
|
|
6
|
+
*/
|
|
3
7
|
const INDEX_VERSION = 3;
|
|
4
8
|
/* Before accounts: every record was the default account's, so it reads as that without a scan. */
|
|
5
9
|
const ACCOUNTLESS_VERSION = 1;
|
|
6
|
-
class Interner {
|
|
7
|
-
values = [];
|
|
8
|
-
at = new Map();
|
|
9
|
-
index(value) {
|
|
10
|
-
const seen = this.at.get(value);
|
|
11
|
-
if (seen !== undefined) {
|
|
12
|
-
return seen;
|
|
13
|
-
}
|
|
14
|
-
const next = this.values.length;
|
|
15
|
-
this.values.push(value);
|
|
16
|
-
this.at.set(value, next);
|
|
17
|
-
return next;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
10
|
export function encodeIndex(index) {
|
|
21
|
-
const
|
|
22
|
-
const sessions = new Interner();
|
|
23
|
-
const folders = new Interner();
|
|
24
|
-
const accounts = new Interner();
|
|
11
|
+
const writer = new RowWriter();
|
|
25
12
|
const files = {};
|
|
26
|
-
const row = (record) => {
|
|
27
|
-
const counted = [
|
|
28
|
-
record.timestampMs,
|
|
29
|
-
models.index(record.model),
|
|
30
|
-
sessions.index(record.sessionId),
|
|
31
|
-
folders.index(record.cwd),
|
|
32
|
-
record.totals.calls,
|
|
33
|
-
record.totals.input,
|
|
34
|
-
record.totals.cacheRead,
|
|
35
|
-
record.totals.cacheWrite,
|
|
36
|
-
record.totals.cacheWrite1h,
|
|
37
|
-
record.totals.output,
|
|
38
|
-
record.totals.reasoning,
|
|
39
|
-
record.dedupeKey ?? 0,
|
|
40
|
-
record.account === undefined ? 0 : accounts.index(record.account) + 1
|
|
41
|
-
];
|
|
42
|
-
if (record.costUsd !== undefined || record.serviceTier !== undefined) {
|
|
43
|
-
counted[13] = record.costUsd;
|
|
44
|
-
}
|
|
45
|
-
if (record.serviceTier !== undefined) {
|
|
46
|
-
counted[14] = record.serviceTier;
|
|
47
|
-
}
|
|
48
|
-
return counted;
|
|
49
|
-
};
|
|
50
13
|
for (const [path, file] of index) {
|
|
51
14
|
files[path] = {
|
|
52
15
|
p: file.provider,
|
|
@@ -54,107 +17,63 @@ export function encodeIndex(index) {
|
|
|
54
17
|
m: file.mtimeMs,
|
|
55
18
|
o: file.offset,
|
|
56
19
|
c: file.codex,
|
|
57
|
-
r: file.records.map(row),
|
|
58
|
-
t: file.tail.map(row),
|
|
20
|
+
r: file.records.map((record) => writer.row(record)),
|
|
21
|
+
t: file.tail.map((record) => writer.row(record)),
|
|
59
22
|
...(file.head === null ? {} : { h: file.head }),
|
|
60
23
|
...(file.parserVersion === undefined ? {} : { v: file.parserVersion }),
|
|
61
24
|
...(file.compressed === undefined ? {} : { z: file.compressed })
|
|
62
25
|
};
|
|
63
26
|
}
|
|
64
|
-
const document = {
|
|
65
|
-
version: INDEX_VERSION,
|
|
66
|
-
models: models.values,
|
|
67
|
-
sessions: sessions.values,
|
|
68
|
-
folders: folders.values,
|
|
69
|
-
accounts: accounts.values,
|
|
70
|
-
files
|
|
71
|
-
};
|
|
27
|
+
const document = { version: INDEX_VERSION, ...writer.tables, files };
|
|
72
28
|
return JSON.stringify(document);
|
|
73
29
|
}
|
|
74
|
-
function isRow(row, version) {
|
|
75
|
-
return (Array.isArray(row) &&
|
|
76
|
-
(version === ACCOUNTLESS_VERSION ? row.length === 12 : row.length >= 13 && row.length <= (version === INDEX_VERSION ? 15 : 14)) &&
|
|
77
|
-
row.slice(0, 11).every((value) => typeof value === 'number') &&
|
|
78
|
-
(typeof row[11] === 'string' || row[11] === 0) &&
|
|
79
|
-
(version === ACCOUNTLESS_VERSION || typeof row[12] === 'number') &&
|
|
80
|
-
(row.length < 14 || row[13] === null || row[13] === undefined || typeof row[13] === 'number') &&
|
|
81
|
-
(row.length < 15 || typeof row[14] === 'string'));
|
|
82
|
-
}
|
|
83
30
|
/*
|
|
84
|
-
*
|
|
85
|
-
* so
|
|
31
|
+
* Null for text that is no index of a version this reads. A file whose rows do not read back is left
|
|
32
|
+
* out, so only that file's usage is missing from what migrates, never a wrong total.
|
|
86
33
|
*/
|
|
87
34
|
export function decodeIndex(text) {
|
|
88
|
-
const index = new Map();
|
|
89
35
|
let document;
|
|
90
36
|
try {
|
|
91
37
|
document = JSON.parse(text);
|
|
92
38
|
}
|
|
93
39
|
catch {
|
|
94
|
-
return
|
|
40
|
+
return null;
|
|
95
41
|
}
|
|
96
42
|
const version = document?.version;
|
|
97
43
|
if ((version !== INDEX_VERSION && version !== 2 && version !== ACCOUNTLESS_VERSION) ||
|
|
98
44
|
!Array.isArray(document.models) ||
|
|
99
45
|
!Array.isArray(document.sessions) ||
|
|
100
46
|
!Array.isArray(document.folders)) {
|
|
101
|
-
return
|
|
47
|
+
return null;
|
|
102
48
|
}
|
|
103
|
-
const
|
|
104
|
-
const
|
|
49
|
+
const index = new Map();
|
|
50
|
+
const tables = { ...document, accounts: Array.isArray(document.accounts) ? document.accounts : [] };
|
|
51
|
+
const shape = { accountless: version === ACCOUNTLESS_VERSION, maxLength: version === INDEX_VERSION ? CURRENT_ROW.maxLength : 14 };
|
|
105
52
|
for (const [path, file] of Object.entries(document.files ?? {})) {
|
|
106
53
|
// Every provider the schema knows, so a new one is never dropped from the index it was saved in.
|
|
107
54
|
if (!UsageProviderSchema.safeParse(file.p).success) {
|
|
108
55
|
continue;
|
|
109
56
|
}
|
|
110
|
-
if (typeof file.s !== 'number' || typeof file.m !== 'number' || typeof file.o !== 'number' || file.o < 0
|
|
57
|
+
if (typeof file.s !== 'number' || typeof file.m !== 'number' || typeof file.o !== 'number' || file.o < 0) {
|
|
111
58
|
continue;
|
|
112
59
|
}
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const valid = isRow(row, version);
|
|
118
|
-
const model = valid ? name(document.models, row[1]) : null;
|
|
119
|
-
const sessionId = valid ? name(document.sessions, row[2]) : null;
|
|
120
|
-
const cwd = valid ? name(document.folders, row[3]) : null;
|
|
121
|
-
const accountAt = valid ? (row[12] ?? 0) : 0;
|
|
122
|
-
const account = accountAt === 0 ? undefined : name(accounts, accountAt - 1);
|
|
123
|
-
if (!valid || model === null || sessionId === null || cwd === null || account === null) {
|
|
124
|
-
broken = true;
|
|
125
|
-
return records;
|
|
126
|
-
}
|
|
127
|
-
records.push({
|
|
128
|
-
provider: file.p,
|
|
129
|
-
timestampMs: row[0],
|
|
130
|
-
model,
|
|
131
|
-
sessionId,
|
|
132
|
-
cwd,
|
|
133
|
-
...(account === undefined ? {} : { account }),
|
|
134
|
-
totals: { calls: row[4], input: row[5], cacheRead: row[6], cacheWrite: row[7], cacheWrite1h: row[8], output: row[9], reasoning: row[10] },
|
|
135
|
-
...(typeof row[13] === 'number' ? { costUsd: row[13] } : {}),
|
|
136
|
-
...(typeof row[14] === 'string' ? { serviceTier: row[14] } : {}),
|
|
137
|
-
dedupeKey: row[11] === 0 ? null : row[11]
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
return records;
|
|
141
|
-
};
|
|
142
|
-
const records = decode(file.r);
|
|
143
|
-
const tail = decode(Array.isArray(file.t) ? file.t : []);
|
|
144
|
-
if (!broken) {
|
|
145
|
-
index.set(path, {
|
|
146
|
-
provider: file.p,
|
|
147
|
-
size: file.s,
|
|
148
|
-
mtimeMs: file.m,
|
|
149
|
-
offset: file.o,
|
|
150
|
-
records,
|
|
151
|
-
tail,
|
|
152
|
-
codex: file.c ?? null,
|
|
153
|
-
head: typeof file.h === 'string' ? file.h : null,
|
|
154
|
-
...(typeof file.v === 'number' ? { parserVersion: file.v } : {}),
|
|
155
|
-
...(typeof file.z === 'boolean' ? { compressed: file.z } : {})
|
|
156
|
-
});
|
|
60
|
+
const records = decodeRows(file.r, tables, file.p, shape);
|
|
61
|
+
const tail = decodeRows(Array.isArray(file.t) ? file.t : [], tables, file.p, shape);
|
|
62
|
+
if (records === null || tail === null) {
|
|
63
|
+
continue;
|
|
157
64
|
}
|
|
65
|
+
index.set(path, {
|
|
66
|
+
provider: file.p,
|
|
67
|
+
size: file.s,
|
|
68
|
+
mtimeMs: file.m,
|
|
69
|
+
offset: file.o,
|
|
70
|
+
records,
|
|
71
|
+
tail,
|
|
72
|
+
codex: file.c ?? null,
|
|
73
|
+
head: typeof file.h === 'string' ? file.h : null,
|
|
74
|
+
...(typeof file.v === 'number' ? { parserVersion: file.v } : {}),
|
|
75
|
+
...(typeof file.z === 'boolean' ? { compressed: file.z } : {})
|
|
76
|
+
});
|
|
158
77
|
}
|
|
159
78
|
return index;
|
|
160
79
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { UsageProvider } from '@adecore/agent-contracts';
|
|
2
|
+
import type { UsageRecord } from './record.ts';
|
|
3
|
+
export type RecordRow = [number, number, number, number, number, number, number, number, number, number, number, string | 0, number?, number?, string?];
|
|
4
|
+
export interface RowTables {
|
|
5
|
+
models: string[];
|
|
6
|
+
sessions: string[];
|
|
7
|
+
folders: string[];
|
|
8
|
+
accounts: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare class RowWriter {
|
|
11
|
+
private readonly models;
|
|
12
|
+
private readonly sessions;
|
|
13
|
+
private readonly folders;
|
|
14
|
+
private readonly accounts;
|
|
15
|
+
get tables(): RowTables;
|
|
16
|
+
row(record: UsageRecord): RecordRow;
|
|
17
|
+
}
|
|
18
|
+
export interface RowShape {
|
|
19
|
+
accountless: boolean;
|
|
20
|
+
maxLength: number;
|
|
21
|
+
}
|
|
22
|
+
export declare const CURRENT_ROW: RowShape;
|
|
23
|
+
export declare function decodeRows(rows: unknown, tables: Partial<Record<keyof RowTables, unknown>>, provider: UsageProvider, shape: RowShape): UsageRecord[] | null;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
class Interner {
|
|
2
|
+
values = [];
|
|
3
|
+
at = new Map();
|
|
4
|
+
index(value) {
|
|
5
|
+
const seen = this.at.get(value);
|
|
6
|
+
if (seen !== undefined) {
|
|
7
|
+
return seen;
|
|
8
|
+
}
|
|
9
|
+
const next = this.values.length;
|
|
10
|
+
this.values.push(value);
|
|
11
|
+
this.at.set(value, next);
|
|
12
|
+
return next;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/* Turns records into rows, collecting the names they share into one set of tables. */
|
|
16
|
+
export class RowWriter {
|
|
17
|
+
models = new Interner();
|
|
18
|
+
sessions = new Interner();
|
|
19
|
+
folders = new Interner();
|
|
20
|
+
accounts = new Interner();
|
|
21
|
+
get tables() {
|
|
22
|
+
return { models: this.models.values, sessions: this.sessions.values, folders: this.folders.values, accounts: this.accounts.values };
|
|
23
|
+
}
|
|
24
|
+
row(record) {
|
|
25
|
+
const counted = [
|
|
26
|
+
record.timestampMs,
|
|
27
|
+
this.models.index(record.model),
|
|
28
|
+
this.sessions.index(record.sessionId),
|
|
29
|
+
this.folders.index(record.cwd),
|
|
30
|
+
record.totals.calls,
|
|
31
|
+
record.totals.input,
|
|
32
|
+
record.totals.cacheRead,
|
|
33
|
+
record.totals.cacheWrite,
|
|
34
|
+
record.totals.cacheWrite1h,
|
|
35
|
+
record.totals.output,
|
|
36
|
+
record.totals.reasoning,
|
|
37
|
+
record.dedupeKey ?? 0,
|
|
38
|
+
record.account === undefined ? 0 : this.accounts.index(record.account) + 1
|
|
39
|
+
];
|
|
40
|
+
if (record.costUsd !== undefined || record.serviceTier !== undefined) {
|
|
41
|
+
counted[13] = record.costUsd;
|
|
42
|
+
}
|
|
43
|
+
if (record.serviceTier !== undefined) {
|
|
44
|
+
counted[14] = record.serviceTier;
|
|
45
|
+
}
|
|
46
|
+
return counted;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export const CURRENT_ROW = { accountless: false, maxLength: 15 };
|
|
50
|
+
function isRow(row, shape) {
|
|
51
|
+
return (Array.isArray(row) &&
|
|
52
|
+
(shape.accountless ? row.length === 12 : row.length >= 13 && row.length <= shape.maxLength) &&
|
|
53
|
+
row.slice(0, 11).every((value) => typeof value === 'number') &&
|
|
54
|
+
(typeof row[11] === 'string' || row[11] === 0) &&
|
|
55
|
+
(shape.accountless || typeof row[12] === 'number') &&
|
|
56
|
+
(row.length < 14 || row[13] === null || row[13] === undefined || typeof row[13] === 'number') &&
|
|
57
|
+
(row.length < 15 || typeof row[14] === 'string'));
|
|
58
|
+
}
|
|
59
|
+
function nameAt(table, at) {
|
|
60
|
+
return Array.isArray(table) && typeof table[at] === 'string' ? table[at] : null;
|
|
61
|
+
}
|
|
62
|
+
/* The records of `rows`, or null when any row does not read back against its tables. */
|
|
63
|
+
export function decodeRows(rows, tables, provider, shape) {
|
|
64
|
+
if (!Array.isArray(rows)) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const records = [];
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
if (!isRow(row, shape)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const model = nameAt(tables.models, row[1]);
|
|
73
|
+
const sessionId = nameAt(tables.sessions, row[2]);
|
|
74
|
+
const cwd = nameAt(tables.folders, row[3]);
|
|
75
|
+
const accountAt = row[12] ?? 0;
|
|
76
|
+
const account = accountAt === 0 ? undefined : nameAt(tables.accounts, accountAt - 1);
|
|
77
|
+
if (model === null || sessionId === null || cwd === null || account === null) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
records.push({
|
|
81
|
+
provider,
|
|
82
|
+
timestampMs: row[0],
|
|
83
|
+
model,
|
|
84
|
+
sessionId,
|
|
85
|
+
cwd,
|
|
86
|
+
...(account === undefined ? {} : { account }),
|
|
87
|
+
totals: { calls: row[4], input: row[5], cacheRead: row[6], cacheWrite: row[7], cacheWrite1h: row[8], output: row[9], reasoning: row[10] },
|
|
88
|
+
...(typeof row[13] === 'number' ? { costUsd: row[13] } : {}),
|
|
89
|
+
...(typeof row[14] === 'string' ? { serviceTier: row[14] } : {}),
|
|
90
|
+
dedupeKey: row[11] === 0 ? null : row[11]
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return records;
|
|
94
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type UsageProvider } from '@adecore/agent-contracts';
|
|
2
|
+
import type { CodexParserState } from './readers/codex.ts';
|
|
3
|
+
export interface ScanState {
|
|
4
|
+
provider: UsageProvider;
|
|
5
|
+
size: number;
|
|
6
|
+
mtimeMs: number;
|
|
7
|
+
offset: number;
|
|
8
|
+
codex: CodexParserState | null;
|
|
9
|
+
head: string | null;
|
|
10
|
+
parserVersion?: number;
|
|
11
|
+
compressed?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface HistoryMark {
|
|
14
|
+
gen: string;
|
|
15
|
+
bytes: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function encodeState(state: ReadonlyMap<string, ScanState>, history: HistoryMark): string;
|
|
18
|
+
export declare function decodeState(text: string): {
|
|
19
|
+
history: HistoryMark;
|
|
20
|
+
files: Map<string, ScanState>;
|
|
21
|
+
} | null;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { UsageProviderSchema } from '@adecore/agent-contracts';
|
|
2
|
+
const STATE_VERSION = 1;
|
|
3
|
+
export function encodeState(state, history) {
|
|
4
|
+
const files = {};
|
|
5
|
+
for (const [path, file] of state) {
|
|
6
|
+
files[path] = {
|
|
7
|
+
p: file.provider,
|
|
8
|
+
s: file.size,
|
|
9
|
+
m: file.mtimeMs,
|
|
10
|
+
o: file.offset,
|
|
11
|
+
c: file.codex,
|
|
12
|
+
...(file.head === null ? {} : { h: file.head }),
|
|
13
|
+
...(file.parserVersion === undefined ? {} : { v: file.parserVersion }),
|
|
14
|
+
...(file.compressed === undefined ? {} : { z: file.compressed })
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const document = { version: STATE_VERSION, history, files };
|
|
18
|
+
return JSON.stringify(document);
|
|
19
|
+
}
|
|
20
|
+
/* Null for text that is no state of this version; an entry that does not read only costs a read of its transcript. */
|
|
21
|
+
export function decodeState(text) {
|
|
22
|
+
let document;
|
|
23
|
+
try {
|
|
24
|
+
document = JSON.parse(text);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const history = document?.history;
|
|
30
|
+
if (document?.version !== STATE_VERSION || typeof history?.gen !== 'string' || typeof history.bytes !== 'number') {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const files = new Map();
|
|
34
|
+
for (const [path, file] of Object.entries(document.files ?? {})) {
|
|
35
|
+
if (!UsageProviderSchema.safeParse(file.p).success) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (typeof file.s !== 'number' || typeof file.m !== 'number' || typeof file.o !== 'number' || file.o < 0) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
files.set(path, {
|
|
42
|
+
provider: file.p,
|
|
43
|
+
size: file.s,
|
|
44
|
+
mtimeMs: file.m,
|
|
45
|
+
offset: file.o,
|
|
46
|
+
codex: file.c ?? null,
|
|
47
|
+
head: typeof file.h === 'string' ? file.h : null,
|
|
48
|
+
...(typeof file.v === 'number' ? { parserVersion: file.v } : {}),
|
|
49
|
+
...(typeof file.z === 'boolean' ? { compressed: file.z } : {})
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return { history: { gen: history.gen, bytes: history.bytes }, files };
|
|
53
|
+
}
|
package/dist/usage/scanner.d.ts
CHANGED
|
@@ -20,14 +20,11 @@ export interface UsageScannerOptions {
|
|
|
20
20
|
type AccountOf = (record: UsageRecord) => string | undefined;
|
|
21
21
|
export declare function accountResolver(root: UsageRootPath, sessionAccounts: () => ReadonlyMap<string, string>): AccountOf;
|
|
22
22
|
export declare class UsageScanner {
|
|
23
|
-
private readonly home;
|
|
24
23
|
private readonly roots;
|
|
25
24
|
private readonly sessionAccounts;
|
|
26
|
-
private
|
|
25
|
+
private readonly store;
|
|
27
26
|
private loaded;
|
|
28
|
-
private dirty;
|
|
29
27
|
constructor(home: string, roots?: UsageRootPath[] | (() => UsageRootPath[]), options?: UsageScannerOptions);
|
|
30
|
-
private get file();
|
|
31
28
|
load(): Promise<void>;
|
|
32
29
|
scan(): Promise<ScanReport>;
|
|
33
30
|
records(): UsageRecord[];
|
package/dist/usage/scanner.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
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 { errorText } from '../error-text.js';
|
|
7
|
-
import { isNotFound
|
|
8
|
-
import { decodeIndex, encodeIndex } from './index-file.js';
|
|
7
|
+
import { isNotFound } from '../fs.js';
|
|
9
8
|
import { foldByKey } from './record.js';
|
|
10
9
|
import { claudeMightCarryUsage, parseClaudeLine } from './readers/claude.js';
|
|
11
10
|
import { GROK_USAGE_FILE, parseGrokUsage } from './readers/grok.js';
|
|
12
11
|
import { hostLogMightCarryUsage, parseHostUsageLine } from './readers/host-log.js';
|
|
13
12
|
import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine } from './readers/codex.js';
|
|
14
13
|
import { usageRoots } from './roots.js';
|
|
14
|
+
import { UsageStore } from './usage-store.js';
|
|
15
15
|
const HASH_CHUNK_BYTES = 65536;
|
|
16
16
|
// 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.
|
|
17
17
|
const READ_CHUNK_BYTES = 1 << 20;
|
|
@@ -77,6 +77,10 @@ function readsWhole(provider) {
|
|
|
77
77
|
function isUsageFile(provider, name) {
|
|
78
78
|
return readsWhole(provider) ? name === GROK_USAGE_FILE : name.endsWith('.jsonl') || (provider === 'codex' && name.endsWith('.jsonl.zst'));
|
|
79
79
|
}
|
|
80
|
+
/* A compressed Codex session is the same transcript as the plain one it was, so both count under one path. */
|
|
81
|
+
function keyOf(path, provider) {
|
|
82
|
+
return provider === 'codex' && path.endsWith('.jsonl.zst') ? path.slice(0, -4) : path;
|
|
83
|
+
}
|
|
80
84
|
async function listUsageFiles(dir, provider, out = []) {
|
|
81
85
|
let entries;
|
|
82
86
|
try {
|
|
@@ -186,42 +190,33 @@ function withAccount(records, accountOf) {
|
|
|
186
190
|
}
|
|
187
191
|
/*
|
|
188
192
|
* Parses only what a transcript appended since the last scan, and starts a file over once it was
|
|
189
|
-
* rewritten or cut. A deleted transcript keeps its totals, since its usage still happened
|
|
193
|
+
* rewritten or cut. A deleted transcript keeps its totals, since its usage still happened; only
|
|
194
|
+
* where the scan left it is forgotten.
|
|
190
195
|
*/
|
|
191
196
|
export class UsageScanner {
|
|
192
|
-
home;
|
|
193
197
|
roots;
|
|
194
198
|
sessionAccounts;
|
|
195
|
-
|
|
199
|
+
store;
|
|
196
200
|
loaded = false;
|
|
197
|
-
dirty = false;
|
|
198
201
|
/* `roots` as a function is asked again on every scan, so an account added since is walked too. */
|
|
199
202
|
constructor(home, roots = usageRoots(), options = {}) {
|
|
200
|
-
this.
|
|
203
|
+
this.store = new UsageStore(join(home, 'usage'));
|
|
201
204
|
this.roots = typeof roots === 'function' ? roots : () => roots;
|
|
202
205
|
this.sessionAccounts = options.sessionAccounts ?? (() => new Map());
|
|
203
206
|
}
|
|
204
|
-
|
|
205
|
-
return join(this.home, 'usage', 'index.json');
|
|
206
|
-
}
|
|
207
|
+
/* A history that does not read fails the scan rather than being written over, which would lose what only it holds. */
|
|
207
208
|
async load() {
|
|
208
209
|
if (this.loaded) {
|
|
209
210
|
return;
|
|
210
211
|
}
|
|
212
|
+
await this.store.load();
|
|
211
213
|
this.loaded = true;
|
|
212
|
-
try {
|
|
213
|
-
this.index = decodeIndex(await readFile(this.file, 'utf8'));
|
|
214
|
-
}
|
|
215
|
-
catch (e) {
|
|
216
|
-
if (!isNotFound(e)) {
|
|
217
|
-
console.warn('The usage index would not read; starting from a cold scan:', errorText(e));
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
214
|
}
|
|
221
215
|
async scan() {
|
|
222
216
|
const started = Date.now();
|
|
223
217
|
await this.load();
|
|
224
218
|
const roots = [];
|
|
219
|
+
const seen = new Set();
|
|
225
220
|
let files = 0;
|
|
226
221
|
let changedFiles = 0;
|
|
227
222
|
let sessions = null;
|
|
@@ -256,6 +251,7 @@ export class UsageScanner {
|
|
|
256
251
|
continue;
|
|
257
252
|
}
|
|
258
253
|
files += 1;
|
|
254
|
+
seen.add(keyOf(path, root.provider));
|
|
259
255
|
try {
|
|
260
256
|
if (await this.readFileInto(path, root.provider, accountOf)) {
|
|
261
257
|
changedFiles += 1;
|
|
@@ -268,26 +264,24 @@ export class UsageScanner {
|
|
|
268
264
|
}
|
|
269
265
|
roots.push({ provider: root.provider, path: root.path, status: failure === null ? 'ok' : 'failed', message: failure });
|
|
270
266
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
267
|
+
// A root that is gone for now costs a read from the start once it is back, which replaces and never adds.
|
|
268
|
+
for (const path of [...this.store.state.keys()]) {
|
|
269
|
+
if (!seen.has(path)) {
|
|
270
|
+
this.store.forget(path);
|
|
271
|
+
}
|
|
275
272
|
}
|
|
273
|
+
await this.store.commit();
|
|
276
274
|
return { at: Date.now(), files, changedFiles, durationMs: Date.now() - started, roots };
|
|
277
275
|
}
|
|
278
|
-
/* Every record the
|
|
276
|
+
/* Every record the history holds, with the copies one message left in several transcripts folded. */
|
|
279
277
|
records() {
|
|
280
|
-
|
|
281
|
-
for (const file of this.index.values()) {
|
|
282
|
-
all.push(...file.records, ...file.tail);
|
|
283
|
-
}
|
|
284
|
-
return foldByKey(all);
|
|
278
|
+
return this.store.records();
|
|
285
279
|
}
|
|
286
280
|
async readFileInto(path, provider, accountOf) {
|
|
287
281
|
const info = await stat(path);
|
|
288
282
|
const compressed = provider === 'codex' && path.endsWith('.jsonl.zst');
|
|
289
|
-
const key =
|
|
290
|
-
const known = this.
|
|
283
|
+
const key = keyOf(path, provider);
|
|
284
|
+
const known = this.store.state.get(key);
|
|
291
285
|
const currentParser = provider !== 'codex' || known?.parserVersion === CODEX_PARSER_VERSION;
|
|
292
286
|
if (currentParser &&
|
|
293
287
|
known !== undefined &&
|
|
@@ -313,25 +307,27 @@ export class UsageScanner {
|
|
|
313
307
|
const hash = grown === null || prefix === null ? createHash('sha256') : prefix;
|
|
314
308
|
const from = grown?.offset ?? 0;
|
|
315
309
|
const parsed = await parseLines(readBytes(path, from, compressed), hash, provider, from, grown?.codex ?? null);
|
|
316
|
-
const
|
|
317
|
-
const
|
|
310
|
+
const usage = { provider, records: withAccount(parsed.records, accountOf), tail: withAccount(parsed.tail, accountOf) };
|
|
311
|
+
const state = {
|
|
318
312
|
provider,
|
|
319
313
|
size: info.size,
|
|
320
314
|
mtimeMs: info.mtimeMs,
|
|
321
315
|
offset: parsed.offset,
|
|
322
|
-
// Folding here keeps the streaming copies of one message out of the index entirely.
|
|
323
|
-
records: foldByKey([...before, ...withAccount(parsed.records, accountOf)]),
|
|
324
|
-
tail: withAccount(parsed.tail, accountOf),
|
|
325
316
|
codex: parsed.codex,
|
|
326
317
|
head: headOf(hash),
|
|
327
318
|
...(provider === 'codex' ? { parserVersion: CODEX_PARSER_VERSION, compressed } : {})
|
|
328
319
|
};
|
|
329
|
-
|
|
330
|
-
|
|
320
|
+
if (grown === null) {
|
|
321
|
+
// Folding here keeps the streaming copies of one message out of the history entirely.
|
|
322
|
+
this.store.replace(key, { ...usage, records: foldByKey(usage.records) }, state);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
this.store.append(key, usage, state);
|
|
326
|
+
}
|
|
331
327
|
return true;
|
|
332
328
|
}
|
|
333
329
|
/*
|
|
334
|
-
* A file rewritten as a whole replaces what the
|
|
330
|
+
* A file rewritten as a whole replaces what the history held of it, so a turn it still lists is never
|
|
335
331
|
* counted twice. One that does not parse, such as one caught halfway through a write, keeps the
|
|
336
332
|
* records it had and is read again on the next scan.
|
|
337
333
|
*/
|
|
@@ -340,17 +336,8 @@ export class UsageScanner {
|
|
|
340
336
|
if (records === null) {
|
|
341
337
|
return false;
|
|
342
338
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
size: info.size,
|
|
346
|
-
mtimeMs: info.mtimeMs,
|
|
347
|
-
offset: info.size,
|
|
348
|
-
records: foldByKey(withAccount(records, accountOf)),
|
|
349
|
-
tail: [],
|
|
350
|
-
codex: null,
|
|
351
|
-
head: null
|
|
352
|
-
});
|
|
353
|
-
this.dirty = true;
|
|
339
|
+
const state = { provider, size: info.size, mtimeMs: info.mtimeMs, offset: info.size, codex: null, head: null };
|
|
340
|
+
this.store.replace(path, { provider, records: foldByKey(withAccount(records, accountOf)), tail: [] }, state);
|
|
354
341
|
return true;
|
|
355
342
|
}
|
|
356
343
|
}
|