@adecore/agents 0.20.1 → 0.21.1
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/attachment-store.d.ts +2 -0
- package/dist/chat/attachment-store.js +38 -2
- package/dist/chat/backend.d.ts +12 -1
- package/dist/chat/chat-core.d.ts +57 -1
- package/dist/chat/chat-core.js +67 -14
- package/dist/chat/chat-session.d.ts +35 -2
- package/dist/chat/chat-session.js +261 -11
- package/dist/chat/chat-store.d.ts +3 -2
- package/dist/chat/chat-store.js +42 -11
- package/dist/chat/codex-backend.d.ts +2 -0
- package/dist/chat/codex-backend.js +51 -1
- package/dist/chat/codex-protocol.js +30 -3
- package/dist/chat/delta-coalescer.js +1 -1
- package/dist/chat/errors.d.ts +1 -1
- package/dist/chat/fake-codex.js +33 -0
- package/dist/chat/generated-image.d.ts +6 -0
- package/dist/chat/generated-image.js +74 -0
- package/dist/chat/projector.js +9 -2
- package/dist/chat/readable-text.d.ts +2 -0
- package/dist/chat/readable-text.js +16 -0
- package/dist/chat/thread.js +6 -0
- package/dist/chat/ui-queries.d.ts +49 -0
- package/dist/chat/ui-queries.js +358 -0
- package/dist/host/handlers.js +3 -0
- package/dist/tasks/task-coordinator.js +6 -2
- package/package.json +5 -4
- package/src/chat/attachment-store.ts +38 -2
- package/src/chat/backend.ts +14 -2
- package/src/chat/chat-core.ts +84 -13
- package/src/chat/chat-session.ts +304 -12
- package/src/chat/chat-store.ts +53 -11
- package/src/chat/codex-backend.ts +50 -1
- package/src/chat/codex-protocol.ts +31 -3
- package/src/chat/delta-coalescer.ts +1 -1
- package/src/chat/errors.ts +2 -0
- package/src/chat/fake-codex.ts +34 -0
- package/src/chat/generated-image.ts +88 -0
- package/src/chat/projector.ts +9 -2
- package/src/chat/readable-text.ts +18 -0
- package/src/chat/thread.ts +6 -0
- package/src/chat/ui-queries.ts +416 -0
- package/src/host/handlers.ts +3 -0
- package/src/tasks/task-coordinator.ts +6 -2
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { notResumedNote } from '@adecore/agent-contracts';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { GeneratedImageInputSchema, notResumedNote } from '@adecore/agent-contracts';
|
|
3
|
+
import { resolveUiChoice, UI_FENCE_LANGUAGE, UiFailure, uiHasFence, uiMayReferenceHost } from '@adecore/intelligent-ui';
|
|
4
|
+
import { UiStream } from '@adecore/intelligent-ui/stream';
|
|
3
5
|
import { commandLabel, isBackgroundWork, reportsOnBackgroundWork, runningInBackground } from './background-work.js';
|
|
4
6
|
import { ChatError } from './errors.js';
|
|
5
7
|
import { limitedTurn, limitResumeAt, limitResumeWake } from './limit-resume.js';
|
|
6
8
|
import { isMainAgentOutput, ThreadProjector } from './projector.js';
|
|
9
|
+
import { readableAssistantText } from './readable-text.js';
|
|
7
10
|
import { ChatThread } from './thread.js';
|
|
8
11
|
import { errorText } from '../error-text.js';
|
|
9
12
|
import { systemClock } from '../outbox/outbox-worker.js';
|
|
10
13
|
// Claude Code names a session about six seconds after its first prompt, and a first turn can run for minutes.
|
|
11
14
|
const TITLE_RECHECK_MS = 10_000;
|
|
15
|
+
// Longer than any fence line or the name of a tag a scan looks for, so a match never falls between two deltas.
|
|
16
|
+
const UI_SCAN_OVERLAP = 64;
|
|
12
17
|
/* One per limited turn, so a second fork of the same turn writes no second note. */
|
|
13
18
|
function continuedNoteId(turnId) {
|
|
14
19
|
return `continued-${turnId}`;
|
|
@@ -70,12 +75,38 @@ export class ChatSession {
|
|
|
70
75
|
pendingPreambles;
|
|
71
76
|
submission = null;
|
|
72
77
|
steering = new Set();
|
|
78
|
+
uiStreams = new Map();
|
|
79
|
+
uiCompiled = new Map();
|
|
80
|
+
capturedUiAccess;
|
|
81
|
+
// How much of a streaming reply's text the fence and host scans already covered, so a delta scans only its tail.
|
|
82
|
+
uiScanned = new Map();
|
|
83
|
+
uiReferencing = new Set();
|
|
84
|
+
uiAttachments = new Map();
|
|
85
|
+
uiFenceLanguage;
|
|
73
86
|
constructor(options) {
|
|
74
87
|
this.options = options;
|
|
88
|
+
this.uiFenceLanguage = options.uiFenceLanguage ?? UI_FENCE_LANGUAGE;
|
|
75
89
|
this.pendingPreambles = options.preambles ?? [];
|
|
90
|
+
this.capturedUiAccess = new Map(Object.entries(options.uiAccess ?? {}));
|
|
76
91
|
this.thread = new ChatThread(options.info, options.items);
|
|
77
92
|
this.projector = new ThreadProjector(this.thread, { providerName: options.provider.name });
|
|
78
93
|
}
|
|
94
|
+
get uiPreviews() {
|
|
95
|
+
const events = [];
|
|
96
|
+
for (const [itemId, preview] of this.uiCompiled) {
|
|
97
|
+
const item = this.thread.get(itemId);
|
|
98
|
+
if (item?.kind === 'assistant' && item.streaming && item.text.length === preview.textLength) {
|
|
99
|
+
events.push({ type: 'delta', itemId, text: '', ui: preview.blocks, textLength: preview.textLength });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return events;
|
|
103
|
+
}
|
|
104
|
+
previewItems(items) {
|
|
105
|
+
return items.map((item) => {
|
|
106
|
+
const preview = this.uiCompiled.get(item.id);
|
|
107
|
+
return item.kind === 'assistant' && preview?.textLength === item.text.length ? { ...item, ui: preview.blocks } : item;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
79
110
|
get id() {
|
|
80
111
|
return this.thread.info.chatId;
|
|
81
112
|
}
|
|
@@ -148,6 +179,7 @@ export class ChatSession {
|
|
|
148
179
|
turnId,
|
|
149
180
|
text,
|
|
150
181
|
createdAt: Date.now(),
|
|
182
|
+
...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {}),
|
|
151
183
|
...(extras.mentions?.length ? { mentions: extras.mentions } : {}),
|
|
152
184
|
...(extras.skills?.length ? { skills: extras.skills } : {}),
|
|
153
185
|
...(extras.chats?.length ? { chats: extras.chats } : {}),
|
|
@@ -161,6 +193,91 @@ export class ChatSession {
|
|
|
161
193
|
this.dispatch(text, extras, turnId);
|
|
162
194
|
return { queued: false, turnId };
|
|
163
195
|
}
|
|
196
|
+
/* What a UI host captured for a reply; null where the capture was denied or is still out, undefined where none started. */
|
|
197
|
+
uiAccess(itemId) {
|
|
198
|
+
return this.capturedUiAccess.get(itemId);
|
|
199
|
+
}
|
|
200
|
+
setUiAccess(itemId, access) {
|
|
201
|
+
if (this.frozen) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
this.capturedUiAccess.set(itemId, access);
|
|
205
|
+
this.options.persistSoon();
|
|
206
|
+
}
|
|
207
|
+
/* The captured access of the replies still in the thread, for the record. */
|
|
208
|
+
get uiAccessRecord() {
|
|
209
|
+
return Object.fromEntries([...this.capturedUiAccess].filter(([itemId]) => this.thread.get(itemId) !== undefined));
|
|
210
|
+
}
|
|
211
|
+
updateUiQueries(itemId, uiQueries) {
|
|
212
|
+
const item = this.thread.get(itemId);
|
|
213
|
+
if (this.frozen || item?.kind !== 'assistant' || item.parentToolUseId) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
this.thread.upsert({ ...item, uiQueries });
|
|
217
|
+
this.options.emit({ type: 'delta', itemId, text: '', uiQueries });
|
|
218
|
+
this.options.persistSoon();
|
|
219
|
+
}
|
|
220
|
+
choose(payload, queries = {}) {
|
|
221
|
+
const item = this.thread.get(payload.itemId);
|
|
222
|
+
const block = item?.kind === 'assistant' && !item.streaming && !item.parentToolUseId ? item.ui?.find((block) => block.id === payload.blockId) : undefined;
|
|
223
|
+
if (item?.kind !== 'assistant' || !block?.revision || block.revision !== payload.revision) {
|
|
224
|
+
throw new ChatError('stale-ui-block', 'This completed UI block is no longer available at the requested revision');
|
|
225
|
+
}
|
|
226
|
+
const sameBlock = (origin) => origin?.itemId === item.id && origin.blockId === block.id && origin.revision === block.revision;
|
|
227
|
+
let previous = item.uiAnswers?.[block.id];
|
|
228
|
+
if (previous?.revision !== block.revision) {
|
|
229
|
+
previous = undefined;
|
|
230
|
+
}
|
|
231
|
+
// The message is logged first: a crash before its answer marker must not send it a second time.
|
|
232
|
+
if (!previous) {
|
|
233
|
+
const sent = this.thread.list().find((entry) => entry.kind === 'user' && sameBlock(entry.uiChoice));
|
|
234
|
+
const queued = this.queue.find((entry) => sameBlock(entry.uiChoice));
|
|
235
|
+
const origin = sent?.kind === 'user' ? sent.uiChoice : queued?.uiChoice;
|
|
236
|
+
const turnId = sent?.turnId ?? queued?.turnId;
|
|
237
|
+
if (origin && turnId) {
|
|
238
|
+
previous = { ...origin, turnId, queued: !sent };
|
|
239
|
+
this.recordUiAnswer(previous);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (previous) {
|
|
243
|
+
if (previous.choiceId !== payload.choiceId) {
|
|
244
|
+
throw new ChatError('ui-already-answered', 'This UI block was already answered');
|
|
245
|
+
}
|
|
246
|
+
return { queued: previous.queued, turnId: previous.turnId };
|
|
247
|
+
}
|
|
248
|
+
let selected;
|
|
249
|
+
try {
|
|
250
|
+
selected = resolveUiChoice(block, payload.choiceId, payload.values, queries);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
throw new ChatError(error instanceof UiFailure ? error.code : 'refused-choice', error instanceof Error ? error.message : 'This choice could not be read');
|
|
254
|
+
}
|
|
255
|
+
const origin = {
|
|
256
|
+
itemId: item.id,
|
|
257
|
+
blockId: block.id,
|
|
258
|
+
revision: block.revision,
|
|
259
|
+
choiceId: payload.choiceId,
|
|
260
|
+
label: selected.label,
|
|
261
|
+
sourceAt: item.createdAt,
|
|
262
|
+
older: this.thread.list().findLast((entry) => entry.kind === 'assistant' && !entry.parentToolUseId)?.id !== item.id,
|
|
263
|
+
values: selected.values,
|
|
264
|
+
at: Date.now()
|
|
265
|
+
};
|
|
266
|
+
const result = this.send(selected.context, { uiChoice: origin });
|
|
267
|
+
this.recordUiAnswer({ ...origin, ...result });
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
recordUiAnswer(answer) {
|
|
271
|
+
const item = this.thread.get(answer.itemId);
|
|
272
|
+
if (item?.kind !== 'assistant') {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (JSON.stringify(item.uiAnswers?.[answer.blockId]) === JSON.stringify(answer)) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
this.options.emit(this.thread.upsert({ ...item, uiAnswers: { ...item.uiAnswers, [answer.blockId]: answer } }));
|
|
279
|
+
this.options.persist();
|
|
280
|
+
}
|
|
164
281
|
sendInput(text, extras = {}) {
|
|
165
282
|
return this.submitInput(text, extras);
|
|
166
283
|
}
|
|
@@ -289,7 +406,7 @@ export class ChatSession {
|
|
|
289
406
|
this.sendAfterReplacement = false;
|
|
290
407
|
this.pauseQueue(false);
|
|
291
408
|
this.setQueue(rest);
|
|
292
|
-
this.dispatch(next.text, { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments }, next.turnId);
|
|
409
|
+
this.dispatch(next.text, { mentions: next.mentions, skills: next.skills, chats: next.chats, attachments: next.attachments, uiChoice: next.uiChoice }, next.turnId);
|
|
293
410
|
}
|
|
294
411
|
dispatch(text, extras, requestedTurnId) {
|
|
295
412
|
this.pauseQueue(false);
|
|
@@ -298,7 +415,10 @@ export class ChatSession {
|
|
|
298
415
|
// A slash command must stay the first thing the CLI reads, as `contextNote` keeps it.
|
|
299
416
|
const references = text.startsWith('/') || !this.options.references ? { ids: [], note: null } : this.options.references(extras.chats);
|
|
300
417
|
const turnId = this.openTurn(text, note, { ...extras, chats: references.ids }, requestedTurnId);
|
|
301
|
-
const
|
|
418
|
+
const choiceNote = extras.uiChoice
|
|
419
|
+
? `The person chose ${JSON.stringify(extras.uiChoice.label)} from your UI reply. Their message is the visible context of that choice.`
|
|
420
|
+
: null;
|
|
421
|
+
const said = [preamble, references.note, choiceNote].filter((part) => part !== null);
|
|
302
422
|
const input = {
|
|
303
423
|
text,
|
|
304
424
|
preamble: said.length === 0 ? null : said.join('\n\n'),
|
|
@@ -410,14 +530,14 @@ export class ChatSession {
|
|
|
410
530
|
}
|
|
411
531
|
/*
|
|
412
532
|
* Starts the chat over: the CLI goes, and the next send starts one without a session to resume.
|
|
413
|
-
*
|
|
414
|
-
*
|
|
533
|
+
* The thread resets at once; the returned promise lets the host wait for pending attachment
|
|
534
|
+
* writes before removing their files. Writing the empty thread is the caller's.
|
|
415
535
|
*/
|
|
416
536
|
clear(force) {
|
|
417
537
|
if (this.busy && !force) {
|
|
418
538
|
throw new ChatError('chat-busy', `Chat ${this.id} is still working on the previous message`);
|
|
419
539
|
}
|
|
420
|
-
|
|
540
|
+
const ended = this.dispose();
|
|
421
541
|
this.generation = nextGeneration();
|
|
422
542
|
this.projector.reset();
|
|
423
543
|
// The fresh CLI hears what the host tells it at launch, as it would on a first turn.
|
|
@@ -456,6 +576,7 @@ export class ChatSession {
|
|
|
456
576
|
}
|
|
457
577
|
})
|
|
458
578
|
]);
|
|
579
|
+
return ended;
|
|
459
580
|
}
|
|
460
581
|
/*
|
|
461
582
|
* A thread read from disk cannot still be streaming or waiting, so whatever was open is closed,
|
|
@@ -836,6 +957,7 @@ export class ChatSession {
|
|
|
836
957
|
}
|
|
837
958
|
/* Ends the process and stops listening to it, as the host goes down; the thread stays as it is. */
|
|
838
959
|
freeze() {
|
|
960
|
+
this.clearUi();
|
|
839
961
|
this.clearStartWarning();
|
|
840
962
|
this.clearWorkflowWarnings();
|
|
841
963
|
this.frozen = true;
|
|
@@ -843,6 +965,7 @@ export class ChatSession {
|
|
|
843
965
|
}
|
|
844
966
|
/* Ends the CLI and everything it started; settles once it exited or was sent the SIGKILL. */
|
|
845
967
|
dispose() {
|
|
968
|
+
this.clearUi();
|
|
846
969
|
this.clearStartWarning();
|
|
847
970
|
this.clearWorkflowWarnings();
|
|
848
971
|
if (this.titleTimer !== null) {
|
|
@@ -988,10 +1111,24 @@ export class ChatSession {
|
|
|
988
1111
|
const skills = extras.skills?.length ? extras.skills : undefined;
|
|
989
1112
|
const chats = extras.chats?.length ? extras.chats : undefined;
|
|
990
1113
|
const attachments = extras.attachments?.length ? extras.attachments : undefined;
|
|
991
|
-
events.push(this.thread.upsert({
|
|
1114
|
+
events.push(this.thread.upsert({
|
|
1115
|
+
id: newId('user'),
|
|
1116
|
+
kind: 'user',
|
|
1117
|
+
createdAt: now,
|
|
1118
|
+
turnId,
|
|
1119
|
+
text,
|
|
1120
|
+
mentions,
|
|
1121
|
+
skills,
|
|
1122
|
+
chats,
|
|
1123
|
+
attachments,
|
|
1124
|
+
...(extras.uiChoice ? { uiChoice: extras.uiChoice } : {})
|
|
1125
|
+
}));
|
|
992
1126
|
}
|
|
993
1127
|
events.push(this.thread.patchInfo({ status: this.thread.statusFor(turnId), activeTurnId: turnId }));
|
|
994
1128
|
this.emit(events);
|
|
1129
|
+
if (extras.uiChoice) {
|
|
1130
|
+
this.recordUiAnswer({ ...extras.uiChoice, turnId, queued: false });
|
|
1131
|
+
}
|
|
995
1132
|
// On disk before the CLI answers, so a host that goes down mid-turn still shows the question.
|
|
996
1133
|
this.options.persist();
|
|
997
1134
|
return turnId;
|
|
@@ -1206,6 +1343,7 @@ export class ChatSession {
|
|
|
1206
1343
|
runtimeMode: info.runtimeMode,
|
|
1207
1344
|
resume: info.agentSessionId,
|
|
1208
1345
|
generation,
|
|
1346
|
+
saveGeneratedImage: this.options.saveGeneratedImage,
|
|
1209
1347
|
instructions: this.options.instructions?.() ?? null,
|
|
1210
1348
|
resumeNote: this.options.resumeNote?.() ?? null,
|
|
1211
1349
|
...(folders.length > 0 ? { folders } : {}),
|
|
@@ -1486,7 +1624,7 @@ export class ChatSession {
|
|
|
1486
1624
|
this.naming = true;
|
|
1487
1625
|
const turnId = done[0].id;
|
|
1488
1626
|
const answer = items
|
|
1489
|
-
.map((item) => (item.kind === 'assistant' && item.turnId === turnId ? item
|
|
1627
|
+
.map((item) => (item.kind === 'assistant' && item.turnId === turnId ? readableAssistantText(item) : ''))
|
|
1490
1628
|
.filter((text) => text !== '')
|
|
1491
1629
|
.join('\n\n');
|
|
1492
1630
|
const agentSessionId = this.thread.info.agentSessionId;
|
|
@@ -1536,9 +1674,121 @@ export class ChatSession {
|
|
|
1536
1674
|
}
|
|
1537
1675
|
emit(events) {
|
|
1538
1676
|
const delegating = this.delegatingChange(events);
|
|
1539
|
-
for (
|
|
1540
|
-
|
|
1677
|
+
for (let event of delegating === null ? events : [...events, delegating]) {
|
|
1678
|
+
if (event.type === 'reset') {
|
|
1679
|
+
this.clearUi();
|
|
1680
|
+
}
|
|
1681
|
+
if (event.type === 'item' && event.item.kind === 'tool') {
|
|
1682
|
+
this.uiAttachments.delete(event.item.turnId);
|
|
1683
|
+
}
|
|
1684
|
+
const item = event.type === 'item' ? event.item : event.type === 'delta' ? this.thread.get(event.itemId) : null;
|
|
1685
|
+
if (item?.kind !== 'assistant' || item.parentToolUseId) {
|
|
1686
|
+
this.options.emit(event);
|
|
1687
|
+
continue;
|
|
1688
|
+
}
|
|
1689
|
+
const tail = this.unscannedUiText(item, event.type === 'item');
|
|
1690
|
+
if (uiMayReferenceHost(tail)) {
|
|
1691
|
+
this.uiReferencing.add(item.id);
|
|
1692
|
+
}
|
|
1693
|
+
let stream = this.uiStreams.get(item.id);
|
|
1694
|
+
if (!stream && !uiHasFence(tail, this.uiFenceLanguage)) {
|
|
1695
|
+
this.forgetUiScan(item);
|
|
1696
|
+
this.options.emit(event);
|
|
1697
|
+
continue;
|
|
1698
|
+
}
|
|
1699
|
+
if (!stream) {
|
|
1700
|
+
stream = new UiStream({
|
|
1701
|
+
querySchemas: this.options.ui?.schemas,
|
|
1702
|
+
fenceLanguage: this.uiFenceLanguage,
|
|
1703
|
+
id: item.id,
|
|
1704
|
+
clock: this.options.clock ?? systemClock,
|
|
1705
|
+
emit: (preview) => {
|
|
1706
|
+
this.uiCompiled.set(item.id, preview);
|
|
1707
|
+
this.options.emit({ type: 'delta', itemId: item.id, text: '', ui: preview.blocks, textLength: preview.textLength });
|
|
1708
|
+
}
|
|
1709
|
+
});
|
|
1710
|
+
this.uiStreams.set(item.id, stream);
|
|
1711
|
+
}
|
|
1712
|
+
if (this.uiReferencing.has(item.id)) {
|
|
1713
|
+
this.options.ui?.observe(item, false);
|
|
1714
|
+
}
|
|
1715
|
+
this.forgetUiScan(item);
|
|
1716
|
+
const latestAttachment = this.latestUiAttachment(item.turnId);
|
|
1717
|
+
if (event.type === 'item' && !item.streaming) {
|
|
1718
|
+
const ui = stream
|
|
1719
|
+
.finish(item.text, latestAttachment)
|
|
1720
|
+
.map((block) => ({ ...block, revision: createHash('sha256').update(JSON.stringify(block)).digest('hex') }));
|
|
1721
|
+
this.uiStreams.delete(item.id);
|
|
1722
|
+
this.uiCompiled.delete(item.id);
|
|
1723
|
+
const diagnostics = [...new Set(ui.flatMap((block) => block.diagnostics.map((diagnostic) => diagnostic.message)))].slice(0, 6);
|
|
1724
|
+
if (diagnostics.length > 0) {
|
|
1725
|
+
const repair = `Your last ${this.uiFenceLanguage} block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(0, 1200);
|
|
1726
|
+
if (!this.pendingPreambles.includes(repair)) {
|
|
1727
|
+
this.pendingPreambles = [...this.pendingPreambles, repair];
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
const current = this.thread.get(item.id);
|
|
1731
|
+
const completed = { ...item, ui, ...(current?.kind === 'assistant' && current.uiQueries ? { uiQueries: current.uiQueries } : {}) };
|
|
1732
|
+
event = this.thread.upsert(completed);
|
|
1733
|
+
this.options.emit(event);
|
|
1734
|
+
this.options.ui?.observe(completed, true);
|
|
1735
|
+
}
|
|
1736
|
+
else {
|
|
1737
|
+
this.options.emit(event);
|
|
1738
|
+
stream.update(item.text, latestAttachment);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
/*
|
|
1743
|
+
* The text a scan has not covered yet, from the start of the line the last scan ended in, since a fence
|
|
1744
|
+
* is one line; a long line is cut short, behind a character no fence or tag can follow.
|
|
1745
|
+
*/
|
|
1746
|
+
unscannedUiText(item, whole) {
|
|
1747
|
+
const scanned = whole ? 0 : (this.uiScanned.get(item.id) ?? 0);
|
|
1748
|
+
this.uiScanned.set(item.id, item.text.length);
|
|
1749
|
+
if (scanned === 0 || scanned > item.text.length) {
|
|
1750
|
+
return item.text;
|
|
1751
|
+
}
|
|
1752
|
+
const line = item.text.lastIndexOf('\n', scanned - 1) + 1;
|
|
1753
|
+
const from = Math.max(line, scanned - UI_SCAN_OVERLAP);
|
|
1754
|
+
return from === line ? item.text.slice(from) : `\0${item.text.slice(from)}`;
|
|
1755
|
+
}
|
|
1756
|
+
forgetUiScan(item) {
|
|
1757
|
+
if (!item.streaming) {
|
|
1758
|
+
this.uiScanned.delete(item.id);
|
|
1759
|
+
this.uiReferencing.delete(item.id);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
/* Looked up once per turn, and again only after one of its tool calls changed. */
|
|
1763
|
+
latestUiAttachment(turnId) {
|
|
1764
|
+
if (this.uiAttachments.has(turnId)) {
|
|
1765
|
+
return this.uiAttachments.get(turnId);
|
|
1766
|
+
}
|
|
1767
|
+
const attachment = this.findUiAttachment(turnId);
|
|
1768
|
+
this.uiAttachments.set(turnId, attachment);
|
|
1769
|
+
return attachment;
|
|
1770
|
+
}
|
|
1771
|
+
findUiAttachment(turnId) {
|
|
1772
|
+
for (const item of this.thread.list().toReversed()) {
|
|
1773
|
+
if (item.kind !== 'tool' || item.turnId !== turnId || item.name !== 'ImageGeneration' || item.state !== 'done') {
|
|
1774
|
+
continue;
|
|
1775
|
+
}
|
|
1776
|
+
const input = GeneratedImageInputSchema.safeParse(item.input);
|
|
1777
|
+
if (input.success && input.data.attachment?.mime.startsWith('image/')) {
|
|
1778
|
+
return input.data.attachment.id;
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
return undefined;
|
|
1782
|
+
}
|
|
1783
|
+
clearUi() {
|
|
1784
|
+
for (const stream of this.uiStreams.values()) {
|
|
1785
|
+
stream.dispose();
|
|
1541
1786
|
}
|
|
1787
|
+
this.uiStreams.clear();
|
|
1788
|
+
this.uiCompiled.clear();
|
|
1789
|
+
this.uiScanned.clear();
|
|
1790
|
+
this.uiReferencing.clear();
|
|
1791
|
+
this.uiAttachments.clear();
|
|
1542
1792
|
}
|
|
1543
1793
|
/* Said on the info as well as by the rows, since a client with the thread closed sees only the info. */
|
|
1544
1794
|
delegatingChange(events) {
|
|
@@ -8,6 +8,7 @@ export interface ChatRecord {
|
|
|
8
8
|
seq: number;
|
|
9
9
|
resetSeq: number;
|
|
10
10
|
preambles: string[];
|
|
11
|
+
uiAccess: Record<string, unknown>;
|
|
11
12
|
extras: ChatRecordExtras;
|
|
12
13
|
lines: ChatLogLine[];
|
|
13
14
|
}
|
|
@@ -28,8 +29,8 @@ export declare class ChatStore {
|
|
|
28
29
|
list(): Promise<string[]>;
|
|
29
30
|
has(chatId: string): Promise<boolean>;
|
|
30
31
|
discardLog(chatId: string): Promise<number>;
|
|
31
|
-
write(chatId: string, info: ChatInfo, items: ChatItem[], at?: ChatSeq, preambles?: readonly string[], extras?: ChatRecordExtras): Promise<number>;
|
|
32
|
-
writeSync(chatId: string, info: ChatInfo, items: ChatItem[], at?: ChatSeq, preambles?: readonly string[], extras?: ChatRecordExtras): void;
|
|
32
|
+
write(chatId: string, info: ChatInfo, items: ChatItem[], at?: ChatSeq, preambles?: readonly string[], extras?: ChatRecordExtras, uiAccess?: Readonly<Record<string, unknown>>): Promise<number>;
|
|
33
|
+
writeSync(chatId: string, info: ChatInfo, items: ChatItem[], at?: ChatSeq, preambles?: readonly string[], extras?: ChatRecordExtras, uiAccess?: Readonly<Record<string, unknown>>): void;
|
|
33
34
|
read(chatId: string): Promise<ChatRecord | null>;
|
|
34
35
|
unreadable(chatId: string): Promise<boolean>;
|
|
35
36
|
private readSnapshot;
|
package/dist/chat/chat-store.js
CHANGED
|
@@ -19,17 +19,40 @@ const RecordSchema = z.looseObject({
|
|
|
19
19
|
seq: z.number().int().nonnegative().optional(),
|
|
20
20
|
resetSeq: z.number().int().nonnegative().optional(),
|
|
21
21
|
// Never on the wire: what goes in front of the next real prompt, once (a fork's note for its agent).
|
|
22
|
-
preambles: z.array(z.string()).optional()
|
|
22
|
+
preambles: z.array(z.string()).optional(),
|
|
23
|
+
// Never on the wire: the access a UI host captured for each reply that reads it, by item id.
|
|
24
|
+
uiAccess: z.record(z.string(), z.unknown()).optional()
|
|
23
25
|
});
|
|
24
|
-
const OWN_KEYS = new Set(['info', 'items', 'seq', 'resetSeq', 'preambles']);
|
|
26
|
+
const OWN_KEYS = new Set(['info', 'items', 'seq', 'resetSeq', 'preambles', 'uiAccess']);
|
|
25
27
|
function extrasOf(record) {
|
|
26
28
|
return Object.fromEntries(Object.entries(record).filter(([key]) => !OWN_KEYS.has(key)));
|
|
27
29
|
}
|
|
28
30
|
function logName(chatId) {
|
|
29
31
|
return `${encodeURIComponent(chatId)}.log`;
|
|
30
32
|
}
|
|
31
|
-
function recordBody(info, items, at, preambles, extras) {
|
|
32
|
-
return JSON.stringify({
|
|
33
|
+
function recordBody(info, items, at, preambles, extras, uiAccess) {
|
|
34
|
+
return JSON.stringify({
|
|
35
|
+
info,
|
|
36
|
+
items,
|
|
37
|
+
...at,
|
|
38
|
+
...(preambles.length === 0 ? {} : { preambles }),
|
|
39
|
+
...(Object.keys(uiAccess).length === 0 ? {} : { uiAccess }),
|
|
40
|
+
...withoutOwnKeys(extras)
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/* A record from before `uiAccess` kept the access inside each reply's `uiQueries`, which the item schema now drops. */
|
|
44
|
+
function liftUiAccess(record) {
|
|
45
|
+
if (typeof record !== 'object' || record === null || 'uiAccess' in record || !Array.isArray(record.items)) {
|
|
46
|
+
return record;
|
|
47
|
+
}
|
|
48
|
+
const uiAccess = {};
|
|
49
|
+
for (const item of record.items) {
|
|
50
|
+
const queries = item?.uiQueries;
|
|
51
|
+
if (typeof queries === 'object' && queries !== null && 'access' in queries) {
|
|
52
|
+
uiAccess[String(item.id)] = queries.access;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return Object.keys(uiAccess).length === 0 ? record : { ...record, uiAccess };
|
|
33
56
|
}
|
|
34
57
|
function withoutOwnKeys(extras) {
|
|
35
58
|
return Object.fromEntries(Object.entries(extras).filter(([key]) => !OWN_KEYS.has(key)));
|
|
@@ -80,8 +103,8 @@ export class ChatStore {
|
|
|
80
103
|
return lines.at(-1)?.seq ?? 0;
|
|
81
104
|
}
|
|
82
105
|
/* Answers how many bytes the record took, which is what tells a caller whether writing it is cheap. */
|
|
83
|
-
async write(chatId, info, items, at = { seq: 0, resetSeq: 0 }, preambles = [], extras = {}) {
|
|
84
|
-
const body = recordBody(info, items, at, preambles, extras);
|
|
106
|
+
async write(chatId, info, items, at = { seq: 0, resetSeq: 0 }, preambles = [], extras = {}, uiAccess = {}) {
|
|
107
|
+
const body = recordBody(info, items, at, preambles, extras, uiAccess);
|
|
85
108
|
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
86
109
|
await writeAtomic(join(this.dir, recordFileName(chatId)), body);
|
|
87
110
|
return body.length;
|
|
@@ -90,9 +113,9 @@ export class ChatStore {
|
|
|
90
113
|
* The same write without a turn of the event loop. A `bun --watch` reload restarts the module
|
|
91
114
|
* before an awaited write comes back, so a shutdown has to put the threads down synchronously.
|
|
92
115
|
*/
|
|
93
|
-
writeSync(chatId, info, items, at = { seq: 0, resetSeq: 0 }, preambles = [], extras = {}) {
|
|
116
|
+
writeSync(chatId, info, items, at = { seq: 0, resetSeq: 0 }, preambles = [], extras = {}, uiAccess = {}) {
|
|
94
117
|
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
95
|
-
writeAtomicSync(join(this.dir, recordFileName(chatId)), recordBody(info, items, at, preambles, extras));
|
|
118
|
+
writeAtomicSync(join(this.dir, recordFileName(chatId)), recordBody(info, items, at, preambles, extras, uiAccess));
|
|
96
119
|
}
|
|
97
120
|
/* Null for a chat with nothing on disk to make it of, and for one whose record does not parse (see `unreadable`). */
|
|
98
121
|
async read(chatId) {
|
|
@@ -117,7 +140,15 @@ export class ChatStore {
|
|
|
117
140
|
resetSeq = line.seq;
|
|
118
141
|
}
|
|
119
142
|
}
|
|
120
|
-
return {
|
|
143
|
+
return {
|
|
144
|
+
...thread.snapshot(),
|
|
145
|
+
seq,
|
|
146
|
+
resetSeq,
|
|
147
|
+
preambles: snapshot.preambles ?? [],
|
|
148
|
+
uiAccess: snapshot.uiAccess ?? {},
|
|
149
|
+
extras: extrasOf(snapshot),
|
|
150
|
+
lines
|
|
151
|
+
};
|
|
121
152
|
}
|
|
122
153
|
/*
|
|
123
154
|
* Whether a record is on disk that this host cannot read: one a newer version wrote, one cut short,
|
|
@@ -146,7 +177,7 @@ export class ChatStore {
|
|
|
146
177
|
await writeAtomic(join(this.dir, recordFileName(chatId)), JSON.stringify(migrated));
|
|
147
178
|
}
|
|
148
179
|
}
|
|
149
|
-
const parsed = RecordSchema.safeParse(record);
|
|
180
|
+
const parsed = RecordSchema.safeParse(liftUiAccess(record));
|
|
150
181
|
return parsed.success ? parsed.data : UNREADABLE;
|
|
151
182
|
}
|
|
152
183
|
async delete(chatId) {
|
|
@@ -199,5 +230,5 @@ function fromLogAlone(lines) {
|
|
|
199
230
|
resetSeq = line.seq;
|
|
200
231
|
}
|
|
201
232
|
}
|
|
202
|
-
return { ...thread.snapshot(), seq: 0, resetSeq, preambles: [], extras: {}, lines };
|
|
233
|
+
return { ...thread.snapshot(), seq: 0, resetSeq, preambles: [], uiAccess: {}, extras: {}, lines };
|
|
203
234
|
}
|
|
@@ -9,6 +9,7 @@ export declare class CodexBackend implements ChatBackend {
|
|
|
9
9
|
readonly acknowledgesTurns = true;
|
|
10
10
|
private promptId;
|
|
11
11
|
private readonly launch;
|
|
12
|
+
private imageWrites;
|
|
12
13
|
private readonly host;
|
|
13
14
|
private readonly protocol;
|
|
14
15
|
private readonly client;
|
|
@@ -50,4 +51,5 @@ export declare class CodexBackend implements ChatBackend {
|
|
|
50
51
|
private handleFrame;
|
|
51
52
|
private handleExit;
|
|
52
53
|
private emit;
|
|
54
|
+
private storeImage;
|
|
53
55
|
}
|
|
@@ -39,6 +39,7 @@ export class CodexBackend {
|
|
|
39
39
|
acknowledgesTurns = true;
|
|
40
40
|
promptId = null;
|
|
41
41
|
launch;
|
|
42
|
+
imageWrites = null;
|
|
42
43
|
host;
|
|
43
44
|
protocol;
|
|
44
45
|
client;
|
|
@@ -321,7 +322,7 @@ export class CodexBackend {
|
|
|
321
322
|
dispose() {
|
|
322
323
|
const transport = this.transport;
|
|
323
324
|
this.transport = null;
|
|
324
|
-
return transport?.dispose()
|
|
325
|
+
return Promise.all([transport?.dispose(), this.imageWrites]).then(() => undefined);
|
|
325
326
|
}
|
|
326
327
|
/* A request whose failure the person has to hear about, since it is the turn that cannot go on. */
|
|
327
328
|
request(method, params, accepted) {
|
|
@@ -381,6 +382,55 @@ export class CodexBackend {
|
|
|
381
382
|
this.interruptOwed = false;
|
|
382
383
|
this.promptId = null;
|
|
383
384
|
}
|
|
385
|
+
if (event.type === 'image.generated' || this.imageWrites !== null) {
|
|
386
|
+
const pending = (this.imageWrites ?? Promise.resolve()).then(async () => {
|
|
387
|
+
if (event.type === 'image.generated') {
|
|
388
|
+
await this.storeImage(event);
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
this.host.onEvent(event);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
this.imageWrites = pending;
|
|
395
|
+
void pending
|
|
396
|
+
.finally(() => {
|
|
397
|
+
if (this.imageWrites === pending) {
|
|
398
|
+
this.imageWrites = null;
|
|
399
|
+
}
|
|
400
|
+
})
|
|
401
|
+
.catch(() => undefined);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
384
404
|
this.host.onEvent(event);
|
|
385
405
|
}
|
|
406
|
+
async storeImage(event) {
|
|
407
|
+
const input = { revisedPrompt: event.revisedPrompt, transparentBackground: event.transparentBackground };
|
|
408
|
+
let attachment;
|
|
409
|
+
try {
|
|
410
|
+
if (!this.launch.saveGeneratedImage) {
|
|
411
|
+
throw new Error('This machine cannot save generated images');
|
|
412
|
+
}
|
|
413
|
+
attachment = await this.launch.saveGeneratedImage(event.ref, event.upload);
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
this.host.onEvent({ type: 'tool.started', ref: event.ref, name: 'ImageGeneration', input, parentRef: null });
|
|
417
|
+
this.host.onEvent({ type: 'tool.done', ref: event.ref, output: errorText(error), state: 'error' });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
this.host.onEvent({
|
|
421
|
+
type: 'tool.started',
|
|
422
|
+
ref: event.ref,
|
|
423
|
+
name: 'ImageGeneration',
|
|
424
|
+
input: {
|
|
425
|
+
...input,
|
|
426
|
+
attachment: {
|
|
427
|
+
...attachment,
|
|
428
|
+
...(event.width === undefined ? {} : { width: event.width }),
|
|
429
|
+
...(event.height === undefined ? {} : { height: event.height })
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
parentRef: null
|
|
433
|
+
});
|
|
434
|
+
this.host.onEvent({ type: 'tool.done', ref: event.ref, output: null, state: 'done' });
|
|
435
|
+
}
|
|
386
436
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generatedImage } from './generated-image.js';
|
|
1
2
|
import { readCodexLimits } from '../usage/limits/normalize.js';
|
|
2
3
|
import { codexRuntimeMode } from '../providers/codex.js';
|
|
3
4
|
import { cleanTitle } from '../title-file.js';
|
|
@@ -567,7 +568,7 @@ export class CodexProtocol {
|
|
|
567
568
|
events.push({ type: 'question.requested', requestId: ref, questions, async: true });
|
|
568
569
|
return;
|
|
569
570
|
}
|
|
570
|
-
this.text(ref, str(item.text) ?? '', completed, events);
|
|
571
|
+
this.text(ref, str(item.text) ?? '', completed, events, str(item.phase) ?? undefined);
|
|
571
572
|
return;
|
|
572
573
|
}
|
|
573
574
|
case 'plan':
|
|
@@ -616,6 +617,32 @@ export class CodexProtocol {
|
|
|
616
617
|
case 'dynamicToolCall':
|
|
617
618
|
this.tool(ref, str(item.tool) ?? 'tool', item.arguments ?? {}, completed, textOf(item.contentItems), item.success !== false, events);
|
|
618
619
|
return;
|
|
620
|
+
case 'imageGeneration': {
|
|
621
|
+
const revisedPrompt = str(item.revisedPrompt) ?? undefined;
|
|
622
|
+
const transparentBackground = typeof item.transparentBackground === 'boolean' ? item.transparentBackground : undefined;
|
|
623
|
+
const input = {
|
|
624
|
+
...(revisedPrompt === undefined ? {} : { revisedPrompt }),
|
|
625
|
+
...(transparentBackground === undefined ? {} : { transparentBackground })
|
|
626
|
+
};
|
|
627
|
+
if (!completed) {
|
|
628
|
+
this.tool(ref, 'ImageGeneration', input, false, '', true, events);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
try {
|
|
632
|
+
if (item.failure || item.status !== 'completed') {
|
|
633
|
+
throw new Error(isRecord(item.failure) ? (str(item.failure.message) ?? 'Image generation failed') : (str(item.failure) ?? 'Image generation failed'));
|
|
634
|
+
}
|
|
635
|
+
const image = generatedImage(str(item.result) ?? '', revisedPrompt);
|
|
636
|
+
events.push({ type: 'image.generated', ref, ...image, ...input });
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
this.tool(ref, 'ImageGeneration', input, true, error instanceof Error ? error.message : 'Image generation failed', false, events);
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
case 'imageView':
|
|
644
|
+
this.tool(ref, 'ImageView', { path: str(item.path) ?? '' }, completed, '', true, events);
|
|
645
|
+
return;
|
|
619
646
|
case 'webSearch': {
|
|
620
647
|
const action = isRecord(item.action) ? item.action : {};
|
|
621
648
|
this.tool(ref, 'WebSearch', { query: str(item.query) ?? str(action.query) ?? '' }, completed, '', true, events);
|
|
@@ -683,8 +710,8 @@ export class CodexProtocol {
|
|
|
683
710
|
}
|
|
684
711
|
return null;
|
|
685
712
|
}
|
|
686
|
-
text(ref, text, completed, events) {
|
|
687
|
-
events.push(completed ?
|
|
713
|
+
text(ref, text, completed, events, phase) {
|
|
714
|
+
events.push({ type: completed ? 'text.done' : 'text.delta', ref, text, ...(phase === undefined ? {} : { phase }) });
|
|
688
715
|
}
|
|
689
716
|
/*
|
|
690
717
|
* An agent Codex spawned, as a spawnAgent call reports it. It works in a thread of its own, so this row
|
package/dist/chat/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { CodedError } from '../coded-error.ts';
|
|
2
|
-
export type ChatErrorCode = 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'task-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' | 'invalid-attachments' | 'subagent-not-found' | 'task-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';
|
|
3
3
|
export declare class ChatError<TCode extends string = ChatErrorCode> extends CodedError<TCode> {
|
|
4
4
|
}
|