@adecore/agents 0.41.0 → 0.42.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/providers/antigravity-models.json +1 -1
- package/dist/providers/claude-models.json +1 -1
- package/dist/providers/codex-models.json +1 -1
- package/dist/providers/copilot-models.json +1 -1
- package/dist/providers/grok-models.json +3 -3
- 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/providers/antigravity-models.json +1 -1
- package/src/providers/claude-models.json +1 -1
- package/src/providers/codex-models.json +1 -1
- package/src/providers/copilot-models.json +1 -1
- package/src/providers/grok-models.json +3 -3
- 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/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
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type FileUsage } from './history-file.ts';
|
|
2
|
+
import { type UsageRecord } from './record.ts';
|
|
3
|
+
import { type ScanState } from './scan-state.ts';
|
|
4
|
+
export declare class UsageStore {
|
|
5
|
+
readonly usage: Map<string, FileUsage>;
|
|
6
|
+
readonly state: Map<string, ScanState>;
|
|
7
|
+
private readonly dir;
|
|
8
|
+
private pending;
|
|
9
|
+
private stateDirty;
|
|
10
|
+
private gen;
|
|
11
|
+
private base;
|
|
12
|
+
private bytes;
|
|
13
|
+
private rewriteNext;
|
|
14
|
+
constructor(dir: string);
|
|
15
|
+
load(): Promise<void>;
|
|
16
|
+
records(): UsageRecord[];
|
|
17
|
+
replace(path: string, usage: FileUsage, state: ScanState): void;
|
|
18
|
+
append(path: string, added: FileUsage, state: ScanState): void;
|
|
19
|
+
forget(path: string): void;
|
|
20
|
+
commit(): Promise<void>;
|
|
21
|
+
private setState;
|
|
22
|
+
private appendPending;
|
|
23
|
+
private rewrite;
|
|
24
|
+
private saveState;
|
|
25
|
+
private migrate;
|
|
26
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
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.js';
|
|
6
|
+
import { isNotFound, removeFile, writeAtomic } from '../fs.js';
|
|
7
|
+
import { applyEntry, decodeHistory, encodeEntry, encodeHeader } from './history-file.js';
|
|
8
|
+
import { decodeIndex } from './index-file.js';
|
|
9
|
+
import { foldByKey } from './record.js';
|
|
10
|
+
import { decodeState, encodeState } from './scan-state.js';
|
|
11
|
+
const HISTORY_FILE = 'history.jsonl';
|
|
12
|
+
const STATE_FILE = 'scan-state.json';
|
|
13
|
+
const LEGACY_INDEX_FILE = 'index.json';
|
|
14
|
+
// Below this, appended lines are not worth a rewrite of the whole history.
|
|
15
|
+
const COMPACT_FLOOR_BYTES = 1 << 20;
|
|
16
|
+
async function readOptional(path) {
|
|
17
|
+
try {
|
|
18
|
+
return await readFile(path);
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
if (isNotFound(e)) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
throw e;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/*
|
|
28
|
+
* The usage of every transcript ever read, kept apart from where the scanner left each one. The state
|
|
29
|
+
* is saved after the lines it accounts for, against the history's size, so after a crash in between it
|
|
30
|
+
* does not match: it is dropped and every transcript is read from the start again, which replaces what
|
|
31
|
+
* a path counted instead of adding to it.
|
|
32
|
+
*/
|
|
33
|
+
export class UsageStore {
|
|
34
|
+
usage = new Map();
|
|
35
|
+
state = new Map();
|
|
36
|
+
dir;
|
|
37
|
+
pending = [];
|
|
38
|
+
stateDirty = false;
|
|
39
|
+
gen = null;
|
|
40
|
+
base = 0;
|
|
41
|
+
bytes = 0;
|
|
42
|
+
// Set when the history on disk may not end where this store thinks it does.
|
|
43
|
+
rewriteNext = false;
|
|
44
|
+
constructor(dir) {
|
|
45
|
+
this.dir = dir;
|
|
46
|
+
}
|
|
47
|
+
async load() {
|
|
48
|
+
this.usage.clear();
|
|
49
|
+
this.state.clear();
|
|
50
|
+
const history = await readOptional(join(this.dir, HISTORY_FILE));
|
|
51
|
+
if (history === null) {
|
|
52
|
+
await this.migrate();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const decoded = decodeHistory(history);
|
|
56
|
+
for (const [path, usage] of decoded.usage) {
|
|
57
|
+
this.usage.set(path, usage);
|
|
58
|
+
}
|
|
59
|
+
if (decoded.complete < history.length) {
|
|
60
|
+
await truncate(join(this.dir, HISTORY_FILE), decoded.complete);
|
|
61
|
+
}
|
|
62
|
+
this.gen = decoded.header?.gen ?? null;
|
|
63
|
+
this.base = decoded.header?.base ?? 0;
|
|
64
|
+
this.bytes = decoded.complete;
|
|
65
|
+
this.rewriteNext = this.gen === null;
|
|
66
|
+
const saved = await readOptional(join(this.dir, STATE_FILE));
|
|
67
|
+
const state = saved === null ? null : decodeState(saved.toString('utf8'));
|
|
68
|
+
if (state !== null && state.history.gen === this.gen && state.history.bytes === this.bytes) {
|
|
69
|
+
for (const [path, file] of state.files) {
|
|
70
|
+
this.state.set(path, file);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// A migration that stopped after writing the history; the index has nothing the history lacks.
|
|
74
|
+
await removeFile(join(this.dir, LEGACY_INDEX_FILE));
|
|
75
|
+
}
|
|
76
|
+
records() {
|
|
77
|
+
const all = [];
|
|
78
|
+
for (const file of this.usage.values()) {
|
|
79
|
+
all.push(...file.records, ...file.tail);
|
|
80
|
+
}
|
|
81
|
+
return foldByKey(all);
|
|
82
|
+
}
|
|
83
|
+
/* A transcript read from the start: what it counts now replaces what it counted. */
|
|
84
|
+
replace(path, usage, state) {
|
|
85
|
+
this.setState(path, state);
|
|
86
|
+
if (isDeepStrictEqual(this.usage.get(path), usage)) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this.usage.set(path, usage);
|
|
90
|
+
this.pending.push({ path, op: 'set', usage });
|
|
91
|
+
}
|
|
92
|
+
/* A transcript read from where it was left: `added` counts on top of what it had. */
|
|
93
|
+
append(path, added, state) {
|
|
94
|
+
this.setState(path, state);
|
|
95
|
+
const known = this.usage.get(path);
|
|
96
|
+
if (added.records.length === 0 && known?.provider === added.provider && isDeepStrictEqual(known.tail, added.tail)) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const entry = { path, op: 'add', usage: { ...added, records: foldByKey(added.records) } };
|
|
100
|
+
applyEntry(this.usage, entry);
|
|
101
|
+
this.pending.push(entry);
|
|
102
|
+
}
|
|
103
|
+
/* Where the scanner left a transcript that is gone; what it counted stays. */
|
|
104
|
+
forget(path) {
|
|
105
|
+
if (this.state.delete(path)) {
|
|
106
|
+
this.stateDirty = true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async commit() {
|
|
110
|
+
if (this.pending.length === 0 && !this.stateDirty) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
114
|
+
try {
|
|
115
|
+
if (this.rewriteNext || (this.pending.length > 0 && this.gen === null)) {
|
|
116
|
+
await this.rewrite();
|
|
117
|
+
}
|
|
118
|
+
else if (this.pending.length > 0) {
|
|
119
|
+
await this.appendPending();
|
|
120
|
+
}
|
|
121
|
+
await this.saveState();
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
// What failed to land is in memory, and a rewrite puts all of it on disk.
|
|
125
|
+
this.pending = [];
|
|
126
|
+
this.rewriteNext = true;
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
setState(path, state) {
|
|
131
|
+
this.state.set(path, state);
|
|
132
|
+
this.stateDirty = true;
|
|
133
|
+
}
|
|
134
|
+
async appendPending() {
|
|
135
|
+
const text = this.pending.map(encodeEntry).join('');
|
|
136
|
+
this.pending = [];
|
|
137
|
+
await appendFile(join(this.dir, HISTORY_FILE), text, { mode: 0o600 });
|
|
138
|
+
this.bytes += Buffer.byteLength(text);
|
|
139
|
+
if (this.bytes > 2 * this.base && this.bytes - this.base > COMPACT_FLOOR_BYTES) {
|
|
140
|
+
await this.rewrite();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/* The history as one `set` line per path, which drops every line a later one superseded. */
|
|
144
|
+
async rewrite() {
|
|
145
|
+
const lines = [...this.usage].map(([path, usage]) => encodeEntry({ path, op: 'set', usage }));
|
|
146
|
+
const gen = randomUUID();
|
|
147
|
+
const body = lines.join('');
|
|
148
|
+
const base = Buffer.byteLength(body);
|
|
149
|
+
const header = encodeHeader({ gen, base });
|
|
150
|
+
this.pending = [];
|
|
151
|
+
await writeAtomic(join(this.dir, HISTORY_FILE), header + body);
|
|
152
|
+
this.gen = gen;
|
|
153
|
+
this.base = base;
|
|
154
|
+
this.bytes = Buffer.byteLength(header + body);
|
|
155
|
+
this.rewriteNext = false;
|
|
156
|
+
}
|
|
157
|
+
async saveState() {
|
|
158
|
+
if (this.gen === null) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
await writeAtomic(join(this.dir, STATE_FILE), encodeState(this.state, { gen: this.gen, bytes: this.bytes }));
|
|
162
|
+
this.stateDirty = false;
|
|
163
|
+
}
|
|
164
|
+
/*
|
|
165
|
+
* Moves an index of the single-file format into history and scan state. The history goes first:
|
|
166
|
+
* once it is there, a load treats the index as moved, so an interrupted migration finishes on the next load.
|
|
167
|
+
*/
|
|
168
|
+
async migrate() {
|
|
169
|
+
const legacy = join(this.dir, LEGACY_INDEX_FILE);
|
|
170
|
+
const text = await readOptional(legacy);
|
|
171
|
+
if (text === null) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const index = decodeIndex(text.toString('utf8'));
|
|
175
|
+
if (index === null) {
|
|
176
|
+
console.warn('The usage index would not read; it is kept beside a fresh history.');
|
|
177
|
+
await rename(legacy, `${legacy}.unreadable`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
for (const [path, file] of index) {
|
|
181
|
+
const { records, tail, provider, ...state } = file;
|
|
182
|
+
this.usage.set(path, { provider, records, tail });
|
|
183
|
+
this.state.set(path, { provider, ...state });
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
await this.rewrite();
|
|
187
|
+
await this.saveState();
|
|
188
|
+
await removeFile(legacy);
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
// The index stays and is moved again on the next load; this run counts from memory.
|
|
192
|
+
this.rewriteNext = true;
|
|
193
|
+
this.stateDirty = true;
|
|
194
|
+
console.warn('The usage index could not be moved to the history yet:', errorText(e));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
4
4
|
"description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -93,8 +93,8 @@
|
|
|
93
93
|
"test": "bun --conditions=source test"
|
|
94
94
|
},
|
|
95
95
|
"dependencies": {
|
|
96
|
-
"@adecore/agent-contracts": "0.
|
|
97
|
-
"@adecore/intelligent-ui": "0.
|
|
96
|
+
"@adecore/agent-contracts": "0.42.0",
|
|
97
|
+
"@adecore/intelligent-ui": "0.42.0",
|
|
98
98
|
"zod": "^4.6.7"
|
|
99
99
|
},
|
|
100
100
|
"devDependencies": {
|
package/src/chat/chat-core.ts
CHANGED
|
@@ -1148,9 +1148,31 @@ export class ChatCore {
|
|
|
1148
1148
|
}
|
|
1149
1149
|
}
|
|
1150
1150
|
|
|
1151
|
-
/* What a turn changed against the tree it started from; null when it has no checkpoint. */
|
|
1152
|
-
turnDiff(chatId: string, turnId: string): Promise<ChatCheckpointDiff | null> {
|
|
1153
|
-
|
|
1151
|
+
/* What a turn changed against the tree it started from, whole also where the thread keeps only its first diffs; null when it has no checkpoint. */
|
|
1152
|
+
async turnDiff(chatId: string, turnId: string): Promise<ChatCheckpointDiff | null> {
|
|
1153
|
+
const session = this.require(chatId);
|
|
1154
|
+
const turn = session.thread.get(turnId);
|
|
1155
|
+
|
|
1156
|
+
if (turn?.kind === 'turn' && turn.checkpointDiffRef && this.store) {
|
|
1157
|
+
return await this.store.readDiff(chatId, turn.checkpointDiffRef.id);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
return await session.turnDiff(turnId);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/* The whole of what a tool call answered, also where the thread keeps only its beginning. */
|
|
1164
|
+
async toolOutput(chatId: string, itemId: string): Promise<string | null> {
|
|
1165
|
+
const tool = this.require(chatId).thread.get(itemId);
|
|
1166
|
+
|
|
1167
|
+
if (tool?.kind !== 'tool') {
|
|
1168
|
+
throw new ChatError('item-not-found', `No tool call ${itemId} in chat ${chatId}`);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
if (!tool.outputRef || !this.store) {
|
|
1172
|
+
return tool.output;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
return await this.store.readPayload(chatId, tool.outputRef.id);
|
|
1154
1176
|
}
|
|
1155
1177
|
|
|
1156
1178
|
approve(chatId: string, requestId: string, decision: 'allow' | 'allow-always' | 'deny', message?: string): void {
|
|
@@ -1575,6 +1597,8 @@ export class ChatCore {
|
|
|
1575
1597
|
// A chat killed while the write was out has no log left to fold.
|
|
1576
1598
|
if (this.logs.get(chatId) === log && folds) {
|
|
1577
1599
|
log.compact(at.seq);
|
|
1600
|
+
// With the log folded, the record alone says which payloads the thread still needs.
|
|
1601
|
+
await this.store.prunePayloads(chatId);
|
|
1578
1602
|
}
|
|
1579
1603
|
});
|
|
1580
1604
|
|
package/src/chat/chat-store.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { mkdirSync } from 'node:fs';
|
|
2
2
|
import { mkdir, open, readdir, readFile, rm, stat } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { ChatInfoSchema, ChatItemSchema, type ChatInfo, type ChatItem } from '@adecore/agent-contracts';
|
|
4
|
+
import { ChatInfoSchema, ChatItemSchema, type ChatCheckpointDiff, type ChatInfo, type ChatItem } from '@adecore/agent-contracts';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { isNotFound, writeAtomic, writeAtomicSync } from '../fs.ts';
|
|
7
7
|
import { recordFileName } from '../record-directory.ts';
|
|
8
8
|
import { migrateInlineAttachments, type AttachmentStore } from './attachment-store.ts';
|
|
9
9
|
import { isBookmarkFileName } from './bookmark-store.ts';
|
|
10
10
|
import { parseLog, type ChatLogLine } from './chat-log.ts';
|
|
11
|
+
import { compactItems, PayloadStore, type PayloadSources } from './payload-store.ts';
|
|
11
12
|
import { ChatThread } from './thread.ts';
|
|
12
13
|
import { isVisualFileName } from './visual-store.ts';
|
|
13
14
|
|
|
@@ -103,17 +104,23 @@ export interface ChatStoreOptions {
|
|
|
103
104
|
|
|
104
105
|
/*
|
|
105
106
|
* One JSON file per chat under `<home>/chats`, written while a turn runs and on shutdown, with
|
|
106
|
-
* the log of what happened since beside it (`ChatLog` appends to it; reading plays it back).
|
|
107
|
+
* the log of what happened since beside it (`ChatLog` appends to it; reading plays it back). An
|
|
108
|
+
* output or diff past the inline limit goes to the chat's payload folder, and the record keeps
|
|
109
|
+
* its beginning (`PayloadStore`).
|
|
107
110
|
*/
|
|
108
111
|
export class ChatStore {
|
|
109
112
|
readonly dir: string;
|
|
110
113
|
private readonly attachments: AttachmentStore | null;
|
|
111
114
|
private readonly isSidecar: (name: string) => boolean;
|
|
115
|
+
private readonly payloads: PayloadStore;
|
|
116
|
+
// The payloads the record this process wrote last refers to, which is what a prune keeps.
|
|
117
|
+
private readonly referenced = new Map<string, Set<string>>();
|
|
112
118
|
|
|
113
119
|
constructor(home: string, options: ChatStoreOptions = {}) {
|
|
114
120
|
this.dir = join(home, 'chats');
|
|
115
121
|
this.attachments = options.attachments ?? null;
|
|
116
122
|
this.isSidecar = options.isSidecar ?? (() => false);
|
|
123
|
+
this.payloads = new PayloadStore(this.dir);
|
|
117
124
|
}
|
|
118
125
|
|
|
119
126
|
logPath(chatId: string): string {
|
|
@@ -169,9 +176,13 @@ export class ChatStore {
|
|
|
169
176
|
extras: ChatRecordExtras = {},
|
|
170
177
|
uiAccess: Readonly<Record<string, unknown>> = {}
|
|
171
178
|
): Promise<number> {
|
|
172
|
-
const
|
|
179
|
+
const compacted = compactItems(items);
|
|
173
180
|
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
181
|
+
// Before the record, so a record on disk never names a payload that is not.
|
|
182
|
+
await this.payloads.ensure(chatId, compacted.payloads);
|
|
183
|
+
const body = recordBody(info, compacted.items, at, preambles, extras, uiAccess);
|
|
174
184
|
await writeAtomic(join(this.dir, recordFileName(chatId)), body);
|
|
185
|
+
this.referenced.set(chatId, new Set(compacted.payloads.keys()));
|
|
175
186
|
|
|
176
187
|
return body.length;
|
|
177
188
|
}
|
|
@@ -189,8 +200,32 @@ export class ChatStore {
|
|
|
189
200
|
extras: ChatRecordExtras = {},
|
|
190
201
|
uiAccess: Readonly<Record<string, unknown>> = {}
|
|
191
202
|
): void {
|
|
203
|
+
const compacted = compactItems(items);
|
|
192
204
|
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
193
|
-
|
|
205
|
+
this.payloads.ensureSync(chatId, compacted.payloads);
|
|
206
|
+
writeAtomicSync(join(this.dir, recordFileName(chatId)), recordBody(info, compacted.items, at, preambles, extras, uiAccess));
|
|
207
|
+
this.referenced.set(chatId, new Set(compacted.payloads.keys()));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* The whole of a tool output or a diff kept apart from the record, by the id its item's ref carries. */
|
|
211
|
+
readPayload(chatId: string, id: string): Promise<string> {
|
|
212
|
+
return this.payloads.read(chatId, id);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
readDiff(chatId: string, id: string): Promise<ChatCheckpointDiff> {
|
|
216
|
+
return this.payloads.readDiff(chatId, id);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/*
|
|
220
|
+
* Removes the payloads the record written last no longer refers to, such as everything a clear
|
|
221
|
+
* emptied. Nothing before this process wrote the record, since only that write says what it holds.
|
|
222
|
+
*/
|
|
223
|
+
async prunePayloads(chatId: string): Promise<void> {
|
|
224
|
+
const keep = this.referenced.get(chatId);
|
|
225
|
+
|
|
226
|
+
if (keep) {
|
|
227
|
+
await this.payloads.prune(chatId, keep);
|
|
228
|
+
}
|
|
194
229
|
}
|
|
195
230
|
|
|
196
231
|
/* Null for a chat with nothing on disk to make it of, and for one whose record does not parse (see `unreadable`). */
|
|
@@ -203,7 +238,9 @@ export class ChatStore {
|
|
|
203
238
|
}
|
|
204
239
|
|
|
205
240
|
if (snapshot === null) {
|
|
206
|
-
|
|
241
|
+
const rebuilt = fromLogAlone(lines);
|
|
242
|
+
|
|
243
|
+
return rebuilt && { ...rebuilt, items: await this.compact(chatId, rebuilt.items) };
|
|
207
244
|
}
|
|
208
245
|
|
|
209
246
|
const seq = snapshot.seq ?? 0;
|
|
@@ -223,8 +260,12 @@ export class ChatStore {
|
|
|
223
260
|
}
|
|
224
261
|
}
|
|
225
262
|
|
|
263
|
+
const { info, items } = thread.snapshot();
|
|
264
|
+
|
|
226
265
|
return {
|
|
227
|
-
|
|
266
|
+
info,
|
|
267
|
+
// A large output the log still holds whole goes apart now, so an open thread never holds one.
|
|
268
|
+
items: await this.compact(chatId, items),
|
|
228
269
|
seq,
|
|
229
270
|
resetSeq,
|
|
230
271
|
preambles: snapshot.preambles ?? [],
|
|
@@ -284,16 +325,50 @@ export class ChatStore {
|
|
|
284
325
|
|
|
285
326
|
const parsed = RecordSchema.safeParse(liftUiAccess(record));
|
|
286
327
|
|
|
287
|
-
|
|
328
|
+
if (!parsed.success) {
|
|
329
|
+
return UNREADABLE;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const compacted = compactItems(parsed.data.items);
|
|
333
|
+
|
|
334
|
+
if (compacted.items.every((item, index) => item === parsed.data.items[index])) {
|
|
335
|
+
return parsed.data;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// A record from before payloads were kept apart moves its large ones out once. The payloads land
|
|
339
|
+
// first and the record is replaced whole, so a move cut short leaves the old record as it was.
|
|
340
|
+
const migrated = { ...parsed.data, items: compacted.items };
|
|
341
|
+
await this.payloads.ensure(chatId, onlyWritten(compacted.payloads));
|
|
342
|
+
await writeAtomic(join(this.dir, recordFileName(chatId)), JSON.stringify(migrated));
|
|
343
|
+
|
|
344
|
+
return migrated;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private async compact(chatId: string, items: ChatItem[]): Promise<ChatItem[]> {
|
|
348
|
+
const compacted = compactItems(items);
|
|
349
|
+
await this.payloads.ensure(chatId, onlyWritten(compacted.payloads));
|
|
350
|
+
|
|
351
|
+
return compacted.items;
|
|
288
352
|
}
|
|
289
353
|
|
|
290
354
|
async delete(chatId: string): Promise<void> {
|
|
291
|
-
|
|
355
|
+
this.referenced.delete(chatId);
|
|
356
|
+
|
|
357
|
+
await Promise.all([
|
|
358
|
+
rm(join(this.dir, recordFileName(chatId)), { force: true }),
|
|
359
|
+
rm(this.logPath(chatId), { force: true }),
|
|
360
|
+
this.payloads.remove(chatId)
|
|
361
|
+
]);
|
|
292
362
|
}
|
|
293
363
|
}
|
|
294
364
|
|
|
295
365
|
const UNREADABLE = Symbol('unreadable');
|
|
296
366
|
|
|
367
|
+
/* The payloads this process holds the text of; a read leaves the ones a record already refers to for the next write to check. */
|
|
368
|
+
function onlyWritten(payloads: PayloadSources): PayloadSources {
|
|
369
|
+
return new Map([...payloads].filter(([, source]) => source !== null));
|
|
370
|
+
}
|
|
371
|
+
|
|
297
372
|
const RECORD_HEAD = Buffer.from('{"info":{');
|
|
298
373
|
const HEAD_CHUNK_BYTES = 16384;
|
|
299
374
|
const QUOTE = 0x22;
|
package/src/chat/errors.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type ChatErrorCode =
|
|
|
23
23
|
| 'transcript-format'
|
|
24
24
|
| 'fork-failed'
|
|
25
25
|
| 'turn-not-found'
|
|
26
|
+
// An output or diff stored apart from the thread that is no longer on disk.
|
|
27
|
+
| 'payload-missing'
|
|
26
28
|
// What a bookmark is refused with.
|
|
27
29
|
| 'item-not-found'
|
|
28
30
|
| 'bookmark-not-found'
|