@haikit/server 0.16.0 → 0.18.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/runtime.js CHANGED
@@ -24,12 +24,32 @@ const WOKEN = "[The user has not said anything. The notifications above arrived
24
24
  /** Appended to the init tool's description in what the model sees. */
25
25
  const INIT_NOTE = "This ran automatically at the start of the conversation, and its result is already above. " +
26
26
  "Calling it again does not run it.";
27
+ /** How long `hai.update` waits for a turn to let the conversation go, by default. */
28
+ const UPDATE_TIMEOUT_MS = 30_000;
29
+ /** How many conversations `hai.update` remembers its surfaces' handles for. */
30
+ const UPDATE_NAMES_KEPT = 10_000;
31
+ /** The first wait before `hai.update` tries a busy conversation again, and the longest. */
32
+ const UPDATE_RETRY_MS = 100;
33
+ const UPDATE_RETRY_MAX_MS = 2_000;
27
34
  export class Hai {
28
35
  config;
29
36
  surfaces = new Map();
30
37
  tools = new Map();
31
38
  notices = new Map();
32
39
  maxWakes;
40
+ /** The derived `query_ui` tool, which answers about superseded surfaces with a note. */
41
+ queryUi;
42
+ /** Handles each conversation has a revision of in flight, so two calls can't revise one at once. */
43
+ revising = new WeakMap();
44
+ /** `hai.update` calls by conversation and surface: one writing, and the newest waiting behind it. */
45
+ updating = new Map();
46
+ /**
47
+ * The surfaces `hai.update` has revised, by conversation: each one's first
48
+ * handle and its latest, both mapped to the first, so calls naming either
49
+ * share a queue. Kept for the most recent conversations only; one let go of
50
+ * costs coalescing, never correctness, since every write revises the latest.
51
+ */
52
+ surfaceNames = new Map();
33
53
  /** What the model gets as its system prompt: the app's, then the scope. */
34
54
  system;
35
55
  seq = 0;
@@ -60,6 +80,7 @@ export class Hai {
60
80
  // that actually exist, and it always exists.
61
81
  const queryUi = this.buildQueryUiTool();
62
82
  this.tools.set(queryUi.name, queryUi);
83
+ this.queryUi = queryUi;
63
84
  if (config.init) {
64
85
  if (this.tools.has(config.init.name)) {
65
86
  throw new Error(`init tool "${config.init.name}" has the same name as another tool`);
@@ -67,13 +88,19 @@ export class Hai {
67
88
  this.tools.set(config.init.name, config.init);
68
89
  }
69
90
  }
70
- /** Whether this app sends notices, so the browser should open the events stream. */
91
+ /**
92
+ * Whether this app sends notices, `hai.update`'s included, so the browser
93
+ * should open the events stream.
94
+ */
71
95
  get sendsNotices() {
72
- return this.notices.size > 0;
96
+ return this.notices.size > 0 || this.config.updates === true;
73
97
  }
74
- /** Whether any of its notices are `wake` notices, which the events route starts turns for. */
98
+ /**
99
+ * Whether any of its notices may be `wake` notices, which the events route
100
+ * starts turns for: a registered one, or an update sent with `wake`.
101
+ */
75
102
  get wakes() {
76
- return [...this.notices.values()].some((n) => n.notice.kind === "wake");
103
+ return this.config.updates === true || [...this.notices.values()].some((n) => n.notice.kind === "wake");
77
104
  }
78
105
  /**
79
106
  * Send a notice to a conversation: its payload to the browser now, over the
@@ -118,6 +145,232 @@ export class Hai {
118
145
  });
119
146
  return { seq: record.seq };
120
147
  };
148
+ /**
149
+ * Revise a surface from outside any turn: a webhook, a job, a price feed.
150
+ * The revision is stored and attached as `ctx.update` does, under the
151
+ * conversation's lease, so a click can never act on props the server doesn't
152
+ * hold. Then an *update notice* tells both sides: the events stream sends
153
+ * the browser the revision, which it swaps into the component on screen,
154
+ * and the model hears which handle replaced which, with the new digest, in
155
+ * the next user message (or at once, with `wake`).
156
+ *
157
+ * `handle` may be any handle the surface has had: the revision replaces the
158
+ * latest. It throws if that one was answered, if the conversation is out of
159
+ * date, and, after waiting `timeoutMs` for a turn to let the conversation
160
+ * go, with `ConversationBusy`. A question waiting on the surface waits on
161
+ * the revision.
162
+ *
163
+ * One write per surface is in flight at a time. A call made meanwhile
164
+ * replaces any still waiting behind it, and every call it replaced resolves
165
+ * to the handle it writes; a wake any of them asked for still wakes.
166
+ */
167
+ update = (conversationId, surface, handle, props, options = {}) => {
168
+ const impl = surface;
169
+ const name = impl?.surface?.name;
170
+ if (this.surfaces.get(name) !== impl) {
171
+ return Promise.reject(new Error(`surface "${name}" is not registered: list it in createHai({ surfaces })`));
172
+ }
173
+ // Required even when notices open the events stream: it is also what has
174
+ // the events route start the turn an update sent with `wake` asks for.
175
+ if (this.config.updates !== true) {
176
+ return Promise.reject(new Error("hai.update is off: set createHai({ updates: true })"));
177
+ }
178
+ const { timeoutMs = UPDATE_TIMEOUT_MS, model, wake } = options;
179
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs < 0) {
180
+ return Promise.reject(new RangeError(`timeoutMs must be a finite number of milliseconds, zero or more (got ${String(timeoutMs)})`));
181
+ }
182
+ if (model !== undefined && typeof model !== "string")
183
+ return Promise.reject(new TypeError("model must be a string"));
184
+ let parsed;
185
+ try {
186
+ // Validated before anything waits: props may come from outside this process.
187
+ parsed = impl.surface.props.parse(props);
188
+ }
189
+ catch (err) {
190
+ return Promise.reject(err);
191
+ }
192
+ return new Promise((resolve, reject) => {
193
+ // the surface's first handle, when it is one `hai.update` has revised here
194
+ const first = this.surfaceNames.get(conversationId)?.get(handle) ?? handle;
195
+ const key = `${conversationId}\u0000${first}`;
196
+ const waiter = { resolve, reject };
197
+ const queue = this.updating.get(key);
198
+ if (queue) {
199
+ const behind = queue.next;
200
+ queue.next = {
201
+ props: parsed,
202
+ model,
203
+ wake: wake === true || behind?.wake === true,
204
+ timeoutMs,
205
+ waiters: [...(behind?.waiters ?? []), waiter],
206
+ };
207
+ return;
208
+ }
209
+ const fresh = { next: { props: parsed, model, wake: wake === true, timeoutMs, waiters: [waiter] } };
210
+ this.updating.set(key, fresh);
211
+ void (async () => {
212
+ for (let job = fresh.next; job; job = fresh.next) {
213
+ fresh.next = null;
214
+ try {
215
+ const { replaced, ...written } = await this.revise(conversationId, impl, handle, job);
216
+ this.name(conversationId, first, replaced, written.handle);
217
+ for (const w of job.waiters)
218
+ w.resolve(written);
219
+ }
220
+ catch (err) {
221
+ for (const w of job.waiters)
222
+ w.reject(err);
223
+ }
224
+ }
225
+ this.updating.delete(key);
226
+ })();
227
+ });
228
+ };
229
+ /** Record that `first`'s surface went from `replaced` to `latest`, for the next call's queue. */
230
+ name(conversationId, first, replaced, latest) {
231
+ const names = this.surfaceNames.get(conversationId) ?? new Map();
232
+ // only the first and the latest are kept: what a caller is likely to hold
233
+ if (replaced !== first)
234
+ names.delete(replaced);
235
+ names.set(first, first).set(latest, first);
236
+ // most recently revised last, so the oldest conversation goes first
237
+ this.surfaceNames.delete(conversationId);
238
+ this.surfaceNames.set(conversationId, names);
239
+ if (this.surfaceNames.size > UPDATE_NAMES_KEPT)
240
+ this.surfaceNames.delete(this.surfaceNames.keys().next().value);
241
+ }
242
+ /**
243
+ * Append the update notice this conversation owes, if any, before anything
244
+ * else is done with it: a revision whose own attempt to append it failed.
245
+ * One a holder appended before its save failed is not appended again.
246
+ */
247
+ async announce(conversation) {
248
+ const owed = conversation.announcing;
249
+ if (!owed)
250
+ return;
251
+ // The model hasn't taken it in either way: that commits with clearing this.
252
+ const unread = (await this.config.store.getNotices(conversation.id, conversation.noticedThrough ?? 0)) ?? [];
253
+ if (!unread.some((n) => n.handle === owed.handle && n.replaces === owed.replaces))
254
+ await this.config.store.putNotice(owed);
255
+ conversation.announcing = null;
256
+ }
257
+ /** One `hai.update` write: take the lease, revise, let the conversation go. */
258
+ async revise(conversationId, impl, handle, job) {
259
+ const { store } = this.config;
260
+ // Loading an unknown id would start a new conversation, so make sure it
261
+ // exists first, with the cheapest read there is.
262
+ if ((await store.getNotices(conversationId, Number.MAX_SAFE_INTEGER, 1)) === null) {
263
+ throw new Error(`no conversation ${conversationId}`);
264
+ }
265
+ // A turn in progress lands first: wait for it, backing off, up to the timeout.
266
+ const deadline = Date.now() + job.timeoutMs;
267
+ let conversation = null;
268
+ for (let wait = UPDATE_RETRY_MS; !conversation; wait = Math.min(wait * 2, UPDATE_RETRY_MAX_MS)) {
269
+ try {
270
+ conversation = await store.loadConversation(conversationId);
271
+ }
272
+ catch (err) {
273
+ const left = deadline - Date.now();
274
+ if (!isConversationBusy(err) || left <= 0)
275
+ throw err;
276
+ await new Promise((r) => setTimeout(r, Math.min(wait, left)));
277
+ // not another try once the timeout has passed, even if the turn has gone
278
+ if (Date.now() >= deadline)
279
+ throw err;
280
+ }
281
+ }
282
+ let failure = null;
283
+ let written = null;
284
+ try {
285
+ // one an earlier update still owes goes first, in order
286
+ await this.announce(conversation);
287
+ written = await this.reviseHeld(conversation, impl, handle, job);
288
+ }
289
+ catch (err) {
290
+ failure = err;
291
+ }
292
+ // As a route does at the end of a request: let the conversation go, with
293
+ // the token kept so the save can prove this is still the rightful holder.
294
+ conversation.leaseUntil = null;
295
+ if (!isStaleLease(failure)) {
296
+ try {
297
+ await store.saveConversation(conversation);
298
+ }
299
+ catch (err) {
300
+ failure ??= err;
301
+ }
302
+ }
303
+ if (failure)
304
+ throw failure;
305
+ return written;
306
+ }
307
+ async reviseHeld(conversation, impl, handle, job) {
308
+ const { store } = this.config;
309
+ if (!conversation.handles.includes(handle))
310
+ throw new Error(`no surface ${handle} in conversation ${conversation.id}`);
311
+ // the surface as it is now: updates queued one behind another each revise the last
312
+ const current = currentHandle(conversation, handle);
313
+ if (conversation.frozen.includes(current))
314
+ throw new Error(`${current} was answered, so it can't be revised`);
315
+ const record = await store.getPayload(current, conversation.id);
316
+ if (!record)
317
+ throw new Error(`no surface ${current} in conversation ${conversation.id}`);
318
+ if (record.component !== impl.surface.name)
319
+ throw new Error(`${current} is a ${record.component}, not a ${impl.surface.name}`);
320
+ // After the reads, right before the write, as for `ctx.update`: a revision
321
+ // must not bring an out-of-date conversation back by superseding the
322
+ // surface that closed it.
323
+ if (await this.refuseIfExpired(conversation, () => { })) {
324
+ throw new Error("this conversation is out of date; nothing more can be revised in it");
325
+ }
326
+ const next = await store.putPayload({
327
+ conversationId: conversation.id,
328
+ component: impl.surface.name,
329
+ version: impl.surface.version,
330
+ props: job.props,
331
+ mode: record.mode,
332
+ staleAfterMs: windowOf(impl),
333
+ }, conversation.leaseToken);
334
+ const digest = impl.impl.digest(job.props, { handle: next });
335
+ // …and once more as it takes effect: the write and the digest take a while too
336
+ const window = strictest(record.staleAfterMs, windowOf(impl));
337
+ if (window !== "never" && !(Number.isFinite(record.createdAt) && window !== undefined && record.createdAt + window > Date.now())) {
338
+ throw new Error("this conversation went out of date while the revision was made; it was not applied");
339
+ }
340
+ conversation.handles.push(next);
341
+ (conversation.superseded ??= {})[current] = next;
342
+ // A question waiting on the surface waits on its revision. Its digest stays
343
+ // as the model was shown it: the update's text rides in the same message as
344
+ // the answer, after it, saying what changed.
345
+ if (conversation.pending?.handle === current)
346
+ conversation.pending = { ...conversation.pending, handle: next };
347
+ // The notice it owes commits with it, in one fenced save, so the revision
348
+ // and its notice can't come apart. Saved before the notice is appended,
349
+ // which proves the lease is still held: a notice for a revision the history
350
+ // lost would show the browser a surface every click on is refused.
351
+ const notice = {
352
+ conversationId: conversation.id,
353
+ name: impl.surface.name,
354
+ version: impl.surface.version,
355
+ payload: null,
356
+ model: `${job.model ? `${job.model} ` : ""}${current} was replaced by ${next}; its earlier digest is out of date. Now: ${digest}`,
357
+ handle: next,
358
+ replaces: current,
359
+ ...(job.wake ? { kind: "wake" } : {}),
360
+ };
361
+ conversation.announcing = notice;
362
+ await store.saveConversation(conversation);
363
+ try {
364
+ await store.putNotice(notice);
365
+ }
366
+ catch (err) {
367
+ // Still owed, and appended by whoever holds the conversation next, before
368
+ // anything else: the model hears of the revision before it runs again.
369
+ throw new Error(`${next} is saved, but its update notice could not be stored yet: it goes out with the conversation's next request (${err.message})`);
370
+ }
371
+ conversation.announcing = null;
372
+ return { handle: next, replaced: current };
373
+ }
121
374
  /**
122
375
  * Start a turn for the `wake` notices this conversation hasn't taken in, if
123
376
  * it can take one now. The events route calls this, and streams the turn
@@ -183,6 +436,7 @@ export class Hai {
183
436
  return outcome;
184
437
  }
185
438
  async wakeTurn(conversation, emit) {
439
+ await this.announce(conversation);
186
440
  // a load of an unknown id starts a new conversation, which has not begun either
187
441
  if (!conversation.messages.length || conversation.status === "awaiting")
188
442
  return "idle";
@@ -275,11 +529,13 @@ export class Hai {
275
529
  */
276
530
  async unreadNotices(conversation) {
277
531
  const through = conversation.noticedThrough ?? 0;
278
- if (!this.notices.size)
532
+ if (!this.sendsNotices)
279
533
  return { blocks: [], through, wake: 0 };
280
534
  const fresh = (await this.config.store.getNotices(conversation.id, through)) ?? [];
281
535
  return {
282
- blocks: fresh.flatMap((n) => n.model === null ? [] : [{ type: "text", text: `[App notification: ${n.name}] ${n.model}` }]),
536
+ blocks: fresh.flatMap((n) => n.model === null
537
+ ? []
538
+ : [{ type: "text", text: n.replaces === undefined ? `[App notification: ${n.name}] ${n.model}` : `[UI update] ${n.model}` }]),
283
539
  through: fresh.at(-1)?.seq ?? through,
284
540
  // the newest unread wake notice, or 0
285
541
  wake: fresh.findLast((n) => n.kind === "wake")?.seq ?? 0,
@@ -359,6 +615,7 @@ export class Hai {
359
615
  }
360
616
  // ─────────────────────────────────────────────────── public API
361
617
  async send(conversation, text, emit) {
618
+ await this.announce(conversation);
362
619
  if (await this.refuseIfExpired(conversation, emit))
363
620
  return;
364
621
  // The first message is the one that starts the conversation.
@@ -408,6 +665,7 @@ export class Hai {
408
665
  async start(conversation, emit) {
409
666
  if (!this.config.init || conversation.messages.length > 0)
410
667
  return;
668
+ await this.announce(conversation);
411
669
  const unread = await this.unreadNotices(conversation);
412
670
  if (await this.runInit(conversation, withText(unread.blocks, STARTED), emit, unread.through))
413
671
  return; // parked on its surface
@@ -468,7 +726,12 @@ export class Hai {
468
726
  if (asked) {
469
727
  emit({ type: "block_update", id: toolBlockId, status: "awaiting", ms, result: asked.digest });
470
728
  conversation.status = "awaiting";
471
- conversation.pending = { toolUseId: call.id, handle: asked.handle, digest: asked.digest, results: [] };
729
+ conversation.pending = {
730
+ toolUseId: call.id,
731
+ handle: currentHandle(conversation, asked.handle),
732
+ digest: asked.digest,
733
+ results: [],
734
+ };
472
735
  emit({ type: "status", status: "awaiting" });
473
736
  await this.emitContext(conversation, emit);
474
737
  return true;
@@ -487,6 +750,7 @@ export class Hai {
487
750
  return false;
488
751
  }
489
752
  async interact(conversation, input, emit) {
753
+ await this.announce(conversation);
490
754
  if (await this.refuseIfExpired(conversation, emit))
491
755
  return;
492
756
  // The payload row carries a conversation id, but that alone is not enough.
@@ -496,6 +760,10 @@ export class Hai {
496
760
  // Membership of the surviving `handles` is what makes those orphans inert.
497
761
  if (!conversation.handles.includes(input.handle))
498
762
  throw new Error("unknown handle");
763
+ // Revised since the user saw it: what they clicked on is no longer there.
764
+ if (conversation.superseded?.[input.handle]) {
765
+ throw new Error(`superseded by ${currentHandle(conversation, input.handle)}`);
766
+ }
499
767
  if (conversation.frozen.includes(input.handle))
500
768
  throw new Error("component is frozen");
501
769
  const record = await this.config.store.getPayload(input.handle, conversation.id);
@@ -620,8 +888,9 @@ export class Hai {
620
888
  async refuseIfExpired(conversation, emit) {
621
889
  if (!conversation.handles.length)
622
890
  return false;
623
- const now = Date.now();
624
891
  const records = await this.config.store.getPayloads(conversation.handles, conversation.id);
892
+ // after the read, so a window that ran out while it was in flight counts
893
+ const now = Date.now();
625
894
  // the surface that went out of date first is the one that closed it
626
895
  let first = null;
627
896
  // getPayloads omits handles it cannot find, and the loop below only sees
@@ -631,6 +900,13 @@ export class Hai {
631
900
  first = { expiredAt: Number.NaN, age: Number.NaN, window: undefined };
632
901
  }
633
902
  for (const record of records) {
903
+ // A revised surface's window stops counting; its revision's counts. Safe
904
+ // because the model has heard the revision before it can act: in a turn
905
+ // as the tool's result, and from outside one in the message this
906
+ // request is about to record. (An answered surface can't be revised, so
907
+ // what the user picked from one still counts.)
908
+ if (conversation.superseded?.[record.handle])
909
+ continue;
634
910
  // a surface the code no longer registers has no current window at all,
635
911
  // which is not the same as one registered without a window ("never")
636
912
  const impl = this.surfaces.get(record.component);
@@ -712,7 +988,19 @@ export class Hai {
712
988
  if (parked) {
713
989
  // Hold the resolved siblings too — the API is all-or-nothing per batch.
714
990
  conversation.status = "awaiting";
715
- conversation.pending = { ...parked, results };
991
+ // At the question's latest revision: a later call in this reply may
992
+ // have revised it, and then its answer goes out under the revision's
993
+ // digest, not the original's.
994
+ // Only the question's part is replaced: anything the asking call said
995
+ // after it rides along as before.
996
+ const handle = currentHandle(conversation, parked.handle);
997
+ const asking = hop.asking;
998
+ let digest = parked.digest;
999
+ if (handle !== parked.handle && asking?.handle === handle && asking.digest !== undefined) {
1000
+ const asked = asking.question;
1001
+ digest = asked !== undefined && parked.digest.startsWith(asked) ? asking.digest + parked.digest.slice(asked.length) : asking.digest;
1002
+ }
1003
+ conversation.pending = { ...parked, handle, digest, results };
716
1004
  emit({ type: "status", status: "awaiting" });
717
1005
  await this.emitContext(conversation, emit);
718
1006
  return;
@@ -745,7 +1033,12 @@ export class Hai {
745
1033
  let asked = null;
746
1034
  // Store a surface and send it out: the payload to the browser, the digest
747
1035
  // back to the tool for the model.
748
- const show = async (impl, props, surfaceMode) => {
1036
+ const show = async (impl, props, surfaceMode,
1037
+ // the handle this revises in place, for `update`
1038
+ replaces,
1039
+ // last word before the surface is attached and sent: throwing leaves the
1040
+ // stored payload unreferenced, inert as an overtaken turn's
1041
+ beforeAttach) => {
749
1042
  // Validate before storing: props may originate outside this process.
750
1043
  const parsed = impl.surface.props.parse(props);
751
1044
  const window = windowOf(impl);
@@ -765,6 +1058,7 @@ export class Hai {
765
1058
  }, conversation.leaseToken);
766
1059
  // The split, in two lines. Digest -> model. Payload -> browser.
767
1060
  const digest = impl.impl.digest(parsed, { handle });
1061
+ beforeAttach?.();
768
1062
  // Recorded only once the digest is in hand. A surface that fails here is
769
1063
  // never shown, and a handle in the history counts toward its freshness,
770
1064
  // so it could otherwise close the conversation over something unseen.
@@ -781,6 +1075,7 @@ export class Hai {
781
1075
  ...(window === "never" ? {} : { staleAfterMs: window }),
782
1076
  // how long ago it was stored: the digest's time, and a replay's wait
783
1077
  ageMs: Date.now() - storing,
1078
+ ...(replaces === undefined ? {} : { replaces }),
784
1079
  });
785
1080
  emit({ type: "ui_props", handle, props: parsed });
786
1081
  return { model: digest, handle };
@@ -817,6 +1112,7 @@ export class Hai {
817
1112
  try {
818
1113
  const ret = await show(impl, props, "elicit");
819
1114
  claim.handle = ret.handle;
1115
+ claim.question = ret.model;
820
1116
  asked = { handle: ret.handle, digest: ret.model };
821
1117
  return ret;
822
1118
  }
@@ -829,6 +1125,64 @@ export class Hai {
829
1125
  settle();
830
1126
  }
831
1127
  };
1128
+ // Revise a surface this conversation shows: a new surface, with a new
1129
+ // handle, that supersedes the old one. Checked before anything is stored,
1130
+ // and claimed before the first await, so two calls can't both revise it.
1131
+ const update = async (impl, handle, props) => {
1132
+ if (!conversation.handles.includes(handle))
1133
+ throw new Error(`no surface ${handle} in this conversation`);
1134
+ if (conversation.frozen.includes(handle))
1135
+ throw new Error(`${handle} was answered, so it can't be revised`);
1136
+ const after = conversation.superseded?.[handle];
1137
+ if (after)
1138
+ throw new Error(`${handle} was revised already: it is ${currentHandle(conversation, handle)} now`);
1139
+ const revising = this.revising.get(conversation) ?? new Set();
1140
+ if (revising.has(handle))
1141
+ throw new Error(`${handle} is being revised by another call`);
1142
+ revising.add(handle);
1143
+ this.revising.set(conversation, revising);
1144
+ try {
1145
+ const record = await this.config.store.getPayload(handle, conversation.id);
1146
+ if (!record)
1147
+ throw new Error(`no surface ${handle} in this conversation`);
1148
+ if (record.component !== impl.surface.name) {
1149
+ throw new Error(`${handle} is a ${record.component}, not a ${impl.surface.name}`);
1150
+ }
1151
+ // Freshness again, last, right before the write: a long turn can
1152
+ // outlast a window that was open when it began, and so can the reads
1153
+ // above. A revision must not bring an out-of-date conversation back
1154
+ // by superseding the surface that closed it.
1155
+ if (await this.refuseIfExpired(conversation, () => { })) {
1156
+ throw new Error("this conversation is out of date; nothing more can be revised in it");
1157
+ }
1158
+ // …and once more as it takes effect: the write and the digest above can
1159
+ // take a while too, and the browser closes the conversation at the old
1160
+ // surface's deadline whether or not a revision is on its way.
1161
+ const window = strictest(record.staleAfterMs, windowOf(impl));
1162
+ const stillFresh = () => {
1163
+ if (window === "never")
1164
+ return;
1165
+ if (!(Number.isFinite(record.createdAt) && window !== undefined && record.createdAt + window > Date.now())) {
1166
+ throw new Error("this conversation went out of date while the revision was made; it was not applied");
1167
+ }
1168
+ };
1169
+ const ret = await show(impl, props, record.mode, handle, stillFresh);
1170
+ (conversation.superseded ??= {})[handle] = ret.handle;
1171
+ // a question waiting on the old surface waits on the new one; when this
1172
+ // call asked it, its result starts with the revision's digest now, so
1173
+ // that is the part a later call's revision replaces
1174
+ const mine = asked?.handle === handle;
1175
+ if (mine)
1176
+ asked = { handle: ret.handle, digest: ret.model };
1177
+ if (hop.asking?.handle === handle) {
1178
+ Object.assign(hop.asking, { handle: ret.handle, digest: ret.model }, mine ? { question: ret.model } : {});
1179
+ }
1180
+ return ret;
1181
+ }
1182
+ finally {
1183
+ revising.delete(handle);
1184
+ }
1185
+ };
832
1186
  // Renders the tool has started. One can still be storing when the tool
833
1187
  // returns or throws: a render it didn't await, or one a rejection in a
834
1188
  // Promise.all overtook. They all finish before this call is decided. Each is
@@ -883,6 +1237,16 @@ export class Hai {
883
1237
  }));
884
1238
  return shown;
885
1239
  }),
1240
+ update: ((impl, handle, props) => {
1241
+ if (done)
1242
+ return Promise.resolve({ model: "Not revised: the tool had already finished." });
1243
+ const revised = update(impl, handle, props);
1244
+ rendering.push(revised.then(() => { }, (err) => {
1245
+ if (isStaleLease(err))
1246
+ lost ??= err;
1247
+ }));
1248
+ return revised;
1249
+ }),
886
1250
  };
887
1251
  let input;
888
1252
  try {
@@ -914,7 +1278,13 @@ export class Hai {
914
1278
  if (lost)
915
1279
  throw lost;
916
1280
  if (outcome.ok) {
917
- const { ret } = outcome;
1281
+ let { ret } = outcome;
1282
+ // A query on a surface since revised answers from what it was, which is
1283
+ // what the model asked about; it is told the surface has moved on.
1284
+ const queried = input?.handle;
1285
+ if (tool === this.queryUi && typeof queried === "string" && conversation.superseded?.[queried]) {
1286
+ ret = { ...ret, model: `${ret.model}\n(${queried} has since been revised: it is ${currentHandle(conversation, queried)} now.)` };
1287
+ }
918
1288
  // The answer goes to the model under the digest of the question this call
919
1289
  // showed. Anything else the tool returned rides along after it, such as a
920
1290
  // render it was refused, so the model still hears it.
@@ -989,6 +1359,16 @@ function strictest(recorded, current) {
989
1359
  return finite.length ? Math.min(...finite) : "never";
990
1360
  }
991
1361
  const newId = () => globalThis.crypto.randomUUID().replaceAll("-", "").slice(0, 16);
1362
+ /** The latest revision of a surface: `handle` itself, or what it was superseded by, all the way along. */
1363
+ function currentHandle(conversation, handle) {
1364
+ const seen = new Set();
1365
+ let at = handle;
1366
+ while (conversation.superseded?.[at] && !seen.has(at)) {
1367
+ seen.add(at);
1368
+ at = conversation.superseded[at];
1369
+ }
1370
+ return at;
1371
+ }
992
1372
  /**
993
1373
  * A user message: `text` after any notice blocks, or `text` alone when there
994
1374
  * are none — so a conversation with no notices keeps exactly the history it