@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.
@@ -235,6 +235,7 @@ export declare class ChatCore {
235
235
  clear(chatId: string, force?: boolean): Promise<void>;
236
236
  cancel(chatId: string, subagents?: boolean): void;
237
237
  turnDiff(chatId: string, turnId: string): Promise<ChatCheckpointDiff | null>;
238
+ toolOutput(chatId: string, itemId: string): Promise<string | null>;
238
239
  approve(chatId: string, requestId: string, decision: 'allow' | 'allow-always' | 'deny', message?: string): void;
239
240
  answer(chatId: string, requestId: string, answers: Record<string, string>): void;
240
241
  dismiss(chatId: string, itemId: string): void;
@@ -780,9 +780,25 @@ export class ChatCore {
780
780
  session.markSubagentsStopped();
781
781
  }
782
782
  }
783
- /* What a turn changed against the tree it started from; null when it has no checkpoint. */
784
- turnDiff(chatId, turnId) {
785
- return this.require(chatId).turnDiff(turnId);
783
+ /* 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. */
784
+ async turnDiff(chatId, turnId) {
785
+ const session = this.require(chatId);
786
+ const turn = session.thread.get(turnId);
787
+ if (turn?.kind === 'turn' && turn.checkpointDiffRef && this.store) {
788
+ return await this.store.readDiff(chatId, turn.checkpointDiffRef.id);
789
+ }
790
+ return await session.turnDiff(turnId);
791
+ }
792
+ /* The whole of what a tool call answered, also where the thread keeps only its beginning. */
793
+ async toolOutput(chatId, itemId) {
794
+ const tool = this.require(chatId).thread.get(itemId);
795
+ if (tool?.kind !== 'tool') {
796
+ throw new ChatError('item-not-found', `No tool call ${itemId} in chat ${chatId}`);
797
+ }
798
+ if (!tool.outputRef || !this.store) {
799
+ return tool.output;
800
+ }
801
+ return await this.store.readPayload(chatId, tool.outputRef.id);
786
802
  }
787
803
  approve(chatId, requestId, decision, message) {
788
804
  if (!this.require(chatId).approve(requestId, decision, message)) {
@@ -1121,6 +1137,8 @@ export class ChatCore {
1121
1137
  // A chat killed while the write was out has no log left to fold.
1122
1138
  if (this.logs.get(chatId) === log && folds) {
1123
1139
  log.compact(at.seq);
1140
+ // With the log folded, the record alone says which payloads the thread still needs.
1141
+ await this.store.prunePayloads(chatId);
1124
1142
  }
1125
1143
  });
1126
1144
  this.writes.set(chatId, next.catch(() => undefined));
@@ -1,4 +1,4 @@
1
- import { type ChatInfo, type ChatItem } from '@adecore/agent-contracts';
1
+ import { type ChatCheckpointDiff, type ChatInfo, type ChatItem } from '@adecore/agent-contracts';
2
2
  import { type AttachmentStore } from './attachment-store.ts';
3
3
  import { type ChatLogLine } from './chat-log.ts';
4
4
  export type ChatRecordExtras = Record<string, unknown>;
@@ -24,6 +24,8 @@ export declare class ChatStore {
24
24
  readonly dir: string;
25
25
  private readonly attachments;
26
26
  private readonly isSidecar;
27
+ private readonly payloads;
28
+ private readonly referenced;
27
29
  constructor(home: string, options?: ChatStoreOptions);
28
30
  logPath(chatId: string): string;
29
31
  list(): Promise<string[]>;
@@ -31,9 +33,13 @@ export declare class ChatStore {
31
33
  discardLog(chatId: string): Promise<number>;
32
34
  write(chatId: string, info: ChatInfo, items: ChatItem[], at?: ChatSeq, preambles?: readonly string[], extras?: ChatRecordExtras, uiAccess?: Readonly<Record<string, unknown>>): Promise<number>;
33
35
  writeSync(chatId: string, info: ChatInfo, items: ChatItem[], at?: ChatSeq, preambles?: readonly string[], extras?: ChatRecordExtras, uiAccess?: Readonly<Record<string, unknown>>): void;
36
+ readPayload(chatId: string, id: string): Promise<string>;
37
+ readDiff(chatId: string, id: string): Promise<ChatCheckpointDiff>;
38
+ prunePayloads(chatId: string): Promise<void>;
34
39
  read(chatId: string): Promise<ChatRecord | null>;
35
40
  unreadable(chatId: string): Promise<boolean>;
36
41
  settled(chatId: string): Promise<boolean>;
37
42
  private readSnapshot;
43
+ private compact;
38
44
  delete(chatId: string): Promise<void>;
39
45
  }
@@ -8,6 +8,7 @@ import { recordFileName } from '../record-directory.js';
8
8
  import { migrateInlineAttachments } from './attachment-store.js';
9
9
  import { isBookmarkFileName } from './bookmark-store.js';
10
10
  import { parseLog } from './chat-log.js';
11
+ import { compactItems, PayloadStore } from './payload-store.js';
11
12
  import { ChatThread } from './thread.js';
12
13
  import { isVisualFileName } from './visual-store.js';
13
14
  // zod strips what it does not know inside the info and the items, so a file written by an older build
@@ -57,16 +58,22 @@ function liftUiAccess(record) {
57
58
  }
58
59
  /*
59
60
  * One JSON file per chat under `<home>/chats`, written while a turn runs and on shutdown, with
60
- * the log of what happened since beside it (`ChatLog` appends to it; reading plays it back).
61
+ * the log of what happened since beside it (`ChatLog` appends to it; reading plays it back). An
62
+ * output or diff past the inline limit goes to the chat's payload folder, and the record keeps
63
+ * its beginning (`PayloadStore`).
61
64
  */
62
65
  export class ChatStore {
63
66
  dir;
64
67
  attachments;
65
68
  isSidecar;
69
+ payloads;
70
+ // The payloads the record this process wrote last refers to, which is what a prune keeps.
71
+ referenced = new Map();
66
72
  constructor(home, options = {}) {
67
73
  this.dir = join(home, 'chats');
68
74
  this.attachments = options.attachments ?? null;
69
75
  this.isSidecar = options.isSidecar ?? (() => false);
76
+ this.payloads = new PayloadStore(this.dir);
70
77
  }
71
78
  logPath(chatId) {
72
79
  return join(this.dir, logName(chatId));
@@ -102,9 +109,13 @@ export class ChatStore {
102
109
  }
103
110
  /* Answers how many bytes the record took, which is what tells a caller whether writing it is cheap. */
104
111
  async write(chatId, info, items, at = { seq: 0, resetSeq: 0 }, preambles = [], extras = {}, uiAccess = {}) {
105
- const body = recordBody(info, items, at, preambles, extras, uiAccess);
112
+ const compacted = compactItems(items);
106
113
  await mkdir(this.dir, { recursive: true, mode: 0o700 });
114
+ // Before the record, so a record on disk never names a payload that is not.
115
+ await this.payloads.ensure(chatId, compacted.payloads);
116
+ const body = recordBody(info, compacted.items, at, preambles, extras, uiAccess);
107
117
  await writeAtomic(join(this.dir, recordFileName(chatId)), body);
118
+ this.referenced.set(chatId, new Set(compacted.payloads.keys()));
108
119
  return body.length;
109
120
  }
110
121
  /*
@@ -112,8 +123,28 @@ export class ChatStore {
112
123
  * before an awaited write comes back, so a shutdown has to put the threads down synchronously.
113
124
  */
114
125
  writeSync(chatId, info, items, at = { seq: 0, resetSeq: 0 }, preambles = [], extras = {}, uiAccess = {}) {
126
+ const compacted = compactItems(items);
115
127
  mkdirSync(this.dir, { recursive: true, mode: 0o700 });
116
- writeAtomicSync(join(this.dir, recordFileName(chatId)), recordBody(info, items, at, preambles, extras, uiAccess));
128
+ this.payloads.ensureSync(chatId, compacted.payloads);
129
+ writeAtomicSync(join(this.dir, recordFileName(chatId)), recordBody(info, compacted.items, at, preambles, extras, uiAccess));
130
+ this.referenced.set(chatId, new Set(compacted.payloads.keys()));
131
+ }
132
+ /* The whole of a tool output or a diff kept apart from the record, by the id its item's ref carries. */
133
+ readPayload(chatId, id) {
134
+ return this.payloads.read(chatId, id);
135
+ }
136
+ readDiff(chatId, id) {
137
+ return this.payloads.readDiff(chatId, id);
138
+ }
139
+ /*
140
+ * Removes the payloads the record written last no longer refers to, such as everything a clear
141
+ * emptied. Nothing before this process wrote the record, since only that write says what it holds.
142
+ */
143
+ async prunePayloads(chatId) {
144
+ const keep = this.referenced.get(chatId);
145
+ if (keep) {
146
+ await this.payloads.prune(chatId, keep);
147
+ }
117
148
  }
118
149
  /* Null for a chat with nothing on disk to make it of, and for one whose record does not parse (see `unreadable`). */
119
150
  async read(chatId) {
@@ -123,7 +154,8 @@ export class ChatStore {
123
154
  return null;
124
155
  }
125
156
  if (snapshot === null) {
126
- return fromLogAlone(lines);
157
+ const rebuilt = fromLogAlone(lines);
158
+ return rebuilt && { ...rebuilt, items: await this.compact(chatId, rebuilt.items) };
127
159
  }
128
160
  const seq = snapshot.seq ?? 0;
129
161
  const thread = new ChatThread(snapshot.info, snapshot.items);
@@ -138,8 +170,11 @@ export class ChatStore {
138
170
  resetSeq = line.seq;
139
171
  }
140
172
  }
173
+ const { info, items } = thread.snapshot();
141
174
  return {
142
- ...thread.snapshot(),
175
+ info,
176
+ // A large output the log still holds whole goes apart now, so an open thread never holds one.
177
+ items: await this.compact(chatId, items),
143
178
  seq,
144
179
  resetSeq,
145
180
  preambles: snapshot.preambles ?? [],
@@ -188,13 +223,39 @@ export class ChatStore {
188
223
  }
189
224
  }
190
225
  const parsed = RecordSchema.safeParse(liftUiAccess(record));
191
- return parsed.success ? parsed.data : UNREADABLE;
226
+ if (!parsed.success) {
227
+ return UNREADABLE;
228
+ }
229
+ const compacted = compactItems(parsed.data.items);
230
+ if (compacted.items.every((item, index) => item === parsed.data.items[index])) {
231
+ return parsed.data;
232
+ }
233
+ // A record from before payloads were kept apart moves its large ones out once. The payloads land
234
+ // first and the record is replaced whole, so a move cut short leaves the old record as it was.
235
+ const migrated = { ...parsed.data, items: compacted.items };
236
+ await this.payloads.ensure(chatId, onlyWritten(compacted.payloads));
237
+ await writeAtomic(join(this.dir, recordFileName(chatId)), JSON.stringify(migrated));
238
+ return migrated;
239
+ }
240
+ async compact(chatId, items) {
241
+ const compacted = compactItems(items);
242
+ await this.payloads.ensure(chatId, onlyWritten(compacted.payloads));
243
+ return compacted.items;
192
244
  }
193
245
  async delete(chatId) {
194
- await Promise.all([rm(join(this.dir, recordFileName(chatId)), { force: true }), rm(this.logPath(chatId), { force: true })]);
246
+ this.referenced.delete(chatId);
247
+ await Promise.all([
248
+ rm(join(this.dir, recordFileName(chatId)), { force: true }),
249
+ rm(this.logPath(chatId), { force: true }),
250
+ this.payloads.remove(chatId)
251
+ ]);
195
252
  }
196
253
  }
197
254
  const UNREADABLE = Symbol('unreadable');
255
+ /* The payloads this process holds the text of; a read leaves the ones a record already refers to for the next write to check. */
256
+ function onlyWritten(payloads) {
257
+ return new Map([...payloads].filter(([, source]) => source !== null));
258
+ }
198
259
  const RECORD_HEAD = Buffer.from('{"info":{');
199
260
  const HEAD_CHUNK_BYTES = 16384;
200
261
  const QUOTE = 0x22;
@@ -1,4 +1,4 @@
1
1
  import { CodedError } from '../coded-error.ts';
2
- export type ChatErrorCode = 'refused-query' | 'stale-ui-query' | 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'project-config-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'subagent-stale' | 'task-not-found' | 'goal-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks' | 'visual-invalid' | 'visual-too-large';
2
+ export type ChatErrorCode = 'refused-query' | 'stale-ui-query' | 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'project-config-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'subagent-stale' | 'task-not-found' | 'goal-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'payload-missing' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks' | 'visual-invalid' | 'visual-too-large';
3
3
  export declare class ChatError<TCode extends string = ChatErrorCode> extends CodedError<TCode> {
4
4
  }
@@ -0,0 +1,28 @@
1
+ import { type ChatCheckpointDiff, type ChatItem } from '@adecore/agent-contracts';
2
+ export declare const PAYLOAD_PREVIEW_BYTES: number;
3
+ export type PayloadSources = Map<string, (() => string) | null>;
4
+ export interface CompactedItems {
5
+ items: ChatItem[];
6
+ payloads: PayloadSources;
7
+ }
8
+ export declare function payloadFolderName(chatId: string): string;
9
+ export declare function previewOf(text: string, maxBytes: number): string;
10
+ export declare function compactItems(items: readonly ChatItem[]): CompactedItems;
11
+ export declare function payloadRefsOf(items: readonly ChatItem[]): Set<string>;
12
+ export declare class PayloadStore {
13
+ private readonly dir;
14
+ private readonly present;
15
+ private readonly writtenDuringPrune;
16
+ constructor(dir: string);
17
+ folder(chatId: string): string;
18
+ ensure(chatId: string, payloads: PayloadSources): Promise<void>;
19
+ ensureSync(chatId: string, payloads: PayloadSources): void;
20
+ read(chatId: string, id: string): Promise<string>;
21
+ readDiff(chatId: string, id: string): Promise<ChatCheckpointDiff>;
22
+ prune(chatId: string, keep: ReadonlySet<string>): Promise<void>;
23
+ remove(chatId: string): Promise<void>;
24
+ private presentIn;
25
+ private mark;
26
+ private otherFolders;
27
+ private otherFoldersSync;
28
+ }
@@ -0,0 +1,350 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants, copyFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
3
+ import { copyFile, mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { gunzip, gzip, gzipSync } from 'node:zlib';
7
+ import { CHAT_PAYLOAD_INLINE_BYTES, ChatCheckpointDiffSchema } from '@adecore/agent-contracts';
8
+ import { isNotFound, writeAtomic, writeAtomicSync } from '../fs.js';
9
+ import { ChatError } from './errors.js';
10
+ /* How much of a value stored apart the item keeps: what a timeline shows before anyone asks for the rest. */
11
+ export const PAYLOAD_PREVIEW_BYTES = 8 * 1024;
12
+ const FOLDER_SUFFIX = '.payloads';
13
+ const ID_PATTERN = /^[0-9a-f]{64}$/;
14
+ const CONTINUATION_MASK = 0xc0;
15
+ const CONTINUATION = 0x80;
16
+ const gzipAsync = promisify(gzip);
17
+ const gunzipAsync = promisify(gunzip);
18
+ // Items never change in place, so an item compacted once answers the same for every write of every chat after it.
19
+ const compactedItems = new WeakMap();
20
+ /* The folder of a chat's payloads beside its record, which `ChatStore.list` passes over since it is no JSON. */
21
+ export function payloadFolderName(chatId) {
22
+ return `${encodeURIComponent(chatId)}${FOLDER_SUFFIX}`;
23
+ }
24
+ function payloadId(text) {
25
+ return createHash('sha256').update(text).digest('hex');
26
+ }
27
+ function byteLength(text) {
28
+ return Buffer.byteLength(text, 'utf8');
29
+ }
30
+ /* Whether a text is past the inline limit, without encoding the short ones: a UTF-16 unit is at most three bytes. */
31
+ function pastInline(text) {
32
+ return text.length * 3 > CHAT_PAYLOAD_INLINE_BYTES && byteLength(text) > CHAT_PAYLOAD_INLINE_BYTES;
33
+ }
34
+ /*
35
+ * The beginning of a text in at most `maxBytes` of UTF-8, never cut inside a character, and cut after
36
+ * the last line break when that keeps at least half of it, so a preview ends on a whole line.
37
+ */
38
+ export function previewOf(text, maxBytes) {
39
+ let units = Math.min(text.length, maxBytes);
40
+ // Half a surrogate pair would encode as a replacement character.
41
+ if (units < text.length && isHighSurrogate(text.charCodeAt(units - 1))) {
42
+ units -= 1;
43
+ }
44
+ const head = Buffer.from(text.slice(0, units), 'utf8');
45
+ let end = Math.min(head.length, maxBytes);
46
+ while (end > 0 && end < head.length && (head[end] & CONTINUATION_MASK) === CONTINUATION) {
47
+ end -= 1;
48
+ }
49
+ const prefix = head.subarray(0, end).toString('utf8');
50
+ if (prefix.length === text.length) {
51
+ return text;
52
+ }
53
+ const lineEnd = prefix.lastIndexOf('\n');
54
+ return lineEnd >= prefix.length / 2 ? prefix.slice(0, lineEnd + 1) : prefix;
55
+ }
56
+ function isHighSurrogate(code) {
57
+ return code >= 0xd800 && code <= 0xdbff;
58
+ }
59
+ /* Every file of a diff, with the diffs that fit the preview in order and the rest held back as empty. */
60
+ function diffPreview(diff) {
61
+ let budget = PAYLOAD_PREVIEW_BYTES;
62
+ const files = diff.files.map((file) => {
63
+ const size = byteLength(file.diff);
64
+ if (size <= budget) {
65
+ budget -= size;
66
+ return file;
67
+ }
68
+ budget = 0;
69
+ return { ...file, diff: '' };
70
+ });
71
+ return { ...diff, files };
72
+ }
73
+ function compactOne(item) {
74
+ if (item.kind === 'tool') {
75
+ if (item.outputRef) {
76
+ return { item, payloads: [[item.outputRef.id, null]] };
77
+ }
78
+ const output = item.output;
79
+ if (output === null || !pastInline(output)) {
80
+ return { item, payloads: [] };
81
+ }
82
+ const ref = { id: payloadId(output), bytes: byteLength(output) };
83
+ return { item: { ...item, output: previewOf(output, PAYLOAD_PREVIEW_BYTES), outputRef: ref }, payloads: [[ref.id, () => output]] };
84
+ }
85
+ if (item.kind === 'turn' && item.checkpointDiff) {
86
+ if (item.checkpointDiffRef) {
87
+ return { item, payloads: [[item.checkpointDiffRef.id, null]] };
88
+ }
89
+ const json = JSON.stringify(item.checkpointDiff);
90
+ if (!pastInline(json)) {
91
+ return { item, payloads: [] };
92
+ }
93
+ const ref = { id: payloadId(json), bytes: byteLength(json) };
94
+ const diff = item.checkpointDiff;
95
+ return { item: { ...item, checkpointDiff: diffPreview(diff), checkpointDiffRef: ref }, payloads: [[ref.id, () => JSON.stringify(diff)]] };
96
+ }
97
+ return { item, payloads: [] };
98
+ }
99
+ /*
100
+ * A thread with every output and diff past the inline limit cut to its beginning, and what has to be
101
+ * on disk for the rest. A payload is named by the hash of its text, so the same text written twice, or
102
+ * copied in from another chat, is one file.
103
+ */
104
+ export function compactItems(items) {
105
+ const payloads = new Map();
106
+ const compacted = items.map((item) => {
107
+ let known = compactedItems.get(item);
108
+ if (known === undefined) {
109
+ known = compactOne(item);
110
+ compactedItems.set(item, known);
111
+ }
112
+ for (const [id, source] of known.payloads) {
113
+ if (source !== null || !payloads.has(id)) {
114
+ payloads.set(id, source);
115
+ }
116
+ }
117
+ return known.item;
118
+ });
119
+ return { items: compacted, payloads };
120
+ }
121
+ export function payloadRefsOf(items) {
122
+ const ids = new Set();
123
+ for (const item of items) {
124
+ if (item.kind === 'tool' && item.outputRef) {
125
+ ids.add(item.outputRef.id);
126
+ }
127
+ else if (item.kind === 'turn' && item.checkpointDiffRef) {
128
+ ids.add(item.checkpointDiffRef.id);
129
+ }
130
+ }
131
+ return ids;
132
+ }
133
+ /*
134
+ * The outputs and diffs of every chat that are too large to keep in its record, one gzip file per
135
+ * payload under `<chats>/<chat id>.payloads/<sha-256>.gz`. Each chat has its own folder, so removing
136
+ * a chat never reaches into a fork that copied from it.
137
+ */
138
+ export class PayloadStore {
139
+ dir;
140
+ // What this process knows is on disk per chat, so a write checks a payload once and not every time.
141
+ present = new Map();
142
+ // What was written while a prune ran, which that prune must not take away again.
143
+ writtenDuringPrune = new Map();
144
+ constructor(dir) {
145
+ this.dir = dir;
146
+ }
147
+ folder(chatId) {
148
+ return join(this.dir, payloadFolderName(chatId));
149
+ }
150
+ /* Puts every payload on disk that is not there yet: from its text, or copied from another chat that has it. */
151
+ async ensure(chatId, payloads) {
152
+ const present = this.presentIn(chatId);
153
+ const folder = this.folder(chatId);
154
+ let others = null;
155
+ for (const [id, source] of payloads) {
156
+ if (present.has(id)) {
157
+ continue;
158
+ }
159
+ const path = join(folder, `${id}.gz`);
160
+ if (await existsAsync(path)) {
161
+ this.mark(chatId, id);
162
+ continue;
163
+ }
164
+ await mkdir(folder, { recursive: true, mode: 0o700 });
165
+ if (source !== null) {
166
+ await writeAtomic(path, await gzipAsync(source()));
167
+ this.mark(chatId, id);
168
+ continue;
169
+ }
170
+ others ??= await this.otherFolders(chatId);
171
+ if (await copyFromAny(others, id, path)) {
172
+ this.mark(chatId, id);
173
+ }
174
+ }
175
+ }
176
+ /* `ensure` without a turn of the event loop, for a write on the way out. */
177
+ ensureSync(chatId, payloads) {
178
+ const present = this.presentIn(chatId);
179
+ const folder = this.folder(chatId);
180
+ let others = null;
181
+ for (const [id, source] of payloads) {
182
+ if (present.has(id)) {
183
+ continue;
184
+ }
185
+ const path = join(folder, `${id}.gz`);
186
+ if (existsSync(path)) {
187
+ this.mark(chatId, id);
188
+ continue;
189
+ }
190
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
191
+ if (source !== null) {
192
+ writeAtomicSync(path, gzipSync(source()));
193
+ this.mark(chatId, id);
194
+ continue;
195
+ }
196
+ others ??= this.otherFoldersSync(chatId);
197
+ if (copyFromAnySync(others, id, path)) {
198
+ this.mark(chatId, id);
199
+ }
200
+ }
201
+ }
202
+ async read(chatId, id) {
203
+ if (!ID_PATTERN.test(id)) {
204
+ throw new ChatError('payload-missing', `No stored payload ${id} in chat ${chatId}`);
205
+ }
206
+ let packed;
207
+ try {
208
+ packed = await readFile(join(this.folder(chatId), `${id}.gz`));
209
+ }
210
+ catch (e) {
211
+ if (isNotFound(e)) {
212
+ throw new ChatError('payload-missing', `The stored payload ${id} of chat ${chatId} is gone`);
213
+ }
214
+ throw e;
215
+ }
216
+ return (await gunzipAsync(packed)).toString('utf8');
217
+ }
218
+ async readDiff(chatId, id) {
219
+ const parsed = ChatCheckpointDiffSchema.safeParse(JSON.parse(await this.read(chatId, id)));
220
+ if (!parsed.success) {
221
+ throw new ChatError('payload-missing', `The stored diff ${id} of chat ${chatId} does not read`);
222
+ }
223
+ return parsed.data;
224
+ }
225
+ /* Removes the payloads of a chat its thread no longer refers to, such as everything before a clear. */
226
+ async prune(chatId, keep) {
227
+ const folder = this.folder(chatId);
228
+ let names;
229
+ try {
230
+ names = await readdir(folder);
231
+ }
232
+ catch (e) {
233
+ if (isNotFound(e)) {
234
+ return;
235
+ }
236
+ throw e;
237
+ }
238
+ const written = new Set();
239
+ this.writtenDuringPrune.set(chatId, written);
240
+ try {
241
+ for (const name of names) {
242
+ const id = name.replace(/\.gz$/, '');
243
+ if (keep.has(id) || written.has(id)) {
244
+ continue;
245
+ }
246
+ this.present.get(chatId)?.delete(id);
247
+ await rm(join(folder, name), { force: true });
248
+ }
249
+ }
250
+ finally {
251
+ this.writtenDuringPrune.delete(chatId);
252
+ }
253
+ }
254
+ async remove(chatId) {
255
+ this.present.delete(chatId);
256
+ await rm(this.folder(chatId), { recursive: true, force: true });
257
+ }
258
+ presentIn(chatId) {
259
+ let present = this.present.get(chatId);
260
+ if (!present) {
261
+ present = new Set();
262
+ this.present.set(chatId, present);
263
+ }
264
+ return present;
265
+ }
266
+ mark(chatId, id) {
267
+ this.presentIn(chatId).add(id);
268
+ this.writtenDuringPrune.get(chatId)?.add(id);
269
+ }
270
+ async otherFolders(chatId) {
271
+ try {
272
+ return foldersOf(this.dir, await readdir(this.dir), chatId);
273
+ }
274
+ catch (e) {
275
+ if (isNotFound(e)) {
276
+ return [];
277
+ }
278
+ throw e;
279
+ }
280
+ }
281
+ otherFoldersSync(chatId) {
282
+ try {
283
+ return foldersOf(this.dir, readdirSync(this.dir), chatId);
284
+ }
285
+ catch (e) {
286
+ if (isNotFound(e)) {
287
+ return [];
288
+ }
289
+ throw e;
290
+ }
291
+ }
292
+ }
293
+ function foldersOf(dir, names, chatId) {
294
+ const own = payloadFolderName(chatId);
295
+ return names.filter((name) => name.endsWith(FOLDER_SUFFIX) && name !== own).map((name) => join(dir, name));
296
+ }
297
+ // A clone where the file system has one, so a fork of a large chat takes no space until either side changes.
298
+ const COPY_MODE = constants.COPYFILE_FICLONE;
299
+ async function copyFromAny(folders, id, target) {
300
+ for (const folder of folders) {
301
+ try {
302
+ await copyFile(join(folder, `${id}.gz`), target, COPY_MODE);
303
+ return true;
304
+ }
305
+ catch (e) {
306
+ if (!isNotFound(e)) {
307
+ throw e;
308
+ }
309
+ }
310
+ }
311
+ return false;
312
+ }
313
+ function copyFromAnySync(folders, id, target) {
314
+ for (const folder of folders) {
315
+ try {
316
+ copyFileSync(join(folder, `${id}.gz`), target, COPY_MODE);
317
+ return true;
318
+ }
319
+ catch (e) {
320
+ if (!isNotFound(e)) {
321
+ throw e;
322
+ }
323
+ }
324
+ }
325
+ return false;
326
+ }
327
+ async function existsAsync(path) {
328
+ try {
329
+ await stat(path);
330
+ return true;
331
+ }
332
+ catch (e) {
333
+ if (isNotFound(e)) {
334
+ return false;
335
+ }
336
+ throw e;
337
+ }
338
+ }
339
+ function existsSync(path) {
340
+ try {
341
+ statSync(path);
342
+ return true;
343
+ }
344
+ catch (e) {
345
+ if (isNotFound(e)) {
346
+ return false;
347
+ }
348
+ throw e;
349
+ }
350
+ }
@@ -222,7 +222,8 @@ export class ThreadProjector {
222
222
  if (target?.kind === 'tool' && target.name === 'Workflow' && event.state === 'done' && event.output?.startsWith(WORKFLOW_LAUNCHED)) {
223
223
  // The call only launched the workflow; the row runs on past the turn until the workflow's task ends.
224
224
  this.backgroundCalls.add(target.id);
225
- events.push(this.thread.upsert({ ...target, output: event.output }));
225
+ const { outputRef: _earlier, ...launched } = target;
226
+ events.push(this.thread.upsert({ ...launched, output: event.output }));
226
227
  break;
227
228
  }
228
229
  this.settleTool(generation, event, events);
@@ -521,6 +522,9 @@ export class ThreadProjector {
521
522
  if (previous?.progress) {
522
523
  item.progress = previous.progress;
523
524
  }
525
+ if (previous?.outputRef) {
526
+ item.outputRef = previous.outputRef;
527
+ }
524
528
  const changes = event.changes ?? previous?.changes;
525
529
  if (changes) {
526
530
  item.changes = changes;
@@ -542,6 +546,10 @@ export class ThreadProjector {
542
546
  // The result supersedes whatever progress said; a settled call carries none.
543
547
  const settled = { ...tool, output: event.output ?? tool.output, state: event.state };
544
548
  delete settled.progress;
549
+ // A stored whole of an earlier output would answer for this one.
550
+ if (event.output != null) {
551
+ delete settled.outputRef;
552
+ }
545
553
  if (event.changes) {
546
554
  settled.changes = event.changes;
547
555
  }
@@ -61,6 +61,7 @@ export function chatHandlers(chats, providers) {
61
61
  return {};
62
62
  },
63
63
  'chat.turnDiff': async (payload) => ({ diff: await chats.turnDiff(payload.chatId, payload.turnId) }),
64
+ 'chat.toolOutput': async (payload) => ({ output: await chats.toolOutput(payload.chatId, payload.itemId) }),
64
65
  'chat.fork': unsupported('fork chats'),
65
66
  'chat.forkInfo': unsupported('fork chats'),
66
67
  'chat.summarize': unsupported('fork chats'),
@@ -0,0 +1,25 @@
1
+ import { type UsageProvider } from '@adecore/agent-contracts';
2
+ import { type UsageRecord } from './record.ts';
3
+ export interface FileUsage {
4
+ provider: UsageProvider;
5
+ records: UsageRecord[];
6
+ tail: UsageRecord[];
7
+ }
8
+ export interface HistoryEntry {
9
+ path: string;
10
+ op: 'set' | 'add';
11
+ usage: FileUsage;
12
+ }
13
+ export interface HistoryHeader {
14
+ gen: string;
15
+ base: number;
16
+ }
17
+ export declare function encodeHeader(header: HistoryHeader): string;
18
+ export declare function encodeEntry(entry: HistoryEntry): string;
19
+ export declare function applyEntry(usage: Map<string, FileUsage>, entry: HistoryEntry): void;
20
+ export interface DecodedHistory {
21
+ header: HistoryHeader | null;
22
+ usage: Map<string, FileUsage>;
23
+ complete: number;
24
+ }
25
+ export declare function decodeHistory(bytes: Uint8Array): DecodedHistory;