@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/README.md +85 -0
- package/dist/routes.js +57 -1
- package/dist/routes.js.map +1 -1
- package/dist/runtime.d.ts +60 -3
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +391 -11
- package/dist/runtime.js.map +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +2 -0
- package/dist/store.js.map +1 -1
- package/package.json +2 -2
- package/src/routes.ts +53 -1
- package/src/runtime.ts +424 -12
- package/src/store.ts +2 -0
package/src/runtime.ts
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
type Tool,
|
|
15
15
|
type ToolCtx,
|
|
16
16
|
type ToolReturn,
|
|
17
|
+
type Update,
|
|
18
|
+
type UpdateOptions,
|
|
17
19
|
type WireEvent,
|
|
18
20
|
} from "@haikit/core";
|
|
19
21
|
|
|
@@ -65,6 +67,13 @@ export interface HaiConfig {
|
|
|
65
67
|
* browser still shows it at once.
|
|
66
68
|
*/
|
|
67
69
|
maxWakes?: { count: number; perMs: number };
|
|
70
|
+
/**
|
|
71
|
+
* Set when app code revises surfaces with `hai.update`, which refuses
|
|
72
|
+
* without it. The browser then opens the events stream, which is how a
|
|
73
|
+
* revision reaches it, and the events route starts the turns an update sent
|
|
74
|
+
* with `wake` asks for.
|
|
75
|
+
*/
|
|
76
|
+
updates?: boolean;
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
/**
|
|
@@ -111,9 +120,37 @@ interface Hop {
|
|
|
111
120
|
* so two renders at once cannot both ask; the handle is known once it shows,
|
|
112
121
|
* and `settled` resolves when its render has shown it or failed.
|
|
113
122
|
*/
|
|
114
|
-
asking: {
|
|
123
|
+
asking: {
|
|
124
|
+
handle: string | null;
|
|
125
|
+
settled: Promise<void>;
|
|
126
|
+
/** The digest it was asked under, and its latest, once a later call in the reply has revised it. */
|
|
127
|
+
question?: string;
|
|
128
|
+
digest?: string;
|
|
129
|
+
} | null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A `hai.update` write, and every call it answers. */
|
|
133
|
+
interface UpdateJob {
|
|
134
|
+
props: unknown;
|
|
135
|
+
model: string | undefined;
|
|
136
|
+
wake: boolean;
|
|
137
|
+
timeoutMs: number;
|
|
138
|
+
waiters: { resolve: (written: { handle: string }) => void; reject: (err: unknown) => void }[];
|
|
115
139
|
}
|
|
116
140
|
|
|
141
|
+
/** The `hai.update` calls for one surface: the next to write, once the one writing is done. */
|
|
142
|
+
interface UpdateQueue {
|
|
143
|
+
next: UpdateJob | null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** How long `hai.update` waits for a turn to let the conversation go, by default. */
|
|
147
|
+
const UPDATE_TIMEOUT_MS = 30_000;
|
|
148
|
+
/** How many conversations `hai.update` remembers its surfaces' handles for. */
|
|
149
|
+
const UPDATE_NAMES_KEPT = 10_000;
|
|
150
|
+
/** The first wait before `hai.update` tries a busy conversation again, and the longest. */
|
|
151
|
+
const UPDATE_RETRY_MS = 100;
|
|
152
|
+
const UPDATE_RETRY_MAX_MS = 2_000;
|
|
153
|
+
|
|
117
154
|
/** An elicit surface a tool call showed, and the digest its answer goes out under. */
|
|
118
155
|
interface Question {
|
|
119
156
|
handle: string;
|
|
@@ -126,6 +163,19 @@ export class Hai {
|
|
|
126
163
|
private readonly tools = new Map<string, Tool>();
|
|
127
164
|
private readonly notices = new Map<string, AnyNoticeImpl>();
|
|
128
165
|
private readonly maxWakes: { count: number; perMs: number };
|
|
166
|
+
/** The derived `query_ui` tool, which answers about superseded surfaces with a note. */
|
|
167
|
+
private readonly queryUi: Tool;
|
|
168
|
+
/** Handles each conversation has a revision of in flight, so two calls can't revise one at once. */
|
|
169
|
+
private readonly revising = new WeakMap<Conversation, Set<string>>();
|
|
170
|
+
/** `hai.update` calls by conversation and surface: one writing, and the newest waiting behind it. */
|
|
171
|
+
private readonly updating = new Map<string, UpdateQueue>();
|
|
172
|
+
/**
|
|
173
|
+
* The surfaces `hai.update` has revised, by conversation: each one's first
|
|
174
|
+
* handle and its latest, both mapped to the first, so calls naming either
|
|
175
|
+
* share a queue. Kept for the most recent conversations only; one let go of
|
|
176
|
+
* costs coalescing, never correctness, since every write revises the latest.
|
|
177
|
+
*/
|
|
178
|
+
private readonly surfaceNames = new Map<string, Map<string, string>>();
|
|
129
179
|
/** What the model gets as its system prompt: the app's, then the scope. */
|
|
130
180
|
readonly system: string;
|
|
131
181
|
private seq = 0;
|
|
@@ -157,6 +207,7 @@ export class Hai {
|
|
|
157
207
|
// that actually exist, and it always exists.
|
|
158
208
|
const queryUi = this.buildQueryUiTool();
|
|
159
209
|
this.tools.set(queryUi.name, queryUi);
|
|
210
|
+
this.queryUi = queryUi;
|
|
160
211
|
|
|
161
212
|
if (config.init) {
|
|
162
213
|
if (this.tools.has(config.init.name)) {
|
|
@@ -166,14 +217,20 @@ export class Hai {
|
|
|
166
217
|
}
|
|
167
218
|
}
|
|
168
219
|
|
|
169
|
-
/**
|
|
220
|
+
/**
|
|
221
|
+
* Whether this app sends notices, `hai.update`'s included, so the browser
|
|
222
|
+
* should open the events stream.
|
|
223
|
+
*/
|
|
170
224
|
get sendsNotices(): boolean {
|
|
171
|
-
return this.notices.size > 0;
|
|
225
|
+
return this.notices.size > 0 || this.config.updates === true;
|
|
172
226
|
}
|
|
173
227
|
|
|
174
|
-
/**
|
|
228
|
+
/**
|
|
229
|
+
* Whether any of its notices may be `wake` notices, which the events route
|
|
230
|
+
* starts turns for: a registered one, or an update sent with `wake`.
|
|
231
|
+
*/
|
|
175
232
|
get wakes(): boolean {
|
|
176
|
-
return [...this.notices.values()].some((n) => n.notice.kind === "wake");
|
|
233
|
+
return this.config.updates === true || [...this.notices.values()].some((n) => n.notice.kind === "wake");
|
|
177
234
|
}
|
|
178
235
|
|
|
179
236
|
/**
|
|
@@ -220,6 +277,233 @@ export class Hai {
|
|
|
220
277
|
return { seq: record.seq };
|
|
221
278
|
};
|
|
222
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Revise a surface from outside any turn: a webhook, a job, a price feed.
|
|
282
|
+
* The revision is stored and attached as `ctx.update` does, under the
|
|
283
|
+
* conversation's lease, so a click can never act on props the server doesn't
|
|
284
|
+
* hold. Then an *update notice* tells both sides: the events stream sends
|
|
285
|
+
* the browser the revision, which it swaps into the component on screen,
|
|
286
|
+
* and the model hears which handle replaced which, with the new digest, in
|
|
287
|
+
* the next user message (or at once, with `wake`).
|
|
288
|
+
*
|
|
289
|
+
* `handle` may be any handle the surface has had: the revision replaces the
|
|
290
|
+
* latest. It throws if that one was answered, if the conversation is out of
|
|
291
|
+
* date, and, after waiting `timeoutMs` for a turn to let the conversation
|
|
292
|
+
* go, with `ConversationBusy`. A question waiting on the surface waits on
|
|
293
|
+
* the revision.
|
|
294
|
+
*
|
|
295
|
+
* One write per surface is in flight at a time. A call made meanwhile
|
|
296
|
+
* replaces any still waiting behind it, and every call it replaced resolves
|
|
297
|
+
* to the handle it writes; a wake any of them asked for still wakes.
|
|
298
|
+
*/
|
|
299
|
+
readonly update: Update = (conversationId, surface, handle, props, options = {}) => {
|
|
300
|
+
const impl = surface as AnySurfaceImpl;
|
|
301
|
+
const name = impl?.surface?.name;
|
|
302
|
+
if (this.surfaces.get(name) !== impl) {
|
|
303
|
+
return Promise.reject(new Error(`surface "${name}" is not registered: list it in createHai({ surfaces })`));
|
|
304
|
+
}
|
|
305
|
+
// Required even when notices open the events stream: it is also what has
|
|
306
|
+
// the events route start the turn an update sent with `wake` asks for.
|
|
307
|
+
if (this.config.updates !== true) {
|
|
308
|
+
return Promise.reject(new Error("hai.update is off: set createHai({ updates: true })"));
|
|
309
|
+
}
|
|
310
|
+
const { timeoutMs = UPDATE_TIMEOUT_MS, model, wake } = options as UpdateOptions;
|
|
311
|
+
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
312
|
+
return Promise.reject(new RangeError(`timeoutMs must be a finite number of milliseconds, zero or more (got ${String(timeoutMs)})`));
|
|
313
|
+
}
|
|
314
|
+
if (model !== undefined && typeof model !== "string") return Promise.reject(new TypeError("model must be a string"));
|
|
315
|
+
let parsed: unknown;
|
|
316
|
+
try {
|
|
317
|
+
// Validated before anything waits: props may come from outside this process.
|
|
318
|
+
parsed = impl.surface.props.parse(props);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
return Promise.reject(err);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return new Promise((resolve, reject) => {
|
|
324
|
+
// the surface's first handle, when it is one `hai.update` has revised here
|
|
325
|
+
const first = this.surfaceNames.get(conversationId)?.get(handle) ?? handle;
|
|
326
|
+
const key = `${conversationId}\u0000${first}`;
|
|
327
|
+
const waiter = { resolve, reject };
|
|
328
|
+
const queue = this.updating.get(key);
|
|
329
|
+
if (queue) {
|
|
330
|
+
const behind = queue.next;
|
|
331
|
+
queue.next = {
|
|
332
|
+
props: parsed,
|
|
333
|
+
model,
|
|
334
|
+
wake: wake === true || behind?.wake === true,
|
|
335
|
+
timeoutMs,
|
|
336
|
+
waiters: [...(behind?.waiters ?? []), waiter],
|
|
337
|
+
};
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const fresh: UpdateQueue = { next: { props: parsed, model, wake: wake === true, timeoutMs, waiters: [waiter] } };
|
|
341
|
+
this.updating.set(key, fresh);
|
|
342
|
+
void (async () => {
|
|
343
|
+
for (let job = fresh.next; job; job = fresh.next) {
|
|
344
|
+
fresh.next = null;
|
|
345
|
+
try {
|
|
346
|
+
const { replaced, ...written } = await this.revise(conversationId, impl, handle, job);
|
|
347
|
+
this.name(conversationId, first, replaced, written.handle);
|
|
348
|
+
for (const w of job.waiters) w.resolve(written);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
for (const w of job.waiters) w.reject(err);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
this.updating.delete(key);
|
|
354
|
+
})();
|
|
355
|
+
});
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/** Record that `first`'s surface went from `replaced` to `latest`, for the next call's queue. */
|
|
359
|
+
private name(conversationId: string, first: string, replaced: string, latest: string) {
|
|
360
|
+
const names = this.surfaceNames.get(conversationId) ?? new Map<string, string>();
|
|
361
|
+
// only the first and the latest are kept: what a caller is likely to hold
|
|
362
|
+
if (replaced !== first) names.delete(replaced);
|
|
363
|
+
names.set(first, first).set(latest, first);
|
|
364
|
+
// most recently revised last, so the oldest conversation goes first
|
|
365
|
+
this.surfaceNames.delete(conversationId);
|
|
366
|
+
this.surfaceNames.set(conversationId, names);
|
|
367
|
+
if (this.surfaceNames.size > UPDATE_NAMES_KEPT) this.surfaceNames.delete(this.surfaceNames.keys().next().value!);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Append the update notice this conversation owes, if any, before anything
|
|
372
|
+
* else is done with it: a revision whose own attempt to append it failed.
|
|
373
|
+
* One a holder appended before its save failed is not appended again.
|
|
374
|
+
*/
|
|
375
|
+
private async announce(conversation: Conversation): Promise<void> {
|
|
376
|
+
const owed = conversation.announcing;
|
|
377
|
+
if (!owed) return;
|
|
378
|
+
// The model hasn't taken it in either way: that commits with clearing this.
|
|
379
|
+
const unread = (await this.config.store.getNotices(conversation.id, conversation.noticedThrough ?? 0)) ?? [];
|
|
380
|
+
if (!unread.some((n) => n.handle === owed.handle && n.replaces === owed.replaces)) await this.config.store.putNotice(owed);
|
|
381
|
+
conversation.announcing = null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** One `hai.update` write: take the lease, revise, let the conversation go. */
|
|
385
|
+
private async revise(
|
|
386
|
+
conversationId: string,
|
|
387
|
+
impl: AnySurfaceImpl,
|
|
388
|
+
handle: string,
|
|
389
|
+
job: UpdateJob,
|
|
390
|
+
): Promise<{ handle: string; replaced: string }> {
|
|
391
|
+
const { store } = this.config;
|
|
392
|
+
// Loading an unknown id would start a new conversation, so make sure it
|
|
393
|
+
// exists first, with the cheapest read there is.
|
|
394
|
+
if ((await store.getNotices(conversationId, Number.MAX_SAFE_INTEGER, 1)) === null) {
|
|
395
|
+
throw new Error(`no conversation ${conversationId}`);
|
|
396
|
+
}
|
|
397
|
+
// A turn in progress lands first: wait for it, backing off, up to the timeout.
|
|
398
|
+
const deadline = Date.now() + job.timeoutMs;
|
|
399
|
+
let conversation: Conversation | null = null;
|
|
400
|
+
for (let wait = UPDATE_RETRY_MS; !conversation; wait = Math.min(wait * 2, UPDATE_RETRY_MAX_MS)) {
|
|
401
|
+
try {
|
|
402
|
+
conversation = await store.loadConversation(conversationId);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
const left = deadline - Date.now();
|
|
405
|
+
if (!isConversationBusy(err) || left <= 0) throw err;
|
|
406
|
+
await new Promise((r) => setTimeout(r, Math.min(wait, left)));
|
|
407
|
+
// not another try once the timeout has passed, even if the turn has gone
|
|
408
|
+
if (Date.now() >= deadline) throw err;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let failure: unknown = null;
|
|
413
|
+
let written: { handle: string; replaced: string } | null = null;
|
|
414
|
+
try {
|
|
415
|
+
// one an earlier update still owes goes first, in order
|
|
416
|
+
await this.announce(conversation);
|
|
417
|
+
written = await this.reviseHeld(conversation, impl, handle, job);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
failure = err;
|
|
420
|
+
}
|
|
421
|
+
// As a route does at the end of a request: let the conversation go, with
|
|
422
|
+
// the token kept so the save can prove this is still the rightful holder.
|
|
423
|
+
conversation.leaseUntil = null;
|
|
424
|
+
if (!isStaleLease(failure)) {
|
|
425
|
+
try {
|
|
426
|
+
await store.saveConversation(conversation);
|
|
427
|
+
} catch (err) {
|
|
428
|
+
failure ??= err;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (failure) throw failure;
|
|
432
|
+
return written!;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private async reviseHeld(
|
|
436
|
+
conversation: Conversation,
|
|
437
|
+
impl: AnySurfaceImpl,
|
|
438
|
+
handle: string,
|
|
439
|
+
job: UpdateJob,
|
|
440
|
+
): Promise<{ handle: string; replaced: string }> {
|
|
441
|
+
const { store } = this.config;
|
|
442
|
+
if (!conversation.handles.includes(handle)) throw new Error(`no surface ${handle} in conversation ${conversation.id}`);
|
|
443
|
+
// the surface as it is now: updates queued one behind another each revise the last
|
|
444
|
+
const current = currentHandle(conversation, handle);
|
|
445
|
+
if (conversation.frozen.includes(current)) throw new Error(`${current} was answered, so it can't be revised`);
|
|
446
|
+
const record = await store.getPayload(current, conversation.id);
|
|
447
|
+
if (!record) throw new Error(`no surface ${current} in conversation ${conversation.id}`);
|
|
448
|
+
if (record.component !== impl.surface.name) throw new Error(`${current} is a ${record.component}, not a ${impl.surface.name}`);
|
|
449
|
+
// After the reads, right before the write, as for `ctx.update`: a revision
|
|
450
|
+
// must not bring an out-of-date conversation back by superseding the
|
|
451
|
+
// surface that closed it.
|
|
452
|
+
if (await this.refuseIfExpired(conversation, () => {})) {
|
|
453
|
+
throw new Error("this conversation is out of date; nothing more can be revised in it");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const next = await store.putPayload(
|
|
457
|
+
{
|
|
458
|
+
conversationId: conversation.id,
|
|
459
|
+
component: impl.surface.name,
|
|
460
|
+
version: impl.surface.version,
|
|
461
|
+
props: job.props,
|
|
462
|
+
mode: record.mode,
|
|
463
|
+
staleAfterMs: windowOf(impl),
|
|
464
|
+
},
|
|
465
|
+
conversation.leaseToken,
|
|
466
|
+
);
|
|
467
|
+
const digest = impl.impl.digest(job.props, { handle: next });
|
|
468
|
+
// …and once more as it takes effect: the write and the digest take a while too
|
|
469
|
+
const window = strictest(record.staleAfterMs, windowOf(impl));
|
|
470
|
+
if (window !== "never" && !(Number.isFinite(record.createdAt) && window !== undefined && record.createdAt + window > Date.now())) {
|
|
471
|
+
throw new Error("this conversation went out of date while the revision was made; it was not applied");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
conversation.handles.push(next);
|
|
475
|
+
(conversation.superseded ??= {})[current] = next;
|
|
476
|
+
// A question waiting on the surface waits on its revision. Its digest stays
|
|
477
|
+
// as the model was shown it: the update's text rides in the same message as
|
|
478
|
+
// the answer, after it, saying what changed.
|
|
479
|
+
if (conversation.pending?.handle === current) conversation.pending = { ...conversation.pending, handle: next };
|
|
480
|
+
// The notice it owes commits with it, in one fenced save, so the revision
|
|
481
|
+
// and its notice can't come apart. Saved before the notice is appended,
|
|
482
|
+
// which proves the lease is still held: a notice for a revision the history
|
|
483
|
+
// lost would show the browser a surface every click on is refused.
|
|
484
|
+
const notice = {
|
|
485
|
+
conversationId: conversation.id,
|
|
486
|
+
name: impl.surface.name,
|
|
487
|
+
version: impl.surface.version,
|
|
488
|
+
payload: null,
|
|
489
|
+
model: `${job.model ? `${job.model} ` : ""}${current} was replaced by ${next}; its earlier digest is out of date. Now: ${digest}`,
|
|
490
|
+
handle: next,
|
|
491
|
+
replaces: current,
|
|
492
|
+
...(job.wake ? { kind: "wake" as const } : {}),
|
|
493
|
+
};
|
|
494
|
+
conversation.announcing = notice;
|
|
495
|
+
await store.saveConversation(conversation);
|
|
496
|
+
try {
|
|
497
|
+
await store.putNotice(notice);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
// Still owed, and appended by whoever holds the conversation next, before
|
|
500
|
+
// anything else: the model hears of the revision before it runs again.
|
|
501
|
+
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 as Error).message})`);
|
|
502
|
+
}
|
|
503
|
+
conversation.announcing = null;
|
|
504
|
+
return { handle: next, replaced: current };
|
|
505
|
+
}
|
|
506
|
+
|
|
223
507
|
/**
|
|
224
508
|
* Start a turn for the `wake` notices this conversation hasn't taken in, if
|
|
225
509
|
* it can take one now. The events route calls this, and streams the turn
|
|
@@ -274,6 +558,7 @@ export class Hai {
|
|
|
274
558
|
}
|
|
275
559
|
|
|
276
560
|
private async wakeTurn(conversation: Conversation, emit: Emit): Promise<WakeOutcome> {
|
|
561
|
+
await this.announce(conversation);
|
|
277
562
|
// a load of an unknown id starts a new conversation, which has not begun either
|
|
278
563
|
if (!conversation.messages.length || conversation.status === "awaiting") return "idle";
|
|
279
564
|
if (await this.refuseIfExpired(conversation, () => {})) return "idle";
|
|
@@ -368,11 +653,13 @@ export class Hai {
|
|
|
368
653
|
conversation: Conversation,
|
|
369
654
|
): Promise<{ blocks: TextBlock[]; through: number; wake: number }> {
|
|
370
655
|
const through = conversation.noticedThrough ?? 0;
|
|
371
|
-
if (!this.
|
|
656
|
+
if (!this.sendsNotices) return { blocks: [], through, wake: 0 };
|
|
372
657
|
const fresh = (await this.config.store.getNotices(conversation.id, through)) ?? [];
|
|
373
658
|
return {
|
|
374
659
|
blocks: fresh.flatMap((n) =>
|
|
375
|
-
n.model === null
|
|
660
|
+
n.model === null
|
|
661
|
+
? []
|
|
662
|
+
: [{ type: "text" as const, text: n.replaces === undefined ? `[App notification: ${n.name}] ${n.model}` : `[UI update] ${n.model}` }],
|
|
376
663
|
),
|
|
377
664
|
through: fresh.at(-1)?.seq ?? through,
|
|
378
665
|
// the newest unread wake notice, or 0
|
|
@@ -469,6 +756,7 @@ export class Hai {
|
|
|
469
756
|
// ─────────────────────────────────────────────────── public API
|
|
470
757
|
|
|
471
758
|
async send(conversation: Conversation, text: string, emit: Emit): Promise<void> {
|
|
759
|
+
await this.announce(conversation);
|
|
472
760
|
if (await this.refuseIfExpired(conversation, emit)) return;
|
|
473
761
|
|
|
474
762
|
// The first message is the one that starts the conversation.
|
|
@@ -518,6 +806,7 @@ export class Hai {
|
|
|
518
806
|
*/
|
|
519
807
|
async start(conversation: Conversation, emit: Emit): Promise<void> {
|
|
520
808
|
if (!this.config.init || conversation.messages.length > 0) return;
|
|
809
|
+
await this.announce(conversation);
|
|
521
810
|
const unread = await this.unreadNotices(conversation);
|
|
522
811
|
if (await this.runInit(conversation, withText(unread.blocks, STARTED), emit, unread.through)) return; // parked on its surface
|
|
523
812
|
conversation.status = "idle";
|
|
@@ -583,7 +872,12 @@ export class Hai {
|
|
|
583
872
|
if (asked) {
|
|
584
873
|
emit({ type: "block_update", id: toolBlockId, status: "awaiting", ms, result: asked.digest });
|
|
585
874
|
conversation.status = "awaiting";
|
|
586
|
-
conversation.pending = {
|
|
875
|
+
conversation.pending = {
|
|
876
|
+
toolUseId: call.id,
|
|
877
|
+
handle: currentHandle(conversation, asked.handle),
|
|
878
|
+
digest: asked.digest,
|
|
879
|
+
results: [],
|
|
880
|
+
};
|
|
587
881
|
emit({ type: "status", status: "awaiting" });
|
|
588
882
|
await this.emitContext(conversation, emit);
|
|
589
883
|
return true;
|
|
@@ -609,6 +903,7 @@ export class Hai {
|
|
|
609
903
|
input: { handle: string; action: string; value: unknown },
|
|
610
904
|
emit: Emit,
|
|
611
905
|
): Promise<void> {
|
|
906
|
+
await this.announce(conversation);
|
|
612
907
|
if (await this.refuseIfExpired(conversation, emit)) return;
|
|
613
908
|
|
|
614
909
|
// The payload row carries a conversation id, but that alone is not enough.
|
|
@@ -618,6 +913,11 @@ export class Hai {
|
|
|
618
913
|
// Membership of the surviving `handles` is what makes those orphans inert.
|
|
619
914
|
if (!conversation.handles.includes(input.handle)) throw new Error("unknown handle");
|
|
620
915
|
|
|
916
|
+
// Revised since the user saw it: what they clicked on is no longer there.
|
|
917
|
+
if (conversation.superseded?.[input.handle]) {
|
|
918
|
+
throw new Error(`superseded by ${currentHandle(conversation, input.handle)}`);
|
|
919
|
+
}
|
|
920
|
+
|
|
621
921
|
if (conversation.frozen.includes(input.handle)) throw new Error("component is frozen");
|
|
622
922
|
|
|
623
923
|
const record = await this.config.store.getPayload(input.handle, conversation.id);
|
|
@@ -742,8 +1042,9 @@ export class Hai {
|
|
|
742
1042
|
*/
|
|
743
1043
|
private async refuseIfExpired(conversation: Conversation, emit: Emit): Promise<boolean> {
|
|
744
1044
|
if (!conversation.handles.length) return false;
|
|
745
|
-
const now = Date.now();
|
|
746
1045
|
const records = await this.config.store.getPayloads(conversation.handles, conversation.id);
|
|
1046
|
+
// after the read, so a window that ran out while it was in flight counts
|
|
1047
|
+
const now = Date.now();
|
|
747
1048
|
|
|
748
1049
|
// the surface that went out of date first is the one that closed it
|
|
749
1050
|
let first: { expiredAt: number; age: number; window: number | undefined } | null = null;
|
|
@@ -756,6 +1057,12 @@ export class Hai {
|
|
|
756
1057
|
}
|
|
757
1058
|
|
|
758
1059
|
for (const record of records) {
|
|
1060
|
+
// A revised surface's window stops counting; its revision's counts. Safe
|
|
1061
|
+
// because the model has heard the revision before it can act: in a turn
|
|
1062
|
+
// as the tool's result, and from outside one in the message this
|
|
1063
|
+
// request is about to record. (An answered surface can't be revised, so
|
|
1064
|
+
// what the user picked from one still counts.)
|
|
1065
|
+
if (conversation.superseded?.[record.handle]) continue;
|
|
759
1066
|
// a surface the code no longer registers has no current window at all,
|
|
760
1067
|
// which is not the same as one registered without a window ("never")
|
|
761
1068
|
const impl = this.surfaces.get(record.component);
|
|
@@ -844,7 +1151,19 @@ export class Hai {
|
|
|
844
1151
|
if (parked) {
|
|
845
1152
|
// Hold the resolved siblings too — the API is all-or-nothing per batch.
|
|
846
1153
|
conversation.status = "awaiting";
|
|
847
|
-
|
|
1154
|
+
// At the question's latest revision: a later call in this reply may
|
|
1155
|
+
// have revised it, and then its answer goes out under the revision's
|
|
1156
|
+
// digest, not the original's.
|
|
1157
|
+
// Only the question's part is replaced: anything the asking call said
|
|
1158
|
+
// after it rides along as before.
|
|
1159
|
+
const handle = currentHandle(conversation, parked.handle);
|
|
1160
|
+
const asking = hop.asking;
|
|
1161
|
+
let digest = parked.digest;
|
|
1162
|
+
if (handle !== parked.handle && asking?.handle === handle && asking.digest !== undefined) {
|
|
1163
|
+
const asked = asking.question;
|
|
1164
|
+
digest = asked !== undefined && parked.digest.startsWith(asked) ? asking.digest + parked.digest.slice(asked.length) : asking.digest;
|
|
1165
|
+
}
|
|
1166
|
+
conversation.pending = { ...parked, handle, digest, results };
|
|
848
1167
|
emit({ type: "status", status: "awaiting" });
|
|
849
1168
|
await this.emitContext(conversation, emit);
|
|
850
1169
|
return;
|
|
@@ -887,7 +1206,16 @@ export class Hai {
|
|
|
887
1206
|
|
|
888
1207
|
// Store a surface and send it out: the payload to the browser, the digest
|
|
889
1208
|
// back to the tool for the model.
|
|
890
|
-
const show = async (
|
|
1209
|
+
const show = async (
|
|
1210
|
+
impl: AnySurfaceImpl,
|
|
1211
|
+
props: unknown,
|
|
1212
|
+
surfaceMode: "display" | "elicit",
|
|
1213
|
+
// the handle this revises in place, for `update`
|
|
1214
|
+
replaces?: string,
|
|
1215
|
+
// last word before the surface is attached and sent: throwing leaves the
|
|
1216
|
+
// stored payload unreferenced, inert as an overtaken turn's
|
|
1217
|
+
beforeAttach?: () => void,
|
|
1218
|
+
) => {
|
|
891
1219
|
// Validate before storing: props may originate outside this process.
|
|
892
1220
|
const parsed = impl.surface.props.parse(props);
|
|
893
1221
|
const window = windowOf(impl);
|
|
@@ -912,6 +1240,7 @@ export class Hai {
|
|
|
912
1240
|
|
|
913
1241
|
// The split, in two lines. Digest -> model. Payload -> browser.
|
|
914
1242
|
const digest = impl.impl.digest(parsed, { handle });
|
|
1243
|
+
beforeAttach?.();
|
|
915
1244
|
// Recorded only once the digest is in hand. A surface that fails here is
|
|
916
1245
|
// never shown, and a handle in the history counts toward its freshness,
|
|
917
1246
|
// so it could otherwise close the conversation over something unseen.
|
|
@@ -928,6 +1257,7 @@ export class Hai {
|
|
|
928
1257
|
...(window === "never" ? {} : { staleAfterMs: window }),
|
|
929
1258
|
// how long ago it was stored: the digest's time, and a replay's wait
|
|
930
1259
|
ageMs: Date.now() - storing,
|
|
1260
|
+
...(replaces === undefined ? {} : { replaces }),
|
|
931
1261
|
});
|
|
932
1262
|
emit({ type: "ui_props", handle, props: parsed });
|
|
933
1263
|
|
|
@@ -970,6 +1300,7 @@ export class Hai {
|
|
|
970
1300
|
try {
|
|
971
1301
|
const ret = await show(impl, props, "elicit");
|
|
972
1302
|
claim.handle = ret.handle;
|
|
1303
|
+
claim.question = ret.model;
|
|
973
1304
|
asked = { handle: ret.handle, digest: ret.model };
|
|
974
1305
|
return ret;
|
|
975
1306
|
} catch (err) {
|
|
@@ -980,6 +1311,57 @@ export class Hai {
|
|
|
980
1311
|
}
|
|
981
1312
|
};
|
|
982
1313
|
|
|
1314
|
+
// Revise a surface this conversation shows: a new surface, with a new
|
|
1315
|
+
// handle, that supersedes the old one. Checked before anything is stored,
|
|
1316
|
+
// and claimed before the first await, so two calls can't both revise it.
|
|
1317
|
+
const update = async (impl: AnySurfaceImpl, handle: string, props: unknown): Promise<ToolReturn> => {
|
|
1318
|
+
if (!conversation.handles.includes(handle)) throw new Error(`no surface ${handle} in this conversation`);
|
|
1319
|
+
if (conversation.frozen.includes(handle)) throw new Error(`${handle} was answered, so it can't be revised`);
|
|
1320
|
+
const after = conversation.superseded?.[handle];
|
|
1321
|
+
if (after) throw new Error(`${handle} was revised already: it is ${currentHandle(conversation, handle)} now`);
|
|
1322
|
+
const revising = this.revising.get(conversation) ?? new Set<string>();
|
|
1323
|
+
if (revising.has(handle)) throw new Error(`${handle} is being revised by another call`);
|
|
1324
|
+
revising.add(handle);
|
|
1325
|
+
this.revising.set(conversation, revising);
|
|
1326
|
+
try {
|
|
1327
|
+
const record = await this.config.store.getPayload(handle, conversation.id);
|
|
1328
|
+
if (!record) throw new Error(`no surface ${handle} in this conversation`);
|
|
1329
|
+
if (record.component !== impl.surface.name) {
|
|
1330
|
+
throw new Error(`${handle} is a ${record.component}, not a ${impl.surface.name}`);
|
|
1331
|
+
}
|
|
1332
|
+
// Freshness again, last, right before the write: a long turn can
|
|
1333
|
+
// outlast a window that was open when it began, and so can the reads
|
|
1334
|
+
// above. A revision must not bring an out-of-date conversation back
|
|
1335
|
+
// by superseding the surface that closed it.
|
|
1336
|
+
if (await this.refuseIfExpired(conversation, () => {})) {
|
|
1337
|
+
throw new Error("this conversation is out of date; nothing more can be revised in it");
|
|
1338
|
+
}
|
|
1339
|
+
// …and once more as it takes effect: the write and the digest above can
|
|
1340
|
+
// take a while too, and the browser closes the conversation at the old
|
|
1341
|
+
// surface's deadline whether or not a revision is on its way.
|
|
1342
|
+
const window = strictest(record.staleAfterMs, windowOf(impl));
|
|
1343
|
+
const stillFresh = () => {
|
|
1344
|
+
if (window === "never") return;
|
|
1345
|
+
if (!(Number.isFinite(record.createdAt) && window !== undefined && record.createdAt + window > Date.now())) {
|
|
1346
|
+
throw new Error("this conversation went out of date while the revision was made; it was not applied");
|
|
1347
|
+
}
|
|
1348
|
+
};
|
|
1349
|
+
const ret = await show(impl, props, record.mode, handle, stillFresh);
|
|
1350
|
+
(conversation.superseded ??= {})[handle] = ret.handle!;
|
|
1351
|
+
// a question waiting on the old surface waits on the new one; when this
|
|
1352
|
+
// call asked it, its result starts with the revision's digest now, so
|
|
1353
|
+
// that is the part a later call's revision replaces
|
|
1354
|
+
const mine = asked?.handle === handle;
|
|
1355
|
+
if (mine) asked = { handle: ret.handle!, digest: ret.model };
|
|
1356
|
+
if (hop.asking?.handle === handle) {
|
|
1357
|
+
Object.assign(hop.asking, { handle: ret.handle!, digest: ret.model }, mine ? { question: ret.model } : {});
|
|
1358
|
+
}
|
|
1359
|
+
return ret;
|
|
1360
|
+
} finally {
|
|
1361
|
+
revising.delete(handle);
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1364
|
+
|
|
983
1365
|
// Renders the tool has started. One can still be storing when the tool
|
|
984
1366
|
// returns or throws: a render it didn't await, or one a rejection in a
|
|
985
1367
|
// Promise.all overtook. They all finish before this call is decided. Each is
|
|
@@ -1033,6 +1415,19 @@ export class Hai {
|
|
|
1033
1415
|
);
|
|
1034
1416
|
return shown;
|
|
1035
1417
|
}) as ToolCtx["render"],
|
|
1418
|
+
update: ((impl: AnySurfaceImpl, handle: string, props: unknown) => {
|
|
1419
|
+
if (done) return Promise.resolve({ model: "Not revised: the tool had already finished." });
|
|
1420
|
+
const revised = update(impl, handle, props);
|
|
1421
|
+
rendering.push(
|
|
1422
|
+
revised.then(
|
|
1423
|
+
() => {},
|
|
1424
|
+
(err) => {
|
|
1425
|
+
if (isStaleLease(err)) lost ??= err;
|
|
1426
|
+
},
|
|
1427
|
+
),
|
|
1428
|
+
);
|
|
1429
|
+
return revised;
|
|
1430
|
+
}) as ToolCtx["update"],
|
|
1036
1431
|
};
|
|
1037
1432
|
|
|
1038
1433
|
let input: unknown;
|
|
@@ -1063,7 +1458,13 @@ export class Hai {
|
|
|
1063
1458
|
if (lost) throw lost;
|
|
1064
1459
|
|
|
1065
1460
|
if (outcome.ok) {
|
|
1066
|
-
|
|
1461
|
+
let { ret } = outcome;
|
|
1462
|
+
// A query on a surface since revised answers from what it was, which is
|
|
1463
|
+
// what the model asked about; it is told the surface has moved on.
|
|
1464
|
+
const queried = (input as { handle?: unknown } | null)?.handle;
|
|
1465
|
+
if (tool === this.queryUi && typeof queried === "string" && conversation.superseded?.[queried]) {
|
|
1466
|
+
ret = { ...ret, model: `${ret.model}\n(${queried} has since been revised: it is ${currentHandle(conversation, queried)} now.)` };
|
|
1467
|
+
}
|
|
1067
1468
|
// The answer goes to the model under the digest of the question this call
|
|
1068
1469
|
// showed. Anything else the tool returned rides along after it, such as a
|
|
1069
1470
|
// render it was refused, so the model still hears it.
|
|
@@ -1143,6 +1544,17 @@ function strictest(recorded: unknown, current: unknown): number | "never" | unde
|
|
|
1143
1544
|
|
|
1144
1545
|
const newId = () => globalThis.crypto.randomUUID().replaceAll("-", "").slice(0, 16);
|
|
1145
1546
|
|
|
1547
|
+
/** The latest revision of a surface: `handle` itself, or what it was superseded by, all the way along. */
|
|
1548
|
+
function currentHandle(conversation: Conversation, handle: string): string {
|
|
1549
|
+
const seen = new Set<string>();
|
|
1550
|
+
let at = handle;
|
|
1551
|
+
while (conversation.superseded?.[at] && !seen.has(at)) {
|
|
1552
|
+
seen.add(at);
|
|
1553
|
+
at = conversation.superseded[at];
|
|
1554
|
+
}
|
|
1555
|
+
return at;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1146
1558
|
interface TextBlock {
|
|
1147
1559
|
type: "text";
|
|
1148
1560
|
text: string;
|
package/src/store.ts
CHANGED
|
@@ -108,6 +108,8 @@ export function memoryStore(options: MemoryStoreOptions = {}): StoreAdapter {
|
|
|
108
108
|
wakes: [],
|
|
109
109
|
wokeThrough: 0,
|
|
110
110
|
wakeTurns: 0,
|
|
111
|
+
superseded: {},
|
|
112
|
+
announcing: null,
|
|
111
113
|
};
|
|
112
114
|
conversations.set(conversation.id, conversation);
|
|
113
115
|
return copy(conversation);
|