@haikit/server 0.11.0 → 0.13.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/src/routes.ts CHANGED
@@ -1,23 +1,32 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { isConversationBusy, isStaleLease } from "@haikit/core";
3
- import type { Emit, WireEvent } from "@haikit/core";
3
+ import type { Emit, NoticeRecord, WireEvent } from "@haikit/core";
4
4
  import type { Hai } from "./runtime.js";
5
5
 
6
6
  /**
7
- * Node http handler for the three routes. Returns true if it handled the
8
- * request.
7
+ * Node http handler for the three routes, and a fourth when the app sends
8
+ * notices. Returns true if it handled the request.
9
9
  *
10
- * All three stream SSE: chat and interact because both can resume the agent
11
- * loop, start because the init tool it runs can render surfaces. Note what the
12
- * interact route accepts: {handle, action, value} and nothing else. The client
13
- * cannot name a tool, a handler, or an action target.
10
+ * The three POST routes stream SSE: chat and interact because both can resume
11
+ * the agent loop, start because the init tool it runs can render surfaces.
12
+ * Note what the interact route accepts: {handle, action, value} and nothing
13
+ * else. The client cannot name a tool, a handler, or an action target.
14
+ *
15
+ * `GET /events` streams a conversation's notices. It accepts nothing from the
16
+ * browser beyond which conversation, and where to resume.
14
17
  */
15
18
  export function nodeHandler(hai: Hai, basePath = "/hai") {
16
19
  return async function handle(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
17
20
  const url = new URL(req.url ?? "/", "http://localhost");
18
- if (req.method !== "POST" || !url.pathname.startsWith(basePath)) return false;
19
-
21
+ if (!url.pathname.startsWith(basePath)) return false;
20
22
  const route = url.pathname.slice(basePath.length);
23
+
24
+ if (req.method === "GET" && route === "/events" && hai.sendsNotices) {
25
+ await streamNotices(hai, req, res, url);
26
+ return true;
27
+ }
28
+
29
+ if (req.method !== "POST") return false;
21
30
  if (route !== "/chat" && route !== "/interact" && route !== "/start") return false;
22
31
 
23
32
  const body = await readJson(req);
@@ -45,7 +54,12 @@ export function nodeHandler(hai: Hai, basePath = "/hai") {
45
54
 
46
55
  const emit = openSSE(res);
47
56
  if (route === "/chat" || route === "/start") {
48
- emit({ type: "hello", conversationId: conversation.id, model: hai.config.model.id });
57
+ emit({
58
+ type: "hello",
59
+ conversationId: conversation.id,
60
+ model: hai.config.model.id,
61
+ ...(hai.sendsNotices ? { events: true as const } : {}),
62
+ });
49
63
  }
50
64
 
51
65
  let superseded = false;
@@ -91,6 +105,149 @@ export function nodeHandler(hai: Hai, basePath = "/hai") {
91
105
  };
92
106
  }
93
107
 
108
+ /**
109
+ * The longest a notice waits to be read. A store's `watch` makes it sooner;
110
+ * this is what still finds a notice whose wake-up was lost.
111
+ */
112
+ const POLL_MS = 2_000;
113
+ /** Proxies drop a stream that says nothing for long; a comment keeps it open. */
114
+ const HEARTBEAT_MS = 25_000;
115
+
116
+ /** Notices read from the store at a time, so a long backlog goes out page by page. */
117
+ const PAGE = 100;
118
+
119
+ /**
120
+ * Stream a conversation's notices: every one after where the browser has got
121
+ * to (`Last-Event-ID` on a reconnect, else `?after=`), then each as it lands.
122
+ * Takes no lease and writes nothing. Each notice goes out as its payload; its
123
+ * model text is not in it: that reaches the browser only as part of the
124
+ * history, in the `context` event, once a turn has taken it in.
125
+ *
126
+ * Bounded both ways. The store is read a page at a time, so a conversation
127
+ * with years of notices is never held in memory at once, and nothing more is
128
+ * written while the socket is still taking what was, so a reader that is slow
129
+ * cannot make the server buffer every notice for it.
130
+ */
131
+ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse, url: URL) {
132
+ const { store } = hai.config;
133
+ const conversationId = url.searchParams.get("conversationId") ?? "";
134
+ const resume = Number(req.headers["last-event-id"] ?? url.searchParams.get("after") ?? 0);
135
+ let after = Number.isSafeInteger(resume) && resume > 0 ? resume : 0;
136
+
137
+ const first = await store.getNotices(conversationId, after, PAGE);
138
+ if (first === null) {
139
+ res.writeHead(404, { "content-type": "application/json" });
140
+ res.end(JSON.stringify({ error: "unknown conversation" }));
141
+ return;
142
+ }
143
+
144
+ res.writeHead(200, {
145
+ "content-type": "text/event-stream",
146
+ "cache-control": "no-cache",
147
+ connection: "keep-alive",
148
+ });
149
+ // Sent now, not with the first frame: with nothing to catch up on, that is a
150
+ // heartbeat away, and the browser's request would hang open until then.
151
+ res.flushHeaders();
152
+ const aborter = new AbortController();
153
+ const { signal } = aborter;
154
+ res.on("close", () => aborter.abort());
155
+
156
+ // Resolves once the socket has taken what is queued, or the browser has gone.
157
+ const write = async (chunk: string) => {
158
+ if (res.write(chunk)) return;
159
+ await new Promise<void>((resolve) => {
160
+ const done = () => {
161
+ res.off("drain", done);
162
+ signal.removeEventListener("abort", done);
163
+ resolve();
164
+ };
165
+ res.on("drain", done);
166
+ signal.addEventListener("abort", done);
167
+ });
168
+ };
169
+
170
+ // Everything after `after`, a page at a time, until a page comes back short.
171
+ // A conversation deleted since the stream opened ends it: there is nothing
172
+ // more to wait for, and the browser's reconnect is then refused.
173
+ const catchUp = async (page: NoticeRecord[] | null = null) => {
174
+ for (;;) {
175
+ const records = page ?? (await store.getNotices(conversationId, after, PAGE));
176
+ if (records === null) return aborter.abort();
177
+ for (const n of records) {
178
+ if (signal.aborted) return;
179
+ if (n.seq <= after) continue;
180
+ const event: WireEvent = {
181
+ type: "notice",
182
+ seq: n.seq,
183
+ name: n.name,
184
+ version: n.version,
185
+ payload: n.payload,
186
+ ...(n.handle === undefined ? {} : { handle: n.handle }),
187
+ };
188
+ await write(`id: ${n.seq}\ndata: ${JSON.stringify(event)}\n\n`);
189
+ after = n.seq;
190
+ }
191
+ if (records.length < PAGE || signal.aborted) return;
192
+ page = null;
193
+ }
194
+ };
195
+
196
+ // a heartbeat is only for a stream gone quiet, never one already backed up
197
+ const heartbeat = setInterval(() => res.writableNeedDrain || res.write(": ping\n\n"), HEARTBEAT_MS);
198
+ const wakes = wakeups(store.watch?.(conversationId, signal), signal)[Symbol.asyncIterator]();
199
+ try {
200
+ await catchUp(first);
201
+ // Watching starts before the next read, so a notice that landed after the
202
+ // backlog was read is either in that read or wakes the loop.
203
+ let next = wakes.next();
204
+ await catchUp();
205
+ while (!signal.aborted) {
206
+ if ((await next).done) break;
207
+ next = wakes.next();
208
+ await catchUp();
209
+ }
210
+ } finally {
211
+ clearInterval(heartbeat);
212
+ aborter.abort();
213
+ await wakes.return?.();
214
+ res.end();
215
+ }
216
+ }
217
+
218
+ /**
219
+ * When the events route reads next: every POLL_MS, and sooner whenever the
220
+ * store's `watch` says a notice may have landed. `watch` alone is not enough.
221
+ * A wake-up can be lost, and a LISTEN connection can drop, and either would
222
+ * leave the stream open on heartbeats with a notice it never reads. A `watch`
223
+ * that ends or fails leaves the reads every POLL_MS to carry on.
224
+ */
225
+ async function* wakeups(watch: AsyncIterable<void> | undefined, signal: AbortSignal): AsyncIterable<void> {
226
+ const steps = watch?.[Symbol.asyncIterator]();
227
+ // started now, so the watch is listening before the caller's next read
228
+ let step: Promise<unknown> | null = steps?.next().then((r) => !r.done, () => false) ?? null;
229
+ try {
230
+ while (!signal.aborted) {
231
+ let timer: ReturnType<typeof setTimeout> | undefined;
232
+ let onAbort: (() => void) | undefined;
233
+ const tick = new Promise<"tick">((resolve) => {
234
+ onAbort = () => resolve("tick");
235
+ timer = setTimeout(onAbort, POLL_MS);
236
+ signal.addEventListener("abort", onAbort);
237
+ });
238
+ const woke = await Promise.race(step ? [tick, step] : [tick]);
239
+ clearTimeout(timer);
240
+ signal.removeEventListener("abort", onAbort!);
241
+ if (signal.aborted) return;
242
+ // the watch stepped: listen for the next; it ended or failed: stop listening
243
+ if (woke !== "tick") step = woke ? steps!.next().then((r) => !r.done, () => false) : null;
244
+ yield;
245
+ }
246
+ } finally {
247
+ await steps?.return?.();
248
+ }
249
+ }
250
+
94
251
  function openSSE(res: ServerResponse): Emit {
95
252
  res.writeHead(200, {
96
253
  "content-type": "text/event-stream",
package/src/runtime.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  import {
2
2
  estTokens,
3
+ type AnyNoticeImpl,
3
4
  isStaleLease,
4
5
  makeCap,
5
6
  type AnySurfaceImpl,
6
7
  type Conversation,
7
8
  type Emit,
8
9
  type ModelAdapter,
10
+ type Notify,
11
+ type Progress,
9
12
  type StoreAdapter,
10
13
  type Tool,
11
14
  type ToolCtx,
@@ -47,6 +50,12 @@ export interface HaiConfig {
47
50
  * model gets "already ran" back instead of running it again.
48
51
  */
49
52
  init?: Tool;
53
+ /**
54
+ * The notices this app sends with `hai.notify`. A notice not listed here is
55
+ * refused, as an unregistered surface is. With any listed, the browser opens
56
+ * the events stream (`GET {base}/events`) to receive them.
57
+ */
58
+ notices?: AnyNoticeImpl[];
50
59
  }
51
60
 
52
61
  /**
@@ -96,6 +105,7 @@ export class Hai {
96
105
  readonly config: HaiConfig;
97
106
  private readonly surfaces = new Map<string, AnySurfaceImpl>();
98
107
  private readonly tools = new Map<string, Tool>();
108
+ private readonly notices = new Map<string, AnyNoticeImpl>();
99
109
  /** What the model gets as its system prompt: the app's, then the scope. */
100
110
  readonly system: string;
101
111
  private seq = 0;
@@ -109,6 +119,10 @@ export class Hai {
109
119
  this.surfaces.set(s.surface.name, s);
110
120
  }
111
121
  for (const t of config.tools) this.tools.set(t.name, t);
122
+ for (const n of config.notices ?? []) {
123
+ if (this.notices.has(n.notice.name)) throw new Error(`two notices are named "${n.notice.name}"`);
124
+ this.notices.set(n.notice.name, n);
125
+ }
112
126
  // query_ui is DERIVED, never authored. It cannot drift from the surfaces
113
127
  // that actually exist, and it always exists.
114
128
  const queryUi = this.buildQueryUiTool();
@@ -122,6 +136,68 @@ export class Hai {
122
136
  }
123
137
  }
124
138
 
139
+ /** Whether this app sends notices, so the browser should open the events stream. */
140
+ get sendsNotices(): boolean {
141
+ return this.notices.size > 0;
142
+ }
143
+
144
+ /**
145
+ * Send a notice to a conversation: its payload to the browser now, over the
146
+ * events stream, and its model text to the model in the next user message
147
+ * the conversation records.
148
+ *
149
+ * Takes no lease, so it works while a turn is streaming, while one is
150
+ * parked, and with nobody connected. It never starts a turn.
151
+ */
152
+ readonly notify: Notify = async (conversationId, notice, payload, options = {}) => {
153
+ const name = notice?.notice?.name;
154
+ if (this.notices.get(name) !== notice) {
155
+ throw new Error(`notice "${name}" is not registered: list it in createHai({ notices })`);
156
+ }
157
+ // Validated before it is stored: notify is called from webhooks and job
158
+ // queues, whose data comes from outside this process.
159
+ const parsed = notice.notice.payload.parse(payload);
160
+ const model: unknown = notice.impl.model(parsed);
161
+ if (model !== null && typeof model !== "string") {
162
+ throw new TypeError(`notice "${name}": model() must return a string or null`);
163
+ }
164
+ // Checked against the stored surfaces, which needs no lease. One left behind
165
+ // by an overtaken turn may pass as well, until it is swept, unlike a click
166
+ // on it: a click changes the history, and the handle here only says where
167
+ // the browser shows the notice. The model never sees it.
168
+ const { handle } = options;
169
+ if (handle !== undefined && !(await this.config.store.getPayload(handle, conversationId))) {
170
+ throw new Error(`notice "${name}": no surface ${handle} in conversation ${conversationId}`);
171
+ }
172
+ const record = await this.config.store.putNotice({
173
+ conversationId,
174
+ name,
175
+ version: notice.notice.version,
176
+ payload: parsed,
177
+ model,
178
+ ...(handle === undefined ? {} : { handle }),
179
+ });
180
+ return { seq: record.seq };
181
+ };
182
+
183
+ /**
184
+ * The notices this conversation's history hasn't taken in yet: their model
185
+ * text as blocks for the user message this request is about to record, and
186
+ * the number to set `noticedThrough` to when it does. Set it only where the
187
+ * message is pushed, so a request refused before then takes nothing in.
188
+ */
189
+ private async unreadNotices(conversation: Conversation): Promise<{ blocks: TextBlock[]; through: number }> {
190
+ const through = conversation.noticedThrough ?? 0;
191
+ if (!this.notices.size) return { blocks: [], through };
192
+ const fresh = (await this.config.store.getNotices(conversation.id, through)) ?? [];
193
+ return {
194
+ blocks: fresh.flatMap((n) =>
195
+ n.model === null ? [] : [{ type: "text" as const, text: `[App notification: ${n.name}] ${n.model}` }],
196
+ ),
197
+ through: fresh.at(-1)?.seq ?? through,
198
+ };
199
+ }
200
+
125
201
  private nid(prefix: string) {
126
202
  return `${prefix}_${++this.seq}`;
127
203
  }
@@ -215,6 +291,10 @@ export class Hai {
215
291
 
216
292
  // The first message is the one that starts the conversation.
217
293
  const starting = conversation.messages.length === 0;
294
+ // Notices ride in the user's message, ahead of what they typed: they
295
+ // arrived before it.
296
+ const unread = await this.unreadNotices(conversation);
297
+ const message = withText(unread.blocks, text);
218
298
 
219
299
  // The user typed while a tool was parked. Every tool_use in a turn must
220
300
  // receive a tool_result, so close the pending one out honestly first.
@@ -240,9 +320,10 @@ export class Hai {
240
320
 
241
321
  emit({ type: "block_start", block: { kind: "user", id: this.nid("b"), text } });
242
322
  if (starting && this.config.init) {
243
- if (await this.runInit(conversation, text, emit)) return; // parked on its surface
323
+ if (await this.runInit(conversation, message, emit, unread.through)) return; // parked on its surface
244
324
  } else {
245
- conversation.messages.push({ role: "user", content: text });
325
+ conversation.messages.push({ role: "user", content: message });
326
+ conversation.noticedThrough = unread.through;
246
327
  }
247
328
  await this.runTurn(conversation, emit);
248
329
  }
@@ -255,22 +336,29 @@ export class Hai {
255
336
  */
256
337
  async start(conversation: Conversation, emit: Emit): Promise<void> {
257
338
  if (!this.config.init || conversation.messages.length > 0) return;
258
- if (await this.runInit(conversation, STARTED, emit)) return; // parked on its surface
339
+ const unread = await this.unreadNotices(conversation);
340
+ if (await this.runInit(conversation, withText(unread.blocks, STARTED), emit, unread.through)) return; // parked on its surface
259
341
  conversation.status = "idle";
260
342
  emit({ type: "status", status: "idle" });
261
343
  await this.emitContext(conversation, emit);
262
344
  }
263
345
 
264
346
  /**
265
- * Run the init tool as the conversation's first tool call, after `text` —
266
- * the user's first message, or STARTED when it runs before one. Returns
267
- * true when it parked the conversation on an elicit surface.
347
+ * Run the init tool as the conversation's first tool call, after `message` —
348
+ * the user's first message, or STARTED when it runs before one, with any
349
+ * unread notices, which take the conversation's notices through `through`.
350
+ * Returns true when it parked the conversation on an elicit surface.
268
351
  *
269
352
  * Nothing reaches the history until the tool has succeeded — not even the
270
353
  * user's message — so a refused start leaves the conversation exactly as
271
354
  * the route found it, and the next message starts it again.
272
355
  */
273
- private async runInit(conversation: Conversation, text: string, emit: Emit): Promise<boolean> {
356
+ private async runInit(
357
+ conversation: Conversation,
358
+ message: string | TextBlock[],
359
+ emit: Emit,
360
+ through: number,
361
+ ): Promise<boolean> {
274
362
  const init = this.config.init!;
275
363
  const call = { type: "tool_use", id: `toolu_init_${newId()}`, name: init.name, input: {} };
276
364
  const toolBlockId = this.nid("b");
@@ -306,7 +394,8 @@ export class Hai {
306
394
  const ms = Date.now() - started;
307
395
  for (const e of held) emit(e);
308
396
 
309
- conversation.messages.push({ role: "user", content: text });
397
+ conversation.messages.push({ role: "user", content: message });
398
+ conversation.noticedThrough = through;
310
399
  conversation.messages.push({ role: "assistant", content: [call] });
311
400
 
312
401
  if (asked) {
@@ -388,11 +477,15 @@ export class Hai {
388
477
  throw new Error(`action failed: ${(err as Error).message}`);
389
478
  }
390
479
 
480
+ // Read after the handler, so a notice its own work sent rides with the click.
481
+ const unread = await this.unreadNotices(conversation);
482
+
391
483
  // what recording the click changes, so a failed save below can undo it
392
484
  const before = {
393
485
  messages: conversation.messages.length,
394
486
  frozen: conversation.frozen.length,
395
487
  status: conversation.status,
488
+ noticedThrough: conversation.noticedThrough,
396
489
  };
397
490
  if (pending) {
398
491
  // resolve: the gate above let it through only for the waiting surface
@@ -408,14 +501,17 @@ export class Hai {
408
501
  // so nothing about the search has reached the model yet.
409
502
  content: `${pending.digest}\n${label}`,
410
503
  },
504
+ // after the tool results, which the API requires to come first
505
+ ...unread.blocks,
411
506
  ],
412
507
  });
413
508
  conversation.pending = null;
414
509
  conversation.status = "idle"; // answered; the save below records that
415
510
  } else {
416
511
  // inform: enriches the conversation without having blocked it
417
- conversation.messages.push({ role: "user", content: `[UI interaction] ${label}` });
512
+ conversation.messages.push({ role: "user", content: withText(unread.blocks, `[UI interaction] ${label}`) });
418
513
  }
514
+ conversation.noticedThrough = unread.through;
419
515
 
420
516
  // Commit the click before the model runs. The handler may have written,
421
517
  // and a model turn can outlast the lease; saved now, the click stays on
@@ -436,6 +532,7 @@ export class Hai {
436
532
  conversation.frozen.length = before.frozen;
437
533
  conversation.pending = pending;
438
534
  conversation.status = before.status;
535
+ conversation.noticedThrough = before.noticedThrough;
439
536
  throw err;
440
537
  }
441
538
 
@@ -706,9 +803,35 @@ export class Hai {
706
803
  // set once the tool has returned or thrown: a render after that shows nothing
707
804
  let done = false;
708
805
 
806
+ // Progress is throttled, not queued: a tool that reports every row would
807
+ // otherwise flood the stream with frames nobody can read. The first goes
808
+ // out at once, later ones at most every PROGRESS_MS, carrying every field
809
+ // named since the last, and whatever is still waiting when the tool
810
+ // returns goes out then.
811
+ let unsent: Progress | null = null;
812
+ let lastSent = 0;
813
+ let timer: ReturnType<typeof setTimeout> | null = null;
814
+ const flushProgress = () => {
815
+ if (timer) clearTimeout(timer);
816
+ timer = null;
817
+ if (!unsent) return;
818
+ emit({ type: "progress", toolId: toolBlockId, ...unsent });
819
+ unsent = null;
820
+ lastSent = Date.now();
821
+ };
822
+
709
823
  const ctx: ToolCtx = {
710
824
  conversationId: conversation.id,
711
825
  text: (model) => ({ model }),
826
+ progress: (progress) => {
827
+ if (done) return;
828
+ const fields = progressFields(progress);
829
+ if (!fields) return;
830
+ unsent = { ...unsent, ...fields };
831
+ const wait = lastSent + PROGRESS_MS - Date.now();
832
+ if (wait <= 0) flushProgress();
833
+ else timer ??= setTimeout(flushProgress, wait);
834
+ },
712
835
  render: ((impl: AnySurfaceImpl, props: unknown, options?: { mode?: "display" | "elicit" }) => {
713
836
  if (done) return Promise.resolve({ model: "Not shown: the tool had already finished." });
714
837
  const shown = render(impl, props, options);
@@ -742,6 +865,8 @@ export class Hai {
742
865
  // in while the others finish. They finish before anything is decided or
743
866
  // undone, so none can open a question, or record a handle, afterwards.
744
867
  done = true;
868
+ // the last frame goes out before the row says the call has finished
869
+ flushProgress();
745
870
  await Promise.all(rendering);
746
871
  // A tool error is information for the model; a lost lease is not. Fencing
747
872
  // exists so a superseded turn stops — converting StaleLease into "tool
@@ -830,6 +955,41 @@ function strictest(recorded: unknown, current: unknown): number | "never" | unde
830
955
 
831
956
  const newId = () => globalThis.crypto.randomUUID().replaceAll("-", "").slice(0, 16);
832
957
 
958
+ interface TextBlock {
959
+ type: "text";
960
+ text: string;
961
+ }
962
+
963
+ /**
964
+ * A user message: `text` after any notice blocks, or `text` alone when there
965
+ * are none — so a conversation with no notices keeps exactly the history it
966
+ * always had.
967
+ */
968
+ const withText = (blocks: TextBlock[], text: string): string | TextBlock[] =>
969
+ blocks.length ? [...blocks, { type: "text", text }] : text;
970
+
971
+ /** The least time between two progress frames from one tool call. */
972
+ const PROGRESS_MS = 100;
973
+
974
+ /**
975
+ * The fields of a `ctx.progress` call worth sending, or null if none are.
976
+ * Checked at runtime because JavaScript callers never see the type, and a
977
+ * progress report is not worth failing a tool over: a field that isn't what
978
+ * its type says is dropped, not thrown on. `done` must be a finite number,
979
+ * zero or more, and `total` a finite number more than zero: there is no bar
980
+ * to draw for a workload of nothing.
981
+ */
982
+ function progressFields(progress: unknown): Progress | null {
983
+ if (!progress || typeof progress !== "object") return null;
984
+ const { message, done, total } = progress as Record<string, unknown>;
985
+ const finite = (n: unknown): n is number => typeof n === "number" && Number.isFinite(n);
986
+ const fields: Progress = {};
987
+ if (typeof message === "string") fields.message = message;
988
+ if (finite(done) && done >= 0) fields.done = done;
989
+ if (finite(total) && total > 0) fields.total = total;
990
+ return Object.keys(fields).length ? fields : null;
991
+ }
992
+
833
993
  /** "2 hours", "15 minutes" — for a sentence a person reads. */
834
994
  function duration(ms: number): string {
835
995
  const units: [number, string][] = [
package/src/store.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  ConversationBusy,
3
3
  StaleLease,
4
4
  type Conversation,
5
+ type NoticeRecord,
5
6
  type PayloadRecord,
6
7
  type StoreAdapter,
7
8
  } from "@haikit/core";
@@ -49,6 +50,11 @@ export function memoryStore(options: MemoryStoreOptions = {}): StoreAdapter {
49
50
  }
50
51
  const conversations = new Map<string, Conversation>();
51
52
  const payloads = new Map<string, PayloadRecord>();
53
+ // conversation id → its notices in seq order, and who is waiting to hear of
54
+ // the next one. Numbered per conversation: nothing awaits between taking a
55
+ // number and appending, so commit order is seq order for free.
56
+ const notices = new Map<string, NoticeRecord[]>();
57
+ const waiting = new Map<string, Set<() => void>>();
52
58
  let convSeq = 0;
53
59
  let handleSeq = 0;
54
60
 
@@ -95,6 +101,7 @@ export function memoryStore(options: MemoryStoreOptions = {}): StoreAdapter {
95
101
  pending: null,
96
102
  leaseUntil: Date.now() + leaseMs,
97
103
  leaseToken: newToken(),
104
+ noticedThrough: 0,
98
105
  };
99
106
  conversations.set(conversation.id, conversation);
100
107
  return copy(conversation);
@@ -129,5 +136,63 @@ export function memoryStore(options: MemoryStoreOptions = {}): StoreAdapter {
129
136
  }
130
137
  return out;
131
138
  },
139
+
140
+ /** Unfenced, as the contract says: a notice lands whoever holds the turn. */
141
+ async putNotice(record) {
142
+ if (!conversations.has(record.conversationId)) {
143
+ throw new Error(`conversation ${record.conversationId} does not exist`);
144
+ }
145
+ const list = notices.get(record.conversationId) ?? [];
146
+ const stored: NoticeRecord = copy({ ...record, seq: (list.at(-1)?.seq ?? 0) + 1, createdAt: Date.now() });
147
+ list.push(stored);
148
+ notices.set(record.conversationId, list);
149
+ for (const wake of waiting.get(record.conversationId) ?? []) wake();
150
+ return copy(stored);
151
+ },
152
+
153
+ async getNotices(conversationId, after, limit = Infinity) {
154
+ if (!conversations.has(conversationId)) return null;
155
+ const list = notices.get(conversationId) ?? [];
156
+ // Seq order, so the first one past `after` is found by halving, and only
157
+ // the page asked for is copied: reading a long backlog a page at a time
158
+ // never walks or copies the rest of it.
159
+ let lo = 0;
160
+ let hi = list.length;
161
+ while (lo < hi) {
162
+ const mid = (lo + hi) >> 1;
163
+ if (list[mid].seq > after) hi = mid;
164
+ else lo = mid + 1;
165
+ }
166
+ return copy(list.slice(lo, lo + limit));
167
+ },
168
+
169
+ async *watch(conversationId, signal) {
170
+ // One wake-up stands for any number of notices: a step resolves once
171
+ // something has landed since the last, and the caller reads them all.
172
+ let landed = false;
173
+ let wake: (() => void) | null = null;
174
+ const listener = () => {
175
+ landed = true;
176
+ wake?.();
177
+ };
178
+ const onAbort = () => wake?.();
179
+ const set = waiting.get(conversationId) ?? new Set();
180
+ set.add(listener);
181
+ waiting.set(conversationId, set);
182
+ signal.addEventListener("abort", onAbort);
183
+ try {
184
+ while (!signal.aborted) {
185
+ if (!landed) await new Promise<void>((r) => (wake = r));
186
+ wake = null;
187
+ if (signal.aborted) return;
188
+ landed = false;
189
+ yield;
190
+ }
191
+ } finally {
192
+ signal.removeEventListener("abort", onAbort);
193
+ set.delete(listener);
194
+ if (!set.size) waiting.delete(conversationId);
195
+ }
196
+ },
132
197
  };
133
198
  }