@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 +53 -0
- package/dist/routes.d.ts +9 -6
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +166 -8
- package/dist/routes.js.map +1 -1
- package/dist/runtime.d.ts +30 -4
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +94 -9
- package/dist/runtime.js.map +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +69 -0
- package/dist/store.js.map +1 -1
- package/package.json +2 -2
- package/src/routes.ts +167 -10
- package/src/runtime.ts +118 -9
- package/src/store.ts +65 -0
package/src/runtime.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
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,
|
|
9
11
|
type Progress,
|
|
10
12
|
type StoreAdapter,
|
|
11
13
|
type Tool,
|
|
@@ -48,6 +50,12 @@ export interface HaiConfig {
|
|
|
48
50
|
* model gets "already ran" back instead of running it again.
|
|
49
51
|
*/
|
|
50
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[];
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
/**
|
|
@@ -97,6 +105,7 @@ export class Hai {
|
|
|
97
105
|
readonly config: HaiConfig;
|
|
98
106
|
private readonly surfaces = new Map<string, AnySurfaceImpl>();
|
|
99
107
|
private readonly tools = new Map<string, Tool>();
|
|
108
|
+
private readonly notices = new Map<string, AnyNoticeImpl>();
|
|
100
109
|
/** What the model gets as its system prompt: the app's, then the scope. */
|
|
101
110
|
readonly system: string;
|
|
102
111
|
private seq = 0;
|
|
@@ -110,6 +119,10 @@ export class Hai {
|
|
|
110
119
|
this.surfaces.set(s.surface.name, s);
|
|
111
120
|
}
|
|
112
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
|
+
}
|
|
113
126
|
// query_ui is DERIVED, never authored. It cannot drift from the surfaces
|
|
114
127
|
// that actually exist, and it always exists.
|
|
115
128
|
const queryUi = this.buildQueryUiTool();
|
|
@@ -123,6 +136,68 @@ export class Hai {
|
|
|
123
136
|
}
|
|
124
137
|
}
|
|
125
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
|
+
|
|
126
201
|
private nid(prefix: string) {
|
|
127
202
|
return `${prefix}_${++this.seq}`;
|
|
128
203
|
}
|
|
@@ -216,6 +291,10 @@ export class Hai {
|
|
|
216
291
|
|
|
217
292
|
// The first message is the one that starts the conversation.
|
|
218
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);
|
|
219
298
|
|
|
220
299
|
// The user typed while a tool was parked. Every tool_use in a turn must
|
|
221
300
|
// receive a tool_result, so close the pending one out honestly first.
|
|
@@ -241,9 +320,10 @@ export class Hai {
|
|
|
241
320
|
|
|
242
321
|
emit({ type: "block_start", block: { kind: "user", id: this.nid("b"), text } });
|
|
243
322
|
if (starting && this.config.init) {
|
|
244
|
-
if (await this.runInit(conversation,
|
|
323
|
+
if (await this.runInit(conversation, message, emit, unread.through)) return; // parked on its surface
|
|
245
324
|
} else {
|
|
246
|
-
conversation.messages.push({ role: "user", content:
|
|
325
|
+
conversation.messages.push({ role: "user", content: message });
|
|
326
|
+
conversation.noticedThrough = unread.through;
|
|
247
327
|
}
|
|
248
328
|
await this.runTurn(conversation, emit);
|
|
249
329
|
}
|
|
@@ -256,22 +336,29 @@ export class Hai {
|
|
|
256
336
|
*/
|
|
257
337
|
async start(conversation: Conversation, emit: Emit): Promise<void> {
|
|
258
338
|
if (!this.config.init || conversation.messages.length > 0) return;
|
|
259
|
-
|
|
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
|
|
260
341
|
conversation.status = "idle";
|
|
261
342
|
emit({ type: "status", status: "idle" });
|
|
262
343
|
await this.emitContext(conversation, emit);
|
|
263
344
|
}
|
|
264
345
|
|
|
265
346
|
/**
|
|
266
|
-
* Run the init tool as the conversation's first tool call, after `
|
|
267
|
-
* the user's first message, or STARTED when it runs before one
|
|
268
|
-
*
|
|
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.
|
|
269
351
|
*
|
|
270
352
|
* Nothing reaches the history until the tool has succeeded — not even the
|
|
271
353
|
* user's message — so a refused start leaves the conversation exactly as
|
|
272
354
|
* the route found it, and the next message starts it again.
|
|
273
355
|
*/
|
|
274
|
-
private async runInit(
|
|
356
|
+
private async runInit(
|
|
357
|
+
conversation: Conversation,
|
|
358
|
+
message: string | TextBlock[],
|
|
359
|
+
emit: Emit,
|
|
360
|
+
through: number,
|
|
361
|
+
): Promise<boolean> {
|
|
275
362
|
const init = this.config.init!;
|
|
276
363
|
const call = { type: "tool_use", id: `toolu_init_${newId()}`, name: init.name, input: {} };
|
|
277
364
|
const toolBlockId = this.nid("b");
|
|
@@ -307,7 +394,8 @@ export class Hai {
|
|
|
307
394
|
const ms = Date.now() - started;
|
|
308
395
|
for (const e of held) emit(e);
|
|
309
396
|
|
|
310
|
-
conversation.messages.push({ role: "user", content:
|
|
397
|
+
conversation.messages.push({ role: "user", content: message });
|
|
398
|
+
conversation.noticedThrough = through;
|
|
311
399
|
conversation.messages.push({ role: "assistant", content: [call] });
|
|
312
400
|
|
|
313
401
|
if (asked) {
|
|
@@ -389,11 +477,15 @@ export class Hai {
|
|
|
389
477
|
throw new Error(`action failed: ${(err as Error).message}`);
|
|
390
478
|
}
|
|
391
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
|
+
|
|
392
483
|
// what recording the click changes, so a failed save below can undo it
|
|
393
484
|
const before = {
|
|
394
485
|
messages: conversation.messages.length,
|
|
395
486
|
frozen: conversation.frozen.length,
|
|
396
487
|
status: conversation.status,
|
|
488
|
+
noticedThrough: conversation.noticedThrough,
|
|
397
489
|
};
|
|
398
490
|
if (pending) {
|
|
399
491
|
// resolve: the gate above let it through only for the waiting surface
|
|
@@ -409,14 +501,17 @@ export class Hai {
|
|
|
409
501
|
// so nothing about the search has reached the model yet.
|
|
410
502
|
content: `${pending.digest}\n${label}`,
|
|
411
503
|
},
|
|
504
|
+
// after the tool results, which the API requires to come first
|
|
505
|
+
...unread.blocks,
|
|
412
506
|
],
|
|
413
507
|
});
|
|
414
508
|
conversation.pending = null;
|
|
415
509
|
conversation.status = "idle"; // answered; the save below records that
|
|
416
510
|
} else {
|
|
417
511
|
// inform: enriches the conversation without having blocked it
|
|
418
|
-
conversation.messages.push({ role: "user", content: `[UI interaction] ${label}` });
|
|
512
|
+
conversation.messages.push({ role: "user", content: withText(unread.blocks, `[UI interaction] ${label}`) });
|
|
419
513
|
}
|
|
514
|
+
conversation.noticedThrough = unread.through;
|
|
420
515
|
|
|
421
516
|
// Commit the click before the model runs. The handler may have written,
|
|
422
517
|
// and a model turn can outlast the lease; saved now, the click stays on
|
|
@@ -437,6 +532,7 @@ export class Hai {
|
|
|
437
532
|
conversation.frozen.length = before.frozen;
|
|
438
533
|
conversation.pending = pending;
|
|
439
534
|
conversation.status = before.status;
|
|
535
|
+
conversation.noticedThrough = before.noticedThrough;
|
|
440
536
|
throw err;
|
|
441
537
|
}
|
|
442
538
|
|
|
@@ -859,6 +955,19 @@ function strictest(recorded: unknown, current: unknown): number | "never" | unde
|
|
|
859
955
|
|
|
860
956
|
const newId = () => globalThis.crypto.randomUUID().replaceAll("-", "").slice(0, 16);
|
|
861
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
|
+
|
|
862
971
|
/** The least time between two progress frames from one tool call. */
|
|
863
972
|
const PROGRESS_MS = 100;
|
|
864
973
|
|
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
|
}
|