@adecore/agents 0.41.1 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat/chat-core.d.ts +1 -0
- package/dist/chat/chat-core.js +21 -3
- package/dist/chat/chat-store.d.ts +7 -1
- package/dist/chat/chat-store.js +68 -7
- package/dist/chat/errors.d.ts +1 -1
- package/dist/chat/payload-store.d.ts +28 -0
- package/dist/chat/payload-store.js +350 -0
- package/dist/chat/projector.js +9 -1
- package/dist/host/handlers.js +1 -0
- package/dist/usage/history-file.d.ts +25 -0
- package/dist/usage/history-file.js +72 -0
- package/dist/usage/index-file.d.ts +1 -1
- package/dist/usage/index-file.js +33 -114
- package/dist/usage/rows.d.ts +23 -0
- package/dist/usage/rows.js +94 -0
- package/dist/usage/scan-state.d.ts +21 -0
- package/dist/usage/scan-state.js +53 -0
- package/dist/usage/scanner.d.ts +1 -4
- package/dist/usage/scanner.js +37 -50
- package/dist/usage/usage-store.d.ts +26 -0
- package/dist/usage/usage-store.js +197 -0
- package/package.json +3 -3
- package/src/chat/chat-core.ts +27 -3
- package/src/chat/chat-store.ts +83 -8
- package/src/chat/errors.ts +2 -0
- package/src/chat/payload-store.ts +459 -0
- package/src/chat/projector.ts +11 -1
- package/src/host/handlers.ts +1 -0
- package/src/host/wiring.ts +1 -1
- package/src/usage/history-file.ts +140 -0
- package/src/usage/index-file.ts +35 -143
- package/src/usage/rows.ts +136 -0
- package/src/usage/scan-state.ts +105 -0
- package/src/usage/scanner.ts +39 -54
- package/src/usage/usage-store.ts +237 -0
|
@@ -0,0 +1,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.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.
|
|
97
|
-
"@adecore/intelligent-ui": "0.
|
|
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": {
|
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'
|