@haikit/server 0.12.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/README.md CHANGED
@@ -113,6 +113,59 @@ tools it was given, and the browser can only send `{handle, action, value}`,
113
113
  so put every check with consequences, such as who may book what, inside the
114
114
  tool or action handler, where it runs as code.
115
115
 
116
+ **The server can tell a conversation something later, with a notice.** A
117
+ webhook confirming a booking, a job finishing: `hai.notify` sends the browser
118
+ the notice's payload now, and the model its `model` text in the next user
119
+ message the conversation records. Declare it once, beside your surfaces:
120
+
121
+ ```ts
122
+ // shared/notices.ts — imported by both halves
123
+ export const holdConfirmed = defineNotice({
124
+ name: "hold_confirmed",
125
+ version: 1,
126
+ payload: z.object({ flightId: z.string(), reference: z.string() }),
127
+ });
128
+
129
+ // server
130
+ const holdConfirmedServer = holdConfirmed.implement({
131
+ // required, like a digest: what the model hears, or null for nothing
132
+ model: (p) => `The airline confirmed the hold on ${p.flightId}, reference ${p.reference}.`,
133
+ });
134
+ const hai = createHai({ model, store, tools, surfaces, system, notices: [holdConfirmedServer] });
135
+
136
+ // anywhere, whenever: a webhook, a queue, an action handler's follow-up
137
+ await hai.notify(conversationId, holdConfirmedServer, { flightId: "AC832", reference: "QX7P2L" }, { handle: "ui_01" });
138
+ ```
139
+
140
+ The payload is typed from the contract, and checked against its schema before
141
+ it is stored. A notice that isn't listed in `notices`, a payload that fails its
142
+ schema, or a `handle` with no surface stored in the conversation is refused.
143
+ Pass a handle from the conversation's saved history, such as an action
144
+ handler's `ctx.handle`. The check is against the stored surfaces, not that
145
+ history, so a surface left behind by an overtaken turn may also pass, until a
146
+ sweep such as `@haikit/postgres`'s `sweepOrphans` removes it. That is no
147
+ promise, just harmless: the handle only says where the browser shows the
148
+ notice, the model never sees it, and a notice can't change anything.
149
+
150
+ `notify` takes no lease, so it works while a turn is streaming, while one is
151
+ parked on a question, and with nobody connected. It never touches the history
152
+ itself: the next request that records a user message takes every unread notice
153
+ in. A typed message carries them ahead of what the user typed, and an answer to
154
+ a question carries them after its tool results. Taking them in commits with
155
+ that request's history, so a turn whose save is lost leaves them unread for
156
+ the next one. A notice never starts a turn.
157
+
158
+ With any notices listed, `hello` tells the browser to open a fourth route,
159
+ `GET /hai/events?conversationId=…`. It streams the conversation's notices,
160
+ each with its sequence number as its SSE `id`, so a browser that reconnects
161
+ with `Last-Event-ID` picks up where it left off. It takes no lease, writes
162
+ nothing, and accepts nothing from the browser but which conversation and where
163
+ to resume. The `model` text never goes out on it. The browser sees that only
164
+ once a turn has taken it in, as part of the history the `context` event shows
165
+ the inspector, the same as a digest. It reads the store every two
166
+ seconds, and a store with `watch` wakes it the moment a notice lands. The
167
+ two-second read stays even then, so a lost wake-up only delays a notice.
168
+
116
169
  **A conversation closes once any of its surfaces passes its `staleAfterMs`.**
117
170
  From then on both routes answer with an `expired` event and the model is not
118
171
  called, so a picker left open over a weekend cannot resolve against last week's
package/dist/routes.d.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { Hai } from "./runtime.js";
3
3
  /**
4
- * Node http handler for the three routes. Returns true if it handled the
5
- * request.
4
+ * Node http handler for the three routes, and a fourth when the app sends
5
+ * notices. Returns true if it handled the request.
6
6
  *
7
- * All three stream SSE: chat and interact because both can resume the agent
8
- * loop, start because the init tool it runs can render surfaces. Note what the
9
- * interact route accepts: {handle, action, value} and nothing else. The client
10
- * cannot name a tool, a handler, or an action target.
7
+ * The three POST routes stream SSE: chat and interact because both can resume
8
+ * the agent loop, start because the init tool it runs can render surfaces.
9
+ * Note what the interact route accepts: {handle, action, value} and nothing
10
+ * else. The client cannot name a tool, a handler, or an action target.
11
+ *
12
+ * `GET /events` streams a conversation's notices. It accepts nothing from the
13
+ * browser beyond which conversation, and where to resume.
11
14
  */
12
15
  export declare function nodeHandler(hai: Hai, basePath?: string): (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
13
16
  //# sourceMappingURL=routes.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAGjE,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,cAAc,CAAC;AAExC;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,SAAS,IACxB,KAAK,eAAe,EAAE,KAAK,cAAc,KAAG,OAAO,CAAC,OAAO,CAAC,CA4E1F"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAGjE,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,cAAc,CAAC;AAExC;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,SAAS,IACxB,KAAK,eAAe,EAAE,KAAK,cAAc,KAAG,OAAO,CAAC,OAAO,CAAC,CAuF1F"}
package/dist/routes.js CHANGED
@@ -1,19 +1,28 @@
1
1
  import { isConversationBusy, isStaleLease } from "@haikit/core";
2
2
  /**
3
- * Node http handler for the three routes. Returns true if it handled the
4
- * request.
3
+ * Node http handler for the three routes, and a fourth when the app sends
4
+ * notices. Returns true if it handled the request.
5
5
  *
6
- * All three stream SSE: chat and interact because both can resume the agent
7
- * loop, start because the init tool it runs can render surfaces. Note what the
8
- * interact route accepts: {handle, action, value} and nothing else. The client
9
- * cannot name a tool, a handler, or an action target.
6
+ * The three POST routes stream SSE: chat and interact because both can resume
7
+ * the agent loop, start because the init tool it runs can render surfaces.
8
+ * Note what the interact route accepts: {handle, action, value} and nothing
9
+ * else. The client cannot name a tool, a handler, or an action target.
10
+ *
11
+ * `GET /events` streams a conversation's notices. It accepts nothing from the
12
+ * browser beyond which conversation, and where to resume.
10
13
  */
11
14
  export function nodeHandler(hai, basePath = "/hai") {
12
15
  return async function handle(req, res) {
13
16
  const url = new URL(req.url ?? "/", "http://localhost");
14
- if (req.method !== "POST" || !url.pathname.startsWith(basePath))
17
+ if (!url.pathname.startsWith(basePath))
15
18
  return false;
16
19
  const route = url.pathname.slice(basePath.length);
20
+ if (req.method === "GET" && route === "/events" && hai.sendsNotices) {
21
+ await streamNotices(hai, req, res, url);
22
+ return true;
23
+ }
24
+ if (req.method !== "POST")
25
+ return false;
17
26
  if (route !== "/chat" && route !== "/interact" && route !== "/start")
18
27
  return false;
19
28
  const body = await readJson(req);
@@ -40,7 +49,12 @@ export function nodeHandler(hai, basePath = "/hai") {
40
49
  }
41
50
  const emit = openSSE(res);
42
51
  if (route === "/chat" || route === "/start") {
43
- emit({ type: "hello", conversationId: conversation.id, model: hai.config.model.id });
52
+ emit({
53
+ type: "hello",
54
+ conversationId: conversation.id,
55
+ model: hai.config.model.id,
56
+ ...(hai.sendsNotices ? { events: true } : {}),
57
+ });
44
58
  }
45
59
  let superseded = false;
46
60
  try {
@@ -87,6 +101,150 @@ export function nodeHandler(hai, basePath = "/hai") {
87
101
  return true;
88
102
  };
89
103
  }
104
+ /**
105
+ * The longest a notice waits to be read. A store's `watch` makes it sooner;
106
+ * this is what still finds a notice whose wake-up was lost.
107
+ */
108
+ const POLL_MS = 2_000;
109
+ /** Proxies drop a stream that says nothing for long; a comment keeps it open. */
110
+ const HEARTBEAT_MS = 25_000;
111
+ /** Notices read from the store at a time, so a long backlog goes out page by page. */
112
+ const PAGE = 100;
113
+ /**
114
+ * Stream a conversation's notices: every one after where the browser has got
115
+ * to (`Last-Event-ID` on a reconnect, else `?after=`), then each as it lands.
116
+ * Takes no lease and writes nothing. Each notice goes out as its payload; its
117
+ * model text is not in it: that reaches the browser only as part of the
118
+ * history, in the `context` event, once a turn has taken it in.
119
+ *
120
+ * Bounded both ways. The store is read a page at a time, so a conversation
121
+ * with years of notices is never held in memory at once, and nothing more is
122
+ * written while the socket is still taking what was, so a reader that is slow
123
+ * cannot make the server buffer every notice for it.
124
+ */
125
+ async function streamNotices(hai, req, res, url) {
126
+ const { store } = hai.config;
127
+ const conversationId = url.searchParams.get("conversationId") ?? "";
128
+ const resume = Number(req.headers["last-event-id"] ?? url.searchParams.get("after") ?? 0);
129
+ let after = Number.isSafeInteger(resume) && resume > 0 ? resume : 0;
130
+ const first = await store.getNotices(conversationId, after, PAGE);
131
+ if (first === null) {
132
+ res.writeHead(404, { "content-type": "application/json" });
133
+ res.end(JSON.stringify({ error: "unknown conversation" }));
134
+ return;
135
+ }
136
+ res.writeHead(200, {
137
+ "content-type": "text/event-stream",
138
+ "cache-control": "no-cache",
139
+ connection: "keep-alive",
140
+ });
141
+ // Sent now, not with the first frame: with nothing to catch up on, that is a
142
+ // heartbeat away, and the browser's request would hang open until then.
143
+ res.flushHeaders();
144
+ const aborter = new AbortController();
145
+ const { signal } = aborter;
146
+ res.on("close", () => aborter.abort());
147
+ // Resolves once the socket has taken what is queued, or the browser has gone.
148
+ const write = async (chunk) => {
149
+ if (res.write(chunk))
150
+ return;
151
+ await new Promise((resolve) => {
152
+ const done = () => {
153
+ res.off("drain", done);
154
+ signal.removeEventListener("abort", done);
155
+ resolve();
156
+ };
157
+ res.on("drain", done);
158
+ signal.addEventListener("abort", done);
159
+ });
160
+ };
161
+ // Everything after `after`, a page at a time, until a page comes back short.
162
+ // A conversation deleted since the stream opened ends it: there is nothing
163
+ // more to wait for, and the browser's reconnect is then refused.
164
+ const catchUp = async (page = null) => {
165
+ for (;;) {
166
+ const records = page ?? (await store.getNotices(conversationId, after, PAGE));
167
+ if (records === null)
168
+ return aborter.abort();
169
+ for (const n of records) {
170
+ if (signal.aborted)
171
+ return;
172
+ if (n.seq <= after)
173
+ continue;
174
+ const event = {
175
+ type: "notice",
176
+ seq: n.seq,
177
+ name: n.name,
178
+ version: n.version,
179
+ payload: n.payload,
180
+ ...(n.handle === undefined ? {} : { handle: n.handle }),
181
+ };
182
+ await write(`id: ${n.seq}\ndata: ${JSON.stringify(event)}\n\n`);
183
+ after = n.seq;
184
+ }
185
+ if (records.length < PAGE || signal.aborted)
186
+ return;
187
+ page = null;
188
+ }
189
+ };
190
+ // a heartbeat is only for a stream gone quiet, never one already backed up
191
+ const heartbeat = setInterval(() => res.writableNeedDrain || res.write(": ping\n\n"), HEARTBEAT_MS);
192
+ const wakes = wakeups(store.watch?.(conversationId, signal), signal)[Symbol.asyncIterator]();
193
+ try {
194
+ await catchUp(first);
195
+ // Watching starts before the next read, so a notice that landed after the
196
+ // backlog was read is either in that read or wakes the loop.
197
+ let next = wakes.next();
198
+ await catchUp();
199
+ while (!signal.aborted) {
200
+ if ((await next).done)
201
+ break;
202
+ next = wakes.next();
203
+ await catchUp();
204
+ }
205
+ }
206
+ finally {
207
+ clearInterval(heartbeat);
208
+ aborter.abort();
209
+ await wakes.return?.();
210
+ res.end();
211
+ }
212
+ }
213
+ /**
214
+ * When the events route reads next: every POLL_MS, and sooner whenever the
215
+ * store's `watch` says a notice may have landed. `watch` alone is not enough.
216
+ * A wake-up can be lost, and a LISTEN connection can drop, and either would
217
+ * leave the stream open on heartbeats with a notice it never reads. A `watch`
218
+ * that ends or fails leaves the reads every POLL_MS to carry on.
219
+ */
220
+ async function* wakeups(watch, signal) {
221
+ const steps = watch?.[Symbol.asyncIterator]();
222
+ // started now, so the watch is listening before the caller's next read
223
+ let step = steps?.next().then((r) => !r.done, () => false) ?? null;
224
+ try {
225
+ while (!signal.aborted) {
226
+ let timer;
227
+ let onAbort;
228
+ const tick = new Promise((resolve) => {
229
+ onAbort = () => resolve("tick");
230
+ timer = setTimeout(onAbort, POLL_MS);
231
+ signal.addEventListener("abort", onAbort);
232
+ });
233
+ const woke = await Promise.race(step ? [tick, step] : [tick]);
234
+ clearTimeout(timer);
235
+ signal.removeEventListener("abort", onAbort);
236
+ if (signal.aborted)
237
+ return;
238
+ // the watch stepped: listen for the next; it ended or failed: stop listening
239
+ if (woke !== "tick")
240
+ step = woke ? steps.next().then((r) => !r.done, () => false) : null;
241
+ yield;
242
+ }
243
+ }
244
+ finally {
245
+ await steps?.return?.();
246
+ }
247
+ }
90
248
  function openSSE(res) {
91
249
  res.writeHead(200, {
92
250
  "content-type": "text/event-stream",
@@ -1 +1 @@
1
- {"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"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"}
package/dist/runtime.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type AnySurfaceImpl, type Conversation, type Emit, type ModelAdapter, type StoreAdapter, type Tool } from "@haikit/core";
1
+ import { type AnyNoticeImpl, type AnySurfaceImpl, type Conversation, type Emit, type ModelAdapter, type Notify, type StoreAdapter, type Tool } from "@haikit/core";
2
2
  export interface HaiConfig {
3
3
  model: ModelAdapter;
4
4
  store: StoreAdapter;
@@ -33,6 +33,12 @@ export interface HaiConfig {
33
33
  * model gets "already ran" back instead of running it again.
34
34
  */
35
35
  init?: Tool;
36
+ /**
37
+ * The notices this app sends with `hai.notify`. A notice not listed here is
38
+ * refused, as an unregistered surface is. With any listed, the browser opens
39
+ * the events stream (`GET {base}/events`) to receive them.
40
+ */
41
+ notices?: AnyNoticeImpl[];
36
42
  }
37
43
  /**
38
44
  * Appended to every app's system prompt unless `scope` says otherwise. Generic
@@ -44,10 +50,29 @@ export declare class Hai {
44
50
  readonly config: HaiConfig;
45
51
  private readonly surfaces;
46
52
  private readonly tools;
53
+ private readonly notices;
47
54
  /** What the model gets as its system prompt: the app's, then the scope. */
48
55
  readonly system: string;
49
56
  private seq;
50
57
  constructor(config: HaiConfig);
58
+ /** Whether this app sends notices, so the browser should open the events stream. */
59
+ get sendsNotices(): boolean;
60
+ /**
61
+ * Send a notice to a conversation: its payload to the browser now, over the
62
+ * events stream, and its model text to the model in the next user message
63
+ * the conversation records.
64
+ *
65
+ * Takes no lease, so it works while a turn is streaming, while one is
66
+ * parked, and with nobody connected. It never starts a turn.
67
+ */
68
+ readonly notify: Notify;
69
+ /**
70
+ * The notices this conversation's history hasn't taken in yet: their model
71
+ * text as blocks for the user message this request is about to record, and
72
+ * the number to set `noticedThrough` to when it does. Set it only where the
73
+ * message is pushed, so a request refused before then takes nothing in.
74
+ */
75
+ private unreadNotices;
51
76
  private nid;
52
77
  private buildQueryUiTool;
53
78
  send(conversation: Conversation, text: string, emit: Emit): Promise<void>;
@@ -59,9 +84,10 @@ export declare class Hai {
59
84
  */
60
85
  start(conversation: Conversation, emit: Emit): Promise<void>;
61
86
  /**
62
- * Run the init tool as the conversation's first tool call, after `text` —
63
- * the user's first message, or STARTED when it runs before one. Returns
64
- * true when it parked the conversation on an elicit surface.
87
+ * Run the init tool as the conversation's first tool call, after `message` —
88
+ * the user's first message, or STARTED when it runs before one, with any
89
+ * unread notices, which take the conversation's notices through `through`.
90
+ * Returns true when it parked the conversation on an elicit surface.
65
91
  *
66
92
  * Nothing reaches the history until the tool has succeeded — not even the
67
93
  * user's message — so a refused start leaves the conversation exactly as
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,KAAK,IAAI,EACT,KAAK,YAAY,EAEjB,KAAK,YAAY,EACjB,KAAK,IAAI,EAIV,MAAM,cAAc,CAAC;AAEtB,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,YAAY,CAAC;IACpB,KAAK,EAAE,YAAY,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IACvB,wCAAwC;IACxC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;;;;;;;;;;OAcG;IACH,IAAI,CAAC,EAAE,IAAI,CAAC;CACb;AASD;;;;GAIG;AACH,eAAO,MAAM,aAAa,ufAQ+B,CAAC;AAuB1D,qBAAa,GAAG;IACd,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAC3B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqC;IAC9D,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA2B;IACjD,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,OAAO,CAAC,GAAG,CAAK;gBAEJ,MAAM,EAAE,SAAS;IAsB7B,OAAO,CAAC,GAAG;IAMX,OAAO,CAAC,gBAAgB;IAkFlB,IAAI,CAAC,YAAY,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAqC/E;;;;;OAKG;IACG,KAAK,CAAC,YAAY,EAAE,YAAY,EAAE,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAQlE;;;;;;;;OAQG;YACW,OAAO;IA+Df,QAAQ,CACZ,YAAY,EAAE,YAAY,EAC1B,KAAK,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,EACzD,IAAI,EAAE,IAAI,GACT,OAAO,CAAC,IAAI,CAAC;IA2GhB;;;;;;;;;;;;;;;;OAgBG;YACW,eAAe;YA4Cf,OAAO;YA0EP,UAAU;YA6NV,WAAW;CAa1B"}
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,aAAa,EAGlB,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,KAAK,IAAI,EACT,KAAK,YAAY,EACjB,KAAK,MAAM,EAEX,KAAK,YAAY,EACjB,KAAK,IAAI,EAIV,MAAM,cAAc,CAAC;AAEtB,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,YAAY,CAAC;IACpB,KAAK,EAAE,YAAY,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IACvB,wCAAwC;IACxC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;;;;;;;;;;OAcG;IACH,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ;;;;OAIG;IACH,OAAO,CAAC,EAAE,aAAa,EAAE,CAAC;CAC3B;AASD;;;;GAIG;AACH,eAAO,MAAM,aAAa,ufAQ+B,CAAC;AAuB1D,qBAAa,GAAG;IACd,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAC3B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqC;IAC9D,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA2B;IACjD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAoC;IAC5D,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,OAAO,CAAC,GAAG,CAAK;gBAEJ,MAAM,EAAE,SAAS;IA0B7B,oFAAoF;IACpF,IAAI,YAAY,IAAI,OAAO,CAE1B;IAED;;;;;;;OAOG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CA6BrB;IAEF;;;;;OAKG;YACW,aAAa;IAY3B,OAAO,CAAC,GAAG;IAMX,OAAO,CAAC,gBAAgB;IAkFlB,IAAI,CAAC,YAAY,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IA0C/E;;;;;OAKG;IACG,KAAK,CAAC,YAAY,EAAE,YAAY,EAAE,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IASlE;;;;;;;;;OASG;YACW,OAAO;IAqEf,QAAQ,CACZ,YAAY,EAAE,YAAY,EAC1B,KAAK,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,EACzD,IAAI,EAAE,IAAI,GACT,OAAO,CAAC,IAAI,CAAC;IAmHhB;;;;;;;;;;;;;;;;OAgBG;YACW,eAAe;YA4Cf,OAAO;YA0EP,UAAU;YA6NV,WAAW;CAa1B"}
package/dist/runtime.js CHANGED
@@ -26,6 +26,7 @@ export class Hai {
26
26
  config;
27
27
  surfaces = new Map();
28
28
  tools = new Map();
29
+ notices = new Map();
29
30
  /** What the model gets as its system prompt: the app's, then the scope. */
30
31
  system;
31
32
  seq = 0;
@@ -39,6 +40,11 @@ export class Hai {
39
40
  }
40
41
  for (const t of config.tools)
41
42
  this.tools.set(t.name, t);
43
+ for (const n of config.notices ?? []) {
44
+ if (this.notices.has(n.notice.name))
45
+ throw new Error(`two notices are named "${n.notice.name}"`);
46
+ this.notices.set(n.notice.name, n);
47
+ }
42
48
  // query_ui is DERIVED, never authored. It cannot drift from the surfaces
43
49
  // that actually exist, and it always exists.
44
50
  const queryUi = this.buildQueryUiTool();
@@ -50,6 +56,64 @@ export class Hai {
50
56
  this.tools.set(config.init.name, config.init);
51
57
  }
52
58
  }
59
+ /** Whether this app sends notices, so the browser should open the events stream. */
60
+ get sendsNotices() {
61
+ return this.notices.size > 0;
62
+ }
63
+ /**
64
+ * Send a notice to a conversation: its payload to the browser now, over the
65
+ * events stream, and its model text to the model in the next user message
66
+ * the conversation records.
67
+ *
68
+ * Takes no lease, so it works while a turn is streaming, while one is
69
+ * parked, and with nobody connected. It never starts a turn.
70
+ */
71
+ notify = async (conversationId, notice, payload, options = {}) => {
72
+ const name = notice?.notice?.name;
73
+ if (this.notices.get(name) !== notice) {
74
+ throw new Error(`notice "${name}" is not registered: list it in createHai({ notices })`);
75
+ }
76
+ // Validated before it is stored: notify is called from webhooks and job
77
+ // queues, whose data comes from outside this process.
78
+ const parsed = notice.notice.payload.parse(payload);
79
+ const model = notice.impl.model(parsed);
80
+ if (model !== null && typeof model !== "string") {
81
+ throw new TypeError(`notice "${name}": model() must return a string or null`);
82
+ }
83
+ // Checked against the stored surfaces, which needs no lease. One left behind
84
+ // by an overtaken turn may pass as well, until it is swept, unlike a click
85
+ // on it: a click changes the history, and the handle here only says where
86
+ // the browser shows the notice. The model never sees it.
87
+ const { handle } = options;
88
+ if (handle !== undefined && !(await this.config.store.getPayload(handle, conversationId))) {
89
+ throw new Error(`notice "${name}": no surface ${handle} in conversation ${conversationId}`);
90
+ }
91
+ const record = await this.config.store.putNotice({
92
+ conversationId,
93
+ name,
94
+ version: notice.notice.version,
95
+ payload: parsed,
96
+ model,
97
+ ...(handle === undefined ? {} : { handle }),
98
+ });
99
+ return { seq: record.seq };
100
+ };
101
+ /**
102
+ * The notices this conversation's history hasn't taken in yet: their model
103
+ * text as blocks for the user message this request is about to record, and
104
+ * the number to set `noticedThrough` to when it does. Set it only where the
105
+ * message is pushed, so a request refused before then takes nothing in.
106
+ */
107
+ async unreadNotices(conversation) {
108
+ const through = conversation.noticedThrough ?? 0;
109
+ if (!this.notices.size)
110
+ return { blocks: [], through };
111
+ const fresh = (await this.config.store.getNotices(conversation.id, through)) ?? [];
112
+ return {
113
+ blocks: fresh.flatMap((n) => n.model === null ? [] : [{ type: "text", text: `[App notification: ${n.name}] ${n.model}` }]),
114
+ through: fresh.at(-1)?.seq ?? through,
115
+ };
116
+ }
53
117
  nid(prefix) {
54
118
  return `${prefix}_${++this.seq}`;
55
119
  }
@@ -128,6 +192,10 @@ export class Hai {
128
192
  return;
129
193
  // The first message is the one that starts the conversation.
130
194
  const starting = conversation.messages.length === 0;
195
+ // Notices ride in the user's message, ahead of what they typed: they
196
+ // arrived before it.
197
+ const unread = await this.unreadNotices(conversation);
198
+ const message = withText(unread.blocks, text);
131
199
  // The user typed while a tool was parked. Every tool_use in a turn must
132
200
  // receive a tool_result, so close the pending one out honestly first.
133
201
  if (conversation.status === "awaiting" && conversation.pending) {
@@ -151,11 +219,12 @@ export class Hai {
151
219
  }
152
220
  emit({ type: "block_start", block: { kind: "user", id: this.nid("b"), text } });
153
221
  if (starting && this.config.init) {
154
- if (await this.runInit(conversation, text, emit))
222
+ if (await this.runInit(conversation, message, emit, unread.through))
155
223
  return; // parked on its surface
156
224
  }
157
225
  else {
158
- conversation.messages.push({ role: "user", content: text });
226
+ conversation.messages.push({ role: "user", content: message });
227
+ conversation.noticedThrough = unread.through;
159
228
  }
160
229
  await this.runTurn(conversation, emit);
161
230
  }
@@ -168,22 +237,24 @@ export class Hai {
168
237
  async start(conversation, emit) {
169
238
  if (!this.config.init || conversation.messages.length > 0)
170
239
  return;
171
- if (await this.runInit(conversation, STARTED, emit))
240
+ const unread = await this.unreadNotices(conversation);
241
+ if (await this.runInit(conversation, withText(unread.blocks, STARTED), emit, unread.through))
172
242
  return; // parked on its surface
173
243
  conversation.status = "idle";
174
244
  emit({ type: "status", status: "idle" });
175
245
  await this.emitContext(conversation, emit);
176
246
  }
177
247
  /**
178
- * Run the init tool as the conversation's first tool call, after `text` —
179
- * the user's first message, or STARTED when it runs before one. Returns
180
- * true when it parked the conversation on an elicit surface.
248
+ * Run the init tool as the conversation's first tool call, after `message` —
249
+ * the user's first message, or STARTED when it runs before one, with any
250
+ * unread notices, which take the conversation's notices through `through`.
251
+ * Returns true when it parked the conversation on an elicit surface.
181
252
  *
182
253
  * Nothing reaches the history until the tool has succeeded — not even the
183
254
  * user's message — so a refused start leaves the conversation exactly as
184
255
  * the route found it, and the next message starts it again.
185
256
  */
186
- async runInit(conversation, text, emit) {
257
+ async runInit(conversation, message, emit, through) {
187
258
  const init = this.config.init;
188
259
  const call = { type: "tool_use", id: `toolu_init_${newId()}`, name: init.name, input: {} };
189
260
  const toolBlockId = this.nid("b");
@@ -220,7 +291,8 @@ export class Hai {
220
291
  const ms = Date.now() - started;
221
292
  for (const e of held)
222
293
  emit(e);
223
- conversation.messages.push({ role: "user", content: text });
294
+ conversation.messages.push({ role: "user", content: message });
295
+ conversation.noticedThrough = through;
224
296
  conversation.messages.push({ role: "assistant", content: [call] });
225
297
  if (asked) {
226
298
  emit({ type: "block_update", id: toolBlockId, status: "awaiting", ms, result: asked.digest });
@@ -294,11 +366,14 @@ export class Hai {
294
366
  catch (err) {
295
367
  throw new Error(`action failed: ${err.message}`);
296
368
  }
369
+ // Read after the handler, so a notice its own work sent rides with the click.
370
+ const unread = await this.unreadNotices(conversation);
297
371
  // what recording the click changes, so a failed save below can undo it
298
372
  const before = {
299
373
  messages: conversation.messages.length,
300
374
  frozen: conversation.frozen.length,
301
375
  status: conversation.status,
376
+ noticedThrough: conversation.noticedThrough,
302
377
  };
303
378
  if (pending) {
304
379
  // resolve: the gate above let it through only for the waiting surface
@@ -314,6 +389,8 @@ export class Hai {
314
389
  // so nothing about the search has reached the model yet.
315
390
  content: `${pending.digest}\n${label}`,
316
391
  },
392
+ // after the tool results, which the API requires to come first
393
+ ...unread.blocks,
317
394
  ],
318
395
  });
319
396
  conversation.pending = null;
@@ -321,8 +398,9 @@ export class Hai {
321
398
  }
322
399
  else {
323
400
  // inform: enriches the conversation without having blocked it
324
- conversation.messages.push({ role: "user", content: `[UI interaction] ${label}` });
401
+ conversation.messages.push({ role: "user", content: withText(unread.blocks, `[UI interaction] ${label}`) });
325
402
  }
403
+ conversation.noticedThrough = unread.through;
326
404
  // Commit the click before the model runs. The handler may have written,
327
405
  // and a model turn can outlast the lease; saved now, the click stays on
328
406
  // record however the turn ends, so it cannot be answered — and its handler
@@ -343,6 +421,7 @@ export class Hai {
343
421
  conversation.frozen.length = before.frozen;
344
422
  conversation.pending = pending;
345
423
  conversation.status = before.status;
424
+ conversation.noticedThrough = before.noticedThrough;
346
425
  throw err;
347
426
  }
348
427
  if (pending)
@@ -733,6 +812,12 @@ function strictest(recorded, current) {
733
812
  return finite.length ? Math.min(...finite) : "never";
734
813
  }
735
814
  const newId = () => globalThis.crypto.randomUUID().replaceAll("-", "").slice(0, 16);
815
+ /**
816
+ * A user message: `text` after any notice blocks, or `text` alone when there
817
+ * are none — so a conversation with no notices keeps exactly the history it
818
+ * always had.
819
+ */
820
+ const withText = (blocks, text) => blocks.length ? [...blocks, { type: "text", text }] : text;
736
821
  /** The least time between two progress frames from one tool call. */
737
822
  const PROGRESS_MS = 100;
738
823
  /**