@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.
Files changed (43) hide show
  1. package/dist/chat/attachment-store.d.ts +2 -0
  2. package/dist/chat/attachment-store.js +38 -2
  3. package/dist/chat/backend.d.ts +12 -1
  4. package/dist/chat/chat-core.d.ts +57 -1
  5. package/dist/chat/chat-core.js +67 -14
  6. package/dist/chat/chat-session.d.ts +35 -2
  7. package/dist/chat/chat-session.js +261 -11
  8. package/dist/chat/chat-store.d.ts +3 -2
  9. package/dist/chat/chat-store.js +42 -11
  10. package/dist/chat/codex-backend.d.ts +2 -0
  11. package/dist/chat/codex-backend.js +51 -1
  12. package/dist/chat/codex-protocol.js +30 -3
  13. package/dist/chat/delta-coalescer.js +1 -1
  14. package/dist/chat/errors.d.ts +1 -1
  15. package/dist/chat/fake-codex.js +33 -0
  16. package/dist/chat/generated-image.d.ts +6 -0
  17. package/dist/chat/generated-image.js +74 -0
  18. package/dist/chat/projector.js +9 -2
  19. package/dist/chat/readable-text.d.ts +2 -0
  20. package/dist/chat/readable-text.js +16 -0
  21. package/dist/chat/thread.js +6 -0
  22. package/dist/chat/ui-queries.d.ts +49 -0
  23. package/dist/chat/ui-queries.js +358 -0
  24. package/dist/host/handlers.js +3 -0
  25. package/dist/tasks/task-coordinator.js +6 -2
  26. package/package.json +5 -4
  27. package/src/chat/attachment-store.ts +38 -2
  28. package/src/chat/backend.ts +14 -2
  29. package/src/chat/chat-core.ts +84 -13
  30. package/src/chat/chat-session.ts +304 -12
  31. package/src/chat/chat-store.ts +53 -11
  32. package/src/chat/codex-backend.ts +50 -1
  33. package/src/chat/codex-protocol.ts +31 -3
  34. package/src/chat/delta-coalescer.ts +1 -1
  35. package/src/chat/errors.ts +2 -0
  36. package/src/chat/fake-codex.ts +34 -0
  37. package/src/chat/generated-image.ts +88 -0
  38. package/src/chat/projector.ts +9 -2
  39. package/src/chat/readable-text.ts +18 -0
  40. package/src/chat/thread.ts +6 -0
  41. package/src/chat/ui-queries.ts +416 -0
  42. package/src/host/handlers.ts +3 -0
  43. package/src/tasks/task-coordinator.ts +6 -2
@@ -1,5 +1,5 @@
1
- import { randomBytes } from 'node:crypto';
2
- import { mkdir, rm, writeFile } from 'node:fs/promises';
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { link, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import { extname, join } from 'node:path';
4
4
  import { attachmentImageMime, type ChatAttachment, type ChatAttachmentUpload } from '@adecore/agent-contracts';
5
5
 
@@ -57,6 +57,42 @@ export class AttachmentStore {
57
57
  return { id, name: upload.name, mime, size: bytes.byteLength, path };
58
58
  }
59
59
 
60
+ async saveGenerated(chatId: string, ref: string, upload: ChatAttachmentUpload): Promise<ChatAttachment> {
61
+ const id = createHash('sha256').update(`codex-image:${ref}`).digest('hex');
62
+ const bytes = Buffer.from(upload.data, 'base64');
63
+ const folder = this.folderOf(chatId);
64
+ await mkdir(folder, { recursive: true, mode: 0o700 });
65
+ const path = join(folder, `${id}.${extensionFor('', upload.mime)}`);
66
+ const temporary = join(folder, `.${id}-${randomBytes(8).toString('hex')}.tmp`);
67
+ try {
68
+ await writeFile(temporary, bytes, { mode: 0o600, flag: 'wx' });
69
+ try {
70
+ // Publish complete bytes without replacing an image another replay has already saved.
71
+ await link(temporary, path);
72
+ } catch (error) {
73
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
74
+ throw error;
75
+ }
76
+ const previous = await readFile(path);
77
+ if (!previous.equals(bytes)) {
78
+ throw new Error('The generated image id already belongs to another image');
79
+ }
80
+ }
81
+ } finally {
82
+ await rm(temporary, { force: true });
83
+ }
84
+ return { id, name: upload.name, mime: upload.mime, size: bytes.byteLength, path };
85
+ }
86
+
87
+ async copy(chatId: string, attachment: ChatAttachment): Promise<ChatAttachment> {
88
+ const bytes = await readFile(attachment.path);
89
+ const folder = this.folderOf(chatId);
90
+ await mkdir(folder, { recursive: true, mode: 0o700 });
91
+ const path = join(folder, `${createHash('sha256').update(attachment.id).digest('hex')}.${extensionFor(attachment.name, attachment.mime)}`);
92
+ await writeFile(path, bytes, { mode: 0o600 });
93
+ return { ...attachment, path };
94
+ }
95
+
60
96
  /* Everything a chat attached; used when the chat itself is deleted. */
61
97
  async removeAll(chatId: string): Promise<void> {
62
98
  await rm(this.folderOf(chatId), { recursive: true, force: true });
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  ChatAttachment,
3
+ ChatAttachmentUpload,
3
4
  ChatFileChange,
4
5
  ChatQuestion,
5
6
  ChatSkill,
@@ -42,6 +43,7 @@ export interface BackendLaunch {
42
43
  folders?: readonly string[];
43
44
  // How the CLI is started; a test runs a fake in the same process, everything else spawns it.
44
45
  spawn?: SpawnChatProcess;
46
+ saveGeneratedImage?(ref: string, upload: ChatAttachmentUpload): Promise<ChatAttachment>;
45
47
  }
46
48
 
47
49
  export interface TurnInput {
@@ -78,9 +80,19 @@ export type BackendEvent =
78
80
  // The CLI renamed its thread, for a protocol that says so.
79
81
  | { type: 'title'; title: string }
80
82
  | { type: 'permissions'; permissionMode: string; effectiveRuntimeMode?: RuntimeMode }
81
- | { type: 'text.delta'; ref: string; text: string }
83
+ | { type: 'text.delta'; ref: string; text: string; phase?: string }
82
84
  // `parentRef` is set for text a subagent wrote; it belongs to that agent's row, not to the thread.
83
- | { type: 'text.done'; ref: string; text: string; parentRef?: string | null }
85
+ | { type: 'text.done'; ref: string; text: string; parentRef?: string | null; phase?: string }
86
+ // Bytes are consumed by the backend before anything reaches the thread or its log.
87
+ | {
88
+ type: 'image.generated';
89
+ ref: string;
90
+ upload: ChatAttachmentUpload;
91
+ width?: number;
92
+ height?: number;
93
+ revisedPrompt?: string;
94
+ transparentBackground?: boolean;
95
+ }
84
96
  // What the model thought before it answered; consecutive blocks become one thinking item.
85
97
  | { type: 'thinking.delta'; ref: string; text: string }
86
98
  | { type: 'thinking.done'; ref: string; text: string }
@@ -2,6 +2,7 @@ import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
3
  import {
4
4
  ChatAttachmentUploadsSchema,
5
+ generatedImageAttachment,
5
6
  type AgentKind,
6
7
  type ChatAttachment,
7
8
  type ChatAttachResult,
@@ -15,6 +16,9 @@ import {
15
16
  type ChatInfo,
16
17
  type ChatItem,
17
18
  type ChatQueuedMessage,
19
+ type ChatUiChoicePayload,
20
+ type ChatUiQueryPayload,
21
+ type ChatUiLinkPayload,
18
22
  type ChatSkill,
19
23
  type ChatSubagentPayload,
20
24
  type ChatSubagentResult,
@@ -23,6 +27,7 @@ import {
23
27
  type ModelSelection,
24
28
  type RuntimeMode
25
29
  } from '@adecore/agent-contracts';
30
+ import { ChatUiQueries, type ChatUiHost } from './ui-queries.ts';
26
31
  import { ClientSinks } from '../client-sinks.ts';
27
32
  import { errorText } from '../error-text.ts';
28
33
  import type { AgentEvent, AgentSink } from '../events.ts';
@@ -52,6 +57,7 @@ import type { CodexProcessSpec } from './codex-thread.ts';
52
57
  import type { CodexClientInfo } from './codex-transport.ts';
53
58
  import { ComposerPreferences } from './composer-preferences.ts';
54
59
  import { DeltaCoalescer } from './delta-coalescer.ts';
60
+ import { readableAssistantText } from './readable-text.ts';
55
61
  import { ChatError } from './errors.ts';
56
62
  import { commandLabel, isBackgroundWork, runsInBackground, type BackgroundWork } from './background-work.ts';
57
63
  import { SubagentReader, type SubagentReaderOptions } from './subagent-reader.ts';
@@ -79,7 +85,8 @@ function isBookmarkable(item: ChatItem): item is BookmarkableItem {
79
85
  /* The start of a message on one line; a message of only attachments is its file names. */
80
86
  function excerptOf(item: BookmarkableItem): string {
81
87
  const files = item.kind === 'user' ? (item.attachments ?? []).map((attachment) => attachment.name).join(', ') : '';
82
- return (item.text.trim() === '' ? files : item.text).replace(/\s+/g, ' ').trim();
88
+ const text = item.kind === 'assistant' ? readableAssistantText(item) : item.text;
89
+ return (text.trim() === '' ? files : text).replace(/\s+/g, ' ').trim();
83
90
  }
84
91
 
85
92
  // One process worked on the turn and one more may take it up after a restart; a loop of resumes could redo a command forever.
@@ -96,6 +103,9 @@ const DEFAULT_RESUME_WORDS: ResumeWords = {
96
103
  };
97
104
 
98
105
  export interface ChatCoreOptions {
106
+ intelligentUi?: ChatUiHost;
107
+ // The info string that marks a UI fence in a reply, for a host that keeps a name of its own.
108
+ uiFenceLanguage?: string;
99
109
  providers: ProviderRegistry;
100
110
  // Where the threads are kept; without it a chat lives as long as the process.
101
111
  store?: ChatStore;
@@ -168,6 +178,8 @@ export class ChatCore {
168
178
  /* Where Claude Code keeps the projects of its default account on this machine; empty when it has none. */
169
179
  readonly claudeProjectsDir: string;
170
180
  readonly composerPreferences = new ComposerPreferences();
181
+ private readonly uiQueries: ChatUiQueries | null;
182
+ private readonly uiFenceLanguage: string | undefined;
171
183
  private readonly checkpoints: TurnCheckpoints | null;
172
184
  private readonly instructions: string | null;
173
185
  private readonly skillIndex: SkillIndex;
@@ -181,7 +193,7 @@ export class ChatCore {
181
193
  // How big each record was the last time it went to disk, and the writes waiting for a big one.
182
194
  private readonly sizes = new Map<string, number>();
183
195
  private readonly waiting = new Map<string, ReturnType<typeof setTimeout>>();
184
- // What the last record held that no event carries (preambles, the host's extras), as its JSON.
196
+ // What the last record held that no event carries (preambles, the host's extras, captured UI access), as its JSON.
185
197
  private readonly unlogged = new Map<string, string>();
186
198
  // The write in flight per chat, so the next one queues behind it instead of racing it.
187
199
  private readonly writes = new Map<string, Promise<void>>();
@@ -194,6 +206,8 @@ export class ChatCore {
194
206
  private readonly limitResume: ChatCoreOptions['limitResume'] | null;
195
207
 
196
208
  constructor(options: ChatCoreOptions) {
209
+ this.uiQueries = options.intelligentUi ? new ChatUiQueries(options.intelligentUi) : null;
210
+ this.uiFenceLanguage = options.uiFenceLanguage;
197
211
  this.providers = options.providers;
198
212
  this.store = options.store ?? null;
199
213
  this.bookmarks = options.bookmarks ?? null;
@@ -390,8 +404,11 @@ export class ChatCore {
390
404
  const references = this.referencesFor(payload.chatId);
391
405
  const session = new ChatSession({
392
406
  info,
407
+ ...(this.uiFenceLanguage ? { uiFenceLanguage: this.uiFenceLanguage } : {}),
408
+ ...(this.uiQueries ? { ui: { schemas: this.uiQueries.schemas, observe: (item, final) => this.uiQueries!.observe(session, item, final) } } : {}),
393
409
  items: stored?.items ?? [],
394
410
  preambles: stored?.preambles ?? [],
411
+ uiAccess: stored?.uiAccess ?? {},
395
412
  provider,
396
413
  command: this.commands[kind] ?? provider.command,
397
414
  ...(this.spawn ? { spawn: this.spawn } : {}),
@@ -400,6 +417,7 @@ export class ChatCore {
400
417
  this.accounts?.launched?.(kind, chatAccount);
401
418
  return env;
402
419
  },
420
+ saveGeneratedImage: (ref, upload) => this.attachments.saveGenerated(payload.chatId, ref, upload),
403
421
  instructions: () => this.instructionsFor(payload.chatId),
404
422
  resumeNote: () => this.resumeNoteFor(payload.chatId),
405
423
  folders: () => this.foldersFor(payload.chatId),
@@ -495,11 +513,16 @@ export class ChatCore {
495
513
  const log = this.logs.get(chatId)!;
496
514
  const events = since === undefined ? null : log.after(since);
497
515
  if (events !== null) {
498
- return { info: session.info, items: [], events, seq: log.seq };
516
+ return { info: session.info, items: [], events: [...events, ...session.uiPreviews], seq: log.seq };
499
517
  }
500
- return historyLimit === undefined
501
- ? { ...session.thread.snapshot(), seq: log.seq }
502
- : { info: session.info, ...session.thread.history(historyLimit), pending: session.thread.pending(), seq: log.seq };
518
+ const page = historyLimit === undefined ? session.thread.snapshot() : session.thread.history(historyLimit);
519
+ return {
520
+ ...page,
521
+ info: session.info,
522
+ items: session.previewItems(page.items),
523
+ ...(historyLimit === undefined ? {} : { pending: session.previewItems(session.thread.pending()) }),
524
+ seq: log.seq
525
+ };
503
526
  }
504
527
 
505
528
  /*
@@ -703,6 +726,34 @@ export class ChatCore {
703
726
  return session.sendInput(text, { ...extras, ...(attachments.length > 0 ? { attachments } : {}) });
704
727
  }
705
728
 
729
+ async linkUi(payload: ChatUiLinkPayload, clientId: string) {
730
+ if (!this.attached.get(payload.chatId)?.has(clientId) || !this.uiQueries) {
731
+ throw new ChatError('refused-query', 'Attach to the chat before resolving its UI links.');
732
+ }
733
+ return this.uiQueries.link(this.require(payload.chatId), payload);
734
+ }
735
+
736
+ async queryUi(payload: ChatUiQueryPayload, clientId: string) {
737
+ if (!this.attached.get(payload.chatId)?.has(clientId)) {
738
+ throw new ChatError('refused-query', 'Attach to the chat before reading its UI queries.');
739
+ }
740
+ if (!this.uiQueries) {
741
+ throw new ChatError('refused-query', 'This host does not provide UI query sources.');
742
+ }
743
+ return this.uiQueries.query(this.require(payload.chatId), payload);
744
+ }
745
+
746
+ async choose(payload: ChatUiChoicePayload, clientId: string): Promise<{ queued: boolean; turnId: string }> {
747
+ if (!this.attached.get(payload.chatId)?.has(clientId)) {
748
+ throw new ChatError('refused-query', 'Attach to the chat before answering its UI.');
749
+ }
750
+ const session = this.require(payload.chatId);
751
+ const queries = await this.uiQueries?.choiceValues(session, payload);
752
+ const result = session.choose(payload, queries);
753
+ await this.persisted(payload.chatId);
754
+ return result;
755
+ }
756
+
706
757
  /* The file behind an attachment id: what this chat's thread or queue says it is, or a visual's page. */
707
758
  attachment(chatId: string, id: string): ChatAttachment | null {
708
759
  const session = this.chats.get(chatId);
@@ -710,7 +761,8 @@ export class ChatCore {
710
761
  return null;
711
762
  }
712
763
  for (const item of session.thread.list()) {
713
- const found = item.kind === 'user' ? item.attachments?.find((attachment) => attachment.id === id) : undefined;
764
+ const generated = generatedImageAttachment(item);
765
+ const found = item.kind === 'user' ? item.attachments?.find((attachment) => attachment.id === id) : generated?.id === id ? generated : null;
714
766
  if (found) {
715
767
  return found;
716
768
  }
@@ -759,7 +811,7 @@ export class ChatCore {
759
811
  throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
760
812
  }
761
813
  await this.clearing(chatId);
762
- session.clear(force);
814
+ const ended = session.clear(force);
763
815
  // Before the record is written, so what it takes along from the record is gone from the write as well.
764
816
  const cleared = this.cleared(chatId);
765
817
  // A debounced write still waiting holds the old thread and must not land after the empty one.
@@ -767,7 +819,7 @@ export class ChatCore {
767
819
  // Folded right away: every line before the reset describes a thread that is gone.
768
820
  await Promise.all([
769
821
  this.persistNow(chatId, true),
770
- this.attachments.removeAll(chatId),
822
+ ended.then(() => this.attachments.removeAll(chatId)),
771
823
  this.bookmarks?.removeChat(chatId),
772
824
  this.visuals?.removeChat(chatId),
773
825
  cleared
@@ -812,8 +864,9 @@ export class ChatCore {
812
864
  const session = this.require(chatId);
813
865
  // A write already out would put the record back after the delete.
814
866
  const writing = this.writes.get(chatId);
815
- void session.dispose();
867
+ const ended = session.dispose();
816
868
  this.subagents.releaseChat(chatId);
869
+ this.uiQueries?.forget(chatId);
817
870
  this.coalescers.get(chatId)?.dispose();
818
871
  this.coalescers.delete(chatId);
819
872
  this.logs.get(chatId)?.close();
@@ -829,6 +882,7 @@ export class ChatCore {
829
882
  const snapshot = session.thread.snapshot();
830
883
  this.forgotten(chatId);
831
884
  await writing;
885
+ await ended;
832
886
  await Promise.all([
833
887
  this.store?.delete(chatId),
834
888
  this.attachments.removeAll(chatId),
@@ -979,7 +1033,15 @@ export class ChatCore {
979
1033
  const log = this.logs.get(chatId);
980
1034
  // Not folded here: an older write still in flight may land after this one, and the log is what covers for it.
981
1035
  try {
982
- this.store.writeSync(chatId, info, items, { seq: log?.seq ?? 0, resetSeq: log?.resetSeq ?? 0 }, session.preambles, this.recordExtras(chatId));
1036
+ this.store.writeSync(
1037
+ chatId,
1038
+ info,
1039
+ items,
1040
+ { seq: log?.seq ?? 0, resetSeq: log?.resetSeq ?? 0 },
1041
+ session.preambles,
1042
+ this.recordExtras(chatId),
1043
+ session.uiAccessRecord
1044
+ );
983
1045
  } catch (e) {
984
1046
  console.error(`Chat record for ${chatId} failed:`, errorText(e));
985
1047
  }
@@ -1138,7 +1200,8 @@ export class ChatCore {
1138
1200
  // A delta held back is already in the thread, so it gets its seq before the snapshot says where it ends.
1139
1201
  this.coalescers.get(chatId)?.flush();
1140
1202
  const extras = this.recordExtras(chatId);
1141
- const unlogged = JSON.stringify([session.preambles, extras]);
1203
+ const uiAccess = session.uiAccessRecord;
1204
+ const unlogged = JSON.stringify([session.preambles, extras, uiAccess]);
1142
1205
  const folds = fold || log.size > COMPACT_ABOVE_BYTES || !log.onDisk;
1143
1206
  // The log already holds every change to the thread, so the whole record is rewritten only to fold
1144
1207
  // the log, on a chat's first write, or for what no event carries.
@@ -1147,7 +1210,7 @@ export class ChatCore {
1147
1210
  }
1148
1211
  const { info, items } = session.thread.snapshot();
1149
1212
  const at = { seq: log.seq, resetSeq: log.resetSeq };
1150
- this.sizes.set(chatId, await this.store.write(chatId, info, items, at, session.preambles, extras));
1213
+ this.sizes.set(chatId, await this.store.write(chatId, info, items, at, session.preambles, extras, uiAccess));
1151
1214
  this.unlogged.set(chatId, unlogged);
1152
1215
  // A chat killed while the write was out has no log left to fold.
1153
1216
  if (this.logs.get(chatId) === log && folds) {
@@ -1162,6 +1225,14 @@ export class ChatCore {
1162
1225
  }
1163
1226
 
1164
1227
  private emit(chatId: string, event: ChatEvent): void {
1228
+ if (event.type === 'delta' && event.ui !== undefined) {
1229
+ // Previews follow all pending text but have no log position or observer side effects.
1230
+ this.coalescers.get(chatId)?.flush();
1231
+ for (const clientId of this.attached.get(chatId) ?? []) {
1232
+ this.sinks.to(clientId, { event: 'chat.event', payload: { chatId, event } });
1233
+ }
1234
+ return;
1235
+ }
1165
1236
  this.activity.set(chatId, Date.now());
1166
1237
  let coalescer = this.coalescers.get(chatId);
1167
1238
  if (!coalescer) {