@fungi.computer/oyster 0.4.1 → 0.6.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.
Files changed (86) hide show
  1. package/README.md +46 -8
  2. package/dist/agents.d.ts +2 -1
  3. package/dist/agents.js +67 -8
  4. package/dist/apps/gate.d.ts +4 -0
  5. package/dist/apps/gate.js +3 -0
  6. package/dist/auth.d.ts +24 -1
  7. package/dist/auth.js +38 -0
  8. package/dist/boot-host-script.js +1 -1
  9. package/dist/build/candidate-cli.mjs +27 -5
  10. package/dist/channels/binding.d.ts +70 -0
  11. package/dist/channels/binding.js +97 -0
  12. package/dist/channels/conversations.d.ts +246 -0
  13. package/dist/channels/conversations.js +705 -0
  14. package/dist/channels/ids.d.ts +8 -0
  15. package/dist/channels/ids.js +15 -0
  16. package/dist/channels/visitor-channel.d.ts +53 -0
  17. package/dist/channels/visitor-channel.js +124 -0
  18. package/dist/channels/visitor-edge.d.ts +115 -0
  19. package/dist/channels/visitor-edge.js +582 -0
  20. package/dist/channels/visitor-request.d.ts +7 -0
  21. package/dist/channels/visitor-request.js +17 -0
  22. package/dist/channels/visitor-stream-slots.d.ts +2 -0
  23. package/dist/channels/visitor-stream-slots.js +33 -0
  24. package/dist/channels/visitors.d.ts +47 -0
  25. package/dist/channels/visitors.js +181 -0
  26. package/dist/cloudflare.d.ts +8 -2
  27. package/dist/cloudflare.js +27 -1
  28. package/dist/computer-target.d.ts +5 -0
  29. package/dist/computer-target.js +11 -0
  30. package/dist/host.d.ts +7 -4
  31. package/dist/host.js +96 -17
  32. package/dist/internal/host-gate.d.ts +6 -4
  33. package/dist/internal/host-gate.js +14 -7
  34. package/dist/invocation.d.ts +11 -4
  35. package/dist/invocation.js +134 -91
  36. package/dist/keys.d.ts +4 -1
  37. package/dist/keys.js +15 -4
  38. package/dist/machine-link/authority.d.ts +7 -0
  39. package/dist/machine-link/delivery.d.ts +12 -0
  40. package/dist/machine-link/delivery.js +22 -1
  41. package/dist/machine-link/disposition.d.ts +16 -0
  42. package/dist/machine-link/disposition.js +65 -0
  43. package/dist/machine-link/host.d.ts +11 -2
  44. package/dist/machine-link/host.js +125 -5
  45. package/dist/machine-link/link.d.ts +4 -0
  46. package/dist/machine-link/link.js +47 -1
  47. package/dist/page/capabilities.js +1 -1
  48. package/dist/page/child-selection.d.ts +27 -0
  49. package/dist/page/child-selection.js +38 -0
  50. package/dist/page/mount.js +1 -1
  51. package/dist/page-script.js +4 -4
  52. package/dist/release.d.ts +13 -0
  53. package/dist/release.js +10 -0
  54. package/dist/request-origin.d.ts +4 -0
  55. package/dist/request-origin.js +10 -0
  56. package/dist/standalone/agent-mail.js +4 -1
  57. package/dist/standalone/agents.d.ts +8 -0
  58. package/dist/standalone/agents.js +30 -11
  59. package/dist/standalone/channel-session-door.d.ts +4 -0
  60. package/dist/standalone/channel-session-door.js +72 -0
  61. package/dist/standalone/computer-fleet.js +7 -1
  62. package/dist/standalone/machine-link.d.ts +2 -0
  63. package/dist/standalone/machine-link.js +34 -1
  64. package/dist/standalone/tenancy.d.ts +3 -0
  65. package/dist/standalone/tenancy.js +7 -2
  66. package/dist/testing/capabilities.d.ts +2 -0
  67. package/dist/testing/capabilities.js +3 -1
  68. package/dist/turnstile.d.ts +27 -0
  69. package/dist/turnstile.js +51 -0
  70. package/dist/workers-git.js +21 -2
  71. package/dist/workers.d.ts +3 -0
  72. package/dist/workers.js +15 -12
  73. package/docs/api/agents.md +8 -4
  74. package/docs/api/apps.md +8 -4
  75. package/docs/api/auth.md +55 -0
  76. package/docs/api/browser.md +40 -16
  77. package/docs/api/build-contract.md +2 -2
  78. package/docs/api/cloudflare.md +26 -1
  79. package/docs/api/host.md +22 -13
  80. package/docs/api/invocation.md +7 -3
  81. package/docs/api/keys.md +25 -3
  82. package/docs/api/workers.md +38 -30
  83. package/docs/site/agent-quickstart.mdx +29 -2
  84. package/examples/standalone/wrangler.celld.jsonc +66 -0
  85. package/examples/standalone/wrangler.jsonc +3 -4
  86. package/package.json +53 -17
@@ -0,0 +1,705 @@
1
+ import { z } from "zod";
2
+ import { authorMeta, FungiMessageMetaSchema, } from "@fungi.computer/agar/agents";
3
+ import { handoffSchema } from "@fungi.computer/agar/channels";
4
+ import { EVENT_REPLAY_WINDOW_SECONDS, channelPartySchema, limitsOf, partyLabel, } from "./binding.js";
5
+ /** Decide one handoff; persistence, notifications and cancellation stay with the owner. */
6
+ export function transitionHandoff(current, event) {
7
+ if (current === null)
8
+ return { kind: "not_found" };
9
+ if (current.version !== event.expectedVersion)
10
+ return { kind: "conflict" };
11
+ switch (event.kind) {
12
+ case "request":
13
+ if (current.state === "requested")
14
+ return { kind: "ok", handoff: current };
15
+ if (current.state !== "agent")
16
+ return { kind: "not_holder" };
17
+ return {
18
+ kind: "ok",
19
+ handoff: {
20
+ state: "requested",
21
+ version: current.version + 1,
22
+ reason: event.reason,
23
+ at: event.at,
24
+ },
25
+ };
26
+ case "take_over":
27
+ if (current.state === "person") {
28
+ if (current.memberId === event.member.id)
29
+ return { kind: "ok", handoff: current };
30
+ if (!event.confirm)
31
+ return { kind: "confirmation_required" };
32
+ }
33
+ return {
34
+ kind: "ok",
35
+ handoff: {
36
+ state: "person",
37
+ version: current.version + 1,
38
+ memberId: event.member.id,
39
+ memberName: event.member.name,
40
+ since: event.at,
41
+ },
42
+ };
43
+ case "hand_back":
44
+ if (current.state !== "person" ||
45
+ (current.memberId !== event.memberId && !event.confirm))
46
+ return { kind: "not_holder" };
47
+ return {
48
+ kind: "ok",
49
+ handoff: { state: "agent", version: current.version + 1 },
50
+ };
51
+ case "dismiss":
52
+ return current.state === "requested"
53
+ ? {
54
+ kind: "ok",
55
+ handoff: { state: "agent", version: current.version + 1 },
56
+ }
57
+ : { kind: "not_holder" };
58
+ }
59
+ }
60
+ const boundedKey = z
61
+ .string()
62
+ .min(1)
63
+ .max(160)
64
+ .regex(/^[\x21-\x7e]+$/u, "Printable ASCII only");
65
+ /** One verified inbound event from a provider edge. */
66
+ const inboundEventSchema = z.strictObject({
67
+ bindingId: z.string().min(1).max(64),
68
+ /** Adapter-chosen conversation key: visitor id, thread id, Message-ID… */
69
+ conversation: boundedKey,
70
+ /** Provider event identity; a retry carries the same one. */
71
+ eventId: boundedKey,
72
+ party: channelPartySchema,
73
+ text: z.string().min(1).max(16_000),
74
+ /** Present for a host-translated caller; provider events use their own identity. */
75
+ sourceRequestId: FungiMessageMetaSchema.shape.sourceRequestId,
76
+ /**
77
+ * SHA-256 (hex) of the verified request body. GitHub does not sign its
78
+ * delivery id, so intake dedupes GitHub events on this too.
79
+ */
80
+ bodyDigest: z
81
+ .string()
82
+ .regex(/^[a-f0-9]{64}$/u)
83
+ .optional(),
84
+ });
85
+ /** Channel events and composer requests do not share the Session owner's key space. */
86
+ export function channelRequestId(bindingId, eventId) {
87
+ return `ch:${bindingId}:${eventId}`;
88
+ }
89
+ const takeoverRow = z.strictObject({
90
+ session_id: z.string(),
91
+ version: z.number().int().positive(),
92
+ member_id: z.string().min(1).max(256),
93
+ member_name: z.string().min(1).max(128),
94
+ previous_member_id: z.string().nullable(),
95
+ taken_at: z.number().int(),
96
+ });
97
+ const DAY_MS = 86_400_000;
98
+ const HOUR_MS = 3_600_000;
99
+ const MINUTE_MS = 60_000;
100
+ /** The one conversation every event of a `named` events binding joins. */
101
+ const NAMED_EVENT_CONVERSATION = "events";
102
+ const conversationRow = z.strictObject({
103
+ binding_id: z.string(),
104
+ conversation: z.string(),
105
+ session_id: z.string(),
106
+ party_json: z.string(),
107
+ created_at: z.number().int(),
108
+ last_event_at: z.number().int(),
109
+ handoff_json: z.string(),
110
+ notify_due_at: z.number().int().nullable(),
111
+ });
112
+ const intakeRow = z.strictObject({
113
+ conversation: z.string(),
114
+ session_id: z.string(),
115
+ text: z.string(),
116
+ state: z.enum(["pending", "accepted", "held"]),
117
+ command_id: z.string().nullable(),
118
+ });
119
+ const countRow = z.strictObject({ count: z.number().int() });
120
+ const eventReceiptRow = z.strictObject({
121
+ event_id: z.string(),
122
+ received_at: z.number().int(),
123
+ state: z.enum(["pending", "accepted", "held"]),
124
+ size: z.number().int().nonnegative(),
125
+ session_id: z.string(),
126
+ });
127
+ /**
128
+ * Oyster's channel conversation owner. It maps (binding, conversation) to one
129
+ * server-minted Session, records each inbound event as pending with one atomic
130
+ * insert-or-ignore, submits it through the Session door under a derived
131
+ * requestId, and marks it accepted only after the door committed. A retry of
132
+ * the same event replays the same command; changed text conflicts.
133
+ */
134
+ const sessionIdRow = z.object({ session_id: z.string() });
135
+ export function createChannelConversations(options) {
136
+ const { sql } = options;
137
+ const ensure = () => {
138
+ sql.exec(`CREATE TABLE IF NOT EXISTS hub_channel_conversations (
139
+ binding_id TEXT NOT NULL,
140
+ conversation TEXT NOT NULL,
141
+ session_id TEXT NOT NULL UNIQUE,
142
+ party_json TEXT NOT NULL,
143
+ created_at INTEGER NOT NULL,
144
+ last_event_at INTEGER NOT NULL,
145
+ handoff_json TEXT NOT NULL DEFAULT '{"state":"agent","version":0}' CHECK (json_valid(handoff_json)),
146
+ notify_due_at INTEGER,
147
+ PRIMARY KEY (binding_id, conversation)
148
+ ) STRICT`);
149
+ sql.exec(`CREATE TABLE IF NOT EXISTS hub_channel_intake (
150
+ binding_id TEXT NOT NULL,
151
+ event_id TEXT NOT NULL,
152
+ conversation TEXT NOT NULL,
153
+ session_id TEXT NOT NULL,
154
+ text TEXT NOT NULL,
155
+ state TEXT NOT NULL CHECK (state IN ('pending','accepted','held')),
156
+ command_id TEXT,
157
+ received_at INTEGER NOT NULL,
158
+ body_digest TEXT,
159
+ PRIMARY KEY (binding_id, event_id)
160
+ ) STRICT`);
161
+ sql.exec("CREATE UNIQUE INDEX IF NOT EXISTS hub_channel_intake_body_digest ON hub_channel_intake(binding_id, body_digest) WHERE body_digest IS NOT NULL");
162
+ sql.exec("CREATE INDEX IF NOT EXISTS hub_channel_intake_binding ON hub_channel_intake(binding_id, received_at)");
163
+ sql.exec("CREATE INDEX IF NOT EXISTS hub_channel_intake_party ON hub_channel_intake(binding_id, conversation, received_at)");
164
+ sql.exec(`CREATE TABLE IF NOT EXISTS hub_channel_bans (
165
+ binding_id TEXT NOT NULL,
166
+ conversation TEXT NOT NULL,
167
+ banned_at INTEGER NOT NULL,
168
+ PRIMARY KEY (binding_id, conversation)
169
+ ) STRICT`);
170
+ sql.exec(`CREATE TABLE IF NOT EXISTS hub_channel_takeovers (
171
+ session_id TEXT NOT NULL,
172
+ version INTEGER NOT NULL,
173
+ member_id TEXT NOT NULL,
174
+ member_name TEXT NOT NULL,
175
+ previous_member_id TEXT,
176
+ taken_at INTEGER NOT NULL,
177
+ PRIMARY KEY (session_id, version)
178
+ ) STRICT`);
179
+ sql.exec(`CREATE TABLE IF NOT EXISTS hub_channel_turns (
180
+ session_id TEXT NOT NULL,
181
+ command_id TEXT NOT NULL,
182
+ handoff_version INTEGER NOT NULL,
183
+ PRIMARY KEY (session_id, command_id)
184
+ ) STRICT`);
185
+ sql.exec(`CREATE TABLE IF NOT EXISTS hub_channel_reply_delivery (
186
+ session_id TEXT NOT NULL,
187
+ command_id TEXT NOT NULL,
188
+ kind TEXT NOT NULL CHECK(kind IN ('message','settlement')),
189
+ reply_id TEXT NOT NULL,
190
+ delivered INTEGER NOT NULL CHECK(delivered IN (0,1)),
191
+ PRIMARY KEY (session_id, command_id, kind, reply_id)
192
+ ) STRICT`);
193
+ };
194
+ const decodeConversation = (row) => {
195
+ const party = channelPartySchema.parse(JSON.parse(row.party_json));
196
+ return {
197
+ bindingId: row.binding_id,
198
+ conversation: row.conversation,
199
+ sessionId: row.session_id,
200
+ party,
201
+ label: partyLabel(party),
202
+ createdAt: row.created_at,
203
+ lastEventAt: row.last_event_at,
204
+ handoff: handoffSchema.parse(JSON.parse(row.handoff_json)),
205
+ };
206
+ };
207
+ const findConversation = (bindingId, conversation) => {
208
+ ensure();
209
+ const raw = sql
210
+ .exec("SELECT * FROM hub_channel_conversations WHERE binding_id=? AND conversation=?", bindingId, conversation)
211
+ .toArray()[0];
212
+ return raw === undefined
213
+ ? null
214
+ : decodeConversation(conversationRow.parse(raw));
215
+ };
216
+ const count = (statement, ...bindings) => countRow.parse(sql.exec(statement, ...bindings).toArray()[0]).count;
217
+ /** Which binding, if any, a Session belongs to. Unbound means owner. */
218
+ const sessionConversation = (sessionId) => {
219
+ ensure();
220
+ const raw = sql
221
+ .exec("SELECT * FROM hub_channel_conversations WHERE session_id=?", sessionId)
222
+ .toArray()[0];
223
+ return raw === undefined
224
+ ? null
225
+ : decodeConversation(conversationRow.parse(raw));
226
+ };
227
+ /** Every channel conversation's Session; owner Sessions are not listed. */
228
+ const sessionIds = () => {
229
+ ensure();
230
+ return sql
231
+ .exec("SELECT session_id FROM hub_channel_conversations")
232
+ .toArray()
233
+ .map((raw) => sessionIdRow.parse(raw).session_id);
234
+ };
235
+ /** Open (or find) the conversation Session without submitting work. */
236
+ const open = async (binding, conversation, party) => {
237
+ if (isBanned(binding.bindingId, conversation))
238
+ return { kind: "denied", reason: "banned" };
239
+ const existing = findConversation(binding.bindingId, conversation);
240
+ if (existing !== null)
241
+ return { kind: "open", conversation: existing };
242
+ const limits = limitsOf(binding);
243
+ const now = options.now();
244
+ if (count("SELECT COUNT(*) AS count FROM hub_channel_conversations WHERE binding_id=? AND created_at>?", binding.bindingId, now - HOUR_MS) >= limits.newPartiesPerHour)
245
+ return { kind: "denied", reason: "new_party_limit" };
246
+ // Session creation is idempotent per request id, so a crash between the
247
+ // door and this row leaves nothing to reconcile: the retry finds the same
248
+ // Session.
249
+ const sessionId = await options.door.createSession(`ch:${binding.bindingId}:${conversation}`);
250
+ options.transactionSync(() => {
251
+ sql.exec("INSERT OR IGNORE INTO hub_channel_conversations (binding_id,conversation,session_id,party_json,created_at,last_event_at) VALUES (?,?,?,?,?,?)", binding.bindingId, conversation, sessionId, JSON.stringify(party), now, now);
252
+ });
253
+ const opened = findConversation(binding.bindingId, conversation);
254
+ if (opened === null)
255
+ throw new Error("Channel conversation was not stored");
256
+ // The row now names the binding, so run admission is known: deliver tools
257
+ // before any run of this Session can call a tool.
258
+ options.door.deliverGrants(sessionId);
259
+ return { kind: "open", conversation: opened };
260
+ };
261
+ const isBanned = (bindingId, conversation) => {
262
+ ensure();
263
+ return (sql
264
+ .exec("SELECT 1 FROM hub_channel_bans WHERE binding_id=? AND conversation=?", bindingId, conversation)
265
+ .toArray().length > 0);
266
+ };
267
+ /**
268
+ * The conversation an event lands in. An incoming-events binding owns the
269
+ * choice: `named` joins every event to one Session, `new` gives each event
270
+ * the key its edge derived from the event id.
271
+ */
272
+ const conversationOf = (binding, event) => binding.selector.provider === "event" && binding.selector.target === "named"
273
+ ? NAMED_EVENT_CONVERSATION
274
+ : event.conversation;
275
+ /**
276
+ * The digest an event is deduped on: only GitHub's, whose delivery id the
277
+ * sender does not sign. Every other verifier either signs its event id
278
+ * (fungi-v1) or has none to dedupe on (secret-url: each request is new).
279
+ */
280
+ const replayDigest = (binding, event) => {
281
+ if (binding.selector.provider !== "event" ||
282
+ binding.selector.verifier !== "github")
283
+ return null;
284
+ // The edge always computes it; a GitHub event without one is a bug there.
285
+ if (event.bodyDigest === undefined)
286
+ throw new Error("A GitHub event needs the digest of its verified body");
287
+ return event.bodyDigest;
288
+ };
289
+ /** The event id that already admitted this body inside the replay window. */
290
+ const digestHit = (bindingId, digest, now) => {
291
+ const row = sql
292
+ .exec("SELECT event_id FROM hub_channel_intake WHERE binding_id=? AND body_digest=? AND received_at>?", bindingId, digest, now - EVENT_REPLAY_WINDOW_SECONDS * 1_000)
293
+ .toArray()[0];
294
+ return row === undefined
295
+ ? null
296
+ : z.strictObject({ event_id: z.string() }).parse(row).event_id;
297
+ };
298
+ /** Has this events binding admitted its per-minute allowance already? */
299
+ const rateLimited = (binding, now) => binding.selector.provider === "event" &&
300
+ count("SELECT COUNT(*) AS count FROM hub_channel_intake WHERE binding_id=? AND received_at>?", binding.bindingId, now - MINUTE_MS) >= binding.selector.eventsPerMinute;
301
+ /** Accept one inbound event as ordinary Agent work, exactly once. */
302
+ const admit = async (rawEvent, retried) => {
303
+ const parsed = inboundEventSchema.parse(rawEvent);
304
+ ensure();
305
+ const binding = options.binding(parsed.bindingId);
306
+ if (binding === null)
307
+ return { kind: "denied", reason: "unmapped" };
308
+ if (binding.lifecycle !== "active")
309
+ return { kind: "denied", reason: "suspended" };
310
+ const conversation = conversationOf(binding, parsed);
311
+ if (isBanned(binding.bindingId, conversation))
312
+ return { kind: "denied", reason: "banned" };
313
+ const limits = limitsOf(binding);
314
+ if (parsed.text.length > limits.charsPerMessage)
315
+ return { kind: "denied", reason: "too_long" };
316
+ // A body that already admitted an event inside the replay window is that
317
+ // event again, whatever delivery id the replay carries.
318
+ const digest = replayDigest(binding, parsed);
319
+ const replayOf = digest === null
320
+ ? null
321
+ : digestHit(binding.bindingId, digest, options.now());
322
+ const event = {
323
+ ...parsed,
324
+ conversation,
325
+ eventId: replayOf ?? parsed.eventId,
326
+ };
327
+ const prior = sql
328
+ .exec("SELECT conversation,session_id,text,state,command_id FROM hub_channel_intake WHERE binding_id=? AND event_id=?", binding.bindingId, event.eventId)
329
+ .toArray()[0];
330
+ const priorIntake = prior === undefined ? null : intakeRow.parse(prior);
331
+ // Visitor bearers own separate conversations. A verified provider-body
332
+ // replay, in contrast, intentionally resolves to its original conversation.
333
+ if (priorIntake !== null &&
334
+ ((event.party.kind === "visitor" &&
335
+ priorIntake.conversation !== event.conversation) ||
336
+ priorIntake.text !== event.text))
337
+ return { kind: "denied", reason: "event_conflict" };
338
+ if (priorIntake?.state === "accepted" && priorIntake.command_id !== null)
339
+ return {
340
+ kind: "accepted",
341
+ sessionId: priorIntake.session_id,
342
+ commandId: priorIntake.command_id,
343
+ delivery: "replay",
344
+ };
345
+ if (priorIntake?.state === "held")
346
+ return {
347
+ kind: "held",
348
+ sessionId: priorIntake.session_id,
349
+ delivery: "replay",
350
+ };
351
+ let sessionId;
352
+ if (priorIntake === null) {
353
+ // Refuse a flood before it opens a Session per rejected event.
354
+ if (rateLimited(binding, options.now()))
355
+ return { kind: "denied", reason: "rate_limited" };
356
+ const opened = await open(binding, event.conversation, event.party);
357
+ if (opened.kind === "denied")
358
+ return opened;
359
+ if (opened.conversation.party.kind !== event.party.kind)
360
+ return { kind: "denied", reason: "event_conflict" };
361
+ sessionId = opened.conversation.sessionId;
362
+ const now = options.now();
363
+ const admitted = options.transactionSync(() => {
364
+ // Digests past the window stop blocking, so the same body may be
365
+ // admitted again later.
366
+ sql.exec("UPDATE hub_channel_intake SET body_digest=NULL WHERE binding_id=? AND body_digest IS NOT NULL AND received_at<=?", binding.bindingId, now - EVENT_REPLAY_WINDOW_SECONDS * 1_000);
367
+ // Session creation yielded. Resolve a competing reservation through
368
+ // the same conversation/text checks before replaying its receipt.
369
+ if (sql
370
+ .exec("SELECT 1 FROM hub_channel_intake WHERE binding_id=? AND event_id=?", binding.bindingId, event.eventId)
371
+ .toArray().length > 0)
372
+ return "replay";
373
+ // Another delivery of this body may also have landed.
374
+ if (digest !== null &&
375
+ digestHit(binding.bindingId, digest, now) !== null)
376
+ return "replay";
377
+ if (count("SELECT COUNT(*) AS count FROM hub_channel_intake WHERE binding_id=? AND conversation=? AND received_at>?", binding.bindingId, event.conversation, now - DAY_MS) >= limits.messagesPerPartyPerDay)
378
+ return "party_daily_limit";
379
+ if (rateLimited(binding, now))
380
+ return "rate_limited";
381
+ sql.exec("INSERT OR IGNORE INTO hub_channel_intake (binding_id,event_id,conversation,session_id,text,state,command_id,received_at,body_digest) VALUES (?,?,?,?,?,'pending',NULL,?,?)", binding.bindingId, event.eventId, event.conversation, sessionId, event.text, now, digest);
382
+ return "inserted";
383
+ });
384
+ if (admitted === "replay") {
385
+ // Intake lost a race while opening the Session. Re-read the winner,
386
+ // including its conversation, rather than treating insert-ignore as ours.
387
+ if (retried)
388
+ throw new Error("A replayed event body was not found");
389
+ return await admit(rawEvent, true);
390
+ }
391
+ if (admitted !== "inserted")
392
+ return { kind: "denied", reason: admitted };
393
+ }
394
+ else
395
+ sessionId = priorIntake.session_id;
396
+ const requestId = channelRequestId(binding.bindingId, event.eventId);
397
+ const delivery = priorIntake === null ? "new" : "replay";
398
+ const party = event.party;
399
+ const author = {
400
+ id: party.kind === "visitor"
401
+ ? party.visitorId
402
+ : party.kind === "discord-user"
403
+ ? party.userId
404
+ : party.kind === "email-sender"
405
+ ? party.address
406
+ : party.kind === "player"
407
+ ? party.playerId
408
+ : party.source,
409
+ name: partyLabel(party).slice(0, 128),
410
+ };
411
+ const meta = event.sourceRequestId === undefined
412
+ ? authorMeta(author)
413
+ : { ...authorMeta(author), sourceRequestId: event.sourceRequestId };
414
+ try {
415
+ // A held conversation is a person's: the event is a run-free party line.
416
+ if (sessionConversation(sessionId)?.handoff.state === "person") {
417
+ await options.door.line(sessionId, {
418
+ requestId,
419
+ author: "party",
420
+ text: event.text,
421
+ meta,
422
+ });
423
+ options.transactionSync(() => {
424
+ sql.exec("UPDATE hub_channel_intake SET state='held' WHERE binding_id=? AND event_id=? AND state='pending'", binding.bindingId, event.eventId);
425
+ sql.exec("UPDATE hub_channel_conversations SET last_event_at=? WHERE session_id=?", options.now(), sessionId);
426
+ });
427
+ return { kind: "held", sessionId, delivery };
428
+ }
429
+ const { commandId } = await options.door.prompt(sessionId, {
430
+ requestId,
431
+ text: event.text,
432
+ meta,
433
+ });
434
+ options.transactionSync(() => {
435
+ sql.exec("UPDATE hub_channel_intake SET state='accepted', command_id=? WHERE binding_id=? AND event_id=? AND state='pending'", commandId, binding.bindingId, event.eventId);
436
+ sql.exec("UPDATE hub_channel_conversations SET last_event_at=? WHERE session_id=?", options.now(), sessionId);
437
+ options.onAccepted?.({ binding, event, sessionId, commandId });
438
+ });
439
+ return { kind: "accepted", sessionId, commandId, delivery };
440
+ }
441
+ catch (error) {
442
+ if (event.party.kind !== "visitor" ||
443
+ !(error instanceof Error) ||
444
+ options.door.errorKind(error, requestId) !== "closed_request")
445
+ throw error;
446
+ // The owner's durable withdrawal fence is authoritative. This reservation
447
+ // never became a line or command; do not present it as pending intake.
448
+ sql.exec("DELETE FROM hub_channel_intake WHERE binding_id=? AND event_id=? AND session_id=? AND state='pending'", binding.bindingId, event.eventId, sessionId);
449
+ return { kind: "denied", reason: "event_conflict" };
450
+ }
451
+ };
452
+ const receive = (rawEvent) => admit(rawEvent, false);
453
+ const changeHandoff = (sessionId, event) => options.transactionSync(() => {
454
+ const current = sessionConversation(sessionId);
455
+ if (current === null)
456
+ return { kind: "not_found" };
457
+ const result = transitionHandoff(current.handoff, event);
458
+ if (result.kind !== "ok")
459
+ return result;
460
+ const next = handoffSchema.parse(result.handoff);
461
+ if (next.version === current.handoff.version)
462
+ return { kind: "ok", conversation: current };
463
+ const changed = sql
464
+ .exec("UPDATE hub_channel_conversations SET handoff_json=?, notify_due_at=? WHERE session_id=? AND json_extract(handoff_json,'$.version')=? RETURNING session_id", JSON.stringify(next), next.state === "requested" ? next.at : null, sessionId, event.expectedVersion)
465
+ .toArray();
466
+ if (changed.length !== 1)
467
+ return { kind: "conflict" };
468
+ if (event.kind === "take_over" && next.state === "person") {
469
+ sql.exec("INSERT INTO hub_channel_takeovers VALUES (?,?,?,?,?,?)", sessionId, next.version, next.memberId, next.memberName, current.handoff.state === "person" ? current.handoff.memberId : null, next.since);
470
+ sql.exec("DELETE FROM hub_channel_takeovers WHERE session_id=? AND version NOT IN (SELECT version FROM hub_channel_takeovers WHERE session_id=? ORDER BY version DESC LIMIT 100)", sessionId, sessionId);
471
+ }
472
+ return { kind: "ok", conversation: { ...current, handoff: next } };
473
+ });
474
+ /** Capture only the immutable command supplied by the real turn-start producer. */
475
+ const captureTurn = (sessionId, commandId) => {
476
+ options.transactionSync(() => {
477
+ const conversation = sessionConversation(sessionId);
478
+ if (conversation === null)
479
+ return;
480
+ sql.exec("INSERT OR IGNORE INTO hub_channel_turns VALUES (?,?,?)", sessionId, commandId, conversation.handoff.version);
481
+ });
482
+ };
483
+ const turnIsCurrent = (sessionId, commandId) => {
484
+ const conversation = sessionConversation(sessionId);
485
+ if (conversation === null || conversation.handoff.state === "person")
486
+ return false;
487
+ const row = sql
488
+ .exec("SELECT handoff_version FROM hub_channel_turns WHERE session_id=? AND command_id=?", sessionId, commandId)
489
+ .toArray()[0];
490
+ if (row === undefined)
491
+ return false;
492
+ const capturedVersion = z
493
+ .strictObject({ handoff_version: z.number().int().nonnegative() })
494
+ .parse(row).handoff_version;
495
+ const handoff = conversation.handoff;
496
+ // Requesting a person leaves the Agent in control: its current turn must
497
+ // still be able to tell the participant someone will follow up. Only the
498
+ // immediately following request is safe; any takeover/hand-back moves
499
+ // beyond it. Never recapture the command at a newer version.
500
+ return (capturedVersion === handoff.version ||
501
+ (handoff.state === "requested" && handoff.version === capturedVersion + 1));
502
+ };
503
+ /** Keep the first delivery decision: later handoffs cannot erase delivered history. */
504
+ const deliverAgentReply = (sessionId, commandId, reply) => options.transactionSync(() => {
505
+ if (sessionConversation(sessionId) === null)
506
+ return false;
507
+ const replyId = reply.kind === "message" ? reply.messageId : "";
508
+ const existing = sql
509
+ .exec("SELECT delivered FROM hub_channel_reply_delivery WHERE session_id=? AND command_id=? AND kind=? AND reply_id=?", sessionId, commandId, reply.kind, replyId)
510
+ .toArray()[0];
511
+ if (existing !== undefined)
512
+ return (z
513
+ .strictObject({ delivered: z.union([z.literal(0), z.literal(1)]) })
514
+ .parse(existing).delivered === 1);
515
+ // Missing turn capture is not proof of a delivery or a permanent draft.
516
+ const captured = sql
517
+ .exec("SELECT command_id FROM hub_channel_turns WHERE session_id=? AND command_id=?", sessionId, commandId)
518
+ .toArray();
519
+ if (captured.length === 0)
520
+ return false;
521
+ const delivered = turnIsCurrent(sessionId, commandId);
522
+ sql.exec("INSERT INTO hub_channel_reply_delivery VALUES (?,?,?,?,?)", sessionId, commandId, reply.kind, replyId, delivered ? 1 : 0);
523
+ return delivered;
524
+ });
525
+ /**
526
+ * The Agent asks for a person; the first reason stands until dismissal or takeover. A
527
+ * new request owes the owner one notification, due now.
528
+ */
529
+ const requestHandoff = (sessionId, reason) => {
530
+ const current = sessionConversation(sessionId);
531
+ if (current === null)
532
+ return { kind: "not_found" };
533
+ return changeHandoff(sessionId, {
534
+ kind: "request",
535
+ expectedVersion: current.handoff.version,
536
+ reason,
537
+ at: options.now(),
538
+ });
539
+ };
540
+ /** Handoffs whose owner notification is due, oldest first. */
541
+ const dueNotifications = (now) => {
542
+ ensure();
543
+ return sql
544
+ .exec("SELECT * FROM hub_channel_conversations WHERE notify_due_at IS NOT NULL AND notify_due_at<=? AND json_extract(handoff_json,'$.state')='requested' ORDER BY notify_due_at LIMIT 16", now)
545
+ .toArray()
546
+ .map((raw) => decodeConversation(conversationRow.parse(raw)));
547
+ };
548
+ /** The earliest owed notification, or null. */
549
+ const nextNotification = () => {
550
+ ensure();
551
+ return z
552
+ .object({ due: z.number().int().nullable() })
553
+ .parse(sql
554
+ .exec("SELECT MIN(notify_due_at) AS due FROM hub_channel_conversations WHERE json_extract(handoff_json,'$.state')='requested'")
555
+ .toArray()[0]).due;
556
+ };
557
+ /** Settle (null) or reschedule one conversation's owed notification. */
558
+ const scheduleNotification = (sessionId, dueAt) => {
559
+ ensure();
560
+ sql.exec("UPDATE hub_channel_conversations SET notify_due_at=? WHERE session_id=? AND json_extract(handoff_json,'$.state')='requested'", dueAt, sessionId);
561
+ };
562
+ /** Whether this exact handoff (by its start time) is still open. */
563
+ const handoffOpen = (sessionId, version) => {
564
+ const handoff = sessionConversation(sessionId)?.handoff;
565
+ return handoff?.state === "requested" && handoff.version === version;
566
+ };
567
+ /**
568
+ * Conversations past their binding's retention, oldest first. A held
569
+ * conversation is a person's and is never swept from under them.
570
+ */
571
+ const expired = (now, retentionMs) => {
572
+ ensure();
573
+ return sql
574
+ .exec("SELECT * FROM hub_channel_conversations WHERE json_extract(handoff_json,'$.state')!='person' ORDER BY last_event_at LIMIT 256")
575
+ .toArray()
576
+ .map((raw) => decodeConversation(conversationRow.parse(raw)))
577
+ .filter((conversation) => conversation.lastEventAt + retentionMs(conversation.bindingId) <= now)
578
+ .slice(0, 32);
579
+ };
580
+ /** The earliest time any unheld conversation passes its retention. */
581
+ const nextExpiry = (retentionMs) => {
582
+ ensure();
583
+ const rows = sql
584
+ .exec("SELECT binding_id, MIN(last_event_at) AS oldest FROM hub_channel_conversations WHERE json_extract(handoff_json,'$.state')!='person' GROUP BY binding_id")
585
+ .toArray()
586
+ .map((raw) => z
587
+ .strictObject({ binding_id: z.string(), oldest: z.number().int() })
588
+ .parse(raw));
589
+ const due = rows.map((row) => row.oldest + retentionMs(row.binding_id));
590
+ return due.length === 0 ? null : Math.min(...due);
591
+ };
592
+ /** Forget a swept conversation: its map row and its intake log. */
593
+ const forget = (conversation) => {
594
+ ensure();
595
+ options.transactionSync(() => {
596
+ sql.exec("DELETE FROM hub_channel_intake WHERE binding_id=? AND conversation=?", conversation.bindingId, conversation.conversation);
597
+ sql.exec("DELETE FROM hub_channel_conversations WHERE session_id=?", conversation.sessionId);
598
+ sql.exec("DELETE FROM hub_channel_turns WHERE session_id=?", conversation.sessionId);
599
+ sql.exec("DELETE FROM hub_channel_reply_delivery WHERE session_id=?", conversation.sessionId);
600
+ sql.exec("DELETE FROM hub_channel_takeovers WHERE session_id=?", conversation.sessionId);
601
+ });
602
+ };
603
+ /** The same transition function owns requests, takeovers, hand back and dismissal. */
604
+ const takeOver = (sessionId, member, expectedVersion, confirm) => changeHandoff(sessionId, {
605
+ kind: "take_over",
606
+ member,
607
+ expectedVersion,
608
+ confirm,
609
+ at: options.now(),
610
+ });
611
+ const handBack = (sessionId, memberId, expectedVersion, confirm) => changeHandoff(sessionId, {
612
+ kind: "hand_back",
613
+ memberId,
614
+ expectedVersion,
615
+ confirm,
616
+ });
617
+ const dismiss = (sessionId, expectedVersion) => changeHandoff(sessionId, { kind: "dismiss", expectedVersion });
618
+ const takeovers = (sessionId) => {
619
+ ensure();
620
+ return sql
621
+ .exec("SELECT * FROM hub_channel_takeovers WHERE session_id=? ORDER BY version DESC LIMIT 100", sessionId)
622
+ .toArray()
623
+ .map((raw) => {
624
+ const row = takeoverRow.parse(raw);
625
+ return {
626
+ version: row.version,
627
+ memberId: row.member_id,
628
+ memberName: row.member_name,
629
+ previousMemberId: row.previous_member_id,
630
+ at: row.taken_at,
631
+ };
632
+ });
633
+ };
634
+ /** Owner action: refuse further events from one conversation. */
635
+ const ban = (bindingId, conversation) => {
636
+ ensure();
637
+ sql.exec("INSERT OR IGNORE INTO hub_channel_bans VALUES (?,?,?)", bindingId, conversation, options.now());
638
+ };
639
+ /** Every conversation, newest activity first, for owner surfaces. */
640
+ const list = (bindingId) => {
641
+ ensure();
642
+ const rows = bindingId === undefined
643
+ ? sql
644
+ .exec("SELECT * FROM hub_channel_conversations ORDER BY last_event_at DESC")
645
+ .toArray()
646
+ : sql
647
+ .exec("SELECT * FROM hub_channel_conversations WHERE binding_id=? ORDER BY last_event_at DESC", bindingId)
648
+ .toArray();
649
+ return rows.map((raw) => decodeConversation(conversationRow.parse(raw)));
650
+ };
651
+ /** Last 100 events on a projected event binding, with no payload or digest. */
652
+ const recentEvents = (bindingId) => {
653
+ ensure();
654
+ if (options.binding(bindingId)?.selector.provider !== "event")
655
+ return [];
656
+ return sql
657
+ .exec("SELECT event_id,received_at,state,length(CAST(text AS BLOB)) AS size,session_id FROM hub_channel_intake WHERE binding_id=? ORDER BY received_at DESC,event_id DESC LIMIT 100", bindingId)
658
+ .toArray()
659
+ .map((raw) => {
660
+ const row = eventReceiptRow.parse(raw);
661
+ return {
662
+ eventId: row.event_id,
663
+ receivedAt: row.received_at,
664
+ status: row.state,
665
+ size: row.size,
666
+ sessionId: row.session_id,
667
+ };
668
+ });
669
+ };
670
+ return {
671
+ ensure,
672
+ open,
673
+ receive,
674
+ /** A visitor bearer may never resolve another conversation's intake key. */
675
+ visitorEventConflict: (bindingId, visitorId, eventId) => {
676
+ const row = sql
677
+ .exec("SELECT conversation FROM hub_channel_intake WHERE binding_id=? AND event_id=?", bindingId, eventId)
678
+ .toArray()[0];
679
+ return (row !== undefined &&
680
+ z.strictObject({ conversation: z.string() }).parse(row).conversation !==
681
+ visitorId);
682
+ },
683
+ ban,
684
+ list,
685
+ recentEvents,
686
+ requestHandoff,
687
+ captureTurn,
688
+ turnIsCurrent,
689
+ deliverAgentReply,
690
+ dueNotifications,
691
+ nextNotification,
692
+ scheduleNotification,
693
+ handoffOpen,
694
+ takeOver,
695
+ handBack,
696
+ dismiss,
697
+ takeovers,
698
+ expired,
699
+ nextExpiry,
700
+ forget,
701
+ find: findConversation,
702
+ sessionConversation,
703
+ sessionIds,
704
+ };
705
+ }