@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.
Files changed (45) hide show
  1. package/dist/chat/chat-core.d.ts +1 -0
  2. package/dist/chat/chat-core.js +21 -3
  3. package/dist/chat/chat-store.d.ts +7 -1
  4. package/dist/chat/chat-store.js +68 -7
  5. package/dist/chat/errors.d.ts +1 -1
  6. package/dist/chat/payload-store.d.ts +28 -0
  7. package/dist/chat/payload-store.js +350 -0
  8. package/dist/chat/projector.js +9 -1
  9. package/dist/host/handlers.js +1 -0
  10. package/dist/providers/antigravity-models.json +1 -1
  11. package/dist/providers/claude-models.json +1 -1
  12. package/dist/providers/codex-models.json +1 -1
  13. package/dist/providers/copilot-models.json +1 -1
  14. package/dist/providers/grok-models.json +3 -3
  15. package/dist/usage/history-file.d.ts +25 -0
  16. package/dist/usage/history-file.js +72 -0
  17. package/dist/usage/index-file.d.ts +1 -1
  18. package/dist/usage/index-file.js +33 -114
  19. package/dist/usage/rows.d.ts +23 -0
  20. package/dist/usage/rows.js +94 -0
  21. package/dist/usage/scan-state.d.ts +21 -0
  22. package/dist/usage/scan-state.js +53 -0
  23. package/dist/usage/scanner.d.ts +1 -4
  24. package/dist/usage/scanner.js +37 -50
  25. package/dist/usage/usage-store.d.ts +26 -0
  26. package/dist/usage/usage-store.js +197 -0
  27. package/package.json +3 -3
  28. package/src/chat/chat-core.ts +27 -3
  29. package/src/chat/chat-store.ts +83 -8
  30. package/src/chat/errors.ts +2 -0
  31. package/src/chat/payload-store.ts +459 -0
  32. package/src/chat/projector.ts +11 -1
  33. package/src/host/handlers.ts +1 -0
  34. package/src/host/wiring.ts +1 -1
  35. package/src/providers/antigravity-models.json +1 -1
  36. package/src/providers/claude-models.json +1 -1
  37. package/src/providers/codex-models.json +1 -1
  38. package/src/providers/copilot-models.json +1 -1
  39. package/src/providers/grok-models.json +3 -3
  40. package/src/usage/history-file.ts +140 -0
  41. package/src/usage/index-file.ts +35 -143
  42. package/src/usage/rows.ts +136 -0
  43. package/src/usage/scan-state.ts +105 -0
  44. package/src/usage/scanner.ts +39 -54
  45. package/src/usage/usage-store.ts +237 -0
@@ -1,17 +1,17 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { mkdir, open, readFile, readdir, stat } from 'node:fs/promises';
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, writeAtomic } from '../fs.js';
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
- index = new Map();
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.home = home;
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
- get file() {
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
- if (this.dirty) {
272
- this.dirty = false;
273
- await mkdir(join(this.home, 'usage'), { recursive: true, mode: 0o700 });
274
- await writeAtomic(this.file, encodeIndex(this.index));
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 index holds, with the copies one message left in several transcripts folded. */
276
+ /* Every record the history holds, with the copies one message left in several transcripts folded. */
279
277
  records() {
280
- const all = [];
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 = compressed ? path.slice(0, -4) : path;
290
- const known = this.index.get(key);
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 before = grown?.records ?? [];
317
- const entry = {
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
- this.index.set(key, entry);
330
- this.dirty = true;
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 index held of it, so a turn it still lists is never
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
- this.index.set(path, {
344
- provider,
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.41.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.41.0",
97
- "@adecore/intelligent-ui": "0.41.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": {
@@ -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'