@adecore/agents 0.21.0 → 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.
@@ -1,18 +1,40 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { copyUiValue, UiBudget } from '@adecore/intelligent-ui';
2
+ import { copyUiValue, UI_HOST_LIMITS, UiBudget, uiMayReferenceHost } from '@adecore/intelligent-ui';
3
3
  import { UiLinkResolutionSchema, uiLinkTargets } from '@adecore/intelligent-ui/links';
4
4
  import { uiQueryArguments, uiQueryFallback } from '@adecore/intelligent-ui/query';
5
5
  import { ChatError } from './errors.js';
6
+ /*
7
+ * A failure a client can say in its own words: `reason` is a code that stays the same across versions,
8
+ * the message is the fallback for a client that does not know the code.
9
+ */
10
+ class UiQueryError extends ChatError {
11
+ reason;
12
+ constructor(code, reason, message) {
13
+ super(code, message);
14
+ this.reason = reason;
15
+ }
16
+ }
17
+ /*
18
+ * What a host's `authorize`, `read` or `link` throws to say why with a code: `code` reaches the reading's
19
+ * `code` and should stay the same across versions, `reason` is the plain sentence a client shows without it.
20
+ */
21
+ export class ChatUiRefusal extends UiQueryError {
22
+ constructor(code, reason) {
23
+ super('refused-query', code, reason);
24
+ }
25
+ }
6
26
  const MAX_QUERY_BYTES = 64 * 1024;
7
27
  const MAX_SNAPSHOT_BYTES = 128 * 1024;
8
- const MAX_QUERIES = 8;
9
- const REFRESH_MS = 10_000;
10
28
  const READ_TIMEOUT_MS = 8_000;
29
+ // A changed input reads at once, but a query never reads more often than this across its inputs.
30
+ const INPUT_READ_MS = 250;
11
31
  export class ChatUiQueries {
12
32
  schemas;
13
33
  captures = new Map();
14
34
  freezing = new Set();
15
35
  cache = new Map();
36
+ // The read id of the newest reading per cache key, which a remount or a second client is answered with.
37
+ latest = new Map();
16
38
  reading = new Map();
17
39
  attempts = new Map();
18
40
  active = new Map();
@@ -23,14 +45,16 @@ export class ChatUiQueries {
23
45
  this.schemas = Object.fromEntries(Object.entries(host.sources).map(([name, source]) => [name, source.args]));
24
46
  }
25
47
  observe(session, item, final) {
26
- if (!item.text.includes('@Query') && !/<(?:File|Diff|Commit|Node)\b/.test(item.text) && !item.ui?.some((block) => Object.keys(block.queries).length)) {
48
+ const key = `${session.id}:${item.id}`;
49
+ // Once a capture started, every delta of a streaming reply would scan its whole text again for nothing.
50
+ const captured = this.captures.has(key) || item.uiQueries?.authorChatId === session.id;
51
+ if (!captured && !uiMayReferenceHost(item.text) && !item.ui?.some((block) => Object.keys(block.queries).length)) {
27
52
  return;
28
53
  }
29
54
  const capture = this.access(session, item);
30
55
  if (!final || !item.ui?.length) {
31
56
  return;
32
57
  }
33
- const key = `${session.id}:${item.id}`;
34
58
  if (this.freezing.has(key)) {
35
59
  return;
36
60
  }
@@ -47,7 +71,7 @@ export class ChatUiQueries {
47
71
  }
48
72
  const readings = Object.create(null);
49
73
  const values = Object.create(null);
50
- for (const name of Object.keys(block.queries).slice(0, MAX_QUERIES)) {
74
+ for (const name of Object.keys(block.queries).slice(0, UI_HOST_LIMITS.queries)) {
51
75
  const reading = await this.query(session, {
52
76
  chatId: session.id,
53
77
  itemId: item.id,
@@ -64,12 +88,12 @@ export class ChatUiQueries {
64
88
  if (this.host.link) {
65
89
  const targets = uiLinkTargets(block, {}, values);
66
90
  const access = await this.access(session, current);
67
- for (const [id, target] of Object.entries(targets).slice(0, 64)) {
91
+ for (const [id, target] of Object.entries(targets).slice(0, UI_HOST_LIMITS.links)) {
68
92
  try {
69
- links[id] = this.linkReading(await this.host.link(session.info, access.access, target));
93
+ links[id] = this.linkReading(await this.host.link(session.info, access, target));
70
94
  }
71
95
  catch (error) {
72
- links[id] = { state: 'plain', reason: this.reason(error) };
96
+ links[id] = { state: 'plain', ...this.failure(error) };
73
97
  }
74
98
  }
75
99
  }
@@ -94,9 +118,9 @@ export class ChatUiQueries {
94
118
  if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
95
119
  frozen.readings = Object.fromEntries(Object.keys(readings).map((name) => [
96
120
  name,
97
- { state: 'failed', readAt: Date.now(), reason: 'The frozen query values are too large.' }
121
+ { state: 'failed', readAt: Date.now(), code: 'snapshot-too-large', reason: 'The frozen query values are too large.' }
98
122
  ]));
99
- frozen.links = Object.fromEntries(Object.keys(links).map((id) => [id, { state: 'plain', reason: 'This link must be checked again.' }]));
123
+ frozen.links = Object.fromEntries(Object.keys(links).map((id) => [id, { state: 'plain', code: 'link-unchecked', reason: 'This link must be checked again.' }]));
100
124
  frozen.fallback = `${block.fallback.slice(0, 2048)}\nSources: ${names}\nThe frozen query values are too large.`;
101
125
  }
102
126
  if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
@@ -108,6 +132,7 @@ export class ChatUiQueries {
108
132
  .catch(() => { })
109
133
  .finally(() => this.freezing.delete(key));
110
134
  }
135
+ /* The writer's access for a reply, captured once; null where it was denied or is unavailable. */
111
136
  access(session, item) {
112
137
  const key = `${session.id}:${item.id}`;
113
138
  const existing = this.captures.get(key);
@@ -115,10 +140,11 @@ export class ChatUiQueries {
115
140
  return existing;
116
141
  }
117
142
  if (item.uiQueries?.authorChatId === session.id) {
118
- return Promise.resolve(item.uiQueries);
143
+ return Promise.resolve(session.uiAccess(item.id) ?? null);
119
144
  }
120
145
  // A pending capture is persisted as denied, so a restart cannot widen the writer's rights.
121
- session.updateUiQueries(item.id, { authorChatId: session.id, access: null, blocks: {} });
146
+ session.setUiAccess(item.id, null);
147
+ session.updateUiQueries(item.id, { authorChatId: session.id, blocks: {} });
122
148
  const captured = this.host
123
149
  .capture(session.info)
124
150
  .then((access) => {
@@ -126,11 +152,10 @@ export class ChatUiQueries {
126
152
  if (Buffer.byteLength(JSON.stringify(captured)) > 32 * 1024) {
127
153
  throw new Error('The UI access snapshot is too large.');
128
154
  }
129
- const state = { authorChatId: session.id, access: captured, blocks: {} };
130
- session.updateUiQueries(item.id, state);
131
- return state;
155
+ session.setUiAccess(item.id, captured);
156
+ return captured;
132
157
  })
133
- .catch(() => ({ authorChatId: session.id, access: null, blocks: {} }))
158
+ .catch(() => null)
134
159
  .finally(() => this.captures.delete(key));
135
160
  this.captures.set(key, captured);
136
161
  return captured;
@@ -139,18 +164,18 @@ export class ChatUiQueries {
139
164
  const item = session.thread.get(payload.itemId);
140
165
  const block = item?.kind === 'assistant' && !item.streaming && !item.parentToolUseId ? item.ui?.find((entry) => entry.id === payload.blockId) : undefined;
141
166
  if (item?.kind !== 'assistant' || !block?.complete || block.revision !== payload.revision) {
142
- throw new ChatError('stale-ui-block', 'This completed UI block is no longer available.');
167
+ throw new UiQueryError('stale-ui-block', 'block-stale', 'This completed UI block is no longer available.');
143
168
  }
144
169
  return { item, block };
145
170
  }
146
171
  stored(session, payload) {
147
172
  const { item, block } = this.storedBlock(session, payload);
148
- if (!Object.hasOwn(block.queries, payload.query) || Object.keys(block.queries).length > MAX_QUERIES) {
149
- throw new ChatError('refused-query', 'This block does not declare an allowed query.');
173
+ if (!Object.hasOwn(block.queries, payload.query) || Object.keys(block.queries).length > UI_HOST_LIMITS.queries) {
174
+ throw new UiQueryError('refused-query', 'query-undeclared', 'This block does not declare an allowed query.');
150
175
  }
151
176
  const name = block.queries[payload.query].source;
152
177
  if (!Object.hasOwn(this.host.sources, name)) {
153
- throw new ChatError('refused-query', 'This query source is not registered.');
178
+ throw new UiQueryError('refused-query', 'source-unregistered', 'This query source is not registered.');
154
179
  }
155
180
  return { item, block, source: this.host.sources[name] };
156
181
  }
@@ -164,19 +189,18 @@ export class ChatUiQueries {
164
189
  ({ source, block, item } = this.stored(session, payload));
165
190
  args = uiQueryArguments(block, payload.query, payload.values);
166
191
  args = source.args.parse(args);
167
- const access = await this.access(session, item);
168
- if (access.access === null) {
169
- throw new ChatError('refused-query', 'The writer’s original access is unavailable.');
192
+ authorAccess = await this.access(session, item);
193
+ if (authorAccess === null) {
194
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
170
195
  }
171
- authorAccess = access.access;
172
196
  await source.authorize(session.info, authorAccess, args);
173
197
  }
174
198
  catch (error) {
175
- return { state: 'refused', readAt: Date.now(), reason: this.reason(error) };
199
+ return { state: 'refused', readAt: Date.now(), ...this.failure(error) };
176
200
  }
177
201
  const key = JSON.stringify([session.id, item.id, block.id, block.revision, payload.query, args]);
178
- const cached = [...this.cache.values()].find((entry) => entry.key === key);
179
- if (cached && Date.now() - cached.reading.readAt < REFRESH_MS) {
202
+ const cached = this.cache.get(this.latest.get(key) ?? '');
203
+ if (cached && Date.now() - cached.reading.readAt < UI_HOST_LIMITS.refreshMilliseconds) {
180
204
  return cached.reading;
181
205
  }
182
206
  const pending = this.reading.get(key);
@@ -184,14 +208,17 @@ export class ChatUiQueries {
184
208
  return pending;
185
209
  }
186
210
  const identity = JSON.stringify([session.id, item.id, block.id, block.revision, payload.query]);
187
- if (Date.now() - (this.attempts.get(identity) ?? 0) < REFRESH_MS) {
188
- return { state: 'failed', readAt: Date.now(), reason: 'This query may refresh once every ten seconds.' };
211
+ const now = Date.now();
212
+ if (now - (this.attempts.get(key) ?? -Infinity) < UI_HOST_LIMITS.refreshMilliseconds ||
213
+ now - (this.attempts.get(identity) ?? -Infinity) < INPUT_READ_MS) {
214
+ return { state: 'failed', readAt: now, code: 'refresh-limit', reason: 'This query may refresh once every ten seconds.' };
189
215
  }
190
216
  if ((this.active.get(session.id) ?? 0) >= 2 || this.totalActive >= 16) {
191
- return { state: 'failed', readAt: Date.now(), reason: 'Too many UI queries are being read.' };
217
+ return { state: 'failed', readAt: now, code: 'busy', reason: 'Too many UI queries are being read.' };
192
218
  }
193
- this.attempts.set(identity, Date.now());
194
- while (this.attempts.size > 128) {
219
+ this.attempts.set(key, now);
220
+ this.attempts.set(identity, now);
221
+ while (this.attempts.size > 256) {
195
222
  this.attempts.delete(this.attempts.keys().next().value);
196
223
  }
197
224
  this.active.set(session.id, (this.active.get(session.id) ?? 0) + 1);
@@ -202,21 +229,24 @@ export class ChatUiQueries {
202
229
  timeout.unref?.();
203
230
  const read = Promise.race([
204
231
  work,
205
- new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(new Error('The UI query timed out.')), { once: true }))
232
+ new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(new UiQueryError('refused-query', 'timed-out', 'The UI query timed out.')), {
233
+ once: true
234
+ }))
206
235
  ])
207
236
  .then((result) => {
208
237
  const value = copyUiValue(source.result.parse(result), new UiBudget());
209
238
  if (Buffer.byteLength(JSON.stringify(value)) > MAX_QUERY_BYTES) {
210
- throw new Error('The UI query result is too large.');
239
+ throw new UiQueryError('refused-query', 'result-too-large', 'The UI query result is too large.');
211
240
  }
212
241
  const reading = { state: 'fresh', value, readId: randomUUID(), readAt: Date.now() };
213
242
  this.cache.set(reading.readId, { key, chatId: session.id, reading });
243
+ this.latest.set(key, reading.readId);
214
244
  while (this.cache.size > 128) {
215
- this.cache.delete(this.cache.keys().next().value);
245
+ this.evict(this.cache.keys().next().value);
216
246
  }
217
247
  return reading;
218
248
  })
219
- .catch((error) => ({ state: 'failed', readAt: Date.now(), reason: this.reason(error) }))
249
+ .catch((error) => ({ state: 'failed', readAt: Date.now(), ...this.failure(error) }))
220
250
  .finally(() => {
221
251
  clearTimeout(timeout);
222
252
  this.reading.delete(key);
@@ -237,31 +267,40 @@ export class ChatUiQueries {
237
267
  this.reading.set(key, read);
238
268
  return read;
239
269
  }
270
+ /* The issued value of every query the block declares, each checked against the read id the person saw. */
240
271
  async choiceValues(session, payload) {
241
272
  const item = session.thread.get(payload.itemId);
242
273
  const block = item?.kind === 'assistant' ? item.ui?.find((entry) => entry.id === payload.blockId) : undefined;
243
274
  if (!block || item?.kind !== 'assistant' || ('choiceId' in payload && item.uiAnswers?.[block.id]?.revision === payload.revision)) {
244
275
  return {};
245
276
  }
277
+ const names = Object.keys(block.queries);
246
278
  const values = Object.create(null);
247
- for (const name of Object.keys(payload.reads ?? {})) {
248
- const identity = { ...payload, query: name };
249
- const { source } = this.stored(session, identity);
279
+ if (names.length === 0) {
280
+ return values;
281
+ }
282
+ const access = await this.access(session, item);
283
+ if (access === null) {
284
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
285
+ }
286
+ for (const name of names) {
287
+ const { source } = this.stored(session, { ...payload, query: name });
250
288
  const args = source.args.parse(uiQueryArguments(block, name, payload.values));
251
- const access = await this.access(session, item);
252
- await source.authorize(session.info, access.access, args);
289
+ await source.authorize(session.info, access, args);
290
+ const readId = payload.reads !== undefined && Object.hasOwn(payload.reads, name) ? payload.reads[name] : undefined;
253
291
  const key = JSON.stringify([session.id, item.id, block.id, block.revision, name, args]);
254
- const readId = payload.reads[name];
255
- const cached = this.cache.get(readId);
292
+ const cached = readId === undefined ? undefined : this.cache.get(readId);
256
293
  const frozen = item.uiQueries?.blocks[block.id];
257
294
  const snapshot = frozen?.revision === block.revision ? frozen?.readings[name] : undefined;
258
- const reading = cached?.key === key
259
- ? cached.reading
260
- : snapshot?.readId === readId && JSON.stringify(args) === JSON.stringify(source.args.parse(uiQueryArguments(block, name)))
261
- ? snapshot
262
- : undefined;
295
+ const reading = readId === undefined
296
+ ? undefined
297
+ : cached?.key === key
298
+ ? cached.reading
299
+ : snapshot?.readId === readId && JSON.stringify(args) === JSON.stringify(source.args.parse(uiQueryArguments(block, name)))
300
+ ? snapshot
301
+ : undefined;
263
302
  if (!reading || reading.state !== 'fresh') {
264
- throw new ChatError('stale-ui-query', 'Refresh this query before sending its choice.');
303
+ throw new UiQueryError('stale-ui-query', 'stale-read', 'Refresh this query before sending its choice.');
265
304
  }
266
305
  values[name] = reading.value;
267
306
  }
@@ -270,34 +309,41 @@ export class ChatUiQueries {
270
309
  async link(session, payload) {
271
310
  try {
272
311
  if (!this.host.link) {
273
- throw new ChatError('refused-query', 'This host does not provide UI links.');
312
+ throw new UiQueryError('refused-query', 'links-unsupported', 'This host does not provide UI links.');
274
313
  }
275
314
  const { item, block } = this.storedBlock(session, payload);
276
315
  if (!item.uiQueries) {
277
- throw new ChatError('refused-query', 'The writer’s original access is unavailable.');
316
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
278
317
  }
279
318
  const access = await this.access(session, item);
280
- if (access.access === null) {
281
- throw new ChatError('refused-query', 'The writer’s original access is unavailable.');
319
+ if (access === null) {
320
+ throw new UiQueryError('refused-query', 'access-unavailable', 'The writer’s original access is unavailable.');
282
321
  }
283
322
  const queries = await this.choiceValues(session, payload);
284
323
  const target = uiLinkTargets(block, payload.values, queries)[payload.nodeId];
285
324
  if (!target) {
286
- throw new ChatError('refused-query', 'This visible node is not a supported link.');
325
+ throw new UiQueryError('refused-query', 'link-unsupported', 'This visible node is not a supported link.');
287
326
  }
288
- return this.linkReading(await this.host.link(session.info, access.access, target));
327
+ return this.linkReading(await this.host.link(session.info, access, target));
289
328
  }
290
329
  catch (error) {
291
- return { state: 'plain', reason: this.reason(error) };
330
+ return { state: 'plain', ...this.failure(error) };
292
331
  }
293
332
  }
294
333
  forget(chatId) {
295
334
  for (const [id, cached] of this.cache) {
296
335
  if (cached.chatId === chatId) {
297
- this.cache.delete(id);
336
+ this.evict(id);
298
337
  }
299
338
  }
300
339
  }
340
+ evict(readId) {
341
+ const cached = this.cache.get(readId);
342
+ this.cache.delete(readId);
343
+ if (cached && this.latest.get(cached.key) === readId) {
344
+ this.latest.delete(cached.key);
345
+ }
346
+ }
301
347
  linkReading(value) {
302
348
  const reading = UiLinkResolutionSchema.parse(copyUiValue(value, new UiBudget()));
303
349
  if (reading.state === 'chip' && !reading.target) {
@@ -305,7 +351,8 @@ export class ChatUiQueries {
305
351
  }
306
352
  return reading;
307
353
  }
308
- reason(error) {
309
- return (error instanceof Error ? error.message : 'This query could not be read.').slice(0, 240);
354
+ failure(error) {
355
+ const reason = (error instanceof Error ? error.message : 'This query could not be read.').slice(0, 240);
356
+ return error instanceof UiQueryError ? { reason, code: error.reason } : { reason };
310
357
  }
311
358
  }
@@ -32,7 +32,7 @@ export function chatHandlers(chats, providers) {
32
32
  return {};
33
33
  },
34
34
  'chat.send': (payload) => chats.send(payload.chatId, payload.text, { delivery: payload.delivery, mentions: payload.mentions, skills: payload.skills, chats: payload.chats }, payload.attachments),
35
- 'chat.uiChoice': (payload) => chats.choose(payload),
35
+ 'chat.uiChoice': (payload, clientId) => chats.choose(payload, clientId),
36
36
  'ui.link': (payload, clientId) => chats.linkUi(payload, clientId),
37
37
  'ui.query': (payload, clientId) => chats.queryUi(payload, clientId),
38
38
  'chat.unqueue': (payload) => ({ message: chats.unqueue(payload.chatId, payload.messageId) }),
@@ -1,12 +1,16 @@
1
1
  import { abortedByMachine, clipText } from '@adecore/agent-contracts';
2
2
  import { BACKGROUND_COMMAND_LIMIT_MS, commandLabel, isBackgroundWork, runningInBackground, runsInBackground } from '../chat/background-work.js';
3
+ import { readableAssistantText } from '../chat/readable-text.js';
3
4
  import { errorText } from '../error-text.js';
4
5
  // A child's answer is its last word of the turn; the thinking and the tools before it are not the result.
5
6
  function answerOf(items, turnId) {
6
7
  for (let i = items.length - 1; i >= 0; i--) {
7
8
  const item = items[i];
8
- if (item.kind === 'assistant' && item.turnId === turnId && !item.parentToolUseId && item.text.trim() !== '') {
9
- return item.text.trim();
9
+ if (item.kind === 'assistant' && item.turnId === turnId && !item.parentToolUseId) {
10
+ const text = readableAssistantText(item).trim();
11
+ if (text !== '') {
12
+ return text;
13
+ }
10
14
  }
11
15
  }
12
16
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,8 +20,8 @@
20
20
  "test": "bun --conditions=source test"
21
21
  },
22
22
  "dependencies": {
23
- "@adecore/agent-contracts": "0.21.0",
24
- "@adecore/intelligent-ui": "0.21.0",
23
+ "@adecore/agent-contracts": "0.21.1",
24
+ "@adecore/intelligent-ui": "0.21.1",
25
25
  "zod": "^4.6.5"
26
26
  },
27
27
  "devDependencies": {
@@ -57,6 +57,7 @@ import type { CodexProcessSpec } from './codex-thread.ts';
57
57
  import type { CodexClientInfo } from './codex-transport.ts';
58
58
  import { ComposerPreferences } from './composer-preferences.ts';
59
59
  import { DeltaCoalescer } from './delta-coalescer.ts';
60
+ import { readableAssistantText } from './readable-text.ts';
60
61
  import { ChatError } from './errors.ts';
61
62
  import { commandLabel, isBackgroundWork, runsInBackground, type BackgroundWork } from './background-work.ts';
62
63
  import { SubagentReader, type SubagentReaderOptions } from './subagent-reader.ts';
@@ -84,7 +85,8 @@ function isBookmarkable(item: ChatItem): item is BookmarkableItem {
84
85
  /* The start of a message on one line; a message of only attachments is its file names. */
85
86
  function excerptOf(item: BookmarkableItem): string {
86
87
  const files = item.kind === 'user' ? (item.attachments ?? []).map((attachment) => attachment.name).join(', ') : '';
87
- 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();
88
90
  }
89
91
 
90
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.
@@ -102,6 +104,8 @@ const DEFAULT_RESUME_WORDS: ResumeWords = {
102
104
 
103
105
  export interface ChatCoreOptions {
104
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;
105
109
  providers: ProviderRegistry;
106
110
  // Where the threads are kept; without it a chat lives as long as the process.
107
111
  store?: ChatStore;
@@ -175,6 +179,7 @@ export class ChatCore {
175
179
  readonly claudeProjectsDir: string;
176
180
  readonly composerPreferences = new ComposerPreferences();
177
181
  private readonly uiQueries: ChatUiQueries | null;
182
+ private readonly uiFenceLanguage: string | undefined;
178
183
  private readonly checkpoints: TurnCheckpoints | null;
179
184
  private readonly instructions: string | null;
180
185
  private readonly skillIndex: SkillIndex;
@@ -188,7 +193,7 @@ export class ChatCore {
188
193
  // How big each record was the last time it went to disk, and the writes waiting for a big one.
189
194
  private readonly sizes = new Map<string, number>();
190
195
  private readonly waiting = new Map<string, ReturnType<typeof setTimeout>>();
191
- // 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.
192
197
  private readonly unlogged = new Map<string, string>();
193
198
  // The write in flight per chat, so the next one queues behind it instead of racing it.
194
199
  private readonly writes = new Map<string, Promise<void>>();
@@ -202,6 +207,7 @@ export class ChatCore {
202
207
 
203
208
  constructor(options: ChatCoreOptions) {
204
209
  this.uiQueries = options.intelligentUi ? new ChatUiQueries(options.intelligentUi) : null;
210
+ this.uiFenceLanguage = options.uiFenceLanguage;
205
211
  this.providers = options.providers;
206
212
  this.store = options.store ?? null;
207
213
  this.bookmarks = options.bookmarks ?? null;
@@ -398,9 +404,11 @@ export class ChatCore {
398
404
  const references = this.referencesFor(payload.chatId);
399
405
  const session = new ChatSession({
400
406
  info,
407
+ ...(this.uiFenceLanguage ? { uiFenceLanguage: this.uiFenceLanguage } : {}),
401
408
  ...(this.uiQueries ? { ui: { schemas: this.uiQueries.schemas, observe: (item, final) => this.uiQueries!.observe(session, item, final) } } : {}),
402
409
  items: stored?.items ?? [],
403
410
  preambles: stored?.preambles ?? [],
411
+ uiAccess: stored?.uiAccess ?? {},
404
412
  provider,
405
413
  command: this.commands[kind] ?? provider.command,
406
414
  ...(this.spawn ? { spawn: this.spawn } : {}),
@@ -735,7 +743,10 @@ export class ChatCore {
735
743
  return this.uiQueries.query(this.require(payload.chatId), payload);
736
744
  }
737
745
 
738
- async choose(payload: ChatUiChoicePayload): Promise<{ queued: boolean; turnId: string }> {
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
+ }
739
750
  const session = this.require(payload.chatId);
740
751
  const queries = await this.uiQueries?.choiceValues(session, payload);
741
752
  const result = session.choose(payload, queries);
@@ -1022,7 +1033,15 @@ export class ChatCore {
1022
1033
  const log = this.logs.get(chatId);
1023
1034
  // Not folded here: an older write still in flight may land after this one, and the log is what covers for it.
1024
1035
  try {
1025
- 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
+ );
1026
1045
  } catch (e) {
1027
1046
  console.error(`Chat record for ${chatId} failed:`, errorText(e));
1028
1047
  }
@@ -1181,7 +1200,8 @@ export class ChatCore {
1181
1200
  // A delta held back is already in the thread, so it gets its seq before the snapshot says where it ends.
1182
1201
  this.coalescers.get(chatId)?.flush();
1183
1202
  const extras = this.recordExtras(chatId);
1184
- const unlogged = JSON.stringify([session.preambles, extras]);
1203
+ const uiAccess = session.uiAccessRecord;
1204
+ const unlogged = JSON.stringify([session.preambles, extras, uiAccess]);
1185
1205
  const folds = fold || log.size > COMPACT_ABOVE_BYTES || !log.onDisk;
1186
1206
  // The log already holds every change to the thread, so the whole record is rewritten only to fold
1187
1207
  // the log, on a chat's first write, or for what no event carries.
@@ -1190,7 +1210,7 @@ export class ChatCore {
1190
1210
  }
1191
1211
  const { info, items } = session.thread.snapshot();
1192
1212
  const at = { seq: log.seq, resetSeq: log.resetSeq };
1193
- 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));
1194
1214
  this.unlogged.set(chatId, unlogged);
1195
1215
  // A chat killed while the write was out has no log left to fold.
1196
1216
  if (this.logs.get(chatId) === log && folds) {