@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,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.41.1",
3
+ "version": "0.43.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.41.1",
97
- "@adecore/intelligent-ui": "0.41.1",
96
+ "@adecore/agent-contracts": "0.43.0",
97
+ "@adecore/intelligent-ui": "0.43.0",
98
98
  "zod": "^4.6.7"
99
99
  },
100
100
  "devDependencies": {
@@ -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
- return this.require(chatId).turnDiff(turnId);
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
 
@@ -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 body = recordBody(info, items, at, preambles, extras, uiAccess);
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
- writeAtomicSync(join(this.dir, recordFileName(chatId)), recordBody(info, items, at, preambles, extras, uiAccess));
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
- return fromLogAlone(lines);
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
- ...thread.snapshot(),
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
- return parsed.success ? parsed.data : UNREADABLE;
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
- await Promise.all([rm(join(this.dir, recordFileName(chatId)), { force: true }), rm(this.logPath(chatId), { force: true })]);
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;
@@ -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'